import { Extension } from '@tiptap/core'; import type { Editor, Range } from '@tiptap/core'; import Suggestion, { type SuggestionProps, type SuggestionKeyDownProps } from '@tiptap/suggestion'; import { ReactRenderer } from '@tiptap/react'; import { SlashMenuList } from './SlashMenuList.js'; export interface SlashItem { title: string; description: string; aliases?: string[]; command: (props: { editor: Editor; range: Range }) => void; } /** The built-in block actions offered by the slash menu. */ export const defaultSlashItems: SlashItem[] = [ { title: 'Heading 1', description: 'Big section heading', aliases: ['h1', 'title'], command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setNode('heading', { level: 1 }).run(), }, { title: 'Heading 2', description: 'Medium section heading', aliases: ['h2', 'subtitle'], command: ({ editor, range }) => editor.chain().focus().deleteRange(range).setNode('heading', { level: 2 }).run(), }, { title: 'Bullet list', description: 'Unordered list', aliases: ['ul', 'unordered'], command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleBulletList().run(), }, { title: 'Numbered list', description: 'Ordered list', aliases: ['ol', 'ordered'], command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleOrderedList().run(), }, { title: 'Quote', description: 'Blockquote', aliases: ['blockquote'], command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleBlockquote().run(), }, { title: 'Code block', description: 'Monospaced code', aliases: ['code', 'pre'], command: ({ editor, range }) => editor.chain().focus().deleteRange(range).toggleCodeBlock().run(), }, ]; /** Pure, case-insensitive filter over title + aliases — unit-testable. */ export function filterSlashItems( query: string, items: SlashItem[] = defaultSlashItems ): SlashItem[] { const q = query.trim().toLowerCase(); if (!q) return items; return items.filter( item => item.title.toLowerCase().includes(q) || (item.aliases ?? []).some(a => a.toLowerCase().includes(q)) ); } /** * `/`-triggered command menu. Built on `@tiptap/suggestion`; the popup is a * React component positioned at the caret. Only mounts while the user is * typing a slash command, so it never appears on first render. */ export const SlashCommands = Extension.create({ name: 'slashCommands', addProseMirrorPlugins() { return [ Suggestion({ editor: this.editor, char: '/', startOfLine: false, items: ({ query }) => filterSlashItems(query), command: ({ editor, range, props }) => props.command({ editor, range }), render: () => { let component: ReactRenderer<{ onKeyDown: (p: SuggestionKeyDownProps) => boolean; }> | null = null; let popup: HTMLDivElement | null = null; const position = (props: SuggestionProps) => { if (!popup) return; const rect = props.clientRect?.(); if (!rect) return; popup.style.left = `${rect.left}px`; popup.style.top = `${rect.bottom + 4}px`; }; return { onStart: (props: SuggestionProps) => { component = new ReactRenderer(SlashMenuList, { props, editor: props.editor }); popup = document.createElement('div'); popup.setAttribute('data-slash-menu', 'true'); popup.style.position = 'absolute'; popup.style.zIndex = '50'; popup.appendChild(component.element); document.body.appendChild(popup); position(props); }, onUpdate: (props: SuggestionProps) => { component?.updateProps(props); position(props); }, onKeyDown: (props: SuggestionKeyDownProps) => { if (props.event.key === 'Escape') { popup?.remove(); return true; } return component?.ref?.onKeyDown(props) ?? false; }, onExit: () => { popup?.remove(); popup = null; component?.destroy(); component = null; }, }; }, }), ]; }, });