feat(ui): add BlockEditor component with block types, slash menu, and drag-and-drop

- add BlockEditor orchestrator with controlled block list and keyboard navigation
- add Block client component with contentEditable sync, drag handles, and markdown shortcuts
- add SlashMenu for inserting block types via `/` command
- add blockRegistry and block type definitions (paragraph, heading, bullet list, numbered list, quote, code, divider)
- add caret and id utility helpers
- export BlockEditor from shared components index
- add BlockEditor demo to admin devkit ComponentsPage
- add README documenting usage and architecture
This commit is contained in:
2026-04-25 17:37:23 -04:00
parent 0c99bf5002
commit 54386d3fe3
18 changed files with 1401 additions and 0 deletions
@@ -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() {
<PreviewBlock title="Loading">
<Loading />
</PreviewBlock>
<PreviewBlock title="BlockEditor">
<BlockEditorDemo />
</PreviewBlock>
</div>
);
}
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 (
<div className="w-full flex flex-col gap-4">
<BlockEditor value={blocks} onChange={setBlocks} />
<details className="text-xs text-neutral-500 dark:text-neutral-400">
<summary className="cursor-pointer select-none">Aperçu JSON</summary>
<pre className="mt-2 p-3 rounded-lg bg-neutral-100 dark:bg-neutral-800/60 overflow-x-auto text-[11px] leading-relaxed">
{JSON.stringify(blocks, null, 2)}
</pre>
</details>
</div>
);
}
@@ -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 (
<div className="text-xs text-red-500 px-2 py-1">
Type de bloc inconnu : {block.type}
</div>
);
}
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 && (
<div className="absolute left-0 right-0 -top-px h-0.5 bg-blue-500 dark:bg-blue-400 pointer-events-none" />
)}
{isDragOverBottom && (
<div className="absolute left-0 right-0 -bottom-px h-0.5 bg-blue-500 dark:bg-blue-400 pointer-events-none" />
)}
</>
);
return (
<div
className="relative group flex items-start gap-1 px-1 py-0.5"
onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)}
draggable={draggable}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragLeave={onDragLeave}
onDrop={handleDrop}
data-block-id={block.id}
>
{dropIndicator}
<div
className={`flex items-center gap-0.5 pt-1 transition-opacity ${hovered ? 'opacity-100' : 'opacity-0'} ${disabled ? 'pointer-events-none' : ''}`}
aria-hidden={!hovered}
>
<button
type="button"
tabIndex={-1}
title="Insérer un bloc"
onClick={() => onPlusClick?.(block.id)}
disabled={disabled}
className="w-5 h-5 flex items-center justify-center rounded text-neutral-500 hover:bg-neutral-200 dark:hover:bg-neutral-700/60 hover:text-neutral-900 dark:hover:text-white text-sm leading-none"
>
+
</button>
<button
type="button"
tabIndex={-1}
title="Glisser pour réordonner"
onMouseDown={handleHandleMouseDown}
disabled={disabled}
className="w-5 h-5 flex items-center justify-center rounded text-neutral-500 hover:bg-neutral-200 dark:hover:bg-neutral-700/60 hover:text-neutral-900 dark:hover:text-white text-xs cursor-grab active:cursor-grabbing leading-none"
>
</button>
</div>
<div className="flex-1 min-w-0">
{def.isText ? (
def.renderPrefix ? (
<div className="flex items-start">
{def.renderPrefix({ block, index, numberedIndex })}
<TextEditable
ref={editableRef}
className={`flex-1 outline-none ${def.textClassName || ''} ${isEmpty ? 'block-empty' : ''}`}
placeholder={def.placeholder}
disabled={disabled}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => onFocus?.(block.id)}
/>
</div>
) : (
<TextEditable
ref={editableRef}
className={`outline-none ${def.textClassName || ''} ${isEmpty ? 'block-empty' : ''}`}
placeholder={def.placeholder}
disabled={disabled}
onInput={handleInput}
onKeyDown={handleKeyDown}
onPaste={handlePaste}
onFocus={() => onFocus?.(block.id)}
/>
)
) : def.Component ? (
<def.Component
block={block}
disabled={disabled}
onChange={(patch) => onContentChange?.(block.id, patch)}
/>
) : (
<div className="text-xs text-red-500">Bloc {block.type} sans rendu.</div>
)}
</div>
</div>
);
});
const TextEditable = forwardRef(function TextEditable(
{ className, placeholder, disabled, onInput, onKeyDown, onPaste, onFocus },
ref,
) {
return (
<div
ref={ref}
contentEditable={!disabled}
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
data-placeholder={placeholder}
className={className}
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}
onFocus={onFocus}
spellCheck="true"
/>
);
});
export default Block;
@@ -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 (
<div className="space-y-2">
{label && (
<label className="block text-xs font-medium text-neutral-700 dark:text-neutral-400">
{label}
</label>
)}
<div
ref={containerRef}
onKeyDown={handleGlobalKeyDown}
className={`block-editor border rounded-xl bg-white dark:bg-neutral-900/60 px-3 py-3 ${error ? 'border-red-500/50' : 'border-neutral-300 dark:border-neutral-700/50'} ${className}`}
>
<BlockEditorStyles />
{placeholder && blocks.length === 1 && !blocks[0].content && (
// Placeholder global injecté via data-placeholder du paragraphe initial
null
)}
{blocks.map((block, i) => (
<Block
key={block.id}
ref={(r) => 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}
/>
))}
</div>
{slashState && (
<SlashMenu
query={slashState.query}
anchorRect={slashState.anchorRect}
enabledBlocks={enabledBlocks}
selectedIndex={slashState.selectedIndex ?? 0}
onSelect={handleSlashSelect}
onHoverIndex={(i) => setSlashState(s => ({ ...s, selectedIndex: i }))}
/>
)}
{error && (
<p className="text-red-600 dark:text-red-400 text-xs">{error}</p>
)}
</div>
);
}
// Styles minimaux : placeholder pour les contentEditable vides.
function BlockEditorStyles() {
return (
<style>{`
.block-editor [contenteditable][data-placeholder]:empty::before {
content: attr(data-placeholder);
color: rgb(163 163 163);
pointer-events: none;
}
.dark .block-editor [contenteditable][data-placeholder]:empty::before {
color: rgb(82 82 82);
}
`}</style>
);
}
+114
View File
@@ -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([]);
<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 (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
<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 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 }) => (
<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, 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.
@@ -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 (
<div
className="fixed z-50 w-64 rounded-xl border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-lg p-3 text-sm text-neutral-500"
style={{ top, left }}
>
Aucune commande pour « {query} »
</div>
);
}
return (
<div
ref={listRef}
className="fixed z-50 w-64 max-h-72 overflow-y-auto rounded-xl border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-lg py-1"
style={{ top, left }}
onMouseDown={(e) => e.preventDefault()} // ne pas voler le focus
>
{items.map((def, i) => {
const active = i === selectedIndex;
return (
<button
key={def.type}
type="button"
data-slash-index={i}
onMouseEnter={() => onHoverIndex?.(i)}
onClick={() => onSelect?.(def.type)}
className={`w-full flex items-center gap-3 px-3 py-2 text-left text-sm transition-colors ${active ? 'bg-neutral-100 dark:bg-neutral-800' : 'hover:bg-neutral-50 dark:hover:bg-neutral-800/60'}`}
>
<span className="w-7 h-7 flex items-center justify-center rounded-md border border-neutral-200 dark:border-neutral-700 text-xs font-medium text-neutral-700 dark:text-neutral-300">
{def.icon}
</span>
<span className="flex-1 min-w-0 truncate text-neutral-900 dark:text-white">
{def.label}
</span>
</button>
);
})}
</div>
);
}
// 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);
}
@@ -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';
@@ -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 (
<span
aria-hidden
className="select-none mt-[0.55em] mr-2 inline-block w-1.5 h-1.5 rounded-full bg-neutral-700 dark:bg-neutral-300 flex-shrink-0"
/>
);
},
create(init = {}) {
return { id: newBlockId(), type: 'bullet_item', content: '', ...init };
},
};
export default BulletItem;
@@ -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;
@@ -0,0 +1,24 @@
import { newBlockId } from '../utils/ids.js';
function DividerComponent() {
return (
<div className="py-2">
<hr className="border-0 h-px bg-neutral-300 dark:bg-neutral-700" />
</div>
);
}
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;
@@ -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];
@@ -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 (
<span
aria-hidden
className="select-none mr-2 text-base leading-relaxed text-neutral-700 dark:text-neutral-300 flex-shrink-0 tabular-nums"
>
{n}.
</span>
);
},
create(init = {}) {
return { id: newBlockId(), type: 'numbered_item', content: '', ...init };
},
};
export default NumberedItem;
@@ -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;
@@ -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;
@@ -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);
}
@@ -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';
@@ -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;
}
@@ -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)}`;
}
+1
View File
@@ -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';