Dev #1
@@ -4,5 +4,6 @@ from . import (
|
||||
element_geometry,
|
||||
element_save,
|
||||
element_delete,
|
||||
element_duplicate,
|
||||
element_rename,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
from flask import redirect, url_for, abort
|
||||
|
||||
import screens
|
||||
|
||||
from core.flask_app import app
|
||||
|
||||
|
||||
@app.route("/game/<slug>/elements/<int:element_id>/duplicate", methods=["POST"])
|
||||
def element_duplicate(slug, element_id):
|
||||
element = screens.get_element(slug, element_id)
|
||||
if not element:
|
||||
abort(404)
|
||||
new_id = screens.duplicate_element(slug, element_id)
|
||||
return redirect(url_for("screen_edit", slug=slug, screen_id=element["screen_id"], selected=new_id))
|
||||
@@ -76,6 +76,7 @@ from .elements.add_element import add_element
|
||||
from .elements.update_element_geometry import update_element_geometry
|
||||
from .elements.save_element_controls import save_element_controls
|
||||
from .elements.delete_element import delete_element
|
||||
from .elements.duplicate_element import duplicate_element
|
||||
from .elements.rename_element import rename_element
|
||||
from .elements.element_label import element_label
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import db
|
||||
|
||||
|
||||
def duplicate_element(slug, element_id):
|
||||
"""Duplique un élément et tout son sous-arbre DANS SON CONTENEUR ACTUEL
|
||||
(même écran, même parent_id que l'original) — le clic droit "Dupliquer"
|
||||
de l'arborescence n'a pas à faire choisir où poser la copie, elle
|
||||
apparaît juste à côté de l'original. Renvoie l'id de la copie (celui à
|
||||
sélectionner ensuite), ou None si l'élément n'existe pas."""
|
||||
conn = db.connect(slug)
|
||||
root_row = conn.execute("SELECT * FROM _screen_elements WHERE id = ?", (element_id,)).fetchone()
|
||||
if not root_row:
|
||||
conn.close()
|
||||
return None
|
||||
root = dict(root_row)
|
||||
source = [
|
||||
dict(r) for r in conn.execute(
|
||||
"SELECT * FROM _screen_elements WHERE screen_id = ?", (root["screen_id"],)
|
||||
).fetchall()
|
||||
]
|
||||
conn.close()
|
||||
|
||||
def copy_one(src, new_parent_id, is_root):
|
||||
conn = db.connect(slug)
|
||||
max_z = conn.execute(
|
||||
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (src["screen_id"],)
|
||||
).fetchone()["m"]
|
||||
x, y = src["x"], src["y"]
|
||||
if is_root and not new_parent_id:
|
||||
# Petit décalage pour que la copie ne recouvre pas exactement
|
||||
# l'original (qui serait alors invisible en dessous).
|
||||
x = min(100, (x or 0) + 4)
|
||||
y = min(100, (y or 0) + 4)
|
||||
name = src.get("name")
|
||||
if is_root and name:
|
||||
name = f"{name} (copie)"
|
||||
conn.execute(
|
||||
"""INSERT INTO _screen_elements
|
||||
(screen_id, parent_id, widget, element_type_id, name, content, attributes,
|
||||
x, y, width, height, z_index, style)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
src["screen_id"], new_parent_id, src["widget"], src.get("element_type_id"), name,
|
||||
src["content"], src["attributes"], x, y, src["width"], src["height"],
|
||||
(max_z or 0) + 1, src["style"],
|
||||
),
|
||||
)
|
||||
new_id = conn.execute("SELECT last_insert_rowid() AS id").fetchone()["id"]
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return new_id
|
||||
|
||||
def walk(src_parent_id, new_parent_id):
|
||||
for child in [s for s in source if s.get("parent_id") == src_parent_id]:
|
||||
new_child_id = copy_one(child, new_parent_id, is_root=False)
|
||||
walk(child["id"], new_child_id)
|
||||
|
||||
new_root_id = copy_one(root, root.get("parent_id"), is_root=True)
|
||||
walk(root["id"], new_root_id)
|
||||
return new_root_id
|
||||
@@ -154,6 +154,33 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
|
||||
.childRow{ padding:7px 10px; font-size:12px; }
|
||||
.childRow .rowTitle{ font-size:12px; }
|
||||
|
||||
/* Arborescence compacte des éléments de l'écran (panneau gauche) — une
|
||||
ligne par élément, indentation par imbrication réelle (profondeur
|
||||
illimitée) plutôt qu'un seul niveau d'enfants comme l'ancienne .childList,
|
||||
pour rester lisible même avec beaucoup d'éléments. */
|
||||
.elementTree{ display:flex; flex-direction:column; gap:1px; max-height:55vh; overflow-y:auto; margin-bottom:14px; }
|
||||
.treeNode{ display:flex; flex-direction:column; }
|
||||
.treeRowLink{
|
||||
display:flex; align-items:center; gap:6px; padding:4px 6px; border-radius:5px;
|
||||
text-decoration:none; color:var(--text); font-size:12.5px; line-height:1.5;
|
||||
white-space:nowrap; overflow:hidden;
|
||||
}
|
||||
.treeRowLink:hover{ background:var(--panel2); }
|
||||
.treeRowLink.selected{ background:var(--accent); color:#0b0e14; font-weight:600; }
|
||||
.treeIcon{ flex:0 0 auto; font-size:12px; }
|
||||
.treeLabel{ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.treeChildren{ display:flex; flex-direction:column; margin-left:10px; padding-left:8px; border-left:1px dashed var(--border); }
|
||||
|
||||
.contextMenu{
|
||||
position:fixed; z-index:500; background:var(--panel); border:1px solid var(--border);
|
||||
border-radius:8px; box-shadow:0 8px 24px rgba(0,0,0,.4); padding:4px; min-width:160px;
|
||||
}
|
||||
.contextMenu button{
|
||||
display:block; width:100%; text-align:left; background:none; border:none; color:var(--text);
|
||||
padding:7px 10px; border-radius:5px; font-size:13px; cursor:pointer;
|
||||
}
|
||||
.contextMenu button:hover{ background:var(--panel2); }
|
||||
|
||||
.propGroup{ border:1px solid var(--border); border-radius:8px; margin-bottom:8px; background:var(--panel); }
|
||||
.propGroup > summary{
|
||||
list-style:none; cursor:pointer; padding:9px 12px; font-size:13px; color:var(--accent);
|
||||
|
||||
+82
-44
@@ -29,16 +29,31 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
{% macro render_tree_node(el) %}
|
||||
<div class="treeNode">
|
||||
<a class="treeRowLink {{ 'selected' if el.id == selected_id else '' }}"
|
||||
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=el.id) }}"
|
||||
data-element-id="{{ el.id }}"
|
||||
oncontextmenu="return showElementContextMenu(event, {{ el.id }});">
|
||||
<span class="treeIcon">{{ widgets.get(el.widget, {}).get('icon', '❔') }}</span>
|
||||
<span class="treeLabel">{{ el|elabel }}</span>
|
||||
</a>
|
||||
{% set kids = children_of.get(el.id, []) %}
|
||||
{% if kids %}
|
||||
<div class="treeChildren">
|
||||
{% for child in kids %}
|
||||
{{ render_tree_node(child) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endmacro %}
|
||||
|
||||
<div class="builder3" id="builder3">
|
||||
{% set el = elements|selectattr("id", "equalto", selected_id)|first if selected_id else none %}
|
||||
<!-- Panneau gauche : ajouter un élément (icônes, sans jargon technique) -->
|
||||
<div class="builderPanel">
|
||||
{% if selected_id and el and el.widget in container_widgets %}
|
||||
<p class="hint" style="background:#2a1f10; border:1px solid #6b4a12; border-radius:8px; padding:8px 12px; margin-bottom:10px;">
|
||||
⚠️ Un conteneur est sélectionné. La grille ci-dessous pose l'élément directement sur l'écran, PAS à l'intérieur du conteneur — pour poser un élément dans le conteneur sélectionné, utilise plutôt <strong>« 📥 Ajouter DANS ce conteneur »</strong> dans le panneau de droite.
|
||||
</p>
|
||||
{% endif %}
|
||||
<details class="propGroup" open>
|
||||
<details class="propGroup">
|
||||
<summary>Ajouter un élément sur l'écran</summary>
|
||||
<div class="propGroupBody">
|
||||
<div class="widgetGrid">
|
||||
@@ -59,7 +74,7 @@
|
||||
Outil, IA...) : chacun a désormais SA PROPRE tuile, avec son nom,
|
||||
plutôt qu'une unique tuile générique "Élément de jeu" qui ajoutait
|
||||
toujours le premier du catalogue sans laisser choisir lequel. -->
|
||||
<details class="propGroup" {{ 'open' if element_types else '' }}>
|
||||
<details class="propGroup">
|
||||
<summary>🧩 Mes éléments de jeu{{ ' (' ~ element_types|length ~ ')' if element_types else '' }}</summary>
|
||||
<div class="propGroupBody">
|
||||
{% if element_types %}
|
||||
@@ -83,24 +98,9 @@
|
||||
|
||||
<h2>Éléments de cet écran</h2>
|
||||
{% if top_level_elements %}
|
||||
<div class="list">
|
||||
<div class="elementTree">
|
||||
{% for el in top_level_elements %}
|
||||
<a class="listRow {{ 'selected' if el.id == selected_id else '' }}" id="listrow-{{ el.id }}"
|
||||
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=el.id) }}">
|
||||
<div class="rowTitle">{{ widgets.get(el.widget, {}).get('icon', '❔') }} {{ el|elabel }}</div>
|
||||
<div class="rowSub">{{ (el.content or '—')[:30] }}</div>
|
||||
</a>
|
||||
{% if el.widget in container_widgets and children_of.get(el.id) %}
|
||||
<div class="childList">
|
||||
{% for child in children_of.get(el.id) %}
|
||||
<a class="listRow childRow {{ 'selected' if child.id == selected_id else '' }}"
|
||||
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=child.id) }}">
|
||||
<div class="rowTitle">↳ {{ widgets.get(child.widget, {}).get('icon', '❔') }} {{ child|elabel }}</div>
|
||||
<div class="rowSub">{{ (child.content or '—')[:26] }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{{ render_tree_node(el) }}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
@@ -108,6 +108,11 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Menu contextuel (clic droit sur un élément de l'arborescence) -->
|
||||
<div id="elementContextMenu" class="contextMenu" style="display:none;">
|
||||
<button type="button" onclick="contextMenuDuplicate()">📄 Dupliquer</button>
|
||||
</div>
|
||||
|
||||
<!-- Centre : l'écran, en très grand -->
|
||||
<div class="builderCanvasArea">
|
||||
{% set current_aspect = screen.preview_aspect or '9/16' %}
|
||||
@@ -156,23 +161,9 @@
|
||||
</div>
|
||||
|
||||
{% if el.widget in container_widgets %}
|
||||
<details class="propGroup containerContentGroup" open>
|
||||
<details class="propGroup containerContentGroup">
|
||||
<summary>📥 Ajouter DANS ce conteneur</summary>
|
||||
<div class="propGroupBody">
|
||||
{% set children = children_of.get(el.id, []) %}
|
||||
{% if children %}
|
||||
<div class="list">
|
||||
{% for child in children %}
|
||||
<a class="listRow childRow {{ 'selected' if child.id == selected_id else '' }}"
|
||||
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=child.id) }}">
|
||||
<div class="rowTitle">↳ {{ widgets.get(child.widget, {}).get('icon', '❔') }} {{ child|elabel }}</div>
|
||||
<div class="rowSub">{{ (child.content or '—')[:26] }}</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="hint">Aucun élément à l'intérieur pour l'instant — ajoute-en un ci-dessous.</p>
|
||||
{% endif %}
|
||||
<div class="widgetGrid">
|
||||
{% for wkey, w in widgets.items() %}
|
||||
<form method="post" action="{{ url_for('element_add_child', slug=game.slug, parent_id=el.id) }}" class="widgetBtnForm">
|
||||
@@ -193,7 +184,7 @@
|
||||
{% for c in selected_controls if c.key in size_ctrl_keys %}{% set _ = size_ctrls.update({c.key: c}) %}{% endfor %}
|
||||
|
||||
<form method="post" action="{{ url_for('element_save', slug=game.slug, element_id=el.id) }}" id="propsForm" onsubmit="event.preventDefault(); return false;">
|
||||
<details class="propGroup" open>
|
||||
<details class="propGroup">
|
||||
<summary>Position & taille</summary>
|
||||
<div class="propGroupBody">
|
||||
<div class="posGrid">
|
||||
@@ -236,7 +227,7 @@
|
||||
</div>
|
||||
</details>
|
||||
{% endif %}
|
||||
<details class="propGroup" {{ 'open' if not ns.started else '' }}>
|
||||
<details class="propGroup">
|
||||
<summary>{{ c.group or 'Propriétés' }}</summary>
|
||||
<div class="propGroupBody">
|
||||
{% set ns.last_group = c.group %}
|
||||
@@ -587,6 +578,53 @@ function saveGeometry(id, x, y, width, height) {
|
||||
|
||||
var SCREEN_EDIT_URL = "{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id) }}";
|
||||
var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};
|
||||
var GAME_SLUG = "{{ game.slug }}";
|
||||
|
||||
// ---------- Menu contextuel (clic droit) de l'arborescence des éléments ----------
|
||||
//
|
||||
// Une seule action pour l'instant : dupliquer l'élément dans son conteneur
|
||||
// actuel (même parent, ou même écran si posé directement dessus) — voir
|
||||
// screens/elements/duplicate_element.py. La duplication passe par un vrai
|
||||
// <form method="post"> soumis dynamiquement : pjax.js intercepte tous les
|
||||
// formulaires internes, donc pas besoin de gérer le fetch/la redirection
|
||||
// nous-mêmes ici.
|
||||
var contextMenuElementId = null;
|
||||
|
||||
function showElementContextMenu(event, elementId) {
|
||||
event.preventDefault();
|
||||
contextMenuElementId = elementId;
|
||||
var menu = document.getElementById('elementContextMenu');
|
||||
menu.style.left = event.pageX + 'px';
|
||||
menu.style.top = event.pageY + 'px';
|
||||
menu.style.display = 'block';
|
||||
return false;
|
||||
}
|
||||
|
||||
function hideElementContextMenu() {
|
||||
var menu = document.getElementById('elementContextMenu');
|
||||
if (menu) menu.style.display = 'none';
|
||||
contextMenuElementId = null;
|
||||
}
|
||||
|
||||
function contextMenuDuplicate() {
|
||||
var id = contextMenuElementId;
|
||||
hideElementContextMenu();
|
||||
if (!id) return;
|
||||
var form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.action = '/game/' + GAME_SLUG + '/elements/' + id + '/duplicate';
|
||||
document.body.appendChild(form);
|
||||
form.requestSubmit();
|
||||
}
|
||||
|
||||
if (!window.__forgeContextMenuBound) {
|
||||
window.__forgeContextMenuBound = true;
|
||||
document.addEventListener('click', function (e) {
|
||||
var menu = document.getElementById('elementContextMenu');
|
||||
if (menu && menu.style.display === 'block' && !menu.contains(e.target)) hideElementContextMenu();
|
||||
});
|
||||
document.addEventListener('scroll', hideElementContextMenu, true);
|
||||
}
|
||||
|
||||
// ---------- Navigation dans l'éditeur sans AUCUN rechargement de page ----------
|
||||
//
|
||||
@@ -636,9 +674,9 @@ function refreshCanvasOnly() {
|
||||
// Aperçu du contenu dans l'arborescence de gauche (ex: texte tronqué) —
|
||||
// sans toucher au panneau de droite, pour ne jamais faire perdre le
|
||||
// focus/curseur pendant qu'on modifie un réglage.
|
||||
var newList = doc.querySelector('.builderPanel .list');
|
||||
var curList = document.querySelector('.builderPanel .list');
|
||||
if (newList && curList) curList.innerHTML = newList.innerHTML;
|
||||
var newTree = doc.querySelector('.builderPanel .elementTree');
|
||||
var curTree = document.querySelector('.builderPanel .elementTree');
|
||||
if (newTree && curTree) curTree.innerHTML = newTree.innerHTML;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -182,6 +182,70 @@ def test_image_gets_object_fit_cover_by_default(client, game):
|
||||
assert "object-fit:cover" in html
|
||||
|
||||
|
||||
def test_duplicate_top_level_element_creates_independent_copy(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "titre"})
|
||||
original_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{original_id}/save", data={"ctrl_content": "Bienvenue"})
|
||||
|
||||
resp = client.post(f"/game/{game}/elements/{original_id}/duplicate", follow_redirects=False)
|
||||
assert resp.status_code == 302
|
||||
new_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
assert new_id != original_id
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert html.count("Bienvenue") == 2 # original + copie, chacun un élément séparé
|
||||
|
||||
# Modifier la copie ne doit pas toucher l'original (deux lignes indépendantes).
|
||||
client.post(f"/game/{game}/elements/{new_id}/save", data={"ctrl_content": "Copie modifiée"})
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert "Bienvenue" in html
|
||||
assert "Copie modifiée" in html
|
||||
|
||||
|
||||
def test_duplicate_container_deep_copies_children(client, game):
|
||||
"""Régression : dupliquer un conteneur doit copier tout son sous-arbre
|
||||
(pas juste la boîte vide) — voir screens/elements/duplicate_element.py,
|
||||
qui réutilise le même parcours récursif que la copie d'écran-modèle."""
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
|
||||
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "titre"})
|
||||
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
client.post(f"/game/{game}/elements/{child_id}/save", data={"ctrl_content": "Titre enfant"})
|
||||
|
||||
resp = client.post(f"/game/{game}/elements/{container_id}/duplicate", follow_redirects=False)
|
||||
new_container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
assert new_container_id != container_id
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert html.count("Titre enfant") == 2 # l'enfant a bien été copié avec son parent
|
||||
|
||||
|
||||
def test_duplicate_child_element_stays_in_same_container(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
|
||||
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
resp = client.post(f"/game/{game}/elements/{container_id}/children/add", data={"widget": "texte"})
|
||||
child_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
client.post(f"/game/{game}/elements/{child_id}/duplicate")
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
|
||||
assert html.count("↳") == 0 # l'ancienne arborescence imbriquée dans "Ajouter DANS ce conteneur" a disparu
|
||||
tree_html = html[html.find('class="elementTree"'):]
|
||||
assert tree_html.count("treeChildren") >= 1 # le conteneur a bien 2 enfants imbriqués (le nouveau + l'original)
|
||||
|
||||
|
||||
def test_screen_editor_property_panels_closed_by_default(client, game):
|
||||
screen_id = _create_screen(client, game)
|
||||
resp = client.post(f"/game/{game}/screens/{screen_id}/elements/add", data={"widget": "conteneur"})
|
||||
container_id = int(re.search(r"selected=(\d+)", resp.headers["Location"]).group(1))
|
||||
|
||||
html = client.get(f"/game/{game}/screens/{screen_id}/edit?selected={container_id}").data.decode()
|
||||
assert "<details class=\"propGroup\" open>" not in html
|
||||
assert "Un conteneur est sélectionné" not in html
|
||||
|
||||
|
||||
def test_delete_element_type_removes_hidden_template_screen(client, game):
|
||||
resp = client.post(f"/game/{game}/element-types", data={"name": "ToDelete", "icon": "🧩"}, follow_redirects=False)
|
||||
html = client.get(f"/game/{game}/element-types").data.decode()
|
||||
|
||||
Reference in New Issue
Block a user