Implemente un systeme de pages pour le support de formation
Build and deploy / test-python (push) Successful in 7m48s
Build and deploy / test-js (push) Successful in 52s
Build and deploy / lint-python (push) Successful in 5m44s
Build and deploy / lint-js (push) Failing after 1m52s
Build and deploy / build-and-push (push) Skipped
Build and deploy / deploy (push) Skipped
Build and deploy / sonarqube (push) Failing after 5m5s

Retour utilisateur : "il faut implementer un systeme de page". Un
support est desormais compose de PLUSIEURS pages (_document_pages),
chacune un document independant affiche seul sur le canevas -- chaque
element appartient a exactement une page via page_id (document_engine/
elements/*, routes/document/document_element_add.py/document_render.py
revalident desormais un page_id explicite). Migration automatique et
silencieuse pour les supports crees avant cette fonctionnalite
(db/supports/ensure_document_pages_schema.py, meme convention que les
ensure_X_schema.py existants) : leurs elements deviennent tous les
enfants d'une "Page 1" creee a la volee, aucune perte de contenu.

Nouveau paquet document_engine/pages/ (add/list/get/rename/move/delete)
et 4 routes dediees (routes/document/document_page_*.py) -- supprimer
la DERNIERE page restante est refuse (garde-fou pose a la route, meme
decoupage que routes/game/screens/screen_delete.py cote jeu, jamais
dans la fonction bas niveau).

Cote editeur : une bande d'ONGLETS au-dessus du canevas (jamais un
panneau lateral, choix explicite de l'utilisateur) -- clic pour changer
de page, double-clic pour renommer (contenteditable), glisser pour
reordonner, "+" pour ajouter, "x" pour supprimer. Changer de page vide
la pile Annuler/Retablir (une commande empilee sur une autre page n'a
plus de sens). Mode Apercu : navigation Page precedente/suivante avec
indicateur "Page X / N" (choix explicite : page par page, pas de
defilement continu), jamais affichee s'il n'y a qu'une seule page.

Verifie : suite pytest complete (702 tests, dont 14 nouveaux pour les
routes de pages), simulation DOM reelle (jsdom, 25 assertions couvrant
tout le cycle de vie cote client -- creation/bascule/renommage/
reordonnancement/suppression de page, portee correcte des elements par
page, pile Annuler/Retablir videe au changement de page, pilule de
navigation en Apercu), et un test de fumee HTTP reel contre le serveur
de dev en marche (creation/ajout d'element/rendu/renommage/suppression
d'une page, refus de supprimer la derniere page, page inconnue -> 404).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-21 13:24:23 +02:00
co-authored by Claude Sonnet 5
parent ecea430ee2
commit ecd483f352
33 changed files with 1048 additions and 110 deletions
+263 -1
View File
@@ -49,8 +49,15 @@ function forgeDocRebuildIndex() {
* ------------------------------------------------------------------- */
function forgeDocApiAdd(kind, parentId) {
// page_id lu depuis l'état ambiant (window.forgeDocState.activePageId),
// jamais un paramètre explicite — même principe que le slug déjà lu
// ainsi juste en dessous : tout appelant de cette fonction ajoute par
// définition à "la page actuellement affichée", inutile de le faire
// remonter dans chacun des ~10 sites d'appel (glisser-déposer, moteur
// de layout, Annuler/Rétablir...).
const body = new URLSearchParams();
body.set('kind', kind);
body.set('page_id', String(window.forgeDocState.activePageId));
if (parentId !== null && parentId !== undefined) body.set('parent_id', String(parentId));
return fetch(`/document/${window.forgeDocState.slug}/elements/add`, { method: 'POST', body })
.then((r) => r.json());
@@ -78,7 +85,8 @@ function forgeDocApiDelete(id) {
}
function forgeDocApiRender() {
return fetch(`/document/${window.forgeDocState.slug}/render`).then((r) => r.json());
const url = `/document/${window.forgeDocState.slug}/render?page_id=${window.forgeDocState.activePageId}`;
return fetch(url).then((r) => r.json());
}
/* ---------------------------------------------------------------------
@@ -401,6 +409,254 @@ function forgeDocBindCanvasDropZone() {
});
}
/* ---------------------------------------------------------------------
* Pages — un support est composé de plusieurs pages (retour utilisateur
* du 21/09/2026 : "il faut implémenter un système de page"), chacune un
* document indépendant affiché SEUL sur le canevas à la fois. Bande
* d'ONGLETS au-dessus du canevas pour créer/renommer (double-clic)/
* réordonner (glisser)/supprimer (voir templates/document/
* document_edit.html) — jamais un panneau latéral (retour utilisateur).
* Changer de page VIDE la pile Annuler/Rétablir (voir forgeDocSwitchPage)
* : une commande empilée sur une autre page rejouerait un appel AJAX
* ciblant des éléments qui ne sont plus affichés, aucun sens à la garder.
* ------------------------------------------------------------------- */
function forgeDocApiPageAdd(title) {
return fetch(`/document/${window.forgeDocState.slug}/pages/add`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(title ? { title } : {}),
}).then((r) => r.json());
}
function forgeDocApiPageRename(pageId, title) {
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/rename`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title }),
}).then((r) => r.json());
}
function forgeDocApiPageDelete(pageId) {
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/delete`, { method: 'POST' })
.then((r) => r.json());
}
function forgeDocApiPageMove(pageId, newIndex) {
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/move`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ new_index: newIndex }),
}).then((r) => r.json());
}
function forgeDocOrderedPages() {
return [...window.forgeDocState.pages].sort((a, b) => a.order_index - b.order_index);
}
function forgeDocUpdatePageNavPill() {
const pill = document.getElementById('docPageNavPill');
if (!pill) return;
const pages = forgeDocOrderedPages();
const show = window.forgeDocState.previewMode && pages.length > 1;
pill.classList.toggle('is-visible', show);
if (!show) return;
const index = pages.findIndex((p) => p.id === window.forgeDocState.activePageId);
document.getElementById('docPageNavLabel').textContent = `Page ${index + 1} / ${pages.length}`;
document.getElementById('docPagePrevBtn').disabled = index <= 0;
document.getElementById('docPageNextBtn').disabled = index >= pages.length - 1;
}
function forgeDocSwitchPage(pageId) {
if (pageId === window.forgeDocState.activePageId) return Promise.resolve();
window.forgeDocState.activePageId = pageId;
window.forgeDocState.undoStack = [];
window.forgeDocState.redoStack = [];
forgeDocUpdateHistoryButtons();
window.forgeDocState.selectedId = null;
forgeDocRenderProps(null);
forgeDocRenderPageTabs();
forgeDocUpdatePageNavPill();
return forgeDocRefreshCanvas();
}
function forgeDocAddPage() {
return forgeDocApiPageAdd().then((result) => {
window.forgeDocState.pages.push({
id: result.id, title: result.title, order_index: window.forgeDocState.pages.length,
});
return forgeDocSwitchPage(result.id);
});
}
function forgeDocRenamePage(pageId, title) {
return forgeDocApiPageRename(pageId, title).then((result) => {
const page = window.forgeDocState.pages.find((p) => p.id === pageId);
if (page) page.title = result.title;
forgeDocRenderPageTabs();
});
}
function forgeDocDeletePage(pageId) {
// Filet de sécurité client (en plus du refus serveur, voir
// routes/document/document_page_delete.py) : jamais même tenter de
// supprimer la dernière page restante.
if (window.forgeDocState.pages.length <= 1) return Promise.resolve();
return forgeDocApiPageDelete(pageId).then((result) => {
if (result.error) {
alert(result.error);
return Promise.resolve();
}
window.forgeDocState.pages = window.forgeDocState.pages.filter((p) => p.id !== pageId);
if (window.forgeDocState.activePageId === pageId) {
return forgeDocSwitchPage(forgeDocOrderedPages()[0].id);
}
forgeDocRenderPageTabs();
return Promise.resolve();
});
}
function forgeDocSelectAllText(el) {
const range = document.createRange();
range.selectNodeContents(el);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
}
function forgeDocStartRenamingPageTab(titleEl) {
const tab = titleEl.closest('.docPageTab');
const pageId = Number(tab.dataset.pageId);
const original = titleEl.textContent;
titleEl.setAttribute('contenteditable', 'true');
titleEl.focus();
forgeDocSelectAllText(titleEl);
function stopEditing() {
titleEl.removeAttribute('contenteditable');
titleEl.removeEventListener('blur', onBlur);
titleEl.removeEventListener('keydown', onKeydown);
}
function onBlur() {
const newTitle = titleEl.textContent.trim();
stopEditing();
if (!newTitle || newTitle === original) {
titleEl.textContent = original;
return;
}
forgeDocRenamePage(pageId, newTitle);
}
function onKeydown(e) {
if (e.key === 'Enter') {
e.preventDefault();
titleEl.blur();
} else if (e.key === 'Escape') {
e.preventDefault();
titleEl.textContent = original;
stopEditing();
}
}
titleEl.addEventListener('blur', onBlur);
titleEl.addEventListener('keydown', onKeydown);
}
function forgeDocRenderPageTabs() {
const container = document.getElementById('docPageTabs');
if (!container) return;
const pages = forgeDocOrderedPages();
container.classList.toggle('docPageTabs--single', pages.length <= 1);
const tabsHtml = pages.map((p) => `
<button type="button"
class="docPageTab ${p.id === window.forgeDocState.activePageId ? 'is-active' : ''}"
data-page-id="${p.id}"
draggable="true">
<span class="docPageTabTitle">${forgeDocEscapeHtml(p.title)}</span>
<span class="docPageTabDelete" data-page-id="${p.id}" title="Supprimer cette page">✕</span>
</button>
`).join('');
container.innerHTML = `${tabsHtml}<button type="button" class="docPageTabAdd" id="docPageTabAdd" aria-label="Ajouter une page" title="Ajouter une page">+</button>`;
container.querySelectorAll('.docPageTab').forEach((tab) => {
tab.addEventListener('click', (e) => {
if (e.target.classList.contains('docPageTabDelete')) return;
forgeDocSwitchPage(Number(tab.dataset.pageId));
});
});
container.querySelectorAll('.docPageTabTitle').forEach((titleEl) => {
titleEl.addEventListener('dblclick', (e) => {
e.stopPropagation();
forgeDocStartRenamingPageTab(titleEl);
});
});
container.querySelectorAll('.docPageTabDelete').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
forgeDocDeletePage(Number(btn.dataset.pageId));
});
});
document.getElementById('docPageTabAdd').addEventListener('click', () => forgeDocAddPage());
}
function forgeDocBindPageTabsDrag() {
// Délégation sur le CONTENEUR (jamais les onglets eux-mêmes, recréés à
// chaque forgeDocRenderPageTabs) : un seul jeu de listeners pour toute
// la session, même principe que forgeDocMotsBindGlobalMouseup.
const container = document.getElementById('docPageTabs');
let draggedId = null;
container.addEventListener('dragstart', (e) => {
const tab = e.target.closest('.docPageTab');
if (!tab) return;
draggedId = Number(tab.dataset.pageId);
tab.classList.add('is-dragging');
e.dataTransfer.effectAllowed = 'move';
});
container.addEventListener('dragend', (e) => {
const tab = e.target.closest('.docPageTab');
if (tab) tab.classList.remove('is-dragging');
});
container.addEventListener('dragover', (e) => {
if (draggedId !== null) e.preventDefault();
});
container.addEventListener('drop', (e) => {
e.preventDefault();
if (draggedId === null) return;
const targetTab = e.target.closest('.docPageTab');
const ordered = forgeDocOrderedPages();
const newIndex = targetTab
? ordered.findIndex((p) => p.id === Number(targetTab.dataset.pageId))
: ordered.length - 1;
const movedId = draggedId;
draggedId = null;
forgeDocApiPageMove(movedId, newIndex).then(() => {
const moved = window.forgeDocState.pages.find((p) => p.id === movedId);
const rest = window.forgeDocState.pages
.filter((p) => p.id !== movedId)
.sort((a, b) => a.order_index - b.order_index);
rest.splice(newIndex, 0, moved);
rest.forEach((p, i) => { p.order_index = i; });
window.forgeDocState.pages = rest;
forgeDocRenderPageTabs();
});
});
}
function forgeDocBindPageNavPill() {
document.getElementById('docPagePrevBtn').addEventListener('click', () => {
const pages = forgeDocOrderedPages();
const index = pages.findIndex((p) => p.id === window.forgeDocState.activePageId);
if (index > 0) forgeDocSwitchPage(pages[index - 1].id);
});
document.getElementById('docPageNextBtn').addEventListener('click', () => {
const pages = forgeDocOrderedPages();
const index = pages.findIndex((p) => p.id === window.forgeDocState.activePageId);
if (index < pages.length - 1) forgeDocSwitchPage(pages[index + 1].id);
});
}
/* ---------------------------------------------------------------------
* Formes libres — position absolue, glissées à la souris (pas de HTML5
* DnD, pas de logique de rangée) avec grille d'accroche réelle.
@@ -2500,6 +2756,7 @@ function forgeDocBindPreviewToggle() {
document.getElementById('docPreviewBtn').addEventListener('click', () => {
window.forgeDocState.previewMode = !window.forgeDocState.previewMode;
document.getElementById('docEditor3').classList.toggle('docEditor3--preview', window.forgeDocState.previewMode);
forgeDocUpdatePageNavPill();
});
}
@@ -2579,6 +2836,8 @@ function forgeDocInit() {
slug: window.FORGE_DOCUMENT.slug,
elements: window.FORGE_DOCUMENT.elements,
elementsById: {},
pages: window.FORGE_DOCUMENT.pages,
activePageId: window.FORGE_DOCUMENT.activePageId,
selectedId: null,
undoStack: [],
redoStack: [],
@@ -2600,6 +2859,9 @@ function forgeDocInit() {
forgeDocBindMobileNav();
forgeDocBindKeyboardShortcuts();
forgeDocBindScenarioTreeModal();
forgeDocRenderPageTabs();
forgeDocBindPageTabsDrag();
forgeDocBindPageNavPill();
forgeDocUpdateHistoryButtons();
document.getElementById('docUndoBtn').addEventListener('click', forgeDocUndo);