Audit complet de mise en forme — Liste à puces/numérotée (4e élément)

Implémente toutes les options manquantes identifiées pour les listes :
typographie complète, style/position/couleur/taille de puce (validés
selon le kind), puce personnalisée en SVG pour les listes à puces,
padding uniforme par élément (nouveau, il n'y en avait aucun), espacement
entre éléments réglable, et tous les attributs de boîte partagés sur la
liste entière. Bordure/fond/padding par élément individuel et sous-listes
imbriquées volontairement différés (portée actée avec l'utilisateur
avant implémentation : transformeraient le stockage des éléments en
objets structurés, chantier bien plus lourd).

Trois ajouts transversaux bénéficiant à plusieurs éléments : sections
"Contenu"/"Conteneur" dans tous les panneaux de propriétés, alignement
vertical du contenu dans son bloc (Titre/Paragraphe/Liste/Image
légendée), et une option pour retirer un thème appliqué ("Aucun modèle"
dans la modale, avec une nouvelle fonction db.remove_document_theme).

Quatre bugs réels trouvés et corrigés en chaîne pendant la validation
avec le thème "Sécurité incendie" : un badge de thème s'affichait
au-dessus du texte au lieu d'à côté ; le correctif a d'abord fait
disparaître les puces/numéros natifs de TOUTES les listes (bug plus
grave que celui corrigé) ; puis un marqueur natif redondant apparaissait
à côté du badge du thème ; puis une règle CSS site-large de spécificité
supérieure empêchait silencieusement ce dernier correctif. Chaque étape
vérifiée par navigateur automatisé sur un support jetable.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-26 15:13:14 +02:00
co-authored by Claude Sonnet 5
parent 8f879f6321
commit 4db1269348
17 changed files with 1080 additions and 34 deletions
+2
View File
@@ -80,6 +80,7 @@ from .supports.create_support import create_support
from .supports.delete_support import delete_support from .supports.delete_support import delete_support
from .supports.get_document_theme import get_document_theme from .supports.get_document_theme import get_document_theme
from .supports.list_supports import list_supports from .supports.list_supports import list_supports
from .supports.remove_document_theme import remove_document_theme
from .supports.set_document_theme import set_document_theme from .supports.set_document_theme import set_document_theme
from .supports.support_dir import support_dir from .supports.support_dir import support_dir
from .supports.support_meta import support_meta from .supports.support_meta import support_meta
@@ -122,6 +123,7 @@ __all__ = [
"support_meta", "support_meta",
"delete_support", "delete_support",
"get_document_theme", "get_document_theme",
"remove_document_theme",
"set_document_theme", "set_document_theme",
"ONBOARDING_TYPES", "ONBOARDING_TYPES",
"DEFAULT_ONBOARDING_TYPE", "DEFAULT_ONBOARDING_TYPE",
+2
View File
@@ -9,6 +9,7 @@ from .delete_support import delete_support
from .ensure_document_pages_schema import ensure_document_pages_schema from .ensure_document_pages_schema import ensure_document_pages_schema
from .get_document_theme import get_document_theme from .get_document_theme import get_document_theme
from .list_supports import list_supports from .list_supports import list_supports
from .remove_document_theme import remove_document_theme
from .set_document_theme import set_document_theme from .set_document_theme import set_document_theme
from .support_connection import connect_support, install_support_teardown_safety_net from .support_connection import connect_support, install_support_teardown_safety_net
from .support_dir import support_dir from .support_dir import support_dir
@@ -25,6 +26,7 @@ __all__ = [
"get_document_theme", "get_document_theme",
"install_support_teardown_safety_net", "install_support_teardown_safety_net",
"list_supports", "list_supports",
"remove_document_theme",
"set_document_theme", "set_document_theme",
"split_slug", "split_slug",
"support_dir", "support_dir",
+15
View File
@@ -0,0 +1,15 @@
from .support_connection import connect_support
def remove_document_theme(slug: str) -> None:
"""Retire le thème appliqué (retour utilisateur du 26/09/2026 :
"aucun modèle" dans la modale doit "revenir à un document de base")
— supprime la LIGNE `_meta` plutôt que d'y stocker une valeur vide,
pour que get_document_theme continue de renvoyer `None` (son
contrat documenté : "aucun thème n'a jamais été appliqué"), jamais
une chaîne vide qui violerait silencieusement ce contrat pour tout
appelant qui compare à `None`."""
conn = connect_support(slug)
conn.execute("DELETE FROM _meta WHERE key = 'theme'")
conn.commit()
conn.close()
+195 -1
View File
@@ -298,7 +298,163 @@ sinon d'un état neutre. Bénéficie automatiquement à TOUS les éléments
qui utilisent déjà ce module (Titre/Paragraphe/Image/Bouton), aucune qui utilisent déjà ce module (Titre/Paragraphe/Image/Bouton), aucune
modification nécessaire ailleurs. modification nécessaire ailleurs.
### 4. Liste à puces / numérotée — à faire ### 4. Liste à puces / numérotée — ✅ audité et validé (commité)
**Portée actée avec l'utilisateur avant implémentation** (question
posée explicitement, deux catégories du tableau impliquaient de
transformer `items` — une simple liste de chaînes — en objets
structurés) : style sur la LISTE ENTIÈRE uniquement, jamais par élément
individuel ni de sous-listes imbriquées (chantier bien plus lourd,
différé à une demande séparée si besoin). Seule exception actée : un
padding UNIFORME appliqué à chaque élément (`item_padding`), pas encore
réglable individuellement.
Implémenté : gras/italique/souligné, police (liste déroulante web-safe,
`FORGE_DOC_FONT_FAMILY_OPTIONS` réutilisé), taille de police, hauteur de
ligne, couleur du texte, style de puce (`list_style_type`, options
propres à chaque kind — disque/cercle/carré/aucune pour puces,
1-2-3/01-02-03/i-ii-iii/I-II-III/a-b-c/A-B-C/aucune pour numérotée),
position de la puce (intérieure/extérieure), couleur et taille de puce
indépendantes du texte, puce personnalisée (icône SVG, liste à puces
uniquement — encodée en URI de données pour `list-style-image`),
espacement intérieur par élément (`item_padding`, **"6px" par défaut,
retour utilisateur explicite : "il faut un padding de base par élément
de liste car y en a pas aujourd'hui"** — n'existait pas du tout avant),
espacement entre éléments réglable (`item_spacing`), tous les attributs
de boîte partagés sur la liste entière (padding/margin/fond/bordure/
ombre/opacité/position du bloc).
Mécanisme technique notable : `marker_color`/`marker_size`/
`item_padding`/`item_spacing` ne peuvent pas passer par un style inline
classique sur le `<ul>`/`<ol>` (impossible de cibler le `::marker` ou le
padding des `<li>` enfants depuis le style de leur parent) — résolu via
des propriétés personnalisées CSS (`--doc-marker-color` etc.), posées
en inline sur le conteneur et consommées par une règle CSS dédiée
(`.docList li`/`.docList li::marker`) qui en hérite. Même technique déjà
utilisée pour la position du bloc des éléments enveloppés (Image).
Volontairement laissé de côté (portée actée ci-dessus) : style/bordure/
fond par élément individuel, sous-listes imbriquées — nécessiteraient
de transformer `items` (liste de chaînes) en objets structurés, refonte
du panneau et du stockage. Comme les autres éléments : responsive par
taille d'écran.
**Existant retroactivement inchangé** : une liste déjà créée avant ce
commit garde ses anciens attributs (juste `items`) — `item_padding`
n'apparaît en style inline QUE pour les nouvelles listes ; ouvrir le
panneau Propriétés d'une ancienne liste et toucher un réglage la fait
bénéficier des nouveaux défauts au passage.
**Bug réel corrigé pendant le test (retour utilisateur : "si j'enlève
les puces ou que les puces se mettent à l'intérieur, il reste un
espace devant la liste, cet espace doit être supprimé")** : le
`padding-left:1.4em` par défaut (réservé pour une puce EXTÉRIEURE)
n'a plus lieu d'être dès que `list_style_type="none"` ou
`list_style_position="inside"` — `_render_list` ajoute alors
automatiquement `padding-left:0;`, prioritaire sur le CSS mais toujours
cédant la place à un `padding` uniforme réglé explicitement par
ailleurs (attributs de boîte partagés).
**Deux ajouts transversaux pendant le test, bénéficiant à plusieurs
éléments à la fois :**
1. **Sections "Contenu"/"Conteneur" dans le panneau Propriétés (retour
utilisateur : "il faut distinguer par des sections la propriété qui
touche au conteneur de celles qui touchent à l'élément qu'il
contient sinon c'est pas compréhensible")** : un sous-titre visuel
(bordure du dessus) sépare maintenant, dans CHAQUE panneau qui
utilise `box_style.py` (Titre/Paragraphe, Image, Bouton, Liste), les
champs propres au CONTENU (texte, icône, puces...) de ceux qui
touchent au CONTENEUR (padding/margin/fond/bordure/ombre/opacité/
position du bloc/alignement du contenu — voir point 2). Le sous-titre
"Conteneur" est posé UNE SEULE FOIS, à l'intérieur de
`forgeDocRenderBoxFieldsHtml` (le module déjà partagé), jamais dupliqué
à chaque appelant.
2. **Alignement vertical du CONTENU dans son bloc (retour utilisateur :
"je peux augmenter la hauteur d'un conteneur mais pas l'alignement
vertical à l'intérieur, pour les listes et il faut aussi vérifier
pour les autres")** : nouvel attribut partagé `content_align`
(`"top"` par défaut, `"center"`/`"bottom"`) posé dans `BOX_DEFAULTS`,
rendu via `render_content_align(a)` — volontairement PAS fusionné
dans `render_box_style` (l'alignement du contenu dépend de l'axe
interne du conteneur : `justify-content` convient à un conteneur en
COLONNE — Titre/Paragraphe/Liste, rendus `display:flex;
flex-direction:column;` pour l'occasion, et la figure d'une image
légendée, déjà flex-colonne — mais serait FAUX pour le Bouton, une
RANGÉE icône+texte qui gère déjà son axe vertical via `align-items`,
déjà correct sans réglage). Contrôle exclu explicitement du panneau
Bouton (`includeContentAlign: false`) pour ne jamais afficher un
réglage sans effet.
**Bug réel corrigé, puis CORRIGÉ UNE SECONDE FOIS après un retour de
régression (capture à l'appui, thème "Sécurité incendie")** :
1. *Premier symptôme* ("dans les liste numéroté [...] la position des
élément à l'intérieur de base est verticale au lieu d'être
horizontale") : ce thème remplace la puce native d'une liste par un
badge (`::before`, voir `static/document/themes/
securite-incendie.css`), pensé pour s'afficher À CÔTÉ du texte.
Corrigé une première fois en posant `display:flex` sur `.docList li`
(`static/document/document-editor.css`).
2. *Régression introduite par ce premier correctif* ("quand j'enlève le
thème [...] on voit plus les puces ou les numéros") : `display:flex`
posé DIRECTEMENT sur le `<li>` remplace entièrement son
`display:list-item` natif — ça supprime le marqueur natif (puce/
numéro) pour TOUTE liste, avec ou sans thème (l'affirmation inverse
dans le premier correctif était FAUSSE, corrigée après une
vérification en conditions réelles, navigateur automatisé). **Corrigé
en ciblant le `::before` LUI-MÊME** (`display:inline-block;
vertical-align:middle; margin-right:10px;`), jamais son parent : le
`<li>` garde son `display:list-item` natif (donc son marqueur), et le
badge s'aligne quand même à côté du texte qui le suit dans le flux
normal.
3. *Redondance restante* : une fois le marqueur natif restauré, les
listes du thème affichaient À LA FOIS leur badge personnalisé ET le
marqueur natif (disc/decimal) en double — le thème ne les avait
jamais explicitement désactivés (aucun `list-style:none` dans
`securite-incendie.css`, il comptait implicitement sur le marqueur
natif pour disparaître tout seul). Corrigé en ajoutant ce reset —
avec le sélecteur d'élément (`ul.docList[...]`/`ol.docList[...]`),
jamais seulement les classes/attributs : `static/style.css` (site
large) porte une règle `.content ol:not([type]) {
list-style-type: decimal; }` d'une spécificité légèrement supérieure
qui l'emportait sinon silencieusement.
4. *Dernière régression du même correctif* : le badge numéroté du thème
posait lui-même `display:flex` (pour centrer son chiffre) — une
valeur qui BLOCKIFIE le `::before` (le repousse hors du flux en
ligne, au-dessus du texte), défaisant le point 1 pour ce cas précis.
Corrigé en `display:inline-flex` (garde le badge en ligne tout en
centrant quand même le chiffre à l'intérieur).
Chaque étape vérifiée par un navigateur automatisé (Playwright) contre
le serveur local, sur un support jetable créé puis supprimé pour
l'occasion — thème appliqué et retiré successivement, capture d'écran
et styles calculés (`getComputedStyle`) inspectés à chaque fois, pas
seulement supposés corrects.
**Audit final** : chaque ligne du tableau d'audit initial pour l'élément
Liste est couverte —
**Typo** (police/taille/gras/italique/souligné/couleur/interligne) ✅,
**Puces/numéros** (style de puce validé par kind, image de puce
personnalisée pour les puces, position intérieure/extérieure, couleur/
taille de puce indépendantes du texte via propriétés personnalisées
CSS) ✅,
**Boîte** (padding/margin sur la liste entière via `box_style.py`,
padding UNIFORME par élément — `item_padding`, avec un vrai défaut non
vide comme demandé —, espacement entre éléments réglable —
`item_spacing` —, indentation couverte par le padding partagé) ✅,
**Bordure/fond** sur la liste entière ✅.
Volontairement différés, portée actée explicitement AVANT
implémentation avec l'utilisateur (question posée, réponse : liste
entière seulement) : bordure/fond/padding/margin **par élément
individuel** (au-delà du padding uniforme) et **listes imbriquées** —
transformeraient `items` (liste de chaînes) en objets structurés, un
chantier bien plus lourd que le reste du tableau, à traiter séparément
si demandé. Comme les autres éléments : responsive par taille d'écran.
245 tests passent, ruff/mypy --strict/bandit/vulture/import-linter/
eslint/stylelint tous clean. Rien d'oublié constaté à cette relecture.
Validé par l'utilisateur (y compris les 4 bugs de thème trouvés et
corrigés pendant la validation), prêt à committer.
### 5. Étiquette (badge) — à compléter ### 5. Étiquette (badge) — à compléter
@@ -349,3 +505,41 @@ distinct de `.is-active`/`.is-dragging`. Pas de test automatisé
possible côté client (aucune suite de tests n'existe pour possible côté client (aucune suite de tests n'existe pour
`document-editor.js`), vérification manuelle uniquement — le mécanisme `document-editor.js`), vérification manuelle uniquement — le mécanisme
serveur sous-jacent, lui, reste couvert par les tests existants. serveur sous-jacent, lui, reste couvert par les tests existants.
## Fonctionnalité hors tableau : retirer le thème appliqué
Retour utilisateur du 26/09/2026 : après avoir choisi un thème puis
vidé toutes les pages, le thème restait appliqué — question légitime
("c'est voulu ?"), réponse : OUI pour la séparation contenu/thème (déjà
le cas), mais il manquait un moyen de retirer un thème une fois choisi.
Ajout demandé : "dans la modale de choix des modèles, ajoute-en un qui
s'appelle Aucun modèle, si l'utilisateur le choisit ça enlève tout
modèle de style choisi pour revenir à un document de base".
- **Nouvelle fonction bas niveau** `db.remove_document_theme(slug)`
(`db/supports/remove_document_theme.py`) — supprime la LIGNE `_meta`
plutôt que d'y stocker une chaîne vide, pour que `get_document_theme`
continue de renvoyer `None` (son contrat documenté : "aucun thème
n'a jamais été appliqué"), jamais une chaîne vide qui le violerait
silencieusement pour tout appelant qui compare à `None` (dont le test
déjà existant `test_new_support_has_no_theme_by_default`).
- **Route** `/document/<slug>/theme/apply` : `theme_id` vide retire
désormais le thème et s'arrête là — `mode` n'a alors aucun sens
(aucun contenu de démonstration pour "aucun modèle") et est ignoré,
jamais validé ni utilisé dans ce cas.
- **Modale "Utiliser un modèle"** : nouvelle carte "Aucun modèle"
toujours en tête de liste (même catalogue vide), id sentinelle `""`
— distincte de `null` (qui reste réservé à "rien n'a encore été
cliqué dans la modale", `forgeDocSelectedTemplateId` à l'ouverture).
La sélectionner remplace l'aperçu (rien à prévisualiser, "aucun
modèle" n'a pas de contenu de démonstration) par un message explicite
et un unique bouton "Retirer le modèle" (jamais les deux boutons
"contenu actuel/du modèle", qui supposent un vrai thème choisi) —
confirmation native avant l'action (changement visuel notable, même
si le contenu n'est jamais touché).
- Testé : `db/supports/remove_document_theme.py` (2 tests bas niveau,
`tests/document/test_support_lifecycle.py`) + la route (2 tests,
`tests/document/test_document_routes.py` — retire vraiment le thème,
laisse le contenu intact, ignore `mode`). Le déclenchement côté
client (clic sur la carte/le bouton) reste manuel faute de suite de
tests JS, comme le reste de l'éditeur.
+37 -1
View File
@@ -165,7 +165,43 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
# "ordonnée ou non" se lit directement sur le kind au moment du # "ordonnée ou non" se lit directement sur le kind au moment du
# rendu (voir render_document_element._render_list), jamais un # rendu (voir render_document_element._render_list), jamais un
# attribut "ordered" redondant à tenir synchronisé avec le kind. # attribut "ordered" redondant à tenir synchronisé avec le kind.
return {"items": ["Premier élément", "Deuxième élément"]} #
# Audit du 26/09/2026 (réglages manquants — colonne "Liste à
# puces/numérotée") : portée actée avec l'utilisateur = style de
# la LISTE ENTIÈRE (typo/puces/boîte/bordure/fond), jamais un
# style par élément individuel ni de sous-listes (chantier bien
# plus lourd, transformerait `items` d'une liste de chaînes en
# objets structurés — différé à une demande séparée). Seule
# exception : `item_padding`, un padding UNIFORME appliqué à
# CHAQUE élément (retour utilisateur explicite : "il faut un
# padding de base par élément de liste car y en a pas
# aujourd'hui") — une valeur PARTAGÉE par tous les éléments,
# jamais réglable individuellement, voir _render_list.
# list_style_type : valide uniquement parmi les valeurs propres
# au kind (disc/circle/square/none pour puces,
# decimal/.../none pour numérotée) — vérifié au rendu, jamais ici.
# svg_markup (puce personnalisée) : ignoré au rendu pour
# liste_numerotee (une puce imagée n'a pas de sens sur une liste
# numérotée, voir _render_list).
return {
"items": ["Premier élément", "Deuxième élément"],
"bold": False,
"italic": False,
"underline": False,
"font_family": "",
"font_size": "",
"line_height": "",
"text_color": "",
"list_style_type": "",
"list_style_position": "outside",
"marker_color": "",
"marker_size": "",
"svg_markup": "",
"item_padding": "6px",
"item_spacing": "",
**BOX_DEFAULTS,
"border": default_border(),
}
if kind == "badge": if kind == "badge":
# Même esprit que titre/paragraphe (bold/align/color... déjà des # Même esprit que titre/paragraphe (bold/align/color... déjà des
# attributs par élément, pas des choix figés par le moteur) : # attributs par élément, pas des choix figés par le moteur) :
+20 -5
View File
@@ -47,10 +47,14 @@ Attributs posés à la création d'un élément de ce type (voir
- **Retour** : un dict d'attributs par défaut, dépendant du `kind` (les - **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/ "attributs de boîte partagés" mentionnés ci-dessous — `padding/margin/
background_color/border_radius/width/max_width/height/min_height/ background_color/border_radius/width/max_width/height/min_height/
max_height/min_width/box_shadow/opacity/align_self/border` — sont max_height/min_width/box_shadow/opacity/align_self/content_align/
toujours les mêmes, voir `rendering/box_style.py` : tous vides, border` — sont toujours les mêmes, voir `rendering/box_style.py` :
`False` ou `"none"`/`"stretch"` par défaut = comportement historique tous vides, `False` ou `"none"`/`"stretch"`/`"top"` par défaut =
inchangé pour le kind qui les gagne) : comportement historique inchangé pour le kind qui les gagne ;
`content_align` — alignement vertical du CONTENU dans son bloc, retour
utilisateur du 26/09/2026 — n'est cependant appliqué au rendu QUE par
les kinds dont le conteneur est en colonne (texte, liste, figure d'une
image légendée), jamais par le Bouton, voir `rendering.md`) :
texte (`content/style` + `bold/italic/underline/strikethrough/align/ texte (`content/style` + `bold/italic/underline/strikethrough/align/
color/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),
@@ -70,7 +74,18 @@ Attributs posés à la création d'un élément de ce type (voir
(typo, jamais gérés par box_style.py) + `svg_markup/icon_position (typo, jamais gérés par box_style.py) + `svg_markup/icon_position
("before"/"after")/icon_size` (icône optionnelle, voir rendering.md) ("before"/"after")/icon_size` (icône optionnelle, voir rendering.md)
+ les attributs de boîte partagés), liste à + les attributs de boîte partagés), liste à
puces/numérotée (`items`, une liste de chaînes), badge puces/numérotée (`items`, une liste de chaînes — style sur la LISTE
ENTIÈRE uniquement, jamais par élément individuel ni de sous-listes,
portée actée avec l'utilisateur le 26/09/2026 : `bold/italic/
underline/font_family/font_size/line_height/text_color` (typo) +
`list_style_type` (valide selon le kind, voir rendering.md)/
`list_style_position`/`marker_color`/`marker_size`/`svg_markup`
(puce personnalisée, liste à puces uniquement) + `item_padding`
(`"6px"` par défaut, PAS vide — retour utilisateur explicite : "il
faut un padding de base par élément de liste car y en a pas
aujourd'hui", UNIFORME sur tous les éléments, jamais réglable
individuellement) + `item_spacing` + les attributs de boîte
partagés), badge
(`content/svg_markup/width/border_radius/bold/uppercase` — tous vides (`content/svg_markup/width/border_radius/bold/uppercase` — tous vides
ou `False` par défaut = comportement historique inchangé, voir ou `False` par défaut = comportement historique inchangé, voir
rendering.md), carte (`label/title/description`, contenu pur sans rendering.md), carte (`label/title/description`, contenu pur sans
+27
View File
@@ -35,8 +35,11 @@ BOX_DEFAULTS = {
"min_width": "", "min_width": "",
"box_shadow": "", "box_shadow": "",
"opacity": "", "opacity": "",
"content_align": "top",
} }
_CONTENT_ALIGN_TO_JUSTIFY_CONTENT = {"center": "center", "bottom": "flex-end"}
# (clé d'attribut, propriété CSS) — chaque paire suit exactement le même # (clé d'attribut, propriété CSS) — chaque paire suit exactement le même
# patron (lire/nettoyer/ajouter si non vide) ; une simple table de # patron (lire/nettoyer/ajouter si non vide) ; une simple table de
@@ -104,3 +107,27 @@ def render_box_style(a: dict[str, Any]) -> str:
parts.append(f"align-self:{html_lib.escape(align_self)};") parts.append(f"align-self:{html_lib.escape(align_self)};")
return " ".join(parts) return " ".join(parts)
def render_content_align(a: dict[str, Any]) -> str:
"""Alignement vertical du CONTENU à l'intérieur de son propre bloc —
utile UNIQUEMENT une fois qu'une hauteur fixe/minimale dépasse la
hauteur naturelle du contenu (retour utilisateur du 26/09/2026 : "je
peux augmenter la hauteur d'un conteneur mais pas l'alignement
vertical à l'intérieur"). Jamais fusionné dans `render_box_style` :
contrairement à `align_self` (position du BLOC dans SON parent, la
même logique convient à tout consommateur), l'alignement du CONTENU
dépend de l'axe interne du conteneur — correct en `justify-content`
pour un conteneur en colonne (texte, liste), mais un bouton
(rangée : icône + texte) gère déjà cet axe autrement (`align-items`,
voir static/document/document-editor.css, .docButton) : chaque
renderer qui veut ce comportement l'appelle donc explicitement lui-
même (voir _render_text/_render_list/_render_image), jamais
automatiquement pour tous les kinds.
- **Retour** : `""` si `content_align` est absent ou `"top"` (défaut,
comportement historique inchangé), sinon la déclaration
`justify-content:...;`.
- **Exceptions** : aucune."""
content_align = str(a.get("content_align", "top"))
justify_content = _CONTENT_ALIGN_TO_JUSTIFY_CONTENT.get(content_align)
return f"justify-content:{justify_content};" if justify_content else ""
@@ -1,9 +1,10 @@
import html as html_lib import html as html_lib
import json import json
import random import random
import urllib.parse
from typing import Any from typing import Any
from .box_style import render_box_style from .box_style import render_box_style, render_content_align
from .sanitize_svg_markup import sanitize_svg_markup from .sanitize_svg_markup import sanitize_svg_markup
@@ -102,6 +103,9 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
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}"
content_align = render_content_align(a)
if content_align:
style += f" {content_align}"
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>'
@@ -199,7 +203,15 @@ def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list
media = f'<div class="docImageLightboxTrigger"{wrapper_style_attr}>{media}</div>' media = f'<div class="docImageLightboxTrigger"{wrapper_style_attr}>{media}</div>'
if caption: if caption:
figure_style_attr = f' style="{box_style}"' if box_style else "" # render_content_align (retour utilisateur du 26/09/2026 :
# "je peux augmenter la hauteur d'un conteneur mais pas
# l'alignement vertical à l'intérieur") n'a de sens ici QUE pour
# la figure (conteneur flex-colonne à plusieurs enfants réels —
# image + légende) : jamais sur l'<img> seul ni sur les
# enveloppes lien/plein écran, qui ne sont pas des conteneurs
# flex-colonne à plusieurs enfants.
figure_style = " ".join(p for p in (box_style, render_content_align(a)) if p)
figure_style_attr = f' style="{figure_style}"' if figure_style else ""
media = ( media = (
f'<figure class="docImageFigure"{figure_style_attr}>{media}' f'<figure class="docImageFigure"{figure_style_attr}>{media}'
f'<figcaption class="docImageCaption">{html_lib.escape(caption)}</figcaption></figure>' f'<figcaption class="docImageCaption">{html_lib.escape(caption)}</figcaption></figure>'
@@ -208,6 +220,101 @@ def _render_image(el: dict[str, Any], _children_by_parent: dict[int | None, list
return media return media
_LIST_STYLE_TYPES = {
"liste_puces": ("disc", "circle", "square", "none"),
"liste_numerotee": (
"decimal",
"decimal-leading-zero",
"lower-roman",
"upper-roman",
"lower-alpha",
"upper-alpha",
"none",
),
}
def _list_text_style(a: dict[str, Any]) -> list[str]:
"""Typographie de la liste ENTIÈRE (jamais par élément individuel,
voir element_kind_labels.py — portée actée avec l'utilisateur)."""
parts = []
if a.get("bold"):
parts.append("font-weight:700;")
if a.get("italic"):
parts.append("font-style:italic;")
if a.get("underline"):
parts.append("text-decoration:underline;")
font_family = str(a.get("font_family", "")).strip()
if font_family:
parts.append(f"font-family:{html_lib.escape(font_family)};")
font_size = str(a.get("font_size", "")).strip()
if font_size:
parts.append(f"font-size:{html_lib.escape(font_size)};")
line_height = str(a.get("line_height", "")).strip()
if line_height:
parts.append(f"line-height:{html_lib.escape(line_height)};")
text_color = str(a.get("text_color", "")).strip()
if text_color:
parts.append(f"color:{html_lib.escape(text_color)};")
return parts
def _list_marker_style(a: dict[str, Any], kind: str) -> list[str]:
"""Puces/numéros — `marker_color`/`marker_size` passent par des
PROPRIÉTÉS PERSONNALISÉES CSS (héritées jusqu'au pseudo-élément
`::marker` de chaque <li>, voir .docList li::marker dans
document-editor.css) : un style inline posé sur le <ul>/<ol> ne peut
pas cibler directement le `::marker` de ses enfants autrement."""
parts = []
list_style_type = str(a.get("list_style_type", ""))
if list_style_type in _LIST_STYLE_TYPES.get(kind, ()):
parts.append(f"list-style-type:{list_style_type};")
position_inside = str(a.get("list_style_position", "outside")) == "inside"
if position_inside:
parts.append("list-style-position:inside;")
# Retour utilisateur du 26/09/2026 : "si j'enlève les puces ou que les
# puces se mettent à l'intérieur, il reste un espace devant la liste,
# cet espace doit être supprimé" — le padding-left:1.4em par défaut
# (document-editor.css, .docList) réserve la place d'une puce
# EXTÉRIEURE ; il n'a plus lieu d'être dès que la puce n'est plus là
# ("none") ou qu'elle rejoint le flux du texte ("inside").
if list_style_type == "none" or position_inside:
parts.append("padding-left:0;")
marker_color = str(a.get("marker_color", "")).strip()
if marker_color:
parts.append(f"--doc-marker-color:{html_lib.escape(marker_color)};")
marker_size = str(a.get("marker_size", "")).strip()
if marker_size:
parts.append(f"--doc-marker-size:{html_lib.escape(marker_size)};")
# Puce personnalisée (image SVG) : liste à puces UNIQUEMENT, une
# puce imagée n'a pas de sens sur une liste numérotée. list-style-image
# prime visuellement sur list-style-type dès qu'il est posé (aucun
# conflit à gérer entre les deux).
svg_markup = str(a.get("svg_markup", "")).strip() if kind == "liste_puces" else ""
if svg_markup:
sanitized = sanitize_svg_markup(svg_markup)
encoded = urllib.parse.quote(sanitized)
parts.append(f'list-style-image:url("data:image/svg+xml,{encoded}");')
return parts
def _list_item_style(a: dict[str, Any]) -> list[str]:
"""`item_padding`/`item_spacing` s'appliquent à CHAQUE <li>, jamais au
conteneur <ul>/<ol> lui-même — même mécanisme de propriété
personnalisée CSS héritée que `_list_marker_style` ci-dessus (voir
.docList li dans document-editor.css). Une valeur UNIFORME partagée
par tous les éléments (retour utilisateur du 26/09/2026), jamais
réglable par élément individuel."""
parts = []
item_padding = str(a.get("item_padding", "")).strip()
if item_padding:
parts.append(f"--doc-item-padding:{html_lib.escape(item_padding)};")
item_spacing = str(a.get("item_spacing", "")).strip()
if item_spacing:
parts.append(f"--doc-item-spacing:{html_lib.escape(item_spacing)};")
return parts
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:
"""Liste à puces (<ul>) ou numérotée (<ol>) — le kind lui-même décide """Liste à puces (<ul>) ou numérotée (<ol>) — le kind lui-même décide
la balise, pas un attribut "ordered" séparé (voir la balise, pas un attribut "ordered" séparé (voir
@@ -216,10 +323,22 @@ def _render_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[
image sans fichier, une liste sans élément n'a rien d'anormal à image sans fichier, une liste sans élément n'a rien d'anormal à
afficher (le créateur vient peut-être de tout supprimer avant d'en afficher (le créateur vient peut-être de tout supprimer avant d'en
retaper un).""" retaper un)."""
items = el["attributes"].get("items", []) a = el["attributes"]
tag = "ol" if el["kind"] == "liste_numerotee" else "ul" kind = el["kind"]
items = a.get("items", [])
tag = "ol" if kind == "liste_numerotee" else "ul"
items_html = "".join(f"<li>{html_lib.escape(str(item))}</li>" for item in items) items_html = "".join(f"<li>{html_lib.escape(str(item))}</li>" for item in items)
return f'<{tag} class="docList" data-element-id="{el["id"]}" data-kind="{el["kind"]}">{items_html}</{tag}>'
style = " ".join(
_list_text_style(a)
+ _list_marker_style(a, kind)
+ _list_item_style(a)
+ [render_box_style(a), render_content_align(a)]
)
style = style.strip()
style_attr = f' style="{style}"' if style else ""
return f'<{tag} class="docList" data-element-id="{el["id"]}" data-kind="{kind}"{style_attr}>{items_html}</{tag}>'
_BUTTON_TEXT_TRANSFORMS = ("uppercase", "lowercase", "capitalize") _BUTTON_TEXT_TRANSFORMS = ("uppercase", "lowercase", "capitalize")
+58 -3
View File
@@ -113,6 +113,41 @@ regroupement à chaque appel.
partagée par les deux) — un `<li>` par entrée de `attributes["items"]`. partagée par les deux) — un `<li>` par entrée de `attributes["items"]`.
Une liste vide rend `<ul>`/`<ol>` sans enfant plutôt qu'un placeholder : Une liste vide rend `<ul>`/`<ol>` sans enfant plutôt qu'un placeholder :
contrairement à une image sans fichier, ce n'est pas un état anormal. contrairement à une image sans fichier, ce n'est pas un état anormal.
Style inline (audit du 26/09/2026, portée actée avec l'utilisateur :
la LISTE ENTIÈRE uniquement, jamais par élément individuel ni de
sous-listes) : `bold`/`italic`/`underline`/`font_family`/`font_size`/
`line_height`/`text_color` (typo, `_list_text_style`) +
`list_style_type` (validé selon le `kind` — `disc`/`circle`/`square`/
`none` pour `liste_puces`, `decimal`/`decimal-leading-zero`/
`lower-roman`/`upper-roman`/`lower-alpha`/`upper-alpha`/`none` pour
`liste_numerotee`, toute autre valeur ignorée)/`list_style_position`
(`"outside"` par défaut) + les attributs de boîte partagés
(`render_box_style`, voir `box_style.py`). `marker_color`/
`marker_size`/`item_padding`/`item_spacing` (`_list_marker_style`/
`_list_item_style`) passent par des PROPRIÉTÉS PERSONNALISÉES CSS
(`--doc-marker-color`/`--doc-marker-size`/`--doc-item-padding`/
`--doc-item-spacing`) : un style inline sur le `<ul>`/`<ol>` ne peut
pas cibler directement le `::marker` ou le padding de ses `<li>`
enfants autrement — ces propriétés sont posées sur le conteneur et
consommées par `static/document/document-editor.css`
(`.docList li`/`.docList li::marker`), qui hérite jusque-là.
`svg_markup` (liste à puces UNIQUEMENT, ignoré pour `liste_numerotee`)
: puce personnalisée — nettoyé par `sanitize_svg_markup` puis encodé
en URI de données (`urllib.parse.quote`) pour `list-style-image`, qui
prime visuellement sur `list_style_type` dès qu'il est posé.
`render_content_align(a)` (voir `box_style.py`) également ajouté :
`.docList` est `display:flex; flex-direction:column;` (un `<li>` garde
son `display:list-item` propre — puce/numéro visibles — même une fois
flex-item, ce sont deux notions indépendantes en CSS).
**Bug réel corrigé** (retour utilisateur du 26/09/2026 : "si j'enlève
les puces ou que les puces se mettent à l'intérieur, il reste un
espace devant la liste, cet espace doit être supprimé") :
`padding-left:0;` est ajouté automatiquement dès que
`list_style_type="none"` ou `list_style_position="inside"` — le
`padding-left:1.4em` par défaut (`.docList`, réservé pour une puce
EXTÉRIEURE) n'a alors plus lieu d'être ; un `padding` uniforme réglé
explicitement par ailleurs (attributs de boîte partagés) reste
prioritaire (déclaré après, dans le même style inline).
- **Quiz** : toujours une carte résumant la config réelle (nombre de - **Quiz** : toujours une carte résumant la config réelle (nombre de
questions, total des points via `quiz_total_points`, minuteur si questions, total des points via `quiz_total_points`, minuteur si
activé) — sanitizée (`sanitize_quiz_config`) avant lecture, jamais un activé) — sanitizée (`sanitize_quiz_config`) avant lecture, jamais un
@@ -246,9 +281,9 @@ Un dict à 4 clés (`BORDER_SIDES`), chacune `{"style": "none", "width":
`{"padding": "", "margin": "", "background_color": "", "border_radius": `{"padding": "", "margin": "", "background_color": "", "border_radius":
"", "align_self": "stretch", "width": "", "max_width": "", "height": "", "", "align_self": "stretch", "width": "", "max_width": "", "height": "",
"min_height": "", "max_height": "", "min_width": "", "box_shadow": "", "min_height": "", "max_height": "", "min_width": "", "box_shadow": "",
"opacity": ""}` — `border` n'y figure PAS (voir `default_border()`, à "opacity": "", "content_align": "top"}` — `border` n'y figure PAS (voir
ajouter séparément par chaque appelant pour éviter le partage par `default_border()`, à ajouter séparément par chaque appelant pour éviter
référence). 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 chaque attribut listé dans Construit les déclarations CSS inline pour chaque attribut listé dans
@@ -269,6 +304,26 @@ colonne).
vide si rien à ajouter. vide si rien à ajouter.
- **Exceptions** : aucune. - **Exceptions** : aucune.
### `render_content_align(a: dict[str, Any]) -> str`
Alignement vertical du CONTENU à l'intérieur de son propre bloc (retour
utilisateur du 26/09/2026 : "je peux augmenter la hauteur d'un
conteneur mais pas l'alignement vertical à l'intérieur") — `content_align`
(`"top"` par défaut, `"center"` ou `"bottom"`) mappé vers
`justify-content` (`""` pour `"top"`, comportement historique inchangé).
Jamais fusionné dans `render_box_style` : contrairement à `align_self`
(position du BLOC dans SON parent, valable pour tout consommateur),
l'alignement du CONTENU dépend de l'axe interne du conteneur —
`justify-content` convient à un conteneur en colonne (`_render_text`/
`_render_list`, dont les classes CSS `.docText`/`.docList` sont
`display:flex; flex-direction:column;`, et la figure d'une image
légendée, déjà flex-colonne), mais serait FAUX pour le Bouton (rangée
icône+texte : l'axe vertical y est déjà géré par `align-items`, voir
`static/document/document-editor.css`, `.docButton`) — chaque renderer
qui veut ce comportement l'appelle donc explicitement lui-même.
- **Retour** : `""` si absent/`"top"`/valeur inconnue, sinon
`"justify-content:...;"`.
- **Exceptions** : aucune.
## `sanitize_svg_markup.py` — nettoyage du contenu SVG inline d'une image ## `sanitize_svg_markup.py` — nettoyage du contenu SVG inline d'une image
### `sanitize_svg_markup(markup: str) -> str` ### `sanitize_svg_markup(markup: str) -> str`
+61 -1
View File
@@ -263,6 +263,16 @@ body.objectEditBody > main.content {
margin-bottom: 10px; margin-bottom: 10px;
} }
/* Sépare visuellement, DANS un même panneau Propriétés, les champs qui
touchent au CONTENU (propre à chaque kind) de ceux qui touchent au
CONTENEUR (position/boîte, partagés — voir forgeDocRenderBoxFieldsHtml)
— retour utilisateur du 26/09/2026 : "sinon c'est pas compréhensible". */
.docPropsSectionLabel {
margin-top: 20px;
padding-top: 14px;
border-top: 1px solid var(--doc-border);
}
.docLibItem { .docLibItem {
border: 1px solid var(--doc-border); border: 1px solid var(--doc-border);
border-radius: 10px; border-radius: 10px;
@@ -587,6 +597,16 @@ body.objectEditBody > main.content {
mots normaux au milieu sans raison) ne coupe QUE si le mot ne tient mots normaux au milieu sans raison) ne coupe QUE si le mot ne tient
vraiment pas sur une ligne. */ vraiment pas sur une ligne. */
overflow-wrap: break-word; overflow-wrap: break-word;
/* flex-colonne (retour utilisateur du 26/09/2026 : "je peux augmenter
la hauteur d'un conteneur mais pas l'alignement vertical à
l'intérieur") : sans effet visuel tant que la hauteur du bloc
correspond à son contenu (comportement historique inchangé) —
n'entre en jeu que si une hauteur fixe/minimale (voir box_style.py)
dépasse la hauteur naturelle du texte, via justify-content
(render_content_align, appliqué uniquement si réglé). */
display: flex;
flex-direction: column;
} }
.docImage.docImagePlaceholder { .docImage.docImagePlaceholder {
@@ -687,8 +707,48 @@ img.docImage {
color: var(--forge-text); color: var(--forge-text);
} }
/* item_padding/item_spacing/marker_color/marker_size (retour utilisateur
du 26/09/2026, audit — colonne "Liste à puces/numérotée") : une
valeur inline sur le <ul>/<ol> ne peut pas cibler directement un <li>
enfant ni son ::marker — passe par des PROPRIÉTÉS PERSONNALISÉES CSS,
posées en style inline sur le conteneur (voir _render_list,
document_engine/rendering/render_document_element.py) et héritées
jusqu'ici (les propriétés personnalisées traversent aussi bien les
descendants que leurs pseudo-éléments). Valeur par défaut = comportement
historique inchangé (4px entre éléments, pas de padding) tant qu'aucune
valeur n'est réglée. */
.docList li {
padding: var(--doc-item-padding, 0);
}
/* Bug réel corrigé une première fois le 26/09/2026 (capture à l'appui,
thème "Sécurité incendie") en posant display:flex ICI sur <li> pour
que le badge en ::before d'un thème (voir static/document/themes/
securite-incendie.css) s'affiche À CÔTÉ du texte plutôt qu'AU-DESSUS.
**Corrigé une seconde fois** (retour utilisateur : "quand j'enlève le
thème [...] on voit plus les puces ou les numéros") : display:flex
sur le <li> lui-même REMPLACE entièrement son display:list-item —
ça supprime le marqueur natif (puce/numéro) pour TOUTE liste, avec ou
sans thème, pas seulement pour celles qui ont un ::before (contraire
à ce qu'affirmait le commentaire précédent, qui était FAUX — vérifié
par un test en conditions réelles). La bonne cible pour la mise en
ligne du badge est le ::before LUI-MÊME (inline-block, jamais son
parent) : le <li> garde son display natif, donc son marqueur, et le
badge d'un thème s'aligne quand même à côté du texte qui le suit
immédiatement dans le flux normal. */
.docList li::before {
display: inline-block;
vertical-align: middle;
margin-right: 10px;
}
.docList li + li { .docList li + li {
margin-top: 4px; margin-top: var(--doc-item-spacing, 4px);
}
.docList li::marker {
color: var(--doc-marker-color, inherit);
font-size: var(--doc-marker-size, inherit);
} }
.docMinigame { .docMinigame {
+197 -13
View File
@@ -1966,6 +1966,43 @@ const FORGE_DOC_FONT_FAMILY_OPTIONS = [
['"Courier New", monospace', 'Courier New (machine à écrire)'], ['"Courier New", monospace', 'Courier New (machine à écrire)'],
]; ];
// Retour utilisateur du 26/09/2026 : "il faut distinguer par des
// sections la propriété qui touche au conteneur de celles qui touchent
// à l'élément qu'il contient sinon c'est pas compréhensible" — un
// simple sous-titre visuel (bordure du dessus), posé entre les champs
// de CONTENU (propres à chaque kind) et les champs de CONTENEUR
// (position/boîte, partagés via forgeDocRenderBoxFieldsHtml), dans
// CHAQUE panneau de propriétés.
function forgeDocPropsSectionHtml(label) {
return `<div class="docSectionLabel docPropsSectionLabel">${label}</div>`;
}
const FORGE_DOC_CONTENT_ALIGN_OPTIONS = [['top', 'Haut'], ['center', 'Centré'], ['bottom', 'Bas']];
function forgeDocRenderContentAlignHtml(a, idPrefix) {
// Retour utilisateur du 26/09/2026 : "je peux augmenter la hauteur
// d'un conteneur mais pas l'alignement vertical à l'intérieur" — sans
// effet tant que la hauteur du bloc correspond à son contenu, voir
// render_content_align (document_engine/rendering/box_style.py).
// Jamais posé pour le Bouton (rangée icône+texte, l'axe vertical y
// est déjà géré par align-items, voir document-editor.css).
const align = a.content_align || 'top';
return `
<div class="docField">
<span class="docFieldLabel">Alignement vertical du contenu</span>
<div class="docSegmented" id="${idPrefix}ContentAlignSeg">
${FORGE_DOC_CONTENT_ALIGN_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${align === v ? 'is-active' : ''}" data-content-align="${v}">${l}</button>`).join('')}
</div>
</div>
`;
}
function forgeDocBindContentAlign(panel, idPrefix, patch) {
panel.querySelectorAll(`#${idPrefix}ContentAlignSeg .docSegBtn`).forEach((btn) => {
btn.addEventListener('click', () => patch({ content_align: btn.dataset.contentAlign }));
});
}
/* --------------------------------------------------------------------- /* ---------------------------------------------------------------------
* Contrôles génériques réutilisables — retour utilisateur du 26/09/2026 : * Contrôles génériques réutilisables — retour utilisateur du 26/09/2026 :
* "jamais l'utilisateur ne doit rentrer une valeur brute à la main" — * "jamais l'utilisateur ne doit rentrer une valeur brute à la main" —
@@ -2019,11 +2056,20 @@ function forgeDocToHexColor(value, fallback) {
return /^#[0-9a-f]{6}$/i.test(value || '') ? value : fallback; return /^#[0-9a-f]{6}$/i.test(value || '') ? value : fallback;
} }
function forgeDocRenderBoxFieldsHtml(a, idPrefix) { function forgeDocRenderBoxFieldsHtml(a, idPrefix, opts = {}) {
// Retour utilisateur du 26/09/2026 : "il faut distinguer par des
// sections la propriété qui touche au conteneur de celles qui
// touchent à l'élément qu'il contient" — ce bloc EST la section
// "Conteneur" pour tous les panneaux qui l'appellent (le contenu
// propre à chaque kind vient avant, dans son propre appelant).
// includeContentAlign=false pour le Bouton uniquement (rangée
// icône+texte, l'axe vertical y est déjà géré par align-items).
const includeContentAlign = opts.includeContentAlign !== false;
const border = a.border || {}; const border = a.border || {};
const alignSelf = a.align_self || 'stretch'; const alignSelf = a.align_self || 'stretch';
const opacityPercent = a.opacity !== '' && a.opacity !== undefined ? Math.round(Number.parseFloat(a.opacity) * 100) : ''; const opacityPercent = a.opacity !== '' && a.opacity !== undefined ? Math.round(Number.parseFloat(a.opacity) * 100) : '';
return ` return `
${forgeDocPropsSectionHtml('Conteneur')}
${forgeDocRenderToggleSliderHtml(`${idPrefix}Padding`, 'Espacement intérieur (padding)', a.padding, { ${forgeDocRenderToggleSliderHtml(`${idPrefix}Padding`, 'Espacement intérieur (padding)', a.padding, {
min: 0, max: 80, step: 2, unit: 'px', fallback: 16, min: 0, max: 80, step: 2, unit: 'px', fallback: 16,
})} })}
@@ -2073,6 +2119,7 @@ function forgeDocRenderBoxFieldsHtml(a, idPrefix) {
${FORGE_DOC_ALIGN_SELF_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${alignSelf === v ? 'is-active' : ''}" data-align-self="${v}">${l}</button>`).join('')} ${FORGE_DOC_ALIGN_SELF_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${alignSelf === v ? 'is-active' : ''}" data-align-self="${v}">${l}</button>`).join('')}
</div> </div>
</div> </div>
${includeContentAlign ? forgeDocRenderContentAlignHtml(a, idPrefix) : ''}
<div class="docField"> <div class="docField">
<span class="docFieldLabel">Bordure — les 4 côtés à la fois</span> <span class="docFieldLabel">Bordure — les 4 côtés à la fois</span>
<div class="docBorderSideRow" id="${idPrefix}BorderAllRow"> <div class="docBorderSideRow" id="${idPrefix}BorderAllRow">
@@ -2121,7 +2168,8 @@ function forgeDocRenderBoxFieldsHtml(a, idPrefix) {
`; `;
} }
function forgeDocBindBoxFields(panel, el, idPrefix, patch) { function forgeDocBindBoxFields(panel, el, idPrefix, patch, opts = {}) {
if (opts.includeContentAlign !== false) forgeDocBindContentAlign(panel, 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 }));
@@ -2191,6 +2239,7 @@ function forgeDocRenderTextProps(panel, el) {
const a = el.attributes; const a = el.attributes;
panel.innerHTML = ` panel.innerHTML = `
${forgeDocPropsHeader('texte')} ${forgeDocPropsHeader('texte')}
${forgeDocPropsSectionHtml('Contenu')}
<div class="docField"> <div class="docField">
<span class="docFieldLabel">Style</span> <span class="docFieldLabel">Style</span>
<div class="docSegmented" id="docStyleSeg"> <div class="docSegmented" id="docStyleSeg">
@@ -2295,6 +2344,7 @@ function forgeDocRenderImageProps(panel, el) {
const clickBehavior = a.click_behavior || ''; const clickBehavior = a.click_behavior || '';
panel.innerHTML = ` panel.innerHTML = `
${forgeDocPropsHeader('image')} ${forgeDocPropsHeader('image')}
${forgeDocPropsSectionHtml('Contenu')}
<div class="docField"> <div class="docField">
<span class="docFieldLabel">Fichier image</span> <span class="docFieldLabel">Fichier image</span>
<div id="docImageUploadStatus">${a.src ? 'Image actuelle en place' : 'Aucun fichier choisi'}</div> <div id="docImageUploadStatus">${a.src ? 'Image actuelle en place' : 'Aucun fichier choisi'}</div>
@@ -2395,6 +2445,7 @@ function forgeDocRenderButtonProps(panel, el) {
: 'Aucun fichier joint'; : 'Aucun fichier joint';
panel.innerHTML = ` panel.innerHTML = `
${forgeDocPropsHeader('bouton')} ${forgeDocPropsHeader('bouton')}
${forgeDocPropsSectionHtml('Contenu')}
<div class="docField"><label for="docButtonLabel">Texte du bouton</label><input type="text" id="docButtonLabel" value="${forgeDocEscapeHtml(a.label || '')}"></div> <div class="docField"><label for="docButtonLabel">Texte du bouton</label><input type="text" id="docButtonLabel" value="${forgeDocEscapeHtml(a.label || '')}"></div>
<div class="docField"><label for="docButtonTarget">Cible (URL ou ancre)</label><input type="text" id="docButtonTarget" value="${forgeDocEscapeHtml(a.target || '')}"></div> <div class="docField"><label for="docButtonTarget">Cible (URL ou ancre)</label><input type="text" id="docButtonTarget" value="${forgeDocEscapeHtml(a.target || '')}"></div>
<div class="docField"> <div class="docField">
@@ -2441,7 +2492,7 @@ function forgeDocRenderButtonProps(panel, el) {
${forgeDocRenderToggleSliderHtml('docButtonLetterSpacing', 'Espacement des lettres', a.letter_spacing, { ${forgeDocRenderToggleSliderHtml('docButtonLetterSpacing', 'Espacement des lettres', a.letter_spacing, {
min: -2, max: 10, step: 0.5, unit: 'px', fallback: 0, min: -2, max: 10, step: 0.5, unit: 'px', fallback: 0,
})} })}
${forgeDocRenderBoxFieldsHtml(a, 'docButton')} ${forgeDocRenderBoxFieldsHtml(a, 'docButton', { includeContentAlign: false })}
${forgeDocDeleteButtonHtml()} ${forgeDocDeleteButtonHtml()}
`; `;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); } function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
@@ -2478,7 +2529,7 @@ function forgeDocRenderButtonProps(panel, el) {
document.getElementById('docButtonFontFamilyInput').addEventListener('change', (e) => patch({ font_family: e.target.value })); document.getElementById('docButtonFontFamilyInput').addEventListener('change', (e) => patch({ font_family: e.target.value }));
forgeDocBindToggleSlider('docButtonFontSize', { unit: 'px' }, (v) => patch({ font_size: v })); forgeDocBindToggleSlider('docButtonFontSize', { unit: 'px' }, (v) => patch({ font_size: v }));
forgeDocBindToggleSlider('docButtonLetterSpacing', { unit: 'px' }, (v) => patch({ letter_spacing: v })); forgeDocBindToggleSlider('docButtonLetterSpacing', { unit: 'px' }, (v) => patch({ letter_spacing: v }));
forgeDocBindBoxFields(panel, el, 'docButton', patch); forgeDocBindBoxFields(panel, el, 'docButton', patch, { includeContentAlign: false });
} }
function forgeDocRenderBadgeProps(panel, el) { function forgeDocRenderBadgeProps(panel, el) {
@@ -2531,14 +2582,30 @@ function forgeDocRenderListItemHtml(item, index) {
`; `;
} }
const FORGE_DOC_LIST_STYLE_TYPE_OPTIONS = {
liste_puces: [['', 'Par défaut'], ['disc', 'Disque'], ['circle', 'Cercle'], ['square', 'Carré'], ['none', 'Aucune']],
liste_numerotee: [
['', 'Par défaut'], ['decimal', '1, 2, 3'], ['decimal-leading-zero', '01, 02, 03'],
['lower-roman', 'i, ii, iii'], ['upper-roman', 'I, II, III'],
['lower-alpha', 'a, b, c'], ['upper-alpha', 'A, B, C'], ['none', 'Aucune'],
],
};
const FORGE_DOC_LIST_STYLE_POSITION_OPTIONS = [['outside', 'Extérieure'], ['inside', 'Intérieure']];
function forgeDocRenderListProps(panel, el) { function forgeDocRenderListProps(panel, el) {
// Même patron répéteur que l'Association/Memory (ajouter/supprimer/ // Même patron répéteur que l'Association/Memory (ajouter/supprimer/
// modifier une ligne, panneau entièrement rechargé après chaque // modifier une ligne, panneau entièrement rechargé après chaque
// modification via patch()) — ici une simple liste de chaînes, pas de // modification via patch()) — ici une simple liste de chaînes, pas de
// structure à 2 champs comme une paire. // structure à 2 champs comme une paire. Mise en forme : sur la LISTE
// ENTIÈRE uniquement (retour utilisateur du 26/09/2026 : pas de style
// par élément individuel ni de sous-listes, portée actée explicitement
// — voir document_engine/labels/element_kind_labels.py), à l'exception
// du padding par élément (item_padding, UNIFORME, voir plus bas).
const a = el.attributes; const a = el.attributes;
const items = a.items || []; const items = a.items || [];
const kindLabel = el.kind === 'liste_numerotee' ? 'liste numérotée' : 'liste à puces'; const isPuces = el.kind !== 'liste_numerotee';
const kindLabel = isPuces ? 'liste à puces' : 'liste numérotée';
const styleTypeOptions = FORGE_DOC_LIST_STYLE_TYPE_OPTIONS[el.kind] || FORGE_DOC_LIST_STYLE_TYPE_OPTIONS.liste_puces;
function patch(partial) { function patch(partial) {
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
@@ -2548,9 +2615,63 @@ function forgeDocRenderListProps(panel, el) {
panel.innerHTML = ` panel.innerHTML = `
${forgeDocPropsHeader(kindLabel)} ${forgeDocPropsHeader(kindLabel)}
${forgeDocPropsSectionHtml('Contenu')}
<div class="docSectionLabel docQuizQuestionsLabel">Éléments de la liste</div> <div class="docSectionLabel docQuizQuestionsLabel">Éléments de la liste</div>
<div id="docListItemsList">${items.length ? items.map(forgeDocRenderListItemHtml).join('') : '<div class="docEmptyState">Aucun élément — ajoute le premier ci-dessous.</div>'}</div> <div id="docListItemsList">${items.length ? items.map(forgeDocRenderListItemHtml).join('') : '<div class="docEmptyState">Aucun élément — ajoute le premier ci-dessous.</div>'}</div>
<button type="button" class="docBtnSecondary docQuizAddBtn" id="docListAddItem">+ Ajouter un élément</button> <button type="button" class="docBtnSecondary docQuizAddBtn" id="docListAddItem">+ Ajouter un élément</button>
<div class="docField">
<span class="docFieldLabel">Mise en forme du texte</span>
<div class="docToolbarRow">
<button type="button" class="docFmtBtn ${a.bold ? 'is-active' : ''}" id="docListBoldBtn" title="Gras">B</button>
<button type="button" class="docFmtBtn docFmtBtn--italic ${a.italic ? 'is-active' : ''}" id="docListItalicBtn" title="Italique">I</button>
<button type="button" class="docFmtBtn docFmtBtn--underline ${a.underline ? 'is-active' : ''}" id="docListUnderlineBtn" title="Souligné">U</button>
</div>
</div>
<div class="docField">
<label for="docListColorInput">Couleur du texte</label>
<input type="color" id="docListColorInput" value="${forgeDocToHexColor(a.text_color, '#e8ecf4')}">
</div>
<div class="docField">
<label for="docListFontFamilyInput">Police de caractère</label>
<select id="docListFontFamilyInput">
${FORGE_DOC_FONT_FAMILY_OPTIONS.map(([v, l]) => `<option value="${forgeDocEscapeHtml(v)}" ${(a.font_family || '') === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</div>
${forgeDocRenderToggleSliderHtml('docListFontSize', 'Taille de police', a.font_size, {
min: 10, max: 32, step: 1, unit: 'px', fallback: 15,
})}
${forgeDocRenderToggleSliderHtml('docListLineHeight', 'Hauteur de ligne', a.line_height, {
min: 1, max: 2.5, step: 0.1, unit: '', fallback: 1.6,
})}
<div class="docField">
<span class="docFieldLabel">Style de puce</span>
<div class="docSegmented" id="docListStyleTypeSeg">
${styleTypeOptions.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.list_style_type || '') === v ? 'is-active' : ''}" data-style-type="${v}">${l}</button>`).join('')}
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Position de la puce</span>
<div class="docSegmented" id="docListStylePositionSeg">
${FORGE_DOC_LIST_STYLE_POSITION_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.list_style_position || 'outside') === v ? 'is-active' : ''}" data-position="${v}">${l}</button>`).join('')}
</div>
</div>
<div class="docField">
<label for="docListMarkerColorInput">Couleur de la puce</label>
<input type="color" id="docListMarkerColorInput" value="${forgeDocToHexColor(a.marker_color, '#e8ecf4')}">
</div>
${forgeDocRenderToggleSliderHtml('docListMarkerSize', 'Taille de la puce', a.marker_size, {
min: 10, max: 40, step: 1, unit: 'px', fallback: 15,
})}
${isPuces ? `
<div class="docField"><label for="docListSvg">Puce personnalisée (icône SVG, optionnel)</label><textarea id="docListSvg" rows="3">${forgeDocEscapeHtml(a.svg_markup || '')}</textarea></div>
` : ''}
${forgeDocRenderBoxFieldsHtml(a, 'docList')}
${forgeDocRenderToggleSliderHtml('docListItemPadding', 'Espacement intérieur par élément', a.item_padding, {
min: 0, max: 40, step: 2, unit: 'px', fallback: 6,
})}
${forgeDocRenderToggleSliderHtml('docListItemSpacing', 'Espacement entre les éléments', a.item_spacing, {
min: 0, max: 40, step: 2, unit: 'px', fallback: 4,
})}
${forgeDocDeleteButtonHtml()} ${forgeDocDeleteButtonHtml()}
`; `;
@@ -2571,6 +2692,28 @@ function forgeDocRenderListProps(panel, el) {
patch({ items: items.map((it, i) => (i === index ? e.target.value : it)) }); patch({ items: items.map((it, i) => (i === index ? e.target.value : it)) });
}); });
}); });
document.getElementById('docListBoldBtn').addEventListener('click', () => patch({ bold: !a.bold }));
document.getElementById('docListItalicBtn').addEventListener('click', () => patch({ italic: !a.italic }));
document.getElementById('docListUnderlineBtn').addEventListener('click', () => patch({ underline: !a.underline }));
document.getElementById('docListColorInput').addEventListener('change', (e) => patch({ text_color: e.target.value }));
document.getElementById('docListFontFamilyInput').addEventListener('change', (e) => patch({ font_family: e.target.value }));
forgeDocBindToggleSlider('docListFontSize', { unit: 'px' }, (v) => patch({ font_size: v }));
forgeDocBindToggleSlider('docListLineHeight', { unit: '' }, (v) => patch({ line_height: v }));
panel.querySelectorAll('#docListStyleTypeSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ list_style_type: btn.dataset.styleType }));
});
panel.querySelectorAll('#docListStylePositionSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ list_style_position: btn.dataset.position }));
});
document.getElementById('docListMarkerColorInput').addEventListener('change', (e) => patch({ marker_color: e.target.value }));
forgeDocBindToggleSlider('docListMarkerSize', { unit: 'px' }, (v) => patch({ marker_size: v }));
if (isPuces) {
document.getElementById('docListSvg').addEventListener('change', (e) => patch({ svg_markup: e.target.value.trim() }));
}
forgeDocBindBoxFields(panel, el, 'docList', patch);
forgeDocBindToggleSlider('docListItemPadding', { unit: 'px' }, (v) => patch({ item_padding: v }));
forgeDocBindToggleSlider('docListItemSpacing', { unit: 'px' }, (v) => patch({ item_spacing: v }));
} }
function forgeDocRenderRowProps(panel, el) { function forgeDocRenderRowProps(panel, el) {
@@ -3635,16 +3778,26 @@ var forgeDocSelectedTemplateId = null;
function forgeDocRenderTemplateList() { function forgeDocRenderTemplateList() {
const list = document.getElementById('docTemplateList'); const list = document.getElementById('docTemplateList');
const themes = window.FORGE_DOCUMENT.themes || []; const themes = window.FORGE_DOCUMENT.themes || [];
if (!themes.length) { // "Aucun modèle" (retour utilisateur du 26/09/2026 : la modale doit
list.innerHTML = '<div class="docEmptyState">Aucun modèle disponible pour le moment.</div>'; // proposer de "revenir à un document de base") — une carte TOUJOURS
return; // présente, même si le catalogue est vide, avec un id sentinelle vide
} // ("" est le themeId qu'attend /document/<slug>/theme/apply pour
list.innerHTML = themes.map((t) => ` // retirer le thème actuel, voir routes/document/document_theme_apply.py) —
// JAMAIS `null`, qui reste réservé à "rien n'a encore été cliqué"
// (voir forgeDocApplyTemplate, forgeDocOpenTemplateModal).
const noneCard = `
<button type="button" class="docTemplateCard ${forgeDocSelectedTemplateId === '' ? 'is-active' : ''}" data-theme-id="">
<div class="docTemplateCardName">Aucun modèle</div>
<div class="docTemplateCardCategory">Document sans mise en forme de modèle</div>
</button>
`;
const themeCards = themes.map((t) => `
<button type="button" class="docTemplateCard ${t.id === forgeDocSelectedTemplateId ? 'is-active' : ''}" data-theme-id="${t.id}"> <button type="button" class="docTemplateCard ${t.id === forgeDocSelectedTemplateId ? 'is-active' : ''}" data-theme-id="${t.id}">
<div class="docTemplateCardName">${forgeDocEscapeHtml(t.name)}</div> <div class="docTemplateCardName">${forgeDocEscapeHtml(t.name)}</div>
<div class="docTemplateCardCategory">${forgeDocEscapeHtml(t.category)}</div> <div class="docTemplateCardCategory">${forgeDocEscapeHtml(t.category)}</div>
</button> </button>
`).join(''); `).join('');
list.innerHTML = noneCard + themeCards;
list.querySelectorAll('.docTemplateCard').forEach((btn) => { list.querySelectorAll('.docTemplateCard').forEach((btn) => {
btn.addEventListener('click', () => forgeDocSelectTemplate(btn.dataset.themeId)); btn.addEventListener('click', () => forgeDocSelectTemplate(btn.dataset.themeId));
}); });
@@ -3656,20 +3809,49 @@ function forgeDocSelectTemplate(themeId) {
const frame = document.getElementById('docTemplatePreviewFrame'); const frame = document.getElementById('docTemplatePreviewFrame');
const empty = document.getElementById('docTemplatePreviewEmpty'); const empty = document.getElementById('docTemplatePreviewEmpty');
const actions = document.getElementById('docTemplatePreviewActions'); const actions = document.getElementById('docTemplatePreviewActions');
const keepBtn = document.getElementById('docTemplateKeepContentBtn');
const useBtn = document.getElementById('docTemplateUseContentBtn');
const removeBtn = document.getElementById('docTemplateRemoveBtn');
actions.hidden = false;
if (themeId === '') {
// "Aucun modèle" : rien à prévisualiser (aucun contenu de
// démonstration n'existe pour "pas de thème"), une seule action a
// du sens ici — retirer le thème actuel, jamais les deux boutons
// "contenu actuel/du modèle" qui supposent un vrai thème choisi.
frame.classList.remove('is-visible');
frame.src = 'about:blank';
empty.hidden = false;
empty.textContent = 'Revient à la mise en forme par défaut, sans modèle. Le contenu actuel du document n\'est jamais supprimé.';
keepBtn.hidden = true;
useBtn.hidden = true;
removeBtn.hidden = false;
return;
}
keepBtn.hidden = false;
useBtn.hidden = false;
removeBtn.hidden = true;
frame.src = `/document/${window.forgeDocState.slug}/theme/${themeId}/preview`; frame.src = `/document/${window.forgeDocState.slug}/theme/${themeId}/preview`;
frame.classList.add('is-visible'); frame.classList.add('is-visible');
empty.hidden = true; empty.hidden = true;
actions.hidden = false;
} }
function forgeDocApplyTemplate(mode) { function forgeDocApplyTemplate(mode) {
if (!forgeDocSelectedTemplateId) return; // "" (Aucun modèle) est un choix explicite valide, jamais bloqué ici —
// seul `null` ("rien n'a encore été cliqué dans la modale") doit
// empêcher l'appel.
if (forgeDocSelectedTemplateId === null) return;
if (mode === 'template_content') { if (mode === 'template_content') {
const confirmed = window.confirm( // eslint-disable-line no-alert const confirmed = window.confirm( // eslint-disable-line no-alert
'Remplacer tout le contenu actuel du document par le contenu de démonstration de ce modèle ? Cette action est irréversible.', 'Remplacer tout le contenu actuel du document par le contenu de démonstration de ce modèle ? Cette action est irréversible.',
); );
if (!confirmed) return; if (!confirmed) return;
} }
if (forgeDocSelectedTemplateId === '') {
const confirmed = window.confirm( // eslint-disable-line no-alert
'Retirer le modèle actuel ? Le document revient à sa mise en forme par défaut (le contenu, lui, n\'est jamais supprimé).',
);
if (!confirmed) return;
}
fetch(`/document/${window.forgeDocState.slug}/theme/apply`, { fetch(`/document/${window.forgeDocState.slug}/theme/apply`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -3699,6 +3881,7 @@ function forgeDocOpenTemplateModal() {
frame.classList.remove('is-visible'); frame.classList.remove('is-visible');
frame.src = 'about:blank'; frame.src = 'about:blank';
empty.hidden = false; empty.hidden = false;
empty.textContent = 'Sélectionnez un modèle pour le visualiser.';
actions.hidden = true; actions.hidden = true;
} }
@@ -3715,6 +3898,7 @@ function forgeDocBindTemplateModal() {
}); });
document.getElementById('docTemplateKeepContentBtn').addEventListener('click', () => forgeDocApplyTemplate('keep_content')); document.getElementById('docTemplateKeepContentBtn').addEventListener('click', () => forgeDocApplyTemplate('keep_content'));
document.getElementById('docTemplateUseContentBtn').addEventListener('click', () => forgeDocApplyTemplate('template_content')); document.getElementById('docTemplateUseContentBtn').addEventListener('click', () => forgeDocApplyTemplate('template_content'));
document.getElementById('docTemplateRemoveBtn').addEventListener('click', () => forgeDocApplyTemplate('keep_content'));
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key !== 'Escape') return; if (e.key !== 'Escape') return;
if (document.getElementById('docTemplateModal').classList.contains('is-open')) forgeDocCloseTemplateModal(); if (document.getElementById('docTemplateModal').classList.contains('is-open')) forgeDocCloseTemplateModal();
+28 -4
View File
@@ -96,11 +96,29 @@
height: 13px; height: 13px;
} }
/* ---- Liste à puces / numérotée : coche sécurité / pastille panneau ---- */ /* ---- Liste à puces / numérotée : coche sécurité / pastille panneau ----
list-style:none supprime la puce/le numéro NATIF : ce thème affiche
son propre badge (::before ci-dessous) à la place, jamais les deux à
la fois (bug réel constaté le 26/09/2026 — sans ce reset, la feuille
de base affiche désormais aussi le marqueur natif en plus du badge,
voir static/document/document-editor.css, .docList li::before, qui a
dû arrêter de neutraliser le display natif du <li> pour corriger un
autre bug — retirer le thème faisait disparaître tout marqueur). ---- */
.docList { .docList {
font-family: var(--theme-font-body); font-family: var(--theme-font-body);
} }
/* Sélecteur avec le type d'élément (ul.../ol...), pas seulement les
classes/attributs : static/style.css (site-wide) porte une règle
`.content ol:not([type]) { list-style-type: decimal; }` d'une
spécificité légèrement supérieure (elle inclut le sélecteur d'élément
`ol`) qui l'emportait sinon silencieusement sur ce reset, même si
celui-ci charge après. */
ul.docList[data-kind="liste_puces"],
ol.docList[data-kind="liste_numerotee"] {
list-style: none;
}
.docList li { .docList li {
background: #faf7f2; background: #faf7f2;
border: 1px solid var(--theme-line); border: 1px solid var(--theme-line);
@@ -110,7 +128,6 @@
.docList[data-kind="liste_puces"] li::before { .docList[data-kind="liste_puces"] li::before {
content: ""; content: "";
flex-shrink: 0;
width: 16px; width: 16px;
height: 16px; height: 16px;
margin-top: 1px; margin-top: 1px;
@@ -126,10 +143,17 @@
.docList[data-kind="liste_numerotee"] li::before { .docList[data-kind="liste_numerotee"] li::before {
counter-increment: doc-theme-step; counter-increment: doc-theme-step;
content: counter(doc-theme-step); content: counter(doc-theme-step);
flex-shrink: 0;
width: 22px; width: 22px;
height: 22px; height: 22px;
display: flex;
/* inline-flex, jamais flex : ce badge doit rester un ::before qui
s'écoule EN LIGNE à côté du texte de l'élément (voir
static/document/document-editor.css, .docList li::before) — flex
(sans inline-) le blockifie, ce qui le repousse au-dessus du texte
plutôt qu'à côté (bug réel constaté le 26/09/2026). inline-flex
garde ce comportement en ligne tout en centrant quand même le
chiffre à l'intérieur du badge (align-items/justify-content). */
display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: 50%; border-radius: 50%;
+1
View File
@@ -277,6 +277,7 @@
hidden> hidden>
<button type="button" class="docBtnSecondary" id="docTemplateKeepContentBtn">Utiliser mon contenu actuel</button> <button type="button" class="docBtnSecondary" id="docTemplateKeepContentBtn">Utiliser mon contenu actuel</button>
<button type="button" class="docBtnSecondary docBtnPrimary" id="docTemplateUseContentBtn">Utiliser le contenu du modèle</button> <button type="button" class="docBtnSecondary docBtnPrimary" id="docTemplateUseContentBtn">Utiliser le contenu du modèle</button>
<button type="button" class="docBtnSecondary docBtnPrimary" id="docTemplateRemoveBtn" hidden>Retirer le modèle</button>
</div> </div>
</div> </div>
</div> </div>
+19 -1
View File
@@ -3,7 +3,7 @@ document_engine/rendering/box_style.py) — testés une seule fois ici,
indépendamment de chaque kind qui les utilise (voir aussi indépendamment de chaque kind qui les utilise (voir aussi
test_document_elements.py pour leur usage réel via _render_text).""" test_document_elements.py pour leur usage réel via _render_text)."""
from document_engine.rendering.box_style import BORDER_SIDES, default_border, render_box_style from document_engine.rendering.box_style import BORDER_SIDES, default_border, render_box_style, render_content_align
def test_default_border_has_all_four_sides_set_to_none() -> None: def test_default_border_has_all_four_sides_set_to_none() -> None:
@@ -76,3 +76,21 @@ def test_render_box_style_escapes_malicious_values() -> None:
border["top"] = {"style": "solid", "width": "1px", "color": '"><script>alert(1)</script>'} border["top"] = {"style": "solid", "width": "1px", "color": '"><script>alert(1)</script>'}
style = render_box_style({"padding": '"><script>alert(2)</script>', "border": border}) style = render_box_style({"padding": '"><script>alert(2)</script>', "border": border})
assert "<script>" not in style assert "<script>" not in style
def test_render_content_align_is_empty_for_default_top() -> None:
# Retour utilisateur du 26/09/2026 : "je peux augmenter la hauteur
# d'un conteneur mais pas l'alignement vertical à l'intérieur" —
# "top" est le comportement historique (contenu en haut), jamais un
# style ajouté pour ne rien changer par défaut.
assert render_content_align({}) == ""
assert render_content_align({"content_align": "top"}) == ""
def test_render_content_align_renders_center_and_bottom() -> None:
assert render_content_align({"content_align": "center"}) == "justify-content:center;"
assert render_content_align({"content_align": "bottom"}) == "justify-content:flex-end;"
def test_render_content_align_ignores_unknown_values() -> None:
assert render_content_align({"content_align": "n-importe-quoi"}) == ""
+239
View File
@@ -161,6 +161,18 @@ def test_titre_box_style_attributes_render_via_shared_box_style(tmp_support_slug
assert "opacity:0.9;" in html assert "opacity:0.9;" in html
def test_titre_content_align_renders_justify_content_when_set(tmp_support_slug_cleanup: Any) -> None:
# Retour utilisateur du 26/09/2026 : "je peux augmenter la hauteur
# d'un conteneur mais pas l'alignement vertical à l'intérieur".
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "titre", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"content": "X", "height": "300px", "content_align": "center"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "justify-content:center;" in html
def test_get_document_element_returns_none_for_missing_id(tmp_support_slug_cleanup: Any) -> None: def test_get_document_element_returns_none_for_missing_id(tmp_support_slug_cleanup: Any) -> None:
slug, _page_id = _new_support(tmp_support_slug_cleanup) slug, _page_id = _new_support(tmp_support_slug_cleanup)
assert doc_engine.get_document_element(slug, 999) is None assert doc_engine.get_document_element(slug, 999) is None
@@ -356,6 +368,211 @@ def test_liste_puces_with_no_items_renders_an_empty_list(tmp_support_slug_cleanu
assert html == f'<ul class="docList" data-element-id="{element_id}" data-kind="liste_puces"></ul>' assert html == f'<ul class="docList" data-element-id="{element_id}" data-kind="liste_puces"></ul>'
def test_liste_default_attributes_include_all_new_formatting_fields(tmp_support_slug_cleanup: Any) -> None:
"""Audit du 26/09/2026 (réglages manquants — colonne "Liste à puces/
numérotée") : chaque nouveau champ doit exister avec une valeur
"neutre" par défaut, sauf item_padding (retour utilisateur explicite :
"il faut un padding de base par élément de liste car y en a pas
aujourd'hui" — comportement historique inchangé sinon, voir
_render_list)."""
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
element = doc_engine.get_document_element(slug, element_id)
assert element is not None
attrs = element["attributes"]
assert attrs["bold"] is False
assert attrs["italic"] is False
assert attrs["underline"] is False
assert attrs["font_family"] == ""
assert attrs["font_size"] == ""
assert attrs["line_height"] == ""
assert attrs["text_color"] == ""
assert attrs["list_style_type"] == ""
assert attrs["list_style_position"] == "outside"
assert attrs["marker_color"] == ""
assert attrs["marker_size"] == ""
assert attrs["svg_markup"] == ""
assert attrs["item_padding"] == "6px"
assert attrs["item_spacing"] == ""
assert attrs["border"] == doc_engine_box_default_border()
html = doc_engine.render_document([element])
assert html == (
f'<ul class="docList" data-element-id="{element_id}" data-kind="liste_puces" '
f'style="--doc-item-padding:6px;">'
f"<li>Premier élément</li><li>Deuxième élément</li></ul>"
)
def test_liste_typography_attributes_render_when_set(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
doc_engine.update_document_element_attributes(
slug,
element_id,
{
"items": ["X"],
"bold": True,
"italic": True,
"underline": True,
"font_family": "Georgia, serif",
"font_size": "18px",
"line_height": "1.8",
"text_color": "#ff0000",
},
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "font-weight:700;" in html
assert "font-style:italic;" in html
assert "text-decoration:underline;" in html
assert "font-family:Georgia, serif;" in html
assert "font-size:18px;" in html
assert "line-height:1.8;" in html
assert "color:#ff0000;" in html
def test_liste_puces_marker_style_type_is_validated_per_kind(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["X"], "list_style_type": "square"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "list-style-type:square;" in html
# Une valeur propre aux listes NUMÉROTÉES est ignorée pour une liste à puces.
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["X"], "list_style_type": "decimal"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "list-style-type" not in html
def test_liste_numerotee_marker_style_type_is_validated_per_kind(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "liste_numerotee", page_id=page_id)
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["X"], "list_style_type": "upper-roman"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "list-style-type:upper-roman;" in html
# Une valeur propre aux listes à puces est ignorée pour une liste numérotée.
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["X"], "list_style_type": "square"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "list-style-type" not in html
def test_liste_style_position_inside_renders(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["X"], "list_style_position": "inside"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "list-style-position:inside;" in html
def test_liste_removes_reserved_indent_when_no_visible_outside_bullet(tmp_support_slug_cleanup: Any) -> None:
# Bug réel constaté le 26/09/2026 : "si j'enlève les puces ou que les
# puces se mettent à l'intérieur, il reste un espace devant la
# liste, cet espace doit être supprimé" — le padding-left:1.4em par
# défaut (document-editor.css, .docList) réserve la place d'une
# puce EXTÉRIEURE, qui n'a plus lieu d'être dans ces deux cas.
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["X"], "list_style_type": "none"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "padding-left:0;" in html
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["X"], "list_style_position": "inside"})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "padding-left:0;" in html
# Ni l'un ni l'autre réglé : la puce par défaut (disc) reste
# extérieure, la réservation d'espace reste nécessaire — jamais
# supprimée sans raison.
doc_engine.update_document_element_attributes(slug, element_id, {"items": ["X"]})
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "padding-left:0;" not in html
def test_liste_marker_color_and_size_render_as_custom_properties(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"items": ["X"], "marker_color": "#00ff00", "marker_size": "20px"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "--doc-marker-color:#00ff00;" in html
assert "--doc-marker-size:20px;" in html
def test_liste_puces_custom_bullet_image_renders_as_data_uri(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"items": ["X"], "svg_markup": "<svg><circle cx='1' cy='1' r='1'/></svg>"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert 'list-style-image:url("data:image/svg+xml,' in html
def test_liste_numerotee_ignores_custom_bullet_image(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "liste_numerotee", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"items": ["X"], "svg_markup": "<svg><circle cx='1' cy='1' r='1'/></svg>"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "list-style-image" not in html
def test_liste_item_padding_and_spacing_render_as_custom_properties(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"items": ["X"], "item_padding": "10px", "item_spacing": "12px"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "--doc-item-padding:10px;" in html
assert "--doc-item-spacing:12px;" in html
def test_liste_box_style_attributes_render_via_shared_box_style(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
border = doc_engine_box_default_border()
border["bottom"] = {"style": "dashed", "width": "3px", "color": "#123456"}
doc_engine.update_document_element_attributes(
slug,
element_id,
{
"items": ["X"],
"padding": "12px",
"margin": "5px",
"background_color": "#fafafa",
"border_radius": "4px",
"align_self": "center",
"border": border,
"box_shadow": "0 4px 12px rgba(0,0,0,.2)",
"opacity": "0.9",
},
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "padding:12px;" in html
assert "margin:5px;" in html
assert "background-color:#fafafa;" in html
assert "border-radius:4px;" in html
assert "align-self:center;" in html
assert "border-bottom:3px dashed #123456;" in html
assert "box-shadow:0 4px 12px rgba(0,0,0,.2);" in html
assert "opacity:0.9;" in html
def test_liste_content_align_renders_justify_content_when_set(tmp_support_slug_cleanup: Any) -> None:
slug, page_id = _new_support(tmp_support_slug_cleanup)
element_id = doc_engine.add_document_element(slug, "liste_puces", page_id=page_id)
doc_engine.update_document_element_attributes(
slug, element_id, {"items": ["X"], "height": "300px", "content_align": "bottom"}
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert "justify-content:flex-end;" in html
def test_badge_default_attributes_and_render(tmp_support_slug_cleanup: Any) -> None: def test_badge_default_attributes_and_render(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, "badge", page_id=page_id) element_id = doc_engine.add_document_element(slug, "badge", page_id=page_id)
@@ -671,6 +888,28 @@ def test_image_box_style_targets_the_figure_when_a_caption_is_present(tmp_suppor
assert html.count("align-self") == 1 assert html.count("align-self") == 1
def test_image_content_align_renders_on_the_figure_when_a_caption_is_present(tmp_support_slug_cleanup: Any) -> None:
# Retour utilisateur du 26/09/2026 : "je peux augmenter la hauteur
# d'un conteneur mais pas l'alignement vertical à l'intérieur" —
# n'a de sens QUE sur la figure (conteneur flex-colonne à plusieurs
# enfants réels : image + légende), jamais sur l'<img> seul.
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",
"height": "400px",
"content_align": "center",
},
)
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
assert '<figure class="docImageFigure" style="height:400px; justify-content:center;">' in html
def test_image_box_style_targets_the_link_wrapper_when_no_caption(tmp_support_slug_cleanup: Any) -> None: 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) slug, page_id = _new_support(tmp_support_slug_cleanup)
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)
+33
View File
@@ -548,6 +548,39 @@ def test_document_theme_apply_template_content_replaces_everything(client: Flask
assert len(pages) == len(theme["seed_pages"]) assert len(pages) == len(theme["seed_pages"])
def test_document_theme_apply_with_empty_theme_id_removes_the_theme(client: FlaskClient, support: str) -> None:
# Retour utilisateur du 26/09/2026 : la modale "Utiliser un modèle"
# propose une carte "Aucun modèle" pour "revenir à un document de
# base" — theme_id vide est le signal que la route interprète comme
# un retrait, jamais comme une erreur de validation.
page_id = _page_id(support)
client.post(f"/document/{support}/elements/add", data={"kind": "titre", "page_id": page_id})
client.post(
f"/document/{support}/theme/apply",
json={"theme_id": "securite-incendie", "mode": "keep_content"},
)
assert db.get_document_theme(support) == "securite-incendie"
resp = client.post(f"/document/{support}/theme/apply", json={"theme_id": ""})
assert resp.status_code == 200
assert resp.get_json() == {"ok": True, "theme_id": ""}
# Retire vraiment la ligne _meta (voir db/supports/remove_document_theme.py)
# — jamais une chaîne vide qui violerait le contrat documenté de
# get_document_theme ("None" = aucun thème jamais appliqué/retiré).
assert db.get_document_theme(support) is None
# Le contenu n'est JAMAIS touché en retirant le thème.
assert len(document_engine.list_document_elements(support, page_id)) == 1
edit_resp = client.get(f"/document/{support}/edit")
assert "document/themes/securite-incendie.css" not in edit_resp.get_data(as_text=True)
def test_document_theme_apply_with_empty_theme_id_ignores_mode(client: FlaskClient, support: str) -> None:
resp = client.post(f"/document/{support}/theme/apply", json={"theme_id": "", "mode": "n-importe-quoi"})
assert resp.status_code == 200
assert db.get_document_theme(support) is None
def test_document_theme_preview_renders_every_seed_page_with_navigation(client: FlaskClient, support: str) -> None: def test_document_theme_preview_renders_every_seed_page_with_navigation(client: FlaskClient, support: str) -> None:
resp = client.get(f"/document/{support}/theme/securite-incendie/preview") resp = client.get(f"/document/{support}/theme/securite-incendie/preview")
assert resp.status_code == 200 assert resp.status_code == 200
+22
View File
@@ -92,3 +92,25 @@ def test_set_document_theme_can_be_changed(tmp_support_slug_cleanup: Any) -> Non
db.set_document_theme(slug, "securite-incendie") db.set_document_theme(slug, "securite-incendie")
db.set_document_theme(slug, "autre-theme") db.set_document_theme(slug, "autre-theme")
assert db.get_document_theme(slug) == "autre-theme" assert db.get_document_theme(slug) == "autre-theme"
def test_remove_document_theme_resets_to_none(tmp_support_slug_cleanup: Any) -> None:
# Retour utilisateur du 26/09/2026 : la modale "Utiliser un modèle"
# propose une carte "Aucun modèle" pour "revenir à un document de
# base" — get_document_theme doit redevenir None, jamais une chaîne
# vide (contrat documenté dans db/supports/get_document_theme.py).
slug = db.create_support("Retire son thème", owner_folder="54")
tmp_support_slug_cleanup(slug)
db.set_document_theme(slug, "securite-incendie")
assert db.get_document_theme(slug) == "securite-incendie"
db.remove_document_theme(slug)
assert db.get_document_theme(slug) is None
assert db.support_meta(slug)["theme"] is None
def test_remove_document_theme_is_a_noop_when_none_was_ever_set(tmp_support_slug_cleanup: Any) -> None:
slug = db.create_support("Jamais de thème", owner_folder="55")
tmp_support_slug_cleanup(slug)
db.remove_document_theme(slug)
assert db.get_document_theme(slug) is None