Refonte du Scenario en arbre de decision (modale dediee, plus de vrai/faux
Build and deploy / test-python (push) Failing after 1m15s
Build and deploy / test-js (push) Successful in 54s
Build and deploy / lint-python (push) Failing after 1m12s
Build and deploy / lint-js (push) Failing after 1m8s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m4s

Retour utilisateur : "une consequence peut mener a d'autres choix et
ainsi de suite, il n'y a pas de notion vrai/faux, il faut une modale
avec la possibilite de construire un veritable arbre de choix
consequence". Remplace le modele plat (situation + 2-4 choix + un seul
bon choix + une consequence terminale) par un vrai graphe de nœuds :
{"title", "nodes": [{"id", "text", "choices": [{"text", "target_id"}]}]}
- nodes[0] est la situation initiale, chaque choix peut pointer vers
n'importe quel autre nœud (branchement, convergence, fins multiples),
un nœud sans choix est une fin de branche valide. Aucune notion de
bonne/mauvaise reponse.

L'arbre se construit desormais dans une modale dediee (trop de
structure pour la colonne etroite du panneau Proprietes) : liste de
nœuds, chaque choix avec un menu deroulant "mene a" listant les autres
nœuds ou "fin de branche". La modale est un composant generique
(.docModal*) independant de tout framework externe.

sanitize_scenario_config degrade silencieusement tout target_id
orphelin (nœud supprime) vers None plutot que de faire echouer le
scenario entier. Le lecteur cote client navigue le graphe nœud par
nœud, le texte du nœud visite remplace le precedent (toujours pas un
Quiz), jusqu'a une fin de branche puis passage au scenario suivant.

Verifie via simulation DOM reelle (jsdom) : navigation ramifiee
(branchement, convergence, fin via nœud vide ET via choix sans cible),
plusieurs arbres a la suite, et l'editeur modal complet (ouverture,
ajout/suppression de nœud avec reparation des references pendantes,
changement de cible, fermeture bouton/fond/Echap).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-20 17:29:24 +02:00
co-authored by Claude Sonnet 5
parent c04a81cfec
commit a529857379
9 changed files with 679 additions and 296 deletions
+71
View File
@@ -1398,6 +1398,77 @@ img.docImage {
padding: 40px;
}
/* ---- Modale générique (utilisée aujourd'hui par l'éditeur d'arbre du
mini-jeu Scénario, voir forgeDocOpenScenarioTreeModal) — overlay +
boîte de dialogue centrée, indépendante de tout framework externe
(aucune dépendance partagée avec game_engine, voir CLAUDE.md). ---- */
.docModalBackdrop {
display: none;
position: fixed;
inset: 0;
background: rgb(11 14 20 / 65%);
z-index: 200;
align-items: center;
justify-content: center;
padding: 24px;
}
.docModalBackdrop.is-open {
display: flex;
}
.docModalDialog {
background: var(--doc-bg-2);
border: 1px solid var(--doc-border);
border-radius: 16px;
width: min(640px, 100%);
max-height: min(80vh, 720px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.docModalHeader {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 20px;
border-bottom: 1px solid var(--doc-border);
}
.docModalTitle {
font-size: 15px;
font-weight: 700;
color: var(--doc-text);
}
.docModalCloseBtn {
border: none;
background: transparent;
color: var(--doc-muted);
font-size: 16px;
cursor: pointer;
padding: 4px;
}
.docModalCloseBtn:hover {
color: var(--doc-text);
}
.docModalBody {
padding: 20px;
overflow-y: auto;
}
/* ---- Scénario : panneau Propriétés — une carte par scénario (titre +
nombre de nœuds + bouton d'ouverture de la modale), l'édition de
l'arbre lui-même se fait entièrement dans la modale (voir
forgeDocRenderScenarioTreeModal). ---- */
.docScenarioCard .docBtnSecondary {
width: 100%;
justify-content: center;
}
/* ===== RESPONSIVE (chrome de l'éditeur lui-même, <900px) ===== */
@media (width <= 900px) {
.docCrumbDim {
+274 -155
View File
@@ -1138,15 +1138,22 @@ function forgeDocInitMotsPlayers() {
/* ---------------------------------------------------------------------
* Scénario — mise en situation RÉELLEMENT interactive en Mode Aperçu :
* l'apprenant lit une situation, choisit une option, découvre la
* conséquence de SON choix ainsi que si c'était le bon, puis passe au
* scénario suivant. Les scénarios se jouent dans l'ORDRE d'écriture
* (jamais mélangés, contrairement à l'Association/Memory/Mots mêlés) :
* ce sont des mises en situation séquentielles. Comme ces trois
* mini-jeux, le dernier scénario reste affiché une fois répondu : seul
* le bouton Recommencer (.docMinigameRestartBar) apparaît, jamais un
* écran de résultat séparé (ça reste le comportement du Quiz). État
* tenu en mémoire, jamais persisté.
* un scénario est un ARBRE DE DÉCISION (voir document_engine/labels/
* scenario_config.py pour la forme exacte), pas une simple question à
* une seule conséquence. L'apprenant lit le texte du nœud courant
* (situation initiale, puis conséquences), choisit une option, et le
* texte du nœud visé REMPLACE l'affichage du nœud précédent (les
* boutons de choix disparaissent avec lui) — et ainsi de suite jusqu'à
* une fin de branche (nœud sans choix). Aucune notion de bonne/mauvaise
* réponse (retour utilisateur du 20/09/2026 : "il n'y a pas de notion
* vrai/faux, l'utilisateur observe les conséquences"). Les scénarios
* (plusieurs arbres indépendants) se jouent dans l'ORDRE d'écriture
* (jamais mélangés, contrairement à l'Association/Memory/Mots mêlés).
* Comme ces trois mini-jeux, le dernier arbre reste affiché une fois une
* fin de branche atteinte : seul le bouton Recommencer
* (.docMinigameRestartBar) apparaît, jamais un écran de résultat séparé
* (ça reste le comportement du Quiz). État tenu en mémoire, jamais
* persisté.
* ------------------------------------------------------------------- */
function forgeDocScenarioPlayerData(playerEl) {
@@ -1158,54 +1165,12 @@ function forgeDocScenarioPlayerData(playerEl) {
}
}
function forgeDocRenderScenarioPlayerScenario(playerEl, state) {
state.answered = false;
const item = state.scenarios[state.current];
const situationEl = playerEl.querySelector('.docScenarioSituation');
const choicesEl = playerEl.querySelector('.docScenarioChoices');
const progEl = playerEl.querySelector('.docScenarioProg');
const nextBtn = playerEl.querySelector('.docScenarioNextBtn');
// Repart d'un bloc "situation" neutre (jamais un résidu des classes
// is-visible/is-ok/is-ko posées par une réponse précédente, voir
// forgeDocScenarioPlayerAnswer ci-dessous).
situationEl.className = 'docScenarioSituation docQuizQuestionText';
situationEl.textContent = item.situation;
choicesEl.innerHTML = '';
nextBtn.classList.remove('is-visible');
playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
item.choices.forEach((choice, idx) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'docQuizOption docScenarioChoice';
btn.textContent = choice.text;
btn.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocScenarioPlayerAnswer(playerEl, state, idx);
});
choicesEl.appendChild(btn);
});
progEl.textContent = `Scénario ${state.current + 1} sur ${state.scenarios.length}`;
function forgeDocScenarioFindNode(tree, nodeId) {
return tree.nodes.find((n) => n.id === nodeId) || tree.nodes[0];
}
function forgeDocScenarioPlayerAnswer(playerEl, state, idx) {
if (state.answered) return;
state.answered = true;
const item = state.scenarios[state.current];
const situationEl = playerEl.querySelector('.docScenarioSituation');
const choicesEl = playerEl.querySelector('.docScenarioChoices');
function forgeDocScenarioReachEnd(playerEl, state) {
const isLast = state.current === state.scenarios.length - 1;
const isCorrect = idx === item.correct_index;
const consequenceText = item.choices[idx].consequence || 'Aucune conséquence renseignée pour ce choix.';
// Ce n'est pas un Quiz : la conséquence REMPLACE la situation (le récit
// avance), et les boutons de choix disparaissent avec elle — jamais un
// encart de feedback séparé pendant que la situation resterait affichée
// (retour utilisateur du 20/09/2026).
situationEl.className = `docScenarioSituation docQuizFeedback is-visible ${isCorrect ? 'is-ok' : 'is-ko'}`;
situationEl.textContent = consequenceText;
choicesEl.innerHTML = '';
if (isLast) {
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
} else {
@@ -1213,24 +1178,71 @@ function forgeDocScenarioPlayerAnswer(playerEl, state, idx) {
}
}
function forgeDocScenarioPlayerChoose(playerEl, state, choice) {
const tree = state.scenarios[state.current];
if (choice.target_id && tree.nodes.some((n) => n.id === choice.target_id)) {
state.currentNodeId = choice.target_id;
forgeDocRenderScenarioNode(playerEl, state);
return;
}
// Choix sans destination valide (fin de branche déclarée sur CE choix,
// ou référence cassée dégradée côté serveur — voir
// sanitize_scenario_config) : le texte du nœud courant reste affiché
// tel quel, seuls les boutons de choix disparaissent.
playerEl.querySelector('.docScenarioChoices').innerHTML = '';
forgeDocScenarioReachEnd(playerEl, state);
}
function forgeDocRenderScenarioNode(playerEl, state) {
const tree = state.scenarios[state.current];
const node = forgeDocScenarioFindNode(tree, state.currentNodeId);
const situationEl = playerEl.querySelector('.docScenarioSituation');
const choicesEl = playerEl.querySelector('.docScenarioChoices');
const progEl = playerEl.querySelector('.docScenarioProg');
situationEl.textContent = node.text;
choicesEl.innerHTML = '';
playerEl.querySelector('.docScenarioNextBtn').classList.remove('is-visible');
playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
progEl.textContent = `Scénario ${state.current + 1} sur ${state.scenarios.length} — ${tree.title}`;
if (!node.choices.length) {
forgeDocScenarioReachEnd(playerEl, state);
return;
}
node.choices.forEach((choice) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'docQuizOption docScenarioChoice';
btn.textContent = choice.text;
btn.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocScenarioPlayerChoose(playerEl, state, choice);
});
choicesEl.appendChild(btn);
});
}
function forgeDocScenarioPlayerNext(playerEl, state) {
state.current += 1;
if (state.current < state.scenarios.length) {
forgeDocRenderScenarioPlayerScenario(playerEl, state);
state.currentNodeId = state.scenarios[state.current].nodes[0].id;
forgeDocRenderScenarioNode(playerEl, state);
}
}
function forgeDocScenarioRestart(playerEl, state) {
state.current = 0;
forgeDocRenderScenarioPlayerScenario(playerEl, state);
state.currentNodeId = state.scenarios[0].nodes[0].id;
forgeDocRenderScenarioNode(playerEl, state);
}
function forgeDocInitScenarioPlayers() {
document.querySelectorAll('.docScenarioPlayer').forEach((playerEl) => {
const scenarios = forgeDocScenarioPlayerData(playerEl);
if (!scenarios.length) return;
const state = { current: 0, answered: false, scenarios };
forgeDocRenderScenarioPlayerScenario(playerEl, state);
if (!scenarios.length || !scenarios[0].nodes || !scenarios[0].nodes.length) return;
const state = { current: 0, scenarios, currentNodeId: scenarios[0].nodes[0].id };
forgeDocRenderScenarioNode(playerEl, state);
playerEl.querySelector('.docScenarioNextBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocScenarioPlayerNext(playerEl, state);
@@ -1844,75 +1856,51 @@ function forgeDocRenderMotsProps(panel, el) {
});
}
/* ---------------------------------------------------------------------
* Scénario — panneau Propriétés : une carte par scénario (titre + nombre
* de nœuds), l'arbre lui-même se construit dans une MODALE dédiée (trop
* de structure — nœuds + choix + destinations — pour la colonne étroite
* du panneau Propriétés, contrairement aux autres mini-jeux). Voir
* forgeDocOpenScenarioTreeModal ci-dessous.
* ------------------------------------------------------------------- */
function forgeDocScenarioNewScenario() {
// Champs pré-remplis (jamais vides) : sanitize_scenario_config
// (routes/document/document_element_update.py) rejette silencieusement
// tout scénario dont la situation est vide, ou tout choix dont le
// texte est vide — même leçon que forgeDocAssociationNewPair/
// forgeDocMemoryNewCard/l'ajout de mot des Mots mêlés.
// Titre et situation initiale pré-remplis (jamais vides) :
// sanitize_scenario_config (routes/document/document_element_update.py)
// rejette silencieusement tout scénario dont le titre est vide, ou
// tout nœud dont le texte est vide — même leçon que
// forgeDocAssociationNewPair/forgeDocMemoryNewCard/l'ajout de mot des
// Mots mêlés.
return {
situation: 'Nouvelle situation',
choices: [
{ text: 'Choix 1', consequence: 'Conséquence du choix 1' },
{ text: 'Choix 2', consequence: 'Conséquence du choix 2' },
],
correct_index: 0,
title: 'Nouveau scénario',
nodes: [{ id: 'n1', text: 'Nouvelle situation', choices: [] }],
};
}
function forgeDocRenderScenarioChoiceHtml(choice, sIndex, cIndex, correctIndex) {
return `
<div class="docScenarioChoiceBlock">
<div class="docQuizChoiceRow">
<input type="radio"
name="docScenarioCorrect${sIndex}"
class="docQuizCorrectRadio docScenarioCorrectRadio"
data-scenario-index="${sIndex}"
data-choice-index="${cIndex}"
${cIndex === correctIndex ? 'checked' : ''}>
<input type="text"
class="docQuizChoiceInput docScenarioChoiceInput"
value="${forgeDocEscapeHtml(choice.text)}"
placeholder="Choix ${cIndex + 1}"
data-scenario-index="${sIndex}"
data-choice-index="${cIndex}">
</div>
<textarea class="docAssocPairInput docScenarioConsequenceInput"
rows="2"
placeholder="Conséquence de ce choix"
data-scenario-index="${sIndex}"
data-choice-index="${cIndex}">${forgeDocEscapeHtml(choice.consequence)}</textarea>
</div>
`;
function forgeDocScenarioNewNode(id) {
return { id, text: 'Nouveau nœud', choices: [] };
}
function forgeDocRenderScenarioHtml(scenario, sIndex) {
const choicesHtml = scenario.choices
.map((c, cIndex) => forgeDocRenderScenarioChoiceHtml(c, sIndex, cIndex, scenario.correct_index))
.join('');
function forgeDocScenarioGenerateNodeId(nodes) {
// Un id JAMAIS réutilisé, même après suppression d'un nœud (sinon un
// choix existant pointant vers un ancien id supprimé pourrait se
// retrouver à pointer vers le NOUVEAU nœud qui hérite du même id, un
// lien créé par erreur plutôt qu'une vraie fin de branche).
const used = nodes.map((n) => Number((n.id.match(/^n(\d+)$/) || [])[1])).filter((n) => !Number.isNaN(n));
const next = (used.length ? Math.max(...used) : 0) + 1;
return `n${next}`;
}
function forgeDocRenderScenarioCardHtml(scenario, sIndex) {
const nodeCount = scenario.nodes.length;
return `
<div class="docQuizQuestion docScenarioCard" data-scenario-index="${sIndex}">
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">Scénario ${sIndex + 1}</span>
<span class="docFieldLabel">${forgeDocEscapeHtml(scenario.title)}</span>
<button type="button" class="docQuizRemoveBtn docScenarioRemoveBtn" data-scenario-index="${sIndex}" title="Supprimer ce scénario">🗑</button>
</div>
<div class="docField">
<textarea class="docScenarioSituationInput"
rows="3"
placeholder="Situation initiale"
data-scenario-index="${sIndex}">${forgeDocEscapeHtml(scenario.situation)}</textarea>
</div>
<div class="docFieldRow">
<label>Nombre de choix</label>
<input type="number"
class="docScenarioChoiceCountInput"
min="2"
max="4"
value="${scenario.choices.length}"
data-scenario-index="${sIndex}"
style="width:60px;">
</div>
<div class="docQuizChoicesList">${choicesHtml}</div>
<div class="docFieldRow"><span class="docFieldLabel">${nodeCount} nœud${nodeCount > 1 ? 's' : ''}</span></div>
<button type="button" class="docBtnSecondary docScenarioEditTreeBtn" data-scenario-index="${sIndex}">✎ Modifier l'arbre</button>
</div>
`;
}
@@ -1936,7 +1924,7 @@ function forgeDocRenderScenarioProps(panel, el) {
</div>
</div>
<div class="docSectionLabel docQuizQuestionsLabel">Scénarios</div>
<div id="docScenarioList">${scenarios.length ? scenarios.map(forgeDocRenderScenarioHtml).join('') : '<div class="docEmptyState">Aucun scénario — ajoute le premier ci-dessous.</div>'}</div>
<div id="docScenarioList">${scenarios.length ? scenarios.map(forgeDocRenderScenarioCardHtml).join('') : '<div class="docEmptyState">Aucun scénario — ajoute le premier ci-dessous.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docScenarioAdd">+ Ajouter un scénario</button>
${forgeDocDeleteButtonHtml()}
`;
@@ -1956,65 +1944,195 @@ function forgeDocRenderScenarioProps(panel, el) {
});
});
panel.querySelectorAll('.docScenarioSituationInput').forEach((input) => {
input.addEventListener('change', (e) => {
const sIndex = Number(input.dataset.scenarioIndex);
patch({ scenarios: scenarios.map((s, i) => (i === sIndex ? { ...s, situation: e.target.value } : s)) });
panel.querySelectorAll('.docScenarioEditTreeBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const sIndex = Number(btn.dataset.scenarioIndex);
forgeDocOpenScenarioTreeModal(el.id, sIndex);
});
});
}
/* ---------------------------------------------------------------------
* 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é.
* ------------------------------------------------------------------- */
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('');
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 forgeDocRenderScenarioTreeModal(elementId, sIndex) {
const el = window.forgeDocState.elementsById[elementId];
const scenario = el && (el.attributes.scenarios || [])[sIndex];
if (!scenario) {
forgeDocCloseScenarioTreeModal();
return;
}
const body = document.getElementById('docScenarioTreeModalBody');
document.getElementById('docScenarioTreeModalTitle').textContent = `Arbre — ${scenario.title}`;
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]);
}
});
}
body.innerHTML = `
<div class="docField">
<label for="docScenarioTreeTitleInput">Nom du scénario</label>
<input type="text" id="docScenarioTreeTitleInput" value="${forgeDocEscapeHtml(scenario.title)}">
</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>
`;
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;
// 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
.filter((_, i) => i !== nIndex)
.map((n) => ({
...n,
choices: n.choices.map((c) => (c.target_id === removedId ? { ...c, target_id: null } : c)),
}));
patch({ nodes });
});
});
panel.querySelectorAll('.docScenarioChoiceCountInput').forEach((input) => {
body.querySelectorAll('.docScenarioTreeTextInput').forEach((input) => {
input.addEventListener('change', (e) => {
const sIndex = Number(input.dataset.scenarioIndex);
const count = Math.max(2, Math.min(4, Number(e.target.value) || 2));
patch({
scenarios: scenarios.map((s, i) => {
if (i !== sIndex) return s;
const choices = s.choices.slice(0, count);
while (choices.length < count) {
choices.push({ text: `Choix ${choices.length + 1}`, consequence: '' });
}
return { ...s, choices, correct_index: s.correct_index < count ? s.correct_index : 0 };
}),
});
const nIndex = Number(input.dataset.nodeIndex);
patch({ nodes: scenario.nodes.map((n, i) => (i === nIndex ? { ...n, text: e.target.value } : n)) });
});
});
panel.querySelectorAll('.docScenarioChoiceInput').forEach((input) => {
input.addEventListener('change', (e) => {
const sIndex = Number(input.dataset.scenarioIndex);
const cIndex = Number(input.dataset.choiceIndex);
body.querySelectorAll('.docScenarioTreeAddChoiceBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const nIndex = Number(btn.dataset.nodeIndex);
patch({
scenarios: scenarios.map((s, i) => (
i === sIndex
? { ...s, choices: s.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) }
: s
nodes: scenario.nodes.map((n, i) => (
i === nIndex ? { ...n, choices: [...n.choices, { text: 'Nouveau choix', target_id: null }] } : n
)),
});
});
});
panel.querySelectorAll('.docScenarioConsequenceInput').forEach((input) => {
input.addEventListener('change', (e) => {
const sIndex = Number(input.dataset.scenarioIndex);
const cIndex = Number(input.dataset.choiceIndex);
body.querySelectorAll('.docScenarioTreeChoiceRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const nIndex = Number(btn.dataset.nodeIndex);
const cIndex = Number(btn.dataset.choiceIndex);
patch({
scenarios: scenarios.map((s, i) => (
i === sIndex
? { ...s, choices: s.choices.map((c, ci) => (ci === cIndex ? { ...c, consequence: e.target.value } : c)) }
: s
nodes: scenario.nodes.map((n, i) => (
i === nIndex ? { ...n, choices: n.choices.filter((_, ci) => ci !== cIndex) } : n
)),
});
});
});
panel.querySelectorAll('.docScenarioCorrectRadio').forEach((radio) => {
radio.addEventListener('change', () => {
const sIndex = Number(radio.dataset.scenarioIndex);
const cIndex = Number(radio.dataset.choiceIndex);
patch({ scenarios: scenarios.map((s, i) => (i === sIndex ? { ...s, correct_index: cIndex } : s)) });
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
)),
});
});
});
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
)),
});
});
});
}
function forgeDocOpenScenarioTreeModal(elementId, sIndex) {
document.getElementById('docScenarioTreeModal').classList.add('is-open');
forgeDocRenderScenarioTreeModal(elementId, sIndex);
}
function forgeDocCloseScenarioTreeModal() {
document.getElementById('docScenarioTreeModal').classList.remove('is-open');
}
function forgeDocBindScenarioTreeModal() {
document.getElementById('docScenarioTreeModalClose').addEventListener('click', forgeDocCloseScenarioTreeModal);
document.getElementById('docScenarioTreeModal').addEventListener('click', (e) => {
if (e.target.id === 'docScenarioTreeModal') forgeDocCloseScenarioTreeModal();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') forgeDocCloseScenarioTreeModal();
});
}
function forgeDocRenderProps(el) {
@@ -2173,6 +2291,7 @@ function forgeDocInit() {
forgeDocBindThemeToggle();
forgeDocBindMobileNav();
forgeDocBindKeyboardShortcuts();
forgeDocBindScenarioTreeModal();
forgeDocUpdateHistoryButtons();
document.getElementById('docUndoBtn').addEventListener('click', forgeDocUndo);