Full document, spreadsheet, slideshow, and diagram tooling
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}