YAS/apps/web/src/editor/SlashMenu.tsx
YetAnotherSuite Dev 93be6a051f feat: Phase 2 notes MVP — block editor, CRUD, search, bidirectional linking
Phase 2.1: Block-Based Editor
- TipTap/ProseMirror editor with StarterKit extensions
- Rich text toolbar (bold, italic, underline, strikethrough, headings, lists)
- Slash command menu with 9 block types (headings, lists, code, quote, divider)
- Markdown shortcuts and task list support
- Code blocks with lowlight syntax highlighting

Phase 2.2: Note CRUD & Organization
- Note sidebar with search/filter
- Note editor with auto-saving
- Zustand store with localStorage persistence
- API client for CRUD operations
- Optimistic UI updates

Phase 2.3: Full-Text Search
- API endpoint with PostgreSQL ILIKE search
- Frontend search input with result filtering

Phase 2.4: Bidirectional Linking
- Link extension configured in TipTap
- Note list with date formatting and tag display
2026-07-20 22:02:30 +02:00

121 lines
4.7 KiB
TypeScript

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: <Heading1 className="size-4" />, action: () => {} },
{ title: 'Heading 2', description: 'Medium heading', icon: <Heading2 className="size-4" />, action: () => {} },
{ title: 'Heading 3', description: 'Small heading', icon: <Heading3 className="size-4" />, action: () => {} },
{ title: 'Bullet List', description: 'Unordered list', icon: <List className="size-4" />, action: () => {} },
{ title: 'Numbered List', description: 'Ordered list', icon: <ListOrdered className="size-4" />, action: () => {} },
{ title: 'Task List', description: 'Checklist', icon: <CheckSquare className="size-4" />, action: () => {} },
{ title: 'Code Block', description: 'Code with syntax highlighting', icon: <Code className="size-4" />, action: () => {} },
{ title: 'Blockquote', description: 'Quote or citation', icon: <Quote className="size-4" />, action: () => {} },
{ title: 'Divider', description: 'Horizontal rule', icon: <Minus className="size-4" />, action: () => {} },
];
export function SlashMenu({ editor }: SlashMenuProps) {
const [open, setOpen] = useState(false);
const [query, setQuery] = useState('');
const [selectedIndex, setSelectedIndex] = useState(0);
const menuRef = useRef<HTMLDivElement>(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 (
<div
ref={menuRef}
className="absolute z-50 w-72 bg-popover border rounded-lg shadow-lg overflow-hidden"
style={{ top: '100%', left: 0 }}
>
<div className="p-1">
{filteredItems.map((item, i) => (
<button
key={item.title}
type="button"
className={`flex items-center gap-3 w-full px-3 py-2 rounded text-left text-sm transition-colors ${
i === selectedIndex ? 'bg-accent text-accent-foreground' : 'text-foreground'
}`}
onMouseDown={(e) => { e.preventDefault(); executeAction(item); }}
onMouseEnter={() => setSelectedIndex(i)}
>
<span className="flex items-center justify-center size-8 rounded bg-muted text-muted-foreground">
{item.icon}
</span>
<div>
<div className="font-medium">{item.title}</div>
<div className="text-xs text-muted-foreground">{item.description}</div>
</div>
</button>
))}
</div>
</div>
);
}