Files
Forge-Engine/static/document/js/document-editor.js
T
williamandClaude Sonnet 5 c04a81cfec
Build and deploy / test-python (push) Failing after 1m2s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / lint-python (push) Failing after 58s
Build and deploy / lint-js (push) Failing after 51s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m1s
La consequence du Scenario remplace la situation, pas un feedback Quiz
Retour utilisateur : "ce n'est pas un quiz, la consequence s'affiche a
la place de la situation precedente". L'ancien design affichait la
situation ET les choix en permanence avec un encart de feedback separe
en dessous (calque sur le Quiz). Desormais un seul bloc de texte
(.docScenarioSituation) sert successivement a la situation PUIS, une
fois un choix fait, a la consequence a sa place ; les boutons de choix
disparaissent avec elle. Supprime l'element .docScenarioConsequence
devenu inutile.

Verifie via simulation DOM reelle (jsdom) : la consequence remplace bien
le texte de la situation (jamais affichee a cote), les choix
disparaissent, le retour a une situation neutre au scenario suivant/au
redemarrage fonctionne.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-20 17:10:56 +02:00

2188 lines
92 KiB
JavaScript

// document-editor.js — éditeur de support de formation (voir
// docs/plan/PLAN.md). État MUTABLE partagé sur window.forgeDocState
// (jamais let/const au premier niveau de ce fichier, voir static/pjax.js —
// un <script src> de page est rejoué tel quel à chaque navigation pjax,
// une redéclaration lèverait "already declared" ; seul `var` est sûr à
// rejouer). Toute mutation passe par le serveur (AJAX) puis rafraîchit le
// canevas depuis SA réponse (jamais un état simulé côté client) —
// Undo/Redo est une PILE DE COMMANDES qui rejoue ces mêmes appels AJAX en
// sens inverse, pas un simple instantané visuel.
// Constante immuable au premier niveau : `let`/`const` lèverait "already
// declared" à la 2e exécution de ce script par pjax.js (voir l'en-tête
// ci-dessus), `var` est le seul choix sûr ici.
// eslint-disable-next-line no-var, vars-on-top
var FORGE_DOC_STYLE_PRESETS = {
titre1: 'Titre 1',
titre2: 'Titre 2',
paragraphe: 'Paragraphe',
legende: 'Légende',
};
// Même raison que FORGE_DOC_STYLE_PRESETS ci-dessus.
// eslint-disable-next-line no-var, vars-on-top
var FORGE_DOC_SHAPE_KINDS = ['rectangle', 'cercle', 'triangle', 'trait'];
function forgeDocIsShapeKind(kind) {
return FORGE_DOC_SHAPE_KINDS.includes(kind);
}
function forgeDocChildrenOf(parentId) {
return window.forgeDocState.elements
.filter((el) => el.parent_id === parentId)
.sort((a, b) => a.order_index - b.order_index);
}
function forgeDocTopLevel() {
return forgeDocChildrenOf(null);
}
function forgeDocRebuildIndex() {
const byId = {};
window.forgeDocState.elements.forEach((el) => { byId[el.id] = el; });
window.forgeDocState.elementsById = byId;
}
/* ---------------------------------------------------------------------
* Appels serveur — un seul point de vérité par opération (voir
* routes/document/), jamais dupliqué côté client.
* ------------------------------------------------------------------- */
function forgeDocApiAdd(kind, parentId) {
const body = new URLSearchParams();
body.set('kind', kind);
if (parentId !== null && parentId !== undefined) body.set('parent_id', String(parentId));
return fetch(`/document/${window.forgeDocState.slug}/elements/add`, { method: 'POST', body })
.then((r) => r.json());
}
function forgeDocApiUpdate(id, attributes) {
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attributes),
}).then((r) => r.json());
}
function forgeDocApiMove(id, newParentId, newIndex) {
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_parent_id: newParentId, new_index: newIndex }),
}).then((r) => r.json());
}
function forgeDocApiDelete(id) {
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/delete`, { method: 'POST' })
.then((r) => r.json());
}
function forgeDocApiRender() {
return fetch(`/document/${window.forgeDocState.slug}/render`).then((r) => r.json());
}
/* ---------------------------------------------------------------------
* Canevas — rafraîchi depuis la VÉRITÉ SERVEUR après chaque mutation
* structurelle (jamais une simple retouche DOM locale, voir
* routes/document/document_render.py).
* ------------------------------------------------------------------- */
function forgeDocApplySelectionClass() {
document.querySelectorAll('#docPage .docSelected').forEach((d) => d.classList.remove('docSelected'));
const { selectedId } = window.forgeDocState;
if (selectedId === null) return;
const dom = document.querySelector(`[data-element-id="${selectedId}"]`);
if (dom) dom.classList.add('docSelected');
}
function forgeDocRefreshCanvas() {
return forgeDocApiRender().then((data) => {
document.getElementById('docPageContent').innerHTML = data.html;
window.forgeDocState.elements = data.elements;
forgeDocRebuildIndex();
forgeDocBindCanvasInteractions();
const { selectedId } = window.forgeDocState;
if (selectedId !== null && window.forgeDocState.elementsById[selectedId]) {
forgeDocApplySelectionClass();
} else {
window.forgeDocState.selectedId = null;
forgeDocRenderProps(null);
}
});
}
/* ---------------------------------------------------------------------
* Undo/Redo — pile de COMMANDES {undo, redo}, chacune rejouant les mêmes
* appels AJAX que l'action d'origine (voir docs/plan/PLAN.md).
* ------------------------------------------------------------------- */
function forgeDocUpdateHistoryButtons() {
const undoBtn = document.getElementById('docUndoBtn');
const redoBtn = document.getElementById('docRedoBtn');
if (undoBtn) undoBtn.disabled = window.forgeDocState.undoStack.length === 0;
if (redoBtn) redoBtn.disabled = window.forgeDocState.redoStack.length === 0;
}
function forgeDocPushCommand(command) {
window.forgeDocState.undoStack.push(command);
window.forgeDocState.redoStack = [];
forgeDocUpdateHistoryButtons();
}
function forgeDocUndo() {
const command = window.forgeDocState.undoStack.pop();
if (!command) return;
command.undo().then(forgeDocRefreshCanvas).then(() => {
window.forgeDocState.redoStack.push(command);
forgeDocUpdateHistoryButtons();
});
}
function forgeDocRedo() {
const command = window.forgeDocState.redoStack.pop();
if (!command) return;
command.redo().then(forgeDocRefreshCanvas).then(() => {
window.forgeDocState.undoStack.push(command);
forgeDocUpdateHistoryButtons();
});
}
/* ---------------------------------------------------------------------
* Mutations de haut niveau — chacune exécute son action puis pousse la
* commande d'annulation correspondante.
* ------------------------------------------------------------------- */
function forgeDocAddElement(kind, parentId) {
const state = { parentId: parentId === undefined ? null : parentId };
function perform() {
return forgeDocApiAdd(kind, state.parentId).then((result) => { state.id = result.id; });
}
return perform().then(() => {
forgeDocPushCommand({
undo: () => forgeDocApiDelete(state.id),
redo: perform,
});
return forgeDocRefreshCanvas().then(() => forgeDocSelectElement(state.id));
});
}
function forgeDocDeleteElement(id) {
const el = window.forgeDocState.elementsById[id];
if (!el) return Promise.resolve();
const state = {
id, parentId: el.parent_id, index: el.order_index, kind: el.kind, attributes: el.attributes,
};
return forgeDocApiDelete(state.id).then(() => {
forgeDocPushCommand({
undo: () => forgeDocApiAdd(state.kind, state.parentId).then((r) => {
state.id = r.id;
return forgeDocApiUpdate(state.id, state.attributes)
.then(() => forgeDocApiMove(state.id, state.parentId, state.index));
}),
redo: () => forgeDocApiDelete(state.id),
});
window.forgeDocState.selectedId = null;
return forgeDocRefreshCanvas();
});
}
function forgeDocUpdateAttributes(id, newAttributes) {
const el = window.forgeDocState.elementsById[id];
if (!el) return Promise.resolve();
const oldAttributes = { ...el.attributes };
return forgeDocApiUpdate(id, newAttributes).then((result) => {
forgeDocPushCommand({
undo: () => forgeDocApiUpdate(id, oldAttributes),
redo: () => forgeDocApiUpdate(id, newAttributes),
});
// Le serveur revalide/nettoie certains kinds (quiz — voir
// routes/document/document_element_update.py) : on adopte SA version,
// jamais celle envoyée telle quelle, pour ne jamais dériver de la
// vérité persistée (ex. un choix en trop silencieusement tronqué).
el.attributes = result.attributes || newAttributes;
const dom = document.querySelector(`[data-element-id="${id}"]`);
if (dom && result.rendered_html) {
const wrapper = document.createElement('div');
wrapper.innerHTML = result.rendered_html;
dom.replaceWith(wrapper.firstElementChild);
forgeDocBindCanvasInteractions();
forgeDocApplySelectionClass();
}
});
}
/* ---------------------------------------------------------------------
* Moteur de layout — glisser-déposer réel (voir docs/plan/PLAN.md §1.2.B) :
* dépose sur la moitié gauche/droite de la boîte englobante d'un élément
* -> rejoint sa rangée (créée à la volée si besoin) ; moitié haute/basse
* -> nouvelle position au top-niveau, avant/après.
* ------------------------------------------------------------------- */
function forgeDocFindDropTarget(clientX, clientY) {
const candidates = Array.from(document.querySelectorAll('#docPage [data-element-id]'))
.filter((dom) => !forgeDocIsShapeKind(dom.dataset.kind));
let best = null;
let bestDist = Infinity;
candidates.forEach((dom) => {
const rect = dom.getBoundingClientRect();
const dist = Math.hypot(clientX - (rect.left + rect.width / 2), clientY - (rect.top + rect.height / 2));
if (dist < bestDist) { bestDist = dist; best = dom; }
});
return best;
}
function forgeDocComputeDropPlacement(targetDom, clientX, clientY) {
const rect = targetDom.getBoundingClientRect();
const relX = (clientX - rect.left) / rect.width;
const relY = (clientY - rect.top) / rect.height;
const targetId = Number.parseInt(targetDom.dataset.elementId, 10);
const targetEl = window.forgeDocState.elementsById[targetId];
if (relY < 0.25) return { mode: 'before', targetEl };
if (relY > 0.75) return { mode: 'after', targetEl };
return { mode: relX < 0.5 ? 'mergeLeft' : 'mergeRight', targetEl };
}
function forgeDocShowDropHighlight(targetDom, mode) {
document.querySelectorAll('.docDropTarget').forEach((d) => {
d.classList.remove('docDropTarget', 'docDropTarget--before', 'docDropTarget--after', 'docDropTarget--mergeLeft', 'docDropTarget--mergeRight');
});
targetDom.classList.add('docDropTarget', `docDropTarget--${mode}`);
}
function forgeDocClearDropHighlight() {
document.querySelectorAll('.docDropTarget').forEach((d) => {
d.classList.remove('docDropTarget', 'docDropTarget--before', 'docDropTarget--after', 'docDropTarget--mergeLeft', 'docDropTarget--mergeRight');
});
}
function forgeDocDropLibraryItem(kind, placement) {
const { targetEl } = placement;
const state = {};
function performForward() {
if (placement.mode === 'mergeLeft' || placement.mode === 'mergeRight') {
if (targetEl.parent_id !== null) {
const siblings = forgeDocChildrenOf(targetEl.parent_id);
const pos = siblings.findIndex((s) => s.id === targetEl.id);
state.index = placement.mode === 'mergeLeft' ? pos : pos + 1;
state.createdRow = false;
return forgeDocApiAdd(kind, targetEl.parent_id).then((r) => {
state.newId = r.id;
return forgeDocApiMove(state.newId, targetEl.parent_id, state.index);
});
}
state.createdRow = true;
state.topPos = forgeDocTopLevel().findIndex((s) => s.id === targetEl.id);
return forgeDocApiAdd('row', null).then((rowResult) => {
state.rowId = rowResult.id;
return forgeDocApiMove(state.rowId, null, state.topPos)
.then(() => forgeDocApiMove(targetEl.id, state.rowId, 0))
.then(() => forgeDocApiAdd(kind, state.rowId))
.then((r) => {
state.newId = r.id;
state.index = placement.mode === 'mergeLeft' ? 0 : 1;
return forgeDocApiMove(state.newId, state.rowId, state.index);
});
});
}
const anchorId = targetEl.parent_id !== null ? targetEl.parent_id : targetEl.id;
const anchorPos = forgeDocTopLevel().findIndex((s) => s.id === anchorId);
state.index = placement.mode === 'before' ? anchorPos : anchorPos + 1;
state.createdRow = false;
return forgeDocApiAdd(kind, null).then((r) => {
state.newId = r.id;
return forgeDocApiMove(state.newId, null, state.index);
});
}
function performUndo() {
const deleteNew = forgeDocApiDelete(state.newId);
if (!state.createdRow) return deleteNew;
return deleteNew
.then(() => forgeDocApiMove(targetEl.id, null, state.topPos))
.then(() => forgeDocApiDelete(state.rowId));
}
return performForward().then(() => {
forgeDocPushCommand({ undo: performUndo, redo: performForward });
return forgeDocRefreshCanvas().then(() => forgeDocSelectElement(state.newId));
});
}
function forgeDocDropExistingElement(draggedId, placement) {
const draggedEl = window.forgeDocState.elementsById[draggedId];
const { targetEl } = placement;
if (!draggedEl || draggedEl.id === targetEl.id || targetEl.parent_id === draggedId) return Promise.resolve();
const isMerge = placement.mode === 'mergeLeft' || placement.mode === 'mergeRight';
if (draggedEl.kind === 'row' && isMerge) return Promise.resolve();
const fromParentId = draggedEl.parent_id;
const fromIndex = draggedEl.order_index;
const state = {};
function performForward() {
if (isMerge) {
if (targetEl.parent_id !== null) {
const siblings = forgeDocChildrenOf(targetEl.parent_id).filter((s) => s.id !== draggedId);
const pos = siblings.findIndex((s) => s.id === targetEl.id);
state.rowId = targetEl.parent_id;
state.index = placement.mode === 'mergeLeft' ? pos : pos + 1;
state.createdRow = false;
return forgeDocApiMove(draggedId, state.rowId, state.index);
}
state.createdRow = true;
state.topPos = forgeDocTopLevel().filter((s) => s.id !== draggedId).findIndex((s) => s.id === targetEl.id);
return forgeDocApiAdd('row', null).then((rowResult) => {
state.rowId = rowResult.id;
return forgeDocApiMove(state.rowId, null, state.topPos)
.then(() => forgeDocApiMove(targetEl.id, state.rowId, 0))
.then(() => {
state.index = placement.mode === 'mergeLeft' ? 0 : 1;
return forgeDocApiMove(draggedId, state.rowId, state.index);
});
});
}
const anchorId = targetEl.parent_id !== null ? targetEl.parent_id : targetEl.id;
const anchorPos = forgeDocTopLevel().filter((s) => s.id !== draggedId).findIndex((s) => s.id === anchorId);
state.index = placement.mode === 'before' ? anchorPos : anchorPos + 1;
state.createdRow = false;
return forgeDocApiMove(draggedId, null, state.index);
}
function performUndo() {
const restore = forgeDocApiMove(draggedId, fromParentId, fromIndex);
if (!state.createdRow) return restore;
return restore
.then(() => forgeDocApiMove(targetEl.id, null, state.topPos))
.then(() => forgeDocApiDelete(state.rowId));
}
return performForward().then(() => {
forgeDocPushCommand({ undo: performUndo, redo: performForward });
return forgeDocRefreshCanvas();
});
}
function forgeDocBindLibraryDragHandlers() {
document.querySelectorAll('.docLibItem[data-add]').forEach((item) => {
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/forge-doc-add', item.dataset.add);
e.dataTransfer.effectAllowed = 'copy';
});
item.addEventListener('click', () => { forgeDocAddElement(item.dataset.add, null); });
});
}
function forgeDocBindCanvasDropZone() {
const docPage = document.getElementById('docPage');
docPage.addEventListener('dragover', (e) => {
const isAdd = e.dataTransfer.types.includes('text/forge-doc-add');
const isMove = e.dataTransfer.types.includes('text/forge-doc-move');
if (!isAdd && !isMove) return;
e.preventDefault();
const target = forgeDocFindDropTarget(e.clientX, e.clientY);
if (!target) { forgeDocClearDropHighlight(); return; }
const placement = forgeDocComputeDropPlacement(target, e.clientX, e.clientY);
forgeDocShowDropHighlight(target, placement.mode);
});
docPage.addEventListener('dragleave', (e) => {
if (e.target === docPage) forgeDocClearDropHighlight();
});
docPage.addEventListener('drop', (e) => {
e.preventDefault();
forgeDocClearDropHighlight();
const target = forgeDocFindDropTarget(e.clientX, e.clientY);
if (!target) return;
const placement = forgeDocComputeDropPlacement(target, e.clientX, e.clientY);
const addKind = e.dataTransfer.getData('text/forge-doc-add');
const moveId = e.dataTransfer.getData('text/forge-doc-move');
if (addKind) { forgeDocDropLibraryItem(addKind, placement); return; }
if (moveId) forgeDocDropExistingElement(Number.parseInt(moveId, 10), placement);
});
}
/* ---------------------------------------------------------------------
* Formes libres — position absolue, glissées à la souris (pas de HTML5
* DnD, pas de logique de rangée) avec grille d'accroche réelle.
* ------------------------------------------------------------------- */
// Même raison que FORGE_DOC_STYLE_PRESETS en tête de fichier.
// eslint-disable-next-line no-var, vars-on-top
var FORGE_DOC_SNAP_SIZE = 8;
function forgeDocBindShapeDrag(dom, elementId) {
dom.addEventListener('mousedown', (e) => {
if (window.forgeDocState.previewMode) return;
e.preventDefault();
e.stopPropagation();
forgeDocSelectElement(elementId);
const el = window.forgeDocState.elementsById[elementId];
window.forgeDocState.shapeDrag = {
id: elementId,
startX: e.clientX,
startY: e.clientY,
origX: el.attributes.x,
origY: el.attributes.y,
};
document.getElementById('docPage').classList.add('docPage--dragging-shape');
});
}
function forgeDocBindShapeDragGlobalHandlers() {
document.addEventListener('mousemove', (e) => {
const drag = window.forgeDocState.shapeDrag;
if (!drag) return;
const zoomFactor = window.forgeDocState.zoom / 100;
const dx = (e.clientX - drag.startX) / zoomFactor;
const dy = (e.clientY - drag.startY) / zoomFactor;
const newX = Math.round((drag.origX + dx) / FORGE_DOC_SNAP_SIZE) * FORGE_DOC_SNAP_SIZE;
const newY = Math.round((drag.origY + dy) / FORGE_DOC_SNAP_SIZE) * FORGE_DOC_SNAP_SIZE;
const dom = document.querySelector(`[data-element-id="${drag.id}"]`);
if (dom) { dom.style.left = `${newX}px`; dom.style.top = `${newY}px`; }
drag.lastX = newX;
drag.lastY = newY;
});
document.addEventListener('mouseup', () => {
const drag = window.forgeDocState.shapeDrag;
if (!drag) return;
window.forgeDocState.shapeDrag = null;
document.getElementById('docPage').classList.remove('docPage--dragging-shape');
if (typeof drag.lastX === 'number' && (drag.lastX !== drag.origX || drag.lastY !== drag.origY)) {
const el = window.forgeDocState.elementsById[drag.id];
forgeDocUpdateAttributes(drag.id, { ...el.attributes, x: drag.lastX, y: drag.lastY });
}
});
}
/* ---------------------------------------------------------------------
* Sélection & panneau Propriétés.
* ------------------------------------------------------------------- */
function forgeDocBindCanvasInteractions() {
document.querySelectorAll('#docPage [data-element-id]').forEach((dom) => {
const elementId = Number.parseInt(dom.dataset.elementId, 10);
dom.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocSelectElement(elementId);
});
if (forgeDocIsShapeKind(dom.dataset.kind)) {
forgeDocBindShapeDrag(dom, elementId);
} else {
dom.setAttribute('draggable', 'true');
dom.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/forge-doc-move', String(elementId));
e.dataTransfer.effectAllowed = 'move';
});
}
});
forgeDocInitQuizPlayers();
forgeDocInitAssociationPlayers();
forgeDocInitMemoryPlayers();
forgeDocInitMotsPlayers();
forgeDocInitScenarioPlayers();
}
/* ---------------------------------------------------------------------
* Quiz — questionnaire RÉELLEMENT interactif en Mode Aperçu (voir
* docs/plan/maquettes/document-formation-web.html, référence visuelle).
* État tenu en mémoire (jamais persisté — une session d'Aperçu se
* réinitialise à chaque rafraîchissement du canevas, exactement comme le
* reste de l'éditeur).
* ------------------------------------------------------------------- */
function forgeDocQuizPlayerQuestions(playerEl) {
try {
const config = JSON.parse(playerEl.dataset.quizConfig || '{}');
return Array.isArray(config.questions) ? config.questions : [];
} catch (e) {
return [];
}
}
function forgeDocRenderQuizPlayerQuestion(playerEl, state) {
state.answered = false;
const item = state.questions[state.current];
const textEl = playerEl.querySelector('.docQuizQuestionText');
const optionsEl = playerEl.querySelector('.docQuizOptions');
const feedbackEl = playerEl.querySelector('.docQuizFeedback');
const nextBtn = playerEl.querySelector('.docQuizNextBtn');
const fillEl = playerEl.querySelector('.docQuizProgressFill');
const progEl = playerEl.querySelector('.docQuizProg');
const scoreEl = playerEl.querySelector('.docQuizScore');
const letters = ['A', 'B', 'C', 'D'];
textEl.textContent = item.text;
optionsEl.innerHTML = '';
feedbackEl.className = 'docQuizFeedback';
feedbackEl.textContent = '';
nextBtn.classList.remove('is-visible');
item.choices.forEach((choice, idx) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'docQuizOption';
btn.innerHTML = `<span class="docQuizOptionLetter">${letters[idx]}</span><span></span>`;
btn.querySelector('span:last-child').textContent = choice;
btn.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocQuizPlayerAnswer(playerEl, state, idx, btn);
});
optionsEl.appendChild(btn);
});
fillEl.style.width = `${(state.current / state.questions.length) * 100}%`;
progEl.textContent = `Question ${state.current + 1} sur ${state.questions.length}`;
scoreEl.textContent = `Score : ${state.score}`;
}
function forgeDocQuizPlayerAnswer(playerEl, state, idx, btn) {
if (state.answered) return;
state.answered = true;
const item = state.questions[state.current];
const options = playerEl.querySelectorAll('.docQuizOption');
options.forEach((o) => { o.disabled = true; });
const feedbackEl = playerEl.querySelector('.docQuizFeedback');
const nextBtn = playerEl.querySelector('.docQuizNextBtn');
const scoreEl = playerEl.querySelector('.docQuizScore');
if (idx === item.correct_index) {
btn.classList.add('is-correct');
state.score += item.points || 0;
feedbackEl.classList.add('is-visible', 'is-ok');
feedbackEl.textContent = 'Bonne réponse !';
} else {
btn.classList.add('is-wrong');
options[item.correct_index].classList.add('is-correct');
feedbackEl.classList.add('is-visible', 'is-ko');
feedbackEl.textContent = 'Mauvaise réponse.';
}
scoreEl.textContent = `Score : ${state.score}`;
nextBtn.classList.add('is-visible');
nextBtn.textContent = state.current === state.questions.length - 1 ? 'Voir le résultat' : 'Question suivante';
}
function forgeDocQuizPlayerTotalPoints(state) {
return state.questions.reduce((sum, q) => sum + (q.points || 0), 0);
}
function forgeDocShowQuizPlayerResult(playerEl, state) {
playerEl.querySelector('.docQuizCard').style.display = 'none';
const resultCard = playerEl.querySelector('.docQuizResultCard');
resultCard.style.display = 'block';
playerEl.querySelector('.docQuizProgressFill').style.width = '100%';
playerEl.querySelector('.docQuizResultBig').textContent = `${state.score} / ${forgeDocQuizPlayerTotalPoints(state)}`;
playerEl.querySelector('.docQuizResultSub').textContent = 'points obtenus';
}
function forgeDocQuizPlayerNext(playerEl, state) {
state.current += 1;
if (state.current < state.questions.length) {
forgeDocRenderQuizPlayerQuestion(playerEl, state);
} else {
forgeDocShowQuizPlayerResult(playerEl, state);
}
}
function forgeDocQuizPlayerRestart(playerEl, state) {
state.current = 0;
state.score = 0;
playerEl.querySelector('.docQuizResultCard').style.display = 'none';
playerEl.querySelector('.docQuizCard').style.display = 'block';
forgeDocRenderQuizPlayerQuestion(playerEl, state);
}
function forgeDocInitQuizPlayers() {
document.querySelectorAll('.docQuizPlayer').forEach((playerEl) => {
const questions = forgeDocQuizPlayerQuestions(playerEl);
if (!questions.length) return;
const state = {
current: 0, score: 0, answered: false, questions,
};
forgeDocRenderQuizPlayerQuestion(playerEl, state);
playerEl.querySelector('.docQuizNextBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocQuizPlayerNext(playerEl, state);
});
playerEl.querySelector('.docQuizRestartBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocQuizPlayerRestart(playerEl, state);
});
});
}
/* ---------------------------------------------------------------------
* Association — glisser-déposer RÉELLEMENT interactif en Mode Aperçu :
* relier chaque carte de gauche à sa correspondance de droite. Deux
* façons de jouer, toutes deux réelles (pas de simulation) : glisser-
* déposer natif HTML5, ou cliquer une carte puis son emplacement (pour
* les appareils sans support fiable du drag). État tenu en mémoire,
* jamais persisté.
* ------------------------------------------------------------------- */
function forgeDocAssociationPlayerData(playerEl) {
try {
const config = JSON.parse(playerEl.dataset.assocConfig || '{}');
return {
left: Array.isArray(config.left) ? config.left : [],
right: Array.isArray(config.right) ? config.right : [],
};
} catch (e) {
return { left: [], right: [] };
}
}
function forgeDocAssociationMatchResult(playerEl, state, pairIndex, itemEl, slotEl, isMatch) {
const feedbackEl = playerEl.querySelector('.docAssocFeedback');
if (!isMatch) {
// "docAssocFeedback" doit rester dans la liste (c'est la classe qui
// permet de retrouver cet élément — voir querySelector ci-dessus) :
// une réaffectation de className qui ne garde que "docQuizFeedback..."
// (réutilisé pour le STYLE visuel) la supprimerait silencieusement,
// rendant l'élément introuvable dès le prochain appel (bug réel
// trouvé le 20/09/2026 via simulation DOM).
feedbackEl.className = 'docAssocFeedback docQuizFeedback is-visible is-ko';
feedbackEl.textContent = "Ce n'est pas la bonne association, réessaie.";
itemEl.classList.add('is-wrong');
slotEl.classList.add('is-wrong');
setTimeout(() => {
itemEl.classList.remove('is-wrong');
slotEl.classList.remove('is-wrong');
}, 500);
return;
}
itemEl.classList.add('is-matched');
itemEl.removeAttribute('draggable');
slotEl.classList.add('is-matched');
state.matched.add(pairIndex);
feedbackEl.className = 'docAssocFeedback docQuizFeedback is-visible is-ok';
feedbackEl.textContent = 'Bonne association !';
playerEl.querySelector('.docAssocProg').textContent = `${state.matched.size} / ${state.total} associées`;
if (state.matched.size >= state.total) {
// Le plateau reste affiché une fois le jeu terminé (voir retour
// utilisateur du 20/09/2026 : "tous les mini-jeux à part le quiz
// doivent continuer à s'afficher même une fois terminé") — seul le
// bouton Recommencer apparaît, jamais un écran de résultat séparé qui
// remplacerait le plateau (ça reste le comportement du Quiz).
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
}
}
function forgeDocRenderAssociationBoard(playerEl, state) {
const leftCol = playerEl.querySelector('.docAssocColumnLeft');
const rightCol = playerEl.querySelector('.docAssocColumnRight');
leftCol.innerHTML = '';
rightCol.innerHTML = '';
state.left.forEach((item) => {
const itemEl = document.createElement('div');
itemEl.className = 'docAssocItem';
itemEl.textContent = item.text;
itemEl.dataset.pairIndex = String(item.pair_index);
itemEl.setAttribute('draggable', 'true');
itemEl.addEventListener('dragstart', (e) => {
e.stopPropagation();
e.dataTransfer.setData('text/forge-assoc-pair', String(item.pair_index));
e.dataTransfer.effectAllowed = 'move';
});
itemEl.addEventListener('click', (e) => {
e.stopPropagation();
if (state.matched.has(item.pair_index)) return;
if (state.selected) state.selected.classList.remove('is-selected');
state.selected = itemEl;
state.selectedPairIndex = item.pair_index;
itemEl.classList.add('is-selected');
});
leftCol.appendChild(itemEl);
});
state.right.forEach((item) => {
const slotEl = document.createElement('div');
slotEl.className = 'docAssocSlot';
slotEl.textContent = item.text;
slotEl.dataset.pairIndex = String(item.pair_index);
slotEl.addEventListener('dragover', (e) => {
if (!state.matched.has(item.pair_index)) e.preventDefault();
});
slotEl.addEventListener('drop', (e) => {
e.preventDefault();
e.stopPropagation();
if (state.matched.has(item.pair_index)) return;
const draggedPairIndex = Number(e.dataTransfer.getData('text/forge-assoc-pair'));
const itemEl = leftCol.querySelector(`.docAssocItem[data-pair-index="${draggedPairIndex}"]`);
if (!itemEl) return;
const isMatch = draggedPairIndex === item.pair_index;
forgeDocAssociationMatchResult(playerEl, state, item.pair_index, itemEl, slotEl, isMatch);
});
slotEl.addEventListener('click', (e) => {
e.stopPropagation();
if (state.matched.has(item.pair_index) || !state.selected) return;
const itemEl = state.selected;
const { selectedPairIndex } = state;
state.selected.classList.remove('is-selected');
state.selected = null;
const isMatch = selectedPairIndex === item.pair_index;
forgeDocAssociationMatchResult(playerEl, state, item.pair_index, itemEl, slotEl, isMatch);
});
rightCol.appendChild(slotEl);
});
}
function forgeDocAssociationRestart(playerEl) {
const data = forgeDocAssociationPlayerData(playerEl);
const state = {
left: data.left,
right: data.right,
matched: new Set(),
total: data.left.length,
selected: null,
selectedPairIndex: null,
};
playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
playerEl.querySelector('.docAssocFeedback').className = 'docAssocFeedback docQuizFeedback';
playerEl.querySelector('.docAssocProg').textContent = `0 / ${state.total} associées`;
forgeDocRenderAssociationBoard(playerEl, state);
}
function forgeDocInitAssociationPlayers() {
document.querySelectorAll('.docAssocPlayer').forEach((playerEl) => {
const data = forgeDocAssociationPlayerData(playerEl);
if (!data.left.length) return;
forgeDocAssociationRestart(playerEl);
playerEl.querySelector('.docAssocRestartBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocAssociationRestart(playerEl);
});
});
}
/* ---------------------------------------------------------------------
* Memory — retournement de cartes RÉELLEMENT interactif en Mode Aperçu :
* mode "paire" (retrouver les deux cartes identiques, classique) ou
* "single" (retourner chaque carte une fois, sans appariement). Contenu
* de chaque face construit via DOM (textContent/img.src, jamais innerHTML
* avec le texte du créateur) — même précaution que le plateau Association.
* ------------------------------------------------------------------- */
function forgeDocMemoryPlayerData(playerEl) {
try {
const config = JSON.parse(playerEl.dataset.memoryConfig || '{}');
return {
mode: config.mode === 'single' ? 'single' : 'paire',
cards: Array.isArray(config.cards) ? config.cards : [],
};
} catch (e) {
return { mode: 'paire', cards: [] };
}
}
function forgeDocShuffleArray(array) {
const copy = [...array];
for (let i = copy.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[copy[i], copy[j]] = [copy[j], copy[i]];
}
return copy;
}
function forgeDocFillMemoryCardFace(container, face) {
container.innerHTML = '';
if (face.image) {
const img = document.createElement('img');
img.src = face.image;
img.alt = '';
container.appendChild(img);
}
if (face.text) {
const span = document.createElement('span');
span.textContent = face.text;
container.appendChild(span);
}
if (!face.image && !face.text) {
const glyph = document.createElement('span');
glyph.className = 'docMemoryCardBackGlyph';
glyph.textContent = '?';
container.appendChild(glyph);
}
}
function forgeDocBuildMemoryCard(cardData, index) {
const cardEl = document.createElement('div');
cardEl.className = 'docMemoryCard';
cardEl.dataset.instanceIndex = String(index);
cardEl.dataset.cardIndex = String(cardData.card_index);
const inner = document.createElement('div');
inner.className = 'docMemoryCardInner';
const front = document.createElement('div');
front.className = 'docMemoryCardFace docMemoryCardFront';
forgeDocFillMemoryCardFace(front, cardData.recto);
const back = document.createElement('div');
back.className = 'docMemoryCardFace docMemoryCardBack';
forgeDocFillMemoryCardFace(back, cardData.verso);
inner.appendChild(front);
inner.appendChild(back);
cardEl.appendChild(inner);
return cardEl;
}
function forgeDocMemoryUpdateProgress(playerEl, state) {
const progEl = playerEl.querySelector('.docMemoryProg');
progEl.textContent = state.mode === 'single'
? `${state.revealed} / ${state.total} cartes retournées`
: `${state.matched} / ${state.totalPairs} paires trouvées`;
}
function forgeDocMemoryCheckComplete(playerEl, state) {
const done = state.mode === 'single' ? state.revealed >= state.total : state.matched >= state.totalPairs;
if (!done) return;
// Le plateau reste affiché une fois le jeu terminé (voir retour
// utilisateur du 20/09/2026) — seul le bouton Recommencer apparaît,
// jamais un écran de résultat séparé (ça reste le comportement du Quiz).
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
}
function forgeDocMemoryFlip(playerEl, state, cardEl) {
if (cardEl.classList.contains('is-flipped') || cardEl.classList.contains('is-matched')) return;
if (state.mode === 'single') {
cardEl.classList.add('is-flipped', 'is-matched');
state.revealed += 1;
forgeDocMemoryUpdateProgress(playerEl, state);
forgeDocMemoryCheckComplete(playerEl, state);
return;
}
if (state.busy) return;
cardEl.classList.add('is-flipped');
state.flippedCards.push(cardEl);
if (state.flippedCards.length < 2) return;
state.busy = true;
const [first, second] = state.flippedCards;
const isMatch = first.dataset.cardIndex === second.dataset.cardIndex;
if (isMatch) {
first.classList.add('is-matched');
second.classList.add('is-matched');
state.matched += 1;
state.flippedCards = [];
state.busy = false;
forgeDocMemoryUpdateProgress(playerEl, state);
forgeDocMemoryCheckComplete(playerEl, state);
} else {
setTimeout(() => {
first.classList.remove('is-flipped');
second.classList.remove('is-flipped');
state.flippedCards = [];
state.busy = false;
}, 800);
}
}
function forgeDocMemoryRestart(playerEl) {
const data = forgeDocMemoryPlayerData(playerEl);
const shuffled = forgeDocShuffleArray(data.cards);
const totalPairs = new Set(data.cards.map((c) => c.card_index)).size;
const state = {
mode: data.mode,
total: data.cards.length,
totalPairs,
matched: 0,
revealed: 0,
flippedCards: [],
busy: false,
};
playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
playerEl.querySelector('.docMemoryTitle').textContent = state.mode === 'single'
? 'Retournez chaque carte'
: 'Retrouvez les paires';
forgeDocMemoryUpdateProgress(playerEl, state);
const grid = playerEl.querySelector('.docMemoryGrid');
grid.innerHTML = '';
shuffled.forEach((cardData, index) => {
const cardEl = forgeDocBuildMemoryCard(cardData, index);
cardEl.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocMemoryFlip(playerEl, state, cardEl);
});
grid.appendChild(cardEl);
});
}
function forgeDocInitMemoryPlayers() {
document.querySelectorAll('.docMemoryPlayer').forEach((playerEl) => {
const data = forgeDocMemoryPlayerData(playerEl);
if (!data.cards.length) return;
forgeDocMemoryRestart(playerEl);
playerEl.querySelector('.docMemoryRestartBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocMemoryRestart(playerEl);
});
});
}
/* ---------------------------------------------------------------------
* Mots mêlés — grille RÉELLEMENT interactive en Mode Aperçu : la grille
* ET la position exacte de chaque mot sont calculées côté serveur (voir
* document_engine/rendering/render_document_element.py::_build_mots_grid,
* jamais recalculées ici) et embarquées en JSON. Une sélection est
* validée par comparaison de COORDONNÉES exactes avec celles d'un mot
* (jamais une comparaison de texte, qui se tromperait sur des lettres
* partagées entre deux mots qui se croisent). Deux façons de
* sélectionner, toutes deux réelles : glisser (mousedown puis survol des
* cases), ou cliquer une case de départ puis une case d'arrivée (repli
* pour les appareils sans glisser fiable — même principe que
* l'Association). État tenu en mémoire, jamais persisté.
* ------------------------------------------------------------------- */
function forgeDocMotsPlayerData(playerEl) {
try {
const config = JSON.parse(playerEl.dataset.motsConfig || '{}');
return {
size: Number(config.size) || 0,
grid: Array.isArray(config.grid) ? config.grid : [],
words: Array.isArray(config.words) ? config.words : [],
};
} catch (e) {
return { size: 0, grid: [], words: [] };
}
}
function forgeDocMotsCellsBetween(start, end) {
const rowDiff = end.row - start.row;
const colDiff = end.col - start.col;
// Seules 4 directions sont autorisées à la pose (voir _build_mots_grid) :
// horizontale, verticale, ou une vraie diagonale (même distance en
// ligne qu'en colonne) — tout le reste n'est pas une sélection valide.
if (rowDiff !== 0 && colDiff !== 0 && Math.abs(rowDiff) !== Math.abs(colDiff)) return null;
const steps = Math.max(Math.abs(rowDiff), Math.abs(colDiff));
const stepRow = steps === 0 ? 0 : rowDiff / steps;
const stepCol = steps === 0 ? 0 : colDiff / steps;
const cells = [];
for (let i = 0; i <= steps; i += 1) {
cells.push({ row: start.row + stepRow * i, col: start.col + stepCol * i });
}
return cells;
}
function forgeDocMotsCellsMatchWord(cells, wordCells) {
if (cells.length !== wordCells.length) return false;
const forward = cells.every((c, i) => c.row === wordCells[i][0] && c.col === wordCells[i][1]);
if (forward) return true;
// L'apprenant peut glisser dans les deux sens d'un même mot placé
// (départ->fin ou fin->départ) : seul le SENS DE POSE (voir
// _build_mots_grid, jamais à l'envers) est contraint, pas le sens de
// lecture de la sélection.
const reversed = [...wordCells].reverse();
return cells.every((c, i) => c.row === reversed[i][0] && c.col === reversed[i][1]);
}
function forgeDocMotsUpdateProgress(playerEl, state) {
playerEl.querySelector('.docMotsProg').textContent = `${state.found.size} / ${state.words.length} mots trouvés`;
}
function forgeDocMotsCheckComplete(playerEl, state) {
if (state.found.size < state.words.length) return;
// Même choix que l'Association/Memory ci-dessus : la grille reste
// affichée une fois tous les mots trouvés, seul ce bouton apparaît.
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
}
function forgeDocMotsClearSelection(state) {
state.selectedCellEls.forEach((cellEl) => cellEl.classList.remove('is-selecting'));
state.selectedCellEls = [];
}
function forgeDocMotsHighlightPath(state, start, end) {
forgeDocMotsClearSelection(state);
const cells = forgeDocMotsCellsBetween(start, end);
if (!cells) return;
cells.forEach(({ row, col }) => {
const cellEl = state.cellEls[row]?.[col];
if (cellEl) {
cellEl.classList.add('is-selecting');
state.selectedCellEls.push(cellEl);
}
});
}
function forgeDocMotsTryMatch(playerEl, state, cells) {
const matchIndex = state.words.findIndex((w, i) => !state.found.has(i) && forgeDocMotsCellsMatchWord(cells, w.cells));
forgeDocMotsClearSelection(state);
if (matchIndex === -1) return;
state.found.add(matchIndex);
cells.forEach(({ row, col }) => {
const cellEl = state.cellEls[row]?.[col];
if (cellEl) cellEl.classList.add('is-found');
});
const chip = playerEl.querySelector(`.docMotsWordChip[data-word-index="${matchIndex}"]`);
if (chip) chip.classList.add('is-found');
forgeDocMotsUpdateProgress(playerEl, state);
forgeDocMotsCheckComplete(playerEl, state);
}
function forgeDocMotsFinishSelection(playerEl, state) {
state.dragging = false;
if (!state.start || !state.current) {
state.start = null;
state.current = null;
return;
}
const cells = forgeDocMotsCellsBetween(state.start, state.current);
state.start = null;
state.current = null;
if (!cells) {
forgeDocMotsClearSelection(state);
return;
}
forgeDocMotsTryMatch(playerEl, state, cells);
}
let forgeDocMotsGlobalMouseupBound = false;
function forgeDocMotsBindGlobalMouseup() {
// Un SEUL listener document-level pour toute la page (jamais un par
// joueur/par restart) : forgeDocInitMotsPlayers est réappelé à chaque
// rafraîchissement du canevas (voir forgeDocBindCanvasInteractions),
// un listener posé à chaque appel s'accumulerait indéfiniment sur
// `document`, qui n'est lui jamais recréé.
if (forgeDocMotsGlobalMouseupBound) return;
forgeDocMotsGlobalMouseupBound = true;
document.addEventListener('mouseup', () => {
document.querySelectorAll('.docMotsPlayer').forEach((playerEl) => {
const state = playerEl.forgeDocMotsState;
if (state && state.dragging) forgeDocMotsFinishSelection(playerEl, state);
});
});
}
function forgeDocRenderMotsBoard(playerEl, state) {
const gridEl = playerEl.querySelector('.docMotsGrid');
const listEl = playerEl.querySelector('.docMotsWordList');
gridEl.innerHTML = '';
gridEl.style.gridTemplateColumns = `repeat(${state.size}, 1fr)`;
state.cellEls = [];
for (let r = 0; r < state.size; r += 1) {
const rowEls = [];
for (let c = 0; c < state.size; c += 1) {
const cellEl = document.createElement('div');
cellEl.className = 'docMotsCell';
cellEl.textContent = state.grid[r][c];
cellEl.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
state.dragging = true;
state.start = { row: r, col: c };
state.current = { row: r, col: c };
forgeDocMotsHighlightPath(state, state.start, state.current);
});
cellEl.addEventListener('mouseenter', () => {
if (!state.dragging || !state.start) return;
state.current = { row: r, col: c };
forgeDocMotsHighlightPath(state, state.start, state.current);
});
cellEl.addEventListener('click', (e) => {
e.stopPropagation();
if (!state.start) {
state.start = { row: r, col: c };
state.current = { row: r, col: c };
forgeDocMotsHighlightPath(state, state.start, state.current);
return;
}
state.current = { row: r, col: c };
forgeDocMotsFinishSelection(playerEl, state);
});
rowEls.push(cellEl);
gridEl.appendChild(cellEl);
}
state.cellEls.push(rowEls);
}
listEl.innerHTML = '';
state.words.forEach((w, i) => {
const chip = document.createElement('span');
chip.className = `docMotsWordChip${state.found.has(i) ? ' is-found' : ''}`;
chip.dataset.wordIndex = String(i);
chip.textContent = w.text;
listEl.appendChild(chip);
});
}
function forgeDocMotsRestart(playerEl) {
const data = forgeDocMotsPlayerData(playerEl);
const state = {
size: data.size,
grid: data.grid,
words: data.words,
found: new Set(),
start: null,
current: null,
dragging: false,
cellEls: [],
selectedCellEls: [],
};
playerEl.forgeDocMotsState = state;
playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
forgeDocMotsUpdateProgress(playerEl, state);
forgeDocRenderMotsBoard(playerEl, state);
}
function forgeDocInitMotsPlayers() {
forgeDocMotsBindGlobalMouseup();
document.querySelectorAll('.docMotsPlayer').forEach((playerEl) => {
const data = forgeDocMotsPlayerData(playerEl);
if (!data.words.length) return;
forgeDocMotsRestart(playerEl);
playerEl.querySelector('.docMotsRestartBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocMotsRestart(playerEl);
});
});
}
/* ---------------------------------------------------------------------
* 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é.
* ------------------------------------------------------------------- */
function forgeDocScenarioPlayerData(playerEl) {
try {
const config = JSON.parse(playerEl.dataset.scenarioConfig || '{}');
return Array.isArray(config.scenarios) ? config.scenarios : [];
} catch (e) {
return [];
}
}
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 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');
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 {
playerEl.querySelector('.docScenarioNextBtn').classList.add('is-visible');
}
}
function forgeDocScenarioPlayerNext(playerEl, state) {
state.current += 1;
if (state.current < state.scenarios.length) {
forgeDocRenderScenarioPlayerScenario(playerEl, state);
}
}
function forgeDocScenarioRestart(playerEl, state) {
state.current = 0;
forgeDocRenderScenarioPlayerScenario(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);
playerEl.querySelector('.docScenarioNextBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocScenarioPlayerNext(playerEl, state);
});
playerEl.querySelector('.docScenarioRestartBtn').addEventListener('click', (e) => {
e.stopPropagation();
forgeDocScenarioRestart(playerEl, state);
});
});
}
function forgeDocSetActiveBottomNav(target) {
document.querySelectorAll('#docBottomNav button').forEach((b) => {
b.classList.toggle('is-active', b.dataset.target === target);
});
}
function forgeDocSelectElement(id) {
window.forgeDocState.selectedId = id;
forgeDocApplySelectionClass();
forgeDocRenderProps(window.forgeDocState.elementsById[id]);
if (window.innerWidth <= 900) {
document.getElementById('docSidebarLeft').classList.remove('is-open');
document.getElementById('docSidebarRight').classList.add('is-open');
forgeDocSetActiveBottomNav('right');
}
}
function forgeDocPropsHeader(label) {
return `<div class="docSectionLabel">Propriétés — ${label}</div>`;
}
function forgeDocDeleteButtonHtml() {
return '<button type="button" class="docDeleteBtn" id="docDeletePropBtn">🗑 Supprimer cet élément</button>';
}
function forgeDocRenderTextProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
${forgeDocPropsHeader('texte')}
<div class="docField">
<span class="docFieldLabel">Style</span>
<div class="docSegmented" id="docStyleSeg">
${Object.keys(FORGE_DOC_STYLE_PRESETS).map((key) => `<button type="button" class="docSegBtn ${a.style === key ? 'is-active' : ''}" data-style="${key}">${FORGE_DOC_STYLE_PRESETS[key]}</button>`).join('')}
</div>
</div>
<div class="docField"><label for="docContentInput">Contenu</label><textarea id="docContentInput" rows="3">${a.content}</textarea></div>
<div class="docField">
<span class="docFieldLabel">Mise en forme</span>
<div class="docToolbarRow">
<button type="button" class="docFmtBtn ${a.bold ? 'is-active' : ''}" id="docBoldBtn" title="Gras">B</button>
<button type="button" class="docFmtBtn docFmtBtn--italic ${a.italic ? 'is-active' : ''}" id="docItalicBtn" title="Italique">I</button>
<button type="button" class="docFmtBtn docFmtBtn--underline ${a.underline ? 'is-active' : ''}" id="docUnderlineBtn" title="Souligné">U</button>
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Alignement</span>
<div class="docToolbarRow" id="docAlignRow">
<button type="button" class="docFmtBtn ${a.align === 'left' ? 'is-active' : ''}" data-align="left">◧</button>
<button type="button" class="docFmtBtn ${a.align === 'center' ? 'is-active' : ''}" data-align="center">▣</button>
<button type="button" class="docFmtBtn ${a.align === 'right' ? 'is-active' : ''}" data-align="right">◨</button>
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Couleur du texte</span>
<div class="docSwatches" id="docColorSwatches">
${['var(--doc-text)', 'var(--doc-muted)', '#ff5f2e', '#ffb020'].map((c) => `<div class="docSwatch ${a.color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c.replace('doc-', 'forge-').replace('var(--forge-text)', 'var(--forge-text)')};"></div>`).join('')}
</div>
</div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docContentInput').addEventListener('change', (e) => patch({ content: e.target.value }));
panel.querySelectorAll('#docStyleSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ style: btn.dataset.style }));
});
document.getElementById('docBoldBtn').addEventListener('click', () => patch({ bold: !a.bold }));
document.getElementById('docItalicBtn').addEventListener('click', () => patch({ italic: !a.italic }));
document.getElementById('docUnderlineBtn').addEventListener('click', () => patch({ underline: !a.underline }));
panel.querySelectorAll('#docAlignRow [data-align]').forEach((btn) => {
btn.addEventListener('click', () => patch({ align: btn.dataset.align }));
});
panel.querySelectorAll('#docColorSwatches .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => patch({ color: sw.dataset.color }));
});
}
function forgeDocRenderShapeProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
${forgeDocPropsHeader('forme')}
<div class="docField"><label for="docShapeLabel">Libellé</label><input type="text" id="docShapeLabel" value="${a.label || ''}"></div>
<div class="docFieldRow"><label for="docShapeX">Position X</label><input type="number" id="docShapeX" value="${a.x}" style="width:90px;"></div>
<div class="docFieldRow"><label for="docShapeY">Position Y</label><input type="number" id="docShapeY" value="${a.y}" style="width:90px;"></div>
<div class="docFieldRow"><label for="docShapeWidth">Largeur</label><input type="number" id="docShapeWidth" value="${a.width}" style="width:90px;"></div>
<div class="docFieldRow"><label for="docShapeHeight">Hauteur</label><input type="number" id="docShapeHeight" value="${a.height}" style="width:90px;"></div>
<div class="docFieldRow"><label for="docShapeRotation">Rotation (°)</label><input type="number" id="docShapeRotation" value="${a.rotation}" style="width:90px;"></div>
<div class="docFieldRow"><label for="docShapeZ">Ordre (z-index)</label><input type="number" id="docShapeZ" value="${a.z_index}" style="width:90px;"></div>
<div class="docFieldRow"><label for="docShapeFill">Couleur de remplissage</label><input type="color" id="docShapeFill" value="${a.fill}"></div>
<div class="docFieldRow"><label for="docShapeStroke">Couleur de bordure</label><input type="color" id="docShapeStroke" value="${a.stroke}"></div>
<div class="docFieldRow"><label for="docShapeStrokeWidth">Épaisseur de bordure</label><input type="number" id="docShapeStrokeWidth" value="${a.stroke_width}" style="width:90px;"></div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docShapeLabel').addEventListener('change', (e) => patch({ label: e.target.value }));
document.getElementById('docShapeX').addEventListener('change', (e) => patch({ x: Number(e.target.value) }));
document.getElementById('docShapeY').addEventListener('change', (e) => patch({ y: Number(e.target.value) }));
document.getElementById('docShapeWidth').addEventListener('change', (e) => patch({ width: Number(e.target.value) }));
document.getElementById('docShapeHeight').addEventListener('change', (e) => patch({ height: Number(e.target.value) }));
document.getElementById('docShapeRotation').addEventListener('change', (e) => patch({ rotation: Number(e.target.value) }));
document.getElementById('docShapeZ').addEventListener('change', (e) => patch({ z_index: Number(e.target.value) }));
document.getElementById('docShapeFill').addEventListener('input', (e) => patch({ fill: e.target.value }));
document.getElementById('docShapeStroke').addEventListener('input', (e) => patch({ stroke: e.target.value }));
document.getElementById('docShapeStrokeWidth').addEventListener('change', (e) => patch({ stroke_width: Number(e.target.value) }));
}
function forgeDocRenderImageProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
${forgeDocPropsHeader('image')}
<div class="docField"><label for="docImageSrc">URL de l'image</label><input type="text" id="docImageSrc" value="${a.src || ''}" placeholder="https://..."></div>
<div class="docField"><label for="docImageAlt">Texte alternatif</label><input type="text" id="docImageAlt" value="${a.alt || ''}"></div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docImageSrc').addEventListener('change', (e) => patch({ src: e.target.value.trim() }));
document.getElementById('docImageAlt').addEventListener('change', (e) => patch({ alt: e.target.value }));
}
function forgeDocRenderButtonProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
${forgeDocPropsHeader('bouton')}
<div class="docField"><label for="docButtonLabel">Texte du bouton</label><input type="text" id="docButtonLabel" value="${a.label || ''}"></div>
<div class="docField"><label for="docButtonTarget">Cible (URL ou ancre)</label><input type="text" id="docButtonTarget" value="${a.target || ''}"></div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docButtonLabel').addEventListener('change', (e) => patch({ label: e.target.value }));
document.getElementById('docButtonTarget').addEventListener('change', (e) => patch({ target: e.target.value.trim() }));
}
function forgeDocRenderRowProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
${forgeDocPropsHeader('rangée')}
<div class="docFieldRow"><label for="docRowGap">Espacement (px)</label><input type="number" id="docRowGap" value="${a.gap}" style="width:90px;"></div>
<div class="docField">
<span class="docFieldLabel">Alignement</span>
<div class="docSegmented" id="docRowAlign">
${['stretch', 'flex-start', 'center', 'flex-end'].map((v) => `<button type="button" class="docSegBtn ${a.align === v ? 'is-active' : ''}" data-align="${v}">${v}</button>`).join('')}
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Justification</span>
<div class="docSegmented" id="docRowJustify">
${['flex-start', 'center', 'flex-end', 'space-between'].map((v) => `<button type="button" class="docSegBtn ${a.justify === v ? 'is-active' : ''}" data-justify="${v}">${v}</button>`).join('')}
</div>
</div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docRowGap').addEventListener('change', (e) => patch({ gap: Number(e.target.value) }));
panel.querySelectorAll('#docRowAlign .docSegBtn').forEach((btn) => btn.addEventListener('click', () => patch({ align: btn.dataset.align })));
panel.querySelectorAll('#docRowJustify .docSegBtn').forEach((btn) => btn.addEventListener('click', () => patch({ justify: btn.dataset.justify })));
}
function forgeDocRenderMinigameProps(panel, el) {
const a = el.attributes;
const label = window.FORGE_DOCUMENT.elementKindLabels[el.kind] || el.kind;
panel.innerHTML = `
${forgeDocPropsHeader(label)}
<div class="docFieldRow"><span class="docFieldLabel">Type</span><span style="font-size:13px;font-weight:700;">${label}</span></div>
<div class="docField">
<span class="docFieldLabel">Couleur du thème</span>
<div class="docSwatches" id="docMinigameColor">
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
</div>
</div>
<div style="font-size:12.5px;color:var(--doc-muted);">Formulaire de contenu détaillé à venir pour ce type d'élément.</div>
${forgeDocDeleteButtonHtml()}
`;
panel.querySelectorAll('#docMinigameColor .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => forgeDocUpdateAttributes(el.id, { ...el.attributes, theme_color: sw.dataset.color }));
});
}
function forgeDocQuizNewQuestion() {
return {
text: 'Nouvelle question', choices: ['Choix 1', 'Choix 2'], correct_index: 0, points: 10,
};
}
function forgeDocRenderQuizQuestionHtml(q, qIndex) {
const choicesHtml = q.choices.map((choice, cIndex) => `
<div class="docQuizChoiceRow">
<input type="radio"
name="docQuizCorrect${qIndex}"
class="docQuizCorrectRadio"
data-question-index="${qIndex}"
data-choice-index="${cIndex}"
${cIndex === q.correct_index ? 'checked' : ''}>
<input type="text"
class="docQuizChoiceInput"
value="${choice}"
placeholder="Choix ${cIndex + 1}"
data-question-index="${qIndex}"
data-choice-index="${cIndex}">
</div>
`).join('');
return `
<div class="docQuizQuestion" data-question-index="${qIndex}">
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">Question ${qIndex + 1}</span>
<button type="button" class="docQuizRemoveBtn" data-question-index="${qIndex}" title="Supprimer cette question">🗑</button>
</div>
<div class="docField">
<textarea class="docQuizTextInput" rows="2" data-question-index="${qIndex}" placeholder="Texte de la question">${q.text}</textarea>
</div>
<div class="docFieldRow">
<label>Nombre de choix</label>
<input type="number" class="docQuizChoiceCountInput" min="2" max="4" value="${q.choices.length}" data-question-index="${qIndex}" style="width:60px;">
</div>
<div class="docQuizChoicesList">${choicesHtml}</div>
<div class="docFieldRow">
<label>Points</label>
<input type="number" class="docQuizPointsInput" min="0" value="${q.points}" data-question-index="${qIndex}" style="width:70px;">
</div>
</div>
`;
}
function forgeDocRenderQuizProps(panel, el) {
const a = el.attributes;
const questions = a.questions || [];
function patch(partial) {
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
});
}
panel.innerHTML = `
${forgeDocPropsHeader('quiz')}
<div class="docFieldRow">
<label class="docFieldLabel" for="docQuizTimerEnabled">Chronomètre</label>
<input type="checkbox" id="docQuizTimerEnabled" ${a.timer_enabled ? 'checked' : ''}>
</div>
${a.timer_enabled ? `
<div class="docFieldRow">
<label for="docQuizTimerSeconds">Secondes par question</label>
<input type="number" id="docQuizTimerSeconds" min="5" max="300" value="${a.timer_seconds}" style="width:70px;">
</div>` : ''}
<div class="docField">
<span class="docFieldLabel">Couleur du thème</span>
<div class="docSwatches" id="docQuizColor">
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
</div>
</div>
<div class="docSectionLabel docQuizQuestionsLabel">Questions</div>
<div id="docQuizQuestionsList">${questions.length ? questions.map(forgeDocRenderQuizQuestionHtml).join('') : '<div class="docEmptyState">Aucune question — ajoute la première ci-dessous.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docQuizAddQuestion">+ Ajouter une question</button>
${forgeDocDeleteButtonHtml()}
`;
document.getElementById('docQuizTimerEnabled').addEventListener('change', (e) => patch({ timer_enabled: e.target.checked }));
const timerSecondsInput = document.getElementById('docQuizTimerSeconds');
if (timerSecondsInput) timerSecondsInput.addEventListener('change', (e) => patch({ timer_seconds: Number(e.target.value) }));
panel.querySelectorAll('#docQuizColor .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
});
document.getElementById('docQuizAddQuestion').addEventListener('click', () => {
patch({ questions: [...questions, forgeDocQuizNewQuestion()] });
});
panel.querySelectorAll('.docQuizRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const qIndex = Number(btn.dataset.questionIndex);
patch({ questions: questions.filter((_, i) => i !== qIndex) });
});
});
panel.querySelectorAll('.docQuizTextInput').forEach((input) => {
input.addEventListener('change', (e) => {
const qIndex = Number(input.dataset.questionIndex);
patch({ questions: questions.map((q, i) => (i === qIndex ? { ...q, text: e.target.value } : q)) });
});
});
panel.querySelectorAll('.docQuizChoiceCountInput').forEach((input) => {
input.addEventListener('change', (e) => {
const qIndex = Number(input.dataset.questionIndex);
const count = Math.max(2, Math.min(4, Number(e.target.value) || 2));
patch({
questions: questions.map((q, i) => {
if (i !== qIndex) return q;
const choices = q.choices.slice(0, count);
while (choices.length < count) choices.push(`Choix ${choices.length + 1}`);
return { ...q, choices, correct_index: q.correct_index < count ? q.correct_index : 0 };
}),
});
});
});
panel.querySelectorAll('.docQuizChoiceInput').forEach((input) => {
input.addEventListener('change', (e) => {
const qIndex = Number(input.dataset.questionIndex);
const cIndex = Number(input.dataset.choiceIndex);
patch({
questions: questions.map((q, i) => (
i === qIndex ? { ...q, choices: q.choices.map((c, ci) => (ci === cIndex ? e.target.value : c)) } : q
)),
});
});
});
panel.querySelectorAll('.docQuizCorrectRadio').forEach((radio) => {
radio.addEventListener('change', () => {
const qIndex = Number(radio.dataset.questionIndex);
const cIndex = Number(radio.dataset.choiceIndex);
patch({ questions: questions.map((q, i) => (i === qIndex ? { ...q, correct_index: cIndex } : q)) });
});
});
panel.querySelectorAll('.docQuizPointsInput').forEach((input) => {
input.addEventListener('change', (e) => {
const qIndex = Number(input.dataset.questionIndex);
const points = Math.max(0, Number(e.target.value) || 0);
patch({ questions: questions.map((q, i) => (i === qIndex ? { ...q, points } : q)) });
});
});
}
function forgeDocAssociationNewPair() {
// Champs pré-remplis (jamais vides) : sanitize_association_config
// (routes/document/document_element_update.py) rejette silencieusement
// toute paire dont un des deux côtés est vide — une paire vide envoyée
// telle quelle disparaîtrait donc aussitôt créée, sans aucun signal
// pour le créateur (bug réel trouvé le 20/09/2026 : "+ Ajouter une
// paire" semblait ne rien faire). Même stratégie que
// forgeDocQuizNewQuestion ci-dessus.
return { left: 'Nouvel élément', right: 'Sa correspondance' };
}
function forgeDocEscapeHtml(text) {
// Texte libre du créateur (Association/Memory), inséré comme CONTENU
// d'un <textarea> ou comme VALEUR d'attribut dans un template string
// (jamais via .value/.setAttribute, voir forgeDocRenderAssociationProps/
// forgeDocRenderMemoryProps qui construisent tout le panneau en une
// seule chaîne) — sans cet échappement, un texte contenant littéralement
// "</textarea>" ou un guillemet romprait le tag/l'attribut et tout ce
// qui suit serait interprété comme du HTML/JS brut dans SA PROPRE
// session d'édition.
return String(text)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function forgeDocRenderAssociationPairHtml(pair, pIndex) {
return `
<div class="docAssocPairRow" data-pair-index="${pIndex}">
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">Paire ${pIndex + 1}</span>
<button type="button" class="docQuizRemoveBtn" data-pair-index="${pIndex}" title="Supprimer cette paire">🗑</button>
</div>
<textarea class="docAssocPairInput"
rows="2"
placeholder="Élément"
data-pair-index="${pIndex}"
data-side="left">${forgeDocEscapeHtml(pair.left)}</textarea>
<div class="docAssocPairLink" aria-hidden="true">↕</div>
<textarea class="docAssocPairInput"
rows="2"
placeholder="Correspondance"
data-pair-index="${pIndex}"
data-side="right">${forgeDocEscapeHtml(pair.right)}</textarea>
</div>
`;
}
function forgeDocRenderAssociationProps(panel, el) {
const a = el.attributes;
const pairs = a.pairs || [];
function patch(partial) {
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
});
}
panel.innerHTML = `
${forgeDocPropsHeader('association')}
<div class="docField">
<span class="docFieldLabel">Couleur du thème</span>
<div class="docSwatches" id="docAssocColor">
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
</div>
</div>
<div class="docSectionLabel docQuizQuestionsLabel">Paires à relier</div>
<div id="docAssocPairsList">${pairs.length ? pairs.map(forgeDocRenderAssociationPairHtml).join('') : '<div class="docEmptyState">Aucune paire — ajoute la première ci-dessous.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docAssocAddPair">+ Ajouter une paire</button>
${forgeDocDeleteButtonHtml()}
`;
panel.querySelectorAll('#docAssocColor .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
});
document.getElementById('docAssocAddPair').addEventListener('click', () => {
patch({ pairs: [...pairs, forgeDocAssociationNewPair()] });
});
panel.querySelectorAll('#docAssocPairsList .docQuizRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const pIndex = Number(btn.dataset.pairIndex);
patch({ pairs: pairs.filter((_, i) => i !== pIndex) });
});
});
panel.querySelectorAll('.docAssocPairInput').forEach((input) => {
input.addEventListener('change', (e) => {
const pIndex = Number(input.dataset.pairIndex);
const { side } = input.dataset;
patch({ pairs: pairs.map((p, i) => (i === pIndex ? { ...p, [side]: e.target.value } : p)) });
});
});
}
function forgeDocMemoryNewCard() {
// Verso pré-rempli (jamais vide) : sanitize_memory_config
// (routes/document/document_element_update.py) rejette silencieusement
// toute carte dont le verso est entièrement vide (rien à révéler) —
// même leçon que forgeDocAssociationNewPair/forgeDocQuizNewQuestion
// ci-dessus (voir leur commentaire : sans ça, "+ Ajouter une carte"
// semblerait ne rien faire).
return { recto: { image: '', text: '' }, verso: { image: '', text: 'Nouvelle carte' } };
}
function forgeDocRenderMemoryCardHtml(card, cIndex) {
return `
<div class="docAssocPairRow" data-card-index="${cIndex}">
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">Carte ${cIndex + 1}</span>
<button type="button" class="docQuizRemoveBtn" data-card-index="${cIndex}" title="Supprimer cette carte">🗑</button>
</div>
<span class="docMemoryFaceLabel">Recto (face cachée, optionnel)</span>
<input type="text"
class="docAssocPairInput"
placeholder="URL de l'image"
value="${forgeDocEscapeHtml(card.recto.image)}"
data-card-index="${cIndex}"
data-face="recto"
data-field="image">
<textarea class="docAssocPairInput"
rows="2"
placeholder="Texte"
data-card-index="${cIndex}"
data-face="recto"
data-field="text">${forgeDocEscapeHtml(card.recto.text)}</textarea>
<span class="docMemoryFaceLabel">Verso (révélé au retournement)</span>
<input type="text"
class="docAssocPairInput"
placeholder="URL de l'image"
value="${forgeDocEscapeHtml(card.verso.image)}"
data-card-index="${cIndex}"
data-face="verso"
data-field="image">
<textarea class="docAssocPairInput"
rows="2"
placeholder="Texte"
data-card-index="${cIndex}"
data-face="verso"
data-field="text">${forgeDocEscapeHtml(card.verso.text)}</textarea>
</div>
`;
}
function forgeDocRenderMemoryProps(panel, el) {
const a = el.attributes;
const cards = a.cards || [];
function patch(partial) {
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
});
}
panel.innerHTML = `
${forgeDocPropsHeader('memory')}
<div class="docField">
<span class="docFieldLabel">Mode</span>
<div class="docSegmented" id="docMemoryMode">
<button type="button" class="docSegBtn ${a.mode === 'paire' ? 'is-active' : ''}" data-mode="paire">Paire (dupliquer)</button>
<button type="button" class="docSegBtn ${a.mode === 'single' ? 'is-active' : ''}" data-mode="single">Simple (sans doublon)</button>
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Couleur du thème</span>
<div class="docSwatches" id="docMemoryColor">
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
</div>
</div>
<div class="docSectionLabel docQuizQuestionsLabel">Cartes</div>
<div id="docMemoryCardsList">${cards.length ? cards.map(forgeDocRenderMemoryCardHtml).join('') : '<div class="docEmptyState">Aucune carte — ajoute la première ci-dessous.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docMemoryAddCard">+ Ajouter une carte</button>
${forgeDocDeleteButtonHtml()}
`;
panel.querySelectorAll('#docMemoryMode .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ mode: btn.dataset.mode }));
});
panel.querySelectorAll('#docMemoryColor .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
});
document.getElementById('docMemoryAddCard').addEventListener('click', () => {
patch({ cards: [...cards, forgeDocMemoryNewCard()] });
});
panel.querySelectorAll('#docMemoryCardsList .docQuizRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const cIndex = Number(btn.dataset.cardIndex);
patch({ cards: cards.filter((_, i) => i !== cIndex) });
});
});
panel.querySelectorAll('#docMemoryCardsList .docAssocPairInput').forEach((input) => {
input.addEventListener('change', (e) => {
const cIndex = Number(input.dataset.cardIndex);
const { face, field } = input.dataset;
patch({
cards: cards.map((c, i) => (i === cIndex ? { ...c, [face]: { ...c[face], [field]: e.target.value } } : c)),
});
});
});
}
function forgeDocRenderMotsWordHtml(word, wIndex) {
return `
<div class="docAssocPairRow docMotsWordRow" data-word-index="${wIndex}">
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">Mot ${wIndex + 1}</span>
<button type="button" class="docQuizRemoveBtn" data-word-index="${wIndex}" title="Supprimer ce mot">🗑</button>
</div>
<input type="text"
class="docAssocPairInput docMotsWordInput"
placeholder="Mot à cacher dans la grille"
maxlength="20"
value="${forgeDocEscapeHtml(word)}"
data-word-index="${wIndex}">
</div>
`;
}
function forgeDocRenderMotsProps(panel, el) {
const a = el.attributes;
const words = a.words || [];
function patch(partial) {
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
});
}
panel.innerHTML = `
${forgeDocPropsHeader('mots mêlés')}
<div class="docField">
<span class="docFieldLabel">Couleur du thème</span>
<div class="docSwatches" id="docMotsColor">
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
</div>
</div>
<div class="docSectionLabel docQuizQuestionsLabel">Mots à cacher (5 à 10 recommandés)</div>
<div id="docMotsWordsList">${words.length ? words.map(forgeDocRenderMotsWordHtml).join('') : '<div class="docEmptyState">Aucun mot — ajoute le premier ci-dessous.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docMotsAddWord">+ Ajouter un mot</button>
${forgeDocDeleteButtonHtml()}
`;
panel.querySelectorAll('#docMotsColor .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
});
document.getElementById('docMotsAddWord').addEventListener('click', () => {
// Mot par défaut non vide (jamais ""), même leçon que
// forgeDocAssociationNewPair/forgeDocMemoryNewCard : sanitize_mots_config
// (routes/document/document_element_update.py) rejette silencieusement
// tout mot qui ne contient aucune lettre exploitable une fois nettoyé.
patch({ words: [...words, 'NOUVEAU'] });
});
panel.querySelectorAll('#docMotsWordsList .docQuizRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const wIndex = Number(btn.dataset.wordIndex);
patch({ words: words.filter((_, i) => i !== wIndex) });
});
});
panel.querySelectorAll('#docMotsWordsList .docMotsWordInput').forEach((input) => {
input.addEventListener('change', (e) => {
const wIndex = Number(input.dataset.wordIndex);
patch({ words: words.map((w, i) => (i === wIndex ? e.target.value : w)) });
});
});
}
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.
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,
};
}
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 forgeDocRenderScenarioHtml(scenario, sIndex) {
const choicesHtml = scenario.choices
.map((c, cIndex) => forgeDocRenderScenarioChoiceHtml(c, sIndex, cIndex, scenario.correct_index))
.join('');
return `
<div class="docQuizQuestion docScenarioCard" data-scenario-index="${sIndex}">
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">Scénario ${sIndex + 1}</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>
`;
}
function forgeDocRenderScenarioProps(panel, el) {
const a = el.attributes;
const scenarios = a.scenarios || [];
function patch(partial) {
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
});
}
panel.innerHTML = `
${forgeDocPropsHeader('scénario')}
<div class="docField">
<span class="docFieldLabel">Couleur du thème</span>
<div class="docSwatches" id="docScenarioColor">
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
</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>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docScenarioAdd">+ Ajouter un scénario</button>
${forgeDocDeleteButtonHtml()}
`;
panel.querySelectorAll('#docScenarioColor .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
});
document.getElementById('docScenarioAdd').addEventListener('click', () => {
patch({ scenarios: [...scenarios, forgeDocScenarioNewScenario()] });
});
panel.querySelectorAll('.docScenarioRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const sIndex = Number(btn.dataset.scenarioIndex);
patch({ scenarios: scenarios.filter((_, i) => i !== sIndex) });
});
});
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('.docScenarioChoiceCountInput').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 };
}),
});
});
});
panel.querySelectorAll('.docScenarioChoiceInput').forEach((input) => {
input.addEventListener('change', (e) => {
const sIndex = Number(input.dataset.scenarioIndex);
const cIndex = Number(input.dataset.choiceIndex);
patch({
scenarios: scenarios.map((s, i) => (
i === sIndex
? { ...s, choices: s.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) }
: s
)),
});
});
});
panel.querySelectorAll('.docScenarioConsequenceInput').forEach((input) => {
input.addEventListener('change', (e) => {
const sIndex = Number(input.dataset.scenarioIndex);
const cIndex = Number(input.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
)),
});
});
});
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)) });
});
});
}
function forgeDocRenderProps(el) {
const panel = document.getElementById('docPropsPanel');
if (!el) {
panel.innerHTML = '<div class="docSectionLabel">Propriétés</div><div class="docEmptyState">Sélectionnez un élément du document pour voir ses propriétés.</div>';
return;
}
if (el.kind === 'titre' || el.kind === 'paragraphe') forgeDocRenderTextProps(panel, el);
else if (forgeDocIsShapeKind(el.kind)) forgeDocRenderShapeProps(panel, el);
else if (el.kind === 'image') forgeDocRenderImageProps(panel, el);
else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el);
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
else if (el.kind === 'memory') forgeDocRenderMemoryProps(panel, el);
else if (el.kind === 'mots') forgeDocRenderMotsProps(panel, el);
else if (el.kind === 'scenario') forgeDocRenderScenarioProps(panel, el);
else forgeDocRenderMinigameProps(panel, el);
const deleteBtn = document.getElementById('docDeletePropBtn');
if (deleteBtn) deleteBtn.addEventListener('click', () => forgeDocDeleteElement(el.id));
}
/* ---------------------------------------------------------------------
* Chrome de l'éditeur : zoom, largeur d'aperçu responsive, Aperçu,
* thème (scopé à .docEditor3), navigation mobile.
* ------------------------------------------------------------------- */
function forgeDocBindZoom() {
const docPage = document.getElementById('docPage');
document.getElementById('docZoomOut').addEventListener('click', () => {
window.forgeDocState.zoom = Math.max(50, window.forgeDocState.zoom - 10);
document.getElementById('docZoomVal').textContent = `${window.forgeDocState.zoom}%`;
docPage.style.transform = `scale(${window.forgeDocState.zoom / 100})`;
});
document.getElementById('docZoomIn').addEventListener('click', () => {
window.forgeDocState.zoom = Math.min(150, window.forgeDocState.zoom + 10);
document.getElementById('docZoomVal').textContent = `${window.forgeDocState.zoom}%`;
docPage.style.transform = `scale(${window.forgeDocState.zoom / 100})`;
});
}
function forgeDocBindWidthPicker() {
document.querySelectorAll('.docWidthBtn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.docWidthBtn').forEach((b) => b.classList.remove('is-active'));
btn.classList.add('is-active');
const docPage = document.getElementById('docPage');
const width = btn.dataset.width;
if (!width) {
docPage.classList.remove('docPage--fixedWidth');
docPage.style.removeProperty('--doc-preview-width');
} else {
docPage.classList.add('docPage--fixedWidth');
docPage.style.setProperty('--doc-preview-width', `${width}px`);
}
});
});
}
function forgeDocBindPreviewToggle() {
document.getElementById('docPreviewBtn').addEventListener('click', () => {
window.forgeDocState.previewMode = !window.forgeDocState.previewMode;
document.getElementById('docEditor3').classList.toggle('docEditor3--preview', window.forgeDocState.previewMode);
});
}
function forgeDocApplyTheme(theme) {
const editor = document.getElementById('docEditor3');
editor.dataset.theme = theme;
const toggle = document.getElementById('docThemeToggle');
toggle.innerHTML = theme === 'light'
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/></svg>'
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M2 12h2M20 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/></svg>';
}
function forgeDocBindThemeToggle() {
let stored = 'dark';
try {
const saved = window.localStorage.getItem('forge-doc-editor-theme');
if (saved === 'light' || saved === 'dark') stored = saved;
} catch (e) { /* stockage indisponible, on reste sur le thème par défaut */ }
forgeDocApplyTheme(stored);
document.getElementById('docThemeToggle').addEventListener('click', () => {
const next = document.getElementById('docEditor3').dataset.theme === 'light' ? 'dark' : 'light';
forgeDocApplyTheme(next);
try { window.localStorage.setItem('forge-doc-editor-theme', next); } catch (e) { /* ignore */ }
});
}
function forgeDocBindMobileNav() {
const left = document.getElementById('docSidebarLeft');
const right = document.getElementById('docSidebarRight');
document.querySelectorAll('#docBottomNav button').forEach((btn) => {
btn.addEventListener('click', () => {
forgeDocSetActiveBottomNav(btn.dataset.target);
left.classList.toggle('is-open', btn.dataset.target === 'left');
right.classList.toggle('is-open', btn.dataset.target === 'right');
});
});
function closePanels() {
left.classList.remove('is-open');
right.classList.remove('is-open');
forgeDocSetActiveBottomNav('doc');
}
document.getElementById('docCloseLeft').addEventListener('click', closePanels);
document.getElementById('docCloseRight').addEventListener('click', closePanels);
}
function forgeDocBindDeselectOnBackgroundClick() {
document.getElementById('docCanvasArea').addEventListener('click', (e) => {
if (e.target.closest('[data-element-id]')) return;
window.forgeDocState.selectedId = null;
forgeDocApplySelectionClass();
forgeDocRenderProps(null);
});
}
function forgeDocBindKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (!document.getElementById('docEditor3')) return;
const isUndo = (e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === 'z';
const isRedo = ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'z')
|| ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y');
if (isUndo) { e.preventDefault(); forgeDocUndo(); }
if (isRedo) { e.preventDefault(); forgeDocRedo(); }
});
}
function forgeDocInit() {
const root = document.getElementById('docEditor3');
if (!root || !window.FORGE_DOCUMENT) return;
window.forgeDocState = {
slug: window.FORGE_DOCUMENT.slug,
elements: window.FORGE_DOCUMENT.elements,
elementsById: {},
selectedId: null,
undoStack: [],
redoStack: [],
previewMode: false,
zoom: 100,
shapeDrag: null,
};
forgeDocRebuildIndex();
forgeDocBindCanvasInteractions();
forgeDocBindLibraryDragHandlers();
forgeDocBindCanvasDropZone();
forgeDocBindShapeDragGlobalHandlers();
forgeDocBindDeselectOnBackgroundClick();
forgeDocBindZoom();
forgeDocBindWidthPicker();
forgeDocBindPreviewToggle();
forgeDocBindThemeToggle();
forgeDocBindMobileNav();
forgeDocBindKeyboardShortcuts();
forgeDocUpdateHistoryButtons();
document.getElementById('docUndoBtn').addEventListener('click', forgeDocUndo);
document.getElementById('docRedoBtn').addEventListener('click', forgeDocRedo);
if (window.innerWidth <= 900) {
document.getElementById('docSidebarLeft').classList.remove('is-open');
document.getElementById('docSidebarRight').classList.remove('is-open');
}
}
forgeDocInit();