Pagination automatique : le contenu qui déborde part sur une nouvelle page

Retour utilisateur du 23/09/2026 : "si il n'y a plus de place sur la
page il faut automatiquement créer une autre page [et y] coller le
contenu et amener l'utilisateur sur la page" — remplace le
comportement précédent (overflow:hidden, contenu clipsé, à gérer
manuellement).

- Nouvelle capacité serveur : document_engine.move_document_element_to_page
  (+ route POST /document/<slug>/elements/<id>/move-to-page) déplace un
  élément (et ses enfants de rangée en cascade) vers une AUTRE page —
  jusqu'ici move_document_element ne gérait que le réordonnancement DANS
  la même page.
- Client : forgeDocCheckPageOverflow, appelée à la fin de CHAQUE
  forgeDocRefreshCanvas (point d'entrée unique après toute mutation) :
  mesure le débordement réel (scrollHeight vs clientHeight), trouve le
  premier élément top-niveau qui dépasse le bas de la page
  (getBoundingClientRect, tient compte du zoom), déplace cet élément et
  tout ce qui le suit vers une page neuve, puis y bascule l'utilisateur.
  Jamais déclenché sur une page mini-jeu (toujours seule sur sa page,
  aucun débordement pertinent à corriger).

Vérifié par un test jsdom dédié (géométrie simulée via
getBoundingClientRect/scrollHeight/clientHeight, jsdom n'ayant pas de
vrai moteur de mise en page) : ordre des déplacements, page inchangée
si le contenu tient, page mini-jeu jamais scindée. 6 nouveaux tests
Python (document_engine + route). 711/711 tests passent.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-23 11:50:23 +02:00
co-authored by Claude Sonnet 5
parent 0e8c6efb06
commit c6e173589f
8 changed files with 224 additions and 1 deletions
+49 -1
View File
@@ -90,6 +90,14 @@ function forgeDocApiDelete(id) {
.then((r) => r.json());
}
function forgeDocApiMoveToPage(id, targetPageId) {
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/move-to-page`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ target_page_id: targetPageId }),
}).then((r) => r.json());
}
function forgeDocApiRender() {
const url = `/document/${window.forgeDocState.slug}/render?page_id=${window.forgeDocState.activePageId}`;
return fetch(url).then((r) => r.json());
@@ -109,6 +117,46 @@ function forgeDocApplySelectionClass() {
if (dom) dom.classList.add('docSelected');
}
function forgeDocCheckPageOverflow() {
// Pagination automatique (retour utilisateur du 23/09/2026 : "si il
// n'y a plus de place sur la page il faut automatiquement créer une
// autre page [et y] coller le contenu [...] et non [...] adapter la
// taille du contenu pour rester sur la page") — jamais sur une page
// mini-jeu (toujours seul sur sa page par construction, voir
// routes/document/document_element_add.py ; .docPageContent y est
// délibérément height:100% donc scrollHeight === clientHeight, aucun
// débordement réel n'est possible/pertinent à corriger là).
const contentEl = document.getElementById('docPageContent');
const pageEl = document.getElementById('docPage');
if (!contentEl || !pageEl || contentEl.querySelector('.docMinigame')) return Promise.resolve();
if (contentEl.scrollHeight <= contentEl.clientHeight + 1) return Promise.resolve();
// Le PREMIER élément top-niveau qui dépasse le bas de la page, et tout
// ce qui le suit dans l'ordre, part ensemble sur une nouvelle page —
// measuré via getBoundingClientRect (tient compte du zoom CSS
// transform éventuel), jamais une estimation par hauteur cumulée qui
// ignorerait marges/gap réels.
const topLevel = forgeDocTopLevel();
const pageBottom = pageEl.getBoundingClientRect().bottom;
let splitIndex = -1;
topLevel.forEach((el, index) => {
if (splitIndex !== -1) return;
const dom = document.querySelector(`#docPage [data-element-id="${el.id}"]`);
if (dom && dom.getBoundingClientRect().bottom > pageBottom + 0.5) splitIndex = index;
});
if (splitIndex === -1) return Promise.resolve();
const overflowing = topLevel.slice(splitIndex);
return forgeDocApiPageAdd().then((result) => {
window.forgeDocState.pages.push({
id: result.id, title: result.title, order_index: window.forgeDocState.pages.length,
});
return overflowing
.reduce((chain, el) => chain.then(() => forgeDocApiMoveToPage(el.id, result.id)), Promise.resolve())
.then(() => forgeDocSwitchPage(result.id));
});
}
function forgeDocRefreshCanvas() {
return forgeDocApiRender().then((data) => {
document.getElementById('docPageContent').innerHTML = data.html;
@@ -122,7 +170,7 @@ function forgeDocRefreshCanvas() {
window.forgeDocState.selectedId = null;
forgeDocRenderProps(null);
}
});
}).then(() => forgeDocCheckPageOverflow());
}
/* ---------------------------------------------------------------------