Move icon gallery to the left panel, drop the redundant Icône tile, add drag-into-container in the tree

- The "icone" widget no longer appears as a generic tile in "Ajouter un élément sur l'écran" / "Ajouter DANS ce conteneur" — the icon gallery (now living under "Ajouter un élément sur l'écran" in the left panel instead of the right one) is the only way to add one, always pre-set to the icon you picked.
- The element tree now supports dragging an element onto a container row to move it inside (last child), from anywhere in the tree — not just reordering within the same parent. Hovering a container row splits it into before/after/inside zones (thirds) when dragging a sibling, or "inside only" when dragging from elsewhere in the tree. New move_element_to_container() rejects non-container targets and cycles (dropping a container into itself or one of its own descendants) silently, mirroring reorder_element's existing safety pattern.

Verified end to end: gallery renders in the left panel with no icone tile in the widget grids, and the move endpoint correctly reparents, rejects a cycle, and rejects a non-container target. Full suite green (89).
This commit is contained in:
william
2026-08-25 06:03:02 +02:00
parent 6d80ed6958
commit 33a887e7ba
6 changed files with 166 additions and 42 deletions
+1
View File
@@ -3,6 +3,7 @@ from . import (
element_add_child, element_add_child,
element_geometry, element_geometry,
element_reorder, element_reorder,
element_move_to_container,
element_save, element_save,
element_delete, element_delete,
element_duplicate, element_duplicate,
@@ -0,0 +1,21 @@
from flask import request, jsonify, abort
import screens
from core.flask_app import app
@app.route("/game/<slug>/elements/<int:element_id>/move-to-container", methods=["POST"])
def element_move_to_container(slug, element_id):
"""Appelé en AJAX quand on glisse un élément DANS l'arborescence pour le
déposer sur un autre élément (un conteneur) plutôt qu'au-dessus/en
dessous — le fait passer enfant de ce conteneur, en dernière position
(voir move_element_to_container, qui refuse aussi silencieusement les
cas invalides : cible non-conteneur, ou cycle)."""
element = screens.get_element(slug, element_id)
if not element:
abort(404)
data = request.get_json(force=True)
new_parent_id = int(data["parent_id"])
screens.move_element_to_container(slug, element_id, new_parent_id)
return jsonify({"ok": True})
+1
View File
@@ -77,6 +77,7 @@ from .elements.add_element import add_element
from .elements.add_tab import add_tab from .elements.add_tab import add_tab
from .elements.update_element_geometry import update_element_geometry from .elements.update_element_geometry import update_element_geometry
from .elements.reorder_element import reorder_element from .elements.reorder_element import reorder_element
from .elements.move_element_to_container import move_element_to_container
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.duplicate_element import duplicate_element
@@ -0,0 +1,62 @@
import db
from ..widgets.container_widgets import CONTAINER_WIDGETS
def move_element_to_container(slug, element_id, new_parent_id):
"""Déplace element_id pour qu'il devienne un enfant de new_parent_id
(glisser-déposer dans l'arborescence, voir screen_edit.html) — posé en
DERNIER enfant, l'ordre précis à l'intérieur se règle ensuite comme
d'habitude par glisser-déposer entre frères et sœurs (voir
reorder_element.py). Refuse silencieusement (ne touche à rien) si :
new_parent_id n'est pas un widget conteneur, si c'est element_id
lui-même, ou si c'est un de ses propres descendants (ça créerait un
cycle — un conteneur ne peut pas finir posé à l'intérieur de lui-même)."""
conn = db.connect(slug)
rows = conn.execute(
"SELECT id, screen_id, parent_id, widget, z_index FROM _screen_elements WHERE id IN (?, ?)",
(element_id, new_parent_id),
).fetchall()
by_id = {r["id"]: r for r in rows}
el = by_id.get(element_id)
target = by_id.get(new_parent_id)
if not el or not target or el["screen_id"] != target["screen_id"]:
conn.close()
return
if target["widget"] not in CONTAINER_WIDGETS:
conn.close()
return
if new_parent_id == element_id:
conn.close()
return
# Cycle : new_parent_id ne doit pas être element_id, ni un descendant de
# element_id (direct ou indirect) — sinon element_id finirait posé à
# l'intérieur de sa propre arborescence.
children_of = {}
for r in conn.execute(
"SELECT id, parent_id FROM _screen_elements WHERE screen_id = ?", (el["screen_id"],)
).fetchall():
if r["parent_id"]:
children_of.setdefault(r["parent_id"], []).append(r["id"])
stack = list(children_of.get(element_id, []))
descendants = set()
while stack:
node = stack.pop()
if node in descendants:
continue
descendants.add(node)
stack.extend(children_of.get(node, []))
if new_parent_id in descendants:
conn.close()
return
max_z = conn.execute(
"SELECT MAX(z_index) AS m FROM _screen_elements WHERE screen_id = ?", (el["screen_id"],)
).fetchone()["m"]
conn.execute(
"UPDATE _screen_elements SET parent_id = ?, z_index = ? WHERE id = ?",
(new_parent_id, (max_z or 0) + 1, element_id),
)
conn.commit()
conn.close()
+1
View File
@@ -303,6 +303,7 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
.treeRow.dragging{ opacity:0.4; } .treeRow.dragging{ opacity:0.4; }
.treeRow.dropBefore{ box-shadow: inset 0 2px 0 0 var(--accent); } .treeRow.dropBefore{ box-shadow: inset 0 2px 0 0 var(--accent); }
.treeRow.dropAfter{ box-shadow: inset 0 -2px 0 0 var(--accent); } .treeRow.dropAfter{ box-shadow: inset 0 -2px 0 0 var(--accent); }
.treeRow.dropInside{ background:rgba(91,140,255,.25); box-shadow: inset 0 0 0 1px var(--accent); border-radius:4px; }
.contextMenu{ .contextMenu{
position:fixed; z-index:500; background:var(--panel); border:1px solid var(--border); position:fixed; z-index:500; background:var(--panel); border:1px solid var(--border);
+80 -42
View File
@@ -31,7 +31,7 @@
{% macro render_tree_node(el) %} {% macro render_tree_node(el) %}
{% set kids = children_of.get(el.id, []) %} {% set kids = children_of.get(el.id, []) %}
<div class="treeNode" data-element-id="{{ el.id }}"> <div class="treeNode" data-element-id="{{ el.id }}">
<div class="treeRow" draggable="true" data-parent-id="{{ el.parent_id or '' }}"> <div class="treeRow" draggable="true" data-parent-id="{{ el.parent_id or '' }}" data-is-container="{{ '1' if el.widget in container_widgets else '' }}">
{% if kids %} {% if kids %}
<button type="button" class="treeToggle" data-node-id="{{ el.id }}" aria-label="Réduire / déplier">▾</button> <button type="button" class="treeToggle" data-node-id="{{ el.id }}" aria-label="Réduire / déplier">▾</button>
{% else %} {% else %}
@@ -82,7 +82,7 @@
<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">
{% for wkey, w in widgets.items() %} {% for wkey, w in widgets.items() if wkey != 'icone' %}
<form method="post" action="{{ url_for('element_add', slug=game.slug, screen_id=screen.id) }}" class="widgetBtnForm"> <form method="post" action="{{ url_for('element_add', slug=game.slug, screen_id=screen.id) }}" class="widgetBtnForm">
<input type="hidden" name="widget" value="{{ wkey }}"> <input type="hidden" name="widget" value="{{ wkey }}">
<button type="submit" class="widgetBtn" title="{{ w.label }}"> <button type="submit" class="widgetBtn" title="{{ w.label }}">
@@ -95,6 +95,27 @@
</div> </div>
</details> </details>
<!-- Galerie d'icônes — pas un widget parmi d'autres dans la grille
ci-dessus (voir "if wkey != 'icone'") : cliquer/glisser une icône ICI
pose directement un élément Icône déjà réglé sur cette icône précise,
plutôt qu'un élément générique qu'il faudrait ensuite configurer. -->
<details class="propGroup">
<summary>🔤 Icônes</summary>
<div class="propGroupBody">
<input type="text" class="input is-small" id="iconGallerySearch" placeholder="Rechercher une icône..." oninput="filterIconGallery(this.value)">
<div class="iconGallery" id="iconGallery">
{% for slug in fontawesome_icons %}
<button type="button" class="iconGalleryTile" draggable="true"
data-icon-class="{{ slug }}" data-icon-search="{{ slug }}"
title="{{ slug }}" onclick="addIconElement('{{ slug }}')">
<i class="icon-svg" style="--icon-url:url('/static/icons/{{ slug }}.svg');"></i>
</button>
{% endfor %}
</div>
<p class="help">Glisse une icône sur le canevas, ou clique dessus pour l'ajouter — sa couleur et sa taille se règlent ensuite dans ses propriétés ("Icône"). Sélection non exhaustive : d'autres icônes pourront être ajoutées à cette liste plus tard si besoin.</p>
</div>
</details>
<!-- Menu rétractable dédié aux éléments de jeu personnalisés (Personnage, <!-- Menu rétractable dédié aux éléments de jeu personnalisés (Personnage,
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
@@ -161,23 +182,6 @@
<button type="button" class="button is-small" onclick="closePropsPanel()" title="Fermer">✕</button> <button type="button" class="button is-small" onclick="closePropsPanel()" title="Fermer">✕</button>
</div> </div>
<div class="floatPanelBody"> <div class="floatPanelBody">
<details class="propGroup">
<summary>🔤 Icônes</summary>
<div class="propGroupBody">
<input type="text" class="input is-small" id="iconGallerySearch" placeholder="Rechercher une icône..." oninput="filterIconGallery(this.value)">
<div class="iconGallery" id="iconGallery">
{% for slug in fontawesome_icons %}
<button type="button" class="iconGalleryTile" draggable="true"
data-icon-class="{{ slug }}" data-icon-search="{{ slug }}"
title="{{ slug }}" onclick="addIconElement('{{ slug }}')">
<i class="icon-svg" style="--icon-url:url('/static/icons/{{ slug }}.svg');"></i>
</button>
{% endfor %}
</div>
<p class="help">Glisse une icône sur le canevas, ou clique dessus pour l'ajouter — sa couleur et sa taille se règlent ensuite dans ses propriétés ("Icône"). Sélection non exhaustive : d'autres icônes pourront être ajoutées à cette liste plus tard si besoin.</p>
</div>
</details>
<!-- Sélecteur visuel d'icône, ouvert depuis le réglage "Icône" d'un <!-- Sélecteur visuel d'icône, ouvert depuis le réglage "Icône" d'un
élément Icône déjà posé (voir plus bas dans la boucle des contrôles) élément Icône déjà posé (voir plus bas dans la boucle des contrôles)
— même galerie que ci-dessus, mais choisir une icône ici REMPLACE — même galerie que ci-dessus, mais choisir une icône ici REMPLACE
@@ -230,7 +234,7 @@
<summary>📥 Ajouter DANS ce conteneur</summary> <summary>📥 Ajouter DANS ce conteneur</summary>
<div class="propGroupBody"> <div class="propGroupBody">
<div class="widgetGrid"> <div class="widgetGrid">
{% for wkey, w in widgets.items() %} {% for wkey, w in widgets.items() if wkey != 'icone' %}
<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">
<input type="hidden" name="widget" value="{{ wkey }}"> <input type="hidden" name="widget" value="{{ wkey }}">
<button type="submit" class="widgetBtn" title="{{ w.label }}"> <button type="submit" class="widgetBtn" title="{{ w.label }}">
@@ -1041,27 +1045,53 @@ if (!window.__forgeTreeToggleBound) {
restoreTreeCollapsedState(); restoreTreeCollapsedState();
// ---------- Arborescence : réordonner par glisser-déposer ---------- // ---------- Arborescence : réordonner ou déplacer dans un conteneur par
// glisser-déposer ----------
// //
// Glisser une ligne au-dessus/en dessous d'une autre ligne DU MÊME // Deux gestes distincts, selon où on relâche sur une ligne cible :
// CONTENEUR (même parent, ou toutes deux à la racine) change leur ordre — // - au-dessus/en dessous d'une ligne DU MÊME CONTENEUR (même parent, ou
// voir screens/elements/reorder_element.py. Écouteurs posés une seule fois // toutes deux à la racine) : change leur ordre (voir reorder_element.py).
// sur document (délégation) : ils survivent au remplacement de #builder3 // - sur le MILIEU d'une ligne dont le widget est un conteneur (n'importe où
// par refreshBuilderPanel/refreshCanvasOnly, pas besoin de les rebrancher. // dans l'arborescence, même un autre parent) : pose l'élément DEDANS, en
// dernier enfant (voir move_element_to_container.py) — c'est ce qui
// permet de ranger un élément existant dans un conteneur sans repasser
// par "Ajouter DANS ce conteneur".
// Écouteurs posés une seule fois sur document (délégation) : ils survivent
// au remplacement de #builder3 par refreshBuilderPanel/refreshCanvasOnly,
// pas besoin de les rebrancher.
var treeDragElementId = null; var treeDragElementId = null;
var treeDragParentId = null; var treeDragParentId = null;
function treeRowDropInfo(row) { function treeRowDropInfo(row) {
var node = row.closest('.treeNode'); var node = row.closest('.treeNode');
return { elementId: node ? node.dataset.elementId : null, parentId: row.dataset.parentId || '' }; return {
elementId: node ? node.dataset.elementId : null,
parentId: row.dataset.parentId || '',
isContainer: row.dataset.isContainer === '1',
};
} }
function clearTreeDropIndicators() { function clearTreeDropIndicators() {
document.querySelectorAll('.treeRow.dropBefore, .treeRow.dropAfter').forEach(function (r) { document.querySelectorAll('.treeRow.dropBefore, .treeRow.dropAfter, .treeRow.dropInside').forEach(function (r) {
r.classList.remove('dropBefore', 'dropAfter'); r.classList.remove('dropBefore', 'dropAfter', 'dropInside');
}); });
} }
// Zone touchée par le curseur dans la ligne cible : avec les frères et
// sœurs, avant/après seulement (moitié haute/basse) ; sur un conteneur,
// une bande centrale "dedans" s'ajoute (tiers haut/milieu/bas) — sauf si ce
// conteneur n'est PAS un frère/sœur du même parent, où seul "dedans" a un
// sens (il n'y a pas d'ordre à changer avec un élément d'un autre parent).
function treeDropZone(row, clientY, sameParent, isContainer) {
var rect = row.getBoundingClientRect();
var ratio = (clientY - rect.top) / rect.height;
if (!sameParent) return isContainer ? 'inside' : null;
if (!isContainer) return ratio < 0.5 ? 'before' : 'after';
if (ratio < 1 / 3) return 'before';
if (ratio > 2 / 3) return 'after';
return 'inside';
}
if (!window.__forgeTreeDragBound) { if (!window.__forgeTreeDragBound) {
window.__forgeTreeDragBound = true; window.__forgeTreeDragBound = true;
@@ -1089,31 +1119,39 @@ if (!window.__forgeTreeDragBound) {
if (!row || treeDragElementId == null) return; if (!row || treeDragElementId == null) return;
var info = treeRowDropInfo(row); var info = treeRowDropInfo(row);
if (info.elementId == null || String(info.elementId) === String(treeDragElementId)) return; if (info.elementId == null || String(info.elementId) === String(treeDragElementId)) return;
if (info.parentId !== treeDragParentId) return; var sameParent = info.parentId === treeDragParentId;
var zone = treeDropZone(row, e.clientY, sameParent, info.isContainer);
if (!zone) return;
e.preventDefault(); e.preventDefault();
e.dataTransfer.dropEffect = 'move'; e.dataTransfer.dropEffect = 'move';
clearTreeDropIndicators(); clearTreeDropIndicators();
var rect = row.getBoundingClientRect(); row.classList.add(zone === 'before' ? 'dropBefore' : zone === 'after' ? 'dropAfter' : 'dropInside');
var before = (e.clientY - rect.top) < rect.height / 2;
row.classList.add(before ? 'dropBefore' : 'dropAfter');
}); });
document.addEventListener('drop', function (e) { document.addEventListener('drop', function (e) {
var row = e.target.closest('.elementTree .treeRow'); var row = e.target.closest('.elementTree .treeRow');
clearTreeDropIndicators(); clearTreeDropIndicators();
if (!row || treeDragElementId == null) return; if (!row || treeDragElementId == null) return;
e.preventDefault();
var info = treeRowDropInfo(row); var info = treeRowDropInfo(row);
if (info.elementId == null || String(info.elementId) === String(treeDragElementId)) return; if (info.elementId == null || String(info.elementId) === String(treeDragElementId)) return;
if (info.parentId !== treeDragParentId) return; var sameParent = info.parentId === treeDragParentId;
var rect = row.getBoundingClientRect(); var zone = treeDropZone(row, e.clientY, sameParent, info.isContainer);
var position = (e.clientY - rect.top) < rect.height / 2 ? 'before' : 'after'; if (!zone) return;
e.preventDefault();
var draggedId = treeDragElementId; var draggedId = treeDragElementId;
fetch('/game/{{ game.slug }}/elements/' + draggedId + '/reorder', { if (zone === 'inside') {
method: 'POST', fetch('/game/{{ game.slug }}/elements/' + draggedId + '/move-to-container', {
headers: { 'Content-Type': 'application/json' }, method: 'POST',
body: JSON.stringify({ target_id: info.elementId, position: position }) headers: { 'Content-Type': 'application/json' },
}).then(refreshAfterReorder); body: JSON.stringify({ parent_id: info.elementId })
}).then(refreshAfterReorder);
} else {
fetch('/game/{{ game.slug }}/elements/' + draggedId + '/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_id: info.elementId, position: zone })
}).then(refreshAfterReorder);
}
}); });
} }