Redesign the screen editor's element tree and add duplication

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 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-23 19:53:38 +02:00
co-authored by Claude Sonnet 5
parent 3039489e39
commit bb79f2f93d
7 changed files with 249 additions and 44 deletions
+1
View File
@@ -4,5 +4,6 @@ from . import (
element_geometry, element_geometry,
element_save, element_save,
element_delete, element_delete,
element_duplicate,
element_rename, element_rename,
) )
+14
View File
@@ -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))
+1
View File
@@ -76,6 +76,7 @@ from .elements.add_element import add_element
from .elements.update_element_geometry import update_element_geometry from .elements.update_element_geometry import update_element_geometry
from .elements.save_element_controls import save_element_controls from .elements.save_element_controls import save_element_controls
from .elements.delete_element import delete_element from .elements.delete_element import delete_element
from .elements.duplicate_element import duplicate_element
from .elements.rename_element import rename_element from .elements.rename_element import rename_element
from .elements.element_label import element_label from .elements.element_label import element_label
+60
View File
@@ -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
+27
View File
@@ -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{ padding:7px 10px; font-size:12px; }
.childRow .rowTitle{ 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{ border:1px solid var(--border); border-radius:8px; margin-bottom:8px; background:var(--panel); }
.propGroup > summary{ .propGroup > summary{
list-style:none; cursor:pointer; padding:9px 12px; font-size:13px; color:var(--accent); list-style:none; cursor:pointer; padding:9px 12px; font-size:13px; color:var(--accent);
+82 -44
View File
@@ -29,16 +29,31 @@
</p> </p>
{% endif %} {% 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"> <div class="builder3" id="builder3">
{% set el = elements|selectattr("id", "equalto", selected_id)|first if selected_id else none %} {% 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) --> <!-- Panneau gauche : ajouter un élément (icônes, sans jargon technique) -->
<div class="builderPanel"> <div class="builderPanel">
{% if selected_id and el and el.widget in container_widgets %} <details class="propGroup">
<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>
<summary>Ajouter un élément sur l'écran</summary> <summary>Ajouter un élément sur l'écran</summary>
<div class="propGroupBody"> <div class="propGroupBody">
<div class="widgetGrid"> <div class="widgetGrid">
@@ -59,7 +74,7 @@
Outil, IA...) : chacun a désormais SA PROPRE tuile, avec son nom, 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 plutôt qu'une unique tuile générique "Élément de jeu" qui ajoutait
toujours le premier du catalogue sans laisser choisir lequel. --> 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> <summary>🧩 Mes éléments de jeu{{ ' (' ~ element_types|length ~ ')' if element_types else '' }}</summary>
<div class="propGroupBody"> <div class="propGroupBody">
{% if element_types %} {% if element_types %}
@@ -83,24 +98,9 @@
<h2>Éléments de cet écran</h2> <h2>Éléments de cet écran</h2>
{% if top_level_elements %} {% if top_level_elements %}
<div class="list"> <div class="elementTree">
{% for el in top_level_elements %} {% for el in top_level_elements %}
<a class="listRow {{ 'selected' if el.id == selected_id else '' }}" id="listrow-{{ el.id }}" {{ render_tree_node(el) }}
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 %}
{% endfor %} {% endfor %}
</div> </div>
{% else %} {% else %}
@@ -108,6 +108,11 @@
{% endif %} {% endif %}
</div> </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 --> <!-- Centre : l'écran, en très grand -->
<div class="builderCanvasArea"> <div class="builderCanvasArea">
{% set current_aspect = screen.preview_aspect or '9/16' %} {% set current_aspect = screen.preview_aspect or '9/16' %}
@@ -156,23 +161,9 @@
</div> </div>
{% if el.widget in container_widgets %} {% if el.widget in container_widgets %}
<details class="propGroup containerContentGroup" open> <details class="propGroup containerContentGroup">
<summary>📥 Ajouter DANS ce conteneur</summary> <summary>📥 Ajouter DANS ce conteneur</summary>
<div class="propGroupBody"> <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"> <div class="widgetGrid">
{% for wkey, w in widgets.items() %} {% for wkey, w in widgets.items() %}
<form method="post" action="{{ url_for('element_add_child', slug=game.slug, parent_id=el.id) }}" class="widgetBtnForm"> <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 %} {% 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;"> <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 &amp; taille</summary> <summary>Position &amp; taille</summary>
<div class="propGroupBody"> <div class="propGroupBody">
<div class="posGrid"> <div class="posGrid">
@@ -236,7 +227,7 @@
</div> </div>
</details> </details>
{% endif %} {% endif %}
<details class="propGroup" {{ 'open' if not ns.started else '' }}> <details class="propGroup">
<summary>{{ c.group or 'Propriétés' }}</summary> <summary>{{ c.group or 'Propriétés' }}</summary>
<div class="propGroupBody"> <div class="propGroupBody">
{% set ns.last_group = c.group %} {% 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 SCREEN_EDIT_URL = "{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id) }}";
var CURRENT_SELECTED_ID = {{ selected_id or 'null' }}; 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 ---------- // ---------- 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é) — // Aperçu du contenu dans l'arborescence de gauche (ex: texte tronqué) —
// sans toucher au panneau de droite, pour ne jamais faire perdre le // sans toucher au panneau de droite, pour ne jamais faire perdre le
// focus/curseur pendant qu'on modifie un réglage. // focus/curseur pendant qu'on modifie un réglage.
var newList = doc.querySelector('.builderPanel .list'); var newTree = doc.querySelector('.builderPanel .elementTree');
var curList = document.querySelector('.builderPanel .list'); var curTree = document.querySelector('.builderPanel .elementTree');
if (newList && curList) curList.innerHTML = newList.innerHTML; if (newTree && curTree) curTree.innerHTML = newTree.innerHTML;
}); });
} }
+64
View File
@@ -182,6 +182,70 @@ def test_image_gets_object_fit_cover_by_default(client, game):
assert "object-fit:cover" in html 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): 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) 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() html = client.get(f"/game/{game}/element-types").data.decode()