Le sélecteur de valeur de comparaison (tout contrôle "..._valeur" : filtre
du Répéteur, Donnée liée, condition de visibilité) proposait déjà une
valeur fixe ou le champ d'un AUTRE objet - manquait la possibilité de
comparer à une variable globale (voir db/global_vars/), qui change elle
aussi en cours de partie mais n'est rattachée à aucun objet précis.
Nouvelle syntaxe interne "{{$nom_variable}}" (le "$" la distingue sans
ambiguïté de "{{Objet.champ}}", qui attend toujours un point) :
_resolve_filter_value (filter_repeater_rows.py) va lire sa valeur actuelle
via db.get_global_variable, comme "{{Objet.champ}}" le fait déjà pour un
champ d'objet. Le panneau de propriétés gagne un troisième mode
"Variable globale" à côté de "Valeur fixe"/"Donnée d'un autre objet",
avec la liste déroulante des variables existantes.
Corrige au passage un bug latent découvert en testant bout en bout : un
Répéteur SANS modèle de ligne (texte brut avec {{champ}}) plantait en
mode jouable avec TypeError - render_repeater.py substitue lui aussi
directement les {{champ}} du ctx dans ce cas (repli), et ce ctx porte
aussi _forge_play_mode (un booléen, voir render_element_html.py) depuis
l'ajout de la condition de visibilité - déjà corrigé pour le chemin
générique (apply_ctx.py) mais pas pour ce chemin séparé.
Le sélecteur de champ pour insérer {{champ}} dans "Contenu" (demandé dans
le même message) existe déjà depuis un tour précédent (voir
insertFieldAtCursor()) - vérifié toujours fonctionnel.
Ajoute tests/test_filter_value_global_variable.py.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2764 lines
135 KiB
HTML
2764 lines
135 KiB
HTML
{% 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 %}
|
||
<!-- Pas de fil d'Ariane sur cette page : le canevas doit être monté le
|
||
plus haut possible (voir .content-builder), et le nom de l'écran est
|
||
de toute façon déjà éditable juste au-dessus (panneau "Éléments"). -->
|
||
{% block breadcrumb_wrap %}{% 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('element_types', slug=game.slug) }}">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). Absents sur un écran-modèle (élément
|
||
de jeu) : ses déclencheurs référenceraient des éléments dont l'id
|
||
change à chaque copie (voir instantiate_template_tree), donc une
|
||
logique/animation posée ici ne fonctionnerait plus une fois
|
||
l'élément réutilisé — seul l'onglet "Écran" a alors un sens. -->
|
||
<div class="builderCanvasArea">
|
||
{% set current_aspect = screen.preview_aspect or '9/16' %}
|
||
{% if not screen.is_template %}
|
||
<div class="builderTabs" id="builderTabs">
|
||
<button type="button" class="builderTabBtn active" data-tab="screen" onclick="switchBuilderTab('screen')">🖥️ Écran</button>
|
||
<button type="button" class="builderTabBtn" data-tab="flow" onclick="switchBuilderTab('flow')">🔀 Logique de la scène</button>
|
||
<button type="button" class="builderTabBtn" data-tab="anim" onclick="switchBuilderTab('anim')">🎬 Timeline d'animation</button>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="builderTabPanel active" data-tab-panel="screen">
|
||
<div class="canvasFrame">
|
||
<div class="canvas" id="canvas" style="aspect-ratio:{{ current_aspect }};">
|
||
{% for el in top_level_elements %}
|
||
<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>
|
||
|
||
{% if not screen.is_template %}
|
||
<div class="builderTabPanel" id="flowTabPanel" data-tab-panel="flow">
|
||
<div class="flowToolbar">
|
||
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('trigger')">➕ Déclencheur</button>
|
||
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('condition')">➕ Condition</button>
|
||
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('action')">➕ Action</button>
|
||
<span class="hint">Clique un point de sortie d'un nœud, puis le point d'entrée d'un autre nœud, pour les relier. Reclique une connexion pour la supprimer.</span>
|
||
</div>
|
||
|
||
<div id="nodeForm" class="nodeForm" style="display:none;">
|
||
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
||
<strong id="nodeFormTitle">Nouveau nœud</strong>
|
||
<button type="button" class="smallBtn button is-small" onclick="closeNodeForm()">✕</button>
|
||
</div>
|
||
|
||
<div id="nodeFormTrigger" style="display:none;">
|
||
<label>Événement<select id="nf-trigger-event" onchange="toggleFlowTriggerFields()">
|
||
{% for key, label in trigger_events %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</select></label>
|
||
<label id="nf-trigger-element-row">Élément<select id="nf-trigger-element">
|
||
{% for e2 in elements %}
|
||
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
|
||
{% endfor %}
|
||
</select></label>
|
||
<p class="hint" id="nf-trigger-repeater-hint" style="display:none;"></p>
|
||
<p class="hint" id="nf-trigger-affichage-hint" style="display:none;"></p>
|
||
</div>
|
||
|
||
<div id="nodeFormCondition" style="display:none;">
|
||
{% if not definitions %}
|
||
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('object_new', slug=game.slug) }}">crées-en un</a>.</p>
|
||
{% else %}
|
||
<label>Objet<select id="nf-cond-definition" onchange="onFlowDefinitionChange('cond')">
|
||
{% for d in definitions %}
|
||
<option value="{{ d.id }}">{{ d.name }}</option>
|
||
{% endfor %}
|
||
</select></label>
|
||
<label>Ligne concernée<select id="nf-cond-row"></select></label>
|
||
<label>Champ<select id="nf-cond-field" onchange="onFlowFieldChange()"></select></label>
|
||
<label>Condition<select id="nf-cond-operator">
|
||
{% for key, label in condition_operators %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</select></label>
|
||
<label>Valeur de comparaison<input type="text" id="nf-cond-value" placeholder="ex. 1, vrai, 10..."></label>
|
||
|
||
<div id="nf-cond-extra-wrap">
|
||
<label id="nf-cond-combinator-row" style="display:none;">Combiner les conditions avec
|
||
<select id="nf-cond-combinator">
|
||
<option value="et">ET (toutes les conditions doivent être vraies)</option>
|
||
<option value="ou">OU (au moins une condition doit être vraie)</option>
|
||
</select>
|
||
</label>
|
||
<div id="nf-cond-extra-clauses"></div>
|
||
<button type="button" class="smallBtn button is-small" onclick="addFlowConditionClause()">+ Ajouter une condition</button>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div id="nodeFormAction" style="display:none;">
|
||
<label>Type d'action<select id="nf-action-type" onchange="toggleFlowActionFields()">
|
||
{% for key, label in action_type_labels.items() %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</select></label>
|
||
|
||
<select id="nf-action-target-screen" style="display:none;">
|
||
{% for s in all_screens %}
|
||
<option value="{{ s.id }}">{{ s.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
|
||
<div id="nf-action-element-fields" style="display:none;">
|
||
<select id="nf-action-target-element">
|
||
{% for e2 in elements %}
|
||
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
|
||
{% endfor %}
|
||
</select>
|
||
<p class="hint" id="nf-action-onglet-hint" style="display:none;"></p>
|
||
<div id="nf-action-element-property-wrap">
|
||
<select id="nf-action-element-property" onchange="toggleFlowElementPropertyValue()" style="margin-top:6px;">
|
||
{% for key, label in element_action_properties %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="nf-action-element-value-visibility" style="margin-top:6px;">
|
||
{% for key, label in element_visibility_values %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="nf-action-element-value-surbrillance" style="display:none; margin-top:6px;">
|
||
{% for key, label in element_surbrillance_values %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="nf-action-element-value-desactive" style="display:none; margin-top:6px;">
|
||
{% for key, label in element_desactive_values %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<input type="color" id="nf-action-element-value-color" style="display:none; margin-top:6px;" value="#5b8cff">
|
||
<input type="number" id="nf-action-element-value-number" style="display:none; margin-top:6px;" value="50" placeholder="Nouvelle valeur en %">
|
||
<input type="text" id="nf-action-element-value-text" style="display:none; margin-top:6px;" placeholder="Nouveau texte affiché">
|
||
|
||
<label id="nf-action-element-toggle-row" style="display:flex; align-items:center; gap:6px; margin-top:10px; font-size:12px; color:var(--text-dim);">
|
||
<input type="checkbox" id="nf-action-element-toggle" onchange="toggleFlowElementToggleMode()" style="width:auto; margin:0;">
|
||
Va-et-vient : au 1er clic, passer à cette valeur ; au 2e clic, revenir à la précédente ; etc.
|
||
</label>
|
||
<div id="nf-action-element-value2-wrap" style="display:none; margin-top:6px;">
|
||
<input type="color" id="nf-action-element-value2-color" style="display:none;" value="#ff0000">
|
||
<input type="number" id="nf-action-element-value2-number" style="display:none;" placeholder="Valeur au 2e état en %">
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="nf-action-wait-fields" style="display:none;">
|
||
<label>Délai en secondes avant de continuer
|
||
<input type="number" id="nf-action-wait-seconds" min="0" step="0.5" value="3" placeholder="ex. 3">
|
||
</label>
|
||
</div>
|
||
|
||
<div id="nf-action-data-fields" style="display:none;">
|
||
{% if not definitions %}
|
||
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('object_new', slug=game.slug) }}">crées-en un</a>.</p>
|
||
{% else %}
|
||
<select id="nf-action-data-definition" onchange="onFlowDefinitionChange('action')">
|
||
{% for d in definitions %}
|
||
<option value="{{ d.id }}">{{ d.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="nf-action-data-row" style="margin-top:6px;"></select>
|
||
<select id="nf-action-data-field" style="margin-top:6px;"></select>
|
||
<select id="nf-action-data-operation" style="margin-top:6px;">
|
||
{% for key, label in data_operations %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<input type="text" id="nf-action-data-value" style="margin-top:6px;" placeholder="Valeur / montant">
|
||
{% endif %}
|
||
</div>
|
||
|
||
<div id="nf-action-variable-fields" style="display:none;">
|
||
{% if not global_variables %}
|
||
<p class="hint">Aucune variable globale pour l'instant — crées-en une depuis "Condition de visibilité" dans les propriétés d'un élément.</p>
|
||
{% else %}
|
||
<select id="nf-action-variable-name">
|
||
{% for v in global_variables %}
|
||
<option value="{{ v.name }}">{{ v.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<select id="nf-action-variable-operation" style="margin-top:6px;">
|
||
{% for key, label in data_operations %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<input type="text" id="nf-action-variable-value" style="margin-top:6px;" placeholder="Valeur / montant">
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="actionsRow"><button type="button" class="smallBtn button is-small is-primary" onclick="submitNodeForm()">+ Ajouter ce nœud</button></div>
|
||
</div>
|
||
|
||
<div class="flowCanvasWrap">
|
||
<svg id="flowEdges" class="flowEdgesSvg"></svg>
|
||
<div id="flowCanvas" class="flowCanvas"></div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="builderTabPanel" id="animTabPanel" data-tab-panel="anim">
|
||
<div class="flowToolbar">
|
||
<label style="display:flex; align-items:center; gap:6px;">
|
||
Piste
|
||
<select id="anim-new-track-element">
|
||
{% for e2 in elements %}
|
||
<option value="{{ e2.id }}">{{ e2|elabel }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</label>
|
||
<button type="button" class="smallBtn button is-small" onclick="addAnimTrack()">➕ Ajouter un clip</button>
|
||
</div>
|
||
|
||
<div id="animClipForm" class="nodeForm" style="display:none;">
|
||
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
|
||
<strong id="animClipFormTitle">Clip d'animation</strong>
|
||
<button type="button" class="smallBtn button is-small" onclick="closeAnimClipForm()">✕</button>
|
||
</div>
|
||
|
||
<label>Type
|
||
<select id="anim-clip-kind" onchange="toggleAnimClipKind()">
|
||
<option value="animate_css">Animation Animate.css</option>
|
||
<option value="custom">Animation personnalisée — images-clés</option>
|
||
</select>
|
||
</label>
|
||
|
||
<div id="anim-clip-animate-css-fields">
|
||
<label>Animation
|
||
<select id="anim-clip-animate-name">
|
||
{% for category, items in animate_css_catalog %}
|
||
<optgroup label="{{ category }}">
|
||
{% for key, label in items %}
|
||
<option value="{{ key }}">{{ label }}</option>
|
||
{% endfor %}
|
||
</optgroup>
|
||
{% endfor %}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
|
||
<div id="anim-clip-custom-fields" style="display:none;">
|
||
<div id="anim-clip-keyframes"></div>
|
||
<button type="button" class="smallBtn button is-small" onclick="addAnimKeyframeRow()">+ Ajouter une image-clé</button>
|
||
</div>
|
||
|
||
<div class="fieldRow">
|
||
<label style="flex:1;">Départ en secondes
|
||
<input type="number" id="anim-clip-start" min="0" step="0.1" value="0">
|
||
</label>
|
||
<label style="flex:1;">Durée en secondes
|
||
<input type="number" id="anim-clip-duration" min="0.1" step="0.1" value="1">
|
||
</label>
|
||
</div>
|
||
<div class="fieldRow">
|
||
<label style="flex:1;">Délai en secondes
|
||
<input type="number" id="anim-clip-delay" min="0" step="0.1" value="0">
|
||
</label>
|
||
<label style="flex:1;">Vitesse
|
||
<select id="anim-clip-easing">
|
||
<option value="ease">Progressive</option>
|
||
<option value="linear">Constante</option>
|
||
<option value="ease-in">Accélère</option>
|
||
<option value="ease-out">Ralentit</option>
|
||
<option value="ease-in-out">Accélère puis ralentit</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<label>Répétitions
|
||
<select id="anim-clip-iteration">
|
||
<option value="1">Une fois</option>
|
||
<option value="2">2 fois</option>
|
||
<option value="3">3 fois</option>
|
||
<option value="infinite">En boucle</option>
|
||
</select>
|
||
</label>
|
||
|
||
<div class="actionsRow">
|
||
<button type="button" class="smallBtn button is-small is-primary" onclick="saveAnimClipForm()">Enregistrer</button>
|
||
<button type="button" class="smallBtn button is-small" onclick="previewAnimClip()">▶ Aperçu</button>
|
||
<button type="button" class="smallBtn button is-small is-danger" onclick="deleteAnimClipForm()">🗑️ Supprimer</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="animTimelineWrap">
|
||
<div id="animRuler" class="animRuler"></div>
|
||
<div id="animTracks" class="animTracks"></div>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
|
||
<!-- Panneau droit : propriétés de l'élément sélectionné — flottant,
|
||
déplaçable à la souris par son en-tête, fermable pour agrandir le
|
||
canevas (voir floatPanel*() plus bas). -->
|
||
<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 & 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 == 'visibilite_cond_variable' %}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>
|
||
{% if not global_variables %}
|
||
<p class="help">Aucune variable globale pour l'instant — <a href="{{ url_for('game_variables', slug=game.slug) }}" 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 "{{champ}}" à 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_variables', slug=game.slug) }}" 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, '"') + '">';
|
||
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").
|
||
var _VAR_REF_RE = new RegExp('^\\{\\{\\s*\\$([^.{}]+)\\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 input = wrap.querySelector('.filterValueFixe input');
|
||
input.value = varSel.value ? (_FILTER_REF_OPEN + '$' + varSel.value + _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 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();
|
||
} 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;
|
||
}
|
||
|
||
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 = 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 + '" 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, '"') + '" 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 = 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 + '" 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 %}
|