screens/flow/constants.py : TRIGGER_EVENTS += "clavier" (À l'appui sur une touche) et "minuteur" (Toutes les X millisecondes) — ni élément ni écran précis pour les deux, comme "evenement" déjà en place. FLOW_NODE_FIELDS += trigger_key/trigger_interval_ms. ensure_flow_schema.py : ALTER TABLE pour les 2 colonnes (patron trigger_custom_event_id). templates/screen_edit.html + static/js/screen_edit/flow-editor.js : - "clavier" : un champ "Touche à surveiller" qui capture lui-même la touche pressée (onkeydown sur l'input, captureFlowTriggerKey()) plutôt que de faire deviner la syntaxe attendue (ev.key du navigateur, ex. "ArrowUp", "a", " " pour Espace). - "minuteur" : un simple champ numérique (millisecondes). - nodeLabel() affiche "⌨️ Touche « X »"/"⏱️ Toutes les N ms" sur le nœud. static/js/play/triggers.js (moteur de jeu) : - bindKeyboardTriggers() : UN SEUL window.addEventListener('keydown', ...) posé une fois pour tout le jeu (voir l'amorçage en fin de templates/play.html) — même patron de scan global que dispatchGameEvent() pour "Sur un événement personnalisé". - runScreenTimerTriggers(screenId) : géré PAR ÉCRAN (appelé depuis showScreen(), static/js/play/screens.js) — démarre les setInterval des nœuds "minuteur" de l'écran affiché, arrête d'abord tous ceux de l'affichage précédent (même principe que runAnimationTimeline) pour ne jamais accumuler des minuteurs sur des écrans quittés. Vérifié : 255 tests passent (5 nouveaux, dont un qui verrouille que la touche Espace — très probablement utilisée en jeu — n'est pas filtrée comme une valeur "vide" par add_flow_node.py), 13 tests node:test toujours au vert, syntaxe JS validée sur tous les fichiers de static/js/play/ et static/js/screen_edit/. Comme le reste du graphe de logique côté client, le comportement RÉEL d'un keydown/setInterval n'est pas testable sans navigateur — test manuel recommandé (touche assignée à un saut, minuteur faisant avancer un compteur). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
656 lines
35 KiB
JavaScript
656 lines
35 KiB
JavaScript
// ---------- 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 : '?') + ' »';
|
|
}
|
|
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;
|
|
|
|
function _flowRowOptionsHtml(info) {
|
|
var options = '<option value="' + FLOW_CLICKED_ROW_ID + '">🖱️ Ligne cliquée (Répéteur)</option>';
|
|
options += info.rows.length
|
|
? info.rows.map(function(r){ return '<option value="' + r.id + '">' + r.label + '</option>'; }).join('')
|
|
: '<option value="" disabled>Aucune donnée dans cet objet</option>';
|
|
return options;
|
|
}
|
|
|
|
function onFlowDefinitionChange(prefix) {
|
|
var defSel = document.getElementById(prefix === 'cond' ? 'nf-cond-definition' : 'nf-action-data-definition');
|
|
if (!defSel) return;
|
|
var info = DEFINITIONS_DATA[defSel.value] || { rows: [], fields: [] };
|
|
var rowSel = document.getElementById(prefix === 'cond' ? 'nf-cond-row' : 'nf-action-data-row');
|
|
var fieldSel = document.getElementById(prefix === 'cond' ? 'nf-cond-field' : 'nf-action-data-field');
|
|
rowSel.innerHTML = _flowRowOptionsHtml(info);
|
|
fieldSel.innerHTML = info.fields.length
|
|
? info.fields.map(function(f){ return '<option value="' + f.name + '" data-type="' + f.type + '">' + f.name + ' (' + f.type + ')</option>'; }).join('')
|
|
: '<option value="">Aucun champ simple</option>';
|
|
}
|
|
|
|
function onFlowFieldChange() {
|
|
// Pas d'action nécessaire ici pour l'instant : le type du champ est relu
|
|
// directement sur l'option sélectionnée au moment de la création du nœud.
|
|
}
|
|
|
|
// 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, '"') + '" 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, '"') + '" 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 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';
|
|
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);
|
|
}
|
|
}
|
|
|
|
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();
|
|
});
|
|
}
|
|
|