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:
@@ -13,6 +13,7 @@ import {
|
|||||||
TagInput,
|
TagInput,
|
||||||
StatCard,
|
StatCard,
|
||||||
Loading,
|
Loading,
|
||||||
|
BlockEditor,
|
||||||
} from '@zen/core/shared/components';
|
} from '@zen/core/shared/components';
|
||||||
import { UserCircle02Icon } from '@zen/core/shared/icons';
|
import { UserCircle02Icon } from '@zen/core/shared/icons';
|
||||||
import AdminHeader from '../components/AdminHeader.js';
|
import AdminHeader from '../components/AdminHeader.js';
|
||||||
@@ -207,6 +208,31 @@ export default function ComponentsPage() {
|
|||||||
<PreviewBlock title="Loading">
|
<PreviewBlock title="Loading">
|
||||||
<Loading />
|
<Loading />
|
||||||
</PreviewBlock>
|
</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>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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)}`;
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ export { default as StatCard } from './StatCard';
|
|||||||
export { default as Table } from './Table';
|
export { default as Table } from './Table';
|
||||||
export { default as Textarea } from './Textarea';
|
export { default as Textarea } from './Textarea';
|
||||||
export { default as MarkdownEditor } from './MarkdownEditor';
|
export { default as MarkdownEditor } from './MarkdownEditor';
|
||||||
|
export { default as BlockEditor } from './BlockEditor';
|
||||||
export { default as PasswordStrengthIndicator } from './PasswordStrengthIndicator';
|
export { default as PasswordStrengthIndicator } from './PasswordStrengthIndicator';
|
||||||
export { default as FilterTabs } from './FilterTabs';
|
export { default as FilterTabs } from './FilterTabs';
|
||||||
export { default as Breadcrumb } from './Breadcrumb';
|
export { default as Breadcrumb } from './Breadcrumb';
|
||||||
|
|||||||
Reference in New Issue
Block a user