experiments in a post-browser web
10
fork

Configure Feed

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

hotkey unification and fixes

+400 -36
+1 -1
app/cmd/index.js
··· 100 100 const initShortcut = (prefs) => { 101 101 api.shortcuts.register(prefs.shortcutKey, () => { 102 102 openInputWindow(prefs); 103 - }); 103 + }, { global: true }); 104 104 }; 105 105 106 106 const init = () => {
+2 -2
app/peeks/index.js
··· 56 56 57 57 api.shortcuts.register(shortcut, () => { 58 58 executeItem(item); 59 - }); 59 + }, { global: true }); 60 60 61 61 registeredShortcuts.push(shortcut); 62 62 } ··· 70 70 console.log('peeks uninit - unregistering', registeredShortcuts.length, 'shortcuts'); 71 71 72 72 registeredShortcuts.forEach(shortcut => { 73 - api.shortcuts.unregister(shortcut); 73 + api.shortcuts.unregister(shortcut, { global: true }); 74 74 }); 75 75 76 76 registeredShortcuts = [];
+2 -2
app/slides/index.js
··· 168 168 169 169 api.shortcuts.register(shortcut, () => { 170 170 executeItem(item); 171 - }); 171 + }, { global: true }); 172 172 173 173 registeredShortcuts.push(shortcut); 174 174 } ··· 183 183 184 184 // Unregister all shortcuts 185 185 registeredShortcuts.forEach(shortcut => { 186 - api.shortcuts.unregister(shortcut); 186 + api.shortcuts.unregister(shortcut, { global: true }); 187 187 }); 188 188 registeredShortcuts = []; 189 189
+2 -2
extensions/groups/background.js
··· 194 194 const initShortcut = shortcut => { 195 195 api.shortcuts.register(shortcut, () => { 196 196 openGroupsWindow(); 197 - }); 197 + }, { global: true }); 198 198 registeredShortcut = shortcut; 199 199 }; 200 200 ··· 225 225 const uninit = () => { 226 226 console.log('[ext:groups] uninit'); 227 227 if (registeredShortcut) { 228 - api.shortcuts.unregister(registeredShortcut); 228 + api.shortcuts.unregister(registeredShortcut, { global: true }); 229 229 registeredShortcut = null; 230 230 } 231 231 uninitCommands();
+176 -21
index.js
··· 277 277 278 278 // ***** Caches ***** 279 279 280 - // keyed on source address 280 + // keyed on shortcut string, value is source address (for global shortcuts) 281 281 const shortcuts = new Map(); 282 282 283 + // keyed on shortcut string, value is { source, callback, replyTopic } 284 + // Local shortcuts only work when app has focus 285 + const localShortcuts = new Map(); 286 + 283 287 // app global prefs configurable by user 284 288 // populated during app init 285 289 let _prefs = {}; ··· 290 294 class WindowManager { 291 295 constructor() { 292 296 this.windows = new Map(); 293 - 297 + 294 298 // Track window close events to clean up 295 299 app.on('browser-window-created', (_, window) => { 296 300 window.on('closed', () => { 297 301 const windowId = window.id; 298 302 const windowData = this.getWindow(windowId); 299 - 303 + 300 304 // Notify subscribers that window was closed 301 305 if (windowData) { 302 306 pubsub.publish(windowData.source, scopes.GLOBAL, 'window:closed', { ··· 304 308 source: windowData.source 305 309 }); 306 310 } 307 - 311 + 308 312 // Remove from window manager 309 313 this.removeWindow(windowId); 314 + }); 315 + 316 + // Handle local shortcuts on all windows via before-input-event 317 + window.webContents.on('before-input-event', (event, input) => { 318 + if (handleLocalShortcut(input)) { 319 + event.preventDefault(); 320 + } 310 321 }); 311 322 }); 312 323 } ··· 652 663 initTray(); 653 664 } 654 665 655 - // update quit shortcut if changed 666 + // update quit shortcut if changed (local shortcut - only works when app has focus) 656 667 const newQuitShortcut = msg.prefs.quitShortcut || strings.defaults.quitShortcut; 657 668 if (newQuitShortcut !== _quitShortcut) { 658 669 if (_quitShortcut) { 659 670 console.log('unregistering old quit shortcut:', _quitShortcut); 660 - globalShortcut.unregister(_quitShortcut); 671 + unregisterLocalShortcut(_quitShortcut); 661 672 } 662 673 console.log('registering new quit shortcut:', newQuitShortcut); 663 - registerShortcut(newQuitShortcut, onQuit); 674 + registerLocalShortcut(newQuitShortcut, onQuit); 664 675 _quitShortcut = newQuitShortcut; 665 676 } 666 677 }); ··· 804 815 }; 805 816 }); 806 817 807 - // Register default quit shortcut - will be updated when prefs arrive 818 + // Register default quit shortcut (local - only works when app has focus) 819 + // Will be updated when prefs arrive 808 820 _quitShortcut = strings.defaults.quitShortcut; 809 - registerShortcut(_quitShortcut, onQuit); 821 + registerLocalShortcut(_quitShortcut, onQuit); 810 822 811 823 // Mark app as ready and process any URLs that arrived during startup 812 824 _appReady = true; ··· 862 874 }); 863 875 864 876 ipcMain.on(strings.msgs.registerShortcut, (ev, msg) => { 865 - console.log('ipc register shortcut', msg); 877 + const isGlobal = msg.global === true; 878 + console.log('ipc register shortcut', msg.shortcut, isGlobal ? '(global)' : '(local)'); 866 879 867 - // record source of shortcut 868 - shortcuts.set(msg.shortcut, msg.source); 880 + const callback = () => { 881 + console.log('on(registershortcut): shortcut executed', msg.shortcut, msg.replyTopic); 882 + ev.reply(msg.replyTopic, { foo: 'bar' }); 883 + }; 869 884 870 - registerShortcut(msg.shortcut, () => { 871 - console.log('on(registershortcut): shortcut executed', msg.shortcut, msg.replyTopic) 872 - ev.reply(msg.replyTopic, { foo: 'bar' }); 873 - }); 885 + if (isGlobal) { 886 + // Global shortcut (works even when app doesn't have focus) 887 + shortcuts.set(msg.shortcut, msg.source); 888 + registerShortcut(msg.shortcut, callback); 889 + } else { 890 + // Local shortcut (only works when app has focus) 891 + const parsed = parseShortcut(msg.shortcut); 892 + localShortcuts.set(msg.shortcut, { source: msg.source, parsed, callback }); 893 + } 874 894 }); 875 895 876 896 ipcMain.on(strings.msgs.unregisterShortcut, (ev, msg) => { 877 - console.log('ipc unregister shortcut', msg); 897 + const isGlobal = msg.global === true; 898 + console.log('ipc unregister shortcut', msg.shortcut, isGlobal ? '(global)' : '(local)'); 878 899 879 - unregisterShortcut(msg.shortcut, res => { 880 - console.log('ipc unregister shortcut callback result:', res); 881 - }); 900 + if (isGlobal) { 901 + unregisterShortcut(msg.shortcut, res => { 902 + console.log('ipc unregister global shortcut callback result:', res); 903 + }); 904 + } else { 905 + unregisterLocalShortcut(msg.shortcut); 906 + } 882 907 }); 883 908 884 909 ipcMain.on(strings.msgs.closeWindow, (ev, msg) => { ··· 1892 1917 const unregisterShortcutsForAddress = (aAddress) => { 1893 1918 for (const [shortcut, address] of shortcuts) { 1894 1919 if (address == aAddress) { 1895 - console.log('unregistering', shortcut, 'for', address); 1920 + console.log('unregistering global shortcut', shortcut, 'for', address); 1896 1921 unregisterShortcut(shortcut); 1897 1922 } 1898 1923 } 1924 + // Also unregister local shortcuts for this address 1925 + for (const [shortcut, data] of localShortcuts) { 1926 + if (data.source === aAddress) { 1927 + console.log('unregistering local shortcut', shortcut, 'for', aAddress); 1928 + localShortcuts.delete(shortcut); 1929 + } 1930 + } 1931 + }; 1932 + 1933 + // Map key names to physical key codes (for before-input-event matching) 1934 + // Electron's input.code follows the USB HID spec 1935 + const keyToCode = { 1936 + // Letters 1937 + 'a': 'KeyA', 'b': 'KeyB', 'c': 'KeyC', 'd': 'KeyD', 'e': 'KeyE', 1938 + 'f': 'KeyF', 'g': 'KeyG', 'h': 'KeyH', 'i': 'KeyI', 'j': 'KeyJ', 1939 + 'k': 'KeyK', 'l': 'KeyL', 'm': 'KeyM', 'n': 'KeyN', 'o': 'KeyO', 1940 + 'p': 'KeyP', 'q': 'KeyQ', 'r': 'KeyR', 's': 'KeyS', 't': 'KeyT', 1941 + 'u': 'KeyU', 'v': 'KeyV', 'w': 'KeyW', 'x': 'KeyX', 'y': 'KeyY', 1942 + 'z': 'KeyZ', 1943 + // Numbers 1944 + '0': 'Digit0', '1': 'Digit1', '2': 'Digit2', '3': 'Digit3', '4': 'Digit4', 1945 + '5': 'Digit5', '6': 'Digit6', '7': 'Digit7', '8': 'Digit8', '9': 'Digit9', 1946 + // Punctuation 1947 + ',': 'Comma', '.': 'Period', '/': 'Slash', ';': 'Semicolon', "'": 'Quote', 1948 + '[': 'BracketLeft', ']': 'BracketRight', '\\': 'Backslash', '`': 'Backquote', 1949 + '-': 'Minus', '=': 'Equal', 1950 + // Special keys 1951 + 'enter': 'Enter', 'return': 'Enter', 1952 + 'tab': 'Tab', 1953 + 'space': 'Space', ' ': 'Space', 1954 + 'backspace': 'Backspace', 1955 + 'delete': 'Delete', 1956 + 'escape': 'Escape', 'esc': 'Escape', 1957 + 'up': 'ArrowUp', 'down': 'ArrowDown', 'left': 'ArrowLeft', 'right': 'ArrowRight', 1958 + 'arrowup': 'ArrowUp', 'arrowdown': 'ArrowDown', 'arrowleft': 'ArrowLeft', 'arrowright': 'ArrowRight', 1959 + 'home': 'Home', 'end': 'End', 1960 + 'pageup': 'PageUp', 'pagedown': 'PageDown', 1961 + // Function keys 1962 + 'f1': 'F1', 'f2': 'F2', 'f3': 'F3', 'f4': 'F4', 'f5': 'F5', 'f6': 'F6', 1963 + 'f7': 'F7', 'f8': 'F8', 'f9': 'F9', 'f10': 'F10', 'f11': 'F11', 'f12': 'F12', 1964 + }; 1965 + 1966 + // Parse shortcut string to match Electron's input event format 1967 + // e.g., 'Alt+Q' -> { alt: true, code: 'KeyQ' } 1968 + // e.g., 'CommandOrControl+Shift+P' -> { meta: true, shift: true, code: 'KeyP' } (on Mac) 1969 + const parseShortcut = (shortcut) => { 1970 + const parts = shortcut.toLowerCase().split('+'); 1971 + const result = { 1972 + ctrl: false, 1973 + alt: false, 1974 + shift: false, 1975 + meta: false, 1976 + code: '' 1977 + }; 1978 + 1979 + for (const part of parts) { 1980 + const p = part.trim(); 1981 + if (p === 'ctrl' || p === 'control') { 1982 + result.ctrl = true; 1983 + } else if (p === 'alt' || p === 'option') { 1984 + result.alt = true; 1985 + } else if (p === 'shift') { 1986 + result.shift = true; 1987 + } else if (p === 'meta' || p === 'cmd' || p === 'command' || p === 'super') { 1988 + result.meta = true; 1989 + } else if (p === 'commandorcontrol' || p === 'cmdorctrl') { 1990 + // On Mac, use meta (Cmd), on others use ctrl 1991 + if (process.platform === 'darwin') { 1992 + result.meta = true; 1993 + } else { 1994 + result.ctrl = true; 1995 + } 1996 + } else { 1997 + // This is the key itself - convert to code 1998 + result.code = keyToCode[p] || p; 1999 + } 2000 + } 2001 + 2002 + return result; 2003 + }; 2004 + 2005 + // Check if an input event matches a parsed shortcut 2006 + const inputMatchesShortcut = (input, parsed) => { 2007 + // Check modifiers 2008 + if (input.alt !== parsed.alt) return false; 2009 + if (input.shift !== parsed.shift) return false; 2010 + if (input.meta !== parsed.meta) return false; 2011 + if (input.control !== parsed.ctrl) return false; 2012 + 2013 + // Check physical key code (case-insensitive comparison) 2014 + return input.code.toLowerCase() === parsed.code.toLowerCase(); 2015 + }; 2016 + 2017 + // Register a local (app-only) shortcut 2018 + const registerLocalShortcut = (shortcut, callback) => { 2019 + console.log('registerLocalShortcut', shortcut); 2020 + 2021 + if (localShortcuts.has(shortcut)) { 2022 + console.log('local shortcut already registered, replacing:', shortcut); 2023 + } 2024 + 2025 + const parsed = parseShortcut(shortcut); 2026 + localShortcuts.set(shortcut, { parsed, callback }); 2027 + }; 2028 + 2029 + // Unregister a local shortcut 2030 + const unregisterLocalShortcut = (shortcut) => { 2031 + console.log('unregisterLocalShortcut', shortcut); 2032 + 2033 + if (!localShortcuts.has(shortcut)) { 2034 + console.error('local shortcut not registered:', shortcut); 2035 + return; 2036 + } 2037 + 2038 + localShortcuts.delete(shortcut); 2039 + }; 2040 + 2041 + // Handle local shortcuts from any focused window 2042 + // Called from before-input-event handler 2043 + const handleLocalShortcut = (input) => { 2044 + // Only handle keyDown events 2045 + if (input.type !== 'keyDown') return false; 2046 + 2047 + for (const [shortcut, data] of localShortcuts) { 2048 + if (inputMatchesShortcut(input, data.parsed)) { 2049 + data.callback(); 2050 + return true; 2051 + } 2052 + } 2053 + return false; 1899 2054 }; 1900 2055 1901 2056 // Ask renderer to handle escape, returns Promise<{ handled: boolean }>
notes/.extensibility.md.swp

This is a binary file and will not be displayed.

+193
notes/shortcuts-api.md
··· 1 + # Peek Shortcuts API 2 + 3 + The shortcuts API allows features and extensions to register keyboard shortcuts that trigger callbacks. 4 + 5 + ## Overview 6 + 7 + Peek supports two types of shortcuts: 8 + 9 + - **Global shortcuts**: Work system-wide, even when the app doesn't have focus. Useful for invoking the app from other applications. 10 + - **Local shortcuts**: Only work when Peek has focus. Safer for actions that shouldn't be triggered accidentally from other apps. 11 + 12 + By default, shortcuts are **local** (app-only). Pass `{ global: true }` to register a global shortcut. 13 + 14 + ## API Reference 15 + 16 + ### `api.shortcuts.register(shortcut, callback, options)` 17 + 18 + Register a keyboard shortcut. 19 + 20 + **Parameters:** 21 + 22 + | Name | Type | Required | Description | 23 + |------|------|----------|-------------| 24 + | `shortcut` | string | Yes | Key combination (e.g., `'Alt+1'`, `'CommandOrControl+Shift+P'`) | 25 + | `callback` | function | Yes | Function to call when shortcut is triggered | 26 + | `options` | object | No | Configuration options | 27 + | `options.global` | boolean | No | If `true`, shortcut works system-wide. Default: `false` | 28 + 29 + **Example:** 30 + 31 + ```javascript 32 + // Local shortcut (only works when app has focus) 33 + api.shortcuts.register('Alt+Q', () => { 34 + console.log('Quit shortcut pressed'); 35 + api.quit(); 36 + }); 37 + 38 + // Global shortcut (works even when app doesn't have focus) 39 + api.shortcuts.register('Alt+1', () => { 40 + console.log('Opening peek 1'); 41 + openPeek(1); 42 + }, { global: true }); 43 + ``` 44 + 45 + ### `api.shortcuts.unregister(shortcut, options)` 46 + 47 + Unregister a previously registered shortcut. 48 + 49 + **Parameters:** 50 + 51 + | Name | Type | Required | Description | 52 + |------|------|----------|-------------| 53 + | `shortcut` | string | Yes | The shortcut to unregister | 54 + | `options` | object | No | Configuration options | 55 + | `options.global` | boolean | No | Must match the registration. Default: `false` | 56 + 57 + **Example:** 58 + 59 + ```javascript 60 + // Unregister a local shortcut 61 + api.shortcuts.unregister('Alt+Q'); 62 + 63 + // Unregister a global shortcut 64 + api.shortcuts.unregister('Alt+1', { global: true }); 65 + ``` 66 + 67 + ## Shortcut String Format 68 + 69 + Shortcuts use Electron's accelerator format. Common modifiers: 70 + 71 + | Modifier | Mac | Windows/Linux | 72 + |----------|-----|---------------| 73 + | `CommandOrControl` | Cmd | Ctrl | 74 + | `Command` / `Cmd` | Cmd | N/A | 75 + | `Control` / `Ctrl` | Ctrl | Ctrl | 76 + | `Alt` / `Option` | Option | Alt | 77 + | `Shift` | Shift | Shift | 78 + | `Meta` / `Super` | Cmd | Win | 79 + 80 + **Examples:** 81 + 82 + - `Alt+1` - Option/Alt + 1 83 + - `CommandOrControl+Shift+P` - Cmd+Shift+P (Mac) or Ctrl+Shift+P (Windows/Linux) 84 + - `Option+Up` - Option + Arrow Up 85 + - `Ctrl+Q` - Control + Q 86 + 87 + ## When to Use Global vs Local 88 + 89 + ### Use Global Shortcuts For: 90 + 91 + - **Invoking the app** (e.g., opening peeks, slides, command palette) 92 + - **Quick access features** that users expect to work from any context 93 + - **Features explicitly configured** as system-wide by the user 94 + 95 + ### Use Local Shortcuts For: 96 + 97 + - **Destructive actions** like quit - prevents accidental triggers from other apps 98 + - **Feature-internal navigation** that only makes sense when the app is active 99 + - **Context-sensitive actions** that depend on app state 100 + 101 + ## Implementation Details 102 + 103 + ### Global Shortcuts 104 + 105 + Uses Electron's `globalShortcut` module. These shortcuts: 106 + 107 + - Work even when the app doesn't have focus 108 + - Are registered at the OS level 109 + - May conflict with shortcuts from other applications 110 + - Are unregistered when the app quits 111 + 112 + ### Local Shortcuts 113 + 114 + Uses Electron's `before-input-event` on each BrowserWindow. These shortcuts: 115 + 116 + - Only work when a Peek window has focus 117 + - Are handled before the webpage receives the input 118 + - Cannot conflict with other applications 119 + - Are more secure for sensitive operations 120 + - Use physical key codes (not characters) for matching, so Option+, works correctly on Mac even though it produces '≤' 121 + 122 + ### Internal Architecture 123 + 124 + ``` 125 + Renderer Process (preload.js) 126 + 127 + │ api.shortcuts.register(shortcut, cb, { global: true/false }) 128 + 129 + 130 + IPC: 'registershortcut' { shortcut, replyTopic, global } 131 + 132 + 133 + Main Process (index.js) 134 + 135 + ├─ global=true ──▶ globalShortcut.register() 136 + │ (stored in `shortcuts` Map) 137 + 138 + └─ global=false ──▶ localShortcuts Map 139 + (handled via before-input-event) 140 + ``` 141 + 142 + ## Feature Examples 143 + 144 + ### Peeks (Global) 145 + 146 + Peeks use global shortcuts so users can quickly invoke them from any app: 147 + 148 + ```javascript 149 + api.shortcuts.register(`Option+${keyNum}`, () => { 150 + openPeekWindow(item); 151 + }, { global: true }); 152 + ``` 153 + 154 + ### Quit (Local) 155 + 156 + The quit shortcut is local to prevent accidentally quitting the app: 157 + 158 + ```javascript 159 + api.shortcuts.register('Option+Q', () => { 160 + app.quit(); 161 + }); // No global flag - defaults to local 162 + ``` 163 + 164 + ## Best Practices 165 + 166 + 1. **Default to local** - Only use global shortcuts when necessary 167 + 2. **Match register/unregister** - Pass the same `global` option to both calls 168 + 3. **Clean up on uninit** - Always unregister shortcuts when a feature unloads 169 + 4. **Track registered shortcuts** - Keep a list for cleanup: 170 + 171 + ```javascript 172 + let registeredShortcuts = []; 173 + 174 + const init = () => { 175 + const shortcut = 'Alt+1'; 176 + api.shortcuts.register(shortcut, callback, { global: true }); 177 + registeredShortcuts.push(shortcut); 178 + }; 179 + 180 + const uninit = () => { 181 + registeredShortcuts.forEach(shortcut => { 182 + api.shortcuts.unregister(shortcut, { global: true }); 183 + }); 184 + registeredShortcuts = []; 185 + }; 186 + ``` 187 + 188 + ## Future Considerations 189 + 190 + - **UI for viewing shortcuts**: Settings panel showing all registered shortcuts 191 + - **Conflict detection**: Warn when a shortcut is already registered 192 + - **User customization**: Allow users to rebind shortcuts 193 + - **Shortcut categories**: Group shortcuts by feature for better organization
+24 -8
preload.js
··· 35 35 api.debugLevel = DEBUG_LEVEL; 36 36 37 37 api.shortcuts = { 38 - register: (shortcut, cb) => { 39 - console.log(src, 'registering ' + shortcut + ' for ' + window.location) 38 + /** 39 + * Register a keyboard shortcut 40 + * @param {string} shortcut - The shortcut key combination (e.g., 'Alt+1', 'CommandOrControl+Q') 41 + * @param {function} cb - Callback function when shortcut is triggered 42 + * @param {object} options - Optional configuration 43 + * @param {boolean} options.global - If true, shortcut works even when app doesn't have focus (default: false) 44 + */ 45 + register: (shortcut, cb, options = {}) => { 46 + const isGlobal = options.global === true; 47 + console.log(src, `registering ${isGlobal ? 'global' : 'local'} shortcut ${shortcut} for ${window.location}`); 40 48 41 - //const replyTopic = `${shortcut}:${window.location}`; 42 49 const replyTopic = `${shortcut}${rndm()}`; 43 50 44 51 ipcRenderer.send('registershortcut', { 45 52 source: sourceAddress, 46 53 shortcut, 47 - replyTopic 54 + replyTopic, 55 + global: isGlobal 48 56 }); 49 57 50 58 ipcRenderer.on(replyTopic, (ev, msg) => { ··· 53 61 console.log(src, 'shortcut execution reply done'); 54 62 }); 55 63 }, 56 - unregister: shortcut => { 57 - console.log('unregistering', shortcut, 'for', window.location) 58 - ipcRenderer.send('registershortcut', { 64 + /** 65 + * Unregister a keyboard shortcut 66 + * @param {string} shortcut - The shortcut to unregister 67 + * @param {object} options - Optional configuration (must match registration) 68 + * @param {boolean} options.global - If true, unregisters a global shortcut (default: false) 69 + */ 70 + unregister: (shortcut, options = {}) => { 71 + const isGlobal = options.global === true; 72 + console.log(`unregistering ${isGlobal ? 'global' : 'local'} shortcut`, shortcut, 'for', window.location); 73 + ipcRenderer.send('unregistershortcut', { 59 74 source: sourceAddress, 60 - shortcut 75 + shortcut, 76 + global: isGlobal 61 77 }); 62 78 } 63 79 };