63ba04d583
- initialize position state with a `bottom` field set to null - use `bottom` css property instead of `top` when menu flips above the anchor to prevent floating when content shrinks on query filtering - remove `items.length` from layout effect dependencies since repositioning on item count change caused the flipping issue - build `positionStyle` object conditionally based on whether `bottom` is set
178 lines
6.0 KiB
JavaScript
178 lines
6.0 KiB
JavaScript
'use client';
|
|
|
|
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
const MENU_WIDTH = 375;
|
|
const MENU_MAX_HEIGHT = 360;
|
|
const VIEWPORT_MARGIN = 8;
|
|
|
|
const SHORTCUT_HINT = {
|
|
paragraph: '',
|
|
heading_1: '#',
|
|
heading_2: '##',
|
|
heading_3: '###',
|
|
heading_4: '####',
|
|
heading_5: '#####',
|
|
heading_6: '######',
|
|
bullet_item: '-',
|
|
numbered_item: '1.',
|
|
checklist: '[]',
|
|
quote: '>',
|
|
code: '```',
|
|
divider: '---',
|
|
image: '',
|
|
};
|
|
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);
|
|
const [position, setPosition] = useState({ top: 0, bottom: null, left: 0, maxHeight: MENU_MAX_HEIGHT });
|
|
|
|
// Scroll l'élément sélectionné dans la vue (interne au menu uniquement)
|
|
useEffect(() => {
|
|
const el = listRef.current?.querySelector(`[data-slash-index="${selectedIndex}"]`);
|
|
if (el) el.scrollIntoView({ block: 'nearest' });
|
|
}, [selectedIndex]);
|
|
|
|
// Positionnement adaptatif : flip au-dessus si pas assez de place en bas,
|
|
// clamp horizontalement, et limite la hauteur à l'espace disponible.
|
|
// Quand on est en mode « au-dessus », on ancre via `bottom` plutôt que
|
|
// `top` — sinon, quand le contenu rétrécit (filtrage par query), le bas
|
|
// de la boîte décolle du champ et flotte en l'air.
|
|
useLayoutEffect(() => {
|
|
if (!anchorRect || typeof window === 'undefined') return;
|
|
const vh = window.innerHeight;
|
|
const vw = window.innerWidth;
|
|
const spaceBelow = vh - anchorRect.bottom - VIEWPORT_MARGIN;
|
|
const spaceAbove = anchorRect.top - VIEWPORT_MARGIN;
|
|
|
|
let top = null;
|
|
let bottom = null;
|
|
let maxHeight;
|
|
if (spaceBelow >= Math.min(MENU_MAX_HEIGHT, 200) || spaceBelow >= spaceAbove) {
|
|
top = anchorRect.bottom + 6;
|
|
maxHeight = Math.max(120, Math.min(MENU_MAX_HEIGHT, spaceBelow - 6));
|
|
} else {
|
|
bottom = vh - anchorRect.top + 6;
|
|
maxHeight = Math.max(120, Math.min(MENU_MAX_HEIGHT, spaceAbove - 6));
|
|
}
|
|
|
|
let left = anchorRect.left;
|
|
if (left + MENU_WIDTH + VIEWPORT_MARGIN > vw) {
|
|
left = Math.max(VIEWPORT_MARGIN, vw - MENU_WIDTH - VIEWPORT_MARGIN);
|
|
}
|
|
if (left < VIEWPORT_MARGIN) left = VIEWPORT_MARGIN;
|
|
|
|
setPosition({ top, bottom, left, maxHeight });
|
|
}, [anchorRect]);
|
|
|
|
if (!anchorRect) return null;
|
|
|
|
const { top, bottom, left, maxHeight } = position;
|
|
const positionStyle = bottom != null
|
|
? { bottom, left, width: MENU_WIDTH, maxHeight }
|
|
: { top, left, width: MENU_WIDTH, maxHeight };
|
|
|
|
if (items.length === 0) {
|
|
return (
|
|
<div
|
|
className="fixed z-50 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md px-3 py-2 text-xs text-neutral-500"
|
|
style={positionStyle}
|
|
>
|
|
Aucune commande pour « {query} »
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
ref={listRef}
|
|
data-slash-menu
|
|
className="fixed z-50 overflow-y-auto rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-md py-1"
|
|
style={positionStyle}
|
|
onMouseDown={(e) => e.preventDefault()} // ne pas voler le focus
|
|
>
|
|
<div className="px-3 pt-1.5 pb-1 text-[10px] font-medium uppercase tracking-wider text-neutral-400 dark:text-neutral-500">
|
|
Blocs de base
|
|
</div>
|
|
{items.map((def, i) => {
|
|
const active = i === selectedIndex;
|
|
const hint = SHORTCUT_HINT[def.type];
|
|
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-2 py-1.5 text-left transition-colors ${active ? 'bg-neutral-100 dark:bg-neutral-800' : ''}`}
|
|
>
|
|
<span className="w-8 h-8 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 flex-shrink-0">
|
|
{def.icon}
|
|
</span>
|
|
<span className="flex-1 min-w-0 truncate text-sm text-neutral-900 dark:text-white">
|
|
{def.label}
|
|
</span>
|
|
{hint && (
|
|
<span className="text-xs font-mono text-neutral-400 dark:text-neutral-500 flex-shrink-0">
|
|
{hint}
|
|
</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);
|
|
}
|