feat(09-02): add frontend keyboard shortcut service, store, and controller

- Create keyboard-shortcut-service.ts singleton with keydown listener
- Shadow DOM active element resolution for scope detection
- Text input suppression (only Escape passes through)
- Scope resolution: text-input > panel-specific > global
- Action dispatch to player/queue stores and Wails bindings
- Create shortcuts-store.ts with Wails persistence and event sync
- Create shortcuts-controller.ts for Lit component integration
- Export store and controller from store/index.ts
- Replace hardcoded Ctrl+F handler in index.ts with service
- Initialize service via import in frontend/index.ts
This commit is contained in:
2026-03-06 21:48:52 -05:00
parent 6285ca9dc4
commit 40d48151dd
5 changed files with 610 additions and 18 deletions
@@ -0,0 +1,60 @@
import type { ReactiveController, ReactiveControllerHost } from 'lit';
import type { ShortcutsState } from '../shortcuts-store';
import { shortcutsStore } from '../shortcuts-store';
/**
* ShortcutsController connects a Lit component to the ShortcutsStore.
*
* Use this controller in components that need to read or change
* shortcut bindings (e.g. the shortcut settings UI in config-page).
*/
export class ShortcutsController implements ReactiveController {
private host: ReactiveControllerHost;
private unsubscribe?: () => void;
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
// ===================================================================
// LIFECYCLE HOOKS
// ===================================================================
hostConnected(): void {
this.unsubscribe = shortcutsStore.subscribe(() => {
this.host.requestUpdate();
});
}
hostDisconnected(): void {
this.unsubscribe?.();
}
// ===================================================================
// STATE ACCESSORS
// ===================================================================
get state(): Readonly<ShortcutsState> {
return shortcutsStore.getState();
}
get bindings(): Map<string, string> {
return shortcutsStore.getBindings();
}
// ===================================================================
// ACTIONS
// ===================================================================
async updateBinding(
action: string,
key: string,
): Promise<void> {
await shortcutsStore.updateBinding(action, key);
}
async resetAll(): Promise<void> {
await shortcutsStore.resetAll();
}
}
+3
View File
@@ -9,3 +9,6 @@ export type { ThemeState, BackgroundShade } from './theme-store';
export { ThemeController } from './controllers/theme-controller';
export { searchStore } from './search-store';
export { SearchController } from './controllers/search-controller';
export { shortcutsStore } from './shortcuts-store';
export type { ShortcutsState } from './shortcuts-store';
export { ShortcutsController } from './controllers/shortcuts-controller';
+189
View File
@@ -0,0 +1,189 @@
import { EventsOn } from '@runtime/runtime';
import {
GetShortcuts,
SetShortcut,
SetShortcuts,
ResetShortcuts,
} from '@go/config/Config';
import { Events } from '../events';
export interface ShortcutsState {
bindings: Map<string, string>; // action → key combo
loaded: boolean;
}
type Subscriber = () => void;
/** Panel scope prefixes for scope-aware lookups. */
const PANEL_PREFIXES = ['tracklist.'] as const;
/**
* ShortcutsStore holds the current keyboard shortcut bindings
* and syncs them with the Go backend via Wails bindings.
*/
class ShortcutsStore {
private state: ShortcutsState = {
bindings: new Map(),
loaded: false,
};
private subscribers = new Set<Subscriber>();
private notifyQueued = false;
constructor() {
this.initializeEventListeners();
this.loadFromBackend();
}
// ===================================================================
// WAILS EVENT BRIDGE
// ===================================================================
private initializeEventListeners(): void {
EventsOn(
Events.ShortcutsConfigChanged,
(data: Record<string, string>) => {
this.state = {
bindings: new Map(Object.entries(data)),
loaded: true,
};
this.notify();
},
);
}
private async loadFromBackend(): Promise<void> {
try {
const raw = await GetShortcuts();
this.state = {
bindings: new Map(Object.entries(raw)),
loaded: true,
};
this.notify();
} catch {
// Use empty bindings on failure; they'll be populated
// when the backend pushes a ShortcutsConfigChanged event.
this.state.loaded = true;
this.notify();
}
}
// ===================================================================
// STATE ACCESS
// ===================================================================
getState(): Readonly<ShortcutsState> {
return this.state;
}
/** Returns the full action→key bindings map. */
getBindings(): Map<string, string> {
return this.state.bindings;
}
/** Look up the key combo for a given action. */
getKeyForAction(action: string): string | undefined {
return this.state.bindings.get(action);
}
/**
* Reverse lookup: find the action bound to a given key combo.
* If a panel scope is provided, panel-specific bindings are checked
* first, then global bindings.
*/
getActionForKey(
key: string,
scope?: string,
): string | undefined {
// If we have a panel scope, check panel-specific bindings first.
if (scope && scope.startsWith('panel:')) {
const panelPrefix = scope.replace('panel:', '') + '.';
for (const [action, boundKey] of this.state.bindings) {
if (
action.startsWith(panelPrefix) &&
boundKey === key
) {
return action;
}
}
}
// Fall back to global (non-panel) bindings.
for (const [action, boundKey] of this.state.bindings) {
if (boundKey !== key) continue;
const isPanel = PANEL_PREFIXES.some((p) =>
action.startsWith(p),
);
if (!isPanel) return action;
}
return undefined;
}
/**
* Check for binding conflicts. Returns the conflicting binding
* or null if no conflict.
*/
findConflict(
key: string,
_scope: string,
excludeAction: string,
): { action: string; key: string } | null {
for (const [action, boundKey] of this.state.bindings) {
if (action === excludeAction) continue;
if (boundKey === key) return { action, key: boundKey };
}
return null;
}
// ===================================================================
// ACTIONS
// ===================================================================
/** Update a single shortcut binding. */
async updateBinding(
action: string,
key: string,
): Promise<void> {
await SetShortcut(action, key);
}
/** Replace all bindings at once. */
async setAll(
bindings: Record<string, string>,
): Promise<void> {
await SetShortcuts(bindings);
}
/** Reset all shortcuts to defaults. */
async resetAll(): Promise<void> {
await ResetShortcuts();
}
// ===================================================================
// SUBSCRIPTION SYSTEM
// ===================================================================
subscribe(callback: Subscriber): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
private notify(): void {
if (this.notifyQueued) return;
this.notifyQueued = true;
queueMicrotask(() => {
this.notifyQueued = false;
this.subscribers.forEach((cb) => cb());
});
}
}
// Singleton instance.
export const shortcutsStore = new ShortcutsStore();