Files
Forge-Engine/static/document/js/document-editor.js
T
williamandClaude Sonnet 5 b4e4e80b7c
Build and deploy / test-python (push) Successful in 10m10s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / lint-python (push) Successful in 4m44s
Build and deploy / lint-js (push) Failing after 1m27s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 4m8s
Panneau gauche à onglets : "Pages" (gestion complète) et "Mise en page" (bibliothèque)
Remplace le carrousel une-page-à-la-fois par un panneau dédié plein
hauteur : liste verticale scrollable de toutes les pages, réordonnage
par glisser OU boutons haut/bas (accessibilité clavier), renommer
(crayon, édition en ligne), supprimer (protégé contre la suppression
de la dernière page), ajouter. La bibliothèque d'éléments passe dans
un second onglet "Mise en page", contenu inchangé. "Mise en page"
actif par défaut au chargement.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 09:03:21 +02:00

2859 lines
119 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',
};
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) {
// page_id lu depuis l'état ambiant (window.forgeDocState.activePageId),
// jamais un paramètre explicite — même principe que le slug déjà lu
// ainsi juste en dessous : tout appelant de cette fonction ajoute par
// définition à "la page actuellement affichée", inutile de le faire
// remonter dans chacun des ~10 sites d'appel (glisser-déposer, moteur
// de layout, Annuler/Rétablir...).
const body = new URLSearchParams();
body.set('kind', kind);
body.set('page_id', String(window.forgeDocState.activePageId));
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() {
const url = `/document/${window.forgeDocState.slug}/render?page_id=${window.forgeDocState.activePageId}`;
return fetch(url).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]'));
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);
});
}
/* ---------------------------------------------------------------------
* Pages — un support est composé de plusieurs pages (retour utilisateur
* du 21/09/2026 : "il faut implémenter un système de page"), chacune un
* document indépendant affiché SEUL sur le canevas à la fois. Onglet
* "Pages" dédié dans le panneau GAUCHE (retour utilisateur du
* 23/09/2026 : "il dois avoir deux onglets [...] un onglet page qui
* liste toute la page, permet de changer l'ordre des pages, d'ajouter,
* supprimer ou modifier le nom") — panneau plein-écran (jamais partagé
* avec la bibliothèque comme les itérations précédentes), liste
* verticale de rangées texte (voir templates/document/document_edit.html,
* .docPageManagerList) : créer/renommer (bouton crayon)/réordonner
* (glisser OU boutons haut/bas)/supprimer. Changer de page VIDE la pile
* Annuler/Rétablir (voir forgeDocSwitchPage) : une commande empilée sur
* une autre page rejouerait un appel AJAX ciblant des éléments qui ne
* sont plus affichés, aucun sens à la garder.
* ------------------------------------------------------------------- */
function forgeDocApiPageAdd(title) {
return fetch(`/document/${window.forgeDocState.slug}/pages/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(title ? { title } : {}),
}).then((r) => r.json());
}
function forgeDocApiPageRename(pageId, title) {
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
}).then((r) => r.json());
}
function forgeDocApiPageDelete(pageId) {
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/delete`, { method: 'POST' })
.then((r) => r.json());
}
function forgeDocApiPageMove(pageId, newIndex) {
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_index: newIndex }),
}).then((r) => r.json());
}
function forgeDocOrderedPages() {
return [...window.forgeDocState.pages].sort((a, b) => a.order_index - b.order_index);
}
function forgeDocUpdatePageNavPill() {
const pill = document.getElementById('docPageNavPill');
if (!pill) return;
const pages = forgeDocOrderedPages();
const show = window.forgeDocState.previewMode && pages.length > 1;
pill.classList.toggle('is-visible', show);
if (!show) return;
const index = pages.findIndex((p) => p.id === window.forgeDocState.activePageId);
document.getElementById('docPageNavLabel').textContent = `Page ${index + 1} / ${pages.length}`;
document.getElementById('docPagePrevBtn').disabled = index <= 0;
document.getElementById('docPageNextBtn').disabled = index >= pages.length - 1;
}
function forgeDocSwitchPage(pageId) {
if (pageId === window.forgeDocState.activePageId) return Promise.resolve();
window.forgeDocState.activePageId = pageId;
window.forgeDocState.undoStack = [];
window.forgeDocState.redoStack = [];
forgeDocUpdateHistoryButtons();
window.forgeDocState.selectedId = null;
forgeDocRenderProps(null);
forgeDocRenderPageManagerList();
forgeDocUpdatePageNavPill();
return forgeDocRefreshCanvas();
}
function forgeDocAddPage() {
return forgeDocApiPageAdd().then((result) => {
window.forgeDocState.pages.push({
id: result.id, title: result.title, order_index: window.forgeDocState.pages.length,
});
return forgeDocSwitchPage(result.id);
});
}
function forgeDocRenamePage(pageId, title) {
return forgeDocApiPageRename(pageId, title).then((result) => {
const page = window.forgeDocState.pages.find((p) => p.id === pageId);
if (page) page.title = result.title;
forgeDocRenderPageManagerList();
});
}
function forgeDocDeletePage(pageId) {
// Filet de sécurité client (en plus du refus serveur, voir
// routes/document/document_page_delete.py) : jamais même tenter de
// supprimer la dernière page restante.
if (window.forgeDocState.pages.length <= 1) return Promise.resolve();
return forgeDocApiPageDelete(pageId).then((result) => {
if (result.error) {
alert(result.error);
return Promise.resolve();
}
window.forgeDocState.pages = window.forgeDocState.pages.filter((p) => p.id !== pageId);
if (window.forgeDocState.activePageId === pageId) {
return forgeDocSwitchPage(forgeDocOrderedPages()[0].id);
}
forgeDocRenderPageManagerList();
return Promise.resolve();
});
}
function forgeDocMovePage(pageId, direction) {
const ordered = forgeDocOrderedPages();
const index = ordered.findIndex((p) => p.id === pageId);
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= ordered.length) return;
forgeDocApiPageMove(pageId, newIndex).then(() => {
const moved = window.forgeDocState.pages.find((p) => p.id === pageId);
const rest = window.forgeDocState.pages
.filter((p) => p.id !== pageId)
.sort((a, b) => a.order_index - b.order_index);
rest.splice(newIndex, 0, moved);
rest.forEach((p, i) => { p.order_index = i; });
window.forgeDocState.pages = rest;
forgeDocRenderPageManagerList();
});
}
function forgeDocSelectAllText(el) {
const range = document.createRange();
range.selectNodeContents(el);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
function forgeDocStartRenamingPage(pageId) {
const item = document.querySelector(`.docPageRow[data-page-id="${pageId}"]`);
if (!item) return;
const titleEl = item.querySelector('.docPageRowTitle');
const original = titleEl.textContent;
titleEl.setAttribute('contenteditable', 'true');
titleEl.focus();
forgeDocSelectAllText(titleEl);
function stopEditing() {
titleEl.removeAttribute('contenteditable');
titleEl.removeEventListener('blur', onBlur);
titleEl.removeEventListener('keydown', onKeydown);
}
function onBlur() {
const newTitle = titleEl.textContent.trim();
stopEditing();
if (!newTitle || newTitle === original) {
titleEl.textContent = original;
return;
}
forgeDocRenamePage(pageId, newTitle);
}
function onKeydown(e) {
if (e.key === 'Enter') {
e.preventDefault();
titleEl.blur();
} else if (e.key === 'Escape') {
e.preventDefault();
titleEl.textContent = original;
stopEditing();
}
}
titleEl.addEventListener('blur', onBlur);
titleEl.addEventListener('keydown', onKeydown);
}
function forgeDocApplyTitleMarquee(titleEl) {
// "défile au passage de la souris" (retour utilisateur du 21/09/2026) :
// seuls les titres RÉELLEMENT tronqués défilent au survol — mesuré ici
// (scrollWidth vs clientWidth), jamais un défilement systématique qui
// ferait sauter les titres courts sans raison.
const textEl = titleEl.querySelector('.docPageRowTitleText');
const overflow = textEl.scrollWidth - titleEl.clientWidth;
if (overflow > 0) {
titleEl.classList.add('has-overflow');
titleEl.style.setProperty('--marquee-distance', `${overflow}px`);
}
}
function forgeDocRenderPageManagerList() {
const container = document.getElementById('docPageManagerList');
if (!container) return;
const pages = forgeDocOrderedPages();
// div + role="button" ici, jamais <button> : la rangée contient de
// vrais <button> d'action (monter/descendre/renommer/supprimer), qu'un
// <button> englobant ne pourrait pas contenir validement. L'équivalent
// clavier (Entrée/Espace) est posé plus bas. Réordonnancement : glisser
// (souris, voir forgeDocBindPageManagerDrag) ET boutons ↑/↓ (clavier),
// les deux appellent le même forgeDocMovePage.
container.innerHTML = pages.map((p, index) => `
<div class="docPageRow ${p.id === window.forgeDocState.activePageId ? 'is-active' : ''}"
role="button"
tabindex="0"
draggable="true"
data-page-id="${p.id}">
<span class="docPageRowHandle" aria-hidden="true">⠿</span>
<span class="docPageRowNumber">${index + 1}</span>
<span class="docPageRowTitle"><span class="docPageRowTitleText">${forgeDocEscapeHtml(p.title)}</span></span>
<span class="docPageRowActions">
<button type="button" class="docPageRowMove" data-dir="up" data-page-id="${p.id}" aria-label="Monter" title="Monter"${index === 0 ? ' disabled' : ''}>↑</button>
<button type="button" class="docPageRowMove" data-dir="down" data-page-id="${p.id}" aria-label="Descendre" title="Descendre"${index === pages.length - 1 ? ' disabled' : ''}>↓</button>
<button type="button" class="docPageRowRename" data-page-id="${p.id}" aria-label="Renommer cette page" title="Renommer cette page">✎</button>
<button type="button" class="docPageRowDelete" data-page-id="${p.id}" aria-label="Supprimer cette page" title="Supprimer cette page"${pages.length <= 1 ? ' disabled' : ''}>✕</button>
</span>
</div>
`).join('');
container.querySelectorAll('.docPageRow[data-page-id]').forEach((row) => {
row.addEventListener('click', (e) => {
if (e.target.closest('.docPageRowActions')) return;
forgeDocSwitchPage(Number(row.dataset.pageId));
});
row.addEventListener('keydown', (e) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
forgeDocSwitchPage(Number(row.dataset.pageId));
});
});
container.querySelectorAll('.docPageRowMove').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocMovePage(Number(btn.dataset.pageId), btn.dataset.dir);
});
});
container.querySelectorAll('.docPageRowRename').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocStartRenamingPage(Number(btn.dataset.pageId));
});
});
container.querySelectorAll('.docPageRowDelete').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocDeletePage(Number(btn.dataset.pageId));
});
});
container.querySelectorAll('.docPageRowTitle').forEach(forgeDocApplyTitleMarquee);
const addBtn = document.getElementById('docPageManagerAdd');
if (addBtn) addBtn.onclick = () => forgeDocAddPage();
}
function forgeDocBindPageManagerDrag() {
// Délégation sur le CONTENEUR (jamais les rangées elles-mêmes,
// recréées à chaque forgeDocRenderPageManagerList) : un seul jeu de
// listeners pour toute la session, même principe que
// forgeDocMotsBindGlobalMouseup.
const container = document.getElementById('docPageManagerList');
if (!container) return;
let draggedId = null;
container.addEventListener('dragstart', (e) => {
const row = e.target.closest('.docPageRow');
if (!row || !row.dataset.pageId) return;
draggedId = Number(row.dataset.pageId);
row.classList.add('is-dragging');
e.dataTransfer.effectAllowed = 'move';
});
container.addEventListener('dragend', (e) => {
const row = e.target.closest('.docPageRow');
if (row) row.classList.remove('is-dragging');
});
container.addEventListener('dragover', (e) => {
if (draggedId !== null) e.preventDefault();
});
container.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedId === null) return;
const targetRow = e.target.closest('.docPageRow[data-page-id]');
const ordered = forgeDocOrderedPages();
const newIndex = targetRow
? ordered.findIndex((p) => p.id === Number(targetRow.dataset.pageId))
: ordered.length - 1;
const movedId = draggedId;
draggedId = null;
if (newIndex < 0) return;
forgeDocApiPageMove(movedId, newIndex).then(() => {
const moved = window.forgeDocState.pages.find((p) => p.id === movedId);
const rest = window.forgeDocState.pages
.filter((p) => p.id !== movedId)
.sort((a, b) => a.order_index - b.order_index);
rest.splice(newIndex, 0, moved);
rest.forEach((p, i) => { p.order_index = i; });
window.forgeDocState.pages = rest;
forgeDocRenderPageManagerList();
});
});
}
function forgeDocSwitchSidebarTab(tabName) {
const tabs = { pages: document.getElementById('docTabPages'), layout: document.getElementById('docTabLayout') };
const panels = { pages: document.getElementById('docTabPanelPages'), layout: document.getElementById('docTabPanelLayout') };
Object.keys(tabs).forEach((name) => {
const isActive = name === tabName;
tabs[name].classList.toggle('is-active', isActive);
tabs[name].setAttribute('aria-selected', String(isActive));
panels[name].hidden = !isActive;
});
}
function forgeDocBindSidebarTabs() {
document.querySelectorAll('.docSidebarTab').forEach((tab) => {
tab.addEventListener('click', () => forgeDocSwitchSidebarTab(tab.dataset.tab));
});
}
function forgeDocBindPageNavPill() {
document.getElementById('docPagePrevBtn').addEventListener('click', () => {
const pages = forgeDocOrderedPages();
const index = pages.findIndex((p) => p.id === window.forgeDocState.activePageId);
if (index > 0) forgeDocSwitchPage(pages[index - 1].id);
});
document.getElementById('docPageNextBtn').addEventListener('click', () => {
const pages = forgeDocOrderedPages();
const index = pages.findIndex((p) => p.id === window.forgeDocState.activePageId);
if (index < pages.length - 1) forgeDocSwitchPage(pages[index + 1].id);
});
}
/* ---------------------------------------------------------------------
* 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);
});
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('#docPage .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('#docPage .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('#docPage .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('#docPage .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('#docPage .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 :
* 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) {
try {
const config = JSON.parse(playerEl.dataset.scenarioConfig || '{}');
return Array.isArray(config.scenarios) ? config.scenarios : [];
} catch (e) {
return [];
}
}
function forgeDocScenarioFindNode(tree, nodeId) {
return tree.nodes.find((n) => n.id === nodeId) || tree.nodes[0];
}
function forgeDocScenarioReachEnd(playerEl, state) {
const isLast = state.current === state.scenarios.length - 1;
if (isLast) {
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
} else {
playerEl.querySelector('.docScenarioNextBtn').classList.add('is-visible');
}
}
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) {
state.currentNodeId = state.scenarios[state.current].nodes[0].id;
forgeDocRenderScenarioNode(playerEl, state);
}
}
function forgeDocScenarioRestart(playerEl, state) {
state.current = 0;
state.currentNodeId = state.scenarios[0].nodes[0].id;
forgeDocRenderScenarioNode(playerEl, state);
}
function forgeDocInitScenarioPlayers() {
document.querySelectorAll('#docPage .docScenarioPlayer').forEach((playerEl) => {
const scenarios = forgeDocScenarioPlayerData(playerEl);
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);
});
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 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)) });
});
});
}
/* ---------------------------------------------------------------------
* 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() {
// 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 {
title: 'Nouveau scénario',
nodes: [{
id: 'n1', text: 'Nouvelle situation', x: 40, y: 40, choices: [],
}],
};
}
function forgeDocScenarioNewNode(id, x, y) {
return {
id, text: 'Nouvelle situation', x, y, choices: [],
};
}
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">${forgeDocEscapeHtml(scenario.title)}</span>
<button type="button" class="docQuizRemoveBtn docScenarioRemoveBtn" data-scenario-index="${sIndex}" title="Supprimer ce scénario">🗑</button>
</div>
<div class="docFieldRow"><span class="docFieldLabel">${nodeCount} situation${nodeCount > 1 ? 's' : ''}</span></div>
<button type="button" class="docBtnSecondary docScenarioEditTreeBtn" data-scenario-index="${sIndex}">✎ Modifier l'arbre</button>
</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(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()}
`;
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('.docScenarioEditTreeBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const sIndex = Number(btn.dataset.scenarioIndex);
forgeDocOpenScenarioTreeModal(el.id, sIndex);
});
});
}
/* ---------------------------------------------------------------------
* Scénario — modale de construction de l'arbre : un VRAI graphe visuel
* (retour utilisateur du 21/09/2026 : "passons à un véritable graphe
* visuel", remplace la première version en liste de nœuds + menus
* déroulants). Les nœuds sont des cartes positionnées/déplaçables à la
* souris (glisser) ; les choix qui ont une destination sont
* dessinés comme des flèches SVG reliant les deux nœuds, étiquetées par
* le texte du choix. Sélectionner un nœud (clic) ouvre son formulaire
* d'édition (texte + choix) dans l'inspecteur (colonne de droite) —
* relier un choix à un autre nœud se fait en cliquant "Relier" puis en
* cliquant le nœud cible sur le graphe (mode "connexion", Échap annule).
* La situation initiale (nodes[0]) reste déplaçable mais jamais
* supprimable. Persistance identique aux autres panneaux : chaque
* modification envoie l'état COMPLET du scénario au serveur (voir
* forgeDocScenarioGraphPatch), jamais un état simulé côté client qui
* pourrait diverger de ce que sanitize_scenario_config a réellement
* accepté — SAUF la position pendant un glisser, mise à jour localement
* en continu pour rester fluide, et persistée en un seul appel au
* relâchement de la souris.
* ------------------------------------------------------------------- */
let forgeDocScenarioGraphState = null;
let forgeDocScenarioGraphDragBound = false;
function forgeDocScenarioGraphNextPosition(nodes) {
// Même formule que _scenario_node_fallback_position côté serveur
// (document_engine/labels/scenario_config.py) — pas requis que ça
// matche exactement puisque le round-trip serveur fait foi de toute
// façon, mais évite un nœud visiblement mal placé le temps de la
// réponse.
const cols = 4;
const stepX = 220;
const stepY = 160;
const margin = 40;
const index = nodes.length;
return { x: margin + (index % cols) * stepX, y: margin + Math.floor(index / cols) * stepY };
}
function forgeDocScenarioNodeLabel(nIndex) {
// Terminologie volontairement cohérente de bout en bout (retour
// utilisateur du 21/09/2026 : "il faut bien choisir les termes") :
// Situation -> Choix -> le choix MÈNE À une nouvelle Situation (avec
// ses propres choix), jamais un vocabulaire technique de graphe
// ("nœud") qui n'a pas de sens pour le créateur du scénario.
return nIndex === 0 ? 'Situation initiale' : `Situation ${nIndex + 1}`;
}
function forgeDocScenarioGraphPatch(partial) {
const gs = forgeDocScenarioGraphState;
if (!gs) return;
const el = window.forgeDocState.elementsById[gs.elementId];
const scenarios = el.attributes.scenarios.map((s, i) => (i === gs.sIndex ? { ...s, ...partial } : s));
forgeDocUpdateAttributes(gs.elementId, { ...el.attributes, scenarios }).then(() => {
forgeDocRenderScenarioTreeModal();
// Le panneau Propriétés affiche le nombre de nœuds par carte : le
// tenir synchronisé pendant que la modale reste ouverte.
if (window.forgeDocState.selectedId === gs.elementId) {
forgeDocRenderProps(window.forgeDocState.elementsById[gs.elementId]);
}
});
}
function forgeDocScenarioGraphNodeRect(nIndex) {
const gs = forgeDocScenarioGraphState;
const nodeEl = gs.nodeEls[nIndex];
if (!nodeEl) return null;
return {
x: nodeEl.offsetLeft, y: nodeEl.offsetTop, width: nodeEl.offsetWidth, height: nodeEl.offsetHeight,
};
}
function forgeDocScenarioGraphRedrawEdges() {
const gs = forgeDocScenarioGraphState;
const { svgEl, innerEl } = gs;
svgEl.innerHTML = '';
innerEl.querySelectorAll('.docScenarioGraphEdgeLabel').forEach((n) => n.remove());
const svgNs = 'http://www.w3.org/2000/svg';
const defs = document.createElementNS(svgNs, 'defs');
const marker = document.createElementNS(svgNs, 'marker');
marker.setAttribute('id', 'docScenarioArrow');
marker.setAttribute('viewBox', '0 0 10 10');
marker.setAttribute('refX', '9');
marker.setAttribute('refY', '5');
marker.setAttribute('markerWidth', '7');
marker.setAttribute('markerHeight', '7');
marker.setAttribute('orient', 'auto-start-reverse');
const arrowPath = document.createElementNS(svgNs, 'path');
arrowPath.setAttribute('d', 'M0,0 L10,5 L0,10 z');
arrowPath.setAttribute('fill', 'var(--doc-muted)');
marker.appendChild(arrowPath);
defs.appendChild(marker);
svgEl.appendChild(defs);
gs.scenario.nodes.forEach((node, nIndex) => {
const fromRect = forgeDocScenarioGraphNodeRect(nIndex);
if (!fromRect) return;
node.choices.forEach((choice) => {
if (!choice.target_id) return;
const targetIndex = gs.scenario.nodes.findIndex((n) => n.id === choice.target_id);
if (targetIndex === -1) return;
const toRect = forgeDocScenarioGraphNodeRect(targetIndex);
if (!toRect) return;
const x1 = fromRect.x + fromRect.width / 2;
const y1 = fromRect.y + fromRect.height / 2;
const x2 = toRect.x + toRect.width / 2;
const y2 = toRect.y + toRect.height / 2;
const line = document.createElementNS(svgNs, 'line');
line.setAttribute('class', 'docScenarioGraphEdge');
line.setAttribute('x1', String(x1));
line.setAttribute('y1', String(y1));
line.setAttribute('x2', String(x2));
line.setAttribute('y2', String(y2));
line.setAttribute('marker-end', 'url(#docScenarioArrow)');
svgEl.appendChild(line);
const label = document.createElement('div');
label.className = 'docScenarioGraphEdgeLabel';
label.style.left = `${(x1 + x2) / 2}px`;
label.style.top = `${(y1 + y2) / 2}px`;
label.textContent = choice.text;
innerEl.appendChild(label);
});
});
}
function forgeDocScenarioGraphUpdateHint() {
const gs = forgeDocScenarioGraphState;
const hintEl = document.getElementById('docScenarioGraphHint');
if (!hintEl) return;
if (gs.connecting) {
hintEl.textContent = 'Cliquez sur la situation cible pour relier ce choix, ou appuyez sur Échap pour annuler.';
hintEl.classList.add('is-connecting');
gs.canvasEl.classList.add('is-connecting');
} else {
hintEl.textContent = 'Glissez une situation pour la déplacer. Cliquez une situation pour modifier son texte et ses choix.';
hintEl.classList.remove('is-connecting');
gs.canvasEl.classList.remove('is-connecting');
}
}
function forgeDocScenarioGraphSelectNode(nIndex) {
const gs = forgeDocScenarioGraphState;
gs.selectedNodeIndex = nIndex;
Object.values(gs.nodeEls).forEach((el) => el.classList.remove('is-selected'));
if (gs.nodeEls[nIndex]) gs.nodeEls[nIndex].classList.add('is-selected');
forgeDocScenarioGraphRenderInspector();
}
function forgeDocScenarioGraphCancelConnect() {
const gs = forgeDocScenarioGraphState;
gs.connecting = null;
Object.values(gs.nodeEls).forEach((el) => el.classList.remove('is-connect-target'));
forgeDocScenarioGraphUpdateHint();
forgeDocScenarioGraphRenderInspector();
}
function forgeDocScenarioGraphStartConnect(nIndex, cIndex) {
const gs = forgeDocScenarioGraphState;
gs.connecting = { nodeIndex: nIndex, choiceIndex: cIndex };
Object.entries(gs.nodeEls).forEach(([i, el]) => {
el.classList.toggle('is-connect-target', Number(i) !== nIndex);
});
forgeDocScenarioGraphUpdateHint();
forgeDocScenarioGraphRenderInspector();
}
function forgeDocScenarioGraphCompleteConnect(targetNodeIndex) {
const gs = forgeDocScenarioGraphState;
const { nodeIndex, choiceIndex } = gs.connecting;
if (targetNodeIndex === nodeIndex) {
forgeDocScenarioGraphCancelConnect();
return;
}
const targetId = gs.scenario.nodes[targetNodeIndex].id;
gs.connecting = null;
const nodes = gs.scenario.nodes.map((n, i) => (
i === nodeIndex
? { ...n, choices: n.choices.map((c, ci) => (ci === choiceIndex ? { ...c, target_id: targetId } : c)) }
: n
));
forgeDocScenarioGraphPatch({ nodes });
}
function forgeDocScenarioGraphNodeMouseDown(e, nIndex) {
e.preventDefault();
e.stopPropagation();
const gs = forgeDocScenarioGraphState;
if (!gs) return;
if (gs.connecting) {
forgeDocScenarioGraphCompleteConnect(nIndex);
return;
}
forgeDocScenarioGraphSelectNode(nIndex);
const node = gs.scenario.nodes[nIndex];
gs.drag = {
nIndex, startX: e.clientX, startY: e.clientY, origX: node.x, origY: node.y, lastX: node.x, lastY: node.y,
};
}
function forgeDocScenarioGraphBindGlobalDrag() {
// Un SEUL listener document-level pour toute la page (même raison que
// forgeDocMotsBindGlobalMouseup) : la modale peut se ré-ouvrir
// plusieurs fois par session sans jamais accumuler de listeners.
if (forgeDocScenarioGraphDragBound) return;
forgeDocScenarioGraphDragBound = true;
document.addEventListener('mousemove', (e) => {
const gs = forgeDocScenarioGraphState;
if (!gs || !gs.drag) return;
const dx = e.clientX - gs.drag.startX;
const dy = e.clientY - gs.drag.startY;
const newX = Math.max(0, gs.drag.origX + dx);
const newY = Math.max(0, gs.drag.origY + dy);
gs.drag.lastX = newX;
gs.drag.lastY = newY;
const nodeEl = gs.nodeEls[gs.drag.nIndex];
if (nodeEl) {
nodeEl.style.left = `${newX}px`;
nodeEl.style.top = `${newY}px`;
}
forgeDocScenarioGraphRedrawEdges();
});
document.addEventListener('mouseup', () => {
const gs = forgeDocScenarioGraphState;
if (!gs || !gs.drag) return;
const {
nIndex, lastX, lastY, origX, origY,
} = gs.drag;
gs.drag = null;
if (lastX === origX && lastY === origY) return;
const nodes = gs.scenario.nodes.map((n, i) => (i === nIndex ? { ...n, x: lastX, y: lastY } : n));
forgeDocScenarioGraphPatch({ nodes });
});
}
function forgeDocScenarioGraphBuildNodeEl(node, nIndex, gs) {
const div = document.createElement('div');
div.className = 'docScenarioGraphNode';
if (nIndex === 0) div.classList.add('is-root');
if (nIndex === gs.selectedNodeIndex) div.classList.add('is-selected');
if (gs.connecting && gs.connecting.nodeIndex !== nIndex) div.classList.add('is-connect-target');
div.style.left = `${node.x}px`;
div.style.top = `${node.y}px`;
div.dataset.nodeIndex = String(nIndex);
const labelEl = document.createElement('div');
labelEl.className = 'docScenarioGraphNodeLabel';
labelEl.textContent = forgeDocScenarioNodeLabel(nIndex);
const textEl = document.createElement('div');
textEl.className = 'docScenarioGraphNodeText';
textEl.textContent = node.text;
div.appendChild(labelEl);
div.appendChild(textEl);
div.addEventListener('mousedown', (e) => forgeDocScenarioGraphNodeMouseDown(e, nIndex));
return div;
}
function forgeDocScenarioGraphRenderInspector() {
const gs = forgeDocScenarioGraphState;
const inspector = document.getElementById('docScenarioGraphInspector');
if (!inspector) return;
const nIndex = gs.selectedNodeIndex;
const node = gs.scenario.nodes[nIndex];
if (!node) {
inspector.innerHTML = '<div class="docScenarioGraphInspectorEmpty">Sélectionnez une situation sur le graphe.</div>';
return;
}
const isRoot = nIndex === 0;
const choicesHtml = node.choices.map((choice, cIndex) => {
const targetIndex = choice.target_id ? gs.scenario.nodes.findIndex((n) => n.id === choice.target_id) : -1;
const isConnectingThis = !!gs.connecting
&& gs.connecting.nodeIndex === nIndex && gs.connecting.choiceIndex === cIndex;
return `
<div class="docScenarioChoiceBlock">
<div class="docQuizChoiceRow">
<input type="text"
class="docQuizChoiceInput docScenarioTreeChoiceInput"
value="${forgeDocEscapeHtml(choice.text)}"
placeholder="Texte du choix"
data-choice-index="${cIndex}">
<button type="button" class="docQuizRemoveBtn docScenarioTreeChoiceRemoveBtn" data-choice-index="${cIndex}" title="Supprimer ce choix">🗑</button>
</div>
<div class="docScenarioChoiceTargetRow">
<span class="docScenarioChoiceTargetLabel">${targetIndex === -1 ? '— Fin de branche —' : `→ ${forgeDocEscapeHtml(forgeDocScenarioNodeLabel(targetIndex))}`}</span>
<button type="button" class="docScenarioChoiceLinkBtn ${isConnectingThis ? 'is-active' : ''}" data-choice-index="${cIndex}" data-action="link">${isConnectingThis ? 'Cliquez une situation…' : 'Relier'}</button>
${targetIndex === -1 ? '' : `<button type="button" class="docScenarioChoiceLinkBtn" data-choice-index="${cIndex}" data-action="unlink">Dissocier</button>`}
</div>
</div>
`;
}).join('');
inspector.innerHTML = `
<div class="docQuizQuestionHeader">
<span class="docFieldLabel">${forgeDocScenarioNodeLabel(nIndex)}</span>
${isRoot ? '' : '<button type="button" class="docQuizRemoveBtn" id="docScenarioTreeNodeRemoveBtn" title="Supprimer cette situation">🗑</button>'}
</div>
<div class="docField">
<textarea id="docScenarioTreeTextInput" rows="4" placeholder="Texte de la situation">${forgeDocEscapeHtml(node.text)}</textarea>
</div>
<div class="docSectionLabel docQuizQuestionsLabel">Choix</div>
<div class="docQuizChoicesList">${choicesHtml || '<div class="docEmptyState">Aucun choix — fin de cette branche.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docScenarioTreeAddChoiceBtn" ${node.choices.length >= 4 ? 'disabled' : ''}>+ Ajouter un choix</button>
`;
const removeBtn = document.getElementById('docScenarioTreeNodeRemoveBtn');
if (removeBtn) {
removeBtn.addEventListener('click', () => {
const removedId = node.id;
// Un choix qui pointait vers ce nœud devient "fin de branche"
// (jamais une référence morte) — même filet de sécurité que
// sanitize_scenario_config côté serveur.
const nodes = gs.scenario.nodes
.filter((_, i) => i !== nIndex)
.map((n) => ({
...n,
choices: n.choices.map((c) => (c.target_id === removedId ? { ...c, target_id: null } : c)),
}));
gs.selectedNodeIndex = 0;
forgeDocScenarioGraphPatch({ nodes });
});
}
document.getElementById('docScenarioTreeTextInput').addEventListener('change', (e) => {
const nodes = gs.scenario.nodes.map((n, i) => (i === nIndex ? { ...n, text: e.target.value } : n));
forgeDocScenarioGraphPatch({ nodes });
});
document.getElementById('docScenarioTreeAddChoiceBtn').addEventListener('click', () => {
const nodes = gs.scenario.nodes.map((n, i) => (
i === nIndex ? { ...n, choices: [...n.choices, { text: 'Nouveau choix', target_id: null }] } : n
));
forgeDocScenarioGraphPatch({ nodes });
});
inspector.querySelectorAll('.docScenarioTreeChoiceInput').forEach((input) => {
input.addEventListener('change', (e) => {
const cIndex = Number(input.dataset.choiceIndex);
const nodes = gs.scenario.nodes.map((n, i) => (
i === nIndex
? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) }
: n
));
forgeDocScenarioGraphPatch({ nodes });
});
});
inspector.querySelectorAll('.docScenarioTreeChoiceRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const cIndex = Number(btn.dataset.choiceIndex);
const nodes = gs.scenario.nodes.map((n, i) => (
i === nIndex ? { ...n, choices: n.choices.filter((_, ci) => ci !== cIndex) } : n
));
forgeDocScenarioGraphPatch({ nodes });
});
});
inspector.querySelectorAll('.docScenarioChoiceLinkBtn[data-action="link"]').forEach((btn) => {
btn.addEventListener('click', () => {
const cIndex = Number(btn.dataset.choiceIndex);
if (gs.connecting && gs.connecting.nodeIndex === nIndex && gs.connecting.choiceIndex === cIndex) {
forgeDocScenarioGraphCancelConnect();
} else {
forgeDocScenarioGraphStartConnect(nIndex, cIndex);
}
});
});
inspector.querySelectorAll('.docScenarioChoiceLinkBtn[data-action="unlink"]').forEach((btn) => {
btn.addEventListener('click', () => {
const cIndex = Number(btn.dataset.choiceIndex);
const nodes = gs.scenario.nodes.map((n, i) => (
i === nIndex
? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, target_id: null } : c)) }
: n
));
forgeDocScenarioGraphPatch({ nodes });
});
});
}
function forgeDocRenderScenarioTreeModal() {
const gs = forgeDocScenarioGraphState;
if (!gs) return;
const el = window.forgeDocState.elementsById[gs.elementId];
const scenario = el && (el.attributes.scenarios || [])[gs.sIndex];
if (!scenario) {
forgeDocCloseScenarioTreeModal();
return;
}
gs.scenario = scenario;
if (gs.selectedNodeIndex >= scenario.nodes.length) gs.selectedNodeIndex = 0;
if (gs.connecting && gs.connecting.nodeIndex >= scenario.nodes.length) gs.connecting = null;
document.getElementById('docScenarioTreeModalTitle').textContent = `Arbre — ${scenario.title}`;
const body = document.getElementById('docScenarioTreeModalBody');
body.innerHTML = `
<div class="docScenarioGraphTitleRow">
<input type="text"
id="docScenarioTreeTitleInput"
value="${forgeDocEscapeHtml(scenario.title)}"
placeholder="Nom du scénario">
</div>
<div class="docScenarioGraphToolbar">
<button type="button" class="docBtnSecondary" id="docScenarioGraphAddNode">+ Ajouter une situation</button>
<span class="docScenarioGraphHint" id="docScenarioGraphHint"></span>
</div>
<div class="docScenarioGraphSplit">
<div class="docScenarioGraphCanvas" id="docScenarioGraphCanvas">
<div class="docScenarioGraphInner" id="docScenarioGraphInner">
<svg class="docScenarioGraphSvg" id="docScenarioGraphSvg"></svg>
</div>
</div>
<div class="docScenarioGraphInspector" id="docScenarioGraphInspector"></div>
</div>
`;
document.getElementById('docScenarioTreeTitleInput').addEventListener('change', (e) => {
forgeDocScenarioGraphPatch({ title: e.target.value });
});
document.getElementById('docScenarioGraphAddNode').addEventListener('click', () => {
const newId = forgeDocScenarioGenerateNodeId(scenario.nodes);
const pos = forgeDocScenarioGraphNextPosition(scenario.nodes);
gs.selectedNodeIndex = scenario.nodes.length;
forgeDocScenarioGraphPatch({ nodes: [...scenario.nodes, forgeDocScenarioNewNode(newId, pos.x, pos.y)] });
});
gs.canvasEl = document.getElementById('docScenarioGraphCanvas');
gs.innerEl = document.getElementById('docScenarioGraphInner');
gs.svgEl = document.getElementById('docScenarioGraphSvg');
gs.nodeEls = {};
scenario.nodes.forEach((node, nIndex) => {
const nodeEl = forgeDocScenarioGraphBuildNodeEl(node, nIndex, gs);
gs.innerEl.appendChild(nodeEl);
gs.nodeEls[nIndex] = nodeEl;
});
gs.canvasEl.addEventListener('click', (e) => {
if (gs.connecting && (e.target === gs.canvasEl || e.target === gs.innerEl)) {
forgeDocScenarioGraphCancelConnect();
}
});
forgeDocScenarioGraphRedrawEdges();
forgeDocScenarioGraphUpdateHint();
forgeDocScenarioGraphRenderInspector();
}
function forgeDocOpenScenarioTreeModal(elementId, sIndex) {
document.getElementById('docScenarioTreeModal').classList.add('is-open');
forgeDocScenarioGraphState = {
elementId, sIndex, selectedNodeIndex: 0, connecting: null, drag: null, nodeEls: {},
};
forgeDocScenarioGraphBindGlobalDrag();
forgeDocRenderScenarioTreeModal();
}
function forgeDocCloseScenarioTreeModal() {
document.getElementById('docScenarioTreeModal').classList.remove('is-open');
forgeDocScenarioGraphState = null;
}
function forgeDocBindScenarioTreeModal() {
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') return;
const gs = forgeDocScenarioGraphState;
if (gs && gs.connecting) {
forgeDocScenarioGraphCancelConnect();
return;
}
forgeDocCloseScenarioTreeModal();
});
}
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 (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);
forgeDocUpdatePageNavPill();
});
}
function forgeDocApplyTheme(theme) {
const editor = document.getElementById('docEditor3');
editor.dataset.theme = theme;
// #docScenarioTreeModal reste un FRÈRE de .docEditor3 dans le HTML
// (jamais un descendant — voir static/document/document-editor.css
// pour pourquoi), donc ne reçoit pas data-theme par simple héritage :
// posé ici explicitement pour que la modale suive le même thème.
const scenarioModal = document.getElementById('docScenarioTreeModal');
if (scenarioModal) scenarioModal.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: {},
pages: window.FORGE_DOCUMENT.pages,
activePageId: window.FORGE_DOCUMENT.activePageId,
selectedId: null,
undoStack: [],
redoStack: [],
previewMode: false,
zoom: 100,
};
forgeDocRebuildIndex();
forgeDocBindCanvasInteractions();
forgeDocBindLibraryDragHandlers();
forgeDocBindCanvasDropZone();
forgeDocBindDeselectOnBackgroundClick();
forgeDocBindZoom();
forgeDocBindWidthPicker();
forgeDocBindPreviewToggle();
forgeDocBindThemeToggle();
forgeDocBindMobileNav();
forgeDocBindKeyboardShortcuts();
forgeDocBindScenarioTreeModal();
forgeDocBindSidebarTabs();
forgeDocRenderPageManagerList();
forgeDocBindPageManagerDrag();
forgeDocBindPageNavPill();
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();