From 9ad50c58b86125c7fe908dea050c2e28fe33d0a5 Mon Sep 17 00:00:00 2001 From: william Date: Sat, 26 Sep 2026 12:06:44 +0200 Subject: [PATCH] =?UTF-8?q?Audit=20complet=20de=20mise=20en=20forme=20?= =?UTF-8?q?=E2=80=94=20Image=20(2e=20=C3=A9l=C3=A9ment=20du=20tableau)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implémente toutes les options manquantes identifiées pour l'élément Image : dimensionnement/ratio/object-fit, filtres CSS, upload de fichier (en plus de l'URL), lien/plein écran au clic, chargement différé, légende, et tous les attributs de boîte partagés déjà créés pour Titre/Paragraphe (padding/margin/fond/bordure/ombre/opacité/ position du bloc). Système de pages : un support peut désormais avoir 0 page (un nouveau support démarre vide), suppression de toutes les pages en un clic, et la pagination automatique insère intelligemment la nouvelle page juste après celle qui déborde plutôt qu'en toute fin de liste. Bugs réels trouvés et corrigés en cours de route : le style de bloc (dont align-self) ciblait l'élément interne au lieu de son enveloppe (légende/lien) ; une image à sa taille native pouvait déclencher une pagination infinie ; upload/mise à jour d'attribut ne déclenchaient jamais le contrôle de débordement. Co-Authored-By: Claude Sonnet 5 --- db/supports/create_support.py | 16 +- db/supports/ensure_document_pages_schema.py | 22 +- docs/plan/AUDIT_MISE_EN_FORME.md | 167 ++++++++- document_engine/__init__.py | 2 + document_engine/labels/element_kind_labels.py | 49 ++- document_engine/labels/labels.md | 28 +- .../pages/delete_all_document_pages.py | 16 + document_engine/pages/list_document_pages.py | 5 +- document_engine/pages/pages.md | 39 ++- .../pages/replace_document_content.py | 9 +- document_engine/rendering/box_style.py | 93 +++-- .../rendering/render_document_element.py | 111 +++++- document_engine/rendering/rendering.md | 39 ++- routes/document/__init__.py | 3 + routes/document/document_edit.py | 19 +- .../document/document_element_upload_image.py | 61 ++++ routes/document/document_page_delete.py | 12 +- routes/document/document_page_delete_all.py | 17 + routes/document/document_uploaded_file.py | 19 ++ static/document/document-editor.css | 82 ++++- static/document/js/document-editor.js | 321 ++++++++++++++++-- templates/document/document_edit.html | 13 +- tests/conftest.py | 12 +- tests/document/test_document_elements.py | 197 ++++++++++- tests/document/test_document_pages_routes.py | 32 +- tests/document/test_document_routes.py | 79 +++++ tests/document/test_support_lifecycle.py | 10 + 27 files changed, 1299 insertions(+), 174 deletions(-) create mode 100644 document_engine/pages/delete_all_document_pages.py create mode 100644 routes/document/document_element_upload_image.py create mode 100644 routes/document/document_page_delete_all.py create mode 100644 routes/document/document_uploaded_file.py diff --git a/db/supports/create_support.py b/db/supports/create_support.py index 971d0c79..b52481c6 100644 --- a/db/supports/create_support.py +++ b/db/supports/create_support.py @@ -13,12 +13,15 @@ def create_support(name: str, owner_folder: str) -> str: mais un schéma entièrement différent (voir document_engine/ : un support est composé de PAGES — _document_pages —, chacune portant son propre flux d'éléments via _document_elements.page_id, jamais - d'écrans/objets de scène comme côté jeu). Une première page ("Page 1") - est toujours créée ici : un support n'existe jamais sans au moins une - page (voir document_engine/pages/delete_document_page.py, qui refuse - de supprimer la dernière). owner_folder n'est jamais optionnel ici - (contrairement à create_game) : un support n'existe pas sans compte - propriétaire.""" + d'écrans/objets de scène comme côté jeu). Créé ici SANS aucune page + (retour utilisateur : l'éditeur doit pouvoir s'ouvrir vide, "nouveau + projet par ex") — l'utilisateur clique "+ Ajouter une page" pour + commencer (voir document_engine/pages/add_document_page.py). Aucune + page n'est donc plus une garantie côté production ; seule la fixture + de test `support` (tests/conftest.py) en crée une par convénience pour + les tests qui ne portent pas sur ce cas précis. owner_folder n'est + jamais optionnel ici (contrairement à create_game) : un support + n'existe pas sans compte propriétaire.""" project_part = slugify(name) base = project_part n = 2 @@ -55,7 +58,6 @@ def create_support(name: str, owner_folder: str) -> str: """ ) conn.execute("INSERT INTO _meta (key, value) VALUES ('name', ?)", (name,)) - conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)") conn.commit() conn.close() return slug diff --git a/db/supports/ensure_document_pages_schema.py b/db/supports/ensure_document_pages_schema.py index c6eb7673..c74e8d74 100644 --- a/db/supports/ensure_document_pages_schema.py +++ b/db/supports/ensure_document_pages_schema.py @@ -8,7 +8,15 @@ def ensure_document_pages_schema(slug: str) -> None: le système de pages avait un seul document implicite ; ses éléments existants deviennent tous les enfants d'une page "Page 1" créée ici automatiquement (comportement le plus proche de l'ancien : un seul - document visible, qui devient simplement sa première page).""" + document visible, qui devient simplement sa première page). + + Ne recrée PLUS "Page 1" à chaque appel dès que _document_pages est + vide (bug qui empêchait tout support d'atteindre 0 page — un support + sans aucune page est un état valide depuis le retour utilisateur + "l'éditeur doit pouvoir s'ouvrir sans aucune page"). La création + automatique de "Page 1" ne se déclenche plus qu'une seule fois, au + moment précis de cette migration historique (juste avant d'ajouter la + colonne page_id, voir plus bas) — jamais ensuite.""" conn = connect_support(slug) conn.execute( """ @@ -20,9 +28,6 @@ def ensure_document_pages_schema(slug: str) -> None: ); """ ) - page_count = conn.execute("SELECT COUNT(*) AS n FROM _document_pages").fetchone()["n"] - if page_count == 0: - conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)") # vertical_align : alignement vertical du contenu de la page # (top/center/bottom — voir document_engine/pages/ @@ -35,7 +40,14 @@ def ensure_document_pages_schema(slug: str) -> None: cols = {r["name"] for r in conn.execute("PRAGMA table_info(_document_elements)").fetchall()} if "page_id" not in cols: - first_page_id = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone()["id"] + # Vrai support pré-pages : ses éléments existants (s'il y en a) + # doivent atterrir quelque part — jamais recréé une fois cette + # migration ponctuelle passée (page_id existera alors déjà). + page_row = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone() + if page_row is None: + conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)") + page_row = conn.execute("SELECT id FROM _document_pages ORDER BY order_index LIMIT 1").fetchone() + first_page_id = page_row["id"] # SQLite autorise ADD COLUMN avec une valeur par défaut CONSTANTE # (jamais une contrainte REFERENCES ici, même pragmatisme que # db/rows/ensure_player_id_column.py : la contrainte de clé diff --git a/docs/plan/AUDIT_MISE_EN_FORME.md b/docs/plan/AUDIT_MISE_EN_FORME.md index a4092b37..89f6156c 100644 --- a/docs/plan/AUDIT_MISE_EN_FORME.md +++ b/docs/plan/AUDIT_MISE_EN_FORME.md @@ -64,6 +64,12 @@ image de fond, `overflow`/`z-index`/position absolue, transitions au survol, réglages différents par taille d'écran (responsive), arrondi par coin (un seul rayon pour les 4 coins, comme l'étiquette). +**Changement rétroactif (au moment de l'Image, ci-dessous)** : `width` +(largeur fixe) a été ajouté à `box_style.py` partagé — Titre/Paragraphe +gagne donc aussi ce réglage a posteriori (en plus de la largeur MAXIMALE +déjà là), sans repasser par une validation dédiée puisque c'est un ajout +pur (aucun comportement existant modifié). + Bugs transversaux trouvés et corrigés pendant ce chantier (concernent TOUT l'éditeur, pas seulement Titre/Paragraphe) : - Panneau Propriétés jamais reconstruit après un clic (boutons/segments @@ -74,7 +80,166 @@ TOUT l'éditeur, pas seulement Titre/Paragraphe) : à tort par les cases à cocher et curseurs, cassant leur apparence native. -### 2. Image — à faire (prochain élément) +### 2. Image — ✅ audité et validé (commité) + +Implémenté : légende (`caption`, enveloppe dans `
/
`), +ajustement dans son cadre (`object_fit` : taille réelle/couvrir/ +contenir/étirer), format/ratio (`aspect_ratio` : libre/carré/4:3/16:9), +filtre (3 préréglages : noir et blanc/sépia/flouté), chargement différé +(`loading="lazy"`), comportement au clic mutuellement exclusif (aucun / +lien externe dans un nouvel onglet / plein écran — un vrai overlay +plein écran côté client, `forgeDocOpenImageLightbox`), tous les +attributs de boîte partagés (dont la nouvelle **largeur fixe**, ajoutée +à `box_style.py` à cette occasion). + +Comme pour les mini-jeux et la pièce jointe d'un bouton, le lien et le +plein écran ne sont réellement cliquables qu'en Mode Aperçu — en +édition, le clic sélectionne l'élément (la navigation native du lien +est bloquée pour ne pas quitter l'éditeur par accident). + +**Ajout (retour utilisateur : "il manque la possibilité d'uploader une +image")** : téléversement d'un fichier depuis l'ordinateur (PNG/JPG/ +GIF/WEBP/SVG), en plus du champ URL externe déjà là — mirroir exact du +mécanisme déjà en place pour la pièce jointe d'un bouton : +`routes/document/document_element_upload_image.py` (stocke sous +`uploads/`, jamais `attachments/` — pas de `as_attachment`, l'image doit +s'afficher, pas se télécharger) et `routes/document/ +document_uploaded_file.py` (route de service dédiée). Le fichier +téléversé vide `svg_markup` au passage (les deux modes ne coexistent +jamais). Le panneau propose maintenant les deux : sélecteur de fichier +(natif, pas de champ texte) en premier, champ URL externe en second +pour une image déjà hébergée ailleurs. + +Volontairement laissé de côté (complexité/valeur douteuse pour du +contenu de formation) : `mix-blend-mode`, découpe `clip-path`, overlay +au survol, arrondi par coin, réglages responsive par taille d'écran. + +Bug pré-existant corrigé au passage : `docImageSrc`/`docImageAlt` +n'étaient pas échappés avant insertion dans l'attribut `value` du +panneau Propriétés (mineur, mais corrigé puisque cette fonction était +déjà réécrite). + +**Bug réel corrigé (retour utilisateur : "la position de bloc ne +fonctionne pas sur l'image")** : `render_box_style` (padding/margin/ +fond/bordure/largeur/position du bloc, dont `align_self`) était +appliqué à l'``/`
` INTERNE, jamais à son enveloppe +(`
`/``/`
`) quand une légende ou un comportement au +clic en ajoutait une — `align-self` posé sur un simple descendant du +flex-item n'a aucun effet CSS, d'où le bouton "position du bloc" sans +effet visible dès qu'une légende ou un lien était configuré. Corrigé +dans `_render_image` (`document_engine/rendering/ +render_document_element.py`) : le style de bloc cible désormais +toujours l'élément réellement top-niveau (enfant direct de +`.docPageContent`), quel que soit l'emboîtement. Changement de +comportement réel sur du contenu existant : une image avec légende/lien +et un fond/une bordure/un padding déjà réglés les verra désormais +appliqués à TOUT le bloc (image + légende), pas seulement à l'image — +c'est le comportement correct/attendu, mais je le signale car ça change +le rendu visuel d'éléments déjà créés. + +**Bug réel corrigé (retour utilisateur : "quand j'ajoute une image ça +crée des pages à l'infini")** : une photo importée à sa taille native +pouvait dépasser une page entière à elle seule ; la pagination +automatique (`forgeDocCheckPageOverflow`, static/document/js/ +document-editor.js) la déplaçait alors sans fin vers une page neuve, +qui débordait identiquement. Corrigé une première fois par un garde-fou générique (si tout le +contenu de la page déborde déjà, rien à répartir, on arrête) + une +hauteur maximale par défaut sur `img.docImage` (plafonnée à la hauteur +intérieure de la page, `static/document/document-editor.css`). + +**Affiné ensuite (retour utilisateur : "si l'image uploadée est trop +grande je préfère qu'elle soit redimensionnée plutôt que bouger sur une +autre page")** : `forgeDocCheckPageOverflow` (static/document/js/ +document-editor.js) évite de paginer une image qui déborde quand il +reste assez de place pour un résultat encore utilisable — elle est +rétrécie SUR PLACE (`max-height` posé en style inline, +calculé à partir de l'espace réellement disponible sous elle sur SA +page actuelle, légende comprise) à chaque rafraîchissement du canevas. +Purement visuel, jamais persisté en attribut (l'espace disponible +dépend du contenu au-dessus, qui change en éditant). Un réglage +explicite de hauteur maximale via le panneau reste prioritaire (une +valeur inline posée par un attribut serait recalculée par-dessus à +l'affichage suivant si elle déborde encore). Le garde-fou anti-boucle +et le défaut CSS restent en place pour les AUTRES kinds et comme filet +de sécurité au tout premier rendu (avant que le JS n'ait tourné). + +**Changement de conception (retour utilisateur : "ce cadre ne devrait +pas changer de taille en fonction de la taille de l'image mais être +fixe et contraindre l'image dedans")** : plutôt que de rétrécir +dynamiquement une image trop grande selon l'espace disponible (approche +fragile, source des deux bugs ci-dessus), un nouvel élément image a +maintenant un cadre FIXE dès sa création — `object_fit="cover"` + +`height="220px"` par défaut au lieu de vides (`document_engine/labels/ +element_kind_labels.py`) — une photo importée est donc TOUJOURS rognée +pour remplir ce cadre, quelle que soit sa résolution native. "Taille +réelle" reste un choix explicite possible dans le panneau (segmented +"Ajustement dans son cadre"). **Ne s'applique qu'aux NOUVEAUX éléments +image** — un élément déjà créé avant ce changement garde ses attributs +`object_fit`/`height` existants (jamais re-migré automatiquement, +`sanitize_element_attributes` ne touche pas au kind "image", voir sa +docstring) ; pour en faire bénéficier une image déjà présente, régler +manuellement "Couvrir" + une hauteur via le panneau. Le rétrécissement +dynamique (garde-fou anti-boucle + `forgeDocCheckPageOverflow` appelé +après upload/mise à jour d'attribut) reste en place comme filet de +sécurité pour une hauteur explicite déraisonnablement grande. + +**Bug réel corrigé une seconde fois (retour utilisateur : "ce n'est pas +redimensionner")** : le rétrécissement ci-dessus vit dans +`forgeDocCheckPageOverflow`, appelée uniquement par +`forgeDocRefreshCanvas` (ajout/déplacement/suppression/Annuler- +Rétablir) — mais **téléverser un fichier dans une image existante** +(`forgeDocApiUploadImage`) et **changer un attribut quelconque** +(`forgeDocUpdateAttributes`, le point d'entrée central de tous les +panneaux) patchent chacun le DOM directement, SANS jamais passer par +ce chemin : le rétrécissement ne se déclenchait donc jamais après un +upload. Corrigé en appelant explicitement `forgeDocCheckPageOverflow()` +à la fin de ces deux fonctions. Pas de test automatisé possible ici : +aucune suite de tests n'existe pour `document-editor.js` +(`package.json` ne couvre que `static/game/js/`), vérification +manuelle uniquement. + +**Affiné une troisième fois (retour utilisateur : "l'ajout de page +quand le contenu déborde dois rester et même être plus intelligent, la +nouvelle page dois etre sous la page qui deborde meme si ya d'autre +page")** : la pagination automatique (pour tout kind, pas seulement les +images désormais épargnées ci-dessus) insérait toujours la page neuve +en toute fin de la bande d'onglets (`add_document_page` l'ajoute +toujours à la fin), même si d'autres pages existaient déjà après celle +qui déborde — déborder sur la page 2 d'un support qui en compte 5 +ajoutait la nouvelle page en position 6 au lieu de 3. Corrigé dans +`forgeDocCheckPageOverflow` : la page est créée puis immédiatement +déplacée (`forgeDocApiPageMove`, mécanisme déjà existant pour le +glisser-déposer du panneau Pages) juste après la page active, décalant +les pages suivantes d'un cran. Extraction d'un helper partagé +`forgeDocReorderLocalPage` (état client après un déplacement) réutilisé +par le glisser-déposer ET par ce nouveau cas, pour ne pas dupliquer ce +calcul. Vérification côté serveur déjà couverte par les tests existants +de `move_document_page`/`document_page_move` (l'insertion "au milieu" +de la bande d'onglets y est déjà testée) ; le déclenchement côté client +reste manuel faute de suite de tests JS. + +**Affiné une quatrième fois** : sous un seuil de place restante +(`FORGE_DOC_MIN_IMAGE_HEIGHT`, 60px) il n'y a plus de place RÉELLE sur +la page (pas seulement pour cette image) — l'image bascule alors dans +la pagination normale ci-dessus au lieu d'être rétrécie à une taille +inutilisable, ce qui empêchait sinon une page pleine d'images +d'enchaîner sur une nouvelle page. Vérifié par un test de bout en bout +en conditions réelles (navigateur automatisé Playwright contre le +serveur local, sur un support jetable créé puis supprimé pour +l'occasion) : 5 images vides ajoutées à la suite produisent bien 3 +pages, chacune avec le cadre fixe "Couvrir" actif par défaut. + +**Audit final** : chaque ligne du tableau d'audit initial pour +l'élément Image est couverte — implémentée (dimensionnement/ratio/ +object-fit, bordure+ombre via `box_style.py`, filtre, lien/lightbox/ +lazy-load/légende, position du bloc dont l'alignement, upload de +fichier) ou explicitement différée ci-dessus avec sa raison +(`object-position`, `mix-blend-mode`, `clip-path`, overlay au survol, +arrondi par coin, responsive par taille d'écran — mêmes exclusions que +Titre/Paragraphe, valeur douteuse pour du contenu de formation). Rien +d'oublié constaté à cette relecture. Validé par l'utilisateur, prêt à +committer. ### 3. Bouton — à faire diff --git a/document_engine/__init__.py b/document_engine/__init__.py index 2ed1e220..7e1c156d 100644 --- a/document_engine/__init__.py +++ b/document_engine/__init__.py @@ -71,6 +71,7 @@ from .labels.scenario_config import ( sanitize_scenario_config, ) from .pages.add_document_page import add_document_page +from .pages.delete_all_document_pages import delete_all_document_pages from .pages.delete_document_page import delete_document_page from .pages.get_document_page import get_document_page from .pages.list_document_pages import list_document_pages @@ -109,6 +110,7 @@ __all__ = [ "VERTICAL_ALIGNS", "add_document_element", "add_document_page", + "delete_all_document_pages", "delete_document_element", "delete_document_page", "element_default_attributes", diff --git a/document_engine/labels/element_kind_labels.py b/document_engine/labels/element_kind_labels.py index d9ade5ae..f8f1eba5 100644 --- a/document_engine/labels/element_kind_labels.py +++ b/document_engine/labels/element_kind_labels.py @@ -48,10 +48,6 @@ _TEXT_DEFAULTS = { "strikethrough": False, "align": "left", "color": "var(--forge-text)", - # Vide par défaut = pleine largeur de .docPageContent (comportement - # historique) ; une valeur CSS libre (ex. "60ch", "480px") restreint - # la largeur du bloc — voir render_document_element._render_text. - "max_width": "", # Vides par défaut = valeurs du préréglage `style` inchangées (voir # _STYLE_PRESETS dans render_document_element.py) — une valeur # explicite les remplace (retour utilisateur du 26/09/2026, audit des @@ -62,8 +58,10 @@ _TEXT_DEFAULTS = { "letter_spacing": "", "text_transform": "none", "text_shadow": "", - # Attributs de boîte partagés avec d'autres kinds (padding/margin/ - # background_color/border_radius/align_self) — voir rendering/box_style.py. + # Attributs de boîte partagés avec d'autres kinds (dont `max_width`, + # ex. "60ch"/"480px" — retour utilisateur du 24/09/2026 : un + # paragraphe doit pouvoir rester plus étroit que la page) — voir + # rendering/box_style.py. **BOX_DEFAULTS, } @@ -88,7 +86,44 @@ def element_default_attributes(kind: str) -> dict[str, Any]: # svg_markup (optionnel) prend le pas sur src au rendu (voir # render_document_element._render_image) — un contenu vectoriel # dessiné/collé directement plutôt qu'un fichier hébergé. - return {"src": "", "alt": "", "svg_markup": ""} + # aspect_ratio/filter_preset : vides par défaut (aucun style + # ajouté). click_behavior/link_url/lazy_load/caption : + # comportement/contenu, pas du style (retour utilisateur du + # 26/09/2026, audit des réglages manquants — colonne "Image"). + # click_behavior ("" | "link" | "lightbox") et link_url sont + # mutuellement dépendants (un lien sans URL ne fait rien au + # rendu, voir _render_image) mais jamais revalidés l'un par + # rapport à l'autre ici : cette combinaison reste sans risque + # quelle qu'elle soit. + # + # object_fit="cover" + height="220px" (au lieu de vides) : cadre + # FIXE par défaut (retour utilisateur du 26/09/2026 : "ce cadre + # ne devrait pas changer de taille en fonction de la taille de + # l'image mais être fixe et contraindre l'image dedans") — une + # photo importée est désormais TOUJOURS rognée pour remplir ce + # cadre, quelle que soit sa résolution native, plutôt que de + # dicter elle-même la taille du bloc. "Taille réelle" reste + # sélectionnable explicitement dans le panneau Propriétés + # (segmented "Ajustement dans son cadre") pour qui préfère + # revenir à l'ancien comportement (hauteur libre, aucun + # object-fit) ; un défaut CSS aveugle sur TOUTE image aurait + # rendu ce choix impossible à distinguer de "jamais réglé", les + # deux valant la chaîne vide. + return { + "src": "", + "alt": "", + "svg_markup": "", + "object_fit": "cover", + "aspect_ratio": "", + "filter_preset": "", + "click_behavior": "", + "link_url": "", + "lazy_load": False, + "caption": "", + **BOX_DEFAULTS, + "height": "220px", + "border": default_border(), + } if kind == "bouton": # attachment_stored_name/attachment_filename (optionnels) : un # fichier téléchargeable joint au bouton (voir routes/document/ diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md index ef6cc180..574aac1e 100644 --- a/document_engine/labels/labels.md +++ b/document_engine/labels/labels.md @@ -44,18 +44,26 @@ l'affichage/debug hors bibliothèque). ## `element_default_attributes(kind: str) -> dict[str, Any]` Attributs posés à la création d'un élément de ce type (voir `document_engine/elements/add_document_element.py`). -- **Retour** : un dict d'attributs par défaut, dépendant du `kind` : +- **Retour** : un dict d'attributs par défaut, dépendant du `kind` (les + "attributs de boîte partagés" mentionnés ci-dessous — `padding/margin/ + background_color/border_radius/width/max_width/height/min_height/ + max_height/min_width/box_shadow/opacity/align_self/border` — sont + toujours les mêmes, voir `rendering/box_style.py` : tous vides, + `False` ou `"none"`/`"stretch"` par défaut = comportement historique + inchangé pour le kind qui les gagne) : texte (`content/style` + `bold/italic/underline/strikethrough/align/ - color/max_width/font_family/font_size/line_height/letter_spacing/ - text_transform/text_shadow` + les attributs de boîte partagés - `padding/margin/background_color/border_radius/height/min_height/ - max_height/min_width/box_shadow/opacity/align_self/border`, voir - `rendering/box_style.py` — tous vides, `False` ou `"none"`/`"stretch"` - par défaut = comportement historique inchangé ; `max_width` - restreint la largeur du bloc, une valeur CSS libre ex. `"60ch"`/ - `"480px"`, voir `rendering.md`), + color/font_family/font_size/line_height/letter_spacing/ + text_transform/text_shadow` + les attributs de boîte partagés), image (`src/alt/svg_markup` — `svg_markup` prend le pas sur `src` au - rendu, voir rendering.md), bouton (`label/target/ + rendu, voir rendering.md — `object_fit="cover"` et `height="220px"` + par défaut (retour utilisateur du 26/09/2026 : "ce cadre ne devrait + pas changer de taille en fonction de la taille de l'image mais être + fixe et contraindre l'image dedans" — un cadre FIXE, jamais dicté par + la résolution native du fichier importé ; "Taille réelle" reste un + choix explicite possible via le panneau), `aspect_ratio/filter_preset` + vides par défaut, `click_behavior` (`""`/`"link"`/`"lightbox"`) + + `link_url` + `lazy_load` + `caption` : comportement/contenu, pas du + style + les attributs de boîte partagés), bouton (`label/target/ attachment_stored_name/attachment_filename` — la pièce jointe est indépendante de `target`, réservé à la navigation), liste à puces/numérotée (`items`, une liste de chaînes), badge diff --git a/document_engine/pages/delete_all_document_pages.py b/document_engine/pages/delete_all_document_pages.py new file mode 100644 index 00000000..628f4ab5 --- /dev/null +++ b/document_engine/pages/delete_all_document_pages.py @@ -0,0 +1,16 @@ +from db.supports import connect_support, ensure_document_pages_schema + + +def delete_all_document_pages(slug: str) -> None: + """Supprime toutes les pages d'un support d'un coup (retour + utilisateur : "une option dans page pour supprimer toute les page + d'un coup") — CASCADE (contrainte FK, voir create_support.py) retire + aussi tous les éléments de contenu du support. Résultat : un support + à 0 page, état volontairement valide (voir list_document_pages.py) ; + l'utilisateur repart d'un éditeur vide comme un support neuf.""" + ensure_document_pages_schema(slug) + conn = connect_support(slug) + conn.execute("DELETE FROM _document_elements") + conn.execute("DELETE FROM _document_pages") + conn.commit() + conn.close() diff --git a/document_engine/pages/list_document_pages.py b/document_engine/pages/list_document_pages.py index e51d5a7f..09455485 100644 --- a/document_engine/pages/list_document_pages.py +++ b/document_engine/pages/list_document_pages.py @@ -7,8 +7,9 @@ def list_document_pages(slug: str) -> list[dict[str, Any]]: """Toutes les pages d'un support, triées par order_index — la bande d'onglets du panneau Propriétés (voir static/document/js/ document-editor.js) et le sélecteur de page du Mode Aperçu en dérivent - directement. Un support a toujours au moins une page (voir - db/supports/create_support.py / ensure_document_pages_schema).""" + directement. Peut renvoyer une liste VIDE (retour utilisateur : un + support neuf, ou vidé via "Supprimer toutes les pages", s'ouvre sans + aucune page — voir routes/document/document_edit.py, qui gère ce cas).""" ensure_document_pages_schema(slug) conn = connect_support(slug) rows = conn.execute("SELECT * FROM _document_pages ORDER BY order_index").fetchall() diff --git a/document_engine/pages/pages.md b/document_engine/pages/pages.md index 309a6182..e6c15e33 100644 --- a/document_engine/pages/pages.md +++ b/document_engine/pages/pages.md @@ -4,13 +4,21 @@ CRUD des pages d'un support de formation (`_document_pages`, voir `db/supports/create_support.py`) — retour utilisateur du 21/09/2026: "il faut implémenter un système de page". Un support est désormais composé de plusieurs pages, chacune portant son propre flux d'éléments -(voir `document_engine/elements/`, filtré par `page_id`). Un support a -TOUJOURS au moins une page (`create_support` en crée une par défaut, -`ensure_document_pages_schema` en garantit une pour les supports plus -anciens) — la garde "jamais supprimer la dernière page" est un -garde-fou métier posé par l'appelant (voir -`routes/document/document_page_delete.py`), pas une contrainte de ce -paquet. +(voir `document_engine/elements/`, filtré par `page_id`). + +**Un support peut avoir 0 page** (retour utilisateur du 26/09/2026 : +"l'éditeur ne dois plus etre obliger d'avoir une page active ou créer, +il peut etre ouvert sans aucune page") — `create_support` n'en crée plus +aucune par défaut, et `ensure_document_pages_schema` ne recrée plus +"Page 1" dès que la table est vide (seule exception : la migration +ponctuelle et historique d'un support pré-pages qui avait déjà des +éléments sans `page_id`). `routes/document/document_edit.py` et le +frontend (`static/document/js/document-editor.js`) gèrent explicitement +cet état "aucune page" (pas de page active, canevas vide avec une +invite à en créer une). La garde "jamais supprimer la dernière page" a +été retirée du côté route (voir `delete_all_document_pages` ci-dessous +et `routes/document/document_page_delete.py`) : ce paquet n'a jamais +posé cette contrainte lui-même. ## `add_document_page(slug: str, title: str | None = None) -> int` Ajoute une page en fin de la bande d'onglets. `title` par défaut : @@ -87,9 +95,18 @@ Supprime une page ET ses éléments (`DELETE FROM _document_elements WHERE page_id = ?` explicite — la contrainte `FOREIGN KEY ... ON DELETE CASCADE` n'existe que pour les supports créés après l'ajout des pages, voir `db/supports/ensure_document_pages_schema.py` pour les anciens). -Ne refuse JAMAIS de supprimer la dernière page restante — cette règle -est posée par l'appelant, pas par cette fonction bas niveau (même -découpage que `routes/game/screens/screen_delete.py` côté jeu, où le -garde-fou vit aussi dans la route). +Ne refuse JAMAIS de supprimer la dernière page restante — un support à +0 page est un état valide (voir plus haut). +- **Retour** : aucun. +- **Exceptions** : aucune. + +## `delete_all_document_pages(slug: str) -> None` +Supprime TOUTES les pages du support d'un coup, et tous leurs éléments +de contenu avec elles (retour utilisateur : "une option dans page pour +supprimer toute les page d'un coup") — action destructive et +irréversible côté serveur, jamais appelée sans confirmation explicite +côté client (voir `static/document/js/document-editor.js`, +`forgeDocDeleteAllPages`). Le support se retrouve à 0 page, exactement +comme un support neuf. - **Retour** : aucun. - **Exceptions** : aucune. diff --git a/document_engine/pages/replace_document_content.py b/document_engine/pages/replace_document_content.py index 79a21e47..198d5747 100644 --- a/document_engine/pages/replace_document_content.py +++ b/document_engine/pages/replace_document_content.py @@ -29,11 +29,10 @@ def replace_document_content(slug: str, seed_pages: list[dict[str, Any]]) -> Non element_default_attributes(kind) (jamais un remplacement brut) pour rester valides même si `seed_pages` n'en précise qu'une partie.""" # Les nouvelles pages sont créées AVANT de supprimer les anciennes - # (jamais l'inverse) : passer par zéro page, même un court instant, - # déclenche le filet de sécurité de ensure_document_pages_schema (un - # support a TOUJOURS au moins une page), qui recréerait une "Page 1" - # vide juste avant l'ajout de la première page du thème — bug réel - # rencontré en écrivant les tests de cette fonction. + # (jamais l'inverse) : ça évite que le support affiche un état "0 page" + # transitoire pendant le remplacement (même si 0 page est désormais un + # état par ailleurs valide, voir list_document_pages.py — ce n'est + # qu'une question d'ordre d'écriture ici, plus un filet de sécurité). old_page_ids = [page["id"] for page in list_document_pages(slug)] for seed_page in seed_pages: page_id = add_document_page(slug) diff --git a/document_engine/rendering/box_style.py b/document_engine/rendering/box_style.py index 8b11bb70..646b91d5 100644 --- a/document_engine/rendering/box_style.py +++ b/document_engine/rendering/box_style.py @@ -27,6 +27,8 @@ BOX_DEFAULTS = { "background_color": "", "border_radius": "", "align_self": "stretch", + "width": "", + "max_width": "", "height": "", "min_height": "", "max_height": "", @@ -36,58 +38,39 @@ BOX_DEFAULTS = { } -def render_box_style(a: dict[str, Any]) -> str: - """Construit les déclarations CSS inline communes à plusieurs kinds à - partir des attributs `padding`/`margin`/`background_color`/ - `border_radius`/`height`/`min_height`/`max_height`/`min_width`/ - `box_shadow`/`opacity`/`border`/`align_self` de `a` — chaîne vide pour - tout attribut absent ou à sa valeur par défaut (aucun style ajouté, - comportement historique inchangé). `border` est un dict à 4 clés - (`BORDER_SIDES`), chacune `{"style", "width", "color"}` — un côté à - `style="none"` (ou absent) ne produit aucune déclaration pour ce - côté, jamais un `border-top:none` explicite. - - **Retour** : les déclarations CSS (`"propriete:valeur; ..."`), - jamais vide ni `None`. - - **Exceptions** : aucune.""" - parts: list[str] = [] +# (clé d'attribut, propriété CSS) — chaque paire suit exactement le même +# patron (lire/nettoyer/ajouter si non vide) ; une simple table de +# correspondance ici évite un enchaînement de blocs `if` identiques +# (complexité cognitive réduite, voir _render_simple_properties). +_SIMPLE_PROPERTIES = ( + ("padding", "padding"), + ("margin", "margin"), + ("background_color", "background-color"), + ("border_radius", "border-radius"), + ("width", "width"), + ("max_width", "max-width"), + ("height", "height"), + ("min_height", "min-height"), + ("max_height", "max-height"), + ("min_width", "min-width"), + ("box_shadow", "box-shadow"), + ("opacity", "opacity"), +) - padding = str(a.get("padding", "")).strip() - if padding: - parts.append(f"padding:{html_lib.escape(padding)};") - margin = str(a.get("margin", "")).strip() - if margin: - parts.append(f"margin:{html_lib.escape(margin)};") +def _render_simple_properties(a: dict[str, Any]) -> list[str]: + parts = [] + for attr_key, css_prop in _SIMPLE_PROPERTIES: + value = str(a.get(attr_key, "")).strip() + if value: + parts.append(f"{css_prop}:{html_lib.escape(value)};") + return parts - background_color = str(a.get("background_color", "")).strip() - if background_color: - parts.append(f"background-color:{html_lib.escape(background_color)};") - - border_radius = str(a.get("border_radius", "")).strip() - if border_radius: - parts.append(f"border-radius:{html_lib.escape(border_radius)};") - - height = str(a.get("height", "")).strip() - if height: - parts.append(f"height:{html_lib.escape(height)};") - min_height = str(a.get("min_height", "")).strip() - if min_height: - parts.append(f"min-height:{html_lib.escape(min_height)};") - max_height = str(a.get("max_height", "")).strip() - if max_height: - parts.append(f"max-height:{html_lib.escape(max_height)};") - min_width = str(a.get("min_width", "")).strip() - if min_width: - parts.append(f"min-width:{html_lib.escape(min_width)};") - - box_shadow = str(a.get("box_shadow", "")).strip() - if box_shadow: - parts.append(f"box-shadow:{html_lib.escape(box_shadow)};") - - opacity = str(a.get("opacity", "")).strip() - if opacity: - parts.append(f"opacity:{html_lib.escape(opacity)};") +def _render_border(a: dict[str, Any]) -> list[str]: + """Un côté à `style="none"` (ou absent) ne produit aucune déclaration + pour ce côté, jamais un `border-top:none` explicite.""" + parts = [] border = a.get("border") or {} for side in BORDER_SIDES: side_border = border.get(side) or {} @@ -96,6 +79,20 @@ def render_box_style(a: dict[str, Any]) -> str: width = html_lib.escape(str(side_border.get("width", "1px"))) color = html_lib.escape(str(side_border.get("color", "var(--doc-border)"))) parts.append(f"border-{side}:{width} {html_lib.escape(style)} {color};") + return parts + + +def render_box_style(a: dict[str, Any]) -> str: + """Construit les déclarations CSS inline communes à plusieurs kinds à + partir des attributs listés dans `_SIMPLE_PROPERTIES` + `border`/ + `align_self` de `a` — chaîne vide pour tout attribut absent ou à sa + valeur par défaut (aucun style ajouté, comportement historique + inchangé). `border` est un dict à 4 clés (`BORDER_SIDES`), chacune + `{"style", "width", "color"}`. + - **Retour** : les déclarations CSS (`"propriete:valeur; ..."`), + jamais vide ni `None`. + - **Exceptions** : aucune.""" + parts = _render_simple_properties(a) + _render_border(a) # align-self ne fait quoi que ce soit d'utile QUE si l'élément a par # ailleurs une taille bornée (max_width/width) — voir la note dans diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py index 66c4af60..16a4409e 100644 --- a/document_engine/rendering/render_document_element.py +++ b/document_engine/rendering/render_document_element.py @@ -94,15 +94,11 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[ if text_shadow: style += f" text-shadow:{html_lib.escape(text_shadow)};" - # max_width optionnel (ex. "60ch", "480px") — vide par défaut (pleine - # largeur de .docPageContent, comportement inchangé). Retour - # utilisateur du 24/09/2026 : un paragraphe doit pouvoir rester plus - # étroit que la page, comme un sous-titre sous un grand titre, sans - # dépendre d'une rangée (qui partagerait la largeur avec un frère). - max_width = str(a.get("max_width", "")).strip() - if max_width: - style += f" max-width:{html_lib.escape(max_width)};" - + # max_width (ex. "60ch", "480px" — retour utilisateur du 24/09/2026 : + # un paragraphe doit pouvoir rester plus étroit que la page, sans + # dépendre d'une rangée qui en partagerait la largeur avec un frère) + # fait maintenant partie des attributs de boîte partagés + # (render_box_style), jamais géré ici en double. box_style = render_box_style(a) if box_style: style += f" {box_style}" @@ -110,8 +106,53 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[ return f'
{content}
' +_IMAGE_OBJECT_FITS = ("cover", "contain", "fill") +_IMAGE_FILTERS = { + "grayscale": "grayscale(1)", + "sepia": "sepia(0.8)", + "blur": "blur(3px)", +} + + +def _image_extra_style(a: dict[str, Any]) -> str: + """Déclarations CSS spécifiques à l'image (`object-fit`/`aspect-ratio`/ + `filter`) — jamais dans `box_style.py` (partagé), qui ne connaît que + des attributs communs à plusieurs kinds.""" + parts = [] + object_fit = str(a.get("object_fit", "")) + if object_fit in _IMAGE_OBJECT_FITS: + parts.append(f"object-fit:{object_fit};") + aspect_ratio = str(a.get("aspect_ratio", "")).strip() + if aspect_ratio: + parts.append(f"aspect-ratio:{html_lib.escape(aspect_ratio)};") + filter_value = _IMAGE_FILTERS.get(str(a.get("filter_preset", ""))) + if filter_value: + parts.append(f"filter:{filter_value};") + return " ".join(parts) + + def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: a = el["attributes"] + click_behavior = str(a.get("click_behavior", "")) + link_url = str(a.get("link_url", "")).strip() + caption = str(a.get("caption", "")).strip() + # render_box_style (padding/margin/fond/bordure/largeur/position du + # bloc, dont align-self) doit se poser sur l'élément RÉELLEMENT + # top-niveau — celui qui est l'enfant direct du flex-column de la + # page (voir .docPageContent, static/document/document-editor.css) — + # jamais sur l'/
interne dès qu'une légende ou un + # comportement au clic l'enveloppe : un align-self posé sur un + # DESCENDANT du flex-item n'a strictement aucun effet côté CSS (bug + # réel constaté le 26/09/2026 : "la position de bloc ne fonctionne + # pas sur l'image"). has_wrapper détermine qui, de l'image elle-même + # ou de son enveloppe, est ce top-niveau. + has_wrapper = bool(caption) or (click_behavior == "link" and link_url) or click_behavior == "lightbox" + box_style = render_box_style(a) + + media_style = " ".join(p for p in (_image_extra_style(a), "" if has_wrapper else box_style) if p) + media_style_attr = f' style="{media_style}"' if media_style else "" + loading_attr = ' loading="lazy"' if a.get("lazy_load") else "" + svg_markup = str(a.get("svg_markup", "")).strip() if svg_markup: # Contenu vectoriel dessiné/collé par le créateur plutôt qu'un @@ -120,15 +161,51 @@ def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list # CHAQUE rendu (jamais seulement à l'écriture) par sanitize_svg_markup, # même défense en profondeur que html.escape sur les autres kinds. sanitized = sanitize_svg_markup(svg_markup) - return f'
{sanitized}
' - src = html_lib.escape(str(a.get("src", ""))) - alt = html_lib.escape(str(a.get("alt", ""))) - if not src: - return ( - f'
' - f"Image — aucun fichier choisi
" + media = ( + f'
{sanitized}
' ) - return f'{alt}' + else: + src = html_lib.escape(str(a.get("src", ""))) + alt = html_lib.escape(str(a.get("alt", ""))) + if not src: + media = ( + f'
Image — aucun fichier choisi
' + ) + else: + media = ( + f'{alt}' + ) + + # Comportement au clic (mutuellement exclusif, voir panneau + # Propriétés) — "lien" ouvre une URL externe dans un nouvel onglet + # (jamais dans l'éditeur lui-même), "plein écran" ouvre un aperçu + # agrandi géré côté client (voir static/document/js/ + # document-editor.js::forgeDocOpenImageLightbox), tous deux + # UNIQUEMENT actifs en Mode Aperçu (même principe que les mini-jeux + # et la pièce jointe d'un bouton). Reçoit le style de bloc UNIQUEMENT + # s'il n'y a pas de légende par-dessus (sinon c'est elle, plus + # englobante encore, qui le reçoit juste plus bas). + if click_behavior == "link" and link_url: + href = html_lib.escape(link_url) + wrapper_style_attr = f' style="{box_style}"' if (box_style and not caption) else "" + media = ( + f'
{media}' + ) + elif click_behavior == "lightbox": + wrapper_style_attr = f' style="{box_style}"' if (box_style and not caption) else "" + media = f'
{media}
' + + if caption: + figure_style_attr = f' style="{box_style}"' if box_style else "" + media = ( + f'
{media}' + f'
{html_lib.escape(caption)}
' + ) + + return media def _render_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str: diff --git a/document_engine/rendering/rendering.md b/document_engine/rendering/rendering.md index b722629c..9a6c0b50 100644 --- a/document_engine/rendering/rendering.md +++ b/document_engine/rendering/rendering.md @@ -52,7 +52,21 @@ regroupement à chaque appel. `
` portant directement ce fragment SVG nettoyé par `sanitize_svg_markup` (voir `sanitize_svg_markup.py` ci-dessous) : un contenu vectoriel dessiné/collé par le créateur plutôt qu'un fichier - hébergé, sans aucun style qui lui soit propre. + hébergé. Style inline : `object_fit` (`"cover"`/`"contain"`/`"fill"`, + toute autre valeur ignorée), `aspect_ratio` (valeur CSS libre, ex. + `"16 / 9"`), `filter_preset` (`"grayscale"`/`"sepia"`/`"blur"`, mappé + vers une vraie valeur `filter` CSS fixe — jamais une valeur de filtre + libre) + les attributs de boîte partagés (`render_box_style`, voir + `box_style.py`). `lazy_load` (`True`) ajoute `loading="lazy"` sur + l'`` uniquement (comportement, pas du style). `click_behavior` + (`""`/`"link"`/`"lightbox"`) enveloppe le tout dans un `` (si `link_url` est aussi renseigné) ou un + `
` — les deux ne deviennent + réellement cliquables qu'en Mode Aperçu (voir static/document/js/ + document-editor.js::forgeDocBindCanvasInteractions/ + forgeDocOpenImageLightbox), même principe que les mini-jeux et la + pièce jointe d'un bouton. `caption` (non vide) enveloppe le tout dans + un `
` échappée. - **Bouton** : ` - +
`).join(''); @@ -733,6 +868,12 @@ function forgeDocRenderPageManagerList() { const addBtn = document.getElementById('docPageManagerAdd'); if (addBtn) addBtn.onclick = () => forgeDocAddPage(); + + const deleteAllBtn = document.getElementById('docPageManagerDeleteAll'); + if (deleteAllBtn) { + deleteAllBtn.disabled = pages.length === 0; + deleteAllBtn.onclick = () => forgeDocDeleteAllPages(); + } } function forgeDocBindPageManagerDrag() { @@ -769,16 +910,7 @@ function forgeDocBindPageManagerDrag() { const movedId = draggedId; draggedId = null; if (newIndex < 0) return; - 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; - forgeDocRenderPageManagerList(); - }); + forgeDocApiPageMove(movedId, newIndex).then(() => forgeDocReorderLocalPage(movedId, newIndex)); }); } @@ -826,6 +958,35 @@ function forgeDocBindPageNavPill() { * Sélection & panneau Propriétés. * ------------------------------------------------------------------- */ +/* --------------------------------------------------------------------- + * Aperçu plein écran d'une image (comportement "click_behavior": + * "lightbox", voir document_engine/rendering/render_document_element.py + * ::_render_image) — un seul overlay réutilisé, créé à la demande. + * ------------------------------------------------------------------- */ + +function forgeDocOpenImageLightbox(src, alt) { + let overlay = document.getElementById('docImageLightboxOverlay'); + if (!overlay) { + overlay = document.createElement('div'); + overlay.id = 'docImageLightboxOverlay'; + overlay.className = 'docImageLightboxOverlay'; + overlay.innerHTML = ''; + overlay.addEventListener('click', forgeDocCloseImageLightbox); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') forgeDocCloseImageLightbox(); + }); + document.body.appendChild(overlay); + } + document.getElementById('docImageLightboxImg').src = src; + document.getElementById('docImageLightboxImg').alt = alt || ''; + overlay.classList.add('is-open'); +} + +function forgeDocCloseImageLightbox() { + const overlay = document.getElementById('docImageLightboxOverlay'); + if (overlay) overlay.classList.remove('is-open'); +} + function forgeDocBindCanvasInteractions() { document.querySelectorAll('#docPage [data-element-id]').forEach((dom) => { const elementId = Number.parseInt(dom.dataset.elementId, 10); @@ -839,6 +1000,22 @@ function forgeDocBindCanvasInteractions() { window.location.href = `/document/${window.forgeDocState.slug}/elements/${elementId}/download-attachment`; return; } + // Image "plein écran au clic" — seulement en Aperçu (même + // principe que ci-dessus) ; en édition, on bloque juste la + // navigation native éventuelle (lien/plein écran) pour rester sur + // la sélection de l'élément. + const lightboxWrapper = dom.closest('.docImageLightboxTrigger'); + if (lightboxWrapper) { + e.preventDefault(); + if (window.forgeDocState.previewMode) { + const img = lightboxWrapper.querySelector('img.docImage'); + if (img) forgeDocOpenImageLightbox(img.src, img.alt); + return; + } + } + if (dom.closest('.docImageLink') && !window.forgeDocState.previewMode) { + e.preventDefault(); + } forgeDocSelectElement(elementId); }); dom.setAttribute('draggable', 'true'); @@ -1807,6 +1984,12 @@ function forgeDocRenderBoxFieldsHtml(a, idPrefix) {
${forgeDocRenderToggleSliderHtml(`${idPrefix}Radius`, 'Arrondi des bords', a.border_radius, { min: 0, max: 100, step: 2, unit: 'px', fallback: 8, + })} + ${forgeDocRenderToggleSliderHtml(`${idPrefix}Width`, 'Largeur fixe', a.width, { + min: 50, max: 1200, step: 10, unit: 'px', fallback: 300, + })} + ${forgeDocRenderToggleSliderHtml(`${idPrefix}MaxWidth`, 'Largeur maximale', a.max_width, { + min: 200, max: 1200, step: 20, unit: 'px', fallback: 600, })} ${forgeDocRenderToggleSliderHtml(`${idPrefix}Height`, 'Hauteur fixe', a.height, { min: 0, max: 800, step: 10, unit: 'px', fallback: 200, @@ -1859,6 +2042,8 @@ function forgeDocBindBoxFields(panel, el, idPrefix, patch) { forgeDocBindToggleSlider(`${idPrefix}Padding`, { unit: 'px' }, (v) => patch({ padding: v })); forgeDocBindToggleSlider(`${idPrefix}Margin`, { unit: 'px' }, (v) => patch({ margin: v })); forgeDocBindToggleSlider(`${idPrefix}Radius`, { unit: 'px' }, (v) => patch({ border_radius: v })); + forgeDocBindToggleSlider(`${idPrefix}Width`, { unit: 'px' }, (v) => patch({ width: v })); + forgeDocBindToggleSlider(`${idPrefix}MaxWidth`, { unit: 'px' }, (v) => patch({ max_width: v })); forgeDocBindToggleSlider(`${idPrefix}Height`, { unit: 'px' }, (v) => patch({ height: v })); forgeDocBindToggleSlider(`${idPrefix}MinHeight`, { unit: 'px' }, (v) => patch({ min_height: v })); forgeDocBindToggleSlider(`${idPrefix}MaxHeight`, { unit: 'px' }, (v) => patch({ max_height: v })); @@ -1959,9 +2144,6 @@ function forgeDocRenderTextProps(panel, el) { ${FORGE_DOC_TEXT_SHADOW_PRESETS.map(([v, l]) => ``).join('')}
- ${forgeDocRenderToggleSliderHtml('docMaxWidth', 'Largeur maximale', a.max_width, { - min: 200, max: 1200, step: 20, unit: 'px', fallback: 600, - })} ${forgeDocRenderBoxFieldsHtml(a, 'docText')} ${forgeDocDeleteButtonHtml()} `; @@ -1969,7 +2151,6 @@ function forgeDocRenderTextProps(panel, el) { function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); } document.getElementById('docContentInput').addEventListener('change', (e) => patch({ content: e.target.value })); - forgeDocBindToggleSlider('docMaxWidth', { unit: 'px' }, (v) => patch({ max_width: v })); panel.querySelectorAll('#docStyleSeg .docSegBtn').forEach((btn) => { btn.addEventListener('click', () => patch({ style: btn.dataset.style })); }); @@ -1994,19 +2175,111 @@ function forgeDocRenderTextProps(panel, el) { forgeDocBindBoxFields(panel, el, 'docText', patch); } +const FORGE_DOC_OBJECT_FIT_OPTIONS = [ + ['', 'Taille réelle'], ['cover', 'Couvrir'], ['contain', 'Contenir'], ['fill', 'Étirer'], +]; +const FORGE_DOC_ASPECT_RATIO_OPTIONS = [ + ['', 'Libre'], ['1 / 1', 'Carré'], ['4 / 3', '4:3'], ['16 / 9', '16:9'], +]; +const FORGE_DOC_FILTER_OPTIONS = [ + ['', 'Aucun'], ['grayscale', 'Noir et blanc'], ['sepia', 'Sépia'], ['blur', 'Flouté'], +]; +const FORGE_DOC_IMAGE_CLICK_OPTIONS = [ + ['', 'Aucun'], ['link', 'Ouvrir un lien'], ['lightbox', 'Plein écran'], +]; + function forgeDocRenderImageProps(panel, el) { const a = el.attributes; + const clickBehavior = a.click_behavior || ''; panel.innerHTML = ` ${forgeDocPropsHeader('image')} -
-
+
+ Fichier image +
${a.src ? 'Image actuelle en place' : 'Aucun fichier choisi'}
+ +
+
+
+
+
+ Ajustement dans son cadre +
+ ${FORGE_DOC_OBJECT_FIT_OPTIONS.map(([v, l]) => ``).join('')} +
+
+
+ Format (ratio) +
+ ${FORGE_DOC_ASPECT_RATIO_OPTIONS.map(([v, l]) => ``).join('')} +
+
+
+ Filtre +
+ ${FORGE_DOC_FILTER_OPTIONS.map(([v, l]) => ``).join('')} +
+
+
+ +
+
+ Au clic (en Aperçu) +
+ ${FORGE_DOC_IMAGE_CLICK_OPTIONS.map(([v, l]) => ``).join('')} +
+
+
+ + +
+ ${forgeDocRenderBoxFieldsHtml(a, 'docImage')} ${forgeDocDeleteButtonHtml()} `; function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); } + document.getElementById('docImageUploadInput').addEventListener('change', (e) => { + const file = e.target.files[0]; + if (!file) return; + forgeDocApiUploadImage(el.id, file).then((result) => { + if (result.error) { alert(result.error); return; } + el.attributes = result.attributes; + forgeDocRenderProps(window.forgeDocState.elementsById[el.id]); + const dom = document.querySelector(`[data-element-id="${el.id}"]`); + if (dom && result.rendered_html) { + const wrapper = document.createElement('div'); + wrapper.innerHTML = result.rendered_html; + dom.replaceWith(wrapper.firstElementChild); + forgeDocBindCanvasInteractions(); + forgeDocApplySelectionClass(); + } + // Bug réel constaté le 26/09/2026 : "quand j'ajoute une image ça + // crée des pages à l'infini" / "ce n'est pas redimensionner" — + // cette route d'upload patch le DOM elle-même (fichier multipart, + // jamais via forgeDocApiUpdate/forgeDocUpdateAttributes qui fait + // déjà cette vérification), donc jamais couverte par le point + // d'entrée central. Vérifiée ICI explicitement pour cette raison. + forgeDocCheckPageOverflow(); + }); + }); document.getElementById('docImageSrc').addEventListener('change', (e) => patch({ src: e.target.value.trim() })); document.getElementById('docImageAlt').addEventListener('change', (e) => patch({ alt: e.target.value })); document.getElementById('docImageSvg').addEventListener('change', (e) => patch({ svg_markup: e.target.value.trim() })); + document.getElementById('docImageCaption').addEventListener('change', (e) => patch({ caption: e.target.value.trim() })); + panel.querySelectorAll('#docImageFitSeg .docSegBtn').forEach((btn) => { + btn.addEventListener('click', () => patch({ object_fit: btn.dataset.fit })); + }); + panel.querySelectorAll('#docImageRatioSeg .docSegBtn').forEach((btn) => { + btn.addEventListener('click', () => patch({ aspect_ratio: btn.dataset.ratio })); + }); + panel.querySelectorAll('#docImageFilterSeg .docSegBtn').forEach((btn) => { + btn.addEventListener('click', () => patch({ filter_preset: btn.dataset.filter })); + }); + document.getElementById('docImageLazyLoad').addEventListener('change', (e) => patch({ lazy_load: e.target.checked })); + panel.querySelectorAll('#docImageClickSeg .docSegBtn').forEach((btn) => { + btn.addEventListener('click', () => patch({ click_behavior: btn.dataset.click })); + }); + document.getElementById('docImageLinkUrl').addEventListener('change', (e) => patch({ link_url: e.target.value.trim() })); + forgeDocBindBoxFields(panel, el, 'docImage', patch); } function forgeDocRenderButtonProps(panel, el) { diff --git a/templates/document/document_edit.html b/templates/document/document_edit.html index b1acae8c..aa367f34 100644 --- a/templates/document/document_edit.html +++ b/templates/document/document_edit.html @@ -152,6 +152,10 @@ toujours visible" — avec beaucoup de pages, il disparaissait en bas de la liste défilante). #} +
{% for page in pages %}
✕ + title="Supprimer cette page">✕
{% endfor %} @@ -207,8 +210,8 @@
{{ rendered_document|safe }}
- {# //NOSONAR S5247 - rendered_document vient de document_engine.render_document, qui échappe (html.escape) tout contenu utilisateur avant interpolation (voir document_engine/rendering/render_document_element.py) ; jamais de HTML brut non échappé ici #} + data-vertical-align="{{ active_page.vertical_align if active_page else 'top' }}">{% if active_page %}{{ rendered_document|safe }}{% else %}
Aucune page — cliquez sur « + Ajouter une page » pour commencer.
{% endif %}
+ {# //NOSONAR S5247 - rendered_document vient de document_engine.render_document, qui échappe (html.escape) tout contenu utilisateur avant interpolation (voir document_engine/rendering/render_document_element.py) ; jamais de HTML brut non échappé ici. Le message "Aucune page" est un littéral, jamais un contenu utilisateur. #}