diff --git a/docs/plan/AUDIT_MISE_EN_FORME.md b/docs/plan/AUDIT_MISE_EN_FORME.md index 89f6156c..5ad1ebf5 100644 --- a/docs/plan/AUDIT_MISE_EN_FORME.md +++ b/docs/plan/AUDIT_MISE_EN_FORME.md @@ -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//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. diff --git a/document_engine/labels/element_kind_labels.py b/document_engine/labels/element_kind_labels.py index f8f1eba5..dfddbb40 100644 --- a/document_engine/labels/element_kind_labels.py +++ b/document_engine/labels/element_kind_labels.py @@ -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 diff --git a/document_engine/labels/labels.md b/document_engine/labels/labels.md index 574aac1e..2b22fc9b 100644 --- a/document_engine/labels/labels.md +++ b/document_engine/labels/labels.md @@ -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 diff --git a/document_engine/rendering/render_document_element.py b/document_engine/rendering/render_document_element.py index 16a4409e..a6daab90 100644 --- a/document_engine/rendering/render_document_element.py +++ b/document_engine/rendering/render_document_element.py @@ -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}' +_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'{sanitize_svg_markup(svg_markup)}' + + 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'{label}' + inner = ( + f"{label_span}{icon_html}" if str(a.get("icon_position", "before")) == "after" else f"{icon_html}{label_span}" + ) + return ( f'' + f'data-kind="bouton"{target_attr}{attachment_attr}{style_attr}>{inner}' ) diff --git a/document_engine/rendering/rendering.md b/document_engine/rendering/rendering.md index 9a6c0b50..d0b8d4db 100644 --- a/document_engine/rendering/rendering.md +++ b/document_engine/rendering/rendering.md @@ -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 `
` échappée. -- **Bouton** : ``).join('')} +
+ Bordure — les 4 côtés à la fois +
+ ${(() => { + // 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 ` + Tous + + + + `; + })()} +
+
Bordure (par côté)
@@ -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) {
Majuscules / minuscules
- ${[['none', 'Normal'], ['uppercase', 'MAJUSCULES'], ['lowercase', 'minuscules'], ['capitalize', 'Capitales']].map(([v, l]) => ``).join('')} + ${FORGE_DOC_TEXT_TRANSFORM_OPTIONS.map(([v, l]) => ``).join('')}
@@ -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')} -
-
+
+
Fichier téléchargeable
${attachmentStatus}
+
+
+ Position de l'icône +
+ ${FORGE_DOC_ICON_POSITION_OPTIONS.map(([v, l]) => ``).join('')} +
+
+ ${forgeDocRenderToggleSliderHtml('docButtonIconSize', "Taille de l'icône", a.icon_size, { + min: 10, max: 48, step: 1, unit: 'px', fallback: 16, + })} +
+ Mise en forme +
+ + +
+
+
+ Majuscules / minuscules +
+ ${FORGE_DOC_TEXT_TRANSFORM_OPTIONS.map(([v, l]) => ``).join('')} +
+
+
+ + +
+
+ + +
+ ${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) { diff --git a/tests/document/test_document_elements.py b/tests/document/test_document_elements.py index 69a03458..0217f8f5 100644 --- a/tests/document/test_document_elements.py +++ b/tests/document/test_document_elements.py @@ -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'' + ) + + +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 = '' + 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": "", "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 = '' + 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 "