À la demande de l'utilisateur (validation de la refonte visuelle du widget Personnage), deux ajustements : - Bibliothèque de sprites Forge étendue aux 6 personnages du pack Kenney "Toon Characters" (aventurier/aventurière, personnage homme/femme, robot, zombie), avec TOUTES leurs poses (45 par personnage) plutôt que 3 — regroupées en 31 animations nommées par personnage (poses numérotées type walk0..walk7 fusionnées en un seul cycle "walk", les autres restant des poses figées à une image). ~1,2 Mo au total, toujours un sous-ensemble curé du pack source (assets/characters/, non versionné) — HD/Parts/Tilesheet/Vector toujours exclus. - Import de sprites personnalisés retiré pour le moment : plus de tuile "➕ Sprite personnalisé" dans la galerie d'ajout, plus de section d'import dans les propriétés d'un personnage — seuls les personnages Forge restent proposés. La résolution serveur de _personnage_data garde son support générique de la source "custom" (aucune migration requise si cette possibilité revient plus tard), mais plus aucune UI ne permet de la créer. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
746 lines
32 KiB
JavaScript
746 lines
32 KiB
JavaScript
// 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();
|
|
}
|
|
|
|
// Phase 8 — galerie "🎭 Personnages" (panneau gauche) : mirror exact
|
|
// d'addIconElement() ci-dessus, pose un personnage Forge déjà configuré
|
|
// (voir element_add.py).
|
|
function addPersonnageElement(forgeCharacter) {
|
|
var form = document.createElement('form');
|
|
form.method = 'post';
|
|
form.action = ELEMENT_ADD_URL;
|
|
form.innerHTML =
|
|
'<input type="hidden" name="widget" value="personnage">' +
|
|
'<input type="hidden" name="forge_character" value="' + forgeCharacter.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();
|
|
});
|
|
});
|
|
}
|