feat(BlockEditor): add inline formatting with rich content model

- migrate block content from plain strings to InlineNode[] structure
- add inline toolbar (bold, italic, code, color, link) on text selection
- add checklist block type with toggle support
- add image block type (URL-based, phase 2)
- add inline serialization helpers (inlineToDom, domToInline)
- add inline types and length utilities
- extend caret utils with range get/set support
- update block registry and all existing block types for new content model
- update demo blocks in ComponentsPage to use rich inline content
- update README to reflect new architecture
This commit is contained in:
2026-04-25 18:27:20 -04:00
parent 3eeaebfa68
commit 5a8d2ad02f
19 changed files with 1244 additions and 126 deletions
+95 -22
View File
@@ -16,23 +16,70 @@ const [blocks, setBlocks] = useState([]);
`value` est un **tableau de blocs JSON**. C'est la source de vérité.
`MarkdownEditor` reste disponible en parallèle pour les usages markdown.
## Format des blocs (Phase 1)
## Format des blocs
Chaque bloc a un `id` (UUID) et un `type`. Selon le type :
| type | champs | description |
|----------------|---------------|----------------------------|
| `paragraph` | `content` | texte brut |
| `heading_1..6` | `content` | titre niveau 1 à 6 |
| `bullet_item` | `content` | élément de liste à puces |
| `numbered_item`| `content` | élément de liste numérotée |
| `quote` | `content` | citation |
| `code` | `content` | bloc de code (monospace) |
| `divider` | — | séparateur horizontal |
| type | champs | description |
|----------------|---------------------------------|----------------------------------|
| `paragraph` | `content` | texte |
| `heading_1..6` | `content` | titre niveau 1 à 6 |
| `bullet_item` | `content` | élément de liste à puces |
| `numbered_item`| `content` | élément de liste numérotée |
| `checklist` | `content`, `checked` | case à cocher + texte |
| `quote` | `content` | citation |
| `code` | `content` | bloc de code (monospace) |
| `divider` | — | séparateur horizontal |
| `image` | `src`, `alt`, `caption` | image (URL uniquement) |
Phase 2 ajoutera : `checklist`, `image`, et le format de `content` passera de
`string` à `InlineNode[]` pour supporter le formatting inline (gras, italique,
couleur, lien). Phase 3 : `table`.
`content` est un **tableau `InlineNode[]`** depuis Phase 2 — voir ci-dessous.
Phase 3 (à venir) : `table`.
## Format `InlineNode`
Le contenu inline d'un bloc texte est un tableau plat de nœuds `text`.
Chaque nœud porte optionnellement des **marks** (formatage).
```js
[
{ type: 'text', text: 'Salut ' },
{ type: 'text', text: 'monde', marks: [{ type: 'bold' }] },
{ type: 'text', text: ' ' },
{ type: 'text', text: 'lien', marks: [{ type: 'link', href: 'https://…' }] },
]
```
### Marks supportées
| type | payload | rendu |
|---|---|---|
| `bold` | — | `<strong>` |
| `italic` | — | `<em>` |
| `underline` | — | `<u>` |
| `strike` | — | `<s>` |
| `code` | — | `<code>` (monospace, fond gris) |
| `link` | `href: string` | `<a href>` (target="_blank") |
| `color` | `color: 'blue' \| 'green' \| 'amber' \| 'red'` | couleur du texte |
| `highlight` | `color: 'blue' \| 'green' \| 'amber' \| 'red'` | surlignage de fond |
Les couleurs sont des **clés de palette** (pas de hex libre) — résolues vers
les classes Tailwind du DESIGN system. Voir `inline/types.js:INLINE_COLORS`.
Le contenu vide est `[]` (jamais `[{type:'text', text:''}]`).
### Helpers exportés
```js
import {
inlineLength, inlineToPlainText, inlineFromText,
sliceInline, concatInline, applyMark, toggleMark,
marksAtOffset, marksInRange, INLINE_COLORS,
} from '@zen/core/shared/components/BlockEditor';
```
Tous les helpers sont **purs** : ils retournent un nouveau tableau normalisé
(nœuds adjacents identiques fusionnés, vides supprimés).
## Props
@@ -47,18 +94,37 @@ couleur, lien). Phase 3 : `table`.
## Interactions clavier
- `/` → ouvre le menu de commandes (filtrable au clavier, ↑ ↓ Entrée pour valider, Échap pour fermer)
- `/` → ouvre le menu de commandes (filtrable, ↑ ↓ Entrée pour valider, Échap pour fermer)
- `# ` → titre 1, `## ` → 2, …, `###### ` → 6
- `- ` → liste à puces
- `1. ` → liste numérotée
- `[] ` ou `[ ] ` → case à cocher
- `> ` → citation
- ` ``` ` → bloc de code
- `---` → séparateur
- `Ctrl/Cmd + B` → gras (sur sélection non vide)
- `Ctrl/Cmd + I` → italique
- `Ctrl/Cmd + U` → soulignement
- `Ctrl/Cmd + E` → code inline
- `Ctrl/Cmd + K` → lien (prompt pour l'URL)
- `Backspace` au début d'un bloc typé → repasse en paragraphe ; au début d'un paragraphe, fusionne avec le bloc précédent (uniquement si la sélection est repliée — sinon le navigateur supprime le texte sélectionné, ex. après `Ctrl+A`)
- `Entrée` sur un item de liste vide → sort de la liste
- `Ctrl/Cmd + Z` / `Ctrl/Cmd + Shift + Z` → undo / redo
- `Ctrl/Cmd + A` → 1er appui : sélectionne le contenu du bloc courant ; 2e appui : sélectionne **tous les blocs** (mode sélection multi-blocs)
## Toolbar de formatage
Quand une sélection non-vide existe dans un bloc, un toolbar flottant
apparaît au-dessus. Il propose :
- **B I U S `</>`** — marks simples (toggle)
- **A** — couleur du texte (popover de la palette)
- **◐** — surlignage (popover)
- **🔗** — lien (popover avec input URL ; ✕ pour retirer)
L'état actif est calculé à partir des marks **communes à toute la plage**
(via `marksInRange`). Toggle off si toute la plage est déjà marquée.
## Sélection multi-blocs
Deux façons d'entrer en mode sélection multi-blocs :
@@ -70,7 +136,7 @@ En mode sélection multi-blocs :
- `Backspace` / `Delete` → supprime tous les blocs sélectionnés
- `Escape` → quitte la sélection
- `Ctrl/Cmd + A` → étend à tous les blocs (no-op si déjà tous sélectionnés)
- `Ctrl/Cmd + C` / `Ctrl/Cmd + X` → copie/coupe le texte concaténé
- `Ctrl/Cmd + C` / `Ctrl/Cmd + X` → copie/coupe le texte concaténé (texte plat)
- frappe d'un caractère imprimable → remplace les blocs sélectionnés par un nouveau paragraphe contenant ce caractère
- clic dans l'éditeur → quitte la sélection
@@ -109,24 +175,31 @@ registerBlock({
Le nouveau type apparaît automatiquement dans le slash menu de tout
`<BlockEditor />` rendu après l'enregistrement. Pour les blocs **texte**, on
fournit à la place `isText: true`, `textTag`, `textClassName`, et optionnellement
`renderPrefix({ block, index, numberedIndex })` pour un préfixe (puce, numéro).
fournit à la place `isText: true`, `textTag`, `textClassName`, et
optionnellement `renderPrefix({ block, onPatch, index, numberedIndex, disabled })`
pour un préfixe (puce, numéro, case à cocher). Le `content` initial doit
être `[]` (un `InlineNode[]` vide).
## Architecture interne
```
BlockEditor.client.js orchestrateur : value/onChange, undo, slash menu, drag-drop
BlockEditor.client.js orchestrateur : value/onChange, undo, slash menu, drag-drop, toolbar
Block.client.js wrapper d'un bloc : handles, contentEditable, paste sanitize
SlashMenu.client.js menu flottant filtrable
blockRegistry.js map type → définition, API publique d'extension
blockTypes/ un fichier par type built-in
inline/types.js InlineNode[] : palette, helpers purs (slice, concat, marks)
inline/serialize.js DOM ↔ InlineNode[] (inlineToDom / domToInline)
inline/Toolbar.client.js barre flottante de formatage
utils/ids.js UUID pour les blocs
utils/caret.js gestion du caret dans un contentEditable
utils/caret.js gestion du caret (multi-Text-nodes)
```
## Limitations connues (Phase 1)
## Limitations connues
- Inline formatting (gras, italique, couleur, lien) **pas encore** : tout est texte brut. Phase 2.
- Pas d'imbrication de listes.
- Paste : seul le texte brut est conservé (sanitize HTML).
- Paste : seul le texte brut est conservé (sanitize HTML). Le formatage
inline copié depuis l'extérieur n'est pas préservé.
- Image : URL uniquement, pas d'upload de fichier (Phase 2). La caption est
une string plate (pas de formatage inline pour l'instant).
- Tables : Phase 3.