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:
+3
-18
@@ -13,7 +13,6 @@ import '@components/genres-view/genres-view.ts';
|
|||||||
import '@components/genre-details/genre-details.ts';
|
import '@components/genre-details/genre-details.ts';
|
||||||
import '@components/search-bar/search-bar.ts';
|
import '@components/search-bar/search-bar.ts';
|
||||||
import '@components/track-details/track-details.ts';
|
import '@components/track-details/track-details.ts';
|
||||||
import type { SearchBar } from '@components/search-bar/search-bar.ts';
|
|
||||||
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
import '@awesome.me/webawesome/dist/styles/themes/default.css';
|
||||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||||
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
|
||||||
@@ -24,6 +23,9 @@ import * as Queue from '@go/queue/Queue';
|
|||||||
// Importing the theme store triggers initialization: it fetches the saved
|
// Importing the theme store triggers initialization: it fetches the saved
|
||||||
// theme from the backend and applies CSS custom properties to :root.
|
// theme from the backend and applies CSS custom properties to :root.
|
||||||
import '@store/theme-store';
|
import '@store/theme-store';
|
||||||
|
// Importing the keyboard shortcut service triggers initialization:
|
||||||
|
// registers the document keydown listener for global shortcuts.
|
||||||
|
import './src/services/keyboard-shortcut-service';
|
||||||
import {
|
import {
|
||||||
hasTrackPayload,
|
hasTrackPayload,
|
||||||
getDragPayload,
|
getDragPayload,
|
||||||
@@ -150,23 +152,6 @@ if (queueButton && queuePanel) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
// Ctrl+F to focus the search bar
|
|
||||||
// ---------------------------------------------------------------
|
|
||||||
|
|
||||||
document.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
|
|
||||||
const bar = document.querySelector(
|
|
||||||
'search-bar',
|
|
||||||
) as SearchBar | null;
|
|
||||||
|
|
||||||
if (bar && !bar.hasAttribute('hidden')) {
|
|
||||||
e.preventDefault();
|
|
||||||
bar.focusInput();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
// Request current state from the backend
|
// Request current state from the backend
|
||||||
// ---------------------------------------------------------------
|
// ---------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
/**
|
||||||
|
* 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 { 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<string, string> = {
|
||||||
|
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';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the current shortcut scope based on the focused element.
|
||||||
|
*
|
||||||
|
* Priority: text-input > panel-specific > global
|
||||||
|
*/
|
||||||
|
function resolveScope(deepEl: Element | null): ShortcutScope {
|
||||||
|
if (isTextInputFocused(deepEl)) return 'text-input';
|
||||||
|
|
||||||
|
// Walk up from the deep active element looking for a
|
||||||
|
// `data-shortcut-scope` attribute on any ancestor.
|
||||||
|
let walker: Element | null = deepEl;
|
||||||
|
|
||||||
|
while (walker) {
|
||||||
|
const scope = walker.getAttribute?.('data-shortcut-scope');
|
||||||
|
|
||||||
|
if (scope) return `panel:${scope}` as ShortcutScope;
|
||||||
|
|
||||||
|
// Cross shadow boundaries: if we're at a shadow root host,
|
||||||
|
// continue walking from the host element.
|
||||||
|
const parent =
|
||||||
|
walker.parentElement ??
|
||||||
|
(walker.getRootNode() as ShadowRoot).host;
|
||||||
|
|
||||||
|
if (parent === walker) break;
|
||||||
|
|
||||||
|
walker = parent ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'global';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// VOLUME / SEEK STEP SIZES
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
const VOLUME_STEP = 5;
|
||||||
|
const SEEK_STEP = 5; // seconds
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// ACTION DISPATCH
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch the action associated with an action ID.
|
||||||
|
*
|
||||||
|
* Each case maps an action string to the appropriate store method
|
||||||
|
* or Wails binding call.
|
||||||
|
*/
|
||||||
|
async function dispatch(action: string): Promise<void> {
|
||||||
|
switch (action) {
|
||||||
|
// Player controls
|
||||||
|
case 'player.playPause':
|
||||||
|
if (playerStore.getState().isPlaying) {
|
||||||
|
playerStore.pause();
|
||||||
|
} else {
|
||||||
|
queueStore.play();
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'player.next':
|
||||||
|
queueStore.next();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'player.previous':
|
||||||
|
queueStore.previous();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'player.volumeUp':
|
||||||
|
await Player.ChangeVolume(VOLUME_STEP);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'player.volumeDown':
|
||||||
|
await Player.ChangeVolume(-VOLUME_STEP);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'player.seekForward': {
|
||||||
|
const pos = await Player.CurrentPositionSeconds();
|
||||||
|
const len = await Player.TrackLengthInSeconds();
|
||||||
|
const target = Math.min(pos + SEEK_STEP, len);
|
||||||
|
|
||||||
|
await Player.Seek(target);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'player.seekBack': {
|
||||||
|
const pos = await Player.CurrentPositionSeconds();
|
||||||
|
const target = Math.max(pos - SEEK_STEP, 0);
|
||||||
|
|
||||||
|
await Player.Seek(target);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'player.shuffle':
|
||||||
|
queueStore.toggleShuffle();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'player.repeat':
|
||||||
|
queueStore.cycleRepeat();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'player.mute':
|
||||||
|
await Player.MuteToggle();
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Navigation
|
||||||
|
case 'nav.search':
|
||||||
|
case 'nav.searchAlt': {
|
||||||
|
const bar = document.querySelector(
|
||||||
|
'search-bar',
|
||||||
|
) as SearchBar | null;
|
||||||
|
|
||||||
|
if (bar && !bar.hasAttribute('hidden')) {
|
||||||
|
bar.focusInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'nav.queue': {
|
||||||
|
const queuePanel = document.getElementById(
|
||||||
|
'queue-panel',
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
|
if (queuePanel) {
|
||||||
|
const isOpen = queuePanel.hasAttribute('open');
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
queuePanel.removeAttribute('open');
|
||||||
|
} else {
|
||||||
|
queuePanel.setAttribute('open', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// App actions
|
||||||
|
case 'app.selectAll':
|
||||||
|
document.execCommand('selectAll');
|
||||||
|
break;
|
||||||
|
|
||||||
|
// Panel-specific: track list
|
||||||
|
case 'tracklist.play':
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent('shortcut:tracklist-play'),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'tracklist.delete':
|
||||||
|
document.dispatchEvent(
|
||||||
|
new CustomEvent('shortcut:tracklist-delete'),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unknown action — silently ignore.
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===================================================================
|
||||||
|
// SERVICE
|
||||||
|
// ===================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KeyboardShortcutService is a singleton that intercepts keydown
|
||||||
|
* events on the document and dispatches matched shortcut actions.
|
||||||
|
*/
|
||||||
|
class KeyboardShortcutService {
|
||||||
|
constructor() {
|
||||||
|
document.addEventListener('keydown', this.handleKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleKeydown = (e: KeyboardEvent): void => {
|
||||||
|
const deepEl = getDeepActiveElement();
|
||||||
|
const scope = resolveScope(deepEl);
|
||||||
|
|
||||||
|
// In text inputs, only allow Escape (to blur the input).
|
||||||
|
if (scope === 'text-input') {
|
||||||
|
if (e.key === 'Escape' && deepEl) {
|
||||||
|
(deepEl as HTMLElement).blur();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Suppress all other shortcuts in text inputs.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the canonical key string.
|
||||||
|
const keyStr = buildKeyString(e);
|
||||||
|
|
||||||
|
if (!keyStr) return;
|
||||||
|
|
||||||
|
// Look up the action: panel-specific first, then global.
|
||||||
|
const action = shortcutsStore.getActionForKey(
|
||||||
|
keyStr,
|
||||||
|
scope,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!action) return;
|
||||||
|
|
||||||
|
// Found a match — prevent default and dispatch.
|
||||||
|
e.preventDefault();
|
||||||
|
void dispatch(action);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Remove the event listener (for cleanup if ever needed). */
|
||||||
|
destroy(): void {
|
||||||
|
document.removeEventListener(
|
||||||
|
'keydown',
|
||||||
|
this.handleKeydown,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance — instantiation registers the keydown listener.
|
||||||
|
export const keyboardShortcutService =
|
||||||
|
new KeyboardShortcutService();
|
||||||
|
|
||||||
|
// Re-export for the shortcut capture widget.
|
||||||
|
export type { KeyboardShortcutService };
|
||||||
@@ -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 { ThemeController } from './controllers/theme-controller';
|
||||||
export { searchStore } from './search-store';
|
export { searchStore } from './search-store';
|
||||||
export { SearchController } from './controllers/search-controller';
|
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