Build and deploy / test-python (push) Successful in 7m48s
Build and deploy / test-js (push) Successful in 52s
Build and deploy / lint-python (push) Successful in 5m44s
Build and deploy / lint-js (push) Failing after 1m52s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m5s
Retour utilisateur : "il faut implementer un systeme de page". Un support est desormais compose de PLUSIEURS pages (_document_pages), chacune un document independant affiche seul sur le canevas -- chaque element appartient a exactement une page via page_id (document_engine/ elements/*, routes/document/document_element_add.py/document_render.py revalident desormais un page_id explicite). Migration automatique et silencieuse pour les supports crees avant cette fonctionnalite (db/supports/ensure_document_pages_schema.py, meme convention que les ensure_X_schema.py existants) : leurs elements deviennent tous les enfants d'une "Page 1" creee a la volee, aucune perte de contenu. Nouveau paquet document_engine/pages/ (add/list/get/rename/move/delete) et 4 routes dediees (routes/document/document_page_*.py) -- supprimer la DERNIERE page restante est refuse (garde-fou pose a la route, meme decoupage que routes/game/screens/screen_delete.py cote jeu, jamais dans la fonction bas niveau). Cote editeur : une bande d'ONGLETS au-dessus du canevas (jamais un panneau lateral, choix explicite de l'utilisateur) -- clic pour changer de page, double-clic pour renommer (contenteditable), glisser pour reordonner, "+" pour ajouter, "x" pour supprimer. Changer de page vide la pile Annuler/Retablir (une commande empilee sur une autre page n'a plus de sens). Mode Apercu : navigation Page precedente/suivante avec indicateur "Page X / N" (choix explicite : page par page, pas de defilement continu), jamais affichee s'il n'y a qu'une seule page. Verifie : suite pytest complete (702 tests, dont 14 nouveaux pour les routes de pages), simulation DOM reelle (jsdom, 25 assertions couvrant tout le cycle de vie cote client -- creation/bascule/renommage/ reordonnancement/suppression de page, portee correcte des elements par page, pile Annuler/Retablir videe au changement de page, pilule de navigation en Apercu), et un test de fumee HTTP reel contre le serveur de dev en marche (creation/ajout d'element/rendu/renommage/suppression d'une page, refus de supprimer la derniere page, page inconnue -> 404). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2877 lines
120 KiB
JavaScript
2877 lines
120 KiB
JavaScript
// document-editor.js — éditeur de support de formation (voir
|
|
// docs/plan/PLAN.md). État MUTABLE partagé sur window.forgeDocState
|
|
// (jamais let/const au premier niveau de ce fichier, voir static/pjax.js —
|
|
// un <script src> de page est rejoué tel quel à chaque navigation pjax,
|
|
// une redéclaration lèverait "already declared" ; seul `var` est sûr à
|
|
// rejouer). Toute mutation passe par le serveur (AJAX) puis rafraîchit le
|
|
// canevas depuis SA réponse (jamais un état simulé côté client) —
|
|
// Undo/Redo est une PILE DE COMMANDES qui rejoue ces mêmes appels AJAX en
|
|
// sens inverse, pas un simple instantané visuel.
|
|
|
|
// Constante immuable au premier niveau : `let`/`const` lèverait "already
|
|
// declared" à la 2e exécution de ce script par pjax.js (voir l'en-tête
|
|
// ci-dessus), `var` est le seul choix sûr ici.
|
|
// eslint-disable-next-line no-var, vars-on-top
|
|
var FORGE_DOC_STYLE_PRESETS = {
|
|
titre1: 'Titre 1',
|
|
titre2: 'Titre 2',
|
|
paragraphe: 'Paragraphe',
|
|
legende: 'Légende',
|
|
};
|
|
|
|
// Même raison que FORGE_DOC_STYLE_PRESETS ci-dessus.
|
|
// eslint-disable-next-line no-var, vars-on-top
|
|
var FORGE_DOC_SHAPE_KINDS = ['rectangle', 'cercle', 'triangle', 'trait'];
|
|
|
|
function forgeDocIsShapeKind(kind) {
|
|
return FORGE_DOC_SHAPE_KINDS.includes(kind);
|
|
}
|
|
|
|
function forgeDocChildrenOf(parentId) {
|
|
return window.forgeDocState.elements
|
|
.filter((el) => el.parent_id === parentId)
|
|
.sort((a, b) => a.order_index - b.order_index);
|
|
}
|
|
|
|
function forgeDocTopLevel() {
|
|
return forgeDocChildrenOf(null);
|
|
}
|
|
|
|
function forgeDocRebuildIndex() {
|
|
const byId = {};
|
|
window.forgeDocState.elements.forEach((el) => { byId[el.id] = el; });
|
|
window.forgeDocState.elementsById = byId;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Appels serveur — un seul point de vérité par opération (voir
|
|
* routes/document/), jamais dupliqué côté client.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocApiAdd(kind, parentId) {
|
|
// page_id lu depuis l'état ambiant (window.forgeDocState.activePageId),
|
|
// jamais un paramètre explicite — même principe que le slug déjà lu
|
|
// ainsi juste en dessous : tout appelant de cette fonction ajoute par
|
|
// définition à "la page actuellement affichée", inutile de le faire
|
|
// remonter dans chacun des ~10 sites d'appel (glisser-déposer, moteur
|
|
// de layout, Annuler/Rétablir...).
|
|
const body = new URLSearchParams();
|
|
body.set('kind', kind);
|
|
body.set('page_id', String(window.forgeDocState.activePageId));
|
|
if (parentId !== null && parentId !== undefined) body.set('parent_id', String(parentId));
|
|
return fetch(`/document/${window.forgeDocState.slug}/elements/add`, { method: 'POST', body })
|
|
.then((r) => r.json());
|
|
}
|
|
|
|
function forgeDocApiUpdate(id, attributes) {
|
|
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/update`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(attributes),
|
|
}).then((r) => r.json());
|
|
}
|
|
|
|
function forgeDocApiMove(id, newParentId, newIndex) {
|
|
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/move`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ new_parent_id: newParentId, new_index: newIndex }),
|
|
}).then((r) => r.json());
|
|
}
|
|
|
|
function forgeDocApiDelete(id) {
|
|
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/delete`, { method: 'POST' })
|
|
.then((r) => r.json());
|
|
}
|
|
|
|
function forgeDocApiRender() {
|
|
const url = `/document/${window.forgeDocState.slug}/render?page_id=${window.forgeDocState.activePageId}`;
|
|
return fetch(url).then((r) => r.json());
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Canevas — rafraîchi depuis la VÉRITÉ SERVEUR après chaque mutation
|
|
* structurelle (jamais une simple retouche DOM locale, voir
|
|
* routes/document/document_render.py).
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocApplySelectionClass() {
|
|
document.querySelectorAll('#docPage .docSelected').forEach((d) => d.classList.remove('docSelected'));
|
|
const { selectedId } = window.forgeDocState;
|
|
if (selectedId === null) return;
|
|
const dom = document.querySelector(`[data-element-id="${selectedId}"]`);
|
|
if (dom) dom.classList.add('docSelected');
|
|
}
|
|
|
|
function forgeDocRefreshCanvas() {
|
|
return forgeDocApiRender().then((data) => {
|
|
document.getElementById('docPageContent').innerHTML = data.html;
|
|
window.forgeDocState.elements = data.elements;
|
|
forgeDocRebuildIndex();
|
|
forgeDocBindCanvasInteractions();
|
|
const { selectedId } = window.forgeDocState;
|
|
if (selectedId !== null && window.forgeDocState.elementsById[selectedId]) {
|
|
forgeDocApplySelectionClass();
|
|
} else {
|
|
window.forgeDocState.selectedId = null;
|
|
forgeDocRenderProps(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Undo/Redo — pile de COMMANDES {undo, redo}, chacune rejouant les mêmes
|
|
* appels AJAX que l'action d'origine (voir docs/plan/PLAN.md).
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocUpdateHistoryButtons() {
|
|
const undoBtn = document.getElementById('docUndoBtn');
|
|
const redoBtn = document.getElementById('docRedoBtn');
|
|
if (undoBtn) undoBtn.disabled = window.forgeDocState.undoStack.length === 0;
|
|
if (redoBtn) redoBtn.disabled = window.forgeDocState.redoStack.length === 0;
|
|
}
|
|
|
|
function forgeDocPushCommand(command) {
|
|
window.forgeDocState.undoStack.push(command);
|
|
window.forgeDocState.redoStack = [];
|
|
forgeDocUpdateHistoryButtons();
|
|
}
|
|
|
|
function forgeDocUndo() {
|
|
const command = window.forgeDocState.undoStack.pop();
|
|
if (!command) return;
|
|
command.undo().then(forgeDocRefreshCanvas).then(() => {
|
|
window.forgeDocState.redoStack.push(command);
|
|
forgeDocUpdateHistoryButtons();
|
|
});
|
|
}
|
|
|
|
function forgeDocRedo() {
|
|
const command = window.forgeDocState.redoStack.pop();
|
|
if (!command) return;
|
|
command.redo().then(forgeDocRefreshCanvas).then(() => {
|
|
window.forgeDocState.undoStack.push(command);
|
|
forgeDocUpdateHistoryButtons();
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Mutations de haut niveau — chacune exécute son action puis pousse la
|
|
* commande d'annulation correspondante.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocAddElement(kind, parentId) {
|
|
const state = { parentId: parentId === undefined ? null : parentId };
|
|
function perform() {
|
|
return forgeDocApiAdd(kind, state.parentId).then((result) => { state.id = result.id; });
|
|
}
|
|
return perform().then(() => {
|
|
forgeDocPushCommand({
|
|
undo: () => forgeDocApiDelete(state.id),
|
|
redo: perform,
|
|
});
|
|
return forgeDocRefreshCanvas().then(() => forgeDocSelectElement(state.id));
|
|
});
|
|
}
|
|
|
|
function forgeDocDeleteElement(id) {
|
|
const el = window.forgeDocState.elementsById[id];
|
|
if (!el) return Promise.resolve();
|
|
const state = {
|
|
id, parentId: el.parent_id, index: el.order_index, kind: el.kind, attributes: el.attributes,
|
|
};
|
|
return forgeDocApiDelete(state.id).then(() => {
|
|
forgeDocPushCommand({
|
|
undo: () => forgeDocApiAdd(state.kind, state.parentId).then((r) => {
|
|
state.id = r.id;
|
|
return forgeDocApiUpdate(state.id, state.attributes)
|
|
.then(() => forgeDocApiMove(state.id, state.parentId, state.index));
|
|
}),
|
|
redo: () => forgeDocApiDelete(state.id),
|
|
});
|
|
window.forgeDocState.selectedId = null;
|
|
return forgeDocRefreshCanvas();
|
|
});
|
|
}
|
|
|
|
function forgeDocUpdateAttributes(id, newAttributes) {
|
|
const el = window.forgeDocState.elementsById[id];
|
|
if (!el) return Promise.resolve();
|
|
const oldAttributes = { ...el.attributes };
|
|
return forgeDocApiUpdate(id, newAttributes).then((result) => {
|
|
forgeDocPushCommand({
|
|
undo: () => forgeDocApiUpdate(id, oldAttributes),
|
|
redo: () => forgeDocApiUpdate(id, newAttributes),
|
|
});
|
|
// Le serveur revalide/nettoie certains kinds (quiz — voir
|
|
// routes/document/document_element_update.py) : on adopte SA version,
|
|
// jamais celle envoyée telle quelle, pour ne jamais dériver de la
|
|
// vérité persistée (ex. un choix en trop silencieusement tronqué).
|
|
el.attributes = result.attributes || newAttributes;
|
|
const dom = document.querySelector(`[data-element-id="${id}"]`);
|
|
if (dom && result.rendered_html) {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.innerHTML = result.rendered_html;
|
|
dom.replaceWith(wrapper.firstElementChild);
|
|
forgeDocBindCanvasInteractions();
|
|
forgeDocApplySelectionClass();
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Moteur de layout — glisser-déposer réel (voir docs/plan/PLAN.md §1.2.B) :
|
|
* dépose sur la moitié gauche/droite de la boîte englobante d'un élément
|
|
* -> rejoint sa rangée (créée à la volée si besoin) ; moitié haute/basse
|
|
* -> nouvelle position au top-niveau, avant/après.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocFindDropTarget(clientX, clientY) {
|
|
const candidates = Array.from(document.querySelectorAll('#docPage [data-element-id]'))
|
|
.filter((dom) => !forgeDocIsShapeKind(dom.dataset.kind));
|
|
let best = null;
|
|
let bestDist = Infinity;
|
|
candidates.forEach((dom) => {
|
|
const rect = dom.getBoundingClientRect();
|
|
const dist = Math.hypot(clientX - (rect.left + rect.width / 2), clientY - (rect.top + rect.height / 2));
|
|
if (dist < bestDist) { bestDist = dist; best = dom; }
|
|
});
|
|
return best;
|
|
}
|
|
|
|
function forgeDocComputeDropPlacement(targetDom, clientX, clientY) {
|
|
const rect = targetDom.getBoundingClientRect();
|
|
const relX = (clientX - rect.left) / rect.width;
|
|
const relY = (clientY - rect.top) / rect.height;
|
|
const targetId = Number.parseInt(targetDom.dataset.elementId, 10);
|
|
const targetEl = window.forgeDocState.elementsById[targetId];
|
|
if (relY < 0.25) return { mode: 'before', targetEl };
|
|
if (relY > 0.75) return { mode: 'after', targetEl };
|
|
return { mode: relX < 0.5 ? 'mergeLeft' : 'mergeRight', targetEl };
|
|
}
|
|
|
|
function forgeDocShowDropHighlight(targetDom, mode) {
|
|
document.querySelectorAll('.docDropTarget').forEach((d) => {
|
|
d.classList.remove('docDropTarget', 'docDropTarget--before', 'docDropTarget--after', 'docDropTarget--mergeLeft', 'docDropTarget--mergeRight');
|
|
});
|
|
targetDom.classList.add('docDropTarget', `docDropTarget--${mode}`);
|
|
}
|
|
|
|
function forgeDocClearDropHighlight() {
|
|
document.querySelectorAll('.docDropTarget').forEach((d) => {
|
|
d.classList.remove('docDropTarget', 'docDropTarget--before', 'docDropTarget--after', 'docDropTarget--mergeLeft', 'docDropTarget--mergeRight');
|
|
});
|
|
}
|
|
|
|
function forgeDocDropLibraryItem(kind, placement) {
|
|
const { targetEl } = placement;
|
|
const state = {};
|
|
|
|
function performForward() {
|
|
if (placement.mode === 'mergeLeft' || placement.mode === 'mergeRight') {
|
|
if (targetEl.parent_id !== null) {
|
|
const siblings = forgeDocChildrenOf(targetEl.parent_id);
|
|
const pos = siblings.findIndex((s) => s.id === targetEl.id);
|
|
state.index = placement.mode === 'mergeLeft' ? pos : pos + 1;
|
|
state.createdRow = false;
|
|
return forgeDocApiAdd(kind, targetEl.parent_id).then((r) => {
|
|
state.newId = r.id;
|
|
return forgeDocApiMove(state.newId, targetEl.parent_id, state.index);
|
|
});
|
|
}
|
|
state.createdRow = true;
|
|
state.topPos = forgeDocTopLevel().findIndex((s) => s.id === targetEl.id);
|
|
return forgeDocApiAdd('row', null).then((rowResult) => {
|
|
state.rowId = rowResult.id;
|
|
return forgeDocApiMove(state.rowId, null, state.topPos)
|
|
.then(() => forgeDocApiMove(targetEl.id, state.rowId, 0))
|
|
.then(() => forgeDocApiAdd(kind, state.rowId))
|
|
.then((r) => {
|
|
state.newId = r.id;
|
|
state.index = placement.mode === 'mergeLeft' ? 0 : 1;
|
|
return forgeDocApiMove(state.newId, state.rowId, state.index);
|
|
});
|
|
});
|
|
}
|
|
const anchorId = targetEl.parent_id !== null ? targetEl.parent_id : targetEl.id;
|
|
const anchorPos = forgeDocTopLevel().findIndex((s) => s.id === anchorId);
|
|
state.index = placement.mode === 'before' ? anchorPos : anchorPos + 1;
|
|
state.createdRow = false;
|
|
return forgeDocApiAdd(kind, null).then((r) => {
|
|
state.newId = r.id;
|
|
return forgeDocApiMove(state.newId, null, state.index);
|
|
});
|
|
}
|
|
|
|
function performUndo() {
|
|
const deleteNew = forgeDocApiDelete(state.newId);
|
|
if (!state.createdRow) return deleteNew;
|
|
return deleteNew
|
|
.then(() => forgeDocApiMove(targetEl.id, null, state.topPos))
|
|
.then(() => forgeDocApiDelete(state.rowId));
|
|
}
|
|
|
|
return performForward().then(() => {
|
|
forgeDocPushCommand({ undo: performUndo, redo: performForward });
|
|
return forgeDocRefreshCanvas().then(() => forgeDocSelectElement(state.newId));
|
|
});
|
|
}
|
|
|
|
function forgeDocDropExistingElement(draggedId, placement) {
|
|
const draggedEl = window.forgeDocState.elementsById[draggedId];
|
|
const { targetEl } = placement;
|
|
if (!draggedEl || draggedEl.id === targetEl.id || targetEl.parent_id === draggedId) return Promise.resolve();
|
|
const isMerge = placement.mode === 'mergeLeft' || placement.mode === 'mergeRight';
|
|
if (draggedEl.kind === 'row' && isMerge) return Promise.resolve();
|
|
|
|
const fromParentId = draggedEl.parent_id;
|
|
const fromIndex = draggedEl.order_index;
|
|
const state = {};
|
|
|
|
function performForward() {
|
|
if (isMerge) {
|
|
if (targetEl.parent_id !== null) {
|
|
const siblings = forgeDocChildrenOf(targetEl.parent_id).filter((s) => s.id !== draggedId);
|
|
const pos = siblings.findIndex((s) => s.id === targetEl.id);
|
|
state.rowId = targetEl.parent_id;
|
|
state.index = placement.mode === 'mergeLeft' ? pos : pos + 1;
|
|
state.createdRow = false;
|
|
return forgeDocApiMove(draggedId, state.rowId, state.index);
|
|
}
|
|
state.createdRow = true;
|
|
state.topPos = forgeDocTopLevel().filter((s) => s.id !== draggedId).findIndex((s) => s.id === targetEl.id);
|
|
return forgeDocApiAdd('row', null).then((rowResult) => {
|
|
state.rowId = rowResult.id;
|
|
return forgeDocApiMove(state.rowId, null, state.topPos)
|
|
.then(() => forgeDocApiMove(targetEl.id, state.rowId, 0))
|
|
.then(() => {
|
|
state.index = placement.mode === 'mergeLeft' ? 0 : 1;
|
|
return forgeDocApiMove(draggedId, state.rowId, state.index);
|
|
});
|
|
});
|
|
}
|
|
const anchorId = targetEl.parent_id !== null ? targetEl.parent_id : targetEl.id;
|
|
const anchorPos = forgeDocTopLevel().filter((s) => s.id !== draggedId).findIndex((s) => s.id === anchorId);
|
|
state.index = placement.mode === 'before' ? anchorPos : anchorPos + 1;
|
|
state.createdRow = false;
|
|
return forgeDocApiMove(draggedId, null, state.index);
|
|
}
|
|
|
|
function performUndo() {
|
|
const restore = forgeDocApiMove(draggedId, fromParentId, fromIndex);
|
|
if (!state.createdRow) return restore;
|
|
return restore
|
|
.then(() => forgeDocApiMove(targetEl.id, null, state.topPos))
|
|
.then(() => forgeDocApiDelete(state.rowId));
|
|
}
|
|
|
|
return performForward().then(() => {
|
|
forgeDocPushCommand({ undo: performUndo, redo: performForward });
|
|
return forgeDocRefreshCanvas();
|
|
});
|
|
}
|
|
|
|
function forgeDocBindLibraryDragHandlers() {
|
|
document.querySelectorAll('.docLibItem[data-add]').forEach((item) => {
|
|
item.addEventListener('dragstart', (e) => {
|
|
e.dataTransfer.setData('text/forge-doc-add', item.dataset.add);
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
});
|
|
item.addEventListener('click', () => { forgeDocAddElement(item.dataset.add, null); });
|
|
});
|
|
}
|
|
|
|
function forgeDocBindCanvasDropZone() {
|
|
const docPage = document.getElementById('docPage');
|
|
docPage.addEventListener('dragover', (e) => {
|
|
const isAdd = e.dataTransfer.types.includes('text/forge-doc-add');
|
|
const isMove = e.dataTransfer.types.includes('text/forge-doc-move');
|
|
if (!isAdd && !isMove) return;
|
|
e.preventDefault();
|
|
const target = forgeDocFindDropTarget(e.clientX, e.clientY);
|
|
if (!target) { forgeDocClearDropHighlight(); return; }
|
|
const placement = forgeDocComputeDropPlacement(target, e.clientX, e.clientY);
|
|
forgeDocShowDropHighlight(target, placement.mode);
|
|
});
|
|
docPage.addEventListener('dragleave', (e) => {
|
|
if (e.target === docPage) forgeDocClearDropHighlight();
|
|
});
|
|
docPage.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
forgeDocClearDropHighlight();
|
|
const target = forgeDocFindDropTarget(e.clientX, e.clientY);
|
|
if (!target) return;
|
|
const placement = forgeDocComputeDropPlacement(target, e.clientX, e.clientY);
|
|
const addKind = e.dataTransfer.getData('text/forge-doc-add');
|
|
const moveId = e.dataTransfer.getData('text/forge-doc-move');
|
|
if (addKind) { forgeDocDropLibraryItem(addKind, placement); return; }
|
|
if (moveId) forgeDocDropExistingElement(Number.parseInt(moveId, 10), placement);
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* 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. Bande
|
|
* d'ONGLETS au-dessus du canevas pour créer/renommer (double-clic)/
|
|
* réordonner (glisser)/supprimer (voir templates/document/
|
|
* document_edit.html) — jamais un panneau latéral (retour utilisateur).
|
|
* Changer de page VIDE la pile Annuler/Rétablir (voir forgeDocSwitchPage)
|
|
* : une commande empilée sur une autre page rejouerait un appel AJAX
|
|
* ciblant des éléments qui ne sont plus affichés, aucun sens à la garder.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocApiPageAdd(title) {
|
|
return fetch(`/document/${window.forgeDocState.slug}/pages/add`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(title ? { title } : {}),
|
|
}).then((r) => r.json());
|
|
}
|
|
|
|
function forgeDocApiPageRename(pageId, title) {
|
|
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/rename`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title }),
|
|
}).then((r) => r.json());
|
|
}
|
|
|
|
function forgeDocApiPageDelete(pageId) {
|
|
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/delete`, { method: 'POST' })
|
|
.then((r) => r.json());
|
|
}
|
|
|
|
function forgeDocApiPageMove(pageId, newIndex) {
|
|
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/move`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ new_index: newIndex }),
|
|
}).then((r) => r.json());
|
|
}
|
|
|
|
function forgeDocOrderedPages() {
|
|
return [...window.forgeDocState.pages].sort((a, b) => a.order_index - b.order_index);
|
|
}
|
|
|
|
function forgeDocUpdatePageNavPill() {
|
|
const pill = document.getElementById('docPageNavPill');
|
|
if (!pill) return;
|
|
const pages = forgeDocOrderedPages();
|
|
const show = window.forgeDocState.previewMode && pages.length > 1;
|
|
pill.classList.toggle('is-visible', show);
|
|
if (!show) return;
|
|
const index = pages.findIndex((p) => p.id === window.forgeDocState.activePageId);
|
|
document.getElementById('docPageNavLabel').textContent = `Page ${index + 1} / ${pages.length}`;
|
|
document.getElementById('docPagePrevBtn').disabled = index <= 0;
|
|
document.getElementById('docPageNextBtn').disabled = index >= pages.length - 1;
|
|
}
|
|
|
|
function forgeDocSwitchPage(pageId) {
|
|
if (pageId === window.forgeDocState.activePageId) return Promise.resolve();
|
|
window.forgeDocState.activePageId = pageId;
|
|
window.forgeDocState.undoStack = [];
|
|
window.forgeDocState.redoStack = [];
|
|
forgeDocUpdateHistoryButtons();
|
|
window.forgeDocState.selectedId = null;
|
|
forgeDocRenderProps(null);
|
|
forgeDocRenderPageTabs();
|
|
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;
|
|
forgeDocRenderPageTabs();
|
|
});
|
|
}
|
|
|
|
function forgeDocDeletePage(pageId) {
|
|
// Filet de sécurité client (en plus du refus serveur, voir
|
|
// routes/document/document_page_delete.py) : jamais même tenter de
|
|
// supprimer la dernière page restante.
|
|
if (window.forgeDocState.pages.length <= 1) return Promise.resolve();
|
|
return forgeDocApiPageDelete(pageId).then((result) => {
|
|
if (result.error) {
|
|
alert(result.error);
|
|
return Promise.resolve();
|
|
}
|
|
window.forgeDocState.pages = window.forgeDocState.pages.filter((p) => p.id !== pageId);
|
|
if (window.forgeDocState.activePageId === pageId) {
|
|
return forgeDocSwitchPage(forgeDocOrderedPages()[0].id);
|
|
}
|
|
forgeDocRenderPageTabs();
|
|
return Promise.resolve();
|
|
});
|
|
}
|
|
|
|
function forgeDocSelectAllText(el) {
|
|
const range = document.createRange();
|
|
range.selectNodeContents(el);
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
|
|
function forgeDocStartRenamingPageTab(titleEl) {
|
|
const tab = titleEl.closest('.docPageTab');
|
|
const pageId = Number(tab.dataset.pageId);
|
|
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 forgeDocRenderPageTabs() {
|
|
const container = document.getElementById('docPageTabs');
|
|
if (!container) return;
|
|
const pages = forgeDocOrderedPages();
|
|
container.classList.toggle('docPageTabs--single', pages.length <= 1);
|
|
const tabsHtml = pages.map((p) => `
|
|
<button type="button"
|
|
class="docPageTab ${p.id === window.forgeDocState.activePageId ? 'is-active' : ''}"
|
|
data-page-id="${p.id}"
|
|
draggable="true">
|
|
<span class="docPageTabTitle">${forgeDocEscapeHtml(p.title)}</span>
|
|
<span class="docPageTabDelete" data-page-id="${p.id}" title="Supprimer cette page">✕</span>
|
|
</button>
|
|
`).join('');
|
|
container.innerHTML = `${tabsHtml}<button type="button" class="docPageTabAdd" id="docPageTabAdd" aria-label="Ajouter une page" title="Ajouter une page">+</button>`;
|
|
|
|
container.querySelectorAll('.docPageTab').forEach((tab) => {
|
|
tab.addEventListener('click', (e) => {
|
|
if (e.target.classList.contains('docPageTabDelete')) return;
|
|
forgeDocSwitchPage(Number(tab.dataset.pageId));
|
|
});
|
|
});
|
|
container.querySelectorAll('.docPageTabTitle').forEach((titleEl) => {
|
|
titleEl.addEventListener('dblclick', (e) => {
|
|
e.stopPropagation();
|
|
forgeDocStartRenamingPageTab(titleEl);
|
|
});
|
|
});
|
|
container.querySelectorAll('.docPageTabDelete').forEach((btn) => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
forgeDocDeletePage(Number(btn.dataset.pageId));
|
|
});
|
|
});
|
|
document.getElementById('docPageTabAdd').addEventListener('click', () => forgeDocAddPage());
|
|
}
|
|
|
|
function forgeDocBindPageTabsDrag() {
|
|
// Délégation sur le CONTENEUR (jamais les onglets eux-mêmes, recréés à
|
|
// chaque forgeDocRenderPageTabs) : un seul jeu de listeners pour toute
|
|
// la session, même principe que forgeDocMotsBindGlobalMouseup.
|
|
const container = document.getElementById('docPageTabs');
|
|
let draggedId = null;
|
|
|
|
container.addEventListener('dragstart', (e) => {
|
|
const tab = e.target.closest('.docPageTab');
|
|
if (!tab) return;
|
|
draggedId = Number(tab.dataset.pageId);
|
|
tab.classList.add('is-dragging');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
container.addEventListener('dragend', (e) => {
|
|
const tab = e.target.closest('.docPageTab');
|
|
if (tab) tab.classList.remove('is-dragging');
|
|
});
|
|
container.addEventListener('dragover', (e) => {
|
|
if (draggedId !== null) e.preventDefault();
|
|
});
|
|
container.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
if (draggedId === null) return;
|
|
const targetTab = e.target.closest('.docPageTab');
|
|
const ordered = forgeDocOrderedPages();
|
|
const newIndex = targetTab
|
|
? ordered.findIndex((p) => p.id === Number(targetTab.dataset.pageId))
|
|
: ordered.length - 1;
|
|
const movedId = draggedId;
|
|
draggedId = null;
|
|
forgeDocApiPageMove(movedId, newIndex).then(() => {
|
|
const moved = window.forgeDocState.pages.find((p) => p.id === movedId);
|
|
const rest = window.forgeDocState.pages
|
|
.filter((p) => p.id !== movedId)
|
|
.sort((a, b) => a.order_index - b.order_index);
|
|
rest.splice(newIndex, 0, moved);
|
|
rest.forEach((p, i) => { p.order_index = i; });
|
|
window.forgeDocState.pages = rest;
|
|
forgeDocRenderPageTabs();
|
|
});
|
|
});
|
|
}
|
|
|
|
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);
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Formes libres — position absolue, glissées à la souris (pas de HTML5
|
|
* DnD, pas de logique de rangée) avec grille d'accroche réelle.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
// Même raison que FORGE_DOC_STYLE_PRESETS en tête de fichier.
|
|
// eslint-disable-next-line no-var, vars-on-top
|
|
var FORGE_DOC_SNAP_SIZE = 8;
|
|
|
|
function forgeDocBindShapeDrag(dom, elementId) {
|
|
dom.addEventListener('mousedown', (e) => {
|
|
if (window.forgeDocState.previewMode) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
forgeDocSelectElement(elementId);
|
|
const el = window.forgeDocState.elementsById[elementId];
|
|
window.forgeDocState.shapeDrag = {
|
|
id: elementId,
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
origX: el.attributes.x,
|
|
origY: el.attributes.y,
|
|
};
|
|
document.getElementById('docPage').classList.add('docPage--dragging-shape');
|
|
});
|
|
}
|
|
|
|
function forgeDocBindShapeDragGlobalHandlers() {
|
|
document.addEventListener('mousemove', (e) => {
|
|
const drag = window.forgeDocState.shapeDrag;
|
|
if (!drag) return;
|
|
const zoomFactor = window.forgeDocState.zoom / 100;
|
|
const dx = (e.clientX - drag.startX) / zoomFactor;
|
|
const dy = (e.clientY - drag.startY) / zoomFactor;
|
|
const newX = Math.round((drag.origX + dx) / FORGE_DOC_SNAP_SIZE) * FORGE_DOC_SNAP_SIZE;
|
|
const newY = Math.round((drag.origY + dy) / FORGE_DOC_SNAP_SIZE) * FORGE_DOC_SNAP_SIZE;
|
|
const dom = document.querySelector(`[data-element-id="${drag.id}"]`);
|
|
if (dom) { dom.style.left = `${newX}px`; dom.style.top = `${newY}px`; }
|
|
drag.lastX = newX;
|
|
drag.lastY = newY;
|
|
});
|
|
document.addEventListener('mouseup', () => {
|
|
const drag = window.forgeDocState.shapeDrag;
|
|
if (!drag) return;
|
|
window.forgeDocState.shapeDrag = null;
|
|
document.getElementById('docPage').classList.remove('docPage--dragging-shape');
|
|
if (typeof drag.lastX === 'number' && (drag.lastX !== drag.origX || drag.lastY !== drag.origY)) {
|
|
const el = window.forgeDocState.elementsById[drag.id];
|
|
forgeDocUpdateAttributes(drag.id, { ...el.attributes, x: drag.lastX, y: drag.lastY });
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Sélection & panneau Propriétés.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocBindCanvasInteractions() {
|
|
document.querySelectorAll('#docPage [data-element-id]').forEach((dom) => {
|
|
const elementId = Number.parseInt(dom.dataset.elementId, 10);
|
|
dom.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
forgeDocSelectElement(elementId);
|
|
});
|
|
if (forgeDocIsShapeKind(dom.dataset.kind)) {
|
|
forgeDocBindShapeDrag(dom, elementId);
|
|
} else {
|
|
dom.setAttribute('draggable', 'true');
|
|
dom.addEventListener('dragstart', (e) => {
|
|
e.dataTransfer.setData('text/forge-doc-move', String(elementId));
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
}
|
|
});
|
|
forgeDocInitQuizPlayers();
|
|
forgeDocInitAssociationPlayers();
|
|
forgeDocInitMemoryPlayers();
|
|
forgeDocInitMotsPlayers();
|
|
forgeDocInitScenarioPlayers();
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Quiz — questionnaire RÉELLEMENT interactif en Mode Aperçu (voir
|
|
* docs/plan/maquettes/document-formation-web.html, référence visuelle).
|
|
* État tenu en mémoire (jamais persisté — une session d'Aperçu se
|
|
* réinitialise à chaque rafraîchissement du canevas, exactement comme le
|
|
* reste de l'éditeur).
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocQuizPlayerQuestions(playerEl) {
|
|
try {
|
|
const config = JSON.parse(playerEl.dataset.quizConfig || '{}');
|
|
return Array.isArray(config.questions) ? config.questions : [];
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
function forgeDocRenderQuizPlayerQuestion(playerEl, state) {
|
|
state.answered = false;
|
|
const item = state.questions[state.current];
|
|
const textEl = playerEl.querySelector('.docQuizQuestionText');
|
|
const optionsEl = playerEl.querySelector('.docQuizOptions');
|
|
const feedbackEl = playerEl.querySelector('.docQuizFeedback');
|
|
const nextBtn = playerEl.querySelector('.docQuizNextBtn');
|
|
const fillEl = playerEl.querySelector('.docQuizProgressFill');
|
|
const progEl = playerEl.querySelector('.docQuizProg');
|
|
const scoreEl = playerEl.querySelector('.docQuizScore');
|
|
const letters = ['A', 'B', 'C', 'D'];
|
|
|
|
textEl.textContent = item.text;
|
|
optionsEl.innerHTML = '';
|
|
feedbackEl.className = 'docQuizFeedback';
|
|
feedbackEl.textContent = '';
|
|
nextBtn.classList.remove('is-visible');
|
|
item.choices.forEach((choice, idx) => {
|
|
const btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'docQuizOption';
|
|
btn.innerHTML = `<span class="docQuizOptionLetter">${letters[idx]}</span><span></span>`;
|
|
btn.querySelector('span:last-child').textContent = choice;
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
forgeDocQuizPlayerAnswer(playerEl, state, idx, btn);
|
|
});
|
|
optionsEl.appendChild(btn);
|
|
});
|
|
fillEl.style.width = `${(state.current / state.questions.length) * 100}%`;
|
|
progEl.textContent = `Question ${state.current + 1} sur ${state.questions.length}`;
|
|
scoreEl.textContent = `Score : ${state.score}`;
|
|
}
|
|
|
|
function forgeDocQuizPlayerAnswer(playerEl, state, idx, btn) {
|
|
if (state.answered) return;
|
|
state.answered = true;
|
|
const item = state.questions[state.current];
|
|
const options = playerEl.querySelectorAll('.docQuizOption');
|
|
options.forEach((o) => { o.disabled = true; });
|
|
const feedbackEl = playerEl.querySelector('.docQuizFeedback');
|
|
const nextBtn = playerEl.querySelector('.docQuizNextBtn');
|
|
const scoreEl = playerEl.querySelector('.docQuizScore');
|
|
if (idx === item.correct_index) {
|
|
btn.classList.add('is-correct');
|
|
state.score += item.points || 0;
|
|
feedbackEl.classList.add('is-visible', 'is-ok');
|
|
feedbackEl.textContent = 'Bonne réponse !';
|
|
} else {
|
|
btn.classList.add('is-wrong');
|
|
options[item.correct_index].classList.add('is-correct');
|
|
feedbackEl.classList.add('is-visible', 'is-ko');
|
|
feedbackEl.textContent = 'Mauvaise réponse.';
|
|
}
|
|
scoreEl.textContent = `Score : ${state.score}`;
|
|
nextBtn.classList.add('is-visible');
|
|
nextBtn.textContent = state.current === state.questions.length - 1 ? 'Voir le résultat' : 'Question suivante';
|
|
}
|
|
|
|
function forgeDocQuizPlayerTotalPoints(state) {
|
|
return state.questions.reduce((sum, q) => sum + (q.points || 0), 0);
|
|
}
|
|
|
|
function forgeDocShowQuizPlayerResult(playerEl, state) {
|
|
playerEl.querySelector('.docQuizCard').style.display = 'none';
|
|
const resultCard = playerEl.querySelector('.docQuizResultCard');
|
|
resultCard.style.display = 'block';
|
|
playerEl.querySelector('.docQuizProgressFill').style.width = '100%';
|
|
playerEl.querySelector('.docQuizResultBig').textContent = `${state.score} / ${forgeDocQuizPlayerTotalPoints(state)}`;
|
|
playerEl.querySelector('.docQuizResultSub').textContent = 'points obtenus';
|
|
}
|
|
|
|
function forgeDocQuizPlayerNext(playerEl, state) {
|
|
state.current += 1;
|
|
if (state.current < state.questions.length) {
|
|
forgeDocRenderQuizPlayerQuestion(playerEl, state);
|
|
} else {
|
|
forgeDocShowQuizPlayerResult(playerEl, state);
|
|
}
|
|
}
|
|
|
|
function forgeDocQuizPlayerRestart(playerEl, state) {
|
|
state.current = 0;
|
|
state.score = 0;
|
|
playerEl.querySelector('.docQuizResultCard').style.display = 'none';
|
|
playerEl.querySelector('.docQuizCard').style.display = 'block';
|
|
forgeDocRenderQuizPlayerQuestion(playerEl, state);
|
|
}
|
|
|
|
function forgeDocInitQuizPlayers() {
|
|
document.querySelectorAll('.docQuizPlayer').forEach((playerEl) => {
|
|
const questions = forgeDocQuizPlayerQuestions(playerEl);
|
|
if (!questions.length) return;
|
|
const state = {
|
|
current: 0, score: 0, answered: false, questions,
|
|
};
|
|
forgeDocRenderQuizPlayerQuestion(playerEl, state);
|
|
playerEl.querySelector('.docQuizNextBtn').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
forgeDocQuizPlayerNext(playerEl, state);
|
|
});
|
|
playerEl.querySelector('.docQuizRestartBtn').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
forgeDocQuizPlayerRestart(playerEl, state);
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Association — glisser-déposer RÉELLEMENT interactif en Mode Aperçu :
|
|
* relier chaque carte de gauche à sa correspondance de droite. Deux
|
|
* façons de jouer, toutes deux réelles (pas de simulation) : glisser-
|
|
* déposer natif HTML5, ou cliquer une carte puis son emplacement (pour
|
|
* les appareils sans support fiable du drag). État tenu en mémoire,
|
|
* jamais persisté.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocAssociationPlayerData(playerEl) {
|
|
try {
|
|
const config = JSON.parse(playerEl.dataset.assocConfig || '{}');
|
|
return {
|
|
left: Array.isArray(config.left) ? config.left : [],
|
|
right: Array.isArray(config.right) ? config.right : [],
|
|
};
|
|
} catch (e) {
|
|
return { left: [], right: [] };
|
|
}
|
|
}
|
|
|
|
function forgeDocAssociationMatchResult(playerEl, state, pairIndex, itemEl, slotEl, isMatch) {
|
|
const feedbackEl = playerEl.querySelector('.docAssocFeedback');
|
|
if (!isMatch) {
|
|
// "docAssocFeedback" doit rester dans la liste (c'est la classe qui
|
|
// permet de retrouver cet élément — voir querySelector ci-dessus) :
|
|
// une réaffectation de className qui ne garde que "docQuizFeedback..."
|
|
// (réutilisé pour le STYLE visuel) la supprimerait silencieusement,
|
|
// rendant l'élément introuvable dès le prochain appel (bug réel
|
|
// trouvé le 20/09/2026 via simulation DOM).
|
|
feedbackEl.className = 'docAssocFeedback docQuizFeedback is-visible is-ko';
|
|
feedbackEl.textContent = "Ce n'est pas la bonne association, réessaie.";
|
|
itemEl.classList.add('is-wrong');
|
|
slotEl.classList.add('is-wrong');
|
|
setTimeout(() => {
|
|
itemEl.classList.remove('is-wrong');
|
|
slotEl.classList.remove('is-wrong');
|
|
}, 500);
|
|
return;
|
|
}
|
|
itemEl.classList.add('is-matched');
|
|
itemEl.removeAttribute('draggable');
|
|
slotEl.classList.add('is-matched');
|
|
state.matched.add(pairIndex);
|
|
feedbackEl.className = 'docAssocFeedback docQuizFeedback is-visible is-ok';
|
|
feedbackEl.textContent = 'Bonne association !';
|
|
playerEl.querySelector('.docAssocProg').textContent = `${state.matched.size} / ${state.total} associées`;
|
|
if (state.matched.size >= state.total) {
|
|
// Le plateau reste affiché une fois le jeu terminé (voir retour
|
|
// utilisateur du 20/09/2026 : "tous les mini-jeux à part le quiz
|
|
// doivent continuer à s'afficher même une fois terminé") — seul le
|
|
// bouton Recommencer apparaît, jamais un écran de résultat séparé qui
|
|
// remplacerait le plateau (ça reste le comportement du Quiz).
|
|
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
|
|
}
|
|
}
|
|
|
|
function forgeDocRenderAssociationBoard(playerEl, state) {
|
|
const leftCol = playerEl.querySelector('.docAssocColumnLeft');
|
|
const rightCol = playerEl.querySelector('.docAssocColumnRight');
|
|
leftCol.innerHTML = '';
|
|
rightCol.innerHTML = '';
|
|
|
|
state.left.forEach((item) => {
|
|
const itemEl = document.createElement('div');
|
|
itemEl.className = 'docAssocItem';
|
|
itemEl.textContent = item.text;
|
|
itemEl.dataset.pairIndex = String(item.pair_index);
|
|
itemEl.setAttribute('draggable', 'true');
|
|
itemEl.addEventListener('dragstart', (e) => {
|
|
e.stopPropagation();
|
|
e.dataTransfer.setData('text/forge-assoc-pair', String(item.pair_index));
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
itemEl.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
if (state.matched.has(item.pair_index)) return;
|
|
if (state.selected) state.selected.classList.remove('is-selected');
|
|
state.selected = itemEl;
|
|
state.selectedPairIndex = item.pair_index;
|
|
itemEl.classList.add('is-selected');
|
|
});
|
|
leftCol.appendChild(itemEl);
|
|
});
|
|
|
|
state.right.forEach((item) => {
|
|
const slotEl = document.createElement('div');
|
|
slotEl.className = 'docAssocSlot';
|
|
slotEl.textContent = item.text;
|
|
slotEl.dataset.pairIndex = String(item.pair_index);
|
|
slotEl.addEventListener('dragover', (e) => {
|
|
if (!state.matched.has(item.pair_index)) e.preventDefault();
|
|
});
|
|
slotEl.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (state.matched.has(item.pair_index)) return;
|
|
const draggedPairIndex = Number(e.dataTransfer.getData('text/forge-assoc-pair'));
|
|
const itemEl = leftCol.querySelector(`.docAssocItem[data-pair-index="${draggedPairIndex}"]`);
|
|
if (!itemEl) return;
|
|
const isMatch = draggedPairIndex === item.pair_index;
|
|
forgeDocAssociationMatchResult(playerEl, state, item.pair_index, itemEl, slotEl, isMatch);
|
|
});
|
|
slotEl.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
if (state.matched.has(item.pair_index) || !state.selected) return;
|
|
const itemEl = state.selected;
|
|
const { selectedPairIndex } = state;
|
|
state.selected.classList.remove('is-selected');
|
|
state.selected = null;
|
|
const isMatch = selectedPairIndex === item.pair_index;
|
|
forgeDocAssociationMatchResult(playerEl, state, item.pair_index, itemEl, slotEl, isMatch);
|
|
});
|
|
rightCol.appendChild(slotEl);
|
|
});
|
|
}
|
|
|
|
function forgeDocAssociationRestart(playerEl) {
|
|
const data = forgeDocAssociationPlayerData(playerEl);
|
|
const state = {
|
|
left: data.left,
|
|
right: data.right,
|
|
matched: new Set(),
|
|
total: data.left.length,
|
|
selected: null,
|
|
selectedPairIndex: null,
|
|
};
|
|
playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
|
|
playerEl.querySelector('.docAssocFeedback').className = 'docAssocFeedback docQuizFeedback';
|
|
playerEl.querySelector('.docAssocProg').textContent = `0 / ${state.total} associées`;
|
|
forgeDocRenderAssociationBoard(playerEl, state);
|
|
}
|
|
|
|
function forgeDocInitAssociationPlayers() {
|
|
document.querySelectorAll('.docAssocPlayer').forEach((playerEl) => {
|
|
const data = forgeDocAssociationPlayerData(playerEl);
|
|
if (!data.left.length) return;
|
|
forgeDocAssociationRestart(playerEl);
|
|
playerEl.querySelector('.docAssocRestartBtn').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
forgeDocAssociationRestart(playerEl);
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Memory — retournement de cartes RÉELLEMENT interactif en Mode Aperçu :
|
|
* mode "paire" (retrouver les deux cartes identiques, classique) ou
|
|
* "single" (retourner chaque carte une fois, sans appariement). Contenu
|
|
* de chaque face construit via DOM (textContent/img.src, jamais innerHTML
|
|
* avec le texte du créateur) — même précaution que le plateau Association.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocMemoryPlayerData(playerEl) {
|
|
try {
|
|
const config = JSON.parse(playerEl.dataset.memoryConfig || '{}');
|
|
return {
|
|
mode: config.mode === 'single' ? 'single' : 'paire',
|
|
cards: Array.isArray(config.cards) ? config.cards : [],
|
|
};
|
|
} catch (e) {
|
|
return { mode: 'paire', cards: [] };
|
|
}
|
|
}
|
|
|
|
function forgeDocShuffleArray(array) {
|
|
const copy = [...array];
|
|
for (let i = copy.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[copy[i], copy[j]] = [copy[j], copy[i]];
|
|
}
|
|
return copy;
|
|
}
|
|
|
|
function forgeDocFillMemoryCardFace(container, face) {
|
|
container.innerHTML = '';
|
|
if (face.image) {
|
|
const img = document.createElement('img');
|
|
img.src = face.image;
|
|
img.alt = '';
|
|
container.appendChild(img);
|
|
}
|
|
if (face.text) {
|
|
const span = document.createElement('span');
|
|
span.textContent = face.text;
|
|
container.appendChild(span);
|
|
}
|
|
if (!face.image && !face.text) {
|
|
const glyph = document.createElement('span');
|
|
glyph.className = 'docMemoryCardBackGlyph';
|
|
glyph.textContent = '?';
|
|
container.appendChild(glyph);
|
|
}
|
|
}
|
|
|
|
function forgeDocBuildMemoryCard(cardData, index) {
|
|
const cardEl = document.createElement('div');
|
|
cardEl.className = 'docMemoryCard';
|
|
cardEl.dataset.instanceIndex = String(index);
|
|
cardEl.dataset.cardIndex = String(cardData.card_index);
|
|
|
|
const inner = document.createElement('div');
|
|
inner.className = 'docMemoryCardInner';
|
|
|
|
const front = document.createElement('div');
|
|
front.className = 'docMemoryCardFace docMemoryCardFront';
|
|
forgeDocFillMemoryCardFace(front, cardData.recto);
|
|
|
|
const back = document.createElement('div');
|
|
back.className = 'docMemoryCardFace docMemoryCardBack';
|
|
forgeDocFillMemoryCardFace(back, cardData.verso);
|
|
|
|
inner.appendChild(front);
|
|
inner.appendChild(back);
|
|
cardEl.appendChild(inner);
|
|
return cardEl;
|
|
}
|
|
|
|
function forgeDocMemoryUpdateProgress(playerEl, state) {
|
|
const progEl = playerEl.querySelector('.docMemoryProg');
|
|
progEl.textContent = state.mode === 'single'
|
|
? `${state.revealed} / ${state.total} cartes retournées`
|
|
: `${state.matched} / ${state.totalPairs} paires trouvées`;
|
|
}
|
|
|
|
function forgeDocMemoryCheckComplete(playerEl, state) {
|
|
const done = state.mode === 'single' ? state.revealed >= state.total : state.matched >= state.totalPairs;
|
|
if (!done) return;
|
|
// Le plateau reste affiché une fois le jeu terminé (voir retour
|
|
// utilisateur du 20/09/2026) — seul le bouton Recommencer apparaît,
|
|
// jamais un écran de résultat séparé (ça reste le comportement du Quiz).
|
|
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
|
|
}
|
|
|
|
function forgeDocMemoryFlip(playerEl, state, cardEl) {
|
|
if (cardEl.classList.contains('is-flipped') || cardEl.classList.contains('is-matched')) return;
|
|
if (state.mode === 'single') {
|
|
cardEl.classList.add('is-flipped', 'is-matched');
|
|
state.revealed += 1;
|
|
forgeDocMemoryUpdateProgress(playerEl, state);
|
|
forgeDocMemoryCheckComplete(playerEl, state);
|
|
return;
|
|
}
|
|
if (state.busy) return;
|
|
cardEl.classList.add('is-flipped');
|
|
state.flippedCards.push(cardEl);
|
|
if (state.flippedCards.length < 2) return;
|
|
state.busy = true;
|
|
const [first, second] = state.flippedCards;
|
|
const isMatch = first.dataset.cardIndex === second.dataset.cardIndex;
|
|
if (isMatch) {
|
|
first.classList.add('is-matched');
|
|
second.classList.add('is-matched');
|
|
state.matched += 1;
|
|
state.flippedCards = [];
|
|
state.busy = false;
|
|
forgeDocMemoryUpdateProgress(playerEl, state);
|
|
forgeDocMemoryCheckComplete(playerEl, state);
|
|
} else {
|
|
setTimeout(() => {
|
|
first.classList.remove('is-flipped');
|
|
second.classList.remove('is-flipped');
|
|
state.flippedCards = [];
|
|
state.busy = false;
|
|
}, 800);
|
|
}
|
|
}
|
|
|
|
function forgeDocMemoryRestart(playerEl) {
|
|
const data = forgeDocMemoryPlayerData(playerEl);
|
|
const shuffled = forgeDocShuffleArray(data.cards);
|
|
const totalPairs = new Set(data.cards.map((c) => c.card_index)).size;
|
|
const state = {
|
|
mode: data.mode,
|
|
total: data.cards.length,
|
|
totalPairs,
|
|
matched: 0,
|
|
revealed: 0,
|
|
flippedCards: [],
|
|
busy: false,
|
|
};
|
|
playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
|
|
playerEl.querySelector('.docMemoryTitle').textContent = state.mode === 'single'
|
|
? 'Retournez chaque carte'
|
|
: 'Retrouvez les paires';
|
|
forgeDocMemoryUpdateProgress(playerEl, state);
|
|
const grid = playerEl.querySelector('.docMemoryGrid');
|
|
grid.innerHTML = '';
|
|
shuffled.forEach((cardData, index) => {
|
|
const cardEl = forgeDocBuildMemoryCard(cardData, index);
|
|
cardEl.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
forgeDocMemoryFlip(playerEl, state, cardEl);
|
|
});
|
|
grid.appendChild(cardEl);
|
|
});
|
|
}
|
|
|
|
function forgeDocInitMemoryPlayers() {
|
|
document.querySelectorAll('.docMemoryPlayer').forEach((playerEl) => {
|
|
const data = forgeDocMemoryPlayerData(playerEl);
|
|
if (!data.cards.length) return;
|
|
forgeDocMemoryRestart(playerEl);
|
|
playerEl.querySelector('.docMemoryRestartBtn').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
forgeDocMemoryRestart(playerEl);
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Mots mêlés — grille RÉELLEMENT interactive en Mode Aperçu : la grille
|
|
* ET la position exacte de chaque mot sont calculées côté serveur (voir
|
|
* document_engine/rendering/render_document_element.py::_build_mots_grid,
|
|
* jamais recalculées ici) et embarquées en JSON. Une sélection est
|
|
* validée par comparaison de COORDONNÉES exactes avec celles d'un mot
|
|
* (jamais une comparaison de texte, qui se tromperait sur des lettres
|
|
* partagées entre deux mots qui se croisent). Deux façons de
|
|
* sélectionner, toutes deux réelles : glisser (mousedown puis survol des
|
|
* cases), ou cliquer une case de départ puis une case d'arrivée (repli
|
|
* pour les appareils sans glisser fiable — même principe que
|
|
* l'Association). État tenu en mémoire, jamais persisté.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocMotsPlayerData(playerEl) {
|
|
try {
|
|
const config = JSON.parse(playerEl.dataset.motsConfig || '{}');
|
|
return {
|
|
size: Number(config.size) || 0,
|
|
grid: Array.isArray(config.grid) ? config.grid : [],
|
|
words: Array.isArray(config.words) ? config.words : [],
|
|
};
|
|
} catch (e) {
|
|
return { size: 0, grid: [], words: [] };
|
|
}
|
|
}
|
|
|
|
function forgeDocMotsCellsBetween(start, end) {
|
|
const rowDiff = end.row - start.row;
|
|
const colDiff = end.col - start.col;
|
|
// Seules 4 directions sont autorisées à la pose (voir _build_mots_grid) :
|
|
// horizontale, verticale, ou une vraie diagonale (même distance en
|
|
// ligne qu'en colonne) — tout le reste n'est pas une sélection valide.
|
|
if (rowDiff !== 0 && colDiff !== 0 && Math.abs(rowDiff) !== Math.abs(colDiff)) return null;
|
|
const steps = Math.max(Math.abs(rowDiff), Math.abs(colDiff));
|
|
const stepRow = steps === 0 ? 0 : rowDiff / steps;
|
|
const stepCol = steps === 0 ? 0 : colDiff / steps;
|
|
const cells = [];
|
|
for (let i = 0; i <= steps; i += 1) {
|
|
cells.push({ row: start.row + stepRow * i, col: start.col + stepCol * i });
|
|
}
|
|
return cells;
|
|
}
|
|
|
|
function forgeDocMotsCellsMatchWord(cells, wordCells) {
|
|
if (cells.length !== wordCells.length) return false;
|
|
const forward = cells.every((c, i) => c.row === wordCells[i][0] && c.col === wordCells[i][1]);
|
|
if (forward) return true;
|
|
// L'apprenant peut glisser dans les deux sens d'un même mot placé
|
|
// (départ->fin ou fin->départ) : seul le SENS DE POSE (voir
|
|
// _build_mots_grid, jamais à l'envers) est contraint, pas le sens de
|
|
// lecture de la sélection.
|
|
const reversed = [...wordCells].reverse();
|
|
return cells.every((c, i) => c.row === reversed[i][0] && c.col === reversed[i][1]);
|
|
}
|
|
|
|
function forgeDocMotsUpdateProgress(playerEl, state) {
|
|
playerEl.querySelector('.docMotsProg').textContent = `${state.found.size} / ${state.words.length} mots trouvés`;
|
|
}
|
|
|
|
function forgeDocMotsCheckComplete(playerEl, state) {
|
|
if (state.found.size < state.words.length) return;
|
|
// Même choix que l'Association/Memory ci-dessus : la grille reste
|
|
// affichée une fois tous les mots trouvés, seul ce bouton apparaît.
|
|
playerEl.querySelector('.docMinigameRestartBar').classList.add('is-visible');
|
|
}
|
|
|
|
function forgeDocMotsClearSelection(state) {
|
|
state.selectedCellEls.forEach((cellEl) => cellEl.classList.remove('is-selecting'));
|
|
state.selectedCellEls = [];
|
|
}
|
|
|
|
function forgeDocMotsHighlightPath(state, start, end) {
|
|
forgeDocMotsClearSelection(state);
|
|
const cells = forgeDocMotsCellsBetween(start, end);
|
|
if (!cells) return;
|
|
cells.forEach(({ row, col }) => {
|
|
const cellEl = state.cellEls[row]?.[col];
|
|
if (cellEl) {
|
|
cellEl.classList.add('is-selecting');
|
|
state.selectedCellEls.push(cellEl);
|
|
}
|
|
});
|
|
}
|
|
|
|
function forgeDocMotsTryMatch(playerEl, state, cells) {
|
|
const matchIndex = state.words.findIndex((w, i) => !state.found.has(i) && forgeDocMotsCellsMatchWord(cells, w.cells));
|
|
forgeDocMotsClearSelection(state);
|
|
if (matchIndex === -1) return;
|
|
state.found.add(matchIndex);
|
|
cells.forEach(({ row, col }) => {
|
|
const cellEl = state.cellEls[row]?.[col];
|
|
if (cellEl) cellEl.classList.add('is-found');
|
|
});
|
|
const chip = playerEl.querySelector(`.docMotsWordChip[data-word-index="${matchIndex}"]`);
|
|
if (chip) chip.classList.add('is-found');
|
|
forgeDocMotsUpdateProgress(playerEl, state);
|
|
forgeDocMotsCheckComplete(playerEl, state);
|
|
}
|
|
|
|
function forgeDocMotsFinishSelection(playerEl, state) {
|
|
state.dragging = false;
|
|
if (!state.start || !state.current) {
|
|
state.start = null;
|
|
state.current = null;
|
|
return;
|
|
}
|
|
const cells = forgeDocMotsCellsBetween(state.start, state.current);
|
|
state.start = null;
|
|
state.current = null;
|
|
if (!cells) {
|
|
forgeDocMotsClearSelection(state);
|
|
return;
|
|
}
|
|
forgeDocMotsTryMatch(playerEl, state, cells);
|
|
}
|
|
|
|
let forgeDocMotsGlobalMouseupBound = false;
|
|
|
|
function forgeDocMotsBindGlobalMouseup() {
|
|
// Un SEUL listener document-level pour toute la page (jamais un par
|
|
// joueur/par restart) : forgeDocInitMotsPlayers est réappelé à chaque
|
|
// rafraîchissement du canevas (voir forgeDocBindCanvasInteractions),
|
|
// un listener posé à chaque appel s'accumulerait indéfiniment sur
|
|
// `document`, qui n'est lui jamais recréé.
|
|
if (forgeDocMotsGlobalMouseupBound) return;
|
|
forgeDocMotsGlobalMouseupBound = true;
|
|
document.addEventListener('mouseup', () => {
|
|
document.querySelectorAll('.docMotsPlayer').forEach((playerEl) => {
|
|
const state = playerEl.forgeDocMotsState;
|
|
if (state && state.dragging) forgeDocMotsFinishSelection(playerEl, state);
|
|
});
|
|
});
|
|
}
|
|
|
|
function forgeDocRenderMotsBoard(playerEl, state) {
|
|
const gridEl = playerEl.querySelector('.docMotsGrid');
|
|
const listEl = playerEl.querySelector('.docMotsWordList');
|
|
gridEl.innerHTML = '';
|
|
gridEl.style.gridTemplateColumns = `repeat(${state.size}, 1fr)`;
|
|
state.cellEls = [];
|
|
for (let r = 0; r < state.size; r += 1) {
|
|
const rowEls = [];
|
|
for (let c = 0; c < state.size; c += 1) {
|
|
const cellEl = document.createElement('div');
|
|
cellEl.className = 'docMotsCell';
|
|
cellEl.textContent = state.grid[r][c];
|
|
cellEl.addEventListener('mousedown', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
state.dragging = true;
|
|
state.start = { row: r, col: c };
|
|
state.current = { row: r, col: c };
|
|
forgeDocMotsHighlightPath(state, state.start, state.current);
|
|
});
|
|
cellEl.addEventListener('mouseenter', () => {
|
|
if (!state.dragging || !state.start) return;
|
|
state.current = { row: r, col: c };
|
|
forgeDocMotsHighlightPath(state, state.start, state.current);
|
|
});
|
|
cellEl.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
if (!state.start) {
|
|
state.start = { row: r, col: c };
|
|
state.current = { row: r, col: c };
|
|
forgeDocMotsHighlightPath(state, state.start, state.current);
|
|
return;
|
|
}
|
|
state.current = { row: r, col: c };
|
|
forgeDocMotsFinishSelection(playerEl, state);
|
|
});
|
|
rowEls.push(cellEl);
|
|
gridEl.appendChild(cellEl);
|
|
}
|
|
state.cellEls.push(rowEls);
|
|
}
|
|
|
|
listEl.innerHTML = '';
|
|
state.words.forEach((w, i) => {
|
|
const chip = document.createElement('span');
|
|
chip.className = `docMotsWordChip${state.found.has(i) ? ' is-found' : ''}`;
|
|
chip.dataset.wordIndex = String(i);
|
|
chip.textContent = w.text;
|
|
listEl.appendChild(chip);
|
|
});
|
|
}
|
|
|
|
function forgeDocMotsRestart(playerEl) {
|
|
const data = forgeDocMotsPlayerData(playerEl);
|
|
const state = {
|
|
size: data.size,
|
|
grid: data.grid,
|
|
words: data.words,
|
|
found: new Set(),
|
|
start: null,
|
|
current: null,
|
|
dragging: false,
|
|
cellEls: [],
|
|
selectedCellEls: [],
|
|
};
|
|
playerEl.forgeDocMotsState = state;
|
|
playerEl.querySelector('.docMinigameRestartBar').classList.remove('is-visible');
|
|
forgeDocMotsUpdateProgress(playerEl, state);
|
|
forgeDocRenderMotsBoard(playerEl, state);
|
|
}
|
|
|
|
function forgeDocInitMotsPlayers() {
|
|
forgeDocMotsBindGlobalMouseup();
|
|
document.querySelectorAll('.docMotsPlayer').forEach((playerEl) => {
|
|
const data = forgeDocMotsPlayerData(playerEl);
|
|
if (!data.words.length) return;
|
|
forgeDocMotsRestart(playerEl);
|
|
playerEl.querySelector('.docMotsRestartBtn').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
forgeDocMotsRestart(playerEl);
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Scénario — mise en situation RÉELLEMENT interactive en Mode Aperçu :
|
|
* 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('.docScenarioPlayer').forEach((playerEl) => {
|
|
const scenarios = forgeDocScenarioPlayerData(playerEl);
|
|
if (!scenarios.length || !scenarios[0].nodes || !scenarios[0].nodes.length) return;
|
|
const state = { current: 0, scenarios, currentNodeId: scenarios[0].nodes[0].id };
|
|
forgeDocRenderScenarioNode(playerEl, state);
|
|
playerEl.querySelector('.docScenarioNextBtn').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
forgeDocScenarioPlayerNext(playerEl, state);
|
|
});
|
|
playerEl.querySelector('.docScenarioRestartBtn').addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
forgeDocScenarioRestart(playerEl, state);
|
|
});
|
|
});
|
|
}
|
|
|
|
function forgeDocSetActiveBottomNav(target) {
|
|
document.querySelectorAll('#docBottomNav button').forEach((b) => {
|
|
b.classList.toggle('is-active', b.dataset.target === target);
|
|
});
|
|
}
|
|
|
|
function forgeDocSelectElement(id) {
|
|
window.forgeDocState.selectedId = id;
|
|
forgeDocApplySelectionClass();
|
|
forgeDocRenderProps(window.forgeDocState.elementsById[id]);
|
|
if (window.innerWidth <= 900) {
|
|
document.getElementById('docSidebarLeft').classList.remove('is-open');
|
|
document.getElementById('docSidebarRight').classList.add('is-open');
|
|
forgeDocSetActiveBottomNav('right');
|
|
}
|
|
}
|
|
|
|
function forgeDocPropsHeader(label) {
|
|
return `<div class="docSectionLabel">Propriétés — ${label}</div>`;
|
|
}
|
|
|
|
function forgeDocDeleteButtonHtml() {
|
|
return '<button type="button" class="docDeleteBtn" id="docDeletePropBtn">🗑 Supprimer cet élément</button>';
|
|
}
|
|
|
|
function forgeDocRenderTextProps(panel, el) {
|
|
const a = el.attributes;
|
|
panel.innerHTML = `
|
|
${forgeDocPropsHeader('texte')}
|
|
<div class="docField">
|
|
<span class="docFieldLabel">Style</span>
|
|
<div class="docSegmented" id="docStyleSeg">
|
|
${Object.keys(FORGE_DOC_STYLE_PRESETS).map((key) => `<button type="button" class="docSegBtn ${a.style === key ? 'is-active' : ''}" data-style="${key}">${FORGE_DOC_STYLE_PRESETS[key]}</button>`).join('')}
|
|
</div>
|
|
</div>
|
|
<div class="docField"><label for="docContentInput">Contenu</label><textarea id="docContentInput" rows="3">${a.content}</textarea></div>
|
|
<div class="docField">
|
|
<span class="docFieldLabel">Mise en forme</span>
|
|
<div class="docToolbarRow">
|
|
<button type="button" class="docFmtBtn ${a.bold ? 'is-active' : ''}" id="docBoldBtn" title="Gras">B</button>
|
|
<button type="button" class="docFmtBtn docFmtBtn--italic ${a.italic ? 'is-active' : ''}" id="docItalicBtn" title="Italique">I</button>
|
|
<button type="button" class="docFmtBtn docFmtBtn--underline ${a.underline ? 'is-active' : ''}" id="docUnderlineBtn" title="Souligné">U</button>
|
|
</div>
|
|
</div>
|
|
<div class="docField">
|
|
<span class="docFieldLabel">Alignement</span>
|
|
<div class="docToolbarRow" id="docAlignRow">
|
|
<button type="button" class="docFmtBtn ${a.align === 'left' ? 'is-active' : ''}" data-align="left">◧</button>
|
|
<button type="button" class="docFmtBtn ${a.align === 'center' ? 'is-active' : ''}" data-align="center">▣</button>
|
|
<button type="button" class="docFmtBtn ${a.align === 'right' ? 'is-active' : ''}" data-align="right">◨</button>
|
|
</div>
|
|
</div>
|
|
<div class="docField">
|
|
<span class="docFieldLabel">Couleur du texte</span>
|
|
<div class="docSwatches" id="docColorSwatches">
|
|
${['var(--doc-text)', 'var(--doc-muted)', '#ff5f2e', '#ffb020'].map((c) => `<div class="docSwatch ${a.color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c.replace('doc-', 'forge-').replace('var(--forge-text)', 'var(--forge-text)')};"></div>`).join('')}
|
|
</div>
|
|
</div>
|
|
${forgeDocDeleteButtonHtml()}
|
|
`;
|
|
|
|
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
|
|
|
document.getElementById('docContentInput').addEventListener('change', (e) => patch({ content: e.target.value }));
|
|
panel.querySelectorAll('#docStyleSeg .docSegBtn').forEach((btn) => {
|
|
btn.addEventListener('click', () => patch({ style: btn.dataset.style }));
|
|
});
|
|
document.getElementById('docBoldBtn').addEventListener('click', () => patch({ bold: !a.bold }));
|
|
document.getElementById('docItalicBtn').addEventListener('click', () => patch({ italic: !a.italic }));
|
|
document.getElementById('docUnderlineBtn').addEventListener('click', () => patch({ underline: !a.underline }));
|
|
panel.querySelectorAll('#docAlignRow [data-align]').forEach((btn) => {
|
|
btn.addEventListener('click', () => patch({ align: btn.dataset.align }));
|
|
});
|
|
panel.querySelectorAll('#docColorSwatches .docSwatch').forEach((sw) => {
|
|
sw.addEventListener('click', () => patch({ color: sw.dataset.color }));
|
|
});
|
|
}
|
|
|
|
function forgeDocRenderShapeProps(panel, el) {
|
|
const a = el.attributes;
|
|
panel.innerHTML = `
|
|
${forgeDocPropsHeader('forme')}
|
|
<div class="docField"><label for="docShapeLabel">Libellé</label><input type="text" id="docShapeLabel" value="${a.label || ''}"></div>
|
|
<div class="docFieldRow"><label for="docShapeX">Position X</label><input type="number" id="docShapeX" value="${a.x}" style="width:90px;"></div>
|
|
<div class="docFieldRow"><label for="docShapeY">Position Y</label><input type="number" id="docShapeY" value="${a.y}" style="width:90px;"></div>
|
|
<div class="docFieldRow"><label for="docShapeWidth">Largeur</label><input type="number" id="docShapeWidth" value="${a.width}" style="width:90px;"></div>
|
|
<div class="docFieldRow"><label for="docShapeHeight">Hauteur</label><input type="number" id="docShapeHeight" value="${a.height}" style="width:90px;"></div>
|
|
<div class="docFieldRow"><label for="docShapeRotation">Rotation (°)</label><input type="number" id="docShapeRotation" value="${a.rotation}" style="width:90px;"></div>
|
|
<div class="docFieldRow"><label for="docShapeZ">Ordre (z-index)</label><input type="number" id="docShapeZ" value="${a.z_index}" style="width:90px;"></div>
|
|
<div class="docFieldRow"><label for="docShapeFill">Couleur de remplissage</label><input type="color" id="docShapeFill" value="${a.fill}"></div>
|
|
<div class="docFieldRow"><label for="docShapeStroke">Couleur de bordure</label><input type="color" id="docShapeStroke" value="${a.stroke}"></div>
|
|
<div class="docFieldRow"><label for="docShapeStrokeWidth">Épaisseur de bordure</label><input type="number" id="docShapeStrokeWidth" value="${a.stroke_width}" style="width:90px;"></div>
|
|
${forgeDocDeleteButtonHtml()}
|
|
`;
|
|
|
|
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
|
|
|
document.getElementById('docShapeLabel').addEventListener('change', (e) => patch({ label: e.target.value }));
|
|
document.getElementById('docShapeX').addEventListener('change', (e) => patch({ x: Number(e.target.value) }));
|
|
document.getElementById('docShapeY').addEventListener('change', (e) => patch({ y: Number(e.target.value) }));
|
|
document.getElementById('docShapeWidth').addEventListener('change', (e) => patch({ width: Number(e.target.value) }));
|
|
document.getElementById('docShapeHeight').addEventListener('change', (e) => patch({ height: Number(e.target.value) }));
|
|
document.getElementById('docShapeRotation').addEventListener('change', (e) => patch({ rotation: Number(e.target.value) }));
|
|
document.getElementById('docShapeZ').addEventListener('change', (e) => patch({ z_index: Number(e.target.value) }));
|
|
document.getElementById('docShapeFill').addEventListener('input', (e) => patch({ fill: e.target.value }));
|
|
document.getElementById('docShapeStroke').addEventListener('input', (e) => patch({ stroke: e.target.value }));
|
|
document.getElementById('docShapeStrokeWidth').addEventListener('change', (e) => patch({ stroke_width: Number(e.target.value) }));
|
|
}
|
|
|
|
function forgeDocRenderImageProps(panel, el) {
|
|
const a = el.attributes;
|
|
panel.innerHTML = `
|
|
${forgeDocPropsHeader('image')}
|
|
<div class="docField"><label for="docImageSrc">URL de l'image</label><input type="text" id="docImageSrc" value="${a.src || ''}" placeholder="https://..."></div>
|
|
<div class="docField"><label for="docImageAlt">Texte alternatif</label><input type="text" id="docImageAlt" value="${a.alt || ''}"></div>
|
|
${forgeDocDeleteButtonHtml()}
|
|
`;
|
|
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
|
document.getElementById('docImageSrc').addEventListener('change', (e) => patch({ src: e.target.value.trim() }));
|
|
document.getElementById('docImageAlt').addEventListener('change', (e) => patch({ alt: e.target.value }));
|
|
}
|
|
|
|
function forgeDocRenderButtonProps(panel, el) {
|
|
const a = el.attributes;
|
|
panel.innerHTML = `
|
|
${forgeDocPropsHeader('bouton')}
|
|
<div class="docField"><label for="docButtonLabel">Texte du bouton</label><input type="text" id="docButtonLabel" value="${a.label || ''}"></div>
|
|
<div class="docField"><label for="docButtonTarget">Cible (URL ou ancre)</label><input type="text" id="docButtonTarget" value="${a.target || ''}"></div>
|
|
${forgeDocDeleteButtonHtml()}
|
|
`;
|
|
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
|
document.getElementById('docButtonLabel').addEventListener('change', (e) => patch({ label: e.target.value }));
|
|
document.getElementById('docButtonTarget').addEventListener('change', (e) => patch({ target: e.target.value.trim() }));
|
|
}
|
|
|
|
function forgeDocRenderRowProps(panel, el) {
|
|
const a = el.attributes;
|
|
panel.innerHTML = `
|
|
${forgeDocPropsHeader('rangée')}
|
|
<div class="docFieldRow"><label for="docRowGap">Espacement (px)</label><input type="number" id="docRowGap" value="${a.gap}" style="width:90px;"></div>
|
|
<div class="docField">
|
|
<span class="docFieldLabel">Alignement</span>
|
|
<div class="docSegmented" id="docRowAlign">
|
|
${['stretch', 'flex-start', 'center', 'flex-end'].map((v) => `<button type="button" class="docSegBtn ${a.align === v ? 'is-active' : ''}" data-align="${v}">${v}</button>`).join('')}
|
|
</div>
|
|
</div>
|
|
<div class="docField">
|
|
<span class="docFieldLabel">Justification</span>
|
|
<div class="docSegmented" id="docRowJustify">
|
|
${['flex-start', 'center', 'flex-end', 'space-between'].map((v) => `<button type="button" class="docSegBtn ${a.justify === v ? 'is-active' : ''}" data-justify="${v}">${v}</button>`).join('')}
|
|
</div>
|
|
</div>
|
|
${forgeDocDeleteButtonHtml()}
|
|
`;
|
|
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
|
document.getElementById('docRowGap').addEventListener('change', (e) => patch({ gap: Number(e.target.value) }));
|
|
panel.querySelectorAll('#docRowAlign .docSegBtn').forEach((btn) => btn.addEventListener('click', () => patch({ align: btn.dataset.align })));
|
|
panel.querySelectorAll('#docRowJustify .docSegBtn').forEach((btn) => btn.addEventListener('click', () => patch({ justify: btn.dataset.justify })));
|
|
}
|
|
|
|
function forgeDocRenderMinigameProps(panel, el) {
|
|
const a = el.attributes;
|
|
const label = window.FORGE_DOCUMENT.elementKindLabels[el.kind] || el.kind;
|
|
panel.innerHTML = `
|
|
${forgeDocPropsHeader(label)}
|
|
<div class="docFieldRow"><span class="docFieldLabel">Type</span><span style="font-size:13px;font-weight:700;">${label}</span></div>
|
|
<div class="docField">
|
|
<span class="docFieldLabel">Couleur du thème</span>
|
|
<div class="docSwatches" id="docMinigameColor">
|
|
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
|
|
</div>
|
|
</div>
|
|
<div style="font-size:12.5px;color:var(--doc-muted);">Formulaire de contenu détaillé à venir pour ce type d'élément.</div>
|
|
${forgeDocDeleteButtonHtml()}
|
|
`;
|
|
panel.querySelectorAll('#docMinigameColor .docSwatch').forEach((sw) => {
|
|
sw.addEventListener('click', () => forgeDocUpdateAttributes(el.id, { ...el.attributes, theme_color: sw.dataset.color }));
|
|
});
|
|
}
|
|
|
|
function forgeDocQuizNewQuestion() {
|
|
return {
|
|
text: 'Nouvelle question', choices: ['Choix 1', 'Choix 2'], correct_index: 0, points: 10,
|
|
};
|
|
}
|
|
|
|
function forgeDocRenderQuizQuestionHtml(q, qIndex) {
|
|
const choicesHtml = q.choices.map((choice, cIndex) => `
|
|
<div class="docQuizChoiceRow">
|
|
<input type="radio"
|
|
name="docQuizCorrect${qIndex}"
|
|
class="docQuizCorrectRadio"
|
|
data-question-index="${qIndex}"
|
|
data-choice-index="${cIndex}"
|
|
${cIndex === q.correct_index ? 'checked' : ''}>
|
|
<input type="text"
|
|
class="docQuizChoiceInput"
|
|
value="${choice}"
|
|
placeholder="Choix ${cIndex + 1}"
|
|
data-question-index="${qIndex}"
|
|
data-choice-index="${cIndex}">
|
|
</div>
|
|
`).join('');
|
|
return `
|
|
<div class="docQuizQuestion" data-question-index="${qIndex}">
|
|
<div class="docQuizQuestionHeader">
|
|
<span class="docFieldLabel">Question ${qIndex + 1}</span>
|
|
<button type="button" class="docQuizRemoveBtn" data-question-index="${qIndex}" title="Supprimer cette question">🗑</button>
|
|
</div>
|
|
<div class="docField">
|
|
<textarea class="docQuizTextInput" rows="2" data-question-index="${qIndex}" placeholder="Texte de la question">${q.text}</textarea>
|
|
</div>
|
|
<div class="docFieldRow">
|
|
<label>Nombre de choix</label>
|
|
<input type="number" class="docQuizChoiceCountInput" min="2" max="4" value="${q.choices.length}" data-question-index="${qIndex}" style="width:60px;">
|
|
</div>
|
|
<div class="docQuizChoicesList">${choicesHtml}</div>
|
|
<div class="docFieldRow">
|
|
<label>Points</label>
|
|
<input type="number" class="docQuizPointsInput" min="0" value="${q.points}" data-question-index="${qIndex}" style="width:70px;">
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function forgeDocRenderQuizProps(panel, el) {
|
|
const a = el.attributes;
|
|
const questions = a.questions || [];
|
|
|
|
function patch(partial) {
|
|
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
|
|
forgeDocRenderProps(window.forgeDocState.elementsById[el.id]);
|
|
});
|
|
}
|
|
|
|
panel.innerHTML = `
|
|
${forgeDocPropsHeader('quiz')}
|
|
<div class="docFieldRow">
|
|
<label class="docFieldLabel" for="docQuizTimerEnabled">Chronomètre</label>
|
|
<input type="checkbox" id="docQuizTimerEnabled" ${a.timer_enabled ? 'checked' : ''}>
|
|
</div>
|
|
${a.timer_enabled ? `
|
|
<div class="docFieldRow">
|
|
<label for="docQuizTimerSeconds">Secondes par question</label>
|
|
<input type="number" id="docQuizTimerSeconds" min="5" max="300" value="${a.timer_seconds}" style="width:70px;">
|
|
</div>` : ''}
|
|
<div class="docField">
|
|
<span class="docFieldLabel">Couleur du thème</span>
|
|
<div class="docSwatches" id="docQuizColor">
|
|
${['#ff5f2e', '#ffb020', 'var(--doc-muted)', 'var(--doc-text)'].map((c) => `<div class="docSwatch ${a.theme_color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c};"></div>`).join('')}
|
|
</div>
|
|
</div>
|
|
<div class="docSectionLabel docQuizQuestionsLabel">Questions</div>
|
|
<div id="docQuizQuestionsList">${questions.length ? questions.map(forgeDocRenderQuizQuestionHtml).join('') : '<div class="docEmptyState">Aucune question — ajoute la première ci-dessous.</div>'}</div>
|
|
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docQuizAddQuestion">+ Ajouter une question</button>
|
|
${forgeDocDeleteButtonHtml()}
|
|
`;
|
|
|
|
document.getElementById('docQuizTimerEnabled').addEventListener('change', (e) => patch({ timer_enabled: e.target.checked }));
|
|
const timerSecondsInput = document.getElementById('docQuizTimerSeconds');
|
|
if (timerSecondsInput) timerSecondsInput.addEventListener('change', (e) => patch({ timer_seconds: Number(e.target.value) }));
|
|
panel.querySelectorAll('#docQuizColor .docSwatch').forEach((sw) => {
|
|
sw.addEventListener('click', () => patch({ theme_color: sw.dataset.color }));
|
|
});
|
|
|
|
document.getElementById('docQuizAddQuestion').addEventListener('click', () => {
|
|
patch({ questions: [...questions, forgeDocQuizNewQuestion()] });
|
|
});
|
|
|
|
panel.querySelectorAll('.docQuizRemoveBtn').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const qIndex = Number(btn.dataset.questionIndex);
|
|
patch({ questions: questions.filter((_, i) => i !== qIndex) });
|
|
});
|
|
});
|
|
|
|
panel.querySelectorAll('.docQuizTextInput').forEach((input) => {
|
|
input.addEventListener('change', (e) => {
|
|
const qIndex = Number(input.dataset.questionIndex);
|
|
patch({ questions: questions.map((q, i) => (i === qIndex ? { ...q, text: e.target.value } : q)) });
|
|
});
|
|
});
|
|
|
|
panel.querySelectorAll('.docQuizChoiceCountInput').forEach((input) => {
|
|
input.addEventListener('change', (e) => {
|
|
const qIndex = Number(input.dataset.questionIndex);
|
|
const count = Math.max(2, Math.min(4, Number(e.target.value) || 2));
|
|
patch({
|
|
questions: questions.map((q, i) => {
|
|
if (i !== qIndex) return q;
|
|
const choices = q.choices.slice(0, count);
|
|
while (choices.length < count) choices.push(`Choix ${choices.length + 1}`);
|
|
return { ...q, choices, correct_index: q.correct_index < count ? q.correct_index : 0 };
|
|
}),
|
|
});
|
|
});
|
|
});
|
|
|
|
panel.querySelectorAll('.docQuizChoiceInput').forEach((input) => {
|
|
input.addEventListener('change', (e) => {
|
|
const qIndex = Number(input.dataset.questionIndex);
|
|
const cIndex = Number(input.dataset.choiceIndex);
|
|
patch({
|
|
questions: questions.map((q, i) => (
|
|
i === qIndex ? { ...q, choices: q.choices.map((c, ci) => (ci === cIndex ? e.target.value : c)) } : q
|
|
)),
|
|
});
|
|
});
|
|
});
|
|
|
|
panel.querySelectorAll('.docQuizCorrectRadio').forEach((radio) => {
|
|
radio.addEventListener('change', () => {
|
|
const qIndex = Number(radio.dataset.questionIndex);
|
|
const cIndex = Number(radio.dataset.choiceIndex);
|
|
patch({ questions: questions.map((q, i) => (i === qIndex ? { ...q, correct_index: cIndex } : q)) });
|
|
});
|
|
});
|
|
|
|
panel.querySelectorAll('.docQuizPointsInput').forEach((input) => {
|
|
input.addEventListener('change', (e) => {
|
|
const qIndex = Number(input.dataset.questionIndex);
|
|
const points = Math.max(0, Number(e.target.value) || 0);
|
|
patch({ questions: questions.map((q, i) => (i === qIndex ? { ...q, points } : q)) });
|
|
});
|
|
});
|
|
}
|
|
|
|
function forgeDocAssociationNewPair() {
|
|
// Champs pré-remplis (jamais vides) : sanitize_association_config
|
|
// (routes/document/document_element_update.py) rejette silencieusement
|
|
// toute paire dont un des deux côtés est vide — une paire vide envoyée
|
|
// telle quelle disparaîtrait donc aussitôt créée, sans aucun signal
|
|
// pour le créateur (bug réel trouvé le 20/09/2026 : "+ Ajouter une
|
|
// paire" semblait ne rien faire). Même stratégie que
|
|
// forgeDocQuizNewQuestion ci-dessus.
|
|
return { left: 'Nouvel élément', right: 'Sa correspondance' };
|
|
}
|
|
|
|
function forgeDocEscapeHtml(text) {
|
|
// Texte libre du créateur (Association/Memory), inséré comme CONTENU
|
|
// d'un <textarea> ou comme VALEUR d'attribut dans un template string
|
|
// (jamais via .value/.setAttribute, voir forgeDocRenderAssociationProps/
|
|
// forgeDocRenderMemoryProps qui construisent tout le panneau en une
|
|
// seule chaîne) — sans cet échappement, un texte contenant littéralement
|
|
// "</textarea>" ou un guillemet romprait le tag/l'attribut et tout ce
|
|
// qui suit serait interprété comme du HTML/JS brut dans SA PROPRE
|
|
// session d'édition.
|
|
return String(text)
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"');
|
|
}
|
|
|
|
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, comme les formes libres du canevas — voir
|
|
* forgeDocBindShapeDrag) ; les choix qui ont une destination sont
|
|
* dessinés comme des flèches SVG reliant les deux nœuds, étiquetées par
|
|
* le texte du choix. Sélectionner un nœud (clic) ouvre son formulaire
|
|
* d'édition (texte + choix) dans l'inspecteur (colonne de droite) —
|
|
* relier un choix à un autre nœud se fait en cliquant "Relier" puis en
|
|
* cliquant le nœud cible sur le graphe (mode "connexion", Échap annule).
|
|
* La situation initiale (nodes[0]) reste déplaçable mais jamais
|
|
* supprimable. Persistance identique aux autres panneaux : chaque
|
|
* modification envoie l'état COMPLET du scénario au serveur (voir
|
|
* forgeDocScenarioGraphPatch), jamais un état simulé côté client qui
|
|
* pourrait diverger de ce que sanitize_scenario_config a réellement
|
|
* accepté — SAUF la position pendant un glisser, mise à jour localement
|
|
* en continu pour rester fluide, et persistée en un seul appel au
|
|
* relâchement de la souris (même principe que forgeDocBindShapeDrag).
|
|
* ------------------------------------------------------------------- */
|
|
|
|
let forgeDocScenarioGraphState = null;
|
|
let forgeDocScenarioGraphDragBound = false;
|
|
|
|
function forgeDocScenarioGraphNextPosition(nodes) {
|
|
// Même formule que _scenario_node_fallback_position côté serveur
|
|
// (document_engine/labels/scenario_config.py) — pas requis que ça
|
|
// matche exactement puisque le round-trip serveur fait foi de toute
|
|
// façon, mais évite un nœud visiblement mal placé le temps de la
|
|
// réponse.
|
|
const cols = 4;
|
|
const stepX = 220;
|
|
const stepY = 160;
|
|
const margin = 40;
|
|
const index = nodes.length;
|
|
return { x: margin + (index % cols) * stepX, y: margin + Math.floor(index / cols) * stepY };
|
|
}
|
|
|
|
function forgeDocScenarioNodeLabel(nIndex) {
|
|
// Terminologie volontairement cohérente de bout en bout (retour
|
|
// utilisateur du 21/09/2026 : "il faut bien choisir les termes") :
|
|
// Situation -> Choix -> le choix MÈNE À une nouvelle Situation (avec
|
|
// ses propres choix), jamais un vocabulaire technique de graphe
|
|
// ("nœud") qui n'a pas de sens pour le créateur du scénario.
|
|
return nIndex === 0 ? 'Situation initiale' : `Situation ${nIndex + 1}`;
|
|
}
|
|
|
|
function forgeDocScenarioGraphPatch(partial) {
|
|
const gs = forgeDocScenarioGraphState;
|
|
if (!gs) return;
|
|
const el = window.forgeDocState.elementsById[gs.elementId];
|
|
const scenarios = el.attributes.scenarios.map((s, i) => (i === gs.sIndex ? { ...s, ...partial } : s));
|
|
forgeDocUpdateAttributes(gs.elementId, { ...el.attributes, scenarios }).then(() => {
|
|
forgeDocRenderScenarioTreeModal();
|
|
// Le panneau Propriétés affiche le nombre de nœuds par carte : le
|
|
// tenir synchronisé pendant que la modale reste ouverte.
|
|
if (window.forgeDocState.selectedId === gs.elementId) {
|
|
forgeDocRenderProps(window.forgeDocState.elementsById[gs.elementId]);
|
|
}
|
|
});
|
|
}
|
|
|
|
function forgeDocScenarioGraphNodeRect(nIndex) {
|
|
const gs = forgeDocScenarioGraphState;
|
|
const nodeEl = gs.nodeEls[nIndex];
|
|
if (!nodeEl) return null;
|
|
return {
|
|
x: nodeEl.offsetLeft, y: nodeEl.offsetTop, width: nodeEl.offsetWidth, height: nodeEl.offsetHeight,
|
|
};
|
|
}
|
|
|
|
function forgeDocScenarioGraphRedrawEdges() {
|
|
const gs = forgeDocScenarioGraphState;
|
|
const { svgEl, innerEl } = gs;
|
|
svgEl.innerHTML = '';
|
|
innerEl.querySelectorAll('.docScenarioGraphEdgeLabel').forEach((n) => n.remove());
|
|
|
|
const svgNs = 'http://www.w3.org/2000/svg';
|
|
const defs = document.createElementNS(svgNs, 'defs');
|
|
const marker = document.createElementNS(svgNs, 'marker');
|
|
marker.setAttribute('id', 'docScenarioArrow');
|
|
marker.setAttribute('viewBox', '0 0 10 10');
|
|
marker.setAttribute('refX', '9');
|
|
marker.setAttribute('refY', '5');
|
|
marker.setAttribute('markerWidth', '7');
|
|
marker.setAttribute('markerHeight', '7');
|
|
marker.setAttribute('orient', 'auto-start-reverse');
|
|
const arrowPath = document.createElementNS(svgNs, 'path');
|
|
arrowPath.setAttribute('d', 'M0,0 L10,5 L0,10 z');
|
|
arrowPath.setAttribute('fill', 'var(--doc-muted)');
|
|
marker.appendChild(arrowPath);
|
|
defs.appendChild(marker);
|
|
svgEl.appendChild(defs);
|
|
|
|
gs.scenario.nodes.forEach((node, nIndex) => {
|
|
const fromRect = forgeDocScenarioGraphNodeRect(nIndex);
|
|
if (!fromRect) return;
|
|
node.choices.forEach((choice) => {
|
|
if (!choice.target_id) return;
|
|
const targetIndex = gs.scenario.nodes.findIndex((n) => n.id === choice.target_id);
|
|
if (targetIndex === -1) return;
|
|
const toRect = forgeDocScenarioGraphNodeRect(targetIndex);
|
|
if (!toRect) return;
|
|
const x1 = fromRect.x + fromRect.width / 2;
|
|
const y1 = fromRect.y + fromRect.height / 2;
|
|
const x2 = toRect.x + toRect.width / 2;
|
|
const y2 = toRect.y + toRect.height / 2;
|
|
const line = document.createElementNS(svgNs, 'line');
|
|
line.setAttribute('class', 'docScenarioGraphEdge');
|
|
line.setAttribute('x1', String(x1));
|
|
line.setAttribute('y1', String(y1));
|
|
line.setAttribute('x2', String(x2));
|
|
line.setAttribute('y2', String(y2));
|
|
line.setAttribute('marker-end', 'url(#docScenarioArrow)');
|
|
svgEl.appendChild(line);
|
|
|
|
const label = document.createElement('div');
|
|
label.className = 'docScenarioGraphEdgeLabel';
|
|
label.style.left = `${(x1 + x2) / 2}px`;
|
|
label.style.top = `${(y1 + y2) / 2}px`;
|
|
label.textContent = choice.text;
|
|
innerEl.appendChild(label);
|
|
});
|
|
});
|
|
}
|
|
|
|
function forgeDocScenarioGraphUpdateHint() {
|
|
const gs = forgeDocScenarioGraphState;
|
|
const hintEl = document.getElementById('docScenarioGraphHint');
|
|
if (!hintEl) return;
|
|
if (gs.connecting) {
|
|
hintEl.textContent = 'Cliquez sur la situation cible pour relier ce choix, ou appuyez sur Échap pour annuler.';
|
|
hintEl.classList.add('is-connecting');
|
|
gs.canvasEl.classList.add('is-connecting');
|
|
} else {
|
|
hintEl.textContent = 'Glissez une situation pour la déplacer. Cliquez une situation pour modifier son texte et ses choix.';
|
|
hintEl.classList.remove('is-connecting');
|
|
gs.canvasEl.classList.remove('is-connecting');
|
|
}
|
|
}
|
|
|
|
function forgeDocScenarioGraphSelectNode(nIndex) {
|
|
const gs = forgeDocScenarioGraphState;
|
|
gs.selectedNodeIndex = nIndex;
|
|
Object.values(gs.nodeEls).forEach((el) => el.classList.remove('is-selected'));
|
|
if (gs.nodeEls[nIndex]) gs.nodeEls[nIndex].classList.add('is-selected');
|
|
forgeDocScenarioGraphRenderInspector();
|
|
}
|
|
|
|
function forgeDocScenarioGraphCancelConnect() {
|
|
const gs = forgeDocScenarioGraphState;
|
|
gs.connecting = null;
|
|
Object.values(gs.nodeEls).forEach((el) => el.classList.remove('is-connect-target'));
|
|
forgeDocScenarioGraphUpdateHint();
|
|
forgeDocScenarioGraphRenderInspector();
|
|
}
|
|
|
|
function forgeDocScenarioGraphStartConnect(nIndex, cIndex) {
|
|
const gs = forgeDocScenarioGraphState;
|
|
gs.connecting = { nodeIndex: nIndex, choiceIndex: cIndex };
|
|
Object.entries(gs.nodeEls).forEach(([i, el]) => {
|
|
el.classList.toggle('is-connect-target', Number(i) !== nIndex);
|
|
});
|
|
forgeDocScenarioGraphUpdateHint();
|
|
forgeDocScenarioGraphRenderInspector();
|
|
}
|
|
|
|
function forgeDocScenarioGraphCompleteConnect(targetNodeIndex) {
|
|
const gs = forgeDocScenarioGraphState;
|
|
const { nodeIndex, choiceIndex } = gs.connecting;
|
|
if (targetNodeIndex === nodeIndex) {
|
|
forgeDocScenarioGraphCancelConnect();
|
|
return;
|
|
}
|
|
const targetId = gs.scenario.nodes[targetNodeIndex].id;
|
|
gs.connecting = null;
|
|
const nodes = gs.scenario.nodes.map((n, i) => (
|
|
i === nodeIndex
|
|
? { ...n, choices: n.choices.map((c, ci) => (ci === choiceIndex ? { ...c, target_id: targetId } : c)) }
|
|
: n
|
|
));
|
|
forgeDocScenarioGraphPatch({ nodes });
|
|
}
|
|
|
|
function forgeDocScenarioGraphNodeMouseDown(e, nIndex) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const gs = forgeDocScenarioGraphState;
|
|
if (!gs) return;
|
|
if (gs.connecting) {
|
|
forgeDocScenarioGraphCompleteConnect(nIndex);
|
|
return;
|
|
}
|
|
forgeDocScenarioGraphSelectNode(nIndex);
|
|
const node = gs.scenario.nodes[nIndex];
|
|
gs.drag = {
|
|
nIndex, startX: e.clientX, startY: e.clientY, origX: node.x, origY: node.y, lastX: node.x, lastY: node.y,
|
|
};
|
|
}
|
|
|
|
function forgeDocScenarioGraphBindGlobalDrag() {
|
|
// Un SEUL listener document-level pour toute la page (même raison que
|
|
// forgeDocMotsBindGlobalMouseup) : la modale peut se ré-ouvrir
|
|
// plusieurs fois par session sans jamais accumuler de listeners.
|
|
if (forgeDocScenarioGraphDragBound) return;
|
|
forgeDocScenarioGraphDragBound = true;
|
|
document.addEventListener('mousemove', (e) => {
|
|
const gs = forgeDocScenarioGraphState;
|
|
if (!gs || !gs.drag) return;
|
|
const dx = e.clientX - gs.drag.startX;
|
|
const dy = e.clientY - gs.drag.startY;
|
|
const newX = Math.max(0, gs.drag.origX + dx);
|
|
const newY = Math.max(0, gs.drag.origY + dy);
|
|
gs.drag.lastX = newX;
|
|
gs.drag.lastY = newY;
|
|
const nodeEl = gs.nodeEls[gs.drag.nIndex];
|
|
if (nodeEl) {
|
|
nodeEl.style.left = `${newX}px`;
|
|
nodeEl.style.top = `${newY}px`;
|
|
}
|
|
forgeDocScenarioGraphRedrawEdges();
|
|
});
|
|
document.addEventListener('mouseup', () => {
|
|
const gs = forgeDocScenarioGraphState;
|
|
if (!gs || !gs.drag) return;
|
|
const {
|
|
nIndex, lastX, lastY, origX, origY,
|
|
} = gs.drag;
|
|
gs.drag = null;
|
|
if (lastX === origX && lastY === origY) return;
|
|
const nodes = gs.scenario.nodes.map((n, i) => (i === nIndex ? { ...n, x: lastX, y: lastY } : n));
|
|
forgeDocScenarioGraphPatch({ nodes });
|
|
});
|
|
}
|
|
|
|
function forgeDocScenarioGraphBuildNodeEl(node, nIndex, gs) {
|
|
const div = document.createElement('div');
|
|
div.className = 'docScenarioGraphNode';
|
|
if (nIndex === 0) div.classList.add('is-root');
|
|
if (nIndex === gs.selectedNodeIndex) div.classList.add('is-selected');
|
|
if (gs.connecting && gs.connecting.nodeIndex !== nIndex) div.classList.add('is-connect-target');
|
|
div.style.left = `${node.x}px`;
|
|
div.style.top = `${node.y}px`;
|
|
div.dataset.nodeIndex = String(nIndex);
|
|
|
|
const labelEl = document.createElement('div');
|
|
labelEl.className = 'docScenarioGraphNodeLabel';
|
|
labelEl.textContent = forgeDocScenarioNodeLabel(nIndex);
|
|
const textEl = document.createElement('div');
|
|
textEl.className = 'docScenarioGraphNodeText';
|
|
textEl.textContent = node.text;
|
|
div.appendChild(labelEl);
|
|
div.appendChild(textEl);
|
|
|
|
div.addEventListener('mousedown', (e) => forgeDocScenarioGraphNodeMouseDown(e, nIndex));
|
|
return div;
|
|
}
|
|
|
|
function forgeDocScenarioGraphRenderInspector() {
|
|
const gs = forgeDocScenarioGraphState;
|
|
const inspector = document.getElementById('docScenarioGraphInspector');
|
|
if (!inspector) return;
|
|
const nIndex = gs.selectedNodeIndex;
|
|
const node = gs.scenario.nodes[nIndex];
|
|
if (!node) {
|
|
inspector.innerHTML = '<div class="docScenarioGraphInspectorEmpty">Sélectionnez une situation sur le graphe.</div>';
|
|
return;
|
|
}
|
|
const isRoot = nIndex === 0;
|
|
const choicesHtml = node.choices.map((choice, cIndex) => {
|
|
const targetIndex = choice.target_id ? gs.scenario.nodes.findIndex((n) => n.id === choice.target_id) : -1;
|
|
const isConnectingThis = !!gs.connecting
|
|
&& gs.connecting.nodeIndex === nIndex && gs.connecting.choiceIndex === cIndex;
|
|
return `
|
|
<div class="docScenarioChoiceBlock">
|
|
<div class="docQuizChoiceRow">
|
|
<input type="text"
|
|
class="docQuizChoiceInput docScenarioTreeChoiceInput"
|
|
value="${forgeDocEscapeHtml(choice.text)}"
|
|
placeholder="Texte du choix"
|
|
data-choice-index="${cIndex}">
|
|
<button type="button" class="docQuizRemoveBtn docScenarioTreeChoiceRemoveBtn" data-choice-index="${cIndex}" title="Supprimer ce choix">🗑</button>
|
|
</div>
|
|
<div class="docScenarioChoiceTargetRow">
|
|
<span class="docScenarioChoiceTargetLabel">${targetIndex === -1 ? '— Fin de branche —' : `→ ${forgeDocEscapeHtml(forgeDocScenarioNodeLabel(targetIndex))}`}</span>
|
|
<button type="button" class="docScenarioChoiceLinkBtn ${isConnectingThis ? 'is-active' : ''}" data-choice-index="${cIndex}" data-action="link">${isConnectingThis ? 'Cliquez une situation…' : 'Relier'}</button>
|
|
${targetIndex === -1 ? '' : `<button type="button" class="docScenarioChoiceLinkBtn" data-choice-index="${cIndex}" data-action="unlink">Dissocier</button>`}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
inspector.innerHTML = `
|
|
<div class="docQuizQuestionHeader">
|
|
<span class="docFieldLabel">${forgeDocScenarioNodeLabel(nIndex)}</span>
|
|
${isRoot ? '' : '<button type="button" class="docQuizRemoveBtn" id="docScenarioTreeNodeRemoveBtn" title="Supprimer cette situation">🗑</button>'}
|
|
</div>
|
|
<div class="docField">
|
|
<textarea id="docScenarioTreeTextInput" rows="4" placeholder="Texte de la situation">${forgeDocEscapeHtml(node.text)}</textarea>
|
|
</div>
|
|
<div class="docSectionLabel docQuizQuestionsLabel">Choix</div>
|
|
<div class="docQuizChoicesList">${choicesHtml || '<div class="docEmptyState">Aucun choix — fin de cette branche.</div>'}</div>
|
|
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docScenarioTreeAddChoiceBtn" ${node.choices.length >= 4 ? 'disabled' : ''}>+ Ajouter un choix</button>
|
|
`;
|
|
|
|
const removeBtn = document.getElementById('docScenarioTreeNodeRemoveBtn');
|
|
if (removeBtn) {
|
|
removeBtn.addEventListener('click', () => {
|
|
const removedId = node.id;
|
|
// Un choix qui pointait vers ce nœud devient "fin de branche"
|
|
// (jamais une référence morte) — même filet de sécurité que
|
|
// sanitize_scenario_config côté serveur.
|
|
const nodes = gs.scenario.nodes
|
|
.filter((_, i) => i !== nIndex)
|
|
.map((n) => ({
|
|
...n,
|
|
choices: n.choices.map((c) => (c.target_id === removedId ? { ...c, target_id: null } : c)),
|
|
}));
|
|
gs.selectedNodeIndex = 0;
|
|
forgeDocScenarioGraphPatch({ nodes });
|
|
});
|
|
}
|
|
|
|
document.getElementById('docScenarioTreeTextInput').addEventListener('change', (e) => {
|
|
const nodes = gs.scenario.nodes.map((n, i) => (i === nIndex ? { ...n, text: e.target.value } : n));
|
|
forgeDocScenarioGraphPatch({ nodes });
|
|
});
|
|
|
|
document.getElementById('docScenarioTreeAddChoiceBtn').addEventListener('click', () => {
|
|
const nodes = gs.scenario.nodes.map((n, i) => (
|
|
i === nIndex ? { ...n, choices: [...n.choices, { text: 'Nouveau choix', target_id: null }] } : n
|
|
));
|
|
forgeDocScenarioGraphPatch({ nodes });
|
|
});
|
|
|
|
inspector.querySelectorAll('.docScenarioTreeChoiceInput').forEach((input) => {
|
|
input.addEventListener('change', (e) => {
|
|
const cIndex = Number(input.dataset.choiceIndex);
|
|
const nodes = gs.scenario.nodes.map((n, i) => (
|
|
i === nIndex
|
|
? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, text: e.target.value } : c)) }
|
|
: n
|
|
));
|
|
forgeDocScenarioGraphPatch({ nodes });
|
|
});
|
|
});
|
|
|
|
inspector.querySelectorAll('.docScenarioTreeChoiceRemoveBtn').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const cIndex = Number(btn.dataset.choiceIndex);
|
|
const nodes = gs.scenario.nodes.map((n, i) => (
|
|
i === nIndex ? { ...n, choices: n.choices.filter((_, ci) => ci !== cIndex) } : n
|
|
));
|
|
forgeDocScenarioGraphPatch({ nodes });
|
|
});
|
|
});
|
|
|
|
inspector.querySelectorAll('.docScenarioChoiceLinkBtn[data-action="link"]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const cIndex = Number(btn.dataset.choiceIndex);
|
|
if (gs.connecting && gs.connecting.nodeIndex === nIndex && gs.connecting.choiceIndex === cIndex) {
|
|
forgeDocScenarioGraphCancelConnect();
|
|
} else {
|
|
forgeDocScenarioGraphStartConnect(nIndex, cIndex);
|
|
}
|
|
});
|
|
});
|
|
|
|
inspector.querySelectorAll('.docScenarioChoiceLinkBtn[data-action="unlink"]').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
const cIndex = Number(btn.dataset.choiceIndex);
|
|
const nodes = gs.scenario.nodes.map((n, i) => (
|
|
i === nIndex
|
|
? { ...n, choices: n.choices.map((c, ci) => (ci === cIndex ? { ...c, target_id: null } : c)) }
|
|
: n
|
|
));
|
|
forgeDocScenarioGraphPatch({ nodes });
|
|
});
|
|
});
|
|
}
|
|
|
|
function forgeDocRenderScenarioTreeModal() {
|
|
const gs = forgeDocScenarioGraphState;
|
|
if (!gs) return;
|
|
const el = window.forgeDocState.elementsById[gs.elementId];
|
|
const scenario = el && (el.attributes.scenarios || [])[gs.sIndex];
|
|
if (!scenario) {
|
|
forgeDocCloseScenarioTreeModal();
|
|
return;
|
|
}
|
|
gs.scenario = scenario;
|
|
if (gs.selectedNodeIndex >= scenario.nodes.length) gs.selectedNodeIndex = 0;
|
|
if (gs.connecting && gs.connecting.nodeIndex >= scenario.nodes.length) gs.connecting = null;
|
|
|
|
document.getElementById('docScenarioTreeModalTitle').textContent = `Arbre — ${scenario.title}`;
|
|
const body = document.getElementById('docScenarioTreeModalBody');
|
|
body.innerHTML = `
|
|
<div class="docScenarioGraphTitleRow">
|
|
<input type="text"
|
|
id="docScenarioTreeTitleInput"
|
|
value="${forgeDocEscapeHtml(scenario.title)}"
|
|
placeholder="Nom du scénario">
|
|
</div>
|
|
<div class="docScenarioGraphToolbar">
|
|
<button type="button" class="docBtnSecondary" id="docScenarioGraphAddNode">+ Ajouter une situation</button>
|
|
<span class="docScenarioGraphHint" id="docScenarioGraphHint"></span>
|
|
</div>
|
|
<div class="docScenarioGraphSplit">
|
|
<div class="docScenarioGraphCanvas" id="docScenarioGraphCanvas">
|
|
<div class="docScenarioGraphInner" id="docScenarioGraphInner">
|
|
<svg class="docScenarioGraphSvg" id="docScenarioGraphSvg"></svg>
|
|
</div>
|
|
</div>
|
|
<div class="docScenarioGraphInspector" id="docScenarioGraphInspector"></div>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('docScenarioTreeTitleInput').addEventListener('change', (e) => {
|
|
forgeDocScenarioGraphPatch({ title: e.target.value });
|
|
});
|
|
|
|
document.getElementById('docScenarioGraphAddNode').addEventListener('click', () => {
|
|
const newId = forgeDocScenarioGenerateNodeId(scenario.nodes);
|
|
const pos = forgeDocScenarioGraphNextPosition(scenario.nodes);
|
|
gs.selectedNodeIndex = scenario.nodes.length;
|
|
forgeDocScenarioGraphPatch({ nodes: [...scenario.nodes, forgeDocScenarioNewNode(newId, pos.x, pos.y)] });
|
|
});
|
|
|
|
gs.canvasEl = document.getElementById('docScenarioGraphCanvas');
|
|
gs.innerEl = document.getElementById('docScenarioGraphInner');
|
|
gs.svgEl = document.getElementById('docScenarioGraphSvg');
|
|
gs.nodeEls = {};
|
|
|
|
scenario.nodes.forEach((node, nIndex) => {
|
|
const nodeEl = forgeDocScenarioGraphBuildNodeEl(node, nIndex, gs);
|
|
gs.innerEl.appendChild(nodeEl);
|
|
gs.nodeEls[nIndex] = nodeEl;
|
|
});
|
|
|
|
gs.canvasEl.addEventListener('click', (e) => {
|
|
if (gs.connecting && (e.target === gs.canvasEl || e.target === gs.innerEl)) {
|
|
forgeDocScenarioGraphCancelConnect();
|
|
}
|
|
});
|
|
|
|
forgeDocScenarioGraphRedrawEdges();
|
|
forgeDocScenarioGraphUpdateHint();
|
|
forgeDocScenarioGraphRenderInspector();
|
|
}
|
|
|
|
function forgeDocOpenScenarioTreeModal(elementId, sIndex) {
|
|
document.getElementById('docScenarioTreeModal').classList.add('is-open');
|
|
forgeDocScenarioGraphState = {
|
|
elementId, sIndex, selectedNodeIndex: 0, connecting: null, drag: null, nodeEls: {},
|
|
};
|
|
forgeDocScenarioGraphBindGlobalDrag();
|
|
forgeDocRenderScenarioTreeModal();
|
|
}
|
|
|
|
function forgeDocCloseScenarioTreeModal() {
|
|
document.getElementById('docScenarioTreeModal').classList.remove('is-open');
|
|
forgeDocScenarioGraphState = null;
|
|
}
|
|
|
|
function forgeDocBindScenarioTreeModal() {
|
|
document.getElementById('docScenarioTreeModalClose').addEventListener('click', forgeDocCloseScenarioTreeModal);
|
|
document.getElementById('docScenarioTreeModal').addEventListener('click', (e) => {
|
|
if (e.target.id === 'docScenarioTreeModal') forgeDocCloseScenarioTreeModal();
|
|
});
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key !== 'Escape') return;
|
|
const gs = forgeDocScenarioGraphState;
|
|
if (gs && gs.connecting) {
|
|
forgeDocScenarioGraphCancelConnect();
|
|
return;
|
|
}
|
|
forgeDocCloseScenarioTreeModal();
|
|
});
|
|
}
|
|
|
|
function forgeDocRenderProps(el) {
|
|
const panel = document.getElementById('docPropsPanel');
|
|
if (!el) {
|
|
panel.innerHTML = '<div class="docSectionLabel">Propriétés</div><div class="docEmptyState">Sélectionnez un élément du document pour voir ses propriétés.</div>';
|
|
return;
|
|
}
|
|
if (el.kind === 'titre' || el.kind === 'paragraphe') forgeDocRenderTextProps(panel, el);
|
|
else if (forgeDocIsShapeKind(el.kind)) forgeDocRenderShapeProps(panel, el);
|
|
else if (el.kind === 'image') forgeDocRenderImageProps(panel, el);
|
|
else if (el.kind === 'bouton') forgeDocRenderButtonProps(panel, el);
|
|
else if (el.kind === 'row') forgeDocRenderRowProps(panel, el);
|
|
else if (el.kind === 'quiz') forgeDocRenderQuizProps(panel, el);
|
|
else if (el.kind === 'association') forgeDocRenderAssociationProps(panel, el);
|
|
else if (el.kind === 'memory') forgeDocRenderMemoryProps(panel, el);
|
|
else if (el.kind === 'mots') forgeDocRenderMotsProps(panel, el);
|
|
else if (el.kind === 'scenario') forgeDocRenderScenarioProps(panel, el);
|
|
else forgeDocRenderMinigameProps(panel, el);
|
|
|
|
const deleteBtn = document.getElementById('docDeletePropBtn');
|
|
if (deleteBtn) deleteBtn.addEventListener('click', () => forgeDocDeleteElement(el.id));
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Chrome de l'éditeur : zoom, largeur d'aperçu responsive, Aperçu,
|
|
* thème (scopé à .docEditor3), navigation mobile.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocBindZoom() {
|
|
const docPage = document.getElementById('docPage');
|
|
document.getElementById('docZoomOut').addEventListener('click', () => {
|
|
window.forgeDocState.zoom = Math.max(50, window.forgeDocState.zoom - 10);
|
|
document.getElementById('docZoomVal').textContent = `${window.forgeDocState.zoom}%`;
|
|
docPage.style.transform = `scale(${window.forgeDocState.zoom / 100})`;
|
|
});
|
|
document.getElementById('docZoomIn').addEventListener('click', () => {
|
|
window.forgeDocState.zoom = Math.min(150, window.forgeDocState.zoom + 10);
|
|
document.getElementById('docZoomVal').textContent = `${window.forgeDocState.zoom}%`;
|
|
docPage.style.transform = `scale(${window.forgeDocState.zoom / 100})`;
|
|
});
|
|
}
|
|
|
|
function forgeDocBindWidthPicker() {
|
|
document.querySelectorAll('.docWidthBtn').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
document.querySelectorAll('.docWidthBtn').forEach((b) => b.classList.remove('is-active'));
|
|
btn.classList.add('is-active');
|
|
const docPage = document.getElementById('docPage');
|
|
const width = btn.dataset.width;
|
|
if (!width) {
|
|
docPage.classList.remove('docPage--fixedWidth');
|
|
docPage.style.removeProperty('--doc-preview-width');
|
|
} else {
|
|
docPage.classList.add('docPage--fixedWidth');
|
|
docPage.style.setProperty('--doc-preview-width', `${width}px`);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function forgeDocBindPreviewToggle() {
|
|
document.getElementById('docPreviewBtn').addEventListener('click', () => {
|
|
window.forgeDocState.previewMode = !window.forgeDocState.previewMode;
|
|
document.getElementById('docEditor3').classList.toggle('docEditor3--preview', window.forgeDocState.previewMode);
|
|
forgeDocUpdatePageNavPill();
|
|
});
|
|
}
|
|
|
|
function forgeDocApplyTheme(theme) {
|
|
const editor = document.getElementById('docEditor3');
|
|
editor.dataset.theme = theme;
|
|
// #docScenarioTreeModal reste un FRÈRE de .docEditor3 dans le HTML
|
|
// (jamais un descendant — voir static/document/document-editor.css
|
|
// pour pourquoi), donc ne reçoit pas data-theme par simple héritage :
|
|
// posé ici explicitement pour que la modale suive le même thème.
|
|
const scenarioModal = document.getElementById('docScenarioTreeModal');
|
|
if (scenarioModal) scenarioModal.dataset.theme = theme;
|
|
const toggle = document.getElementById('docThemeToggle');
|
|
toggle.innerHTML = theme === 'light'
|
|
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/></svg>'
|
|
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M2 12h2M20 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/></svg>';
|
|
}
|
|
|
|
function forgeDocBindThemeToggle() {
|
|
let stored = 'dark';
|
|
try {
|
|
const saved = window.localStorage.getItem('forge-doc-editor-theme');
|
|
if (saved === 'light' || saved === 'dark') stored = saved;
|
|
} catch (e) { /* stockage indisponible, on reste sur le thème par défaut */ }
|
|
forgeDocApplyTheme(stored);
|
|
document.getElementById('docThemeToggle').addEventListener('click', () => {
|
|
const next = document.getElementById('docEditor3').dataset.theme === 'light' ? 'dark' : 'light';
|
|
forgeDocApplyTheme(next);
|
|
try { window.localStorage.setItem('forge-doc-editor-theme', next); } catch (e) { /* ignore */ }
|
|
});
|
|
}
|
|
|
|
function forgeDocBindMobileNav() {
|
|
const left = document.getElementById('docSidebarLeft');
|
|
const right = document.getElementById('docSidebarRight');
|
|
document.querySelectorAll('#docBottomNav button').forEach((btn) => {
|
|
btn.addEventListener('click', () => {
|
|
forgeDocSetActiveBottomNav(btn.dataset.target);
|
|
left.classList.toggle('is-open', btn.dataset.target === 'left');
|
|
right.classList.toggle('is-open', btn.dataset.target === 'right');
|
|
});
|
|
});
|
|
function closePanels() {
|
|
left.classList.remove('is-open');
|
|
right.classList.remove('is-open');
|
|
forgeDocSetActiveBottomNav('doc');
|
|
}
|
|
document.getElementById('docCloseLeft').addEventListener('click', closePanels);
|
|
document.getElementById('docCloseRight').addEventListener('click', closePanels);
|
|
}
|
|
|
|
function forgeDocBindDeselectOnBackgroundClick() {
|
|
document.getElementById('docCanvasArea').addEventListener('click', (e) => {
|
|
if (e.target.closest('[data-element-id]')) return;
|
|
window.forgeDocState.selectedId = null;
|
|
forgeDocApplySelectionClass();
|
|
forgeDocRenderProps(null);
|
|
});
|
|
}
|
|
|
|
function forgeDocBindKeyboardShortcuts() {
|
|
document.addEventListener('keydown', (e) => {
|
|
if (!document.getElementById('docEditor3')) return;
|
|
const isUndo = (e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === 'z';
|
|
const isRedo = ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'z')
|
|
|| ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y');
|
|
if (isUndo) { e.preventDefault(); forgeDocUndo(); }
|
|
if (isRedo) { e.preventDefault(); forgeDocRedo(); }
|
|
});
|
|
}
|
|
|
|
function forgeDocInit() {
|
|
const root = document.getElementById('docEditor3');
|
|
if (!root || !window.FORGE_DOCUMENT) return;
|
|
|
|
window.forgeDocState = {
|
|
slug: window.FORGE_DOCUMENT.slug,
|
|
elements: window.FORGE_DOCUMENT.elements,
|
|
elementsById: {},
|
|
pages: window.FORGE_DOCUMENT.pages,
|
|
activePageId: window.FORGE_DOCUMENT.activePageId,
|
|
selectedId: null,
|
|
undoStack: [],
|
|
redoStack: [],
|
|
previewMode: false,
|
|
zoom: 100,
|
|
shapeDrag: null,
|
|
};
|
|
forgeDocRebuildIndex();
|
|
|
|
forgeDocBindCanvasInteractions();
|
|
forgeDocBindLibraryDragHandlers();
|
|
forgeDocBindCanvasDropZone();
|
|
forgeDocBindShapeDragGlobalHandlers();
|
|
forgeDocBindDeselectOnBackgroundClick();
|
|
forgeDocBindZoom();
|
|
forgeDocBindWidthPicker();
|
|
forgeDocBindPreviewToggle();
|
|
forgeDocBindThemeToggle();
|
|
forgeDocBindMobileNav();
|
|
forgeDocBindKeyboardShortcuts();
|
|
forgeDocBindScenarioTreeModal();
|
|
forgeDocRenderPageTabs();
|
|
forgeDocBindPageTabsDrag();
|
|
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();
|