Files
Forge-Engine/static/document/js/document-editor.js
T
williamandClaude Sonnet 5 8f879f6321 Audit complet de mise en forme — Bouton (3e élément du tableau)
Implémente toutes les options manquantes identifiées pour l'élément
Bouton : typographie (gras/italique/transformation/police/taille/
espacement des lettres/couleur), icône SVG optionnelle avec position
(avant/après le texte) et taille réglables — au-delà de l'Étiquette,
qui n'a qu'une icône fixe —, tous les attributs de boîte partagés déjà
créés pour Titre/Paragraphe/Image, et un effet visuel universel au
survol/clic.

Deux ajouts transversaux demandés pendant le test : un réglage "les 4
côtés de la bordure à la fois" dans le module de boîte partagé
(bénéficie automatiquement à tous les éléments qui l'utilisent), et le
glisser-déposer d'un élément du canevas vers une autre page via le
panneau Pages (le mécanisme serveur existait déjà pour la pagination
automatique, seule l'interaction manuelle manquait).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 12:47:46 +02:00

3936 lines
175 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...).
// Point d'entrée UNIQUE de tout ajout : un support peut avoir 0 page
// (retour utilisateur du 26/09/2026) — bloquer ICI, une fois, couvre
// tous les appelants (bibliothèque, glisser-déposer, rangée créée à la
// volée) sans dupliquer ce contrôle dans chacun.
if (window.forgeDocState.activePageId === null) {
alert('Ajoutez d\'abord une page avant d\'ajouter du contenu.');
return Promise.reject(new Error('aucune page active'));
}
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())
.then((result) => {
// Point d'entrée UNIQUE de tout ajout (voir routes/document/
// document_element_add.py) : gérer l'erreur ICI, une fois, couvre
// tous les appelants (bibliothèque, glisser-déposer, création de
// rangée à la volée, Annuler/Rétablir) sans dupliquer la logique
// dans chacun — la promesse rejetée interrompt simplement leur
// .then() sans autre effet de bord (pas de commande Annuler pour
// un ajout qui n'a jamais eu lieu).
if (result.error) {
alert(result.error);
return Promise.reject(new Error(result.error));
}
return result;
});
}
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 forgeDocApiMoveToPage(id, targetPageId) {
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/move-to-page`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_page_id: targetPageId }),
}).then((r) => r.json());
}
function forgeDocApiUploadAttachment(id, file) {
// Multipart, jamais du JSON comme forgeDocApiUpdate ci-dessus — un
// fichier ne se sérialise pas dans le corps JSON d'un PATCH classique
// (voir routes/document/document_element_upload_attachment.py).
const body = new FormData();
body.set('file', file);
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/upload-attachment`, { method: 'POST', body })
.then((r) => r.json());
}
function forgeDocApiUploadImage(id, file) {
// Mirroir de forgeDocApiUploadAttachment ci-dessus, mais route dédiée
// au kind "image" (voir routes/document/document_element_upload_image.py) :
// stocke sous uploads/ (jamais attachments/) et vide svg_markup côté serveur.
const body = new FormData();
body.set('file', file);
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/upload-image`, { method: 'POST', body })
.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 forgeDocCheckPageOverflow() {
// Pagination automatique (retour utilisateur du 23/09/2026 : "si il
// n'y a plus de place sur la page il faut automatiquement créer une
// autre page [et y] coller le contenu [...] et non [...] adapter la
// taille du contenu pour rester sur la page") — jamais sur une page
// mini-jeu (toujours seul sur sa page par construction, voir
// routes/document/document_element_add.py ; .docPageContent y est
// délibérément height:100% donc scrollHeight === clientHeight, aucun
// débordement réel n'est possible/pertinent à corriger là).
const contentEl = document.getElementById('docPageContent');
const pageEl = document.getElementById('docPage');
if (!contentEl || !pageEl || contentEl.querySelector('.docMinigame')) return Promise.resolve();
if (contentEl.scrollHeight <= contentEl.clientHeight + 1) return Promise.resolve();
// Le PREMIER élément top-niveau qui dépasse le bas de la page, et tout
// ce qui le suit dans l'ordre, part ensemble sur une nouvelle page —
// measuré via getBoundingClientRect (tient compte du zoom CSS
// transform éventuel), jamais une estimation par hauteur cumulée qui
// ignorerait marges/gap réels.
const topLevel = forgeDocTopLevel();
const pageBottom = pageEl.getBoundingClientRect().bottom;
let splitIndex = -1;
topLevel.forEach((el, index) => {
if (splitIndex !== -1) return;
const dom = document.querySelector(`#docPage [data-element-id="${el.id}"]`);
if (dom && dom.getBoundingClientRect().bottom > pageBottom + 0.5) splitIndex = index;
});
if (splitIndex === -1) return Promise.resolve();
const overflowingEl = topLevel[splitIndex];
const FORGE_DOC_MIN_IMAGE_HEIGHT = 60;
if (overflowingEl.kind === 'image') {
// Retour utilisateur du 26/09/2026 : "si l'image uploadée est trop
// grande je préfère qu'elle soit redimensionnée plutôt que bouger sur
// une autre page" — une image qui déborde est rétrécie SUR PLACE à
// l'espace réellement disponible sous elle, plutôt que paginée.
// Ajustement purement VISUEL (jamais persisté en attribut), recalculé
// à chaque rafraîchissement du canevas (voir forgeDocRefreshCanvas).
//
// MAIS uniquement s'il reste assez de place pour un résultat encore
// utilisable (retour utilisateur suivant : "le contenu continue de se
// mettre même quand il n'y a plus de place [...] aucune page ne se
// crée automatiquement" — une page déjà pleine d'images empilait des
// vignettes rétrécies à l'infini plutôt que de basculer sur une
// nouvelle page) : sous FORGE_DOC_MIN_IMAGE_HEIGHT, il ne reste plus
// de place RÉELLE sur cette page, pas seulement pour cette image —
// on tombe alors dans la pagination normale ci-dessous, comme
// n'importe quel autre kind.
const dom = document.querySelector(`#docPage [data-element-id="${overflowingEl.id}"]`);
if (dom) {
const figure = dom.closest('.docImageFigure');
const caption = figure ? figure.querySelector('.docImageCaption') : null;
const captionSpace = caption ? caption.getBoundingClientRect().height + 8 : 0;
const available = Math.floor(pageBottom - dom.getBoundingClientRect().top - captionSpace - 2);
if (available >= FORGE_DOC_MIN_IMAGE_HEIGHT) {
dom.style.maxHeight = `${available}px`;
return Promise.resolve();
}
}
}
const overflowing = topLevel.slice(splitIndex);
// Garde-fou contre une boucle infinie (bug réel constaté le 26/09/2026 :
// "quand j'ajoute une image ça crée des pages à l'infini") — si TOUT le
// top-niveau de la page déborde déjà, le déplacer sur une page neuve ne
// fait que reproduire EXACTEMENT le même débordement là-bas, à l'infini.
// Rien à répartir dans ce cas : on laisse le contenu tel quel (clipsé
// par .docPage { overflow: hidden }, voir sa règle CSS) — au créateur de
// redimensionner l'élément lui-même.
if (overflowing.length === topLevel.length) return Promise.resolve();
// La page neuve se glisse JUSTE APRÈS la page qui déborde (retour
// utilisateur du 26/09/2026 : "la nouvelle page dois etre sous la page
// qui deborde meme si ya d'autre page") — jamais ajoutée en toute fin
// de la bande d'onglets s'il existe déjà d'autres pages après celle-ci
// (ex. déborder sur la page 2 d'un support qui en compte 5 insère la
// nouvelle page en position 3, décale les anciennes 3/4/5 en 4/5/6).
const insertIndex = forgeDocOrderedPages().findIndex((p) => p.id === window.forgeDocState.activePageId) + 1;
return forgeDocApiPageAdd().then((result) => {
window.forgeDocState.pages.push({
id: result.id, title: result.title, order_index: window.forgeDocState.pages.length,
});
return forgeDocApiPageMove(result.id, insertIndex)
.then(() => forgeDocReorderLocalPage(result.id, insertIndex))
.then(() => overflowing.reduce(
(chain, el) => chain.then(() => forgeDocApiMoveToPage(el.id, result.id)),
Promise.resolve(),
))
.then(() => forgeDocSwitchPage(result.id));
});
}
function forgeDocRenderEmptyPageState() {
// Support à 0 page (retour utilisateur du 26/09/2026) — jamais appeler
// /render sans page_id (404 côté serveur, voir document_render.py) :
// le canevas affiche directement une invite à créer une page.
const contentEl = document.getElementById('docPageContent');
contentEl.innerHTML = '<div class="docEmptyState docEmptyPageState">Aucune page — cliquez sur « + Ajouter une page » pour commencer.</div>';
contentEl.dataset.verticalAlign = 'top';
window.forgeDocState.elements = [];
forgeDocRebuildIndex();
window.forgeDocState.selectedId = null;
forgeDocRefreshRightPanelDefault();
}
function forgeDocRefreshCanvas() {
if (window.forgeDocState.activePageId === null) {
forgeDocRenderEmptyPageState();
return Promise.resolve();
}
return forgeDocApiRender().then((data) => {
const contentEl = document.getElementById('docPageContent');
contentEl.innerHTML = data.html;
contentEl.dataset.verticalAlign = data.vertical_align;
window.forgeDocState.elements = data.elements;
forgeDocRebuildIndex();
forgeDocBindCanvasInteractions();
const { selectedId } = window.forgeDocState;
if (selectedId !== null && window.forgeDocState.elementsById[selectedId]) {
forgeDocApplySelectionClass();
} else {
window.forgeDocState.selectedId = null;
forgeDocRefreshRightPanelDefault();
}
}).then(() => forgeDocCheckPageOverflow());
}
/* ---------------------------------------------------------------------
* 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 forgeDocMoveElementToPage(elementId, targetPageId) {
// Retour utilisateur du 26/09/2026 : "j'aimerais pouvoir glisser
// déposer un élément d'une page dans une autre page" — déposé depuis
// le canevas sur une rangée du panneau Pages (voir
// forgeDocRenderPageManagerList). move_document_element_to_page
// (document_engine/elements/) redevient TOUJOURS top-niveau sur la
// page cible (une rangée d'origine n'a aucun sens comme enfant d'une
// rangée de la page cible) — Annuler restaure donc explicitement
// l'ancien parent/position via forgeDocApiMove, une fois l'élément
// revenu sur son ancienne page.
const el = window.forgeDocState.elementsById[elementId];
if (!el || targetPageId === window.forgeDocState.activePageId) return Promise.resolve();
const state = {
id: elementId,
fromPageId: window.forgeDocState.activePageId,
toPageId: targetPageId,
parentId: el.parent_id,
index: el.order_index,
};
function performForward() { return forgeDocApiMoveToPage(state.id, state.toPageId); }
function performUndo() {
return forgeDocApiMoveToPage(state.id, state.fromPageId)
.then(() => forgeDocApiMove(state.id, state.parentId, state.index));
}
return performForward().then(() => {
forgeDocPushCommand({ undo: performUndo, redo: performForward });
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();
}
// Bug réel constaté le 26/09/2026 : les boutons (gras/alignement/
// segmentés...) ne reflétaient jamais leur nouvel état tant que la
// page n'était pas rechargée — leur classe "is-active" était figée
// au moment du rendu initial du panneau, jamais réévaluée après un
// clic. Point d'entrée UNIQUE de toute mise à jour d'attributs :
// reconstruire le panneau ICI, une fois, couvre TOUS les panneaux
// (texte, image, bouton, mini-jeux...) sans dupliquer cet appel dans
// chacun de leurs boutons.
if (window.forgeDocState.selectedId === id) {
forgeDocRenderProps(el);
}
// Bug réel constaté le 26/09/2026 : uploader une image trop grande
// (ou tout autre changement d'attribut qui agrandit un élément) ne
// déclenchait JAMAIS le rétrécissement/la pagination — seules les
// mutations passant par forgeDocRefreshCanvas (ajout/déplacement/
// suppression/Annuler-Rétablir) le faisaient. Cette fonction est le
// point d'entrée UNIQUE de toute mise à jour d'attributs (voir
// commentaire ci-dessus) : vérifier ICI, une fois, couvre aussi
// l'upload d'image (voir forgeDocApiUploadImage plus bas, qui route
// son propre patch DOM à travers ce même point) sans dupliquer
// l'appel dans chaque site qui modifie un attribut.
return forgeDocCheckPageOverflow();
});
}
/* ---------------------------------------------------------------------
* 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 forgeDocApiPageDeleteAll() {
return fetch(`/document/${window.forgeDocState.slug}/pages/delete-all`, { 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 forgeDocApiPageVerticalAlign(pageId, verticalAlign) {
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/vertical-align`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ vertical_align: verticalAlign }),
}).then((r) => r.json());
}
function forgeDocOrderedPages() {
return [...window.forgeDocState.pages].sort((a, b) => a.order_index - b.order_index);
}
function forgeDocReorderLocalPage(movedId, newIndex) {
// Reflète côté client un déplacement déjà appliqué côté serveur (voir
// move_document_page.py : renumérotation intégrale de order_index sur
// TOUTES les pages) — mêmes calculs, jamais dupliqués autrement que
// dans cette seule fonction (glisser-déposer du panneau Pages ET
// pagination automatique, voir forgeDocCheckPageOverflow).
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 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;
forgeDocRefreshRightPanelDefault();
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 forgeDocSetPageVerticalAlign(pageId, verticalAlign) {
return forgeDocApiPageVerticalAlign(pageId, verticalAlign).then((result) => {
const page = window.forgeDocState.pages.find((p) => p.id === pageId);
if (page) page.vertical_align = result.vertical_align;
if (pageId === window.forgeDocState.activePageId) {
const contentEl = document.getElementById('docPageContent');
if (contentEl) contentEl.dataset.verticalAlign = result.vertical_align;
}
forgeDocRenderPageProps();
});
}
function forgeDocDeletePage(pageId) {
// Supprimer la dernière page restante est désormais autorisé (retour
// utilisateur du 26/09/2026 : un support à 0 page est un état valide,
// voir document_engine/pages/pages.md) — plus de garde-fou ici.
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) {
const remaining = forgeDocOrderedPages();
return forgeDocSwitchPage(remaining.length ? remaining[0].id : null);
}
forgeDocRenderPageManagerList();
return Promise.resolve();
});
}
function forgeDocDeleteAllPages() {
// Action destructive et irréversible (retour utilisateur : "une option
// dans page pour supprimer toute les page d'un coup") — confirmation
// native obligatoire avant tout appel serveur.
if (!window.confirm('Supprimer TOUTES les pages et tout leur contenu ? Cette action est irréversible.')) {
return Promise.resolve();
}
return forgeDocApiPageDeleteAll().then(() => {
window.forgeDocState.pages = [];
window.forgeDocState.undoStack = [];
window.forgeDocState.redoStack = [];
forgeDocUpdateHistoryButtons();
return forgeDocSwitchPage(null);
});
}
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 (renommer/supprimer), qu'un <button>
// englobant ne pourrait pas contenir validement. L'équivalent clavier
// (Entrée/Espace) est posé plus bas. Réordonnancement UNIQUEMENT par
// glisser (voir forgeDocBindPageManagerDrag) — pas de boutons monter/
// descendre (retour utilisateur du 23/09/2026 : "je pouvais changer
// l'ordre des pages en glisser déposer donc les flèches [...] sont
// inutiles [...] donner plus de place à l'affichage du nom").
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="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">✕</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));
});
// Glisser un élément du canevas jusqu'ici pour le déplacer vers CETTE
// page (retour utilisateur du 26/09/2026 : "j'aimerais pouvoir
// glisser déposer un élément d'une page dans une autre page") — même
// charge utile ("text/forge-doc-move") que le réordonnancement d'un
// élément AU SEIN d'une page (voir forgeDocBindCanvasDropZone), donc
// aucune modification de la source de glisser côté canevas. Sans
// rapport avec le glisser-déposer des PAGES elles-mêmes (délégué au
// conteneur, voir forgeDocBindPageManagerDrag juste plus bas) : les
// deux coexistent sur la même rangée sans conflit, chacun ignorant
// le type de charge utile qui ne le concerne pas.
row.addEventListener('dragover', (e) => {
if (!e.dataTransfer.types.includes('text/forge-doc-move')) return;
e.preventDefault();
row.classList.add('docPageRow--dropTarget');
});
row.addEventListener('dragleave', () => row.classList.remove('docPageRow--dropTarget'));
row.addEventListener('drop', (e) => {
const moveId = e.dataTransfer.getData('text/forge-doc-move');
row.classList.remove('docPageRow--dropTarget');
if (!moveId) return;
e.preventDefault();
e.stopPropagation();
forgeDocMoveElementToPage(Number.parseInt(moveId, 10), Number(row.dataset.pageId));
});
});
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();
const deleteAllBtn = document.getElementById('docPageManagerDeleteAll');
if (deleteAllBtn) {
deleteAllBtn.disabled = pages.length === 0;
deleteAllBtn.onclick = () => forgeDocDeleteAllPages();
}
}
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(() => forgeDocReorderLocalPage(movedId, newIndex));
});
}
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;
});
// Le panneau Propriétés (droite) suit l'onglet actif (retour
// utilisateur du 24/09/2026) : la page active sur "Pages", l'élément
// sélectionné (ou rien) sur "Mise en page" — jamais les deux en même
// temps.
if (tabName === 'pages') {
forgeDocRenderPageProps();
} else {
const { selectedId } = window.forgeDocState;
forgeDocRenderProps(selectedId !== null ? window.forgeDocState.elementsById[selectedId] : null);
}
}
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.
* ------------------------------------------------------------------- */
/* ---------------------------------------------------------------------
* Aperçu plein écran d'une image (comportement "click_behavior":
* "lightbox", voir document_engine/rendering/render_document_element.py
* ::_render_image) — un seul overlay réutilisé, créé à la demande.
* ------------------------------------------------------------------- */
function forgeDocOpenImageLightbox(src, alt) {
let overlay = document.getElementById('docImageLightboxOverlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'docImageLightboxOverlay';
overlay.className = 'docImageLightboxOverlay';
overlay.innerHTML = '<img class="docImageLightboxImg" id="docImageLightboxImg" alt="">';
overlay.addEventListener('click', forgeDocCloseImageLightbox);
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') forgeDocCloseImageLightbox();
});
document.body.appendChild(overlay);
}
document.getElementById('docImageLightboxImg').src = src;
document.getElementById('docImageLightboxImg').alt = alt || '';
overlay.classList.add('is-open');
}
function forgeDocCloseImageLightbox() {
const overlay = document.getElementById('docImageLightboxOverlay');
if (overlay) overlay.classList.remove('is-open');
}
function forgeDocBindCanvasInteractions() {
document.querySelectorAll('#docPage [data-element-id]').forEach((dom) => {
const elementId = Number.parseInt(dom.dataset.elementId, 10);
dom.addEventListener('click', (e) => {
e.stopPropagation();
// En Aperçu, un bouton portant un fichier joint déclenche son
// téléchargement plutôt que la sélection (réservée à l'édition) —
// même principe que les mini-jeux, jouables uniquement en Aperçu
// (voir static/document/document-editor.css, .docEditor3--preview).
if (window.forgeDocState.previewMode && dom.dataset.attachmentFilename) {
window.location.href = `/document/${window.forgeDocState.slug}/elements/${elementId}/download-attachment`;
return;
}
// Image "plein écran au clic" — seulement en Aperçu (même
// principe que ci-dessus) ; en édition, on bloque juste la
// navigation native éventuelle (lien/plein écran) pour rester sur
// la sélection de l'élément.
const lightboxWrapper = dom.closest('.docImageLightboxTrigger');
if (lightboxWrapper) {
e.preventDefault();
if (window.forgeDocState.previewMode) {
const img = lightboxWrapper.querySelector('img.docImage');
if (img) forgeDocOpenImageLightbox(img.src, img.alt);
return;
}
}
if (dom.closest('.docImageLink') && !window.forgeDocState.previewMode) {
e.preventDefault();
}
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>';
}
/* ---------------------------------------------------------------------
* Propriétés de PAGE (alignement vertical du contenu) — affichées dans
* le panneau de droite quand l'onglet "Pages" du panneau de gauche est
* actif (retour utilisateur du 24/09/2026), à la place des propriétés
* d'un élément sélectionné. Jamais les deux en même temps : changer
* d'onglet ou sélectionner un élément bascule l'un ou l'autre (voir
* forgeDocSwitchSidebarTab/forgeDocRefreshRightPanelDefault).
* ------------------------------------------------------------------- */
function forgeDocIsPagesTabActive() {
const tab = document.getElementById('docTabPages');
return !!tab && tab.classList.contains('is-active');
}
function forgeDocRenderPageProps() {
const panel = document.getElementById('docPropsPanel');
if (!panel) return;
const page = window.forgeDocState.pages.find((p) => p.id === window.forgeDocState.activePageId);
if (!page) {
panel.innerHTML = '<div class="docSectionLabel">Propriétés</div><div class="docEmptyState">Aucune page active.</div>';
return;
}
const align = page.vertical_align || 'top';
const options = [
{ value: 'top', label: 'Haut' },
{ value: 'center', label: 'Centré' },
{ value: 'bottom', label: 'Bas' },
];
panel.innerHTML = `
${forgeDocPropsHeader('page')}
<div class="docField">
<span class="docFieldLabel">Alignement vertical du contenu</span>
<div class="docSegmented" id="docPageAlignSeg">
${options.map((o) => `<button type="button" class="docSegBtn ${align === o.value ? 'is-active' : ''}" data-align="${o.value}">${o.label}</button>`).join('')}
</div>
</div>
`;
panel.querySelectorAll('#docPageAlignSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => forgeDocSetPageVerticalAlign(page.id, btn.dataset.align));
});
}
function forgeDocRefreshRightPanelDefault() {
if (forgeDocIsPagesTabActive()) forgeDocRenderPageProps();
else forgeDocRenderProps(null);
}
/* ---------------------------------------------------------------------
* Champs de "boîte" PARTAGÉS par plusieurs kinds (padding/margin/couleur
* de fond/arrondi/bordure par côté/position du bloc) — voir
* document_engine/rendering/box_style.py, même jeu d'attributs côté
* serveur. Un seul HTML + un seul binding réutilisés par chaque panneau
* de propriétés plutôt que dupliqués (retour utilisateur du 26/09/2026 :
* audit complet des réglages manquants, à couvrir élément par élément).
* ------------------------------------------------------------------- */
const FORGE_DOC_BORDER_SIDES = [
['top', 'Haut'], ['right', 'Droite'], ['bottom', 'Bas'], ['left', 'Gauche'],
];
const FORGE_DOC_BORDER_STYLES = [
['none', 'Aucune'], ['solid', 'Solide'], ['dashed', 'Tirets'], ['dotted', 'Pointillés'], ['double', 'Double'],
];
const FORGE_DOC_ALIGN_SELF_OPTIONS = [
['stretch', 'Pleine largeur'], ['flex-start', 'Gauche'], ['center', 'Centré'], ['flex-end', 'Droite'],
];
const FORGE_DOC_SHADOW_PRESETS = [
['', 'Aucune'], ['0 2px 6px rgba(0,0,0,.15)', 'Légère'], ['0 8px 24px rgba(0,0,0,.25)', 'Marquée'],
];
const FORGE_DOC_TEXT_SHADOW_PRESETS = [
['', 'Aucune'], ['1px 1px 2px rgba(0,0,0,.35)', 'Légère'], ['2px 2px 6px rgba(0,0,0,.5)', 'Marquée'],
];
const FORGE_DOC_FONT_FAMILY_OPTIONS = [
['', 'Police du thème'],
['system-ui, -apple-system, sans-serif', 'Système (sans-serif)'],
['Georgia, serif', 'Georgia (serif)'],
['"Times New Roman", serif', 'Times New Roman (serif)'],
['Arial, Helvetica, sans-serif', 'Arial'],
['Verdana, sans-serif', 'Verdana'],
['Tahoma, sans-serif', 'Tahoma'],
['"Trebuchet MS", sans-serif', 'Trebuchet MS'],
['"Courier New", monospace', 'Courier New (machine à écrire)'],
];
/* ---------------------------------------------------------------------
* Contrôles génériques réutilisables — retour utilisateur du 26/09/2026 :
* "jamais l'utilisateur ne doit rentrer une valeur brute à la main" —
* un curseur (avec une case "activer" puisqu'un <input type=range> ne
* peut pas représenter "aucune valeur") remplace tout champ de texte
* libre pour une valeur numérique optionnelle.
* ------------------------------------------------------------------- */
function forgeDocRenderToggleSliderHtml(idPrefix, label, rawValue, opts) {
const hasValue = rawValue !== undefined && rawValue !== null && rawValue !== '';
const numeric = hasValue ? Number.parseFloat(rawValue) : opts.fallback;
const displayUnit = opts.displayUnit !== undefined ? opts.displayUnit : opts.unit;
return `
<div class="docField docSliderField">
<span class="docFieldLabel">${label}</span>
<div class="docSliderRow">
<input type="checkbox" id="${idPrefix}Toggle" title="Activer" ${hasValue ? 'checked' : ''}>
<input type="range" id="${idPrefix}Range" min="${opts.min}" max="${opts.max}" step="${opts.step}" value="${numeric}" ${hasValue ? '' : 'disabled'}>
<span class="docSliderValue" id="${idPrefix}Value">${hasValue ? numeric + displayUnit : '—'}</span>
</div>
</div>
`;
}
function forgeDocBindToggleSlider(idPrefix, opts, onChange) {
const toggle = document.getElementById(`${idPrefix}Toggle`);
const range = document.getElementById(`${idPrefix}Range`);
const valueLabel = document.getElementById(`${idPrefix}Value`);
const displayUnit = opts.displayUnit !== undefined ? opts.displayUnit : opts.unit;
const toAttr = opts.toAttr || ((n) => `${n}${opts.unit}`);
range.addEventListener('input', () => {
valueLabel.textContent = range.value + displayUnit;
});
range.addEventListener('change', () => {
if (toggle.checked) onChange(toAttr(Number.parseFloat(range.value)));
});
toggle.addEventListener('change', () => {
range.disabled = !toggle.checked;
if (toggle.checked) {
valueLabel.textContent = range.value + displayUnit;
onChange(toAttr(Number.parseFloat(range.value)));
} else {
valueLabel.textContent = '—';
onChange('');
}
});
}
function forgeDocToHexColor(value, fallback) {
return /^#[0-9a-f]{6}$/i.test(value || '') ? value : fallback;
}
function forgeDocRenderBoxFieldsHtml(a, idPrefix) {
const border = a.border || {};
const alignSelf = a.align_self || 'stretch';
const opacityPercent = a.opacity !== '' && a.opacity !== undefined ? Math.round(Number.parseFloat(a.opacity) * 100) : '';
return `
${forgeDocRenderToggleSliderHtml(`${idPrefix}Padding`, 'Espacement intérieur (padding)', a.padding, {
min: 0, max: 80, step: 2, unit: 'px', fallback: 16,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}Margin`, 'Espacement extérieur (margin)', a.margin, {
min: 0, max: 80, step: 2, unit: 'px', fallback: 16,
})}
<div class="docField">
<label for="${idPrefix}Bg">Couleur de fond</label>
<div class="docColorFieldRow">
<input type="color" id="${idPrefix}Bg" value="${forgeDocToHexColor(a.background_color, '#ffffff')}">
<button type="button" class="docBtnSecondary" id="${idPrefix}BgNone">Transparent</button>
</div>
</div>
${forgeDocRenderToggleSliderHtml(`${idPrefix}Radius`, 'Arrondi des bords', a.border_radius, {
min: 0, max: 100, step: 2, unit: 'px', fallback: 8,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}Width`, 'Largeur fixe', a.width, {
min: 50, max: 1200, step: 10, unit: 'px', fallback: 300,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}MaxWidth`, 'Largeur maximale', a.max_width, {
min: 200, max: 1200, step: 20, unit: 'px', fallback: 600,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}Height`, 'Hauteur fixe', a.height, {
min: 0, max: 800, step: 10, unit: 'px', fallback: 200,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}MinHeight`, 'Hauteur minimale', a.min_height, {
min: 0, max: 800, step: 10, unit: 'px', fallback: 200,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}MaxHeight`, 'Hauteur maximale', a.max_height, {
min: 0, max: 800, step: 10, unit: 'px', fallback: 200,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}MinWidth`, 'Largeur minimale', a.min_width, {
min: 0, max: 800, step: 10, unit: 'px', fallback: 200,
})}
<div class="docField">
<span class="docFieldLabel">Ombre portée du bloc</span>
<div class="docSegmented" id="${idPrefix}ShadowSeg">
${FORGE_DOC_SHADOW_PRESETS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.box_shadow || '') === v ? 'is-active' : ''}" data-shadow="${forgeDocEscapeHtml(v)}">${l}</button>`).join('')}
</div>
</div>
${forgeDocRenderToggleSliderHtml(`${idPrefix}Opacity`, 'Opacité', opacityPercent, {
min: 0, max: 100, step: 5, unit: '%', fallback: 100,
})}
<div class="docField">
<span class="docFieldLabel">Position du bloc</span>
<div class="docSegmented" id="${idPrefix}AlignSelfSeg">
${FORGE_DOC_ALIGN_SELF_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${alignSelf === v ? 'is-active' : ''}" data-align-self="${v}">${l}</button>`).join('')}
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Bordure — les 4 côtés à la fois</span>
<div class="docBorderSideRow" id="${idPrefix}BorderAllRow">
${(() => {
// Retour utilisateur du 26/09/2026 : "une option pour modifier les
// 4 bordures en même temps" — un raccourci qui applique la MÊME
// valeur aux 4 côtés d'un coup, en plus (jamais à la place) du
// réglage par côté juste en dessous. Si les 4 côtés portent déjà
// exactement la même valeur, cette rangée en repart (plutôt que
// d'afficher un état neutre déconnecté de la réalité) ; sinon elle
// repart d'un état neutre ("Aucune"/1px/couleur par défaut), un
// signe que les côtés diffèrent actuellement.
const sides = FORGE_DOC_BORDER_SIDES.map(([side]) => border[side] || {});
const allSame = sides.every((s) => (
s.style === sides[0].style && s.width === sides[0].width && s.color === sides[0].color
));
const common = allSame ? sides[0] : {};
return `
<span class="docBorderSideLabel">Tous</span>
<select class="docBorderStyleSelect" id="${idPrefix}BorderAllStyle">
${FORGE_DOC_BORDER_STYLES.map(([v, l]) => `<option value="${v}" ${(common.style || 'none') === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
<input type="range" class="docBorderWidthRange" id="${idPrefix}BorderAllWidth" min="1" max="10" step="1" value="${Number.parseFloat(common.width) || 1}" title="Épaisseur">
<input type="color" class="docBorderColorInput" id="${idPrefix}BorderAllColor" value="${forgeDocToHexColor(common.color, '#c9c4b8')}" title="Couleur">
`;
})()}
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Bordure (par côté)</span>
<div class="docBorderEditor" id="${idPrefix}BorderEditor">
${FORGE_DOC_BORDER_SIDES.map(([side, label]) => {
const sideBorder = border[side] || {};
return `
<div class="docBorderSideRow" data-side="${side}">
<span class="docBorderSideLabel">${label}</span>
<select class="docBorderStyleSelect" data-side="${side}">
${FORGE_DOC_BORDER_STYLES.map(([v, l]) => `<option value="${v}" ${(sideBorder.style || 'none') === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
<input type="range" class="docBorderWidthRange" data-side="${side}" min="1" max="10" step="1" value="${Number.parseFloat(sideBorder.width) || 1}" title="Épaisseur">
<input type="color" class="docBorderColorInput" data-side="${side}" value="${forgeDocToHexColor(sideBorder.color, '#c9c4b8')}" title="Couleur">
</div>`;
}).join('')}
</div>
</div>
`;
}
function forgeDocBindBoxFields(panel, el, idPrefix, patch) {
forgeDocBindToggleSlider(`${idPrefix}Padding`, { unit: 'px' }, (v) => patch({ padding: v }));
forgeDocBindToggleSlider(`${idPrefix}Margin`, { unit: 'px' }, (v) => patch({ margin: v }));
forgeDocBindToggleSlider(`${idPrefix}Radius`, { unit: 'px' }, (v) => patch({ border_radius: v }));
forgeDocBindToggleSlider(`${idPrefix}Width`, { unit: 'px' }, (v) => patch({ width: v }));
forgeDocBindToggleSlider(`${idPrefix}MaxWidth`, { unit: 'px' }, (v) => patch({ max_width: v }));
forgeDocBindToggleSlider(`${idPrefix}Height`, { unit: 'px' }, (v) => patch({ height: v }));
forgeDocBindToggleSlider(`${idPrefix}MinHeight`, { unit: 'px' }, (v) => patch({ min_height: v }));
forgeDocBindToggleSlider(`${idPrefix}MaxHeight`, { unit: 'px' }, (v) => patch({ max_height: v }));
forgeDocBindToggleSlider(`${idPrefix}MinWidth`, { unit: 'px' }, (v) => patch({ min_width: v }));
// Toujours une vraie valeur numérique quand la case est cochée (jamais
// "" pour 100%, même si ça revient visuellement au même) : sinon l'état
// "activé" de la case ne survivrait pas à un rechargement du panneau
// (hasValue redeviendrait faux, la case semblerait décochée à tort).
forgeDocBindToggleSlider(`${idPrefix}Opacity`, { unit: '%', toAttr: (n) => String(n / 100) }, (v) => patch({ opacity: v }));
document.getElementById(`${idPrefix}Bg`).addEventListener('change', (e) => patch({ background_color: e.target.value }));
document.getElementById(`${idPrefix}BgNone`).addEventListener('click', () => patch({ background_color: '' }));
panel.querySelectorAll(`#${idPrefix}ShadowSeg .docSegBtn`).forEach((btn) => {
btn.addEventListener('click', () => patch({ box_shadow: btn.dataset.shadow }));
});
panel.querySelectorAll(`#${idPrefix}AlignSelfSeg .docSegBtn`).forEach((btn) => {
btn.addEventListener('click', () => patch({ align_self: btn.dataset.alignSelf }));
});
function applyBorderToAllSides() {
const style = document.getElementById(`${idPrefix}BorderAllStyle`).value;
const width = `${document.getElementById(`${idPrefix}BorderAllWidth`).value}px`;
const color = document.getElementById(`${idPrefix}BorderAllColor`).value;
const sideValue = { style, width, color };
patch({
border: {
top: sideValue, right: sideValue, bottom: sideValue, left: sideValue,
},
});
}
document.getElementById(`${idPrefix}BorderAllStyle`).addEventListener('change', applyBorderToAllSides);
document.getElementById(`${idPrefix}BorderAllWidth`).addEventListener('change', applyBorderToAllSides);
document.getElementById(`${idPrefix}BorderAllColor`).addEventListener('change', applyBorderToAllSides);
function patchBorderSide(side, partial) {
// Toujours relu depuis el.attributes AU MOMENT de l'appel (jamais un
// instantané capturé au moment du binding) : el.attributes est
// réassigné en place par forgeDocUpdateAttributes après chaque
// modification — un instantané figé ferait perdre le changement
// précédent dès qu'on modifie un 2e côté/champ.
const currentBorder = { ...el.attributes.border };
const nextSide = { ...currentBorder[side], ...partial };
patch({ border: { ...currentBorder, [side]: nextSide } });
}
panel.querySelectorAll(`#${idPrefix}BorderEditor .docBorderStyleSelect`).forEach((sel) => {
sel.addEventListener('change', (e) => patchBorderSide(sel.dataset.side, { style: e.target.value }));
});
panel.querySelectorAll(`#${idPrefix}BorderEditor .docBorderWidthRange`).forEach((range) => {
range.addEventListener('change', (e) => patchBorderSide(range.dataset.side, { width: `${e.target.value}px` }));
});
panel.querySelectorAll(`#${idPrefix}BorderEditor .docBorderColorInput`).forEach((inp) => {
inp.addEventListener('change', (e) => patchBorderSide(inp.dataset.side, { color: e.target.value }));
});
}
const FORGE_DOC_TEXT_TRANSFORM_OPTIONS = [
['none', 'Normal'], ['uppercase', 'MAJUSCULES'], ['lowercase', 'minuscules'], ['capitalize', 'Capitales'],
];
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>
<button type="button" class="docFmtBtn ${a.strikethrough ? 'is-active' : ''}" id="docStrikeBtn" title="Barré"><s>S</s></button>
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Alignement du texte</span>
<div class="docToolbarRow" id="docAlignRow">
<button type="button" class="docFmtBtn ${a.align === 'left' ? 'is-active' : ''}" data-align="left" title="Aligné à gauche">◧</button>
<button type="button" class="docFmtBtn ${a.align === 'center' ? 'is-active' : ''}" data-align="center" title="Centré">▣</button>
<button type="button" class="docFmtBtn ${a.align === 'right' ? 'is-active' : ''}" data-align="right" title="Aligné à droite">◨</button>
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Majuscules / minuscules</span>
<div class="docSegmented" id="docTextTransformSeg">
${FORGE_DOC_TEXT_TRANSFORM_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.text_transform || 'none') === v ? 'is-active' : ''}" data-transform="${v}">${l}</button>`).join('')}
</div>
</div>
<div class="docField">
<label for="docColorInput">Couleur du texte</label>
<input type="color" id="docColorInput" value="${forgeDocToHexColor(a.color, '#14161c')}">
</div>
<div class="docField">
<label for="docFontFamilyInput">Police de caractère</label>
<select id="docFontFamilyInput">
${FORGE_DOC_FONT_FAMILY_OPTIONS.map(([v, l]) => `<option value="${forgeDocEscapeHtml(v)}" ${(a.font_family || '') === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</div>
${forgeDocRenderToggleSliderHtml('docFontSize', 'Taille de police', a.font_size, {
min: 10, max: 72, step: 1, unit: 'px', fallback: 16,
})}
${forgeDocRenderToggleSliderHtml('docLineHeight', 'Hauteur de ligne', a.line_height, {
min: 1, max: 2.5, step: 0.1, unit: '', fallback: 1.5,
})}
${forgeDocRenderToggleSliderHtml('docLetterSpacing', 'Espacement des lettres', a.letter_spacing, {
min: -2, max: 10, step: 0.5, unit: 'px', fallback: 0,
})}
<div class="docField">
<span class="docFieldLabel">Ombre du texte</span>
<div class="docSegmented" id="docTextShadowSeg">
${FORGE_DOC_TEXT_SHADOW_PRESETS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.text_shadow || '') === v ? 'is-active' : ''}" data-shadow="${forgeDocEscapeHtml(v)}">${l}</button>`).join('')}
</div>
</div>
${forgeDocRenderBoxFieldsHtml(a, 'docText')}
${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 }));
document.getElementById('docStrikeBtn').addEventListener('click', () => patch({ strikethrough: !a.strikethrough }));
panel.querySelectorAll('#docAlignRow [data-align]').forEach((btn) => {
btn.addEventListener('click', () => patch({ align: btn.dataset.align }));
});
panel.querySelectorAll('#docTextTransformSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ text_transform: btn.dataset.transform }));
});
document.getElementById('docColorInput').addEventListener('change', (e) => patch({ color: e.target.value }));
document.getElementById('docFontFamilyInput').addEventListener('change', (e) => patch({ font_family: e.target.value }));
forgeDocBindToggleSlider('docFontSize', { unit: 'px' }, (v) => patch({ font_size: v }));
forgeDocBindToggleSlider('docLineHeight', { unit: '' }, (v) => patch({ line_height: v }));
forgeDocBindToggleSlider('docLetterSpacing', { unit: 'px' }, (v) => patch({ letter_spacing: v }));
panel.querySelectorAll('#docTextShadowSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ text_shadow: btn.dataset.shadow }));
});
forgeDocBindBoxFields(panel, el, 'docText', patch);
}
const FORGE_DOC_OBJECT_FIT_OPTIONS = [
['', 'Taille réelle'], ['cover', 'Couvrir'], ['contain', 'Contenir'], ['fill', 'Étirer'],
];
const FORGE_DOC_ASPECT_RATIO_OPTIONS = [
['', 'Libre'], ['1 / 1', 'Carré'], ['4 / 3', '4:3'], ['16 / 9', '16:9'],
];
const FORGE_DOC_FILTER_OPTIONS = [
['', 'Aucun'], ['grayscale', 'Noir et blanc'], ['sepia', 'Sépia'], ['blur', 'Flouté'],
];
const FORGE_DOC_IMAGE_CLICK_OPTIONS = [
['', 'Aucun'], ['link', 'Ouvrir un lien'], ['lightbox', 'Plein écran'],
];
function forgeDocRenderImageProps(panel, el) {
const a = el.attributes;
const clickBehavior = a.click_behavior || '';
panel.innerHTML = `
${forgeDocPropsHeader('image')}
<div class="docField">
<span class="docFieldLabel">Fichier image</span>
<div id="docImageUploadStatus">${a.src ? 'Image actuelle en place' : 'Aucun fichier choisi'}</div>
<input type="file" id="docImageUploadInput" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml">
</div>
<div class="docField"><label for="docImageSrc">Ou URL d'une image externe</label><input type="text" id="docImageSrc" value="${forgeDocEscapeHtml(a.src || '')}" placeholder="https://..."></div>
<div class="docField"><label for="docImageAlt">Texte alternatif</label><input type="text" id="docImageAlt" value="${forgeDocEscapeHtml(a.alt || '')}"></div>
<div class="docField"><label for="docImageSvg">Code SVG (prioritaire sur l'URL si renseigné)</label><textarea id="docImageSvg" rows="4">${forgeDocEscapeHtml(a.svg_markup || '')}</textarea></div>
<div class="docField"><label for="docImageCaption">Légende (optionnel)</label><input type="text" id="docImageCaption" value="${forgeDocEscapeHtml(a.caption || '')}"></div>
<div class="docField">
<span class="docFieldLabel">Ajustement dans son cadre</span>
<div class="docSegmented" id="docImageFitSeg">
${FORGE_DOC_OBJECT_FIT_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.object_fit || '') === v ? 'is-active' : ''}" data-fit="${v}">${l}</button>`).join('')}
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Format (ratio)</span>
<div class="docSegmented" id="docImageRatioSeg">
${FORGE_DOC_ASPECT_RATIO_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.aspect_ratio || '') === v ? 'is-active' : ''}" data-ratio="${forgeDocEscapeHtml(v)}">${l}</button>`).join('')}
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Filtre</span>
<div class="docSegmented" id="docImageFilterSeg">
${FORGE_DOC_FILTER_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.filter_preset || '') === v ? 'is-active' : ''}" data-filter="${v}">${l}</button>`).join('')}
</div>
</div>
<div class="docField">
<label class="docCheckboxLabel"><input type="checkbox" id="docImageLazyLoad" ${a.lazy_load ? 'checked' : ''}> Chargement différé (images longues à charger)</label>
</div>
<div class="docField">
<span class="docFieldLabel">Au clic (en Aperçu)</span>
<div class="docSegmented" id="docImageClickSeg">
${FORGE_DOC_IMAGE_CLICK_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${clickBehavior === v ? 'is-active' : ''}" data-click="${v}">${l}</button>`).join('')}
</div>
</div>
<div class="docField" id="docImageLinkUrlField" ${clickBehavior === 'link' ? '' : 'hidden'}>
<label for="docImageLinkUrl">URL du lien</label>
<input type="text" id="docImageLinkUrl" value="${forgeDocEscapeHtml(a.link_url || '')}" placeholder="https://...">
</div>
${forgeDocRenderBoxFieldsHtml(a, 'docImage')}
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docImageUploadInput').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
forgeDocApiUploadImage(el.id, file).then((result) => {
if (result.error) { alert(result.error); return; }
el.attributes = result.attributes;
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
const dom = document.querySelector(`[data-element-id="${el.id}"]`);
if (dom && result.rendered_html) {
const wrapper = document.createElement('div');
wrapper.innerHTML = result.rendered_html;
dom.replaceWith(wrapper.firstElementChild);
forgeDocBindCanvasInteractions();
forgeDocApplySelectionClass();
}
// Bug réel constaté le 26/09/2026 : "quand j'ajoute une image ça
// crée des pages à l'infini" / "ce n'est pas redimensionner" —
// cette route d'upload patch le DOM elle-même (fichier multipart,
// jamais via forgeDocApiUpdate/forgeDocUpdateAttributes qui fait
// déjà cette vérification), donc jamais couverte par le point
// d'entrée central. Vérifiée ICI explicitement pour cette raison.
forgeDocCheckPageOverflow();
});
});
document.getElementById('docImageSrc').addEventListener('change', (e) => patch({ src: e.target.value.trim() }));
document.getElementById('docImageAlt').addEventListener('change', (e) => patch({ alt: e.target.value }));
document.getElementById('docImageSvg').addEventListener('change', (e) => patch({ svg_markup: e.target.value.trim() }));
document.getElementById('docImageCaption').addEventListener('change', (e) => patch({ caption: e.target.value.trim() }));
panel.querySelectorAll('#docImageFitSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ object_fit: btn.dataset.fit }));
});
panel.querySelectorAll('#docImageRatioSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ aspect_ratio: btn.dataset.ratio }));
});
panel.querySelectorAll('#docImageFilterSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ filter_preset: btn.dataset.filter }));
});
document.getElementById('docImageLazyLoad').addEventListener('change', (e) => patch({ lazy_load: e.target.checked }));
panel.querySelectorAll('#docImageClickSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ click_behavior: btn.dataset.click }));
});
document.getElementById('docImageLinkUrl').addEventListener('change', (e) => patch({ link_url: e.target.value.trim() }));
forgeDocBindBoxFields(panel, el, 'docImage', patch);
}
const FORGE_DOC_ICON_POSITION_OPTIONS = [
['before', 'Avant le texte'], ['after', 'Après le texte'],
];
function forgeDocRenderButtonProps(panel, el) {
const a = el.attributes;
const attachmentStatus = a.attachment_filename
? `Fichier joint : ${forgeDocEscapeHtml(a.attachment_filename)}`
: 'Aucun fichier joint';
panel.innerHTML = `
${forgeDocPropsHeader('bouton')}
<div class="docField"><label for="docButtonLabel">Texte du bouton</label><input type="text" id="docButtonLabel" value="${forgeDocEscapeHtml(a.label || '')}"></div>
<div class="docField"><label for="docButtonTarget">Cible (URL ou ancre)</label><input type="text" id="docButtonTarget" value="${forgeDocEscapeHtml(a.target || '')}"></div>
<div class="docField">
<span class="docFieldLabel">Fichier téléchargeable</span>
<div id="docButtonAttachmentStatus">${attachmentStatus}</div>
<input type="file" id="docButtonAttachmentInput">
</div>
<div class="docField"><label for="docButtonSvg">Icône SVG (optionnel)</label><textarea id="docButtonSvg" rows="3">${forgeDocEscapeHtml(a.svg_markup || '')}</textarea></div>
<div class="docField">
<span class="docFieldLabel">Position de l'icône</span>
<div class="docSegmented" id="docButtonIconPosSeg">
${FORGE_DOC_ICON_POSITION_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.icon_position || 'before') === v ? 'is-active' : ''}" data-pos="${v}">${l}</button>`).join('')}
</div>
</div>
${forgeDocRenderToggleSliderHtml('docButtonIconSize', "Taille de l'icône", a.icon_size, {
min: 10, max: 48, step: 1, unit: 'px', fallback: 16,
})}
<div class="docField">
<span class="docFieldLabel">Mise en forme</span>
<div class="docToolbarRow">
<button type="button" class="docFmtBtn ${a.bold ? 'is-active' : ''}" id="docButtonBoldBtn" title="Gras">B</button>
<button type="button" class="docFmtBtn docFmtBtn--italic ${a.italic ? 'is-active' : ''}" id="docButtonItalicBtn" title="Italique">I</button>
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Majuscules / minuscules</span>
<div class="docSegmented" id="docButtonTextTransformSeg">
${FORGE_DOC_TEXT_TRANSFORM_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.text_transform || 'none') === v ? 'is-active' : ''}" data-transform="${v}">${l}</button>`).join('')}
</div>
</div>
<div class="docField">
<label for="docButtonColorInput">Couleur du texte</label>
<input type="color" id="docButtonColorInput" value="${forgeDocToHexColor(a.text_color, '#14100c')}">
</div>
<div class="docField">
<label for="docButtonFontFamilyInput">Police de caractère</label>
<select id="docButtonFontFamilyInput">
${FORGE_DOC_FONT_FAMILY_OPTIONS.map(([v, l]) => `<option value="${forgeDocEscapeHtml(v)}" ${(a.font_family || '') === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</div>
${forgeDocRenderToggleSliderHtml('docButtonFontSize', 'Taille de police', a.font_size, {
min: 10, max: 32, step: 1, unit: 'px', fallback: 14,
})}
${forgeDocRenderToggleSliderHtml('docButtonLetterSpacing', 'Espacement des lettres', a.letter_spacing, {
min: -2, max: 10, step: 0.5, unit: 'px', fallback: 0,
})}
${forgeDocRenderBoxFieldsHtml(a, 'docButton')}
${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() }));
document.getElementById('docButtonAttachmentInput').addEventListener('change', (e) => {
const file = e.target.files[0];
if (!file) return;
forgeDocApiUploadAttachment(el.id, file).then((result) => {
if (result.error) { alert(result.error); return; }
el.attributes = result.attributes;
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
const dom = document.querySelector(`[data-element-id="${el.id}"]`);
if (dom && result.rendered_html) {
const wrapper = document.createElement('div');
wrapper.innerHTML = result.rendered_html;
dom.replaceWith(wrapper.firstElementChild);
forgeDocBindCanvasInteractions();
forgeDocApplySelectionClass();
}
});
});
document.getElementById('docButtonSvg').addEventListener('change', (e) => patch({ svg_markup: e.target.value.trim() }));
panel.querySelectorAll('#docButtonIconPosSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ icon_position: btn.dataset.pos }));
});
forgeDocBindToggleSlider('docButtonIconSize', { unit: 'px' }, (v) => patch({ icon_size: v }));
document.getElementById('docButtonBoldBtn').addEventListener('click', () => patch({ bold: !a.bold }));
document.getElementById('docButtonItalicBtn').addEventListener('click', () => patch({ italic: !a.italic }));
panel.querySelectorAll('#docButtonTextTransformSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ text_transform: btn.dataset.transform }));
});
document.getElementById('docButtonColorInput').addEventListener('change', (e) => patch({ text_color: e.target.value }));
document.getElementById('docButtonFontFamilyInput').addEventListener('change', (e) => patch({ font_family: e.target.value }));
forgeDocBindToggleSlider('docButtonFontSize', { unit: 'px' }, (v) => patch({ font_size: v }));
forgeDocBindToggleSlider('docButtonLetterSpacing', { unit: 'px' }, (v) => patch({ letter_spacing: v }));
forgeDocBindBoxFields(panel, el, 'docButton', patch);
}
function forgeDocRenderBadgeProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
${forgeDocPropsHeader('étiquette')}
<div class="docField"><label for="docBadgeContent">Contenu</label><input type="text" id="docBadgeContent" value="${forgeDocEscapeHtml(a.content || '')}"></div>
<div class="docField"><label for="docBadgeSvg">Icône SVG (optionnel)</label><textarea id="docBadgeSvg" rows="3">${forgeDocEscapeHtml(a.svg_markup || '')}</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="docBadgeBoldBtn" title="Gras">B</button>
<button type="button" class="docFmtBtn ${a.uppercase ? 'is-active' : ''}" id="docBadgeUppercaseBtn" title="Majuscules">AA</button>
</div>
</div>
<div class="docField"><label for="docBadgeWidth">Largeur (optionnel, ex. fit-content, 220px)</label><input type="text" id="docBadgeWidth" value="${forgeDocEscapeHtml(a.width || '')}" placeholder="pleine largeur"></div>
<div class="docField"><label for="docBadgeRadius">Arrondi des bords (optionnel, ex. 7px, 999px)</label><input type="text" id="docBadgeRadius" value="${forgeDocEscapeHtml(a.border_radius || '')}"></div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docBadgeContent').addEventListener('change', (e) => patch({ content: e.target.value }));
document.getElementById('docBadgeSvg').addEventListener('change', (e) => patch({ svg_markup: e.target.value.trim() }));
document.getElementById('docBadgeBoldBtn').addEventListener('click', () => patch({ bold: !a.bold }));
document.getElementById('docBadgeUppercaseBtn').addEventListener('click', () => patch({ uppercase: !a.uppercase }));
document.getElementById('docBadgeWidth').addEventListener('change', (e) => patch({ width: e.target.value.trim() }));
document.getElementById('docBadgeRadius').addEventListener('change', (e) => patch({ border_radius: e.target.value.trim() }));
}
function forgeDocRenderCarteProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
${forgeDocPropsHeader('carte')}
<div class="docField"><label for="docCardLabel">Repère (lettre, chiffre...)</label><input type="text" id="docCardLabel" value="${forgeDocEscapeHtml(a.label || '')}"></div>
<div class="docField"><label for="docCardTitle">Titre</label><input type="text" id="docCardTitle" value="${forgeDocEscapeHtml(a.title || '')}"></div>
<div class="docField"><label for="docCardDescription">Description</label><textarea id="docCardDescription" rows="3">${forgeDocEscapeHtml(a.description || '')}</textarea></div>
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docCardLabel').addEventListener('change', (e) => patch({ label: e.target.value }));
document.getElementById('docCardTitle').addEventListener('change', (e) => patch({ title: e.target.value }));
document.getElementById('docCardDescription').addEventListener('change', (e) => patch({ description: e.target.value }));
}
function forgeDocRenderListItemHtml(item, index) {
return `
<div class="docAssocPairRow" data-item-index="${index}">
<input type="text" class="docAssocPairInput docListItemInput" data-item-index="${index}" value="${forgeDocEscapeHtml(item)}">
<button type="button" class="docQuizRemoveBtn" data-item-index="${index}" aria-label="Supprimer cet élément" title="Supprimer cet élément">✕</button>
</div>
`;
}
function forgeDocRenderListProps(panel, el) {
// Même patron répéteur que l'Association/Memory (ajouter/supprimer/
// modifier une ligne, panneau entièrement rechargé après chaque
// modification via patch()) — ici une simple liste de chaînes, pas de
// structure à 2 champs comme une paire.
const a = el.attributes;
const items = a.items || [];
const kindLabel = el.kind === 'liste_numerotee' ? 'liste numérotée' : 'liste à puces';
function patch(partial) {
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
});
}
panel.innerHTML = `
${forgeDocPropsHeader(kindLabel)}
<div class="docSectionLabel docQuizQuestionsLabel">Éléments de la liste</div>
<div id="docListItemsList">${items.length ? items.map(forgeDocRenderListItemHtml).join('') : '<div class="docEmptyState">Aucun élément — ajoute le premier ci-dessous.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docListAddItem">+ Ajouter un élément</button>
${forgeDocDeleteButtonHtml()}
`;
document.getElementById('docListAddItem').addEventListener('click', () => {
patch({ items: [...items, 'Nouvel élément'] });
});
panel.querySelectorAll('#docListItemsList .docQuizRemoveBtn').forEach((btn) => {
btn.addEventListener('click', () => {
const index = Number(btn.dataset.itemIndex);
patch({ items: items.filter((_, i) => i !== index) });
});
});
panel.querySelectorAll('.docListItemInput').forEach((input) => {
input.addEventListener('change', (e) => {
const index = Number(input.dataset.itemIndex);
patch({ items: items.map((it, i) => (i === index ? e.target.value : it)) });
});
});
}
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();
});
}
/* ---------------------------------------------------------------------
* Modale "Utiliser un modèle" — liste les thèmes du catalogue (voir
* document_engine/themes/theme_catalog.py, transmis tel quel via
* FORGE_DOCUMENT.themes) et prévisualise RÉELLEMENT le thème sélectionné
* dans un <iframe> pointant sur /document/<slug>/theme/<id>/preview
* (jamais un style recopié à la main côté client — ce qui est
* prévisualisé est exactement ce qui s'affichera une fois appliqué).
* ------------------------------------------------------------------- */
// eslint-disable-next-line no-var, vars-on-top
var forgeDocSelectedTemplateId = null;
function forgeDocRenderTemplateList() {
const list = document.getElementById('docTemplateList');
const themes = window.FORGE_DOCUMENT.themes || [];
if (!themes.length) {
list.innerHTML = '<div class="docEmptyState">Aucun modèle disponible pour le moment.</div>';
return;
}
list.innerHTML = themes.map((t) => `
<button type="button" class="docTemplateCard ${t.id === forgeDocSelectedTemplateId ? 'is-active' : ''}" data-theme-id="${t.id}">
<div class="docTemplateCardName">${forgeDocEscapeHtml(t.name)}</div>
<div class="docTemplateCardCategory">${forgeDocEscapeHtml(t.category)}</div>
</button>
`).join('');
list.querySelectorAll('.docTemplateCard').forEach((btn) => {
btn.addEventListener('click', () => forgeDocSelectTemplate(btn.dataset.themeId));
});
}
function forgeDocSelectTemplate(themeId) {
forgeDocSelectedTemplateId = themeId;
forgeDocRenderTemplateList();
const frame = document.getElementById('docTemplatePreviewFrame');
const empty = document.getElementById('docTemplatePreviewEmpty');
const actions = document.getElementById('docTemplatePreviewActions');
frame.src = `/document/${window.forgeDocState.slug}/theme/${themeId}/preview`;
frame.classList.add('is-visible');
empty.hidden = true;
actions.hidden = false;
}
function forgeDocApplyTemplate(mode) {
if (!forgeDocSelectedTemplateId) return;
if (mode === 'template_content') {
const confirmed = window.confirm( // eslint-disable-line no-alert
'Remplacer tout le contenu actuel du document par le contenu de démonstration de ce modèle ? Cette action est irréversible.',
);
if (!confirmed) return;
}
fetch(`/document/${window.forgeDocState.slug}/theme/apply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ theme_id: forgeDocSelectedTemplateId, mode }),
}).then((r) => r.json()).then((result) => {
if (result.error) { alert(result.error); return; } // eslint-disable-line no-alert
// Le thème change le CSS chargé en <head> (voir templates/document/
// document_edit.html, extra_head) et potentiellement tout le contenu
// (mode "template_content") — un rechargement complet est le seul
// moyen simple de rester synchronisé avec la vérité serveur, jamais
// une resynchronisation partielle de l'état client ici.
window.location.reload();
});
}
function forgeDocOpenTemplateModal() {
document.getElementById('docTemplateModal').classList.add('is-open');
// Toujours vide à l'ouverture (retour utilisateur du 24/09/2026 :
// "aucun template ne dois etre sélectionner de base") — même si un
// thème est déjà appliqué à ce support, le choisir ici reste une
// action explicite de l'utilisateur, jamais présélectionnée.
forgeDocSelectedTemplateId = null;
forgeDocRenderTemplateList();
const frame = document.getElementById('docTemplatePreviewFrame');
const empty = document.getElementById('docTemplatePreviewEmpty');
const actions = document.getElementById('docTemplatePreviewActions');
frame.classList.remove('is-visible');
frame.src = 'about:blank';
empty.hidden = false;
actions.hidden = true;
}
function forgeDocCloseTemplateModal() {
document.getElementById('docTemplateModal').classList.remove('is-open');
document.getElementById('docTemplatePreviewFrame').src = 'about:blank';
}
function forgeDocBindTemplateModal() {
document.getElementById('docTemplateBtn').addEventListener('click', forgeDocOpenTemplateModal);
document.getElementById('docTemplateModalClose').addEventListener('click', forgeDocCloseTemplateModal);
document.getElementById('docTemplateModal').addEventListener('click', (e) => {
if (e.target.id === 'docTemplateModal') forgeDocCloseTemplateModal();
});
document.getElementById('docTemplateKeepContentBtn').addEventListener('click', () => forgeDocApplyTemplate('keep_content'));
document.getElementById('docTemplateUseContentBtn').addEventListener('click', () => forgeDocApplyTemplate('template_content'));
document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return;
if (document.getElementById('docTemplateModal').classList.contains('is-open')) forgeDocCloseTemplateModal();
});
}
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 === 'liste_puces' || el.kind === 'liste_numerotee') forgeDocRenderListProps(panel, el);
else if (el.kind === 'badge') forgeDocRenderBadgeProps(panel, el);
else if (el.kind === 'carte') forgeDocRenderCarteProps(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 forgeDocSetPreviewMode(enabled) {
// Mode Aperçu = plein écran RÉEL, pas un simple agrandissement CSS
// (retour utilisateur du 23/09/2026 : "en mode apercu en plein
// ecrant avec possibilité de retourner à l'éditeur") — la Fullscreen
// API contourne d'un coup toute la question de clipping par
// overflow:hidden sur main.content (voir les commentaires CSS sur
// .docEditor3/la modale Scénario) : l'élément quitte entièrement le
// rendu normal de la page, plus de souci d'ancêtre.
window.forgeDocState.previewMode = enabled;
const editor = document.getElementById('docEditor3');
editor.classList.toggle('docEditor3--preview', enabled);
forgeDocUpdatePageNavPill();
if (enabled) {
if (editor.requestFullscreen) editor.requestFullscreen().catch(() => {});
} else if (document.fullscreenElement) {
document.exitFullscreen().catch(() => {});
}
}
function forgeDocBindPreviewToggle() {
document.getElementById('docPreviewBtn').addEventListener('click', () => {
forgeDocSetPreviewMode(!window.forgeDocState.previewMode);
});
document.getElementById('docPreviewExitBtn').addEventListener('click', () => forgeDocSetPreviewMode(false));
// Synchronise l'état si le créateur quitte le plein écran nativement
// (Échap, F11...) sans passer par nos boutons — sinon previewMode
// resterait vrai (mini-jeux "jouables") alors que l'écran normal de
// l'éditeur est revenu.
document.addEventListener('fullscreenchange', () => {
if (!document.fullscreenElement && window.forgeDocState.previewMode) {
forgeDocSetPreviewMode(false);
}
});
}
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 templateModal = document.getElementById('docTemplateModal');
if (templateModal) templateModal.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();
forgeDocRefreshRightPanelDefault();
});
}
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();
forgeDocBindTemplateModal();
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();