document_engine/labels/association_config.py (nouveau) : modele de
donnees, meme convention resolve_X/sanitize_X que quiz_config.py -
DEFAULT_ASSOCIATION_CONFIG, sanitize_association_config (chaque paire
doit avoir ses deux cotes non vides, sinon supprimee silencieusement ;
liste tronquee a MAX_PAIRS=8).
Cote serveur, routes/document/document_element_update.py revalide
desormais aussi l'association avant persistance (meme raisonnement que
pour le quiz). Le rendu (_render_association) affiche un resume reel
(nombre de paires) et, des qu'au moins une paire existe, un plateau de
glisser-deposer REELEMENT interactif en Mode Apercu
(_render_association_player) : les deux colonnes (termes/
correspondances) sont melangees independamment (random.shuffle,
melange d'affichage documente dans CODE_QUALITY.md) puis embarquees en
JSON dans un attribut data-assoc-config.
Cote editeur, le panneau Proprietes d'une association ("relier
visuellement deux champs qui vont ensemble") est une liste de paires
repetable, chaque ligne reliant visuellement un champ "Element" et un
champ "Correspondance" par un glyphe ↔. Le plateau jouable en Apercu
(static/document/js/document-editor.js) supporte deux facons de jouer,
toutes deux reelles : glisser-deposer HTML5 natif, ou cliquer une
carte puis son emplacement (repli pour les appareils sans support
fiable du drag) - bonne association verrouillee en vert, mauvaise
signalee puis reinitialisee, ecran de resultat une fois toutes les
paires associees.
Bug reel trouve ET corrige via simulation DOM complete (glisser-
deposer + clic simules, pas juste un chargement de page) : le
feedback visuel reutilisait la classe CSS du quiz via une
reaffectation de className qui supprimait au passage la classe
d'identite docAssocFeedback, rendant l'element introuvable des le
premier essai de match (aurait plante en usage reel des la premiere
tentative). Corrige en gardant toujours les deux classes ensemble.
Tests : 8 tests purs (tests/document/test_association_config.py, sans
Flask) + 1 test de route verifiant la sanitization a l'ecriture.
SKIP=djlint : backlog H021 pre-existant, aucun template touche ici.
ruff/mypy --strict/vulture/bandit/import-linter/eslint/stylelint tous
verts ; 50 tests document verifies frais. Verification manuelle live
complete : ajout, sanitization sur paire invalide, rendu du plateau,
et simulation DOM du gameplay reel (glisser-deposer correct/incorrect,
clic-selection, progression, ecran de resultat) - script de
diagnostic non conserve dans le depot.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1314 lines
57 KiB
JavaScript
1314 lines
57 KiB
JavaScript
// document-editor.js — éditeur de support de formation (voir
|
|
// docs/plan/PLAN.md). État MUTABLE partagé sur window.forgeDocState
|
|
// (jamais let/const au premier niveau de ce fichier, voir static/pjax.js —
|
|
// un <script src> de page est rejoué tel quel à chaque navigation pjax,
|
|
// une redéclaration lèverait "already declared" ; seul `var` est sûr à
|
|
// rejouer). Toute mutation passe par le serveur (AJAX) puis rafraîchit le
|
|
// canevas depuis SA réponse (jamais un état simulé côté client) —
|
|
// Undo/Redo est une PILE DE COMMANDES qui rejoue ces mêmes appels AJAX en
|
|
// sens inverse, pas un simple instantané visuel.
|
|
|
|
// Constante immuable au premier niveau : `let`/`const` lèverait "already
|
|
// declared" à la 2e exécution de ce script par pjax.js (voir l'en-tête
|
|
// ci-dessus), `var` est le seul choix sûr ici.
|
|
// eslint-disable-next-line no-var, vars-on-top
|
|
var FORGE_DOC_STYLE_PRESETS = {
|
|
titre1: 'Titre 1',
|
|
titre2: 'Titre 2',
|
|
paragraphe: 'Paragraphe',
|
|
legende: 'Légende',
|
|
};
|
|
|
|
// Même raison que FORGE_DOC_STYLE_PRESETS ci-dessus.
|
|
// eslint-disable-next-line no-var, vars-on-top
|
|
var FORGE_DOC_SHAPE_KINDS = ['rectangle', 'cercle', 'triangle', 'trait'];
|
|
|
|
function forgeDocIsShapeKind(kind) {
|
|
return FORGE_DOC_SHAPE_KINDS.includes(kind);
|
|
}
|
|
|
|
function forgeDocChildrenOf(parentId) {
|
|
return window.forgeDocState.elements
|
|
.filter((el) => el.parent_id === parentId)
|
|
.sort((a, b) => a.order_index - b.order_index);
|
|
}
|
|
|
|
function forgeDocTopLevel() {
|
|
return forgeDocChildrenOf(null);
|
|
}
|
|
|
|
function forgeDocRebuildIndex() {
|
|
const byId = {};
|
|
window.forgeDocState.elements.forEach((el) => { byId[el.id] = el; });
|
|
window.forgeDocState.elementsById = byId;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Appels serveur — un seul point de vérité par opération (voir
|
|
* routes/document/), jamais dupliqué côté client.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocApiAdd(kind, parentId) {
|
|
const body = new URLSearchParams();
|
|
body.set('kind', kind);
|
|
if (parentId !== null && parentId !== undefined) body.set('parent_id', String(parentId));
|
|
return fetch(`/document/${window.forgeDocState.slug}/elements/add`, { method: 'POST', body })
|
|
.then((r) => r.json());
|
|
}
|
|
|
|
function forgeDocApiUpdate(id, attributes) {
|
|
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/update`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(attributes),
|
|
}).then((r) => r.json());
|
|
}
|
|
|
|
function forgeDocApiMove(id, newParentId, newIndex) {
|
|
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/move`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ new_parent_id: newParentId, new_index: newIndex }),
|
|
}).then((r) => r.json());
|
|
}
|
|
|
|
function forgeDocApiDelete(id) {
|
|
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/delete`, { method: 'POST' })
|
|
.then((r) => r.json());
|
|
}
|
|
|
|
function forgeDocApiRender() {
|
|
return fetch(`/document/${window.forgeDocState.slug}/render`).then((r) => r.json());
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Canevas — rafraîchi depuis la VÉRITÉ SERVEUR après chaque mutation
|
|
* structurelle (jamais une simple retouche DOM locale, voir
|
|
* routes/document/document_render.py).
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocApplySelectionClass() {
|
|
document.querySelectorAll('#docPage .docSelected').forEach((d) => d.classList.remove('docSelected'));
|
|
const { selectedId } = window.forgeDocState;
|
|
if (selectedId === null) return;
|
|
const dom = document.querySelector(`[data-element-id="${selectedId}"]`);
|
|
if (dom) dom.classList.add('docSelected');
|
|
}
|
|
|
|
function forgeDocRefreshCanvas() {
|
|
return forgeDocApiRender().then((data) => {
|
|
document.getElementById('docPageContent').innerHTML = data.html;
|
|
window.forgeDocState.elements = data.elements;
|
|
forgeDocRebuildIndex();
|
|
forgeDocBindCanvasInteractions();
|
|
const { selectedId } = window.forgeDocState;
|
|
if (selectedId !== null && window.forgeDocState.elementsById[selectedId]) {
|
|
forgeDocApplySelectionClass();
|
|
} else {
|
|
window.forgeDocState.selectedId = null;
|
|
forgeDocRenderProps(null);
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Undo/Redo — pile de COMMANDES {undo, redo}, chacune rejouant les mêmes
|
|
* appels AJAX que l'action d'origine (voir docs/plan/PLAN.md).
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocUpdateHistoryButtons() {
|
|
const undoBtn = document.getElementById('docUndoBtn');
|
|
const redoBtn = document.getElementById('docRedoBtn');
|
|
if (undoBtn) undoBtn.disabled = window.forgeDocState.undoStack.length === 0;
|
|
if (redoBtn) redoBtn.disabled = window.forgeDocState.redoStack.length === 0;
|
|
}
|
|
|
|
function forgeDocPushCommand(command) {
|
|
window.forgeDocState.undoStack.push(command);
|
|
window.forgeDocState.redoStack = [];
|
|
forgeDocUpdateHistoryButtons();
|
|
}
|
|
|
|
function forgeDocUndo() {
|
|
const command = window.forgeDocState.undoStack.pop();
|
|
if (!command) return;
|
|
command.undo().then(forgeDocRefreshCanvas).then(() => {
|
|
window.forgeDocState.redoStack.push(command);
|
|
forgeDocUpdateHistoryButtons();
|
|
});
|
|
}
|
|
|
|
function forgeDocRedo() {
|
|
const command = window.forgeDocState.redoStack.pop();
|
|
if (!command) return;
|
|
command.redo().then(forgeDocRefreshCanvas).then(() => {
|
|
window.forgeDocState.undoStack.push(command);
|
|
forgeDocUpdateHistoryButtons();
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Mutations de haut niveau — chacune exécute son action puis pousse la
|
|
* commande d'annulation correspondante.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocAddElement(kind, parentId) {
|
|
const state = { parentId: parentId === undefined ? null : parentId };
|
|
function perform() {
|
|
return forgeDocApiAdd(kind, state.parentId).then((result) => { state.id = result.id; });
|
|
}
|
|
return perform().then(() => {
|
|
forgeDocPushCommand({
|
|
undo: () => forgeDocApiDelete(state.id),
|
|
redo: perform,
|
|
});
|
|
return forgeDocRefreshCanvas().then(() => forgeDocSelectElement(state.id));
|
|
});
|
|
}
|
|
|
|
function forgeDocDeleteElement(id) {
|
|
const el = window.forgeDocState.elementsById[id];
|
|
if (!el) return Promise.resolve();
|
|
const state = {
|
|
id, parentId: el.parent_id, index: el.order_index, kind: el.kind, attributes: el.attributes,
|
|
};
|
|
return forgeDocApiDelete(state.id).then(() => {
|
|
forgeDocPushCommand({
|
|
undo: () => forgeDocApiAdd(state.kind, state.parentId).then((r) => {
|
|
state.id = r.id;
|
|
return forgeDocApiUpdate(state.id, state.attributes)
|
|
.then(() => forgeDocApiMove(state.id, state.parentId, state.index));
|
|
}),
|
|
redo: () => forgeDocApiDelete(state.id),
|
|
});
|
|
window.forgeDocState.selectedId = null;
|
|
return forgeDocRefreshCanvas();
|
|
});
|
|
}
|
|
|
|
function forgeDocUpdateAttributes(id, newAttributes) {
|
|
const el = window.forgeDocState.elementsById[id];
|
|
if (!el) return Promise.resolve();
|
|
const oldAttributes = { ...el.attributes };
|
|
return forgeDocApiUpdate(id, newAttributes).then((result) => {
|
|
forgeDocPushCommand({
|
|
undo: () => forgeDocApiUpdate(id, oldAttributes),
|
|
redo: () => forgeDocApiUpdate(id, newAttributes),
|
|
});
|
|
// Le serveur revalide/nettoie certains kinds (quiz — voir
|
|
// routes/document/document_element_update.py) : on adopte SA version,
|
|
// jamais celle envoyée telle quelle, pour ne jamais dériver de la
|
|
// vérité persistée (ex. un choix en trop silencieusement tronqué).
|
|
el.attributes = result.attributes || newAttributes;
|
|
const dom = document.querySelector(`[data-element-id="${id}"]`);
|
|
if (dom && result.rendered_html) {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.innerHTML = result.rendered_html;
|
|
dom.replaceWith(wrapper.firstElementChild);
|
|
forgeDocBindCanvasInteractions();
|
|
forgeDocApplySelectionClass();
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Moteur de layout — glisser-déposer réel (voir docs/plan/PLAN.md §1.2.B) :
|
|
* dépose sur la moitié gauche/droite de la boîte englobante d'un élément
|
|
* -> rejoint sa rangée (créée à la volée si besoin) ; moitié haute/basse
|
|
* -> nouvelle position au top-niveau, avant/après.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocFindDropTarget(clientX, clientY) {
|
|
const candidates = Array.from(document.querySelectorAll('#docPage [data-element-id]'))
|
|
.filter((dom) => !forgeDocIsShapeKind(dom.dataset.kind));
|
|
let best = null;
|
|
let bestDist = Infinity;
|
|
candidates.forEach((dom) => {
|
|
const rect = dom.getBoundingClientRect();
|
|
const dist = Math.hypot(clientX - (rect.left + rect.width / 2), clientY - (rect.top + rect.height / 2));
|
|
if (dist < bestDist) { bestDist = dist; best = dom; }
|
|
});
|
|
return best;
|
|
}
|
|
|
|
function forgeDocComputeDropPlacement(targetDom, clientX, clientY) {
|
|
const rect = targetDom.getBoundingClientRect();
|
|
const relX = (clientX - rect.left) / rect.width;
|
|
const relY = (clientY - rect.top) / rect.height;
|
|
const targetId = Number.parseInt(targetDom.dataset.elementId, 10);
|
|
const targetEl = window.forgeDocState.elementsById[targetId];
|
|
if (relY < 0.25) return { mode: 'before', targetEl };
|
|
if (relY > 0.75) return { mode: 'after', targetEl };
|
|
return { mode: relX < 0.5 ? 'mergeLeft' : 'mergeRight', targetEl };
|
|
}
|
|
|
|
function forgeDocShowDropHighlight(targetDom, mode) {
|
|
document.querySelectorAll('.docDropTarget').forEach((d) => {
|
|
d.classList.remove('docDropTarget', 'docDropTarget--before', 'docDropTarget--after', 'docDropTarget--mergeLeft', 'docDropTarget--mergeRight');
|
|
});
|
|
targetDom.classList.add('docDropTarget', `docDropTarget--${mode}`);
|
|
}
|
|
|
|
function forgeDocClearDropHighlight() {
|
|
document.querySelectorAll('.docDropTarget').forEach((d) => {
|
|
d.classList.remove('docDropTarget', 'docDropTarget--before', 'docDropTarget--after', 'docDropTarget--mergeLeft', 'docDropTarget--mergeRight');
|
|
});
|
|
}
|
|
|
|
function forgeDocDropLibraryItem(kind, placement) {
|
|
const { targetEl } = placement;
|
|
const state = {};
|
|
|
|
function performForward() {
|
|
if (placement.mode === 'mergeLeft' || placement.mode === 'mergeRight') {
|
|
if (targetEl.parent_id !== null) {
|
|
const siblings = forgeDocChildrenOf(targetEl.parent_id);
|
|
const pos = siblings.findIndex((s) => s.id === targetEl.id);
|
|
state.index = placement.mode === 'mergeLeft' ? pos : pos + 1;
|
|
state.createdRow = false;
|
|
return forgeDocApiAdd(kind, targetEl.parent_id).then((r) => {
|
|
state.newId = r.id;
|
|
return forgeDocApiMove(state.newId, targetEl.parent_id, state.index);
|
|
});
|
|
}
|
|
state.createdRow = true;
|
|
state.topPos = forgeDocTopLevel().findIndex((s) => s.id === targetEl.id);
|
|
return forgeDocApiAdd('row', null).then((rowResult) => {
|
|
state.rowId = rowResult.id;
|
|
return forgeDocApiMove(state.rowId, null, state.topPos)
|
|
.then(() => forgeDocApiMove(targetEl.id, state.rowId, 0))
|
|
.then(() => forgeDocApiAdd(kind, state.rowId))
|
|
.then((r) => {
|
|
state.newId = r.id;
|
|
state.index = placement.mode === 'mergeLeft' ? 0 : 1;
|
|
return forgeDocApiMove(state.newId, state.rowId, state.index);
|
|
});
|
|
});
|
|
}
|
|
const anchorId = targetEl.parent_id !== null ? targetEl.parent_id : targetEl.id;
|
|
const anchorPos = forgeDocTopLevel().findIndex((s) => s.id === anchorId);
|
|
state.index = placement.mode === 'before' ? anchorPos : anchorPos + 1;
|
|
state.createdRow = false;
|
|
return forgeDocApiAdd(kind, null).then((r) => {
|
|
state.newId = r.id;
|
|
return forgeDocApiMove(state.newId, null, state.index);
|
|
});
|
|
}
|
|
|
|
function performUndo() {
|
|
const deleteNew = forgeDocApiDelete(state.newId);
|
|
if (!state.createdRow) return deleteNew;
|
|
return deleteNew
|
|
.then(() => forgeDocApiMove(targetEl.id, null, state.topPos))
|
|
.then(() => forgeDocApiDelete(state.rowId));
|
|
}
|
|
|
|
return performForward().then(() => {
|
|
forgeDocPushCommand({ undo: performUndo, redo: performForward });
|
|
return forgeDocRefreshCanvas().then(() => forgeDocSelectElement(state.newId));
|
|
});
|
|
}
|
|
|
|
function forgeDocDropExistingElement(draggedId, placement) {
|
|
const draggedEl = window.forgeDocState.elementsById[draggedId];
|
|
const { targetEl } = placement;
|
|
if (!draggedEl || draggedEl.id === targetEl.id || targetEl.parent_id === draggedId) return Promise.resolve();
|
|
const isMerge = placement.mode === 'mergeLeft' || placement.mode === 'mergeRight';
|
|
if (draggedEl.kind === 'row' && isMerge) return Promise.resolve();
|
|
|
|
const fromParentId = draggedEl.parent_id;
|
|
const fromIndex = draggedEl.order_index;
|
|
const state = {};
|
|
|
|
function performForward() {
|
|
if (isMerge) {
|
|
if (targetEl.parent_id !== null) {
|
|
const siblings = forgeDocChildrenOf(targetEl.parent_id).filter((s) => s.id !== draggedId);
|
|
const pos = siblings.findIndex((s) => s.id === targetEl.id);
|
|
state.rowId = targetEl.parent_id;
|
|
state.index = placement.mode === 'mergeLeft' ? pos : pos + 1;
|
|
state.createdRow = false;
|
|
return forgeDocApiMove(draggedId, state.rowId, state.index);
|
|
}
|
|
state.createdRow = true;
|
|
state.topPos = forgeDocTopLevel().filter((s) => s.id !== draggedId).findIndex((s) => s.id === targetEl.id);
|
|
return forgeDocApiAdd('row', null).then((rowResult) => {
|
|
state.rowId = rowResult.id;
|
|
return forgeDocApiMove(state.rowId, null, state.topPos)
|
|
.then(() => forgeDocApiMove(targetEl.id, state.rowId, 0))
|
|
.then(() => {
|
|
state.index = placement.mode === 'mergeLeft' ? 0 : 1;
|
|
return forgeDocApiMove(draggedId, state.rowId, state.index);
|
|
});
|
|
});
|
|
}
|
|
const anchorId = targetEl.parent_id !== null ? targetEl.parent_id : targetEl.id;
|
|
const anchorPos = forgeDocTopLevel().filter((s) => s.id !== draggedId).findIndex((s) => s.id === anchorId);
|
|
state.index = placement.mode === 'before' ? anchorPos : anchorPos + 1;
|
|
state.createdRow = false;
|
|
return forgeDocApiMove(draggedId, null, state.index);
|
|
}
|
|
|
|
function performUndo() {
|
|
const restore = forgeDocApiMove(draggedId, fromParentId, fromIndex);
|
|
if (!state.createdRow) return restore;
|
|
return restore
|
|
.then(() => forgeDocApiMove(targetEl.id, null, state.topPos))
|
|
.then(() => forgeDocApiDelete(state.rowId));
|
|
}
|
|
|
|
return performForward().then(() => {
|
|
forgeDocPushCommand({ undo: performUndo, redo: performForward });
|
|
return forgeDocRefreshCanvas();
|
|
});
|
|
}
|
|
|
|
function forgeDocBindLibraryDragHandlers() {
|
|
document.querySelectorAll('.docLibItem[data-add]').forEach((item) => {
|
|
item.addEventListener('dragstart', (e) => {
|
|
e.dataTransfer.setData('text/forge-doc-add', item.dataset.add);
|
|
e.dataTransfer.effectAllowed = 'copy';
|
|
});
|
|
item.addEventListener('click', () => { forgeDocAddElement(item.dataset.add, null); });
|
|
});
|
|
}
|
|
|
|
function forgeDocBindCanvasDropZone() {
|
|
const docPage = document.getElementById('docPage');
|
|
docPage.addEventListener('dragover', (e) => {
|
|
const isAdd = e.dataTransfer.types.includes('text/forge-doc-add');
|
|
const isMove = e.dataTransfer.types.includes('text/forge-doc-move');
|
|
if (!isAdd && !isMove) return;
|
|
e.preventDefault();
|
|
const target = forgeDocFindDropTarget(e.clientX, e.clientY);
|
|
if (!target) { forgeDocClearDropHighlight(); return; }
|
|
const placement = forgeDocComputeDropPlacement(target, e.clientX, e.clientY);
|
|
forgeDocShowDropHighlight(target, placement.mode);
|
|
});
|
|
docPage.addEventListener('dragleave', (e) => {
|
|
if (e.target === docPage) forgeDocClearDropHighlight();
|
|
});
|
|
docPage.addEventListener('drop', (e) => {
|
|
e.preventDefault();
|
|
forgeDocClearDropHighlight();
|
|
const target = forgeDocFindDropTarget(e.clientX, e.clientY);
|
|
if (!target) return;
|
|
const placement = forgeDocComputeDropPlacement(target, e.clientX, e.clientY);
|
|
const addKind = e.dataTransfer.getData('text/forge-doc-add');
|
|
const moveId = e.dataTransfer.getData('text/forge-doc-move');
|
|
if (addKind) { forgeDocDropLibraryItem(addKind, placement); return; }
|
|
if (moveId) forgeDocDropExistingElement(Number.parseInt(moveId, 10), placement);
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Formes libres — position absolue, glissées à la souris (pas de HTML5
|
|
* DnD, pas de logique de rangée) avec grille d'accroche réelle.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
// Même raison que FORGE_DOC_STYLE_PRESETS en tête de fichier.
|
|
// eslint-disable-next-line no-var, vars-on-top
|
|
var FORGE_DOC_SNAP_SIZE = 8;
|
|
|
|
function forgeDocBindShapeDrag(dom, elementId) {
|
|
dom.addEventListener('mousedown', (e) => {
|
|
if (window.forgeDocState.previewMode) return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
forgeDocSelectElement(elementId);
|
|
const el = window.forgeDocState.elementsById[elementId];
|
|
window.forgeDocState.shapeDrag = {
|
|
id: elementId,
|
|
startX: e.clientX,
|
|
startY: e.clientY,
|
|
origX: el.attributes.x,
|
|
origY: el.attributes.y,
|
|
};
|
|
document.getElementById('docPage').classList.add('docPage--dragging-shape');
|
|
});
|
|
}
|
|
|
|
function forgeDocBindShapeDragGlobalHandlers() {
|
|
document.addEventListener('mousemove', (e) => {
|
|
const drag = window.forgeDocState.shapeDrag;
|
|
if (!drag) return;
|
|
const zoomFactor = window.forgeDocState.zoom / 100;
|
|
const dx = (e.clientX - drag.startX) / zoomFactor;
|
|
const dy = (e.clientY - drag.startY) / zoomFactor;
|
|
const newX = Math.round((drag.origX + dx) / FORGE_DOC_SNAP_SIZE) * FORGE_DOC_SNAP_SIZE;
|
|
const newY = Math.round((drag.origY + dy) / FORGE_DOC_SNAP_SIZE) * FORGE_DOC_SNAP_SIZE;
|
|
const dom = document.querySelector(`[data-element-id="${drag.id}"]`);
|
|
if (dom) { dom.style.left = `${newX}px`; dom.style.top = `${newY}px`; }
|
|
drag.lastX = newX;
|
|
drag.lastY = newY;
|
|
});
|
|
document.addEventListener('mouseup', () => {
|
|
const drag = window.forgeDocState.shapeDrag;
|
|
if (!drag) return;
|
|
window.forgeDocState.shapeDrag = null;
|
|
document.getElementById('docPage').classList.remove('docPage--dragging-shape');
|
|
if (typeof drag.lastX === 'number' && (drag.lastX !== drag.origX || drag.lastY !== drag.origY)) {
|
|
const el = window.forgeDocState.elementsById[drag.id];
|
|
forgeDocUpdateAttributes(drag.id, { ...el.attributes, x: drag.lastX, y: drag.lastY });
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* Sélection & panneau Propriétés.
|
|
* ------------------------------------------------------------------- */
|
|
|
|
function forgeDocBindCanvasInteractions() {
|
|
document.querySelectorAll('#docPage [data-element-id]').forEach((dom) => {
|
|
const elementId = Number.parseInt(dom.dataset.elementId, 10);
|
|
dom.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
forgeDocSelectElement(elementId);
|
|
});
|
|
if (forgeDocIsShapeKind(dom.dataset.kind)) {
|
|
forgeDocBindShapeDrag(dom, elementId);
|
|
} else {
|
|
dom.setAttribute('draggable', 'true');
|
|
dom.addEventListener('dragstart', (e) => {
|
|
e.dataTransfer.setData('text/forge-doc-move', String(elementId));
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
}
|
|
});
|
|
forgeDocInitQuizPlayers();
|
|
forgeDocInitAssociationPlayers();
|
|
}
|
|
|
|
/* ---------------------------------------------------------------------
|
|
* 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) {
|
|
playerEl.querySelector('.docAssocCard').style.display = 'none';
|
|
playerEl.querySelector('.docAssocResultCard').style.display = 'block';
|
|
}
|
|
}
|
|
|
|
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('.docAssocResultCard').style.display = 'none';
|
|
playerEl.querySelector('.docAssocCard').style.display = 'block';
|
|
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);
|
|
});
|
|
});
|
|
}
|
|
|
|
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() {
|
|
return { left: '', right: '' };
|
|
}
|
|
|
|
function forgeDocRenderAssociationPairHtml(pair, pIndex) {
|
|
return `
|
|
<div class="docAssocPairRow" data-pair-index="${pIndex}">
|
|
<input type="text"
|
|
class="docAssocPairInput"
|
|
value="${pair.left}"
|
|
placeholder="Élément"
|
|
data-pair-index="${pIndex}"
|
|
data-side="left">
|
|
<span class="docAssocPairLink" aria-hidden="true">↔</span>
|
|
<input type="text"
|
|
class="docAssocPairInput"
|
|
value="${pair.right}"
|
|
placeholder="Correspondance"
|
|
data-pair-index="${pIndex}"
|
|
data-side="right">
|
|
<button type="button" class="docQuizRemoveBtn" data-pair-index="${pIndex}" title="Supprimer cette paire">🗑</button>
|
|
</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 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 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();
|