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
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:
co-authored by
Claude Sonnet 5
parent
ecea430ee2
commit
ecd483f352
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user