mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-11 17:27:54 +02:00
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:
10 files changed
+956
-91
No files matched your search
@@ -206,7 +206,9 @@ même raison.
|
||||
journal, limitation des connexions, chiffrement des secrets, coordonnées du
|
||||
référent, **mots de passe temporaires jamais mis en file**, secrets de
|
||||
messagerie jamais renvoyés au navigateur, aucune copie cachée dans la chaîne
|
||||
d'envoi, et rien rendu en HTML brut côté informations.
|
||||
d'envoi, rien rendu en HTML brut côté informations, et **le HTML de l'éditeur
|
||||
visuel qui ne quitte jamais la page** (formulaire à champ caché, collage passé
|
||||
par `DOMParser`, réinjection limitée à `richTextToEditorHtml`).
|
||||
|
||||
## Référencement (SEO)
|
||||
|
||||
@@ -309,16 +311,38 @@ porte ce que publie le bureau depuis `/backend/informations`. Deux tables :
|
||||
`information_reads`.
|
||||
|
||||
- **Texte riche** : `src/lib/rich-text.ts` est **pur** (`tests/rich-text.test.ts`)
|
||||
et analyse un sous-ensemble de Markdown — `**gras**`, `*italique*`, `- puce`,
|
||||
`1. numéro`, `[texte](https://…)`, `## sous-titre`. Deux rendus, un seul
|
||||
et analyse un sous-ensemble de Markdown — `**gras**`, `_italique_`, `- puce`,
|
||||
`1. numéro`, `[texte](https://…)`, `## sous-titre`. Trois rendus, un seul
|
||||
analyseur : `richTextNodes()` pour l'écran (des éléments React, jamais
|
||||
`dangerouslySetInnerHTML`), `richTextToEmailHtml()` pour le message. L'aperçu
|
||||
du formulaire passe par le premier, il est donc fidèle par construction. Un
|
||||
lien hors `http`/`https` perd sa cible et ne garde que son libellé
|
||||
(`safeHttpUrl`, partagé avec `email-templates.ts`).
|
||||
- **Pas de WYSIWYG** : il produirait du HTML, qu'il faudrait stocker puis
|
||||
assainir — seconde dépendance, seconde surface d'attaque. La saisie est un
|
||||
`<textarea>` avec une barre qui encadre la sélection (`setRangeText`).
|
||||
`dangerouslySetInnerHTML`), `richTextToEmailHtml()` pour le message,
|
||||
`richTextToEditorHtml()` pour remplir l'éditeur. Un lien hors `http`/`https`
|
||||
perd sa cible et ne garde que son libellé (`safeHttpUrl`, partagé avec
|
||||
`email-templates.ts`). L'italique s'écrit avec des tirets bas : `***x***`
|
||||
serait ambigu pour l'analyseur, `**_x_**` ne l'est pas ; `*étoiles*` reste
|
||||
accepté en lecture, et un tiret bas au milieu d'un mot
|
||||
(`fichier_de_sauvegarde`) n'ouvre rien.
|
||||
- **La syntaxe ne se montre jamais** : `RichTextEditor` est un
|
||||
`contentEditable` avec une barre d'outils (G, I, Sous-titre, listes, lien).
|
||||
L'adhérent qui rédige voit du gras, pas des étoiles. Le balisage voyage dans
|
||||
un `<input type="hidden">`.
|
||||
- **Le WYSIWYG porte sur la saisie, pas sur le stockage.** C'est l'invariant à
|
||||
ne pas casser : à chaque frappe, `src/lib/rich-text-dom.ts`
|
||||
(`serializeToRichText`, **pur**, `tests/rich-text-dom.test.ts`) 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 : la
|
||||
base ne reçoit que le format balisé, que le serveur ré-analyse avec le même
|
||||
analyseur qu'avant. Aucun assainisseur HTML n'est donc nécessaire.
|
||||
Le module lit aussi le gras/italique **codés en style CSS**
|
||||
(`<span style="font-weight:700">` de Google Docs), le style explicite
|
||||
l'emportant sur la balise — sans quoi le `<b style="font-weight:normal">` dont
|
||||
Docs enveloppe tout document mettrait le texte entier en gras.
|
||||
- **Un collage est analysé par `DOMParser`**, jamais par une affectation
|
||||
d'`innerHTML` : le document produit est inerte, donc un `<img onerror>` collé
|
||||
ne s'exécute pas. Ce qui est réinjecté dans l'éditeur est du HTML **produit
|
||||
par nous** (`richTextToEditorHtml`), jamais celui du presse-papier.
|
||||
- **Les sauts de ligne sont normalisés à l'enregistrement** : l'encodage des
|
||||
formulaires HTML convertit tout `\n` en `\r\n`, et le balisage stocké
|
||||
différerait sinon de celui que l'éditeur a produit (`saveInformation`).
|
||||
- **Une seule image**, en couverture, jamais dans le corps : une data-URI
|
||||
recopiée dans chaque ligne de la file pèserait 3 Mo par destinataire. Elle est
|
||||
servie aux clients mail par `/api/informations/[id]/image`, qui ne répond que
|
||||
|
||||
@@ -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'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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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("");
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { serializeToRichText, type MinimalNode } from "../src/lib/rich-text-dom";
|
||||
import { parseRichText, richTextToEmailHtml, richTextToPlain } from "../src/lib/rich-text";
|
||||
|
||||
/**
|
||||
* Faux DOM minimal : le sérialiseur ne connaît du navigateur que `MinimalNode`,
|
||||
* ce qui permet de le tester sans jsdom.
|
||||
*/
|
||||
function el(name: string, children: MinimalNode[] = [], attributes: Record<string, string> = {}): MinimalNode {
|
||||
return {
|
||||
nodeType: 1,
|
||||
nodeName: name,
|
||||
childNodes: children,
|
||||
getAttribute: (key: string) => attributes[key] ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function txt(value: string): MinimalNode {
|
||||
return { nodeType: 3, nodeName: "#text", textContent: value, childNodes: [] };
|
||||
}
|
||||
|
||||
const root = (...children: MinimalNode[]) => el("DIV", children);
|
||||
|
||||
test("un paragraphe simple ressort tel quel", () => {
|
||||
assert.equal(serializeToRichText(root(el("P", [txt("Bonjour à tous.")]))), "Bonjour à tous.");
|
||||
});
|
||||
|
||||
test("deux paragraphes sont séparés par une ligne vide", () => {
|
||||
const markup = serializeToRichText(root(el("P", [txt("Premier.")]), el("P", [txt("Second.")])));
|
||||
assert.equal(markup, "Premier.\n\nSecond.");
|
||||
assert.equal(parseRichText(markup).length, 2);
|
||||
});
|
||||
|
||||
test("le gras et l'italique deviennent des marqueurs", () => {
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("P", [txt("Rendez-vous "), el("STRONG", [txt("mardi")]), txt(" à "), el("EM", [txt("Pompey")])]))),
|
||||
"Rendez-vous **mardi** à _Pompey_",
|
||||
);
|
||||
});
|
||||
|
||||
test("les balises B et I d'execCommand valent STRONG et EM", () => {
|
||||
assert.equal(serializeToRichText(root(el("P", [el("B", [txt("a")]), el("I", [txt("b")])]))), "**a**_b_");
|
||||
});
|
||||
|
||||
test("gras et italique imbriqués se relisent correctement", () => {
|
||||
// C'est la raison du tiret bas : « ***x*** » serait ambigu pour l'analyseur.
|
||||
const markup = serializeToRichText(root(el("P", [el("STRONG", [el("EM", [txt("très important")])])])));
|
||||
assert.equal(markup, "**_très important_**");
|
||||
const blocks = parseRichText(markup);
|
||||
assert.equal(blocks[0].kind, "paragraph");
|
||||
const strong = blocks[0].kind === "paragraph" ? blocks[0].children[0] : null;
|
||||
assert.equal(strong?.kind, "strong");
|
||||
assert.equal(strong?.kind === "strong" ? strong.children[0].kind : null, "em");
|
||||
});
|
||||
|
||||
test("un saut de ligne dans un paragraphe est conservé", () => {
|
||||
const markup = serializeToRichText(root(el("P", [txt("Une ligne"), el("BR"), txt("une autre")])));
|
||||
assert.equal(markup, "Une ligne\nune autre");
|
||||
assert.equal(parseRichText(markup).length, 1);
|
||||
});
|
||||
|
||||
test("les listes à puces et numérotées prennent leur préfixe", () => {
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("UL", [el("LI", [txt("un")]), el("LI", [txt("deux")])]))),
|
||||
"- un\n- deux",
|
||||
);
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("OL", [el("LI", [txt("a")]), el("LI", [txt("b")]), el("LI", [txt("c")])]))),
|
||||
"1. a\n2. b\n3. c",
|
||||
);
|
||||
});
|
||||
|
||||
test("la numérotation repart à un sur une seconde liste", () => {
|
||||
const markup = serializeToRichText(
|
||||
root(
|
||||
el("OL", [el("LI", [txt("a")])]),
|
||||
el("P", [txt("Entre les deux.")]),
|
||||
el("OL", [el("LI", [txt("b")])]),
|
||||
),
|
||||
);
|
||||
assert.equal(markup, "1. a\n\nEntre les deux.\n\n1. b");
|
||||
});
|
||||
|
||||
test("un titre devient un sous-titre", () => {
|
||||
assert.equal(serializeToRichText(root(el("H4", [txt("Ordre du jour")]))), "## Ordre du jour");
|
||||
// Les autres niveaux de titre, venus d'un collage, se rabattent sur le même.
|
||||
assert.equal(serializeToRichText(root(el("H1", [txt("Collé depuis le web")]))), "## Collé depuis le web");
|
||||
});
|
||||
|
||||
test("un lien http(s) garde sa cible, les autres perdent la leur", () => {
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("P", [el("A", [txt("le site")], { href: "https://pleinr.fr/" })]))),
|
||||
"[le site](https://pleinr.fr/)",
|
||||
);
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("P", [el("A", [txt("piège")], { href: "javascript:alert(1)" })]))),
|
||||
"piège",
|
||||
);
|
||||
assert.equal(serializeToRichText(root(el("P", [el("A", [txt("sans cible")])]))), "sans cible");
|
||||
});
|
||||
|
||||
test("tout ce qui n'est pas reconnu perd sa balise et garde son texte", () => {
|
||||
// Couleur, taille, police : rien de tout cela n'existe dans le format stocké.
|
||||
const markup = serializeToRichText(
|
||||
root(el("P", [el("SPAN", [txt("du texte")], { style: "color:red;font-size:32px" }), el("FONT", [txt(" coloré")], { color: "blue" })])),
|
||||
);
|
||||
assert.equal(markup, "du texte coloré");
|
||||
});
|
||||
|
||||
test("le contenu exécutable est retiré, texte compris", () => {
|
||||
const markup = serializeToRichText(
|
||||
root(
|
||||
el("SCRIPT", [txt("alert(1)")]),
|
||||
el("STYLE", [txt("body{display:none}")]),
|
||||
el("P", [txt("Bonjour"), el("IFRAME", [txt("piège")])]),
|
||||
),
|
||||
);
|
||||
assert.equal(markup, "Bonjour");
|
||||
assert.ok(!markup.includes("alert"));
|
||||
assert.ok(!markup.includes("display:none"));
|
||||
});
|
||||
|
||||
test("un collage de Word — div imbriqués et styles — se ramène au texte structuré", () => {
|
||||
const pasted = root(
|
||||
el("DIV", [
|
||||
el("P", [el("SPAN", [txt("Compte rendu")], { style: "font-size:24px" })]),
|
||||
el("UL", [el("LI", [el("SPAN", [el("B", [txt("Point 1")])])]), el("LI", [txt("Point 2")])]),
|
||||
]),
|
||||
);
|
||||
assert.equal(serializeToRichText(pasted), "Compte rendu\n\n- **Point 1**\n- Point 2");
|
||||
});
|
||||
|
||||
test("les espaces insécables de l'éditeur redeviennent des espaces", () => {
|
||||
assert.equal(serializeToRichText(root(el("P", [txt("19\u00a0h\u00a0précises")]))), "19 h précises");
|
||||
});
|
||||
|
||||
test("une mise en forme appliquée à une sélection vide ne produit pas de marqueur", () => {
|
||||
// Chrome laisse un `<b></b>` quand on active le gras sans rien taper.
|
||||
assert.equal(serializeToRichText(root(el("P", [el("B", []), txt("texte")]))), "texte");
|
||||
assert.ok(!serializeToRichText(root(el("P", [el("B", [txt(" ")]), txt("x")]))).includes("**"));
|
||||
});
|
||||
|
||||
test("un éditeur vide produit une chaîne vide", () => {
|
||||
assert.equal(serializeToRichText(root()), "");
|
||||
assert.equal(serializeToRichText(root(el("P", [el("BR")]))), "");
|
||||
assert.equal(serializeToRichText(root(el("P", [txt(" ")]))), "");
|
||||
});
|
||||
|
||||
test("l'aller-retour édition → stockage → rendu conserve le sens", () => {
|
||||
const markup = serializeToRichText(
|
||||
root(
|
||||
el("H4", [txt("Assemblée générale")]),
|
||||
el("P", [txt("Elle se tiendra "), el("STRONG", [txt("salle Jean-Jaurès")]), txt(".")]),
|
||||
el("UL", [el("LI", [txt("comptes 2026")]), el("LI", [txt("budget")])]),
|
||||
el("P", [el("A", [txt("le compte rendu")], { href: "https://pleinr.fr/association" })]),
|
||||
),
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
markup,
|
||||
"## Assemblée générale\n\nElle se tiendra **salle Jean-Jaurès**.\n\n- comptes 2026\n- budget\n\n[le compte rendu](https://pleinr.fr/association)",
|
||||
);
|
||||
|
||||
const html = richTextToEmailHtml(markup);
|
||||
assert.ok(html.includes("<strong"));
|
||||
assert.ok(html.includes("<ul"));
|
||||
assert.ok(html.includes('href="https://pleinr.fr/association"'));
|
||||
assert.ok(richTextToPlain(markup).includes("Assemblée générale"));
|
||||
});
|
||||
|
||||
test("le gras codé en style CSS est reconnu — cas Google Docs", () => {
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("P", [el("SPAN", [txt("important")], { style: "font-weight:700" })]))),
|
||||
"**important**",
|
||||
);
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("P", [el("SPAN", [txt("nuance")], { style: "font-style: italic" })]))),
|
||||
"_nuance_",
|
||||
);
|
||||
});
|
||||
|
||||
test("le style explicite l'emporte sur la balise", () => {
|
||||
// Google Docs enveloppe tout le document dans un `<b style="font-weight:normal">`.
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("B", [el("P", [txt("tout le document")])], { style: "font-weight:normal" }))),
|
||||
"tout le document",
|
||||
);
|
||||
});
|
||||
|
||||
test("un marqueur n'est jamais doublé", () => {
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("P", [el("B", [el("SPAN", [txt("x")], { style: "font-weight:bold" })])]))),
|
||||
"**x**",
|
||||
);
|
||||
});
|
||||
|
||||
test("gras et italique combinés s'imbriquent dans le bon ordre", () => {
|
||||
assert.equal(
|
||||
serializeToRichText(root(el("P", [el("SPAN", [txt("les deux")], { style: "font-weight:700;font-style:italic" })]))),
|
||||
"**_les deux_**",
|
||||
);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
parseRichText,
|
||||
richTextExcerpt,
|
||||
richTextNodes,
|
||||
richTextToEditorHtml,
|
||||
richTextToEmailHtml,
|
||||
richTextToPlain,
|
||||
safeHttpUrl,
|
||||
@@ -164,3 +165,34 @@ test("une saisie vide ne produit aucun bloc", () => {
|
||||
assert.equal(richTextToEmailHtml(""), "");
|
||||
assert.equal(richTextExcerpt(""), "");
|
||||
});
|
||||
|
||||
test("le tiret bas vaut italique, mais seulement en bord de mot", () => {
|
||||
assert.ok(richTextToEmailHtml("_doux_").includes("<em>doux</em>"));
|
||||
assert.ok(richTextToEmailHtml("**_gras italique_**").includes("<em>gras italique</em>"));
|
||||
// Un nom de fichier ne doit pas devenir italique en son milieu.
|
||||
assert.equal(richTextToPlain("fichier_de_sauvegarde.pdf"), "fichier_de_sauvegarde.pdf");
|
||||
assert.ok(!richTextToEmailHtml("fichier_de_sauvegarde.pdf").includes("<em>"));
|
||||
assert.equal(richTextToPlain("un _ seul"), "un _ seul");
|
||||
});
|
||||
|
||||
test("le rendu vers l'éditeur reprend les balises que le sérialiseur sait relire", () => {
|
||||
const html = richTextToEditorHtml("## Titre\n\nDu **gras** et un [lien](https://pleinr.fr)\n\n- un\n- deux\n\n1. a");
|
||||
assert.ok(html.includes("<h4>Titre</h4>"));
|
||||
assert.ok(html.includes("<strong>gras</strong>"));
|
||||
assert.ok(html.includes('<a href="https://pleinr.fr/">lien</a>'));
|
||||
assert.ok(html.includes("<ul><li>un</li><li>deux</li></ul>"));
|
||||
assert.ok(html.includes("<ol><li>a</li></ol>"));
|
||||
});
|
||||
|
||||
test("un contenu vide offre une ligne où écrire", () => {
|
||||
assert.equal(richTextToEditorHtml(""), "<p><br></p>");
|
||||
assert.equal(richTextToEditorHtml(" "), "<p><br></p>");
|
||||
});
|
||||
|
||||
test("le rendu vers l'éditeur échappe le texte et refuse les cibles hostiles", () => {
|
||||
const html = richTextToEditorHtml('<script>alert(1)</script> & "guillemets"');
|
||||
assert.ok(html.includes("<script>"));
|
||||
assert.ok(html.includes("&"));
|
||||
assert.ok(!html.includes("<script"));
|
||||
assert.ok(!richTextToEditorHtml("[x](javascript:alert(1))").includes("<a "));
|
||||
});
|
||||
@@ -277,6 +277,7 @@ describe("Informations — rien n'est rendu en HTML brut", () => {
|
||||
"../src/lib/rich-text.ts",
|
||||
"../src/components/InformationCard.tsx",
|
||||
"../src/app/backend/informations/InformationForm.tsx",
|
||||
"../src/components/RichTextEditor.tsx",
|
||||
"../src/app/backend/espace/informations/page.tsx",
|
||||
];
|
||||
|
||||
@@ -320,3 +321,46 @@ describe("Boucles de fond — jamais dans le bundle edge", () => {
|
||||
assert.ok(worker.includes('process.env.MAIL_WORKER !== "off"'));
|
||||
});
|
||||
});
|
||||
|
||||
describe("Éditeur visuel — le HTML ne quitte jamais la page", () => {
|
||||
const editor = readFileSync(new URL("../src/components/RichTextEditor.tsx", import.meta.url), "utf8");
|
||||
const form = readFileSync(new URL("../src/app/backend/informations/InformationForm.tsx", import.meta.url), "utf8");
|
||||
|
||||
// L'édition est visuelle, mais ce qui part au serveur reste le format
|
||||
// balisé restreint : c'est tout l'arrangement qui permet un éditeur
|
||||
// confortable sans stocker de HTML tiers.
|
||||
it("le formulaire envoie le balisage sérialisé, jamais du HTML", () => {
|
||||
assert.ok(form.includes('<input type="hidden" name="body"'), "le corps doit voyager en champ caché");
|
||||
assert.ok(!form.includes("innerHTML"), "le formulaire ne manipule pas de HTML");
|
||||
assert.ok(editor.includes("serializeToRichText("), "l'éditeur doit resérialiser ce qu'il édite");
|
||||
});
|
||||
|
||||
it("seul du HTML produit par nous est injecté dans la zone d'édition", () => {
|
||||
// Toute écriture de HTML dans l'éditeur passe par notre générateur : jamais
|
||||
// une chaîne venue du presse-papier ou du serveur.
|
||||
for (const call of editor.match(/innerHTML\s*=\s*[^;\n]+/g) ?? []) {
|
||||
assert.ok(call.includes("richTextToEditorHtml("), `HTML injecté sans générateur : ${call}`);
|
||||
}
|
||||
for (const call of editor.match(/insertHTML"[^)]*\)/g) ?? []) {
|
||||
assert.ok(call.includes("richTextToEditorHtml("), `HTML inséré sans générateur : ${call}`);
|
||||
}
|
||||
});
|
||||
|
||||
it("le collage est analysé dans un document inerte", () => {
|
||||
// `DOMParser` n'exécute rien et ne charge aucune ressource, contrairement
|
||||
// à une affectation d'`innerHTML` — même sur un nœud détaché, un
|
||||
// `<img onerror>` peut s'y déclencher.
|
||||
assert.ok(editor.includes('getData("text/html")'), "le collage HTML doit être traité");
|
||||
assert.ok(editor.includes("new DOMParser().parseFromString("), "le HTML collé doit passer par DOMParser");
|
||||
const pasteIndex = editor.indexOf('getData("text/html")');
|
||||
const parserIndex = editor.indexOf("new DOMParser().parseFromString(");
|
||||
assert.ok(parserIndex > pasteIndex, "le HTML collé doit être analysé avant toute réinjection");
|
||||
});
|
||||
|
||||
it("le sérialiseur écarte les balises exécutables", () => {
|
||||
const serializer = readFileSync(new URL("../src/lib/rich-text-dom.ts", import.meta.url), "utf8");
|
||||
for (const tag of ["SCRIPT", "STYLE", "IFRAME", "OBJECT"]) {
|
||||
assert.ok(serializer.includes(`"${tag}"`), `${tag} devrait être explicitement écarté`);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user