Éditeur de scène : Logique/Timeline en onglets plein écran, réglages d'écran déplacés dans le panneau Éléments

Remplace l'ancien panneau du bas rétractable/redimensionnable à la souris
(Logique de la scène, Timeline d'animation) par un système d'onglets au
centre de l'éditeur - Écran / Logique de la scène / Timeline d'animation
- un seul visible à la fois, occupant systématiquement tout l'espace
disponible (switchBuilderTab() dans screen_edit.html). Changer d'onglet
équivaut à "fermer" celui qu'on quitte ; plus besoin d'une poignée de
redimensionnement séparée puisque l'onglet actif prend déjà toute la
place. Supprime au passage tout l'ancien mécanisme (toggleFlowPanel/
toggleAnimPanel, poignées flowResizeHandle/animResizeHandle, classe CSS
.flowPanel) devenu inutile.

Déplace aussi le renommage de l'écran, le bouton "Jouer depuis le début"
et le choix du format d'aperçu (Portrait/Paysage/Carré) - jusqu'ici
au-dessus du canevas - dans le panneau flottant "Éléments" (celui qui
porte déjà ce nom, à gauche) : des réglages qu'on touche rarement une
fois l'écran en construction, qui n'ont plus besoin de rester en
permanence visibles au-dessus de la zone de travail.

Corrige au passage un bug découvert pendant ce tour : sur la page "Nouvel
objet" (2 colonnes), le bouton "+ Ajouter un champ" avait disparu -
placé APRÈS la zone de liste à défilement (flex-grow:1) dans la colonne
de droite, un flex-grow imprévisible dans ce contexte le poussait hors de
vue. Déplacé avant la liste (statique, toujours visible), pattern déjà
éprouvé ailleurs sur cette même page.

Deux tests mis à jour pour refléter intentionnellement la nouvelle
structure : la présence de "animTabPanel" (au lieu de l'ancien
"animPanel"), et un marqueur plus précis pour distinguer le bloc de
propriétés "Onglets" d'une simple occurrence du même texte dans une liste
déroulante de la Logique de la scène (qui apparaît désormais plus tôt
dans le document, cet éditeur de flow étant maintenant un onglet du
centre plutôt qu'un panneau tout en bas de page).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-08-25 11:27:50 +02:00
co-authored by Claude Sonnet 5
parent 2051b375ac
commit ea9f91fc19
5 changed files with 351 additions and 450 deletions
+19 -31
View File
@@ -114,7 +114,7 @@ body.objectEditBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden
.content-objectEdit .colLeft{ overflow-y:auto; height:100%; }
.content-objectEdit .colRight{ display:flex; flex-direction:column; min-height:0; height:100%; }
.content-objectEdit .colRight h2{ flex:0 0 auto; }
.content-objectEdit .colRight > .hint{ flex:0 0 auto; }
.content-objectEdit .colRight > .hint, .content-objectEdit .colRight > button{ flex:0 0 auto; }
.fieldsTableWrap{ flex:1 1 auto; min-height:0; overflow:auto; border:1px solid var(--border); border-radius:8px; }
.fieldsTable{ width:100%; border-collapse:collapse; font-size:12.5px; }
@@ -149,7 +149,6 @@ body.builderBody > main.content{ flex:1 1 auto; min-height:0; overflow:hidden; d
width:100%;
max-width:1600px;
}
.content-builder > .fieldRow:first-child{ flex:0 0 auto; }
/* 3 colonnes : ajout d'éléments (gauche) / écran en grand (centre) / propriétés (droite) —
toutes les trois occupent exactement la même hauteur (celle qui reste
@@ -375,37 +374,26 @@ code{ background:var(--panel2); border:1px solid var(--border); border-radius:4p
.floatPanelReopenBtn{ display:none !important; }
}
/* ---------- Logique de la scène : éditeur de flow à nœuds ---------- */
/* Panneau du bas, dans le même flux flexbox que .builder3 (pas une
surcouche/overlay) : replié, il ne prend que la hauteur de son en-tête et
.builder3 récupère tout le reste de l'espace ; ouvert, sa hauteur est
fixée en pixels (réglable à la souris via la poignée juste au-dessus) et
.builder3 rétrécit d'autant automatiquement — jamais l'un par-dessus
l'autre. */
.flowPanel{
flex:0 0 auto; border:1px solid var(--border); border-radius:10px; background:var(--panel);
margin-top:8px; display:flex; flex-direction:column; overflow:hidden;
/* ---------- Onglets du centre : Écran / Logique de la scène / Timeline
d'animation ---------- */
/* Un seul onglet visible à la fois (voir switchBuilderTab() dans
screen_edit.html), chacun occupant tout l'espace disponible dans
.builderCanvasArea — remplace l'ancien panneau du bas rétractable/
redimensionnable à la souris : un onglet "prend toute la place" par
nature, pas besoin d'une poignée de redimensionnement séparée, et en
changer d'onglet équivaut à "fermer" celui qu'on quitte. */
.builderTabs{ display:flex; gap:2px; flex:0 0 auto; border-bottom:1px solid var(--border); margin-bottom:10px; }
.builderTabBtn{
background:transparent; border:none; border-bottom:2px solid transparent; border-radius:0;
padding:8px 12px; color:var(--text-dim); font-size:12.5px; cursor:pointer;
}
.flowPanelHeader{
flex:0 0 auto; padding:10px 16px; font-weight:700; cursor:pointer; user-select:none;
display:flex; align-items:center; gap:6px;
}
.flowPanelHeader:hover{ background:var(--panel2); }
.flowToggleIcon{ color:var(--text-dim); font-size:11px; transition:transform .15s; display:inline-block; }
.flowPanel.open .flowToggleIcon{ transform:rotate(90deg); }
.flowPanelBody{ flex:1 1 auto; min-height:0; padding:0 16px 16px; display:none; flex-direction:column; }
.flowPanel.open .flowPanelBody{ display:flex; }
.flowToolbar{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px; flex:0 0 auto; }
.builderTabBtn:hover{ color:var(--text); }
.builderTabBtn.active{ color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
.flowResizeHandle{
flex:0 0 auto; height:10px; margin-top:6px; cursor:row-resize; position:relative; display:none;
}
.flowResizeHandle.visible{ display:block; }
.flowResizeHandle::before{
content:""; position:absolute; left:50%; top:3px; width:60px; height:4px;
background:var(--border); border-radius:2px; transform:translateX(-50%);
}
.flowResizeHandle:hover::before{ background:var(--accent); }
.builderTabPanel{ flex:1 1 auto; min-height:0; display:none; flex-direction:column; }
.builderTabPanel.active{ display:flex; }
.flowToolbar{ display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-bottom:10px; flex:0 0 auto; }
.nodeForm{ border:1px solid var(--border); border-radius:8px; padding:12px; margin-bottom:12px; background:var(--panel2); max-width:420px; flex:0 0 auto; }
.nodeForm label{ margin-top:8px; }
+1 -1
View File
@@ -24,10 +24,10 @@
<div class="colRight">
<h2>Champs</h2>
<button type="button" class="button is-outlined is-fullwidth mb-2" id="btnAddField">+ Ajouter un champ</button>
<div class="fieldsTableWrap" style="padding:10px;">
<div id="fieldsList"></div>
</div>
<button type="button" class="button is-outlined is-fullwidth mt-2" id="btnAddField">+ Ajouter un champ</button>
</div>
</div>
</form>
+314 -404
View File
@@ -12,16 +12,6 @@
<li class="is-active"><a>{{ screen.name }}</a></li>
{% endblock %}
{% block content %}
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
<form method="post" action="{{ url_for('screen_rename', slug=game.slug, screen_id=screen.id) }}" class="field has-addons" style="align-items:center; margin:0;">
<div class="control"><input class="input" type="text" name="name" value="{{ screen.name }}" required style="font-size:16px; font-weight:700; width:auto;"></div>
<div class="control"><button type="submit" class="button is-small">Renommer</button></div>
</form>
{% if not screen.is_template %}
<a class="button is-small is-primary" href="{{ url_for('game_play', slug=game.slug) }}" target="_blank">▶️ Jouer depuis le début</a>
{% endif %}
</div>
{% if screen.is_template %}
<p class="hint" style="background:#1c2130; border:1px solid #2b3140; border-radius:8px; padding:10px 14px; margin:10px 0;">
🧩 Contenu d'un élément de jeu réutilisable.
@@ -67,6 +57,25 @@
<button type="button" class="button is-small" onclick="closeLeftPanel()" title="Fermer">✕</button>
</div>
<div class="floatPanelBody">
<!-- Nom de l'écran, aperçu jouable, et format de l'aperçu (Portrait/
Paysage/Carré) : regroupés ici plutôt qu'au-dessus du canevas —
ce sont des réglages qu'on touche rarement une fois l'écran en
cours de construction, pas besoin qu'ils prennent en permanence de
la place dans la zone centrale. -->
<form method="post" action="{{ url_for('screen_rename', slug=game.slug, screen_id=screen.id) }}" class="field has-addons" style="align-items:center; margin-bottom:8px;">
<div class="control is-expanded"><input class="input is-small" type="text" name="name" value="{{ screen.name }}" required></div>
<div class="control"><button type="submit" class="button is-small">Renommer</button></div>
</form>
{% if not screen.is_template %}
<a class="button is-small is-primary is-fullwidth mb-2" href="{{ url_for('game_play', slug=game.slug) }}" target="_blank">▶️ Jouer depuis le début</a>
{% endif %}
{% set current_aspect = screen.preview_aspect or '9/16' %}
<div class="buttons has-addons are-small mb-2" style="flex-wrap:nowrap;">
<button type="button" class="button smallBtn aspectBtn {{ 'active' if current_aspect == '9/16' else '' }}" data-aspect="9/16">📱 Portrait</button>
<button type="button" class="button smallBtn aspectBtn {{ 'active' if current_aspect == '16/9' else '' }}" data-aspect="16/9">🖥️ Paysage</button>
<button type="button" class="button smallBtn aspectBtn {{ 'active' if current_aspect == '1/1' else '' }}" data-aspect="1/1">◻️ Carré</button>
</div>
<hr style="border-color:var(--border); margin:10px 0;">
<h2 class="subtitle is-6">Éléments de cet écran</h2>
{% if top_level_elements %}
<div class="elementTree">
@@ -149,15 +158,25 @@
<button type="button" onclick="contextMenuDuplicate()">📄 Dupliquer</button>
</div>
<!-- Centre : l'écran, en très grand -->
<!-- Centre : l'écran (onglet "Écran"), ou la logique de la scène / la
timeline d'animation quand leur onglet est actif — un seul onglet
visible à la fois, chacun occupant tout l'espace disponible (voir
switchBuilderTab() plus bas). Absents sur un écran-modèle (élément
de jeu) : ses déclencheurs référenceraient des éléments dont l'id
change à chaque copie (voir instantiate_template_tree), donc une
logique/animation posée ici ne fonctionnerait plus une fois
l'élément réutilisé — seul l'onglet "Écran" a alors un sens. -->
<div class="builderCanvasArea">
{% set current_aspect = screen.preview_aspect or '9/16' %}
<div class="canvasToolbar">
<span class="hint">Aperçu :</span>
<button type="button" class="smallBtn aspectBtn {{ 'active' if current_aspect == '9/16' else '' }}" data-aspect="9/16">📱 Portrait</button>
<button type="button" class="smallBtn aspectBtn {{ 'active' if current_aspect == '16/9' else '' }}" data-aspect="16/9">🖥️ Paysage</button>
<button type="button" class="smallBtn aspectBtn {{ 'active' if current_aspect == '1/1' else '' }}" data-aspect="1/1">◻️ Carré</button>
{% if not screen.is_template %}
<div class="builderTabs" id="builderTabs">
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Écran</button>
<button type="button" class="builderTabBtn" data-tab="flow" onclick="switchBuilderTab('flow')">🔀 Logique de la scène</button>
<button type="button" class="builderTabBtn" data-tab="anim" onclick="switchBuilderTab('anim')">🎬 Timeline d'animation</button>
</div>
{% endif %}
<div class="builderTabPanel active" data-tab-panel="screen">
<div class="canvasFrame">
<div class="canvas" id="canvas" style="aspect-ratio:{{ current_aspect }};">
{% for el in top_level_elements %}
@@ -172,6 +191,268 @@
<p class="hint" style="text-align:center;">Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner.</p>
</div>
{% if not screen.is_template %}
<div class="builderTabPanel" id="flowTabPanel" data-tab-panel="flow">
<div class="flowToolbar">
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('trigger')">➕ Déclencheur</button>
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('condition')">➕ Condition</button>
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('action')">➕ Action</button>
<span class="hint">Clique un point de sortie d'un nœud, puis le point d'entrée d'un autre nœud, pour les relier. Reclique une connexion pour la supprimer.</span>
</div>
<div id="nodeForm" class="nodeForm" style="display:none;">
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
<strong id="nodeFormTitle">Nouveau nœud</strong>
<button type="button" class="smallBtn button is-small" onclick="closeNodeForm()">✕</button>
</div>
<div id="nodeFormTrigger" style="display:none;">
<label>Événement<select id="nf-trigger-event" onchange="toggleFlowTriggerFields()">
{% for key, label in trigger_events %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select></label>
<label id="nf-trigger-element-row">Élément<select id="nf-trigger-element">
{% for e2 in elements %}
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
{% endfor %}
</select></label>
<p class="hint" id="nf-trigger-repeater-hint" style="display:none;"></p>
<p class="hint" id="nf-trigger-affichage-hint" style="display:none;"></p>
</div>
<div id="nodeFormCondition" style="display:none;">
{% if not definitions %}
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('object_new', slug=game.slug) }}">crées-en un</a>.</p>
{% else %}
<label>Objet<select id="nf-cond-definition" onchange="onFlowDefinitionChange('cond')">
{% for d in definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select></label>
<label>Ligne concernée<select id="nf-cond-row"></select></label>
<label>Champ<select id="nf-cond-field" onchange="onFlowFieldChange()"></select></label>
<label>Condition<select id="nf-cond-operator">
{% for key, label in condition_operators %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select></label>
<label>Valeur de comparaison<input type="text" id="nf-cond-value" placeholder="ex. 1, vrai, 10..."></label>
<div id="nf-cond-extra-wrap">
<label id="nf-cond-combinator-row" style="display:none;">Combiner les conditions avec
<select id="nf-cond-combinator">
<option value="et">ET (toutes les conditions doivent être vraies)</option>
<option value="ou">OU (au moins une condition doit être vraie)</option>
</select>
</label>
<div id="nf-cond-extra-clauses"></div>
<button type="button" class="smallBtn button is-small" onclick="addFlowConditionClause()">+ Ajouter une condition</button>
</div>
{% endif %}
</div>
<div id="nodeFormAction" style="display:none;">
<label>Type d'action<select id="nf-action-type" onchange="toggleFlowActionFields()">
{% for key, label in action_type_labels.items() %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select></label>
<select id="nf-action-target-screen" style="display:none;">
{% for s in all_screens %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
<div id="nf-action-element-fields" style="display:none;">
<select id="nf-action-target-element">
{% for e2 in elements %}
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
{% endfor %}
</select>
<p class="hint" id="nf-action-onglet-hint" style="display:none;"></p>
<div id="nf-action-element-property-wrap">
<select id="nf-action-element-property" onchange="toggleFlowElementPropertyValue()" style="margin-top:6px;">
{% for key, label in element_action_properties %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<select id="nf-action-element-value-visibility" style="margin-top:6px;">
{% for key, label in element_visibility_values %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<select id="nf-action-element-value-surbrillance" style="display:none; margin-top:6px;">
{% for key, label in element_surbrillance_values %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<select id="nf-action-element-value-desactive" style="display:none; margin-top:6px;">
{% for key, label in element_desactive_values %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<input type="color" id="nf-action-element-value-color" style="display:none; margin-top:6px;" value="#5b8cff">
<input type="number" id="nf-action-element-value-number" style="display:none; margin-top:6px;" value="50" placeholder="Nouvelle valeur en %">
<input type="text" id="nf-action-element-value-text" style="display:none; margin-top:6px;" placeholder="Nouveau texte affiché">
<label id="nf-action-element-toggle-row" style="display:flex; align-items:center; gap:6px; margin-top:10px; font-size:12px; color:var(--text-dim);">
<input type="checkbox" id="nf-action-element-toggle" onchange="toggleFlowElementToggleMode()" style="width:auto; margin:0;">
Va-et-vient : au 1er clic, passer à cette valeur ; au 2e clic, revenir à la précédente ; etc.
</label>
<div id="nf-action-element-value2-wrap" style="display:none; margin-top:6px;">
<input type="color" id="nf-action-element-value2-color" style="display:none;" value="#ff0000">
<input type="number" id="nf-action-element-value2-number" style="display:none;" placeholder="Valeur au 2e état en %">
</div>
</div>
</div>
<div id="nf-action-wait-fields" style="display:none;">
<label>Délai en secondes avant de continuer
<input type="number" id="nf-action-wait-seconds" min="0" step="0.5" value="3" placeholder="ex. 3">
</label>
</div>
<div id="nf-action-data-fields" style="display:none;">
{% if not definitions %}
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('object_new', slug=game.slug) }}">crées-en un</a>.</p>
{% else %}
<select id="nf-action-data-definition" onchange="onFlowDefinitionChange('action')">
{% for d in definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
<select id="nf-action-data-row" style="margin-top:6px;"></select>
<select id="nf-action-data-field" style="margin-top:6px;"></select>
<select id="nf-action-data-operation" style="margin-top:6px;">
{% for key, label in data_operations %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<input type="text" id="nf-action-data-value" style="margin-top:6px;" placeholder="Valeur / montant">
{% endif %}
</div>
<div id="nf-action-variable-fields" style="display:none;">
{% if not global_variables %}
<p class="hint">Aucune variable globale pour l'instant — crées-en une depuis "Condition de visibilité" dans les propriétés d'un élément.</p>
{% else %}
<select id="nf-action-variable-name">
{% for v in global_variables %}
<option value="{{ v.name }}">{{ v.name }}</option>
{% endfor %}
</select>
<select id="nf-action-variable-operation" style="margin-top:6px;">
{% for key, label in data_operations %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<input type="text" id="nf-action-variable-value" style="margin-top:6px;" placeholder="Valeur / montant">
{% endif %}
</div>
</div>
<div class="actionsRow"><button type="button" class="smallBtn button is-small is-primary" onclick="submitNodeForm()">+ Ajouter ce nœud</button></div>
</div>
<div class="flowCanvasWrap">
<svg id="flowEdges" class="flowEdgesSvg"></svg>
<div id="flowCanvas" class="flowCanvas"></div>
</div>
</div>
<div class="builderTabPanel" id="animTabPanel" data-tab-panel="anim">
<div class="flowToolbar">
<label style="display:flex; align-items:center; gap:6px;">
Piste
<select id="anim-new-track-element">
{% for e2 in elements %}
<option value="{{ e2.id }}">{{ e2|elabel }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="smallBtn button is-small" onclick="addAnimTrack()">➕ Ajouter un clip</button>
</div>
<div id="animClipForm" class="nodeForm" style="display:none;">
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
<strong id="animClipFormTitle">Clip d'animation</strong>
<button type="button" class="smallBtn button is-small" onclick="closeAnimClipForm()">✕</button>
</div>
<label>Type
<select id="anim-clip-kind" onchange="toggleAnimClipKind()">
<option value="animate_css">Animation Animate.css</option>
<option value="custom">Animation personnalisée — images-clés</option>
</select>
</label>
<div id="anim-clip-animate-css-fields">
<label>Animation
<select id="anim-clip-animate-name">
{% for category, items in animate_css_catalog %}
<optgroup label="{{ category }}">
{% for key, label in items %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</label>
</div>
<div id="anim-clip-custom-fields" style="display:none;">
<div id="anim-clip-keyframes"></div>
<button type="button" class="smallBtn button is-small" onclick="addAnimKeyframeRow()">+ Ajouter une image-clé</button>
</div>
<div class="fieldRow">
<label style="flex:1;">Départ en secondes
<input type="number" id="anim-clip-start" min="0" step="0.1" value="0">
</label>
<label style="flex:1;">Durée en secondes
<input type="number" id="anim-clip-duration" min="0.1" step="0.1" value="1">
</label>
</div>
<div class="fieldRow">
<label style="flex:1;">Délai en secondes
<input type="number" id="anim-clip-delay" min="0" step="0.1" value="0">
</label>
<label style="flex:1;">Vitesse
<select id="anim-clip-easing">
<option value="ease">Progressive</option>
<option value="linear">Constante</option>
<option value="ease-in">Accélère</option>
<option value="ease-out">Ralentit</option>
<option value="ease-in-out">Accélère puis ralentit</option>
</select>
</label>
</div>
<label>Répétitions
<select id="anim-clip-iteration">
<option value="1">Une fois</option>
<option value="2">2 fois</option>
<option value="3">3 fois</option>
<option value="infinite">En boucle</option>
</select>
</label>
<div class="actionsRow">
<button type="button" class="smallBtn button is-small is-primary" onclick="saveAnimClipForm()">Enregistrer</button>
<button type="button" class="smallBtn button is-small" onclick="previewAnimClip()">▶ Aperçu</button>
<button type="button" class="smallBtn button is-small is-danger" onclick="deleteAnimClipForm()">🗑️ Supprimer</button>
</div>
</div>
<div class="animTimelineWrap">
<div id="animRuler" class="animRuler"></div>
<div id="animTracks" class="animTracks"></div>
</div>
</div>
{% endif %}
</div>
<!-- Panneau droit : propriétés de l'élément sélectionné — flottant,
déplaçable à la souris par son en-tête, fermable pour agrandir le
canevas (voir floatPanel*() plus bas). -->
@@ -468,298 +749,6 @@
</div>
</div>
{% if not screen.is_template %}
<!-- Poignée de redimensionnement : visible seulement quand le panneau du bas
est ouvert, on la tire à la souris pour régler sa hauteur. -->
<div class="flowResizeHandle" id="flowResizeHandle"></div>
<!-- Panneau bas, rétractable : logique de la scène (éditeur de flow à nœuds).
Volontairement PAS un <details> natif : sa hauteur ouverte doit pouvoir
être réglée à la souris (voir flowResizeHandle), ce qu'un <details> ne
permet pas nativement. Absent pour un écran-modèle (élément de jeu) :
ses déclencheurs référenceraient des éléments dont l'id change à chaque
copie (voir instantiate_template_tree), donc une logique posée ici ne
fonctionnerait plus une fois l'élément réutilisé. -->
<div class="flowPanel" id="flowPanel">
<div class="flowPanelHeader" onclick="toggleFlowPanel()">
<span class="flowToggleIcon">▸</span> 🔀 Logique de la scène — nœuds et connexions
</div>
<div class="flowPanelBody" id="flowPanelBody">
<div class="flowToolbar">
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('trigger')">➕ Déclencheur</button>
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('condition')">➕ Condition</button>
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('action')">➕ Action</button>
<span class="hint">Clique un point de sortie d'un nœud, puis le point d'entrée d'un autre nœud, pour les relier. Reclique une connexion pour la supprimer.</span>
</div>
<div id="nodeForm" class="nodeForm" style="display:none;">
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
<strong id="nodeFormTitle">Nouveau nœud</strong>
<button type="button" class="smallBtn button is-small" onclick="closeNodeForm()">✕</button>
</div>
<div id="nodeFormTrigger" style="display:none;">
<label>Événement<select id="nf-trigger-event" onchange="toggleFlowTriggerFields()">
{% for key, label in trigger_events %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select></label>
<label id="nf-trigger-element-row">Élément<select id="nf-trigger-element">
{% for e2 in elements %}
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
{% endfor %}
</select></label>
<p class="hint" id="nf-trigger-repeater-hint" style="display:none;"></p>
<p class="hint" id="nf-trigger-affichage-hint" style="display:none;"></p>
</div>
<div id="nodeFormCondition" style="display:none;">
{% if not definitions %}
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('object_new', slug=game.slug) }}">crées-en un</a>.</p>
{% else %}
<label>Objet<select id="nf-cond-definition" onchange="onFlowDefinitionChange('cond')">
{% for d in definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select></label>
<label>Ligne concernée<select id="nf-cond-row"></select></label>
<label>Champ<select id="nf-cond-field" onchange="onFlowFieldChange()"></select></label>
<label>Condition<select id="nf-cond-operator">
{% for key, label in condition_operators %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select></label>
<label>Valeur de comparaison<input type="text" id="nf-cond-value" placeholder="ex. 1, vrai, 10..."></label>
<div id="nf-cond-extra-wrap">
<label id="nf-cond-combinator-row" style="display:none;">Combiner les conditions avec
<select id="nf-cond-combinator">
<option value="et">ET (toutes les conditions doivent être vraies)</option>
<option value="ou">OU (au moins une condition doit être vraie)</option>
</select>
</label>
<div id="nf-cond-extra-clauses"></div>
<button type="button" class="smallBtn button is-small" onclick="addFlowConditionClause()">+ Ajouter une condition</button>
</div>
{% endif %}
</div>
<div id="nodeFormAction" style="display:none;">
<label>Type d'action<select id="nf-action-type" onchange="toggleFlowActionFields()">
{% for key, label in action_type_labels.items() %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select></label>
<select id="nf-action-target-screen" style="display:none;">
{% for s in all_screens %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
<div id="nf-action-element-fields" style="display:none;">
<select id="nf-action-target-element">
{% for e2 in elements %}
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
{% endfor %}
</select>
<p class="hint" id="nf-action-onglet-hint" style="display:none;"></p>
<div id="nf-action-element-property-wrap">
<select id="nf-action-element-property" onchange="toggleFlowElementPropertyValue()" style="margin-top:6px;">
{% for key, label in element_action_properties %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<select id="nf-action-element-value-visibility" style="margin-top:6px;">
{% for key, label in element_visibility_values %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<select id="nf-action-element-value-surbrillance" style="display:none; margin-top:6px;">
{% for key, label in element_surbrillance_values %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<select id="nf-action-element-value-desactive" style="display:none; margin-top:6px;">
{% for key, label in element_desactive_values %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<input type="color" id="nf-action-element-value-color" style="display:none; margin-top:6px;" value="#5b8cff">
<input type="number" id="nf-action-element-value-number" style="display:none; margin-top:6px;" value="50" placeholder="Nouvelle valeur en %">
<input type="text" id="nf-action-element-value-text" style="display:none; margin-top:6px;" placeholder="Nouveau texte affiché">
<label id="nf-action-element-toggle-row" style="display:flex; align-items:center; gap:6px; margin-top:10px; font-size:12px; color:var(--text-dim);">
<input type="checkbox" id="nf-action-element-toggle" onchange="toggleFlowElementToggleMode()" style="width:auto; margin:0;">
Va-et-vient : au 1er clic, passer à cette valeur ; au 2e clic, revenir à la précédente ; etc.
</label>
<div id="nf-action-element-value2-wrap" style="display:none; margin-top:6px;">
<input type="color" id="nf-action-element-value2-color" style="display:none;" value="#ff0000">
<input type="number" id="nf-action-element-value2-number" style="display:none;" placeholder="Valeur au 2e état en %">
</div>
</div>
</div>
<div id="nf-action-wait-fields" style="display:none;">
<label>Délai en secondes avant de continuer
<input type="number" id="nf-action-wait-seconds" min="0" step="0.5" value="3" placeholder="ex. 3">
</label>
</div>
<div id="nf-action-data-fields" style="display:none;">
{% if not definitions %}
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('object_new', slug=game.slug) }}">crées-en un</a>.</p>
{% else %}
<select id="nf-action-data-definition" onchange="onFlowDefinitionChange('action')">
{% for d in definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
<select id="nf-action-data-row" style="margin-top:6px;"></select>
<select id="nf-action-data-field" style="margin-top:6px;"></select>
<select id="nf-action-data-operation" style="margin-top:6px;">
{% for key, label in data_operations %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<input type="text" id="nf-action-data-value" style="margin-top:6px;" placeholder="Valeur / montant">
{% endif %}
</div>
<div id="nf-action-variable-fields" style="display:none;">
{% if not global_variables %}
<p class="hint">Aucune variable globale pour l'instant — crées-en une depuis "Condition de visibilité" dans les propriétés d'un élément.</p>
{% else %}
<select id="nf-action-variable-name">
{% for v in global_variables %}
<option value="{{ v.name }}">{{ v.name }}</option>
{% endfor %}
</select>
<select id="nf-action-variable-operation" style="margin-top:6px;">
{% for key, label in data_operations %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<input type="text" id="nf-action-variable-value" style="margin-top:6px;" placeholder="Valeur / montant">
{% endif %}
</div>
</div>
<div class="actionsRow"><button type="button" class="smallBtn button is-small is-primary" onclick="submitNodeForm()">+ Ajouter ce nœud</button></div>
</div>
<div class="flowCanvasWrap">
<svg id="flowEdges" class="flowEdgesSvg"></svg>
<div id="flowCanvas" class="flowCanvas"></div>
</div>
</div>
</div>
<!-- Poignée de redimensionnement du panneau timeline, même principe que
flowResizeHandle ci-dessus. -->
<div class="flowResizeHandle" id="animResizeHandle"></div>
<!-- Panneau bas, rétractable, positionné sous "Logique de la scène" :
timeline d'animation. Une piste par élément animé, des clips posés sur
un axe du temps (secondes depuis l'affichage de l'écran) — glisse un
clip pour changer son départ, tire son bord droit pour changer sa
durée. Chaque clip est soit une animation de la bibliothèque
Animate.css, soit une animation personnalisée (images-clés propres à
ce jeu). -->
<div class="flowPanel" id="animPanel">
<div class="flowPanelHeader" onclick="toggleAnimPanel()">
<span class="flowToggleIcon">▸</span> 🎬 Timeline d'animation
</div>
<div class="flowPanelBody" id="animPanelBody">
<div class="flowToolbar">
<label style="display:flex; align-items:center; gap:6px;">
Piste
<select id="anim-new-track-element">
{% for e2 in elements %}
<option value="{{ e2.id }}">{{ e2|elabel }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="smallBtn button is-small" onclick="addAnimTrack()">➕ Ajouter un clip</button>
</div>
<div id="animClipForm" class="nodeForm" style="display:none;">
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
<strong id="animClipFormTitle">Clip d'animation</strong>
<button type="button" class="smallBtn button is-small" onclick="closeAnimClipForm()">✕</button>
</div>
<label>Type
<select id="anim-clip-kind" onchange="toggleAnimClipKind()">
<option value="animate_css">Animation Animate.css</option>
<option value="custom">Animation personnalisée — images-clés</option>
</select>
</label>
<div id="anim-clip-animate-css-fields">
<label>Animation
<select id="anim-clip-animate-name">
{% for category, items in animate_css_catalog %}
<optgroup label="{{ category }}">
{% for key, label in items %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</label>
</div>
<div id="anim-clip-custom-fields" style="display:none;">
<div id="anim-clip-keyframes"></div>
<button type="button" class="smallBtn button is-small" onclick="addAnimKeyframeRow()">+ Ajouter une image-clé</button>
</div>
<div class="fieldRow">
<label style="flex:1;">Départ en secondes
<input type="number" id="anim-clip-start" min="0" step="0.1" value="0">
</label>
<label style="flex:1;">Durée en secondes
<input type="number" id="anim-clip-duration" min="0.1" step="0.1" value="1">
</label>
</div>
<div class="fieldRow">
<label style="flex:1;">Délai en secondes
<input type="number" id="anim-clip-delay" min="0" step="0.1" value="0">
</label>
<label style="flex:1;">Vitesse
<select id="anim-clip-easing">
<option value="ease">Progressive</option>
<option value="linear">Constante</option>
<option value="ease-in">Accélère</option>
<option value="ease-out">Ralentit</option>
<option value="ease-in-out">Accélère puis ralentit</option>
</select>
</label>
</div>
<label>Répétitions
<select id="anim-clip-iteration">
<option value="1">Une fois</option>
<option value="2">2 fois</option>
<option value="3">3 fois</option>
<option value="infinite">En boucle</option>
</select>
</label>
<div class="actionsRow">
<button type="button" class="smallBtn button is-small is-primary" onclick="saveAnimClipForm()">Enregistrer</button>
<button type="button" class="smallBtn button is-small" onclick="previewAnimClip()">▶ Aperçu</button>
<button type="button" class="smallBtn button is-small is-danger" onclick="deleteAnimClipForm()">🗑️ Supprimer</button>
</div>
</div>
<div class="animTimelineWrap">
<div id="animRuler" class="animRuler"></div>
<div id="animTracks" class="animTracks"></div>
</div>
</div>
</div>
{% endif %}
<script>
function saveGeometry(id, x, y, width, height) {
@@ -2251,58 +2240,24 @@ function submitNodeForm() {
}
// Panneau de logique de scène absent sur un écran-modèle (élément de jeu) —
// voir le commentaire Jinja au-dessus de la div flowPanel.
var HAS_FLOW_PANEL = !!document.getElementById('flowPanel');
// voir le commentaire Jinja au-dessus de .builderTabs.
var HAS_FLOW_PANEL = !!document.getElementById('flowTabPanel');
if (HAS_FLOW_PANEL) { renderFlow(); }
// ---------- Ouverture / fermeture et redimensionnement du panneau du bas ----------
var FLOW_PANEL_DEFAULT_HEIGHT = 340;
var flowPanelLastHeight = FLOW_PANEL_DEFAULT_HEIGHT;
function toggleFlowPanel() {
var panel = document.getElementById('flowPanel');
var handle = document.getElementById('flowResizeHandle');
if (!panel || !handle) return;
var isOpen = panel.classList.contains('open');
if (isOpen) {
flowPanelLastHeight = panel.getBoundingClientRect().height;
panel.classList.remove('open');
panel.style.height = '';
handle.classList.remove('visible');
} else {
panel.classList.add('open');
panel.style.height = flowPanelLastHeight + 'px';
handle.classList.add('visible');
requestAnimationFrame(drawFlowEdges);
}
}
if (HAS_FLOW_PANEL) (function(){
var handle = document.getElementById('flowResizeHandle');
var panel = document.getElementById('flowPanel');
handle.addEventListener('mousedown', function(e){
e.preventDefault();
if (!panel.classList.contains('open')) return;
var startY = e.clientY;
var startHeight = panel.getBoundingClientRect().height;
var wrap = document.querySelector('.content-builder');
var maxHeight = Math.max(160, wrap.getBoundingClientRect().height - 160);
function onMove(ev) {
var dy = ev.clientY - startY;
var newHeight = Math.min(maxHeight, Math.max(140, startHeight - dy));
panel.style.height = newHeight + 'px';
flowPanelLastHeight = newHeight;
drawFlowEdges();
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
// ---------- Onglets du centre : Écran / Logique de la scène / Timeline
// d'animation — un seul visible à la fois, chacun occupant tout l'espace
// disponible (remplace l'ancien panneau du bas rétractable/redimensionnable
// à la souris : un onglet "prend toute la place" par nature, et en changer
// équivaut à "fermer" celui qu'on quitte). ----------
function switchBuilderTab(tab) {
document.querySelectorAll('.builderTabBtn').forEach(function (btn) {
btn.classList.toggle('active', btn.dataset.tab === tab);
});
})();
document.querySelectorAll('.builderTabPanel').forEach(function (panel) {
panel.classList.toggle('active', panel.dataset.tabPanel === tab);
});
if (tab === 'flow') requestAnimationFrame(drawFlowEdges);
}
// ---------- Timeline d'animation : piste par élément, clips Animate.css ou
// personnalisés (images-clés), glissés/redimensionnés à la souris sur un
@@ -2617,53 +2572,8 @@ function previewAnimClip() {
}
// Panneau timeline absent sur un écran-modèle (élément de jeu), comme le
// panneau de logique — voir le commentaire Jinja au-dessus de la div animPanel.
var HAS_ANIM_PANEL = !!document.getElementById('animPanel');
// panneau de logique — voir le commentaire Jinja au-dessus de .builderTabs.
var HAS_ANIM_PANEL = !!document.getElementById('animTabPanel');
if (HAS_ANIM_PANEL) { renderAnimTimeline(); }
var ANIM_PANEL_DEFAULT_HEIGHT = 260;
var animPanelLastHeight = ANIM_PANEL_DEFAULT_HEIGHT;
function toggleAnimPanel() {
var panel = document.getElementById('animPanel');
var handle = document.getElementById('animResizeHandle');
if (!panel || !handle) return;
var isOpen = panel.classList.contains('open');
if (isOpen) {
animPanelLastHeight = panel.getBoundingClientRect().height;
panel.classList.remove('open');
panel.style.height = '';
handle.classList.remove('visible');
} else {
panel.classList.add('open');
panel.style.height = animPanelLastHeight + 'px';
handle.classList.add('visible');
}
}
if (HAS_ANIM_PANEL) (function() {
var handle = document.getElementById('animResizeHandle');
var panel = document.getElementById('animPanel');
handle.addEventListener('mousedown', function(e) {
e.preventDefault();
if (!panel.classList.contains('open')) return;
var startY = e.clientY;
var startHeight = panel.getBoundingClientRect().height;
var wrap = document.querySelector('.content-builder');
var maxHeight = Math.max(160, wrap.getBoundingClientRect().height - 160);
function onMove(ev) {
var dy = ev.clientY - startY;
var newHeight = Math.min(maxHeight, Math.max(140, startHeight - dy));
panel.style.height = newHeight + 'px';
animPanelLastHeight = newHeight;
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
})();
</script>
{% endblock %}
+1 -1
View File
@@ -89,7 +89,7 @@ def test_clip_delete_removes_it(client, game):
def test_screen_edit_exposes_timeline_panel_and_animate_css_cdn(client, game):
screen_id = _create_screen(client, game)
html = client.get(f"/game/{game}/screens/{screen_id}/edit").data.decode()
assert "animPanel" in html
assert "animTabPanel" in html
assert "Timeline d'animation" in html
assert "animate.css" in html
+7 -4
View File
@@ -20,10 +20,13 @@ def _create_onglets(client, slug, screen_id):
def _tab_rename_ids(html):
"""Ids des formulaires de renommage DANS le bloc "Onglets" du panneau de
propriétés — pas le premier /elements/<id>/rename de la page, qui est le
champ "Nommer" générique de l'élément actuellement sélectionné (pas
forcément un onglet, ex. l'élément Onglets lui-même)."""
block = html.split("Onglets (", 1)[-1]
propriétés — pas le premier /elements/<id>/rename de la page (le champ
"Nommer" générique de l'élément actuellement sélectionné), ni une
occurrence de "Onglets (" dans une liste déroulante d'éléments de la
Logique de la scène (ex. "<option>Onglets (elt-1)</option>"), qui existe
aussi sur la page — seul l'en-tête du groupe de réglages ("<summary>🗂️
Onglets (") repère sans ambiguïté le bloc de propriétés lui-même."""
block = html.split(">🗂️ Onglets (", 1)[-1]
return [int(x) for x in re.findall(r"/elements/(\d+)/rename", block)]