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:
@@ -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})
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user