Remplace la modale liste+menus deroulants du Scenario par un vrai graphe visuel
Build and deploy / test-python (push) Successful in 7m32s
Build and deploy / test-js (push) Successful in 49s
Build and deploy / lint-python (push) Successful in 5m21s
Build and deploy / lint-js (push) Failing after 1m12s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 3m53s

Retour utilisateur : "passons a un veritable graphe visuel". Chaque
nœud gagne une position x/y (document_engine/labels/scenario_config.py
: un x/y manquant/invalide retombe sur un quadrillage en cascade derive
de l'index du nœud, jamais (0, 0) pour tous les nœuds qui les
empilerait au meme endroit).

Cote editeur (static/document/js/document-editor.js) : les nœuds sont
des cartes deplacables a la souris sur un canevas (glisser-deposer reel,
meme principe que le glisser des formes libres), les choix relies a une
cible sont dessines comme des fleches SVG etiquetees par leur texte
(jamais un menu deroulant). Editer le texte/les choix d'un nœud se fait
dans un panneau inspecteur (colonne de droite) pour le nœud
selectionne ; relier un choix se fait en cliquant "Relier" puis le nœud
cible sur le graphe (mode connexion, Echap annule sans fermer la
modale). La modale generique (.docModal*) est agrandie specifiquement
pour ce graphe (jusqu'a 1180px) sans toucher sa taille par defaut.

Verifie via simulation DOM reelle (jsdom) : rendu des nœuds/positions,
glisser-deposer avec persistance au relachement, traces des fleches SVG
+ etiquettes, workflow complet du mode connexion, suppression d'un nœud
avec reparation des references pendantes, et les 3 façons de fermer la
modale (bouton/fond/Echap) y compris l'annulation du mode connexion
sans fermer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-21 09:03:47 +02:00
co-authored by Claude Sonnet 5
parent 7e504b7865
commit d92f75a803
6 changed files with 777 additions and 159 deletions
+432 -135
View File
@@ -1873,12 +1873,16 @@ function forgeDocScenarioNewScenario() {
// Mots mêlés.
return {
title: 'Nouveau scénario',
nodes: [{ id: 'n1', text: 'Nouvelle situation', choices: [] }],
nodes: [{
id: 'n1', text: 'Nouvelle situation', x: 40, y: 40, choices: [],
}],
};
}
function forgeDocScenarioNewNode(id) {
return { id, text: 'Nouveau nœud', choices: [] };
function forgeDocScenarioNewNode(id, x, y) {
return {
id, text: 'Nouveau nœud', x, y, choices: [],
};
}
function forgeDocScenarioGenerateNodeId(nodes) {
@@ -1953,176 +1957,463 @@ function forgeDocRenderScenarioProps(panel, el) {
}
/* ---------------------------------------------------------------------
* Scénario — modale de construction de l'arbre : une carte par nœud
* (texte + liste de choix), chaque choix a un menu déroulant "mène à"
* listant les autres nœuds du même scénario (ou "— Fin de branche —").
* La situation initiale (nodes[0]) n'est jamais supprimable depuis ici.
* Persistance identique aux autres panneaux : chaque modification
* envoie l'état COMPLET du scénario au serveur (voir patch ci-dessous),
* jamais un état simulé côté client qui pourrait diverger de ce que
* sanitize_scenario_config a réellement accepté.
* Scénario — modale de construction de l'arbre : un VRAI graphe visuel
* (retour utilisateur du 21/09/2026 : "passons à un véritable graphe
* visuel", remplace la première version en liste de nœuds + menus
* déroulants). Les nœuds sont des cartes positionnées/déplaçables à la
* souris (glisser, comme les formes libres du canevas — voir
* forgeDocBindShapeDrag) ; les choix qui ont une destination sont
* dessinés comme des flèches SVG reliant les deux nœuds, étiquetées par
* le texte du choix. Sélectionner un nœud (clic) ouvre son formulaire
* d'édition (texte + choix) dans l'inspecteur (colonne de droite) —
* relier un choix à un autre nœud se fait en cliquant "Relier" puis en
* cliquant le nœud cible sur le graphe (mode "connexion", Échap annule).
* La situation initiale (nodes[0]) reste déplaçable mais jamais
* supprimable. Persistance identique aux autres panneaux : chaque
* modification envoie l'état COMPLET du scénario au serveur (voir
* forgeDocScenarioGraphPatch), jamais un état simulé côté client qui
* pourrait diverger de ce que sanitize_scenario_config a réellement
* accepté — SAUF la position pendant un glisser, mise à jour localement
* en continu pour rester fluide, et persistée en un seul appel au
* relâchement de la souris (même principe que forgeDocBindShapeDrag).
* ------------------------------------------------------------------- */
function forgeDocRenderScenarioNodeHtml(node, nIndex, allNodes, isRoot) {
const choicesHtml = node.choices.map((choice, cIndex) => `
<div class="docScenarioChoiceBlock">
<div class="docQuizChoiceRow">
<input type="text"
class="docQuizChoiceInput docScenarioTreeChoiceInput"
value="${forgeDocEscapeHtml(choice.text)}"
placeholder="Texte du choix"
data-node-index="${nIndex}"
data-choice-index="${cIndex}">
<button type="button" class="docQuizRemoveBtn docScenarioTreeChoiceRemoveBtn" data-node-index="${nIndex}" data-choice-index="${cIndex}" title="Supprimer ce choix">🗑</button>
</div>
<div class="docFieldRow">
<label>Mène à</label>
<select class="docScenarioTreeTargetSelect" data-node-index="${nIndex}" data-choice-index="${cIndex}">
<option value="">— Fin de branche —</option>
${allNodes.filter((n) => n.id !== node.id).map((n) => `<option value="${n.id}" ${choice.target_id === n.id ? 'selected' : ''}>${forgeDocEscapeHtml(n.text.slice(0, 40) || n.id)}</option>`).join('')}
</select>
</div>
</div>
`).join('');
let forgeDocScenarioGraphState = null;
let forgeDocScenarioGraphDragBound = false;
return `
<div class="docQuizQuestion docScenarioTreeNode" data-node-index="${nIndex}">
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">${isRoot ? 'Situation initiale' : `Nœud ${nIndex + 1}`}</span>
${isRoot ? '' : `<button type="button" class="docQuizRemoveBtn docScenarioTreeNodeRemoveBtn" data-node-index="${nIndex}" title="Supprimer ce nœud">🗑</button>`}
</div>
<div class="docField">
<textarea class="docScenarioTreeTextInput" rows="3" data-node-index="${nIndex}" placeholder="Texte (situation ou conséquence)">${forgeDocEscapeHtml(node.text)}</textarea>
</div>
<div class="docSectionLabel docQuizQuestionsLabel">Choix</div>
<div class="docQuizChoicesList">${choicesHtml || '<div class="docEmptyState">Aucun choix — fin de cette branche.</div>'}</div>
<button type="button" class="docBtnSecondary docScenarioTreeAddChoiceBtn" data-node-index="${nIndex}" ${node.choices.length >= 4 ? 'disabled' : ''}>+ Ajouter un choix</button>
</div>
`;
function forgeDocScenarioGraphNextPosition(nodes) {
// Même formule que _scenario_node_fallback_position côté serveur
// (document_engine/labels/scenario_config.py) — pas requis que ça
// matche exactement puisque le round-trip serveur fait foi de toute
// façon, mais évite un nœud visiblement mal placé le temps de la
// réponse.
const cols = 4;
const stepX = 220;
const stepY = 160;
const margin = 40;
const index = nodes.length;
return { x: margin + (index % cols) * stepX, y: margin + Math.floor(index / cols) * stepY };
}
function forgeDocRenderScenarioTreeModal(elementId, sIndex) {
const el = window.forgeDocState.elementsById[elementId];
const scenario = el && (el.attributes.scenarios || [])[sIndex];
if (!scenario) {
forgeDocCloseScenarioTreeModal();
function forgeDocScenarioNodeLabel(nIndex) {
return nIndex === 0 ? 'Départ' : `Nœud ${nIndex + 1}`;
}
function forgeDocScenarioGraphPatch(partial) {
const gs = forgeDocScenarioGraphState;
if (!gs) return;
const el = window.forgeDocState.elementsById[gs.elementId];
const scenarios = el.attributes.scenarios.map((s, i) => (i === gs.sIndex ? { ...s, ...partial } : s));
forgeDocUpdateAttributes(gs.elementId, { ...el.attributes, scenarios }).then(() => {
forgeDocRenderScenarioTreeModal();
// Le panneau Propriétés affiche le nombre de nœuds par carte : le
// tenir synchronisé pendant que la modale reste ouverte.
if (window.forgeDocState.selectedId === gs.elementId) {
forgeDocRenderProps(window.forgeDocState.elementsById[gs.elementId]);
}
});
}
function forgeDocScenarioGraphNodeRect(nIndex) {
const gs = forgeDocScenarioGraphState;
const nodeEl = gs.nodeEls[nIndex];
if (!nodeEl) return null;
return {
x: nodeEl.offsetLeft, y: nodeEl.offsetTop, width: nodeEl.offsetWidth, height: nodeEl.offsetHeight,
};
}
function forgeDocScenarioGraphRedrawEdges() {
const gs = forgeDocScenarioGraphState;
const { svgEl, innerEl } = gs;
svgEl.innerHTML = '';
innerEl.querySelectorAll('.docScenarioGraphEdgeLabel').forEach((n) => n.remove());
const svgNs = 'http://www.w3.org/2000/svg';
const defs = document.createElementNS(svgNs, 'defs');
const marker = document.createElementNS(svgNs, 'marker');
marker.setAttribute('id', 'docScenarioArrow');
marker.setAttribute('viewBox', '0 0 10 10');
marker.setAttribute('refX', '9');
marker.setAttribute('refY', '5');
marker.setAttribute('markerWidth', '7');
marker.setAttribute('markerHeight', '7');
marker.setAttribute('orient', 'auto-start-reverse');
const arrowPath = document.createElementNS(svgNs, 'path');
arrowPath.setAttribute('d', 'M0,0 L10,5 L0,10 z');
arrowPath.setAttribute('fill', 'var(--doc-muted)');
marker.appendChild(arrowPath);
defs.appendChild(marker);
svgEl.appendChild(defs);
gs.scenario.nodes.forEach((node, nIndex) => {
const fromRect = forgeDocScenarioGraphNodeRect(nIndex);
if (!fromRect) return;
node.choices.forEach((choice) => {
if (!choice.target_id) return;
const targetIndex = gs.scenario.nodes.findIndex((n) => n.id === choice.target_id);
if (targetIndex === -1) return;
const toRect = forgeDocScenarioGraphNodeRect(targetIndex);
if (!toRect) return;
const x1 = fromRect.x + fromRect.width / 2;
const y1 = fromRect.y + fromRect.height / 2;
const x2 = toRect.x + toRect.width / 2;
const y2 = toRect.y + toRect.height / 2;
const line = document.createElementNS(svgNs, 'line');
line.setAttribute('class', 'docScenarioGraphEdge');
line.setAttribute('x1', String(x1));
line.setAttribute('y1', String(y1));
line.setAttribute('x2', String(x2));
line.setAttribute('y2', String(y2));
line.setAttribute('marker-end', 'url(#docScenarioArrow)');
svgEl.appendChild(line);
const label = document.createElement('div');
label.className = 'docScenarioGraphEdgeLabel';
label.style.left = `${(x1 + x2) / 2}px`;
label.style.top = `${(y1 + y2) / 2}px`;
label.textContent = choice.text;
innerEl.appendChild(label);
});
});
}
function forgeDocScenarioGraphUpdateHint() {
const gs = forgeDocScenarioGraphState;
const hintEl = document.getElementById('docScenarioGraphHint');
if (!hintEl) return;
if (gs.connecting) {
hintEl.textContent = 'Cliquez sur le nœud cible pour relier ce choix, ou appuyez sur Échap pour annuler.';
hintEl.classList.add('is-connecting');
gs.canvasEl.classList.add('is-connecting');
} else {
hintEl.textContent = 'Glissez un nœud pour le déplacer. Cliquez un nœud pour modifier son texte et ses choix.';
hintEl.classList.remove('is-connecting');
gs.canvasEl.classList.remove('is-connecting');
}
}
function forgeDocScenarioGraphSelectNode(nIndex) {
const gs = forgeDocScenarioGraphState;
gs.selectedNodeIndex = nIndex;
Object.values(gs.nodeEls).forEach((el) => el.classList.remove('is-selected'));
if (gs.nodeEls[nIndex]) gs.nodeEls[nIndex].classList.add('is-selected');
forgeDocScenarioGraphRenderInspector();
}
function forgeDocScenarioGraphCancelConnect() {
const gs = forgeDocScenarioGraphState;
gs.connecting = null;
Object.values(gs.nodeEls).forEach((el) => el.classList.remove('is-connect-target'));
forgeDocScenarioGraphUpdateHint();
forgeDocScenarioGraphRenderInspector();
}
function forgeDocScenarioGraphStartConnect(nIndex, cIndex) {
const gs = forgeDocScenarioGraphState;
gs.connecting = { nodeIndex: nIndex, choiceIndex: cIndex };
Object.entries(gs.nodeEls).forEach(([i, el]) => {
el.classList.toggle('is-connect-target', Number(i) !== nIndex);
});
forgeDocScenarioGraphUpdateHint();
forgeDocScenarioGraphRenderInspector();
}
function forgeDocScenarioGraphCompleteConnect(targetNodeIndex) {
const gs = forgeDocScenarioGraphState;
const { nodeIndex, choiceIndex } = gs.connecting;
if (targetNodeIndex === nodeIndex) {
forgeDocScenarioGraphCancelConnect();
return;
}
const body = document.getElementById('docScenarioTreeModalBody');
document.getElementById('docScenarioTreeModalTitle').textContent = `Arbre — ${scenario.title}`;
const targetId = gs.scenario.nodes[targetNodeIndex].id;
gs.connecting = null;
const nodes = gs.scenario.nodes.map((n, i) => (
i === nodeIndex
? { ...n, choices: n.choices.map((c, ci) => (ci === choiceIndex ? { ...c, target_id: targetId } : c)) }
: n
));
forgeDocScenarioGraphPatch({ nodes });
}
function patch(partial) {
const scenarios = el.attributes.scenarios.map((s, i) => (i === sIndex ? { ...s, ...partial } : s));
forgeDocUpdateAttributes(elementId, { ...el.attributes, scenarios }).then(() => {
forgeDocRenderScenarioTreeModal(elementId, sIndex);
// Le panneau Propriétés affiche le nombre de nœuds par carte :
// le tenir synchronisé pendant que la modale reste ouverte.
if (window.forgeDocState.selectedId === elementId) {
forgeDocRenderProps(window.forgeDocState.elementsById[elementId]);
}
});
function forgeDocScenarioGraphNodeMouseDown(e, nIndex) {
e.preventDefault();
e.stopPropagation();
const gs = forgeDocScenarioGraphState;
if (!gs) return;
if (gs.connecting) {
forgeDocScenarioGraphCompleteConnect(nIndex);
return;
}
forgeDocScenarioGraphSelectNode(nIndex);
const node = gs.scenario.nodes[nIndex];
gs.drag = {
nIndex, startX: e.clientX, startY: e.clientY, origX: node.x, origY: node.y, lastX: node.x, lastY: node.y,
};
}
body.innerHTML = `
<div class="docField">
<label for="docScenarioTreeTitleInput">Nom du scénario</label>
<input type="text" id="docScenarioTreeTitleInput" value="${forgeDocEscapeHtml(scenario.title)}">
function forgeDocScenarioGraphBindGlobalDrag() {
// Un SEUL listener document-level pour toute la page (même raison que
// forgeDocMotsBindGlobalMouseup) : la modale peut se ré-ouvrir
// plusieurs fois par session sans jamais accumuler de listeners.
if (forgeDocScenarioGraphDragBound) return;
forgeDocScenarioGraphDragBound = true;
document.addEventListener('mousemove', (e) => {
const gs = forgeDocScenarioGraphState;
if (!gs || !gs.drag) return;
const dx = e.clientX - gs.drag.startX;
const dy = e.clientY - gs.drag.startY;
const newX = Math.max(0, gs.drag.origX + dx);
const newY = Math.max(0, gs.drag.origY + dy);
gs.drag.lastX = newX;
gs.drag.lastY = newY;
const nodeEl = gs.nodeEls[gs.drag.nIndex];
if (nodeEl) {
nodeEl.style.left = `${newX}px`;
nodeEl.style.top = `${newY}px`;
}
forgeDocScenarioGraphRedrawEdges();
});
document.addEventListener('mouseup', () => {
const gs = forgeDocScenarioGraphState;
if (!gs || !gs.drag) return;
const {
nIndex, lastX, lastY, origX, origY,
} = gs.drag;
gs.drag = null;
if (lastX === origX && lastY === origY) return;
const nodes = gs.scenario.nodes.map((n, i) => (i === nIndex ? { ...n, x: lastX, y: lastY } : n));
forgeDocScenarioGraphPatch({ nodes });
});
}
function forgeDocScenarioGraphBuildNodeEl(node, nIndex, gs) {
const div = document.createElement('div');
div.className = 'docScenarioGraphNode';
if (nIndex === 0) div.classList.add('is-root');
if (nIndex === gs.selectedNodeIndex) div.classList.add('is-selected');
if (gs.connecting && gs.connecting.nodeIndex !== nIndex) div.classList.add('is-connect-target');
div.style.left = `${node.x}px`;
div.style.top = `${node.y}px`;
div.dataset.nodeIndex = String(nIndex);
const labelEl = document.createElement('div');
labelEl.className = 'docScenarioGraphNodeLabel';
labelEl.textContent = forgeDocScenarioNodeLabel(nIndex);
const textEl = document.createElement('div');
textEl.className = 'docScenarioGraphNodeText';
textEl.textContent = node.text;
div.appendChild(labelEl);
div.appendChild(textEl);
div.addEventListener('mousedown', (e) => forgeDocScenarioGraphNodeMouseDown(e, nIndex));
return div;
}
function forgeDocScenarioGraphRenderInspector() {
const gs = forgeDocScenarioGraphState;
const inspector = document.getElementById('docScenarioGraphInspector');
if (!inspector) return;
const nIndex = gs.selectedNodeIndex;
const node = gs.scenario.nodes[nIndex];
if (!node) {
inspector.innerHTML = '<div class="docScenarioGraphInspectorEmpty">Sélectionnez un nœud sur le graphe.</div>';
return;
}
const isRoot = nIndex === 0;
const choicesHtml = node.choices.map((choice, cIndex) => {
const targetIndex = choice.target_id ? gs.scenario.nodes.findIndex((n) => n.id === choice.target_id) : -1;
const isConnectingThis = !!gs.connecting
&& gs.connecting.nodeIndex === nIndex && gs.connecting.choiceIndex === cIndex;
return `
<div class="docScenarioChoiceBlock">
<div class="docQuizChoiceRow">
<input type="text"
class="docQuizChoiceInput docScenarioTreeChoiceInput"
value="${forgeDocEscapeHtml(choice.text)}"
placeholder="Texte du choix"
data-choice-index="${cIndex}">
<button type="button" class="docQuizRemoveBtn docScenarioTreeChoiceRemoveBtn" data-choice-index="${cIndex}" title="Supprimer ce choix">🗑</button>
</div>
<div class="docScenarioChoiceTargetRow">
<span class="docScenarioChoiceTargetLabel">${targetIndex === -1 ? '— Fin de branche —' : `→ ${forgeDocEscapeHtml(forgeDocScenarioNodeLabel(targetIndex))}`}</span>
<button type="button" class="docScenarioChoiceLinkBtn ${isConnectingThis ? 'is-active' : ''}" data-choice-index="${cIndex}" data-action="link">${isConnectingThis ? 'Cliquez un nœud…' : 'Relier'}</button>
${targetIndex === -1 ? '' : `<button type="button" class="docScenarioChoiceLinkBtn" data-choice-index="${cIndex}" data-action="unlink">Dissocier</button>`}
</div>
</div>
`;
}).join('');
inspector.innerHTML = `
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">${forgeDocScenarioNodeLabel(nIndex)}</span>
${isRoot ? '' : '<button type="button" class="docQuizRemoveBtn" id="docScenarioTreeNodeRemoveBtn" title="Supprimer ce nœud">🗑</button>'}
</div>
<div id="docScenarioTreeNodesList">${scenario.nodes.map((n, i) => forgeDocRenderScenarioNodeHtml(n, i, scenario.nodes, i === 0)).join('')}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn docScenarioTreeAddNodeBtn">+ Ajouter un nœud</button>
<div class="docField">
<textarea id="docScenarioTreeTextInput" rows="4" placeholder="Texte (situation ou conséquence)">${forgeDocEscapeHtml(node.text)}</textarea>
</div>
<div class="docSectionLabel docQuizQuestionsLabel">Choix</div>
<div class="docQuizChoicesList">${choicesHtml || '<div class="docEmptyState">Aucun choix — fin de cette branche.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docScenarioTreeAddChoiceBtn" ${node.choices.length >= 4 ? 'disabled' : ''}>+ Ajouter un choix</button>
`;
document.getElementById('docScenarioTreeTitleInput').addEventListener('change', (e) => patch({ title: e.target.value }));
body.querySelector('.docScenarioTreeAddNodeBtn').addEventListener('click', () => {
const newId = forgeDocScenarioGenerateNodeId(scenario.nodes);
patch({ nodes: [...scenario.nodes, forgeDocScenarioNewNode(newId)] });
});
body.querySelectorAll('.docScenarioTreeNodeRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const nIndex = Number(btn.dataset.nodeIndex);
const removedId = scenario.nodes[nIndex].id;
const removeBtn = document.getElementById('docScenarioTreeNodeRemoveBtn');
if (removeBtn) {
removeBtn.addEventListener('click', () => {
const removedId = node.id;
// Un choix qui pointait vers ce nœud devient "fin de branche"
// (jamais une référence morte) — même filet de sécurité que
// sanitize_scenario_config côté serveur.
const nodes = scenario.nodes
const nodes = gs.scenario.nodes
.filter((_, i) => i !== nIndex)
.map((n) => ({
...n,
choices: n.choices.map((c) => (c.target_id === removedId ? { ...c, target_id: null } : c)),
}));
patch({ nodes });
gs.selectedNodeIndex = 0;
forgeDocScenarioGraphPatch({ nodes });
});
}
document.getElementById('docScenarioTreeTextInput').addEventListener('change', (e) => {
const nodes = gs.scenario.nodes.map((n, i) => (i === nIndex ? { ...n, text: e.target.value } : n));
forgeDocScenarioGraphPatch({ nodes });
});
body.querySelectorAll('.docScenarioTreeTextInput').forEach((input) => {
document.getElementById('docScenarioTreeAddChoiceBtn').addEventListener('click', () => {
const nodes = gs.scenario.nodes.map((n, i) => (
i === nIndex ? { ...n, choices: [...n.choices, { text: 'Nouveau choix', target_id: null }] } : n
));
forgeDocScenarioGraphPatch({ nodes });
});
inspector.querySelectorAll('.docScenarioTreeChoiceInput').forEach((input) => {
input.addEventListener('change', (e) => {
const nIndex = Number(input.dataset.nodeIndex);
patch({ nodes: scenario.nodes.map((n, i) => (i === nIndex ? { ...n, text: e.target.value } : n)) });
});
});
body.querySelectorAll('.docScenarioTreeAddChoiceBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const nIndex = Number(btn.dataset.nodeIndex);
patch({
nodes: scenario.nodes.map((n, i) => (
i === nIndex ? { ...n, choices: [...n.choices, { text: 'Nouveau choix', target_id: null }] } : n
)),
});
});
});
body.querySelectorAll('.docScenarioTreeChoiceRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const nIndex = Number(btn.dataset.nodeIndex);
const cIndex = Number(btn.dataset.choiceIndex);
patch({
nodes: scenario.nodes.map((n, i) => (
i === nIndex ? { ...n, choices: n.choices.filter((_, ci) => ci !== cIndex) } : n
)),
});
});
});
body.querySelectorAll('.docScenarioTreeChoiceInput').forEach((input) => {
input.addEventListener('change', (e) => {
const nIndex = Number(input.dataset.nodeIndex);
const cIndex = Number(input.dataset.choiceIndex);
patch({
nodes: scenario.nodes.map((n, i) => (
i === nIndex
? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) }
: n
)),
});
const nodes = gs.scenario.nodes.map((n, i) => (
i === nIndex
? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) }
: n
));
forgeDocScenarioGraphPatch({ nodes });
});
});
body.querySelectorAll('.docScenarioTreeTargetSelect').forEach((select) => {
select.addEventListener('change', (e) => {
const nIndex = Number(select.dataset.nodeIndex);
const cIndex = Number(select.dataset.choiceIndex);
const targetId = e.target.value || null;
patch({
nodes: scenario.nodes.map((n, i) => (
i === nIndex
? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, target_id: targetId } : c)) }
: n
)),
});
inspector.querySelectorAll('.docScenarioTreeChoiceRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const cIndex = Number(btn.dataset.choiceIndex);
const nodes = gs.scenario.nodes.map((n, i) => (
i === nIndex ? { ...n, choices: n.choices.filter((_, ci) => ci !== cIndex) } : n
));
forgeDocScenarioGraphPatch({ nodes });
});
});
inspector.querySelectorAll('.docScenarioChoiceLinkBtn[data-action="link"]').forEach((btn) => {
btn.addEventListener('click', () => {
const cIndex = Number(btn.dataset.choiceIndex);
if (gs.connecting && gs.connecting.nodeIndex === nIndex && gs.connecting.choiceIndex === cIndex) {
forgeDocScenarioGraphCancelConnect();
} else {
forgeDocScenarioGraphStartConnect(nIndex, cIndex);
}
});
});
inspector.querySelectorAll('.docScenarioChoiceLinkBtn[data-action="unlink"]').forEach((btn) => {
btn.addEventListener('click', () => {
const cIndex = Number(btn.dataset.choiceIndex);
const nodes = gs.scenario.nodes.map((n, i) => (
i === nIndex
? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, target_id: null } : c)) }
: n
));
forgeDocScenarioGraphPatch({ nodes });
});
});
}
function forgeDocRenderScenarioTreeModal() {
const gs = forgeDocScenarioGraphState;
if (!gs) return;
const el = window.forgeDocState.elementsById[gs.elementId];
const scenario = el && (el.attributes.scenarios || [])[gs.sIndex];
if (!scenario) {
forgeDocCloseScenarioTreeModal();
return;
}
gs.scenario = scenario;
if (gs.selectedNodeIndex >= scenario.nodes.length) gs.selectedNodeIndex = 0;
if (gs.connecting && gs.connecting.nodeIndex >= scenario.nodes.length) gs.connecting = null;
document.getElementById('docScenarioTreeModalTitle').textContent = `Arbre — ${scenario.title}`;
const body = document.getElementById('docScenarioTreeModalBody');
body.innerHTML = `
<div class="docScenarioGraphTitleRow">
<input type="text"
id="docScenarioTreeTitleInput"
value="${forgeDocEscapeHtml(scenario.title)}"
placeholder="Nom du scénario">
</div>
<div class="docScenarioGraphToolbar">
<button type="button" class="docBtnSecondary" id="docScenarioGraphAddNode">+ Ajouter un nœud</button>
<span class="docScenarioGraphHint" id="docScenarioGraphHint"></span>
</div>
<div class="docScenarioGraphSplit">
<div class="docScenarioGraphCanvas" id="docScenarioGraphCanvas">
<div class="docScenarioGraphInner" id="docScenarioGraphInner">
<svg class="docScenarioGraphSvg" id="docScenarioGraphSvg"></svg>
</div>
</div>
<div class="docScenarioGraphInspector" id="docScenarioGraphInspector"></div>
</div>
`;
document.getElementById('docScenarioTreeTitleInput').addEventListener('change', (e) => {
forgeDocScenarioGraphPatch({ title: e.target.value });
});
document.getElementById('docScenarioGraphAddNode').addEventListener('click', () => {
const newId = forgeDocScenarioGenerateNodeId(scenario.nodes);
const pos = forgeDocScenarioGraphNextPosition(scenario.nodes);
gs.selectedNodeIndex = scenario.nodes.length;
forgeDocScenarioGraphPatch({ nodes: [...scenario.nodes, forgeDocScenarioNewNode(newId, pos.x, pos.y)] });
});
gs.canvasEl = document.getElementById('docScenarioGraphCanvas');
gs.innerEl = document.getElementById('docScenarioGraphInner');
gs.svgEl = document.getElementById('docScenarioGraphSvg');
gs.nodeEls = {};
scenario.nodes.forEach((node, nIndex) => {
const nodeEl = forgeDocScenarioGraphBuildNodeEl(node, nIndex, gs);
gs.innerEl.appendChild(nodeEl);
gs.nodeEls[nIndex] = nodeEl;
});
gs.canvasEl.addEventListener('click', (e) => {
if (gs.connecting && (e.target === gs.canvasEl || e.target === gs.innerEl)) {
forgeDocScenarioGraphCancelConnect();
}
});
forgeDocScenarioGraphRedrawEdges();
forgeDocScenarioGraphUpdateHint();
forgeDocScenarioGraphRenderInspector();
}
function forgeDocOpenScenarioTreeModal(elementId, sIndex) {
document.getElementById('docScenarioTreeModal').classList.add('is-open');
forgeDocRenderScenarioTreeModal(elementId, sIndex);
forgeDocScenarioGraphState = {
elementId, sIndex, selectedNodeIndex: 0, connecting: null, drag: null, nodeEls: {},
};
forgeDocScenarioGraphBindGlobalDrag();
forgeDocRenderScenarioTreeModal();
}
function forgeDocCloseScenarioTreeModal() {
document.getElementById('docScenarioTreeModal').classList.remove('is-open');
forgeDocScenarioGraphState = null;
}
function forgeDocBindScenarioTreeModal() {
@@ -2131,7 +2422,13 @@ function forgeDocBindScenarioTreeModal() {
if (e.target.id === 'docScenarioTreeModal') forgeDocCloseScenarioTreeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') forgeDocCloseScenarioTreeModal();
if (e.key !== 'Escape') return;
const gs = forgeDocScenarioGraphState;
if (gs && gs.connecting) {
forgeDocScenarioGraphCancelConnect();
return;
}
forgeDocCloseScenarioTreeModal();
});
}