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:
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user