Files
Forge-Engine/static/game/js/screen_edit/tree-panels.js
T
williamandClaude Sonnet 5 b2e933f322
Build and deploy / test-python (push) Successful in 11m12s
Build and deploy / test-js (push) Successful in 53s
Build and deploy / lint-python (push) Successful in 3m56s
Build and deploy / lint-js (push) Successful in 3m1s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m58s
Reorganisation game/document : renommage screens->game_engine + sous-dossiers game/ dans routes, scripts, static, templates, tests
Prepare la scission a venir entre l'editeur Jeu 2D et le futur editeur
Support de formation (voir docs/plan/PLAN.md), sans toucher a
l'architecture en couches existante :

- screens/ renomme en game_engine/ (nom clair pour le moteur du jeu 2D,
  avant l'arrivee d'un second "moteur" cote document) : ~85 imports
  corriges, contrat import-linter mis a jour, meme forme de couches.
- routes/, scripts/, static/, templates/, tests/ : tout ce qui est
  propre au jeu 2D deplace dans un sous-dossier game/ de chacun
  (routes/game/, static/game/, templates/game/, tests/game/,
  scripts/game/) ; ce qui est partage par le site (auth, onboarding,
  dashboard, uploads, db/) reste a la racine de chaque dossier. Un
  sous-dossier document/ (vide) cree dans chacun pour le futur chantier.
- styles/ volontairement inchange : les 3 fichiers sources sont
  concatenes en un seul static/style.css charge par tout le site,
  scinder leur CONTENU (editeur vs partage) serait un refactor CSS
  distinct, pas un deplacement mecanique.
- Chaine d'export SCORM (publish/build_scorm_package.py) mise a jour en
  profondeur : copie des assets, URLs d'icones relatives a
  static/style.css (qui ne bouge pas), manifeste, wrapper SCORM.
- Deux regressions d'un sweep de renommage anterieur corrigees au passage
  (screens.js/screens/scene-objects incorrectement convertis en
  game_engine.js/game_engine/scene-objects dans des commentaires).
- Effet de bord Windows decouvert et corrige : git mv + Path.write_text
  convertissent des fichiers en CRLF (core.autocrlf=true) - ~189 fichiers
  normalises en LF.
- .eslintrc.json/package.json : uniquement les chemins de glob mis a jour
  (static/game/js/...) ; la preparation eslint-plugin-unicorn du lot 7
  reste volontairement non committee (package-lock.json restaure a la
  version precedente).

Verifications : ruff, mypy --strict (391 fichiers), vulture, bandit,
lint-imports tous verts ; 591/591 tests Python, 276/276 tests JS ;
demarrage serveur + requetes HTTP manuelles confirmant que les assets
deplaces repondent en 200 au nouvel emplacement et 404 a l'ancien.

SKIP=djlint : backlog H021 (styles inline) deja documente comme dette
assumee dans CODE_QUALITY.md section 6, aucun template touche par ce
commit au-dela d'un deplacement de fichier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 12:27:53 +02:00

597 lines
26 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/game/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, y, width, height,
}),
});
}
// ---------- 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((node) => {
const id = node.dataset.elementId;
let collapsed = false;
try { collapsed = localStorage.getItem(treeCollapseKey(id)) === '1'; } catch (e) {} // NOSONAR S2486 - lecture localStorage optionnelle (etat replie/deplie), echec sans consequence, reste "deplie" par defaut
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 {
const current = JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}');
localStorage.setItem(floatPanelStateKey(key), JSON.stringify(Object.assign(current, state)));
} catch (e) {} // NOSONAR S2486 - localStorage optionnel, voir CODE_QUALITY.md
}
function loadFloatPanelState(key) {
try { return JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}'); } catch (e) { return {}; }
}
function restoreFloatPanelState(key, panelId, reopenBtnId) {
const panel = document.getElementById(panelId);
const reopenBtn = document.getElementById(reopenBtnId);
if (!panel) return;
const 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';
}
const 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) {
const panel = document.getElementById(panelId);
const 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) {
const panel = document.getElementById(panelId);
const 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', (e) => {
const handle = e.target.closest('.floatPanelHeader');
if (!handle || e.target.closest('button')) return;
const panel = document.getElementById(handle.dataset.dragPanel);
const key = handle.dataset.dragKey;
if (!panel) return;
const rect = panel.getBoundingClientRect();
const startX = e.clientX; const
startY = e.clientY;
const startLeft = rect.left; const
startTop = rect.top;
panel.style.right = 'auto';
function onMove(ev) {
const maxLeft = window.innerWidth - panel.offsetWidth - 4;
const maxTop = window.innerHeight - 40;
const left = Math.max(4, Math.min(maxLeft, startLeft + (ev.clientX - startX)));
const 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: Number.parseFloat(panel.style.left), top: Number.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) {
const 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.replaceAll('"', '&quot;')}">`;
document.body.appendChild(form);
form.requestSubmit();
}
if (!window.__forgeIconGalleryDragBound) {
window.__forgeIconGalleryDragBound = true;
let draggedIconClass = null;
document.addEventListener('dragstart', (e) => {
const 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) {} // NOSONAR S2486 - setData() cosmetique HTML5 (validite du drag cross-navigateur) ; le drop lit draggedIconClass (variable JS), jamais dataTransfer.getData()
});
document.addEventListener('dragend', (e) => {
const tile = e.target.closest('.iconGalleryTile');
if (tile) tile.classList.remove('dragging');
draggedIconClass = null;
});
document.addEventListener('dragover', (e) => {
if (!draggedIconClass || !e.target.closest('#canvas')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
document.addEventListener('drop', (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) {
const canvas = document.getElementById('canvas');
if (!canvas) return;
canvas.querySelectorAll('.elementHighlighted').forEach((n) => {
n.classList.remove('elementHighlighted');
});
if (!id) return;
const 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) {
const groupId = btn.dataset.tabBtn;
const targetId = btn.dataset.tabTarget;
document.querySelectorAll(`[data-tab-btn="${groupId}"]`).forEach((b) => {
b.classList.toggle('is-active', b === btn);
});
document.querySelectorAll(`[data-tab-group="${groupId}"]`).forEach((p) => {
p.style.display = (p.id === targetId) ? '' : 'none';
});
}
if (!window.__forgeTreeToggleBound) {
window.__forgeTreeToggleBound = true;
document.addEventListener('click', (e) => {
const btn = e.target.closest('.treeToggle');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
const node = btn.closest('.treeNode');
const collapsed = node.classList.toggle('collapsed');
try { localStorage.setItem(treeCollapseKey(node.dataset.elementId), collapsed ? '1' : '0'); } catch (err) {} // NOSONAR S2486 - ecriture localStorage optionnelle (etat replie/deplie), echec sans consequence
});
}
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.
let treeDragElementId = null;
let treeDragParentId = null;
function treeRowDropInfo(row) {
const 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((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) {
const rect = row.getBoundingClientRect();
const 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', (e) => {
const row = e.target.closest('.elementTree .treeRow');
if (!row) return;
const 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) {} // NOSONAR S2486 - setData() cosmetique HTML5 ; le drop lit treeDragElementId (variable JS), jamais dataTransfer.getData()
});
document.addEventListener('dragend', (e) => {
const row = e.target.closest('.elementTree .treeRow');
if (row) row.classList.remove('dragging');
clearTreeDropIndicators();
treeDragElementId = null;
treeDragParentId = null;
});
document.addEventListener('dragover', (e) => {
const row = e.target.closest('.elementTree .treeRow');
if (!row || treeDragElementId == null) return;
const info = treeRowDropInfo(row);
if (info.elementId == null || String(info.elementId) === String(treeDragElementId)) return;
const sameParent = info.parentId === treeDragParentId;
const zone = treeDropZone(row, e.clientY, sameParent, info.isContainer);
if (!zone) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
clearTreeDropIndicators();
let zoneClass = 'dropInside';
if (zone === 'before') zoneClass = 'dropBefore';
else if (zone === 'after') zoneClass = 'dropAfter';
row.classList.add(zoneClass);
});
document.addEventListener('drop', (e) => {
const row = e.target.closest('.elementTree .treeRow');
clearTreeDropIndicators();
if (!row || treeDragElementId == null) return;
const info = treeRowDropInfo(row);
if (info.elementId == null || String(info.elementId) === String(treeDragElementId)) return;
const sameParent = info.parentId === treeDragParentId;
const zone = treeDropZone(row, e.clientY, sameParent, info.isContainer);
if (!zone) return;
e.preventDefault();
const 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() {
const url = SCREEN_EDIT_URL + (CURRENT_SELECTED_ID ? (`?selected=${CURRENT_SELECTED_ID}`) : '');
return fetch(url, { headers: { 'X-Requested-With': 'pjax' } })
.then((r) => r.text())
.then((html) => {
const doc = new DOMParser().parseFromString(html, 'text/html');
const newCanvas = doc.getElementById('canvas');
const curCanvas = document.getElementById('canvas');
if (newCanvas && curCanvas) {
curCanvas.innerHTML = newCanvas.innerHTML;
applySelectionHighlight(CURRENT_SELECTED_ID);
}
const newTree = doc.querySelector('#panelLeftFloat .elementTree');
const 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/game/js/scenes/scene-editor.js)
// — jamais "?selected=null/undefined" dans l'URL.
const url = SCREEN_EDIT_URL + (selectedId ? (`?selected=${selectedId}`) : '');
return fetch(url, { headers: { 'X-Requested-With': 'pjax' } })
.then((r) => r.text().then((html) => ({ html, url: r.url || url })))
.then((result) => {
const doc = new DOMParser().parseFromString(result.html, 'text/html');
const newBuilder = doc.getElementById('builder3');
const curBuilder = document.getElementById('builder3');
if (newBuilder && curBuilder) {
curBuilder.innerHTML = newBuilder.innerHTML;
}
CURRENT_SELECTED_ID = selectedId || null;
window.history.pushState({ pjax: true }, '', result.url);
initBuilderPanel();
// Éditeur de scène 2D (voir static/game/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(() => {
// É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();
const url = `${SCREEN_EDIT_URL}?selected=${CURRENT_SELECTED_ID}`;
return fetch(url, { headers: { 'X-Requested-With': 'pjax' } })
.then((r) => r.text())
.then((html) => {
const doc = new DOMParser().parseFromString(html, 'text/html');
const newCanvas = doc.getElementById('canvas');
const 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.
const newTree = doc.querySelector('#panelLeftFloat .elementTree');
const 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();
const el = document.getElementById(`el-${id}`);
const canvas = document.getElementById('canvas');
const rect = canvas.getBoundingClientRect();
const startX = e.clientX; const
startY = e.clientY;
const startLeft = Number.parseFloat(el.style.left); const
startTop = Number.parseFloat(el.style.top);
const width = Number.parseFloat(el.style.width); const
height = Number.parseFloat(el.style.height);
let 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).
const clickedTarget = e.target.closest('[data-element-id]');
const clickedId = clickedTarget ? clickedTarget.dataset.elementId : id;
function onMove(ev) {
const dx = ev.clientX - startX; const
dy = ev.clientY - startY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) moved = true;
const newLeft = Math.max(0, Math.min(100 - width, startLeft + (dx / rect.width) * 100));
const 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) {
const newLeft = Number.parseFloat(el.style.left); const
newTop = Number.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();
const el = document.getElementById(`el-${id}`);
const canvas = document.getElementById('canvas');
const rect = canvas.getBoundingClientRect();
const startX = e.clientX; const
startY = e.clientY;
const startWidth = Number.parseFloat(el.style.width); const
startHeight = Number.parseFloat(el.style.height);
const left = Number.parseFloat(el.style.left); const
top = Number.parseFloat(el.style.top);
function onMove(ev) {
const dx = ev.clientX - startX; const
dy = ev.clientY - startY;
const newWidth = Math.max(5, Math.min(100 - left, startWidth + (dx / rect.width) * 100));
const 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);
const newWidth = Number.parseFloat(el.style.width); const
newHeight = Number.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) {
const xEl = document.getElementById('pos-x'); const
yEl = document.getElementById('pos-y');
const wEl = document.getElementById('pos-w'); const
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).
let autosaveTimer = null;
let 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() {
const form = document.getElementById('propsForm');
if (!form) return Promise.resolve();
flashAutosave('saving');
const data = new FormData(form);
return fetch(form.getAttribute('action'), {
method: 'POST', body: data, headers: { 'X-Requested-With': 'pjax' },
}).then(() => {
flashAutosave('saved');
return refreshCanvasOnly();
});
}
function debouncedSubmitPropsForm() {
clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(submitPropsForm, 500);
}
function bindPropsAutosave() {
const form = document.getElementById('propsForm');
autosaveIndicator = document.getElementById('autosaveStatus');
if (!form) return;
form.querySelectorAll('input, textarea, select').forEach((field) => {
if (field.classList.contains('uploadInput')) return; // géré séparément (upload de fichier)
const debounced = field.type === 'text' || field.type === 'range' || field.tagName === 'TEXTAREA';
field.addEventListener(debounced ? 'input' : 'change', () => {
// 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();
});
});
}