Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 338 lines 8.2 kB view raw
1/** 2 * Slash Command Menu — Notion-style command palette 3 * 4 * Pure logic module: command definitions, filtering, and menu state. 5 * No DOM dependencies — rendering is handled in main.js and the TipTap extension. 6 */ 7import type { SlashCommandItem, SlashCommandCategory, SlashCommandGroup } from './types.js'; 8 9// ============================================================ 10// Placeholder strings (used by Placeholder extension config) 11// ============================================================ 12 13/** Placeholder shown when the editor is completely empty */ 14export const PLACEHOLDER_EMPTY = "Type '/' for commands, or just start typing..."; 15 16/** Placeholder shown on empty focused blocks */ 17export const PLACEHOLDER_BLOCK = "Type '/' for commands"; 18 19// ============================================================ 20// Categories 21// ============================================================ 22 23export const SLASH_COMMAND_CATEGORIES: SlashCommandCategory[] = [ 24 { id: 'text', label: 'Text' }, 25 { id: 'lists', label: 'Lists' }, 26 { id: 'media', label: 'Media' }, 27 { id: 'code', label: 'Code' }, 28 { id: 'quote', label: 'Quote' }, 29 { id: 'advanced', label: 'Advanced' }, 30]; 31 32// ============================================================ 33// Command Items 34// ============================================================ 35 36export const SLASH_COMMAND_ITEMS: SlashCommandItem[] = [ 37 // --- Text --- 38 { 39 id: 'paragraph', 40 name: 'Paragraph', 41 description: 'Plain text block', 42 category: 'text', 43 icon: '\u00B6', 44 shortcut: null, 45 }, 46 { 47 id: 'heading1', 48 name: 'Heading 1', 49 description: 'Large section heading', 50 category: 'text', 51 icon: 'H1', 52 shortcut: 'Mod+Alt+1', 53 }, 54 { 55 id: 'heading2', 56 name: 'Heading 2', 57 description: 'Medium section heading', 58 category: 'text', 59 icon: 'H2', 60 shortcut: 'Mod+Alt+2', 61 }, 62 { 63 id: 'heading3', 64 name: 'Heading 3', 65 description: 'Small section heading', 66 category: 'text', 67 icon: 'H3', 68 shortcut: 'Mod+Alt+3', 69 }, 70 71 // --- Lists --- 72 { 73 id: 'bulletList', 74 name: 'Bullet List', 75 description: 'Unordered list with bullets', 76 category: 'lists', 77 icon: '\u2022', 78 shortcut: null, 79 }, 80 { 81 id: 'numberedList', 82 name: 'Numbered List', 83 description: 'Ordered list with numbers', 84 category: 'lists', 85 icon: '1.', 86 shortcut: null, 87 }, 88 { 89 id: 'taskList', 90 name: 'Task List', 91 description: 'Checklist with checkboxes', 92 category: 'lists', 93 icon: '\u2611', 94 shortcut: null, 95 }, 96 97 // --- Media --- 98 { 99 id: 'image', 100 name: 'Image', 101 description: 'Embed an image', 102 category: 'media', 103 icon: '\uD83D\uDDBC', 104 shortcut: null, 105 }, 106 { 107 id: 'table', 108 name: 'Table', 109 description: 'Insert a table', 110 category: 'media', 111 icon: '\u2637', 112 shortcut: null, 113 }, 114 { 115 id: 'horizontalRule', 116 name: 'Horizontal Rule', 117 description: 'Visual divider line', 118 category: 'media', 119 icon: '\u2500', 120 shortcut: null, 121 }, 122 123 // --- Code --- 124 { 125 id: 'mermaid', 126 name: 'Diagram', 127 description: 'Mermaid flowchart, sequence, ER, or Gantt diagram', 128 category: 'code', 129 icon: '\u25C7', 130 shortcut: null, 131 }, 132 { 133 id: 'mathBlock', 134 name: 'Math Equation', 135 description: 'LaTeX math equation (KaTeX)', 136 category: 'code', 137 icon: '\u03A3', 138 shortcut: null, 139 }, 140 { 141 id: 'codeBlock', 142 name: 'Code Block', 143 description: 'Fenced code block with syntax highlighting', 144 category: 'code', 145 icon: '</>', 146 shortcut: null, 147 }, 148 { 149 id: 'inlineCode', 150 name: 'Inline Code', 151 description: 'Inline code span', 152 category: 'code', 153 icon: '`c`', 154 shortcut: 'Mod+E', 155 }, 156 157 // --- Quote --- 158 { 159 id: 'blockquote', 160 name: 'Blockquote', 161 description: 'Quoted text block', 162 category: 'quote', 163 icon: '\u201C', 164 shortcut: null, 165 }, 166 { 167 id: 'callout', 168 name: 'Callout', 169 description: 'Highlighted callout box', 170 category: 'quote', 171 icon: '\uD83D\uDCA1', 172 shortcut: null, 173 }, 174 175 // --- Advanced --- 176 { 177 id: 'toggle', 178 name: 'Toggle', 179 description: 'Collapsible section', 180 category: 'advanced', 181 icon: '\u25B6', 182 shortcut: null, 183 }, 184 { 185 id: 'footnote', 186 name: 'Footnote', 187 description: 'Insert a footnote reference', 188 category: 'advanced', 189 icon: '\u2020', 190 shortcut: null, 191 }, 192 { 193 id: 'tableOfContents', 194 name: 'Table of Contents', 195 description: 'Auto-generated heading outline', 196 category: 'advanced', 197 icon: '\uD83D\uDCCB', 198 shortcut: null, 199 }, 200 { 201 id: 'pageBreak', 202 name: 'Page Break', 203 description: 'Insert a page break', 204 category: 'advanced', 205 icon: '\u23CE', 206 shortcut: 'Mod+Enter', 207 }, 208 { 209 id: 'link', 210 name: 'Link', 211 description: 'Insert a hyperlink', 212 category: 'advanced', 213 icon: '\uD83D\uDD17', 214 shortcut: 'Mod+K', 215 }, 216]; 217 218// ============================================================ 219// Filtering 220// ============================================================ 221 222/** 223 * Filter slash command items by a search query. 224 * Matches against name, description, and category label. 225 */ 226export function filterCommands(query: string): SlashCommandItem[] { 227 const q = (query || '').trim().toLowerCase(); 228 if (!q) return [...SLASH_COMMAND_ITEMS]; 229 230 const catLabelMap: Record<string, string> = {}; 231 for (const cat of SLASH_COMMAND_CATEGORIES) { 232 catLabelMap[cat.id] = cat.label.toLowerCase(); 233 } 234 235 return SLASH_COMMAND_ITEMS.filter(item => { 236 const name = item.name.toLowerCase(); 237 const desc = item.description.toLowerCase(); 238 const catLabel = catLabelMap[item.category] || ''; 239 return name.includes(q) || desc.includes(q) || catLabel.includes(q); 240 }); 241} 242 243/** 244 * Look up a command by its id. 245 */ 246export function findCommandById(id: string): SlashCommandItem | null { 247 if (!id) return null; 248 return SLASH_COMMAND_ITEMS.find(item => item.id === id) || null; 249} 250 251/** 252 * Get all items in a given category. 253 */ 254export function getCategoryItems(categoryId: string): SlashCommandItem[] { 255 return SLASH_COMMAND_ITEMS.filter(item => item.category === categoryId); 256} 257 258// ============================================================ 259// Menu State 260// ============================================================ 261 262/** 263 * Manages the state of the slash command menu (open/closed, query, selection). 264 * Pure state object — no DOM coupling. 265 */ 266export class SlashMenuState { 267 isOpen: boolean; 268 query: string; 269 selectedIndex: number; 270 271 constructor() { 272 this.isOpen = false; 273 this.query = ''; 274 this.selectedIndex = 0; 275 } 276 277 open(): void { 278 this.isOpen = true; 279 this.query = ''; 280 this.selectedIndex = 0; 281 } 282 283 close(): void { 284 this.isOpen = false; 285 this.query = ''; 286 this.selectedIndex = 0; 287 } 288 289 setQuery(query: string): void { 290 this.query = query; 291 this.selectedIndex = 0; 292 } 293 294 getFilteredItems(): SlashCommandItem[] { 295 return filterCommands(this.query); 296 } 297 298 moveDown(): void { 299 const items = this.getFilteredItems(); 300 if (items.length === 0) return; 301 this.selectedIndex = (this.selectedIndex + 1) % items.length; 302 } 303 304 moveUp(): void { 305 const items = this.getFilteredItems(); 306 if (items.length === 0) return; 307 this.selectedIndex = (this.selectedIndex - 1 + items.length) % items.length; 308 } 309 310 getSelectedItem(): SlashCommandItem | null { 311 if (!this.isOpen) return null; 312 const items = this.getFilteredItems(); 313 if (items.length === 0) return null; 314 return items[this.selectedIndex] || null; 315 } 316 317 /** 318 * Return filtered items grouped by category, in category order. 319 * Only includes categories that have matching items. 320 */ 321 getGroupedItems(): SlashCommandGroup[] { 322 const filtered = this.getFilteredItems(); 323 const groups: SlashCommandGroup[] = []; 324 325 for (const cat of SLASH_COMMAND_CATEGORIES) { 326 const catItems = filtered.filter(item => item.category === cat.id); 327 if (catItems.length > 0) { 328 groups.push({ 329 id: cat.id, 330 label: cat.label, 331 items: catItems, 332 }); 333 } 334 } 335 336 return groups; 337 } 338}