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_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})
|
||||
@@ -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()
|
||||
@@ -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);
|
||||
|
||||
+80
-42
@@ -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;
|
||||
fetch('/game/{{ game.slug }}/elements/' + draggedId + '/reorder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target_id: info.elementId, position: position })
|
||||
}).then(refreshAfterReorder);
|
||||
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: zone })
|
||||
}).then(refreshAfterReorder);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user