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. #}