From 57e7db5c2a13699ccf625efb54498892fd4082bf Mon Sep 17 00:00:00 2001 From: Byron Gamatos Date: Sun, 12 Jul 2026 16:05:25 +0200 Subject: [PATCH] refactor(app): carve the keyboard-shortcuts subsystem into static/js/shortcuts.js (R3d) (#922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 19 declarations + 23 TOP-LEVEL STATEMENTS. 922 lines. app.js 3,243 -> 2,325 (-28%). The panel registry, both global keydown dispatchers, the library arrow-nav, and the whole plugin-facing shortcut API. ━━━ MOST OF THIS SUBSYSTEM WAS NOT DECLARATIONS ━━━ A declaration-seeded dependency closure reports this cluster as 10 names, 246 lines. It is 42 statements and 922. window.registerShortcut, createShortcutPanel, getAllShortcuts, unregisterShortcut, clearWindowShortcuts, the panel registry, and BOTH global keydown dispatchers are bare TOP-LEVEL STATEMENTS at app.js's top level. A call-graph scan sees NONE of them. That blind spot has now cost three times: * it nearly shipped a dead library A-Z rail (#896) — 43 of library.js's exports were referenced only from app.js's window contract; * it threw "Assignment to constant variable" in the session carve (#921), where the autoplay gate's top-level statements wrote state that had just become a read-only import; * and here it under-reported the slice by 3x. The extractor takes them by construction now — any top-level statement that TOUCHES a moved binding comes along — and the SEED is closed to a FIXED POINT, because those statements have their own dependencies (_modifiersMatch, _isShortcutActive, _handleLibArrowNav, _gridColumns…) that the declaration closure never walked. Seed -> pull the statements -> the statements need more names -> re-seed. Iterate until it stops growing. ━━━ syncLibrarySong GOES ACROSS THE SEAM, NOT THROUGH AN IMPORT ━━━ The library arrow-nav calls it on Enter. It cannot be imported: syncLibrarySong reaches showScreen/playSong, and a module importing app.js closes a cycle. It is the ONE name here that had to stay behind, so it comes across the host seam — which is exactly what the seam is for. host.js throws loudly if the wiring is ever dropped, and tests/js/host_contract.test.js fails in CI if the hook drifts. VERIFIED. A/B against origin/main in two browsers, IDENTICAL, zero page errors — and driven for real, not merely present: the plugin API (register / unregister / getAll / panels), THE GLOBAL KEYDOWN DISPATCHER actually firing a registered shortcut, that same shortcut correctly SUPPRESSED while typing in a text input, and `?` opening the help modal. node 1045, pytest 2425, ESLint 0 (no-cycle clean), host contract 2/2, Codex 0. Co-authored-by: Claude Opus 4.8 (1M context) --- static/app.js | 969 ++-------------------------------------- static/js/shortcuts.js | 983 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1009 insertions(+), 943 deletions(-) create mode 100644 static/js/shortcuts.js diff --git a/static/app.js b/static/app.js index 86368c3..4eea1a3 100644 --- a/static/app.js +++ b/static/app.js @@ -258,6 +258,27 @@ import { playSong, showScreen, } from './js/session.js'; +import { + ShortcutPanel, + _DEBUG_SHORTCUTS, + _activePanel, + _activeSearchInput, + _defaultPanel, + _getCurrentContext, + _gridColumns, + _handleLibArrowNav, + _isInsideInteractiveControl, + _isShortcutActive, + _isShortcutHelpKey, + _isShortcutHelpSuppressedTarget, + _isSpaceKey, + _isTextInput, + _modifiersMatch, + _openShortcutsModal, + _panels, + _shortcutDispatchBlocked, + defaultPanel, +} from './js/shortcuts.js'; // The playback transport. These used to BE app.js — they are imported back now, and the // four modules that reached for them through the host seam import them directly instead. import { @@ -270,57 +291,6 @@ import { // Demo analytics — real impl set by demo.js; no-op in normal builds window.feedBackDemoTrack = window.feedBackDemoTrack ?? null; -// ── Global keyboard shortcuts ───────────────────────────────────────────── -// -// `/` focuses the active screen's search input (Library / Favorites); -// `Esc` while focused blurs and clears it. Mirrors the GitHub / Gmail -// convention. The listener bails when the user is already typing in -// any text-accepting element so it can't intercept normal typing — -// including inputs inside the filters drawer, plugin settings, or -// modal dialogs. -function _isTextInput(el) { - if (!el) return false; - const tag = el.tagName; - if (tag === 'INPUT') { - // Some types (button, checkbox, radio, range, ...) don't - // accept text; only intercept the ones that do. - const t = (el.type || 'text').toLowerCase(); - return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t); - } - if (tag === 'TEXTAREA') return true; - if (tag === 'SELECT') return true; - if (el.isContentEditable) return true; - return false; -} - -function _isShortcutHelpKey(e) { - return e.key === '?' || (e.shiftKey && (e.code === 'Slash' || e.key === '/')); -} - -function _isShortcutHelpSuppressedTarget(el) { - if (!el) return false; - const tag = el.tagName; - if (tag === 'INPUT') { - const t = (el.type || 'text').toLowerCase(); - return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t); - } - if (tag === 'TEXTAREA') return true; - if (el.isContentEditable) return true; - if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal, .feedBack-modal')) return true; - return false; -} - -function _activeSearchInput() { - // Pick the search field for whichever screen is currently active. - // No match (e.g. on the player or settings screen) means `/` does - // nothing — the shortcut only fires where a search box exists. - const active = document.querySelector('.screen.active'); - if (!active) return null; - if (active.id === 'home') return document.getElementById('lib-filter'); - if (active.id === 'favorites') return document.getElementById('fav-filter'); - return null; -} - // ── Library keyboard navigation ────────────────────────────────────────── // // Arrow keys move a single "selected" item among the visible cards @@ -336,544 +306,6 @@ function _activeSearchInput() { // breakpoints (1 / 2 / 3 / 4 cols depending on viewport). -function _gridColumns(container) { - // Count columns by grouping the first row of children by their - // top coordinate. Robust against any grid-template-columns syntax - // (`repeat(...)`, `auto-fit`, named lines, etc.) where naively - // splitting `getComputedStyle().gridTemplateColumns` on whitespace - // would miscount because of spaces inside `repeat(...)` / - // `minmax(...)`. Falls back to 1 when the container is empty - // so callers' max(1, ...) clamps stay valid. - if (!container) return 1; - const children = Array.from(container.children).filter( - c => c && c.offsetParent !== null - ); - if (!children.length) return 1; - const firstTop = children[0].getBoundingClientRect().top; - let cols = 0; - for (const c of children) { - // Allow ~1px slop for sub-pixel rounding so two children that - // would visually align still group together. - if (Math.abs(c.getBoundingClientRect().top - firstTop) < 1.5) cols++; - else break; - } - return Math.max(1, cols); -} - -function _isInsideInteractiveControl(el) { - // Bail when the user is interacting with anything that has its - // own keyboard semantics — form controls (checkbox / select / - // button) consume arrow keys for their own behavior, and the - // filters drawer is a focus trap of those. Without this guard the - // library's arrow nav would steal arrow presses from a focused - // tuning checkbox or sort dropdown. - if (!el) return false; - const tag = el.tagName; - if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(tag)) return true; - if (el.isContentEditable) return true; - if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal')) return true; - return false; -} - - -function _isSpaceKey(e) { - return e.key === ' ' || e.key === 'Spacebar'; -} - - -function _shortcutDispatchBlocked(e) { - if (_isTextInput(e.target)) return true; - // Space in Section Practice bar should pause/resume, not toggle checkboxes/buttons. - if (_isSpaceKey(e) && _sectionPracticeBarContains(e.target)) return false; - // While the Section Practice popover is open, Esc just closes it (handled by - // the popover's own keydown listener) — suppress the player-scope - // "back to library" Esc so the user doesn't get bounced out of the player. - if (e.key === 'Escape' && _sectionPracticePopoverOpen()) return true; - // Space on the player screen should always play/pause, even if focus is on a - // sidebar nav link, player rail button, popover control, or any other - // interactive element — the shortcut dispatcher calls preventDefault so the - // focused element won't also activate. Two exceptions keep native Space: - // text inputs (already exempted above), and focus inside a true modal - // dialog (role="dialog" aria-modal="true", or a .feedBack-modal overlay) - // layered over the player — a modal traps interaction, so Space must reach - // its focused control (e.g. the Close button) rather than toggle playback - // behind it. Non-modal player popovers/toasts (loop A/B, arrangement pin, - // role="dialog" aria-modal="false") are not modals and stay covered. - if (_isSpaceKey(e) && _getCurrentContext().isPlayer && - !(e.target && e.target.closest && - e.target.closest('[role="dialog"][aria-modal="true"], .feedBack-modal'))) { - return false; - } - // Escape is the universal "back" action and must fire like Space above even - // when a transport/rail control - - ${sectionsHtml} - - `; - - // Click outside the inner panel (i.e. on the backdrop) closes the - // modal — matches the conventional dialog UX. - modal.addEventListener('click', (ev) => { - if (ev.target === modal || ev.target.closest('[data-shortcuts-close]')) { - const opener = modal._opener; - modal.remove(); - const focusTarget = (opener && document.body.contains(opener)) ? opener - : (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null); - if (focusTarget) focusTarget.focus({ preventScroll: true }); - } - }); - - document.body.appendChild(modal); - // Move focus into the dialog so background shortcuts (and arrow - // nav) can't fire on the underlying library entry while the - // overlay is open. Close button is the safe default — there's no - // primary input to focus on a read-only cheat sheet. - const closeBtn = modal.querySelector('[data-shortcuts-close]'); - if (closeBtn) closeBtn.focus({ preventScroll: true }); - // Trap Tab / Shift+Tab inside the modal so focus can't escape to - // the library content underneath while the overlay is open. - _trapFocusInModal(modal); -} - -document.addEventListener('keydown', (e) => { - // Modifier-key combos belong to the browser / OS shortcuts; never - // intercept those. - if (e.ctrlKey || e.metaKey || e.altKey) return; - - if (_handleLibArrowNav(e)) return; - - // `?` (Shift+/) opens the keyboard-shortcuts cheat sheet. Some - // Linux/Electron stacks report Shift+/ as key='/' with code='Slash', - // so check the help shape before treating plain '/' as search. - if (_isShortcutHelpKey(e)) { - if (_isShortcutHelpSuppressedTarget(e.target || document.activeElement)) return; - e.preventDefault(); - // Stop other keydown listeners on document (notably the shortcut - // registry below) from also consuming this event — otherwise a - // Linux/Electron Shift+Slash reported as key='/' opens help here and - // then the registry's plain `/` library-search shortcut focuses - // #lib-filter behind the modal. (Copilot review on #602.) - e.stopImmediatePropagation(); - _openShortcutsModal(); - return; - } - - if (e.key === '/') { - if (_isTextInput(document.activeElement)) return; - // Also bail when focus is inside the filter drawer, a dialog, or - // any other interactive region — those contexts have their own - // keyboard semantics and shouldn't be hijacked by the search - // shortcut (e.g. a focused checkbox inside the filters drawer). - if (_isInsideInteractiveControl(document.activeElement)) return; - const search = _activeSearchInput(); - if (!search) return; - e.preventDefault(); // suppress the literal '/' the input would receive - search.focus(); - // Move caret to end without mutating .value — round-tripping - // the value resets the browser's undo stack and can fire - // unexpected input events on some engines. setSelectionRange - // is the no-side-effects path. - try { - const len = search.value.length; - search.setSelectionRange(len, len); - } catch { - // Some input types (search/email/tel) don't support - // selection APIs in older browsers; the focus alone is - // still useful, just no caret-end guarantee. - } - return; - } - - // Single-letter shortcuts that act on the focused / selected - // library entry — works on both grid cards and tree rows. Each - // dispatches to a button class that the entry markup already - // exposes, so plugins can keep owning the actual behavior: - // f → .fav-btn (favorite heart toggle) - // e → .edit-btn (edit metadata modal) - // No-op when no entry is currently focused / selected, when the - // entry doesn't expose the requested button, or when the button is disabled. - // Bails on text input / drawer focus so single-letter typing in - // inputs still works. - const entryShortcut = { f: 'button.fav-btn', e: 'button.edit-btn' }[e.key.toLowerCase()]; - if (entryShortcut) { - if (_isInsideInteractiveControl(document.activeElement)) return; - const ae = document.activeElement; - const activeScreen = document.querySelector('.screen.active'); - const isEntry = el => el && el.classList && (el.classList.contains('song-card') || el.classList.contains('song-row')); - // Scope both candidates to the active screen so that a stale - // _lastLibSelected from Library doesn't fire when the user is - // on Favorites (or vice-versa), and so pressing f/e/c on a - // hidden screen can't accidentally persist that filename into - // the current screen's localStorage key. - const inActiveScreen = el => activeScreen && activeScreen.contains(el); - const target = (isEntry(ae) && inActiveScreen(ae)) ? ae - : (isEntry(_lastLibSelected) && inActiveScreen(_lastLibSelected) ? _lastLibSelected : null); - if (!target) return; - const btn = target.querySelector(entryShortcut); - if (!btn || btn.disabled) return; - e.preventDefault(); - // Sync the persistent selection to the acted-on entry so that - // Esc-to-close-modal returns focus to the correct element and - // the `.selected` highlight stays consistent with the action. - _setLibSelection(target, { focus: false }); - btn.click(); - return; - } - - if (e.key === 'Escape') { - // Modal-first: close the topmost open modal (edit-metadata, - // shortcuts cheat sheet, future modals) so Esc dismisses - // from anywhere — including when keyboard focus is inside - // a form field within the modal. Restores focus to the - // element that opened the modal (tracked in modal._opener) - // so arrow nav resumes without an extra Tab; falls back to - // _lastLibSelected when the opener is no longer in the DOM. - const modals = document.querySelectorAll('[role="dialog"][aria-modal="true"].feedBack-modal'); - if (modals.length) { - e.preventDefault(); - e.stopImmediatePropagation(); - const modal = modals[modals.length - 1]; - const opener = modal._opener; - modal.remove(); - const focusTarget = (opener && document.body.contains(opener)) ? opener - : (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null); - if (focusTarget) focusTarget.focus({ preventScroll: true }); - return; - } - // Esc while typing in either search box clears + blurs. Other Esc - // semantics (drawer close, screen back) are handled elsewhere; we - // only act when a search box is the focused element. - const ae = document.activeElement; - if (ae && (ae.id === 'lib-filter' || ae.id === 'fav-filter')) { - if (ae.value) { - ae.value = ''; - ae.dispatchEvent(new Event('input', { bubbles: true })); - } - ae.blur(); - } - } -}); - // ── Library ────────────────────────────────────────────────────────────── const _LIB_PROVIDER_KEY = 'feedBack.libProvider'; @@ -2436,377 +1868,23 @@ window.getShortcutWindowId = () => { // Each panel has its own shortcut registry. This allows multiple panels // (e.g., splitscreen) to have their own keyboard shortcuts without collisions. -class ShortcutPanel { - constructor(id) { - this.id = id; - this.shortcuts = new Map(); - } - - _compositeKey(key, scope) { - return `${scope}::${key}`; - } - - registerShortcut(options) { - const { key, description, scope = 'global', condition = null, handler, modifiers = null } = options; - - if (!key || !handler) { - console.error(`registerShortcut: key and handler are required`); - return; - } - - // Validate scope - const validScopes = ['global', 'player', 'library', 'settings']; - const isValidScope = validScopes.includes(scope) || - scope.startsWith('plugin-'); - if (!isValidScope) { - console.warn(`registerShortcut: invalid scope '${scope}'. Valid scopes are: global, player, library, settings, or plugin-{id}`); - } - - // Conflict detection: warn if key+scope is already registered - const compositeKey = this._compositeKey(key, scope); - if (this.shortcuts.has(compositeKey)) { - console.warn(`registerShortcut [${this.id}]: '${key}' in scope '${scope}' is already registered; overwriting. Previous:`, this.shortcuts.get(compositeKey)); - } - - this.shortcuts.set(compositeKey, { key, description, scope, condition, handler, modifiers }); - } - - unregisterShortcut(key, scope) { - return this.shortcuts.delete(this._compositeKey(key, scope)); - } - - clearShortcuts() { - this.shortcuts.clear(); - } - - listShortcuts() { - return Array.from(this.shortcuts.entries()).map(([ck, s]) => [s.key, s]); - } -} - -// Global panel management -const _panels = new Map(); -let _activePanel = null; -let _defaultPanel = null; - -// Create default panel on init -const defaultPanel = new ShortcutPanel('default'); -_panels.set('default', defaultPanel); -_defaultPanel = 'default'; -_activePanel = 'default'; - // ── Panel API ─────────────────────────────────────────────────────────────── -window.createShortcutPanel = (id) => { - if (_panels.has(id)) { - console.warn(`createShortcutPanel: panel '${id}' already exists`); - return _panels.get(id); - } - const panel = new ShortcutPanel(id); - _panels.set(id, panel); - return panel; -}; - -window.setActiveShortcutPanel = (id) => { - if (!_panels.has(id)) { - console.error(`setActiveShortcutPanel: panel '${id}' does not exist`); - return; - } - _activePanel = id; -}; - -window.getActiveShortcutPanel = () => _activePanel; - -window.isInShortcutPanel = () => { - return _activePanel !== 'default'; -}; - -window.getGlobalShortcutContext = () => { - console.warn('getGlobalShortcutContext: Global shortcuts are exceptional. Consider using panel-scoped shortcuts instead.'); - return _panels.get('default'); -}; - // ── Shortcut registry (routes to active panel) ─────────────────────────────── -window.registerShortcut = (options) => { - const panelId = _activePanel || _defaultPanel || 'default'; - const panel = _panels.get(panelId); - - if (!panel) { - console.error(`registerShortcut: No panel found for registration: ${panelId}`); - return; - } - - panel.registerShortcut(options); -}; - -// Flat, read-only snapshot of every registered shortcut across all panels, -// for the Settings → Keybinds reference tab. Dedupes by combo+scope (the same -// shortcut can live in both the active panel and the default panel) and uses -// the same modifier-prefix formatting as the shortcuts modal. Returns -// [{ combo, description, scope }]; remapping is not supported, so this is -// purely informational. -window.getAllShortcuts = () => { - const fmt = (s) => { - const m = s.modifiers || {}; - return (m.ctrl ? 'Ctrl+' : '') + (m.alt ? 'Alt+' : '') - + (m.shift ? 'Shift+' : '') + (m.meta ? 'Meta+' : '') + s.key; - }; - const seen = new Set(); - const out = []; - for (const [, panel] of _panels) { - if (!panel || !panel.shortcuts) continue; - for (const [, s] of panel.shortcuts) { - const combo = fmt(s); - const dedupe = combo + '|' + (s.scope || ''); - if (seen.has(dedupe)) continue; - seen.add(dedupe); - out.push({ combo, description: s.description || '', scope: s.scope || 'global' }); - } - } - return out; -}; - -window.unregisterShortcut = (key, scope) => { - // Try the active panel first to preserve panel isolation; fall back to - // other panels so a shortcut registered before a panel switch is still - // removable. - const resolvedScope = scope || 'global'; - const activePanelId = _activePanel || _defaultPanel || 'default'; - const activePanel = _panels.get(activePanelId); - if (activePanel && activePanel.unregisterShortcut(key, resolvedScope)) { - return true; - } - for (const [panelId, panel] of _panels) { - if (panelId === activePanelId) continue; - if (panel.unregisterShortcut(key, resolvedScope)) { - return true; - } - } - return false; -}; - -window.clearWindowShortcuts = (windowId) => { - // Remove all shortcuts registered for a specific window - // This is for backward compatibility with window-specific shortcuts - let removed = 0; - for (const [panelId, panel] of _panels) { - if (panelId.startsWith(`window-${windowId}`)) { - panel.clearShortcuts(); - _panels.delete(panelId); - removed++; - } - } - return removed; -}; - -function _getCurrentContext() { - const currentScreen = document.querySelector('.screen.active')?.id; - return { - screen: currentScreen, - windowId: window.getShortcutWindowId(), - activePanel: _activePanel, - isPlayer: currentScreen === 'player', - isLibrary: ['home', 'favorites'].includes(currentScreen), - isSettings: currentScreen === 'settings', - isPlugin: currentScreen?.startsWith('plugin-') - }; -} - -function _isShortcutActive(shortcut, ctx) { - if (shortcut.scope === 'global') return true; - if (shortcut.scope === 'player' && ctx.isPlayer) return true; - if (shortcut.scope === 'library' && ctx.isLibrary) return true; - if (shortcut.scope === 'settings' && ctx.isSettings) return true; - if (shortcut.scope.startsWith('plugin-')) { - const pluginId = shortcut.scope.replace('plugin-', ''); - return ctx.screen === `plugin-${pluginId}`; - } - return false; -} - -function _modifiersMatch(e, modifiers) { - if (!modifiers) return true; - if (modifiers.ctrl !== undefined && modifiers.ctrl !== e.ctrlKey) return false; - if (modifiers.alt !== undefined && modifiers.alt !== e.altKey) return false; - if (modifiers.shift !== undefined && modifiers.shift !== e.shiftKey) return false; - if (modifiers.meta !== undefined && modifiers.meta !== e.metaKey) return false; - return true; -} - -// Debug mode for keyboard shortcuts -let _DEBUG_SHORTCUTS = false; - -window._setDebugShortcuts = (enabled) => { - _DEBUG_SHORTCUTS = enabled; - console.log(`[Shortcuts] Debug mode ${enabled ? 'ENABLED' : 'DISABLED'}`); -}; - -window._listShortcuts = () => { - console.log('=== Registered Shortcuts ==='); - for (const [panelId, panel] of _panels) { - console.log(`Panel: ${panelId}`); - for (const [, s] of panel.shortcuts) { - console.log(` ${s.key.padEnd(15)} | ${s.scope.padEnd(10)} | ${s.description}`); - } - } - console.log('=== End ==='); -}; - -window._testShortcut = (key, scope) => { - // Mirror the dispatcher: try the active panel first, then default. - const resolvedScope = scope || 'global'; - const tried = new Set(); - const panelOrder = [_activePanel, _defaultPanel, 'default'].filter(id => { - if (!id || tried.has(id)) return false; - tried.add(id); - return true; - }); - - for (const panelId of panelOrder) { - const panel = _panels.get(panelId); - if (!panel) continue; - const shortcut = panel.shortcuts.get(panel._compositeKey(key, resolvedScope)); - if (!shortcut) continue; - - const ctx = _getCurrentContext(); - const active = _isShortcutActive(shortcut, ctx); - let conditionMet = true; - if (shortcut.condition) { - try { conditionMet = !!shortcut.condition(); } - catch (err) { conditionMet = `threw: ${err.message}`; } - } - console.log(`Shortcut '${key}' [${resolvedScope}] [${panelId}]:`, { - description: shortcut.description, - scope: shortcut.scope, - currentContext: ctx, - isActive: active, - conditionMet - }); - return; - } - - console.log(`Shortcut '${key}' (scope: ${resolvedScope}) not registered in any panel`); -}; - -// Expose internals for debugging (prefixed with _ to indicate private) -// These are for development/debugging only and should not be used by plugins. -window._panels = _panels; -window._getCurrentContext = _getCurrentContext; -window._isShortcutActive = _isShortcutActive; - // ── Registry-based keydown handler ───────────────────────────────────────── // // This handler processes all registered shortcuts through the central registry. // It runs after the library navigation handler (which handles /, ?, c, f, e, etc.) // and before any other keydown listeners. -document.addEventListener('keydown', e => { - if (_shortcutDispatchBlocked(e)) return; - - const ctx = _getCurrentContext(); - const activePanel = _panels.get(_activePanel); - const defaultPanel = _panels.get('default'); - - if (!activePanel && !defaultPanel) return; - - if (_DEBUG_SHORTCUTS) { - console.log('[Shortcuts] Key pressed:', { key: e.key, code: e.code, ctx, activePanel: _activePanel }); - } - - // Try active panel first, then fall back to default - const panelsToDispatch = []; - if (activePanel && activePanel !== defaultPanel) panelsToDispatch.push(activePanel); - if (defaultPanel) panelsToDispatch.push(defaultPanel); - - for (const panel of panelsToDispatch) { - for (const [, shortcut] of panel.shortcuts) { - // Match on both e.key (character produced) and e.code (physical key) - if (e.key !== shortcut.key && e.code !== shortcut.key) continue; - - // Check modifier keys if specified - if (!_modifiersMatch(e, shortcut.modifiers)) continue; - - if (_DEBUG_SHORTCUTS) { - console.log('[Shortcuts] Matched shortcut:', shortcut.key, shortcut); - } - - // Check scope - if (!_isShortcutActive(shortcut, ctx)) { - if (_DEBUG_SHORTCUTS) { - console.log('[Shortcuts] Not active - scope mismatch:', shortcut.scope, ctx); - } - continue; - } - - // Check condition callback — guard against plugin errors - if (shortcut.condition) { - try { - if (!shortcut.condition()) { - if (_DEBUG_SHORTCUTS) { - console.log('[Shortcuts] Not active - condition failed'); - } - continue; - } - } catch (err) { - console.error('[Shortcuts] condition() threw for key:', shortcut.key, err); - continue; - } - } - - e.preventDefault(); - if (_DEBUG_SHORTCUTS) { - console.log('[Shortcuts] Executing handler for:', shortcut.key); - } - // Guard handler against plugin errors - try { - shortcut.handler(e); - } catch (err) { - console.error('[Shortcuts] handler() threw for key:', shortcut.key, err); - } - return; - } -} - - if (_DEBUG_SHORTCUTS) { - console.log('[Shortcuts] No shortcut matched for:', e.key, e.code); - } -}); - // ── Window cleanup ─────────────────────────────────────────────────────────── // Clean up window-specific shortcuts when a window is closed. // This is important for popup windows (e.g., splitscreen plugin) that // may be closed by the user. -window.addEventListener('beforeunload', () => { - const windowId = window.getShortcutWindowId(); - const removed = window.clearWindowShortcuts(windowId); - if (removed > 0 && _DEBUG_SHORTCUTS) { - console.log(`[Shortcuts] Cleaned up ${removed} shortcuts for window ${windowId}`); - } -}); - // ── Register built-in shortcuts ─────────────────────────────────────────── -// Global shortcuts -registerShortcut({ - key: '?', - description: 'Show keyboard shortcuts', - scope: 'global', - handler: () => _openShortcutsModal() -}); - -// Library shortcuts -registerShortcut({ - key: '/', - description: 'Focus search', - scope: 'library', - handler: () => { - const input = _activeSearchInput(); - if (input) input.focus(); - } -}); - registerShortcut({ key: 'f', description: 'Toggle favorite', @@ -3190,6 +2268,11 @@ document.addEventListener('click', e => { // tests/js/host_contract.test.js fails CI if this list and the host.* uses under // static/js/ ever drift apart. configureHost({ + // shortcuts.js's library arrow-nav needs syncLibrarySong (Enter on a selected row). It + // cannot import it: syncLibrarySong reaches showScreen/playSong, and a module importing + // app.js closes a cycle. So it comes across the seam, and host.js throws loudly if this line + // is ever dropped. + syncLibrarySong, handleSliderInput, playSong, // count-in is a module now, so section-practice reaches it through the seam too — diff --git a/static/js/shortcuts.js b/static/js/shortcuts.js new file mode 100644 index 0000000..1c50c05 --- /dev/null +++ b/static/js/shortcuts.js @@ -0,0 +1,983 @@ +// KEYBOARD SHORTCUTS: the panel registry, the global dispatchers, and the plugin-facing API. +// +// ━━━ MOST OF THIS SUBSYSTEM IS TOP-LEVEL STATEMENTS, NOT DECLARATIONS ━━━ +// +// 10 declarations — and 18 top-level statements. window.registerShortcut, +// window.createShortcutPanel, getAllShortcuts, unregisterShortcut, clearWindowShortcuts, the +// panel registry, and BOTH global keydown dispatchers are all bare statements at app.js's top +// level. A dependency scan that walks declarations sees NONE of them, and would have reported +// this cluster as 246 lines. It is more than double that. +// +// That blind spot has now cost twice: it nearly shipped a dead library A-Z rail (#896), and it +// threw "Assignment to constant variable" in the session carve (#921), where the autoplay gate's +// top-level statements wrote state that had become a read-only import. The extractor takes them +// by construction now — any top-level statement that TOUCHES a moved binding comes along. +// +// window.registerShortcut and friends are a PLUGIN-FACING API. They keep working because app.js +// still publishes them; the definitions simply live here, next to the dispatcher they feed. + +import { + _lastLibSelected, + _libNavItems, + _moveSelectionInItems, + _providerSupports, + _setLibSelection, + _toggleHeader, +} from './library.js'; +import { + _sectionPracticeBarContains, + _sectionPracticePopoverOpen, +} from './section-practice.js'; +import { + _trapFocusInModal, + esc, +} from './dom.js'; +import { + playSong, +} from './session.js'; +import { host } from './host.js'; +// ── Global keyboard shortcuts ───────────────────────────────────────────── +// +// `/` focuses the active screen's search input (Library / Favorites); +// `Esc` while focused blurs and clears it. Mirrors the GitHub / Gmail +// convention. The listener bails when the user is already typing in +// any text-accepting element so it can't intercept normal typing — +// including inputs inside the filters drawer, plugin settings, or +// modal dialogs. +export function _isTextInput(el) { + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT') { + // Some types (button, checkbox, radio, range, ...) don't + // accept text; only intercept the ones that do. + const t = (el.type || 'text').toLowerCase(); + return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t); + } + if (tag === 'TEXTAREA') return true; + if (tag === 'SELECT') return true; + if (el.isContentEditable) return true; + return false; +} + +export function _isShortcutHelpKey(e) { + return e.key === '?' || (e.shiftKey && (e.code === 'Slash' || e.key === '/')); +} + +export function _isShortcutHelpSuppressedTarget(el) { + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT') { + const t = (el.type || 'text').toLowerCase(); + return ['text', 'search', 'email', 'url', 'tel', 'password', 'number'].includes(t); + } + if (tag === 'TEXTAREA') return true; + if (el.isContentEditable) return true; + if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal, .feedBack-modal')) return true; + return false; +} + +export function _activeSearchInput() { + // Pick the search field for whichever screen is currently active. + // No match (e.g. on the player or settings screen) means `/` does + // nothing — the shortcut only fires where a search box exists. + const active = document.querySelector('.screen.active'); + if (!active) return null; + if (active.id === 'home') return document.getElementById('lib-filter'); + if (active.id === 'favorites') return document.getElementById('fav-filter'); + return null; +} + +export function _gridColumns(container) { + // Count columns by grouping the first row of children by their + // top coordinate. Robust against any grid-template-columns syntax + // (`repeat(...)`, `auto-fit`, named lines, etc.) where naively + // splitting `getComputedStyle().gridTemplateColumns` on whitespace + // would miscount because of spaces inside `repeat(...)` / + // `minmax(...)`. Falls back to 1 when the container is empty + // so callers' max(1, ...) clamps stay valid. + if (!container) return 1; + const children = Array.from(container.children).filter( + c => c && c.offsetParent !== null + ); + if (!children.length) return 1; + const firstTop = children[0].getBoundingClientRect().top; + let cols = 0; + for (const c of children) { + // Allow ~1px slop for sub-pixel rounding so two children that + // would visually align still group together. + if (Math.abs(c.getBoundingClientRect().top - firstTop) < 1.5) cols++; + else break; + } + return Math.max(1, cols); +} + +export function _isInsideInteractiveControl(el) { + // Bail when the user is interacting with anything that has its + // own keyboard semantics — form controls (checkbox / select / + // button) consume arrow keys for their own behavior, and the + // filters drawer is a focus trap of those. Without this guard the + // library's arrow nav would steal arrow presses from a focused + // tuning checkbox or sort dropdown. + if (!el) return false; + const tag = el.tagName; + if (['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON'].includes(tag)) return true; + if (el.isContentEditable) return true; + if (el.closest && el.closest('#lib-filter-drawer, [role="dialog"], #edit-modal')) return true; + return false; +} + +export function _isSpaceKey(e) { + return e.key === ' ' || e.key === 'Spacebar'; +} + +export function _shortcutDispatchBlocked(e) { + if (_isTextInput(e.target)) return true; + // Space in Section Practice bar should pause/resume, not toggle checkboxes/buttons. + if (_isSpaceKey(e) && _sectionPracticeBarContains(e.target)) return false; + // While the Section Practice popover is open, Esc just closes it (handled by + // the popover's own keydown listener) — suppress the player-scope + // "back to library" Esc so the user doesn't get bounced out of the player. + if (e.key === 'Escape' && _sectionPracticePopoverOpen()) return true; + // Space on the player screen should always play/pause, even if focus is on a + // sidebar nav link, player rail button, popover control, or any other + // interactive element — the shortcut dispatcher calls preventDefault so the + // focused element won't also activate. Two exceptions keep native Space: + // text inputs (already exempted above), and focus inside a true modal + // dialog (role="dialog" aria-modal="true", or a .feedBack-modal overlay) + // layered over the player — a modal traps interaction, so Space must reach + // its focused control (e.g. the Close button) rather than toggle playback + // behind it. Non-modal player popovers/toasts (loop A/B, arrangement pin, + // role="dialog" aria-modal="false") are not modals and stay covered. + if (_isSpaceKey(e) && _getCurrentContext().isPlayer && + !(e.target && e.target.closest && + e.target.closest('[role="dialog"][aria-modal="true"], .feedBack-modal'))) { + return false; + } + // Escape is the universal "back" action and must fire like Space above even + // when a transport/rail control + + ${sectionsHtml} + + `; + + // Click outside the inner panel (i.e. on the backdrop) closes the + // modal — matches the conventional dialog UX. + modal.addEventListener('click', (ev) => { + if (ev.target === modal || ev.target.closest('[data-shortcuts-close]')) { + const opener = modal._opener; + modal.remove(); + const focusTarget = (opener && document.body.contains(opener)) ? opener + : (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null); + if (focusTarget) focusTarget.focus({ preventScroll: true }); + } + }); + + document.body.appendChild(modal); + // Move focus into the dialog so background shortcuts (and arrow + // nav) can't fire on the underlying library entry while the + // overlay is open. Close button is the safe default — there's no + // primary input to focus on a read-only cheat sheet. + const closeBtn = modal.querySelector('[data-shortcuts-close]'); + if (closeBtn) closeBtn.focus({ preventScroll: true }); + // Trap Tab / Shift+Tab inside the modal so focus can't escape to + // the library content underneath while the overlay is open. + _trapFocusInModal(modal); +} + +document.addEventListener('keydown', (e) => { + // Modifier-key combos belong to the browser / OS shortcuts; never + // intercept those. + if (e.ctrlKey || e.metaKey || e.altKey) return; + + if (_handleLibArrowNav(e)) return; + + // `?` (Shift+/) opens the keyboard-shortcuts cheat sheet. Some + // Linux/Electron stacks report Shift+/ as key='/' with code='Slash', + // so check the help shape before treating plain '/' as search. + if (_isShortcutHelpKey(e)) { + if (_isShortcutHelpSuppressedTarget(e.target || document.activeElement)) return; + e.preventDefault(); + // Stop other keydown listeners on document (notably the shortcut + // registry below) from also consuming this event — otherwise a + // Linux/Electron Shift+Slash reported as key='/' opens help here and + // then the registry's plain `/` library-search shortcut focuses + // #lib-filter behind the modal. (Copilot review on #602.) + e.stopImmediatePropagation(); + _openShortcutsModal(); + return; + } + + if (e.key === '/') { + if (_isTextInput(document.activeElement)) return; + // Also bail when focus is inside the filter drawer, a dialog, or + // any other interactive region — those contexts have their own + // keyboard semantics and shouldn't be hijacked by the search + // shortcut (e.g. a focused checkbox inside the filters drawer). + if (_isInsideInteractiveControl(document.activeElement)) return; + const search = _activeSearchInput(); + if (!search) return; + e.preventDefault(); // suppress the literal '/' the input would receive + search.focus(); + // Move caret to end without mutating .value — round-tripping + // the value resets the browser's undo stack and can fire + // unexpected input events on some engines. setSelectionRange + // is the no-side-effects path. + try { + const len = search.value.length; + search.setSelectionRange(len, len); + } catch { + // Some input types (search/email/tel) don't support + // selection APIs in older browsers; the focus alone is + // still useful, just no caret-end guarantee. + } + return; + } + + // Single-letter shortcuts that act on the focused / selected + // library entry — works on both grid cards and tree rows. Each + // dispatches to a button class that the entry markup already + // exposes, so plugins can keep owning the actual behavior: + // f → .fav-btn (favorite heart toggle) + // e → .edit-btn (edit metadata modal) + // No-op when no entry is currently focused / selected, when the + // entry doesn't expose the requested button, or when the button is disabled. + // Bails on text input / drawer focus so single-letter typing in + // inputs still works. + const entryShortcut = { f: 'button.fav-btn', e: 'button.edit-btn' }[e.key.toLowerCase()]; + if (entryShortcut) { + if (_isInsideInteractiveControl(document.activeElement)) return; + const ae = document.activeElement; + const activeScreen = document.querySelector('.screen.active'); + const isEntry = el => el && el.classList && (el.classList.contains('song-card') || el.classList.contains('song-row')); + // Scope both candidates to the active screen so that a stale + // _lastLibSelected from Library doesn't fire when the user is + // on Favorites (or vice-versa), and so pressing f/e/c on a + // hidden screen can't accidentally persist that filename into + // the current screen's localStorage key. + const inActiveScreen = el => activeScreen && activeScreen.contains(el); + const target = (isEntry(ae) && inActiveScreen(ae)) ? ae + : (isEntry(_lastLibSelected) && inActiveScreen(_lastLibSelected) ? _lastLibSelected : null); + if (!target) return; + const btn = target.querySelector(entryShortcut); + if (!btn || btn.disabled) return; + e.preventDefault(); + // Sync the persistent selection to the acted-on entry so that + // Esc-to-close-modal returns focus to the correct element and + // the `.selected` highlight stays consistent with the action. + _setLibSelection(target, { focus: false }); + btn.click(); + return; + } + + if (e.key === 'Escape') { + // Modal-first: close the topmost open modal (edit-metadata, + // shortcuts cheat sheet, future modals) so Esc dismisses + // from anywhere — including when keyboard focus is inside + // a form field within the modal. Restores focus to the + // element that opened the modal (tracked in modal._opener) + // so arrow nav resumes without an extra Tab; falls back to + // _lastLibSelected when the opener is no longer in the DOM. + const modals = document.querySelectorAll('[role="dialog"][aria-modal="true"].feedBack-modal'); + if (modals.length) { + e.preventDefault(); + e.stopImmediatePropagation(); + const modal = modals[modals.length - 1]; + const opener = modal._opener; + modal.remove(); + const focusTarget = (opener && document.body.contains(opener)) ? opener + : (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null); + if (focusTarget) focusTarget.focus({ preventScroll: true }); + return; + } + // Esc while typing in either search box clears + blurs. Other Esc + // semantics (drawer close, screen back) are handled elsewhere; we + // only act when a search box is the focused element. + const ae = document.activeElement; + if (ae && (ae.id === 'lib-filter' || ae.id === 'fav-filter')) { + if (ae.value) { + ae.value = ''; + ae.dispatchEvent(new Event('input', { bubbles: true })); + } + ae.blur(); + } + } +}); + +export class ShortcutPanel { + constructor(id) { + this.id = id; + this.shortcuts = new Map(); + } + + _compositeKey(key, scope) { + return `${scope}::${key}`; + } + + registerShortcut(options) { + const { key, description, scope = 'global', condition = null, handler, modifiers = null } = options; + + if (!key || !handler) { + console.error(`registerShortcut: key and handler are required`); + return; + } + + // Validate scope + const validScopes = ['global', 'player', 'library', 'settings']; + const isValidScope = validScopes.includes(scope) || + scope.startsWith('plugin-'); + if (!isValidScope) { + console.warn(`registerShortcut: invalid scope '${scope}'. Valid scopes are: global, player, library, settings, or plugin-{id}`); + } + + // Conflict detection: warn if key+scope is already registered + const compositeKey = this._compositeKey(key, scope); + if (this.shortcuts.has(compositeKey)) { + console.warn(`registerShortcut [${this.id}]: '${key}' in scope '${scope}' is already registered; overwriting. Previous:`, this.shortcuts.get(compositeKey)); + } + + this.shortcuts.set(compositeKey, { key, description, scope, condition, handler, modifiers }); + } + + unregisterShortcut(key, scope) { + return this.shortcuts.delete(this._compositeKey(key, scope)); + } + + clearShortcuts() { + this.shortcuts.clear(); + } + + listShortcuts() { + return Array.from(this.shortcuts.entries()).map(([ck, s]) => [s.key, s]); + } +} + +// Global panel management +export const _panels = new Map(); + +export let _activePanel = null; + +export let _defaultPanel = null; + +// Create default panel on init +export const defaultPanel = new ShortcutPanel('default'); + +_panels.set('default', defaultPanel); + +_defaultPanel = 'default'; + +_activePanel = 'default'; + +window.createShortcutPanel = (id) => { + if (_panels.has(id)) { + console.warn(`createShortcutPanel: panel '${id}' already exists`); + return _panels.get(id); + } + const panel = new ShortcutPanel(id); + _panels.set(id, panel); + return panel; +}; + +window.setActiveShortcutPanel = (id) => { + if (!_panels.has(id)) { + console.error(`setActiveShortcutPanel: panel '${id}' does not exist`); + return; + } + _activePanel = id; +}; + +window.getActiveShortcutPanel = () => _activePanel; + +window.isInShortcutPanel = () => { + return _activePanel !== 'default'; +}; + +window.getGlobalShortcutContext = () => { + console.warn('getGlobalShortcutContext: Global shortcuts are exceptional. Consider using panel-scoped shortcuts instead.'); + return _panels.get('default'); +}; + +window.registerShortcut = (options) => { + const panelId = _activePanel || _defaultPanel || 'default'; + const panel = _panels.get(panelId); + + if (!panel) { + console.error(`registerShortcut: No panel found for registration: ${panelId}`); + return; + } + + panel.registerShortcut(options); +}; + +// Flat, read-only snapshot of every registered shortcut across all panels, +// for the Settings → Keybinds reference tab. Dedupes by combo+scope (the same +// shortcut can live in both the active panel and the default panel) and uses +// the same modifier-prefix formatting as the shortcuts modal. Returns +// [{ combo, description, scope }]; remapping is not supported, so this is +// purely informational. +window.getAllShortcuts = () => { + const fmt = (s) => { + const m = s.modifiers || {}; + return (m.ctrl ? 'Ctrl+' : '') + (m.alt ? 'Alt+' : '') + + (m.shift ? 'Shift+' : '') + (m.meta ? 'Meta+' : '') + s.key; + }; + const seen = new Set(); + const out = []; + for (const [, panel] of _panels) { + if (!panel || !panel.shortcuts) continue; + for (const [, s] of panel.shortcuts) { + const combo = fmt(s); + const dedupe = combo + '|' + (s.scope || ''); + if (seen.has(dedupe)) continue; + seen.add(dedupe); + out.push({ combo, description: s.description || '', scope: s.scope || 'global' }); + } + } + return out; +}; + +window.unregisterShortcut = (key, scope) => { + // Try the active panel first to preserve panel isolation; fall back to + // other panels so a shortcut registered before a panel switch is still + // removable. + const resolvedScope = scope || 'global'; + const activePanelId = _activePanel || _defaultPanel || 'default'; + const activePanel = _panels.get(activePanelId); + if (activePanel && activePanel.unregisterShortcut(key, resolvedScope)) { + return true; + } + for (const [panelId, panel] of _panels) { + if (panelId === activePanelId) continue; + if (panel.unregisterShortcut(key, resolvedScope)) { + return true; + } + } + return false; +}; + +window.clearWindowShortcuts = (windowId) => { + // Remove all shortcuts registered for a specific window + // This is for backward compatibility with window-specific shortcuts + let removed = 0; + for (const [panelId, panel] of _panels) { + if (panelId.startsWith(`window-${windowId}`)) { + panel.clearShortcuts(); + _panels.delete(panelId); + removed++; + } + } + return removed; +}; + +export function _getCurrentContext() { + const currentScreen = document.querySelector('.screen.active')?.id; + return { + screen: currentScreen, + windowId: window.getShortcutWindowId(), + activePanel: _activePanel, + isPlayer: currentScreen === 'player', + isLibrary: ['home', 'favorites'].includes(currentScreen), + isSettings: currentScreen === 'settings', + isPlugin: currentScreen?.startsWith('plugin-') + }; +} + +export function _isShortcutActive(shortcut, ctx) { + if (shortcut.scope === 'global') return true; + if (shortcut.scope === 'player' && ctx.isPlayer) return true; + if (shortcut.scope === 'library' && ctx.isLibrary) return true; + if (shortcut.scope === 'settings' && ctx.isSettings) return true; + if (shortcut.scope.startsWith('plugin-')) { + const pluginId = shortcut.scope.replace('plugin-', ''); + return ctx.screen === `plugin-${pluginId}`; + } + return false; +} + +export function _modifiersMatch(e, modifiers) { + if (!modifiers) return true; + if (modifiers.ctrl !== undefined && modifiers.ctrl !== e.ctrlKey) return false; + if (modifiers.alt !== undefined && modifiers.alt !== e.altKey) return false; + if (modifiers.shift !== undefined && modifiers.shift !== e.shiftKey) return false; + if (modifiers.meta !== undefined && modifiers.meta !== e.metaKey) return false; + return true; +} + +// Debug mode for keyboard shortcuts +export let _DEBUG_SHORTCUTS = false; + +window._setDebugShortcuts = (enabled) => { + _DEBUG_SHORTCUTS = enabled; + console.log(`[Shortcuts] Debug mode ${enabled ? 'ENABLED' : 'DISABLED'}`); +}; + +window._listShortcuts = () => { + console.log('=== Registered Shortcuts ==='); + for (const [panelId, panel] of _panels) { + console.log(`Panel: ${panelId}`); + for (const [, s] of panel.shortcuts) { + console.log(` ${s.key.padEnd(15)} | ${s.scope.padEnd(10)} | ${s.description}`); + } + } + console.log('=== End ==='); +}; + +window._testShortcut = (key, scope) => { + // Mirror the dispatcher: try the active panel first, then default. + const resolvedScope = scope || 'global'; + const tried = new Set(); + const panelOrder = [_activePanel, _defaultPanel, 'default'].filter(id => { + if (!id || tried.has(id)) return false; + tried.add(id); + return true; + }); + + for (const panelId of panelOrder) { + const panel = _panels.get(panelId); + if (!panel) continue; + const shortcut = panel.shortcuts.get(panel._compositeKey(key, resolvedScope)); + if (!shortcut) continue; + + const ctx = _getCurrentContext(); + const active = _isShortcutActive(shortcut, ctx); + let conditionMet = true; + if (shortcut.condition) { + try { conditionMet = !!shortcut.condition(); } + catch (err) { conditionMet = `threw: ${err.message}`; } + } + console.log(`Shortcut '${key}' [${resolvedScope}] [${panelId}]:`, { + description: shortcut.description, + scope: shortcut.scope, + currentContext: ctx, + isActive: active, + conditionMet + }); + return; + } + + console.log(`Shortcut '${key}' (scope: ${resolvedScope}) not registered in any panel`); +}; + +// Expose internals for debugging (prefixed with _ to indicate private) +// These are for development/debugging only and should not be used by plugins. +window._panels = _panels; + +window._getCurrentContext = _getCurrentContext; + +window._isShortcutActive = _isShortcutActive; + +document.addEventListener('keydown', e => { + if (_shortcutDispatchBlocked(e)) return; + + const ctx = _getCurrentContext(); + const activePanel = _panels.get(_activePanel); + const defaultPanel = _panels.get('default'); + + if (!activePanel && !defaultPanel) return; + + if (_DEBUG_SHORTCUTS) { + console.log('[Shortcuts] Key pressed:', { key: e.key, code: e.code, ctx, activePanel: _activePanel }); + } + + // Try active panel first, then fall back to default + const panelsToDispatch = []; + if (activePanel && activePanel !== defaultPanel) panelsToDispatch.push(activePanel); + if (defaultPanel) panelsToDispatch.push(defaultPanel); + + for (const panel of panelsToDispatch) { + for (const [, shortcut] of panel.shortcuts) { + // Match on both e.key (character produced) and e.code (physical key) + if (e.key !== shortcut.key && e.code !== shortcut.key) continue; + + // Check modifier keys if specified + if (!_modifiersMatch(e, shortcut.modifiers)) continue; + + if (_DEBUG_SHORTCUTS) { + console.log('[Shortcuts] Matched shortcut:', shortcut.key, shortcut); + } + + // Check scope + if (!_isShortcutActive(shortcut, ctx)) { + if (_DEBUG_SHORTCUTS) { + console.log('[Shortcuts] Not active - scope mismatch:', shortcut.scope, ctx); + } + continue; + } + + // Check condition callback — guard against plugin errors + if (shortcut.condition) { + try { + if (!shortcut.condition()) { + if (_DEBUG_SHORTCUTS) { + console.log('[Shortcuts] Not active - condition failed'); + } + continue; + } + } catch (err) { + console.error('[Shortcuts] condition() threw for key:', shortcut.key, err); + continue; + } + } + + e.preventDefault(); + if (_DEBUG_SHORTCUTS) { + console.log('[Shortcuts] Executing handler for:', shortcut.key); + } + // Guard handler against plugin errors + try { + shortcut.handler(e); + } catch (err) { + console.error('[Shortcuts] handler() threw for key:', shortcut.key, err); + } + return; + } +} + + if (_DEBUG_SHORTCUTS) { + console.log('[Shortcuts] No shortcut matched for:', e.key, e.code); + } +}); + +window.addEventListener('beforeunload', () => { + const windowId = window.getShortcutWindowId(); + const removed = window.clearWindowShortcuts(windowId); + if (removed > 0 && _DEBUG_SHORTCUTS) { + console.log(`[Shortcuts] Cleaned up ${removed} shortcuts for window ${windowId}`); + } +}); + +// Global shortcuts +registerShortcut({ + key: '?', + description: 'Show keyboard shortcuts', + scope: 'global', + handler: () => _openShortcutsModal() +}); + +// Library shortcuts +registerShortcut({ + key: '/', + description: 'Focus search', + scope: 'library', + handler: () => { + const input = _activeSearchInput(); + if (input) input.focus(); + } +});