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_geometry,
element_reorder,
element_move_to_container,
element_save,
element_delete,
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.update_element_geometry import update_element_geometry
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.delete_element import delete_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.dropBefore{ 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{
position:fixed; z-index:500; background:var(--panel); border:1px solid var(--border);
+76 -38
View File
@@ -31,7 +31,7 @@
{% macro render_tree_node(el) %}
{% set kids = children_of.get(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 %}
<button type="button" class="treeToggle" data-node-id="{{ el.id }}" aria-label="Réduire / déplier">▾</button>
{% else %}
@@ -82,7 +82,7 @@
<summary>Ajouter un élément sur l'écran</summary>
<div class="propGroupBody">
<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">
<input type="hidden" name="widget" value="{{ wkey }}">
<button type="submit" class="widgetBtn" title="{{ w.label }}">
@@ -95,6 +95,27 @@
</div>
</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,
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
@@ -161,23 +182,6 @@
<button type="button" class="button is-small" onclick="closePropsPanel()" title="Fermer">✕</button>
</div>
<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
é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
@@ -230,7 +234,7 @@
<summary>📥 Ajouter DANS ce conteneur</summary>
<div class="propGroupBody">
<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">
<input type="hidden" name="widget" value="{{ wkey }}">
<button type="submit" class="widgetBtn" title="{{ w.label }}">
@@ -1041,27 +1045,53 @@ if (!window.__forgeTreeToggleBound) {
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
// CONTENEUR (même parent, ou toutes deux à la racine) change leur ordre —
// voir screens/elements/reorder_element.py. É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.
// Deux gestes distincts, selon où on relâche sur une ligne cible :
// - au-dessus/en dessous d'une ligne DU MÊME CONTENEUR (même parent, ou
// toutes deux à la racine) : change leur ordre (voir reorder_element.py).
// - sur le MILIEU d'une ligne dont le widget est un conteneur (n'importe où
// 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 treeDragParentId = null;
function treeRowDropInfo(row) {
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() {
document.querySelectorAll('.treeRow.dropBefore, .treeRow.dropAfter').forEach(function (r) {
r.classList.remove('dropBefore', 'dropAfter');
document.querySelectorAll('.treeRow.dropBefore, .treeRow.dropAfter, .treeRow.dropInside').forEach(function (r) {
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) {
window.__forgeTreeDragBound = true;
@@ -1089,31 +1119,39 @@ if (!window.__forgeTreeDragBound) {
if (!row || treeDragElementId == null) return;
var info = treeRowDropInfo(row);
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.dataTransfer.dropEffect = 'move';
clearTreeDropIndicators();
var rect = row.getBoundingClientRect();
var before = (e.clientY - rect.top) < rect.height / 2;
row.classList.add(before ? 'dropBefore' : 'dropAfter');
row.classList.add(zone === 'before' ? 'dropBefore' : zone === 'after' ? 'dropAfter' : 'dropInside');
});
document.addEventListener('drop', function (e) {
var row = e.target.closest('.elementTree .treeRow');
clearTreeDropIndicators();
if (!row || treeDragElementId == null) return;
e.preventDefault();
var info = treeRowDropInfo(row);
if (info.elementId == null || String(info.elementId) === String(treeDragElementId)) return;
if (info.parentId !== treeDragParentId) return;
var rect = row.getBoundingClientRect();
var position = (e.clientY - rect.top) < rect.height / 2 ? 'before' : 'after';
var sameParent = info.parentId === treeDragParentId;
var zone = treeDropZone(row, e.clientY, sameParent, info.isContainer);
if (!zone) return;
e.preventDefault();
var draggedId = treeDragElementId;
if (zone === 'inside') {
fetch('/game/{{ game.slug }}/elements/' + draggedId + '/move-to-container', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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: position })
body: JSON.stringify({ target_id: info.elementId, position: zone })
}).then(refreshAfterReorder);
}
});
}