From 54386d3fe30cb52ca0266e87d5320a2fff675fa3 Mon Sep 17 00:00:00 2001 From: Hyko Date: Sat, 25 Apr 2026 17:37:23 -0400 Subject: [PATCH] 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 --- .../admin/devkit/ComponentsPage.client.js | 26 + .../components/BlockEditor/Block.client.js | 302 ++++++++++ .../BlockEditor/BlockEditor.client.js | 528 ++++++++++++++++++ src/shared/components/BlockEditor/README.md | 114 ++++ .../BlockEditor/SlashMenu.client.js | 111 ++++ .../components/BlockEditor/blockRegistry.js | 47 ++ .../BlockEditor/blockTypes/BulletList.js | 26 + .../components/BlockEditor/blockTypes/Code.js | 20 + .../BlockEditor/blockTypes/Divider.js | 24 + .../BlockEditor/blockTypes/Heading.js | 36 ++ .../BlockEditor/blockTypes/NumberedList.js | 29 + .../BlockEditor/blockTypes/Paragraph.js | 17 + .../BlockEditor/blockTypes/Quote.js | 19 + .../BlockEditor/blockTypes/index.js | 25 + src/shared/components/BlockEditor/index.js | 21 + .../components/BlockEditor/utils/caret.js | 47 ++ .../components/BlockEditor/utils/ids.js | 8 + src/shared/components/index.js | 1 + 18 files changed, 1401 insertions(+) create mode 100644 src/shared/components/BlockEditor/Block.client.js create mode 100644 src/shared/components/BlockEditor/BlockEditor.client.js create mode 100644 src/shared/components/BlockEditor/README.md create mode 100644 src/shared/components/BlockEditor/SlashMenu.client.js create mode 100644 src/shared/components/BlockEditor/blockRegistry.js create mode 100644 src/shared/components/BlockEditor/blockTypes/BulletList.js create mode 100644 src/shared/components/BlockEditor/blockTypes/Code.js create mode 100644 src/shared/components/BlockEditor/blockTypes/Divider.js create mode 100644 src/shared/components/BlockEditor/blockTypes/Heading.js create mode 100644 src/shared/components/BlockEditor/blockTypes/NumberedList.js create mode 100644 src/shared/components/BlockEditor/blockTypes/Paragraph.js create mode 100644 src/shared/components/BlockEditor/blockTypes/Quote.js create mode 100644 src/shared/components/BlockEditor/blockTypes/index.js create mode 100644 src/shared/components/BlockEditor/index.js create mode 100644 src/shared/components/BlockEditor/utils/caret.js create mode 100644 src/shared/components/BlockEditor/utils/ids.js 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';