diff --git a/src/features/admin/devkit/ComponentsPage.client.js b/src/features/admin/devkit/ComponentsPage.client.js
index d90b2e5..0e6947e 100644
--- a/src/features/admin/devkit/ComponentsPage.client.js
+++ b/src/features/admin/devkit/ComponentsPage.client.js
@@ -13,6 +13,7 @@ import {
TagInput,
StatCard,
Loading,
+ BlockEditor,
} from '@zen/core/shared/components';
import { UserCircle02Icon } from '@zen/core/shared/icons';
import AdminHeader from '../components/AdminHeader.js';
@@ -207,6 +208,31 @@ export default function ComponentsPage() {
+
+
+
+
+
+ );
+}
+
+function BlockEditorDemo() {
+ const [blocks, setBlocks] = useState([
+ { id: 'demo-1', type: 'heading_1', content: 'Bienvenue dans BlockEditor' },
+ { id: 'demo-2', type: 'paragraph', content: "Tapez '/' pour ouvrir le menu de commandes." },
+ { id: 'demo-3', type: 'bullet_item', content: 'Glissez la poignée ⋮⋮ pour réordonner' },
+ { id: 'demo-4', type: 'bullet_item', content: 'Tapez `# ` au début pour un titre, `- ` pour une puce' },
+ { id: 'demo-5', type: 'paragraph', content: '' },
+ ]);
+ return (
+
+
+
+ Aperçu JSON
+
+{JSON.stringify(blocks, null, 2)}
+
+
);
}
diff --git a/src/shared/components/BlockEditor/Block.client.js b/src/shared/components/BlockEditor/Block.client.js
new file mode 100644
index 0000000..71ff63c
--- /dev/null
+++ b/src/shared/components/BlockEditor/Block.client.js
@@ -0,0 +1,302 @@
+'use client';
+
+import React, { useEffect, useImperativeHandle, useRef, useState, forwardRef } from 'react';
+import { getBlockDef } from './blockRegistry.js';
+import {
+ getCaretOffset,
+ setCaretOffset,
+ focusEnd,
+ isCaretAtStart,
+} from './utils/caret.js';
+
+// Wrapper d'un bloc unique. Gère :
+// - le contentEditable pour les blocs texte (sync uncontrolled ↔ value)
+// - les handles à gauche (drag, +)
+// - les évènements clavier (Enter, Backspace, /, shortcuts markdown)
+// - le drop zone pour le drag-and-drop natif
+//
+// L'orchestrateur (BlockEditor) reçoit des évènements via les callbacks et
+// mute la liste des blocs en conséquence.
+
+const Block = forwardRef(function Block(
+ {
+ block,
+ index,
+ numberedIndex,
+ disabled,
+ isDragOverTop,
+ isDragOverBottom,
+ onContentChange,
+ onEnter,
+ onBackspaceAtStart,
+ onSlashOpen,
+ onSlashClose,
+ onShortcutMatch,
+ onFocus,
+ onDragStart,
+ onDragEnd,
+ onDragOver,
+ onDragLeave,
+ onDrop,
+ onPlusClick,
+ },
+ ref,
+) {
+ const def = getBlockDef(block.type);
+ const editableRef = useRef(null);
+ const [hovered, setHovered] = useState(false);
+ const [draggable, setDraggable] = useState(false);
+
+ useImperativeHandle(ref, () => ({
+ focus: (offset) => {
+ if (!def?.isText) return;
+ if (typeof offset === 'number') setCaretOffset(editableRef.current, offset);
+ else focusEnd(editableRef.current);
+ },
+ getElement: () => editableRef.current,
+ }), [def]);
+
+ // Synchronisation contrôlée → DOM : on n'écrit dans le contentEditable que
+ // si la valeur externe diffère de ce qui est affiché (cas undo/redo, ou
+ // transformation de bloc). Sinon on laisse le DOM gérer pour préserver le caret.
+ useEffect(() => {
+ if (!def?.isText) return;
+ const el = editableRef.current;
+ if (!el) return;
+ const next = block.content ?? '';
+ if (el.textContent !== next) {
+ el.textContent = next;
+ }
+ }, [block.content, def?.isText]);
+
+ if (!def) {
+ return (
+
+ Type de bloc inconnu : {block.type}
+
+ );
+ }
+
+ function handleInput() {
+ const el = editableRef.current;
+ if (!el) return;
+ const text = el.textContent ?? '';
+
+ // Détection slash menu : "/" au tout début ou après un espace
+ if (!disabled && text === '/' && getCaretOffset(el) === 1) {
+ onSlashOpen?.({ blockId: block.id, query: '' });
+ } else if (text.startsWith('/') && !text.includes(' ', 1)) {
+ onSlashOpen?.({ blockId: block.id, query: text.slice(1) });
+ } else {
+ onSlashClose?.();
+ }
+
+ // Markdown shortcut : si le contenu commence par un préfixe connu suivi
+ // d'un espace, on demande à l'orchestrateur de transformer le bloc.
+ if (!def.disableShortcuts) {
+ const match = text.match(/^(#{1,6} |- |1\. |> |```\s|---)/);
+ if (match) {
+ const prefix = match[1];
+ onShortcutMatch?.({ blockId: block.id, prefix, rest: text.slice(prefix.length) });
+ return;
+ }
+ }
+
+ onContentChange?.(block.id, text);
+ }
+
+ function handleKeyDown(e) {
+ // Si le slash menu est ouvert (texte commence par "/" sans espace),
+ // ne pas intercepter Enter / Arrow / Escape — l'orchestrateur les gère.
+ const el = editableRef.current;
+ const text = el?.textContent ?? '';
+ const slashOpen = text.startsWith('/') && !text.slice(1).includes(' ');
+ if (slashOpen && ['Enter', 'ArrowUp', 'ArrowDown', 'Escape'].includes(e.key)) {
+ return;
+ }
+
+ if (e.key === '/') {
+ // L'ouverture se fait dans handleInput après que le caractère soit écrit.
+ return;
+ }
+
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault();
+ const el = editableRef.current;
+ const offset = el ? getCaretOffset(el) : 0;
+ const text = el?.textContent ?? '';
+ onEnter?.({ blockId: block.id, offset, text });
+ return;
+ }
+
+ if (e.key === 'Backspace') {
+ const el = editableRef.current;
+ if (el && isCaretAtStart(el)) {
+ e.preventDefault();
+ onBackspaceAtStart?.({ blockId: block.id, text: el.textContent ?? '' });
+ }
+ }
+ }
+
+ function handlePaste(e) {
+ // MVP : on colle uniquement du texte brut pour éviter le HTML externe.
+ e.preventDefault();
+ const text = e.clipboardData.getData('text/plain');
+ if (!text) return;
+ document.execCommand('insertText', false, text);
+ }
+
+ function handleHandleMouseDown() {
+ setDraggable(true);
+ }
+
+ function handleDragStart(e) {
+ if (!draggable) {
+ e.preventDefault();
+ return;
+ }
+ e.dataTransfer.effectAllowed = 'move';
+ e.dataTransfer.setData('text/plain', block.id);
+ onDragStart?.(block.id);
+ }
+
+ function handleDragEnd() {
+ setDraggable(false);
+ onDragEnd?.();
+ }
+
+ function handleDragOver(e) {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = 'move';
+ const rect = e.currentTarget.getBoundingClientRect();
+ const isTop = e.clientY < rect.top + rect.height / 2;
+ onDragOver?.(block.id, isTop ? 'top' : 'bottom');
+ }
+
+ function handleDrop(e) {
+ e.preventDefault();
+ const sourceId = e.dataTransfer.getData('text/plain');
+ if (sourceId && sourceId !== block.id) {
+ onDrop?.(sourceId, block.id);
+ }
+ }
+
+ const isEmpty = def.isText && (!block.content || block.content.length === 0);
+
+ const dropIndicator = (
+ <>
+ {isDragOverTop && (
+
+ )}
+ {isDragOverBottom && (
+
+ )}
+ >
+ );
+
+ return (
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ draggable={draggable}
+ onDragStart={handleDragStart}
+ onDragEnd={handleDragEnd}
+ onDragOver={handleDragOver}
+ onDragLeave={onDragLeave}
+ onDrop={handleDrop}
+ data-block-id={block.id}
+ >
+ {dropIndicator}
+
+
+
+
+
+
+
+ {def.isText ? (
+ def.renderPrefix ? (
+
+ {def.renderPrefix({ block, index, numberedIndex })}
+ onFocus?.(block.id)}
+ />
+
+ ) : (
+
onFocus?.(block.id)}
+ />
+ )
+ ) : def.Component ? (
+ onContentChange?.(block.id, patch)}
+ />
+ ) : (
+ Bloc {block.type} sans rendu.
+ )}
+
+
+ );
+});
+
+const TextEditable = forwardRef(function TextEditable(
+ { className, placeholder, disabled, onInput, onKeyDown, onPaste, onFocus },
+ ref,
+) {
+ return (
+
+ );
+});
+
+export default Block;
diff --git a/src/shared/components/BlockEditor/BlockEditor.client.js b/src/shared/components/BlockEditor/BlockEditor.client.js
new file mode 100644
index 0000000..cf224a6
--- /dev/null
+++ b/src/shared/components/BlockEditor/BlockEditor.client.js
@@ -0,0 +1,528 @@
+'use client';
+
+import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import Block from './Block.client.js';
+import SlashMenu, { getSlashItems } from './SlashMenu.client.js';
+import { getBlockDef, DEFAULT_BLOCK_TYPE } from './blockRegistry.js';
+import { registerBuiltInBlocks } from './blockTypes/index.js';
+import { newBlockId } from './utils/ids.js';
+
+registerBuiltInBlocks();
+
+const UNDO_DEBOUNCE_MS = 600;
+const MAX_HISTORY = 50;
+
+// Mappe les préfixes markdown à un type de bloc.
+const SHORTCUT_TO_TYPE = {
+ '# ': 'heading_1',
+ '## ': 'heading_2',
+ '### ': 'heading_3',
+ '#### ': 'heading_4',
+ '##### ': 'heading_5',
+ '###### ': 'heading_6',
+ '- ': 'bullet_item',
+ '1. ': 'numbered_item',
+ '> ': 'quote',
+ '```': 'code',
+ '---': 'divider',
+};
+
+function makeBlock(type, init) {
+ const def = getBlockDef(type) || getBlockDef(DEFAULT_BLOCK_TYPE);
+ return def.create(init || {});
+}
+
+function ensureNonEmpty(blocks) {
+ if (!Array.isArray(blocks) || blocks.length === 0) {
+ return [makeBlock(DEFAULT_BLOCK_TYPE)];
+ }
+ return blocks;
+}
+
+export default function BlockEditor({
+ value,
+ onChange,
+ label,
+ error,
+ placeholder,
+ disabled = false,
+ className = '',
+ enabledBlocks,
+}) {
+ const blocks = useMemo(() => ensureNonEmpty(value), [value]);
+ const blockRefs = useRef(new Map());
+ const containerRef = useRef(null);
+ const [focusBlockId, setFocusBlockId] = useState(null);
+ const [focusOffset, setFocusOffset] = useState(null);
+
+ // --- Undo / Redo ---
+ const [undoStack, setUndoStack] = useState([]);
+ const [redoStack, setRedoStack] = useState([]);
+ const undoDebounceRef = useRef(null);
+
+ function pushUndo(prev) {
+ setUndoStack(s => {
+ const next = [...s, prev];
+ return next.length > MAX_HISTORY ? next.slice(1) : next;
+ });
+ setRedoStack([]);
+ }
+
+ function commitChange(next, opts = {}) {
+ if (opts.immediate) {
+ if (undoDebounceRef.current) {
+ clearTimeout(undoDebounceRef.current);
+ undoDebounceRef.current = null;
+ }
+ pushUndo(blocks);
+ } else {
+ if (!undoDebounceRef.current) {
+ const snapshot = blocks;
+ undoDebounceRef.current = setTimeout(() => {
+ pushUndo(snapshot);
+ undoDebounceRef.current = null;
+ }, UNDO_DEBOUNCE_MS);
+ }
+ }
+ onChange?.(next);
+ }
+
+ function handleUndo() {
+ if (undoStack.length === 0) return;
+ if (undoDebounceRef.current) {
+ clearTimeout(undoDebounceRef.current);
+ undoDebounceRef.current = null;
+ }
+ const prev = undoStack[undoStack.length - 1];
+ setRedoStack(r => [...r, blocks]);
+ setUndoStack(s => s.slice(0, -1));
+ onChange?.(prev);
+ }
+
+ function handleRedo() {
+ if (redoStack.length === 0) return;
+ if (undoDebounceRef.current) {
+ clearTimeout(undoDebounceRef.current);
+ undoDebounceRef.current = null;
+ }
+ const next = redoStack[redoStack.length - 1];
+ setUndoStack(u => [...u, blocks]);
+ setRedoStack(r => r.slice(0, -1));
+ onChange?.(next);
+ }
+
+ useEffect(() => () => {
+ if (undoDebounceRef.current) clearTimeout(undoDebounceRef.current);
+ }, []);
+
+ // --- Numérotation des items numérotés (consécutifs) ---
+ const numberedIndexByBlockId = useMemo(() => {
+ const map = new Map();
+ let n = 0;
+ for (const b of blocks) {
+ if (b.type === 'numbered_item') {
+ n += 1;
+ map.set(b.id, n);
+ } else {
+ n = 0;
+ }
+ }
+ return map;
+ }, [blocks]);
+
+ // --- Focus impératif sur un bloc après mutation ---
+ useEffect(() => {
+ if (!focusBlockId) return;
+ const ref = blockRefs.current.get(focusBlockId);
+ if (ref?.focus) {
+ ref.focus(focusOffset);
+ }
+ setFocusBlockId(null);
+ setFocusOffset(null);
+ }, [focusBlockId, focusOffset, blocks]);
+
+ function setBlockRef(id, ref) {
+ if (ref) blockRefs.current.set(id, ref);
+ else blockRefs.current.delete(id);
+ }
+
+ // --- Mutations ---
+ function updateBlock(id, patch) {
+ const next = blocks.map(b => (b.id === id ? { ...b, ...patch } : b));
+ commitChange(next);
+ }
+
+ function replaceBlock(id, newBlock, focus = true) {
+ const next = blocks.map(b => (b.id === id ? newBlock : b));
+ commitChange(next, { immediate: true });
+ if (focus) {
+ setFocusBlockId(newBlock.id);
+ setFocusOffset(0);
+ }
+ }
+
+ function insertAfter(id, newBlock, focus = true) {
+ const idx = blocks.findIndex(b => b.id === id);
+ if (idx < 0) return;
+ const next = [...blocks.slice(0, idx + 1), newBlock, ...blocks.slice(idx + 1)];
+ commitChange(next, { immediate: true });
+ if (focus) {
+ setFocusBlockId(newBlock.id);
+ setFocusOffset(0);
+ }
+ }
+
+ function removeBlock(id, focusPrev = true) {
+ const idx = blocks.findIndex(b => b.id === id);
+ if (idx < 0) return;
+ const next = blocks.filter(b => b.id !== id);
+ const finalNext = next.length === 0 ? [makeBlock(DEFAULT_BLOCK_TYPE)] : next;
+ commitChange(finalNext, { immediate: true });
+ if (focusPrev) {
+ const prev = idx > 0 ? blocks[idx - 1] : finalNext[0];
+ setFocusBlockId(prev.id);
+ setFocusOffset(prev.content?.length ?? 0);
+ }
+ }
+
+ // --- Évènements provenant des blocs ---
+ function handleContentChange(id, text) {
+ const next = blocks.map(b => (b.id === id ? { ...b, content: text } : b));
+ commitChange(next);
+ }
+
+ function handleEnter({ blockId, offset, text }) {
+ const idx = blocks.findIndex(b => b.id === blockId);
+ if (idx < 0) return;
+ const current = blocks[idx];
+
+ // Sur un item de liste vide, sortir de la liste (devient paragraphe)
+ if (
+ (current.type === 'bullet_item' || current.type === 'numbered_item') &&
+ (text ?? '').length === 0
+ ) {
+ const replaced = makeBlock(DEFAULT_BLOCK_TYPE);
+ const next = blocks.map(b => (b.id === blockId ? replaced : b));
+ commitChange(next, { immediate: true });
+ setFocusBlockId(replaced.id);
+ setFocusOffset(0);
+ return;
+ }
+
+ const before = (text ?? '').slice(0, offset);
+ const after = (text ?? '').slice(offset);
+
+ // Le bloc courant garde la partie avant le caret
+ const updated = { ...current, content: before };
+
+ // Le nouveau bloc hérite du type pour les listes, sinon paragraphe
+ const newType =
+ current.type === 'bullet_item' || current.type === 'numbered_item'
+ ? current.type
+ : DEFAULT_BLOCK_TYPE;
+ const newBlock = makeBlock(newType, { content: after });
+
+ const next = [
+ ...blocks.slice(0, idx),
+ updated,
+ newBlock,
+ ...blocks.slice(idx + 1),
+ ];
+ commitChange(next, { immediate: true });
+ setFocusBlockId(newBlock.id);
+ setFocusOffset(0);
+ }
+
+ function handleBackspaceAtStart({ blockId, text }) {
+ const idx = blocks.findIndex(b => b.id === blockId);
+ if (idx < 0) return;
+ const current = blocks[idx];
+
+ // Si le bloc est typé (heading, list, quote, code) et non vide → repasse
+ // en paragraphe sans rien supprimer.
+ if (current.type !== DEFAULT_BLOCK_TYPE) {
+ const replaced = makeBlock(DEFAULT_BLOCK_TYPE, { content: text ?? '' });
+ const next = blocks.map(b => (b.id === blockId ? replaced : b));
+ commitChange(next, { immediate: true });
+ setFocusBlockId(replaced.id);
+ setFocusOffset(0);
+ return;
+ }
+
+ // Sinon : merge avec le bloc précédent s'il est texte
+ if (idx === 0) return;
+ const prev = blocks[idx - 1];
+ const prevDef = getBlockDef(prev.type);
+ if (!prevDef?.isText) {
+ // précédent non-texte : on supprime juste le bloc courant
+ removeBlock(blockId, true);
+ return;
+ }
+ const mergedOffset = (prev.content ?? '').length;
+ const merged = { ...prev, content: (prev.content ?? '') + (text ?? '') };
+ const next = [...blocks.slice(0, idx - 1), merged, ...blocks.slice(idx + 1)];
+ commitChange(next, { immediate: true });
+ setFocusBlockId(merged.id);
+ setFocusOffset(mergedOffset);
+ }
+
+ function handleShortcutMatch({ blockId, prefix, rest }) {
+ const newType = SHORTCUT_TO_TYPE[prefix.trimEnd()] || SHORTCUT_TO_TYPE[prefix];
+ if (!newType) return;
+ const def = getBlockDef(newType);
+ if (!def) return;
+
+ if (newType === 'divider') {
+ // Insère un divider et place le caret dans un nouveau paragraphe vide
+ const idx = blocks.findIndex(b => b.id === blockId);
+ if (idx < 0) return;
+ const divider = makeBlock('divider');
+ const after = makeBlock(DEFAULT_BLOCK_TYPE);
+ const next = [
+ ...blocks.slice(0, idx),
+ divider,
+ after,
+ ...blocks.slice(idx + 1),
+ ];
+ commitChange(next, { immediate: true });
+ setFocusBlockId(after.id);
+ setFocusOffset(0);
+ return;
+ }
+
+ const replaced = def.create({ content: rest ?? '' });
+ const next = blocks.map(b => (b.id === blockId ? replaced : b));
+ commitChange(next, { immediate: true });
+ setFocusBlockId(replaced.id);
+ setFocusOffset(0);
+ }
+
+ // --- Slash menu ---
+ const [slashState, setSlashState] = useState(null);
+ // { blockId, query, anchorRect, selectedIndex }
+
+ function openSlashFor(blockId, query = '') {
+ const ref = blockRefs.current.get(blockId);
+ const el = ref?.getElement?.();
+ if (!el) return;
+ const rect = el.getBoundingClientRect();
+ setSlashState(prev => ({
+ blockId,
+ query,
+ anchorRect: rect,
+ selectedIndex: prev?.blockId === blockId ? prev.selectedIndex ?? 0 : 0,
+ }));
+ }
+
+ function handleSlashOpen({ blockId, query }) {
+ openSlashFor(blockId, query);
+ }
+
+ function handleSlashClose() {
+ setSlashState(null);
+ }
+
+ function handleSlashSelect(type) {
+ if (!slashState) return;
+ const { blockId } = slashState;
+ const def = getBlockDef(type);
+ if (!def) return;
+
+ const idx = blocks.findIndex(b => b.id === blockId);
+ if (idx < 0) return;
+ const current = blocks[idx];
+
+ if (def.isText) {
+ // Remplace le bloc courant en gardant son contenu (purgé du / de query)
+ const text = (current.content ?? '').replace(/^\/\S*/, '');
+ const replaced = def.create({ content: text });
+ const next = blocks.map(b => (b.id === blockId ? replaced : b));
+ commitChange(next, { immediate: true });
+ setFocusBlockId(replaced.id);
+ setFocusOffset(text.length);
+ } else {
+ // Bloc non-texte : insère après et nettoie le bloc courant
+ const cleared = { ...current, content: (current.content ?? '').replace(/^\/\S*/, '') };
+ const inserted = def.create();
+ const after =
+ cleared.content.length === 0
+ ? // remplacer le bloc courant par le bloc non-texte puis ajouter un paragraphe vide
+ [
+ ...blocks.slice(0, idx),
+ inserted,
+ makeBlock(DEFAULT_BLOCK_TYPE),
+ ...blocks.slice(idx + 1),
+ ]
+ : [
+ ...blocks.slice(0, idx),
+ cleared,
+ inserted,
+ makeBlock(DEFAULT_BLOCK_TYPE),
+ ...blocks.slice(idx + 1),
+ ];
+ commitChange(after, { immediate: true });
+ }
+ setSlashState(null);
+ }
+
+ function handlePlusClick(blockId) {
+ const idx = blocks.findIndex(b => b.id === blockId);
+ if (idx < 0) return;
+ const newBlock = makeBlock(DEFAULT_BLOCK_TYPE);
+ const next = [...blocks.slice(0, idx + 1), newBlock, ...blocks.slice(idx + 1)];
+ commitChange(next, { immediate: true });
+ setFocusBlockId(newBlock.id);
+ setFocusOffset(0);
+ // Ouvre le slash menu après la mise à jour
+ setTimeout(() => openSlashFor(newBlock.id, ''), 0);
+ }
+
+ // --- Drag & drop ---
+ const [dragOver, setDragOver] = useState(null); // { blockId, position: 'top'|'bottom' }
+
+ function handleDragOver(blockId, position) {
+ setDragOver({ blockId, position });
+ }
+
+ function handleDragLeave() {
+ // ne rien faire de spécial : le prochain dragOver écrasera l'état
+ }
+
+ function handleDragEnd() {
+ setDragOver(null);
+ }
+
+ function handleDrop(sourceId, targetId) {
+ setDragOver(null);
+ if (sourceId === targetId) return;
+ const sourceIdx = blocks.findIndex(b => b.id === sourceId);
+ const targetIdx = blocks.findIndex(b => b.id === targetId);
+ if (sourceIdx < 0 || targetIdx < 0) return;
+ const position = dragOver?.position || 'bottom';
+ const without = blocks.filter(b => b.id !== sourceId);
+ let insertIdx = without.findIndex(b => b.id === targetId);
+ if (position === 'bottom') insertIdx += 1;
+ const moved = blocks[sourceIdx];
+ const next = [...without.slice(0, insertIdx), moved, ...without.slice(insertIdx)];
+ commitChange(next, { immediate: true });
+ }
+
+ // --- Raccourcis globaux ---
+ function handleGlobalKeyDown(e) {
+ // Slash menu : navigation et sélection
+ if (slashState) {
+ const items = getSlashItems(slashState.query, enabledBlocks);
+ if (e.key === 'ArrowDown') {
+ e.preventDefault();
+ setSlashState(s => ({ ...s, selectedIndex: Math.min((s.selectedIndex ?? 0) + 1, Math.max(items.length - 1, 0)) }));
+ return;
+ }
+ if (e.key === 'ArrowUp') {
+ e.preventDefault();
+ setSlashState(s => ({ ...s, selectedIndex: Math.max((s.selectedIndex ?? 0) - 1, 0) }));
+ return;
+ }
+ if (e.key === 'Enter') {
+ if (items.length > 0) {
+ e.preventDefault();
+ e.stopPropagation();
+ const def = items[slashState.selectedIndex ?? 0];
+ if (def) handleSlashSelect(def.type);
+ return;
+ }
+ }
+ if (e.key === 'Escape') {
+ e.preventDefault();
+ setSlashState(null);
+ return;
+ }
+ }
+
+ // Undo / Redo
+ if ((e.ctrlKey || e.metaKey) && e.key === 'z') {
+ e.preventDefault();
+ if (e.shiftKey) handleRedo();
+ else handleUndo();
+ return;
+ }
+ if ((e.ctrlKey || e.metaKey) && e.key === 'y') {
+ e.preventDefault();
+ handleRedo();
+ return;
+ }
+ }
+
+ return (
+
+ {label && (
+
+ )}
+
+
+ {placeholder && blocks.length === 1 && !blocks[0].content && (
+ // Placeholder global injecté via data-placeholder du paragraphe initial
+ null
+ )}
+ {blocks.map((block, i) => (
+ setBlockRef(block.id, r)}
+ block={block}
+ index={i}
+ numberedIndex={numberedIndexByBlockId.get(block.id)}
+ disabled={disabled}
+ isDragOverTop={dragOver?.blockId === block.id && dragOver.position === 'top'}
+ isDragOverBottom={dragOver?.blockId === block.id && dragOver.position === 'bottom'}
+ onContentChange={handleContentChange}
+ onEnter={handleEnter}
+ onBackspaceAtStart={handleBackspaceAtStart}
+ onShortcutMatch={handleShortcutMatch}
+ onSlashOpen={handleSlashOpen}
+ onSlashClose={handleSlashClose}
+ onDragStart={() => setDragOver(null)}
+ onDragEnd={handleDragEnd}
+ onDragOver={handleDragOver}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ onPlusClick={handlePlusClick}
+ />
+ ))}
+
+ {slashState && (
+
setSlashState(s => ({ ...s, selectedIndex: i }))}
+ />
+ )}
+ {error && (
+ {error}
+ )}
+
+ );
+}
+
+// Styles minimaux : placeholder pour les contentEditable vides.
+function BlockEditorStyles() {
+ return (
+
+ );
+}
diff --git a/src/shared/components/BlockEditor/README.md b/src/shared/components/BlockEditor/README.md
new file mode 100644
index 0000000..931e5cf
--- /dev/null
+++ b/src/shared/components/BlockEditor/README.md
@@ -0,0 +1,114 @@
+# 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 (Phase 1)
+
+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 |
+
+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`.
+
+## Props
+
+```jsx
+ void
+ label, error, placeholder, disabled, className
+ enabledBlocks={[...]} // optionnel : restreindre les types disponibles
+/>
+```
+
+## Interactions clavier
+
+- `/` → ouvre le menu de commandes (filtrable au clavier, ↑ ↓ Entrée pour valider, Échap pour fermer)
+- `# ` → titre 1, `## ` → 2, …, `###### ` → 6
+- `- ` → liste à puces
+- `1. ` → liste numérotée
+- `> ` → citation
+- ` ``` ` → bloc de code
+- `---` → séparateur
+- `Backspace` au début d'un bloc typé → repasse en paragraphe ; au début d'un paragraphe, fusionne avec le bloc précédent
+- `Entrée` sur un item de liste vide → sort de la liste
+- `Ctrl/Cmd + Z` / `Ctrl/Cmd + Shift + Z` → undo / redo
+
+## Drag and drop
+
+Chaque bloc affiche au survol :
+- une poignée `+` pour insérer un bloc en dessous (ouvre le slash menu)
+- une poignée `⋮⋮` pour glisser-déposer (réordonner)
+
+Le drag-and-drop utilise l'API HTML5 native, pas de dépendance externe.
+
+## É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, index, numberedIndex })` pour un préfixe (puce, numéro).
+
+## Architecture interne
+
+```
+BlockEditor.client.js orchestrateur : value/onChange, undo, slash menu, drag-drop
+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
+utils/ids.js UUID pour les blocs
+utils/caret.js gestion du caret dans un contentEditable
+```
+
+## Limitations connues (Phase 1)
+
+- 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).
+- Tables : Phase 3.
diff --git a/src/shared/components/BlockEditor/SlashMenu.client.js b/src/shared/components/BlockEditor/SlashMenu.client.js
new file mode 100644
index 0000000..1d6276f
--- /dev/null
+++ b/src/shared/components/BlockEditor/SlashMenu.client.js
@@ -0,0 +1,111 @@
+'use client';
+
+import React, { useEffect, useMemo, useRef, useState } from 'react';
+import { listBlocks } from './blockRegistry.js';
+
+// Menu flottant des commandes. Affiché ancré à un élément (anchorRect).
+// La navigation clavier (↑ ↓ Enter Esc) est gérée par le composant parent
+// via la méthode imperative move()/select() — au MVP on garde simple :
+// le composant parent passe `query` et `selectedIndex` ; on déclenche
+// `onSelect` au clic ou via Enter (intercepté côté parent).
+
+function fuzzyScore(label, keywords, query) {
+ const q = query.toLowerCase().trim();
+ if (!q) return 1;
+ const haystack = [label, ...(keywords || [])].join(' ').toLowerCase();
+ if (haystack.includes(q)) return 2;
+ // Match partiel : tous les caractères dans l'ordre
+ let i = 0;
+ for (const c of haystack) {
+ if (c === q[i]) i++;
+ if (i === q.length) return 1;
+ }
+ return 0;
+}
+
+export default function SlashMenu({
+ query = '',
+ anchorRect,
+ enabledBlocks,
+ selectedIndex,
+ onSelect,
+ onHoverIndex,
+}) {
+ const allowed = useMemo(() => {
+ const all = listBlocks();
+ return enabledBlocks ? all.filter(b => enabledBlocks.includes(b.type)) : all;
+ }, [enabledBlocks]);
+
+ const items = useMemo(() => {
+ return allowed
+ .map(def => ({ def, score: fuzzyScore(def.label, def.keywords, query) }))
+ .filter(x => x.score > 0)
+ .sort((a, b) => b.score - a.score)
+ .map(x => x.def);
+ }, [allowed, query]);
+
+ const listRef = useRef(null);
+
+ // Scroll l'élément sélectionné dans la vue
+ useEffect(() => {
+ const el = listRef.current?.querySelector(`[data-slash-index="${selectedIndex}"]`);
+ if (el) el.scrollIntoView({ block: 'nearest' });
+ }, [selectedIndex]);
+
+ if (!anchorRect) return null;
+
+ const top = anchorRect.bottom + 6;
+ const left = anchorRect.left;
+
+ if (items.length === 0) {
+ return (
+
+ Aucune commande pour « {query} »
+
+ );
+ }
+
+ return (
+ e.preventDefault()} // ne pas voler le focus
+ >
+ {items.map((def, i) => {
+ const active = i === selectedIndex;
+ return (
+
+ );
+ })}
+
+ );
+}
+
+// Helper exposé pour le parent : ordre des items pour navigation clavier.
+export function getSlashItems(query, enabledBlocks) {
+ const all = listBlocks();
+ const allowed = enabledBlocks ? all.filter(b => enabledBlocks.includes(b.type)) : all;
+ return allowed
+ .map(def => ({ def, score: fuzzyScore(def.label, def.keywords, query) }))
+ .filter(x => x.score > 0)
+ .sort((a, b) => b.score - a.score)
+ .map(x => x.def);
+}
diff --git a/src/shared/components/BlockEditor/blockRegistry.js b/src/shared/components/BlockEditor/blockRegistry.js
new file mode 100644
index 0000000..2112bfb
--- /dev/null
+++ b/src/shared/components/BlockEditor/blockRegistry.js
@@ -0,0 +1,47 @@
+// Registre extensible des types de blocs.
+// Les blocs built-in s'enregistrent dans defaultBlocks.js (chargé par index.js).
+// Les consommateurs peuvent appeler `registerBlock` pour ajouter leurs propres types.
+//
+// Forme d'une définition de bloc :
+// {
+// type: string, // id unique (ex: 'paragraph', 'my_custom')
+// label: string, // libellé affiché dans le slash menu
+// icon: string, // glyphe court (emoji ou caractère)
+// keywords: string[], // termes de recherche pour le slash menu
+// shortcut?: string, // préfixe markdown qui convertit (ex: '# ', '- ')
+// shortcutTransform?: (block, match) => block, // optionnel : transforme un bloc existant
+// create: (init?) => Block, // construit un nouveau bloc
+// isText: boolean, // true si le bloc a un contentEditable de texte
+// textTag?: string, // pour info / rendu en mode display
+// textClassName?: string, // classes appliquées au contentEditable
+// placeholder?: string, // texte fantôme quand le bloc est vide et focus
+// renderPrefix?: (ctx) => ReactNode, // pour les listes (puce, numéro)
+// Component?: ReactComponent, // pour blocs non-texte ; reçoit { block, onChange, disabled }
+// }
+
+const registry = new Map();
+
+export function registerBlock(def) {
+ if (!def || typeof def.type !== 'string') {
+ throw new Error('registerBlock: `type` is required');
+ }
+ if (typeof def.create !== 'function') {
+ throw new Error(`registerBlock(${def.type}): \`create\` is required`);
+ }
+ registry.set(def.type, def);
+}
+
+export function getBlockDef(type) {
+ return registry.get(type) || null;
+}
+
+export function listBlocks() {
+ return Array.from(registry.values());
+}
+
+export function isBlockText(type) {
+ const def = registry.get(type);
+ return Boolean(def?.isText);
+}
+
+export const DEFAULT_BLOCK_TYPE = 'paragraph';
diff --git a/src/shared/components/BlockEditor/blockTypes/BulletList.js b/src/shared/components/BlockEditor/blockTypes/BulletList.js
new file mode 100644
index 0000000..1af25ca
--- /dev/null
+++ b/src/shared/components/BlockEditor/blockTypes/BulletList.js
@@ -0,0 +1,26 @@
+import { newBlockId } from '../utils/ids.js';
+
+const BulletItem = {
+ type: 'bullet_item',
+ label: 'Liste à puces',
+ icon: '•',
+ keywords: ['liste', 'list', 'puce', 'bullet', 'ul'],
+ shortcut: '- ',
+ isText: true,
+ textTag: 'li',
+ textClassName: 'text-base leading-relaxed text-neutral-900 dark:text-white',
+ placeholder: 'Élément de liste',
+ renderPrefix() {
+ return (
+
+ );
+ },
+ create(init = {}) {
+ return { id: newBlockId(), type: 'bullet_item', content: '', ...init };
+ },
+};
+
+export default BulletItem;
diff --git a/src/shared/components/BlockEditor/blockTypes/Code.js b/src/shared/components/BlockEditor/blockTypes/Code.js
new file mode 100644
index 0000000..dc2887c
--- /dev/null
+++ b/src/shared/components/BlockEditor/blockTypes/Code.js
@@ -0,0 +1,20 @@
+import { newBlockId } from '../utils/ids.js';
+
+const Code = {
+ type: 'code',
+ label: 'Bloc de code',
+ icon: '>',
+ keywords: ['code', 'pre', 'snippet'],
+ shortcut: '``` ',
+ isText: true,
+ disableShortcuts: true, // pas de markdown shortcut à l'intérieur
+ textTag: 'pre',
+ textClassName:
+ 'block w-full font-mono text-sm whitespace-pre-wrap break-words rounded-lg bg-neutral-100 dark:bg-neutral-800/80 px-4 py-3 text-neutral-900 dark:text-neutral-100',
+ placeholder: 'Code…',
+ create(init = {}) {
+ return { id: newBlockId(), type: 'code', content: '', ...init };
+ },
+};
+
+export default Code;
diff --git a/src/shared/components/BlockEditor/blockTypes/Divider.js b/src/shared/components/BlockEditor/blockTypes/Divider.js
new file mode 100644
index 0000000..037ecd6
--- /dev/null
+++ b/src/shared/components/BlockEditor/blockTypes/Divider.js
@@ -0,0 +1,24 @@
+import { newBlockId } from '../utils/ids.js';
+
+function DividerComponent() {
+ return (
+
+
+
+ );
+}
+
+const Divider = {
+ type: 'divider',
+ label: 'Séparateur',
+ icon: '—',
+ keywords: ['separateur', 'divider', 'hr', 'ligne', 'line'],
+ shortcut: '---',
+ isText: false,
+ create(init = {}) {
+ return { id: newBlockId(), type: 'divider', ...init };
+ },
+ Component: DividerComponent,
+};
+
+export default Divider;
diff --git a/src/shared/components/BlockEditor/blockTypes/Heading.js b/src/shared/components/BlockEditor/blockTypes/Heading.js
new file mode 100644
index 0000000..41f971d
--- /dev/null
+++ b/src/shared/components/BlockEditor/blockTypes/Heading.js
@@ -0,0 +1,36 @@
+import { newBlockId } from '../utils/ids.js';
+
+const HEADING_STYLES = {
+ 1: 'text-3xl font-bold leading-tight text-neutral-900 dark:text-white',
+ 2: 'text-2xl font-bold leading-tight text-neutral-900 dark:text-white',
+ 3: 'text-xl font-semibold leading-snug text-neutral-900 dark:text-white',
+ 4: 'text-lg font-semibold leading-snug text-neutral-900 dark:text-white',
+ 5: 'text-base font-semibold leading-normal text-neutral-900 dark:text-white',
+ 6: 'text-sm font-semibold uppercase tracking-wide text-neutral-700 dark:text-neutral-300',
+};
+
+function makeHeading(level) {
+ return {
+ type: `heading_${level}`,
+ label: `Titre ${level}`,
+ icon: `H${level}`,
+ keywords: [`titre ${level}`, `heading ${level}`, `h${level}`],
+ isText: true,
+ textTag: `h${level}`,
+ textClassName: HEADING_STYLES[level],
+ placeholder: `Titre ${level}`,
+ shortcut: `${'#'.repeat(level)} `,
+ create(init = {}) {
+ return { id: newBlockId(), type: `heading_${level}`, content: '', ...init };
+ },
+ };
+}
+
+export const Heading1 = makeHeading(1);
+export const Heading2 = makeHeading(2);
+export const Heading3 = makeHeading(3);
+export const Heading4 = makeHeading(4);
+export const Heading5 = makeHeading(5);
+export const Heading6 = makeHeading(6);
+
+export default [Heading1, Heading2, Heading3, Heading4, Heading5, Heading6];
diff --git a/src/shared/components/BlockEditor/blockTypes/NumberedList.js b/src/shared/components/BlockEditor/blockTypes/NumberedList.js
new file mode 100644
index 0000000..96785b6
--- /dev/null
+++ b/src/shared/components/BlockEditor/blockTypes/NumberedList.js
@@ -0,0 +1,29 @@
+import { newBlockId } from '../utils/ids.js';
+
+const NumberedItem = {
+ type: 'numbered_item',
+ label: 'Liste numérotée',
+ icon: '1.',
+ keywords: ['liste numerotee', 'numbered list', 'ordonnee', 'ordered', 'ol'],
+ shortcut: '1. ',
+ isText: true,
+ textTag: 'li',
+ textClassName: 'text-base leading-relaxed text-neutral-900 dark:text-white',
+ placeholder: 'Élément numéroté',
+ renderPrefix({ numberedIndex }) {
+ const n = typeof numberedIndex === 'number' ? numberedIndex : 1;
+ return (
+
+ {n}.
+
+ );
+ },
+ create(init = {}) {
+ return { id: newBlockId(), type: 'numbered_item', content: '', ...init };
+ },
+};
+
+export default NumberedItem;
diff --git a/src/shared/components/BlockEditor/blockTypes/Paragraph.js b/src/shared/components/BlockEditor/blockTypes/Paragraph.js
new file mode 100644
index 0000000..0230207
--- /dev/null
+++ b/src/shared/components/BlockEditor/blockTypes/Paragraph.js
@@ -0,0 +1,17 @@
+import { newBlockId } from '../utils/ids.js';
+
+const Paragraph = {
+ type: 'paragraph',
+ label: 'Texte',
+ icon: '¶',
+ keywords: ['paragraphe', 'paragraph', 'texte', 'text', 'p'],
+ isText: true,
+ textTag: 'p',
+ textClassName: 'text-base leading-relaxed text-neutral-900 dark:text-white',
+ placeholder: "Tapez '/' pour les commandes…",
+ create(init = {}) {
+ return { id: newBlockId(), type: 'paragraph', content: '', ...init };
+ },
+};
+
+export default Paragraph;
diff --git a/src/shared/components/BlockEditor/blockTypes/Quote.js b/src/shared/components/BlockEditor/blockTypes/Quote.js
new file mode 100644
index 0000000..5f10176
--- /dev/null
+++ b/src/shared/components/BlockEditor/blockTypes/Quote.js
@@ -0,0 +1,19 @@
+import { newBlockId } from '../utils/ids.js';
+
+const Quote = {
+ type: 'quote',
+ label: 'Citation',
+ icon: '❝',
+ keywords: ['citation', 'quote', 'blockquote'],
+ shortcut: '> ',
+ isText: true,
+ textTag: 'blockquote',
+ textClassName:
+ 'text-base leading-relaxed italic text-neutral-700 dark:text-neutral-300 border-l-4 border-neutral-300 dark:border-neutral-700 pl-4 py-1',
+ placeholder: 'Citation…',
+ create(init = {}) {
+ return { id: newBlockId(), type: 'quote', content: '', ...init };
+ },
+};
+
+export default Quote;
diff --git a/src/shared/components/BlockEditor/blockTypes/index.js b/src/shared/components/BlockEditor/blockTypes/index.js
new file mode 100644
index 0000000..9c53f04
--- /dev/null
+++ b/src/shared/components/BlockEditor/blockTypes/index.js
@@ -0,0 +1,25 @@
+// Enregistrement des blocs built-in. Importé une seule fois depuis le barrel
+// principal pour garantir que les types sont disponibles avant utilisation.
+
+import { registerBlock } from '../blockRegistry.js';
+import Paragraph from './Paragraph.js';
+import HeadingList from './Heading.js';
+import BulletItem from './BulletList.js';
+import NumberedItem from './NumberedList.js';
+import Quote from './Quote.js';
+import Code from './Code.js';
+import Divider from './Divider.js';
+
+let registered = false;
+
+export function registerBuiltInBlocks() {
+ if (registered) return;
+ registered = true;
+ registerBlock(Paragraph);
+ HeadingList.forEach(registerBlock);
+ registerBlock(BulletItem);
+ registerBlock(NumberedItem);
+ registerBlock(Quote);
+ registerBlock(Code);
+ registerBlock(Divider);
+}
diff --git a/src/shared/components/BlockEditor/index.js b/src/shared/components/BlockEditor/index.js
new file mode 100644
index 0000000..9485588
--- /dev/null
+++ b/src/shared/components/BlockEditor/index.js
@@ -0,0 +1,21 @@
+// Barrel public du BlockEditor.
+//
+// Importer le composant depuis :
+// import { BlockEditor } from '@zen/core/shared/components';
+//
+// Pour enregistrer un type de bloc custom dans une app consommatrice :
+// import { registerBlock } from '@zen/core/shared/components/BlockEditor';
+// registerBlock({ type: 'kpi', label: 'KPI', icon: '📊', isText: false,
+// create: () => ({ id: crypto.randomUUID(), type: 'kpi' }),
+// Component: MyKpiBlock });
+
+export { default as BlockEditor } from './BlockEditor.client.js';
+export { default } from './BlockEditor.client.js';
+export {
+ registerBlock,
+ getBlockDef,
+ listBlocks,
+ isBlockText,
+ DEFAULT_BLOCK_TYPE,
+} from './blockRegistry.js';
+export { newBlockId } from './utils/ids.js';
diff --git a/src/shared/components/BlockEditor/utils/caret.js b/src/shared/components/BlockEditor/utils/caret.js
new file mode 100644
index 0000000..ce7a12c
--- /dev/null
+++ b/src/shared/components/BlockEditor/utils/caret.js
@@ -0,0 +1,47 @@
+// Helpers de gestion du caret pour les contentEditable mono-bloc.
+// On reste sur du texte brut au MVP : un seul Text node enfant (ou aucun).
+
+export function getCaretOffset(el) {
+ const sel = typeof window !== 'undefined' ? window.getSelection() : null;
+ if (!sel || sel.rangeCount === 0) return 0;
+ const range = sel.getRangeAt(0);
+ if (!el.contains(range.startContainer)) return 0;
+ const pre = range.cloneRange();
+ pre.selectNodeContents(el);
+ pre.setEnd(range.startContainer, range.startOffset);
+ return pre.toString().length;
+}
+
+export function setCaretOffset(el, offset) {
+ if (!el) return;
+ el.focus();
+ const sel = window.getSelection();
+ if (!sel) return;
+ const range = document.createRange();
+ const text = el.firstChild;
+ if (!text) {
+ range.setStart(el, 0);
+ range.collapse(true);
+ } else {
+ const max = text.textContent?.length ?? 0;
+ const pos = Math.max(0, Math.min(offset, max));
+ range.setStart(text, pos);
+ range.collapse(true);
+ }
+ sel.removeAllRanges();
+ sel.addRange(range);
+}
+
+export function focusEnd(el) {
+ if (!el) return;
+ const len = (el.textContent ?? '').length;
+ setCaretOffset(el, len);
+}
+
+export function isCaretAtStart(el) {
+ return getCaretOffset(el) === 0;
+}
+
+export function isCaretAtEnd(el) {
+ return getCaretOffset(el) === (el.textContent ?? '').length;
+}
diff --git a/src/shared/components/BlockEditor/utils/ids.js b/src/shared/components/BlockEditor/utils/ids.js
new file mode 100644
index 0000000..7781b62
--- /dev/null
+++ b/src/shared/components/BlockEditor/utils/ids.js
@@ -0,0 +1,8 @@
+// Génère un ID stable pour un bloc. randomUUID est dispo dans tous les navigateurs
+// modernes côté client ; en SSR on retombe sur un fallback simple.
+export function newBlockId() {
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
+ return crypto.randomUUID();
+ }
+ return `b_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
+}
diff --git a/src/shared/components/index.js b/src/shared/components/index.js
index e879040..f25c99b 100644
--- a/src/shared/components/index.js
+++ b/src/shared/components/index.js
@@ -21,6 +21,7 @@ export { default as StatCard } from './StatCard';
export { default as Table } from './Table';
export { default as Textarea } from './Textarea';
export { default as MarkdownEditor } from './MarkdownEditor';
+export { default as BlockEditor } from './BlockEditor';
export { default as PasswordStrengthIndicator } from './PasswordStrengthIndicator';
export { default as FilterTabs } from './FilterTabs';
export { default as Breadcrumb } from './Breadcrumb';