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:
william
2026-09-26 12:47:46 +02:00
co-authored by Claude Sonnet 5
parent 9ad50c58b8
commit 8f879f6321
8 changed files with 529 additions and 16 deletions
+79 -1
View File
@@ -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 à
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
@@ -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
de Google Fonts depuis l'éditeur) — évite d'introduire un mécanisme de
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.
+29 -1
View File
@@ -131,7 +131,35 @@ def element_default_attributes(kind: str) -> dict[str, Any]:
# qui reste réservé à la navigation (URL/ancre). Jamais les deux
# à la fois côté UI (voir document-editor.js), mais rien ne
# 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"):
# 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
+5 -1
View File
@@ -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
style + les attributs de boîte partagés), bouton (`label/target/
attachment_stored_name/attachment_filename` — la pièce jointe est
indépendante de `target`, réservé à la navigation), liste à
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
(`content/svg_markup/width/border_radius/bold/uppercase` — tous vides
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}>'
_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:
a = el["attributes"]
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.
attachment_filename = html_lib.escape(str(a.get("attachment_filename", "")))
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 (
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>'
)
+19 -2
View File
@@ -67,14 +67,31 @@ regroupement à chaque appel.
forgeDocOpenImageLightbox), même principe que les mini-jeux et la
pièce jointe d'un bouton. `caption` (non vide) enveloppe le tout dans
un `<figure><figcaption>` échappée.
- **Bouton** : `<button>` avec son `label`, un `data-target` optionnel
- **Bouton** : `<button>` avec son `label` (enveloppé dans
`<span class="docButtonLabel">`), un `data-target` optionnel
(navigation) et un `data-attachment-filename` optionnel — marqueur
mécanique posé quand un fichier a été joint (voir
`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
du support ; `static/document/js/document-editor.js` l'assemble à
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"]`
échappé, précédé d'un `<span class="docBadgeIcon">` optionnel si
`svg_markup` est non vide (nettoyé par `sanitize_svg_markup`, même
+51
View File
@@ -626,7 +626,16 @@ img.docImage {
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 {
display: inline-flex;
align-items: center;
gap: 8px;
border: none;
border-radius: 999px;
padding: 10px 22px;
@@ -635,6 +644,39 @@ img.docImage {
background: var(--doc-accent);
color: #14100c;
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 {
@@ -1510,6 +1552,15 @@ img.docImage {
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 {
outline: 2px solid var(--doc-accent);
outline-offset: 1px;
+164 -3
View File
@@ -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) {
const el = window.forgeDocState.elementsById[id];
if (!el) return Promise.resolve();
@@ -851,6 +882,30 @@ function forgeDocRenderPageManagerList() {
e.preventDefault();
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) => {
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('')}
</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">
<span class="docFieldLabel">Bordure (par côté)</span>
<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 }));
});
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) {
// Toujours relu depuis el.attributes AU MOMENT de l'appel (jamais un
// 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) {
const a = el.attributes;
panel.innerHTML = `
@@ -2116,7 +2218,7 @@ function forgeDocRenderTextProps(panel, el) {
<div class="docField">
<span class="docFieldLabel">Majuscules / minuscules</span>
<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 class="docField">
@@ -2282,6 +2384,10 @@ function forgeDocRenderImageProps(panel, el) {
forgeDocBindBoxFields(panel, el, 'docImage', patch);
}
const FORGE_DOC_ICON_POSITION_OPTIONS = [
['before', 'Avant le texte'], ['after', 'Après le texte'],
];
function forgeDocRenderButtonProps(panel, el) {
const a = el.attributes;
const attachmentStatus = a.attachment_filename
@@ -2289,13 +2395,53 @@ function forgeDocRenderButtonProps(panel, el) {
: 'Aucun fichier joint';
panel.innerHTML = `
${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="docButtonTarget">Cible (URL ou ancre)</label><input type="text" id="docButtonTarget" value="${a.target || ''}"></div>
<div class="docField"><label for="docButtonLabel">Texte du bouton</label><input type="text" id="docButtonLabel" value="${forgeDocEscapeHtml(a.label || '')}"></div>
<div class="docField"><label for="docButtonTarget">Cible (URL ou ancre)</label><input type="text" id="docButtonTarget" value="${forgeDocEscapeHtml(a.target || '')}"></div>
<div class="docField">
<span class="docFieldLabel">Fichier téléchargeable</span>
<div id="docButtonAttachmentStatus">${attachmentStatus}</div>
<input type="file" id="docButtonAttachmentInput">
</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()}
`;
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) {
+126 -7
View File
@@ -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)
element_id = doc_engine.add_document_element(slug, "bouton", page_id=page_id)
element = doc_engine.get_document_element(slug, element_id)
assert element is not None
assert element["attributes"] == {
"label": "Bouton",
"target": "",
"attachment_stored_name": "",
"attachment_filename": "",
}
attrs = element["attributes"]
assert attrs["label"] == "Bouton"
assert attrs["target"] == ""
assert attrs["attachment_stored_name"] == ""
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: