1"use client";
2
3
10import { useEffect, useMemo, useRef, useState } from "react";
11import {
12 Bookmark,
13 Braces,
14 Calculator,
15 Database,
16 File as FileIcon,
17 Film,
18 Heading1,
19 Heading2,
20 Heading3,
21 Image as ImageIcon,
22 List,
23 ListOrdered,
24 Minus,
25 Music,
26 Quote,
27 SquareCheck,
28 Table as TableIcon,
29 ToggleRight,
30 Type,
31 Undo2,
32 Wand2
33} from "lucide-react";
34import { cn, fuzzyMatch } from "@/lib/utils";
35import { BLOCK_TYPE_LABELS } from "@/lib/constants";
36import type { BlockType } from "@/lib/types";
37
38export interface SlashCommand {
39 id: string;
40 label: string;
41 keywords: string;
42 icon: React.ComponentType<{ className?: string }>;
43
44 blockType?: BlockType;
45
46 special?: "image" | "database" | "synced" | "template" | "table";
47 group: string;
48}
49
50const COMMANDS: SlashCommand[] = [
51 { id: "paragraph", label: "Text", keywords: "paragraph plain text", icon: Type, blockType: "paragraph", group: "Basics" },
52 { id: "h1", label: "Heading 1", keywords: "heading 1 title h1", icon: Heading1, blockType: "heading_1", group: "Basics" },
53 { id: "h2", label: "Heading 2", keywords: "heading 2 subtitle h2", icon: Heading2, blockType: "heading_2", group: "Basics" },
54 { id: "h3", label: "Heading 3", keywords: "heading 3 h3", icon: Heading3, blockType: "heading_3", group: "Basics" },
55 { id: "todo", label: "To-do list", keywords: "todo task checkbox done", icon: SquareCheck, blockType: "todo", group: "Basics" },
56 { id: "quote", label: "Quote", keywords: "blockquote citation", icon: Quote, blockType: "quote", group: "Basics" },
57 { id: "code", label: "Code", keywords: "code snippet programming", icon: Braces, blockType: "code", group: "Basics" },
58 { id: "bullet", label: "Bulleted list", keywords: "bullet unordered list", icon: List, blockType: "bulleted_list", group: "Lists" },
59 { id: "numbered", label: "Numbered list", keywords: "numbered ordered list", icon: ListOrdered, blockType: "numbered_list", group: "Lists" },
60
61
62
63
64
65 { id: "image", label: "Image", keywords: "image picture photo upload", icon: ImageIcon, special: "image", group: "Media" },
66 { id: "file", label: "File", keywords: "file attachment document", icon: FileIcon, blockType: "file", group: "Media" },
67 { id: "embed", label: "Embed", keywords: "embed iframe video youtube", icon: Film, blockType: "embed", group: "Media" },
68 { id: "bookmark", label: "Bookmark", keywords: "bookmark link url website", icon: Bookmark, blockType: "bookmark", group: "Media" },
69 { id: "divider", label: "Divider", keywords: "divider separator hr line", icon: Minus, blockType: "divider", group: "Media" },
70 { id: "callout", label: "Callout", keywords: "callout note highlight", icon: Type, blockType: "callout", group: "Media" },
71 { id: "toggle", label: "Toggle list", keywords: "toggle collapsible expand", icon: ToggleRight, blockType: "toggle", group: "Media" },
72 { id: "video", label: "Video", keywords: "video movie clip", icon: Film, blockType: "video", group: "Media" },
73 { id: "audio", label: "Audio", keywords: "audio sound music", icon: Music, blockType: "audio", group: "Media" },
74 { id: "spacer", label: "Spacer", keywords: "spacer space gap", icon: Minus, blockType: "spacer", group: "Media" },
75 { id: "toc", label: "Table of contents", keywords: "toc contents headings outline", icon: List, blockType: "toc", group: "Media" },
76 { id: "columns", label: "Columns", keywords: "columns split 2 3", icon: TableIcon, blockType: "column_list", group: "Media" },
77
78
79
80
81 { id: "table", label: "Table", keywords: "table grid rows columns", icon: TableIcon, special: "table", group: "Media" },
82 { id: "equation", label: "Equation", keywords: "equation math latex formula", icon: Calculator, blockType: "equation", group: "Advanced" },
83 { id: "database", label: "Add database", keywords: "database new db table records", icon: Database, special: "database", group: "Advanced" },
84 { id: "linked_database", label: "Linked database", keywords: "linked database embed view existing", icon: TableIcon, blockType: "linked_database", group: "Advanced" },
85 { id: "child_database", label: "Linked database (row)", keywords: "child database embed row", icon: Database, blockType: "child_database", group: "Advanced" },
86 { id: "synced", label: "Insert synced block", keywords: "synced block mirror reference", icon: Undo2, special: "synced", group: "Advanced" },
87 { id: "template", label: "Insert template", keywords: "template insert reuse", icon: Wand2, special: "template", group: "Advanced" }
88];
89
90export interface SlashMenuProps {
91
92 query: string;
93
94 anchor: { top: number; left: number };
95 onPick: (command: SlashCommand) => void;
96 onClose: () => void;
97}
98
99export function SlashMenu({ query, anchor, onPick, onClose }: SlashMenuProps) {
100 const [active, setActive] = useState(0);
101 const listRef = useRef<HTMLUListElement>(null);
102 const rootRef = useRef<HTMLDivElement>(null);
103
104
105
106
107
108
109 useEffect(() => {
110 const onClick = (e: MouseEvent) => {
111 if (rootRef.current && !rootRef.current.contains(e.target as Node)) onClose();
112 };
113 document.addEventListener("mousedown", onClick);
114 return () => document.removeEventListener("mousedown", onClick);
115 }, [onClose]);
116
117 const filtered = useMemo(() => {
118 const q = query.toLowerCase();
119 return COMMANDS.filter((c) => fuzzyMatch(q, c.label + " " + c.keywords));
120 }, [query]);
121
122
123 useEffect(() => {
124 setActive(0);
125 }, [query]);
126 useEffect(() => {
127 if (active >= filtered.length) setActive(Math.max(0, filtered.length - 1));
128 }, [filtered.length, active]);
129 useEffect(() => {
130 listRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });
131 }, [active]);
132
133 useEffect(() => {
134 const onKey = (e: KeyboardEvent) => {
135 if (e.key === "ArrowDown") {
136 e.preventDefault();
137 e.stopPropagation();
138 setActive((a) => Math.min(a + 1, filtered.length - 1));
139 } else if (e.key === "ArrowUp") {
140 e.preventDefault();
141 e.stopPropagation();
142 setActive((a) => Math.max(a - 1, 0));
143 } else if (e.key === "Enter") {
144 e.preventDefault();
145 e.stopPropagation();
146 const cmd = filtered[active];
147 if (cmd) onPick(cmd);
148 } else if (e.key === "Escape") {
149 e.preventDefault();
150 e.stopPropagation();
151 onClose();
152 }
153 };
154 document.addEventListener("keydown", onKey, true);
155 return () => document.removeEventListener("keydown", onKey, true);
156 }, [filtered, active, onPick, onClose]);
157
158
159 const grouped = useMemo(() => {
160 const groups = new Map<string, SlashCommand[]>();
161 for (const c of filtered) {
162 const list = groups.get(c.group) ?? [];
163 list.push(c);
164 groups.set(c.group, list);
165 }
166 return [...groups.entries()];
167 }, [filtered]);
168
169 let flatIndex = -1;
170
171
172
173
174
175
176 const LIST_MAX = 326;
177 const vh = typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY;
178 const flipUp = anchor.top + LIST_MAX + 40 > vh - 16;
179 const top = flipUp ? Math.max(8, anchor.top - LIST_MAX - 40) : anchor.top;
180
181 return (
182 <div
183 ref={rootRef}
184 role="dialog"
185 aria-label="Slash commands"
186 data-testid="slash-menu"
187 className="fixed z-[70] w-80 rounded-lg border border-border bg-popover shadow-2xl"
188 style={{ top, left: anchor.left }}
189 >
190 <p className="border-b border-border px-3 py-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
191 Basic blocks & commands
192 </p>
193 <ul ref={listRef} className="max-h-[326px] overflow-y-auto p-1" role="listbox">
194 {grouped.length === 0 ? (
195 <li className="px-3 py-4 text-center text-sm text-muted-foreground">No matching command.</li>
196 ) : (
197 grouped.map(([group, cmds]) => (
198 <li key={group}>
199 <p className="px-2 pb-0.5 pt-1 text-[11px] font-medium text-muted-foreground">{group}</p>
200 <ul>
201 {cmds.map((cmd) => {
202 flatIndex += 1;
203 const idx = flatIndex;
204 return (
205 <li key={cmd.id} role="option" aria-selected={idx === active} data-index={idx}>
206 <button
207 type="button"
208 data-testid={`slash-menu-item-${cmd.id}`}
209 onMouseEnter={() => setActive(idx)}
210
211
212
213
214
215
216
217
218 onMouseDown={(e) => e.preventDefault()}
219 onClick={() => onPick(cmd)}
220 className={cn(
221 "flex w-full items-center gap-3 rounded-md px-2 py-1.5 text-left text-sm",
222 idx === active ? "bg-accent" : "hover:bg-accent/60"
223 )}
224 >
225 <span className="flex size-8 shrink-0 items-center justify-center rounded border border-border bg-background">
226 <cmd.icon className="size-4 text-muted-foreground" aria-hidden />
227 </span>
228 <span className="min-w-0 flex-1">
229 <span className="block truncate font-medium">{cmd.label}</span>
230 {cmd.blockType ? (
231 <span className="block truncate text-xs text-muted-foreground">
232 {BLOCK_TYPE_LABELS[cmd.blockType]}
233 </span>
234 ) : null}
235 </span>
236 </button>
237 </li>
238 );
239 })}
240 </ul>
241 </li>
242 ))
243 )}
244 </ul>
245 </div>
246 );
247}
248