Full document, spreadsheet, slideshow, and diagram tooling
1/**
2 * Command Palette (Cmd+K / Ctrl+K)
3 *
4 * A self-contained, fast command palette that works on all pages
5 * (landing, docs, sheets). Supports fuzzy search, keyboard navigation,
6 * categorized results, and light/dark themes.
7 */
8
9export interface PaletteAction {
10 id: string;
11 label: string;
12 category: 'document' | 'action';
13 icon?: string;
14 shortcut?: string;
15 action: () => void;
16}
17
18export interface PaletteConfig {
19 actions: PaletteAction[];
20 fetchDocuments?: () => Promise<PaletteAction[]>;
21}
22
23export interface CommandPaletteHandle {
24 open: () => void;
25 close: () => void;
26 destroy: () => void;
27}
28
29/**
30 * Fuzzy match: split query into words, each word must appear
31 * somewhere in the label (case insensitive). Order doesn't matter.
32 */
33export function fuzzyMatch(label: string, query: string): boolean {
34 if (!query || !query.trim()) return true;
35 const lower = label.toLowerCase();
36 const words = query.toLowerCase().trim().split(/\s+/);
37 return words.every(w => lower.includes(w));
38}
39
40/**
41 * Filter actions by query using fuzzy matching.
42 */
43export function filterActions(actions: PaletteAction[], query: string): PaletteAction[] {
44 if (!query || !query.trim()) return actions;
45 return actions.filter(a => fuzzyMatch(a.label, query));
46}
47
48/**
49 * Group actions by category, returning groups in order:
50 * 'action' first, then 'document'.
51 */
52export function groupByCategory(actions: PaletteAction[]): { category: string; items: PaletteAction[] }[] {
53 const actionItems = actions.filter(a => a.category === 'action');
54 const docItems = actions.filter(a => a.category === 'document');
55 const groups: { category: string; items: PaletteAction[] }[] = [];
56 if (actionItems.length > 0) groups.push({ category: 'Actions', items: actionItems });
57 if (docItems.length > 0) groups.push({ category: 'Documents', items: docItems });
58 return groups;
59}
60
61/**
62 * Compute the next selected index when navigating with arrow keys.
63 * Wraps around.
64 */
65export function navigateIndex(current: number, total: number, direction: 'up' | 'down'): number {
66 if (total === 0) return -1;
67 if (direction === 'down') {
68 return (current + 1) % total;
69 }
70 return (current - 1 + total) % total;
71}
72
73/**
74 * Create a command palette instance. Injects DOM, handles keyboard,
75 * renders results. Call `.destroy()` to clean up.
76 */
77export function createCommandPalette(config: PaletteConfig): CommandPaletteHandle {
78 // --- Build DOM ---
79 const backdrop = document.createElement('div');
80 backdrop.className = 'cmd-palette-backdrop';
81 backdrop.setAttribute('role', 'dialog');
82 backdrop.setAttribute('aria-modal', 'true');
83 backdrop.setAttribute('aria-label', 'Command palette');
84
85 const container = document.createElement('div');
86 container.className = 'cmd-palette';
87
88 const input = document.createElement('input');
89 input.className = 'cmd-palette-input';
90 input.type = 'text';
91 input.placeholder = 'Type a command or search...';
92 input.setAttribute('aria-label', 'Command palette search');
93 input.spellcheck = false;
94 input.autocomplete = 'off';
95
96 const resultsList = document.createElement('div');
97 resultsList.className = 'cmd-palette-results';
98 resultsList.setAttribute('role', 'listbox');
99
100 container.appendChild(input);
101 container.appendChild(resultsList);
102 backdrop.appendChild(container);
103
104 // --- State ---
105 let isOpen = false;
106 let allActions: PaletteAction[] = [...config.actions];
107 let filteredActions: PaletteAction[] = [];
108 let selectedIndex = 0;
109 let documentsFetched = false;
110
111 function render() {
112 const query = input.value;
113 filteredActions = filterActions(allActions, query);
114 const groups = groupByCategory(filteredActions);
115
116 if (filteredActions.length === 0) {
117 resultsList.innerHTML = '<div class="cmd-palette-empty">No results found</div>';
118 selectedIndex = -1;
119 return;
120 }
121
122 // Clamp selected index
123 if (selectedIndex >= filteredActions.length) selectedIndex = 0;
124 if (selectedIndex < 0) selectedIndex = 0;
125
126 let html = '';
127 let flatIndex = 0;
128 for (const group of groups) {
129 html += `<div class="cmd-palette-category">${escapeHtml(group.category)}</div>`;
130 for (const item of group.items) {
131 const isSelected = flatIndex === selectedIndex;
132 html += `<div class="cmd-palette-item${isSelected ? ' cmd-palette-item-selected' : ''}" data-index="${flatIndex}" role="option" aria-selected="${isSelected}">`;
133 if (item.icon) {
134 html += `<span class="cmd-palette-item-icon">${escapeHtml(item.icon)}</span>`;
135 }
136 html += `<span class="cmd-palette-item-label">${escapeHtml(item.label)}</span>`;
137 if (item.shortcut) {
138 html += `<span class="cmd-palette-item-shortcut">${escapeHtml(item.shortcut)}</span>`;
139 }
140 html += '</div>';
141 flatIndex++;
142 }
143 }
144 resultsList.innerHTML = html;
145
146 // Scroll selected into view
147 const selectedEl = resultsList.querySelector('.cmd-palette-item-selected');
148 if (selectedEl) {
149 selectedEl.scrollIntoView({ block: 'nearest' });
150 }
151 }
152
153 function executeSelected() {
154 if (selectedIndex >= 0 && selectedIndex < filteredActions.length) {
155 const action = filteredActions[selectedIndex];
156 close();
157 action.action();
158 }
159 }
160
161 function close() {
162 if (!isOpen) return;
163 isOpen = false;
164 backdrop.classList.remove('cmd-palette-open');
165 // Remove after animation
166 setTimeout(() => {
167 if (!isOpen) backdrop.remove();
168 }, 150);
169 }
170
171 function open() {
172 if (isOpen) return;
173 isOpen = true;
174 input.value = '';
175 selectedIndex = 0;
176 document.body.appendChild(backdrop);
177 // Force reflow for animation
178 backdrop.offsetHeight;
179 backdrop.classList.add('cmd-palette-open');
180 input.focus();
181
182 // Fetch documents on first open (or always if provided)
183 if (config.fetchDocuments && !documentsFetched) {
184 documentsFetched = true;
185 config.fetchDocuments().then(docs => {
186 // Merge, avoiding duplicate IDs
187 const existingIds = new Set(allActions.map(a => a.id));
188 for (const doc of docs) {
189 if (!existingIds.has(doc.id)) {
190 allActions.push(doc);
191 }
192 }
193 if (isOpen) render();
194 }).catch(() => {
195 // silently fail — actions still work
196 });
197 }
198
199 render();
200 }
201
202 // --- Event handlers ---
203 function onInput() {
204 selectedIndex = 0;
205 render();
206 }
207
208 function onKeydown(e: KeyboardEvent) {
209 if (e.key === 'Escape') {
210 e.preventDefault();
211 e.stopPropagation();
212 close();
213 } else if (e.key === 'ArrowDown') {
214 e.preventDefault();
215 selectedIndex = navigateIndex(selectedIndex, filteredActions.length, 'down');
216 render();
217 } else if (e.key === 'ArrowUp') {
218 e.preventDefault();
219 selectedIndex = navigateIndex(selectedIndex, filteredActions.length, 'up');
220 render();
221 } else if (e.key === 'Enter') {
222 e.preventDefault();
223 executeSelected();
224 }
225 }
226
227 function onBackdropClick(e: MouseEvent) {
228 if (e.target === backdrop) {
229 close();
230 }
231 }
232
233 function onResultClick(e: MouseEvent) {
234 const item = (e.target as HTMLElement).closest('.cmd-palette-item') as HTMLElement | null;
235 if (item) {
236 const index = parseInt(item.dataset.index || '0', 10);
237 selectedIndex = index;
238 executeSelected();
239 }
240 }
241
242 function onGlobalKeydown(e: KeyboardEvent) {
243 const mod = e.metaKey || e.ctrlKey;
244 if (mod && e.key.toLowerCase() === 'k') {
245 // Don't hijack Cmd+K when user is in a contenteditable or specific input
246 // that uses Cmd+K for link insertion. Only intercept if not already open.
247 e.preventDefault();
248 if (isOpen) {
249 close();
250 } else {
251 open();
252 }
253 }
254 }
255
256 // Attach listeners
257 input.addEventListener('input', onInput);
258 input.addEventListener('keydown', onKeydown);
259 backdrop.addEventListener('click', onBackdropClick);
260 resultsList.addEventListener('click', onResultClick);
261 document.addEventListener('keydown', onGlobalKeydown);
262
263 return {
264 open,
265 close,
266 destroy() {
267 close();
268 document.removeEventListener('keydown', onGlobalKeydown);
269 input.removeEventListener('input', onInput);
270 input.removeEventListener('keydown', onKeydown);
271 backdrop.removeEventListener('click', onBackdropClick);
272 resultsList.removeEventListener('click', onResultClick);
273 },
274 };
275}
276
277function escapeHtml(text: string): string {
278 const div = document.createElement('div');
279 div.textContent = text;
280 return div.innerHTML;
281}