import { TextSelection } from "@tiptap/pm/state"; import type { Editor } from "@tiptap/react"; import { useCallback, useEffect, useMemo, useRef, useState, type CSSProperties, } from "react"; import { cn } from "../utils.js"; import { pickAndInsertImage, type ImageUploadFn } from "./ImageExtension.js"; /** A single slash-menu block command. Apps can extend the default list. */ export interface SlashCommandItem { title: string; description: string; /** * Optional hidden search text. Use this for raw block types and aliases that * should match slash queries without making the visible description verbose. */ searchText?: string; /** Short text glyph shown in the menu (T, H1, tbl, …). */ icon: string; /** Hide this command when the shared editor feature is disabled. */ requires?: "tables" | "tasks" | "codeBlock"; action: (editor: Editor) => void; } /** * Put the caret inside the block a slash command just created or transformed. * * Tiptap commands usually preserve a text selection, but commands that replace * the current paragraph (notably code blocks and horizontal rules) can leave a * node selection or a caret at the following block. Resolving from the slash * range keeps the behavior consistent for built-in and app-provided block * commands: after selecting a block, typing starts in that block immediately. */ export function focusEditorInInsertedBlock( editor: Editor, anchorPosition?: number, ): void { if (editor.isDestroyed) return; const { state } = editor; const anchor = Math.max( 1, Math.min(anchorPosition ?? state.selection.from, state.doc.content.size), ); let containingTextblock: number | null = null; let nextTextblock: number | null = null; let previousTextblock: number | null = null; state.doc.nodesBetween(0, state.doc.content.size, (node, pos) => { if (!node.isTextblock) return; const start = pos + 1; const end = pos + node.nodeSize - 1; if (anchor >= start && anchor <= end) { containingTextblock ??= anchor; return; } if (start > anchor) { if (nextTextblock === null || start < nextTextblock) { nextTextblock = start; } return; } if (previousTextblock === null || start > previousTextblock) { previousTextblock = Math.min(anchor, end); } }); const target = containingTextblock ?? nextTextblock ?? previousTextblock; if (target === null) { editor.commands.focus(); return; } const transaction = state.tr.setSelection( TextSelection.create( state.doc, Math.max(1, Math.min(target, state.doc.content.size)), ), ); editor.view.dispatch(transaction.scrollIntoView()); editor.view.focus(); } export interface SlashCommandFeatureFlags { tables?: boolean; tasks?: boolean; codeBlock?: boolean; } export function filterSlashCommandItems( items: readonly SlashCommandItem[], features?: SlashCommandFeatureFlags, ): SlashCommandItem[] { return items.filter( (item) => !item.requires || features?.[item.requires] !== false, ); } /** * The default block commands — Plan's current set. Apps pass their own `items` * (typically `[...DEFAULT_SLASH_COMMANDS, ...extra]`) to extend it. */ export const DEFAULT_SLASH_COMMANDS: SlashCommandItem[] = [ { title: "Text", description: "Plain text block", icon: "T", action: (editor) => editor.chain().focus().setParagraph().run(), }, { title: "Heading 1", description: "Large heading", icon: "H1", action: (editor) => editor.chain().focus().toggleHeading({ level: 1 }).run(), }, { title: "Heading 2", description: "Section heading", icon: "H2", action: (editor) => editor.chain().focus().toggleHeading({ level: 2 }).run(), }, { title: "Heading 3", description: "Subheading", icon: "H3", action: (editor) => editor.chain().focus().toggleHeading({ level: 3 }).run(), }, { title: "Bulleted list", description: "Unordered list", icon: "-", action: (editor) => editor.chain().focus().toggleBulletList().run(), }, { title: "Numbered list", description: "Ordered list", icon: "1.", action: (editor) => editor.chain().focus().toggleOrderedList().run(), }, { title: "To-do list", description: "Checklist items", icon: "[]", requires: "tasks", action: (editor) => editor.chain().focus().toggleTaskList().run(), }, { title: "Quote", description: "Block quote", icon: '"', action: (editor) => editor.chain().focus().toggleBlockquote().run(), }, { title: "Code block", description: "Code snippet", icon: "<>", requires: "codeBlock", action: (editor) => editor.chain().focus().toggleCodeBlock().run(), }, { title: "Divider", description: "Horizontal rule", icon: "-", action: (editor) => editor.chain().focus().setHorizontalRule().run(), }, { title: "Table", description: "Three by three table", icon: "tbl", requires: "tables", action: (editor) => editor .chain() .focus() .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) .run(), }, ]; /** * Build the `/image` slash command for the shared image block. Requires the * editor to mount the shared image extension (`features.image`) and an * {@link ImageUploadFn}; the command opens a native file picker and uploads + * inserts the chosen image(s). Add it to the list an app passes to * {@link SlashCommandMenu} (e.g. `[...DEFAULT_SLASH_COMMANDS, createImageSlashCommand(upload)]`). */ export function createImageSlashCommand( upload: ImageUploadFn, ): SlashCommandItem { return { title: "Image", description: "Upload an image", icon: "img", action: (editor) => pickAndInsertImage(editor.view, upload), }; } export interface SlashCommandMenuProps { editor: Editor; /** Block command list. Defaults to {@link DEFAULT_SLASH_COMMANDS}. */ items?: SlashCommandItem[]; } /** * The shared "/" block-insert menu. Detects a `/query` at the caret, filters the * provided command list, and renders a fixed-position picker with keyboard * navigation. Extracted from the inline plan menu so apps share one * implementation and only swap the command list. */ export function SlashCommandMenu({ editor, items = DEFAULT_SLASH_COMMANDS, }: SlashCommandMenuProps) { const [isOpen, setIsOpen] = useState(false); const [query, setQuery] = useState(""); const [selectedIndex, setSelectedIndex] = useState(0); const [position, setPosition] = useState<{ top: number; left: number; flipUp: boolean; } | null>(null); const menuRef = useRef(null); const selectedItemRef = useRef(null); const slashPosRef = useRef(null); const filteredCommands = useMemo(() => { const normalizedQuery = query.toLowerCase(); return items.filter( (cmd) => cmd.title.toLowerCase().includes(normalizedQuery) || cmd.description.toLowerCase().includes(normalizedQuery) || cmd.searchText?.toLowerCase().includes(normalizedQuery), ); }, [items, query]); const close = useCallback(() => { setIsOpen(false); setQuery(""); slashPosRef.current = null; }, []); const executeCommand = useCallback( (command: SlashCommandItem) => { if (slashPosRef.current !== null) { const { from } = editor.state.selection; const anchorPosition = slashPosRef.current; editor .chain() .focus() .deleteRange({ from: anchorPosition, to: from }) .run(); command.action(editor); focusEditorInInsertedBlock(editor, anchorPosition); } else { command.action(editor); focusEditorInInsertedBlock(editor); } close(); }, [close, editor], ); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (!isOpen) return; if (event.key === "ArrowDown") { event.preventDefault(); if (filteredCommands.length === 0) return; setSelectedIndex((index) => (index + 1) % filteredCommands.length); } else if (event.key === "ArrowUp") { event.preventDefault(); if (filteredCommands.length === 0) return; setSelectedIndex( (index) => (index - 1 + filteredCommands.length) % filteredCommands.length, ); } else if (event.key === "Enter") { event.preventDefault(); const command = filteredCommands[selectedIndex]; if (command) executeCommand(command); } else if (event.key === "Escape") { close(); } }; document.addEventListener("keydown", handleKeyDown, true); return () => document.removeEventListener("keydown", handleKeyDown, true); }, [close, executeCommand, filteredCommands, isOpen, selectedIndex]); useEffect(() => { if (!isOpen) return; const menu = menuRef.current; const item = selectedItemRef.current; if (!menu || !item) return; const itemTop = item.offsetTop; const itemBottom = itemTop + item.offsetHeight; const visibleTop = menu.scrollTop; const visibleBottom = visibleTop + menu.clientHeight; if (itemTop < visibleTop) { menu.scrollTop = itemTop; } else if (itemBottom > visibleBottom) { menu.scrollTop = itemBottom - menu.clientHeight; } }, [filteredCommands.length, isOpen, selectedIndex]); useEffect(() => { const handleTransaction = () => { const { state } = editor; const { from } = state.selection; const textBefore = state.doc.textBetween( Math.max(0, from - 32), from, "\n", ); const slashMatch = textBefore.match(/\/([a-zA-Z0-9 ]*)$/); if (!slashMatch) { if (isOpen) close(); return; } const slashStart = from - slashMatch[0].length; slashPosRef.current = slashStart; setQuery(slashMatch[1]); setSelectedIndex(0); const coords = editor.view.coordsAtPos(from); const menuHeight = 320; const spaceBelow = window.innerHeight - coords.bottom; const flipUp = spaceBelow < menuHeight && coords.top > menuHeight; setPosition({ top: flipUp ? coords.top : coords.bottom + 4, left: Math.min(coords.left, window.innerWidth - 250), flipUp, }); setIsOpen(true); }; editor.on("transaction", handleTransaction); return () => { editor.off("transaction", handleTransaction); }; }, [close, editor, isOpen]); if (!isOpen || !position || filteredCommands.length === 0) return null; return (
Blocks
{filteredCommands.map((command, index) => ( ))}
); }