Files
Forge-Engine/templates/screen_edit.html
T
william 9cc6704e08 Replace the icon widget's plain text field with a visual searchable picker
The properties panel's "Icône" setting was a raw text input expecting a Font Awesome class name typed by hand — not something a non-developer using Forge Engine should have to know. It's now a button showing the current icon that opens a modal with the same searchable icon grid as the add-gallery; picking one replaces the selected element's icon and autosaves immediately, no typing required. The class name is still stored the same way (a hidden input feeding the existing generic attr:class control), so no backend changes were needed.
2026-08-24 19:26:35 +02:00

2454 lines
115 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}{{ screen.name }} — {{ game.name }}{% endblock %}
{% block content_class %}content-builder{% endblock %}
{% block body_class %}builderBody{% endblock %}
{% block extra_head %}
<link rel="stylesheet" href="{{ google_fonts_link }}">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
{% endblock %}
{% block breadcrumb %}
<li><a href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a></li>
<li><a href="{{ url_for('screens_list', slug=game.slug) }}">Écrans</a></li>
<li class="is-active"><a>{{ screen.name }}</a></li>
{% endblock %}
{% block content %}
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
<form method="post" action="{{ url_for('screen_rename', slug=game.slug, screen_id=screen.id) }}" class="field has-addons" style="align-items:center; margin:0;">
<div class="control"><input class="input" type="text" name="name" value="{{ screen.name }}" required style="font-size:16px; font-weight:700; width:auto;"></div>
<div class="control"><button type="submit" class="button is-small">Renommer</button></div>
</form>
{% if not screen.is_template %}
<a class="button is-small is-primary" href="{{ url_for('game_play', slug=game.slug) }}" target="_blank">▶️ Jouer depuis le début</a>
{% endif %}
</div>
{% if screen.is_template %}
<p class="hint" style="background:#1c2130; border:1px solid #2b3140; border-radius:8px; padding:10px 14px; margin:10px 0;">
🧩 Contenu d'un élément de jeu réutilisable.
</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 '' }}">
{% 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">
<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() %}
<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>
<!-- 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, en très grand -->
<div class="builderCanvasArea">
{% set current_aspect = screen.preview_aspect or '9/16' %}
<div class="canvasToolbar">
<span class="hint">Aperçu :</span>
<button type="button" class="smallBtn aspectBtn {{ 'active' if current_aspect == '9/16' else '' }}" data-aspect="9/16">📱 Portrait</button>
<button type="button" class="smallBtn aspectBtn {{ 'active' if current_aspect == '16/9' else '' }}" data-aspect="16/9">🖥️ Paysage</button>
<button type="button" class="smallBtn aspectBtn {{ 'active' if current_aspect == '1/1' else '' }}" data-aspect="1/1">◻️ Carré</button>
</div>
<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>
<!-- 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">
<details class="propGroup">
<summary>🔤 Icônes (Font Awesome)</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="fa-solid fa-{{ slug }}" data-icon-search="{{ slug }}"
title="fa-{{ slug }}" onclick="addIconElement('fa-solid fa-{{ slug }}')">
<i class="fa-solid fa-{{ slug }}"></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 : n'importe quelle autre classe Font Awesome 5 reste utilisable en la tapant directement dans le réglage "Icône".</p>
</div>
</details>
<!-- 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="fa-{{ slug }}" onclick="chooseIcon('fa-solid fa-{{ slug }}')">
<i class="fa-solid fa-{{ slug }}"></i>
</button>
{% endfor %}
</div>
</section>
</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) }}"
onsubmit="return confirm('Supprimer cet élément ?');">
<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 in container_widgets %}
<details class="propGroup containerContentGroup">
<summary>📥 Ajouter DANS ce conteneur</summary>
<div class="propGroupBody">
<div class="widgetGrid">
{% for wkey, w in widgets.items() %}
<form method="post" action="{{ url_for('element_add_child', slug=game.slug, parent_id=el.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>
{% endif %}
{% 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) }}" onsubmit="return confirm('Supprimer cet onglet et tout son contenu ?');">
<button type="submit" class="button is-small is-danger">🗑️</button>
</form>
</div>
</div>
</div>
{% endfor %}
{% else %}
<p class="help">Aucun onglet pour l'instant.</p>
{% endif %}
<form method="post" action="{{ url_for('element_tab_add', slug=game.slug, element_id=el.id) }}" style="margin-top:8px;">
<button type="submit" class="button is-small is-primary is-fullwidth">➕ Ajouter un onglet</button>
</form>
</div>
</details>
{% endif %}
{% set size_ctrl_keys = ['child-width', 'child-height'] %}
{% set size_ctrls = {} %}
{% for c in selected_controls if c.key in size_ctrl_keys %}{% set _ = size_ctrls.update({c.key: c}) %}{% endfor %}
<form method="post" action="{{ url_for('element_save', slug=game.slug, element_id=el.id) }}" id="propsForm" onsubmit="event.preventDefault(); return false;">
<details class="propGroup">
<summary>Position &amp; taille</summary>
<div class="propGroupBody">
<div class="columns is-multiline posGrid">
<div class="column is-half">
<div class="field"><label class="label is-small">X (%)</label>
<div class="control"><input class="input is-small" type="number" id="pos-x" min="0" max="100" value="{{ el.x }}" {{ 'disabled' if el.parent_id else '' }}></div>
</div>
</div>
<div class="column is-half">
<div class="field"><label class="label is-small">Y (%)</label>
<div class="control"><input class="input is-small" type="number" id="pos-y" min="0" max="100" value="{{ el.y }}" {{ 'disabled' if el.parent_id else '' }}></div>
</div>
</div>
{% if el.parent_id %}
<div class="column is-half">
<div class="field"><label class="label is-small">Largeur</label>
<div class="field has-addons sizeField">
<div class="control is-expanded"><input class="input is-small" type="number" name="ctrl_child-width_value" min="0" value="{{ size_ctrls['child-width'].value.value or '' }}" placeholder="auto"></div>
<div class="control">
<div class="select is-small">
<select name="ctrl_child-width_unit">
<option value="%" {{ 'selected' if size_ctrls['child-width'].value.unit == '%' else '' }}>%</option>
<option value="px" {{ 'selected' if size_ctrls['child-width'].value.unit == 'px' else '' }}>px</option>
</select>
</div>
</div>
</div>
</div>
</div>
<div class="column is-half">
<div class="field"><label class="label is-small">Hauteur</label>
<div class="field has-addons sizeField">
<div class="control is-expanded"><input class="input is-small" type="number" name="ctrl_child-height_value" min="0" value="{{ size_ctrls['child-height'].value.value or '' }}" placeholder="auto"></div>
<div class="control">
<div class="select is-small">
<select name="ctrl_child-height_unit">
<option value="%" {{ 'selected' if size_ctrls['child-height'].value.unit == '%' else '' }}>%</option>
<option value="px" {{ 'selected' if size_ctrls['child-height'].value.unit == 'px' else '' }}>px</option>
</select>
</div>
</div>
</div>
</div>
</div>
{% else %}
<div class="column is-half">
<div class="field"><label class="label is-small">Largeur (%)</label>
<div class="control"><input class="input is-small" type="number" id="pos-w" min="1" max="100" value="{{ el.width }}"></div>
</div>
</div>
<div class="column is-half">
<div class="field"><label class="label is-small">Hauteur (%)</label>
<div class="control"><input class="input is-small" type="number" id="pos-h" min="1" max="100" value="{{ el.height }}"></div>
</div>
</div>
{% endif %}
</div>
{% if el.parent_id %}
<p class="help">X/Y ne s'appliquent qu'à un élément posé directement sur l'écran — celui-ci suit la disposition de son conteneur. Largeur/Hauteur : laisser vide garde la taille automatique ; "%" (relatif au conteneur) convient à la plupart des cas, "px" pour une taille précise.</p>
{% endif %}
</div>
</details>
{% set ns = namespace(last_group=None, started=False) %}
{% for c in selected_controls if c.key not in size_ctrl_keys %}
{% if c.group != ns.last_group %}
{% if ns.started %}
</div>
</details>
{% endif %}
<details class="propGroup">
<summary>{{ c.group or 'Propriétés' }}</summary>
<div class="propGroupBody">
{% set ns.last_group = c.group %}
{% set ns.started = True %}
{% endif %}
<div class="field controlRow">
{% 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="{{ c.value or c.default }}"></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.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 %}
{% elif c.type == 'textarea' %}
<div class="control"><textarea class="textarea" name="ctrl_{{ c.key }}" rows="4" placeholder="{{ c.placeholder or '' }}">{{ c.value }}</textarea></div>
{% 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 == '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 }}">
{% for opt in c.options %}
<option value="{{ opt[0] }}" {{ 'selected' if c.value == opt[0] else '' }}>{{ opt[1] }}</option>
{% endfor %}
</select>
</div>
</div>
{% 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>
{% if not screen.is_template %}
<!-- Poignée de redimensionnement : visible seulement quand le panneau du bas
est ouvert, on la tire à la souris pour régler sa hauteur. -->
<div class="flowResizeHandle" id="flowResizeHandle"></div>
<!-- Panneau bas, rétractable : logique de la scène (éditeur de flow à nœuds).
Volontairement PAS un <details> natif : sa hauteur ouverte doit pouvoir
être réglée à la souris (voir flowResizeHandle), ce qu'un <details> ne
permet pas nativement. Absent pour un écran-modèle (élément de jeu) :
ses déclencheurs référenceraient des éléments dont l'id change à chaque
copie (voir instantiate_template_tree), donc une logique posée ici ne
fonctionnerait plus une fois l'élément réutilisé. -->
<div class="flowPanel" id="flowPanel">
<div class="flowPanelHeader" onclick="toggleFlowPanel()">
<span class="flowToggleIcon">▸</span> 🔀 Logique de la scène — nœuds et connexions
</div>
<div class="flowPanelBody" id="flowPanelBody">
<div class="flowToolbar">
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('trigger')">➕ Déclencheur</button>
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('condition')">➕ Condition</button>
<button type="button" class="smallBtn button is-small" onclick="openNodeForm('action')">➕ Action</button>
<span class="hint">Clique un point de sortie d'un nœud, puis le point d'entrée d'un autre nœud, pour les relier. Reclique une connexion pour la supprimer.</span>
</div>
<div id="nodeForm" class="nodeForm" style="display:none;">
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
<strong id="nodeFormTitle">Nouveau nœud</strong>
<button type="button" class="smallBtn button is-small" onclick="closeNodeForm()">✕</button>
</div>
<div id="nodeFormTrigger" style="display:none;">
<label>Événement<select id="nf-trigger-event" onchange="toggleFlowTriggerFields()">
{% for key, label in trigger_events %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select></label>
<label id="nf-trigger-element-row">Élément<select id="nf-trigger-element">
{% for e2 in elements %}
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
{% endfor %}
</select></label>
<p class="hint" id="nf-trigger-repeater-hint" style="display:none;"></p>
<p class="hint" id="nf-trigger-affichage-hint" style="display:none;"></p>
</div>
<div id="nodeFormCondition" style="display:none;">
{% if not definitions %}
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('object_new', slug=game.slug) }}">crées-en un</a>.</p>
{% else %}
<label>Objet<select id="nf-cond-definition" onchange="onFlowDefinitionChange('cond')">
{% for d in definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select></label>
<label>Ligne concernée<select id="nf-cond-row"></select></label>
<label>Champ<select id="nf-cond-field" onchange="onFlowFieldChange()"></select></label>
<label>Condition<select id="nf-cond-operator">
{% for key, label in condition_operators %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select></label>
<label>Valeur de comparaison<input type="text" id="nf-cond-value" placeholder="ex. 1, vrai, 10..."></label>
<div id="nf-cond-extra-wrap">
<label id="nf-cond-combinator-row" style="display:none;">Combiner les conditions avec
<select id="nf-cond-combinator">
<option value="et">ET (toutes les conditions doivent être vraies)</option>
<option value="ou">OU (au moins une condition doit être vraie)</option>
</select>
</label>
<div id="nf-cond-extra-clauses"></div>
<button type="button" class="smallBtn button is-small" onclick="addFlowConditionClause()">+ Ajouter une condition</button>
</div>
{% endif %}
</div>
<div id="nodeFormAction" style="display:none;">
<label>Type d'action<select id="nf-action-type" onchange="toggleFlowActionFields()">
{% for key, label in action_type_labels.items() %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select></label>
<select id="nf-action-target-screen" style="display:none;">
{% for s in all_screens %}
<option value="{{ s.id }}">{{ s.name }}</option>
{% endfor %}
</select>
<div id="nf-action-element-fields" style="display:none;">
<select id="nf-action-target-element">
{% for e2 in elements %}
<option value="{{ e2.id }}">{{ e2|elabel }} (elt-{{ e2.id }})</option>
{% endfor %}
</select>
<p class="hint" id="nf-action-onglet-hint" style="display:none;"></p>
<div id="nf-action-element-property-wrap">
<select id="nf-action-element-property" onchange="toggleFlowElementPropertyValue()" style="margin-top:6px;">
{% for key, label in element_action_properties %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<select id="nf-action-element-value-visibility" style="margin-top:6px;">
{% for key, label in element_visibility_values %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<select id="nf-action-element-value-surbrillance" style="display:none; margin-top:6px;">
{% for key, label in element_surbrillance_values %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<select id="nf-action-element-value-desactive" style="display:none; margin-top:6px;">
{% for key, label in element_desactive_values %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<input type="color" id="nf-action-element-value-color" style="display:none; margin-top:6px;" value="#5b8cff">
<input type="number" id="nf-action-element-value-number" style="display:none; margin-top:6px;" value="50" placeholder="Nouvelle valeur en %">
<input type="text" id="nf-action-element-value-text" style="display:none; margin-top:6px;" placeholder="Nouveau texte affiché">
<label id="nf-action-element-toggle-row" style="display:flex; align-items:center; gap:6px; margin-top:10px; font-size:12px; color:var(--text-dim);">
<input type="checkbox" id="nf-action-element-toggle" onchange="toggleFlowElementToggleMode()" style="width:auto; margin:0;">
Va-et-vient : au 1er clic, passer à cette valeur ; au 2e clic, revenir à la précédente ; etc.
</label>
<div id="nf-action-element-value2-wrap" style="display:none; margin-top:6px;">
<input type="color" id="nf-action-element-value2-color" style="display:none;" value="#ff0000">
<input type="number" id="nf-action-element-value2-number" style="display:none;" placeholder="Valeur au 2e état en %">
</div>
</div>
</div>
<div id="nf-action-wait-fields" style="display:none;">
<label>Délai en secondes avant de continuer
<input type="number" id="nf-action-wait-seconds" min="0" step="0.5" value="3" placeholder="ex. 3">
</label>
</div>
<div id="nf-action-data-fields" style="display:none;">
{% if not definitions %}
<p class="hint">Aucun objet défini dans ce jeu pour l'instant — <a href="{{ url_for('object_new', slug=game.slug) }}">crées-en un</a>.</p>
{% else %}
<select id="nf-action-data-definition" onchange="onFlowDefinitionChange('action')">
{% for d in definitions %}
<option value="{{ d.id }}">{{ d.name }}</option>
{% endfor %}
</select>
<select id="nf-action-data-row" style="margin-top:6px;"></select>
<select id="nf-action-data-field" style="margin-top:6px;"></select>
<select id="nf-action-data-operation" style="margin-top:6px;">
{% for key, label in data_operations %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</select>
<input type="text" id="nf-action-data-value" style="margin-top:6px;" placeholder="Valeur / montant">
{% endif %}
</div>
</div>
<div class="actionsRow"><button type="button" class="smallBtn button is-small is-primary" onclick="submitNodeForm()">+ Ajouter ce nœud</button></div>
</div>
<div class="flowCanvasWrap">
<svg id="flowEdges" class="flowEdgesSvg"></svg>
<div id="flowCanvas" class="flowCanvas"></div>
</div>
</div>
</div>
<!-- Poignée de redimensionnement du panneau timeline, même principe que
flowResizeHandle ci-dessus. -->
<div class="flowResizeHandle" id="animResizeHandle"></div>
<!-- Panneau bas, rétractable, positionné sous "Logique de la scène" :
timeline d'animation. Une piste par élément animé, des clips posés sur
un axe du temps (secondes depuis l'affichage de l'écran) — glisse un
clip pour changer son départ, tire son bord droit pour changer sa
durée. Chaque clip est soit une animation de la bibliothèque
Animate.css, soit une animation personnalisée (images-clés propres à
ce jeu). -->
<div class="flowPanel" id="animPanel">
<div class="flowPanelHeader" onclick="toggleAnimPanel()">
<span class="flowToggleIcon">▸</span> 🎬 Timeline d'animation
</div>
<div class="flowPanelBody" id="animPanelBody">
<div class="flowToolbar">
<label style="display:flex; align-items:center; gap:6px;">
Piste
<select id="anim-new-track-element">
{% for e2 in elements %}
<option value="{{ e2.id }}">{{ e2|elabel }}</option>
{% endfor %}
</select>
</label>
<button type="button" class="smallBtn button is-small" onclick="addAnimTrack()">➕ Ajouter un clip</button>
</div>
<div id="animClipForm" class="nodeForm" style="display:none;">
<div class="fieldRow" style="align-items:center; justify-content:space-between;">
<strong id="animClipFormTitle">Clip d'animation</strong>
<button type="button" class="smallBtn button is-small" onclick="closeAnimClipForm()">✕</button>
</div>
<label>Type
<select id="anim-clip-kind" onchange="toggleAnimClipKind()">
<option value="animate_css">Animation Animate.css</option>
<option value="custom">Animation personnalisée — images-clés</option>
</select>
</label>
<div id="anim-clip-animate-css-fields">
<label>Animation
<select id="anim-clip-animate-name">
{% for category, items in animate_css_catalog %}
<optgroup label="{{ category }}">
{% for key, label in items %}
<option value="{{ key }}">{{ label }}</option>
{% endfor %}
</optgroup>
{% endfor %}
</select>
</label>
</div>
<div id="anim-clip-custom-fields" style="display:none;">
<div id="anim-clip-keyframes"></div>
<button type="button" class="smallBtn button is-small" onclick="addAnimKeyframeRow()">+ Ajouter une image-clé</button>
</div>
<div class="fieldRow">
<label style="flex:1;">Départ en secondes
<input type="number" id="anim-clip-start" min="0" step="0.1" value="0">
</label>
<label style="flex:1;">Durée en secondes
<input type="number" id="anim-clip-duration" min="0.1" step="0.1" value="1">
</label>
</div>
<div class="fieldRow">
<label style="flex:1;">Délai en secondes
<input type="number" id="anim-clip-delay" min="0" step="0.1" value="0">
</label>
<label style="flex:1;">Vitesse
<select id="anim-clip-easing">
<option value="ease">Progressive</option>
<option value="linear">Constante</option>
<option value="ease-in">Accélère</option>
<option value="ease-out">Ralentit</option>
<option value="ease-in-out">Accélère puis ralentit</option>
</select>
</label>
</div>
<label>Répétitions
<select id="anim-clip-iteration">
<option value="1">Une fois</option>
<option value="2">2 fois</option>
<option value="3">3 fois</option>
<option value="infinite">En boucle</option>
</select>
</label>
<div class="actionsRow">
<button type="button" class="smallBtn button is-small is-primary" onclick="saveAnimClipForm()">Enregistrer</button>
<button type="button" class="smallBtn button is-small" onclick="previewAnimClip()">▶ Aperçu</button>
<button type="button" class="smallBtn button is-small is-danger" onclick="deleteAnimClipForm()">🗑️ Supprimer</button>
</div>
</div>
<div class="animTimelineWrap">
<div id="animRuler" class="animRuler"></div>
<div id="animTracks" class="animTracks"></div>
</div>
</div>
</div>
{% endif %}
<script>
function saveGeometry(id, x, y, width, height) {
return fetch("/game/{{ game.slug }}/elements/" + id + "/geometry", {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({x: x, y: y, width: width, height: height})
});
}
var SCREEN_EDIT_URL = "{{ url_for('screen_edit', slug=game.slug, screen_id=screen.id) }}";
var CURRENT_SELECTED_ID = {{ selected_id or 'null' }};
var GAME_SLUG = "{{ game.slug }}";
var SCREEN_ID = {{ screen.id }};
// ---------- Menu contextuel (clic droit) de l'arborescence des éléments ----------
//
// Une seule action pour l'instant : dupliquer l'élément dans son conteneur
// actuel (même parent, ou même écran si posé directement dessus) — voir
// screens/elements/duplicate_element.py. La duplication passe par un vrai
// <form method="post"> soumis dynamiquement : pjax.js intercepte tous les
// formulaires internes, donc pas besoin de gérer le fetch/la redirection
// nous-mêmes ici.
var contextMenuElementId = null;
function showElementContextMenu(event, elementId) {
event.preventDefault();
contextMenuElementId = elementId;
var menu = document.getElementById('elementContextMenu');
menu.style.left = event.pageX + 'px';
menu.style.top = event.pageY + 'px';
menu.style.display = 'block';
return false;
}
function hideElementContextMenu() {
var menu = document.getElementById('elementContextMenu');
if (menu) menu.style.display = 'none';
contextMenuElementId = null;
}
function contextMenuDuplicate() {
var id = contextMenuElementId;
hideElementContextMenu();
if (!id) return;
var form = document.createElement('form');
form.method = 'post';
form.action = '/game/' + GAME_SLUG + '/elements/' + id + '/duplicate';
document.body.appendChild(form);
form.requestSubmit();
}
if (!window.__forgeContextMenuBound) {
window.__forgeContextMenuBound = true;
document.addEventListener('click', function (e) {
var menu = document.getElementById('elementContextMenu');
if (menu && menu.style.display === 'block' && !menu.contains(e.target)) hideElementContextMenu();
});
document.addEventListener('scroll', hideElementContextMenu, true);
}
// ---------- Arborescence : replier/déplier un conteneur ----------
//
// Le triangle (.treeToggle) replie/déplie SANS toucher à la sélection : le
// nom de l'élément reste un lien à part (.treeRowLink) qui, lui, sélectionne
// toujours l'élément et affiche ses réglages — cliquer sur un conteneur
// replié donne donc bien accès à ses propriétés, comme demandé. L'état
// replié/déplié est mémorisé par élément dans localStorage pour survivre à
// un changement de sélection ou un rechargement de l'éditeur.
function treeCollapseKey(nodeId) {
return 'forgeTreeCollapsed:' + GAME_SLUG + ':' + SCREEN_ID + ':' + nodeId;
}
function restoreTreeCollapsedState() {
document.querySelectorAll('.elementTree .treeNode').forEach(function (node) {
var id = node.dataset.elementId;
var collapsed = false;
try { collapsed = localStorage.getItem(treeCollapseKey(id)) === '1'; } catch (e) {}
node.classList.toggle('collapsed', collapsed);
});
}
// ---------- Panneaux gauche/droite : flottants, déplaçables, fermables ----------
//
// Sortis du flux (position:fixed en CSS) pour ne jamais rétrécir le canevas
// selon où on les pose — les fermer libère cette largeur pour une
// prévisualisation plus fidèle à ce que "Jouer" affiche réellement (voir
// bug remonté : des panneaux trop larges réduisaient le canevas au point de
// casser la mise en page des onglets). Un seul jeu de fonctions génériques
// (key = identifiant court mémorisé dans localStorage, distinct par
// panneau) plutôt qu'une copie par panneau. Position et état ouvert/fermé
// survivent à un changement de sélection (qui remplace tout #builder3, voir
// refreshBuilderPanel) ou un rechargement — voir les appels à
// restoreFloatPanelState() dans initBuilderPanel().
function floatPanelStateKey(key) {
return 'forgeFloatPanel:' + GAME_SLUG + ':' + SCREEN_ID + ':' + key;
}
function saveFloatPanelState(key, state) {
try {
var current = JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}');
localStorage.setItem(floatPanelStateKey(key), JSON.stringify(Object.assign(current, state)));
} catch (e) {}
}
function loadFloatPanelState(key) {
try { return JSON.parse(localStorage.getItem(floatPanelStateKey(key)) || '{}'); } catch (e) { return {}; }
}
function restoreFloatPanelState(key, panelId, reopenBtnId) {
var panel = document.getElementById(panelId);
var reopenBtn = document.getElementById(reopenBtnId);
if (!panel) return;
var state = loadFloatPanelState(key);
if (state.left != null && state.top != null) {
panel.style.left = state.left + 'px';
panel.style.top = state.top + 'px';
panel.style.right = 'auto';
}
var open = state.open !== false; // ouvert par défaut tant que rien n'a été fermé
panel.classList.toggle('hidden', !open);
if (reopenBtn) reopenBtn.classList.toggle('visible', !open);
}
function closeFloatPanel(key, panelId, reopenBtnId) {
var panel = document.getElementById(panelId);
var reopenBtn = document.getElementById(reopenBtnId);
if (panel) panel.classList.add('hidden');
if (reopenBtn) reopenBtn.classList.add('visible');
saveFloatPanelState(key, { open: false });
}
function openFloatPanel(key, panelId, reopenBtnId) {
var panel = document.getElementById(panelId);
var reopenBtn = document.getElementById(reopenBtnId);
if (panel) panel.classList.remove('hidden');
if (reopenBtn) reopenBtn.classList.remove('visible');
saveFloatPanelState(key, { open: true });
}
// Raccourcis utilisés directement par les attributs onclick du gabarit.
function closePropsPanel() { closeFloatPanel('props', 'propsPanelFloat', 'propsPanelReopenBtn'); }
function openPropsPanel() { openFloatPanel('props', 'propsPanelFloat', 'propsPanelReopenBtn'); }
function closeLeftPanel() { closeFloatPanel('left', 'panelLeftFloat', 'panelLeftReopenBtn'); }
function openLeftPanel() { openFloatPanel('left', 'panelLeftFloat', 'panelLeftReopenBtn'); }
if (!window.__forgeFloatPanelDragBound) {
window.__forgeFloatPanelDragBound = true;
document.addEventListener('mousedown', function (e) {
var handle = e.target.closest('.floatPanelHeader');
if (!handle || e.target.closest('button')) return;
var panel = document.getElementById(handle.dataset.dragPanel);
var key = handle.dataset.dragKey;
if (!panel) return;
var rect = panel.getBoundingClientRect();
var startX = e.clientX, startY = e.clientY;
var startLeft = rect.left, startTop = rect.top;
panel.style.right = 'auto';
function onMove(ev) {
var maxLeft = window.innerWidth - panel.offsetWidth - 4;
var maxTop = window.innerHeight - 40;
var left = Math.max(4, Math.min(maxLeft, startLeft + (ev.clientX - startX)));
var top = Math.max(4, Math.min(maxTop, startTop + (ev.clientY - startY)));
panel.style.left = left + 'px';
panel.style.top = top + 'px';
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
saveFloatPanelState(key, { left: parseFloat(panel.style.left), top: parseFloat(panel.style.top) });
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
// ---------- Galerie d'icônes Font Awesome (panneau de propriétés) ----------
//
// Cliquer une tuile l'ajoute directement (comme n'importe quelle tuile de
// "Ajouter un élément sur l'écran") ; la glisser jusque sur le canevas fait
// la même chose au relâchement — le drag HTML5 natif ne permet pas de créer
// un vrai élément positionné exactement sous le curseur sans aller-retour
// serveur supplémentaire, donc on se contente ici de déclencher le même
// ajout "à la position par défaut" que le clic, l'utilisateur repositionne
// ensuite comme pour tout nouvel élément.
function addIconElement(iconClass) {
var form = document.createElement('form');
form.method = 'post';
form.action = "{{ url_for('element_add', slug=game.slug, screen_id=screen.id) }}";
form.innerHTML =
'<input type="hidden" name="widget" value="icone">' +
'<input type="hidden" name="icon_class" value="' + iconClass.replace(/"/g, '&quot;') + '">';
document.body.appendChild(form);
form.requestSubmit();
}
function filterIconGallery(query) {
var q = query.trim().toLowerCase();
document.querySelectorAll('#iconGallery .iconGalleryTile').forEach(function (tile) {
var match = !q || tile.dataset.iconSearch.indexOf(q) !== -1;
tile.classList.toggle('iconHidden', !match);
});
}
// ---------- Sélecteur visuel d'icône (réglage "Icône" d'un élément déjà
// posé) — même liste que la galerie d'ajout ci-dessus, mais choisir une
// icône ici remplace celle de l'élément SÉLECTIONNÉ (champ caché
// "field-icon_class") au lieu d'en créer un nouveau. ----------
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(iconClass) {
var input = document.getElementById('field-icon_class');
var preview = document.getElementById('iconPickerPreview');
if (input) input.value = iconClass;
if (preview) preview.className = iconClass;
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 par glisser-déposer ----------
//
// Glisser une ligne au-dessus/en dessous d'une autre ligne DU MÊME
// CONTENEUR (même parent, ou toutes deux à la racine) change leur ordre —
// voir screens/elements/reorder_element.py. É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 || '' };
}
function clearTreeDropIndicators() {
document.querySelectorAll('.treeRow.dropBefore, .treeRow.dropAfter').forEach(function (r) {
r.classList.remove('dropBefore', 'dropAfter');
});
}
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;
if (info.parentId !== treeDragParentId) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
clearTreeDropIndicators();
var rect = row.getBoundingClientRect();
var before = (e.clientY - rect.top) < rect.height / 2;
row.classList.add(before ? 'dropBefore' : 'dropAfter');
});
document.addEventListener('drop', function (e) {
var row = e.target.closest('.elementTree .treeRow');
clearTreeDropIndicators();
if (!row || treeDragElementId == null) return;
e.preventDefault();
var info = treeRowDropInfo(row);
if (info.elementId == null || String(info.elementId) === String(treeDragElementId)) return;
if (info.parentId !== treeDragParentId) return;
var rect = row.getBoundingClientRect();
var position = (e.clientY - rect.top) < rect.height / 2 ? 'before' : 'after';
var draggedId = treeDragElementId;
fetch('/game/{{ game.slug }}/elements/' + draggedId + '/reorder', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_id: info.elementId, position: position })
}).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);
}
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();
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.
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>';
var fieldOptions = info.fields.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;
});
}
// 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');
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 === '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, '&quot;') + '" onchange="flowConditionExtraClauses[' + idx + '].value = this.value"></label>' +
'<button type="button" class="smallBtn button is-small" onclick="removeFlowConditionClause(' + idx + ')">Retirer cette condition</button>' +
'</div>';
}).join('');
flowConditionExtraClauses.forEach(function(clause, idx) {
var defSel = document.getElementById('nf-cond-extra-definition-' + idx);
if (defSel && clause.definition_id) defSel.value = clause.definition_id;
onFlowClauseDefinitionChange(idx);
var opSel = document.getElementById('nf-cond-extra-operator-' + idx);
if (opSel) opSel.value = clause.operator || 'egal';
});
}
function onFlowClauseDefinitionChange(idx) {
var defSel = document.getElementById('nf-cond-extra-definition-' + idx);
var rowSel = document.getElementById('nf-cond-extra-row-' + idx);
var fieldSel = document.getElementById('nf-cond-extra-field-' + idx);
if (!defSel || !rowSel || !fieldSel) return;
flowConditionExtraClauses[idx].definition_id = defSel.value;
var info = DEFINITIONS_DATA[defSel.value] || { rows: [], fields: [] };
rowSel.innerHTML = 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 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';
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 === '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 la div flowPanel.
var HAS_FLOW_PANEL = !!document.getElementById('flowPanel');
if (HAS_FLOW_PANEL) { renderFlow(); }
// ---------- Ouverture / fermeture et redimensionnement du panneau du bas ----------
var FLOW_PANEL_DEFAULT_HEIGHT = 340;
var flowPanelLastHeight = FLOW_PANEL_DEFAULT_HEIGHT;
function toggleFlowPanel() {
var panel = document.getElementById('flowPanel');
var handle = document.getElementById('flowResizeHandle');
if (!panel || !handle) return;
var isOpen = panel.classList.contains('open');
if (isOpen) {
flowPanelLastHeight = panel.getBoundingClientRect().height;
panel.classList.remove('open');
panel.style.height = '';
handle.classList.remove('visible');
} else {
panel.classList.add('open');
panel.style.height = flowPanelLastHeight + 'px';
handle.classList.add('visible');
requestAnimationFrame(drawFlowEdges);
}
}
if (HAS_FLOW_PANEL) (function(){
var handle = document.getElementById('flowResizeHandle');
var panel = document.getElementById('flowPanel');
handle.addEventListener('mousedown', function(e){
e.preventDefault();
if (!panel.classList.contains('open')) return;
var startY = e.clientY;
var startHeight = panel.getBoundingClientRect().height;
var wrap = document.querySelector('.content-builder');
var maxHeight = Math.max(160, wrap.getBoundingClientRect().height - 160);
function onMove(ev) {
var dy = ev.clientY - startY;
var newHeight = Math.min(maxHeight, Math.max(140, startHeight - dy));
panel.style.height = newHeight + 'px';
flowPanelLastHeight = newHeight;
drawFlowEdges();
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
})();
// ---------- 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 la div animPanel.
var HAS_ANIM_PANEL = !!document.getElementById('animPanel');
if (HAS_ANIM_PANEL) { renderAnimTimeline(); }
var ANIM_PANEL_DEFAULT_HEIGHT = 260;
var animPanelLastHeight = ANIM_PANEL_DEFAULT_HEIGHT;
function toggleAnimPanel() {
var panel = document.getElementById('animPanel');
var handle = document.getElementById('animResizeHandle');
if (!panel || !handle) return;
var isOpen = panel.classList.contains('open');
if (isOpen) {
animPanelLastHeight = panel.getBoundingClientRect().height;
panel.classList.remove('open');
panel.style.height = '';
handle.classList.remove('visible');
} else {
panel.classList.add('open');
panel.style.height = animPanelLastHeight + 'px';
handle.classList.add('visible');
}
}
if (HAS_ANIM_PANEL) (function() {
var handle = document.getElementById('animResizeHandle');
var panel = document.getElementById('animPanel');
handle.addEventListener('mousedown', function(e) {
e.preventDefault();
if (!panel.classList.contains('open')) return;
var startY = e.clientY;
var startHeight = panel.getBoundingClientRect().height;
var wrap = document.querySelector('.content-builder');
var maxHeight = Math.max(160, wrap.getBoundingClientRect().height - 160);
function onMove(ev) {
var dy = ev.clientY - startY;
var newHeight = Math.min(maxHeight, Math.max(140, startHeight - dy));
panel.style.height = newHeight + 'px';
animPanelLastHeight = newHeight;
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
})();
</script>
{% endblock %}