Informations : éditeur visuel à la place de la syntaxe Markdown

La barre d'outils écrivait des étoiles dans un textarea : l'adhérent qui
rédigeait devait connaître une syntaxe pour obtenir du gras. On édite
désormais comme dans un traitement de texte — on sélectionne, on clique sur
G, le texte devient gras à l'écran.

Le format stocké ne change pas d'un octet. C'est tout l'enjeu : le WYSIWYG
porte sur la saisie, pas sur le stockage. À chaque frappe, le navigateur
retraverse ce qu'il a édité (serializeToRichText) et n'en garde que le gras,
l'italique, les listes, les sous-titres et les liens ; le balisage part dans
un champ caché et le serveur le ré-analyse avec l'analyseur existant. Le HTML
de l'éditeur ne quitte jamais la page, donc aucun assainisseur HTML n'entre
dans le produit — le risque n'a jamais été la surface d'édition, mais la base.

- rich-text-dom.ts : sérialiseur pur, testable sans navigateur (MinimalNode).
  Il lit aussi le gras codé en style CSS, pour qu'un brouillon collé depuis
  Google Docs ne perde pas sa mise en forme ; le style explicite l'emporte sur
  la balise, sinon le <b style="font-weight:normal"> dont Docs enveloppe tout
  document mettrait le texte entier en gras.
- Un collage passe par DOMParser, dont le document est inerte : un <img
  onerror> collé ne s'exécute pas. Seul du HTML produit par nous est réinjecté.
- L'italique s'écrit _ainsi_ : **_x_** est lisible par l'analyseur là où ***x***
  serait ambigu. Un tiret bas au milieu d'un mot n'ouvre rien.
- saveInformation normalise les CRLF que l'encodage des formulaires introduit.

Vérifié au navigateur contre une base réelle : aller-retour identique après
enregistrement, et un collage hostile (script, img onerror, iframe, style,
lien javascript:) ressort en texte structuré sans qu'une seule alerte parte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QsLjRAnuLwivqxCbM4WeP
This commit is contained in:
Claude committed 2026-09-15 06:37:52 +00:00
1 parent 90b121a3c1
commit cc33cf5fbc
10 files changed
+956 -91

No files matched your search

+3 -1
View File
@@ -1449,7 +1449,9 @@ export async function saveInformation(
const id = Number(formData.get("id") ?? 0) || null;
const title = asString(formData, "title");
const body = asString(formData, "body");
// L'encodage des formulaires HTML convertit tout saut de ligne en CRLF :
// sans cela le balisage stocké différerait de celui que l'éditeur a produit.
const body = asString(formData, "body").replace(/\r\n?/g, "\n");
if (!title) return { error: "Donnez un titre à cette information." };
if (!body) return { error: "Le message est vide." };
@@ -1,19 +1,23 @@
"use client";
import { useRef, useState } from "react";
import { useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ImageField } from "@/components/ImageField";
import { richTextNodes } from "@/lib/rich-text";
import { RichTextEditor } from "@/components/RichTextEditor";
import { saveInformation } from "../actions";
/**
* Rédaction d'une information.
*
* Pas d'éditeur WYSIWYG : un `<textarea>` et des boutons qui encadrent la
* sélection, plus un aperçu rendu par **le même** analyseur que le site
* (`richTextNodes`). L'aperçu est donc fidèle par construction, et rien de ce
* qui est saisi ne peut devenir du HTML.
* L'édition est **visuelle** : on sélectionne, on clique sur « G », le texte
* devient gras à l'écran. Aucune syntaxe n'est montrée — un rédacteur
* d'association n'a pas à connaître Markdown.
*
* Ce qui est enregistré reste malgré tout le format balisé restreint : c'est
* `RichTextEditor` qui resérialise à chaque frappe. Aucun HTML tiers n'atteint
* la base, et l'éditeur est son propre aperçu — il n'y a plus deux rendus à
* comparer.
*/
type Draft = {
@@ -24,18 +28,8 @@ type Draft = {
pinned: boolean;
};
const TOOLS = [
{ label: "B", title: "Gras", before: "**", after: "**", sample: "texte en gras", strong: true },
{ label: "I", title: "Italique", before: "*", after: "*", sample: "texte en italique", italic: true },
{ label: "Titre", title: "Sous-titre", before: "## ", after: "", sample: "Sous-titre", line: true },
{ label: "• liste", title: "Liste à puces", before: "- ", after: "", sample: "premier point", line: true },
{ label: "1. liste", title: "Liste numérotée", before: "1. ", after: "", sample: "première étape", line: true },
{ label: "Lien", title: "Lien", before: "[", after: "](https://)", sample: "libellé du lien" },
] as const;
export function InformationForm({ draft }: { draft: Draft }) {
const router = useRouter();
const bodyRef = useRef<HTMLTextAreaElement>(null);
const [title, setTitle] = useState(draft.title);
const [body, setBody] = useState(draft.body);
const [pinned, setPinned] = useState(draft.pinned);
@@ -45,19 +39,6 @@ export function InformationForm({ draft }: { draft: Draft }) {
// seul un changement de clé le remet à vide.
const [round, setRound] = useState(0);
function applyTool(tool: (typeof TOOLS)[number]) {
const field = bodyRef.current;
if (!field) return;
const { selectionStart, selectionEnd } = field;
const selected = field.value.slice(selectionStart, selectionEnd) || tool.sample;
// `setRangeText` conserve la pile d'annulation du navigateur, ce qu'une
// réécriture complète de la valeur ferait perdre.
const insert = "line" in tool && tool.line && selectionStart > 0 && field.value[selectionStart - 1] !== "\n";
field.setRangeText(`${insert ? "\n" : ""}${tool.before}${selected}${tool.after}`, selectionStart, selectionEnd, "select");
field.focus();
setBody(field.value);
}
async function submit(formData: FormData) {
setSaving(true);
setError(null);
@@ -99,44 +80,18 @@ export function InformationForm({ draft }: { draft: Draft }) {
style={{ marginBottom: 15, fontWeight: 700 }}
/>
<label className="field-label" htmlFor="information-body">Message</label>
<div
style={{
display: "flex", alignItems: "center", gap: 3, flexWrap: "wrap",
border: "1px solid #ddd2bb", borderBottom: "none", borderRadius: "10px 10px 0 0",
background: "#fff", padding: "6px 7px",
}}
>
{TOOLS.map((tool) => (
<button
key={tool.label}
type="button"
title={tool.title}
onClick={() => applyTool(tool)}
style={{
minWidth: 30, height: 28, padding: "0 9px", borderRadius: 7, border: "none", background: "transparent",
color: "#6c6150", fontSize: 12.5, fontWeight: 700, cursor: "pointer",
fontStyle: "italic" in tool && tool.italic ? "italic" : undefined,
}}
>
{tool.label}
</button>
))}
</div>
<textarea
id="information-body"
ref={bodyRef}
className="field"
name="body"
rows={10}
value={body}
onChange={(event) => setBody(event.target.value)}
placeholder={"Rédigez ici. Laissez une ligne vide entre deux paragraphes.\n\n- une puce\n**du gras**, [un lien](https://pleinr.fr)"}
style={{ borderRadius: "0 0 10px 10px", resize: "vertical", lineHeight: 1.7 }}
<label className="field-label" id="information-body-label">Message</label>
<RichTextEditor
value={draft.body}
onChange={setBody}
ariaLabel="Message de l'information"
placeholder="Rédigez votre message. Sélectionnez du texte puis cliquez sur G pour le mettre en gras."
/>
{/* Le format balisé ne se montre pas : il voyage dans un champ caché. */}
<input type="hidden" name="body" value={body} />
<p className="field-hint" style={{ marginTop: 6 }}>
Mise en forme simple : <strong>**gras**</strong>, <em>*italique*</em>, <code>- puce</code>,{" "}
<code>[libellé](https://…)</code>. Les liens s&apos;ouvrent dans un nouvel onglet.
Gras, italique, sous-titres, listes et liens. Le collage depuis un traitement de texte
conserve la mise en forme reconnue et laisse tomber le reste.
</p>
<div style={{ marginTop: 16 }}>
@@ -153,20 +108,6 @@ export function InformationForm({ draft }: { draft: Draft }) {
</span>
</label>
{body.trim() && (
<div style={{ marginTop: 18, borderTop: "1px solid #f0e8d6", paddingTop: 16 }}>
<div style={{ fontSize: 11, letterSpacing: "0.1em", textTransform: "uppercase", color: "#9a8d72", fontWeight: 800, marginBottom: 10 }}>
Aperçu
</div>
<div style={{ background: "#faf7ef", border: "1px solid #e6dcc6", borderRadius: 12, padding: "14px 16px" }}>
<h3 className="font-display" style={{ margin: "0 0 8px", fontWeight: 700, fontSize: 17, color: "#26201a" }}>
{title || "Sans titre"}
</h3>
<div className="rich-text">{richTextNodes(body)}</div>
</div>
</div>
)}
{error && (
<div role="alert" style={{ marginTop: 14, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>
{error}
+86
View File
@@ -1039,6 +1039,92 @@ img {
text-underline-offset: 2px;
}
/* Éditeur visuel des informations : ce qu'on voit est ce qui sera publié. La
zone reprend `.rich-text` pour le rythme des paragraphes et des listes, de
sorte que l'éditeur est son propre aperçu. */
.rich-editor-toolbar {
display: flex;
align-items: center;
gap: 3px;
flex-wrap: wrap;
border: 1px solid var(--field-border);
border-bottom: none;
border-radius: 10px 10px 0 0;
background: #fff;
padding: 6px 7px;
}
.rich-editor-tool {
min-width: 30px;
height: 28px;
padding: 0 9px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--muted);
font-family: inherit;
font-size: 12.5px;
font-weight: 700;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease;
}
.rich-editor-tool:hover {
background: var(--cream-3);
color: var(--ink-strong);
}
.rich-editor-tool.is-active {
background: var(--gold);
color: #33291d;
}
.rich-editor-separator {
width: 1px;
height: 18px;
margin: 0 5px;
background: var(--border-2);
}
.rich-editor-link {
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
border: 1px solid var(--field-border);
border-bottom: none;
background: var(--cream);
padding: 8px 9px;
}
.rich-editor-link .field {
flex: 1;
min-width: 180px;
background: #fff;
}
.rich-editor {
position: relative;
min-height: 190px;
border: 1px solid var(--field-border);
border-radius: 0 0 10px 10px;
background: var(--field-bg);
padding: 13px 15px;
overflow-y: auto;
cursor: text;
}
.rich-editor:focus {
outline: 2px solid rgba(44, 111, 179, 0.35);
outline-offset: 1px;
}
/* Le texte d'invite s'efface dès la première frappe : `:empty` ne suffit pas,
le navigateur laisse un paragraphe vide dans la zone. Il est posé en
superposition et non à la place du paragraphe : masquer celui-ci ferait
tomber le curseur à côté, et le navigateur écrirait du texte nu hors de tout
bloc. */
.rich-editor[data-empty="true"]::before {
content: attr(data-placeholder);
position: absolute;
left: 15px;
right: 15px;
top: 13px;
color: var(--muted-4);
pointer-events: none;
}
/* Rédaction d'une information : éditeur et listes côte à côte, empilés dès
que la colonne de droite n'a plus la place d'être lisible. */
@media (max-width: 1100px) {
+262
View File
@@ -0,0 +1,262 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { richTextToEditorHtml } from "@/lib/rich-text";
import { serializeToRichText } from "@/lib/rich-text-dom";
/**
* Éditeur visuel : on écrit, on sélectionne, on clique sur **G**, et le texte
* devient gras à l'écran. Aucune syntaxe à connaître.
*
* Ce qui part au serveur reste pourtant le format balisé restreint : à chaque
* frappe, `serializeToRichText` retraverse le contenu édité et n'en garde que
* le gras, l'italique, les listes, les sous-titres et les liens. Le HTML de
* l'éditeur ne quitte jamais la page — c'était là, et non dans l'édition
* visuelle, que se trouvait le risque.
*
* `document.execCommand` est officiellement déprécié mais reste implémenté par
* tous les navigateurs, et c'est la seule façon d'obtenir une édition riche
* sans embarquer un moteur d'édition complet. Le jour où il faudrait en
* changer, seul ce fichier bougerait : le contrat de stockage est le
* sérialiseur, pas l'éditeur.
*/
type Tool = {
key: string;
label: string;
title: string;
command: string;
argument?: string;
/** Nom d'état interrogé pour allumer le bouton quand le curseur est dedans. */
state?: string;
render?: "bold" | "italic";
};
const TOOLS: Tool[] = [
{ key: "bold", label: "G", title: "Gras (Ctrl+B)", command: "bold", state: "bold", render: "bold" },
{ key: "italic", label: "I", title: "Italique (Ctrl+I)", command: "italic", state: "italic", render: "italic" },
{ key: "h4", label: "Sous-titre", title: "Transformer la ligne en sous-titre", command: "formatBlock", argument: "<h4>" },
{ key: "ul", label: "Liste à puces", title: "Liste à puces", command: "insertUnorderedList", state: "insertUnorderedList" },
{ key: "ol", label: "Liste numérotée", title: "Liste numérotée", command: "insertOrderedList", state: "insertOrderedList" },
];
export function RichTextEditor({
value,
onChange,
placeholder = "Rédigez votre message…",
ariaLabel = "Message",
}: {
value: string;
onChange: (markup: string) => void;
placeholder?: string;
ariaLabel?: string;
}) {
const editorRef = useRef<HTMLDivElement>(null);
const [active, setActive] = useState<Record<string, boolean>>({});
const [linkUrl, setLinkUrl] = useState<string | null>(null);
const savedRange = useRef<Range | null>(null);
const sync = useCallback(() => {
const editor = editorRef.current;
if (!editor) return;
editor.dataset.empty = String(editor.textContent?.trim().length === 0);
onChange(serializeToRichText(editor));
}, [onChange]);
// Contenu initial posé une seule fois : réécrire le HTML à chaque frappe
// replacerait le curseur au début de la zone.
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
editor.innerHTML = richTextToEditorHtml(value);
editor.dataset.empty = String(editor.textContent?.trim().length === 0);
try {
// Sans cela, Chrome écrit `<span style="font-weight:bold">` au lieu de
// `<b>` — le sérialiseur, qui ne lit pas les styles, perdrait le gras.
document.execCommand("styleWithCSS", false, "false");
document.execCommand("defaultParagraphSeparator", false, "p");
} catch {
/* Navigateur sans execCommand : la saisie reste possible, sans mise en forme. */
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Les boutons s'allument selon l'endroit où se trouve le curseur.
useEffect(() => {
function refresh() {
const editor = editorRef.current;
if (!editor || !editor.contains(document.getSelection()?.anchorNode ?? null)) return;
const next: Record<string, boolean> = {};
for (const tool of TOOLS) {
if (!tool.state) continue;
try {
next[tool.key] = document.queryCommandState(tool.state);
} catch {
next[tool.key] = false;
}
}
setActive(next);
}
document.addEventListener("selectionchange", refresh);
return () => document.removeEventListener("selectionchange", refresh);
}, []);
function run(command: string, argument?: string) {
editorRef.current?.focus();
try {
document.execCommand(command, false, argument);
} catch {
/* Commande refusée : on laisse le texte tel quel. */
}
sync();
}
function applyTool(tool: Tool) {
// Re-cliquer sur « Sous-titre » revient à un paragraphe ordinaire.
if (tool.command === "formatBlock" && currentBlock() === "H4") {
run("formatBlock", "<p>");
return;
}
run(tool.command, tool.argument);
}
function currentBlock(): string {
try {
return String(document.queryCommandValue("formatBlock") ?? "").toUpperCase();
} catch {
return "";
}
}
function openLink() {
const selection = document.getSelection();
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
setLinkUrl("");
savedRange.current = null;
return;
}
// La sélection est perdue dès que le champ d'adresse prend le focus :
// on la met de côté pour la retrouver à la validation.
savedRange.current = selection.getRangeAt(0).cloneRange();
setLinkUrl("https://");
}
function confirmLink() {
const url = (linkUrl ?? "").trim();
setLinkUrl(null);
if (!url || !savedRange.current) return;
const selection = document.getSelection();
selection?.removeAllRanges();
selection?.addRange(savedRange.current);
savedRange.current = null;
// Une adresse non http(s) est écartée par le sérialiseur : le libellé
// reste, le lien disparaît.
run("createLink", url);
}
function onPaste(event: React.ClipboardEvent<HTMLDivElement>) {
event.preventDefault();
const html = event.clipboardData.getData("text/html");
const text = event.clipboardData.getData("text/plain");
if (html) {
// Le HTML collé — d'un Word, d'une page web — est analysé dans un
// document **inerte** : `DOMParser` n'exécute rien et ne charge aucune
// ressource, contrairement à une affectation d'`innerHTML`. On le
// repasse ensuite par notre format, donc seule la mise en forme connue
// survit ; on ne réinjecte que du HTML produit par nous.
const parsed = new DOMParser().parseFromString(html, "text/html");
run("insertHTML", richTextToEditorHtml(serializeToRichText(parsed.body)));
return;
}
run("insertText", text);
}
return (
<div>
<div className="rich-editor-toolbar" role="toolbar" aria-label="Mise en forme">
{TOOLS.map((tool) => (
<button
key={tool.key}
type="button"
title={tool.title}
aria-pressed={active[tool.key] ?? false}
onMouseDown={(event) => event.preventDefault()}
onClick={() => applyTool(tool)}
className={`rich-editor-tool${active[tool.key] ? " is-active" : ""}`}
style={{
fontWeight: tool.render === "bold" ? 800 : undefined,
fontStyle: tool.render === "italic" ? "italic" : undefined,
}}
>
{tool.label}
</button>
))}
<span className="rich-editor-separator" />
<button
type="button"
title="Insérer un lien sur le texte sélectionné"
onMouseDown={(event) => event.preventDefault()}
onClick={openLink}
className="rich-editor-tool"
>
Lien
</button>
<button
type="button"
title="Retirer la mise en forme de la sélection"
onMouseDown={(event) => event.preventDefault()}
onClick={() => run("removeFormat")}
className="rich-editor-tool"
>
Effacer la mise en forme
</button>
</div>
{linkUrl !== null && (
<div className="rich-editor-link">
{savedRange.current ? (
<>
<input
className="field"
value={linkUrl}
autoFocus
placeholder="https://…"
onChange={(event) => setLinkUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
confirmLink();
}
if (event.key === "Escape") setLinkUrl(null);
}}
/>
<button type="button" className="rich-editor-tool" onClick={confirmLink}>Ajouter</button>
<button type="button" className="rich-editor-tool" onClick={() => setLinkUrl(null)}>Annuler</button>
</>
) : (
<>
<span className="field-hint">Sélectionnez d&apos;abord le texte à transformer en lien.</span>
<button type="button" className="rich-editor-tool" onClick={() => setLinkUrl(null)}>Fermer</button>
</>
)}
</div>
)}
<div
ref={editorRef}
className="rich-editor rich-text"
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label={ariaLabel}
data-placeholder={placeholder}
onInput={sync}
onBlur={sync}
onPaste={onPaste}
/>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { safeHttpUrl } from "@/lib/rich-text";
/**
* Conversion d'un contenu édité visuellement vers le format balisé stocké.
*
* C'est la pièce qui permet un éditeur « comme dans Word » sans renoncer à
* l'invariant du produit : ce qui est **stocké** reste le sous-ensemble
* restreint de `src/lib/rich-text.ts`, jamais du HTML tiers. Le danger n'a
* jamais été la surface d'édition, mais la base de données — alors le
* navigateur retraverse ce qu'il a édité et n'en garde que le gras,
* l'italique, les listes, les sous-titres et les liens. Tout le reste — styles
* de Word, `<span>`, `<script>`, attributs d'événement — disparaît avant même
* de quitter la page.
*
* Le module est **pur** (`tests/rich-text-dom.test.ts`) : il ne connaît du DOM
* que `MinimalNode`, ce qui le rend testable sans navigateur.
*/
/** Ce que le sérialiseur a besoin de savoir d'un nœud. */
export type MinimalNode = {
nodeType: number;
nodeName: string;
textContent?: string | null;
childNodes: ArrayLike<MinimalNode>;
getAttribute?: (name: string) => string | null;
};
const ELEMENT_NODE = 1;
const TEXT_NODE = 3;
/** Éléments qui ouvrent une nouvelle ligne dans le format balisé. */
const BLOCKS = new Set(["P", "DIV", "LI", "BLOCKQUOTE", "H1", "H2", "H3", "H4", "H5", "H6", "SECTION", "ARTICLE"]);
/** Éléments dont le contenu ne doit jamais être lu, même comme texte. */
const DROPPED = new Set(["SCRIPT", "STYLE", "NOSCRIPT", "TEMPLATE", "IFRAME", "OBJECT", "SVG"]);
type LineKind = "paragraph" | "heading" | "bullet" | "ordered";
type Line = { kind: LineKind; text: string };
function nodeName(node: MinimalNode): string {
return node.nodeType === ELEMENT_NODE ? String(node.nodeName ?? "").toUpperCase() : "";
}
function children(node: MinimalNode): MinimalNode[] {
return Array.from(node.childNodes ?? []);
}
/** Texte d'un nœud, espaces insécables de l'éditeur ramenés à des espaces. */
function plain(value: string | null | undefined): string {
return String(value ?? "").replace(/\u00a0/g, " ").replace(/\r/g, "");
}
/** Contenu en ligne d'un nœud : gras, italique, liens, sauts de ligne. */
function inline(node: MinimalNode): string {
if (node.nodeType === TEXT_NODE) return plain(node.textContent);
if (node.nodeType !== ELEMENT_NODE) return "";
const name = nodeName(node);
if (DROPPED.has(name)) return "";
if (name === "BR") return "\n";
const inner = children(node).map(inline).join("");
// Un marqueur autour d'une sélection vide produirait « **** », que
// l'analyseur relirait comme du texte littéral.
if (!inner.trim()) return inner;
if (name === "A") {
const href = safeHttpUrl(node.getAttribute?.("href") ?? "");
return href ? `[${inner}](${href})` : inner;
}
const style = node.getAttribute?.("style") ?? "";
let marked = inner;
// L'italique en premier : le gras l'enveloppe, jamais l'inverse.
if (isItalic(name, style) && !wrapped(marked, "_")) marked = `_${marked}_`;
if (isBold(name, style) && !wrapped(marked, "**")) marked = `**${marked}**`;
return marked;
}
/** Déjà encadré par ce marqueur : ne pas le doubler. */
function wrapped(value: string, marker: string): boolean {
return value.startsWith(marker) && value.endsWith(marker) && value.length > marker.length * 2;
}
/**
* Un traitement de texte code souvent le gras en style plutôt qu'en balise :
* Google Docs écrit `<span style="font-weight:700">`. Sans cela, coller un
* brouillon rédigé ailleurs perdrait silencieusement toute sa mise en forme.
*
* Le style explicite l'emporte sur la balise, parce que Google Docs enveloppe
* aussi tout le document dans un `<b style="font-weight:normal">` — le prendre
* au mot mettrait le compte rendu entier en gras.
*/
function isBold(name: string, style: string): boolean {
if (/font-weight\s*:\s*(normal|[1-5]00)\b/i.test(style)) return false;
return name === "STRONG" || name === "B" || /font-weight\s*:\s*(bold(er)?|[6-9]00)\b/i.test(style);
}
/**
* L'italique est marqué par des tirets bas et non des étoiles :
* « **_gras italique_** » reste lisible par l'analyseur, là où « ***…*** »
* serait ambigu.
*/
function isItalic(name: string, style: string): boolean {
if (/font-style\s*:\s*normal\b/i.test(style)) return false;
return name === "EM" || name === "I" || /font-style\s*:\s*italic\b/i.test(style);
}
function push(lines: Line[], kind: LineKind, text: string) {
// Les espaces de bord disparaissent, les sauts de ligne internes restent :
// ce sont eux qui portent les retours à la ligne dans un paragraphe.
const cleaned = text.replace(/[ \t]+\n/g, "\n").replace(/\n[ \t]+/g, "\n").trim();
if (cleaned) lines.push({ kind, text: cleaned });
}
function walk(node: MinimalNode, lines: Line[], ordered: boolean) {
let buffer = "";
const flush = () => {
push(lines, "paragraph", buffer);
buffer = "";
};
for (const child of children(node)) {
const name = nodeName(child);
if (DROPPED.has(name)) continue;
if (name === "UL" || name === "OL") {
flush();
walk(child, lines, name === "OL");
continue;
}
if (name === "LI") {
flush();
push(lines, ordered ? "ordered" : "bullet", inline(child));
continue;
}
if (BLOCKS.has(name)) {
flush();
// Un `<div>` d'éditeur peut contenir d'autres blocs plutôt que du texte.
if (children(child).some((grandChild) => {
const grandName = nodeName(grandChild);
return BLOCKS.has(grandName) || grandName === "UL" || grandName === "OL";
})) {
walk(child, lines, ordered);
} else {
push(lines, /^H[1-6]$/.test(name) ? "heading" : "paragraph", inline(child));
}
continue;
}
// Texte nu et éléments en ligne : ils appartiennent au paragraphe courant.
buffer += inline(child);
}
flush();
}
function isList(kind: LineKind): boolean {
return kind === "bullet" || kind === "ordered";
}
/**
* Sérialise un fragment édité vers le format balisé.
*
* `root` est typiquement la `<div contenteditable>` de l'éditeur, ou le
* `body` d'un document inerte produit par `DOMParser` lors d'un collage.
*/
export function serializeToRichText(root: MinimalNode): string {
const lines: Line[] = [];
walk(root, lines, false);
let counter = 0;
return lines
.map((line, index) => {
const previous = lines[index - 1];
const continues = previous && isList(previous.kind) && previous.kind === line.kind;
counter = line.kind === "ordered" ? (continues ? counter + 1 : 1) : 0;
const prefix =
line.kind === "heading" ? "## " : line.kind === "bullet" ? "- " : line.kind === "ordered" ? `${counter}. ` : "";
// Les éléments d'une même liste se suivent ; tout le reste est séparé par
// une ligne vide, qui marque un nouveau paragraphe.
const separator = index === 0 ? "" : continues ? "\n" : "\n\n";
return `${separator}${prefix}${line.text}`;
})
.join("");
}
+81 -1
View File
@@ -11,10 +11,15 @@ import { createElement, Fragment } from "react";
* produirait du HTML, qu'il faudrait stocker puis assainir : deuxième
* dépendance, deuxième surface d'attaque.
*
* Grammaire : `**gras**`, `*italique*`, `- puce`, `1. numéro`,
* Grammaire : `**gras**`, `*italique*` ou `_italique_`, `- puce`, `1. numéro`,
* `[texte](https://…)`, `## sous-titre`, ligne vide = paragraphe.
* Un saut de ligne simple reste un saut de ligne.
*
* Ce balisage n'est **pas** montré à l'utilisateur : il écrit dans un éditeur
* visuel (`src/components/RichTextEditor.tsx`) et c'est le navigateur qui
* resérialise vers ce format (`src/lib/rich-text-dom.ts`) avant l'envoi. Le
* format reste donc le contrat de stockage, jamais une syntaxe à apprendre.
*
* Deux rendus, un seul analyseur : `richTextNodes()` pour l'écran,
* `richTextToEmailHtml()` pour le message. L'aperçu du formulaire passe par
* `richTextNodes()`, il est donc fidèle par construction.
@@ -46,6 +51,19 @@ export function safeHttpUrl(value: string): string {
}
}
/** Vrai en bord de mot : début, fin, espace ou ponctuation. */
function isWordEdge(char: string | undefined): boolean {
return char === undefined || !/[\p{L}\p{N}]/u.test(char);
}
/** Position du tiret bas fermant, ou -1 s'il n'y en a pas en bord de mot. */
function closingUnderscore(text: string, from: number): number {
for (let index = from; index < text.length; index += 1) {
if (text[index] === "_" && isWordEdge(text[index + 1])) return index;
}
return -1;
}
function parseInline(text: string): Inline[] {
const nodes: Inline[] = [];
let buffer = "";
@@ -77,6 +95,19 @@ function parseInline(text: string): Inline[] {
continue;
}
}
// Tiret bas accepté comme italique — c'est ce qu'émet le sérialiseur, pour
// éviter l'ambiguïté de « ***gras italique*** ». Il ne compte qu'aux
// frontières de mot, sans quoi « fichier_de_sauvegarde » deviendrait
// italique en son milieu.
if (text[index] === "_" && isWordEdge(text[index - 1])) {
const end = closingUnderscore(text, index + 1);
if (end > index + 1) {
flush();
nodes.push({ kind: "em", children: parseInline(text.slice(index + 1, end)) });
index = end + 1;
continue;
}
}
if (text[index] === "[") {
const close = text.indexOf("]", index + 1);
if (close > index && text[close + 1] === "(") {
@@ -316,3 +347,52 @@ export function richTextExcerpt(source: string, max = 160): string {
const lastSpace = cut.lastIndexOf(" ");
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
}
// ---- Rendu vers l'éditeur visuel ----
function inlineEditorHtml(nodes: Inline[]): string {
return nodes
.map((node) => {
switch (node.kind) {
case "break":
return "<br>";
case "strong":
return `<strong>${inlineEditorHtml(node.children)}</strong>`;
case "em":
return `<em>${inlineEditorHtml(node.children)}</em>`;
case "link":
return `<a href="${esc(node.href)}">${inlineEditorHtml(node.children)}</a>`;
default:
return esc(node.value);
}
})
.join("");
}
/**
* HTML minimal posé dans la zone d'édition visuelle.
*
* Produit exactement les balises que `serializeToRichText` sait relire, pour
* que l'aller-retour édition → stockage → édition soit stable. Un contenu vide
* rend un paragraphe avec un `<br>` : sans lui, le navigateur n'offre aucune
* ligne où poser le curseur.
*/
export function richTextToEditorHtml(source: string): string {
const blocks = parseRichText(source);
if (blocks.length === 0) return "<p><br></p>";
return blocks
.map((block) => {
switch (block.kind) {
case "heading":
return `<h4>${inlineEditorHtml(block.children)}</h4>`;
case "bullets":
return `<ul>${block.items.map((item) => `<li>${inlineEditorHtml(item)}</li>`).join("")}</ul>`;
case "ordered":
return `<ol>${block.items.map((item) => `<li>${inlineEditorHtml(item)}</li>`).join("")}</ol>`;
default:
return `<p>${inlineEditorHtml(block.children) || "<br>"}</p>`;
}
})
.join("");
}