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
+2
View File
@@ -27,6 +27,7 @@ from .elements.delete_document_element import delete_document_element
from .elements.get_document_element import get_document_element from .elements.get_document_element import get_document_element
from .elements.list_document_elements import list_document_elements from .elements.list_document_elements import list_document_elements
from .elements.move_document_element import move_document_element from .elements.move_document_element import move_document_element
from .elements.move_document_element_to_page import move_document_element_to_page
from .elements.update_document_element_attributes import update_document_element_attributes from .elements.update_document_element_attributes import update_document_element_attributes
from .labels.association_config import ( from .labels.association_config import (
DEFAULT_ASSOCIATION_CONFIG, DEFAULT_ASSOCIATION_CONFIG,
@@ -109,6 +110,7 @@ __all__ = [
"list_document_elements", "list_document_elements",
"list_document_pages", "list_document_pages",
"move_document_element", "move_document_element",
"move_document_element_to_page",
"move_document_page", "move_document_page",
"quiz_total_points", "quiz_total_points",
"render_document", "render_document",
+18
View File
@@ -57,6 +57,24 @@ affichée).
inexistant ne fait rien (retour silencieux après vérification de son inexistant ne fait rien (retour silencieux après vérification de son
existence). existence).
## `move_document_element_to_page(slug: str, element_id: int, target_page_id: int) -> None`
Déplace un élément vers une AUTRE page du même support — utilisé par la
pagination automatique (retour utilisateur du 23/09/2026 : quand le
contenu déborde d'une page, l'élément en trop est déplacé vers une
nouvelle page plutôt que d'y rester tassé). L'élément redevient TOUJOURS
top-niveau sur la page cible (`parent_id` remis à `NULL`) — une rangée
qui existait sur l'ancienne page n'a aucun sens comme enfant d'une
rangée de la page cible. Si l'élément déplacé est lui-même une rangée,
ses enfants directs (même `parent_id`) SUIVENT sur la page cible
(`page_id` mis à jour en cascade, `parent_id` inchangé) — sans cette
cascade ils resteraient orphelins d'une page qu'ils n'occupent plus
(`list_document_elements`, filtré par `page_id`, ne les retrouverait
plus). Renumérote les anciens frères après le retrait.
- **Retour** : aucun.
- **Exceptions** : aucune levée explicitement ; un `element_id`
inexistant ne fait rien (retour silencieux après vérification de son
existence).
## `delete_document_element(slug: str, element_id: int) -> None` ## `delete_document_element(slug: str, element_id: int) -> None`
Supprime un élément. La contrainte `FOREIGN KEY ... ON DELETE CASCADE` Supprime un élément. La contrainte `FOREIGN KEY ... ON DELETE CASCADE`
(voir `db/supports/create_support.py`) retire automatiquement ses (voir `db/supports/create_support.py`) retire automatiquement ses
@@ -0,0 +1,47 @@
from db.supports import connect_support, ensure_document_pages_schema
def move_document_element_to_page(slug: str, element_id: int, target_page_id: int) -> None:
"""Déplace un élément vers une AUTRE page du même support — utilisé
par la 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 déplacer] le contenu", voir
static/document/js/document-editor.js, forgeDocCheckPageOverflow).
L'élément redevient TOUJOURS top-niveau sur la page cible (parent_id
NULL) — une rangée qui existait sur l'ancienne page n'a aucun sens
comme enfant d'une rangée de la page cible. Si l'élément déplacé est
lui-même une rangée, ses enfants directs (même parent_id) SUIVENT sur
la page cible (cascade sur page_id, parent_id inchangé) : sans cette
cascade, list_document_elements (filtré par page_id) ne les
retrouverait plus, alors qu'ils resteraient en base rattachés à une
rangée désormais sur une autre page — état incohérent silencieux."""
ensure_document_pages_schema(slug)
conn = connect_support(slug)
row = conn.execute("SELECT page_id, parent_id FROM _document_elements WHERE id = ?", (element_id,)).fetchone()
if not row:
conn.close()
return
old_page_id = row["page_id"]
old_parent_id = row["parent_id"]
old_siblings = [
r["id"]
for r in conn.execute(
"SELECT id FROM _document_elements WHERE page_id = ? AND parent_id IS ? AND id != ? ORDER BY order_index",
(old_page_id, old_parent_id, element_id),
).fetchall()
]
for index, sibling_id in enumerate(old_siblings):
conn.execute("UPDATE _document_elements SET order_index = ? WHERE id = ?", (index, sibling_id))
new_top_level_count = conn.execute(
"SELECT COUNT(*) AS n FROM _document_elements WHERE page_id = ? AND parent_id IS NULL", (target_page_id,)
).fetchone()["n"]
conn.execute(
"UPDATE _document_elements SET page_id = ?, parent_id = NULL, order_index = ? WHERE id = ?",
(target_page_id, new_top_level_count, element_id),
)
conn.execute("UPDATE _document_elements SET page_id = ? WHERE parent_id = ?", (target_page_id, element_id))
conn.commit()
conn.close()
+1
View File
@@ -9,6 +9,7 @@ from . import ( # noqa: F401 - enregistre les routes definies dans chaque modul
document_element_add, document_element_add,
document_element_delete, document_element_delete,
document_element_move, document_element_move,
document_element_move_to_page,
document_element_update, document_element_update,
document_new, document_new,
document_page_add, document_page_add,
@@ -0,0 +1,24 @@
from flask import jsonify, request
from werkzeug.wrappers import Response
import document_engine
from core.flask_app import app
@app.route("/document/<slug>/elements/<int:element_id>/move-to-page", methods=["POST"])
def document_element_move_to_page(slug: str, element_id: int) -> Response | tuple[Response, int]:
"""Déplace un élément vers une AUTRE page du support — appelée par la
pagination automatique côté client (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", voir
static/document/js/document-editor.js, forgeDocCheckPageOverflow),
jamais par un glisser-déposer manuel (qui reste sur
document_element_move, réordonnancement dans la MÊME page)."""
payload = request.get_json(force=True) or {}
target_page_id = payload.get("target_page_id")
if not isinstance(target_page_id, int) or isinstance(target_page_id, bool):
return jsonify({"error": "page cible introuvable"}), 404
if document_engine.get_document_page(slug, target_page_id) is None:
return jsonify({"error": "page cible introuvable"}), 404
document_engine.move_document_element_to_page(slug, element_id, target_page_id)
return jsonify({"ok": True})
+49 -1
View File
@@ -90,6 +90,14 @@ function forgeDocApiDelete(id) {
.then((r) => r.json()); .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() { function forgeDocApiRender() {
const url = `/document/${window.forgeDocState.slug}/render?page_id=${window.forgeDocState.activePageId}`; const url = `/document/${window.forgeDocState.slug}/render?page_id=${window.forgeDocState.activePageId}`;
return fetch(url).then((r) => r.json()); return fetch(url).then((r) => r.json());
@@ -109,6 +117,46 @@ function forgeDocApplySelectionClass() {
if (dom) dom.classList.add('docSelected'); 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() { function forgeDocRefreshCanvas() {
return forgeDocApiRender().then((data) => { return forgeDocApiRender().then((data) => {
document.getElementById('docPageContent').innerHTML = data.html; document.getElementById('docPageContent').innerHTML = data.html;
@@ -122,7 +170,7 @@ function forgeDocRefreshCanvas() {
window.forgeDocState.selectedId = null; window.forgeDocState.selectedId = null;
forgeDocRenderProps(null); forgeDocRenderProps(null);
} }
}); }).then(() => forgeDocCheckPageOverflow());
} }
/* --------------------------------------------------------------------- /* ---------------------------------------------------------------------
+54
View File
@@ -126,3 +126,57 @@ def test_render_document_includes_row_children(tmp_support_slug_cleanup: Any) ->
assert 'data-kind="row"' in html assert 'data-kind="row"' in html
assert 'data-kind="titre"' in html assert 'data-kind="titre"' in html
assert f'data-element-id="{titre_id}"' in html assert f'data-element-id="{titre_id}"' in html
def test_move_document_element_to_page_transfers_a_top_level_element(tmp_support_slug_cleanup: Any) -> None:
slug, page1_id = _new_support(tmp_support_slug_cleanup)
page2_id = doc_engine.add_document_page(slug)
element_id = doc_engine.add_document_element(slug, "titre", page_id=page1_id)
doc_engine.move_document_element_to_page(slug, element_id, page2_id)
moved = doc_engine.get_document_element(slug, element_id)
assert moved is not None
assert moved["page_id"] == page2_id
assert moved["parent_id"] is None
assert [el["id"] for el in doc_engine.list_document_elements(slug, page1_id)] == []
assert [el["id"] for el in doc_engine.list_document_elements(slug, page2_id)] == [element_id]
def test_move_document_element_to_page_cascades_row_children(tmp_support_slug_cleanup: Any) -> None:
slug, page1_id = _new_support(tmp_support_slug_cleanup)
page2_id = doc_engine.add_document_page(slug)
row_id = doc_engine.add_document_element(slug, "row", page_id=page1_id)
child_id = doc_engine.add_document_element(slug, "titre", page_id=page1_id, parent_id=row_id)
doc_engine.move_document_element_to_page(slug, row_id, page2_id)
child = doc_engine.get_document_element(slug, child_id)
assert child is not None
assert child["page_id"] == page2_id
assert child["parent_id"] == row_id
page2_ids = {el["id"] for el in doc_engine.list_document_elements(slug, page2_id)}
assert page2_ids == {row_id, child_id}
def test_move_document_element_to_page_renumbers_remaining_siblings(tmp_support_slug_cleanup: Any) -> None:
slug, page1_id = _new_support(tmp_support_slug_cleanup)
page2_id = doc_engine.add_document_page(slug)
first_id = doc_engine.add_document_element(slug, "titre", page_id=page1_id)
second_id = doc_engine.add_document_element(slug, "paragraphe", page_id=page1_id)
third_id = doc_engine.add_document_element(slug, "image", page_id=page1_id)
doc_engine.move_document_element_to_page(slug, second_id, page2_id)
remaining = doc_engine.list_document_elements(slug, page1_id)
remaining_ordered = sorted(remaining, key=lambda el: el["order_index"])
assert [el["id"] for el in remaining_ordered] == [first_id, third_id]
assert [el["order_index"] for el in remaining_ordered] == [0, 1]
def test_move_document_element_to_page_on_missing_id_is_a_noop(tmp_support_slug_cleanup: Any) -> None:
slug, page1_id = _new_support(tmp_support_slug_cleanup)
page2_id = doc_engine.add_document_page(slug)
doc_engine.move_document_element_to_page(slug, 999, page2_id)
assert doc_engine.list_document_elements(slug, page1_id) == []
assert doc_engine.list_document_elements(slug, page2_id) == []
+29
View File
@@ -264,6 +264,35 @@ def test_document_element_move_and_delete(client: FlaskClient, support: str) ->
assert delete_resp.get_json()["ok"] is True assert delete_resp.get_json()["ok"] is True
def test_document_element_move_to_page_transfers_the_element(client: FlaskClient, support: str) -> None:
"""Retour utilisateur du 23/09/2026 : pagination automatique — un
élément qui déborde d'une page est déplacé vers une autre, ici la
route qui porte ce déplacement (jamais document_element_move, qui
ne fait que réordonner DANS la même page)."""
page1_id = _page_id(support)
page2_id = document_engine.add_document_page(support)
element_id = client.post(
f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page1_id}
).get_json()["id"]
resp = client.post(f"/document/{support}/elements/{element_id}/move-to-page", json={"target_page_id": page2_id})
assert resp.status_code == 200
assert resp.get_json()["ok"] is True
moved = document_engine.get_document_element(support, element_id)
assert moved is not None
assert moved["page_id"] == page2_id
def test_document_element_move_to_page_rejects_an_unknown_target(client: FlaskClient, support: str) -> None:
page_id = _page_id(support)
element_id = client.post(
f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id}
).get_json()["id"]
resp = client.post(f"/document/{support}/elements/{element_id}/move-to-page", json={"target_page_id": 999})
assert resp.status_code == 404
def test_restricted_user_can_have_one_game_and_one_support_at_once( def test_restricted_user_can_have_one_game_and_one_support_at_once(
user_client: FlaskClient, tmp_game_slug_cleanup: Any, tmp_support_slug_cleanup: Any user_client: FlaskClient, tmp_game_slug_cleanup: Any, tmp_support_slug_cleanup: Any
) -> None: ) -> None: