Files
core/src/shared/components/BlockEditor/README.md
T
hykocx 30cd0bbd81 feat(BlockEditor): add clear formatting button to inline toolbar
- add `removeAllMarks` function to inline/types.js
- implement `applyRemoveAllMarks` handler in BlockEditor client
- add `TextClearIcon` button with separator in Toolbar component
- expose `onClearMarks` prop on InlineToolbar
- update README to document new clear formatting action
2026-04-25 19:23:45 -04:00

8.4 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,
} 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 à 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 <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é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é (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

Drag and drop

Chaque bloc affiche au survol :

  • une poignée Add01Icon pour insérer un bloc en dessous (ouvre le slash menu)
  • une poignée DragDropVerticalIcon pour glisser-déposer (réordonner)

Les icônes proviennent de src/shared/icons/index.js.

Le drag-and-drop utilise l'API HTML5 native, pas de dépendance externe.

É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)

Limitations connues

  • Pas d'imbrication de listes.
  • 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.