/** * Slash Command Menu — Notion-style command palette * * Pure logic module: command definitions, filtering, and menu state. * No DOM dependencies — rendering is handled in main.js and the TipTap extension. */ import type { SlashCommandItem, SlashCommandCategory, SlashCommandGroup } from './types.js'; // ============================================================ // Placeholder strings (used by Placeholder extension config) // ============================================================ /** Placeholder shown when the editor is completely empty */ export const PLACEHOLDER_EMPTY = "Type '/' for commands, or just start typing..."; /** Placeholder shown on empty focused blocks */ export const PLACEHOLDER_BLOCK = "Type '/' for commands"; // ============================================================ // Categories // ============================================================ export const SLASH_COMMAND_CATEGORIES: SlashCommandCategory[] = [ { id: 'text', label: 'Text' }, { id: 'lists', label: 'Lists' }, { id: 'media', label: 'Media' }, { id: 'code', label: 'Code' }, { id: 'quote', label: 'Quote' }, { id: 'advanced', label: 'Advanced' }, ]; // ============================================================ // Command Items // ============================================================ export const SLASH_COMMAND_ITEMS: SlashCommandItem[] = [ // --- Text --- { id: 'paragraph', name: 'Paragraph', description: 'Plain text block', category: 'text', icon: '\u00B6', shortcut: null, }, { id: 'heading1', name: 'Heading 1', description: 'Large section heading', category: 'text', icon: 'H1', shortcut: 'Mod+Alt+1', }, { id: 'heading2', name: 'Heading 2', description: 'Medium section heading', category: 'text', icon: 'H2', shortcut: 'Mod+Alt+2', }, { id: 'heading3', name: 'Heading 3', description: 'Small section heading', category: 'text', icon: 'H3', shortcut: 'Mod+Alt+3', }, // --- Lists --- { id: 'bulletList', name: 'Bullet List', description: 'Unordered list with bullets', category: 'lists', icon: '\u2022', shortcut: null, }, { id: 'numberedList', name: 'Numbered List', description: 'Ordered list with numbers', category: 'lists', icon: '1.', shortcut: null, }, { id: 'taskList', name: 'Task List', description: 'Checklist with checkboxes', category: 'lists', icon: '\u2611', shortcut: null, }, // --- Media --- { id: 'image', name: 'Image', description: 'Embed an image', category: 'media', icon: '\uD83D\uDDBC', shortcut: null, }, { id: 'table', name: 'Table', description: 'Insert a table', category: 'media', icon: '\u2637', shortcut: null, }, { id: 'horizontalRule', name: 'Horizontal Rule', description: 'Visual divider line', category: 'media', icon: '\u2500', shortcut: null, }, // --- Code --- { id: 'mermaid', name: 'Diagram', description: 'Mermaid flowchart, sequence, ER, or Gantt diagram', category: 'code', icon: '\u25C7', shortcut: null, }, { id: 'mathBlock', name: 'Math Equation', description: 'LaTeX math equation (KaTeX)', category: 'code', icon: '\u03A3', shortcut: null, }, { id: 'codeBlock', name: 'Code Block', description: 'Fenced code block with syntax highlighting', category: 'code', icon: '', shortcut: null, }, { id: 'inlineCode', name: 'Inline Code', description: 'Inline code span', category: 'code', icon: '`c`', shortcut: 'Mod+E', }, // --- Quote --- { id: 'blockquote', name: 'Blockquote', description: 'Quoted text block', category: 'quote', icon: '\u201C', shortcut: null, }, { id: 'callout', name: 'Callout', description: 'Highlighted callout box', category: 'quote', icon: '\uD83D\uDCA1', shortcut: null, }, // --- Advanced --- { id: 'toggle', name: 'Toggle', description: 'Collapsible section', category: 'advanced', icon: '\u25B6', shortcut: null, }, { id: 'footnote', name: 'Footnote', description: 'Insert a footnote reference', category: 'advanced', icon: '\u2020', shortcut: null, }, { id: 'tableOfContents', name: 'Table of Contents', description: 'Auto-generated heading outline', category: 'advanced', icon: '\uD83D\uDCCB', shortcut: null, }, { id: 'pageBreak', name: 'Page Break', description: 'Insert a page break', category: 'advanced', icon: '\u23CE', shortcut: 'Mod+Enter', }, { id: 'link', name: 'Link', description: 'Insert a hyperlink', category: 'advanced', icon: '\uD83D\uDD17', shortcut: 'Mod+K', }, ]; // ============================================================ // Filtering // ============================================================ /** * Filter slash command items by a search query. * Matches against name, description, and category label. */ export function filterCommands(query: string): SlashCommandItem[] { const q = (query || '').trim().toLowerCase(); if (!q) return [...SLASH_COMMAND_ITEMS]; const catLabelMap: Record = {}; for (const cat of SLASH_COMMAND_CATEGORIES) { catLabelMap[cat.id] = cat.label.toLowerCase(); } return SLASH_COMMAND_ITEMS.filter(item => { const name = item.name.toLowerCase(); const desc = item.description.toLowerCase(); const catLabel = catLabelMap[item.category] || ''; return name.includes(q) || desc.includes(q) || catLabel.includes(q); }); } /** * Look up a command by its id. */ export function findCommandById(id: string): SlashCommandItem | null { if (!id) return null; return SLASH_COMMAND_ITEMS.find(item => item.id === id) || null; } /** * Get all items in a given category. */ export function getCategoryItems(categoryId: string): SlashCommandItem[] { return SLASH_COMMAND_ITEMS.filter(item => item.category === categoryId); } // ============================================================ // Menu State // ============================================================ /** * Manages the state of the slash command menu (open/closed, query, selection). * Pure state object — no DOM coupling. */ export class SlashMenuState { isOpen: boolean; query: string; selectedIndex: number; constructor() { this.isOpen = false; this.query = ''; this.selectedIndex = 0; } open(): void { this.isOpen = true; this.query = ''; this.selectedIndex = 0; } close(): void { this.isOpen = false; this.query = ''; this.selectedIndex = 0; } setQuery(query: string): void { this.query = query; this.selectedIndex = 0; } getFilteredItems(): SlashCommandItem[] { return filterCommands(this.query); } moveDown(): void { const items = this.getFilteredItems(); if (items.length === 0) return; this.selectedIndex = (this.selectedIndex + 1) % items.length; } moveUp(): void { const items = this.getFilteredItems(); if (items.length === 0) return; this.selectedIndex = (this.selectedIndex - 1 + items.length) % items.length; } getSelectedItem(): SlashCommandItem | null { if (!this.isOpen) return null; const items = this.getFilteredItems(); if (items.length === 0) return null; return items[this.selectedIndex] || null; } /** * Return filtered items grouped by category, in category order. * Only includes categories that have matching items. */ getGroupedItems(): SlashCommandGroup[] { const filtered = this.getFilteredItems(); const groups: SlashCommandGroup[] = []; for (const cat of SLASH_COMMAND_CATEGORIES) { const catItems = filtered.filter(item => item.category === cat.id); if (catItems.length > 0) { groups.push({ id: cat.id, label: cat.label, items: catItems, }); } } return groups; } }