mirror of
https://github.com/R0m1k3/PleinR.git
synced 2026-10-22 22:58:04 +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
|
journal, limitation des connexions, chiffrement des secrets, coordonnées du
|
||||||
référent, **mots de passe temporaires jamais mis en file**, secrets de
|
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
|
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)
|
## Référencement (SEO)
|
||||||
|
|
||||||
@@ -309,16 +311,38 @@ porte ce que publie le bureau depuis `/backend/informations`. Deux tables :
|
|||||||
`information_reads`.
|
`information_reads`.
|
||||||
|
|
||||||
- **Texte riche** : `src/lib/rich-text.ts` est **pur** (`tests/rich-text.test.ts`)
|
- **Texte riche** : `src/lib/rich-text.ts` est **pur** (`tests/rich-text.test.ts`)
|
||||||
et analyse un sous-ensemble de Markdown — `**gras**`, `*italique*`, `- puce`,
|
et analyse un sous-ensemble de Markdown — `**gras**`, `_italique_`, `- puce`,
|
||||||
`1. numéro`, `[texte](https://…)`, `## sous-titre`. Deux rendus, un seul
|
`1. numéro`, `[texte](https://…)`, `## sous-titre`. Trois rendus, un seul
|
||||||
analyseur : `richTextNodes()` pour l'écran (des éléments React, jamais
|
analyseur : `richTextNodes()` pour l'écran (des éléments React, jamais
|
||||||
`dangerouslySetInnerHTML`), `richTextToEmailHtml()` pour le message. L'aperçu
|
`dangerouslySetInnerHTML`), `richTextToEmailHtml()` pour le message,
|
||||||
du formulaire passe par le premier, il est donc fidèle par construction. Un
|
`richTextToEditorHtml()` pour remplir l'éditeur. Un lien hors `http`/`https`
|
||||||
lien hors `http`/`https` perd sa cible et ne garde que son libellé
|
perd sa cible et ne garde que son libellé (`safeHttpUrl`, partagé avec
|
||||||
(`safeHttpUrl`, partagé avec `email-templates.ts`).
|
`email-templates.ts`). L'italique s'écrit avec des tirets bas : `***x***`
|
||||||
- **Pas de WYSIWYG** : il produirait du HTML, qu'il faudrait stocker puis
|
serait ambigu pour l'analyseur, `**_x_**` ne l'est pas ; `*étoiles*` reste
|
||||||
assainir — seconde dépendance, seconde surface d'attaque. La saisie est un
|
accepté en lecture, et un tiret bas au milieu d'un mot
|
||||||
`<textarea>` avec une barre qui encadre la sélection (`setRangeText`).
|
(`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
|
- **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
|
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
|
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 id = Number(formData.get("id") ?? 0) || null;
|
||||||
const title = asString(formData, "title");
|
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 (!title) return { error: "Donnez un titre à cette information." };
|
||||||
if (!body) return { error: "Le message est vide." };
|
if (!body) return { error: "Le message est vide." };
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRef, useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ImageField } from "@/components/ImageField";
|
import { ImageField } from "@/components/ImageField";
|
||||||
import { richTextNodes } from "@/lib/rich-text";
|
import { RichTextEditor } from "@/components/RichTextEditor";
|
||||||
import { saveInformation } from "../actions";
|
import { saveInformation } from "../actions";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rédaction d'une information.
|
* Rédaction d'une information.
|
||||||
*
|
*
|
||||||
* Pas d'éditeur WYSIWYG : un `<textarea>` et des boutons qui encadrent la
|
* L'édition est **visuelle** : on sélectionne, on clique sur « G », le texte
|
||||||
* sélection, plus un aperçu rendu par **le même** analyseur que le site
|
* devient gras à l'écran. Aucune syntaxe n'est montrée — un rédacteur
|
||||||
* (`richTextNodes`). L'aperçu est donc fidèle par construction, et rien de ce
|
* d'association n'a pas à connaître Markdown.
|
||||||
* qui est saisi ne peut devenir du HTML.
|
*
|
||||||
|
* 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 = {
|
type Draft = {
|
||||||
@@ -24,18 +28,8 @@ type Draft = {
|
|||||||
pinned: boolean;
|
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 }) {
|
export function InformationForm({ draft }: { draft: Draft }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const bodyRef = useRef<HTMLTextAreaElement>(null);
|
|
||||||
const [title, setTitle] = useState(draft.title);
|
const [title, setTitle] = useState(draft.title);
|
||||||
const [body, setBody] = useState(draft.body);
|
const [body, setBody] = useState(draft.body);
|
||||||
const [pinned, setPinned] = useState(draft.pinned);
|
const [pinned, setPinned] = useState(draft.pinned);
|
||||||
@@ -45,19 +39,6 @@ export function InformationForm({ draft }: { draft: Draft }) {
|
|||||||
// seul un changement de clé le remet à vide.
|
// seul un changement de clé le remet à vide.
|
||||||
const [round, setRound] = useState(0);
|
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) {
|
async function submit(formData: FormData) {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -99,44 +80,18 @@ export function InformationForm({ draft }: { draft: Draft }) {
|
|||||||
style={{ marginBottom: 15, fontWeight: 700 }}
|
style={{ marginBottom: 15, fontWeight: 700 }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<label className="field-label" htmlFor="information-body">Message</label>
|
<label className="field-label" id="information-body-label">Message</label>
|
||||||
<div
|
<RichTextEditor
|
||||||
style={{
|
value={draft.body}
|
||||||
display: "flex", alignItems: "center", gap: 3, flexWrap: "wrap",
|
onChange={setBody}
|
||||||
border: "1px solid #ddd2bb", borderBottom: "none", borderRadius: "10px 10px 0 0",
|
ariaLabel="Message de l'information"
|
||||||
background: "#fff", padding: "6px 7px",
|
placeholder="Rédigez votre message. Sélectionnez du texte puis cliquez sur G pour le mettre en gras."
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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 }}
|
|
||||||
/>
|
/>
|
||||||
|
{/* 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 }}>
|
<p className="field-hint" style={{ marginTop: 6 }}>
|
||||||
Mise en forme simple : <strong>**gras**</strong>, <em>*italique*</em>, <code>- puce</code>,{" "}
|
Gras, italique, sous-titres, listes et liens. Le collage depuis un traitement de texte
|
||||||
<code>[libellé](https://…)</code>. Les liens s'ouvrent dans un nouvel onglet.
|
conserve la mise en forme reconnue et laisse tomber le reste.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
@@ -153,20 +108,6 @@ export function InformationForm({ draft }: { draft: Draft }) {
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</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 && (
|
{error && (
|
||||||
<div role="alert" style={{ marginTop: 14, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>
|
<div role="alert" style={{ marginTop: 14, color: "#d8472b", fontSize: 13, fontWeight: 700 }}>
|
||||||
{error}
|
{error}
|
||||||
|
|||||||
@@ -1039,6 +1039,92 @@ img {
|
|||||||
text-underline-offset: 2px;
|
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
|
/* 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. */
|
que la colonne de droite n'a plus la place d'être lisible. */
|
||||||
@media (max-width: 1100px) {
|
@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
|
* produirait du HTML, qu'il faudrait stocker puis assainir : deuxième
|
||||||
* dépendance, deuxième surface d'attaque.
|
* 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.
|
* `[texte](https://…)`, `## sous-titre`, ligne vide = paragraphe.
|
||||||
* Un saut de ligne simple reste un saut de ligne.
|
* 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,
|
* Deux rendus, un seul analyseur : `richTextNodes()` pour l'écran,
|
||||||
* `richTextToEmailHtml()` pour le message. L'aperçu du formulaire passe par
|
* `richTextToEmailHtml()` pour le message. L'aperçu du formulaire passe par
|
||||||
* `richTextNodes()`, il est donc fidèle par construction.
|
* `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[] {
|
function parseInline(text: string): Inline[] {
|
||||||
const nodes: Inline[] = [];
|
const nodes: Inline[] = [];
|
||||||
let buffer = "";
|
let buffer = "";
|
||||||
@@ -77,6 +95,19 @@ function parseInline(text: string): Inline[] {
|
|||||||
continue;
|
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] === "[") {
|
if (text[index] === "[") {
|
||||||
const close = text.indexOf("]", index + 1);
|
const close = text.indexOf("]", index + 1);
|
||||||
if (close > index && text[close + 1] === "(") {
|
if (close > index && text[close + 1] === "(") {
|
||||||
@@ -316,3 +347,52 @@ export function richTextExcerpt(source: string, max = 160): string {
|
|||||||
const lastSpace = cut.lastIndexOf(" ");
|
const lastSpace = cut.lastIndexOf(" ");
|
||||||
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
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,
|
parseRichText,
|
||||||
richTextExcerpt,
|
richTextExcerpt,
|
||||||
richTextNodes,
|
richTextNodes,
|
||||||
|
richTextToEditorHtml,
|
||||||
richTextToEmailHtml,
|
richTextToEmailHtml,
|
||||||
richTextToPlain,
|
richTextToPlain,
|
||||||
safeHttpUrl,
|
safeHttpUrl,
|
||||||
@@ -164,3 +165,34 @@ test("une saisie vide ne produit aucun bloc", () => {
|
|||||||
assert.equal(richTextToEmailHtml(""), "");
|
assert.equal(richTextToEmailHtml(""), "");
|
||||||
assert.equal(richTextExcerpt(""), "");
|
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/lib/rich-text.ts",
|
||||||
"../src/components/InformationCard.tsx",
|
"../src/components/InformationCard.tsx",
|
||||||
"../src/app/backend/informations/InformationForm.tsx",
|
"../src/app/backend/informations/InformationForm.tsx",
|
||||||
|
"../src/components/RichTextEditor.tsx",
|
||||||
"../src/app/backend/espace/informations/page.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"'));
|
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