- import `notionJsonToBlocks` in `Block.client.js` and prioritize `text/_notion-blocks-v3-production` mime over `text/html` on paste - implement `notionJsonToBlocks` and `notionValueToBlock` in `clipboard.js` to convert notion block json to editor blocks, preserving native types (`to_do`, `sub_sub_header`, etc.) - update README to document the notion mime priority in paste handling
12 KiB
BlockEditor
Éditeur WYSIWYG par blocs, style Notion. Construit en interne (pas de
ProseMirror/Lexical/Tiptap). Un contentEditable par bloc, pas un seul
contentEditable global — c'est plus robuste et plus simple à étendre.
Utilisation
import { BlockEditor } from '@zen/core/shared/components';
const [blocks, setBlocks] = useState([]);
<BlockEditor value={blocks} onChange={setBlocks} label="Contenu" />
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
Chaque bloc a un id (UUID) et un type. Selon le type :
| 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) |
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).
[
{ 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: <key> | '#rrggbb' |
couleur du texte |
highlight |
color: <key> | '#rrggbb' |
surlignage de fond |
Le champ color accepte soit une clé de palette (blue, green, amber,
red) — résolue vers les classes Tailwind du DESIGN system —, soit une
string hex #rrggbb choisie par l'utilisateur, appliquée via style inline.
Voir inline/types.js:INLINE_COLORS et isHexColor.
Le contenu vide est [] (jamais [{type:'text', text:''}]).
Helpers exportés
import {
inlineLength, inlineToPlainText, inlineFromText,
sliceInline, concatInline, applyMark, toggleMark, removeAllMarks,
marksAtOffset, marksInRange, INLINE_COLORS,
isHexColor, collectUsedColors,
blocksToHtml, blocksToPlainText, htmlToBlocks,
} 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
<BlockEditor
value={blocks} // Block[]
onChange={setBlocks} // (Block[]) => void
label, error, placeholder, disabled, className
enabledBlocks={[...]} // optionnel : restreindre les types disponibles
/>
Interactions clavier
/→ ouvre le menu de commandes (filtrable, ↑ ↓ Entrée pour valider, Échap pour fermer)#→ titre 1,##→ 2, …,######→ 6-→ liste à puces1.→ liste numérotée[]ou[ ]→ case à cocher>→ citation```→ bloc de code---→ séparateurCtrl/Cmd + B→ gras (sur sélection non vide)Ctrl/Cmd + I→ italiqueCtrl/Cmd + U→ soulignementCtrl/Cmd + E→ code inlineCtrl/Cmd + K→ lien (prompt pour l'URL)Backspaceau 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èsCtrl+A)Entréesur un item de liste vide → sort de la listeCtrl/Cmd + Z/Ctrl/Cmd + Shift + Z→ undo / redoCtrl/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 : palette par défaut + couleurs déjà
utilisées dans le document + bouton
+pour une couleur libre via<input type="color">) - ◐ — surlignage (même structure de popover)
- 🔗 — lien (popover avec input URL ; ✕ pour retirer)
- T/ — effacer tout le formatage de la sélection (supprime toutes les marks)
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 :
- Souris : un drag qui traverse plusieurs blocs bascule automatiquement en sélection bloc (les contenteditables sont défocus, surlignage bleu transparent sur les blocs sélectionnés). Évite la fusion accidentelle de texte entre blocs lors d'un Backspace/Delete.
- Clavier : double
Ctrl/Cmd + A(cf. ci-dessus).
En mode sélection multi-blocs :
Backspace/Delete→ supprime tous les blocs sélectionnésEscape→ quitte la sélectionCtrl/Cmd + A→ étend à tous les blocs (no-op si déjà tous sélectionnés)Ctrl/Cmd + C/Ctrl/Cmd + X→ copie/coupe les blocs sélectionnés au format HTML structuré (titres, gras, listes, citations, …) + fallbacktext/plain. Collable dans Word, Google Docs, Slack, ou un autreBlockEditor.Ctrl/Cmd + V→ remplace les blocs sélectionnés par le contenu HTML du presse-papier (parsé parhtmlToBlocks).- 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
Poignées au survol
Chaque bloc affiche au survol deux boutons à gauche, chacun ouvrant un
dropdown manuel (state local + fermeture sur clic extérieur / Escape) :
Add01Icon(« + ») — ouvre le menu d'insertion : liste tous les types disponibles (filtrés parenabledBlocks) avec une icône en boîte. Le clic sur un type insère le bloc juste en dessous du bloc courant.DragDropVerticalIcon— double rôle : presser-glisser pour réordonner (drag-and-drop HTML5 natif), simple clic pour ouvrir le menu d'actions.
Les icônes proviennent de src/shared/icons/index.js.
Menu d'actions du bloc
- Transformer ▸ — sous-menu qui s'ouvre au survol, listant les types de
blocs texte disponibles (paragraphe, titres 1 à 6, listes, citation, code)
avec une icône en boîte. Cliquer sur un type remplace le bloc courant en
conservant son contenu inline. L'item est masqué pour les blocs non-texte
(image, séparateur). Le filtrage respecte la prop
enabledBlocks. - Dupliquer — insère une copie du bloc juste en dessous (nouvel
id). - Supprimer — retire le bloc (équivalent à
Backspaceau début d'un bloc vide), avec focus replacé sur le bloc précédent.
Le drag (mousedown + déplacement) et le clic (ouverture du menu) cohabitent
sur le même bouton : si un dragstart réel se produit, un drapeau interne
(justDraggedRef) supprime l'ouverture du menu lors du click qui suit.
Sinon (clic sans déplacement), le menu s'ouvre normalement. Les dropdowns
sont des composants maison (pas de Headless UI ici) car MenuButton ouvrait
sur pointerdown, ce qui empêchait le clic-maintenu nécessaire au drag.
Étendre — enregistrer un bloc custom
import { registerBlock, newBlockId } from '@zen/core/shared/components/BlockEditor';
registerBlock({
type: 'kpi',
label: 'KPI',
icon: <ChartIcon width={18} height={18} />,
keywords: ['kpi', 'metric', 'stat'],
isText: false,
create: () => ({ id: newBlockId(), type: 'kpi', value: 0 }),
Component: ({ block, onChange, disabled }) => (
<input
type="number"
value={block.value}
disabled={disabled}
onChange={(e) => onChange({ value: Number(e.target.value) })}
/>
),
});
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, 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, 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 (multi-Text-nodes)
Copier-coller avec formatage
Le presse-papier transporte deux MIME en parallèle : text/html (structure
- formatage) et
text/plain(fallback). Côté éditeur :
- Copy / Cut :
blocksToHtml(selected)produit un HTML standard (<h1>…<h6>,<p>,<ul>/<ol><li>,<blockquote>,<pre><code>,<hr>,<figure><img>). Pour les sélections multi-blocs (focus défocus), on passe par l'API asyncnavigator.clipboard.writeavec unClipboardItemqui inclut les deux MIME. - Paste dans un bloc :
Block.handlePastelit d'abord la MIME propriétairetext/_notion-blocks-v3-production(parsée parnotionJsonToBlocks) — Notion conserve ses types natifs (to_do,sub_sub_header, …) là où le HTML aplatitto_doen<ul><li>. À défaut, retombe surtext/htmlparsé parhtmlToBlocks. Si un seul paragraphe est produit, son contenu inline est splicé au caret. Sinon le bloc courant est coupé en deux et les blocs collés sont insérés entre les deux moitiés (avec fusion tête/queue si extrémités = paragraphe). - Paste en sélection multi-blocs :
navigator.clipboard.read()lit le HTML du presse-papier, le convertit, et remplace les blocs sélectionnés.
Tags HTML reconnus en entrée : <h1>–<h6>, <p>, <ul>/<ol><li>,
<ul data-checklist>, <ul class="to-do-list"> (Notion), <ul class="task-list"> (GitHub-flavored markdown), ou <li> contenant <input type="checkbox"> ou <div class="checkbox checkbox-on/off"> (Notion),
<blockquote>, <pre>, <code>, <hr>, <figure>, <img>, plus
toutes les marks de domToInline (<strong>/<b>, <em>/<i>, <u>,
<s>/<strike>/<del>, <code>, <a href>, <span data-color> /
<span data-highlight>).
Les wrappers de Word / Google Docs (<b id=...>, <div> de mise en
page) sont traversés pour atteindre les éléments block-level
descendants. Les styles CSS inline (font-weight, font-style,
text-decoration) sont également lus en plus des tags sémantiques.
Limitations connues
- Pas d'imbrication de listes.
- 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.