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:
co-authored by
Claude Sonnet 5
parent
8f879f6321
commit
4db1269348
@@ -80,6 +80,7 @@ from .supports.create_support import create_support
|
||||
from .supports.delete_support import delete_support
|
||||
from .supports.get_document_theme import get_document_theme
|
||||
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.support_dir import support_dir
|
||||
from .supports.support_meta import support_meta
|
||||
@@ -122,6 +123,7 @@ __all__ = [
|
||||
"support_meta",
|
||||
"delete_support",
|
||||
"get_document_theme",
|
||||
"remove_document_theme",
|
||||
"set_document_theme",
|
||||
"ONBOARDING_TYPES",
|
||||
"DEFAULT_ONBOARDING_TYPE",
|
||||
|
||||
@@ -9,6 +9,7 @@ from .delete_support import delete_support
|
||||
from .ensure_document_pages_schema import ensure_document_pages_schema
|
||||
from .get_document_theme import get_document_theme
|
||||
from .list_supports import list_supports
|
||||
from .remove_document_theme import remove_document_theme
|
||||
from .set_document_theme import set_document_theme
|
||||
from .support_connection import connect_support, install_support_teardown_safety_net
|
||||
from .support_dir import support_dir
|
||||
@@ -25,6 +26,7 @@ __all__ = [
|
||||
"get_document_theme",
|
||||
"install_support_teardown_safety_net",
|
||||
"list_supports",
|
||||
"remove_document_theme",
|
||||
"set_document_theme",
|
||||
"split_slug",
|
||||
"support_dir",
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
`document-editor.js`), vérification manuelle uniquement — le mécanisme
|
||||
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.
|
||||
|
||||
@@ -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
|
||||
# rendu (voir render_document_element._render_list), jamais un
|
||||
# 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":
|
||||
# Même esprit que titre/paragraphe (bold/align/color... déjà des
|
||||
# attributs par élément, pas des choix figés par le moteur) :
|
||||
|
||||
@@ -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
|
||||
"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) :
|
||||
max_height/min_width/box_shadow/opacity/align_self/content_align/
|
||||
border` — sont toujours les mêmes, voir `rendering/box_style.py` :
|
||||
tous vides, `False` ou `"none"`/`"stretch"`/`"top"` par défaut =
|
||||
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/
|
||||
color/font_family/font_size/line_height/letter_spacing/
|
||||
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
|
||||
("before"/"after")/icon_size` (icône optionnelle, voir rendering.md)
|
||||
+ 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
|
||||
ou `False` par défaut = comportement historique inchangé, voir
|
||||
rendering.md), carte (`label/title/description`, contenu pur sans
|
||||
|
||||
@@ -35,8 +35,11 @@ BOX_DEFAULTS = {
|
||||
"min_width": "",
|
||||
"box_shadow": "",
|
||||
"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
|
||||
# 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)};")
|
||||
|
||||
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 json
|
||||
import random
|
||||
import urllib.parse
|
||||
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
|
||||
|
||||
|
||||
@@ -102,6 +103,9 @@ def _render_text(el: dict[str, Any], _children_by_parent: dict[int | None, list[
|
||||
box_style = render_box_style(a)
|
||||
if 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>'
|
||||
|
||||
@@ -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>'
|
||||
|
||||
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 = (
|
||||
f'<figure class="docImageFigure"{figure_style_attr}>{media}'
|
||||
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
|
||||
|
||||
|
||||
_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:
|
||||
"""Liste à puces (<ul>) ou numérotée (<ol>) — le kind lui-même décide
|
||||
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 à
|
||||
afficher (le créateur vient peut-être de tout supprimer avant d'en
|
||||
retaper un)."""
|
||||
items = el["attributes"].get("items", [])
|
||||
tag = "ol" if el["kind"] == "liste_numerotee" else "ul"
|
||||
a = el["attributes"]
|
||||
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)
|
||||
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")
|
||||
|
||||
@@ -113,6 +113,41 @@ regroupement à chaque appel.
|
||||
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 :
|
||||
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
|
||||
questions, total des points via `quiz_total_points`, minuteur si
|
||||
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":
|
||||
"", "align_self": "stretch", "width": "", "max_width": "", "height": "",
|
||||
"min_height": "", "max_height": "", "min_width": "", "box_shadow": "",
|
||||
"opacity": ""}` — `border` n'y figure PAS (voir `default_border()`, à
|
||||
ajouter séparément par chaque appelant pour éviter le partage par
|
||||
référence).
|
||||
"opacity": "", "content_align": "top"}` — `border` n'y figure PAS (voir
|
||||
`default_border()`, à ajouter séparément par chaque appelant pour éviter
|
||||
le partage par référence).
|
||||
|
||||
### `render_box_style(a: dict[str, Any]) -> str`
|
||||
Construit les déclarations CSS inline pour chaque attribut listé dans
|
||||
@@ -269,6 +304,26 @@ colonne).
|
||||
vide si rien à ajouter.
|
||||
- **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(markup: str) -> str`
|
||||
|
||||
@@ -263,6 +263,16 @@ body.objectEditBody > main.content {
|
||||
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 {
|
||||
border: 1px solid var(--doc-border);
|
||||
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
|
||||
vraiment pas sur une ligne. */
|
||||
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 {
|
||||
@@ -687,8 +707,48 @@ img.docImage {
|
||||
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 {
|
||||
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 {
|
||||
|
||||
@@ -1966,6 +1966,43 @@ const FORGE_DOC_FONT_FAMILY_OPTIONS = [
|
||||
['"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 :
|
||||
* "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;
|
||||
}
|
||||
|
||||
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 alignSelf = a.align_self || 'stretch';
|
||||
const opacityPercent = a.opacity !== '' && a.opacity !== undefined ? Math.round(Number.parseFloat(a.opacity) * 100) : '';
|
||||
return `
|
||||
${forgeDocPropsSectionHtml('Conteneur')}
|
||||
${forgeDocRenderToggleSliderHtml(`${idPrefix}Padding`, 'Espacement intérieur (padding)', a.padding, {
|
||||
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('')}
|
||||
</div>
|
||||
</div>
|
||||
${includeContentAlign ? forgeDocRenderContentAlignHtml(a, idPrefix) : ''}
|
||||
<div class="docField">
|
||||
<span class="docFieldLabel">Bordure — les 4 côtés à la fois</span>
|
||||
<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}Margin`, { unit: 'px' }, (v) => patch({ margin: v }));
|
||||
forgeDocBindToggleSlider(`${idPrefix}Radius`, { unit: 'px' }, (v) => patch({ border_radius: v }));
|
||||
@@ -2191,6 +2239,7 @@ function forgeDocRenderTextProps(panel, el) {
|
||||
const a = el.attributes;
|
||||
panel.innerHTML = `
|
||||
${forgeDocPropsHeader('texte')}
|
||||
${forgeDocPropsSectionHtml('Contenu')}
|
||||
<div class="docField">
|
||||
<span class="docFieldLabel">Style</span>
|
||||
<div class="docSegmented" id="docStyleSeg">
|
||||
@@ -2295,6 +2344,7 @@ function forgeDocRenderImageProps(panel, el) {
|
||||
const clickBehavior = a.click_behavior || '';
|
||||
panel.innerHTML = `
|
||||
${forgeDocPropsHeader('image')}
|
||||
${forgeDocPropsSectionHtml('Contenu')}
|
||||
<div class="docField">
|
||||
<span class="docFieldLabel">Fichier image</span>
|
||||
<div id="docImageUploadStatus">${a.src ? 'Image actuelle en place' : 'Aucun fichier choisi'}</div>
|
||||
@@ -2395,6 +2445,7 @@ function forgeDocRenderButtonProps(panel, el) {
|
||||
: 'Aucun fichier joint';
|
||||
panel.innerHTML = `
|
||||
${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="docButtonTarget">Cible (URL ou ancre)</label><input type="text" id="docButtonTarget" value="${forgeDocEscapeHtml(a.target || '')}"></div>
|
||||
<div class="docField">
|
||||
@@ -2441,7 +2492,7 @@ function forgeDocRenderButtonProps(panel, el) {
|
||||
${forgeDocRenderToggleSliderHtml('docButtonLetterSpacing', 'Espacement des lettres', a.letter_spacing, {
|
||||
min: -2, max: 10, step: 0.5, unit: 'px', fallback: 0,
|
||||
})}
|
||||
${forgeDocRenderBoxFieldsHtml(a, 'docButton')}
|
||||
${forgeDocRenderBoxFieldsHtml(a, 'docButton', { includeContentAlign: false })}
|
||||
${forgeDocDeleteButtonHtml()}
|
||||
`;
|
||||
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 }));
|
||||
forgeDocBindToggleSlider('docButtonFontSize', { unit: 'px' }, (v) => patch({ font_size: 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) {
|
||||
@@ -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) {
|
||||
// Même patron répéteur que l'Association/Memory (ajouter/supprimer/
|
||||
// modifier une ligne, panneau entièrement rechargé après chaque
|
||||
// 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 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) {
|
||||
forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }).then(() => {
|
||||
@@ -2548,9 +2615,63 @@ function forgeDocRenderListProps(panel, el) {
|
||||
|
||||
panel.innerHTML = `
|
||||
${forgeDocPropsHeader(kindLabel)}
|
||||
${forgeDocPropsSectionHtml('Contenu')}
|
||||
<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>
|
||||
<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()}
|
||||
`;
|
||||
|
||||
@@ -2571,6 +2692,28 @@ function forgeDocRenderListProps(panel, el) {
|
||||
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) {
|
||||
@@ -3635,16 +3778,26 @@ var forgeDocSelectedTemplateId = null;
|
||||
function forgeDocRenderTemplateList() {
|
||||
const list = document.getElementById('docTemplateList');
|
||||
const themes = window.FORGE_DOCUMENT.themes || [];
|
||||
if (!themes.length) {
|
||||
list.innerHTML = '<div class="docEmptyState">Aucun modèle disponible pour le moment.</div>';
|
||||
return;
|
||||
}
|
||||
list.innerHTML = themes.map((t) => `
|
||||
// "Aucun modèle" (retour utilisateur du 26/09/2026 : la modale doit
|
||||
// proposer de "revenir à un document de base") — une carte TOUJOURS
|
||||
// présente, même si le catalogue est vide, avec un id sentinelle vide
|
||||
// ("" est le themeId qu'attend /document/<slug>/theme/apply pour
|
||||
// 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}">
|
||||
<div class="docTemplateCardName">${forgeDocEscapeHtml(t.name)}</div>
|
||||
<div class="docTemplateCardCategory">${forgeDocEscapeHtml(t.category)}</div>
|
||||
</button>
|
||||
`).join('');
|
||||
list.innerHTML = noneCard + themeCards;
|
||||
list.querySelectorAll('.docTemplateCard').forEach((btn) => {
|
||||
btn.addEventListener('click', () => forgeDocSelectTemplate(btn.dataset.themeId));
|
||||
});
|
||||
@@ -3656,20 +3809,49 @@ function forgeDocSelectTemplate(themeId) {
|
||||
const frame = document.getElementById('docTemplatePreviewFrame');
|
||||
const empty = document.getElementById('docTemplatePreviewEmpty');
|
||||
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.classList.add('is-visible');
|
||||
empty.hidden = true;
|
||||
actions.hidden = false;
|
||||
}
|
||||
|
||||
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') {
|
||||
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.',
|
||||
);
|
||||
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`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -3699,6 +3881,7 @@ function forgeDocOpenTemplateModal() {
|
||||
frame.classList.remove('is-visible');
|
||||
frame.src = 'about:blank';
|
||||
empty.hidden = false;
|
||||
empty.textContent = 'Sélectionnez un modèle pour le visualiser.';
|
||||
actions.hidden = true;
|
||||
}
|
||||
|
||||
@@ -3715,6 +3898,7 @@ function forgeDocBindTemplateModal() {
|
||||
});
|
||||
document.getElementById('docTemplateKeepContentBtn').addEventListener('click', () => forgeDocApplyTemplate('keep_content'));
|
||||
document.getElementById('docTemplateUseContentBtn').addEventListener('click', () => forgeDocApplyTemplate('template_content'));
|
||||
document.getElementById('docTemplateRemoveBtn').addEventListener('click', () => forgeDocApplyTemplate('keep_content'));
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Escape') return;
|
||||
if (document.getElementById('docTemplateModal').classList.contains('is-open')) forgeDocCloseTemplateModal();
|
||||
|
||||
@@ -96,11 +96,29 @@
|
||||
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 {
|
||||
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 {
|
||||
background: #faf7f2;
|
||||
border: 1px solid var(--theme-line);
|
||||
@@ -110,7 +128,6 @@
|
||||
|
||||
.docList[data-kind="liste_puces"] li::before {
|
||||
content: "";
|
||||
flex-shrink: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 1px;
|
||||
@@ -126,10 +143,17 @@
|
||||
.docList[data-kind="liste_numerotee"] li::before {
|
||||
counter-increment: doc-theme-step;
|
||||
content: counter(doc-theme-step);
|
||||
flex-shrink: 0;
|
||||
width: 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;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
|
||||
@@ -277,6 +277,7 @@
|
||||
hidden>
|
||||
<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="docTemplateRemoveBtn" hidden>Retirer le modèle</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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
|
||||
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:
|
||||
@@ -76,3 +76,21 @@ def test_render_box_style_escapes_malicious_values() -> None:
|
||||
border["top"] = {"style": "solid", "width": "1px", "color": '"><script>alert(1)</script>'}
|
||||
style = render_box_style({"padding": '"><script>alert(2)</script>', "border": border})
|
||||
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"}) == ""
|
||||
|
||||
@@ -161,6 +161,18 @@ def test_titre_box_style_attributes_render_via_shared_box_style(tmp_support_slug
|
||||
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:
|
||||
slug, _page_id = _new_support(tmp_support_slug_cleanup)
|
||||
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>'
|
||||
|
||||
|
||||
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:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
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
|
||||
|
||||
|
||||
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:
|
||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||
element_id = doc_engine.add_document_element(slug, "image", page_id=page_id)
|
||||
|
||||
@@ -548,6 +548,39 @@ def test_document_theme_apply_template_content_replaces_everything(client: Flask
|
||||
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:
|
||||
resp = client.get(f"/document/{support}/theme/securite-incendie/preview")
|
||||
assert resp.status_code == 200
|
||||
|
||||
@@ -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, "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
|
||||
|
||||
Reference in New Issue
Block a user