'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 (
Aucune commande pour « {query} »
); } return (
e.preventDefault()} // ne pas voler le focus >
Blocs de base
{items.map((def, i) => { const active = i === selectedIndex; const hint = SHORTCUT_HINT[def.type]; 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); }