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) %}
+
+{% 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