import { useState, useCallback, useEffect, useRef } from 'react'; import { type Editor } from '@tiptap/react'; import { Heading1, Heading2, Heading3, List, ListOrdered, CheckSquare, Code, Quote, Minus, } from 'lucide-react'; interface SlashMenuProps { editor: Editor } interface SlashItem { title: string; description: string; icon: React.ReactNode; action: () => void; } const items: SlashItem[] = [ { title: 'Heading 1', description: 'Large heading', icon: , action: () => {} }, { title: 'Heading 2', description: 'Medium heading', icon: , action: () => {} }, { title: 'Heading 3', description: 'Small heading', icon: , action: () => {} }, { title: 'Bullet List', description: 'Unordered list', icon: , action: () => {} }, { title: 'Numbered List', description: 'Ordered list', icon: , action: () => {} }, { title: 'Task List', description: 'Checklist', icon: , action: () => {} }, { title: 'Code Block', description: 'Code with syntax highlighting', icon: , action: () => {} }, { title: 'Blockquote', description: 'Quote or citation', icon: , action: () => {} }, { title: 'Divider', description: 'Horizontal rule', icon: , action: () => {} }, ]; export function SlashMenu({ editor }: SlashMenuProps) { const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const [selectedIndex, setSelectedIndex] = useState(0); const menuRef = useRef(null); const filteredItems = query ? items.filter((item) => item.title.toLowerCase().includes(query.toLowerCase())) : items; const executeAction = useCallback((item: SlashItem) => { const { chain } = editor; const index = items.indexOf(item); const actions = [ () => chain().focus().toggleHeading({ level: 1 }).run(), () => chain().focus().toggleHeading({ level: 2 }).run(), () => chain().focus().toggleHeading({ level: 3 }).run(), () => chain().focus().toggleBulletList().run(), () => chain().focus().toggleOrderedList().run(), () => chain().focus().toggleTaskList().run(), () => chain().focus().toggleCodeBlock().run(), () => chain().focus().toggleBlockquote().run(), () => chain().focus().setHorizontalRule().run(), ]; actions[index]?.(); setOpen(false); setQuery(''); }, [editor]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (!open) return; if (e.key === 'ArrowDown') { e.preventDefault(); setSelectedIndex((i) => (i + 1) % filteredItems.length); } if (e.key === 'ArrowUp') { e.preventDefault(); setSelectedIndex((i) => (i - 1 + filteredItems.length) % filteredItems.length); } if (e.key === 'Enter') { e.preventDefault(); executeAction(filteredItems[selectedIndex]!); } if (e.key === 'Escape') { setOpen(false); setQuery(''); } }; document.addEventListener('keydown', handleKeyDown); return () => document.removeEventListener('keydown', handleKeyDown); }, [open, filteredItems, selectedIndex, executeAction]); useEffect(() => { setSelectedIndex(0); }, [query]); editor.on('selectionUpdate', () => { const { state } = editor; const { $from } = state.selection; const textBefore = $from.parent.textBetween(0, $from.parentOffset); const slashMatch = textBefore.match(/\/(\w*)$/); if (slashMatch) { setOpen(true); setQuery(slashMatch[1] ?? ''); } else if (open) { setOpen(false); setQuery(''); } }); if (!open) return null; return ( {filteredItems.map((item, i) => ( { e.preventDefault(); executeAction(item); }} onMouseEnter={() => setSelectedIndex(i)} > {item.icon} {item.title} {item.description} ))} ); }