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

- add BlockEditor orchestrator with controlled block list and keyboard navigation
- add Block client component with contentEditable sync, drag handles, and markdown shortcuts
- add SlashMenu for inserting block types via `/` command
- add blockRegistry and block type definitions (paragraph, heading, bullet list, numbered list, quote, code, divider)
- add caret and id utility helpers
- export BlockEditor from shared components index
- add BlockEditor demo to admin devkit ComponentsPage
- add README documenting usage and architecture
This commit is contained in:
2026-04-25 17:37:23 -04:00
parent 0c99bf5002
commit 54386d3fe3
18 changed files with 1401 additions and 0 deletions
@@ -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);
}