/** * Slash command ProseMirror plugin for Tiptap. * * Detects '/' typed at the start of an empty block and shows a floating menu * with block type options. Keyboard navigation: arrow keys + Enter + Escape. */ import { Plugin, PluginKey } from '@tiptap/pm/state'; import type { EditorView } from '@tiptap/pm/view'; import type { Editor } from '@tiptap/core'; /** Inline SVG icons for slash menu items (16×16, stroke-based, currentColor) */ const SLASH_ICONS: Record = { text: '', heading1: '1', heading2: '2', heading3: '3', bulletList: '', orderedList: '123', taskList: '', codeBlock: '', blockquote: '', horizontalRule: '', image: '', }; export interface SlashMenuItem { title: string; icon: string; description: string; command: (editor: Editor) => void; } export const SLASH_ITEMS: SlashMenuItem[] = [ { title: 'Text', icon: 'text', description: 'Plain paragraph text', command: (e) => e.chain().focus().setParagraph().run(), }, { title: 'Heading 1', icon: 'heading1', description: 'Large section heading', command: (e) => e.chain().focus().setHeading({ level: 1 }).run(), }, { title: 'Heading 2', icon: 'heading2', description: 'Medium section heading', command: (e) => e.chain().focus().setHeading({ level: 2 }).run(), }, { title: 'Heading 3', icon: 'heading3', description: 'Small section heading', command: (e) => e.chain().focus().setHeading({ level: 3 }).run(), }, { title: 'Bullet List', icon: 'bulletList', description: 'Unordered bullet list', command: (e) => e.chain().focus().toggleBulletList().run(), }, { title: 'Numbered List', icon: 'orderedList', description: 'Ordered numbered list', command: (e) => e.chain().focus().toggleOrderedList().run(), }, { title: 'Task List', icon: 'taskList', description: 'Checklist with checkboxes', command: (e) => e.chain().focus().toggleTaskList().run(), }, { title: 'Code Block', icon: 'codeBlock', description: 'Syntax-highlighted code block', command: (e) => e.chain().focus().toggleCodeBlock().run(), }, { title: 'Blockquote', icon: 'blockquote', description: 'Indented quote block', command: (e) => e.chain().focus().toggleBlockquote().run(), }, { title: 'Horizontal Rule', icon: 'horizontalRule', description: 'Visual divider line', command: (e) => e.chain().focus().setHorizontalRule().run(), }, { title: 'Image', icon: 'image', description: 'Insert an image from URL', command: (e) => { // Dispatch custom event for parent to show URL popover const event = new CustomEvent('slash-insert-image', { bubbles: true, composed: true }); (e.view.dom as HTMLElement).dispatchEvent(event); }, }, ]; const pluginKey = new PluginKey('slashCommand'); export function createSlashCommandPlugin(editor: Editor, shadowRoot: ShadowRoot): Plugin { let menuEl: HTMLDivElement | null = null; let selectedIndex = 0; let filteredItems: SlashMenuItem[] = []; let query = ''; let active = false; let triggerPos = -1; function show(view: EditorView) { if (!menuEl) { menuEl = document.createElement('div'); menuEl.className = 'slash-menu'; shadowRoot.appendChild(menuEl); } active = true; selectedIndex = 0; query = ''; filteredItems = SLASH_ITEMS; updateMenuContent(); positionMenu(view); menuEl.style.display = 'block'; } function hide() { active = false; query = ''; triggerPos = -1; if (menuEl) menuEl.style.display = 'none'; } function updateMenuContent() { if (!menuEl) return; menuEl.innerHTML = `
Insert block
` + filteredItems .map( (item, i) => `
${SLASH_ICONS[item.icon] || item.icon}
${item.title}
${item.description}
${i === selectedIndex ? 'Enter' : ''}
`, ) .join(''); // Click handlers menuEl.querySelectorAll('.slash-menu-item').forEach((el) => { el.addEventListener('mousedown', (e) => { e.preventDefault(); const idx = parseInt((el as HTMLElement).dataset.index || '0'); executeItem(idx); }); el.addEventListener('mouseenter', () => { selectedIndex = parseInt((el as HTMLElement).dataset.index || '0'); updateMenuContent(); }); }); } function positionMenu(view: EditorView) { if (!menuEl) return; const { from } = view.state.selection; const coords = view.coordsAtPos(from); const shadowHost = shadowRoot.host as HTMLElement; const hostRect = shadowHost.getBoundingClientRect(); menuEl.style.left = `${coords.left - hostRect.left}px`; menuEl.style.top = `${coords.bottom - hostRect.top + 4}px`; } function filterItems() { const q = query.toLowerCase(); filteredItems = q ? SLASH_ITEMS.filter( (item) => item.title.toLowerCase().includes(q) || item.description.toLowerCase().includes(q), ) : SLASH_ITEMS; selectedIndex = Math.min(selectedIndex, Math.max(0, filteredItems.length - 1)); updateMenuContent(); } function executeItem(index: number) { const item = filteredItems[index]; if (!item) return; // Delete the slash + query text const { state } = editor.view; const tr = state.tr.delete(triggerPos, state.selection.from); editor.view.dispatch(tr); item.command(editor); hide(); } return new Plugin({ key: pluginKey, props: { handleKeyDown(view, event) { if (active) { if (event.key === 'ArrowDown') { event.preventDefault(); selectedIndex = (selectedIndex + 1) % filteredItems.length; updateMenuContent(); return true; } if (event.key === 'ArrowUp') { event.preventDefault(); selectedIndex = (selectedIndex - 1 + filteredItems.length) % filteredItems.length; updateMenuContent(); return true; } if (event.key === 'Enter') { event.preventDefault(); executeItem(selectedIndex); return true; } if (event.key === 'Escape') { event.preventDefault(); hide(); return true; } if (event.key === 'Backspace') { if (query.length === 0) { // Backspace deletes the '/', close menu hide(); return false; // let ProseMirror handle the deletion } query = query.slice(0, -1); filterItems(); return false; // let ProseMirror handle the deletion } if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) { query += event.key; filterItems(); if (filteredItems.length === 0) { hide(); } return false; // let ProseMirror insert the character } } return false; }, handleTextInput(view, from, to, text) { if (text === '/' && !active) { // Check if cursor is at start of an empty block const { $from } = view.state.selection; const isAtStart = $from.parentOffset === 0; const isEmpty = $from.parent.textContent === ''; if (isAtStart && isEmpty) { triggerPos = from; // Defer show to after the '/' is inserted setTimeout(() => show(view), 0); } } return false; }, }, view() { return { update(view) { if (active && menuEl) { positionMenu(view); } }, destroy() { if (menuEl) { menuEl.remove(); menuEl = null; } }, }; }, }); }