import {isMac, keySymbols} from '../../utils.ts'; import {trimTrailingWhitespaceFromView} from './utils.ts'; import type {EditorView} from '@codemirror/view'; import type {CodemirrorModules} from './main.ts'; export type PaletteCommand = { label: string; keys: string; run: (view: EditorView) => void; }; function formatKeys(keys: string): string[][] { return keys.split(' ').map((chord) => chord.split('+').map((k) => keySymbols[k] || k)); } export function commandPalette(cm: CodemirrorModules) { const commands: PaletteCommand[] = [ {label: 'Undo', keys: 'Mod+Z', run: cm.commands.undo}, {label: 'Redo', keys: 'Mod+Shift+Z', run: cm.commands.redo}, {label: 'Find', keys: 'Mod+F', run: cm.search.openSearchPanel}, {label: 'Go to line', keys: 'Mod+Alt+G', run: cm.search.gotoLine}, {label: 'Select All', keys: 'Mod+A', run: cm.commands.selectAll}, {label: 'Delete Line', keys: 'Mod+Shift+K', run: cm.commands.deleteLine}, {label: 'Move Line Up', keys: 'Alt+Up', run: cm.commands.moveLineUp}, {label: 'Move Line Down', keys: 'Alt+Down', run: cm.commands.moveLineDown}, {label: 'Copy Line Up', keys: 'Shift+Alt+Up', run: cm.commands.copyLineUp}, {label: 'Copy Line Down', keys: 'Shift+Alt+Down', run: cm.commands.copyLineDown}, {label: 'Toggle Comment', keys: 'Mod+/', run: cm.commands.toggleComment}, {label: 'Insert Blank Line', keys: 'Mod+Enter', run: cm.commands.insertBlankLine}, {label: 'Add Cursor Above', keys: isMac ? 'Mod+Alt+Up' : 'Ctrl+Alt+Up', run: cm.commands.addCursorAbove}, {label: 'Add Cursor Below', keys: isMac ? 'Mod+Alt+Down' : 'Ctrl+Alt+Down', run: cm.commands.addCursorBelow}, {label: 'Add Next Occurrence', keys: 'Mod+D', run: cm.search.selectNextOccurrence}, {label: 'Go to Matching Bracket', keys: 'Mod+Shift+\\', run: cm.commands.cursorMatchingBracket}, {label: 'Indent More', keys: 'Mod+]', run: cm.commands.indentMore}, {label: 'Indent Less', keys: 'Mod+[', run: cm.commands.indentLess}, {label: 'Fold Code', keys: isMac ? 'Mod+Alt+[' : 'Ctrl+Shift+[', run: cm.language.foldCode}, {label: 'Unfold Code', keys: isMac ? 'Mod+Alt+]' : 'Ctrl+Shift+]', run: cm.language.unfoldCode}, {label: 'Fold All', keys: 'Ctrl+Alt+[', run: cm.language.foldAll}, {label: 'Unfold All', keys: 'Ctrl+Alt+]', run: cm.language.unfoldAll}, {label: 'Trigger Autocomplete', keys: 'Ctrl+Space', run: cm.autocomplete.startCompletion}, {label: 'Trim Trailing Whitespace', keys: 'Mod+K Mod+X', run: trimTrailingWhitespaceFromView}, ]; let overlay: HTMLElement | null = null; let filtered: PaletteCommand[] = []; let selectedIndex = 0; let cleanupClickOutside: (() => void) | null = null; function hide(view: EditorView) { if (!overlay) return; cleanupClickOutside?.(); cleanupClickOutside = null; overlay.remove(); overlay = null; view.focus(); } function renderList(list: HTMLElement, query: string) { list.textContent = ''; if (!filtered.length) { const empty = document.createElement('div'); empty.className = 'cm-command-palette-empty'; empty.textContent = 'No matches'; list.append(empty); return; } for (const [index, cmd] of filtered.entries()) { const item = document.createElement('div'); item.className = 'cm-command-palette-item'; item.setAttribute('role', 'option'); item.setAttribute('data-index', String(index)); if (index === selectedIndex) item.setAttribute('aria-selected', 'true'); const label = document.createElement('span'); label.className = 'cm-command-palette-label'; const matchIndex = query ? cmd.label.toLowerCase().indexOf(query) : -1; if (matchIndex !== -1) { label.append(cmd.label.slice(0, matchIndex)); const mark = document.createElement('mark'); mark.textContent = cmd.label.slice(matchIndex, matchIndex + query.length); label.append(mark, cmd.label.slice(matchIndex + query.length)); } else { label.textContent = cmd.label; } item.append(label); if (cmd.keys) { const keysEl = document.createElement('span'); keysEl.className = 'cm-command-palette-keys'; for (const [chordIndex, chord] of formatKeys(cmd.keys).entries()) { if (chordIndex > 0) keysEl.append('→'); for (const k of chord) { const kbd = document.createElement('kbd'); kbd.textContent = k; keysEl.append(kbd); } } item.append(keysEl); } list.append(item); } } function show(view: EditorView, items?: PaletteCommand[], placeholder?: string) { const container = view.dom.closest('.code-editor-container')!; overlay = document.createElement('div'); overlay.className = 'cm-command-palette'; const input = document.createElement('input'); input.className = 'cm-command-palette-input'; input.placeholder = placeholder || 'Type a command...'; const list = document.createElement('div'); list.className = 'cm-command-palette-list'; list.setAttribute('role', 'listbox'); const source = items || commands; filtered = source; selectedIndex = 0; const updateSelected = () => { list.querySelector('[aria-selected]')?.removeAttribute('aria-selected'); const el = list.children[selectedIndex] as HTMLElement | undefined; if (el) { el.setAttribute('aria-selected', 'true'); if (el.offsetTop < list.scrollTop) { list.scrollTop = el.offsetTop; } else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight) { list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight; } } }; const execute = (cmd: PaletteCommand) => { hide(view); cmd.run(view); }; list.addEventListener('pointerover', (e) => { const item = (e.target as Element).closest('.cm-command-palette-item'); if (!item) return; selectedIndex = Number(item.getAttribute('data-index')); updateSelected(); }); list.addEventListener('mousedown', (e) => { const item = (e.target as Element).closest('.cm-command-palette-item'); if (!item) return; e.preventDefault(); const cmd = filtered[Number(item.getAttribute('data-index'))]; if (cmd) execute(cmd); }); input.addEventListener('input', () => { const q = input.value.toLowerCase(); filtered = q ? source.filter((cmd) => cmd.label.toLowerCase().includes(q)) : source; selectedIndex = 0; renderList(list, q); }); input.addEventListener('keydown', (e) => { if (e.key === 'ArrowDown') { e.preventDefault(); selectedIndex = (selectedIndex + 1) % filtered.length; updateSelected(); } else if (e.key === 'ArrowUp') { e.preventDefault(); selectedIndex = (selectedIndex - 1 + filtered.length) % filtered.length; updateSelected(); } else if (e.key === 'Enter') { e.preventDefault(); if (filtered[selectedIndex]) execute(filtered[selectedIndex]); } else if (e.key === 'Escape') { e.preventDefault(); hide(view); } }); overlay.append(input, list); container.append(overlay); renderList(list, ''); input.focus(); const handleClickOutside = (e: MouseEvent) => { const target = e.target as Element; if (overlay && !overlay.contains(target) && !target.closest('.js-code-command-palette')) { hide(view); } }; document.addEventListener('mousedown', handleClickOutside); cleanupClickOutside = () => document.removeEventListener('mousedown', handleClickOutside); } function showWithItems(view: EditorView, items: PaletteCommand[], placeholder: string) { if (overlay) hide(view); show(view, items, placeholder); } function togglePalette(view: EditorView) { if (overlay) { hide(view); } else { show(view); } return true; } return { extensions: cm.view.keymap.of([ {key: 'Mod-Shift-p', run: togglePalette, preventDefault: true}, {key: 'F1', run: togglePalette, preventDefault: true}, ]), togglePalette, showWithItems, }; }