Files
Forge-Engine/static/js/screen_edit/flow-editor.js
T
williamandClaude Sonnet 5 92fbfbc9dd
Build and deploy / test-python (push) Successful in 1m26s
Build and deploy / test-js (push) Successful in 7s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Phase 4 : ajout dynamique d'une ligne à l'exécution
Nouveau sentinel LAST_INSERTED_ROW_ID = -3 (screens/flow/constants.py),
aux côtés de CLICKED_ROW_ID = -1 — même principe : un id de ligne
n'existe qu'APRÈS l'insertion, jamais connu à la création du nœud.
Permet d'enchaîner un nœud "Ajouter une ligne" (crée une ligne VIDE) puis
un ou plusieurs nœuds "Modifier une donnée" déjà existants (ciblant
"➕ Dernière ligne ajoutée" dans le sélecteur "Ligne concernée", partagé
avec les conditions) pour renseigner ses champs — réutilise 100% du
mécanisme actuel, aucun nouveau format de payload multi-champs.

screens/data_actions/apply_add_row_action.py : db.get_definition +
db.insert_row(slug, definition, {}, player_id) — la ligne appartient au
joueur qui agit pour un objet per_player (Phase 1).

Nouvelles routes (créateur ET publique, comme prévu dès la Phase 1) :
POST /game/<slug>/flow/nodes/<id>/run-add-row et son miroir
/jouer/<slug>/.../run-add-row — renvoient {"ok", "row_id"}.
routes/flow/flow_node_run_data.py (+ son miroir public) : résout aussi
LAST_INSERTED_ROW_ID (en plus de CLICKED_ROW_ID déjà en place) via
last_inserted_row_id transmis par le client.

static/js/play/actions.js : runActionNode branche "ajouter_ligne" ->
fetch la nouvelle route, pose window.lastInsertedRowId, puis
refreshRuntimeData() (un Répéteur lié affiche la nouvelle ligne au
prochain rendu, confirmé par l'audit préalable — aucun ajustement du
mécanisme de rafraîchissement nécessaire). La branche "modifier_donnee"
transmet désormais aussi last_inserted_row_id, comme clicked_row_id.

templates/screen_edit.html + static/js/screen_edit/flow-editor.js :
nouveau type d'action "Ajouter une ligne à un objet" (juste un
sélecteur d'objet, aucun champ à remplir — le rappel du fonctionnement
enchaîné est affiché directement dans le formulaire) ; le sélecteur
"Ligne concernée" (partagé Condition/Modifier une donnée) gagne l'option
"➕ Dernière ligne ajoutée" à côté de "🖱️ Ligne cliquée".

Vérifié : 259 tests passent (4 nouveaux, dont un bout-en-bout via HTTP
qui enchaîne réellement les deux nœuds et vérifie le champ renseigné, et
un qui verrouille l'isolation par joueur de la ligne créée), 13 tests
node:test toujours au vert, syntaxe JS validée.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-30 17:18:10 +02:00

674 lines
36 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ---------- Logique de la scène : éditeur de flow à nœuds ----------
//
// FLOW_NODES_INITIAL/FLOW_EDGES_INITIAL/FLOW_BLOCKS/ELEMENTS_LABELS/
// SCREENS_MAP/*_LABELS_MAP/CUSTOM_EVENTS_MAP/GAME_SLUG/SCREEN_ID sont
// posés par le petit <script> inline en tête de screen_edit.html (données
// Jinja) — voir static/js/play/ pour le même patron déjà en place côté
// moteur de jeu.
var FLOW_SCREEN_ID = SCREEN_ID;
// Blocs de logique (voir screens/flow/blocks/) : ALL_FLOW porte TOUS les
// nœuds/arêtes de cet écran, tous blocs confondus (chargement inchangé) ;
// un seul bloc est ouvert à la fois (modale unique, à la Unity) — pas
// besoin d'une seconde copie filtrée à tenir synchronisée, currentBlockNodes()/
// currentBlockEdges() recalculent la vue du bloc ouvert à chaque rendu.
var ALL_FLOW = {
nodes: FLOW_NODES_INITIAL,
edges: FLOW_EDGES_INITIAL,
};
var CURRENT_BLOCK_ID = null;
function currentBlockNodes() {
return ALL_FLOW.nodes.filter(function(n) { return n.block_id === CURRENT_BLOCK_ID; });
}
function currentBlockEdges() {
var ids = {};
currentBlockNodes().forEach(function(n) { ids[n.id] = true; });
return ALL_FLOW.edges.filter(function(e) { return ids[e.from_node_id] && ids[e.to_node_id]; });
}
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'];
if (node.trigger_event === 'evenement') {
var listenedEvent = CUSTOM_EVENTS_MAP[node.trigger_custom_event_id];
return '📣 Sur « ' + (listenedEvent ? listenedEvent.name : '?') + ' »';
}
if (node.trigger_event === 'clavier') {
return '⌨️ Touche « ' + (node.trigger_key === ' ' ? 'Espace' : (node.trigger_key || '?')) + ' »';
}
if (node.trigger_event === 'minuteur') {
return '⏱️ Toutes les ' + (node.trigger_interval_ms || '?') + ' ms';
}
return '🖱️ ' + (TRIGGER_LABELS_MAP[node.trigger_event] || 'Au clic') + ' sur ' +
(ELEMENTS_LABELS[node.trigger_element_id] || '?');
}
if (node.node_type === 'condition') {
var clauseSubject = function(c, field, variable) {
return (c.source ?? c.cond_source) === 'variable' ? ('🔧 ' + (variable || c.cond_variable || '?')) : (field || c.cond_field || '?');
};
var mainSubject = clauseSubject(node, node.cond_field, node.cond_variable);
var mainClause = '❓ SI ' + mainSubject + ' ' + (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/variable
// 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 clauseSubject(c, c.field, c.variable) + ' ' + (CONDITION_OPERATOR_LABELS_MAP[c.operator] || c.operator) + ' ' + (c.value || '');
}).join(combLabel);
} catch (e) { /* JSON invalide : on retombe sur la clause historique */ }
}
return mainClause;
}
// action
var base = '⚡ ' + (ACTION_TYPE_LABELS_MAP[node.action_type] || node.action_type);
if (node.action_type === 'aller_a') {
base += ' → ' + (SCREENS_MAP[node.target_screen_id] || '?');
} else if (node.action_type === 'ouvrir_ligne') {
base += ' → ' + (SCREENS_MAP[node.target_screen_id] || '?') + ' (ligne cliquée)';
} else if (node.action_type === 'activer_onglet') {
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' (masque ses frères)';
} else if (node.action_type === 'modifier_element') {
base += ' → ' + (ELEMENTS_LABELS[node.target_element_id] || '?') + ' : ' + (ELEMENT_PROPERTY_LABELS_MAP[node.element_property] || node.element_property);
if (node.element_property === 'visibilite') {
base += ' = ' + (ELEMENT_VISIBILITY_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_property === 'surbrillance') {
base += ' = ' + (ELEMENT_SURBRILLANCE_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_property === 'desactive') {
base += ' = ' + (ELEMENT_DESACTIVE_LABELS_MAP[node.element_value] || node.element_value);
} else if (node.element_value && node.element_value.indexOf('||') !== -1) {
var vparts = node.element_value.split('||');
base += ' = ' + vparts[0] + ' ↔ ' + vparts[1] + ' (va-et-vient)';
} else {
base += ' = ' + node.element_value;
}
} else if (node.action_type === 'modifier_donnee') {
base += ' → ' + (node.target_field || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
if (node.data_value) base += ' ' + node.data_value;
} else if (node.action_type === 'modifier_variable') {
base += ' → ' + (node.target_variable || '?') + ' : ' + (DATA_OPERATION_LABELS_MAP[node.data_operation] || node.data_operation);
if (node.data_value) base += ' ' + node.data_value;
} else if (node.action_type === 'attendre') {
base += ' (' + (node.data_value || '0') + ' s)';
} else if (node.action_type === 'declencher_evenement') {
// Notification pure : rien d'autre à afficher que le nom de
// l'événement (voir screens/custom_events/) — aucun élément/ligne
// n'est jamais transmis au déclenchement.
var dispatchedEvent = CUSTOM_EVENTS_MAP[node.target_custom_event_id];
base += ' « ' + (dispatchedEvent ? dispatchedEvent.name : '?') + ' »';
} else if (node.action_type === 'ajouter_ligne') {
base += ' → ' + (DEFINITIONS_NAMES[node.target_definition_id] || '?');
}
return base;
}
function renderFlow() {
var canvas = document.getElementById('flowCanvas');
var svg = document.getElementById('flowEdges');
canvas.innerHTML = '';
svg.innerHTML = '';
currentBlockNodes().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 = '';
currentBlockEdges().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){
ALL_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(){
ALL_FLOW.edges = ALL_FLOW.edges.filter(function(e){ return e.id !== edgeId; });
drawFlowEdges();
});
}
function deleteFlowNode(nodeId) {
fetch(flowUrl('/flow/nodes/' + nodeId + '/delete'), { method: 'POST' }).then(function(){
ALL_FLOW.nodes = ALL_FLOW.nodes.filter(function(n){ return n.id !== nodeId; });
ALL_FLOW.edges = ALL_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 = ALL_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') {
document.getElementById('nf-cond-source').value = 'objet';
onFlowConditionSourceChange();
onFlowDefinitionChange('cond');
flowConditionExtraClauses = [];
renderFlowConditionClauses();
}
if (type === 'action') {
document.getElementById('nf-action-element-toggle').checked = false;
toggleFlowActionFields();
}
}
function toggleFlowTriggerFields() {
var eventVal = document.getElementById('nf-trigger-event').value;
var isAffichage = eventVal === 'affichage';
// "Sur un événement personnalisé" ne concerne ni un élément précis ni
// l'écran entier : c'est un scan global (voir dispatchGameEvent() dans
// play.html), le seul réglage propre à ce nœud est QUEL événement.
var isCustomEvent = eventVal === 'evenement';
// Idem "clavier"/"minuteur" (Phase 3) : ni élément ni écran précis.
var isKeyboard = eventVal === 'clavier';
var isTimer = eventVal === 'minuteur';
var noElement = isAffichage || isCustomEvent || isKeyboard || isTimer;
document.getElementById('nf-trigger-element-row').style.display = noElement ? 'none' : 'block';
document.getElementById('nf-trigger-repeater-hint').style.display = noElement ? 'none' : 'block';
document.getElementById('nf-trigger-affichage-hint').style.display = isAffichage ? 'block' : 'none';
document.getElementById('nf-trigger-event-row').style.display = isCustomEvent ? 'block' : 'none';
document.getElementById('nf-trigger-key-row').style.display = isKeyboard ? 'block' : 'none';
document.getElementById('nf-trigger-interval-row').style.display = isTimer ? 'block' : 'none';
}
// Capture la touche pressée dans le champ "Touche à surveiller" (Phase 3)
// — évite de faire deviner la syntaxe attendue (ev.key du navigateur,
// ex. "ArrowUp", "a", " " pour la barre d'espace) : l'input lui-même
// écoute keydown et se remplit tout seul, jamais tapé à la main.
function captureFlowTriggerKey(event) {
event.preventDefault();
document.getElementById('nf-trigger-key').value = event.key;
document.getElementById('nf-trigger-key-display').value = event.key === ' ' ? 'Espace' : event.key;
}
function closeNodeForm() {
document.getElementById('nodeForm').style.display = 'none';
currentNodeFormType = null;
}
// CLICKED_ROW_ID (-1, voir screens/flow/constants.py) : "la ligne de
// Répéteur sur laquelle on vient de cliquer" — pour un déclencheur "Au
// clic" posé sur un Répéteur, chaque ligne exécute le MÊME graphe et la
// ligne cliquée n'est jamais connue à l'avance ; choisir une ligne fixe
// dans l'éditeur n'a donc pas de sens pour ce cas. Résolue au moment de
// l'exécution (window.lastClickedRowId, voir play.html), pas à la
// création du nœud — proposée en tête de TOUTE liste "Ligne concernée",
// avant les lignes réelles de l'objet.
var FLOW_CLICKED_ROW_ID = -1;
// LAST_INSERTED_ROW_ID (-3, voir screens/flow/constants.py) : "la ligne
// qu'un nœud Action 'Ajouter une ligne' vient de créer" (Phase 4) — même
// principe que CLICKED_ROW_ID ci-dessus, résolue au moment de l'exécution
// (window.lastInsertedRowId, voir static/js/play/actions.js) puisqu'un
// id de ligne n'existe qu'APRÈS l'insertion, jamais connu à la création
// du nœud. Permet d'enchaîner "Ajouter une ligne" -> "Modifier une
// donnée"/condition ciblant cette même ligne fraîchement créée.
var FLOW_LAST_INSERTED_ROW_ID = -3;
function _flowRowOptionsHtml(info) {
var options = '<option value="' + FLOW_CLICKED_ROW_ID + '">🖱️ Ligne cliquée (Répéteur)</option>';
options += '<option value="' + FLOW_LAST_INSERTED_ROW_ID + '">➕ Dernière ligne ajoutée</option>';
options += info.rows.length
? info.rows.map(function(r){ return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
: '<option value="" disabled>Aucune donnée dans cet objet</option>';
return options;
}
function onFlowDefinitionChange(prefix) {
var defSel = document.getElementById(prefix === 'cond' ? 'nf-cond-definition' : 'nf-action-data-definition');
if (!defSel) return;
var info = DEFINITIONS_DATA[defSel.value] || { rows: [], fields: [] };
var rowSel = document.getElementById(prefix === 'cond' ? 'nf-cond-row' : 'nf-action-data-row');
var fieldSel = document.getElementById(prefix === 'cond' ? 'nf-cond-field' : 'nf-action-data-field');
rowSel.innerHTML = _flowRowOptionsHtml(info);
fieldSel.innerHTML = info.fields.length
? info.fields.map(function(f){ return '<option value="' + f.name + '" data-type="' + f.type + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
: '<option value="">Aucun champ simple</option>';
}
function onFlowFieldChange() {
// Pas d'action nécessaire ici pour l'instant : le type du champ est relu
// directement sur l'option sélectionnée au moment de la création du nœud.
}
// Bascule la clause PRINCIPALE d'un nœud Condition entre "Champ d'un
// objet" (historique) et "Variable globale" — voir ensure_flow_schema.py
// (cond_source) et evaluateConditionClause() dans play.html. Même bascule
// pour une clause SUPPLÉMENTAIRE : voir onFlowClauseSourceChange() plus
// bas, sur le même principe mais scopée par index.
function onFlowConditionSourceChange() {
var isVariable = document.getElementById('nf-cond-source').value === 'variable';
document.getElementById('nf-cond-objet-fields').style.display = isVariable ? 'none' : 'block';
document.getElementById('nf-cond-variable-fields').style.display = isVariable ? 'block' : 'none';
var hint = document.getElementById('nf-cond-no-objet-hint');
if (hint) hint.style.display = isVariable ? 'none' : 'block';
}
// ---------- 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({
source: 'objet', definition_id: defaultDefId, row_id: '', field: '', field_type: '',
variable: '', variable_chemin: '', operator: 'egal', value: '',
});
renderFlowConditionClauses();
}
function removeFlowConditionClause(idx) {
flowConditionExtraClauses.splice(idx, 1);
renderFlowConditionClauses();
}
// Bascule une clause SUPPLÉMENTAIRE (indexée) entre "Champ d'un objet" et
// "Variable globale" — même principe que onFlowConditionSourceChange() pour
// la clause principale, scopé par index plutôt que par id fixe.
function onFlowClauseSourceChange(idx) {
var sel = document.getElementById('nf-cond-extra-source-' + idx);
if (!sel) return;
flowConditionExtraClauses[idx].source = sel.value;
var isVariable = sel.value === 'variable';
var objetFields = document.getElementById('nf-cond-extra-objet-fields-' + idx);
var varFields = document.getElementById('nf-cond-extra-variable-fields-' + idx);
if (objetFields) objetFields.style.display = isVariable ? 'none' : 'block';
if (varFields) varFields.style.display = isVariable ? 'block' : 'none';
}
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) {
var isVariable = clause.source === 'variable';
return '' +
'<div class="flowConditionClauseRow" style="border-top:1px dashed var(--border); margin-top:8px; padding-top:8px;">' +
'<label>Source<select id="nf-cond-extra-source-' + idx + '" onchange="onFlowClauseSourceChange(' + idx + ')">' +
'<option value="objet"' + (isVariable ? '' : ' selected') + '>Champ d\'un objet</option>' +
'<option value="variable"' + (isVariable ? ' selected' : '') + '>Variable globale</option>' +
'</select></label>' +
'<div id="nf-cond-extra-objet-fields-' + idx + '" style="display:' + (isVariable ? 'none' : 'block') + ';">' +
'<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>' +
'</div>' +
'<div id="nf-cond-extra-variable-fields-' + idx + '" style="display:' + (isVariable ? 'block' : 'none') + ';">' +
'<label>Variable<select id="nf-cond-extra-variable-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].variable = this.value">' +
'<option value="">(sélectionner une variable)</option>' + GLOBAL_VARIABLES_OPTIONS_HTML +
'</select></label>' +
'<label>Chemin (optionnel)<input type="text" id="nf-cond-extra-variable-chemin-' + idx + '" placeholder="ex. .nom ou [0].valeur" value="' + (clause.variable_chemin || '').replace(/"/g, '&quot;') + '" onchange="flowConditionExtraClauses[' + idx + '].variable_chemin = this.value"></label>' +
'</div>' +
'<label>Condition<select id="nf-cond-extra-operator-' + idx + '" onchange="flowConditionExtraClauses[' + idx + '].operator = this.value">' +
CONDITION_OPERATORS_HTML +
'</select></label>' +
'<label>Valeur de comparaison<input type="text" id="nf-cond-extra-value-' + idx + '" placeholder="ex. 1, vrai, 10..." value="' + (clause.value || '').replace(/"/g, '&quot;') + '" onchange="flowConditionExtraClauses[' + idx + '].value = this.value"></label>' +
'<button type="button" class="smallBtn button is-small" onclick="removeFlowConditionClause(' + idx + ')">Retirer cette condition</button>' +
'</div>';
}).join('');
flowConditionExtraClauses.forEach(function(clause, idx) {
var defSel = document.getElementById('nf-cond-extra-definition-' + idx);
if (defSel && clause.definition_id) defSel.value = clause.definition_id;
onFlowClauseDefinitionChange(idx);
var opSel = document.getElementById('nf-cond-extra-operator-' + idx);
if (opSel) opSel.value = clause.operator || 'egal';
var varSel = document.getElementById('nf-cond-extra-variable-' + idx);
if (varSel && clause.variable) varSel.value = clause.variable;
});
}
function onFlowClauseDefinitionChange(idx) {
var defSel = document.getElementById('nf-cond-extra-definition-' + idx);
var rowSel = document.getElementById('nf-cond-extra-row-' + idx);
var fieldSel = document.getElementById('nf-cond-extra-field-' + idx);
if (!defSel || !rowSel || !fieldSel) return;
flowConditionExtraClauses[idx].definition_id = defSel.value;
var info = DEFINITIONS_DATA[defSel.value] || { rows: [], fields: [] };
rowSel.innerHTML = _flowRowOptionsHtml(info);
fieldSel.innerHTML = info.fields.length
? info.fields.map(function(f){ return '<option value="' + f.name + '" data-type="' + f.type + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
: '<option value="">Aucun champ simple</option>';
flowConditionExtraClauses[idx].row_id = rowSel.value;
onFlowClauseFieldChange(idx);
}
function onFlowClauseFieldChange(idx) {
var fieldSel = document.getElementById('nf-cond-extra-field-' + idx);
if (!fieldSel) return;
var opt = fieldSel.options[fieldSel.selectedIndex];
flowConditionExtraClauses[idx].field = fieldSel.value;
flowConditionExtraClauses[idx].field_type = opt ? opt.dataset.type : '';
}
function toggleFlowActionFields() {
var typeSel = document.getElementById('nf-action-type');
var screenSel = document.getElementById('nf-action-target-screen');
var elementFields = document.getElementById('nf-action-element-fields');
var dataFields = document.getElementById('nf-action-data-fields');
var variableFields = document.getElementById('nf-action-variable-fields');
var waitFields = document.getElementById('nf-action-wait-fields');
var eventFields = document.getElementById('nf-action-event-fields');
var addRowFields = document.getElementById('nf-action-add-row-fields');
var isOnglet = typeSel.value === 'activer_onglet';
screenSel.style.display = (typeSel.value === 'aller_a' || typeSel.value === 'ouvrir_ligne') ? 'block' : 'none';
elementFields.style.display = (typeSel.value === 'modifier_element' || isOnglet) ? 'block' : 'none';
dataFields.style.display = typeSel.value === 'modifier_donnee' ? 'block' : 'none';
variableFields.style.display = typeSel.value === 'modifier_variable' ? 'block' : 'none';
waitFields.style.display = typeSel.value === 'attendre' ? 'block' : 'none';
if (eventFields) eventFields.style.display = typeSel.value === 'declencher_evenement' ? 'block' : 'none';
if (addRowFields) addRowFields.style.display = typeSel.value === 'ajouter_ligne' ? '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');
updateDataValuePlaceholder('nf-action-data-operation', 'nf-action-data-value');
updateDataValuePlaceholder('nf-action-variable-operation', 'nf-action-variable-value');
}
// "alea" (Phase 2 — hasard) attend "min,max" dans data_value (voir
// screens/data_actions/compute_operation.py) plutôt qu'un seul nombre
// comme les autres opérations — indice visuel dans le champ plutôt que
// d'ajouter un champ dédié (aucun changement de schéma nécessaire).
function updateDataValuePlaceholder(operationSelectId, valueInputId) {
var opSel = document.getElementById(operationSelectId);
var valueInp = document.getElementById(valueInputId);
if (!opSel || !valueInp) return;
valueInp.placeholder = opSel.value === 'alea' ? 'min,max (ex. 1,6)' : 'Valeur / montant';
}
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 + currentBlockNodes().length * 10, block_id: CURRENT_BLOCK_ID };
if (currentNodeFormType === 'trigger') {
payload.trigger_event = document.getElementById('nf-trigger-event').value;
if (payload.trigger_event === 'evenement') {
payload.trigger_custom_event_id = parseInt(document.getElementById('nf-trigger-custom-event').value, 10);
} else if (payload.trigger_event === 'clavier') {
payload.trigger_key = document.getElementById('nf-trigger-key').value;
} else if (payload.trigger_event === 'minuteur') {
payload.trigger_interval_ms = parseInt(document.getElementById('nf-trigger-interval').value, 10);
} else if (payload.trigger_event !== 'affichage') {
payload.trigger_element_id = parseInt(document.getElementById('nf-trigger-element').value, 10);
}
} else if (currentNodeFormType === 'condition') {
var condSource = document.getElementById('nf-cond-source').value;
payload.cond_source = condSource;
payload.cond_operator = document.getElementById('nf-cond-operator').value;
payload.cond_value = document.getElementById('nf-cond-value').value;
if (condSource === 'variable') {
payload.cond_variable = document.getElementById('nf-cond-variable').value;
payload.cond_variable_chemin = document.getElementById('nf-cond-variable-chemin').value;
} else {
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 : '';
}
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 mainClause = condSource === 'variable'
? { source: 'variable', variable: payload.cond_variable, variable_chemin: payload.cond_variable_chemin,
operator: payload.cond_operator, value: payload.cond_value }
: { source: 'objet', 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 };
var allClauses = [mainClause].concat(flowConditionExtraClauses.map(function(c) {
return c.source === 'variable'
? { source: 'variable', variable: c.variable, variable_chemin: c.variable_chemin, operator: c.operator, value: c.value }
: { source: 'objet', definition_id: parseInt(c.definition_id, 10), row_id: parseInt(c.row_id, 10) || null,
field: c.field, field_type: c.field_type, operator: c.operator, value: c.value };
}));
payload.cond_clauses = JSON.stringify(allClauses);
payload.cond_combinator = document.getElementById('nf-cond-combinator').value;
}
} else if (currentNodeFormType === 'action') {
var actionType = document.getElementById('nf-action-type').value;
payload.action_type = actionType;
if (actionType === 'aller_a' || actionType === 'ouvrir_ligne') {
payload.target_screen_id = parseInt(document.getElementById('nf-action-target-screen').value, 10);
} else if (actionType === 'activer_onglet') {
payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10);
} else if (actionType === 'modifier_element') {
payload.target_element_id = parseInt(document.getElementById('nf-action-target-element').value, 10);
var prop = document.getElementById('nf-action-element-property').value;
payload.element_property = prop;
var isColorProp = prop === 'bg_color' || prop === 'border_color';
var toggleOn = prop !== 'visibilite' && prop !== 'surbrillance' && prop !== 'desactive' && document.getElementById('nf-action-element-toggle').checked;
if (prop === 'visibilite') {
payload.element_value = document.getElementById('nf-action-element-value-visibility').value;
} else if (prop === 'surbrillance') {
payload.element_value = document.getElementById('nf-action-element-value-surbrillance').value;
} else if (prop === 'desactive') {
payload.element_value = document.getElementById('nf-action-element-value-desactive').value;
} else if (prop === 'contenu') {
payload.element_value = document.getElementById('nf-action-element-value-text').value;
} else {
var v1 = isColorProp
? document.getElementById('nf-action-element-value-color').value
: document.getElementById('nf-action-element-value-number').value;
if (toggleOn) {
var v2 = isColorProp
? document.getElementById('nf-action-element-value2-color').value
: document.getElementById('nf-action-element-value2-number').value;
payload.element_value = v1 + '||' + v2;
} else {
payload.element_value = v1;
}
}
} else if (actionType === 'modifier_donnee') {
payload.target_definition_id = parseInt(document.getElementById('nf-action-data-definition').value, 10);
payload.target_row_id = parseInt(document.getElementById('nf-action-data-row').value, 10) || null;
payload.target_field = document.getElementById('nf-action-data-field').value;
payload.data_operation = document.getElementById('nf-action-data-operation').value;
payload.data_value = document.getElementById('nf-action-data-value').value;
} else if (actionType === 'modifier_variable') {
payload.target_variable = document.getElementById('nf-action-variable-name').value;
payload.data_operation = document.getElementById('nf-action-variable-operation').value;
payload.data_value = document.getElementById('nf-action-variable-value').value;
} else if (actionType === 'attendre') {
payload.data_value = document.getElementById('nf-action-wait-seconds').value;
} else if (actionType === 'declencher_evenement') {
// Notification pure : aucun élément/ligne à transmettre (voir
// screens/custom_events/) — c'est à l'écouteur de décider quoi
// faire, avec ses propres réglages habituels.
payload.target_custom_event_id = parseInt(document.getElementById('nf-action-custom-event').value, 10);
} else if (actionType === 'ajouter_ligne') {
// Phase 4 — crée une ligne VIDE ; ses champs se renseignent via un ou
// plusieurs nœuds "Modifier une donnée" enchaînés, ciblant
// FLOW_LAST_INSERTED_ROW_ID ("➕ Dernière ligne ajoutée").
payload.target_definition_id = parseInt(document.getElementById('nf-action-add-row-definition').value, 10);
}
}
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){
ALL_FLOW.nodes.push(node);
closeNodeForm();
renderFlow();
});
}