experiments in a post-browser web
10
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(shortcuts): extract shared shortcut parser to app/lib

+317
+51
app/lib/shortcuts.d.ts
··· 1 + export declare const keyToCode: Record<string, string>; 2 + 3 + export interface ParsedShortcut { 4 + ctrl: boolean; 5 + alt: boolean; 6 + shift: boolean; 7 + meta: boolean; 8 + code: string; 9 + } 10 + 11 + export interface InputEvent { 12 + type: string; 13 + alt: boolean; 14 + shift: boolean; 15 + meta: boolean; 16 + control: boolean; 17 + code: string; 18 + } 19 + 20 + export interface ModeConditions { 21 + majorMode?: string; 22 + } 23 + 24 + export declare function parseShortcut(shortcut: string): ParsedShortcut; 25 + 26 + export declare function inputMatchesShortcut( 27 + input: InputEvent, 28 + parsed: ParsedShortcut 29 + ): boolean; 30 + 31 + export interface ShortcutRegistry { 32 + register( 33 + shortcut: string, 34 + source: string, 35 + callback: () => void, 36 + modeConditions?: ModeConditions 37 + ): void; 38 + unregister( 39 + shortcut: string, 40 + source?: string, 41 + modeConditions?: ModeConditions 42 + ): void; 43 + handle(input: InputEvent, focusedWindowId?: number): boolean; 44 + unregisterAll(address: string): void; 45 + clear(): void; 46 + } 47 + 48 + export declare function createShortcutRegistry(options?: { 49 + checkModeConditions?: (windowId: number, majorMode: string) => boolean; 50 + debug?: boolean; 51 + }): ShortcutRegistry;
+264
app/lib/shortcuts.js
··· 1 + /** 2 + * Shared Shortcut Parser & Matcher 3 + * 4 + * Platform-agnostic shortcut parsing, matching, and registry management. 5 + * Works in any web context (Electron renderer, Tauri webview, plain browser). 6 + * 7 + * NOTE: The Electron backend (backend/electron/shortcuts.ts) maintains its own 8 + * copy of this logic due to TS rootDir boundaries. Keep them in sync. 9 + * 10 + * @example 11 + * import { parseShortcut, inputMatchesShortcut } from './shortcuts.js'; 12 + * const parsed = parseShortcut('Ctrl+Shift+K'); 13 + * const matches = inputMatchesShortcut(event, parsed); 14 + */ 15 + 16 + // Map key names to physical key codes (for keyboard event matching) 17 + // Follows the USB HID / KeyboardEvent.code spec 18 + export const keyToCode = { 19 + // Letters 20 + 'a': 'KeyA', 'b': 'KeyB', 'c': 'KeyC', 'd': 'KeyD', 'e': 'KeyE', 21 + 'f': 'KeyF', 'g': 'KeyG', 'h': 'KeyH', 'i': 'KeyI', 'j': 'KeyJ', 22 + 'k': 'KeyK', 'l': 'KeyL', 'm': 'KeyM', 'n': 'KeyN', 'o': 'KeyO', 23 + 'p': 'KeyP', 'q': 'KeyQ', 'r': 'KeyR', 's': 'KeyS', 't': 'KeyT', 24 + 'u': 'KeyU', 'v': 'KeyV', 'w': 'KeyW', 'x': 'KeyX', 'y': 'KeyY', 25 + 'z': 'KeyZ', 26 + // Numbers 27 + '0': 'Digit0', '1': 'Digit1', '2': 'Digit2', '3': 'Digit3', '4': 'Digit4', 28 + '5': 'Digit5', '6': 'Digit6', '7': 'Digit7', '8': 'Digit8', '9': 'Digit9', 29 + // Punctuation 30 + ',': 'Comma', '.': 'Period', '/': 'Slash', ';': 'Semicolon', "'": 'Quote', 31 + '[': 'BracketLeft', ']': 'BracketRight', '\\': 'Backslash', '`': 'Backquote', 32 + '-': 'Minus', '=': 'Equal', 33 + // Special keys 34 + 'enter': 'Enter', 'return': 'Enter', 35 + 'tab': 'Tab', 36 + 'space': 'Space', ' ': 'Space', 37 + 'backspace': 'Backspace', 38 + 'delete': 'Delete', 39 + 'escape': 'Escape', 'esc': 'Escape', 40 + 'up': 'ArrowUp', 'down': 'ArrowDown', 'left': 'ArrowLeft', 'right': 'ArrowRight', 41 + 'arrowup': 'ArrowUp', 'arrowdown': 'ArrowDown', 'arrowleft': 'ArrowLeft', 'arrowright': 'ArrowRight', 42 + 'home': 'Home', 'end': 'End', 43 + 'pageup': 'PageUp', 'pagedown': 'PageDown', 44 + // Function keys 45 + 'f1': 'F1', 'f2': 'F2', 'f3': 'F3', 'f4': 'F4', 'f5': 'F5', 'f6': 'F6', 46 + 'f7': 'F7', 'f8': 'F8', 'f9': 'F9', 'f10': 'F10', 'f11': 'F11', 'f12': 'F12', 47 + }; 48 + 49 + /** 50 + * Detect platform for CommandOrControl resolution. 51 + * Works in browser (navigator.platform) and Node/Electron (process.platform). 52 + */ 53 + function isMac() { 54 + if (typeof navigator !== 'undefined' && navigator.platform) { 55 + return navigator.platform.startsWith('Mac'); 56 + } 57 + if (typeof process !== 'undefined' && process.platform) { 58 + return process.platform === 'darwin'; 59 + } 60 + return false; 61 + } 62 + 63 + /** 64 + * Parse shortcut string into structured form. 65 + * e.g., 'Alt+Q' -> { alt: true, code: 'KeyQ' } 66 + * e.g., 'CommandOrControl+Shift+P' -> { meta: true, shift: true, code: 'KeyP' } (on Mac) 67 + * 68 + * @param {string} shortcut - Shortcut string like "Ctrl+Shift+K" 69 + * @returns {ParsedShortcut} 70 + */ 71 + export function parseShortcut(shortcut) { 72 + const parts = shortcut.toLowerCase().split('+'); 73 + const result = { 74 + ctrl: false, 75 + alt: false, 76 + shift: false, 77 + meta: false, 78 + code: '' 79 + }; 80 + 81 + for (const part of parts) { 82 + const p = part.trim(); 83 + if (p === 'ctrl' || p === 'control') { 84 + result.ctrl = true; 85 + } else if (p === 'alt' || p === 'option') { 86 + result.alt = true; 87 + } else if (p === 'shift') { 88 + result.shift = true; 89 + } else if (p === 'meta' || p === 'cmd' || p === 'command' || p === 'super') { 90 + result.meta = true; 91 + } else if (p === 'commandorcontrol' || p === 'cmdorctrl') { 92 + // On Mac, use meta (Cmd), on others use ctrl 93 + if (isMac()) { 94 + result.meta = true; 95 + } else { 96 + result.ctrl = true; 97 + } 98 + } else { 99 + // This is the key itself - convert to code 100 + result.code = keyToCode[p] || p; 101 + } 102 + } 103 + 104 + return result; 105 + } 106 + 107 + /** 108 + * Check if an input event matches a parsed shortcut. 109 + * 110 + * The input object should have: { alt, shift, meta, control, code } 111 + * This matches both Electron's before-input-event format and browser KeyboardEvent 112 + * (with control mapped from ctrlKey). 113 + * 114 + * @param {InputEvent} input - The keyboard input event 115 + * @param {ParsedShortcut} parsed - The parsed shortcut to match against 116 + * @returns {boolean} 117 + */ 118 + export function inputMatchesShortcut(input, parsed) { 119 + // Check modifiers 120 + if (input.alt !== parsed.alt) return false; 121 + if (input.shift !== parsed.shift) return false; 122 + if (input.meta !== parsed.meta) return false; 123 + if (input.control !== parsed.ctrl) return false; 124 + 125 + // Check physical key code (case-insensitive comparison) 126 + return input.code.toLowerCase() === parsed.code.toLowerCase(); 127 + } 128 + 129 + /** 130 + * Create a shortcut registry for managing local shortcuts with mode-conditional support. 131 + * 132 + * @param {object} [options] 133 + * @param {function} [options.checkModeConditions] - Function (windowId, majorMode) => boolean 134 + * for mode-conditional matching. If not provided, mode-conditional shortcuts never match. 135 + * @param {boolean} [options.debug] - Enable debug logging 136 + * @returns {ShortcutRegistry} 137 + */ 138 + export function createShortcutRegistry(options = {}) { 139 + const { checkModeConditions, debug } = options; 140 + 141 + // Local shortcuts: shortcut string -> array of entries 142 + const localShortcuts = new Map(); 143 + 144 + /** 145 + * Register a local shortcut. 146 + * Supports mode-conditional shortcuts: same key can have different handlers for different modes. 147 + */ 148 + function register(shortcut, source, callback, modeConditions) { 149 + debug && console.log('[shortcuts] register', shortcut, modeConditions ? `mode:${modeConditions.majorMode}` : ''); 150 + 151 + const parsed = parseShortcut(shortcut); 152 + const entry = { source, parsed, callback, modeConditions }; 153 + 154 + const entries = localShortcuts.get(shortcut) || []; 155 + 156 + if (modeConditions?.majorMode) { 157 + // Mode-conditional: add to array 158 + entries.push(entry); 159 + } else { 160 + // Non-conditional: find and replace any existing non-conditional entry 161 + const nonConditionalIndex = entries.findIndex(e => !e.modeConditions?.majorMode); 162 + if (nonConditionalIndex >= 0) { 163 + entries[nonConditionalIndex] = entry; 164 + } else { 165 + entries.push(entry); 166 + } 167 + } 168 + 169 + localShortcuts.set(shortcut, entries); 170 + } 171 + 172 + /** 173 + * Unregister a local shortcut. 174 + * If modeConditions provided, only removes matching entry; otherwise removes non-conditional entry. 175 + */ 176 + function unregister(shortcut, source, modeConditions) { 177 + debug && console.log('[shortcuts] unregister', shortcut); 178 + 179 + const entries = localShortcuts.get(shortcut); 180 + if (!entries || entries.length === 0) { 181 + debug && console.log('[shortcuts] not registered:', shortcut); 182 + return; 183 + } 184 + 185 + const filtered = entries.filter(entry => { 186 + if (source && entry.source !== source) return true; 187 + if (modeConditions?.majorMode) { 188 + return entry.modeConditions?.majorMode !== modeConditions.majorMode; 189 + } 190 + return !!entry.modeConditions?.majorMode; 191 + }); 192 + 193 + if (filtered.length > 0) { 194 + localShortcuts.set(shortcut, filtered); 195 + } else { 196 + localShortcuts.delete(shortcut); 197 + } 198 + } 199 + 200 + /** 201 + * Handle local shortcuts from a keyboard input event. 202 + * Returns true if shortcut was handled. 203 + * Mode-conditional shortcuts are checked first, falling back to non-conditional. 204 + * 205 + * @param {InputEvent} input 206 + * @param {number} [focusedWindowId] 207 + * @returns {boolean} 208 + */ 209 + function handle(input, focusedWindowId) { 210 + // Only handle keyDown events 211 + if (input.type !== 'keyDown') return false; 212 + 213 + for (const [, entries] of localShortcuts) { 214 + for (const entry of entries) { 215 + if (inputMatchesShortcut(input, entry.parsed)) { 216 + if (entry.modeConditions?.majorMode) { 217 + if (focusedWindowId !== undefined && checkModeConditions) { 218 + const modeMatches = checkModeConditions( 219 + focusedWindowId, 220 + entry.modeConditions.majorMode 221 + ); 222 + if (modeMatches) { 223 + entry.callback(); 224 + return true; 225 + } 226 + continue; 227 + } 228 + } else { 229 + entry.callback(); 230 + return true; 231 + } 232 + } 233 + } 234 + } 235 + 236 + return false; 237 + } 238 + 239 + /** 240 + * Unregister all shortcuts registered by a specific source address. 241 + */ 242 + function unregisterAll(address) { 243 + for (const [shortcut, entries] of localShortcuts) { 244 + const filtered = entries.filter(entry => entry.source !== address); 245 + if (filtered.length > 0) { 246 + localShortcuts.set(shortcut, filtered); 247 + } else { 248 + localShortcuts.delete(shortcut); 249 + } 250 + if (entries.length !== filtered.length) { 251 + debug && console.log('[shortcuts] unregistered', shortcut, 'for', address); 252 + } 253 + } 254 + } 255 + 256 + /** 257 + * Clear all registered shortcuts. 258 + */ 259 + function clear() { 260 + localShortcuts.clear(); 261 + } 262 + 263 + return { register, unregister, handle, unregisterAll, clear }; 264 + }
+2
backend/electron/shortcuts.ts
··· 6 6 * - Local shortcuts (only work when app has focus) 7 7 * - Mode-conditional shortcuts (only trigger in specific modes) 8 8 * - Shortcut parsing and matching 9 + * 10 + * NOTE: Shared copy lives in app/lib/shortcuts.js for frontend/Tauri use. 9 11 */ 10 12 11 13 import { DEBUG } from './config.js';