"use client"; /** * Slash menu — appears when the user types "/" in a text block: fuzzy * filtered command list (same groups as the TypeMenu plus "Add database", * "Insert synced block", "Insert template"), full keyboard navigation * (↑/↓, Enter, Esc). Selecting a command inserts a new block after the * current one (the current block is split/kept per command semantics). */ import { useEffect, useMemo, useRef, useState } from "react"; import { Bookmark, Braces, Calculator, Database, File as FileIcon, Film, Heading1, Heading2, Heading3, Image as ImageIcon, List, ListOrdered, Minus, Music, Quote, SquareCheck, Table as TableIcon, ToggleRight, Type, Undo2, Wand2 } from "lucide-react"; import { cn, fuzzyMatch } from "@/lib/utils"; import { BLOCK_TYPE_LABELS } from "@/lib/constants"; import type { BlockType } from "@/lib/types"; export interface SlashCommand { id: string; label: string; keywords: string; icon: React.ComponentType<{ className?: string }>; /** block type to insert (for plain type commands). */ blockType?: BlockType; /** Special insertion (handled by the page-level insert flow). */ special?: "image" | "database" | "synced" | "template" | "table"; group: string; } const COMMANDS: SlashCommand[] = [ { id: "paragraph", label: "Text", keywords: "paragraph plain text", icon: Type, blockType: "paragraph", group: "Basics" }, { id: "h1", label: "Heading 1", keywords: "heading 1 title h1", icon: Heading1, blockType: "heading_1", group: "Basics" }, { id: "h2", label: "Heading 2", keywords: "heading 2 subtitle h2", icon: Heading2, blockType: "heading_2", group: "Basics" }, { id: "h3", label: "Heading 3", keywords: "heading 3 h3", icon: Heading3, blockType: "heading_3", group: "Basics" }, { id: "todo", label: "To-do list", keywords: "todo task checkbox done", icon: SquareCheck, blockType: "todo", group: "Basics" }, { id: "quote", label: "Quote", keywords: "blockquote citation", icon: Quote, blockType: "quote", group: "Basics" }, { id: "code", label: "Code", keywords: "code snippet programming", icon: Braces, blockType: "code", group: "Basics" }, { id: "bullet", label: "Bulleted list", keywords: "bullet unordered list", icon: List, blockType: "bulleted_list", group: "Lists" }, { id: "numbered", label: "Numbered list", keywords: "numbered ordered list", icon: ListOrdered, blockType: "numbered_list", group: "Lists" }, // "image" is a SPECIAL (not blockType) command: picking it opens the insert // dialog's Image tab (file picker → presigned upload → image block with the // signed URL). A bare image block has no src and no reachable picker, so it // could never actually be uploaded (the renderer's "use Insert image from // the toolbar" hint referenced a toolbar button that does not exist). { id: "image", label: "Image", keywords: "image picture photo upload", icon: ImageIcon, special: "image", group: "Media" }, { id: "file", label: "File", keywords: "file attachment document", icon: FileIcon, blockType: "file", group: "Media" }, { id: "embed", label: "Embed", keywords: "embed iframe video youtube", icon: Film, blockType: "embed", group: "Media" }, { id: "bookmark", label: "Bookmark", keywords: "bookmark link url website", icon: Bookmark, blockType: "bookmark", group: "Media" }, { id: "divider", label: "Divider", keywords: "divider separator hr line", icon: Minus, blockType: "divider", group: "Media" }, { id: "callout", label: "Callout", keywords: "callout note highlight", icon: Type, blockType: "callout", group: "Media" }, { id: "toggle", label: "Toggle list", keywords: "toggle collapsible expand", icon: ToggleRight, blockType: "toggle", group: "Media" }, { id: "video", label: "Video", keywords: "video movie clip", icon: Film, blockType: "video", group: "Media" }, { id: "audio", label: "Audio", keywords: "audio sound music", icon: Music, blockType: "audio", group: "Media" }, { id: "spacer", label: "Spacer", keywords: "spacer space gap", icon: Minus, blockType: "spacer", group: "Media" }, { id: "toc", label: "Table of contents", keywords: "toc contents headings outline", icon: List, blockType: "toc", group: "Media" }, { id: "columns", label: "Columns", keywords: "columns split 2 3", icon: TableIcon, blockType: "column_list", group: "Media" }, // "table" is a SPECIAL (not blockType) command: picking it opens the insert // dialog's Table tab (rows × cols → grid pre-created, like image/database). // A bare table block has no columns/rows, so the renderer draws nothing — // it could never be filled in (verified by probe: thead/tbody both empty). { id: "table", label: "Table", keywords: "table grid rows columns", icon: TableIcon, special: "table", group: "Media" }, { id: "equation", label: "Equation", keywords: "equation math latex formula", icon: Calculator, blockType: "equation", group: "Advanced" }, { id: "database", label: "Add database", keywords: "database new db table records", icon: Database, special: "database", group: "Advanced" }, { id: "linked_database", label: "Linked database", keywords: "linked database embed view existing", icon: TableIcon, blockType: "linked_database", group: "Advanced" }, { id: "child_database", label: "Linked database (row)", keywords: "child database embed row", icon: Database, blockType: "child_database", group: "Advanced" }, { id: "synced", label: "Insert synced block", keywords: "synced block mirror reference", icon: Undo2, special: "synced", group: "Advanced" }, { id: "template", label: "Insert template", keywords: "template insert reuse", icon: Wand2, special: "template", group: "Advanced" } ]; export interface SlashMenuProps { /** The typed query after "/". */ query: string; /** Position to anchor the menu (caret rect). */ anchor: { top: number; left: number }; onPick: (command: SlashCommand) => void; onClose: () => void; } export function SlashMenu({ query, anchor, onPick, onClose }: SlashMenuProps) { const [active, setActive] = useState(0); const listRef = useRef(null); const rootRef = useRef(null); // Click-outside dismissal (mirrors TypeMenu). The editor's onBlur path only // covers clicks that BLUR the contenteditable — a click inside the editor's // blank trailing zone (or elsewhere that keeps it focused) leaves the menu // stuck open. A document-level mousedown that lands outside the menu root // closes it regardless of focus. useEffect(() => { const onClick = (e: MouseEvent) => { if (rootRef.current && !rootRef.current.contains(e.target as Node)) onClose(); }; document.addEventListener("mousedown", onClick); return () => document.removeEventListener("mousedown", onClick); }, [onClose]); const filtered = useMemo(() => { const q = query.toLowerCase(); return COMMANDS.filter((c) => fuzzyMatch(q, c.label + " " + c.keywords)); }, [query]); // Reset active on filter change; keep it in range. useEffect(() => { setActive(0); }, [query]); useEffect(() => { if (active >= filtered.length) setActive(Math.max(0, filtered.length - 1)); }, [filtered.length, active]); useEffect(() => { listRef.current?.querySelector(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" }); }, [active]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === "ArrowDown") { e.preventDefault(); e.stopPropagation(); setActive((a) => Math.min(a + 1, filtered.length - 1)); } else if (e.key === "ArrowUp") { e.preventDefault(); e.stopPropagation(); setActive((a) => Math.max(a - 1, 0)); } else if (e.key === "Enter") { e.preventDefault(); e.stopPropagation(); const cmd = filtered[active]; if (cmd) onPick(cmd); } else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); onClose(); } }; document.addEventListener("keydown", onKey, true); return () => document.removeEventListener("keydown", onKey, true); }, [filtered, active, onPick, onClose]); // Group the filtered list preserving COMMANDS order. const grouped = useMemo(() => { const groups = new Map(); for (const c of filtered) { const list = groups.get(c.group) ?? []; list.push(c); groups.set(c.group, list); } return [...groups.entries()]; }, [filtered]); let flatIndex = -1; // Keep the menu inside the viewport: the list is height-capped below, so // the whole menu is at most LIST_MAX + the header row. When the caret is // near the bottom (e.g. an empty last block on a dense page), opening // downward would push the lower groups (Media/Advanced — "Add database") // below the fold where they cannot be clicked; flip upward instead. const LIST_MAX = 326; const vh = typeof window !== "undefined" ? window.innerHeight : Number.POSITIVE_INFINITY; const flipUp = anchor.top + LIST_MAX + 40 > vh - 16; const top = flipUp ? Math.max(8, anchor.top - LIST_MAX - 40) : anchor.top; return (

Basic blocks & commands

    {grouped.length === 0 ? (
  • No matching command.
  • ) : ( grouped.map(([group, cmds]) => (
  • {group}

      {cmds.map((cmd) => { flatIndex += 1; const idx = flatIndex; return (
    • ); })}
  • )) )}
); }