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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user