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(); + } +});