Audit complet de mise en forme — Bouton (3e élément du tableau)
Implémente toutes les options manquantes identifiées pour l'élément Bouton : typographie (gras/italique/transformation/police/taille/ espacement des lettres/couleur), icône SVG optionnelle avec position (avant/après le texte) et taille réglables — au-delà de l'Étiquette, qui n'a qu'une icône fixe —, tous les attributs de boîte partagés déjà créés pour Titre/Paragraphe/Image, et un effet visuel universel au survol/clic. Deux ajouts transversaux demandés pendant le test : un réglage "les 4 côtés de la bordure à la fois" dans le module de boîte partagé (bénéficie automatiquement à tous les éléments qui l'utilisent), et le glisser-déposer d'un élément du canevas vers une autre page via le panneau Pages (le mécanisme serveur existait déjà pour la pagination automatique, seule l'interaction manuelle manquait). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
9ad50c58b8
commit
8f879f6321
@@ -241,7 +241,62 @@ Titre/Paragraphe, valeur douteuse pour du contenu de formation). Rien
|
|||||||
d'oublié constaté à cette relecture. Validé par l'utilisateur, prêt à
|
d'oublié constaté à cette relecture. Validé par l'utilisateur, prêt à
|
||||||
committer.
|
committer.
|
||||||
|
|
||||||
### 3. Bouton — à faire
|
### 3. Bouton — ✅ audité et validé (commité)
|
||||||
|
|
||||||
|
Implémenté : gras/italique, majuscules/minuscules/capitales, police
|
||||||
|
(liste déroulante web-safe, réutilise `FORGE_DOC_FONT_FAMILY_OPTIONS`
|
||||||
|
déjà créé pour Titre/Paragraphe), taille de police, espacement des
|
||||||
|
lettres, couleur du texte (sélecteur natif), icône SVG optionnelle
|
||||||
|
(position avant/après le texte, taille réglable — le bouton dépasse ici
|
||||||
|
l'étiquette, qui n'a toujours qu'une icône fixe sans position ni taille
|
||||||
|
réglables), tous les attributs de boîte partagés (padding, margin,
|
||||||
|
couleur de fond, arrondi, largeur/hauteur, ombre portée, opacité,
|
||||||
|
position du bloc, bordure par côté). États interactifs survol/actif :
|
||||||
|
effet visuel UNIVERSEL (assombrissement léger au survol, léger
|
||||||
|
tassement au clic), automatique pour tous les boutons sans réglage à
|
||||||
|
faire — jamais une couleur de survol personnalisable par bouton
|
||||||
|
(mécanisme CSS fragile pour une valeur ajoutée faible en contenu de
|
||||||
|
formation). Curseur (`cursor:pointer`) et transition au survol déjà en
|
||||||
|
place.
|
||||||
|
|
||||||
|
Mécanisme partagé réutilisé tel quel : `document_engine/rendering/
|
||||||
|
box_style.py` (comme Titre/Paragraphe/Image) + `sanitize_svg_markup`
|
||||||
|
(comme l'Image et l'Étiquette pour l'icône). Extraction d'une constante
|
||||||
|
JS partagée `FORGE_DOC_TEXT_TRANSFORM_OPTIONS` (utilisée par Titre/
|
||||||
|
Paragraphe ET Bouton, plus de duplication de ce tableau d'options).
|
||||||
|
|
||||||
|
Volontairement laissé de côté (même rationale que Titre/Paragraphe/
|
||||||
|
Image) : dégradé/image de fond, arrondi par coin (un seul rayon pour
|
||||||
|
les 4 coins), réglages responsive par taille d'écran. **État
|
||||||
|
"désactivé"** délibérément absent : un bouton de contenu de formation
|
||||||
|
n'est pas un vrai contrôle de formulaire avec un état programmatique
|
||||||
|
désactivé — aucun équivalent clair sans inventer un concept artificiel;
|
||||||
|
à ajouter si un besoin précis se présente.
|
||||||
|
|
||||||
|
**Audit final** : chaque ligne du tableau d'audit initial pour
|
||||||
|
l'élément Bouton est couverte — typo (police/taille/gras/italique/
|
||||||
|
transformation/espacement), boîte (padding/margin/largeur/hauteur/
|
||||||
|
position du bloc), bordure/ombre (style/couleur/épaisseur/par côté +
|
||||||
|
le nouveau réglage "les 4 côtés à la fois"/ombre portée), fond
|
||||||
|
(couleur), icône, effets (transition/curseur) — chacune implémentée ou
|
||||||
|
explicitement différée ci-dessus avec sa raison (dégradé/image de fond,
|
||||||
|
arrondi par coin, responsive, état désactivé). 9 tests dédiés passent
|
||||||
|
(224 au total dans `tests/document/`), ruff/mypy --strict/bandit/
|
||||||
|
vulture/import-linter/eslint/stylelint tous clean. Rien d'oublié
|
||||||
|
constaté à cette relecture. Validé par l'utilisateur, prêt à committer.
|
||||||
|
|
||||||
|
**Ajout transversal pendant le test (retour utilisateur : "pour les
|
||||||
|
bordures, il faudrait une option pour modifier les 4 bordures en même
|
||||||
|
temps")** : une rangée "Bordure — les 4 côtés à la fois" a été ajoutée
|
||||||
|
au-dessus du réglage par côté existant, dans le module PARTAGÉ
|
||||||
|
`forgeDocRenderBoxFieldsHtml`/`forgeDocBindBoxFields` (static/document/
|
||||||
|
js/document-editor.js) — un changement de style/épaisseur/couleur y
|
||||||
|
applique la MÊME valeur aux 4 côtés d'un coup (en plus, jamais à la
|
||||||
|
place, du réglage par côté qui reste utilisable après pour affiner).
|
||||||
|
Repart de l'état actuel si les 4 côtés portent déjà la même valeur,
|
||||||
|
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 — à faire
|
||||||
|
|
||||||
@@ -271,3 +326,26 @@ position de l'icône. Pourra réutiliser directement `box_style.py`.
|
|||||||
- Polices : liste fermée de polices web-safe (aucun chargement dynamique
|
- Polices : liste fermée de polices web-safe (aucun chargement dynamique
|
||||||
de Google Fonts depuis l'éditeur) — évite d'introduire un mécanisme de
|
de Google Fonts depuis l'éditeur) — évite d'introduire un mécanisme de
|
||||||
chargement de police, hors périmètre de cet audit.
|
chargement de police, hors périmètre de cet audit.
|
||||||
|
|
||||||
|
## Fonctionnalité hors tableau : glisser-déposer un élément vers une autre page
|
||||||
|
|
||||||
|
Retour utilisateur du 26/09/2026 : "j'aimerais pouvoir glisser déposer
|
||||||
|
un élément d'une page dans une autre page" — sans rapport avec l'audit
|
||||||
|
de mise en forme élément par élément, mais traité dans la foulée.
|
||||||
|
Le mécanisme bas niveau (`document_engine.move_document_element_to_page`
|
||||||
|
+ la route `/elements/<id>/move-to-page`) existait déjà, utilisé
|
||||||
|
uniquement par la pagination automatique (voir
|
||||||
|
`forgeDocCheckPageOverflow`) — déjà bien testé côté serveur
|
||||||
|
(`test_document_elements.py`/`test_document_routes.py`).
|
||||||
|
|
||||||
|
Ajout : glisser un élément du canevas jusqu'à une rangée du panneau
|
||||||
|
Pages (onglet "Pages" du panneau gauche) le déplace vers cette page —
|
||||||
|
même charge utile de glisser (`"text/forge-doc-move"`) que le
|
||||||
|
réordonnancement au sein d'une page, aucune modification de la source
|
||||||
|
de glisser côté canevas. Nouvelle fonction `forgeDocMoveElementToPage`
|
||||||
|
(commande Annuler/Rétablir complète, comme le reste des mutations).
|
||||||
|
Surlignage visuel de la rangée survolée (`.docPageRow--dropTarget`),
|
||||||
|
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.
|
||||||
|
|||||||
@@ -131,7 +131,35 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
|
|||||||
# qui reste réservé à la navigation (URL/ancre). Jamais les deux
|
# qui reste réservé à la navigation (URL/ancre). Jamais les deux
|
||||||
# à la fois côté UI (voir document-editor.js), mais rien ne
|
# à la fois côté UI (voir document-editor.js), mais rien ne
|
||||||
# l'empêche structurellement ici.
|
# l'empêche structurellement ici.
|
||||||
return {"label": "Bouton", "target": "", "attachment_stored_name": "", "attachment_filename": ""}
|
#
|
||||||
|
# Audit du 26/09/2026 (réglages manquants — colonne "Bouton") :
|
||||||
|
# bold/italic/text_transform/font_family/font_size/letter_spacing/
|
||||||
|
# text_color (typo, jamais gérés par box_style.py) + svg_markup/
|
||||||
|
# icon_position/icon_size (icône avant/après le texte, absente
|
||||||
|
# même de l'étiquette qui n'a qu'un SVG fixe sans position ni
|
||||||
|
# taille réglables) + les attributs de boîte partagés. bold=False
|
||||||
|
# PAR DÉFAUT (le CSS de base garde son font-weight:700 tel quel
|
||||||
|
# tant que "bold" n'est pas explicitement activé — voir
|
||||||
|
# _render_button, qui ne pousse à 800 QUE si bold=True — aucune
|
||||||
|
# régression visuelle sur les boutons déjà créés).
|
||||||
|
return {
|
||||||
|
"label": "Bouton",
|
||||||
|
"target": "",
|
||||||
|
"attachment_stored_name": "",
|
||||||
|
"attachment_filename": "",
|
||||||
|
"bold": False,
|
||||||
|
"italic": False,
|
||||||
|
"text_transform": "none",
|
||||||
|
"font_family": "",
|
||||||
|
"font_size": "",
|
||||||
|
"letter_spacing": "",
|
||||||
|
"text_color": "",
|
||||||
|
"svg_markup": "",
|
||||||
|
"icon_position": "before",
|
||||||
|
"icon_size": "",
|
||||||
|
**BOX_DEFAULTS,
|
||||||
|
"border": default_border(),
|
||||||
|
}
|
||||||
if kind in ("liste_puces", "liste_numerotee"):
|
if kind in ("liste_puces", "liste_numerotee"):
|
||||||
# Une seule et même structure d'attributs pour les deux kinds —
|
# Une seule et même structure d'attributs pour les deux kinds —
|
||||||
# "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
|
||||||
|
|||||||
@@ -65,7 +65,11 @@ Attributs posés à la création d'un élément de ce type (voir
|
|||||||
`link_url` + `lazy_load` + `caption` : comportement/contenu, pas du
|
`link_url` + `lazy_load` + `caption` : comportement/contenu, pas du
|
||||||
style + les attributs de boîte partagés), bouton (`label/target/
|
style + les attributs de boîte partagés), bouton (`label/target/
|
||||||
attachment_stored_name/attachment_filename` — la pièce jointe est
|
attachment_stored_name/attachment_filename` — la pièce jointe est
|
||||||
indépendante de `target`, réservé à la navigation), liste à
|
indépendante de `target`, réservé à la navigation — + `bold/italic/
|
||||||
|
text_transform/font_family/font_size/letter_spacing/text_color`
|
||||||
|
(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), 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
|
||||||
|
|||||||
@@ -222,6 +222,51 @@ def _render_list(el: dict[str, Any], _children_by_parent: dict[int | None, list[
|
|||||||
return f'<{tag} class="docList" data-element-id="{el["id"]}" data-kind="{el["kind"]}">{items_html}</{tag}>'
|
return f'<{tag} class="docList" data-element-id="{el["id"]}" data-kind="{el["kind"]}">{items_html}</{tag}>'
|
||||||
|
|
||||||
|
|
||||||
|
_BUTTON_TEXT_TRANSFORMS = ("uppercase", "lowercase", "capitalize")
|
||||||
|
|
||||||
|
|
||||||
|
def _button_text_style(a: dict[str, Any]) -> list[str]:
|
||||||
|
"""Déclarations de typographie propres au bouton (jamais dans
|
||||||
|
box_style.py, partagé avec d'autres kinds qui n'ont pas tous une
|
||||||
|
notion de texte)."""
|
||||||
|
parts = []
|
||||||
|
if a.get("bold"):
|
||||||
|
# 800 (jamais 700, déjà le poids par défaut du CSS de base) :
|
||||||
|
# "gras" ne fait que RENFORCER le poids existant, jamais
|
||||||
|
# l'affaiblir — aucun bouton déjà créé ne change d'apparence tant
|
||||||
|
# que cette case n'est pas cochée explicitement.
|
||||||
|
parts.append("font-weight:800;")
|
||||||
|
if a.get("italic"):
|
||||||
|
parts.append("font-style:italic;")
|
||||||
|
text_transform = str(a.get("text_transform", "none"))
|
||||||
|
if text_transform in _BUTTON_TEXT_TRANSFORMS:
|
||||||
|
parts.append(f"text-transform:{text_transform};")
|
||||||
|
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)};")
|
||||||
|
letter_spacing = str(a.get("letter_spacing", "")).strip()
|
||||||
|
if letter_spacing:
|
||||||
|
parts.append(f"letter-spacing:{html_lib.escape(letter_spacing)};")
|
||||||
|
text_color = str(a.get("text_color", "")).strip()
|
||||||
|
if text_color:
|
||||||
|
parts.append(f"color:{html_lib.escape(text_color)};")
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _button_icon_html(a: dict[str, Any]) -> str:
|
||||||
|
svg_markup = str(a.get("svg_markup", "")).strip()
|
||||||
|
if not svg_markup:
|
||||||
|
return ""
|
||||||
|
icon_size = str(a.get("icon_size", "")).strip()
|
||||||
|
size_style = (
|
||||||
|
f' style="width:{html_lib.escape(icon_size)}; height:{html_lib.escape(icon_size)};"' if icon_size else ""
|
||||||
|
)
|
||||||
|
return f'<span class="docButtonIcon"{size_style}>{sanitize_svg_markup(svg_markup)}</span>'
|
||||||
|
|
||||||
|
|
||||||
def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, list[dict[str, Any]]]) -> str:
|
||||||
a = el["attributes"]
|
a = el["attributes"]
|
||||||
label = html_lib.escape(str(a.get("label", "Bouton")))
|
label = html_lib.escape(str(a.get("label", "Bouton")))
|
||||||
@@ -236,9 +281,19 @@ def _render_button(el: dict[str, Any], _children_by_parent: dict[int | None, lis
|
|||||||
# que le reste des appels AJAX de l'éditeur.
|
# que le reste des appels AJAX de l'éditeur.
|
||||||
attachment_filename = html_lib.escape(str(a.get("attachment_filename", "")))
|
attachment_filename = html_lib.escape(str(a.get("attachment_filename", "")))
|
||||||
attachment_attr = f' data-attachment-filename="{attachment_filename}"' if attachment_filename else ""
|
attachment_attr = f' data-attachment-filename="{attachment_filename}"' if attachment_filename else ""
|
||||||
|
|
||||||
|
style = " ".join(_button_text_style(a) + [render_box_style(a)]).strip()
|
||||||
|
style_attr = f' style="{style}"' if style else ""
|
||||||
|
|
||||||
|
icon_html = _button_icon_html(a)
|
||||||
|
label_span = f'<span class="docButtonLabel">{label}</span>'
|
||||||
|
inner = (
|
||||||
|
f"{label_span}{icon_html}" if str(a.get("icon_position", "before")) == "after" else f"{icon_html}{label_span}"
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
f'<button type="button" class="docButton" data-element-id="{el["id"]}" '
|
f'<button type="button" class="docButton" data-element-id="{el["id"]}" '
|
||||||
f'data-kind="bouton"{target_attr}{attachment_attr}>{label}</button>'
|
f'data-kind="bouton"{target_attr}{attachment_attr}{style_attr}>{inner}</button>'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -67,14 +67,31 @@ regroupement à chaque appel.
|
|||||||
forgeDocOpenImageLightbox), même principe que les mini-jeux et la
|
forgeDocOpenImageLightbox), même principe que les mini-jeux et la
|
||||||
pièce jointe d'un bouton. `caption` (non vide) enveloppe le tout dans
|
pièce jointe d'un bouton. `caption` (non vide) enveloppe le tout dans
|
||||||
un `<figure><figcaption>` échappée.
|
un `<figure><figcaption>` échappée.
|
||||||
- **Bouton** : `<button>` avec son `label`, un `data-target` optionnel
|
- **Bouton** : `<button>` avec son `label` (enveloppé dans
|
||||||
|
`<span class="docButtonLabel">`), un `data-target` optionnel
|
||||||
(navigation) et un `data-attachment-filename` optionnel — marqueur
|
(navigation) et un `data-attachment-filename` optionnel — marqueur
|
||||||
mécanique posé quand un fichier a été joint (voir
|
mécanique posé quand un fichier a été joint (voir
|
||||||
`routes/document/document_element_upload_attachment.py`), jamais
|
`routes/document/document_element_upload_attachment.py`), jamais
|
||||||
l'URL de téléchargement elle-même (ce renderer ne connaît pas le slug
|
l'URL de téléchargement elle-même (ce renderer ne connaît pas le slug
|
||||||
du support ; `static/document/js/document-editor.js` l'assemble à
|
du support ; `static/document/js/document-editor.js` l'assemble à
|
||||||
partir de `data-element-id` + `FORGE_DOCUMENT.slug`, même principe que
|
partir de `data-element-id` + `FORGE_DOCUMENT.slug`, même principe que
|
||||||
le reste des appels AJAX de l'éditeur).
|
le reste des appels AJAX de l'éditeur). Style inline (audit du
|
||||||
|
26/09/2026, réglages manquants — colonne "Bouton") : `bold` pousse
|
||||||
|
`font-weight` à `800` (jamais en dessous du `700` déjà posé par le CSS
|
||||||
|
de base — "gras" ne fait que renforcer, jamais affaiblir, aucune
|
||||||
|
régression visuelle sur les boutons déjà créés), `italic`/
|
||||||
|
`text_transform` (`"uppercase"`/`"lowercase"`/`"capitalize"`, jamais
|
||||||
|
`"none"`)/`font_family`/`font_size`/`letter_spacing`/`text_color`
|
||||||
|
(vides par défaut) + les attributs de boîte partagés
|
||||||
|
(`render_box_style`, voir `box_style.py`). `svg_markup` (optionnel,
|
||||||
|
nettoyé par `sanitize_svg_markup`) ajoute un `<span
|
||||||
|
class="docButtonIcon">` avant OU après `.docButtonLabel` selon
|
||||||
|
`icon_position` (`"before"` par défaut), dimensionné par `icon_size`
|
||||||
|
(vide = `1em`, suit la taille du texte). États interactifs
|
||||||
|
survol/actif : effet CSS universel (`filter`/`transform`, voir
|
||||||
|
`static/document/document-editor.css`), jamais configurable par
|
||||||
|
attribut — pas de notion d'état "désactivé" pour un bouton de contenu
|
||||||
|
(ce n'est pas un vrai contrôle de formulaire).
|
||||||
- **Étiquette** (`badge`) : `<div>` portant `attributes["content"]`
|
- **Étiquette** (`badge`) : `<div>` portant `attributes["content"]`
|
||||||
échappé, précédé d'un `<span class="docBadgeIcon">` optionnel si
|
échappé, précédé d'un `<span class="docBadgeIcon">` optionnel si
|
||||||
`svg_markup` est non vide (nettoyé par `sanitize_svg_markup`, même
|
`svg_markup` est non vide (nettoyé par `sanitize_svg_markup`, même
|
||||||
|
|||||||
@@ -626,7 +626,16 @@ img.docImage {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* display:inline-flex + gap (au lieu du bloc simple d'origine) : accueille
|
||||||
|
l'icône optionnelle avant/après le texte (retour utilisateur du
|
||||||
|
26/09/2026, audit — colonne "Icône" : "aucune icône à côté du texte du
|
||||||
|
bouton, contrairement au badge") sans avoir à gérer d'espacement en
|
||||||
|
dur — sans icône, .docButtonLabel reste seul enfant, gap n'a alors
|
||||||
|
aucun effet visible (rien à espacer). */
|
||||||
.docButton {
|
.docButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 10px 22px;
|
padding: 10px 22px;
|
||||||
@@ -635,6 +644,39 @@ img.docImage {
|
|||||||
background: var(--doc-accent);
|
background: var(--doc-accent);
|
||||||
color: #14100c;
|
color: #14100c;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
transition: filter 0.15s ease, transform 0.08s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* États interactifs (retour utilisateur du 26/09/2026, audit — colonne
|
||||||
|
"États interactifs" : "aucune notion d'état n'existe") — un effet
|
||||||
|
UNIVERSEL (jamais configurable par bouton, volontairement : une
|
||||||
|
couleur de survol par instance aurait exigé une propriété CSS
|
||||||
|
personnalisée par bouton, fragile et sans grande valeur ajoutée pour
|
||||||
|
du contenu de formation) suffit à donner un vrai retour visuel
|
||||||
|
survol/actif à TOUS les boutons, y compris ceux déjà créés. "Désactivé"
|
||||||
|
volontairement laissé de côté : un bouton de contenu n'est pas un
|
||||||
|
vrai contrôle de formulaire, aucun état "désactivé" n'a de sens ici
|
||||||
|
(voir docs/plan/AUDIT_MISE_EN_FORME.md). */
|
||||||
|
.docButton:hover {
|
||||||
|
filter: brightness(0.92);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docButton:active {
|
||||||
|
transform: scale(0.97);
|
||||||
|
}
|
||||||
|
|
||||||
|
.docButtonIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 1em;
|
||||||
|
height: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docButtonIcon svg {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.docList {
|
.docList {
|
||||||
@@ -1510,6 +1552,15 @@ img.docImage {
|
|||||||
box-shadow: 0 0 0 1px var(--doc-accent);
|
box-shadow: 0 0 0 1px var(--doc-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cible de dépôt d'un élément du canevas glissé depuis une AUTRE page
|
||||||
|
(retour utilisateur du 26/09/2026), distincte de .is-dragging
|
||||||
|
(glisser une PAGE) et de .is-active (page actuellement affichée). */
|
||||||
|
.docPageRow--dropTarget {
|
||||||
|
border-color: var(--doc-accent);
|
||||||
|
border-style: dashed;
|
||||||
|
box-shadow: 0 0 0 2px var(--doc-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.docPageRow:focus-visible {
|
.docPageRow:focus-visible {
|
||||||
outline: 2px solid var(--doc-accent);
|
outline: 2px solid var(--doc-accent);
|
||||||
outline-offset: 1px;
|
outline-offset: 1px;
|
||||||
|
|||||||
@@ -347,6 +347,37 @@ function forgeDocDeleteElement(id) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function forgeDocMoveElementToPage(elementId, targetPageId) {
|
||||||
|
// Retour utilisateur du 26/09/2026 : "j'aimerais pouvoir glisser
|
||||||
|
// déposer un élément d'une page dans une autre page" — déposé depuis
|
||||||
|
// le canevas sur une rangée du panneau Pages (voir
|
||||||
|
// forgeDocRenderPageManagerList). move_document_element_to_page
|
||||||
|
// (document_engine/elements/) redevient TOUJOURS top-niveau sur la
|
||||||
|
// page cible (une rangée d'origine n'a aucun sens comme enfant d'une
|
||||||
|
// rangée de la page cible) — Annuler restaure donc explicitement
|
||||||
|
// l'ancien parent/position via forgeDocApiMove, une fois l'élément
|
||||||
|
// revenu sur son ancienne page.
|
||||||
|
const el = window.forgeDocState.elementsById[elementId];
|
||||||
|
if (!el || targetPageId === window.forgeDocState.activePageId) return Promise.resolve();
|
||||||
|
const state = {
|
||||||
|
id: elementId,
|
||||||
|
fromPageId: window.forgeDocState.activePageId,
|
||||||
|
toPageId: targetPageId,
|
||||||
|
parentId: el.parent_id,
|
||||||
|
index: el.order_index,
|
||||||
|
};
|
||||||
|
function performForward() { return forgeDocApiMoveToPage(state.id, state.toPageId); }
|
||||||
|
function performUndo() {
|
||||||
|
return forgeDocApiMoveToPage(state.id, state.fromPageId)
|
||||||
|
.then(() => forgeDocApiMove(state.id, state.parentId, state.index));
|
||||||
|
}
|
||||||
|
return performForward().then(() => {
|
||||||
|
forgeDocPushCommand({ undo: performUndo, redo: performForward });
|
||||||
|
window.forgeDocState.selectedId = null;
|
||||||
|
return forgeDocRefreshCanvas();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function forgeDocUpdateAttributes(id, newAttributes) {
|
function forgeDocUpdateAttributes(id, newAttributes) {
|
||||||
const el = window.forgeDocState.elementsById[id];
|
const el = window.forgeDocState.elementsById[id];
|
||||||
if (!el) return Promise.resolve();
|
if (!el) return Promise.resolve();
|
||||||
@@ -851,6 +882,30 @@ function forgeDocRenderPageManagerList() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
forgeDocSwitchPage(Number(row.dataset.pageId));
|
forgeDocSwitchPage(Number(row.dataset.pageId));
|
||||||
});
|
});
|
||||||
|
// Glisser un élément du canevas jusqu'ici pour le déplacer vers CETTE
|
||||||
|
// page (retour utilisateur du 26/09/2026 : "j'aimerais pouvoir
|
||||||
|
// glisser déposer un élément d'une page dans une autre page") — même
|
||||||
|
// charge utile ("text/forge-doc-move") que le réordonnancement d'un
|
||||||
|
// élément AU SEIN d'une page (voir forgeDocBindCanvasDropZone), donc
|
||||||
|
// aucune modification de la source de glisser côté canevas. Sans
|
||||||
|
// rapport avec le glisser-déposer des PAGES elles-mêmes (délégué au
|
||||||
|
// conteneur, voir forgeDocBindPageManagerDrag juste plus bas) : les
|
||||||
|
// deux coexistent sur la même rangée sans conflit, chacun ignorant
|
||||||
|
// le type de charge utile qui ne le concerne pas.
|
||||||
|
row.addEventListener('dragover', (e) => {
|
||||||
|
if (!e.dataTransfer.types.includes('text/forge-doc-move')) return;
|
||||||
|
e.preventDefault();
|
||||||
|
row.classList.add('docPageRow--dropTarget');
|
||||||
|
});
|
||||||
|
row.addEventListener('dragleave', () => row.classList.remove('docPageRow--dropTarget'));
|
||||||
|
row.addEventListener('drop', (e) => {
|
||||||
|
const moveId = e.dataTransfer.getData('text/forge-doc-move');
|
||||||
|
row.classList.remove('docPageRow--dropTarget');
|
||||||
|
if (!moveId) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
forgeDocMoveElementToPage(Number.parseInt(moveId, 10), Number(row.dataset.pageId));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
container.querySelectorAll('.docPageRowRename').forEach((btn) => {
|
container.querySelectorAll('.docPageRowRename').forEach((btn) => {
|
||||||
btn.addEventListener('click', (e) => {
|
btn.addEventListener('click', (e) => {
|
||||||
@@ -2018,6 +2073,34 @@ 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>
|
||||||
|
<div class="docField">
|
||||||
|
<span class="docFieldLabel">Bordure — les 4 côtés à la fois</span>
|
||||||
|
<div class="docBorderSideRow" id="${idPrefix}BorderAllRow">
|
||||||
|
${(() => {
|
||||||
|
// Retour utilisateur du 26/09/2026 : "une option pour modifier les
|
||||||
|
// 4 bordures en même temps" — un raccourci qui applique la MÊME
|
||||||
|
// valeur aux 4 côtés d'un coup, en plus (jamais à la place) du
|
||||||
|
// réglage par côté juste en dessous. Si les 4 côtés portent déjà
|
||||||
|
// exactement la même valeur, cette rangée en repart (plutôt que
|
||||||
|
// d'afficher un état neutre déconnecté de la réalité) ; sinon elle
|
||||||
|
// repart d'un état neutre ("Aucune"/1px/couleur par défaut), un
|
||||||
|
// signe que les côtés diffèrent actuellement.
|
||||||
|
const sides = FORGE_DOC_BORDER_SIDES.map(([side]) => border[side] || {});
|
||||||
|
const allSame = sides.every((s) => (
|
||||||
|
s.style === sides[0].style && s.width === sides[0].width && s.color === sides[0].color
|
||||||
|
));
|
||||||
|
const common = allSame ? sides[0] : {};
|
||||||
|
return `
|
||||||
|
<span class="docBorderSideLabel">Tous</span>
|
||||||
|
<select class="docBorderStyleSelect" id="${idPrefix}BorderAllStyle">
|
||||||
|
${FORGE_DOC_BORDER_STYLES.map(([v, l]) => `<option value="${v}" ${(common.style || 'none') === v ? 'selected' : ''}>${l}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<input type="range" class="docBorderWidthRange" id="${idPrefix}BorderAllWidth" min="1" max="10" step="1" value="${Number.parseFloat(common.width) || 1}" title="Épaisseur">
|
||||||
|
<input type="color" class="docBorderColorInput" id="${idPrefix}BorderAllColor" value="${forgeDocToHexColor(common.color, '#c9c4b8')}" title="Couleur">
|
||||||
|
`;
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="docField">
|
<div class="docField">
|
||||||
<span class="docFieldLabel">Bordure (par côté)</span>
|
<span class="docFieldLabel">Bordure (par côté)</span>
|
||||||
<div class="docBorderEditor" id="${idPrefix}BorderEditor">
|
<div class="docBorderEditor" id="${idPrefix}BorderEditor">
|
||||||
@@ -2064,6 +2147,21 @@ function forgeDocBindBoxFields(panel, el, idPrefix, patch) {
|
|||||||
btn.addEventListener('click', () => patch({ align_self: btn.dataset.alignSelf }));
|
btn.addEventListener('click', () => patch({ align_self: btn.dataset.alignSelf }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function applyBorderToAllSides() {
|
||||||
|
const style = document.getElementById(`${idPrefix}BorderAllStyle`).value;
|
||||||
|
const width = `${document.getElementById(`${idPrefix}BorderAllWidth`).value}px`;
|
||||||
|
const color = document.getElementById(`${idPrefix}BorderAllColor`).value;
|
||||||
|
const sideValue = { style, width, color };
|
||||||
|
patch({
|
||||||
|
border: {
|
||||||
|
top: sideValue, right: sideValue, bottom: sideValue, left: sideValue,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
document.getElementById(`${idPrefix}BorderAllStyle`).addEventListener('change', applyBorderToAllSides);
|
||||||
|
document.getElementById(`${idPrefix}BorderAllWidth`).addEventListener('change', applyBorderToAllSides);
|
||||||
|
document.getElementById(`${idPrefix}BorderAllColor`).addEventListener('change', applyBorderToAllSides);
|
||||||
|
|
||||||
function patchBorderSide(side, partial) {
|
function patchBorderSide(side, partial) {
|
||||||
// Toujours relu depuis el.attributes AU MOMENT de l'appel (jamais un
|
// Toujours relu depuis el.attributes AU MOMENT de l'appel (jamais un
|
||||||
// instantané capturé au moment du binding) : el.attributes est
|
// instantané capturé au moment du binding) : el.attributes est
|
||||||
@@ -2085,6 +2183,10 @@ function forgeDocBindBoxFields(panel, el, idPrefix, patch) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORGE_DOC_TEXT_TRANSFORM_OPTIONS = [
|
||||||
|
['none', 'Normal'], ['uppercase', 'MAJUSCULES'], ['lowercase', 'minuscules'], ['capitalize', 'Capitales'],
|
||||||
|
];
|
||||||
|
|
||||||
function forgeDocRenderTextProps(panel, el) {
|
function forgeDocRenderTextProps(panel, el) {
|
||||||
const a = el.attributes;
|
const a = el.attributes;
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
@@ -2116,7 +2218,7 @@ function forgeDocRenderTextProps(panel, el) {
|
|||||||
<div class="docField">
|
<div class="docField">
|
||||||
<span class="docFieldLabel">Majuscules / minuscules</span>
|
<span class="docFieldLabel">Majuscules / minuscules</span>
|
||||||
<div class="docSegmented" id="docTextTransformSeg">
|
<div class="docSegmented" id="docTextTransformSeg">
|
||||||
${[['none', 'Normal'], ['uppercase', 'MAJUSCULES'], ['lowercase', 'minuscules'], ['capitalize', 'Capitales']].map(([v, l]) => `<button type="button" class="docSegBtn ${(a.text_transform || 'none') === v ? 'is-active' : ''}" data-transform="${v}">${l}</button>`).join('')}
|
${FORGE_DOC_TEXT_TRANSFORM_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.text_transform || 'none') === v ? 'is-active' : ''}" data-transform="${v}">${l}</button>`).join('')}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="docField">
|
<div class="docField">
|
||||||
@@ -2282,6 +2384,10 @@ function forgeDocRenderImageProps(panel, el) {
|
|||||||
forgeDocBindBoxFields(panel, el, 'docImage', patch);
|
forgeDocBindBoxFields(panel, el, 'docImage', patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const FORGE_DOC_ICON_POSITION_OPTIONS = [
|
||||||
|
['before', 'Avant le texte'], ['after', 'Après le texte'],
|
||||||
|
];
|
||||||
|
|
||||||
function forgeDocRenderButtonProps(panel, el) {
|
function forgeDocRenderButtonProps(panel, el) {
|
||||||
const a = el.attributes;
|
const a = el.attributes;
|
||||||
const attachmentStatus = a.attachment_filename
|
const attachmentStatus = a.attachment_filename
|
||||||
@@ -2289,13 +2395,53 @@ function forgeDocRenderButtonProps(panel, el) {
|
|||||||
: 'Aucun fichier joint';
|
: 'Aucun fichier joint';
|
||||||
panel.innerHTML = `
|
panel.innerHTML = `
|
||||||
${forgeDocPropsHeader('bouton')}
|
${forgeDocPropsHeader('bouton')}
|
||||||
<div class="docField"><label for="docButtonLabel">Texte du bouton</label><input type="text" id="docButtonLabel" value="${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="${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">
|
||||||
<span class="docFieldLabel">Fichier téléchargeable</span>
|
<span class="docFieldLabel">Fichier téléchargeable</span>
|
||||||
<div id="docButtonAttachmentStatus">${attachmentStatus}</div>
|
<div id="docButtonAttachmentStatus">${attachmentStatus}</div>
|
||||||
<input type="file" id="docButtonAttachmentInput">
|
<input type="file" id="docButtonAttachmentInput">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="docField"><label for="docButtonSvg">Icône SVG (optionnel)</label><textarea id="docButtonSvg" rows="3">${forgeDocEscapeHtml(a.svg_markup || '')}</textarea></div>
|
||||||
|
<div class="docField">
|
||||||
|
<span class="docFieldLabel">Position de l'icône</span>
|
||||||
|
<div class="docSegmented" id="docButtonIconPosSeg">
|
||||||
|
${FORGE_DOC_ICON_POSITION_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.icon_position || 'before') === v ? 'is-active' : ''}" data-pos="${v}">${l}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
${forgeDocRenderToggleSliderHtml('docButtonIconSize', "Taille de l'icône", a.icon_size, {
|
||||||
|
min: 10, max: 48, step: 1, unit: 'px', fallback: 16,
|
||||||
|
})}
|
||||||
|
<div class="docField">
|
||||||
|
<span class="docFieldLabel">Mise en forme</span>
|
||||||
|
<div class="docToolbarRow">
|
||||||
|
<button type="button" class="docFmtBtn ${a.bold ? 'is-active' : ''}" id="docButtonBoldBtn" title="Gras">B</button>
|
||||||
|
<button type="button" class="docFmtBtn docFmtBtn--italic ${a.italic ? 'is-active' : ''}" id="docButtonItalicBtn" title="Italique">I</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docField">
|
||||||
|
<span class="docFieldLabel">Majuscules / minuscules</span>
|
||||||
|
<div class="docSegmented" id="docButtonTextTransformSeg">
|
||||||
|
${FORGE_DOC_TEXT_TRANSFORM_OPTIONS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.text_transform || 'none') === v ? 'is-active' : ''}" data-transform="${v}">${l}</button>`).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="docField">
|
||||||
|
<label for="docButtonColorInput">Couleur du texte</label>
|
||||||
|
<input type="color" id="docButtonColorInput" value="${forgeDocToHexColor(a.text_color, '#14100c')}">
|
||||||
|
</div>
|
||||||
|
<div class="docField">
|
||||||
|
<label for="docButtonFontFamilyInput">Police de caractère</label>
|
||||||
|
<select id="docButtonFontFamilyInput">
|
||||||
|
${FORGE_DOC_FONT_FAMILY_OPTIONS.map(([v, l]) => `<option value="${forgeDocEscapeHtml(v)}" ${(a.font_family || '') === v ? 'selected' : ''}>${l}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
${forgeDocRenderToggleSliderHtml('docButtonFontSize', 'Taille de police', a.font_size, {
|
||||||
|
min: 10, max: 32, step: 1, unit: 'px', fallback: 14,
|
||||||
|
})}
|
||||||
|
${forgeDocRenderToggleSliderHtml('docButtonLetterSpacing', 'Espacement des lettres', a.letter_spacing, {
|
||||||
|
min: -2, max: 10, step: 0.5, unit: 'px', fallback: 0,
|
||||||
|
})}
|
||||||
|
${forgeDocRenderBoxFieldsHtml(a, 'docButton')}
|
||||||
${forgeDocDeleteButtonHtml()}
|
${forgeDocDeleteButtonHtml()}
|
||||||
`;
|
`;
|
||||||
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
|
||||||
@@ -2318,6 +2464,21 @@ function forgeDocRenderButtonProps(panel, el) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
document.getElementById('docButtonSvg').addEventListener('change', (e) => patch({ svg_markup: e.target.value.trim() }));
|
||||||
|
panel.querySelectorAll('#docButtonIconPosSeg .docSegBtn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => patch({ icon_position: btn.dataset.pos }));
|
||||||
|
});
|
||||||
|
forgeDocBindToggleSlider('docButtonIconSize', { unit: 'px' }, (v) => patch({ icon_size: v }));
|
||||||
|
document.getElementById('docButtonBoldBtn').addEventListener('click', () => patch({ bold: !a.bold }));
|
||||||
|
document.getElementById('docButtonItalicBtn').addEventListener('click', () => patch({ italic: !a.italic }));
|
||||||
|
panel.querySelectorAll('#docButtonTextTransformSeg .docSegBtn').forEach((btn) => {
|
||||||
|
btn.addEventListener('click', () => patch({ text_transform: btn.dataset.transform }));
|
||||||
|
});
|
||||||
|
document.getElementById('docButtonColorInput').addEventListener('change', (e) => patch({ text_color: 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('docButtonLetterSpacing', { unit: 'px' }, (v) => patch({ letter_spacing: v }));
|
||||||
|
forgeDocBindBoxFields(panel, el, 'docButton', patch);
|
||||||
}
|
}
|
||||||
|
|
||||||
function forgeDocRenderBadgeProps(panel, el) {
|
function forgeDocRenderBadgeProps(panel, el) {
|
||||||
|
|||||||
@@ -692,17 +692,136 @@ def test_image_box_style_targets_the_link_wrapper_when_no_caption(tmp_support_sl
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_bouton_default_attributes_include_empty_attachment_fields(tmp_support_slug_cleanup: Any) -> None:
|
def test_bouton_default_attributes_include_all_new_formatting_fields(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
"""Audit du 26/09/2026 (réglages manquants — colonne "Bouton") : chaque
|
||||||
|
nouveau champ doit exister avec une valeur "neutre" par défaut
|
||||||
|
(comportement historique inchangé, voir _render_button)."""
|
||||||
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
|
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
|
||||||
element = doc_engine.get_document_element(slug, element_id)
|
element = doc_engine.get_document_element(slug, element_id)
|
||||||
assert element is not None
|
assert element is not None
|
||||||
assert element["attributes"] == {
|
attrs = element["attributes"]
|
||||||
"label": "Bouton",
|
assert attrs["label"] == "Bouton"
|
||||||
"target": "",
|
assert attrs["target"] == ""
|
||||||
"attachment_stored_name": "",
|
assert attrs["attachment_stored_name"] == ""
|
||||||
"attachment_filename": "",
|
assert attrs["attachment_filename"] == ""
|
||||||
}
|
assert attrs["bold"] is False
|
||||||
|
assert attrs["italic"] is False
|
||||||
|
assert attrs["text_transform"] == "none"
|
||||||
|
assert attrs["font_family"] == ""
|
||||||
|
assert attrs["font_size"] == ""
|
||||||
|
assert attrs["letter_spacing"] == ""
|
||||||
|
assert attrs["text_color"] == ""
|
||||||
|
assert attrs["svg_markup"] == ""
|
||||||
|
assert attrs["icon_position"] == "before"
|
||||||
|
assert attrs["icon_size"] == ""
|
||||||
|
assert attrs["padding"] == ""
|
||||||
|
assert attrs["align_self"] == "stretch"
|
||||||
|
assert attrs["border"] == doc_engine_box_default_border()
|
||||||
|
|
||||||
|
html = doc_engine.render_document([element])
|
||||||
|
assert html == (
|
||||||
|
f'<button type="button" class="docButton" data-element-id="{element_id}" '
|
||||||
|
f'data-kind="bouton"><span class="docButtonLabel">Bouton</span></button>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_bouton_bold_and_italic_render_without_lowering_default_weight(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(slug, element_id, {"label": "X", "bold": True, "italic": True})
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert "font-weight:800;" in html
|
||||||
|
assert "font-style:italic;" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_bouton_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, "bouton", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug,
|
||||||
|
element_id,
|
||||||
|
{
|
||||||
|
"label": "X",
|
||||||
|
"text_transform": "uppercase",
|
||||||
|
"font_family": "Georgia, serif",
|
||||||
|
"font_size": "18px",
|
||||||
|
"letter_spacing": "0.05em",
|
||||||
|
"text_color": "#ffffff",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert "text-transform:uppercase;" in html
|
||||||
|
assert "font-family:Georgia, serif;" in html
|
||||||
|
assert "font-size:18px;" in html
|
||||||
|
assert "letter-spacing:0.05em;" in html
|
||||||
|
assert "color:#ffffff;" in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_bouton_icon_renders_before_by_default_and_after_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, "bouton", page_id=page_id)
|
||||||
|
svg = '<svg><circle cx="1" cy="1" r="1"/></svg>'
|
||||||
|
doc_engine.update_document_element_attributes(slug, element_id, {"label": "X", "svg_markup": svg})
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert html.index("docButtonIcon") < html.index("docButtonLabel")
|
||||||
|
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug, element_id, {"label": "X", "svg_markup": svg, "icon_position": "after"}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert html.index("docButtonLabel") < html.index("docButtonIcon")
|
||||||
|
|
||||||
|
|
||||||
|
def test_bouton_icon_size_sets_explicit_width_and_height(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
|
||||||
|
doc_engine.update_document_element_attributes(
|
||||||
|
slug, element_id, {"label": "X", "svg_markup": "<svg></svg>", "icon_size": "24px"}
|
||||||
|
)
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert 'style="width:24px; height:24px;"' in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_bouton_svg_markup_is_sanitized(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
slug, page_id = _new_support(tmp_support_slug_cleanup)
|
||||||
|
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
|
||||||
|
malicious = '<svg onload="alert(1)"><script>alert(2)</script></svg>'
|
||||||
|
doc_engine.update_document_element_attributes(slug, element_id, {"label": "X", "svg_markup": malicious})
|
||||||
|
html = doc_engine.render_document(doc_engine.list_document_elements(slug, page_id))
|
||||||
|
assert "<script>" not in html
|
||||||
|
assert "onload" not in html
|
||||||
|
|
||||||
|
|
||||||
|
def test_bouton_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, "bouton", 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,
|
||||||
|
{
|
||||||
|
"label": "X",
|
||||||
|
"padding": "12px 20px",
|
||||||
|
"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 20px;" 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_replace_document_content_rebuilds_pages_and_elements_from_seed(tmp_support_slug_cleanup: Any) -> None:
|
def test_replace_document_content_rebuilds_pages_and_elements_from_seed(tmp_support_slug_cleanup: Any) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user