···11+/**
22+ * Shared Shortcut Parser & Matcher
33+ *
44+ * Platform-agnostic shortcut parsing, matching, and registry management.
55+ * Works in any web context (Electron renderer, Tauri webview, plain browser).
66+ *
77+ * NOTE: The Electron backend (backend/electron/shortcuts.ts) maintains its own
88+ * copy of this logic due to TS rootDir boundaries. Keep them in sync.
99+ *
1010+ * @example
1111+ * import { parseShortcut, inputMatchesShortcut } from './shortcuts.js';
1212+ * const parsed = parseShortcut('Ctrl+Shift+K');
1313+ * const matches = inputMatchesShortcut(event, parsed);
1414+ */
1515+1616+// Map key names to physical key codes (for keyboard event matching)
1717+// Follows the USB HID / KeyboardEvent.code spec
1818+export const keyToCode = {
1919+ // Letters
2020+ 'a': 'KeyA', 'b': 'KeyB', 'c': 'KeyC', 'd': 'KeyD', 'e': 'KeyE',
2121+ 'f': 'KeyF', 'g': 'KeyG', 'h': 'KeyH', 'i': 'KeyI', 'j': 'KeyJ',
2222+ 'k': 'KeyK', 'l': 'KeyL', 'm': 'KeyM', 'n': 'KeyN', 'o': 'KeyO',
2323+ 'p': 'KeyP', 'q': 'KeyQ', 'r': 'KeyR', 's': 'KeyS', 't': 'KeyT',
2424+ 'u': 'KeyU', 'v': 'KeyV', 'w': 'KeyW', 'x': 'KeyX', 'y': 'KeyY',
2525+ 'z': 'KeyZ',
2626+ // Numbers
2727+ '0': 'Digit0', '1': 'Digit1', '2': 'Digit2', '3': 'Digit3', '4': 'Digit4',
2828+ '5': 'Digit5', '6': 'Digit6', '7': 'Digit7', '8': 'Digit8', '9': 'Digit9',
2929+ // Punctuation
3030+ ',': 'Comma', '.': 'Period', '/': 'Slash', ';': 'Semicolon', "'": 'Quote',
3131+ '[': 'BracketLeft', ']': 'BracketRight', '\\': 'Backslash', '`': 'Backquote',
3232+ '-': 'Minus', '=': 'Equal',
3333+ // Special keys
3434+ 'enter': 'Enter', 'return': 'Enter',
3535+ 'tab': 'Tab',
3636+ 'space': 'Space', ' ': 'Space',
3737+ 'backspace': 'Backspace',
3838+ 'delete': 'Delete',
3939+ 'escape': 'Escape', 'esc': 'Escape',
4040+ 'up': 'ArrowUp', 'down': 'ArrowDown', 'left': 'ArrowLeft', 'right': 'ArrowRight',
4141+ 'arrowup': 'ArrowUp', 'arrowdown': 'ArrowDown', 'arrowleft': 'ArrowLeft', 'arrowright': 'ArrowRight',
4242+ 'home': 'Home', 'end': 'End',
4343+ 'pageup': 'PageUp', 'pagedown': 'PageDown',
4444+ // Function keys
4545+ 'f1': 'F1', 'f2': 'F2', 'f3': 'F3', 'f4': 'F4', 'f5': 'F5', 'f6': 'F6',
4646+ 'f7': 'F7', 'f8': 'F8', 'f9': 'F9', 'f10': 'F10', 'f11': 'F11', 'f12': 'F12',
4747+};
4848+4949+/**
5050+ * Detect platform for CommandOrControl resolution.
5151+ * Works in browser (navigator.platform) and Node/Electron (process.platform).
5252+ */
5353+function isMac() {
5454+ if (typeof navigator !== 'undefined' && navigator.platform) {
5555+ return navigator.platform.startsWith('Mac');
5656+ }
5757+ if (typeof process !== 'undefined' && process.platform) {
5858+ return process.platform === 'darwin';
5959+ }
6060+ return false;
6161+}
6262+6363+/**
6464+ * Parse shortcut string into structured form.
6565+ * e.g., 'Alt+Q' -> { alt: true, code: 'KeyQ' }
6666+ * e.g., 'CommandOrControl+Shift+P' -> { meta: true, shift: true, code: 'KeyP' } (on Mac)
6767+ *
6868+ * @param {string} shortcut - Shortcut string like "Ctrl+Shift+K"
6969+ * @returns {ParsedShortcut}
7070+ */
7171+export function parseShortcut(shortcut) {
7272+ const parts = shortcut.toLowerCase().split('+');
7373+ const result = {
7474+ ctrl: false,
7575+ alt: false,
7676+ shift: false,
7777+ meta: false,
7878+ code: ''
7979+ };
8080+8181+ for (const part of parts) {
8282+ const p = part.trim();
8383+ if (p === 'ctrl' || p === 'control') {
8484+ result.ctrl = true;
8585+ } else if (p === 'alt' || p === 'option') {
8686+ result.alt = true;
8787+ } else if (p === 'shift') {
8888+ result.shift = true;
8989+ } else if (p === 'meta' || p === 'cmd' || p === 'command' || p === 'super') {
9090+ result.meta = true;
9191+ } else if (p === 'commandorcontrol' || p === 'cmdorctrl') {
9292+ // On Mac, use meta (Cmd), on others use ctrl
9393+ if (isMac()) {
9494+ result.meta = true;
9595+ } else {
9696+ result.ctrl = true;
9797+ }
9898+ } else {
9999+ // This is the key itself - convert to code
100100+ result.code = keyToCode[p] || p;
101101+ }
102102+ }
103103+104104+ return result;
105105+}
106106+107107+/**
108108+ * Check if an input event matches a parsed shortcut.
109109+ *
110110+ * The input object should have: { alt, shift, meta, control, code }
111111+ * This matches both Electron's before-input-event format and browser KeyboardEvent
112112+ * (with control mapped from ctrlKey).
113113+ *
114114+ * @param {InputEvent} input - The keyboard input event
115115+ * @param {ParsedShortcut} parsed - The parsed shortcut to match against
116116+ * @returns {boolean}
117117+ */
118118+export function inputMatchesShortcut(input, parsed) {
119119+ // Check modifiers
120120+ if (input.alt !== parsed.alt) return false;
121121+ if (input.shift !== parsed.shift) return false;
122122+ if (input.meta !== parsed.meta) return false;
123123+ if (input.control !== parsed.ctrl) return false;
124124+125125+ // Check physical key code (case-insensitive comparison)
126126+ return input.code.toLowerCase() === parsed.code.toLowerCase();
127127+}
128128+129129+/**
130130+ * Create a shortcut registry for managing local shortcuts with mode-conditional support.
131131+ *
132132+ * @param {object} [options]
133133+ * @param {function} [options.checkModeConditions] - Function (windowId, majorMode) => boolean
134134+ * for mode-conditional matching. If not provided, mode-conditional shortcuts never match.
135135+ * @param {boolean} [options.debug] - Enable debug logging
136136+ * @returns {ShortcutRegistry}
137137+ */
138138+export function createShortcutRegistry(options = {}) {
139139+ const { checkModeConditions, debug } = options;
140140+141141+ // Local shortcuts: shortcut string -> array of entries
142142+ const localShortcuts = new Map();
143143+144144+ /**
145145+ * Register a local shortcut.
146146+ * Supports mode-conditional shortcuts: same key can have different handlers for different modes.
147147+ */
148148+ function register(shortcut, source, callback, modeConditions) {
149149+ debug && console.log('[shortcuts] register', shortcut, modeConditions ? `mode:${modeConditions.majorMode}` : '');
150150+151151+ const parsed = parseShortcut(shortcut);
152152+ const entry = { source, parsed, callback, modeConditions };
153153+154154+ const entries = localShortcuts.get(shortcut) || [];
155155+156156+ if (modeConditions?.majorMode) {
157157+ // Mode-conditional: add to array
158158+ entries.push(entry);
159159+ } else {
160160+ // Non-conditional: find and replace any existing non-conditional entry
161161+ const nonConditionalIndex = entries.findIndex(e => !e.modeConditions?.majorMode);
162162+ if (nonConditionalIndex >= 0) {
163163+ entries[nonConditionalIndex] = entry;
164164+ } else {
165165+ entries.push(entry);
166166+ }
167167+ }
168168+169169+ localShortcuts.set(shortcut, entries);
170170+ }
171171+172172+ /**
173173+ * Unregister a local shortcut.
174174+ * If modeConditions provided, only removes matching entry; otherwise removes non-conditional entry.
175175+ */
176176+ function unregister(shortcut, source, modeConditions) {
177177+ debug && console.log('[shortcuts] unregister', shortcut);
178178+179179+ const entries = localShortcuts.get(shortcut);
180180+ if (!entries || entries.length === 0) {
181181+ debug && console.log('[shortcuts] not registered:', shortcut);
182182+ return;
183183+ }
184184+185185+ const filtered = entries.filter(entry => {
186186+ if (source && entry.source !== source) return true;
187187+ if (modeConditions?.majorMode) {
188188+ return entry.modeConditions?.majorMode !== modeConditions.majorMode;
189189+ }
190190+ return !!entry.modeConditions?.majorMode;
191191+ });
192192+193193+ if (filtered.length > 0) {
194194+ localShortcuts.set(shortcut, filtered);
195195+ } else {
196196+ localShortcuts.delete(shortcut);
197197+ }
198198+ }
199199+200200+ /**
201201+ * Handle local shortcuts from a keyboard input event.
202202+ * Returns true if shortcut was handled.
203203+ * Mode-conditional shortcuts are checked first, falling back to non-conditional.
204204+ *
205205+ * @param {InputEvent} input
206206+ * @param {number} [focusedWindowId]
207207+ * @returns {boolean}
208208+ */
209209+ function handle(input, focusedWindowId) {
210210+ // Only handle keyDown events
211211+ if (input.type !== 'keyDown') return false;
212212+213213+ for (const [, entries] of localShortcuts) {
214214+ for (const entry of entries) {
215215+ if (inputMatchesShortcut(input, entry.parsed)) {
216216+ if (entry.modeConditions?.majorMode) {
217217+ if (focusedWindowId !== undefined && checkModeConditions) {
218218+ const modeMatches = checkModeConditions(
219219+ focusedWindowId,
220220+ entry.modeConditions.majorMode
221221+ );
222222+ if (modeMatches) {
223223+ entry.callback();
224224+ return true;
225225+ }
226226+ continue;
227227+ }
228228+ } else {
229229+ entry.callback();
230230+ return true;
231231+ }
232232+ }
233233+ }
234234+ }
235235+236236+ return false;
237237+ }
238238+239239+ /**
240240+ * Unregister all shortcuts registered by a specific source address.
241241+ */
242242+ function unregisterAll(address) {
243243+ for (const [shortcut, entries] of localShortcuts) {
244244+ const filtered = entries.filter(entry => entry.source !== address);
245245+ if (filtered.length > 0) {
246246+ localShortcuts.set(shortcut, filtered);
247247+ } else {
248248+ localShortcuts.delete(shortcut);
249249+ }
250250+ if (entries.length !== filtered.length) {
251251+ debug && console.log('[shortcuts] unregistered', shortcut, 'for', address);
252252+ }
253253+ }
254254+ }
255255+256256+ /**
257257+ * Clear all registered shortcuts.
258258+ */
259259+ function clear() {
260260+ localShortcuts.clear();
261261+ }
262262+263263+ return { register, unregister, handle, unregisterAll, clear };
264264+}
+2
backend/electron/shortcuts.ts
···66 * - Local shortcuts (only work when app has focus)
77 * - Mode-conditional shortcuts (only trigger in specific modes)
88 * - Shortcut parsing and matching
99+ *
1010+ * NOTE: Shared copy lives in app/lib/shortcuts.js for frontend/Tauri use.
911 */
10121113import { DEBUG } from './config.js';