Files
Forge-Engine/static/js/screen_edit/panel-init.js
T
williamandClaude Sonnet 5 fe807ba51e Phase -1 (suite) : découpe l'éditeur de screen_edit.html en modules JS
Même chantier que le commit précédent (moteur de jeu, play.html) —
templates/screen_edit.html était un unique fichier HTML+CSS+JS de 3312
lignes, tout l'éditeur (arborescence, panneaux flottants, canevas,
formulaire de propriétés, éditeur de flow à nœuds, blocs de logique,
timeline d'animation) vivant dans UN SEUL <script>.

Ce fichier est plus imbriqué que play.html : de nombreux appels
s'exécutent au niveau racine du script (pas seulement des déclarations
de fonctions), et JavaScript hoiste les déclarations `function` sur
TOUT le script — un appel au niveau racine peut donc référencer une
fonction déclarée PLUS LOIN dans le même fichier. Découper naïvement
casserait cet ordre implicite. Un audit dédié (analyse ligne par ligne
de chaque appel racine + son graphe d'appel transitif) a identifié 3
références "en avance" réelles, toutes regroupées dans la même zone
(initBuilderPanel()/toggleActionFields() → bindAspectButtons/
toggleElementPropertyValue/onDataDefinitionChange) — le découpage
respecte cette contrainte : chaque fichier est une TRANCHE SÉQUENTIELLE
de l'original (jamais une réorganisation), et cette zone spécifique
reste un seul fichier (panel-init.js) pour que le hoisting continue de
fonctionner exactement comme avant.

5 fichiers sous static/js/screen_edit/ :
- tree-panels.js — arborescence, menu contextuel, panneaux flottants
  gauche/droite, galerie d'icônes, modale de suppression/choix d'icône,
  glisser-déposer du canevas, panneau de propriétés (autosave).
- panel-init.js — (ré)initialisation du panneau central après chaque
  changement de sélection, filtres de répéteur/donnée liée, condition
  de visibilité, champs d'action du formulaire de nœud.
- flow-editor.js — éditeur de flow à nœuds (rendu du graphe, formulaire
  d'ajout de nœud, blocs de logique — currentBlockNodes/Edges).
- tabs-and-blocks.js — onglets du centre, panneaux flottants génériques
  (drag/resize/plein écran), modale d'un bloc de logique.
- animation-timeline.js — timeline d'animation (clips Animate.css/
  personnalisés).

Toutes les données injectées par Jinja (GAME_SLUG, SCREEN_ID,
DEFINITIONS_DATA, FLOW_NODES_INITIAL, ELEMENTS_LABELS, CUSTOM_EVENTS_MAP,
ANIM_CLIPS...) sont posées UNE FOIS par un petit <script> inline resté
dans le template, avant les <script src> — même patron que
static/js/play/. Le seul bout de logique resté inline est la toute
petite IIFE d'ouverture initiale (?tab=/?block=), qui dépend directement
de request.args et doit s'exécuter après que tous les fichiers soient
chargés.

tests/conftest.py : screen_edit_js_bundle() (même principe que
play_js_bundle(), Phase -1 précédente) — 4 tests qui vérifiaient la
présence de telle fonction/chaîne dans le HTML de l'éditeur (le JS y
était inline) sont mis à jour pour chercher dans ce bundle. Un des deux
échecs révélait un test déjà fragile (assert "Ligne cliquée" in html
vérifiait en réalité le TEXTE SOURCE d'un <script> inline, jamais du
HTML réellement rendu — ce texte ne peut plus s'y trouver une fois la
fonction qui le construit dynamiquement déplacée dans un fichier
externe) : corrigé pour vérifier le bundle JS + la disponibilité de la
route séparément.

Vérifié : 215 tests passent, syntaxe JS validée sur les 5 nouveaux
fichiers (node --check) et sur les <script> inline restants (rendus via
le client de test). Test manuel recommandé (édition complète d'une
scène : arborescence, propriétés, glisser-déposer, logique de flow,
blocs, timeline) avant de considérer ce découpage définitivement sans
risque — comme pour play.html, ce fichier n'a pas de harnais de test
DOM automatisé.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 14:38:57 +02:00

437 lines
22 KiB
JavaScript

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');
// 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;
if (canvasEl) {
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); });
})();
bindAspectButtons();
document.querySelectorAll('.uploadInput').forEach(function (input) {
input.addEventListener('change', function () {
if (!input.files.length) return;
var status = input.parentElement.querySelector('.uploadStatus');
var targetField = document.getElementById(input.dataset.target);
status.textContent = 'Envoi en cours...';
var 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é ✓ 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."; }
})
.catch(function () { status.textContent = "Échec de l'envoi."; });
});
});
bindPropsAutosave();
bindJaugeDefinitionSelect();
bindDefinitionFieldSelects('field-definition_id', ['field-filtre_champ', 'field-filtre2_champ']);
bindDefinitionFieldSelects('field-visibilite_cond_definition_id', ['field-visibilite_cond_champ']);
bindClauseListDefinitionSelect('field-data_definition_id');
bindClauseListRows();
initFilterValuePickers();
var visCondModeSel = document.getElementById('field-visibilite_cond_mode');
if (visCondModeSel) toggleVisCondFields(visCondModeSel);
restoreTreeCollapsedState();
applySelectionHighlight(CURRENT_SELECTED_ID);
restoreFloatPanelState('props', 'propsPanelFloat', 'propsPanelReopenBtn');
restoreFloatPanelState('left', 'panelLeftFloat', 'panelLeftReopenBtn');
}
// Jauge : quand on change l'objet de données à suivre SANS quitter le
// panneau (donc sans le rechargement serveur qui repeuplerait row_id/champ/
// champ_nom via controls_with_values.py), on reconstruit ces trois menus à
// la volée à partir de DEFINITIONS_DATA (même source que les formulaires de
// la Logique de la scène, voir onFlowDefinitionChange). DEFINITIONS_DATA
// est déclaré plus bas dans ce script (donc encore "undefined" au moment où
// initBuilderPanel() tourne en tout premier) — sans conséquence ici,
// puisqu'on ne le lit que dans le gestionnaire, jamais au moment du bind.
// Libellé d'un champ dans une liste déroulante — précise l'objet visé pour
// 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;
}
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');
if (!defSel || !rowSel || !champSel || !champNomSel) return; // pas la Jauge
defSel.addEventListener('change', function () {
var 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('')
: '<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('');
champSel.innerHTML = fieldOptions || '<option value="">Aucun champ simple</option>';
champNomSel.innerHTML = '<option value="">— aucun —</option>' + fieldOptions;
});
}
// ---------- Réglages "champ" d'un filtre (Répéteur/Donnée liée) : listes
// déroulantes peuplées avec les VRAIS champs de l'objet choisi, jamais
// tapées à la main — même principe que bindJaugeDefinitionSelect() ci-dessus,
// généralisé pour s'appliquer à un nombre quelconque de listes "champ"
// rattachées à un même sélecteur d'objet (ici : filtre_champ + filtre2_champ,
// ou data_filtre_champ + data_filtre2_champ). ----------
// ---------- Condition de visibilité (voir VISIBILITY_CONDITION_CONTROLS) :
// n'affiche que les réglages pertinents pour le mode choisi (variable
// globale, ou champ d'un objet) — les deux autres restent en base (peu
// 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;
row.style.display = show ? '' : 'none';
});
}
function bindDefinitionFieldSelects(defSelId, fieldSelIds) {
var defSel = document.getElementById(defSelId);
if (!defSel) return;
var fieldSels = fieldSelIds.map(function (id) { return 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; });
});
}
// ---------- "Donnée liée" : liste de conditions ILLIMITÉE (champ/opérateur/
// valeur), combinées entre elles par ET/OU (voir c_clause_list.py,
// save_element_controls.py, resolve_bound_row.py). Chaque ligne existante
// est rendue côté serveur (au chargement du panneau) ; "+ Ajouter une
// condition" clone un <template> pour en ajouter de nouvelles à la volée,
// 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 () {
if (debounced) debouncedSubmitPropsForm(); else submitPropsForm();
});
});
var removeBtn = row.querySelector('.clauseRemoveBtn');
if (removeBtn) {
removeBtn.addEventListener('click', function () {
row.remove();
updateClauseCombinatorVisibility(key);
submitPropsForm();
});
}
}
function updateClauseCombinatorVisibility(key) {
var wrap = document.getElementById('clauseListWrap-' + key);
var 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);
if (!tmpl || !wrap) return;
var clone = tmpl.content.cloneNode(true);
var 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');
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.
initFilterValuePickers();
updateClauseCombinatorVisibility(key);
submitPropsForm();
}
// Rebranche les lignes déjà rendues par le serveur (rechargement du
// 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); });
});
}
// Repeuple le sélecteur "champ" de TOUTES les lignes de condition actuelles
// (interrogées à chaque changement, donc couvre aussi les lignes ajoutées
// après coup) quand on change l'objet lié — même principe que
// 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);
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;
sel.innerHTML = opts;
sel.value = current; // reste sélectionné si ce champ existe encore dans le nouvel objet
});
});
}
// ---------- Valeur de comparaison d'un filtre : fixe, ou donnée d'un autre
// objet ----------
//
// La valeur envoyée au serveur reste une simple chaîne "texte" (voir
// _resolve_filter_value côté Python) — soit une valeur fixe, soit une
// référence à un champ d'un autre objet dans un format interne précis. Le
// mode "dynamique" ci-dessous se contente de reconstruire cette chaîne à
// partir de deux listes déroulantes (objet, champ) plutôt que de la faire
// 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*\\}\\}$');
// 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*\\}\\}$');
function _filterValueDefinitionId(objName) {
var 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('');
}
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) : '';
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) : '';
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');
fixe.style.display = sel.value === 'fixe' ? 'block' : 'none';
dyn.style.display = sel.value === 'dynamique' ? 'block' : 'none';
varWrap.style.display = sel.value === 'variable' ? 'block' : 'none';
if (sel.value === 'dynamique') updateFilterValueFromDynamic(wrap);
else if (sel.value === 'variable') updateFilterValueFromVariable(wrap);
}
function onFilterValueObjChange(sel) {
var wrap = sel.closest('.filterValueControl');
populateFilterValueFields(wrap.querySelector('.filterValueFieldSel'), _filterValueDefinitionId(sel.value), '');
updateFilterValueFromDynamic(wrap);
}
function onFilterValueFieldChange(sel) {
updateFilterValueFromDynamic(sel.closest('.filterValueControl'));
}
function onFilterValueVarChange(sel) {
updateFilterValueFromVariable(sel.closest('.filterValueControl'));
}
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);
if (varMatch) {
modeSel.value = 'variable';
wrap.querySelector('.filterValueFixe').style.display = 'none';
wrap.querySelector('.filterValueVariable').style.display = 'block';
varSel.value = varMatch[1].trim();
if (varPathInput) varPathInput.value = (varMatch[2] || '').trim();
} else if (objMatch) {
modeSel.value = 'dynamique';
wrap.querySelector('.filterValueFixe').style.display = 'none';
wrap.querySelector('.filterValueDynamique').style.display = 'block';
objSel.value = objMatch[1].trim();
populateFilterValueFields(fieldSel, _filterValueDefinitionId(objSel.value), objMatch[2].trim());
} else {
modeSel.value = 'fixe';
}
});
}
// Le retour/avance du navigateur (bouton "précédent") est déjà pris en
// charge par pjax.js (navigation globale sans rechargement, voir
// static/pjax.js) : il refait un rendu complet de <main> et rejoue ce
// script (voir runScriptsIn dans pjax.js), ce qui rappelle initBuilderPanel()
// ci-dessous — donc le canevas et le panneau de propriétés se retrouvent à
// 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');
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');
toggleElementPropertyValue(propSel);
}
if (sel.value === 'modifier_donnee') {
var 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'); });
if (sel.value === 'visibilite') {
visSel.style.display = 'block'; visSel.setAttribute('name', 'element_value');
} else if (sel.value === 'bg_color' || sel.value === 'border_color') {
colorInp.style.display = 'block'; colorInp.setAttribute('name', 'element_value');
} else {
numInp.style.display = 'block'; numInp.setAttribute('name', 'element_value');
}
}
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');
if (!rowSel || !fieldSel) return;
rowSel.innerHTML = info.rows.length
? info.rows.map(function(r){ return '<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('')
: '<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('.alignOption').classList.add('active', 'is-primary');
}
// Choix du format d'aperçu (Portrait/Paysage/Carré) : mémorisé côté serveur
// (voir screen_set_aspect.py) pour ne pas repartir sur Portrait à chaque
// ajout/suppression d'élément ou changement de sélection — ces actions
// redemandent ce panneau au serveur, qui doit donc connaître le choix déjà
// fait. Appelé depuis initBuilderPanel() (voir plus haut), une seule fois
// par (re)rendu de ce panneau.
function bindAspectButtons() {
document.querySelectorAll('.aspectBtn').forEach(function (btn) {
btn.addEventListener('click', function () {
document.querySelectorAll('.aspectBtn').forEach(function (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' },
body: JSON.stringify({ aspect: btn.dataset.aspect }),
});
});
});
}
document.querySelectorAll('.uploadInput').forEach(function(input){
input.addEventListener('change', function(){
if (!input.files.length) return;
var status = input.parentElement.querySelector('.uploadStatus');
var targetField = document.getElementById(input.dataset.target);
status.textContent = 'Envoi en cours...';
var 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é ✓ Enregistrement...';
// Enregistre tout de suite (comme un clic sur "Enregistrer") pour
// 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');
if (form) { form.requestSubmit ? form.requestSubmit() : form.submit(); }
}
else { status.textContent = "Échec de l'envoi."; }
})
.catch(function(){ status.textContent = "Échec de l'envoi."; });
});
});