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:
co-authored by
Claude Sonnet 5
parent
0e8c6efb06
commit
c6e173589f
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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})
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------
|
||||
|
||||
@@ -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) == []
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user