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
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:
co-authored by
Claude Sonnet 5
parent
7e504b7865
commit
d92f75a803
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user