Files
openchamber/packages/ui/src/lib/shortcuts/dispatcher.ts
T
Bohdan Triapitsyn f1c3870909 feat(ui): land the centralized shortcuts core from #2532 with review fixes
The schema/config/bindings/registry/dispatcher module, useKeybind hooks,
recording dialog, reworked shortcuts settings page, help dialog, and the
localized action labels — re-based onto current main rather than merged
(the branch predates 440+ commits including the session-tabs shortcuts).

Review fixes applied on top of the original:
- close_session_tab (alt+w) joins the schema with labels in every locale;
  it shipped on main after the PR's base and would otherwise silently die.
- switch_context_surface's special-case in conflict resolution is now a
  declared prefixStyle config property instead of a magic id string.
- Duplicate handler registration warns in dev builds.
- The risky-browser-shortcut warning inspects every chord and covers
  mod+q/d/h/j/o/u plus mod+shift+w/q.
- The dispatcher remembers which target armed a two-chord prefix so the
  window-level completion handler can distinguish a deliberate sequence
  from typing in an editable field (guard lands with the dispatch hook).
- Schema tests: unique normalized default bindings enforced, and the
  flat-file-era override format proven to keep resolving.
2026-08-26 10:47:44 +03:00

171 lines
5.1 KiB
TypeScript

import {
eventMatchesShortcut,
normalizeCombo,
parseShortcut,
UNASSIGNED_SHORTCUT,
type ShortcutCombo,
} from './bindings';
import { type ShortcutHandler, ShortcutRegistry } from './registry';
import type { ShortcutActionId } from './schema';
import { isIMECompositionEvent } from '../ime';
const SEQUENCE_TIMEOUT_MS = 3000;
const MODIFIER_KEYS = new Set(['alt', 'control', 'meta', 'shift']);
export interface ShortcutDispatcherOptions {
registry: ShortcutRegistry;
getBinding: (actionId: ShortcutActionId) => ShortcutCombo;
now?: () => number;
timeoutMs?: number;
}
interface BindingMatch {
chords: string[];
handler: ShortcutHandler;
}
/** Stateless with respect to the DOM; callers decide whether a consumed event is prevented. */
export class ShortcutDispatcher {
private readonly now: () => number;
private readonly timeoutMs: number;
private prefix: string | undefined;
// The target the leader chord was pressed on. DOM-agnostic (opaque
// EventTarget): callers use it to decide whether an unmodified completion
// key arriving from an EDITABLE target is a deliberate sequence (same
// target as the arming press) or typing that must not be swallowed.
private prefixTarget: EventTarget | null = null;
private expiresAt = 0;
private prefixSuspensionVersion = 0;
private readonly capturedPrefixEvents = new WeakSet<KeyboardEvent>();
constructor(private readonly options: ShortcutDispatcherOptions) {
this.now = options.now ?? Date.now;
this.timeoutMs = options.timeoutMs ?? SEQUENCE_TIMEOUT_MS;
}
dispatch(event: KeyboardEvent): boolean {
if (event.repeat || isIMECompositionEvent(event) || MODIFIER_KEYS.has(event.key.toLowerCase())) {
return false;
}
if (event.key === 'Escape' && this.hasActivePrefix()) {
return this.handleEscape();
}
this.hasActivePrefix();
const matches = this.getMatches();
if (this.prefix) {
const pending = this.getPrefixMatches(matches, event);
if (pending.length > 0) {
this.clear();
return this.invoke(pending, event);
}
this.clear();
}
const singles = matches.filter((match) => (
match.chords.length === 1 && eventMatchesShortcut(event, match.chords[0])
));
if (singles.length > 0 && this.invoke(singles, event)) {
return true;
}
const leader = matches.find((match) => (
match.chords.length === 2 && eventMatchesShortcut(event, match.chords[0])
));
if (leader) {
this.prefix = leader.chords[0];
this.prefixTarget = event.target;
this.expiresAt = this.now() + this.timeoutMs;
this.prefixSuspensionVersion = this.options.registry.getSuspensionVersion();
return true;
}
return false;
}
clear(): void {
this.prefix = undefined;
this.prefixTarget = null;
this.expiresAt = 0;
this.prefixSuspensionVersion = 0;
}
getActivePrefixTarget(): EventTarget | null {
return this.hasActivePrefix() ? this.prefixTarget : null;
}
handleBlur(): void {
this.clear();
}
handleEscape(): boolean {
const hadPrefix = this.hasActivePrefix();
this.clear();
return hadPrefix;
}
hasActivePrefix(): boolean {
if (!this.prefix) return false;
if (
this.now() >= this.expiresAt
|| this.prefixSuspensionVersion !== this.options.registry.getSuspensionVersion()
) {
this.clear();
return false;
}
return true;
}
dispatchActivePrefix(event: KeyboardEvent): boolean {
this.capturedPrefixEvents.add(event);
if (isIMECompositionEvent(event)) {
if (event.repeat || MODIFIER_KEYS.has(event.key.toLowerCase()) || !this.hasActivePrefix()) {
return false;
}
const pending = this.getPrefixMatches(this.getMatches(), event);
this.clear();
return pending.length > 0 ? this.invoke(pending, event) : false;
}
return this.dispatch(event);
}
consumeCapturedPrefixEvent(event: KeyboardEvent): boolean {
if (!this.capturedPrefixEvents.has(event)) return false;
this.capturedPrefixEvents.delete(event);
return true;
}
private invoke(matches: BindingMatch[], event: KeyboardEvent): boolean {
for (const match of matches) {
if (match.handler(event) !== false) {
return true;
}
}
return false;
}
private getPrefixMatches(matches: BindingMatch[], event: KeyboardEvent): BindingMatch[] {
return matches.filter((match) => (
match.chords.length === 2
&& match.chords[0] === this.prefix
&& eventMatchesShortcut(event, match.chords[1])
));
}
private getMatches(): BindingMatch[] {
const matches: BindingMatch[] = [];
for (const actionId of this.options.registry.actionIds()) {
const handler = this.options.registry.get(actionId);
if (!handler) continue;
const binding = normalizeCombo(this.options.getBinding(actionId));
const parsed = parseShortcut(binding);
if (!parsed || parsed.chords.some((chord) => !chord.key || chord.key === UNASSIGNED_SHORTCUT)) {
continue;
}
matches.push({ chords: binding.split(' '), handler });
}
return matches;
}
}