/** * Keyboard Shortcut Service * * Singleton that listens for keydown events on document and dispatches * shortcut actions based on the current scope. Handles: * * - Key string normalization (modifiers in canonical order) * - Shadow DOM active element resolution * - Text input suppression (only Escape passes through) * - Scope resolution: text-input > panel-specific > global * - Action dispatch to player/queue/nav stores */ import { shortcutsStore } from '@store/shortcuts-store'; import { ambientShortcutScope } from './shortcut-scope'; import { playerStore } from '@store/player-store'; import { queueStore } from '@store/queue-store'; import * as Player from '@go/player/Player'; import type { SearchBar } from '@components/search-bar/search-bar'; // =================================================================== // KEY STRING UTILITIES // =================================================================== /** Map of KeyboardEvent.key values to canonical key names. */ const KEY_ALIASES: Record = { ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', ' ': 'Space', }; /** Keys that are modifier-only presses and should be ignored. */ const MODIFIER_KEYS = new Set([ 'Control', 'Alt', 'Shift', 'Meta', ]); /** * Build a canonical key string from a KeyboardEvent. * * Format: `[Ctrl+][Alt+][Shift+]Key` * Examples: "Ctrl+F", "Space", "Shift+Delete", "N" * * Exported for reuse by the shortcut-capture widget (Plan 04). */ export function buildKeyString(e: KeyboardEvent): string { // Skip bare modifier presses. if (MODIFIER_KEYS.has(e.key)) return ''; const parts: string[] = []; // Modifiers in fixed order. Treat Meta (Cmd on Mac) as Ctrl. if (e.ctrlKey || e.metaKey) parts.push('Ctrl'); if (e.altKey) parts.push('Alt'); if (e.shiftKey) parts.push('Shift'); // Normalize the key name. let key = KEY_ALIASES[e.key] ?? e.key; // Single printable characters → uppercase. if (key.length === 1) { key = key.toUpperCase(); } parts.push(key); return parts.join('+'); } // =================================================================== // SHADOW DOM HELPERS // =================================================================== /** * Walk the shadow DOM active element chain to find the deepest * focused element. Necessary because `document.activeElement` * stops at the shadow host boundary. */ function getDeepActiveElement(): Element | null { let el = document.activeElement; while (el?.shadowRoot?.activeElement) { el = el.shadowRoot.activeElement; } return el; } /** Text input types that should suppress shortcuts. */ const TEXT_INPUT_TYPES = new Set([ 'text', 'search', 'url', 'email', 'password', 'number', 'tel', ]); /** * Check whether the deepest active element is a text input. */ function isTextInputFocused(el: Element | null): boolean { if (!el) return false; const tag = el.tagName.toUpperCase(); if (tag === 'TEXTAREA') return true; if (tag === 'INPUT') { const inputType = ( el as HTMLInputElement ).type.toLowerCase(); return TEXT_INPUT_TYPES.has(inputType) || inputType === ''; } if ((el as HTMLElement).isContentEditable) return true; return false; } // =================================================================== // SCOPE RESOLUTION // =================================================================== type ShortcutScope = | 'text-input' | `panel:${string}` | 'global'; /** * Elements that own particular keys themselves. * * The global bindings are unmodified single keys (Space, arrows, letters) * — see Decision 1 in plan 007 — so without this a focused button cannot * be activated with Space and a `