From c6e173589fa3c162a795bd52402de4ad0d0af423 Mon Sep 17 00:00:00 2001 From: william Date: Wed, 23 Sep 2026 11:50:23 +0200 Subject: [PATCH] =?UTF-8?q?Pagination=20automatique=20:=20le=20contenu=20q?= =?UTF-8?q?ui=20d=C3=A9borde=20part=20sur=20une=20nouvelle=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//elements//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 --- document_engine/__init__.py | 2 + document_engine/elements/elements.md | 18 +++++++ .../elements/move_document_element_to_page.py | 47 ++++++++++++++++ routes/document/__init__.py | 1 + .../document/document_element_move_to_page.py | 24 +++++++++ static/document/js/document-editor.js | 50 ++++++++++++++++- tests/document/test_document_elements.py | 54 +++++++++++++++++++ tests/document/test_document_routes.py | 29 ++++++++++ 8 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 document_engine/elements/move_document_element_to_page.py create mode 100644 routes/document/document_element_move_to_page.py diff --git a/document_engine/__init__.py b/document_engine/__init__.py index 7520be92..e4521c2d 100644 --- a/document_engine/__init__.py +++ b/document_engine/__init__.py @@ -27,6 +27,7 @@ from .elements.delete_document_element import delete_document_element from .elements.get_document_element import get_document_element from .elements.list_document_elements import list_document_elements 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 .labels.association_config import ( DEFAULT_ASSOCIATION_CONFIG, @@ -109,6 +110,7 @@ __all__ = [ "list_document_elements", "list_document_pages", "move_document_element", + "move_document_element_to_page", "move_document_page", "quiz_total_points", "render_document", diff --git a/document_engine/elements/elements.md b/document_engine/elements/elements.md index 7f30954d..893e1cd4 100644 --- a/document_engine/elements/elements.md +++ b/document_engine/elements/elements.md @@ -57,6 +57,24 @@ affichée). inexistant ne fait rien (retour silencieux après vérification de son 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` Supprime un élément. La contrainte `FOREIGN KEY ... ON DELETE CASCADE` (voir `db/supports/create_support.py`) retire automatiquement ses diff --git a/document_engine/elements/move_document_element_to_page.py b/document_engine/elements/move_document_element_to_page.py new file mode 100644 index 00000000..3f2402dd --- /dev/null +++ b/document_engine/elements/move_document_element_to_page.py @@ -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() diff --git a/routes/document/__init__.py b/routes/document/__init__.py index 7d324cf9..0d9ef49d 100644 --- a/routes/document/__init__.py +++ b/routes/document/__init__.py @@ -9,6 +9,7 @@ from . import ( # noqa: F401 - enregistre les routes definies dans chaque modul document_element_add, document_element_delete, document_element_move, + document_element_move_to_page, document_element_update, document_new, document_page_add, diff --git a/routes/document/document_element_move_to_page.py b/routes/document/document_element_move_to_page.py new file mode 100644 index 00000000..1ada1119 --- /dev/null +++ b/routes/document/document_element_move_to_page.py @@ -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//elements//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}) diff --git a/static/document/js/document-editor.js b/static/document/js/document-editor.js index a8d9fd66..c37fbeb3 100644 --- a/static/document/js/document-editor.js +++ b/static/document/js/document-editor.js @@ -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()); } /* --------------------------------------------------------------------- diff --git a/tests/document/test_document_elements.py b/tests/document/test_document_elements.py index b6b4ab45..8944a34e 100644 --- a/tests/document/test_document_elements.py +++ b/tests/document/test_document_elements.py @@ -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="titre"' 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) == [] diff --git a/tests/document/test_document_routes.py b/tests/document/test_document_routes.py index 38e2d0ac..3bb87342 100644 --- a/tests/document/test_document_routes.py +++ b/tests/document/test_document_routes.py @@ -264,6 +264,35 @@ def test_document_element_move_and_delete(client: FlaskClient, support: str) -> 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( user_client: FlaskClient, tmp_game_slug_cleanup: Any, tmp_support_slug_cleanup: Any ) -> None: