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>
317 lines
13 KiB
JavaScript
317 lines
13 KiB
JavaScript
// ---------- Timeline d'animation : piste par élément, clips Animate.css ou
|
|
// personnalisés (images-clés), glissés/redimensionnés à la souris sur un
|
|
// axe du temps en secondes depuis l'affichage de l'écran. Même principe de
|
|
// panneau rétractable/redimensionnable que "Logique de la scène"
|
|
// ci-dessus, positionné juste en dessous. ----------
|
|
|
|
// ANIM_CLIPS/ANIMATE_CSS_LABELS/GAME_SLUG : voir le petit <script> inline
|
|
// en tête de screen_edit.html (données Jinja).
|
|
var ANIM_SCREEN_ID = SCREEN_ID;
|
|
var ANIM_PX_PER_SEC = 60;
|
|
var animCurrentClipId = null;
|
|
|
|
function animUrl(path) { return "/game/" + GAME_SLUG + path; }
|
|
|
|
function animClipLabel(clip) {
|
|
if (clip.kind === 'custom') return '🎨 Personnalisée';
|
|
return '🎞️ ' + (ANIMATE_CSS_LABELS[clip.animate_name] || clip.animate_name);
|
|
}
|
|
|
|
function renderAnimTimeline() {
|
|
var lanesWrap = document.getElementById('animTracks');
|
|
var ticksWrap = document.getElementById('animRuler');
|
|
if (!lanesWrap || !ticksWrap) return;
|
|
lanesWrap.innerHTML = '';
|
|
ticksWrap.innerHTML = '';
|
|
|
|
var maxTime = ANIM_CLIPS.reduce(function(m, c) { return Math.max(m, (c.start_time || 0) + (c.duration || 1)); }, 5);
|
|
var totalSeconds = Math.ceil(maxTime) + 2;
|
|
var totalWidth = totalSeconds * ANIM_PX_PER_SEC;
|
|
|
|
var spacer = document.createElement('div');
|
|
spacer.className = 'animRulerSpacer';
|
|
ticksWrap.appendChild(spacer);
|
|
var ticksLane = document.createElement('div');
|
|
ticksLane.className = 'animRulerTicksWrap';
|
|
ticksLane.style.width = totalWidth + 'px';
|
|
for (var s = 0; s <= totalSeconds; s++) {
|
|
var tick = document.createElement('div');
|
|
tick.className = 'animRulerTick';
|
|
tick.style.left = (s * ANIM_PX_PER_SEC) + 'px';
|
|
tick.textContent = s + 's';
|
|
ticksLane.appendChild(tick);
|
|
}
|
|
ticksWrap.appendChild(ticksLane);
|
|
|
|
var elementIds = [];
|
|
ANIM_CLIPS.forEach(function(c) { if (elementIds.indexOf(c.element_id) === -1) elementIds.push(c.element_id); });
|
|
|
|
if (!elementIds.length) {
|
|
var empty = document.createElement('p');
|
|
empty.className = 'hint';
|
|
empty.style.padding = '10px 0';
|
|
empty.textContent = "Aucun clip pour l'instant — choisis un élément ci-dessus et clique « Ajouter un clip ».";
|
|
lanesWrap.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
elementIds.forEach(function(elId) {
|
|
var track = document.createElement('div');
|
|
track.className = 'animTrack';
|
|
|
|
var label = document.createElement('div');
|
|
label.className = 'animTrackLabel';
|
|
label.textContent = ELEMENTS_LABELS[elId] || ('elt-' + elId);
|
|
track.appendChild(label);
|
|
|
|
var lane = document.createElement('div');
|
|
lane.className = 'animTrackLane';
|
|
lane.style.width = totalWidth + 'px';
|
|
|
|
ANIM_CLIPS.filter(function(c) { return c.element_id === elId; }).forEach(function(clip) {
|
|
var block = document.createElement('div');
|
|
block.className = 'animClip';
|
|
block.style.left = ((clip.start_time || 0) * ANIM_PX_PER_SEC) + 'px';
|
|
block.style.width = Math.max(24, (clip.duration || 1) * ANIM_PX_PER_SEC) + 'px';
|
|
block.textContent = animClipLabel(clip);
|
|
block.dataset.clipId = clip.id;
|
|
block.addEventListener('mousedown', function(e) { onAnimClipMouseDown(e, clip.id); });
|
|
var handle = document.createElement('div');
|
|
handle.className = 'animClipResizeHandle';
|
|
handle.addEventListener('mousedown', function(e) { onAnimClipResizeMouseDown(e, clip.id); });
|
|
block.appendChild(handle);
|
|
lane.appendChild(block);
|
|
});
|
|
|
|
track.appendChild(lane);
|
|
lanesWrap.appendChild(track);
|
|
});
|
|
}
|
|
|
|
function onAnimClipMouseDown(e, clipId) {
|
|
if (e.target.classList.contains('animClipResizeHandle')) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
var clip = ANIM_CLIPS.find(function(c) { return c.id === clipId; });
|
|
var block = document.querySelector('.animClip[data-clip-id="' + clipId + '"]');
|
|
var startX = e.clientX;
|
|
var startLeft = parseFloat(block.style.left);
|
|
var moved = false;
|
|
function onMove(ev) {
|
|
var dx = ev.clientX - startX;
|
|
if (Math.abs(dx) > 3) moved = true;
|
|
block.style.left = Math.max(0, startLeft + dx) + 'px';
|
|
}
|
|
function onUp() {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
if (moved) {
|
|
var newStart = Math.round((parseFloat(block.style.left) / ANIM_PX_PER_SEC) * 10) / 10;
|
|
clip.start_time = newStart;
|
|
updateAnimClip(clipId, { start_time: newStart });
|
|
} else {
|
|
openAnimClipForm(clip);
|
|
}
|
|
}
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
}
|
|
|
|
function onAnimClipResizeMouseDown(e, clipId) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
var clip = ANIM_CLIPS.find(function(c) { return c.id === clipId; });
|
|
var block = document.querySelector('.animClip[data-clip-id="' + clipId + '"]');
|
|
var startX = e.clientX;
|
|
var startWidth = parseFloat(block.style.width);
|
|
function onMove(ev) {
|
|
var dx = ev.clientX - startX;
|
|
block.style.width = Math.max(10, startWidth + dx) + 'px';
|
|
}
|
|
function onUp() {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
var newDuration = Math.round((parseFloat(block.style.width) / ANIM_PX_PER_SEC) * 10) / 10;
|
|
clip.duration = newDuration;
|
|
updateAnimClip(clipId, { duration: newDuration });
|
|
}
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
}
|
|
|
|
function updateAnimClip(clipId, fields) {
|
|
return fetch(animUrl('/animations/clips/' + clipId + '/update'), {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(fields)
|
|
}).then(function(r) { return r.json(); }).then(function(updated) {
|
|
var idx = ANIM_CLIPS.findIndex(function(c) { return c.id === updated.id; });
|
|
if (idx > -1) ANIM_CLIPS[idx] = updated;
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
function addAnimTrack() {
|
|
var elId = parseInt(document.getElementById('anim-new-track-element').value, 10);
|
|
fetch(animUrl('/screens/' + ANIM_SCREEN_ID + '/animations/clips/add'), {
|
|
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ element_id: elId, kind: 'animate_css', animate_name: 'fadeIn', start_time: 0, duration: 1 }),
|
|
}).then(function(r) { return r.json(); }).then(function(clip) {
|
|
ANIM_CLIPS.push(clip);
|
|
renderAnimTimeline();
|
|
openAnimClipForm(clip);
|
|
});
|
|
}
|
|
|
|
function toggleAnimClipKind() {
|
|
var kind = document.getElementById('anim-clip-kind').value;
|
|
document.getElementById('anim-clip-animate-css-fields').style.display = kind === 'animate_css' ? 'block' : 'none';
|
|
document.getElementById('anim-clip-custom-fields').style.display = kind === 'custom' ? 'block' : 'none';
|
|
}
|
|
|
|
function addAnimKeyframeRow(percent, cssText) {
|
|
var wrap = document.getElementById('anim-clip-keyframes');
|
|
var row = document.createElement('div');
|
|
row.className = 'fieldRow animKeyframeRow';
|
|
var percentInput = document.createElement('input');
|
|
percentInput.type = 'number'; percentInput.min = '0'; percentInput.max = '100'; percentInput.step = '1';
|
|
percentInput.className = 'anim-kf-percent'; percentInput.style.flex = '0 0 70px';
|
|
percentInput.value = percent != null ? percent : 0;
|
|
var cssInput = document.createElement('textarea');
|
|
cssInput.className = 'anim-kf-css'; cssInput.style.flex = '1'; cssInput.rows = 2;
|
|
cssInput.placeholder = 'ex. opacity: 0; transform: scale(0.5);';
|
|
cssInput.value = cssText || '';
|
|
var removeBtn = document.createElement('button');
|
|
removeBtn.type = 'button'; removeBtn.className = 'smallBtn danger';
|
|
removeBtn.textContent = '✕';
|
|
removeBtn.onclick = function() { row.remove(); };
|
|
row.appendChild(percentInput);
|
|
row.appendChild(cssInput);
|
|
row.appendChild(removeBtn);
|
|
wrap.appendChild(row);
|
|
}
|
|
|
|
function collectAnimKeyframes() {
|
|
var rows = document.querySelectorAll('#anim-clip-keyframes .animKeyframeRow');
|
|
var keyframes = [];
|
|
rows.forEach(function(row) {
|
|
var percent = parseFloat(row.querySelector('.anim-kf-percent').value) || 0;
|
|
var cssText = row.querySelector('.anim-kf-css').value;
|
|
var props = {};
|
|
cssText.split(';').forEach(function(decl) {
|
|
var parts = decl.split(':');
|
|
if (parts.length === 2) {
|
|
var prop = parts[0].trim(), val = parts[1].trim();
|
|
if (prop && val) props[prop] = val;
|
|
}
|
|
});
|
|
keyframes.push({ percent: percent, props: props });
|
|
});
|
|
return keyframes;
|
|
}
|
|
|
|
function openAnimClipForm(clip) {
|
|
animCurrentClipId = clip.id;
|
|
document.getElementById('animClipForm').style.display = 'block';
|
|
document.getElementById('animClipFormTitle').textContent = 'Clip — ' + (ELEMENTS_LABELS[clip.element_id] || ('elt-' + clip.element_id));
|
|
document.getElementById('anim-clip-kind').value = clip.kind || 'animate_css';
|
|
document.getElementById('anim-clip-animate-name').value = clip.animate_name || 'fadeIn';
|
|
document.getElementById('anim-clip-start').value = clip.start_time || 0;
|
|
document.getElementById('anim-clip-duration').value = clip.duration || 1;
|
|
document.getElementById('anim-clip-delay').value = clip.delay || 0;
|
|
document.getElementById('anim-clip-easing').value = clip.easing || 'ease';
|
|
document.getElementById('anim-clip-iteration').value = clip.iteration_count || '1';
|
|
document.getElementById('anim-clip-keyframes').innerHTML = '';
|
|
var keyframes = [];
|
|
if (clip.custom_keyframes) {
|
|
try { keyframes = JSON.parse(clip.custom_keyframes); } catch (e) { /* ignore */ }
|
|
}
|
|
if (!keyframes.length) keyframes = [{ percent: 0, props: { opacity: '0' } }, { percent: 100, props: { opacity: '1' } }];
|
|
keyframes.forEach(function(k) {
|
|
var cssText = Object.keys(k.props || {}).map(function(p) { return p + ': ' + k.props[p] + ';'; }).join(' ');
|
|
addAnimKeyframeRow(k.percent, cssText);
|
|
});
|
|
toggleAnimClipKind();
|
|
document.querySelectorAll('.animClip').forEach(function(b) {
|
|
b.classList.toggle('selected', parseInt(b.dataset.clipId, 10) === clip.id);
|
|
});
|
|
}
|
|
|
|
function closeAnimClipForm() {
|
|
animCurrentClipId = null;
|
|
document.getElementById('animClipForm').style.display = 'none';
|
|
document.querySelectorAll('.animClip').forEach(function(b) { b.classList.remove('selected'); });
|
|
}
|
|
|
|
function saveAnimClipForm() {
|
|
if (!animCurrentClipId) return Promise.resolve();
|
|
var kind = document.getElementById('anim-clip-kind').value;
|
|
var fields = {
|
|
kind: kind,
|
|
animate_name: document.getElementById('anim-clip-animate-name').value,
|
|
start_time: parseFloat(document.getElementById('anim-clip-start').value) || 0,
|
|
duration: parseFloat(document.getElementById('anim-clip-duration').value) || 1,
|
|
delay: parseFloat(document.getElementById('anim-clip-delay').value) || 0,
|
|
easing: document.getElementById('anim-clip-easing').value,
|
|
iteration_count: document.getElementById('anim-clip-iteration').value,
|
|
};
|
|
if (kind === 'custom') fields.custom_keyframes = collectAnimKeyframes();
|
|
return updateAnimClip(animCurrentClipId, fields).then(function() { renderAnimTimeline(); });
|
|
}
|
|
|
|
function deleteAnimClipForm() {
|
|
if (!animCurrentClipId) return;
|
|
if (!confirm('Supprimer ce clip ?')) return;
|
|
var id = animCurrentClipId;
|
|
fetch(animUrl('/animations/clips/' + id + '/delete'), { method: 'POST' }).then(function() {
|
|
ANIM_CLIPS = ANIM_CLIPS.filter(function(c) { return c.id !== id; });
|
|
closeAnimClipForm();
|
|
renderAnimTimeline();
|
|
});
|
|
}
|
|
|
|
function previewAnimClip() {
|
|
if (!animCurrentClipId) return;
|
|
saveAnimClipForm().then(function() {
|
|
var clip = ANIM_CLIPS.find(function(c) { return c.id === animCurrentClipId; });
|
|
if (!clip) return;
|
|
var target = document.querySelector('#canvas [data-anim-target="' + clip.element_id + '"]');
|
|
if (!target) return;
|
|
var duration = clip.duration || 1, delay = clip.delay || 0;
|
|
var iterationCount = clip.iteration_count || '1';
|
|
if (clip.kind === 'custom') {
|
|
var keyframes = [];
|
|
try { keyframes = JSON.parse(clip.custom_keyframes || '[]'); } catch (e) { /* ignore */ }
|
|
var steps = keyframes.map(function(k) {
|
|
var props = Object.keys(k.props || {}).map(function(p) { return p + ':' + k.props[p] + ';'; }).join(' ');
|
|
return k.percent + '% { ' + props + ' }';
|
|
}).join(' ');
|
|
var styleTag = document.getElementById('animEditorPreviewStyle');
|
|
if (!styleTag) {
|
|
styleTag = document.createElement('style');
|
|
styleTag.id = 'animEditorPreviewStyle';
|
|
document.head.appendChild(styleTag);
|
|
}
|
|
styleTag.textContent = '@keyframes animEditorPreview { ' + steps + ' }';
|
|
target.style.animation = 'animEditorPreview ' + duration + 's ' + (clip.easing || 'ease') + ' ' + delay + 's ' + iterationCount;
|
|
target.addEventListener('animationend', function handler() {
|
|
target.style.animation = '';
|
|
target.removeEventListener('animationend', handler);
|
|
});
|
|
} else {
|
|
var animClass = 'animate__' + clip.animate_name;
|
|
target.style.setProperty('--animate-duration', duration + 's');
|
|
target.style.setProperty('--animate-delay', delay + 's');
|
|
target.style.animationIterationCount = iterationCount;
|
|
target.classList.add('animate__animated', animClass);
|
|
target.addEventListener('animationend', function handler() {
|
|
target.classList.remove('animate__animated', animClass);
|
|
target.removeEventListener('animationend', handler);
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// Panneau timeline absent sur un écran-modèle (élément de jeu), comme le
|
|
// panneau de logique — voir le commentaire Jinja au-dessus de .builderTabs.
|
|
var HAS_ANIM_PANEL = !!document.getElementById('animTabPanel');
|
|
if (HAS_ANIM_PANEL) { renderAnimTimeline(); }
|