Phase -1 (suite) : découpe l'éditeur de screen_edit.html en modules JS
Même chantier que le commit précédent (moteur de jeu, play.html) — templates/screen_edit.html était un unique fichier HTML+CSS+JS de 3312 lignes, tout l'éditeur (arborescence, panneaux flottants, canevas, formulaire de propriétés, éditeur de flow à nœuds, blocs de logique, timeline d'animation) vivant dans UN SEUL <script>. Ce fichier est plus imbriqué que play.html : de nombreux appels s'exécutent au niveau racine du script (pas seulement des déclarations de fonctions), et JavaScript hoiste les déclarations `function` sur TOUT le script — un appel au niveau racine peut donc référencer une fonction déclarée PLUS LOIN dans le même fichier. Découper naïvement casserait cet ordre implicite. Un audit dédié (analyse ligne par ligne de chaque appel racine + son graphe d'appel transitif) a identifié 3 références "en avance" réelles, toutes regroupées dans la même zone (initBuilderPanel()/toggleActionFields() → bindAspectButtons/ toggleElementPropertyValue/onDataDefinitionChange) — le découpage respecte cette contrainte : chaque fichier est une TRANCHE SÉQUENTIELLE de l'original (jamais une réorganisation), et cette zone spécifique reste un seul fichier (panel-init.js) pour que le hoisting continue de fonctionner exactement comme avant. 5 fichiers sous static/js/screen_edit/ : - tree-panels.js — arborescence, menu contextuel, panneaux flottants gauche/droite, galerie d'icônes, modale de suppression/choix d'icône, glisser-déposer du canevas, panneau de propriétés (autosave). - panel-init.js — (ré)initialisation du panneau central après chaque changement de sélection, filtres de répéteur/donnée liée, condition de visibilité, champs d'action du formulaire de nœud. - flow-editor.js — éditeur de flow à nœuds (rendu du graphe, formulaire d'ajout de nœud, blocs de logique — currentBlockNodes/Edges). - tabs-and-blocks.js — onglets du centre, panneaux flottants génériques (drag/resize/plein écran), modale d'un bloc de logique. - animation-timeline.js — timeline d'animation (clips Animate.css/ personnalisés). Toutes les données injectées par Jinja (GAME_SLUG, SCREEN_ID, DEFINITIONS_DATA, FLOW_NODES_INITIAL, ELEMENTS_LABELS, CUSTOM_EVENTS_MAP, ANIM_CLIPS...) sont posées UNE FOIS par un petit <script> inline resté dans le template, avant les <script src> — même patron que static/js/play/. Le seul bout de logique resté inline est la toute petite IIFE d'ouverture initiale (?tab=/?block=), qui dépend directement de request.args et doit s'exécuter après que tous les fichiers soient chargés. tests/conftest.py : screen_edit_js_bundle() (même principe que play_js_bundle(), Phase -1 précédente) — 4 tests qui vérifiaient la présence de telle fonction/chaîne dans le HTML de l'éditeur (le JS y était inline) sont mis à jour pour chercher dans ce bundle. Un des deux échecs révélait un test déjà fragile (assert "Ligne cliquée" in html vérifiait en réalité le TEXTE SOURCE d'un <script> inline, jamais du HTML réellement rendu — ce texte ne peut plus s'y trouver une fois la fonction qui le construit dynamiquement déplacée dans un fichier externe) : corrigé pour vérifier le bundle JS + la disponibilité de la route séparément. Vérifié : 215 tests passent, syntaxe JS validée sur les 5 nouveaux fichiers (node --check) et sur les <script> inline restants (rendus via le client de test). Test manuel recommandé (édition complète d'une scène : arborescence, propriétés, glisser-déposer, logique de flow, blocs, timeline) avant de considérer ce découpage définitivement sans risque — comme pour play.html, ce fichier n'a pas de harnais de test DOM automatisé. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
dbada333d5
commit
fe807ba51e
@@ -0,0 +1,731 @@
|
||||
// Données injectées par Jinja (GAME_SLUG, SCREEN_ID, SCREEN_EDIT_URL,
|
||||
// CURRENT_SELECTED_ID, ELEMENT_ADD_URL...) : voir le petit <script> inline
|
||||
// resté dans screen_edit.html, en tête de page — ce fichier statique ne
|
||||
// passe jamais par le moteur de templates, voir static/js/play/ pour le
|
||||
// même patron déjà en place côté moteur de jeu.
|
||||
function saveGeometry(id, x, y, width, height) {
|
||||
return fetch("/game/" + GAME_SLUG + "/elements/" + id + "/geometry", {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({x: x, y: y, width: width, height: height})
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 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);
|
||||
}
|
||||
|
||||
// ---------- Arborescence : replier/déplier un conteneur ----------
|
||||
//
|
||||
// Le triangle (.treeToggle) replie/déplie SANS toucher à la sélection : le
|
||||
// nom de l'élément reste un lien à part (.treeRowLink) qui, lui, sélectionne
|
||||
// toujours l'élément et affiche ses réglages — cliquer sur un conteneur
|
||||
// replié donne donc bien accès à ses propriétés, comme demandé. L'état
|
||||
// replié/déplié est mémorisé par élément dans localStorage pour survivre à
|
||||
// un changement de sélection ou un rechargement de l'éditeur.
|
||||
function treeCollapseKey(nodeId) {
|
||||
return 'forgeTreeCollapsed:' + GAME_SLUG + ':' + SCREEN_ID + ':' + nodeId;
|
||||
}
|
||||
|
||||
function restoreTreeCollapsedState() {
|
||||
document.querySelectorAll('.elementTree .treeNode').forEach(function (node) {
|
||||
var id = node.dataset.elementId;
|
||||
var collapsed = false;
|
||||
try { collapsed = localStorage.getItem(treeCollapseKey(id)) === '1'; } catch (e) {}
|
||||
node.classList.toggle('collapsed', collapsed);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Panneaux gauche/droite : flottants, déplaçables, fermables ----------
|
||||
//
|
||||
// Sortis du flux (position:fixed en CSS) pour ne jamais rétrécir le canevas
|
||||
// selon où on les pose — les fermer libère cette largeur pour une
|
||||
// prévisualisation plus fidèle à ce que "Jouer" affiche réellement (voir
|
||||
// bug remonté : des panneaux trop larges réduisaient le canevas au point de
|
||||
// casser la mise en page des onglets). Un seul jeu de fonctions génériques
|
||||
// (key = identifiant court mémorisé dans localStorage, distinct par
|
||||
// panneau) plutôt qu'une copie par panneau. Position et état ouvert/fermé
|
||||
// survivent à un changement de sélection (qui remplace tout #builder3, voir
|
||||
// refreshBuilderPanel) ou un rechargement — voir les appels à
|
||||
// restoreFloatPanelState() dans initBuilderPanel().
|
||||
function floatPanelStateKey(key) {
|
||||
return 'forgeFloatPanel:' + GAME_SLUG + ':' + SCREEN_ID + ':' + key;
|
||||
}
|
||||
|
||||
function saveFloatPanelState(key, state) {
|
||||
try {
|
||||
var current = JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}');
|
||||
localStorage.setItem(floatPanelStateKey(key), JSON.stringify(Object.assign(current, state)));
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function loadFloatPanelState(key) {
|
||||
try { return JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}'); } catch (e) { return {}; }
|
||||
}
|
||||
|
||||
function restoreFloatPanelState(key, panelId, reopenBtnId) {
|
||||
var panel = document.getElementById(panelId);
|
||||
var reopenBtn = document.getElementById(reopenBtnId);
|
||||
if (!panel) return;
|
||||
var state = loadFloatPanelState(key);
|
||||
if (state.left != null && state.top != null) {
|
||||
panel.style.left = state.left + 'px';
|
||||
panel.style.top = state.top + 'px';
|
||||
panel.style.right = 'auto';
|
||||
}
|
||||
var open = state.open !== false; // ouvert par défaut tant que rien n'a été fermé
|
||||
panel.classList.toggle('hidden', !open);
|
||||
if (reopenBtn) reopenBtn.classList.toggle('visible', !open);
|
||||
}
|
||||
|
||||
function closeFloatPanel(key, panelId, reopenBtnId) {
|
||||
var panel = document.getElementById(panelId);
|
||||
var reopenBtn = document.getElementById(reopenBtnId);
|
||||
if (panel) panel.classList.add('hidden');
|
||||
if (reopenBtn) reopenBtn.classList.add('visible');
|
||||
saveFloatPanelState(key, { open: false });
|
||||
}
|
||||
|
||||
function openFloatPanel(key, panelId, reopenBtnId) {
|
||||
var panel = document.getElementById(panelId);
|
||||
var reopenBtn = document.getElementById(reopenBtnId);
|
||||
if (panel) panel.classList.remove('hidden');
|
||||
if (reopenBtn) reopenBtn.classList.remove('visible');
|
||||
saveFloatPanelState(key, { open: true });
|
||||
}
|
||||
|
||||
// Raccourcis utilisés directement par les attributs onclick du gabarit.
|
||||
function closePropsPanel() { closeFloatPanel('props', 'propsPanelFloat', 'propsPanelReopenBtn'); }
|
||||
function openPropsPanel() { openFloatPanel('props', 'propsPanelFloat', 'propsPanelReopenBtn'); }
|
||||
function closeLeftPanel() { closeFloatPanel('left', 'panelLeftFloat', 'panelLeftReopenBtn'); }
|
||||
function openLeftPanel() { openFloatPanel('left', 'panelLeftFloat', 'panelLeftReopenBtn'); }
|
||||
|
||||
if (!window.__forgeFloatPanelDragBound) {
|
||||
window.__forgeFloatPanelDragBound = true;
|
||||
document.addEventListener('mousedown', function (e) {
|
||||
var handle = e.target.closest('.floatPanelHeader');
|
||||
if (!handle || e.target.closest('button')) return;
|
||||
var panel = document.getElementById(handle.dataset.dragPanel);
|
||||
var key = handle.dataset.dragKey;
|
||||
if (!panel) return;
|
||||
var rect = panel.getBoundingClientRect();
|
||||
var startX = e.clientX, startY = e.clientY;
|
||||
var startLeft = rect.left, startTop = rect.top;
|
||||
panel.style.right = 'auto';
|
||||
function onMove(ev) {
|
||||
var maxLeft = window.innerWidth - panel.offsetWidth - 4;
|
||||
var maxTop = window.innerHeight - 40;
|
||||
var left = Math.max(4, Math.min(maxLeft, startLeft + (ev.clientX - startX)));
|
||||
var top = Math.max(4, Math.min(maxTop, startTop + (ev.clientY - startY)));
|
||||
panel.style.left = left + 'px';
|
||||
panel.style.top = top + 'px';
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
saveFloatPanelState(key, { left: parseFloat(panel.style.left), top: parseFloat(panel.style.top) });
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Galerie d'icônes Font Awesome (panneau de propriétés) ----------
|
||||
//
|
||||
// Cliquer une tuile l'ajoute directement (comme n'importe quelle tuile de
|
||||
// "Ajouter un élément sur l'écran") ; la glisser jusque sur le canevas fait
|
||||
// la même chose au relâchement — le drag HTML5 natif ne permet pas de créer
|
||||
// un vrai élément positionné exactement sous le curseur sans aller-retour
|
||||
// serveur supplémentaire, donc on se contente ici de déclencher le même
|
||||
// ajout "à la position par défaut" que le clic, l'utilisateur repositionne
|
||||
// ensuite comme pour tout nouvel élément.
|
||||
function addIconElement(iconClass) {
|
||||
var form = document.createElement('form');
|
||||
form.method = 'post';
|
||||
form.action = ELEMENT_ADD_URL;
|
||||
form.innerHTML =
|
||||
'<input type="hidden" name="widget" value="icone">' +
|
||||
'<input type="hidden" name="icon_class" value="' + iconClass.replace(/"/g, '"') + '">';
|
||||
document.body.appendChild(form);
|
||||
form.requestSubmit();
|
||||
}
|
||||
|
||||
function filterIconGallery(query) {
|
||||
var q = query.trim().toLowerCase();
|
||||
document.querySelectorAll('#iconGallery .iconGalleryTile').forEach(function (tile) {
|
||||
var match = !q || tile.dataset.iconSearch.indexOf(q) !== -1;
|
||||
tile.classList.toggle('iconHidden', !match);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Sélecteur visuel d'icône (réglage "Icône" d'un élément déjà
|
||||
// posé) — même liste que la galerie d'ajout ci-dessus, mais choisir une
|
||||
// icône ici remplace celle de l'élément SÉLECTIONNÉ (champ caché
|
||||
// "field-icon_class") au lieu d'en créer un nouveau. ----------
|
||||
// ---------- Confirmation de suppression d'un élément (modale custom, pas
|
||||
// le confirm() natif du navigateur) — voir deleteConfirmModal plus haut.
|
||||
// Interroge element_delete_impact.py pour savoir si des nœuds de la
|
||||
// Logique de la scène seraient aussi supprimés, et l'affiche si oui,
|
||||
// avant que l'utilisateur confirme quoi que ce soit. ----------
|
||||
var _pendingDeleteForm = null;
|
||||
|
||||
function handleDeleteElementSubmit(event, form) {
|
||||
if (form.dataset.confirmed === '1') {
|
||||
// Deuxième passage, après clic sur "Supprimer" dans la modale (voir
|
||||
// confirmDeleteElement()) : laisse cette fois la soumission suivre son
|
||||
// cours normalement (pjax.js l'intercepte comme n'importe quel autre
|
||||
// formulaire, voir static/pjax.js).
|
||||
form.dataset.confirmed = '';
|
||||
return true;
|
||||
}
|
||||
event.preventDefault();
|
||||
_pendingDeleteForm = form;
|
||||
document.getElementById('deleteConfirmMessage').textContent = form.dataset.confirmMessage || 'Supprimer cet élément ?';
|
||||
var warnEl = document.getElementById('deleteConfirmFlowWarning');
|
||||
warnEl.style.display = 'none';
|
||||
warnEl.textContent = '';
|
||||
document.getElementById('deleteConfirmModal').classList.add('is-active');
|
||||
var m = form.getAttribute('action').match(/\/elements\/(\d+)\/delete$/);
|
||||
if (m) {
|
||||
fetch("/game/" + GAME_SLUG + "/elements/" + m[1] + "/delete-impact")
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
if (!data.flow_node_count) return;
|
||||
var n = data.flow_node_count;
|
||||
var plural = n > 1 ? 's' : '';
|
||||
warnEl.textContent = '⚠️ ' + n + ' connexion' + plural + ' de la Logique de la scène (déclencheur/action) utilise' +
|
||||
(n > 1 ? 'nt' : '') + ' cet élément et sera' + (n > 1 ? 'ont' : '') + ' aussi supprimée' + plural + '.';
|
||||
warnEl.style.display = 'block';
|
||||
})
|
||||
.catch(function () {});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function closeDeleteConfirmModal() {
|
||||
var modal = document.getElementById('deleteConfirmModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
_pendingDeleteForm = null;
|
||||
}
|
||||
|
||||
function confirmDeleteElement() {
|
||||
if (!_pendingDeleteForm) return;
|
||||
var form = _pendingDeleteForm;
|
||||
_pendingDeleteForm = null;
|
||||
closeDeleteConfirmModal();
|
||||
form.dataset.confirmed = '1';
|
||||
if (form.requestSubmit) form.requestSubmit(); else form.submit();
|
||||
}
|
||||
|
||||
function openIconPicker() {
|
||||
var modal = document.getElementById('iconPickerModal');
|
||||
if (modal) modal.classList.add('is-active');
|
||||
}
|
||||
|
||||
function closeIconPicker() {
|
||||
var modal = document.getElementById('iconPickerModal');
|
||||
if (modal) modal.classList.remove('is-active');
|
||||
}
|
||||
|
||||
function chooseIcon(iconSlug) {
|
||||
var input = document.getElementById('field-icon_class');
|
||||
var preview = document.getElementById('iconPickerPreview');
|
||||
if (input) input.value = iconSlug;
|
||||
if (preview) preview.style.setProperty('--icon-url', "url('/static/icons/" + iconSlug + ".svg')");
|
||||
closeIconPicker();
|
||||
submitPropsForm();
|
||||
}
|
||||
|
||||
function filterIconPickerModal(query) {
|
||||
var q = query.trim().toLowerCase();
|
||||
document.querySelectorAll('#iconPickerGrid .iconGalleryTile').forEach(function (tile) {
|
||||
var match = !q || tile.dataset.iconSearch.indexOf(q) !== -1;
|
||||
tile.classList.toggle('iconHidden', !match);
|
||||
});
|
||||
}
|
||||
|
||||
if (!window.__forgeIconPickerEscBound) {
|
||||
window.__forgeIconPickerEscBound = true;
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key === 'Escape') closeIconPicker();
|
||||
});
|
||||
}
|
||||
|
||||
if (!window.__forgeIconGalleryDragBound) {
|
||||
window.__forgeIconGalleryDragBound = true;
|
||||
var draggedIconClass = null;
|
||||
|
||||
document.addEventListener('dragstart', function (e) {
|
||||
var tile = e.target.closest('.iconGalleryTile');
|
||||
if (!tile) return;
|
||||
draggedIconClass = tile.dataset.iconClass;
|
||||
tile.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'copy';
|
||||
try { e.dataTransfer.setData('text/plain', draggedIconClass); } catch (err) {}
|
||||
});
|
||||
|
||||
document.addEventListener('dragend', function (e) {
|
||||
var tile = e.target.closest('.iconGalleryTile');
|
||||
if (tile) tile.classList.remove('dragging');
|
||||
draggedIconClass = null;
|
||||
});
|
||||
|
||||
document.addEventListener('dragover', function (e) {
|
||||
if (!draggedIconClass || !e.target.closest('#canvas')) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
});
|
||||
|
||||
document.addEventListener('drop', function (e) {
|
||||
if (!draggedIconClass || !e.target.closest('#canvas')) return;
|
||||
e.preventDefault();
|
||||
addIconElement(draggedIconClass);
|
||||
draggedIconClass = null;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Surbrillance sur le canevas de l'élément sélectionné ----------
|
||||
//
|
||||
// Un élément posé DIRECTEMENT sur l'écran a déjà son cadre de sélection via
|
||||
// la classe "canvasElement.selected" (posée côté serveur). Un élément
|
||||
// imbriqué dans un conteneur/répéteur/groupe de champs n'a pas ce cadre
|
||||
// séparé — sa propre balise porte "data-element-id" (voir visible_attrs.py)
|
||||
// mais rien ne la mettait en valeur, donc sélectionner un enfant dans
|
||||
// l'arborescence ne se voyait pas du tout sur le canevas. Ce contour
|
||||
// générique cible directement cette balise, quelle que soit sa profondeur.
|
||||
function applySelectionHighlight(id) {
|
||||
var canvas = document.getElementById('canvas');
|
||||
if (!canvas) return;
|
||||
canvas.querySelectorAll('.elementHighlighted').forEach(function (n) {
|
||||
n.classList.remove('elementHighlighted');
|
||||
});
|
||||
if (!id) return;
|
||||
var node = canvas.querySelector('[data-element-id="' + id + '"]');
|
||||
if (node) node.classList.add('elementHighlighted');
|
||||
}
|
||||
|
||||
// ---------- Élément à onglets : bascule d'onglet côté client ----------
|
||||
//
|
||||
// Un seul panneau visible à la fois (voir render_onglets.py) — basculé ici
|
||||
// plutôt que par un aller-retour serveur, pour un rendu instantané aussi
|
||||
// bien dans l'éditeur qu'en jeu (voir la même fonction dans play.html).
|
||||
function forgeShowTab(btn) {
|
||||
var groupId = btn.dataset.tabBtn;
|
||||
var targetId = btn.dataset.tabTarget;
|
||||
document.querySelectorAll('[data-tab-btn="' + groupId + '"]').forEach(function (b) {
|
||||
b.classList.toggle('is-active', b === btn);
|
||||
});
|
||||
document.querySelectorAll('[data-tab-group="' + groupId + '"]').forEach(function (p) {
|
||||
p.style.display = (p.id === targetId) ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
if (!window.__forgeTreeToggleBound) {
|
||||
window.__forgeTreeToggleBound = true;
|
||||
document.addEventListener('click', function (e) {
|
||||
var btn = e.target.closest('.treeToggle');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var node = btn.closest('.treeNode');
|
||||
var collapsed = node.classList.toggle('collapsed');
|
||||
try { localStorage.setItem(treeCollapseKey(node.dataset.elementId), collapsed ? '1' : '0'); } catch (err) {}
|
||||
});
|
||||
}
|
||||
|
||||
restoreTreeCollapsedState();
|
||||
|
||||
// ---------- Arborescence : réordonner ou déplacer dans un conteneur par
|
||||
// glisser-déposer ----------
|
||||
//
|
||||
// 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 désormais LA
|
||||
// façon de ranger un élément dans un conteneur (l'ancien menu dédié
|
||||
// "Ajouter DANS ce conteneur" du panneau de propriétés a été retiré,
|
||||
// devenu redondant avec ce geste).
|
||||
// É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 || '',
|
||||
isContainer: row.dataset.isContainer === '1',
|
||||
};
|
||||
}
|
||||
|
||||
function clearTreeDropIndicators() {
|
||||
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;
|
||||
|
||||
document.addEventListener('dragstart', function (e) {
|
||||
var row = e.target.closest('.elementTree .treeRow');
|
||||
if (!row) return;
|
||||
var info = treeRowDropInfo(row);
|
||||
treeDragElementId = info.elementId;
|
||||
treeDragParentId = info.parentId;
|
||||
row.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
try { e.dataTransfer.setData('text/plain', String(info.elementId)); } catch (err) {}
|
||||
});
|
||||
|
||||
document.addEventListener('dragend', function (e) {
|
||||
var row = e.target.closest('.elementTree .treeRow');
|
||||
if (row) row.classList.remove('dragging');
|
||||
clearTreeDropIndicators();
|
||||
treeDragElementId = null;
|
||||
treeDragParentId = null;
|
||||
});
|
||||
|
||||
document.addEventListener('dragover', function (e) {
|
||||
var row = e.target.closest('.elementTree .treeRow');
|
||||
if (!row || treeDragElementId == null) return;
|
||||
var info = treeRowDropInfo(row);
|
||||
if (info.elementId == null || String(info.elementId) === String(treeDragElementId)) 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();
|
||||
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;
|
||||
var info = treeRowDropInfo(row);
|
||||
if (info.elementId == null || String(info.elementId) === String(treeDragElementId)) return;
|
||||
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: zone })
|
||||
}).then(refreshAfterReorder);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAfterReorder() {
|
||||
var url = SCREEN_EDIT_URL + (CURRENT_SELECTED_ID ? ('?selected=' + CURRENT_SELECTED_ID) : '');
|
||||
return fetch(url, { headers: { 'X-Requested-With': 'pjax' } })
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) {
|
||||
var doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
var newCanvas = doc.getElementById('canvas');
|
||||
var curCanvas = document.getElementById('canvas');
|
||||
if (newCanvas && curCanvas) {
|
||||
curCanvas.innerHTML = newCanvas.innerHTML;
|
||||
applySelectionHighlight(CURRENT_SELECTED_ID);
|
||||
}
|
||||
var newTree = doc.querySelector('#panelLeftFloat .elementTree');
|
||||
var curTree = document.querySelector('#panelLeftFloat .elementTree');
|
||||
if (newTree && curTree) { curTree.innerHTML = newTree.innerHTML; }
|
||||
restoreTreeCollapsedState();
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- Navigation dans l'éditeur sans AUCUN rechargement de page ----------
|
||||
//
|
||||
// Se déplacer dans le canevas (glisser, redimensionner, cliquer un autre
|
||||
// élément) et modifier les réglages d'un élément ne doivent jamais recharger
|
||||
// la page : on va chercher en arrière-plan (fetch) uniquement le fragment
|
||||
// HTML dont on a besoin (le bloc #builder3 en entier pour changer de
|
||||
// sélection, ou juste #canvas pour refléter un changement visuel après une
|
||||
// sauvegarde automatique) et on le remplace dans le DOM courant. L'URL
|
||||
// (?selected=...) reste mise à jour via l'historique (pushState), pour que
|
||||
// le bouton "précédent" du navigateur continue à fonctionner normalement.
|
||||
|
||||
function refreshBuilderPanel(selectedId) {
|
||||
var url = SCREEN_EDIT_URL + "?selected=" + selectedId;
|
||||
return fetch(url, { headers: { "X-Requested-With": "pjax" } })
|
||||
.then(function (r) { return r.text().then(function (html) { return { html: html, url: r.url || url }; }); })
|
||||
.then(function (result) {
|
||||
var doc = new DOMParser().parseFromString(result.html, "text/html");
|
||||
var newBuilder = doc.getElementById("builder3");
|
||||
var curBuilder = document.getElementById("builder3");
|
||||
if (newBuilder && curBuilder) {
|
||||
curBuilder.innerHTML = newBuilder.innerHTML;
|
||||
}
|
||||
CURRENT_SELECTED_ID = selectedId;
|
||||
history.pushState({ pjax: true }, "", result.url);
|
||||
initBuilderPanel();
|
||||
})
|
||||
.catch(function () {
|
||||
// Échec réseau : on retombe sur une vraie navigation plutôt que de
|
||||
// laisser l'éditeur dans un état incohérent.
|
||||
window.location.href = url;
|
||||
});
|
||||
}
|
||||
|
||||
function refreshCanvasOnly() {
|
||||
if (!CURRENT_SELECTED_ID) return Promise.resolve();
|
||||
var url = SCREEN_EDIT_URL + "?selected=" + CURRENT_SELECTED_ID;
|
||||
return fetch(url, { headers: { "X-Requested-With": "pjax" } })
|
||||
.then(function (r) { return r.text(); })
|
||||
.then(function (html) {
|
||||
var doc = new DOMParser().parseFromString(html, "text/html");
|
||||
var newCanvas = doc.getElementById("canvas");
|
||||
var curCanvas = document.getElementById("canvas");
|
||||
if (newCanvas && curCanvas) {
|
||||
curCanvas.innerHTML = newCanvas.innerHTML;
|
||||
applySelectionHighlight(CURRENT_SELECTED_ID);
|
||||
}
|
||||
// Aperçu du contenu dans l'arborescence de gauche (ex: texte tronqué) —
|
||||
// sans toucher au panneau de droite, pour ne jamais faire perdre le
|
||||
// focus/curseur pendant qu'on modifie un réglage.
|
||||
var newTree = doc.querySelector('#panelLeftFloat .elementTree');
|
||||
var curTree = document.querySelector('#panelLeftFloat .elementTree');
|
||||
if (newTree && curTree) { curTree.innerHTML = newTree.innerHTML; restoreTreeCollapsedState(); }
|
||||
});
|
||||
}
|
||||
|
||||
function goToSelected(id) {
|
||||
return refreshBuilderPanel(id);
|
||||
}
|
||||
|
||||
// ---------- Glisser-déposer / redimensionner sur le canevas ----------
|
||||
//
|
||||
// Un mousedown qui bouge de plus de quelques pixels déplace l'élément
|
||||
// (position sauvegardée en arrière-plan à la fin, sans recharger la page —
|
||||
// comme l'élément déplacé reste celui déjà sélectionné, on se contente de
|
||||
// mettre à jour les champs X/Y/Largeur/Hauteur en local, rien d'autre à
|
||||
// rafraîchir) ; un mousedown qui ne bouge pas est traité comme un simple
|
||||
// clic de sélection (qui, lui, doit rafraîchir le panneau de droite pour
|
||||
// afficher les réglages du nouvel élément choisi).
|
||||
function onElementMouseDown(e, id) {
|
||||
if (e.target.classList.contains('resizeHandle')) return;
|
||||
e.preventDefault();
|
||||
var el = document.getElementById('el-' + id);
|
||||
var canvas = document.getElementById('canvas');
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var startX = e.clientX, startY = e.clientY;
|
||||
var startLeft = parseFloat(el.style.left), startTop = parseFloat(el.style.top);
|
||||
var width = parseFloat(el.style.width), height = parseFloat(el.style.height);
|
||||
var moved = false;
|
||||
// Un clic (sans glisser) sélectionne l'élément précis qu'on a touché —
|
||||
// même s'il est physiquement imbriqué dans ce conteneur — pour éviter de
|
||||
// devoir repasser par l'arborescence à chaque fois. Le glisser-déposer,
|
||||
// lui, reste toujours celui de la boîte de plus haut niveau (id).
|
||||
var clickedTarget = e.target.closest('[data-element-id]');
|
||||
var clickedId = clickedTarget ? clickedTarget.dataset.elementId : id;
|
||||
|
||||
function onMove(ev) {
|
||||
var dx = ev.clientX - startX, dy = ev.clientY - startY;
|
||||
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
|
||||
var newLeft = Math.max(0, Math.min(100 - width, startLeft + (dx / rect.width) * 100));
|
||||
var newTop = Math.max(0, Math.min(100 - height, startTop + (dy / rect.height) * 100));
|
||||
el.style.left = newLeft + '%';
|
||||
el.style.top = newTop + '%';
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
if (moved) {
|
||||
var newLeft = parseFloat(el.style.left), newTop = parseFloat(el.style.top);
|
||||
updatePosFields(newLeft, newTop, width, height);
|
||||
saveGeometry(id, newLeft, newTop, width, height);
|
||||
} else if (String(clickedId) !== String(CURRENT_SELECTED_ID)) {
|
||||
goToSelected(clickedId);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
function onResizeMouseDown(e, id) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
var el = document.getElementById('el-' + id);
|
||||
var canvas = document.getElementById('canvas');
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
var startX = e.clientX, startY = e.clientY;
|
||||
var startWidth = parseFloat(el.style.width), startHeight = parseFloat(el.style.height);
|
||||
var left = parseFloat(el.style.left), top = parseFloat(el.style.top);
|
||||
|
||||
function onMove(ev) {
|
||||
var dx = ev.clientX - startX, dy = ev.clientY - startY;
|
||||
var newWidth = Math.max(5, Math.min(100 - left, startWidth + (dx / rect.width) * 100));
|
||||
var newHeight = Math.max(5, Math.min(100 - top, startHeight + (dy / rect.height) * 100));
|
||||
el.style.width = newWidth + '%';
|
||||
el.style.height = newHeight + '%';
|
||||
}
|
||||
function onUp() {
|
||||
document.removeEventListener('mousemove', onMove);
|
||||
document.removeEventListener('mouseup', onUp);
|
||||
var newWidth = parseFloat(el.style.width), newHeight = parseFloat(el.style.height);
|
||||
updatePosFields(left, top, newWidth, newHeight);
|
||||
saveGeometry(id, left, top, newWidth, newHeight);
|
||||
}
|
||||
document.addEventListener('mousemove', onMove);
|
||||
document.addEventListener('mouseup', onUp);
|
||||
}
|
||||
|
||||
function updatePosFields(x, y, w, h) {
|
||||
var xEl = document.getElementById('pos-x'), yEl = document.getElementById('pos-y');
|
||||
var wEl = document.getElementById('pos-w'), hEl = document.getElementById('pos-h');
|
||||
if (xEl) xEl.value = Math.round(x * 10) / 10;
|
||||
if (yEl) yEl.value = Math.round(y * 10) / 10;
|
||||
if (wEl) wEl.value = Math.round(w * 10) / 10;
|
||||
if (hEl) hEl.value = Math.round(h * 10) / 10;
|
||||
}
|
||||
|
||||
// ---------- Panneau de propriétés : tout est enregistré automatiquement ----------
|
||||
//
|
||||
// Il n'y a plus de bouton "Enregistrer" : chaque réglage se sauvegarde tout
|
||||
// seul dès qu'on le change (immédiatement pour une case à cocher/couleur/
|
||||
// liste déroulante, après une courte pause pour un champ texte ou un
|
||||
// slider qu'on est en train de glisser, pour éviter d'envoyer une requête à
|
||||
// chaque caractère tapé ou à chaque pixel de glissement).
|
||||
var autosaveTimer = null;
|
||||
var autosaveIndicator = null;
|
||||
|
||||
function flashAutosave(state) {
|
||||
if (!autosaveIndicator) return;
|
||||
autosaveIndicator.classList.remove('saving', 'saved');
|
||||
if (state === 'saving') {
|
||||
autosaveIndicator.textContent = 'Enregistrement...';
|
||||
autosaveIndicator.classList.add('saving');
|
||||
} else {
|
||||
autosaveIndicator.textContent = 'Enregistré automatiquement ✓';
|
||||
autosaveIndicator.classList.add('saved');
|
||||
}
|
||||
}
|
||||
|
||||
function submitPropsForm() {
|
||||
var form = document.getElementById('propsForm');
|
||||
if (!form) return Promise.resolve();
|
||||
flashAutosave('saving');
|
||||
var data = new FormData(form);
|
||||
return fetch(form.getAttribute('action'), {
|
||||
method: 'POST', body: data, headers: { "X-Requested-With": "pjax" }
|
||||
}).then(function () {
|
||||
flashAutosave('saved');
|
||||
return refreshCanvasOnly();
|
||||
});
|
||||
}
|
||||
|
||||
function debouncedSubmitPropsForm() {
|
||||
clearTimeout(autosaveTimer);
|
||||
autosaveTimer = setTimeout(submitPropsForm, 500);
|
||||
}
|
||||
|
||||
// Insère "{{champ}}" à l'emplacement du curseur dans le champ "Contenu"
|
||||
// (voir controls_with_values.py, control["field_options"] posé une fois
|
||||
// "Lier à un objet de données" réglé) — évite d'avoir à taper la syntaxe
|
||||
// à la main sans savoir quels noms de champs existent. OPEN/CLOSE en deux
|
||||
// morceaux : Jinja interpréterait "{{"/"}}" littéralement adjacents dans
|
||||
// le SOURCE de ce template comme le début d'une expression s'il les
|
||||
// rencontrait tels quels (même à l'intérieur d'un <script>), voir la même
|
||||
// astuce pour _FILTER_REF_OPEN/_FILTER_REF_CLOSE plus bas.
|
||||
function insertFieldAtCursor(fieldId, pickerId) {
|
||||
var field = document.getElementById(fieldId);
|
||||
var picker = document.getElementById(pickerId);
|
||||
if (!field || !picker || !picker.value) return;
|
||||
var open = '{' + '{', close = '}' + '}';
|
||||
var text = open + picker.value + close;
|
||||
var start = field.selectionStart || 0, end = field.selectionEnd || 0;
|
||||
field.value = field.value.substring(0, start) + text + field.value.substring(end);
|
||||
var newPos = start + text.length;
|
||||
field.focus();
|
||||
field.setSelectionRange(newPos, newPos);
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
picker.value = '';
|
||||
}
|
||||
|
||||
function bindPropsAutosave() {
|
||||
var form = document.getElementById('propsForm');
|
||||
autosaveIndicator = document.getElementById('autosaveStatus');
|
||||
if (!form) return;
|
||||
form.querySelectorAll('input, textarea, select').forEach(function (field) {
|
||||
if (field.classList.contains('uploadInput')) return; // géré séparément (upload de fichier)
|
||||
var debounced = field.type === 'text' || field.type === 'range' || field.tagName === 'TEXTAREA';
|
||||
field.addEventListener(debounced ? 'input' : 'change', function () {
|
||||
// Le bascule visuelle de l'alignement (case active) est déjà gérée
|
||||
// par l'attribut onchange="syncAlign(this)" posé sur chaque radio.
|
||||
if (debounced) debouncedSubmitPropsForm(); else submitPropsForm();
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user