A top-level element already got a selection border via its .canvasElement wrapper, but a child posed inside a container/répéteur/ groupe de champs has no such separate frame, so selecting it from the tree gave no visual feedback on the canvas at all. applySelectionHighlight() now targets the element's own tag directly via data-element-id (present on every rendered element, nested or not) and outlines it, re-run after every canvas refresh path (initial load, full pjax navigation, partial panel refresh, and the autosave-only canvas refresh). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2005 lines
94 KiB
HTML
2005 lines
94 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}{{ screen.name }} — {{ game.name }}{% endblock %}
|
||
{% block content_class %}content-builder{% endblock %}
|
||
{% block body_class %}builderBody{% endblock %}
|
||
{% block extra_head %}
|
||
<link rel="stylesheet" href="{{ google_fonts_link }}">
|
||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/animate.css/4.1.1/animate.min.css">
|
||
{% endblock %}
|
||
{% block breadcrumb %}
|
||
<a class="crumb" href="{{ url_for('game_dashboard', slug=game.slug) }}">{{ game.name }}</a>
|
||
<span class="crumb-sep">/</span>
|
||
<a class="crumb" href="{{ url_for('screens_list', slug=game.slug) }}">Écrans</a>
|
||
<span class="crumb-sep">/</span><span class="crumb">{{ screen.name }}</span>
|
||
{% 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="fieldRow" style="align-items:center; margin:0;">
|
||
<input type="text" name="name" value="{{ screen.name }}" required style="font-size:16px; font-weight:700; width:auto;">
|
||
<button type="submit" class="smallBtn">Renommer</button>
|
||
</form>
|
||
{% if not screen.is_template %}
|
||
<a class="smallBtn 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">
|
||
{% 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) -->
|
||
<div class="builderPanel">
|
||
<h2>É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>
|
||
|
||
<!-- 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é -->
|
||
<div class="builderPanel builderPanelRight">
|
||
{% 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 style="margin:0;">{{ w.get('icon','❔') }} {{ el|elabel }}{% if el.name %} <span class="hint">— {{ 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="smallBtn danger">🗑️</button>
|
||
</form>
|
||
</div>
|
||
<form method="post" action="{{ url_for('element_rename', slug=game.slug, element_id=el.id) }}" class="fieldRow" style="align-items:center; margin:6px 0;">
|
||
<input type="text" name="name" value="{{ el.name or '' }}" placeholder="Donne un nom à cet élément" style="flex:1;">
|
||
<button type="submit" class="smallBtn">Nommer</button>
|
||
</form>
|
||
<div class="fieldRow" style="align-items:center; margin:6px 0;">
|
||
<label style="width:auto; margin-right:6px;">ID</label>
|
||
<input type="text" value="elt-{{ el.id }}" readonly style="flex:1; opacity:0.7;">
|
||
</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 %}
|
||
|
||
{% set size_ctrl_keys = ['child-width', 'child-height'] %}
|
||
{% set size_ctrls = {} %}
|
||
{% for c in selected_controls if c.key in size_ctrl_keys %}{% set _ = size_ctrls.update({c.key: c}) %}{% endfor %}
|
||
|
||
<form method="post" action="{{ url_for('element_save', slug=game.slug, element_id=el.id) }}" id="propsForm" onsubmit="event.preventDefault(); return false;">
|
||
<details class="propGroup">
|
||
<summary>Position & taille</summary>
|
||
<div class="propGroupBody">
|
||
<div class="posGrid">
|
||
<label>X (%)<input type="number" id="pos-x" min="0" max="100" value="{{ el.x }}" {{ 'disabled' if el.parent_id else '' }}></label>
|
||
<label>Y (%)<input type="number" id="pos-y" min="0" max="100" value="{{ el.y }}" {{ 'disabled' if el.parent_id else '' }}></label>
|
||
{% if el.parent_id %}
|
||
<label>Largeur
|
||
<div class="sizeField">
|
||
<input type="number" name="ctrl_child-width_value" min="0" value="{{ size_ctrls['child-width'].value.value or '' }}" placeholder="auto">
|
||
<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>
|
||
</label>
|
||
<label>Hauteur
|
||
<div class="sizeField">
|
||
<input type="number" name="ctrl_child-height_value" min="0" value="{{ size_ctrls['child-height'].value.value or '' }}" placeholder="auto">
|
||
<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>
|
||
</label>
|
||
{% else %}
|
||
<label>Largeur (%)<input type="number" id="pos-w" min="1" max="100" value="{{ el.width }}"></label>
|
||
<label>Hauteur (%)<input type="number" id="pos-h" min="1" max="100" value="{{ el.height }}"></label>
|
||
{% endif %}
|
||
</div>
|
||
{% if el.parent_id %}
|
||
<p class="hint">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="controlRow">
|
||
<label>{{ c.label }}</label>
|
||
{% if c.type in ('text', 'url') %}
|
||
<input type="text" name="ctrl_{{ c.key }}" value="{{ c.value }}" placeholder="{{ c.placeholder or '' }}" class="{{ 'uploadTextField' if c.allow_upload else '' }}" id="field-{{ c.key }}">
|
||
{% if c.allow_upload %}
|
||
<div class="uploadRow">
|
||
<input type="file" class="uploadInput" accept="{{ c.upload_accept }}" data-target="field-{{ c.key }}">
|
||
<span class="hint uploadStatus"></span>
|
||
</div>
|
||
<p class="hint">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' %}
|
||
<textarea name="ctrl_{{ c.key }}" rows="4" placeholder="{{ c.placeholder or '' }}">{{ c.value }}</textarea>
|
||
{% elif c.type == 'color' %}
|
||
<input type="color" name="ctrl_{{ c.key }}" value="{{ c.value or c.default }}">
|
||
{% elif c.type == 'slider' %}
|
||
<div class="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">{{ c.value }}{{ c.unit }}</span>
|
||
</div>
|
||
{% elif c.type == 'scale' %}
|
||
<div class="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">x{{ c.value }}</span>
|
||
</div>
|
||
{% elif c.type in ('checkbox', 'toggle_token') %}
|
||
<input type="checkbox" name="ctrl_{{ c.key }}" {{ 'checked' if c.value else '' }}>
|
||
{% elif c.type == 'align' %}
|
||
<div class="alignGroup">
|
||
{% for val, icon in [('left','⬅️'),('center','⬛'),('right','➡️'),('justify','☰')] %}
|
||
<label class="alignOption {{ 'active' if c.value == val else '' }}">
|
||
<input type="radio" name="ctrl_{{ c.key }}" value="{{ val }}" {{ 'checked' if c.value == val else '' }} onchange="syncAlign(this)">
|
||
{{ icon }}
|
||
</label>
|
||
{% endfor %}
|
||
</div>
|
||
{% elif c.type in ('select', 'preset') %}
|
||
<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>
|
||
{% 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>
|
||
|
||
{% 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" onclick="openNodeForm('trigger')">➕ Déclencheur</button>
|
||
<button type="button" class="smallBtn" onclick="openNodeForm('condition')">➕ Condition</button>
|
||
<button type="button" class="smallBtn" 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" 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" 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 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" 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" 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" 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 primary" onclick="saveAnimClipForm()">Enregistrer</button>
|
||
<button type="button" class="smallBtn" onclick="previewAnimClip()">▶ Aperçu</button>
|
||
<button type="button" class="smallBtn 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);
|
||
});
|
||
}
|
||
|
||
// ---------- 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');
|
||
}
|
||
|
||
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();
|
||
|
||
// ---------- 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('.builderPanel .elementTree');
|
||
var curTree = document.querySelector('.builderPanel .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);
|
||
}
|
||
|
||
// 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'); });
|
||
radio.closest('.alignOption').classList.add('active');
|
||
}
|
||
|
||
// 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, '"') + '" onchange="flowConditionExtraClauses[' + idx + '].value = this.value"></label>' +
|
||
'<button type="button" class="smallBtn" 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 %}
|