Files
Forge-Engine/static/document/js/document-editor.js
T
williamandClaude Sonnet 5 9fb22be1ae
Build and deploy / test-python (push) Failing after 1m5s
Build and deploy / test-js (push) Successful in 51s
Build and deploy / lint-python (push) Failing after 1m2s
Build and deploy / lint-js (push) Failing after 1m4s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 1m0s
Quiz reellement interactif en Mode Apercu (voir maquette document-formation-web.html)
Le quiz affichait jusqu'ici la meme carte resume en edition comme en
Apercu. Ajoute un vrai questionnaire jouable, visible UNIQUEMENT en
Mode Apercu (masque en edition via CSS, jamais les deux a la fois) :
options de reponse cliquables, marquage correct/incorrect immediat,
score qui accumule les points de chaque question repondue
correctement, progression "Question suivante", et un ecran de
resultat final (score obtenu / total des points possibles) avec
"Recommencer le quiz" - structure et identite visuelle calquees sur
docs/plan/maquettes/document-formation-web.html (cartes en degrade,
options A/B/C/D, feedback vert/orange), adaptees aux tokens --doc-*
deja en place (theme clair/sombre inclus, nouvelles variables
--doc-quiz-success-*/--doc-quiz-danger-*).

Cote rendu (document_engine/rendering/render_document_element.py),
_render_quiz ajoute desormais le questionnaire (fonction privee
_render_quiz_player) des qu'au moins une question existe : les
questions sont embarquees en JSON dans un attribut data-quiz-config
(jamais un <script> par element), echappees pour l'HTML - aucun
aller-retour serveur pendant qu'on joue, tout le deroulé (reponse/
score/suivant/resultat) est gere par
static/document/js/document-editor.js. pointer-events, desactive sur
tout element en Apercu pour empecher la selection/le glisser-deposer
pendant le test, est reactive specifiquement dans le questionnaire
pour qu'il reste reellement cliquable.

Tests : 3 nouveaux tests de rendu purs (sans Flask, dont un qui
verifie l'echappement HTML du texte d'une question contre une
injection). Verifie en live via une simulation DOM complete
(basculer Apercu, repondre correctement puis incorrectement,
verifier score/feedback/etat des options, passer a la question
suivante, voir l'ecran de resultat) - le script de diagnostic n'a pas
ete conserve dans le depot.

SKIP=djlint : backlog H021 pre-existant, aucun template touche ici.
ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous
verts ; 48 tests cibles (document + onboarding) verifies frais.

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

1097 lines
49 KiB
JavaScript

// document-editor.js — éditeur de support de formation (voir
// docs/plan/PLAN.md). État MUTABLE partagé sur window.forgeDocState
// (jamais let/const au premier niveau de ce fichier, voir static/pjax.js —
// un <script src> de page est rejoué tel quel à chaque navigation pjax,
// une redéclaration lèverait "already declared" ; seul `var` est sûr à
// rejouer). Toute mutation passe par le serveur (AJAX) puis rafraîchit le
// canevas depuis SA réponse (jamais un état simulé côté client) —
// Undo/Redo est une PILE DE COMMANDES qui rejoue ces mêmes appels AJAX en
// sens inverse, pas un simple instantané visuel.
// Constante immuable au premier niveau : `let`/`const` lèverait "already
// declared" à la 2e exécution de ce script par pjax.js (voir l'en-tête
// ci-dessus), `var` est le seul choix sûr ici.
// eslint-disable-next-line no-var, vars-on-top
var FORGE_DOC_STYLE_PRESETS = {
titre1: 'Titre 1',
titre2: 'Titre 2',
paragraphe: 'Paragraphe',
legende: 'Légende',
};
// Même raison que FORGE_DOC_STYLE_PRESETS ci-dessus.
// eslint-disable-next-line no-var, vars-on-top
var FORGE_DOC_SHAPE_KINDS = ['rectangle', 'cercle', 'triangle', 'trait'];
function forgeDocIsShapeKind(kind) {
return FORGE_DOC_SHAPE_KINDS.includes(kind);
}
function forgeDocChildrenOf(parentId) {
return window.forgeDocState.elements
.filter((el) => el.parent_id === parentId)
.sort((a, b) => a.order_index - b.order_index);
}
function forgeDocTopLevel() {
return forgeDocChildrenOf(null);
}
function forgeDocRebuildIndex() {
const byId = {};
window.forgeDocState.elements.forEach((el) => { byId[el.id] = el; });
window.forgeDocState.elementsById = byId;
}
/* ---------------------------------------------------------------------
* Appels serveur — un seul point de vérité par opération (voir
* routes/document/), jamais dupliqué côté client.
* ------------------------------------------------------------------- */
function forgeDocApiAdd(kind, parentId) {
const body = new URLSearchParams();
body.set('kind', kind);
if (parentId !== null && parentId !== undefined) body.set('parent_id', String(parentId));
return fetch(`/document/${window.forgeDocState.slug}/elements/add`, { method: 'POST', body })
.then((r) => r.json());
}
function forgeDocApiUpdate(id, attributes) {
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(attributes),
}).then((r) => r.json());
}
function forgeDocApiMove(id, newParentId, newIndex) {
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_parent_id: newParentId, new_index: newIndex }),
}).then((r) => r.json());
}
function forgeDocApiDelete(id) {
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/delete`, { method: 'POST' })
.then((r) => r.json());
}
function forgeDocApiRender() {
return fetch(`/document/${window.forgeDocState.slug}/render`).then((r) => r.json());
}
/* ---------------------------------------------------------------------
* Canevas — rafraîchi depuis la VÉRITÉ SERVEUR après chaque mutation
* structurelle (jamais une simple retouche DOM locale, voir
* routes/document/document_render.py).
* ------------------------------------------------------------------- */
function forgeDocApplySelectionClass() {
document.querySelectorAll('#docPage .docSelected').forEach((d) => d.classList.remove('docSelected'));
const { selectedId } = window.forgeDocState;
if (selectedId === null) return;
const dom = document.querySelector(`[data-element-id="${selectedId}"]`);
if (dom) dom.classList.add('docSelected');
}
function forgeDocRefreshCanvas() {
return forgeDocApiRender().then((data) => {
document.getElementById('docPageContent').innerHTML = data.html;
window.forgeDocState.elements = data.elements;
forgeDocRebuildIndex();
forgeDocBindCanvasInteractions();
const { selectedId } = window.forgeDocState;
if (selectedId !== null && window.forgeDocState.elementsById[selectedId]) {
forgeDocApplySelectionClass();
} else {
window.forgeDocState.selectedId = null;
forgeDocRenderProps(null);
}
});
}
/* ---------------------------------------------------------------------
* Undo/Redo — pile de COMMANDES {undo, redo}, chacune rejouant les mêmes
* appels AJAX que l'action d'origine (voir docs/plan/PLAN.md).
* ------------------------------------------------------------------- */
function forgeDocUpdateHistoryButtons() {
const undoBtn = document.getElementById('docUndoBtn');
const redoBtn = document.getElementById('docRedoBtn');
if (undoBtn) undoBtn.disabled = window.forgeDocState.undoStack.length === 0;
if (redoBtn) redoBtn.disabled = window.forgeDocState.redoStack.length === 0;
}
function forgeDocPushCommand(command) {
window.forgeDocState.undoStack.push(command);
window.forgeDocState.redoStack = [];
forgeDocUpdateHistoryButtons();
}
function forgeDocUndo() {
const command = window.forgeDocState.undoStack.pop();
if (!command) return;
command.undo().then(forgeDocRefreshCanvas).then(() => {
window.forgeDocState.redoStack.push(command);
forgeDocUpdateHistoryButtons();
});
}
function forgeDocRedo() {
const command = window.forgeDocState.redoStack.pop();
if (!command) return;
command.redo().then(forgeDocRefreshCanvas).then(() => {
window.forgeDocState.undoStack.push(command);
forgeDocUpdateHistoryButtons();
});
}
/* ---------------------------------------------------------------------
* Mutations de haut niveau — chacune exécute son action puis pousse la
* commande d'annulation correspondante.
* ------------------------------------------------------------------- */
function forgeDocAddElement(kind, parentId) {
const state = { parentId: parentId === undefined ? null : parentId };
function perform() {
return forgeDocApiAdd(kind, state.parentId).then((result) => { state.id = result.id; });
}
return perform().then(() => {
forgeDocPushCommand({
undo: () => forgeDocApiDelete(state.id),
redo: perform,
});
return forgeDocRefreshCanvas().then(() => forgeDocSelectElement(state.id));
});
}
function forgeDocDeleteElement(id) {
const el = window.forgeDocState.elementsById[id];
if (!el) return Promise.resolve();
const state = {
id, parentId: el.parent_id, index: el.order_index, kind: el.kind, attributes: el.attributes,
};
return forgeDocApiDelete(state.id).then(() => {
forgeDocPushCommand({
undo: () => forgeDocApiAdd(state.kind, state.parentId).then((r) => {
state.id = r.id;
return forgeDocApiUpdate(state.id, state.attributes)
.then(() => forgeDocApiMove(state.id, state.parentId, state.index));
}),
redo: () => forgeDocApiDelete(state.id),
});
window.forgeDocState.selectedId = null;
return forgeDocRefreshCanvas();
});
}
function forgeDocUpdateAttributes(id, newAttributes) {
const el = window.forgeDocState.elementsById[id];
if (!el) return Promise.resolve();
const oldAttributes = { ...el.attributes };
return forgeDocApiUpdate(id, newAttributes).then((result) => {
forgeDocPushCommand({
undo: () => forgeDocApiUpdate(id, oldAttributes),
redo: () => forgeDocApiUpdate(id, newAttributes),
});
// Le serveur revalide/nettoie certains kinds (quiz — voir
// routes/document/document_element_update.py) : on adopte SA version,
// jamais celle envoyée telle quelle, pour ne jamais dériver de la
// vérité persistée (ex. un choix en trop silencieusement tronqué).
el.attributes = result.attributes || newAttributes;
const dom = document.querySelector(`[data-element-id="${id}"]`);
if (dom && result.rendered_html) {
const wrapper = document.createElement('div');
wrapper.innerHTML = result.rendered_html;
dom.replaceWith(wrapper.firstElementChild);
forgeDocBindCanvasInteractions();
forgeDocApplySelectionClass();
}
});
}
/* ---------------------------------------------------------------------
* Moteur de layout — glisser-déposer réel (voir docs/plan/PLAN.md §1.2.B) :
* dépose sur la moitié gauche/droite de la boîte englobante d'un élément
* -> rejoint sa rangée (créée à la volée si besoin) ; moitié haute/basse
* -> nouvelle position au top-niveau, avant/après.
* ------------------------------------------------------------------- */
function forgeDocFindDropTarget(clientX, clientY) {
const candidates = Array.from(document.querySelectorAll('#docPage [data-element-id]'))
.filter((dom) => !forgeDocIsShapeKind(dom.dataset.kind));
let best = null;
let bestDist = Infinity;
candidates.forEach((dom) => {
const rect = dom.getBoundingClientRect();
const dist = Math.hypot(clientX - (rect.left + rect.width / 2), clientY - (rect.top + rect.height / 2));
if (dist < bestDist) { bestDist = dist; best = dom; }
});
return best;
}
function forgeDocComputeDropPlacement(targetDom, clientX, clientY) {
const rect = targetDom.getBoundingClientRect();
const relX = (clientX - rect.left) / rect.width;
const relY = (clientY - rect.top) / rect.height;
const targetId = Number.parseInt(targetDom.dataset.elementId, 10);
const targetEl = window.forgeDocState.elementsById[targetId];
if (relY < 0.25) return { mode: 'before', targetEl };
if (relY > 0.75) return { mode: 'after', targetEl };
return { mode: relX < 0.5 ? 'mergeLeft' : 'mergeRight', targetEl };
}
function forgeDocShowDropHighlight(targetDom, mode) {
document.querySelectorAll('.docDropTarget').forEach((d) => {
d.classList.remove('docDropTarget', 'docDropTarget--before', 'docDropTarget--after', 'docDropTarget--mergeLeft', 'docDropTarget--mergeRight');
});
targetDom.classList.add('docDropTarget', `docDropTarget--${mode}`);
}
function forgeDocClearDropHighlight() {
document.querySelectorAll('.docDropTarget').forEach((d) => {
d.classList.remove('docDropTarget', 'docDropTarget--before', 'docDropTarget--after', 'docDropTarget--mergeLeft', 'docDropTarget--mergeRight');
});
}
function forgeDocDropLibraryItem(kind, placement) {
const { targetEl } = placement;
const state = {};
function performForward() {
if (placement.mode === 'mergeLeft' || placement.mode === 'mergeRight') {
if (targetEl.parent_id !== null) {
const siblings = forgeDocChildrenOf(targetEl.parent_id);
const pos = siblings.findIndex((s) => s.id === targetEl.id);
state.index = placement.mode === 'mergeLeft' ? pos : pos + 1;
state.createdRow = false;
return forgeDocApiAdd(kind, targetEl.parent_id).then((r) => {
state.newId = r.id;
return forgeDocApiMove(state.newId, targetEl.parent_id, state.index);
});
}
state.createdRow = true;
state.topPos = forgeDocTopLevel().findIndex((s) => s.id === targetEl.id);
return forgeDocApiAdd('row', null).then((rowResult) => {
state.rowId = rowResult.id;
return forgeDocApiMove(state.rowId, null, state.topPos)
.then(() => forgeDocApiMove(targetEl.id, state.rowId, 0))
.then(() => forgeDocApiAdd(kind, state.rowId))
.then((r) => {
state.newId = r.id;
state.index = placement.mode === 'mergeLeft' ? 0 : 1;
return forgeDocApiMove(state.newId, state.rowId, state.index);
});
});
}
const anchorId = targetEl.parent_id !== null ? targetEl.parent_id : targetEl.id;
const anchorPos = forgeDocTopLevel().findIndex((s) => s.id === anchorId);
state.index = placement.mode === 'before' ? anchorPos : anchorPos + 1;
state.createdRow = false;
return forgeDocApiAdd(kind, null).then((r) => {
state.newId = r.id;
return forgeDocApiMove(state.newId, null, state.index);
});
}
function performUndo() {
const deleteNew = forgeDocApiDelete(state.newId);
if (!state.createdRow) return deleteNew;
return deleteNew
.then(() => forgeDocApiMove(targetEl.id, null, state.topPos))
.then(() => forgeDocApiDelete(state.rowId));
}
return performForward().then(() => {
forgeDocPushCommand({ undo: performUndo, redo: performForward });
return forgeDocRefreshCanvas().then(() => forgeDocSelectElement(state.newId));
});
}
function forgeDocDropExistingElement(draggedId, placement) {
const draggedEl = window.forgeDocState.elementsById[draggedId];
const { targetEl } = placement;
if (!draggedEl || draggedEl.id === targetEl.id || targetEl.parent_id === draggedId) return Promise.resolve();
const isMerge = placement.mode === 'mergeLeft' || placement.mode === 'mergeRight';
if (draggedEl.kind === 'row' && isMerge) return Promise.resolve();
const fromParentId = draggedEl.parent_id;
const fromIndex = draggedEl.order_index;
const state = {};
function performForward() {
if (isMerge) {
if (targetEl.parent_id !== null) {
const siblings = forgeDocChildrenOf(targetEl.parent_id).filter((s) => s.id !== draggedId);
const pos = siblings.findIndex((s) => s.id === targetEl.id);
state.rowId = targetEl.parent_id;
state.index = placement.mode === 'mergeLeft' ? pos : pos + 1;
state.createdRow = false;
return forgeDocApiMove(draggedId, state.rowId, state.index);
}
state.createdRow = true;
state.topPos = forgeDocTopLevel().filter((s) => s.id !== draggedId).findIndex((s) => s.id === targetEl.id);
return forgeDocApiAdd('row', null).then((rowResult) => {
state.rowId = rowResult.id;
return forgeDocApiMove(state.rowId, null, state.topPos)
.then(() => forgeDocApiMove(targetEl.id, state.rowId, 0))
.then(() => {
state.index = placement.mode === 'mergeLeft' ? 0 : 1;
return forgeDocApiMove(draggedId, state.rowId, state.index);
});
});
}
const anchorId = targetEl.parent_id !== null ? targetEl.parent_id : targetEl.id;
const anchorPos = forgeDocTopLevel().filter((s) => s.id !== draggedId).findIndex((s) => s.id === anchorId);
state.index = placement.mode === 'before' ? anchorPos : anchorPos + 1;
state.createdRow = false;
return forgeDocApiMove(draggedId, null, state.index);
}
function performUndo() {
const restore = forgeDocApiMove(draggedId, fromParentId, fromIndex);
if (!state.createdRow) return restore;
return restore
.then(() => forgeDocApiMove(targetEl.id, null, state.topPos))
.then(() => forgeDocApiDelete(state.rowId));
}
return performForward().then(() => {
forgeDocPushCommand({ undo: performUndo, redo: performForward });
return forgeDocRefreshCanvas();
});
}
function forgeDocBindLibraryDragHandlers() {
document.querySelectorAll('.docLibItem[data-add]').forEach((item) => {
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/forge-doc-add', item.dataset.add);
e.dataTransfer.effectAllowed = 'copy';
});
item.addEventListener('click', () => { forgeDocAddElement(item.dataset.add, null); });
});
}
function forgeDocBindCanvasDropZone() {
const docPage = document.getElementById('docPage');
docPage.addEventListener('dragover', (e) => {
const isAdd = e.dataTransfer.types.includes('text/forge-doc-add');
const isMove = e.dataTransfer.types.includes('text/forge-doc-move');
if (!isAdd && !isMove) return;
e.preventDefault();
const target = forgeDocFindDropTarget(e.clientX, e.clientY);
if (!target) { forgeDocClearDropHighlight(); return; }
const placement = forgeDocComputeDropPlacement(target, e.clientX, e.clientY);
forgeDocShowDropHighlight(target, placement.mode);
});
docPage.addEventListener('dragleave', (e) => {
if (e.target === docPage) forgeDocClearDropHighlight();
});
docPage.addEventListener('drop', (e) => {
e.preventDefault();
forgeDocClearDropHighlight();
const target = forgeDocFindDropTarget(e.clientX, e.clientY);
if (!target) return;
const placement = forgeDocComputeDropPlacement(target, e.clientX, e.clientY);
const addKind = e.dataTransfer.getData('text/forge-doc-add');
const moveId = e.dataTransfer.getData('text/forge-doc-move');
if (addKind) { forgeDocDropLibraryItem(addKind, placement); return; }
if (moveId) forgeDocDropExistingElement(Number.parseInt(moveId, 10), placement);
});
}
/* ---------------------------------------------------------------------
* Formes libres — position absolue, glissées à la souris (pas de HTML5
* DnD, pas de logique de rangée) avec grille d'accroche réelle.
* ------------------------------------------------------------------- */
// Même raison que FORGE_DOC_STYLE_PRESETS en tête de fichier.
// eslint-disable-next-line no-var, vars-on-top
var FORGE_DOC_SNAP_SIZE = 8;
function forgeDocBindShapeDrag(dom, elementId) {
dom.addEventListener('mousedown', (e) => {
if (window.forgeDocState.previewMode) return;
e.preventDefault();
e.stopPropagation();
forgeDocSelectElement(elementId);
const el = window.forgeDocState.elementsById[elementId];
window.forgeDocState.shapeDrag = {
id: elementId,
startX: e.clientX,
startY: e.clientY,
origX: el.attributes.x,
origY: el.attributes.y,
};
document.getElementById('docPage').classList.add('docPage--dragging-shape');
});
}
function forgeDocBindShapeDragGlobalHandlers() {
document.addEventListener('mousemove', (e) => {
const drag = window.forgeDocState.shapeDrag;
if (!drag) return;
const zoomFactor = window.forgeDocState.zoom / 100;
const dx = (e.clientX - drag.startX) / zoomFactor;
const dy = (e.clientY - drag.startY) / zoomFactor;
const newX = Math.round((drag.origX + dx) / FORGE_DOC_SNAP_SIZE) * FORGE_DOC_SNAP_SIZE;
const newY = Math.round((drag.origY + dy) / FORGE_DOC_SNAP_SIZE) * FORGE_DOC_SNAP_SIZE;
const dom = document.querySelector(`[data-element-id="${drag.id}"]`);
if (dom) { dom.style.left = `${newX}px`; dom.style.top = `${newY}px`; }
drag.lastX = newX;
drag.lastY = newY;
});
document.addEventListener('mouseup', () => {
const drag = window.forgeDocState.shapeDrag;
if (!drag) return;
window.forgeDocState.shapeDrag = null;
document.getElementById('docPage').classList.remove('docPage--dragging-shape');
if (typeof drag.lastX === 'number' && (drag.lastX !== drag.origX || drag.lastY !== drag.origY)) {
const el = window.forgeDocState.elementsById[drag.id];
forgeDocUpdateAttributes(drag.id, { ...el.attributes, x: drag.lastX, y: drag.lastY });
}
});
}
/* ---------------------------------------------------------------------
* Sélection & panneau Propriétés.
* ------------------------------------------------------------------- */
function forgeDocBindCanvasInteractions() {
document.querySelectorAll('#docPage [data-element-id]').forEach((dom) => {
const elementId = Number.parseInt(dom.dataset.elementId, 10);
dom.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocSelectElement(elementId);
});
if (forgeDocIsShapeKind(dom.dataset.kind)) {
forgeDocBindShapeDrag(dom, elementId);
} else {
dom.setAttribute('draggable', 'true');
dom.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('text/forge-doc-move', String(elementId));
e.dataTransfer.effectAllowed = 'move';
});
}
});
forgeDocInitQuizPlayers();
}
/* ---------------------------------------------------------------------
* 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);
});
});
}
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 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 forgeDocRenderMinigameProps(panel, el);
const deleteBtn = document.getElementById('docDeletePropBtn');
if (deleteBtn) deleteBtn.addEventListener('click', () => forgeDocDeleteElement(el.id));
}
/* ---------------------------------------------------------------------
* Chrome de l'éditeur : zoom, largeur d'aperçu responsive, Aperçu,
* thème (scopé à .docEditor3), navigation mobile.
* ------------------------------------------------------------------- */
function forgeDocBindZoom() {
const docPage = document.getElementById('docPage');
document.getElementById('docZoomOut').addEventListener('click', () => {
window.forgeDocState.zoom = Math.max(50, window.forgeDocState.zoom - 10);
document.getElementById('docZoomVal').textContent = `${window.forgeDocState.zoom}%`;
docPage.style.transform = `scale(${window.forgeDocState.zoom / 100})`;
});
document.getElementById('docZoomIn').addEventListener('click', () => {
window.forgeDocState.zoom = Math.min(150, window.forgeDocState.zoom + 10);
document.getElementById('docZoomVal').textContent = `${window.forgeDocState.zoom}%`;
docPage.style.transform = `scale(${window.forgeDocState.zoom / 100})`;
});
}
function forgeDocBindWidthPicker() {
document.querySelectorAll('.docWidthBtn').forEach((btn) => {
btn.addEventListener('click', () => {
document.querySelectorAll('.docWidthBtn').forEach((b) => b.classList.remove('is-active'));
btn.classList.add('is-active');
const docPage = document.getElementById('docPage');
const width = btn.dataset.width;
if (!width) {
docPage.classList.remove('docPage--fixedWidth');
docPage.style.removeProperty('--doc-preview-width');
} else {
docPage.classList.add('docPage--fixedWidth');
docPage.style.setProperty('--doc-preview-width', `${width}px`);
}
});
});
}
function forgeDocBindPreviewToggle() {
document.getElementById('docPreviewBtn').addEventListener('click', () => {
window.forgeDocState.previewMode = !window.forgeDocState.previewMode;
document.getElementById('docEditor3').classList.toggle('docEditor3--preview', window.forgeDocState.previewMode);
});
}
function forgeDocApplyTheme(theme) {
const editor = document.getElementById('docEditor3');
editor.dataset.theme = theme;
const toggle = document.getElementById('docThemeToggle');
toggle.innerHTML = theme === 'light'
? '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 14.5A8.5 8.5 0 1 1 9.5 4a7 7 0 0 0 10.5 10.5z"/></svg>'
: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M2 12h2M20 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"/></svg>';
}
function forgeDocBindThemeToggle() {
let stored = 'dark';
try {
const saved = window.localStorage.getItem('forge-doc-editor-theme');
if (saved === 'light' || saved === 'dark') stored = saved;
} catch (e) { /* stockage indisponible, on reste sur le thème par défaut */ }
forgeDocApplyTheme(stored);
document.getElementById('docThemeToggle').addEventListener('click', () => {
const next = document.getElementById('docEditor3').dataset.theme === 'light' ? 'dark' : 'light';
forgeDocApplyTheme(next);
try { window.localStorage.setItem('forge-doc-editor-theme', next); } catch (e) { /* ignore */ }
});
}
function forgeDocBindMobileNav() {
const left = document.getElementById('docSidebarLeft');
const right = document.getElementById('docSidebarRight');
document.querySelectorAll('#docBottomNav button').forEach((btn) => {
btn.addEventListener('click', () => {
forgeDocSetActiveBottomNav(btn.dataset.target);
left.classList.toggle('is-open', btn.dataset.target === 'left');
right.classList.toggle('is-open', btn.dataset.target === 'right');
});
});
function closePanels() {
left.classList.remove('is-open');
right.classList.remove('is-open');
forgeDocSetActiveBottomNav('doc');
}
document.getElementById('docCloseLeft').addEventListener('click', closePanels);
document.getElementById('docCloseRight').addEventListener('click', closePanels);
}
function forgeDocBindDeselectOnBackgroundClick() {
document.getElementById('docCanvasArea').addEventListener('click', (e) => {
if (e.target.closest('[data-element-id]')) return;
window.forgeDocState.selectedId = null;
forgeDocApplySelectionClass();
forgeDocRenderProps(null);
});
}
function forgeDocBindKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
if (!document.getElementById('docEditor3')) return;
const isUndo = (e.ctrlKey || e.metaKey) && !e.shiftKey && e.key.toLowerCase() === 'z';
const isRedo = ((e.ctrlKey || e.metaKey) && e.shiftKey && e.key.toLowerCase() === 'z')
|| ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y');
if (isUndo) { e.preventDefault(); forgeDocUndo(); }
if (isRedo) { e.preventDefault(); forgeDocRedo(); }
});
}
function forgeDocInit() {
const root = document.getElementById('docEditor3');
if (!root || !window.FORGE_DOCUMENT) return;
window.forgeDocState = {
slug: window.FORGE_DOCUMENT.slug,
elements: window.FORGE_DOCUMENT.elements,
elementsById: {},
selectedId: null,
undoStack: [],
redoStack: [],
previewMode: false,
zoom: 100,
shapeDrag: null,
};
forgeDocRebuildIndex();
forgeDocBindCanvasInteractions();
forgeDocBindLibraryDragHandlers();
forgeDocBindCanvasDropZone();
forgeDocBindShapeDragGlobalHandlers();
forgeDocBindDeselectOnBackgroundClick();
forgeDocBindZoom();
forgeDocBindWidthPicker();
forgeDocBindPreviewToggle();
forgeDocBindThemeToggle();
forgeDocBindMobileNav();
forgeDocBindKeyboardShortcuts();
forgeDocUpdateHistoryButtons();
document.getElementById('docUndoBtn').addEventListener('click', forgeDocUndo);
document.getElementById('docRedoBtn').addEventListener('click', forgeDocRedo);
if (window.innerWidth <= 900) {
document.getElementById('docSidebarLeft').classList.remove('is-open');
document.getElementById('docSidebarRight').classList.remove('is-open');
}
}
forgeDocInit();