"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( '
', ) .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.forwardRefCallout