Audit complet de mise en forme — Titre/Paragraphe (1er élément du tableau)

Nouveau module partagé document_engine/rendering/box_style.py
(padding/margin/background_color/border_radius/border par côté/height/
min-height/max-height/min-width/box-shadow/opacity/align_self) —
réutilisable tel quel par tous les kinds suivants du tableau d'audit.

Titre/Paragraphe gagnent : barré, police de caractère, taille de
police, hauteur de ligne, espacement des lettres, majuscules/
minuscules/capitales, ombre du texte, et tous les attributs de boîte
partagés ci-dessus. Interface entièrement à base de curseurs/cases à
cocher/listes déroulantes/sélecteurs de couleur natifs — plus aucun
champ de texte libre pour une valeur CSS (retour utilisateur).

Deux bugs transversaux corrigés au passage (concernent tout
l'éditeur) :
- Le panneau Propriétés n'était jamais reconstruit après un clic sur un
  bouton (gras/alignement/segments...) — il fallait recharger la page
  pour voir l'état réel. Corrigé dans forgeDocUpdateAttributes, point
  d'entrée unique de toute mise à jour d'attribut.
- Les cases à cocher et curseurs héritaient à tort le style d'un champ
  de texte (padding/bordure/fond/largeur 100%) via la règle générique
  .docField input.

Ajoute docs/plan/AUDIT_MISE_EN_FORME.md : suivi de l'audit élément par
élément (Titre/Paragraphe traité, Image ensuite).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
william
2026-09-26 09:25:30 +02:00
co-authored by Claude Sonnet 5
parent e03bea39c5
commit 6c7675fad0
10 changed files with 942 additions and 25 deletions
+263 -11
View File
@@ -282,6 +282,17 @@ function forgeDocUpdateAttributes(id, newAttributes) {
forgeDocBindCanvasInteractions();
forgeDocApplySelectionClass();
}
// Bug réel constaté le 26/09/2026 : les boutons (gras/alignement/
// segmentés...) ne reflétaient jamais leur nouvel état tant que la
// page n'était pas rechargée — leur classe "is-active" était figée
// au moment du rendu initial du panneau, jamais réévaluée après un
// clic. Point d'entrée UNIQUE de toute mise à jour d'attributs :
// reconstruire le panneau ICI, une fois, couvre TOUS les panneaux
// (texte, image, bouton, mini-jeux...) sans dupliquer cet appel dans
// chacun de leurs boutons.
if (window.forgeDocState.selectedId === id) {
forgeDocRenderProps(el);
}
});
}
@@ -1687,6 +1698,208 @@ function forgeDocRefreshRightPanelDefault() {
else forgeDocRenderProps(null);
}
/* ---------------------------------------------------------------------
* Champs de "boîte" PARTAGÉS par plusieurs kinds (padding/margin/couleur
* de fond/arrondi/bordure par côté/position du bloc) — voir
* document_engine/rendering/box_style.py, même jeu d'attributs côté
* serveur. Un seul HTML + un seul binding réutilisés par chaque panneau
* de propriétés plutôt que dupliqués (retour utilisateur du 26/09/2026 :
* audit complet des réglages manquants, à couvrir élément par élément).
* ------------------------------------------------------------------- */
const FORGE_DOC_BORDER_SIDES = [
['top', 'Haut'], ['right', 'Droite'], ['bottom', 'Bas'], ['left', 'Gauche'],
];
const FORGE_DOC_BORDER_STYLES = [
['none', 'Aucune'], ['solid', 'Solide'], ['dashed', 'Tirets'], ['dotted', 'Pointillés'], ['double', 'Double'],
];
const FORGE_DOC_ALIGN_SELF_OPTIONS = [
['stretch', 'Pleine largeur'], ['flex-start', 'Gauche'], ['center', 'Centré'], ['flex-end', 'Droite'],
];
const FORGE_DOC_SHADOW_PRESETS = [
['', 'Aucune'], ['0 2px 6px rgba(0,0,0,.15)', 'Légère'], ['0 8px 24px rgba(0,0,0,.25)', 'Marquée'],
];
const FORGE_DOC_TEXT_SHADOW_PRESETS = [
['', 'Aucune'], ['1px 1px 2px rgba(0,0,0,.35)', 'Légère'], ['2px 2px 6px rgba(0,0,0,.5)', 'Marquée'],
];
const FORGE_DOC_FONT_FAMILY_OPTIONS = [
['', 'Police du thème'],
['system-ui, -apple-system, sans-serif', 'Système (sans-serif)'],
['Georgia, serif', 'Georgia (serif)'],
['"Times New Roman", serif', 'Times New Roman (serif)'],
['Arial, Helvetica, sans-serif', 'Arial'],
['Verdana, sans-serif', 'Verdana'],
['Tahoma, sans-serif', 'Tahoma'],
['"Trebuchet MS", sans-serif', 'Trebuchet MS'],
['"Courier New", monospace', 'Courier New (machine à écrire)'],
];
/* ---------------------------------------------------------------------
* Contrôles génériques réutilisables — retour utilisateur du 26/09/2026 :
* "jamais l'utilisateur ne doit rentrer une valeur brute à la main" —
* un curseur (avec une case "activer" puisqu'un <input type=range> ne
* peut pas représenter "aucune valeur") remplace tout champ de texte
* libre pour une valeur numérique optionnelle.
* ------------------------------------------------------------------- */
function forgeDocRenderToggleSliderHtml(idPrefix, label, rawValue, opts) {
const hasValue = rawValue !== undefined && rawValue !== null && rawValue !== '';
const numeric = hasValue ? Number.parseFloat(rawValue) : opts.fallback;
const displayUnit = opts.displayUnit !== undefined ? opts.displayUnit : opts.unit;
return `
<div class="docField docSliderField">
<span class="docFieldLabel">${label}</span>
<div class="docSliderRow">
<input type="checkbox" id="${idPrefix}Toggle" title="Activer" ${hasValue ? 'checked' : ''}>
<input type="range" id="${idPrefix}Range" min="${opts.min}" max="${opts.max}" step="${opts.step}" value="${numeric}" ${hasValue ? '' : 'disabled'}>
<span class="docSliderValue" id="${idPrefix}Value">${hasValue ? numeric + displayUnit : '—'}</span>
</div>
</div>
`;
}
function forgeDocBindToggleSlider(idPrefix, opts, onChange) {
const toggle = document.getElementById(`${idPrefix}Toggle`);
const range = document.getElementById(`${idPrefix}Range`);
const valueLabel = document.getElementById(`${idPrefix}Value`);
const displayUnit = opts.displayUnit !== undefined ? opts.displayUnit : opts.unit;
const toAttr = opts.toAttr || ((n) => `${n}${opts.unit}`);
range.addEventListener('input', () => {
valueLabel.textContent = range.value + displayUnit;
});
range.addEventListener('change', () => {
if (toggle.checked) onChange(toAttr(Number.parseFloat(range.value)));
});
toggle.addEventListener('change', () => {
range.disabled = !toggle.checked;
if (toggle.checked) {
valueLabel.textContent = range.value + displayUnit;
onChange(toAttr(Number.parseFloat(range.value)));
} else {
valueLabel.textContent = '—';
onChange('');
}
});
}
function forgeDocToHexColor(value, fallback) {
return /^#[0-9a-f]{6}$/i.test(value || '') ? value : fallback;
}
function forgeDocRenderBoxFieldsHtml(a, idPrefix) {
const border = a.border || {};
const alignSelf = a.align_self || 'stretch';
const opacityPercent = a.opacity !== '' && a.opacity !== undefined ? Math.round(Number.parseFloat(a.opacity) * 100) : '';
return `
${forgeDocRenderToggleSliderHtml(`${idPrefix}Padding`, 'Espacement intérieur (padding)', a.padding, {
min: 0, max: 80, step: 2, unit: 'px', fallback: 16,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}Margin`, 'Espacement extérieur (margin)', a.margin, {
min: 0, max: 80, step: 2, unit: 'px', fallback: 16,
})}
<div class="docField">
<label for="${idPrefix}Bg">Couleur de fond</label>
<div class="docColorFieldRow">
<input type="color" id="${idPrefix}Bg" value="${forgeDocToHexColor(a.background_color, '#ffffff')}">
<button type="button" class="docBtnSecondary" id="${idPrefix}BgNone">Transparent</button>
</div>
</div>
${forgeDocRenderToggleSliderHtml(`${idPrefix}Radius`, 'Arrondi des bords', a.border_radius, {
min: 0, max: 100, step: 2, unit: 'px', fallback: 8,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}Height`, 'Hauteur fixe', a.height, {
min: 0, max: 800, step: 10, unit: 'px', fallback: 200,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}MinHeight`, 'Hauteur minimale', a.min_height, {
min: 0, max: 800, step: 10, unit: 'px', fallback: 200,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}MaxHeight`, 'Hauteur maximale', a.max_height, {
min: 0, max: 800, step: 10, unit: 'px', fallback: 200,
})}
${forgeDocRenderToggleSliderHtml(`${idPrefix}MinWidth`, 'Largeur minimale', a.min_width, {
min: 0, max: 800, step: 10, unit: 'px', fallback: 200,
})}
<div class="docField">
<span class="docFieldLabel">Ombre portée du bloc</span>
<div class="docSegmented" id="${idPrefix}ShadowSeg">
${FORGE_DOC_SHADOW_PRESETS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.box_shadow || '') === v ? 'is-active' : ''}" data-shadow="${forgeDocEscapeHtml(v)}">${l}</button>`).join('')}
</div>
</div>
${forgeDocRenderToggleSliderHtml(`${idPrefix}Opacity`, 'Opacité', opacityPercent, {
min: 0, max: 100, step: 5, unit: '%', fallback: 100,
})}
<div class="docField">
<span class="docFieldLabel">Position du bloc</span>
<div class="docSegmented" id="${idPrefix}AlignSelfSeg">
${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 (par côté)</span>
<div class="docBorderEditor" id="${idPrefix}BorderEditor">
${FORGE_DOC_BORDER_SIDES.map(([side, label]) => {
const sideBorder = border[side] || {};
return `
<div class="docBorderSideRow" data-side="${side}">
<span class="docBorderSideLabel">${label}</span>
<select class="docBorderStyleSelect" data-side="${side}">
${FORGE_DOC_BORDER_STYLES.map(([v, l]) => `<option value="${v}" ${(sideBorder.style || 'none') === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
<input type="range" class="docBorderWidthRange" data-side="${side}" min="1" max="10" step="1" value="${Number.parseFloat(sideBorder.width) || 1}" title="Épaisseur">
<input type="color" class="docBorderColorInput" data-side="${side}" value="${forgeDocToHexColor(sideBorder.color, '#c9c4b8')}" title="Couleur">
</div>`;
}).join('')}
</div>
</div>
`;
}
function forgeDocBindBoxFields(panel, el, idPrefix, patch) {
forgeDocBindToggleSlider(`${idPrefix}Padding`, { unit: 'px' }, (v) => patch({ padding: v }));
forgeDocBindToggleSlider(`${idPrefix}Margin`, { unit: 'px' }, (v) => patch({ margin: v }));
forgeDocBindToggleSlider(`${idPrefix}Radius`, { unit: 'px' }, (v) => patch({ border_radius: v }));
forgeDocBindToggleSlider(`${idPrefix}Height`, { unit: 'px' }, (v) => patch({ height: v }));
forgeDocBindToggleSlider(`${idPrefix}MinHeight`, { unit: 'px' }, (v) => patch({ min_height: v }));
forgeDocBindToggleSlider(`${idPrefix}MaxHeight`, { unit: 'px' }, (v) => patch({ max_height: v }));
forgeDocBindToggleSlider(`${idPrefix}MinWidth`, { unit: 'px' }, (v) => patch({ min_width: v }));
// Toujours une vraie valeur numérique quand la case est cochée (jamais
// "" pour 100%, même si ça revient visuellement au même) : sinon l'état
// "activé" de la case ne survivrait pas à un rechargement du panneau
// (hasValue redeviendrait faux, la case semblerait décochée à tort).
forgeDocBindToggleSlider(`${idPrefix}Opacity`, { unit: '%', toAttr: (n) => String(n / 100) }, (v) => patch({ opacity: v }));
document.getElementById(`${idPrefix}Bg`).addEventListener('change', (e) => patch({ background_color: e.target.value }));
document.getElementById(`${idPrefix}BgNone`).addEventListener('click', () => patch({ background_color: '' }));
panel.querySelectorAll(`#${idPrefix}ShadowSeg .docSegBtn`).forEach((btn) => {
btn.addEventListener('click', () => patch({ box_shadow: btn.dataset.shadow }));
});
panel.querySelectorAll(`#${idPrefix}AlignSelfSeg .docSegBtn`).forEach((btn) => {
btn.addEventListener('click', () => patch({ align_self: btn.dataset.alignSelf }));
});
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
// réassigné en place par forgeDocUpdateAttributes après chaque
// modification — un instantané figé ferait perdre le changement
// précédent dès qu'on modifie un 2e côté/champ.
const currentBorder = { ...el.attributes.border };
const nextSide = { ...currentBorder[side], ...partial };
patch({ border: { ...currentBorder, [side]: nextSide } });
}
panel.querySelectorAll(`#${idPrefix}BorderEditor .docBorderStyleSelect`).forEach((sel) => {
sel.addEventListener('change', (e) => patchBorderSide(sel.dataset.side, { style: e.target.value }));
});
panel.querySelectorAll(`#${idPrefix}BorderEditor .docBorderWidthRange`).forEach((range) => {
range.addEventListener('change', (e) => patchBorderSide(range.dataset.side, { width: `${e.target.value}px` }));
});
panel.querySelectorAll(`#${idPrefix}BorderEditor .docBorderColorInput`).forEach((inp) => {
inp.addEventListener('change', (e) => patchBorderSide(inp.dataset.side, { color: e.target.value }));
});
}
function forgeDocRenderTextProps(panel, el) {
const a = el.attributes;
panel.innerHTML = `
@@ -1704,42 +1917,81 @@ function forgeDocRenderTextProps(panel, el) {
<button type="button" class="docFmtBtn ${a.bold ? 'is-active' : ''}" id="docBoldBtn" title="Gras">B</button>
<button type="button" class="docFmtBtn docFmtBtn--italic ${a.italic ? 'is-active' : ''}" id="docItalicBtn" title="Italique">I</button>
<button type="button" class="docFmtBtn docFmtBtn--underline ${a.underline ? 'is-active' : ''}" id="docUnderlineBtn" title="Souligné">U</button>
<button type="button" class="docFmtBtn ${a.strikethrough ? 'is-active' : ''}" id="docStrikeBtn" title="Barré"><s>S</s></button>
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Alignement</span>
<span class="docFieldLabel">Alignement du texte</span>
<div class="docToolbarRow" id="docAlignRow">
<button type="button" class="docFmtBtn ${a.align === 'left' ? 'is-active' : ''}" data-align="left">◧</button>
<button type="button" class="docFmtBtn ${a.align === 'center' ? 'is-active' : ''}" data-align="center">▣</button>
<button type="button" class="docFmtBtn ${a.align === 'right' ? 'is-active' : ''}" data-align="right">◨</button>
<button type="button" class="docFmtBtn ${a.align === 'left' ? 'is-active' : ''}" data-align="left" title="Aligné à gauche">◧</button>
<button type="button" class="docFmtBtn ${a.align === 'center' ? 'is-active' : ''}" data-align="center" title="Centré">▣</button>
<button type="button" class="docFmtBtn ${a.align === 'right' ? 'is-active' : ''}" data-align="right" title="Aligné à droite">◨</button>
</div>
</div>
<div class="docField">
<span class="docFieldLabel">Couleur du texte</span>
<div class="docSwatches" id="docColorSwatches">
${['var(--doc-text)', 'var(--doc-muted)', '#ff5f2e', '#ffb020'].map((c) => `<div class="docSwatch ${a.color === c ? 'is-active' : ''}" data-color="${c}" style="background:${c.replace('doc-', 'forge-').replace('var(--forge-text)', 'var(--forge-text)')};"></div>`).join('')}
<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('')}
</div>
</div>
<div class="docField"><label for="docMaxWidthInput">Largeur maximale (optionnel, ex. 60ch, 480px)</label><input type="text" id="docMaxWidthInput" value="${a.max_width || ''}" placeholder="pleine largeur"></div>
<div class="docField">
<label for="docColorInput">Couleur du texte</label>
<input type="color" id="docColorInput" value="${forgeDocToHexColor(a.color, '#14161c')}">
</div>
<div class="docField">
<label for="docFontFamilyInput">Police de caractère</label>
<select id="docFontFamilyInput">
${FORGE_DOC_FONT_FAMILY_OPTIONS.map(([v, l]) => `<option value="${forgeDocEscapeHtml(v)}" ${(a.font_family || '') === v ? 'selected' : ''}>${l}</option>`).join('')}
</select>
</div>
${forgeDocRenderToggleSliderHtml('docFontSize', 'Taille de police', a.font_size, {
min: 10, max: 72, step: 1, unit: 'px', fallback: 16,
})}
${forgeDocRenderToggleSliderHtml('docLineHeight', 'Hauteur de ligne', a.line_height, {
min: 1, max: 2.5, step: 0.1, unit: '', fallback: 1.5,
})}
${forgeDocRenderToggleSliderHtml('docLetterSpacing', 'Espacement des lettres', a.letter_spacing, {
min: -2, max: 10, step: 0.5, unit: 'px', fallback: 0,
})}
<div class="docField">
<span class="docFieldLabel">Ombre du texte</span>
<div class="docSegmented" id="docTextShadowSeg">
${FORGE_DOC_TEXT_SHADOW_PRESETS.map(([v, l]) => `<button type="button" class="docSegBtn ${(a.text_shadow || '') === v ? 'is-active' : ''}" data-shadow="${forgeDocEscapeHtml(v)}">${l}</button>`).join('')}
</div>
</div>
${forgeDocRenderToggleSliderHtml('docMaxWidth', 'Largeur maximale', a.max_width, {
min: 200, max: 1200, step: 20, unit: 'px', fallback: 600,
})}
${forgeDocRenderBoxFieldsHtml(a, 'docText')}
${forgeDocDeleteButtonHtml()}
`;
function patch(partial) { forgeDocUpdateAttributes(el.id, { ...el.attributes, ...partial }); }
document.getElementById('docContentInput').addEventListener('change', (e) => patch({ content: e.target.value }));
document.getElementById('docMaxWidthInput').addEventListener('change', (e) => patch({ max_width: e.target.value.trim() }));
forgeDocBindToggleSlider('docMaxWidth', { unit: 'px' }, (v) => patch({ max_width: v }));
panel.querySelectorAll('#docStyleSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ style: btn.dataset.style }));
});
document.getElementById('docBoldBtn').addEventListener('click', () => patch({ bold: !a.bold }));
document.getElementById('docItalicBtn').addEventListener('click', () => patch({ italic: !a.italic }));
document.getElementById('docUnderlineBtn').addEventListener('click', () => patch({ underline: !a.underline }));
document.getElementById('docStrikeBtn').addEventListener('click', () => patch({ strikethrough: !a.strikethrough }));
panel.querySelectorAll('#docAlignRow [data-align]').forEach((btn) => {
btn.addEventListener('click', () => patch({ align: btn.dataset.align }));
});
panel.querySelectorAll('#docColorSwatches .docSwatch').forEach((sw) => {
sw.addEventListener('click', () => patch({ color: sw.dataset.color }));
panel.querySelectorAll('#docTextTransformSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ text_transform: btn.dataset.transform }));
});
document.getElementById('docColorInput').addEventListener('change', (e) => patch({ color: e.target.value }));
document.getElementById('docFontFamilyInput').addEventListener('change', (e) => patch({ font_family: e.target.value }));
forgeDocBindToggleSlider('docFontSize', { unit: 'px' }, (v) => patch({ font_size: v }));
forgeDocBindToggleSlider('docLineHeight', { unit: '' }, (v) => patch({ line_height: v }));
forgeDocBindToggleSlider('docLetterSpacing', { unit: 'px' }, (v) => patch({ letter_spacing: v }));
panel.querySelectorAll('#docTextShadowSeg .docSegBtn').forEach((btn) => {
btn.addEventListener('click', () => patch({ text_shadow: btn.dataset.shadow }));
});
forgeDocBindBoxFields(panel, el, 'docText', patch);
}
function forgeDocRenderImageProps(panel, el) {