Audit complet de mise en forme — Image (2e élément du tableau)
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
6c7675fad0
commit
9ad50c58b8
@@ -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
|
mais un schéma entièrement différent (voir document_engine/ : un
|
||||||
support est composé de PAGES — _document_pages —, chacune portant son
|
support est composé de PAGES — _document_pages —, chacune portant son
|
||||||
propre flux d'éléments via _document_elements.page_id, jamais
|
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")
|
d'écrans/objets de scène comme côté jeu). Créé ici SANS aucune page
|
||||||
est toujours créée ici : un support n'existe jamais sans au moins une
|
(retour utilisateur : l'éditeur doit pouvoir s'ouvrir vide, "nouveau
|
||||||
page (voir document_engine/pages/delete_document_page.py, qui refuse
|
projet par ex") — l'utilisateur clique "+ Ajouter une page" pour
|
||||||
de supprimer la dernière). owner_folder n'est jamais optionnel ici
|
commencer (voir document_engine/pages/add_document_page.py). Aucune
|
||||||
(contrairement à create_game) : un support n'existe pas sans compte
|
page n'est donc plus une garantie côté production ; seule la fixture
|
||||||
propriétaire."""
|
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)
|
project_part = slugify(name)
|
||||||
base = project_part
|
base = project_part
|
||||||
n = 2
|
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 _meta (key, value) VALUES ('name', ?)", (name,))
|
||||||
conn.execute("INSERT INTO _document_pages (title, order_index) VALUES ('Page 1', 0)")
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
return slug
|
return slug
|
||||||
|
|||||||
@@ -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
|
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
|
existants deviennent tous les enfants d'une page "Page 1" créée ici
|
||||||
automatiquement (comportement le plus proche de l'ancien : un seul
|
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 = connect_support(slug)
|
||||||
conn.execute(
|
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
|
# vertical_align : alignement vertical du contenu de la page
|
||||||
# (top/center/bottom — voir document_engine/pages/
|
# (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()}
|
cols = {r["name"] for r in conn.execute("PRAGMA table_info(_document_elements)").fetchall()}
|
||||||
if "page_id" not in cols:
|
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
|
# SQLite autorise ADD COLUMN avec une valeur par défaut CONSTANTE
|
||||||
# (jamais une contrainte REFERENCES ici, même pragmatisme que
|
# (jamais une contrainte REFERENCES ici, même pragmatisme que
|
||||||
# db/rows/ensure_player_id_column.py : la contrainte de clé
|
# db/rows/ensure_player_id_column.py : la contrainte de clé
|
||||||
|
|||||||
@@ -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
|
survol, réglages différents par taille d'écran (responsive), arrondi
|
||||||
par coin (un seul rayon pour les 4 coins, comme l'étiquette).
|
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
|
Bugs transversaux trouvés et corrigés pendant ce chantier (concernent
|
||||||
TOUT l'éditeur, pas seulement Titre/Paragraphe) :
|
TOUT l'éditeur, pas seulement Titre/Paragraphe) :
|
||||||
- Panneau Propriétés jamais reconstruit après un clic (boutons/segments
|
- 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
|
à tort par les cases à cocher et curseurs, cassant leur apparence
|
||||||
native.
|
native.
|
||||||
|
|
||||||
### 2. Image — à faire (prochain élément)
|
### 2. Image — ✅ audité et validé (commité)
|
||||||
|
|
||||||
|
Implémenté : légende (`caption`, enveloppe dans `<figure>/<figcaption>`),
|
||||||
|
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'`<img>`/`<div>` INTERNE, jamais à son enveloppe
|
||||||
|
(`<figure>`/`<a class="docImageLink">`/`<div class=
|
||||||
|
"docImageLightboxTrigger">`) 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
|
### 3. Bouton — à faire
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ from .labels.scenario_config import (
|
|||||||
sanitize_scenario_config,
|
sanitize_scenario_config,
|
||||||
)
|
)
|
||||||
from .pages.add_document_page import add_document_page
|
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.delete_document_page import delete_document_page
|
||||||
from .pages.get_document_page import get_document_page
|
from .pages.get_document_page import get_document_page
|
||||||
from .pages.list_document_pages import list_document_pages
|
from .pages.list_document_pages import list_document_pages
|
||||||
@@ -109,6 +110,7 @@ __all__ = [
|
|||||||
"VERTICAL_ALIGNS",
|
"VERTICAL_ALIGNS",
|
||||||
"add_document_element",
|
"add_document_element",
|
||||||
"add_document_page",
|
"add_document_page",
|
||||||
|
"delete_all_document_pages",
|
||||||
"delete_document_element",
|
"delete_document_element",
|
||||||
"delete_document_page",
|
"delete_document_page",
|
||||||
"element_default_attributes",
|
"element_default_attributes",
|
||||||
|
|||||||
@@ -48,10 +48,6 @@ _TEXT_DEFAULTS = {
|
|||||||
"strikethrough": False,
|
"strikethrough": False,
|
||||||
"align": "left",
|
"align": "left",
|
||||||
"color": "var(--forge-text)",
|
"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
|
# Vides par défaut = valeurs du préréglage `style` inchangées (voir
|
||||||
# _STYLE_PRESETS dans render_document_element.py) — une valeur
|
# _STYLE_PRESETS dans render_document_element.py) — une valeur
|
||||||
# explicite les remplace (retour utilisateur du 26/09/2026, audit des
|
# explicite les remplace (retour utilisateur du 26/09/2026, audit des
|
||||||
@@ -62,8 +58,10 @@ _TEXT_DEFAULTS = {
|
|||||||
"letter_spacing": "",
|
"letter_spacing": "",
|
||||||
"text_transform": "none",
|
"text_transform": "none",
|
||||||
"text_shadow": "",
|
"text_shadow": "",
|
||||||
# Attributs de boîte partagés avec d'autres kinds (padding/margin/
|
# Attributs de boîte partagés avec d'autres kinds (dont `max_width`,
|
||||||
# background_color/border_radius/align_self) — voir rendering/box_style.py.
|
# 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,
|
**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
|
# svg_markup (optionnel) prend le pas sur src au rendu (voir
|
||||||
# render_document_element._render_image) — un contenu vectoriel
|
# render_document_element._render_image) — un contenu vectoriel
|
||||||
# dessiné/collé directement plutôt qu'un fichier hébergé.
|
# 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":
|
if kind == "bouton":
|
||||||
# attachment_stored_name/attachment_filename (optionnels) : un
|
# attachment_stored_name/attachment_filename (optionnels) : un
|
||||||
# fichier téléchargeable joint au bouton (voir routes/document/
|
# fichier téléchargeable joint au bouton (voir routes/document/
|
||||||
|
|||||||
@@ -44,18 +44,26 @@ l'affichage/debug hors bibliothèque).
|
|||||||
## `element_default_attributes(kind: str) -> dict[str, Any]`
|
## `element_default_attributes(kind: str) -> dict[str, Any]`
|
||||||
Attributs posés à la création d'un élément de ce type (voir
|
Attributs posés à la création d'un élément de ce type (voir
|
||||||
`document_engine/elements/add_document_element.py`).
|
`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/
|
texte (`content/style` + `bold/italic/underline/strikethrough/align/
|
||||||
color/max_width/font_family/font_size/line_height/letter_spacing/
|
color/font_family/font_size/line_height/letter_spacing/
|
||||||
text_transform/text_shadow` + les attributs de boîte partagés
|
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`),
|
|
||||||
image (`src/alt/svg_markup` — `svg_markup` prend le pas sur `src` au
|
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
|
attachment_stored_name/attachment_filename` — la pièce jointe est
|
||||||
indépendante de `target`, réservé à la navigation), liste à
|
indépendante de `target`, réservé à la navigation), liste à
|
||||||
puces/numérotée (`items`, une liste de chaînes), badge
|
puces/numérotée (`items`, une liste de chaînes), badge
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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
|
"""Toutes les pages d'un support, triées par order_index — la bande
|
||||||
d'onglets du panneau Propriétés (voir static/document/js/
|
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
|
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
|
directement. Peut renvoyer une liste VIDE (retour utilisateur : un
|
||||||
db/supports/create_support.py / ensure_document_pages_schema)."""
|
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)
|
ensure_document_pages_schema(slug)
|
||||||
conn = connect_support(slug)
|
conn = connect_support(slug)
|
||||||
rows = conn.execute("SELECT * FROM _document_pages ORDER BY order_index").fetchall()
|
rows = conn.execute("SELECT * FROM _document_pages ORDER BY order_index").fetchall()
|
||||||
|
|||||||
@@ -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:
|
`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
|
"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
|
composé de plusieurs pages, chacune portant son propre flux d'éléments
|
||||||
(voir `document_engine/elements/`, filtré par `page_id`). Un support a
|
(voir `document_engine/elements/`, filtré par `page_id`).
|
||||||
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
|
**Un support peut avoir 0 page** (retour utilisateur du 26/09/2026 :
|
||||||
anciens) — la garde "jamais supprimer la dernière page" est un
|
"l'éditeur ne dois plus etre obliger d'avoir une page active ou créer,
|
||||||
garde-fou métier posé par l'appelant (voir
|
il peut etre ouvert sans aucune page") — `create_support` n'en crée plus
|
||||||
`routes/document/document_page_delete.py`), pas une contrainte de ce
|
aucune par défaut, et `ensure_document_pages_schema` ne recrée plus
|
||||||
paquet.
|
"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`
|
## `add_document_page(slug: str, title: str | None = None) -> int`
|
||||||
Ajoute une page en fin de la bande d'onglets. `title` par défaut :
|
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
|
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,
|
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).
|
voir `db/supports/ensure_document_pages_schema.py` pour les anciens).
|
||||||
Ne refuse JAMAIS de supprimer la dernière page restante — cette règle
|
Ne refuse JAMAIS de supprimer la dernière page restante — un support à
|
||||||
est posée par l'appelant, pas par cette fonction bas niveau (même
|
0 page est un état valide (voir plus haut).
|
||||||
découpage que `routes/game/screens/screen_delete.py` côté jeu, où le
|
- **Retour** : aucun.
|
||||||
garde-fou vit aussi dans la route).
|
- **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.
|
- **Retour** : aucun.
|
||||||
- **Exceptions** : aucune.
|
- **Exceptions** : aucune.
|
||||||
|
|||||||
@@ -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
|
element_default_attributes(kind) (jamais un remplacement brut) pour
|
||||||
rester valides même si `seed_pages` n'en précise qu'une partie."""
|
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
|
# Les nouvelles pages sont créées AVANT de supprimer les anciennes
|
||||||
# (jamais l'inverse) : passer par zéro page, même un court instant,
|
# (jamais l'inverse) : ça évite que le support affiche un état "0 page"
|
||||||
# déclenche le filet de sécurité de ensure_document_pages_schema (un
|
# transitoire pendant le remplacement (même si 0 page est désormais un
|
||||||
# support a TOUJOURS au moins une page), qui recréerait une "Page 1"
|
# état par ailleurs valide, voir list_document_pages.py — ce n'est
|
||||||
# vide juste avant l'ajout de la première page du thème — bug réel
|
# qu'une question d'ordre d'écriture ici, plus un filet de sécurité).
|
||||||
# rencontré en écrivant les tests de cette fonction.
|
|
||||||
old_page_ids = [page["id"] for page in list_document_pages(slug)]
|
old_page_ids = [page["id"] for page in list_document_pages(slug)]
|
||||||
for seed_page in seed_pages:
|
for seed_page in seed_pages:
|
||||||
page_id = add_document_page(slug)
|
page_id = add_document_page(slug)
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ BOX_DEFAULTS = {
|
|||||||
"background_color": "",
|
"background_color": "",
|
||||||
"border_radius": "",
|
"border_radius": "",
|
||||||
"align_self": "stretch",
|
"align_self": "stretch",
|
||||||
|
"width": "",
|
||||||
|
"max_width": "",
|
||||||
"height": "",
|
"height": "",
|
||||||
"min_height": "",
|
"min_height": "",
|
||||||
"max_height": "",
|
"max_height": "",
|
||||||
@@ -36,58 +38,39 @@ BOX_DEFAULTS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def render_box_style(a: dict[str, Any]) -> str:
|
# (clé d'attribut, propriété CSS) — chaque paire suit exactement le même
|
||||||
"""Construit les déclarations CSS inline communes à plusieurs kinds à
|
# patron (lire/nettoyer/ajouter si non vide) ; une simple table de
|
||||||
partir des attributs `padding`/`margin`/`background_color`/
|
# correspondance ici évite un enchaînement de blocs `if` identiques
|
||||||
`border_radius`/`height`/`min_height`/`max_height`/`min_width`/
|
# (complexité cognitive réduite, voir _render_simple_properties).
|
||||||
`box_shadow`/`opacity`/`border`/`align_self` de `a` — chaîne vide pour
|
_SIMPLE_PROPERTIES = (
|
||||||
tout attribut absent ou à sa valeur par défaut (aucun style ajouté,
|
("padding", "padding"),
|
||||||
comportement historique inchangé). `border` est un dict à 4 clés
|
("margin", "margin"),
|
||||||
(`BORDER_SIDES`), chacune `{"style", "width", "color"}` — un côté à
|
("background_color", "background-color"),
|
||||||
`style="none"` (ou absent) ne produit aucune déclaration pour ce
|
("border_radius", "border-radius"),
|
||||||
côté, jamais un `border-top:none` explicite.
|
("width", "width"),
|
||||||
- **Retour** : les déclarations CSS (`"propriete:valeur; ..."`),
|
("max_width", "max-width"),
|
||||||
jamais vide ni `None`.
|
("height", "height"),
|
||||||
- **Exceptions** : aucune."""
|
("min_height", "min-height"),
|
||||||
parts: list[str] = []
|
("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()
|
def _render_simple_properties(a: dict[str, Any]) -> list[str]:
|
||||||
if margin:
|
parts = []
|
||||||
parts.append(f"margin:{html_lib.escape(margin)};")
|
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 {}
|
border = a.get("border") or {}
|
||||||
for side in BORDER_SIDES:
|
for side in BORDER_SIDES:
|
||||||
side_border = border.get(side) or {}
|
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")))
|
width = html_lib.escape(str(side_border.get("width", "1px")))
|
||||||
color = html_lib.escape(str(side_border.get("color", "var(--doc-border)")))
|
color = html_lib.escape(str(side_border.get("color", "var(--doc-border)")))
|
||||||
parts.append(f"border-{side}:{width} {html_lib.escape(style)} {color};")
|
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
|
# 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
|
# ailleurs une taille bornée (max_width/width) — voir la note dans
|
||||||
|
|||||||
@@ -94,15 +94,11 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
|
|||||||
if text_shadow:
|
if text_shadow:
|
||||||
style += f" text-shadow:{html_lib.escape(text_shadow)};"
|
style += f" text-shadow:{html_lib.escape(text_shadow)};"
|
||||||
|
|
||||||
# max_width optionnel (ex. "60ch", "480px") — vide par défaut (pleine
|
# max_width (ex. "60ch", "480px" — retour utilisateur du 24/09/2026 :
|
||||||
# largeur de .docPageContent, comportement inchangé). Retour
|
# un paragraphe doit pouvoir rester plus étroit que la page, sans
|
||||||
# utilisateur du 24/09/2026 : un paragraphe doit pouvoir rester plus
|
# dépendre d'une rangée qui en partagerait la largeur avec un frère)
|
||||||
# étroit que la page, comme un sous-titre sous un grand titre, sans
|
# fait maintenant partie des attributs de boîte partagés
|
||||||
# dépendre d'une rangée (qui partagerait la largeur avec un frère).
|
# (render_box_style), jamais géré ici en double.
|
||||||
max_width = str(a.get("max_width", "")).strip()
|
|
||||||
if max_width:
|
|
||||||
style += f" max-width:{html_lib.escape(max_width)};"
|
|
||||||
|
|
||||||
box_style = render_box_style(a)
|
box_style = render_box_style(a)
|
||||||
if box_style:
|
if box_style:
|
||||||
style += f" {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'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>'
|
return f'<div class="docText" data-element-id="{el["id"]}" data-kind="{el["kind"]}" style="{style}">{content}</div>'
|
||||||
|
|
||||||
|
|
||||||
|
_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:
|
def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
a = el["attributes"]
|
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'<img>/<div> 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()
|
svg_markup = str(a.get("svg_markup", "")).strip()
|
||||||
if svg_markup:
|
if svg_markup:
|
||||||
# Contenu vectoriel dessiné/collé par le créateur plutôt qu'un
|
# 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,
|
# CHAQUE rendu (jamais seulement à l'écriture) par sanitize_svg_markup,
|
||||||
# même défense en profondeur que html.escape sur les autres kinds.
|
# même défense en profondeur que html.escape sur les autres kinds.
|
||||||
sanitized = sanitize_svg_markup(svg_markup)
|
sanitized = sanitize_svg_markup(svg_markup)
|
||||||
return f'<div class="docImage" data-element-id="{el["id"]}" data-kind="image">{sanitized}</div>'
|
media = (
|
||||||
|
f'<div class="docImage" data-element-id="{el["id"]}" data-kind="image"{media_style_attr}>{sanitized}</div>'
|
||||||
|
)
|
||||||
|
else:
|
||||||
src = html_lib.escape(str(a.get("src", "")))
|
src = html_lib.escape(str(a.get("src", "")))
|
||||||
alt = html_lib.escape(str(a.get("alt", "")))
|
alt = html_lib.escape(str(a.get("alt", "")))
|
||||||
if not src:
|
if not src:
|
||||||
return (
|
media = (
|
||||||
f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" data-kind="image">'
|
f'<div class="docImage docImagePlaceholder" data-element-id="{el["id"]}" '
|
||||||
f"Image — aucun fichier choisi</div>"
|
f'data-kind="image"{media_style_attr}>Image — aucun fichier choisi</div>'
|
||||||
)
|
)
|
||||||
return f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" src="{src}" alt="{alt}">'
|
else:
|
||||||
|
media = (
|
||||||
|
f'<img class="docImage" data-element-id="{el["id"]}" data-kind="image" '
|
||||||
|
f'src="{src}" alt="{alt}"{media_style_attr}{loading_attr}>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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'<a class="docImageLink" href="{href}" target="_blank" '
|
||||||
|
f'rel="noopener noreferrer"{wrapper_style_attr}>{media}</a>'
|
||||||
|
)
|
||||||
|
elif click_behavior == "lightbox":
|
||||||
|
wrapper_style_attr = f' style="{box_style}"' if (box_style and not caption) else ""
|
||||||
|
media = f'<div class="docImageLightboxTrigger"{wrapper_style_attr}>{media}</div>'
|
||||||
|
|
||||||
|
if caption:
|
||||||
|
figure_style_attr = f' style="{box_style}"' if box_style else ""
|
||||||
|
media = (
|
||||||
|
f'<figure class="docImageFigure"{figure_style_attr}>{media}'
|
||||||
|
f'<figcaption class="docImageCaption">{html_lib.escape(caption)}</figcaption></figure>'
|
||||||
|
)
|
||||||
|
|
||||||
|
return media
|
||||||
|
|
||||||
|
|
||||||
def _render_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
def _render_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
|
|||||||
@@ -52,7 +52,21 @@ regroupement à chaque appel.
|
|||||||
`<div>` portant directement ce fragment SVG nettoyé par
|
`<div>` portant directement ce fragment SVG nettoyé par
|
||||||
`sanitize_svg_markup` (voir `sanitize_svg_markup.py` ci-dessous) : un
|
`sanitize_svg_markup` (voir `sanitize_svg_markup.py` ci-dessous) : un
|
||||||
contenu vectoriel dessiné/collé par le créateur plutôt qu'un fichier
|
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'`<img>` uniquement (comportement, pas du style). `click_behavior`
|
||||||
|
(`""`/`"link"`/`"lightbox"`) enveloppe le tout dans un `<a target="_blank"
|
||||||
|
rel="noopener noreferrer">` (si `link_url` est aussi renseigné) ou un
|
||||||
|
`<div class="docImageLightboxTrigger">` — 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 `<figure><figcaption>` échappée.
|
||||||
- **Bouton** : `<button>` avec son `label`, un `data-target` optionnel
|
- **Bouton** : `<button>` avec son `label`, un `data-target` optionnel
|
||||||
(navigation) et un `data-attachment-filename` optionnel — marqueur
|
(navigation) et un `data-attachment-filename` optionnel — marqueur
|
||||||
mécanique posé quand un fichier a été joint (voir
|
mécanique posé quand un fichier a été joint (voir
|
||||||
@@ -213,17 +227,22 @@ Un dict à 4 clés (`BORDER_SIDES`), chacune `{"style": "none", "width":
|
|||||||
|
|
||||||
### `BOX_DEFAULTS: dict[str, Any]`
|
### `BOX_DEFAULTS: dict[str, Any]`
|
||||||
`{"padding": "", "margin": "", "background_color": "", "border_radius":
|
`{"padding": "", "margin": "", "background_color": "", "border_radius":
|
||||||
"", "align_self": "stretch", "height": "", "min_height": "",
|
"", "align_self": "stretch", "width": "", "max_width": "", "height": "",
|
||||||
"max_height": "", "min_width": "", "box_shadow": "", "opacity": ""}` —
|
"min_height": "", "max_height": "", "min_width": "", "box_shadow": "",
|
||||||
`border` n'y figure PAS (voir `default_border()`, à ajouter séparément
|
"opacity": ""}` — `border` n'y figure PAS (voir `default_border()`, à
|
||||||
par chaque appelant pour éviter le partage par référence).
|
ajouter séparément par chaque appelant pour éviter le partage par
|
||||||
|
référence).
|
||||||
|
|
||||||
### `render_box_style(a: dict[str, Any]) -> str`
|
### `render_box_style(a: dict[str, Any]) -> str`
|
||||||
Construit les déclarations CSS inline pour `padding`/`margin`/
|
Construit les déclarations CSS inline pour chaque attribut listé dans
|
||||||
`background_color`/`border_radius`/`height`/`min_height`/`max_height`/
|
`BOX_DEFAULTS` (padding/margin/background_color/border_radius/width/
|
||||||
`min_width`/`box_shadow`/`opacity`/`border`/`align_self` de `a` — un
|
max_width/height/min_height/max_height/min_width/box_shadow/opacity) +
|
||||||
attribut absent ou à sa valeur par défaut ne produit AUCUNE déclaration
|
`border`/`align_self` de `a`, via une table de correspondance
|
||||||
(comportement historique inchangé). `border` est un dict à 4 clés
|
(clé d'attribut, propriété CSS) plutôt qu'un bloc `if` par attribut
|
||||||
|
(complexité cognitive — voir `_render_simple_properties`/`_render_border`,
|
||||||
|
privées) — un attribut absent ou à sa valeur par défaut ne produit
|
||||||
|
AUCUNE déclaration (comportement historique inchangé). `border` est un
|
||||||
|
dict à 4 clés
|
||||||
(`BORDER_SIDES`), chacune `{"style", "width", "color"}` — un côté à
|
(`BORDER_SIDES`), chacune `{"style", "width", "color"}` — un côté à
|
||||||
`style="none"` (ou absent) ne produit rien pour ce côté, jamais un
|
`style="none"` (ou absent) ne produit rien pour ce côté, jamais un
|
||||||
`border-top:none` explicite. `align-self` n'est ajouté que si différent
|
`border-top:none` explicite. `align-self` n'est ajouté que si différent
|
||||||
|
|||||||
@@ -13,13 +13,16 @@ from . import ( # noqa: F401 - enregistre les routes definies dans chaque modul
|
|||||||
document_element_move_to_page,
|
document_element_move_to_page,
|
||||||
document_element_update,
|
document_element_update,
|
||||||
document_element_upload_attachment,
|
document_element_upload_attachment,
|
||||||
|
document_element_upload_image,
|
||||||
document_new,
|
document_new,
|
||||||
document_page_add,
|
document_page_add,
|
||||||
document_page_delete,
|
document_page_delete,
|
||||||
|
document_page_delete_all,
|
||||||
document_page_move,
|
document_page_move,
|
||||||
document_page_rename,
|
document_page_rename,
|
||||||
document_page_vertical_align,
|
document_page_vertical_align,
|
||||||
document_render,
|
document_render,
|
||||||
document_theme_apply,
|
document_theme_apply,
|
||||||
document_theme_preview,
|
document_theme_preview,
|
||||||
|
document_uploaded_file,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,12 +37,21 @@ def document_edit(slug: str) -> str:
|
|||||||
constaté le 21/09/2026)."""
|
constaté le 21/09/2026)."""
|
||||||
support = db.support_meta(slug)
|
support = db.support_meta(slug)
|
||||||
pages = document_engine.list_document_pages(slug)
|
pages = document_engine.list_document_pages(slug)
|
||||||
active_page = pages[0]
|
# active_page peut être None : un support peut avoir 0 page (retour
|
||||||
|
# utilisateur du 26/09/2026, voir document_engine/pages/pages.md) — le
|
||||||
|
# canevas et le panneau Pages doivent alors afficher un état "aucune
|
||||||
|
# page" plutôt que de planter, voir document_edit.html et
|
||||||
|
# document-editor.js (forgeDocSwitchPage/forgeDocRefreshCanvas).
|
||||||
|
active_page = pages[0] if pages else None
|
||||||
|
|
||||||
active_elements: list[dict[str, Any]] = [
|
active_elements: list[dict[str, Any]] = (
|
||||||
|
[
|
||||||
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
|
{**el, "attributes": document_engine.sanitize_element_attributes(el["kind"], el["attributes"])}
|
||||||
for el in document_engine.list_document_elements(slug, active_page["id"])
|
for el in document_engine.list_document_elements(slug, active_page["id"])
|
||||||
]
|
]
|
||||||
|
if active_page is not None
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
active_theme = document_engine.get_document_theme_entry(support["theme"]) if support["theme"] else None
|
active_theme = document_engine.get_document_theme_entry(support["theme"]) if support["theme"] else None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from flask import jsonify, request, url_for
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
|
import document_engine
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
_ALLOWED_IMAGE_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg")
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/elements/<int:element_id>/upload-image", methods=["POST"])
|
||||||
|
def document_element_upload_image(slug: str, element_id: int) -> Response | tuple[Response, int]:
|
||||||
|
"""Téléverse un fichier image pour un élément "image" (voir
|
||||||
|
_render_image, document_engine/rendering/render_document_element.py)
|
||||||
|
— mirroir de document_element_upload_attachment.py, mais stocké sous
|
||||||
|
`uploads/` (jamais `attachments/`, réservé au bouton) et réservé au
|
||||||
|
kind "image". `svg_markup` est vidé au passage : un fichier
|
||||||
|
téléversé implique `src`, jamais les deux modes en même temps (voir
|
||||||
|
element_default_attributes, `svg_markup` prioritaire sur `src` au
|
||||||
|
rendu — le vider ici évite qu'un ancien code SVG masque
|
||||||
|
silencieusement la photo qui vient d'être choisie)."""
|
||||||
|
element = document_engine.get_document_element(slug, element_id)
|
||||||
|
if element is None:
|
||||||
|
return jsonify({"error": "élément introuvable"}), 404
|
||||||
|
if element["kind"] != "image":
|
||||||
|
return jsonify({"error": "seul un élément image peut recevoir un fichier téléversé"}), 400
|
||||||
|
f = request.files.get("file")
|
||||||
|
if not f or not f.filename:
|
||||||
|
return jsonify({"error": "Aucun fichier reçu"}), 400
|
||||||
|
ext = os.path.splitext(f.filename)[1].lower()
|
||||||
|
if ext not in _ALLOWED_IMAGE_EXTENSIONS:
|
||||||
|
return jsonify({"error": "Format d'image non pris en charge (PNG, JPG, GIF, WEBP, SVG uniquement)"}), 400
|
||||||
|
stored_name = uuid.uuid4().hex + ext
|
||||||
|
uploads_dir = os.path.join(db.support_dir(slug), "uploads")
|
||||||
|
os.makedirs(uploads_dir, exist_ok=True)
|
||||||
|
f.save(os.path.join(uploads_dir, stored_name))
|
||||||
|
|
||||||
|
attributes = {
|
||||||
|
**element["attributes"],
|
||||||
|
"src": url_for("document_uploaded_file", slug=slug, filename=stored_name),
|
||||||
|
"svg_markup": "",
|
||||||
|
}
|
||||||
|
document_engine.update_document_element_attributes(slug, element_id, attributes)
|
||||||
|
element = db.assert_not_none(
|
||||||
|
document_engine.get_document_element(slug, element_id),
|
||||||
|
"element_id verifie present juste au-dessus, aucune suppression concurrente possible entre-temps ici",
|
||||||
|
)
|
||||||
|
elements_by_parent: dict[int | None, list[dict[str, Any]]] = {}
|
||||||
|
for el in document_engine.list_document_elements(slug, element["page_id"]):
|
||||||
|
elements_by_parent.setdefault(el["parent_id"], []).append(el)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"ok": True,
|
||||||
|
"attributes": element["attributes"],
|
||||||
|
"rendered_html": document_engine.render_document_element(element, elements_by_parent),
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -7,14 +7,12 @@ from core.flask_app import app
|
|||||||
|
|
||||||
@app.route("/document/<slug>/pages/<int:page_id>/delete", methods=["POST"])
|
@app.route("/document/<slug>/pages/<int:page_id>/delete", methods=["POST"])
|
||||||
def document_page_delete(slug: str, page_id: int) -> Response | tuple[Response, int]:
|
def document_page_delete(slug: str, page_id: int) -> Response | tuple[Response, int]:
|
||||||
"""Supprime une page — refuse de supprimer la DERNIÈRE page restante
|
"""Supprime une page, y compris la dernière restante — un support à 0
|
||||||
(un support a toujours au moins une page, voir document_engine/pages/
|
page est un état valide (retour utilisateur du 26/09/2026 : "l'éditeur
|
||||||
pages.md) : le garde-fou vit ici, à la route, pas dans
|
ne dois plus etre obliger d'avoir une page active", voir
|
||||||
delete_document_page (même découpage que routes/game/screens/
|
document_engine/pages/pages.md). Voir aussi document_page_delete_all.py
|
||||||
screen_delete.py côté jeu, qui bloque aussi au niveau route)."""
|
pour tout supprimer d'un coup."""
|
||||||
if document_engine.get_document_page(slug, page_id) is None:
|
if document_engine.get_document_page(slug, page_id) is None:
|
||||||
return jsonify({"error": "page introuvable"}), 404
|
return jsonify({"error": "page introuvable"}), 404
|
||||||
if len(document_engine.list_document_pages(slug)) <= 1:
|
|
||||||
return jsonify({"error": "impossible de supprimer la dernière page"}), 400
|
|
||||||
document_engine.delete_document_page(slug, page_id)
|
document_engine.delete_document_page(slug, page_id)
|
||||||
return jsonify({"ok": True})
|
return jsonify({"ok": True})
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from flask import jsonify
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import document_engine
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/pages/delete-all", methods=["POST"])
|
||||||
|
def document_page_delete_all(slug: str) -> Response:
|
||||||
|
"""Supprime toutes les pages du support d'un coup (retour utilisateur :
|
||||||
|
"une option dans page pour supprimer toute les page d'un coup") — le
|
||||||
|
support se retrouve à 0 page, exactement comme un support neuf. Action
|
||||||
|
destructive et irréversible, jamais appelée sans confirmation
|
||||||
|
explicite côté client (voir static/document/js/document-editor.js,
|
||||||
|
forgeDocDeleteAllPages)."""
|
||||||
|
document_engine.delete_all_document_pages(slug)
|
||||||
|
return jsonify({"ok": True})
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
from flask import send_from_directory
|
||||||
|
from werkzeug.wrappers import Response
|
||||||
|
|
||||||
|
import db
|
||||||
|
from core.flask_app import app
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/document/<slug>/uploads/<path:filename>")
|
||||||
|
def document_uploaded_file(slug: str, filename: str) -> Response:
|
||||||
|
"""Sert un fichier téléversé pour ce support (voir
|
||||||
|
document_element_upload_image.py) — mirroir de
|
||||||
|
routes/uploads/uploaded_file.py côté jeu, mais sous le dossier du
|
||||||
|
SUPPORT (db.support_dir). Affiché inline (jamais en téléchargement,
|
||||||
|
contrairement à document_element_download_attachment.py) : c'est une
|
||||||
|
image destinée à s'afficher dans la page, pas un fichier à
|
||||||
|
récupérer."""
|
||||||
|
return send_from_directory(os.path.join(db.support_dir(slug), "uploads"), filename)
|
||||||
@@ -604,8 +604,24 @@ body.objectEditBody > main.content {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* max-height : filet de sécurité pour une hauteur explicite (ou
|
||||||
|
l'absence de hauteur, image affichée à sa taille native) qui
|
||||||
|
dépasserait déraisonnablement une page (bug réel constaté le
|
||||||
|
26/09/2026 : "quand j'ajoute une image ça crée des pages à
|
||||||
|
l'infini") — le cadre FIXE par défaut (hauteur + object-fit:cover)
|
||||||
|
vit désormais dans les attributs par défaut d'un nouvel élément image
|
||||||
|
(voir element_kind_labels.element_default_attributes, retour
|
||||||
|
utilisateur : "ce cadre ne devrait pas changer de taille en fonction
|
||||||
|
de la taille de l'image mais être fixe"), pas ici en CSS : "Taille
|
||||||
|
réelle" (object_fit vide) doit rester un choix explicite possible
|
||||||
|
dans le panneau Propriétés, qu'un défaut CSS aveugle sur TOUTE image
|
||||||
|
casserait silencieusement (aucune façon de distinguer en CSS "jamais
|
||||||
|
réglé" de "réglé sur Taille réelle", les deux valant la chaîne vide).
|
||||||
|
Un réglage explicite de hauteur maximale prime toujours sur ce filet
|
||||||
|
(spécificité inline > feuille externe). */
|
||||||
img.docImage {
|
img.docImage {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
max-height: calc(var(--doc-page-width, 960px) * 210 / 297 - 96px);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -1427,7 +1443,8 @@ img.docImage {
|
|||||||
23/09/2026 : "le bouton pour ajouter une page dois être en haut
|
23/09/2026 : "le bouton pour ajouter une page dois être en haut
|
||||||
toujours visible") — flex-shrink:0 pour ne jamais être compressé par
|
toujours visible") — flex-shrink:0 pour ne jamais être compressé par
|
||||||
.docPageManagerList, seule cette dernière défile. */
|
.docPageManagerList, seule cette dernière défile. */
|
||||||
.docPageManagerAdd {
|
.docPageManagerAdd,
|
||||||
|
.docPageManagerDeleteAll {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1799,6 +1816,18 @@ img.docImage {
|
|||||||
padding: 30px 10px;
|
padding: 30px 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* État "aucune page" du canevas (retour utilisateur du 26/09/2026 :
|
||||||
|
l'éditeur peut s'ouvrir sans page active) — occupe toute la hauteur
|
||||||
|
disponible de .docPageContent (flex column, voir plus haut) pour
|
||||||
|
centrer le message quel que soit vertical_align. */
|
||||||
|
.docEmptyPageState {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== BOTTOM NAV (mobile) ===== */
|
/* ===== BOTTOM NAV (mobile) ===== */
|
||||||
.docBottomNav {
|
.docBottomNav {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -2563,3 +2592,54 @@ img.docImage {
|
|||||||
background: var(--doc-bg-2);
|
background: var(--doc-bg-2);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---- Image : légende, lien, plein écran (voir document_engine/
|
||||||
|
rendering/render_document_element.py::_render_image et
|
||||||
|
static/document/js/document-editor.js::forgeDocOpenImageLightbox). ---- */
|
||||||
|
.docImageFigure {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docImageCaption {
|
||||||
|
font-size: 12.5px;
|
||||||
|
color: var(--doc-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docImageLink {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docImageLightboxTrigger {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docEditor3.docEditor3--preview .docImageLightboxTrigger {
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docImageLightboxOverlay {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 300;
|
||||||
|
background: rgb(11 14 20 / 90%);
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px;
|
||||||
|
cursor: zoom-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docImageLightboxOverlay.is-open {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docImageLightboxImg {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 20px 60px -20px rgb(0 0 0 / 60%);
|
||||||
|
}
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ function forgeDocApiAdd(kind, parentId) {
|
|||||||
// définition à "la page actuellement affichée", inutile de le faire
|
// définition à "la page actuellement affichée", inutile de le faire
|
||||||
// remonter dans chacun des ~10 sites d'appel (glisser-déposer, moteur
|
// remonter dans chacun des ~10 sites d'appel (glisser-déposer, moteur
|
||||||
// de layout, Annuler/Rétablir...).
|
// de layout, Annuler/Rétablir...).
|
||||||
|
// Point d'entrée UNIQUE de tout ajout : un support peut avoir 0 page
|
||||||
|
// (retour utilisateur du 26/09/2026) — bloquer ICI, une fois, couvre
|
||||||
|
// tous les appelants (bibliothèque, glisser-déposer, rangée créée à la
|
||||||
|
// volée) sans dupliquer ce contrôle dans chacun.
|
||||||
|
if (window.forgeDocState.activePageId === null) {
|
||||||
|
alert('Ajoutez d\'abord une page avant d\'ajouter du contenu.');
|
||||||
|
return Promise.reject(new Error('aucune page active'));
|
||||||
|
}
|
||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
body.set('kind', kind);
|
body.set('kind', kind);
|
||||||
body.set('page_id', String(window.forgeDocState.activePageId));
|
body.set('page_id', String(window.forgeDocState.activePageId));
|
||||||
@@ -108,6 +116,16 @@ function forgeDocApiUploadAttachment(id, file) {
|
|||||||
.then((r) => r.json());
|
.then((r) => r.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forgeDocApiUploadImage(id, file) {
|
||||||
|
// Mirroir de forgeDocApiUploadAttachment ci-dessus, mais route dédiée
|
||||||
|
// au kind "image" (voir routes/document/document_element_upload_image.py) :
|
||||||
|
// stocke sous uploads/ (jamais attachments/) et vide svg_markup côté serveur.
|
||||||
|
const body = new FormData();
|
||||||
|
body.set('file', file);
|
||||||
|
return fetch(`/document/${window.forgeDocState.slug}/elements/${id}/upload-image`, { method: 'POST', body })
|
||||||
|
.then((r) => r.json());
|
||||||
|
}
|
||||||
|
|
||||||
function forgeDocApiRender() {
|
function forgeDocApiRender() {
|
||||||
const url = `/document/${window.forgeDocState.slug}/render?page_id=${window.forgeDocState.activePageId}`;
|
const url = `/document/${window.forgeDocState.slug}/render?page_id=${window.forgeDocState.activePageId}`;
|
||||||
return fetch(url).then((r) => r.json());
|
return fetch(url).then((r) => r.json());
|
||||||
@@ -156,18 +174,87 @@ function forgeDocCheckPageOverflow() {
|
|||||||
});
|
});
|
||||||
if (splitIndex === -1) return Promise.resolve();
|
if (splitIndex === -1) return Promise.resolve();
|
||||||
|
|
||||||
|
const overflowingEl = topLevel[splitIndex];
|
||||||
|
const FORGE_DOC_MIN_IMAGE_HEIGHT = 60;
|
||||||
|
if (overflowingEl.kind === 'image') {
|
||||||
|
// Retour utilisateur du 26/09/2026 : "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" — une image qui déborde est rétrécie SUR PLACE à
|
||||||
|
// l'espace réellement disponible sous elle, plutôt que paginée.
|
||||||
|
// Ajustement purement VISUEL (jamais persisté en attribut), recalculé
|
||||||
|
// à chaque rafraîchissement du canevas (voir forgeDocRefreshCanvas).
|
||||||
|
//
|
||||||
|
// MAIS uniquement s'il reste assez de place pour un résultat encore
|
||||||
|
// utilisable (retour utilisateur suivant : "le contenu continue de se
|
||||||
|
// mettre même quand il n'y a plus de place [...] aucune page ne se
|
||||||
|
// crée automatiquement" — une page déjà pleine d'images empilait des
|
||||||
|
// vignettes rétrécies à l'infini plutôt que de basculer sur une
|
||||||
|
// nouvelle page) : sous FORGE_DOC_MIN_IMAGE_HEIGHT, il ne reste plus
|
||||||
|
// de place RÉELLE sur cette page, pas seulement pour cette image —
|
||||||
|
// on tombe alors dans la pagination normale ci-dessous, comme
|
||||||
|
// n'importe quel autre kind.
|
||||||
|
const dom = document.querySelector(`#docPage [data-element-id="${overflowingEl.id}"]`);
|
||||||
|
if (dom) {
|
||||||
|
const figure = dom.closest('.docImageFigure');
|
||||||
|
const caption = figure ? figure.querySelector('.docImageCaption') : null;
|
||||||
|
const captionSpace = caption ? caption.getBoundingClientRect().height + 8 : 0;
|
||||||
|
const available = Math.floor(pageBottom - dom.getBoundingClientRect().top - captionSpace - 2);
|
||||||
|
if (available >= FORGE_DOC_MIN_IMAGE_HEIGHT) {
|
||||||
|
dom.style.maxHeight = `${available}px`;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const overflowing = topLevel.slice(splitIndex);
|
const overflowing = topLevel.slice(splitIndex);
|
||||||
|
// Garde-fou contre une boucle infinie (bug réel constaté le 26/09/2026 :
|
||||||
|
// "quand j'ajoute une image ça crée des pages à l'infini") — si TOUT le
|
||||||
|
// top-niveau de la page déborde déjà, le déplacer sur une page neuve ne
|
||||||
|
// fait que reproduire EXACTEMENT le même débordement là-bas, à l'infini.
|
||||||
|
// Rien à répartir dans ce cas : on laisse le contenu tel quel (clipsé
|
||||||
|
// par .docPage { overflow: hidden }, voir sa règle CSS) — au créateur de
|
||||||
|
// redimensionner l'élément lui-même.
|
||||||
|
if (overflowing.length === topLevel.length) return Promise.resolve();
|
||||||
|
|
||||||
|
// La page neuve se glisse JUSTE APRÈS la page qui déborde (retour
|
||||||
|
// utilisateur du 26/09/2026 : "la nouvelle page dois etre sous la page
|
||||||
|
// qui deborde meme si ya d'autre page") — jamais ajoutée en toute fin
|
||||||
|
// de la bande d'onglets s'il existe déjà d'autres pages après celle-ci
|
||||||
|
// (ex. déborder sur la page 2 d'un support qui en compte 5 insère la
|
||||||
|
// nouvelle page en position 3, décale les anciennes 3/4/5 en 4/5/6).
|
||||||
|
const insertIndex = forgeDocOrderedPages().findIndex((p) => p.id === window.forgeDocState.activePageId) + 1;
|
||||||
return forgeDocApiPageAdd().then((result) => {
|
return forgeDocApiPageAdd().then((result) => {
|
||||||
window.forgeDocState.pages.push({
|
window.forgeDocState.pages.push({
|
||||||
id: result.id, title: result.title, order_index: window.forgeDocState.pages.length,
|
id: result.id, title: result.title, order_index: window.forgeDocState.pages.length,
|
||||||
});
|
});
|
||||||
return overflowing
|
return forgeDocApiPageMove(result.id, insertIndex)
|
||||||
.reduce((chain, el) => chain.then(() => forgeDocApiMoveToPage(el.id, result.id)), Promise.resolve())
|
.then(() => forgeDocReorderLocalPage(result.id, insertIndex))
|
||||||
|
.then(() => overflowing.reduce(
|
||||||
|
(chain, el) => chain.then(() => forgeDocApiMoveToPage(el.id, result.id)),
|
||||||
|
Promise.resolve(),
|
||||||
|
))
|
||||||
.then(() => forgeDocSwitchPage(result.id));
|
.then(() => forgeDocSwitchPage(result.id));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forgeDocRenderEmptyPageState() {
|
||||||
|
// Support à 0 page (retour utilisateur du 26/09/2026) — jamais appeler
|
||||||
|
// /render sans page_id (404 côté serveur, voir document_render.py) :
|
||||||
|
// le canevas affiche directement une invite à créer une page.
|
||||||
|
const contentEl = document.getElementById('docPageContent');
|
||||||
|
contentEl.innerHTML = '<div class="docEmptyState docEmptyPageState">Aucune page — cliquez sur « + Ajouter une page » pour commencer.</div>';
|
||||||
|
contentEl.dataset.verticalAlign = 'top';
|
||||||
|
window.forgeDocState.elements = [];
|
||||||
|
forgeDocRebuildIndex();
|
||||||
|
window.forgeDocState.selectedId = null;
|
||||||
|
forgeDocRefreshRightPanelDefault();
|
||||||
|
}
|
||||||
|
|
||||||
function forgeDocRefreshCanvas() {
|
function forgeDocRefreshCanvas() {
|
||||||
|
if (window.forgeDocState.activePageId === null) {
|
||||||
|
forgeDocRenderEmptyPageState();
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
return forgeDocApiRender().then((data) => {
|
return forgeDocApiRender().then((data) => {
|
||||||
const contentEl = document.getElementById('docPageContent');
|
const contentEl = document.getElementById('docPageContent');
|
||||||
contentEl.innerHTML = data.html;
|
contentEl.innerHTML = data.html;
|
||||||
@@ -293,6 +380,17 @@ function forgeDocUpdateAttributes(id, newAttributes) {
|
|||||||
if (window.forgeDocState.selectedId === id) {
|
if (window.forgeDocState.selectedId === id) {
|
||||||
forgeDocRenderProps(el);
|
forgeDocRenderProps(el);
|
||||||
}
|
}
|
||||||
|
// Bug réel constaté le 26/09/2026 : uploader une image trop grande
|
||||||
|
// (ou tout autre changement d'attribut qui agrandit un élément) ne
|
||||||
|
// déclenchait JAMAIS le rétrécissement/la pagination — seules les
|
||||||
|
// mutations passant par forgeDocRefreshCanvas (ajout/déplacement/
|
||||||
|
// suppression/Annuler-Rétablir) le faisaient. Cette fonction est le
|
||||||
|
// point d'entrée UNIQUE de toute mise à jour d'attributs (voir
|
||||||
|
// commentaire ci-dessus) : vérifier ICI, une fois, couvre aussi
|
||||||
|
// l'upload d'image (voir forgeDocApiUploadImage plus bas, qui route
|
||||||
|
// son propre patch DOM à travers ce même point) sans dupliquer
|
||||||
|
// l'appel dans chaque site qui modifie un attribut.
|
||||||
|
return forgeDocCheckPageOverflow();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -523,6 +621,11 @@ function forgeDocApiPageDelete(pageId) {
|
|||||||
.then((r) => r.json());
|
.then((r) => r.json());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forgeDocApiPageDeleteAll() {
|
||||||
|
return fetch(`/document/${window.forgeDocState.slug}/pages/delete-all`, { method: 'POST' })
|
||||||
|
.then((r) => r.json());
|
||||||
|
}
|
||||||
|
|
||||||
function forgeDocApiPageMove(pageId, newIndex) {
|
function forgeDocApiPageMove(pageId, newIndex) {
|
||||||
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/move`, {
|
return fetch(`/document/${window.forgeDocState.slug}/pages/${pageId}/move`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -543,6 +646,22 @@ function forgeDocOrderedPages() {
|
|||||||
return [...window.forgeDocState.pages].sort((a, b) => a.order_index - b.order_index);
|
return [...window.forgeDocState.pages].sort((a, b) => a.order_index - b.order_index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forgeDocReorderLocalPage(movedId, newIndex) {
|
||||||
|
// Reflète côté client un déplacement déjà appliqué côté serveur (voir
|
||||||
|
// move_document_page.py : renumérotation intégrale de order_index sur
|
||||||
|
// TOUTES les pages) — mêmes calculs, jamais dupliqués autrement que
|
||||||
|
// dans cette seule fonction (glisser-déposer du panneau Pages ET
|
||||||
|
// pagination automatique, voir forgeDocCheckPageOverflow).
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
function forgeDocUpdatePageNavPill() {
|
function forgeDocUpdatePageNavPill() {
|
||||||
const pill = document.getElementById('docPageNavPill');
|
const pill = document.getElementById('docPageNavPill');
|
||||||
if (!pill) return;
|
if (!pill) return;
|
||||||
@@ -599,10 +718,9 @@ function forgeDocSetPageVerticalAlign(pageId, verticalAlign) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function forgeDocDeletePage(pageId) {
|
function forgeDocDeletePage(pageId) {
|
||||||
// Filet de sécurité client (en plus du refus serveur, voir
|
// Supprimer la dernière page restante est désormais autorisé (retour
|
||||||
// routes/document/document_page_delete.py) : jamais même tenter de
|
// utilisateur du 26/09/2026 : un support à 0 page est un état valide,
|
||||||
// supprimer la dernière page restante.
|
// voir document_engine/pages/pages.md) — plus de garde-fou ici.
|
||||||
if (window.forgeDocState.pages.length <= 1) return Promise.resolve();
|
|
||||||
return forgeDocApiPageDelete(pageId).then((result) => {
|
return forgeDocApiPageDelete(pageId).then((result) => {
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
alert(result.error);
|
alert(result.error);
|
||||||
@@ -610,13 +728,30 @@ function forgeDocDeletePage(pageId) {
|
|||||||
}
|
}
|
||||||
window.forgeDocState.pages = window.forgeDocState.pages.filter((p) => p.id !== pageId);
|
window.forgeDocState.pages = window.forgeDocState.pages.filter((p) => p.id !== pageId);
|
||||||
if (window.forgeDocState.activePageId === pageId) {
|
if (window.forgeDocState.activePageId === pageId) {
|
||||||
return forgeDocSwitchPage(forgeDocOrderedPages()[0].id);
|
const remaining = forgeDocOrderedPages();
|
||||||
|
return forgeDocSwitchPage(remaining.length ? remaining[0].id : null);
|
||||||
}
|
}
|
||||||
forgeDocRenderPageManagerList();
|
forgeDocRenderPageManagerList();
|
||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forgeDocDeleteAllPages() {
|
||||||
|
// Action destructive et irréversible (retour utilisateur : "une option
|
||||||
|
// dans page pour supprimer toute les page d'un coup") — confirmation
|
||||||
|
// native obligatoire avant tout appel serveur.
|
||||||
|
if (!window.confirm('Supprimer TOUTES les pages et tout leur contenu ? Cette action est irréversible.')) {
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return forgeDocApiPageDeleteAll().then(() => {
|
||||||
|
window.forgeDocState.pages = [];
|
||||||
|
window.forgeDocState.undoStack = [];
|
||||||
|
window.forgeDocState.redoStack = [];
|
||||||
|
forgeDocUpdateHistoryButtons();
|
||||||
|
return forgeDocSwitchPage(null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function forgeDocSelectAllText(el) {
|
function forgeDocSelectAllText(el) {
|
||||||
const range = document.createRange();
|
const range = document.createRange();
|
||||||
range.selectNodeContents(el);
|
range.selectNodeContents(el);
|
||||||
@@ -701,7 +836,7 @@ function forgeDocRenderPageManagerList() {
|
|||||||
<span class="docPageRowTitle"><span class="docPageRowTitleText">${forgeDocEscapeHtml(p.title)}</span></span>
|
<span class="docPageRowTitle"><span class="docPageRowTitleText">${forgeDocEscapeHtml(p.title)}</span></span>
|
||||||
<span class="docPageRowActions">
|
<span class="docPageRowActions">
|
||||||
<button type="button" class="docPageRowRename" data-page-id="${p.id}" aria-label="Renommer cette page" title="Renommer cette page">✎</button>
|
<button type="button" class="docPageRowRename" data-page-id="${p.id}" aria-label="Renommer cette page" title="Renommer cette page">✎</button>
|
||||||
<button type="button" class="docPageRowDelete" data-page-id="${p.id}" aria-label="Supprimer cette page" title="Supprimer cette page"${pages.length <= 1 ? ' disabled' : ''}>✕</button>
|
<button type="button" class="docPageRowDelete" data-page-id="${p.id}" aria-label="Supprimer cette page" title="Supprimer cette page">✕</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
@@ -733,6 +868,12 @@ function forgeDocRenderPageManagerList() {
|
|||||||
|
|
||||||
const addBtn = document.getElementById('docPageManagerAdd');
|
const addBtn = document.getElementById('docPageManagerAdd');
|
||||||
if (addBtn) addBtn.onclick = () => forgeDocAddPage();
|
if (addBtn) addBtn.onclick = () => forgeDocAddPage();
|
||||||
|
|
||||||
|
const deleteAllBtn = document.getElementById('docPageManagerDeleteAll');
|
||||||
|
if (deleteAllBtn) {
|
||||||
|
deleteAllBtn.disabled = pages.length === 0;
|
||||||
|
deleteAllBtn.onclick = () => forgeDocDeleteAllPages();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeDocBindPageManagerDrag() {
|
function forgeDocBindPageManagerDrag() {
|
||||||
@@ -769,16 +910,7 @@ function forgeDocBindPageManagerDrag() {
|
|||||||
const movedId = draggedId;
|
const movedId = draggedId;
|
||||||
draggedId = null;
|
draggedId = null;
|
||||||
if (newIndex < 0) return;
|
if (newIndex < 0) return;
|
||||||
forgeDocApiPageMove(movedId, newIndex).then(() => {
|
forgeDocApiPageMove(movedId, newIndex).then(() => forgeDocReorderLocalPage(movedId, newIndex));
|
||||||
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();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -826,6 +958,35 @@ function forgeDocBindPageNavPill() {
|
|||||||
* Sélection & panneau Propriétés.
|
* 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 = '<img class="docImageLightboxImg" id="docImageLightboxImg" alt="">';
|
||||||
|
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() {
|
function forgeDocBindCanvasInteractions() {
|
||||||
document.querySelectorAll('#docPage [data-element-id]').forEach((dom) => {
|
document.querySelectorAll('#docPage [data-element-id]').forEach((dom) => {
|
||||||
const elementId = Number.parseInt(dom.dataset.elementId, 10);
|
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`;
|
window.location.href = `/document/${window.forgeDocState.slug}/elements/${elementId}/download-attachment`;
|
||||||
return;
|
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);
|
forgeDocSelectElement(elementId);
|
||||||
});
|
});
|
||||||
dom.setAttribute('draggable', 'true');
|
dom.setAttribute('draggable', 'true');
|
||||||
@@ -1807,6 +1984,12 @@ function forgeDocRenderBoxFieldsHtml(a, idPrefix) {
|
|||||||
</div>
|
</div>
|
||||||
${forgeDocRenderToggleSliderHtml(`${idPrefix}Radius`, 'Arrondi des bords', a.border_radius, {
|
${forgeDocRenderToggleSliderHtml(`${idPrefix}Radius`, 'Arrondi des bords', a.border_radius, {
|
||||||
min: 0, max: 100, step: 2, unit: 'px', fallback: 8,
|
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, {
|
${forgeDocRenderToggleSliderHtml(`${idPrefix}Height`, 'Hauteur fixe', a.height, {
|
||||||
min: 0, max: 800, step: 10, unit: 'px', fallback: 200,
|
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}Padding`, { unit: 'px' }, (v) => patch({ padding: v }));
|
||||||
forgeDocBindToggleSlider(`${idPrefix}Margin`, { unit: 'px' }, (v) => patch({ margin: v }));
|
forgeDocBindToggleSlider(`${idPrefix}Margin`, { unit: 'px' }, (v) => patch({ margin: v }));
|
||||||
forgeDocBindToggleSlider(`${idPrefix}Radius`, { unit: 'px' }, (v) => patch({ border_radius: 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}Height`, { unit: 'px' }, (v) => patch({ height: v }));
|
||||||
forgeDocBindToggleSlider(`${idPrefix}MinHeight`, { unit: 'px' }, (v) => patch({ min_height: v }));
|
forgeDocBindToggleSlider(`${idPrefix}MinHeight`, { unit: 'px' }, (v) => patch({ min_height: v }));
|
||||||
forgeDocBindToggleSlider(`${idPrefix}MaxHeight`, { unit: 'px' }, (v) => patch({ max_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]) => `<button type="button" class="docSegBtn ${(a.text_shadow || '') === v ? 'is-active' : ''}" data-shadow="${forgeDocEscapeHtml(v)}">${l}</button>`).join('')}
|
${FORGE_DOC_TEXT_SHADOW_PRESETS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.text_shadow || '') === v ? 'is-active' : ''}" data-shadow="${forgeDocEscapeHtml(v)}">${l}</button>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
${forgeDocRenderToggleSliderHtml('docMaxWidth', 'Largeur maximale', a.max_width, {
|
|
||||||
min: 200, max: 1200, step: 20, unit: 'px', fallback: 600,
|
|
||||||
})}
|
|
||||||
${forgeDocRenderBoxFieldsHtml(a, 'docText')}
|
${forgeDocRenderBoxFieldsHtml(a, 'docText')}
|
||||||
${forgeDocDeleteButtonHtml()}
|
${forgeDocDeleteButtonHtml()}
|
||||||
`;
|
`;
|
||||||
@@ -1969,7 +2151,6 @@ function forgeDocRenderTextProps(panel, el) {
|
|||||||
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
||||||
|
|
||||||
document.getElementById('docContentInput').addEventListener('change', (e) => patch({ content: e.target.value }));
|
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) => {
|
panel.querySelectorAll('#docStyleSeg .docSegBtn').forEach((btn) => {
|
||||||
btn.addEventListener('click', () => patch({ style: btn.dataset.style }));
|
btn.addEventListener('click', () => patch({ style: btn.dataset.style }));
|
||||||
});
|
});
|
||||||
@@ -1994,19 +2175,111 @@ function forgeDocRenderTextProps(panel, el) {
|
|||||||
forgeDocBindBoxFields(panel, el, 'docText', patch);
|
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) {
|
function forgeDocRenderImageProps(panel, el) {
|
||||||
const a = el.attributes;
|
const a = el.attributes;
|
||||||
|
const clickBehavior = a.click_behavior || '';
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
${forgeDocPropsHeader('image')}
|
${forgeDocPropsHeader('image')}
|
||||||
<div class="docField"><label for="docImageSrc">URL de l'image</label><input type="text" id="docImageSrc" value="${a.src || ''}" placeholder="https://..."></div>
|
<div class="docField">
|
||||||
<div class="docField"><label for="docImageAlt">Texte alternatif</label><input type="text" id="docImageAlt" value="${a.alt || ''}"></div>
|
<span class="docFieldLabel">Fichier image</span>
|
||||||
|
<div id="docImageUploadStatus">${a.src ? 'Image actuelle en place' : 'Aucun fichier choisi'}</div>
|
||||||
|
<input type="file" id="docImageUploadInput" accept="image/png,image/jpeg,image/gif,image/webp,image/svg+xml">
|
||||||
|
</div>
|
||||||
|
<div class="docField"><label for="docImageSrc">Ou URL d'une image externe</label><input type="text" id="docImageSrc" value="${forgeDocEscapeHtml(a.src || '')}" placeholder="https://..."></div>
|
||||||
|
<div class="docField"><label for="docImageAlt">Texte alternatif</label><input type="text" id="docImageAlt" value="${forgeDocEscapeHtml(a.alt || '')}"></div>
|
||||||
<div class="docField"><label for="docImageSvg">Code SVG (prioritaire sur l'URL si renseigné)</label><textarea id="docImageSvg" rows="4">${forgeDocEscapeHtml(a.svg_markup || '')}</textarea></div>
|
<div class="docField"><label for="docImageSvg">Code SVG (prioritaire sur l'URL si renseigné)</label><textarea id="docImageSvg" rows="4">${forgeDocEscapeHtml(a.svg_markup || '')}</textarea></div>
|
||||||
|
<div class="docField"><label for="docImageCaption">Légende (optionnel)</label><input type="text" id="docImageCaption" value="${forgeDocEscapeHtml(a.caption || '')}"></div>
|
||||||
|
<div class="docField">
|
||||||
|
<span class="docFieldLabel">Ajustement dans son cadre</span>
|
||||||
|
<div class="docSegmented" id="docImageFitSeg">
|
||||||
|
${FORGE_DOC_OBJECT_FIT_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.object_fit || '') === v ? 'is-active' : ''}" data-fit="${v}">${l}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docField">
|
||||||
|
<span class="docFieldLabel">Format (ratio)</span>
|
||||||
|
<div class="docSegmented" id="docImageRatioSeg">
|
||||||
|
${FORGE_DOC_ASPECT_RATIO_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.aspect_ratio || '') === v ? 'is-active' : ''}" data-ratio="${forgeDocEscapeHtml(v)}">${l}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docField">
|
||||||
|
<span class="docFieldLabel">Filtre</span>
|
||||||
|
<div class="docSegmented" id="docImageFilterSeg">
|
||||||
|
${FORGE_DOC_FILTER_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.filter_preset || '') === v ? 'is-active' : ''}" data-filter="${v}">${l}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docField">
|
||||||
|
<label class="docCheckboxLabel"><input type="checkbox" id="docImageLazyLoad" ${a.lazy_load ? 'checked' : ''}> Chargement différé (images longues à charger)</label>
|
||||||
|
</div>
|
||||||
|
<div class="docField">
|
||||||
|
<span class="docFieldLabel">Au clic (en Aperçu)</span>
|
||||||
|
<div class="docSegmented" id="docImageClickSeg">
|
||||||
|
${FORGE_DOC_IMAGE_CLICK_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${clickBehavior === v ? 'is-active' : ''}" data-click="${v}">${l}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docField" id="docImageLinkUrlField" ${clickBehavior === 'link' ? '' : 'hidden'}>
|
||||||
|
<label for="docImageLinkUrl">URL du lien</label>
|
||||||
|
<input type="text" id="docImageLinkUrl" value="${forgeDocEscapeHtml(a.link_url || '')}" placeholder="https://...">
|
||||||
|
</div>
|
||||||
|
${forgeDocRenderBoxFieldsHtml(a, 'docImage')}
|
||||||
${forgeDocDeleteButtonHtml()}
|
${forgeDocDeleteButtonHtml()}
|
||||||
`;
|
`;
|
||||||
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
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('docImageSrc').addEventListener('change', (e) => patch({ src: e.target.value.trim() }));
|
||||||
document.getElementById('docImageAlt').addEventListener('change', (e) => patch({ alt: e.target.value }));
|
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('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) {
|
function forgeDocRenderButtonProps(panel, el) {
|
||||||
|
|||||||
@@ -152,6 +152,10 @@
|
|||||||
toujours visible" — avec beaucoup de pages, il disparaissait en
|
toujours visible" — avec beaucoup de pages, il disparaissait en
|
||||||
bas de la liste défilante). #}
|
bas de la liste défilante). #}
|
||||||
<button type="button" class="docBtnSecondary docPageManagerAdd" id="docPageManagerAdd">+ Ajouter une page</button>
|
<button type="button" class="docBtnSecondary docPageManagerAdd" id="docPageManagerAdd">+ Ajouter une page</button>
|
||||||
|
<button type="button"
|
||||||
|
class="docDeleteBtn docPageManagerDeleteAll"
|
||||||
|
id="docPageManagerDeleteAll"
|
||||||
|
{{ 'disabled' if not pages else '' }}>🗑 Supprimer toutes les pages</button>
|
||||||
<div class="docPageManagerList" id="docPageManagerList">
|
<div class="docPageManagerList" id="docPageManagerList">
|
||||||
{% for page in pages %}
|
{% for page in pages %}
|
||||||
<div class="docPageRow {{ 'is-active' if page.id == active_page.id else '' }}"
|
<div class="docPageRow {{ 'is-active' if page.id == active_page.id else '' }}"
|
||||||
@@ -178,8 +182,7 @@
|
|||||||
class="docPageRowDelete"
|
class="docPageRowDelete"
|
||||||
data-page-id="{{ page.id }}"
|
data-page-id="{{ page.id }}"
|
||||||
aria-label="Supprimer cette page"
|
aria-label="Supprimer cette page"
|
||||||
title="Supprimer cette page"
|
title="Supprimer cette page">✕</button>
|
||||||
{{ 'disabled' if pages|length <= 1 else '' }}>✕</button>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -207,8 +210,8 @@
|
|||||||
<div class="docPage" id="docPage">
|
<div class="docPage" id="docPage">
|
||||||
<div class="docPageContent"
|
<div class="docPageContent"
|
||||||
id="docPageContent"
|
id="docPageContent"
|
||||||
data-vertical-align="{{ active_page.vertical_align }}">{{ rendered_document|safe }}</div>
|
data-vertical-align="{{ active_page.vertical_align if active_page else 'top' }}">{% if active_page %}{{ rendered_document|safe }}{% else %}<div class="docEmptyState docEmptyPageState">Aucune page — cliquez sur « + Ajouter une page » pour commencer.</div>{% endif %}</div>
|
||||||
{# //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 #}
|
{# //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. #}
|
||||||
</div>
|
</div>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="docPreviewExitBtn"
|
class="docPreviewExitBtn"
|
||||||
@@ -285,7 +288,7 @@ window.FORGE_DOCUMENT = {
|
|||||||
elements: {{ elements|tojson }},
|
elements: {{ elements|tojson }},
|
||||||
elementKindLabels: {{ element_kind_labels|tojson }},
|
elementKindLabels: {{ element_kind_labels|tojson }},
|
||||||
pages: {{ pages|tojson }},
|
pages: {{ pages|tojson }},
|
||||||
activePageId: {{ active_page.id|tojson }},
|
activePageId: {{ (active_page.id if active_page else none)|tojson }},
|
||||||
themes: {{ document_themes|tojson }},
|
themes: {{ document_themes|tojson }},
|
||||||
activeThemeId: {{ support.theme|tojson }}
|
activeThemeId: {{ support.theme|tojson }}
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-1
@@ -205,12 +205,22 @@ def support(client: FlaskClient) -> Generator[str, None, None]:
|
|||||||
"""Crée un support de formation de test frais via /documents/new
|
"""Crée un support de formation de test frais via /documents/new
|
||||||
(mirroir de la fixture `game` ci-dessus, pour l'autre type de projet —
|
(mirroir de la fixture `game` ci-dessus, pour l'autre type de projet —
|
||||||
entité racine séparée, voir docs/plan/PLAN.md) et le supprime après le
|
entité racine séparée, voir docs/plan/PLAN.md) et le supprime après le
|
||||||
test, quel que soit son résultat."""
|
test, quel que soit son résultat.
|
||||||
|
|
||||||
|
Un support neuf n'a plus aucune page par défaut (retour utilisateur
|
||||||
|
du 26/09/2026, voir document_engine/pages/pages.md) — cette fixture
|
||||||
|
lui en crée une par convénience, puisque la quasi-totalité des tests
|
||||||
|
existants portent sur du contenu et présupposent une première page
|
||||||
|
"Page 1" déjà là (comme avant ce changement). Les tests qui portent
|
||||||
|
spécifiquement sur l'état "0 page" créent leur propre support brut
|
||||||
|
via db.create_support(...) plutôt que d'utiliser cette fixture."""
|
||||||
import db.supports
|
import db.supports
|
||||||
|
import document_engine
|
||||||
|
|
||||||
resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False)
|
resp = client.post("/documents/new", data={"name": "pytest_test_support"}, follow_redirects=False)
|
||||||
assert resp.status_code == 302
|
assert resp.status_code == 302
|
||||||
slug = resp.headers["Location"].rstrip("/").split("/")[-2]
|
slug = resp.headers["Location"].rstrip("/").split("/")[-2]
|
||||||
|
document_engine.add_document_page(slug)
|
||||||
yield slug
|
yield slug
|
||||||
if os.path.isdir(db.supports.support_dir(slug)):
|
if os.path.isdir(db.supports.support_dir(slug)):
|
||||||
db.delete_support(slug)
|
db.delete_support(slug)
|
||||||
|
|||||||
@@ -16,9 +16,14 @@ from document_engine.rendering.box_style import default_border as doc_engine_box
|
|||||||
|
|
||||||
|
|
||||||
def _new_support(tmp_support_slug_cleanup: Any, name: str = "Support de test") -> tuple[str, int]:
|
def _new_support(tmp_support_slug_cleanup: Any, name: str = "Support de test") -> tuple[str, int]:
|
||||||
|
# db.create_support ne crée plus aucune page par défaut (retour
|
||||||
|
# utilisateur du 26/09/2026, voir document_engine/pages/pages.md) —
|
||||||
|
# ce helper en ajoute une explicitement, puisque tous les tests de ce
|
||||||
|
# fichier portent sur des éléments qui doivent bien appartenir à une
|
||||||
|
# page existante.
|
||||||
slug = db.create_support(name, owner_folder="90")
|
slug = db.create_support(name, owner_folder="90")
|
||||||
tmp_support_slug_cleanup(slug)
|
tmp_support_slug_cleanup(slug)
|
||||||
page_id = doc_engine.list_document_pages(slug)[0]["id"]
|
page_id = doc_engine.add_document_page(slug)
|
||||||
return slug, page_id
|
return slug, page_id
|
||||||
|
|
||||||
|
|
||||||
@@ -468,7 +473,25 @@ def test_image_default_attributes_include_empty_svg_markup(tmp_support_slug_clea
|
|||||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||||
element = doc_engine.get_document_element(slug, element_id)
|
element = doc_engine.get_document_element(slug, element_id)
|
||||||
assert element is not None
|
assert element is not None
|
||||||
assert element["attributes"] == {"src": "", "alt": "", "svg_markup": ""}
|
attrs = element["attributes"]
|
||||||
|
assert attrs["src"] == ""
|
||||||
|
assert attrs["alt"] == ""
|
||||||
|
assert attrs["svg_markup"] == ""
|
||||||
|
# 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 TOUJOURS rognée pour remplir ce cadre par défaut ;
|
||||||
|
# "Taille réelle" (object_fit="") reste un choix explicite possible
|
||||||
|
# via le panneau Propriétés, jamais le défaut d'un nouvel élément.
|
||||||
|
assert attrs["object_fit"] == "cover"
|
||||||
|
assert attrs["height"] == "220px"
|
||||||
|
assert attrs["aspect_ratio"] == ""
|
||||||
|
assert attrs["filter_preset"] == ""
|
||||||
|
assert attrs["click_behavior"] == ""
|
||||||
|
assert attrs["link_url"] == ""
|
||||||
|
assert attrs["lazy_load"] is False
|
||||||
|
assert attrs["caption"] == ""
|
||||||
|
assert attrs["border"] == doc_engine_box_default_border()
|
||||||
|
|
||||||
|
|
||||||
def test_image_with_svg_markup_takes_priority_over_src(tmp_support_slug_cleanup: Any) -> None:
|
def test_image_with_svg_markup_takes_priority_over_src(tmp_support_slug_cleanup: Any) -> None:
|
||||||
@@ -499,6 +522,176 @@ def test_image_svg_markup_strips_script_tag(tmp_support_slug_cleanup: Any) -> No
|
|||||||
assert "alert(" not in html
|
assert "alert(" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_object_fit_renders_only_known_values(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "object_fit": "cover"}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert "object-fit:cover;" in html
|
||||||
|
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "object_fit": "n-importe-quoi"}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert "object-fit" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_aspect_ratio_renders_and_is_escaped(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "aspect_ratio": "16 / 9"}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert "aspect-ratio:16 / 9;" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_filter_preset_maps_to_a_real_css_filter(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "filter_preset": "grayscale"}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert "filter:grayscale(1);" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_lazy_load_adds_the_loading_attribute(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "lazy_load": True}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert 'loading="lazy"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_caption_wraps_in_a_figure(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "caption": "Une légende <script>x</script>"}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert '<figure class="docImageFigure">' in html
|
||||||
|
assert '<figcaption class="docImageCaption">' in html
|
||||||
|
assert "<script>" not in html
|
||||||
|
assert "<script>" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_click_behavior_link_wraps_in_an_anchor(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug,
|
||||||
|
element_id,
|
||||||
|
{
|
||||||
|
"src": "https://exemple.test/a.png",
|
||||||
|
"alt": "",
|
||||||
|
"click_behavior": "link",
|
||||||
|
"link_url": "https://exemple.test/cible",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert (
|
||||||
|
'<a class="docImageLink" href="https://exemple.test/cible" target="_blank" rel="noopener noreferrer">' in html
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_click_behavior_link_without_url_does_not_wrap(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "click_behavior": "link", "link_url": ""}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert "docImageLink" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_click_behavior_lightbox_wraps_in_a_trigger_div(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug, element_id, {"src": "https://exemple.test/a.png", "alt": "", "click_behavior": "lightbox"}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert '<div class="docImageLightboxTrigger">' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_box_style_attributes_render_and_placeholder_gets_them_too(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||||
|
border = doc_engine_box_default_border()
|
||||||
|
border["left"] = {"style": "dotted", "width": "2px", "color": "#00ff00"}
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug,
|
||||||
|
element_id,
|
||||||
|
{
|
||||||
|
"src": "",
|
||||||
|
"alt": "",
|
||||||
|
"padding": "8px",
|
||||||
|
"border_radius": "20px",
|
||||||
|
"width": "300px",
|
||||||
|
"border": border,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert "docImagePlaceholder" in html
|
||||||
|
assert "padding:8px;" in html
|
||||||
|
assert "border-radius:20px;" in html
|
||||||
|
assert "width:300px;" in html
|
||||||
|
assert "border-left:2px dotted #00ff00;" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_box_style_targets_the_figure_when_a_caption_is_present(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
# Bug réel constaté le 26/09/2026 : "la position de bloc ne fonctionne
|
||||||
|
# pas sur l'image" — align-self (et le reste de render_box_style) doit
|
||||||
|
# se poser sur l'élément RÉELLEMENT top-niveau (l'enfant direct du
|
||||||
|
# flex-column de la page), jamais sur l'<img> interne quand une
|
||||||
|
# légende l'enveloppe : sans quoi align-self n'a aucun effet visuel.
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug,
|
||||||
|
element_id,
|
||||||
|
{
|
||||||
|
"src": "https://exemple.test/a.png",
|
||||||
|
"alt": "",
|
||||||
|
"caption": "Légende",
|
||||||
|
"align_self": "center",
|
||||||
|
"width": "300px",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert '<figure class="docImageFigure" style="width:300px; align-self:center;">' in html
|
||||||
|
# align-self ne doit apparaître qu'UNE fois (sur la figure), jamais
|
||||||
|
# aussi sur l'<img> interne (ce qui n'aurait aucun effet CSS).
|
||||||
|
assert html.count("align-self") == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_image_box_style_targets_the_link_wrapper_when_no_caption(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug,
|
||||||
|
element_id,
|
||||||
|
{
|
||||||
|
"src": "https://exemple.test/a.png",
|
||||||
|
"alt": "",
|
||||||
|
"click_behavior": "link",
|
||||||
|
"link_url": "https://exemple.test/cible",
|
||||||
|
"align_self": "center",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert (
|
||||||
|
'<a class="docImageLink" href="https://exemple.test/cible" target="_blank" rel="noopener noreferrer" '
|
||||||
|
'style="align-self:center;">' in html
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_bouton_default_attributes_include_empty_attachment_fields(tmp_support_slug_cleanup: Any) -> None:
|
def test_bouton_default_attributes_include_empty_attachment_fields(tmp_support_slug_cleanup: Any) -> None:
|
||||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
|
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
"""Routes HTTP des pages d'un support de formation (routes/document/
|
"""Routes HTTP des pages d'un support de formation (routes/document/
|
||||||
document_page_*.py) — retour utilisateur du 21/09/2026 : "il faut
|
document_page_*.py) — retour utilisateur du 21/09/2026 : "il faut
|
||||||
implémenter un système de page". Un support a toujours au moins une page
|
implémenter un système de page". La fixture `support` crée une première
|
||||||
(créée par défaut avec le support, voir la fixture `support`)."""
|
page par convénience pour ces tests (voir tests/conftest.py) ; en
|
||||||
|
production un support neuf n'a plus aucune page, et supprimer la
|
||||||
|
dernière page restante est désormais autorisé (retour utilisateur du
|
||||||
|
26/09/2026, voir document_engine/pages/pages.md)."""
|
||||||
|
|
||||||
from flask.testing import FlaskClient
|
from flask.testing import FlaskClient
|
||||||
|
|
||||||
@@ -50,12 +53,29 @@ def test_document_page_rename_rejects_an_unknown_page(client: FlaskClient, suppo
|
|||||||
assert client.post(f"/document/{support}/pages/999/rename", json={"title": "X"}).status_code == 404
|
assert client.post(f"/document/{support}/pages/999/rename", json={"title": "X"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_document_page_delete_refuses_the_last_remaining_page(client: FlaskClient, support: str) -> None:
|
def test_document_page_delete_allows_deleting_the_last_remaining_page(client: FlaskClient, support: str) -> None:
|
||||||
page_id = _page_id(support)
|
page_id = _page_id(support)
|
||||||
resp = client.post(f"/document/{support}/pages/{page_id}/delete")
|
resp = client.post(f"/document/{support}/pages/{page_id}/delete")
|
||||||
assert resp.status_code == 400
|
assert resp.status_code == 200
|
||||||
assert "error" in resp.get_json()
|
assert resp.get_json()["ok"] is True
|
||||||
assert document_engine.get_document_page(support, page_id) is not None
|
assert document_engine.get_document_page(support, page_id) is None
|
||||||
|
assert document_engine.list_document_pages(support) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_page_delete_all_wipes_every_page_and_its_elements(client: FlaskClient, support: str) -> None:
|
||||||
|
first_page_id = _page_id(support)
|
||||||
|
second_page_id = client.post(f"/document/{support}/pages/add", json={}).get_json()["id"]
|
||||||
|
element_resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": second_page_id})
|
||||||
|
element_id = element_resp.get_json()["id"]
|
||||||
|
|
||||||
|
resp = client.post(f"/document/{support}/pages/delete-all")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["ok"] is True
|
||||||
|
|
||||||
|
assert document_engine.list_document_pages(support) == []
|
||||||
|
assert document_engine.get_document_page(support, first_page_id) is None
|
||||||
|
assert document_engine.get_document_page(support, second_page_id) is None
|
||||||
|
assert document_engine.get_document_element(support, element_id) is None
|
||||||
|
|
||||||
|
|
||||||
def test_document_page_delete_removes_a_non_last_page_and_its_elements(client: FlaskClient, support: str) -> None:
|
def test_document_page_delete_removes_a_non_last_page_and_its_elements(client: FlaskClient, support: str) -> None:
|
||||||
|
|||||||
@@ -39,6 +39,19 @@ def test_document_edit_renders_directly_no_intermediate_page(client: FlaskClient
|
|||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_document_edit_works_on_a_support_with_zero_pages(client: FlaskClient, support: str) -> None:
|
||||||
|
# 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" — un support neuf (ou vidé via "Supprimer toutes les
|
||||||
|
# pages") n'a plus de page active, l'éditeur ne doit pas planter.
|
||||||
|
client.post(f"/document/{support}/pages/delete-all")
|
||||||
|
resp = client.get(f"/document/{support}/edit")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
html = resp.get_data(as_text=True)
|
||||||
|
assert "activePageId: null" in html
|
||||||
|
assert "pages: []" in html
|
||||||
|
|
||||||
|
|
||||||
def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None:
|
def test_document_element_add_returns_rendered_html(client: FlaskClient, support: str) -> None:
|
||||||
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": _page_id(support)})
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -417,6 +430,72 @@ def test_download_attachment_404s_when_nothing_was_uploaded(client: FlaskClient,
|
|||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def _add_image(client: FlaskClient, support: str) -> int:
|
||||||
|
resp = client.post(f"/document/{support}/elements/add", data={"kind": "image", "page_id": _page_id(support)})
|
||||||
|
return int(resp.get_json()["id"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_image_sets_src_and_clears_svg_markup(client: FlaskClient, support: str) -> None:
|
||||||
|
element_id = _add_image(client, support)
|
||||||
|
document_engine.update_document_element_attributes(support, element_id, {"svg_markup": "<svg></svg>"})
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/{element_id}/upload-image",
|
||||||
|
data={"file": (io.BytesIO(b"fake png bytes"), "photo.png")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.get_json()
|
||||||
|
assert payload["ok"] is True
|
||||||
|
assert payload["attributes"]["svg_markup"] == ""
|
||||||
|
src = payload["attributes"]["src"]
|
||||||
|
assert f"/document/{support}/uploads/" in src
|
||||||
|
assert f'src="{src}"' in payload["rendered_html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_image_rejects_a_non_image_element(client: FlaskClient, support: str) -> None:
|
||||||
|
element_id = _add_bouton(client, support)
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/{element_id}/upload-image",
|
||||||
|
data={"file": (io.BytesIO(b"peu importe"), "x.png")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_image_rejects_a_missing_file(client: FlaskClient, support: str) -> None:
|
||||||
|
element_id = _add_image(client, support)
|
||||||
|
resp = client.post(f"/document/{support}/elements/{element_id}/upload-image", data={})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_upload_image_rejects_a_disallowed_extension(client: FlaskClient, support: str) -> None:
|
||||||
|
element_id = _add_image(client, support)
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/{element_id}/upload-image",
|
||||||
|
data={"file": (io.BytesIO(b"#!/bin/sh"), "script.sh")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploaded_file_serves_the_stored_image(client: FlaskClient, support: str) -> None:
|
||||||
|
element_id = _add_image(client, support)
|
||||||
|
resp = client.post(
|
||||||
|
f"/document/{support}/elements/{element_id}/upload-image",
|
||||||
|
data={"file": (io.BytesIO(b"fake png bytes"), "photo.png")},
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
)
|
||||||
|
src = resp.get_json()["attributes"]["src"]
|
||||||
|
resp = client.get(src)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.data == b"fake png bytes"
|
||||||
|
|
||||||
|
|
||||||
|
def test_uploaded_file_404s_for_an_unknown_filename(client: FlaskClient, support: str) -> None:
|
||||||
|
resp = client.get(f"/document/{support}/uploads/inconnu.png")
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_document_edit_has_no_theme_link_by_default(client: FlaskClient, support: str) -> None:
|
def test_document_edit_has_no_theme_link_by_default(client: FlaskClient, support: str) -> None:
|
||||||
resp = client.get(f"/document/{support}/edit")
|
resp = client.get(f"/document/{support}/edit")
|
||||||
assert "document/themes/securite-incendie.css" not in resp.get_data(as_text=True)
|
assert "document/themes/securite-incendie.css" not in resp.get_data(as_text=True)
|
||||||
|
|||||||
@@ -16,6 +16,16 @@ def test_create_support_creates_its_own_db_file_and_schema(tmp_support_slug_clea
|
|||||||
assert meta["name"] == "Sécurité incendie"
|
assert meta["name"] == "Sécurité incendie"
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_support_has_no_page_by_default(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
# Retour utilisateur du 26/09/2026 : "l'éditeur ne dois plus etre
|
||||||
|
# obliger d'avoir une page active" — voir document_engine/pages/pages.md.
|
||||||
|
import document_engine
|
||||||
|
|
||||||
|
slug = db.create_support("Nouveau projet", owner_folder="52")
|
||||||
|
tmp_support_slug_cleanup(slug)
|
||||||
|
assert document_engine.list_document_pages(slug) == []
|
||||||
|
|
||||||
|
|
||||||
def test_list_supports_scopes_to_owner_and_excludes_games(
|
def test_list_supports_scopes_to_owner_and_excludes_games(
|
||||||
tmp_support_slug_cleanup: Any, tmp_game_slug_cleanup: Any
|
tmp_support_slug_cleanup: Any, tmp_game_slug_cleanup: Any
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user