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 281 lines 8.6 kB view raw
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}