"use client"; import { Extension, type Editor, type Range, ReactRenderer, } from "@tiptap/react"; import Suggestion, { type SuggestionKeyDownProps } from "@tiptap/suggestion"; import { PluginKey } from "@tiptap/pm/state"; import * as React from "react"; import { Heading1, Heading2, Heading3, List, ListOrdered, ListTodo, ImageIcon, Code2, Table2, Minus, MessageSquareQuote, ChevronRight, Sparkles, Type, } from "lucide-react"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; export const slashCommandPluginKey = new PluginKey("slashCommand"); export type SlashItem = { title: string; description: string; section: string; icon: React.ComponentType<{ className?: string }>; command: (opts: { editor: Editor; range: Range }) => void; }; /** TipTap chain typing does not merge all extension commands on the base Editor type. */ function afterSlashDelete(editor: Editor, range: Range) { // eslint-disable-next-line @typescript-eslint/no-explicit-any return editor.chain().focus().deleteRange(range) as any; } function getSlashItems(): SlashItem[] { return [ { title: "Paragraph", description: "Plain text block", section: "Text", icon: Type, command: ({ editor, range }) => { afterSlashDelete(editor, range).setParagraph().run(); }, }, { title: "Heading 1", description: "Large section title", section: "Text", icon: Heading1, command: ({ editor, range }) => { afterSlashDelete(editor, range).setHeading({ level: 1 }).run(); }, }, { title: "Heading 2", description: "Medium section title", section: "Text", icon: Heading2, command: ({ editor, range }) => { afterSlashDelete(editor, range).setHeading({ level: 2 }).run(); }, }, { title: "Heading 3", description: "Small section title", section: "Text", icon: Heading3, command: ({ editor, range }) => { afterSlashDelete(editor, range).setHeading({ level: 3 }).run(); }, }, { title: "Bullet List", description: "Unordered list", section: "Lists", icon: List, command: ({ editor, range }) => { afterSlashDelete(editor, range).toggleBulletList().run(); }, }, { title: "Numbered List", description: "Ordered list", section: "Lists", icon: ListOrdered, command: ({ editor, range }) => { afterSlashDelete(editor, range).toggleOrderedList().run(); }, }, { title: "Task List", description: "Checklist with tasks", section: "Lists", icon: ListTodo, command: ({ editor, range }) => { afterSlashDelete(editor, range).toggleTaskList().run(); }, }, { title: "Image", description: "Embed an image by URL", section: "Media", icon: ImageIcon, command: ({ editor, range }) => { const url = window.prompt("Image URL"); if (!url) return; afterSlashDelete(editor, range).setImage({ src: url }).run(); }, }, { title: "Code Block", description: "Syntax-highlighted code", section: "Media", icon: Code2, command: ({ editor, range }) => { afterSlashDelete(editor, range).toggleCodeBlock().run(); }, }, { title: "Table", description: "3×3 table with header", section: "Media", icon: Table2, command: ({ editor, range }) => { afterSlashDelete(editor, range) .insertTable({ rows: 3, cols: 3, withHeaderRow: true }) .run(); }, }, { title: "Horizontal Rule", description: "Divider line", section: "Media", icon: Minus, command: ({ editor, range }) => { afterSlashDelete(editor, range).setHorizontalRule().run(); }, }, { title: "Callout", description: "Highlighted callout block", section: "Advanced", icon: Sparkles, command: ({ editor, range }) => { afterSlashDelete(editor, range) .insertContent( '

Callout

', ) .run(); }, }, { title: "Toggle", description: "Collapsible section (placeholder)", section: "Advanced", icon: ChevronRight, command: ({ editor, range }) => { afterSlashDelete(editor, range) .insertContent({ type: "heading", attrs: { level: 3 }, content: [{ type: "text", text: "Toggle heading" }], }) .run(); }, }, { title: "Quote", description: "Blockquote citation", section: "Advanced", icon: MessageSquareQuote, command: ({ editor, range }) => { afterSlashDelete(editor, range).toggleBlockquote().run(); }, }, ]; } function filterItems(query: string): SlashItem[] { const q = query.trim().toLowerCase(); const all = getSlashItems(); if (!q) return all; return all.filter( (item) => item.title.toLowerCase().includes(q) || item.description.toLowerCase().includes(q) || item.section.toLowerCase().includes(q), ); } export type SlashMenuProps = { items: SlashItem[]; command: (item: SlashItem) => void; editor: Editor; }; export type SlashMenuHandle = { onKeyDown: (props: SuggestionKeyDownProps) => boolean; }; export const SlashMenu = React.forwardRef( function SlashMenu({ items, command }, ref) { const [selected, setSelected] = React.useState(0); React.useEffect(() => { setSelected(0); }, [items]); const grouped = React.useMemo(() => { const map = new Map(); for (const item of items) { const list = map.get(item.section) ?? []; list.push(item); map.set(item.section, list); } return map; }, [items]); React.useImperativeHandle(ref, () => ({ onKeyDown: ({ event }) => { if (items.length === 0) return false; if (event.key === "ArrowDown") { event.preventDefault(); setSelected((i) => (i + 1) % Math.max(items.length, 1)); return true; } if (event.key === "ArrowUp") { event.preventDefault(); setSelected((i) => i === 0 ? Math.max(items.length - 1, 0) : i - 1, ); return true; } if (event.key === "Enter") { event.preventDefault(); const item = items[selected]; if (item) command(item); return true; } return false; }, })); if (items.length === 0) { return (
No matching commands
); } let flatIndex = 0; return (
{Array.from(grouped.entries()).map( ([section, sectionItems], sectionIndex, sections) => (
{section}
{sectionItems.map((item) => { const idx = flatIndex++; const Icon = item.icon; const isActive = idx === selected; return ( ); })}
{sectionIndex < sections.length - 1 ? ( ) : null}
), )}
); }, ); SlashMenu.displayName = "SlashMenu"; export const SlashCommand = Extension.create({ name: "slashCommand", addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, pluginKey: slashCommandPluginKey, char: "/", allowSpaces: true, startOfLine: false, command: ({ editor: ed, range, props }) => { props.command({ editor: ed, range }); }, items: ({ query }) => filterItems(query), render: () => { let component: ReactRenderer | null = null; return { onStart: (props) => { component = new ReactRenderer(SlashMenu, { props: { ...props, command: (item: SlashItem) => { item.command({ editor: props.editor, range: props.range, }); }, }, editor: props.editor, }); component.element.style.position = "absolute"; component.element.style.zIndex = "100"; document.body.appendChild(component.element); updatePosition(props); }, onUpdate: (props) => { component?.updateProps({ ...props, command: (item: SlashItem) => { item.command({ editor: props.editor, range: props.range, }); }, }); updatePosition(props); }, onExit: () => { component?.destroy(); component = null; }, onKeyDown: (keyProps) => { const ref = component?.ref as SlashMenuHandle | null; if (ref?.onKeyDown) { return ref.onKeyDown(keyProps); } return false; }, }; function updatePosition(p: { clientRect?: (() => DOMRect | null) | null; }) { if (!component) return; const rect = p.clientRect?.(); if (!rect) return; const el = component.element; el.style.left = `${rect.left}px`; el.style.top = `${rect.bottom + 6}px`; } }, }), ]; }, });