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()