Files
Forge-Engine/templates/screen_edit.html
T
williamandClaude Sonnet 5 aaf9954446 Variables globales Objet/Tableau, lisibles via un chemin dans les conditions et filtres
Deux nouveaux types de variable globale (db/constants.py) : "objet" et
"tableau" — valeur stockée en JSON (colonne TEXT existante), avec
validation à la création/modification (db/global_vars/
coerce_structured_value.py) : un JSON invalide retombe sur un défaut sûr
("{}"/"[]") plutôt que de corrompre silencieusement la variable pour
toutes ses lectures suivantes. apply_variable_action.py n'a besoin
d'aucun changement — "definir_texte" écrit déjà n'importe quelle chaîne
telle quelle.

Nouvelle résolution de chemin, partagée (screens/rendering/
filter_repeater_rows.py::_resolve_variable_path) : navigue dans la
valeur JSON d'une variable selon un chemin ".champ"/"[index]" chaînable
(ex. ".arme.degats", "[0].valeur") — ne lève jamais, renvoie None si le
JSON est invalide ou qu'un segment du chemin ne correspond à rien.
Branchée à deux endroits, qui lisaient déjà une variable globale :

- La valeur de comparaison {{$nom_variable}} (filtres de Répéteur ET
  Condition de visibilité, qui partagent le même
  _resolve_filter_value()) accepte maintenant un chemin optionnel :
  {{$perso.nom}}, {{$scores[0]}}. Le sélecteur "Variable globale" du
  panneau de propriétés (screen_edit.html, .filterValueVariable) gagne un
  champ "Chemin optionnel" à côté du choix de variable — même regex
  étendue côté JS (_VAR_REF_RE) que côté Python (_VAR_REF_PATTERN), pour
  que la valeur round-trip correctement à la réouverture du panneau.

- La variable VÉRIFIÉE par une Condition de visibilité en mode "variable"
  (choisie via un <select>, pas la syntaxe {{$...}}) gagne son propre
  nouveau contrôle "Chemin dans la variable" (visibility_condition_
  controls.py) — nécessaire pour comparer un champ d'un Objet ou un
  élément d'un Tableau, pas seulement la variable entière.

game_dashboard.html (onglet Variables) : la valeur par défaut de la barre
de création devient un <textarea> (fonctionne aussi bien pour un JSON
multi-ligne qu'un scalaire court), et la cellule "Valeur" du tableau des
variables existantes devient un <textarea> quand le type est objet/
tableau.

4 nouveaux tests (tests/test_variable_object_array.py) : validation JSON
à la création, filtre de Répéteur avec chemin chaîné, condition de
visibilité avec accès par index de tableau, chemin invalide/absent sans
plantage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-27 14:28:23 +02:00

2785 lines
137 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}{{ screen.name }} — {{ game.name }}{% endblock %}
{% block content_class %}content-builder{% endblock %}
{% block body_class %}builderBody{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ google_fonts_link }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
{% endblock %}
{% block content %}
{% 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.
</p>
{% endif %}
{% macro render_tree_node(el) %}
{% set kids = children_of.get(el.id, []) %}
<div class="treeNode" data-element-id="{{ el.id }}">
<div class="treeRow" draggable="true" data-parent-id="{{ el.parent_id or '' }}" data-is-container="{{ '1' if el.widget in container_widgets else '' }}">
{% if kids %}
<button type="button" class="treeToggle" data-node-id="{{ el.id }}" aria-label="Réduire / déplier">▾</button>
{% else %}
<span class="treeToggleSpacer"></span>
{% endif %}
<a class="treeRowLink {{ 'selected' if el.id == selected_id else '' }}"
href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=el.id) }}"
data-element-id="{{ el.id }}"
oncontextmenu="return showElementContextMenu(event, {{ el.id }});">
<span class="treeIcon">{{ widgets.get(el.widget, {}).get('icon', '❔') }}</span>
<span class="treeLabel">{{ el|elabel }}</span>
</a>
</div>
{% if kids %}
<div class="treeChildren">
{% for child in kids %}
{{ render_tree_node(child) }}
{% endfor %}
</div>
{% endif %}
</div>
{% endmacro %}
<div class="builder3" id="builder3">
{% set el = elements|selectattr("id", "equalto", selected_id)|first if selected_id else none %}
<!-- Panneau gauche : ajouter un élément (icônes, sans jargon technique) —
flottant, déplaçable, fermable, même mécanisme que le panneau droit
(voir floatPanel*() plus bas). -->
<button type="button" id="panelLeftReopenBtn" class="button is-small floatPanelReopenBtn" onclick="openLeftPanel()" title="Rouvrir le panneau des éléments">🧩 Éléments</button>
<div class="floatPanel floatPanel--left" id="panelLeftFloat">
<div class="floatPanelHeader" data-drag-panel="panelLeftFloat" data-drag-key="left">
<span class="floatPanelTitle">⋮⋮ Éléments de cet écran</span>
<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">
{% for el in top_level_elements %}
{{ render_tree_node(el) }}
{% endfor %}
</div>
{% else %}
<p class="hint">Aucun élément pour l'instant.</p>
{% endif %}
<details class="propGroup">
<summary>Ajouter un élément sur l'écran</summary>
<div class="propGroupBody">
<div class="widgetGrid">
{% for wkey, w in widgets.items() if wkey != 'icone' %}
<form method="post" action="{{ url_for('element_add', slug=game.slug, screen_id=screen.id) }}" class="widgetBtnForm">
<input type="hidden" name="widget" value="{{ wkey }}">
<button type="submit" class="widgetBtn" title="{{ w.label }}">
<span class="widgetIcon">{{ w.icon }}</span>
<span class="widgetLabel">{{ w.label }}</span>
</button>
</form>
{% endfor %}
</div>
</div>
</details>
<!-- Galerie d'icônes — pas un widget parmi d'autres dans la grille
ci-dessus (voir "if wkey != 'icone'") : cliquer/glisser une icône ICI
pose directement un élément Icône déjà réglé sur cette icône précise,
plutôt qu'un élément générique qu'il faudrait ensuite configurer. -->
<details class="propGroup">
<summary>🔤 Icônes</summary>
<div class="propGroupBody">
<input type="text" class="input is-small" id="iconGallerySearch" placeholder="Rechercher une icône..." oninput="filterIconGallery(this.value)">
<div class="iconGallery" id="iconGallery">
{% for slug in fontawesome_icons %}
<button type="button" class="iconGalleryTile" draggable="true"
data-icon-class="{{ slug }}" data-icon-search="{{ slug }}"
title="{{ slug }}" onclick="addIconElement('{{ slug }}')">
<i class="icon-svg" style="--icon-url:url('/static/icons/{{ slug }}.svg');"></i>
</button>
{% endfor %}
</div>
<p class="help">Glisse une icône sur le canevas, ou clique dessus pour l'ajouter — sa couleur et sa taille se règlent ensuite dans ses propriétés ("Icône"). Sélection non exhaustive : d'autres icônes pourront être ajoutées à cette liste plus tard si besoin.</p>
</div>
</details>
<!-- Menu rétractable dédié aux éléments de jeu personnalisés (Personnage,
Outil, IA...) : chacun a désormais SA PROPRE tuile, avec son nom,
plutôt qu'une unique tuile générique "Élément de jeu" qui ajoutait
toujours le premier du catalogue sans laisser choisir lequel. -->
<details class="propGroup">
<summary>🧩 Mes éléments de jeu{{ ' (' ~ element_types|length ~ ')' if element_types else '' }}</summary>
<div class="propGroupBody">
{% if element_types %}
<div class="widgetGrid">
{% for et in element_types %}
<form method="post" action="{{ url_for('element_add', slug=game.slug, screen_id=screen.id) }}" class="widgetBtnForm">
<input type="hidden" name="widget" value="__catalogue__">
<input type="hidden" name="element_type_id" value="{{ et.id }}">
<button type="submit" class="widgetBtn" title="{{ et.name }}">
<span class="widgetIcon">{{ et.icon or '🧩' }}</span>
<span class="widgetLabel">{{ et.name }}</span>
</button>
</form>
{% endfor %}
</div>
{% else %}
<p class="hint">Pas encore d'élément de jeu personnalisé — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='element_types') }}">crées-en un</a>.</p>
{% endif %}
</div>
</details>
</div>
</div>
<!-- Menu contextuel (clic droit sur un élément de l'arborescence) -->
<div id="elementContextMenu" class="contextMenu" style="display:none;">
<button type="button" onclick="contextMenuDuplicate()">📄 Dupliquer</button>
</div>
<!-- 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). Présents aussi sur un écran-modèle
(élément de jeu réutilisable, ex. "mail content") : ses enfants
gardent désormais TOUJOURS le même id, à chaque exemplaire posé sur
n'importe quelle scène (rechargés en direct depuis ce modèle à
chaque affichage, voir _render_element_type_children côté serveur —
l'ancien mécanisme qui les copiait avec un nouvel id à chaque pose,
instantiate_template_tree, a été retiré). Un déclencheur/une
animation posé ici, sur un enfant du modèle, s'exécute donc bien
partout où ce modèle est utilisé (voir findTriggerNode/
collectAnimationClips dans templates/play.html). -->
<div class="builderCanvasArea">
{% set current_aspect = screen.preview_aspect or '9/16' %}
<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>
<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 %}
<div class="canvasElement {{ 'selected' if el.id == selected_id else '' }}" id="el-{{ el.id }}" style="{{ el|elstyle }}" data-anim-target="{{ el.id }}"
onmousedown="onElementMouseDown(event, {{ el.id }})">
<div class="canvasElementInner" style="{{ el|eltransform }}">{{ el.rendered_html|safe }}</div>
<div class="resizeHandle" onmousedown="onResizeMouseDown(event, {{ el.id }})"></div>
</div>
{% endfor %}
</div>
</div>
<p class="hint" style="text-align:center;">Glisse pour déplacer, tire le coin pour redimensionner, clique pour sélectionner.</p>
</div>
<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('game_dashboard', slug=game.slug, tab='objects') }}">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('game_dashboard', slug=game.slug, tab='objects') }}">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>
</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). -->
<button type="button" id="propsPanelReopenBtn" class="button is-small floatPanelReopenBtn" onclick="openPropsPanel()" title="Rouvrir le panneau de propriétés">⚙️ Propriétés</button>
<div class="floatPanel floatPanel--right" id="propsPanelFloat">
<div class="floatPanelHeader" data-drag-panel="propsPanelFloat" data-drag-key="props">
<span class="floatPanelTitle">⋮⋮ Propriétés</span>
<button type="button" class="button is-small" onclick="closePropsPanel()" title="Fermer">✕</button>
</div>
<div class="floatPanelBody">
<!-- Sélecteur visuel d'icône, ouvert depuis le réglage "Icône" d'un
élément Icône déjà posé (voir plus bas dans la boucle des contrôles)
— même galerie que ci-dessus, mais choisir une icône ici REMPLACE
celle de l'élément sélectionné au lieu d'en ajouter un nouveau. Une
seule instance partagée (pas besoin de la refaire par élément). -->
<div class="modal" id="iconPickerModal">
<div class="modal-background" onclick="closeIconPicker()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">Choisir une icône</p>
<button class="delete" aria-label="Fermer" onclick="closeIconPicker()"></button>
</header>
<section class="modal-card-body">
<input type="text" class="input is-small" placeholder="Rechercher une icône..." oninput="filterIconPickerModal(this.value)">
<div class="iconGallery" id="iconPickerGrid" style="max-height:50vh;">
{% for slug in fontawesome_icons %}
<button type="button" class="iconGalleryTile" data-icon-search="{{ slug }}"
title="{{ slug }}" onclick="chooseIcon('{{ slug }}')">
<i class="icon-svg" style="--icon-url:url('/static/icons/{{ slug }}.svg');"></i>
</button>
{% endfor %}
</div>
</section>
</div>
</div>
<!-- Confirmation de suppression d'un élément — remplace le confirm()
natif du navigateur pour pouvoir avertir, le cas échéant, que des
nœuds de la Logique de la scène seront aussi supprimés (voir
handleDeleteElementSubmit()/element_delete_impact.py) : le
confirm() natif ne pouvait afficher qu'un texte figé au moment du
rendu, jamais une info calculée après coup. -->
<div class="modal" id="deleteConfirmModal">
<div class="modal-background" onclick="closeDeleteConfirmModal()"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">🗑️ <span id="deleteConfirmMessage">Supprimer cet élément ?</span></p>
<button class="delete" aria-label="Fermer" onclick="closeDeleteConfirmModal()"></button>
</header>
<section class="modal-card-body">
<p class="hint">Cette action est irréversible.</p>
<p class="hint" id="deleteConfirmFlowWarning" style="display:none; margin-top:10px; color:var(--danger, #e2685f);"></p>
</section>
<footer class="modal-card-foot" style="justify-content:flex-end; gap:8px;">
<button type="button" class="button is-small" onclick="closeDeleteConfirmModal()">Annuler</button>
<button type="button" class="button is-small is-danger" onclick="confirmDeleteElement()">🗑️ Supprimer</button>
</footer>
</div>
</div>
{% if not selected_id %}
<div class="hint" style="padding:20px 0;">Sélectionne un élément (sur l'écran ou dans la liste) pour voir et modifier ses propriétés ici.</div>
{% else %}
{% set el = elements|selectattr("id", "equalto", selected_id)|first %}
{% if el %}
{% set w = widgets.get(el.widget, {}) %}
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
<h2 class="subtitle is-6" style="margin:0;">{{ w.get('icon','❔') }} {{ el|elabel }}{% if el.name %} <span class="has-text-grey">— {{ w.get('label', el.widget) }}</span>{% endif %}</h2>
<form method="post" action="{{ url_for('element_delete', slug=game.slug, element_id=el.id) }}"
data-confirm-message="Supprimer cet élément ?" onsubmit="return handleDeleteElementSubmit(event, this);">
<button type="submit" class="button is-small is-danger">🗑️</button>
</form>
</div>
<form method="post" action="{{ url_for('element_rename', slug=game.slug, element_id=el.id) }}" class="field has-addons" style="margin:6px 0;">
<div class="control is-expanded"><input class="input" type="text" name="name" value="{{ el.name or '' }}" placeholder="Donne un nom à cet élément"></div>
<div class="control"><button type="submit" class="button is-small">Nommer</button></div>
</form>
<div class="field has-addons" style="margin:6px 0;">
<div class="control"><a class="button is-small is-static">ID</a></div>
<div class="control is-expanded"><input class="input" type="text" value="elt-{{ el.id }}" readonly style="opacity:0.7;"></div>
</div>
{% if el.widget == 'onglets' %}
{% set tabs = children_of.get(el.id, []) %}
<details class="propGroup">
<summary>🗂️ Onglets{{ ' (' ~ tabs|length ~ ')' if tabs else '' }}</summary>
<div class="propGroupBody">
{% if tabs %}
{% for tab in tabs %}
<div class="box" style="padding:10px; display:flex; flex-direction:column; gap:6px;">
<form method="post" action="{{ url_for('element_rename', slug=game.slug, element_id=tab.id) }}" class="field has-addons" style="margin:0;">
<div class="control is-expanded"><input class="input is-small" type="text" name="name" value="{{ tab.name or ('Onglet ' ~ loop.index) }}" placeholder="Nom de l'onglet"></div>
<div class="control"><button type="submit" class="button is-small">Renommer</button></div>
</form>
<div class="field has-addons" style="margin:0;">
<div class="control is-expanded"><a class="button is-small is-fullwidth" href="{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id, selected=tab.id) }}">📥 Contenu de l'onglet</a></div>
<div class="control">
<form method="post" action="{{ url_for('element_delete', slug=game.slug, element_id=tab.id) }}"
data-confirm-message="Supprimer cet onglet et tout son contenu ?" onsubmit="return handleDeleteElementSubmit(event, this);">
<button type="submit" class="button is-small is-danger">🗑️</button>
</form>
</div>
</div>
</div>
{% endfor %}
{% else %}
<p class="help">Aucun onglet pour l'instant.</p>
{% endif %}
<form method="post" action="{{ url_for('element_tab_add', slug=game.slug, element_id=el.id) }}" style="margin-top:8px;">
<button type="submit" class="button is-small is-primary is-fullwidth">➕ Ajouter un onglet</button>
</form>
</div>
</details>
{% endif %}
{% set size_ctrl_keys = ['child-width', 'child-height'] %}
{% set size_ctrls = {} %}
{% for c in selected_controls if c.key in size_ctrl_keys %}{% set _ = size_ctrls.update({c.key: c}) %}{% endfor %}
<form method="post" action="{{ url_for('element_save', slug=game.slug, element_id=el.id) }}" id="propsForm" onsubmit="event.preventDefault(); return false;">
<details class="propGroup">
<summary>Position &amp; taille</summary>
<div class="propGroupBody">
<div class="columns is-multiline posGrid">
<div class="column is-half">
<div class="field"><label class="label is-small">X (%)</label>
<div class="control"><input class="input is-small" type="number" id="pos-x" min="0" max="100" value="{{ el.x }}" {{ 'disabled' if el.parent_id else '' }}></div>
</div>
</div>
<div class="column is-half">
<div class="field"><label class="label is-small">Y (%)</label>
<div class="control"><input class="input is-small" type="number" id="pos-y" min="0" max="100" value="{{ el.y }}" {{ 'disabled' if el.parent_id else '' }}></div>
</div>
</div>
{% if el.parent_id %}
<div class="column is-half">
<div class="field"><label class="label is-small">Largeur</label>
<div class="field has-addons sizeField">
<div class="control is-expanded"><input class="input is-small" type="number" name="ctrl_child-width_value" min="0" value="{{ size_ctrls['child-width'].value.value or '' }}" placeholder="auto"></div>
<div class="control">
<div class="select is-small">
<select name="ctrl_child-width_unit">
<option value="%" {{ 'selected' if size_ctrls['child-width'].value.unit == '%' else '' }}>%</option>
<option value="px" {{ 'selected' if size_ctrls['child-width'].value.unit == 'px' else '' }}>px</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="column is-half">
<div class="field"><label class="label is-small">Hauteur</label>
<div class="field has-addons sizeField">
<div class="control is-expanded"><input class="input is-small" type="number" name="ctrl_child-height_value" min="0" value="{{ size_ctrls['child-height'].value.value or '' }}" placeholder="auto"></div>
<div class="control">
<div class="select is-small">
<select name="ctrl_child-height_unit">
<option value="%" {{ 'selected' if size_ctrls['child-height'].value.unit == '%' else '' }}>%</option>
<option value="px" {{ 'selected' if size_ctrls['child-height'].value.unit == 'px' else '' }}>px</option>
</select>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="column is-half">
<div class="field"><label class="label is-small">Largeur (%)</label>
<div class="control"><input class="input is-small" type="number" id="pos-w" min="1" max="100" value="{{ el.width }}"></div>
</div>
</div>
<div class="column is-half">
<div class="field"><label class="label is-small">Hauteur (%)</label>
<div class="control"><input class="input is-small" type="number" id="pos-h" min="1" max="100" value="{{ el.height }}"></div>
</div>
</div>
{% endif %}
</div>
{% if el.parent_id %}
<p class="help">X/Y ne s'appliquent qu'à un élément posé directement sur l'écran — celui-ci suit la disposition de son conteneur. Largeur/Hauteur : laisser vide garde la taille automatique ; "%" (relatif au conteneur) convient à la plupart des cas, "px" pour une taille précise.</p>
{% endif %}
</div>
</details>
{% set ns = namespace(last_group=None, started=False) %}
{% for c in selected_controls if c.key not in size_ctrl_keys %}
{% if c.group != ns.last_group %}
{% if ns.started %}
</div>
</details>
{% endif %}
<details class="propGroup">
<summary>{{ c.group or 'Propriétés' }}</summary>
<div class="propGroupBody">
{% set ns.last_group = c.group %}
{% set ns.started = True %}
{% endif %}
<div class="field controlRow{{ ' visCondRow' if c.key.startswith('visibilite_cond_') and c.key != 'visibilite_cond_mode' else '' }}"
{% if c.key in ('visibilite_cond_variable', 'visibilite_cond_variable_chemin') %}data-vis-cond-scope="variable"
{% elif c.key in ('visibilite_cond_definition_id', 'visibilite_cond_champ') %}data-vis-cond-scope="objet"
{% elif c.key in ('visibilite_cond_operateur', 'visibilite_cond_valeur') %}data-vis-cond-scope="any"
{% endif %}>
{% if c.type in ('checkbox', 'toggle_token') %}
<div class="control">
<label class="checkbox">
<input type="checkbox" name="ctrl_{{ c.key }}" {{ 'checked' if c.value else '' }}>
{{ c.label }}
</label>
</div>
{% else %}
<label class="label is-small">{{ c.label }}</label>
{% if c.key == 'icon_class' %}
<div class="control">
<button type="button" class="button is-fullwidth" onclick="openIconPicker()">
<span class="icon"><i id="iconPickerPreview" class="icon-svg" style="--icon-url:url('/static/icons/{{ c.value or c.default }}.svg');"></i></span>
<span>Changer l'icône</span>
</button>
<input type="hidden" name="ctrl_{{ c.key }}" id="field-{{ c.key }}" value="{{ c.value or c.default }}">
</div>
{% elif c.key.endswith('_valeur') %}
<!-- Valeur de comparaison d'un filtre — soit une valeur fixe
tapée à la main, soit une donnée d'un AUTRE objet qui change
en cours de partie (ex: le niveau courant), choisie via deux
listes déroulantes plutôt que tapée à la main sous la forme
interne attendue — voir initFilterValuePickers() et
updateFilterValueFromDynamic() plus bas, qui reconstruisent
cette syntaxe en coulisses dans le champ caché ci-dessous,
seul réellement envoyé au serveur. -->
<div class="control filterValueControl" data-current-value="{{ c.value }}">
<div class="select is-small is-fullwidth">
<select class="filterValueMode" onchange="onFilterValueModeChange(this)">
<option value="fixe">Valeur fixe</option>
<option value="dynamique">Donnée d'un autre objet (change en jeu)</option>
<option value="variable">Variable globale (change en jeu)</option>
</select>
</div>
<div class="filterValueFixe" style="margin-top:6px;">
<input class="input is-small" type="text" name="ctrl_{{ c.key }}" id="field-{{ c.key }}" value="{{ c.value }}" placeholder="{{ c.placeholder or '' }}">
</div>
<div class="filterValueDynamique" style="display:none; margin-top:6px;">
<div class="select is-small is-fullwidth">
<select class="filterValueObjSel" onchange="onFilterValueObjChange(this)">
<option value="">— objet —</option>
{% for d in definitions %}
<option value="{{ d.name }}" data-definition-id="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
</div>
<div class="select is-small is-fullwidth" style="margin-top:6px;">
<select class="filterValueFieldSel" onchange="onFilterValueFieldChange(this)">
<option value="">— champ —</option>
</select>
</div>
</div>
<div class="filterValueVariable" style="display:none; margin-top:6px;">
<div class="select is-small is-fullwidth">
<select class="filterValueVarSel" onchange="onFilterValueVarChange(this)">
<option value="">— variable —</option>
{% for v in global_variables %}
<option value="{{ v.name }}">{{ v.name }}</option>
{% endfor %}
</select>
</div>
<!-- Utile seulement si la variable choisie est de type
"objet"/"tableau" : sans chemin, c'est toute la variable
(son JSON) qui est utilisée comme valeur de comparaison. -->
<input class="input is-small filterValueVarPath" type="text" placeholder="Chemin optionnel (ex. .nom ou [0].valeur)" style="margin-top:6px;" oninput="updateFilterValueFromVariable(this.closest('.filterValueControl'))">
{% if not global_variables %}
<p class="help">Aucune variable globale pour l'instant — <a href="{{ url_for('game_dashboard', slug=game.slug, tab='variables') }}" target="_blank">crées-en une ↗</a></p>
{% endif %}
</div>
</div>
{% elif c.type in ('text', 'url') %}
<div class="control">
<input class="input {{ 'uploadTextField' if c.allow_upload else '' }}" type="text" name="ctrl_{{ c.key }}" value="{{ c.value }}" placeholder="{{ c.placeholder or '' }}" id="field-{{ c.key }}">
</div>
{% if c.allow_upload %}
<div class="control uploadRow">
<input type="file" class="uploadInput" accept="{{ c.upload_accept }}" data-target="field-{{ c.key }}">
<span class="help uploadStatus"></span>
</div>
<p class="help">Colle une adresse (URL) ci-dessus, ou choisis un fichier sur ton ordinateur — il sera hébergé automatiquement avec ce jeu.</p>
{% endif %}
{% if c.field_options %}
<div class="control fieldRow" style="margin-top:6px;">
<div class="select is-small is-expanded"><select id="fieldPicker-{{ c.key }}">
<option value="">— champ de l'objet lié —</option>
{% for fname, flabel in c.field_options %}
<option value="{{ fname }}">{{ flabel }}</option>
{% endfor %}
</select></div>
<button type="button" class="button is-small" onclick="insertFieldAtCursor('field-{{ c.key }}', 'fieldPicker-{{ c.key }}')">+ Ajouter</button>
</div>
{% endif %}
{% elif c.type == 'textarea' %}
<div class="control"><textarea class="textarea" name="ctrl_{{ c.key }}" id="field-{{ c.key }}" rows="4" placeholder="{{ c.placeholder or '' }}">{{ c.value }}</textarea></div>
{% if c.field_options %}
<div class="control fieldRow" style="margin-top:6px;">
<div class="select is-small is-expanded"><select id="fieldPicker-{{ c.key }}">
<option value="">— champ de l'objet lié —</option>
{% for fname, flabel in c.field_options %}
<option value="{{ fname }}">{{ flabel }}</option>
{% endfor %}
</select></div>
<button type="button" class="button is-small" onclick="insertFieldAtCursor('field-{{ c.key }}', 'fieldPicker-{{ c.key }}')">+ Ajouter</button>
</div>
<p class="help">Insère "&#123;&#123;champ&#125;&#125;" à l'emplacement du curseur — affichera la valeur de ce champ, sur la ligne trouvée par "Donnée liée" ci-dessous.</p>
{% endif %}
{% elif c.type == 'color' %}
<div class="control"><input class="input" type="color" name="ctrl_{{ c.key }}" value="{{ c.value or c.default }}"></div>
{% elif c.type == 'slider' %}
<div class="control sliderRow">
<input type="range" name="ctrl_{{ c.key }}" min="{{ c.min }}" max="{{ c.max }}" step="{{ c.step }}" value="{{ c.value }}"
oninput="this.nextElementSibling.textContent = this.value + '{{ c.unit }}'">
<span class="sliderValue tag">{{ c.value }}{{ c.unit }}</span>
</div>
{% elif c.type == 'scale' %}
<div class="control sliderRow">
<input type="range" name="ctrl_{{ c.key }}" min="{{ c.min }}" max="{{ c.max }}" step="{{ c.step }}" value="{{ c.value }}"
oninput="this.nextElementSibling.textContent = 'x' + this.value">
<span class="sliderValue tag">x{{ c.value }}</span>
</div>
{% elif c.type == 'shadow' %}
<div class="control">
<div class="fieldRow">
<div class="formField" style="flex:1;"><label class="label is-small">Décalage X</label><input class="input is-small" type="number" name="ctrl_{{ c.key }}_x" value="{{ c.value.x }}"></div>
<div class="formField" style="flex:1;"><label class="label is-small">Décalage Y</label><input class="input is-small" type="number" name="ctrl_{{ c.key }}_y" value="{{ c.value.y }}"></div>
</div>
<div class="fieldRow" style="margin-top:6px;">
<div class="formField" style="flex:1;"><label class="label is-small">Flou</label><input class="input is-small" type="number" min="0" name="ctrl_{{ c.key }}_blur" value="{{ c.value.blur }}"></div>
<div class="formField" style="flex:1;"><label class="label is-small">Étendue</label><input class="input is-small" type="number" min="0" name="ctrl_{{ c.key }}_spread" value="{{ c.value.spread }}"></div>
</div>
<div class="fieldRow" style="margin-top:6px; align-items:center;">
<input class="input" type="color" style="flex:0 0 60px; padding:2px;" name="ctrl_{{ c.key }}_color" value="{{ c.value.color }}">
<div class="sliderRow" style="flex:1;">
<input type="range" name="ctrl_{{ c.key }}_opacity" min="0" max="1" step="0.05" value="{{ c.value.opacity }}"
oninput="this.nextElementSibling.textContent = Math.round(this.value * 100) + '%'">
<span class="sliderValue tag">{{ (c.value.opacity * 100)|round|int }}%</span>
</div>
</div>
<p class="help">Décalages, flou et étendue tous à 0 = pas d'ombre.</p>
</div>
{% elif c.type == 'align' %}
<div class="control">
<div class="buttons has-addons alignGroup">
{% for val, icon in [('left','⬅️'),('center','⬛'),('right','➡️'),('justify','☰')] %}
<label class="button is-small alignOption {{ 'is-primary active' if c.value == val else '' }}">
<input type="radio" name="ctrl_{{ c.key }}" value="{{ val }}" {{ 'checked' if c.value == val else '' }} onchange="syncAlign(this)" style="display:none;">
{{ icon }}
</label>
{% endfor %}
</div>
</div>
{% elif c.type in ('select', 'preset') %}
<div class="control">
<div class="select is-fullwidth">
<select name="ctrl_{{ c.key }}" id="field-{{ c.key }}" {% if c.key == 'visibilite_cond_mode' %}onchange="toggleVisCondFields(this)"{% endif %}>
{% for opt in c.options %}
<option value="{{ opt[0] }}" {{ 'selected' if c.value == opt[0] else '' }}>{{ opt[1] }}</option>
{% endfor %}
</select>
</div>
</div>
{% if c.key == 'visibilite_cond_variable' %}
<p class="help">Pas la variable qu'il te faut ? <a href="{{ url_for('game_dashboard', slug=game.slug, tab='variables') }}" target="_blank">Gérer les variables du jeu ↗</a></p>
{% endif %}
{% endif %}
{% endif %}
</div>
{% endfor %}
{% if ns.started %}
</div>
</details>
{% endif %}
<div class="actionsRow"><span class="hint autosaveStatus" id="autosaveStatus">Enregistré automatiquement</span></div>
</form>
{% endif %}
{% endif %}
</div>
</div>
</div>
<script>
function saveGeometry(id, x, y, width, height) {
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})
});
}
var SCREEN_EDIT_URL = "{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id) }}";
var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};
var GAME_SLUG = "{{ game.slug }}";
var SCREEN_ID = {{ screen.id }};
// ---------- 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
// nom de l'élément reste un lien à part (.treeRowLink) qui, lui, sélectionne
// toujours l'élément et affiche ses réglages — cliquer sur un conteneur
// replié donne donc bien accès à ses propriétés, comme demandé. L'état
// 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;
}
function restoreTreeCollapsedState() {
document.querySelectorAll('.elementTree .treeNode').forEach(function (node) {
var id = node.dataset.elementId;
var collapsed = false;
try { collapsed = localStorage.getItem(treeCollapseKey(id)) === '1'; } catch (e) {}
node.classList.toggle('collapsed', collapsed);
});
}
// ---------- Panneaux gauche/droite : flottants, déplaçables, fermables ----------
//
// Sortis du flux (position:fixed en CSS) pour ne jamais rétrécir le canevas
// selon où on les pose — les fermer libère cette largeur pour une
// prévisualisation plus fidèle à ce que "Jouer" affiche réellement (voir
// bug remonté : des panneaux trop larges réduisaient le canevas au point de
// casser la mise en page des onglets). Un seul jeu de fonctions génériques
// (key = identifiant court mémorisé dans localStorage, distinct par
// panneau) plutôt qu'une copie par panneau. Position et état ouvert/fermé
// survivent à un changement de sélection (qui remplace tout #builder3, voir
// refreshBuilderPanel) ou un rechargement — voir les appels à
// restoreFloatPanelState() dans initBuilderPanel().
function floatPanelStateKey(key) {
return 'forgeFloatPanel:' + GAME_SLUG + ':' + SCREEN_ID + ':' + key;
}
function saveFloatPanelState(key, state) {
try {
var current = JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}');
localStorage.setItem(floatPanelStateKey(key), JSON.stringify(Object.assign(current, state)));
} catch (e) {}
}
function loadFloatPanelState(key) {
try { return JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}'); } catch (e) { return {}; }
}
function restoreFloatPanelState(key, panelId, reopenBtnId) {
var panel = document.getElementById(panelId);
var reopenBtn = document.getElementById(reopenBtnId);
if (!panel) return;
var state = loadFloatPanelState(key);
if (state.left != null && state.top != null) {
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é
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);
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);
if (panel) panel.classList.remove('hidden');
if (reopenBtn) reopenBtn.classList.remove('visible');
saveFloatPanelState(key, { open: true });
}
// Raccourcis utilisés directement par les attributs onclick du gabarit.
function closePropsPanel() { closeFloatPanel('props', 'propsPanelFloat', 'propsPanelReopenBtn'); }
function openPropsPanel() { openFloatPanel('props', 'propsPanelFloat', 'propsPanelReopenBtn'); }
function closeLeftPanel() { closeFloatPanel('left', 'panelLeftFloat', 'panelLeftReopenBtn'); }
function openLeftPanel() { openFloatPanel('left', 'panelLeftFloat', 'panelLeftReopenBtn'); }
if (!window.__forgeFloatPanelDragBound) {
window.__forgeFloatPanelDragBound = true;
document.addEventListener('mousedown', function (e) {
var handle = e.target.closest('.floatPanelHeader');
if (!handle || e.target.closest('button')) return;
var panel = document.getElementById(handle.dataset.dragPanel);
var 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;
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';
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveFloatPanelState(key, { left: parseFloat(panel.style.left), top: parseFloat(panel.style.top) });
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
// ---------- Galerie d'icônes Font Awesome (panneau de propriétés) ----------
//
// Cliquer une tuile l'ajoute directement (comme n'importe quelle tuile de
// "Ajouter un élément sur l'écran") ; la glisser jusque sur le canevas fait
// la même chose au relâchement — le drag HTML5 natif ne permet pas de créer
// un vrai élément positionné exactement sous le curseur sans aller-retour
// serveur supplémentaire, donc on se contente ici de déclencher le même
// 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');
form.method = 'post';
form.action = "{{ url_for('element_add', slug=game.slug, screen_id=screen.id) }}";
form.innerHTML =
'<input type="hidden" name="widget" value="icone">' +
'<input type="hidden" name="icon_class" value="' + iconClass.replace(/"/g, '&quot;') + '">';
document.body.appendChild(form);
form.requestSubmit();
}
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;
document.addEventListener('dragstart', function (e) {
var tile = e.target.closest('.iconGalleryTile');
if (!tile) return;
draggedIconClass = tile.dataset.iconClass;
tile.classList.add('dragging');
e.dataTransfer.effectAllowed = 'copy';
try { e.dataTransfer.setData('text/plain', draggedIconClass); } catch (err) {}
});
document.addEventListener('dragend', function (e) {
var tile = e.target.closest('.iconGalleryTile');
if (tile) tile.classList.remove('dragging');
draggedIconClass = null;
});
document.addEventListener('dragover', function (e) {
if (!draggedIconClass || !e.target.closest('#canvas')) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'copy';
});
document.addEventListener('drop', function (e) {
if (!draggedIconClass || !e.target.closest('#canvas')) return;
e.preventDefault();
addIconElement(draggedIconClass);
draggedIconClass = null;
});
}
// ---------- Surbrillance sur le canevas de l'élément sélectionné ----------
//
// Un élément posé DIRECTEMENT sur l'écran a déjà son cadre de sélection via
// la classe "canvasElement.selected" (posée côté serveur). Un élément
// imbriqué dans un conteneur/répéteur/groupe de champs n'a pas ce cadre
// séparé — sa propre balise porte "data-element-id" (voir visible_attrs.py)
// mais rien ne la mettait en valeur, donc sélectionner un enfant dans
// 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');
if (!canvas) return;
canvas.querySelectorAll('.elementHighlighted').forEach(function (n) {
n.classList.remove('elementHighlighted');
});
if (!id) return;
var node = canvas.querySelector('[data-element-id="' + id + '"]');
if (node) node.classList.add('elementHighlighted');
}
// ---------- Élément à onglets : bascule d'onglet côté client ----------
//
// Un seul panneau visible à la fois (voir render_onglets.py) — basculé ici
// 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) {
b.classList.toggle('is-active', b === btn);
});
document.querySelectorAll('[data-tab-group="' + groupId + '"]').forEach(function (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');
if (!btn) return;
e.preventDefault();
e.stopPropagation();
var node = btn.closest('.treeNode');
var collapsed = node.classList.toggle('collapsed');
try { localStorage.setItem(treeCollapseKey(node.dataset.elementId), collapsed ? '1' : '0'); } catch (err) {}
});
}
restoreTreeCollapsedState();
// ---------- Arborescence : réordonner ou déplacer dans un conteneur par
// glisser-déposer ----------
//
// Deux gestes distincts, selon où on relâche sur une ligne cible :
// - au-dessus/en dessous d'une ligne DU MÊME CONTENEUR (même parent, ou
// toutes deux à la racine) : change leur ordre (voir reorder_element.py).
// - sur le MILIEU d'une ligne dont le widget est un conteneur (n'importe où
// dans l'arborescence, même un autre parent) : pose l'élément DEDANS, en
// dernier enfant (voir move_element_to_container.py) — c'est désormais LA
// façon de ranger un élément dans un conteneur (l'ancien menu dédié
// "Ajouter DANS ce conteneur" du panneau de propriétés a été retiré,
// devenu redondant avec ce geste).
// É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;
function treeRowDropInfo(row) {
var node = row.closest('.treeNode');
return {
elementId: node ? node.dataset.elementId : null,
parentId: row.dataset.parentId || '',
isContainer: row.dataset.isContainer === '1',
};
}
function clearTreeDropIndicators() {
document.querySelectorAll('.treeRow.dropBefore, .treeRow.dropAfter, .treeRow.dropInside').forEach(function (r) {
r.classList.remove('dropBefore', 'dropAfter', 'dropInside');
});
}
// Zone touchée par le curseur dans la ligne cible : avec les frères et
// sœurs, avant/après seulement (moitié haute/basse) ; sur un conteneur,
// une bande centrale "dedans" s'ajoute (tiers haut/milieu/bas) — sauf si ce
// 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;
if (!sameParent) return isContainer ? 'inside' : null;
if (!isContainer) return ratio < 0.5 ? 'before' : 'after';
if (ratio < 1 / 3) return 'before';
if (ratio > 2 / 3) return 'after';
return 'inside';
}
if (!window.__forgeTreeDragBound) {
window.__forgeTreeDragBound = true;
document.addEventListener('dragstart', function (e) {
var row = e.target.closest('.elementTree .treeRow');
if (!row) return;
var info = treeRowDropInfo(row);
treeDragElementId = info.elementId;
treeDragParentId = info.parentId;
row.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
try { e.dataTransfer.setData('text/plain', String(info.elementId)); } catch (err) {}
});
document.addEventListener('dragend', function (e) {
var 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');
if (!row || treeDragElementId == null) return;
var 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);
if (!zone) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
clearTreeDropIndicators();
row.classList.add(zone === 'before' ? 'dropBefore' : zone === 'after' ? 'dropAfter' : 'dropInside');
});
document.addEventListener('drop', function (e) {
var row = e.target.closest('.elementTree .treeRow');
clearTreeDropIndicators();
if (!row || treeDragElementId == null) return;
var 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);
if (!zone) return;
e.preventDefault();
var draggedId = treeDragElementId;
if (zone === 'inside') {
fetch('/game/{{ game.slug }}/elements/' + draggedId + '/move-to-container', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ parent_id: info.elementId })
}).then(refreshAfterReorder);
} else {
fetch('/game/{{ game.slug }}/elements/' + draggedId + '/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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) : '');
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');
if (newCanvas && curCanvas) {
curCanvas.innerHTML = newCanvas.innerHTML;
applySelectionHighlight(CURRENT_SELECTED_ID);
}
var newTree = doc.querySelector('#panelLeftFloat .elementTree');
var curTree = document.querySelector('#panelLeftFloat .elementTree');
if (newTree && curTree) { curTree.innerHTML = newTree.innerHTML; }
restoreTreeCollapsedState();
});
}
// ---------- Navigation dans l'éditeur sans AUCUN rechargement de page ----------
//
// Se déplacer dans le canevas (glisser, redimensionner, cliquer un autre
// élément) et modifier les réglages d'un élément ne doivent jamais recharger
// la page : on va chercher en arrière-plan (fetch) uniquement le fragment
// HTML dont on a besoin (le bloc #builder3 en entier pour changer de
// sélection, ou juste #canvas pour refléter un changement visuel après une
// sauvegarde automatique) et on le remplace dans le DOM courant. L'URL
// (?selected=...) reste mise à jour via l'historique (pushState), pour que
// le bouton "précédent" du navigateur continue à fonctionner normalement.
function refreshBuilderPanel(selectedId) {
var url = SCREEN_EDIT_URL + "?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");
if (newBuilder && curBuilder) {
curBuilder.innerHTML = newBuilder.innerHTML;
}
CURRENT_SELECTED_ID = selectedId;
history.pushState({ pjax: true }, "", result.url);
initBuilderPanel();
})
.catch(function () {
// É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;
});
}
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");
if (newCanvas && curCanvas) {
curCanvas.innerHTML = newCanvas.innerHTML;
applySelectionHighlight(CURRENT_SELECTED_ID);
}
// 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');
if (newTree && curTree) { curTree.innerHTML = newTree.innerHTML; restoreTreeCollapsedState(); }
});
}
function goToSelected(id) {
return refreshBuilderPanel(id);
}
// ---------- Glisser-déposer / redimensionner sur le canevas ----------
//
// Un mousedown qui bouge de plus de quelques pixels déplace l'élément
// (position sauvegardée en arrière-plan à la fin, sans recharger la page —
// comme l'élément déplacé reste celui déjà sélectionné, on se contente de
// mettre à jour les champs X/Y/Largeur/Hauteur en local, rien d'autre à
// rafraîchir) ; un mousedown qui ne bouge pas est traité comme un simple
// clic de sélection (qui, lui, doit rafraîchir le panneau de droite pour
// afficher les réglages du nouvel élément choisi).
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;
// 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;
function onMove(ev) {
var dx = ev.clientX - startX, 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 + '%';
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
if (moved) {
var newLeft = parseFloat(el.style.left), newTop = parseFloat(el.style.top);
updatePosFields(newLeft, newTop, width, height);
saveGeometry(id, newLeft, newTop, width, height);
} else if (String(clickedId) !== String(CURRENT_SELECTED_ID)) {
goToSelected(clickedId);
}
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
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);
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 + '%';
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
var newWidth = parseFloat(el.style.width), newHeight = parseFloat(el.style.height);
updatePosFields(left, top, newWidth, newHeight);
saveGeometry(id, left, top, newWidth, newHeight);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
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');
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;
if (hEl) hEl.value = Math.round(h * 10) / 10;
}
// ---------- Panneau de propriétés : tout est enregistré automatiquement ----------
//
// Il n'y a plus de bouton "Enregistrer" : chaque réglage se sauvegarde tout
// seul dès qu'on le change (immédiatement pour une case à cocher/couleur/
// 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;
function flashAutosave(state) {
if (!autosaveIndicator) return;
autosaveIndicator.classList.remove('saving', 'saved');
if (state === 'saving') {
autosaveIndicator.textContent = 'Enregistrement...';
autosaveIndicator.classList.add('saving');
} else {
autosaveIndicator.textContent = 'Enregistré automatiquement ✓';
autosaveIndicator.classList.add('saved');
}
}
function submitPropsForm() {
var form = document.getElementById('propsForm');
if (!form) return Promise.resolve();
flashAutosave('saving');
var data = new FormData(form);
return fetch(form.getAttribute('action'), {
method: 'POST', body: data, headers: { "X-Requested-With": "pjax" }
}).then(function () {
flashAutosave('saved');
return refreshCanvasOnly();
});
}
function debouncedSubmitPropsForm() {
clearTimeout(autosaveTimer);
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');
autosaveIndicator = document.getElementById('autosaveStatus');
if (!form) return;
form.querySelectorAll('input, textarea, select').forEach(function (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 () {
// 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();
});
});
}
// ---------- (Ré)initialisation du panneau central après chaque changement
// de sélection — nécessaire car #builder3 est entièrement remplacé, donc
// tout ce qui y était lié par addEventListener doit être rebranché (les
// attributs onclick/onmousedown inline, eux, fonctionnent automatiquement
// dès que le HTML est injecté, sans rien à refaire). ----------
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-data_definition_id', ['field-data_filtre_champ', 'field-data_filtre2_champ']);
bindDefinitionFieldSelects('field-visibilite_cond_definition_id', ['field-visibilite_cond_champ']);
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; });
});
}
// ---------- 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 DEFINITIONS_DATA = {{ definitions_json|safe }};
var CONDITION_OPERATORS_HTML = document.getElementById('nf-cond-operator') ? document.getElementById('nf-cond-operator').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."; });
});
});
// ---------- Logique de la scène : éditeur de flow à nœuds ----------
var FLOW_SCREEN_ID = {{ screen.id }};
var FLOW = {
nodes: {{ flow_nodes_json|safe }},
edges: {{ flow_edges_json|safe }},
};
var ELEMENTS_LABELS = {{ elements_labels_json|safe }};
var SCREENS_MAP = {{ screens_map_json|safe }};
var ACTION_TYPE_LABELS_MAP = {{ action_type_labels_json|safe }};
var ELEMENT_PROPERTY_LABELS_MAP = {{ element_action_property_labels_json|safe }};
var ELEMENT_VISIBILITY_LABELS_MAP = {{ element_visibility_labels_json|safe }};
var ELEMENT_SURBRILLANCE_LABELS_MAP = {{ element_surbrillance_labels_json|safe }};
var ELEMENT_DESACTIVE_LABELS_MAP = {{ element_desactive_labels_json|safe }};
var DATA_OPERATION_LABELS_MAP = {{ data_operation_labels_json|safe }};
var CONDITION_OPERATOR_LABELS_MAP = {{ condition_operator_labels_json|safe }};
var TRIGGER_LABELS_MAP = {{ trigger_labels_json|safe }};
var flowPendingPort = null; // {nodeId, port} en attente de connexion
function flowUrl(path) { return "/game/{{ game.slug }}" + path; }
function nodeLabel(node) {
if (node.node_type === 'trigger') {
if (node.trigger_event === 'affichage') return '🖥️ ' + TRIGGER_LABELS_MAP['affichage'];
return '🖱️ ' + (TRIGGER_LABELS_MAP[node.trigger_event] || 'Au clic') + ' sur ' +
(ELEMENTS_LABELS[node.trigger_element_id] || '?');
}
if (node.node_type === 'condition') {
var mainClause = '❓ SI ' + (node.cond_field || '?') + ' ' + (CONDITION_OPERATOR_LABELS_MAP[node.cond_operator] || node.cond_operator) + ' ' + (node.cond_value || '');
if (node.cond_clauses) {
// 2.4 — plusieurs clauses combinées : on affiche chaque champ testé,
// relié par le connecteur ET/OU choisi, plutôt qu'une seule clause.
try {
var clauses = JSON.parse(node.cond_clauses);
var combLabel = node.cond_combinator === 'ou' ? ' OU ' : ' ET ';
return '❓ SI ' + clauses.map(function(c) {
return (c.field || '?') + ' ' + (CONDITION_OPERATOR_LABELS_MAP[c.operator] || c.operator) + ' ' + (c.value || '');
}).join(combLabel);
} catch (e) { /* JSON invalide : on retombe sur la clause historique */ }
}
return mainClause;
}
// action
var base = '⚡ ' + (ACTION_TYPE_LABELS_MAP[node.action_type] || node.action_type);
if (node.action_type === 'aller_a') {
base += ' → ' + (SCREENS_MAP[node.target_screen_id] || '?');
} else if (node.action_type === 'ouvrir_ligne') {
base += ' → ' + (SCREENS_MAP[node.target_screen_id] || '?') + ' (ligne cliquée)';
} else if (node.action_type === 'activer_onglet') {
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' (masque ses frères)';
} else if (node.action_type === 'modifier_element') {
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property);
if (node.element_property === 'visibilite') {
base += ' = ' + (ELEMENT_VISIBILITY_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_property === 'surbrillance') {
base += ' = ' + (ELEMENT_SURBRILLANCE_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_property === 'desactive') {
base += ' = ' + (ELEMENT_DESACTIVE_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_value && node.element_value.indexOf('||') !== -1) {
var vparts = node.element_value.split('||');
base += ' = ' + vparts[0] + ' ↔ ' + vparts[1] + ' (va-et-vient)';
} else {
base += ' = ' + node.element_value;
}
} else if (node.action_type === 'modifier_donnee') {
base += ' → ' + (node.target_field || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
if (node.data_value) base += ' ' + node.data_value;
} else if (node.action_type === 'modifier_variable') {
base += ' → ' + (node.target_variable || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
if (node.data_value) base += ' ' + node.data_value;
} else if (node.action_type === 'attendre') {
base += ' (' + (node.data_value || '0') + ' s)';
}
return base;
}
function renderFlow() {
var canvas = document.getElementById('flowCanvas');
var svg = document.getElementById('flowEdges');
canvas.innerHTML = '';
svg.innerHTML = '';
FLOW.nodes.forEach(function(node) {
var div = document.createElement('div');
div.className = 'flowNode flowNode-' + node.node_type;
div.style.left = node.x + 'px';
div.style.top = node.y + 'px';
div.dataset.nodeId = node.id;
var del = document.createElement('button');
del.className = 'flowNodeDelete';
del.type = 'button';
del.textContent = '✕';
del.onclick = function(ev) { ev.stopPropagation(); deleteFlowNode(node.id); };
div.appendChild(del);
var body = document.createElement('div');
body.className = 'flowNodeBody';
body.textContent = nodeLabel(node);
div.appendChild(body);
if (node.node_type !== 'trigger') {
var inPort = document.createElement('div');
inPort.className = 'flowPort flowPortIn';
inPort.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'in', null); };
div.appendChild(inPort);
}
if (node.node_type === 'condition') {
var outTrue = document.createElement('div');
outTrue.className = 'flowPort flowPortOut flowPortTrue';
outTrue.title = 'Vrai';
outTrue.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'out', 'vrai'); };
div.appendChild(outTrue);
var outFalse = document.createElement('div');
outFalse.className = 'flowPort flowPortOut flowPortFalse';
outFalse.title = 'Faux';
outFalse.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'out', 'faux'); };
div.appendChild(outFalse);
} else {
var outPort = document.createElement('div');
outPort.className = 'flowPort flowPortOut';
outPort.onclick = function(ev) { ev.stopPropagation(); onPortClick(node.id, 'out', 'out'); };
div.appendChild(outPort);
}
div.addEventListener('mousedown', function(e) { onFlowNodeMouseDown(e, node.id); });
canvas.appendChild(div);
});
// Redessine les fils après que les nœuds soient dans le DOM (pour lire leur position réelle).
requestAnimationFrame(drawFlowEdges);
}
function portPosition(nodeId, side, sub) {
var div = document.querySelector('.flowNode[data-node-id="' + nodeId + '"]');
if (!div) return null;
var wrap = document.querySelector('.flowCanvasWrap');
var wrapRect = wrap.getBoundingClientRect();
var selector = side === 'in' ? '.flowPortIn' : (sub === 'vrai' ? '.flowPortTrue' : (sub === 'faux' ? '.flowPortFalse' : '.flowPortOut'));
var port = div.querySelector(selector);
if (!port) return null;
var r = port.getBoundingClientRect();
return { x: r.left + r.width / 2 - wrapRect.left + wrap.scrollLeft, y: r.top + r.height / 2 - wrapRect.top + wrap.scrollTop };
}
function drawFlowEdges() {
var svg = document.getElementById('flowEdges');
svg.innerHTML = '';
FLOW.edges.forEach(function(edge) {
var from = portPosition(edge.from_node_id, 'out', edge.from_port);
var to = portPosition(edge.to_node_id, 'in', null);
if (!from || !to) return;
var line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
line.setAttribute('x1', from.x); line.setAttribute('y1', from.y);
line.setAttribute('x2', to.x); line.setAttribute('y2', to.y);
line.setAttribute('class', 'flowEdgeLine' + (edge.from_port === 'faux' ? ' flowEdgeFalse' : (edge.from_port === 'vrai' ? ' flowEdgeTrue' : '')));
line.style.pointerEvents = 'stroke';
line.addEventListener('click', function() { deleteFlowEdge(edge.id); });
svg.appendChild(line);
});
}
function onPortClick(nodeId, side, port) {
if (side === 'out') {
flowPendingPort = { nodeId: nodeId, port: port };
return;
}
// side === 'in'
if (!flowPendingPort) return;
if (flowPendingPort.nodeId === nodeId) { flowPendingPort = null; return; }
fetch(flowUrl('/screens/' + FLOW_SCREEN_ID + '/flow/edges/add'), {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId })
}).then(function(r){ return r.json(); }).then(function(edge){
FLOW.edges.push({ id: edge.id, screen_id: FLOW_SCREEN_ID, from_node_id: flowPendingPort.nodeId, from_port: flowPendingPort.port, to_node_id: nodeId });
flowPendingPort = null;
drawFlowEdges();
});
}
function deleteFlowEdge(edgeId) {
fetch(flowUrl('/flow/edges/' + edgeId + '/delete'), { method: 'POST' }).then(function(){
FLOW.edges = FLOW.edges.filter(function(e){ return e.id !== edgeId; });
drawFlowEdges();
});
}
function deleteFlowNode(nodeId) {
fetch(flowUrl('/flow/nodes/' + nodeId + '/delete'), { method: 'POST' }).then(function(){
FLOW.nodes = FLOW.nodes.filter(function(n){ return n.id !== nodeId; });
FLOW.edges = FLOW.edges.filter(function(e){ return e.from_node_id !== nodeId && e.to_node_id !== nodeId; });
renderFlow();
});
}
function onFlowNodeMouseDown(e, nodeId) {
if (e.target.classList.contains('flowPort') || e.target.classList.contains('flowNodeDelete')) return;
e.preventDefault();
var node = FLOW.nodes.find(function(n){ return n.id === nodeId; });
var div = document.querySelector('.flowNode[data-node-id="' + nodeId + '"]');
var startX = e.clientX, startY = e.clientY;
var startLeft = node.x, startTop = node.y;
function onMove(ev) {
var dx = ev.clientX - startX, dy = ev.clientY - startY;
node.x = Math.max(0, startLeft + dx);
node.y = Math.max(0, startTop + dy);
div.style.left = node.x + 'px';
div.style.top = node.y + 'px';
drawFlowEdges();
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
fetch(flowUrl('/flow/nodes/' + nodeId + '/move'), {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ x: node.x, y: node.y })
});
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
}
// ---------- Formulaire d'ajout de nœud ----------
var currentNodeFormType = null;
function openNodeForm(type) {
currentNodeFormType = type;
document.getElementById('nodeForm').style.display = 'block';
document.getElementById('nodeFormTitle').textContent =
type === 'trigger' ? 'Nouveau déclencheur' : (type === 'condition' ? 'Nouvelle condition' : 'Nouvelle action');
document.getElementById('nodeFormTrigger').style.display = type === 'trigger' ? 'block' : 'none';
document.getElementById('nodeFormCondition').style.display = type === 'condition' ? 'block' : 'none';
document.getElementById('nodeFormAction').style.display = type === 'action' ? 'block' : 'none';
if (type === 'trigger') toggleFlowTriggerFields();
if (type === 'condition') {
onFlowDefinitionChange('cond');
flowConditionExtraClauses = [];
renderFlowConditionClauses();
}
if (type === 'action') {
document.getElementById('nf-action-element-toggle').checked = false;
toggleFlowActionFields();
}
}
function toggleFlowTriggerFields() {
var isAffichage = document.getElementById('nf-trigger-event').value === 'affichage';
document.getElementById('nf-trigger-element-row').style.display = isAffichage ? 'none' : 'block';
document.getElementById('nf-trigger-repeater-hint').style.display = isAffichage ? 'none' : 'block';
document.getElementById('nf-trigger-affichage-hint').style.display = isAffichage ? 'block' : 'none';
}
function closeNodeForm() {
document.getElementById('nodeForm').style.display = 'none';
currentNodeFormType = null;
}
// CLICKED_ROW_ID (-1, voir screens/flow/constants.py) : "la ligne de
// Répéteur sur laquelle on vient de cliquer" — pour un déclencheur "Au
// clic" posé sur un Répéteur, chaque ligne exécute le MÊME graphe et la
// ligne cliquée n'est jamais connue à l'avance ; choisir une ligne fixe
// dans l'éditeur n'a donc pas de sens pour ce cas. Résolue au moment de
// l'exécution (window.lastClickedRowId, voir play.html), pas à la
// création du nœud — proposée en tête de TOUTE liste "Ligne concernée",
// avant les lignes réelles de l'objet.
var FLOW_CLICKED_ROW_ID = -1;
function _flowRowOptionsHtml(info) {
var options = '<option value="' + FLOW_CLICKED_ROW_ID + '">🖱️ Ligne cliquée (Répéteur)</option>';
options += info.rows.length
? info.rows.map(function(r){ return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
: '<option value="" disabled>Aucune donnée dans cet objet</option>';
return options;
}
function onFlowDefinitionChange(prefix) {
var defSel = document.getElementById(prefix === 'cond' ? 'nf-cond-definition' : 'nf-action-data-definition');
if (!defSel) return;
var info = DEFINITIONS_DATA[defSel.value] || { rows: [], fields: [] };
var rowSel = document.getElementById(prefix === 'cond' ? 'nf-cond-row' : 'nf-action-data-row');
var fieldSel = document.getElementById(prefix === 'cond' ? 'nf-cond-field' : 'nf-action-data-field');
rowSel.innerHTML = _flowRowOptionsHtml(info);
fieldSel.innerHTML = info.fields.length
? info.fields.map(function(f){ return '<option value="' + f.name + '" data-type="' + f.type + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
: '<option value="">Aucun champ simple</option>';
}
function onFlowFieldChange() {
// Pas d'action nécessaire ici pour l'instant : le type du champ est relu
// directement sur l'option sélectionnée au moment de la création du nœud.
}
// ---------- 2.4 — clauses supplémentaires (ET/OU) d'un nœud Condition ----------
// Chaque clause supplémentaire réutilise EXACTEMENT le même modèle que la
// clause principale (nf-cond-definition/row/field/operator/value) mais dans
// une ligne indépendante, numérotée par index dans flowConditionExtraClauses.
var flowConditionExtraClauses = [];
function addFlowConditionClause() {
var firstDefSel = document.getElementById('nf-cond-definition');
var defaultDefId = firstDefSel && firstDefSel.value ? firstDefSel.value : '';
flowConditionExtraClauses.push({ definition_id: defaultDefId, row_id: '', field: '', field_type: '', operator: 'egal', value: '' });
renderFlowConditionClauses();
}
function removeFlowConditionClause(idx) {
flowConditionExtraClauses.splice(idx, 1);
renderFlowConditionClauses();
}
function renderFlowConditionClauses() {
var wrap = document.getElementById('nf-cond-extra-clauses');
var combinatorRow = document.getElementById('nf-cond-combinator-row');
if (!wrap || !combinatorRow) return;
combinatorRow.style.display = flowConditionExtraClauses.length ? 'block' : 'none';
var defOptionsHtml = document.getElementById('nf-cond-definition') ? document.getElementById('nf-cond-definition').innerHTML : '';
wrap.innerHTML = flowConditionExtraClauses.map(function(clause, idx) {
return '' +
'<div class="flowConditionClauseRow" style="border-top:1px dashed #ccc; margin-top:8px; padding-top:8px;">' +
'<label>Objet<select id="nf-cond-extra-definition-' + idx + '" onchange="onFlowClauseDefinitionChange(' + idx + ')">' + defOptionsHtml + '</select></label>' +
'<label>Ligne concernée<select id="nf-cond-extra-row-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].row_id = this.value"></select></label>' +
'<label>Champ<select id="nf-cond-extra-field-' + idx + '" onchange="onFlowClauseFieldChange(' + idx + ')"></select></label>' +
'<label>Condition<select id="nf-cond-extra-operator-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].operator = this.value">' +
CONDITION_OPERATORS_HTML +
'</select></label>' +
'<label>Valeur de comparaison<input type="text" id="nf-cond-extra-value-' + idx + '" placeholder="ex. 1, vrai, 10..." value="' + (clause.value || '').replace(/"/g, '&quot;') + '" onchange="flowConditionExtraClauses[' + idx + '].value = this.value"></label>' +
'<button type="button" class="smallBtn button is-small" onclick="removeFlowConditionClause(' + idx + ')">Retirer cette condition</button>' +
'</div>';
}).join('');
flowConditionExtraClauses.forEach(function(clause, idx) {
var defSel = document.getElementById('nf-cond-extra-definition-' + idx);
if (defSel && clause.definition_id) defSel.value = clause.definition_id;
onFlowClauseDefinitionChange(idx);
var opSel = document.getElementById('nf-cond-extra-operator-' + idx);
if (opSel) opSel.value = clause.operator || 'egal';
});
}
function onFlowClauseDefinitionChange(idx) {
var defSel = document.getElementById('nf-cond-extra-definition-' + idx);
var rowSel = document.getElementById('nf-cond-extra-row-' + idx);
var fieldSel = document.getElementById('nf-cond-extra-field-' + idx);
if (!defSel || !rowSel || !fieldSel) return;
flowConditionExtraClauses[idx].definition_id = defSel.value;
var info = DEFINITIONS_DATA[defSel.value] || { rows: [], fields: [] };
rowSel.innerHTML = _flowRowOptionsHtml(info);
fieldSel.innerHTML = info.fields.length
? info.fields.map(function(f){ return '<option value="' + f.name + '" data-type="' + f.type + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
: '<option value="">Aucun champ simple</option>';
flowConditionExtraClauses[idx].row_id = rowSel.value;
onFlowClauseFieldChange(idx);
}
function onFlowClauseFieldChange(idx) {
var fieldSel = document.getElementById('nf-cond-extra-field-' + idx);
if (!fieldSel) return;
var opt = fieldSel.options[fieldSel.selectedIndex];
flowConditionExtraClauses[idx].field = fieldSel.value;
flowConditionExtraClauses[idx].field_type = opt ? opt.dataset.type : '';
}
function toggleFlowActionFields() {
var typeSel = document.getElementById('nf-action-type');
var screenSel = document.getElementById('nf-action-target-screen');
var elementFields = document.getElementById('nf-action-element-fields');
var dataFields = document.getElementById('nf-action-data-fields');
var variableFields = document.getElementById('nf-action-variable-fields');
var waitFields = document.getElementById('nf-action-wait-fields');
var isOnglet = typeSel.value === 'activer_onglet';
screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none';
waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none';
document.getElementById('nf-action-onglet-hint').style.display = isOnglet ? 'block' : 'none';
document.getElementById('nf-action-element-property-wrap').style.display = isOnglet ? 'none' : 'block';
if (typeSel.value === 'modifier_element') toggleFlowElementPropertyValue();
if (typeSel.value === 'modifier_donnee') onFlowDefinitionChange('action');
}
function toggleFlowElementPropertyValue() {
var propSel = document.getElementById('nf-action-element-property');
var visSel = document.getElementById('nf-action-element-value-visibility');
var surbrillanceSel = document.getElementById('nf-action-element-value-surbrillance');
var desactiveSel = document.getElementById('nf-action-element-value-desactive');
var colorInp = document.getElementById('nf-action-element-value-color');
var numInp = document.getElementById('nf-action-element-value-number');
var textInp = document.getElementById('nf-action-element-value-text');
var toggleRow = document.getElementById('nf-action-element-toggle-row');
[visSel, surbrillanceSel, desactiveSel, colorInp, numInp, textInp].forEach(function(el){ el.style.display = 'none'; });
if (propSel.value === 'visibilite') {
visSel.style.display = 'block';
toggleRow.style.display = 'none';
document.getElementById('nf-action-element-toggle').checked = false;
} else if (propSel.value === 'surbrillance') {
surbrillanceSel.style.display = 'block';
toggleRow.style.display = 'none';
document.getElementById('nf-action-element-toggle').checked = false;
} else if (propSel.value === 'desactive') {
desactiveSel.style.display = 'block';
toggleRow.style.display = 'none';
document.getElementById('nf-action-element-toggle').checked = false;
} else if (propSel.value === 'contenu') {
textInp.style.display = 'block';
toggleRow.style.display = 'none';
document.getElementById('nf-action-element-toggle').checked = false;
} else if (propSel.value === 'bg_color' || propSel.value === 'border_color') {
colorInp.style.display = 'block';
toggleRow.style.display = 'flex';
} else {
numInp.style.display = 'block';
toggleRow.style.display = 'flex';
}
toggleFlowElementToggleMode();
}
function toggleFlowElementToggleMode() {
var propSel = document.getElementById('nf-action-element-property');
var checked = document.getElementById('nf-action-element-toggle').checked;
var wrap = document.getElementById('nf-action-element-value2-wrap');
var colorInp2 = document.getElementById('nf-action-element-value2-color');
var numInp2 = document.getElementById('nf-action-element-value2-number');
var isColor = propSel.value === 'bg_color' || propSel.value === 'border_color';
wrap.style.display = checked ? 'block' : 'none';
colorInp2.style.display = (checked && isColor) ? 'block' : 'none';
numInp2.style.display = (checked && !isColor) ? 'block' : 'none';
}
function submitNodeForm() {
var payload = { node_type: currentNodeFormType, x: 20, y: 20 + FLOW.nodes.length * 10 };
if (currentNodeFormType === 'trigger') {
payload.trigger_event = document.getElementById('nf-trigger-event').value;
if (payload.trigger_event !== 'affichage') {
payload.trigger_element_id = parseInt(document.getElementById('nf-trigger-element').value, 10);
}
} else if (currentNodeFormType === 'condition') {
var fieldSel = document.getElementById('nf-cond-field');
var fieldOpt = fieldSel.options[fieldSel.selectedIndex];
payload.cond_definition_id = parseInt(document.getElementById('nf-cond-definition').value, 10);
payload.cond_row_id = parseInt(document.getElementById('nf-cond-row').value, 10) || null;
payload.cond_field = fieldSel.value;
payload.cond_field_type = fieldOpt ? fieldOpt.dataset.type : '';
payload.cond_operator = document.getElementById('nf-cond-operator').value;
payload.cond_value = document.getElementById('nf-cond-value').value;
if (flowConditionExtraClauses.length) {
// 2.4 — clauses combinées : la clause "historique" ci-dessus devient
// la première clause de la liste, suivie des clauses supplémentaires.
var allClauses = [{
definition_id: payload.cond_definition_id, row_id: payload.cond_row_id,
field: payload.cond_field, field_type: payload.cond_field_type,
operator: payload.cond_operator, value: payload.cond_value
}].concat(flowConditionExtraClauses.map(function(c) {
return {
definition_id: parseInt(c.definition_id, 10), row_id: parseInt(c.row_id, 10) || null,
field: c.field, field_type: c.field_type, operator: c.operator, value: c.value
};
}));
payload.cond_clauses = JSON.stringify(allClauses);
payload.cond_combinator = document.getElementById('nf-cond-combinator').value;
}
} else if (currentNodeFormType === 'action') {
var actionType = document.getElementById('nf-action-type').value;
payload.action_type = actionType;
if (actionType === 'aller_a' || actionType === 'ouvrir_ligne') {
payload.target_screen_id = parseInt(document.getElementById('nf-action-target-screen').value, 10);
} else if (actionType === 'activer_onglet') {
payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10);
} else if (actionType === 'modifier_element') {
payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10);
var prop = document.getElementById('nf-action-element-property').value;
payload.element_property = prop;
var isColorProp = prop === 'bg_color' || prop === 'border_color';
var toggleOn = prop !== 'visibilite' && prop !== 'surbrillance' && prop !== 'desactive' && document.getElementById('nf-action-element-toggle').checked;
if (prop === 'visibilite') {
payload.element_value = document.getElementById('nf-action-element-value-visibility').value;
} else if (prop === 'surbrillance') {
payload.element_value = document.getElementById('nf-action-element-value-surbrillance').value;
} else if (prop === 'desactive') {
payload.element_value = document.getElementById('nf-action-element-value-desactive').value;
} else if (prop === 'contenu') {
payload.element_value = document.getElementById('nf-action-element-value-text').value;
} else {
var v1 = isColorProp
? document.getElementById('nf-action-element-value-color').value
: document.getElementById('nf-action-element-value-number').value;
if (toggleOn) {
var v2 = isColorProp
? document.getElementById('nf-action-element-value2-color').value
: document.getElementById('nf-action-element-value2-number').value;
payload.element_value = v1 + '||' + v2;
} else {
payload.element_value = v1;
}
}
} else if (actionType === 'modifier_donnee') {
payload.target_definition_id = parseInt(document.getElementById('nf-action-data-definition').value, 10);
payload.target_row_id = parseInt(document.getElementById('nf-action-data-row').value, 10) || null;
payload.target_field = document.getElementById('nf-action-data-field').value;
payload.data_operation = document.getElementById('nf-action-data-operation').value;
payload.data_value = document.getElementById('nf-action-data-value').value;
} else if (actionType === 'modifier_variable') {
payload.target_variable = document.getElementById('nf-action-variable-name').value;
payload.data_operation = document.getElementById('nf-action-variable-operation').value;
payload.data_value = document.getElementById('nf-action-variable-value').value;
} else if (actionType === 'attendre') {
payload.data_value = document.getElementById('nf-action-wait-seconds').value;
}
}
fetch(flowUrl('/screens/' + FLOW_SCREEN_ID + '/flow/nodes/add'), {
method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(payload)
}).then(function(r){ return r.json(); }).then(function(node){
FLOW.nodes.push(node);
closeNodeForm();
renderFlow();
});
}
// Panneau de logique de scène absent sur un écran-modèle (élément de jeu) —
// voir le commentaire Jinja au-dessus de .builderTabs.
var HAS_FLOW_PANEL = !!document.getElementById('flowTabPanel');
if (HAS_FLOW_PANEL) { renderFlow(); }
// ---------- 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
// 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. ----------
var ANIM_SCREEN_ID = {{ screen.id }};
var ANIM_CLIPS = {{ animation_clips_json|safe }};
var ANIMATE_CSS_LABELS = {{ animate_css_labels_json|safe }};
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(); }
</script>
{% endblock %}