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:
co-authored by
Claude Sonnet 5
parent
7db4803b93
commit
c57420c8c9
@@ -8,19 +8,19 @@
|
||||
// collision" — ce dernier n'a donc plus besoin de charger
|
||||
// tabs-and-blocks.js/flow-editor.js/animation-timeline.js du tout). ----------
|
||||
function switchBuilderTab(tab) {
|
||||
document.querySelectorAll('.builderTabBtn').forEach(function (btn) {
|
||||
document.querySelectorAll('.builderTabBtn').forEach((btn) => {
|
||||
btn.classList.toggle('active', btn.dataset.tab === tab);
|
||||
});
|
||||
document.querySelectorAll('.builderTabPanel').forEach(function (panel) {
|
||||
document.querySelectorAll('.builderTabPanel').forEach((panel) => {
|
||||
panel.classList.toggle('active', panel.dataset.tabPanel === tab);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleDashCreate(tab) {
|
||||
var bar = document.getElementById('dashCreate-' + tab);
|
||||
const bar = document.getElementById(`dashCreate-${tab}`);
|
||||
bar.style.display = bar.style.display === 'none' ? 'flex' : 'none';
|
||||
if (bar.style.display === 'flex') {
|
||||
var firstInput = bar.querySelector('input, select');
|
||||
const firstInput = bar.querySelector('input, select');
|
||||
if (firstInput) firstInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
+161
-165
@@ -1,49 +1,52 @@
|
||||
function initBuilderPanel() {
|
||||
(function () {
|
||||
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');
|
||||
// pos-w/pos-h n'existent pas pour un élément posé dans un conteneur (sa
|
||||
// taille se règle via les champs "Largeur/Hauteur (%)"/"fixe (px)" du
|
||||
// formulaire général à la place, voir size_ctrls plus haut) — X/Y non
|
||||
// plus, mais ils restent dans le DOM (juste désactivés) pour cet élément.
|
||||
if (!xEl || !wEl) return;
|
||||
function apply() {
|
||||
var canvasEl = document.getElementById('el-' + CURRENT_SELECTED_ID);
|
||||
var x = parseFloat(xEl.value) || 0, y = parseFloat(yEl.value) || 0;
|
||||
var w = parseFloat(wEl.value) || 1, h = parseFloat(hEl.value) || 1;
|
||||
const canvasEl = document.getElementById(`el-${CURRENT_SELECTED_ID}`);
|
||||
const x = parseFloat(xEl.value) || 0; const
|
||||
y = parseFloat(yEl.value) || 0;
|
||||
const w = parseFloat(wEl.value) || 1; const
|
||||
h = parseFloat(hEl.value) || 1;
|
||||
if (canvasEl) {
|
||||
canvasEl.style.left = x + '%'; canvasEl.style.top = y + '%';
|
||||
canvasEl.style.width = w + '%'; canvasEl.style.height = h + '%';
|
||||
canvasEl.style.left = `${x}%`; canvasEl.style.top = `${y}%`;
|
||||
canvasEl.style.width = `${w}%`; canvasEl.style.height = `${h}%`;
|
||||
}
|
||||
saveGeometry(CURRENT_SELECTED_ID, x, y, w, h);
|
||||
}
|
||||
[xEl, yEl, wEl, hEl].forEach(function (el) { el.addEventListener('change', apply); });
|
||||
})();
|
||||
[xEl, yEl, wEl, hEl].forEach((el) => { el.addEventListener('change', apply); });
|
||||
}());
|
||||
|
||||
bindAspectButtons();
|
||||
bindScreenBackgroundMusicField();
|
||||
|
||||
document.querySelectorAll('.uploadInput').forEach(function (input) {
|
||||
input.addEventListener('change', function () {
|
||||
document.querySelectorAll('.uploadInput').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (!input.files.length) return;
|
||||
var status = input.parentElement.querySelector('.uploadStatus');
|
||||
var targetField = document.getElementById(input.dataset.target);
|
||||
const status = input.parentElement.querySelector('.uploadStatus');
|
||||
const targetField = document.getElementById(input.dataset.target);
|
||||
status.textContent = 'Envoi en cours...';
|
||||
var data = new FormData();
|
||||
const data = new FormData();
|
||||
data.append('file', input.files[0]);
|
||||
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
fetch(`/game/${GAME_SLUG}/upload`, { method: 'POST', body: data })
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
if (json.url) {
|
||||
targetField.value = json.url;
|
||||
status.textContent = 'Fichier envoyé — Enregistrement...';
|
||||
// Enregistre tout de suite, comme n'importe quel autre réglage —
|
||||
// un fichier envoyé ne doit jamais rester "en attente".
|
||||
submitPropsForm().then(function () { status.textContent = 'Fichier envoyé — Enregistré.'; });
|
||||
}
|
||||
else { status.textContent = "Échec de l'envoi."; }
|
||||
submitPropsForm().then(() => { status.textContent = 'Fichier envoyé — Enregistré.'; });
|
||||
} else { status.textContent = "Échec de l'envoi."; }
|
||||
})
|
||||
.catch(function () { status.textContent = "Échec de l'envoi."; });
|
||||
.catch(() => { status.textContent = "Échec de l'envoi."; });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,21 +55,20 @@ function initBuilderPanel() {
|
||||
// élément), il n'y a pas de formulaire à autosauvegarder ici — le champ
|
||||
// texte est simplement rempli, la valeur part avec le reste du nœud au
|
||||
// clic sur "Créer" (voir submitNodeForm()).
|
||||
document.querySelectorAll('.nodeFormUploadInput').forEach(function (input) {
|
||||
input.addEventListener('change', function () {
|
||||
document.querySelectorAll('.nodeFormUploadInput').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (!input.files.length) return;
|
||||
var status = input.parentElement.querySelector('.uploadStatus');
|
||||
var targetField = document.getElementById(input.dataset.target);
|
||||
const status = input.parentElement.querySelector('.uploadStatus');
|
||||
const targetField = document.getElementById(input.dataset.target);
|
||||
status.textContent = 'Envoi en cours...';
|
||||
var data = new FormData();
|
||||
const data = new FormData();
|
||||
data.append('file', input.files[0]);
|
||||
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
if (json.url) { targetField.value = json.url; status.textContent = 'Fichier envoyé.'; }
|
||||
else { status.textContent = "Échec de l'envoi."; }
|
||||
fetch(`/game/${GAME_SLUG}/upload`, { method: 'POST', body: data })
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
if (json.url) { targetField.value = json.url; status.textContent = 'Fichier envoyé.'; } else { status.textContent = "Échec de l'envoi."; }
|
||||
})
|
||||
.catch(function () { status.textContent = "Échec de l'envoi."; });
|
||||
.catch(() => { status.textContent = "Échec de l'envoi."; });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -77,7 +79,7 @@ function initBuilderPanel() {
|
||||
bindClauseListDefinitionSelect('field-data_definition_id');
|
||||
bindClauseListRows();
|
||||
initFilterValuePickers();
|
||||
var visCondModeSel = document.getElementById('field-visibilite_cond_mode');
|
||||
const visCondModeSel = document.getElementById('field-visibilite_cond_mode');
|
||||
if (visCondModeSel) toggleVisCondFields(visCondModeSel);
|
||||
restoreTreeCollapsedState();
|
||||
applySelectionHighlight(CURRENT_SELECTED_ID);
|
||||
@@ -97,27 +99,27 @@ function initBuilderPanel() {
|
||||
// un champ "relation" (ex. "parcour (→ parcours)"), même règle que
|
||||
// _field_label() côté Python (controls_with_values.py).
|
||||
function fieldOptionLabel(f) {
|
||||
return (f.type === 'relation' && f.relation_definition_name) ? (f.name + ' (→ ' + f.relation_definition_name + ')') : f.name;
|
||||
return (f.type === 'relation' && f.relation_definition_name) ? (`${f.name} (→ ${f.relation_definition_name})`) : f.name;
|
||||
}
|
||||
|
||||
function bindJaugeDefinitionSelect() {
|
||||
var defSel = document.getElementById('field-definition_id');
|
||||
var rowSel = document.getElementById('field-row_id');
|
||||
var champSel = document.getElementById('field-champ');
|
||||
var champNomSel = document.getElementById('field-champ_nom');
|
||||
const defSel = document.getElementById('field-definition_id');
|
||||
const rowSel = document.getElementById('field-row_id');
|
||||
const champSel = document.getElementById('field-champ');
|
||||
const champNomSel = document.getElementById('field-champ_nom');
|
||||
if (!defSel || !rowSel || !champSel || !champNomSel) return; // pas la Jauge
|
||||
defSel.addEventListener('change', function () {
|
||||
var info = (DEFINITIONS_DATA || {})[defSel.value] || { rows: [], fields: [] };
|
||||
defSel.addEventListener('change', () => {
|
||||
const info = (DEFINITIONS_DATA || {})[defSel.value] || { rows: [], fields: [] };
|
||||
rowSel.innerHTML = info.rows.length
|
||||
? info.rows.map(function (r) { return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
|
||||
? info.rows.map((r) => `<option value="${r.id}">${r.label}</option>`).join('')
|
||||
: '<option value="">Aucune donnée dans cet objet</option>';
|
||||
// La Jauge affiche une valeur/un nom tel quel — un champ "relation" (un
|
||||
// id, pas une valeur) n'a pas sa place ici, même exclusion que côté
|
||||
// serveur (voir controls_with_values.py).
|
||||
var simpleFields = info.fields.filter(function (f) { return f.type !== 'relation'; });
|
||||
var fieldOptions = simpleFields.map(function (f) { return '<option value="' + f.name + '">' + f.name + '</option>'; }).join('');
|
||||
const simpleFields = info.fields.filter((f) => f.type !== 'relation');
|
||||
const fieldOptions = simpleFields.map((f) => `<option value="${f.name}">${f.name}</option>`).join('');
|
||||
champSel.innerHTML = fieldOptions || '<option value="">Aucun champ simple</option>';
|
||||
champNomSel.innerHTML = '<option value="">— aucun —</option>' + fieldOptions;
|
||||
champNomSel.innerHTML = `<option value="">— aucun —</option>${fieldOptions}`;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,24 +135,24 @@ function bindJaugeDefinitionSelect() {
|
||||
// importe, seul le mode courant est lu par is_element_visible()) mais sont
|
||||
// masqués pour ne pas embrouiller. ----------
|
||||
function toggleVisCondFields(sel) {
|
||||
var mode = sel.value;
|
||||
document.querySelectorAll('.visCondRow').forEach(function (row) {
|
||||
var scope = row.dataset.visCondScope;
|
||||
var show = scope === 'any' ? !!mode : scope === mode;
|
||||
const mode = sel.value;
|
||||
document.querySelectorAll('.visCondRow').forEach((row) => {
|
||||
const scope = row.dataset.visCondScope;
|
||||
const show = scope === 'any' ? !!mode : scope === mode;
|
||||
row.style.display = show ? '' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
|
||||
var defSel = document.getElementById(defSelId);
|
||||
const defSel = document.getElementById(defSelId);
|
||||
if (!defSel) return;
|
||||
var fieldSels = fieldSelIds.map(function (id) { return document.getElementById(id); }).filter(Boolean);
|
||||
const fieldSels = fieldSelIds.map((id) => document.getElementById(id)).filter(Boolean);
|
||||
if (!fieldSels.length) return;
|
||||
defSel.addEventListener('change', function () {
|
||||
var info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
||||
var opts = '<option value="">(sélectionner un objet d\'abord)</option>' +
|
||||
info.fields.map(function (f) { return '<option value="' + f.name + '">' + fieldOptionLabel(f) + '</option>'; }).join('');
|
||||
fieldSels.forEach(function (sel) { sel.innerHTML = opts; });
|
||||
defSel.addEventListener('change', () => {
|
||||
const info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
||||
const opts = `<option value="">(sélectionner un objet d'abord)</option>${
|
||||
info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('')}`;
|
||||
fieldSels.forEach((sel) => { sel.innerHTML = opts; });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -162,15 +164,15 @@ function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
|
||||
// sans aller-retour serveur — même principe que "fieldsList"/"varValueRows"
|
||||
// ailleurs dans l'outil. ----------
|
||||
function wireClauseRow(row, key) {
|
||||
row.querySelectorAll('input, select').forEach(function (field) {
|
||||
var debounced = field.tagName === 'INPUT';
|
||||
field.addEventListener(debounced ? 'input' : 'change', function () {
|
||||
row.querySelectorAll('input, select').forEach((field) => {
|
||||
const debounced = field.tagName === 'INPUT';
|
||||
field.addEventListener(debounced ? 'input' : 'change', () => {
|
||||
if (debounced) debouncedSubmitPropsForm(); else submitPropsForm();
|
||||
});
|
||||
});
|
||||
var removeBtn = row.querySelector('.clauseRemoveBtn');
|
||||
const removeBtn = row.querySelector('.clauseRemoveBtn');
|
||||
if (removeBtn) {
|
||||
removeBtn.addEventListener('click', function () {
|
||||
removeBtn.addEventListener('click', () => {
|
||||
row.remove();
|
||||
updateClauseCombinatorVisibility(key);
|
||||
submitPropsForm();
|
||||
@@ -179,25 +181,25 @@ function wireClauseRow(row, key) {
|
||||
}
|
||||
|
||||
function updateClauseCombinatorVisibility(key) {
|
||||
var wrap = document.getElementById('clauseListWrap-' + key);
|
||||
var combRow = document.getElementById('clauseCombinatorRow-' + key);
|
||||
const wrap = document.getElementById(`clauseListWrap-${key}`);
|
||||
const combRow = document.getElementById(`clauseCombinatorRow-${key}`);
|
||||
if (!wrap || !combRow) return;
|
||||
combRow.style.display = wrap.querySelectorAll('.clauseRow').length > 1 ? '' : 'none';
|
||||
}
|
||||
|
||||
function addClauseRow(key) {
|
||||
var tmpl = document.getElementById('clauseRowTemplate-' + key);
|
||||
var wrap = document.getElementById('clauseListWrap-' + key);
|
||||
const tmpl = document.getElementById(`clauseRowTemplate-${key}`);
|
||||
const wrap = document.getElementById(`clauseListWrap-${key}`);
|
||||
if (!tmpl || !wrap) return;
|
||||
var clone = tmpl.content.cloneNode(true);
|
||||
var row = clone.querySelector('.clauseRow');
|
||||
const clone = tmpl.content.cloneNode(true);
|
||||
const row = clone.querySelector('.clauseRow');
|
||||
wireClauseRow(row, key);
|
||||
wrap.appendChild(clone);
|
||||
// Repeuple tout de suite son sélecteur "Champ" avec les VRAIS champs de
|
||||
// l'objet déjà choisi (sans ça, une ligne ajoutée après coup resterait
|
||||
// bloquée sur "(sélectionner un objet d'abord)" même si un objet est
|
||||
// déjà réglé plus haut dans le panneau).
|
||||
var defSel = document.getElementById('field-data_definition_id');
|
||||
const defSel = document.getElementById('field-data_definition_id');
|
||||
if (defSel && defSel.value) defSel.dispatchEvent(new Event('change'));
|
||||
// Initialise le sélecteur de valeur (fixe/dynamique/variable) de cette
|
||||
// nouvelle ligne — sans effet sur les lignes déjà initialisées.
|
||||
@@ -210,9 +212,9 @@ function addClauseRow(key) {
|
||||
// panneau, ex. sélection d'un autre élément) — les lignes ajoutées EN
|
||||
// COURS de session par addClauseRow() se branchent déjà elles-mêmes.
|
||||
function bindClauseListRows() {
|
||||
document.querySelectorAll('.clauseListControl').forEach(function (control) {
|
||||
var key = control.dataset.clauseKey;
|
||||
control.querySelectorAll('.clauseRow').forEach(function (row) { wireClauseRow(row, key); });
|
||||
document.querySelectorAll('.clauseListControl').forEach((control) => {
|
||||
const key = control.dataset.clauseKey;
|
||||
control.querySelectorAll('.clauseRow').forEach((row) => { wireClauseRow(row, key); });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -222,14 +224,14 @@ function bindClauseListRows() {
|
||||
// bindDefinitionFieldSelects ci-dessus, généralisé pour un nombre de lignes
|
||||
// variable plutôt qu'une liste fixe d'ids capturée une fois pour toutes.
|
||||
function bindClauseListDefinitionSelect(defSelId) {
|
||||
var defSel = document.getElementById(defSelId);
|
||||
const defSel = document.getElementById(defSelId);
|
||||
if (!defSel) return;
|
||||
defSel.addEventListener('change', function () {
|
||||
var info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
||||
var opts = '<option value="">(sélectionner un objet d\'abord)</option>' +
|
||||
info.fields.map(function (f) { return '<option value="' + f.name + '">' + fieldOptionLabel(f) + '</option>'; }).join('');
|
||||
document.querySelectorAll('.clauseChampSelect').forEach(function (sel) {
|
||||
var current = sel.value;
|
||||
defSel.addEventListener('change', () => {
|
||||
const info = (DEFINITIONS_DATA || {})[defSel.value] || { fields: [] };
|
||||
const opts = `<option value="">(sélectionner un objet d'abord)</option>${
|
||||
info.fields.map((f) => `<option value="${f.name}">${fieldOptionLabel(f)}</option>`).join('')}`;
|
||||
document.querySelectorAll('.clauseChampSelect').forEach((sel) => {
|
||||
const current = sel.value;
|
||||
sel.innerHTML = opts;
|
||||
sel.value = current; // reste sélectionné si ce champ existe encore dans le nouvel objet
|
||||
});
|
||||
@@ -247,51 +249,49 @@ function bindClauseListDefinitionSelect(defSelId) {
|
||||
// taper à la main. OPEN/CLOSE évitent d'écrire les deux accolades l'une
|
||||
// contre l'autre dans le source (Jinja les interpréterait comme le DÉBUT
|
||||
// d'une expression s'il les rencontrait telles quelles).
|
||||
var _FILTER_REF_OPEN = '{' + '{';
|
||||
var _FILTER_REF_CLOSE = '}' + '}';
|
||||
var _FILTER_REF_RE = new RegExp('^\\{\\{\\s*([^.{}]+)\\.([^.{}]+)\\s*\\}\\}$');
|
||||
const _FILTER_REF_OPEN = '{' + '{';
|
||||
const _FILTER_REF_CLOSE = '}' + '}';
|
||||
const _FILTER_REF_RE = /^\{\{\s*([^.{}]+)\.([^.{}]+)\s*\}\}$/;
|
||||
// Une paire d'accolades doublées entourant "$nom_variable" (voir
|
||||
// _VAR_REF_PATTERN côté Python, filter_repeater_rows.py) référence une
|
||||
// variable globale — le "$" la distingue sans ambiguïté de _FILTER_REF_RE
|
||||
// ci-dessus, qui attend toujours un point ("Objet.champ"). Groupe 2 : un
|
||||
// chemin optionnel ".champ"/"[index]" (chaînable) à l'intérieur de la
|
||||
// variable, utile pour une variable "objet"/"tableau".
|
||||
var _VAR_REF_RE = new RegExp('^\\{\\{\\s*\\$([^.{}\\[\\]]+)((?:\\.[^.{}\\[\\]]+|\\[\\d+\\])*)\\s*\\}\\}$');
|
||||
const _VAR_REF_RE = /^\{\{\s*\$([^.{}[\]]+)((?:\.[^.{}[\]]+|\[\d+\])*)\s*\}\}$/;
|
||||
|
||||
function _filterValueDefinitionId(objName) {
|
||||
var opt = document.querySelector('.filterValueObjSel option[value="' + CSS.escape(objName) + '"]');
|
||||
const opt = document.querySelector(`.filterValueObjSel option[value="${CSS.escape(objName)}"]`);
|
||||
return opt ? opt.dataset.definitionId : null;
|
||||
}
|
||||
|
||||
function populateFilterValueFields(fieldSel, defId, selectedName) {
|
||||
var info = (DEFINITIONS_DATA || {})[defId] || { fields: [] };
|
||||
fieldSel.innerHTML = '<option value="">— champ —</option>' + info.fields.map(function (f) {
|
||||
return '<option value="' + f.name + '"' + (f.name === selectedName ? ' selected' : '') + '>' + fieldOptionLabel(f) + '</option>';
|
||||
}).join('');
|
||||
const info = (DEFINITIONS_DATA || {})[defId] || { fields: [] };
|
||||
fieldSel.innerHTML = `<option value="">— champ —</option>${info.fields.map((f) => `<option value="${f.name}"${f.name === selectedName ? ' selected' : ''}>${fieldOptionLabel(f)}</option>`).join('')}`;
|
||||
}
|
||||
|
||||
function updateFilterValueFromDynamic(wrap) {
|
||||
var objSel = wrap.querySelector('.filterValueObjSel');
|
||||
var fieldSel = wrap.querySelector('.filterValueFieldSel');
|
||||
var input = wrap.querySelector('.filterValueFixe input');
|
||||
input.value = (objSel.value && fieldSel.value) ? (_FILTER_REF_OPEN + objSel.value + '.' + fieldSel.value + _FILTER_REF_CLOSE) : '';
|
||||
const objSel = wrap.querySelector('.filterValueObjSel');
|
||||
const fieldSel = wrap.querySelector('.filterValueFieldSel');
|
||||
const input = wrap.querySelector('.filterValueFixe input');
|
||||
input.value = (objSel.value && fieldSel.value) ? (`${_FILTER_REF_OPEN + objSel.value}.${fieldSel.value}${_FILTER_REF_CLOSE}`) : '';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function updateFilterValueFromVariable(wrap) {
|
||||
var varSel = wrap.querySelector('.filterValueVarSel');
|
||||
var pathInput = wrap.querySelector('.filterValueVarPath');
|
||||
var input = wrap.querySelector('.filterValueFixe input');
|
||||
var path = pathInput ? pathInput.value.trim() : '';
|
||||
input.value = varSel.value ? (_FILTER_REF_OPEN + '$' + varSel.value + path + _FILTER_REF_CLOSE) : '';
|
||||
const varSel = wrap.querySelector('.filterValueVarSel');
|
||||
const pathInput = wrap.querySelector('.filterValueVarPath');
|
||||
const input = wrap.querySelector('.filterValueFixe input');
|
||||
const path = pathInput ? pathInput.value.trim() : '';
|
||||
input.value = varSel.value ? (`${_FILTER_REF_OPEN}$${varSel.value}${path}${_FILTER_REF_CLOSE}`) : '';
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function onFilterValueModeChange(sel) {
|
||||
var wrap = sel.closest('.filterValueControl');
|
||||
var fixe = wrap.querySelector('.filterValueFixe');
|
||||
var dyn = wrap.querySelector('.filterValueDynamique');
|
||||
var varWrap = wrap.querySelector('.filterValueVariable');
|
||||
const wrap = sel.closest('.filterValueControl');
|
||||
const fixe = wrap.querySelector('.filterValueFixe');
|
||||
const dyn = wrap.querySelector('.filterValueDynamique');
|
||||
const varWrap = wrap.querySelector('.filterValueVariable');
|
||||
fixe.style.display = sel.value === 'fixe' ? 'block' : 'none';
|
||||
dyn.style.display = sel.value === 'dynamique' ? 'block' : 'none';
|
||||
varWrap.style.display = sel.value === 'variable' ? 'block' : 'none';
|
||||
@@ -300,7 +300,7 @@ function onFilterValueModeChange(sel) {
|
||||
}
|
||||
|
||||
function onFilterValueObjChange(sel) {
|
||||
var wrap = sel.closest('.filterValueControl');
|
||||
const wrap = sel.closest('.filterValueControl');
|
||||
populateFilterValueFields(wrap.querySelector('.filterValueFieldSel'), _filterValueDefinitionId(sel.value), '');
|
||||
updateFilterValueFromDynamic(wrap);
|
||||
}
|
||||
@@ -314,15 +314,15 @@ function onFilterValueVarChange(sel) {
|
||||
}
|
||||
|
||||
function initFilterValuePickers() {
|
||||
document.querySelectorAll('.filterValueControl').forEach(function (wrap) {
|
||||
var modeSel = wrap.querySelector('.filterValueMode');
|
||||
var objSel = wrap.querySelector('.filterValueObjSel');
|
||||
var fieldSel = wrap.querySelector('.filterValueFieldSel');
|
||||
var varSel = wrap.querySelector('.filterValueVarSel');
|
||||
var varPathInput = wrap.querySelector('.filterValueVarPath');
|
||||
var current = (wrap.dataset.currentValue || '').trim();
|
||||
var varMatch = _VAR_REF_RE.exec(current);
|
||||
var objMatch = _FILTER_REF_RE.exec(current);
|
||||
document.querySelectorAll('.filterValueControl').forEach((wrap) => {
|
||||
const modeSel = wrap.querySelector('.filterValueMode');
|
||||
const objSel = wrap.querySelector('.filterValueObjSel');
|
||||
const fieldSel = wrap.querySelector('.filterValueFieldSel');
|
||||
const varSel = wrap.querySelector('.filterValueVarSel');
|
||||
const varPathInput = wrap.querySelector('.filterValueVarPath');
|
||||
const current = (wrap.dataset.currentValue || '').trim();
|
||||
const varMatch = _VAR_REF_RE.exec(current);
|
||||
const objMatch = _FILTER_REF_RE.exec(current);
|
||||
if (varMatch) {
|
||||
modeSel.value = 'variable';
|
||||
wrap.querySelector('.filterValueFixe').style.display = 'none';
|
||||
@@ -349,34 +349,31 @@ function initFilterValuePickers() {
|
||||
// jour automatiquement, sans qu'on ait besoin d'un second écouteur ici.
|
||||
initBuilderPanel();
|
||||
|
||||
var CONDITION_OPERATORS_HTML = document.getElementById('nf-cond-operator') ? document.getElementById('nf-cond-operator').innerHTML : '';
|
||||
var GLOBAL_VARIABLES_OPTIONS_HTML = document.getElementById('nf-cond-variable') ? document.getElementById('nf-cond-variable').innerHTML : '';
|
||||
|
||||
function toggleActionFields(sel) {
|
||||
var form = sel.closest('.actionAddForm');
|
||||
var screenSel = form.querySelector('.targetScreenSelect');
|
||||
var elementFields = form.querySelector('.targetElementFields');
|
||||
var dataFields = form.querySelector('.targetDataFields');
|
||||
const form = sel.closest('.actionAddForm');
|
||||
const screenSel = form.querySelector('.targetScreenSelect');
|
||||
const elementFields = form.querySelector('.targetElementFields');
|
||||
const dataFields = form.querySelector('.targetDataFields');
|
||||
screenSel.style.display = sel.value === 'aller_a' ? 'block' : 'none';
|
||||
elementFields.style.display = sel.value === 'modifier_element' ? 'block' : 'none';
|
||||
dataFields.style.display = sel.value === 'modifier_donnee' ? 'block' : 'none';
|
||||
if (sel.value === 'modifier_element') {
|
||||
var propSel = elementFields.querySelector('.elementPropertySelect');
|
||||
const propSel = elementFields.querySelector('.elementPropertySelect');
|
||||
toggleElementPropertyValue(propSel);
|
||||
}
|
||||
if (sel.value === 'modifier_donnee') {
|
||||
var defSel = dataFields.querySelector('.targetDefinitionSelect');
|
||||
const defSel = dataFields.querySelector('.targetDefinitionSelect');
|
||||
if (defSel) onDataDefinitionChange(defSel);
|
||||
}
|
||||
}
|
||||
document.querySelectorAll('.actionTypeSelect').forEach(toggleActionFields);
|
||||
|
||||
function toggleElementPropertyValue(sel) {
|
||||
var wrap = sel.closest('.targetElementFields');
|
||||
var visSel = wrap.querySelector('.elementValueVisibility');
|
||||
var colorInp = wrap.querySelector('.elementValueColor');
|
||||
var numInp = wrap.querySelector('.elementValueNumber');
|
||||
[visSel, colorInp, numInp].forEach(function(el){ el.style.display = 'none'; el.removeAttribute('name'); });
|
||||
const wrap = sel.closest('.targetElementFields');
|
||||
const visSel = wrap.querySelector('.elementValueVisibility');
|
||||
const colorInp = wrap.querySelector('.elementValueColor');
|
||||
const numInp = wrap.querySelector('.elementValueNumber');
|
||||
[visSel, colorInp, numInp].forEach((el) => { el.style.display = 'none'; el.removeAttribute('name'); });
|
||||
if (sel.value === 'visibilite') {
|
||||
visSel.style.display = 'block'; visSel.setAttribute('name', 'element_value');
|
||||
} else if (sel.value === 'bg_color' || sel.value === 'border_color') {
|
||||
@@ -388,22 +385,22 @@ function toggleElementPropertyValue(sel) {
|
||||
document.querySelectorAll('.elementPropertySelect').forEach(toggleElementPropertyValue);
|
||||
|
||||
function onDataDefinitionChange(sel) {
|
||||
var wrap = sel.closest('.targetDataFields');
|
||||
var info = DEFINITIONS_DATA[sel.value] || {rows: [], fields: []};
|
||||
var rowSel = wrap.querySelector('.targetRowSelect');
|
||||
var fieldSel = wrap.querySelector('.targetFieldSelect');
|
||||
const wrap = sel.closest('.targetDataFields');
|
||||
const info = DEFINITIONS_DATA[sel.value] || { rows: [], fields: [] };
|
||||
const rowSel = wrap.querySelector('.targetRowSelect');
|
||||
const fieldSel = wrap.querySelector('.targetFieldSelect');
|
||||
if (!rowSel || !fieldSel) return;
|
||||
rowSel.innerHTML = info.rows.length
|
||||
? info.rows.map(function(r){ return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
|
||||
? info.rows.map((r) => `<option value="${r.id}">${r.label}</option>`).join('')
|
||||
: '<option value="">Aucune donnée dans cet objet</option>';
|
||||
fieldSel.innerHTML = info.fields.length
|
||||
? info.fields.map(function(f){ return '<option value="' + f.name + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
|
||||
? info.fields.map((f) => `<option value="${f.name}">${f.name} (${f.type})</option>`).join('')
|
||||
: '<option value="">Aucun champ simple</option>';
|
||||
}
|
||||
document.querySelectorAll('.targetDefinitionSelect').forEach(onDataDefinitionChange);
|
||||
|
||||
function syncAlign(radio) {
|
||||
radio.closest('.alignGroup').querySelectorAll('.alignOption').forEach(function(o){ o.classList.remove('active', 'is-primary'); });
|
||||
radio.closest('.alignGroup').querySelectorAll('.alignOption').forEach((o) => { o.classList.remove('active', 'is-primary'); });
|
||||
radio.closest('.alignOption').classList.add('active', 'is-primary');
|
||||
}
|
||||
|
||||
@@ -418,69 +415,70 @@ function syncAlign(radio) {
|
||||
// format d'aperçu ci-dessous), plus un bouton d'envoi de fichier qui
|
||||
// remplit le champ URL puis déclenche le même enregistrement.
|
||||
function saveScreenBackgroundMusic(url) {
|
||||
return fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/background-music", {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url: url }),
|
||||
return fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/background-music`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
}
|
||||
|
||||
function bindScreenBackgroundMusicField() {
|
||||
var urlInput = document.getElementById('screen-bg-music-url');
|
||||
const urlInput = document.getElementById('screen-bg-music-url');
|
||||
if (urlInput) {
|
||||
urlInput.addEventListener('change', function () { saveScreenBackgroundMusic(urlInput.value); });
|
||||
urlInput.addEventListener('change', () => { saveScreenBackgroundMusic(urlInput.value); });
|
||||
}
|
||||
document.querySelectorAll('.screenUploadInput').forEach(function (input) {
|
||||
input.addEventListener('change', function () {
|
||||
document.querySelectorAll('.screenUploadInput').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (!input.files.length) return;
|
||||
var status = input.parentElement.querySelector('.uploadStatus');
|
||||
var targetField = document.getElementById(input.dataset.target);
|
||||
const status = input.parentElement.querySelector('.uploadStatus');
|
||||
const targetField = document.getElementById(input.dataset.target);
|
||||
status.textContent = 'Envoi en cours...';
|
||||
var data = new FormData();
|
||||
const data = new FormData();
|
||||
data.append('file', input.files[0]);
|
||||
fetch("/game/" + GAME_SLUG + "/upload", { method: 'POST', body: data })
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
fetch(`/game/${GAME_SLUG}/upload`, { method: 'POST', body: data })
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
if (json.url) {
|
||||
targetField.value = json.url;
|
||||
status.textContent = 'Fichier envoyé — Enregistrement...';
|
||||
saveScreenBackgroundMusic(json.url).then(function () { status.textContent = 'Fichier envoyé — Enregistré.'; });
|
||||
}
|
||||
else { status.textContent = "Échec de l'envoi."; }
|
||||
saveScreenBackgroundMusic(json.url).then(() => { status.textContent = 'Fichier envoyé — Enregistré.'; });
|
||||
} else { status.textContent = "Échec de l'envoi."; }
|
||||
})
|
||||
.catch(function () { status.textContent = "Échec de l'envoi."; });
|
||||
.catch(() => { status.textContent = "Échec de l'envoi."; });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function bindAspectButtons() {
|
||||
document.querySelectorAll('.aspectBtn').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
document.querySelectorAll('.aspectBtn').forEach(function (b) { b.classList.remove('active'); });
|
||||
document.querySelectorAll('.aspectBtn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.aspectBtn').forEach((b) => { b.classList.remove('active'); });
|
||||
btn.classList.add('active');
|
||||
// Dans l'éditeur (≥1300px), le canevas remplit tout .canvasFrame
|
||||
// (voir .canvas dans style.css) — cet aspect-ratio ne sert donc plus
|
||||
// qu'au repli en page empilée (<1300px, voir le media query) et à
|
||||
// l'aperçu jouable ("Jouer"), pas à la mise en page ici.
|
||||
document.getElementById('canvas').style.aspectRatio = btn.dataset.aspect;
|
||||
fetch("/game/" + GAME_SLUG + "/screens/" + SCREEN_ID + "/aspect", {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
fetch(`/game/${GAME_SLUG}/screens/${SCREEN_ID}/aspect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ aspect: btn.dataset.aspect }),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.uploadInput').forEach(function(input){
|
||||
input.addEventListener('change', function(){
|
||||
document.querySelectorAll('.uploadInput').forEach((input) => {
|
||||
input.addEventListener('change', () => {
|
||||
if (!input.files.length) return;
|
||||
var status = input.parentElement.querySelector('.uploadStatus');
|
||||
var targetField = document.getElementById(input.dataset.target);
|
||||
const status = input.parentElement.querySelector('.uploadStatus');
|
||||
const targetField = document.getElementById(input.dataset.target);
|
||||
status.textContent = 'Envoi en cours...';
|
||||
var data = new FormData();
|
||||
const data = new FormData();
|
||||
data.append('file', input.files[0]);
|
||||
fetch("/game/" + GAME_SLUG + "/upload", {method: 'POST', body: data})
|
||||
.then(function(r){ return r.json(); })
|
||||
.then(function(json){
|
||||
fetch(`/game/${GAME_SLUG}/upload`, { method: 'POST', body: data })
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
if (json.url) {
|
||||
targetField.value = json.url;
|
||||
status.textContent = 'Fichier envoyé — Enregistrement...';
|
||||
@@ -488,12 +486,10 @@ document.querySelectorAll('.uploadInput').forEach(function(input){
|
||||
// qu'un fichier envoyé ne reste jamais "en attente" si on oublie
|
||||
// de cliquer sur le bouton ensuite — l'image doit apparaître dès
|
||||
// l'envoi, sans étape supplémentaire à retenir.
|
||||
var form = document.getElementById('propsForm');
|
||||
const form = document.getElementById('propsForm');
|
||||
if (form) { form.requestSubmit ? form.requestSubmit() : form.submit(); }
|
||||
}
|
||||
else { status.textContent = "Échec de l'envoi."; }
|
||||
} else { status.textContent = "Échec de l'envoi."; }
|
||||
})
|
||||
.catch(function(){ status.textContent = "Échec de l'envoi."; });
|
||||
.catch(() => { status.textContent = "Échec de l'envoi."; });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,18 +8,18 @@
|
||||
// Forge (SPRITE_LIBRARY) sont proposés.
|
||||
|
||||
function _personnageElementId() {
|
||||
var body = document.getElementById('personnagePanelBody');
|
||||
const body = document.getElementById('personnagePanelBody');
|
||||
return body ? parseInt(body.dataset.elementId, 10) : null;
|
||||
}
|
||||
|
||||
function _savePersonnageData(data) {
|
||||
var elementId = _personnageElementId();
|
||||
const elementId = _personnageElementId();
|
||||
if (!elementId) return Promise.resolve();
|
||||
return fetch('/game/' + GAME_SLUG + '/elements/' + elementId + '/personnage', {
|
||||
return fetch(`/game/${GAME_SLUG}/elements/${elementId}/personnage`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data),
|
||||
})
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (json) {
|
||||
.then((r) => r.json())
|
||||
.then((json) => {
|
||||
if (json.animations) {
|
||||
// Reste en phase avec le sélecteur d'animation du nœud de flow/clip
|
||||
// de Timeline (voir flow-editor.js/animation-timeline.js), sans
|
||||
@@ -32,8 +32,8 @@ function _savePersonnageData(data) {
|
||||
}
|
||||
|
||||
function swapPersonnageCharacter(forgeCharacter) {
|
||||
document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach(function (tile) {
|
||||
tile.classList.toggle('selected', tile.getAttribute('onclick') === "swapPersonnageCharacter('" + forgeCharacter + "')");
|
||||
document.querySelectorAll('#personnageGallerySwap .iconGalleryTile').forEach((tile) => {
|
||||
tile.classList.toggle('selected', tile.getAttribute('onclick') === `swapPersonnageCharacter('${forgeCharacter}')`);
|
||||
});
|
||||
_savePersonnageData({ source: 'forge', forge_character: forgeCharacter });
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// dans l'éditeur — mirror allégé de son moteur, scopé à l'idle de chaque
|
||||
// personnage du canevas (un seul intervalle actif par élément, comme le
|
||||
// moteur de jeu).
|
||||
var _personnageIdleIntervals = {};
|
||||
const _personnageIdleIntervals = {};
|
||||
|
||||
function _stopPersonnageIdlePreview(elementId) {
|
||||
if (_personnageIdleIntervals[elementId]) {
|
||||
@@ -19,8 +19,8 @@ function _startPersonnageIdlePreview(targetEl, elementId, frames, fps) {
|
||||
if (!frames || !frames.length) return;
|
||||
targetEl.src = frames[0];
|
||||
if (frames.length === 1) return;
|
||||
var frameIndex = 0;
|
||||
_personnageIdleIntervals[elementId] = setInterval(function () {
|
||||
let frameIndex = 0;
|
||||
_personnageIdleIntervals[elementId] = setInterval(() => {
|
||||
// Navigation pjax (voir pjax.js) : le canevas précédent est remplacé
|
||||
// sans que ce script ne soit prévenu — s'auto-arrête dès que son
|
||||
// élément a quitté le DOM plutôt que de tourner indéfiniment dans le
|
||||
@@ -34,17 +34,17 @@ function _startPersonnageIdlePreview(targetEl, elementId, frames, fps) {
|
||||
// Appelée après tout changement de personnage/animations (voir
|
||||
// personnage-panel.js) pour relancer l'aperçu sans recharger la page.
|
||||
function restartPersonnageIdlePreview(elementId, animations) {
|
||||
var targetEl = document.querySelector('#canvas [data-element-id="' + elementId + '"][data-personnage="1"]');
|
||||
const targetEl = document.querySelector(`#canvas [data-element-id="${elementId}"][data-personnage="1"]`);
|
||||
if (!targetEl) return;
|
||||
var frames = (animations && (animations.idle || Object.values(animations)[0])) || [];
|
||||
const frames = (animations && (animations.idle || Object.values(animations)[0])) || [];
|
||||
_startPersonnageIdlePreview(targetEl, elementId, frames, 8);
|
||||
}
|
||||
|
||||
function startAllPersonnagePreviews() {
|
||||
document.querySelectorAll('#canvas [data-personnage="1"]').forEach(function (targetEl) {
|
||||
var elementId = parseInt(targetEl.dataset.elementId, 10);
|
||||
var animations = ELEMENT_ANIMATIONS_MAP[elementId];
|
||||
var frames = (animations && (animations.idle || Object.values(animations)[0])) || null;
|
||||
document.querySelectorAll('#canvas [data-personnage="1"]').forEach((targetEl) => {
|
||||
const elementId = parseInt(targetEl.dataset.elementId, 10);
|
||||
const animations = ELEMENT_ANIMATIONS_MAP[elementId];
|
||||
const frames = (animations && (animations.idle || Object.values(animations)[0])) || null;
|
||||
// Pas d'entrée dans ELEMENT_ANIMATIONS_MAP (personnage vidé/sprite
|
||||
// personnalisé sans frame) : garde l'image déjà posée par le rendu
|
||||
// serveur (ou rien), pas d'intervalle à démarrer.
|
||||
|
||||
@@ -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, '"') + '">';
|
||||
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;
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user