Intègre un chat IA capable de manipuler la scène via les mêmes fonctions que l'éditeur manuel (objets, variables, déclencheurs, images générées), avec conversations multiples par écran façon Claude. Corrige au passage le rafraîchissement pjax hors-ordre, l'onglet IA/déclencheurs vide après sélection d'un objet, la comparaison de booléens dans les conditions, et le blocage du glisser-déposer hors du cadre caméra après un redimensionnement de fond par l'IA. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
763 lines
33 KiB
JavaScript
763 lines
33 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) {
|
|
// selectedId falsy (0/null/undefined) : referme le panneau de propriétés
|
|
// plutôt que de garder une sélection (ex. après suppression de l'objet
|
|
// sélectionné, voir deleteSceneObject(), static/js/scenes/scene-editor.js)
|
|
// — jamais "?selected=null/undefined" dans l'URL.
|
|
var url = SCREEN_EDIT_URL + (selectedId ? ("?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 || null;
|
|
history.pushState({ pjax: true }, "", result.url);
|
|
initBuilderPanel();
|
|
// Éditeur de scène 2D (voir static/js/scenes/scene-editor.js) : ce
|
|
// fichier générique (partagé avec l'éditeur document) ne connaît pas
|
|
// ses réglages propres (position en pixels, panneau "Commandes",
|
|
// aperçu animé des personnages) — rien à faire ici pour l'éditeur
|
|
// document, où cette fonction n'existe simplement pas.
|
|
if (typeof initSceneBuilderPanel === "function") initSceneBuilderPanel();
|
|
// Bug corrigé : #builder3 contient AUSSI les onglets "Déclencheurs"
|
|
// et "Générer avec l'IA" (composants JS, pas de simple HTML statique)
|
|
// — curBuilder.innerHTML = ... ne réexécute aucun script, et sans cet
|
|
// appel explicite leur contenu restait vide (jusqu'à un vrai
|
|
// rechargement de page) dès qu'on sélectionnait un objet de la scène.
|
|
if (typeof initTriggersTab === "function") initTriggersTab();
|
|
if (typeof initIaTab === "function") initIaTab();
|
|
})
|
|
.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();
|
|
});
|
|
});
|
|
}
|