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
+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("");
|
||||
}
|
||||
Reference in new issue
Block a user