From bb79f2f93d8a2cf9514f4c5d62dac6421e78e043 Mon Sep 17 00:00:00 2001 From: william Date: Sun, 23 Aug 2026 19:53:38 +0200 Subject: [PATCH] Redesign the screen editor's element tree and add duplication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Éléments de cet écran" tree now renders every level of nesting (previously stopped after one level of children) as a compact single-line list, and right-clicking a row opens a context menu to duplicate the element (and its full subtree) in place, in its current container. Also: all property panels start collapsed instead of some being open by default, the redundant nested element list inside "Ajouter DANS ce conteneur" is removed (it only needs the widget picker), and the now-unneeded "a container is selected" warning banner is gone. Co-Authored-By: Claude Sonnet 5 --- routes/elements/__init__.py | 1 + routes/elements/element_duplicate.py | 14 +++ screens/__init__.py | 1 + screens/elements/duplicate_element.py | 60 ++++++++++++ static/style.css | 27 ++++++ templates/screen_edit.html | 126 +++++++++++++++++--------- tests/test_screens_and_elements.py | 64 +++++++++++++ 7 files changed, 249 insertions(+), 44 deletions(-) create mode 100644 routes/elements/element_duplicate.py create mode 100644 screens/elements/duplicate_element.py diff --git a/routes/elements/__init__.py b/routes/elements/__init__.py index 4a687a8c..c9c16444 100644 --- a/routes/elements/__init__.py +++ b/routes/elements/__init__.py @@ -4,5 +4,6 @@ from . import ( element_geometry, element_save, element_delete, + element_duplicate, element_rename, ) diff --git a/routes/elements/element_duplicate.py b/routes/elements/element_duplicate.py new file mode 100644 index 00000000..bca9841b --- /dev/null +++ b/routes/elements/element_duplicate.py @@ -0,0 +1,14 @@ +from flask import redirect, url_for, abort + +import screens + +from core.flask_app import app + + +@app.route("/game//elements//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)) diff --git a/screens/__init__.py b/screens/__init__.py index 44014358..d06ae73c 100644 --- a/screens/__init__.py +++ b/screens/__init__.py @@ -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 diff --git a/screens/elements/duplicate_element.py b/screens/elements/duplicate_element.py new file mode 100644 index 00000000..ecd20e0b --- /dev/null +++ b/screens/elements/duplicate_element.py @@ -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 diff --git a/static/style.css b/static/style.css index a6b89ccc..79169b6b 100644 --- a/static/style.css +++ b/static/style.css @@ -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); diff --git a/templates/screen_edit.html b/templates/screen_edit.html index 953404b1..f5e39591 100644 --- a/templates/screen_edit.html +++ b/templates/screen_edit.html @@ -29,16 +29,31 @@

{% endif %} +{% macro render_tree_node(el) %} +
+ + {{ widgets.get(el.widget, {}).get('icon', '❔') }} + {{ el|elabel }} + + {% set kids = children_of.get(el.id, []) %} + {% if kids %} +
+ {% for child in kids %} + {{ render_tree_node(child) }} + {% endfor %} +
+ {% endif %} +
+{% endmacro %} +
{% set el = elements|selectattr("id", "equalto", selected_id)|first if selected_id else none %}
- {% if selected_id and el and el.widget in container_widgets %} -

- ⚠️ 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 « 📥 Ajouter DANS ce conteneur » dans le panneau de droite. -

- {% endif %} -
+
Ajouter un élément sur l'écran
@@ -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. --> -
+
🧩 Mes éléments de jeu{{ ' (' ~ element_types|length ~ ')' if element_types else '' }}
{% if element_types %} @@ -83,24 +98,9 @@

Éléments de cet écran

{% if top_level_elements %} -
+
{% for el in top_level_elements %} - -
{{ widgets.get(el.widget, {}).get('icon', '❔') }} {{ el|elabel }}
-
{{ (el.content or '—')[:30] }}
-
- {% if el.widget in container_widgets and children_of.get(el.id) %} - - {% endif %} + {{ render_tree_node(el) }} {% endfor %}
{% else %} @@ -108,6 +108,11 @@ {% endif %}
+ + +
{% set current_aspect = screen.preview_aspect or '9/16' %} @@ -156,23 +161,9 @@
{% if el.widget in container_widgets %} -
+
📥 Ajouter DANS ce conteneur
- {% set children = children_of.get(el.id, []) %} - {% if children %} - - {% else %} -

Aucun élément à l'intérieur pour l'instant — ajoute-en un ci-dessous.

- {% endif %}
{% for wkey, w in widgets.items() %}
@@ -193,7 +184,7 @@ {% for c in selected_controls if c.key in size_ctrl_keys %}{% set _ = size_ctrls.update({c.key: c}) %}{% endfor %} -
+
Position & taille
@@ -236,7 +227,7 @@
{% endif %} -
+
{{ c.group or 'Propriétés' }}
{% 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 +// 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; }); } diff --git a/tests/test_screens_and_elements.py b/tests/test_screens_and_elements.py index 3beb98ee..b8fad436 100644 --- a/tests/test_screens_and_elements.py +++ b/tests/test_screens_and_elements.py @@ -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 "
" 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()