Files
Forge-Engine/static/js/screen_edit/tree-panels.js
T
williamandClaude Sonnet 5 ccf836f2c5
Build and deploy / test-python (push) Successful in 11m18s
Build and deploy / test-js (push) Successful in 1m16s
Build and deploy / lint-python (push) Successful in 4m24s
Build and deploy / lint-js (push) Successful in 2m51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Successful in 5m30s
Lot 4 modernisation JS (SonarLint) + restauration CI sonarqube non-bloquante
Corrige les findings SonarQube (via SonarLint IDE, fichier par fichier)
sur ~24 fichiers static/js/ : parseFloat/parseInt -> Number.*,
.replace(/x/g,y) -> .replaceAll, .indexOf() -> .includes()/.startsWith(),
getAttribute/setAttribute -> .dataset, tableaux -> Set, x && x.y -> x?.y
(verifie site par site), extraction de template litteraux imbriques,
ternaires imbriquees, refactors de complexite cognitive (S3776) via
tables de dispatch, Object.hasOwn, .at(), et deduplication de fonctions
identiques (S4144). Deux exceptions S2486 documentees/corrigees
(filter-repeater-rows.js) et un cas S2703 de partage inter-scripts
complete (_collisionWizard, trigger-editor.js <-> collision-rules-editor.js).
Details complets dans CODE_QUALITY.md section 5.

Restaure aussi le job CI "sonarqube" (non-bloquant) dans
.gitea/workflows/deploy.yml maintenant que l'instance prod est
operationnelle.

Suites vertes : 276/276 JS (node --test), 591/591 Python (pytest).

SKIP=djlint sur ce commit : hook djlint bloquant sur le backlog H021
(styles inline, 49 occurrences/6 templates) deja documente comme dette
assumee non traitee dans CODE_QUALITY.md section 6, aucun rapport avec
ce commit (aucun template touche ici) - valide explicitement avec
l'utilisateur avant de contourner ce hook.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-18 16:38:29 +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/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/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/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();
});
});
}