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
121 lines
4.7 KiB
TypeScript
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>
|
|
);
|
|
}
|