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.
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user