# 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 ```jsx import { BlockEditor } from '@zen/core/shared/components'; 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 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). ```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` | — | `` | | `italic` | — | `` | | `underline` | — | `` | | `strike` | — | `` | | `code` | — | `` (monospace, fond gris) | | `link` | `href: string` | `` (target="_blank") | | `color` | `color: \| '#rrggbb'` | couleur du texte | | `highlight` | `color: \| '#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 ```js 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 ```jsx 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 à 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 : palette par défaut + couleurs déjà utilisées dans le document + bouton `+` pour une couleur libre via ``) - **◐** — 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é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 les blocs sélectionnés au format **HTML structuré** (titres, gras, listes, citations, …) + fallback `text/plain`. Collable dans Word, Google Docs, Slack, ou un autre `BlockEditor`. - `Ctrl/Cmd + V` → remplace les blocs sélectionnés par le contenu HTML du presse-papier (parsé par `htmlToBlocks`). - 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 par `enabledBlocks`) 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`](../../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 à `Backspace` au 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 ```js import { registerBlock, newBlockId } from '@zen/core/shared/components/BlockEditor'; registerBlock({ type: 'kpi', label: 'KPI', icon: , keywords: ['kpi', 'metric', 'stat'], isText: false, create: () => ({ id: newBlockId(), type: 'kpi', value: 0 }), Component: ({ block, onChange, disabled }) => ( onChange({ value: Number(e.target.value) })} /> ), }); ``` Le nouveau type apparaît automatiquement dans le slash menu de tout `` 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 (`

`, `

`, `