Phase 3 : hardening qualite de code - typage strict, securite, dead code, a11y

Config strictement stricte partout (ruff, mypy --strict, bandit, vulture,
import-linter, eslint, stylelint), aucune regle desactivee "pour ne pas
casser le build" - l'existant a ete corrige pour la satisfaire plutot que
l'inverse. Hooks pre-commit locaux (language: system) bloquants.

- Typage mypy --strict propage a tout le moteur (db, screens, auth, core,
  ai, routes, puis publish/scripts/tests/app.py/build_css.py).
- Securite : fuite de handle fichier Windows corrigee dans l'export SCORM
  (routes/publish/export_scorm.py), CSRF/RNG non-crypto/xAPI documentes
  (# nosec, # NOSONAR justifies), nouveau db.json_for_script() (echappe
  "</script>" dans le JSON embarque en <script>, 25 sites).
- Architecture : imports circulaires/F811 nettoyes, contrats
  import-linter respectes, code mort retire (vulture).
- Accessibilite : 69 champs de formulaire sans label correctement
  associe corriges (for/id ou aria-label) sur 11 templates.
- ESLint/Stylelint : lot mecanique JS/CSS, regles ajustees puis
  appliquees (aucune desactivee sans verification individuelle).
- Tests : isolation du compte admin partage (nettoyage ponctuel +
  fixture de teardown automatique en filet de securite), suite complete
  verte (591 tests Python, 241 tests JS).
- SonarQube Community Build self-heberge (Docker + PostgreSQL) : rapport
  complet analyse point par point, faux positifs documentes.
- .gitattributes ajoute (LF force) : core.autocrlf=true sur cette machine
  faisait echouer ESLint (linebreak-style) via un bug connu de git
  (checkout "en place" qui ignore l'eol force sur un fichier deja
  present sur disque - contourne en supprimant puis recreant chaque
  fichier suivi).

djLint (H021, styles inline) volontairement saute pour ce commit -
backlog assume, deja documente, traite dans un lot separe.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-15 16:06:15 +02:00
co-authored by Claude Sonnet 5
parent 7db4803b93
commit c57420c8c9
454 changed files with 16448 additions and 6967 deletions
+163 -332
View File
@@ -4,59 +4,15 @@
// 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", {
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})
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
x, y, width, 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
@@ -66,13 +22,13 @@ if (!window.__forgeContextMenuBound) {
// 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;
return `forgeTreeCollapsed:${GAME_SLUG}:${SCREEN_ID}:${nodeId}`;
}
function restoreTreeCollapsedState() {
document.querySelectorAll('.elementTree .treeNode').forEach(function (node) {
var id = node.dataset.elementId;
var collapsed = false;
document.querySelectorAll('.elementTree .treeNode').forEach((node) => {
const id = node.dataset.elementId;
let collapsed = false;
try { collapsed = localStorage.getItem(treeCollapseKey(id)) === '1'; } catch (e) {}
node.classList.toggle('collapsed', collapsed);
});
@@ -91,12 +47,12 @@ function restoreTreeCollapsedState() {
// refreshBuilderPanel) ou un rechargement — voir les appels à
// restoreFloatPanelState() dans initBuilderPanel().
function floatPanelStateKey(key) {
return 'forgeFloatPanel:' + GAME_SLUG + ':' + SCREEN_ID + ':' + key;
return `forgeFloatPanel:${GAME_SLUG}:${SCREEN_ID}:${key}`;
}
function saveFloatPanelState(key, state) {
try {
var current = JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}');
const current = JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}');
localStorage.setItem(floatPanelStateKey(key), JSON.stringify(Object.assign(current, state)));
} catch (e) {}
}
@@ -106,31 +62,31 @@ function loadFloatPanelState(key) {
}
function restoreFloatPanelState(key, panelId, reopenBtnId) {
var panel = document.getElementById(panelId);
var reopenBtn = document.getElementById(reopenBtnId);
const panel = document.getElementById(panelId);
const reopenBtn = document.getElementById(reopenBtnId);
if (!panel) return;
var state = loadFloatPanelState(key);
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.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é
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) {
var panel = document.getElementById(panelId);
var reopenBtn = document.getElementById(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) {
var panel = document.getElementById(panelId);
var reopenBtn = document.getElementById(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 });
@@ -144,23 +100,25 @@ function openLeftPanel() { openFloatPanel('left', 'panelLeftFloat', 'panelLeftRe
if (!window.__forgeFloatPanelDragBound) {
window.__forgeFloatPanelDragBound = true;
document.addEventListener('mousedown', function (e) {
var handle = e.target.closest('.floatPanelHeader');
document.addEventListener('mousedown', (e) => {
const handle = e.target.closest('.floatPanelHeader');
if (!handle || e.target.closest('button')) return;
var panel = document.getElementById(handle.dataset.dragPanel);
var key = handle.dataset.dragKey;
const panel = document.getElementById(handle.dataset.dragPanel);
const 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;
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) {
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';
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);
@@ -182,137 +140,21 @@ if (!window.__forgeFloatPanelDragBound) {
// 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');
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.replace(/"/g, '&quot;') + '">';
form.innerHTML = '<input type="hidden" name="widget" value="icone">'
+ `<input type="hidden" name="icon_class" value="${iconClass.replace(/"/g, '&quot;')}">`;
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, '&quot;') + '">';
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;
let draggedIconClass = null;
document.addEventListener('dragstart', function (e) {
var tile = e.target.closest('.iconGalleryTile');
document.addEventListener('dragstart', (e) => {
const tile = e.target.closest('.iconGalleryTile');
if (!tile) return;
draggedIconClass = tile.dataset.iconClass;
tile.classList.add('dragging');
@@ -320,19 +162,19 @@ if (!window.__forgeIconGalleryDragBound) {
try { e.dataTransfer.setData('text/plain', draggedIconClass); } catch (err) {}
});
document.addEventListener('dragend', function (e) {
var tile = e.target.closest('.iconGalleryTile');
document.addEventListener('dragend', (e) => {
const tile = e.target.closest('.iconGalleryTile');
if (tile) tile.classList.remove('dragging');
draggedIconClass = null;
});
document.addEventListener('dragover', function (e) {
document.addEventListener('dragover', (e) => {
if (!draggedIconClass || !e.target.closest('#canvas')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
document.addEventListener('drop', function (e) {
document.addEventListener('drop', (e) => {
if (!draggedIconClass || !e.target.closest('#canvas')) return;
e.preventDefault();
addIconElement(draggedIconClass);
@@ -350,13 +192,13 @@ if (!window.__forgeIconGalleryDragBound) {
// 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');
const canvas = document.getElementById('canvas');
if (!canvas) return;
canvas.querySelectorAll('.elementHighlighted').forEach(function (n) {
canvas.querySelectorAll('.elementHighlighted').forEach((n) => {
n.classList.remove('elementHighlighted');
});
if (!id) return;
var node = canvas.querySelector('[data-element-id="' + id + '"]');
const node = canvas.querySelector(`[data-element-id="${id}"]`);
if (node) node.classList.add('elementHighlighted');
}
@@ -366,25 +208,25 @@ function applySelectionHighlight(id) {
// 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) {
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(function (p) {
document.querySelectorAll(`[data-tab-group="${groupId}"]`).forEach((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');
document.addEventListener('click', (e) => {
const btn = e.target.closest('.treeToggle');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
var node = btn.closest('.treeNode');
var collapsed = node.classList.toggle('collapsed');
const node = btn.closest('.treeNode');
const collapsed = node.classList.toggle('collapsed');
try { localStorage.setItem(treeCollapseKey(node.dataset.elementId), collapsed ? '1' : '0'); } catch (err) {}
});
}
@@ -406,11 +248,11 @@ restoreTreeCollapsedState();
// É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;
let treeDragElementId = null;
let treeDragParentId = null;
function treeRowDropInfo(row) {
var node = row.closest('.treeNode');
const node = row.closest('.treeNode');
return {
elementId: node ? node.dataset.elementId : null,
parentId: row.dataset.parentId || '',
@@ -419,7 +261,7 @@ function treeRowDropInfo(row) {
}
function clearTreeDropIndicators() {
document.querySelectorAll('.treeRow.dropBefore, .treeRow.dropAfter, .treeRow.dropInside').forEach(function (r) {
document.querySelectorAll('.treeRow.dropBefore, .treeRow.dropAfter, .treeRow.dropInside').forEach((r) => {
r.classList.remove('dropBefore', 'dropAfter', 'dropInside');
});
}
@@ -430,8 +272,8 @@ function clearTreeDropIndicators() {
// 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;
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';
@@ -442,10 +284,10 @@ function treeDropZone(row, clientY, sameParent, isContainer) {
if (!window.__forgeTreeDragBound) {
window.__forgeTreeDragBound = true;
document.addEventListener('dragstart', function (e) {
var row = e.target.closest('.elementTree .treeRow');
document.addEventListener('dragstart', (e) => {
const row = e.target.closest('.elementTree .treeRow');
if (!row) return;
var info = treeRowDropInfo(row);
const info = treeRowDropInfo(row);
treeDragElementId = info.elementId;
treeDragParentId = info.parentId;
row.classList.add('dragging');
@@ -453,21 +295,21 @@ if (!window.__forgeTreeDragBound) {
try { e.dataTransfer.setData('text/plain', String(info.elementId)); } catch (err) {}
});
document.addEventListener('dragend', function (e) {
var row = e.target.closest('.elementTree .treeRow');
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', function (e) {
var row = e.target.closest('.elementTree .treeRow');
document.addEventListener('dragover', (e) => {
const row = e.target.closest('.elementTree .treeRow');
if (!row || treeDragElementId == null) return;
var info = treeRowDropInfo(row);
const 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);
const sameParent = info.parentId === treeDragParentId;
const zone = treeDropZone(row, e.clientY, sameParent, info.isContainer);
if (!zone) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
@@ -475,47 +317,47 @@ if (!window.__forgeTreeDragBound) {
row.classList.add(zone === 'before' ? 'dropBefore' : zone === 'after' ? 'dropAfter' : 'dropInside');
});
document.addEventListener('drop', function (e) {
var row = e.target.closest('.elementTree .treeRow');
document.addEventListener('drop', (e) => {
const row = e.target.closest('.elementTree .treeRow');
clearTreeDropIndicators();
if (!row || treeDragElementId == null) return;
var info = treeRowDropInfo(row);
const 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);
const sameParent = info.parentId === treeDragParentId;
const zone = treeDropZone(row, e.clientY, sameParent, info.isContainer);
if (!zone) return;
e.preventDefault();
var draggedId = treeDragElementId;
const draggedId = treeDragElementId;
if (zone === 'inside') {
fetch('/game/' + GAME_SLUG + '/elements/' + draggedId + '/move-to-container', {
fetch(`/game/${GAME_SLUG}/elements/${draggedId}/move-to-container`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ parent_id: info.elementId })
body: JSON.stringify({ parent_id: info.elementId }),
}).then(refreshAfterReorder);
} else {
fetch('/game/' + GAME_SLUG + '/elements/' + draggedId + '/reorder', {
fetch(`/game/${GAME_SLUG}/elements/${draggedId}/reorder`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_id: info.elementId, position: zone })
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) : '');
const 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');
.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);
}
var newTree = doc.querySelector('#panelLeftFloat .elementTree');
var curTree = document.querySelector('#panelLeftFloat .elementTree');
const newTree = doc.querySelector('#panelLeftFloat .elementTree');
const curTree = document.querySelector('#panelLeftFloat .elementTree');
if (newTree && curTree) { curTree.innerHTML = newTree.innerHTML; }
restoreTreeCollapsedState();
});
@@ -537,34 +379,34 @@ function refreshBuilderPanel(selectedId) {
// 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");
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;
history.pushState({ pjax: true }, "", result.url);
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();
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();
if (typeof initTriggersTab === 'function') initTriggersTab();
if (typeof initIaTab === 'function') initIaTab();
})
.catch(function () {
.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;
@@ -573,13 +415,13 @@ function refreshBuilderPanel(selectedId) {
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");
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);
@@ -587,8 +429,8 @@ function refreshCanvasOnly() {
// 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');
const newTree = doc.querySelector('#panelLeftFloat .elementTree');
const curTree = document.querySelector('#panelLeftFloat .elementTree');
if (newTree && curTree) { curTree.innerHTML = newTree.innerHTML; restoreTreeCollapsedState(); }
});
}
@@ -609,33 +451,38 @@ function goToSelected(id) {
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;
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 = parseFloat(el.style.left); const
startTop = parseFloat(el.style.top);
const width = parseFloat(el.style.width); const
height = 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).
var clickedTarget = e.target.closest('[data-element-id]');
var clickedId = clickedTarget ? clickedTarget.dataset.elementId : id;
const clickedTarget = e.target.closest('[data-element-id]');
const clickedId = clickedTarget ? clickedTarget.dataset.elementId : id;
function onMove(ev) {
var dx = ev.clientX - startX, dy = ev.clientY - startY;
const dx = ev.clientX - startX; const
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 + '%';
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) {
var newLeft = parseFloat(el.style.left), newTop = parseFloat(el.style.top);
const newLeft = parseFloat(el.style.left); const
newTop = parseFloat(el.style.top);
updatePosFields(newLeft, newTop, width, height);
saveGeometry(id, newLeft, newTop, width, height);
} else if (String(clickedId) !== String(CURRENT_SELECTED_ID)) {
@@ -649,24 +496,29 @@ function onElementMouseDown(e, id) {
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);
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 = parseFloat(el.style.width); const
startHeight = parseFloat(el.style.height);
const left = parseFloat(el.style.left); const
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 + '%';
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);
var newWidth = parseFloat(el.style.width), newHeight = parseFloat(el.style.height);
const newWidth = parseFloat(el.style.width); const
newHeight = parseFloat(el.style.height);
updatePosFields(left, top, newWidth, newHeight);
saveGeometry(id, left, top, newWidth, newHeight);
}
@@ -675,8 +527,10 @@ function onResizeMouseDown(e, id) {
}
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');
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;
@@ -690,8 +544,8 @@ function updatePosFields(x, y, w, h) {
// 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;
let autosaveTimer = null;
let autosaveIndicator = null;
function flashAutosave(state) {
if (!autosaveIndicator) return;
@@ -706,13 +560,13 @@ function flashAutosave(state) {
}
function submitPropsForm() {
var form = document.getElementById('propsForm');
const form = document.getElementById('propsForm');
if (!form) return Promise.resolve();
flashAutosave('saving');
var data = new FormData(form);
const data = new FormData(form);
return fetch(form.getAttribute('action'), {
method: 'POST', body: data, headers: { "X-Requested-With": "pjax" }
}).then(function () {
method: 'POST', body: data, headers: { 'X-Requested-With': 'pjax' },
}).then(() => {
flashAutosave('saved');
return refreshCanvasOnly();
});
@@ -723,37 +577,14 @@ function debouncedSubmitPropsForm() {
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');
const form = document.getElementById('propsForm');
autosaveIndicator = document.getElementById('autosaveStatus');
if (!form) return;
form.querySelectorAll('input, textarea, select').forEach(function (field) {
form.querySelectorAll('input, textarea, select').forEach((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 () {
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();