@tanstack/hotkeys 0.15.1 → 0.16.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { ConflictBehavior, HotkeyCallback, HotkeyChord, HotkeyMeta, HotkeySequence, ParsedHotkey, RegisterableHotkey, RegisterableHotkeyChord } from "./hotkey.types.js";
1
+ import { ConflictBehavior, HotkeyCallback, HotkeyCallbackContextFor, HotkeyChord, HotkeyMeta, HotkeySequence, ParsedHotkeyChord, RegisterableHotkey, RegisterableHotkeyChord, RegisterableHotkeySequence } from "./hotkey.types.js";
2
2
  import { AppHotkeysStore } from "./app-hotkeys.js";
3
3
  import { Store } from "@tanstack/store";
4
4
  //#region src/hotkey-manager.d.ts
@@ -39,14 +39,30 @@ export interface HotkeyOptions {
39
39
  ignoreInputs?: boolean | 'auto';
40
40
  /** The target platform for resolving 'Mod' */
41
41
  platform?: 'mac' | 'windows' | 'linux';
42
- /** Prevent the default browser action when the hotkey matches. Defaults to true */
42
+ /**
43
+ * Prevent the default browser action when the hotkey matches. For a
44
+ * sequence, every step that advances it does. Defaults to true
45
+ */
43
46
  preventDefault?: boolean;
44
- /** Stop event propagation when the hotkey matches. Defaults to true */
47
+ /**
48
+ * Stop event propagation when the hotkey matches. For a sequence, only the
49
+ * last step does, so earlier steps reach other listeners. Defaults to true
50
+ */
45
51
  stopPropagation?: boolean;
46
52
  /** The DOM element to attach the event listener to. Defaults to document. */
47
53
  target?: Target | null;
48
54
  /** Optional metadata (name, description, custom fields via declaration merging) */
49
55
  meta?: HotkeyMeta;
56
+ /**
57
+ * Scope this hotkey belongs to. Defaults to `'global'`, which is always
58
+ * active. Other scopes are active while activated with
59
+ * `HotkeyManager.activateScope`, which framework providers do while
60
+ * mounted. While an exclusive scope is active, only its hotkeys, those of
61
+ * scopes nested in it, and those of scopes it allows fire.
62
+ * When active scopes share a hotkey on the same target, the most recently
63
+ * activated scope handles it.
64
+ */
65
+ scope?: string;
50
66
  /** Id of the app hotkey this registration implements. Adapters set this when registering an app hotkey from `defineAppHotkeys`. */
51
67
  appHotkeyId?: string;
52
68
  }
@@ -64,7 +80,7 @@ export interface HotkeySequenceOptions extends HotkeyOptions {
64
80
  * The options type for a hotkey: chord options for chords (including one-step
65
81
  * tuples), sequence options for sequences.
66
82
  */
67
- export type HotkeyOptionsFor<T extends RegisterableHotkey> = [T] extends [never] ? HotkeyChordOptions & HotkeySequenceOptions : T extends readonly [RegisterableHotkeyChord] ? HotkeyChordOptions : T extends HotkeySequence ? HotkeySequenceOptions : HotkeyChordOptions;
83
+ export type HotkeyOptionsFor<T extends RegisterableHotkey> = [T] extends [never] ? HotkeyChordOptions & HotkeySequenceOptions : T extends readonly [RegisterableHotkeyChord] ? HotkeyChordOptions : T extends RegisterableHotkeySequence ? HotkeySequenceOptions : HotkeyChordOptions;
68
84
  interface RegistrationBase<TOptions extends HotkeyOptions> {
69
85
  /** Unique identifier for this registration */
70
86
  id: string;
@@ -83,7 +99,7 @@ interface RegistrationBase<TOptions extends HotkeyOptions> {
83
99
  export interface HotkeyChordRegistration extends RegistrationBase<HotkeyChordOptions> {
84
100
  kind: 'chord';
85
101
  hotkey: HotkeyChord;
86
- parsedHotkey: ParsedHotkey;
102
+ parsedChord: ParsedHotkeyChord;
87
103
  /** The concrete key/code that activated a requireReset registration. */
88
104
  activeMatch?: {
89
105
  key: string;
@@ -94,7 +110,7 @@ export interface HotkeyChordRegistration extends RegistrationBase<HotkeyChordOpt
94
110
  export interface HotkeySequenceRegistration extends RegistrationBase<HotkeySequenceOptions> {
95
111
  kind: 'sequence';
96
112
  hotkey: HotkeySequence;
97
- parsedSequence: Array<ParsedHotkey>;
113
+ parsedSequence: Array<ParsedHotkeyChord>;
98
114
  /** Steps matched in the current attempt */
99
115
  currentIndex: number;
100
116
  /** `Date.now()` when the last step in the current attempt matched */
@@ -112,14 +128,14 @@ interface RegistrationViewBase {
112
128
  export interface HotkeyChordRegistrationView extends RegistrationViewBase {
113
129
  kind: 'chord';
114
130
  hotkey: HotkeyChord;
115
- parsedHotkey: ParsedHotkey;
131
+ parsedChord: ParsedHotkeyChord;
116
132
  options: HotkeyChordOptions;
117
133
  }
118
134
  /** Public view of a sequence registration, without its callback. */
119
135
  export interface HotkeySequenceRegistrationView extends RegistrationViewBase {
120
136
  kind: 'sequence';
121
137
  hotkey: HotkeySequence;
122
- parsedSequence: Array<ParsedHotkey>;
138
+ parsedSequence: Array<ParsedHotkeyChord>;
123
139
  options: HotkeySequenceOptions;
124
140
  /** Steps matched in the current attempt (0 when idle or just completed). */
125
141
  matchedStepCount: number;
@@ -172,6 +188,44 @@ export interface HotkeyRegistrationHandle<TOptions extends HotkeyOptions = Hotke
172
188
  /** Unregister this hotkey */
173
189
  unregister: () => void;
174
190
  }
191
+ /** Active hotkey scopes, published on `HotkeyManager.scopes`. */
192
+ export interface HotkeyScopesState {
193
+ /**
194
+ * Activated scopes, lowest precedence first. `'global'` is always active
195
+ * and not listed.
196
+ */
197
+ active: ReadonlyArray<string>;
198
+ /** The exclusive scope that owns the keyboard, or `null` when none is active. */
199
+ exclusive: string | null;
200
+ /**
201
+ * Scopes the exclusive scope allows that are active, possibly including
202
+ * `'global'`. Empty when no scope is exclusive.
203
+ */
204
+ allowed: ReadonlyArray<string>;
205
+ /**
206
+ * Scopes that are active but cannot fire because another scope is
207
+ * exclusive, possibly including `'global'`.
208
+ */
209
+ suspended: ReadonlyArray<string>;
210
+ }
211
+ /** Options for `HotkeyManager.activateScope`. */
212
+ export interface ActivateHotkeyScopeOptions {
213
+ /**
214
+ * Suspend every other scope, including `'global'`, until this activation is
215
+ * released. Scopes nested in this one stay active. Pass `{ allow }` to keep
216
+ * other scopes active too, such as `{ allow: ['global'] }`. An allowed scope
217
+ * still has to be activated, except `'global'`.
218
+ */
219
+ exclusive?: boolean | {
220
+ allow: ReadonlyArray<string>;
221
+ };
222
+ /**
223
+ * The scope this one is nested in. A nested scope takes precedence over its
224
+ * ancestors even when it was activated first, as nested providers are when
225
+ * their effects run child-first. Defaults to `'global'`.
226
+ */
227
+ parent?: string;
228
+ }
175
229
  /**
176
230
  * Singleton manager for hotkey registrations, including sequences.
177
231
  *
@@ -225,6 +279,8 @@ export declare class HotkeyManager {
225
279
  * devtools can list declared hotkeys that are not registered.
226
280
  */
227
281
  readonly appHotkeys: Store<AppHotkeysStore | null>;
282
+ /** Active scopes. Subscribe to show which hotkeys are suspended. */
283
+ readonly scopes: Store<HotkeyScopesState>;
228
284
  private constructor();
229
285
  /**
230
286
  * Gets the singleton instance of HotkeyManager.
@@ -234,17 +290,44 @@ export declare class HotkeyManager {
234
290
  * Resets the singleton instance. Useful for testing.
235
291
  */
236
292
  static resetInstance(): void;
293
+ /**
294
+ * Activates a scope until the returned function is called. Activations are
295
+ * counted, so a scope stays active until every activation is released.
296
+ * Framework providers call this while mounted.
297
+ *
298
+ * When active scopes share a hotkey, a nested scope takes precedence over
299
+ * its ancestors, and otherwise the most recently activated scope wins. The
300
+ * same order picks the exclusive scope when several are active, and only
301
+ * that scope's `allow` list applies.
302
+ *
303
+ * @example
304
+ * ```ts
305
+ * const release = manager.activateScope('dialog', { exclusive: true })
306
+ * // Only hotkeys with scope 'dialog', or scopes nested in it, fire now.
307
+ * release()
308
+ *
309
+ * manager.activateScope('dialog', { exclusive: { allow: ['global'] } })
310
+ * // Hotkeys with scope 'global' fire too.
311
+ * ```
312
+ */
313
+ activateScope(scope: string, options?: ActivateHotkeyScopeOptions): () => void;
314
+ /**
315
+ * Whether hotkeys in a scope can fire: the scope is `'global'` or
316
+ * activated, and either no scope is exclusive or this scope is the
317
+ * exclusive one, nested in it, or allowed by it.
318
+ */
319
+ isScopeActive(scope?: string): boolean;
237
320
  /**
238
321
  * Registers a hotkey handler and returns a handle for updating the registration.
239
322
  *
240
- * Pass a chord (`'Mod+S'` or a RawHotkey object) or a sequence (`['G', 'G']`).
323
+ * Pass a chord (`'Mod+S'` or a RawHotkeyChord object) or a sequence (`['G', 'G']`).
241
324
  * A one-step sequence registers as its chord. Chords accept `requireReset`;
242
325
  * sequences accept `timeout`.
243
326
  *
244
327
  * The returned handle allows updating the callback and options without
245
328
  * re-registering, which is useful for avoiding stale closures in React.
246
329
  *
247
- * @param hotkey - A chord string, RawHotkey object, or sequence
330
+ * @param hotkey - A chord string, RawHotkeyChord object, or sequence
248
331
  * @param callback - The function to call when the hotkey is pressed
249
332
  * @param options - Options for the hotkey behavior
250
333
  * @returns A handle for managing the registration
@@ -263,7 +346,7 @@ export declare class HotkeyManager {
263
346
  * handle.unregister()
264
347
  * ```
265
348
  */
266
- register<T extends RegisterableHotkey>(hotkey: T, callback: HotkeyCallback, options?: HotkeyOptionsFor<T>): HotkeyRegistrationHandle<HotkeyOptionsFor<T>>;
349
+ register<const T extends RegisterableHotkey>(hotkey: T, callback: HotkeyCallback<HotkeyCallbackContextFor<T>>, options?: HotkeyOptionsFor<T>): HotkeyRegistrationHandle<HotkeyOptionsFor<T>>;
267
350
  /**
268
351
  * Resets the progress of every in-progress sequence.
269
352
  */
@@ -1,15 +1,15 @@
1
1
  import { normalizeKeyName } from "./constants.js";
2
2
  import { detectPlatform } from "./platform.js";
3
3
  import { keysEqual, normalizeKeyboardEvent } from "./_keyboard-event.js";
4
- import { isHotkeySequence, isModifierKey, parseHotkey, rawHotkeyToParsedHotkey, simplifyHotkey } from "./parse.js";
4
+ import { isHotkeySequence, isModifierKey, parseHotkey, simplifyHotkey } from "./parse.js";
5
5
  import { formatHotkey } from "./format.js";
6
- import { matchKeyboardEvent, parsedHotkeysEqual } from "./_match.js";
6
+ import { matchKeyboardEvent, parsedChordsEqual } from "./_match.js";
7
7
  import { DEFAULT_SEQUENCE_TIMEOUT, areHotkeysEqual } from "./match.js";
8
8
  import { getTargetDocument, isEventForTarget, shouldIgnoreInputEvent } from "./_event-target.js";
9
9
  import { TargetListeners } from "./_target-listeners.js";
10
10
  import { managerRef } from "./_manager-ref.js";
11
11
  import { isRecordingEvent } from "./_recording-guard.js";
12
- import { defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
12
+ import { GLOBAL_SCOPE, defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, optionsEqual } from "./_registration.js";
13
13
  import { Store } from "@tanstack/store";
14
14
 
15
15
  //#region src/hotkey-manager.ts
@@ -25,7 +25,7 @@ function toHotkeyRegistrationView(reg) {
25
25
  ...base,
26
26
  kind: "chord",
27
27
  hotkey: reg.hotkey,
28
- parsedHotkey: reg.parsedHotkey,
28
+ parsedChord: reg.parsedChord,
29
29
  options: reg.options
30
30
  };
31
31
  const length = reg.parsedSequence.length;
@@ -41,6 +41,10 @@ function toHotkeyRegistrationView(reg) {
41
41
  };
42
42
  }
43
43
  let registrationIdCounter = 0;
44
+ /** The string a chord registers as: strings as written, objects formatted from their parsed form. */
45
+ function chordString(chord, parsed) {
46
+ return typeof chord === "string" ? chord : formatHotkey(parsed);
47
+ }
44
48
  /**
45
49
  * Generates a unique ID for hotkey registrations.
46
50
  */
@@ -58,7 +62,7 @@ function optionsForKind(kind, options) {
58
62
  }
59
63
  /** Steps with Mod already resolved for the registration's platform. */
60
64
  function resolvedSteps(registration) {
61
- return registration.kind === "chord" ? [registration.parsedHotkey] : registration.parsedSequence;
65
+ return registration.kind === "chord" ? [registration.parsedChord] : registration.parsedSequence;
62
66
  }
63
67
  /** Whether a registration skips keys that focused controls use, resolving `'auto'`. */
64
68
  function ignoresInputs(registration) {
@@ -66,7 +70,7 @@ function ignoresInputs(registration) {
66
70
  return ignoreInputs === "auto" ? getDefaultIgnoreInputs(resolvedSteps(registration)[0]) : ignoreInputs;
67
71
  }
68
72
  function sameResolvedSteps(left, right) {
69
- return left.length === right.length && left.every((step, index) => parsedHotkeysEqual(step, right[index]));
73
+ return left.length === right.length && left.every((step, index) => parsedChordsEqual(step, right[index]));
70
74
  }
71
75
  function formatForMessage(hotkey) {
72
76
  return isHotkeySequence(hotkey) ? hotkey.join(" ") : hotkey;
@@ -123,12 +127,27 @@ var HotkeyManager = class HotkeyManager {
123
127
  * devtools can list declared hotkeys that are not registered.
124
128
  */
125
129
  appHotkeys = new Store(null);
130
+ /** Active scopes. Subscribe to show which hotkeys are suspended. */
131
+ scopes = new Store({
132
+ active: [],
133
+ exclusive: null,
134
+ allowed: [],
135
+ suspended: []
136
+ });
126
137
  #platform;
138
+ #scopeActivations = /* @__PURE__ */ new Set();
139
+ #scopeActivationCount = 0;
140
+ /** Active scopes, lowest precedence first, derived from activations. */
141
+ #scopeOrder = [];
142
+ /** Scopes that can fire while a scope is exclusive, or `null` when none is. */
143
+ #liveScopes = null;
127
144
  #listeners = new TargetListeners((...args) => this.#processTargetEvent(...args));
128
145
  /** Events that continue a sequence in progress, decided before any listener runs. */
129
146
  #sequenceOwned = /* @__PURE__ */ new WeakMap();
130
147
  /** Physical keys whose keydown a sequence owned, so their repeats and keyup are owned too. */
131
148
  #sequenceOwnedKeys = /* @__PURE__ */ new Set();
149
+ /** Timers that clear a sequence's progress once its timeout passes. */
150
+ #sequenceResetTimers = /* @__PURE__ */ new Map();
132
151
  constructor() {
133
152
  this.#platform = detectPlatform();
134
153
  }
@@ -145,6 +164,114 @@ var HotkeyManager = class HotkeyManager {
145
164
  managerRef.current?.destroy();
146
165
  managerRef.current = null;
147
166
  }
167
+ /**
168
+ * Activates a scope until the returned function is called. Activations are
169
+ * counted, so a scope stays active until every activation is released.
170
+ * Framework providers call this while mounted.
171
+ *
172
+ * When active scopes share a hotkey, a nested scope takes precedence over
173
+ * its ancestors, and otherwise the most recently activated scope wins. The
174
+ * same order picks the exclusive scope when several are active, and only
175
+ * that scope's `allow` list applies.
176
+ *
177
+ * @example
178
+ * ```ts
179
+ * const release = manager.activateScope('dialog', { exclusive: true })
180
+ * // Only hotkeys with scope 'dialog', or scopes nested in it, fire now.
181
+ * release()
182
+ *
183
+ * manager.activateScope('dialog', { exclusive: { allow: ['global'] } })
184
+ * // Hotkeys with scope 'global' fire too.
185
+ * ```
186
+ */
187
+ activateScope(scope, options = {}) {
188
+ if (scope === "global") return () => {};
189
+ const { exclusive } = options;
190
+ const allow = exclusive ? exclusive === true ? [] : exclusive.allow : null;
191
+ if (allow && [...this.#scopeActivations].some((other) => other.allow && other.scope === scope)) console.warn(`Exclusive hotkey scope '${scope}' is already active. Hotkeys from both activations will fire; give each a unique scope.`);
192
+ const activation = {
193
+ scope,
194
+ parent: options.parent ?? "global",
195
+ allow,
196
+ order: ++this.#scopeActivationCount
197
+ };
198
+ this.#scopeActivations.add(activation);
199
+ this.#updateScopes();
200
+ return () => {
201
+ if (this.#scopeActivations.delete(activation)) this.#updateScopes();
202
+ };
203
+ }
204
+ /**
205
+ * Whether hotkeys in a scope can fire: the scope is `'global'` or
206
+ * activated, and either no scope is exclusive or this scope is the
207
+ * exclusive one, nested in it, or allowed by it.
208
+ */
209
+ isScopeActive(scope = GLOBAL_SCOPE) {
210
+ if (this.#liveScopes) return this.#liveScopes.has(scope);
211
+ return scope === "global" || this.#scopeOrder.includes(scope);
212
+ }
213
+ /** Higher for scopes with higher precedence; `'global'` is 0. */
214
+ #scopeRank(scope = GLOBAL_SCOPE) {
215
+ return this.#scopeOrder.indexOf(scope) + 1;
216
+ }
217
+ /** Recomputes scope precedence and publishes it. */
218
+ #updateScopes() {
219
+ const parents = /* @__PURE__ */ new Map();
220
+ const latest = /* @__PURE__ */ new Map();
221
+ for (const activation of this.#scopeActivations) {
222
+ parents.set(activation.scope, activation.parent);
223
+ latest.set(activation.scope, Math.max(latest.get(activation.scope) ?? 0, activation.order));
224
+ }
225
+ const depth = (scope) => {
226
+ let result = 0;
227
+ const seen = /* @__PURE__ */ new Set();
228
+ for (let current = parents.get(scope); current !== void 0 && !seen.has(current); current = parents.get(current)) {
229
+ seen.add(current);
230
+ result++;
231
+ }
232
+ return result;
233
+ };
234
+ const since = /* @__PURE__ */ new Map();
235
+ for (const [scope, order] of latest) {
236
+ const seen = /* @__PURE__ */ new Set();
237
+ for (let current = scope; current !== void 0 && !seen.has(current); current = parents.get(current)) {
238
+ seen.add(current);
239
+ const own = latest.get(current);
240
+ if (own !== void 0) since.set(current, Math.min(since.get(current) ?? own, order));
241
+ }
242
+ }
243
+ const compare = (a, b) => since.get(a) - since.get(b) || depth(a) - depth(b) || latest.get(a) - latest.get(b);
244
+ this.#scopeOrder = [...latest.keys()].sort(compare);
245
+ const exclusive = [...this.#scopeActivations].filter((activation) => activation.allow).sort((a, b) => compare(a.scope, b.scope) || a.order - b.order).pop();
246
+ const activated = [GLOBAL_SCOPE, ...this.#scopeOrder];
247
+ let allowed = [];
248
+ this.#liveScopes = null;
249
+ if (exclusive?.allow) {
250
+ const { allow } = exclusive;
251
+ const isNested = (scope) => {
252
+ const seen = /* @__PURE__ */ new Set();
253
+ for (let current = scope; current !== void 0 && !seen.has(current); current = parents.get(current)) {
254
+ if (current === exclusive.scope) return true;
255
+ seen.add(current);
256
+ }
257
+ return false;
258
+ };
259
+ allowed = activated.filter((scope) => !isNested(scope) && allow.includes(scope));
260
+ this.#liveScopes = /* @__PURE__ */ new Set([...activated.filter(isNested), ...allowed]);
261
+ }
262
+ const live = this.#liveScopes;
263
+ this.scopes.setState(() => ({
264
+ active: this.#scopeOrder,
265
+ exclusive: exclusive?.scope ?? null,
266
+ allowed,
267
+ suspended: live ? activated.filter((scope) => !live.has(scope)) : []
268
+ }));
269
+ this.#notify();
270
+ }
271
+ /** Whether a registration can fire now: enabled, in an active scope. */
272
+ #isLive(registration) {
273
+ return registration.options.enabled !== false && this.isScopeActive(registration.options.scope);
274
+ }
148
275
  /** Notifies subscribers after in-place updates such as counts and sequence progress. */
149
276
  #notify() {
150
277
  this.registrations.setState((prev) => new Map(prev));
@@ -188,21 +315,23 @@ var HotkeyManager = class HotkeyManager {
188
315
  hasFired: false
189
316
  };
190
317
  let registration;
191
- if (sequence) registration = {
192
- ...common,
193
- kind: "sequence",
194
- hotkey,
195
- parsedSequence: hotkey.map((step) => parseHotkey(step, platform)),
196
- currentIndex: 0,
197
- lastKeyTime: 0
198
- };
199
- else {
200
- const parsedHotkey = typeof hotkey === "string" ? parseHotkey(hotkey, platform) : rawHotkeyToParsedHotkey(hotkey, platform);
318
+ if (sequence) {
319
+ const parsedSequence = parseHotkey(hotkey, platform);
320
+ registration = {
321
+ ...common,
322
+ kind: "sequence",
323
+ hotkey: hotkey.map((step, index) => chordString(step, parsedSequence[index])),
324
+ parsedSequence,
325
+ currentIndex: 0,
326
+ lastKeyTime: 0
327
+ };
328
+ } else {
329
+ const parsedChord = parseHotkey(hotkey, platform);
201
330
  registration = {
202
331
  ...common,
203
332
  kind: "chord",
204
- hotkey: typeof hotkey === "string" ? hotkey : formatHotkey(parsedHotkey),
205
- parsedHotkey
333
+ hotkey: chordString(hotkey, parsedChord),
334
+ parsedChord
206
335
  };
207
336
  }
208
337
  const conflicting = this.#findConflictingRegistration(registration);
@@ -262,6 +391,35 @@ var HotkeyManager = class HotkeyManager {
262
391
  return next;
263
392
  });
264
393
  this.#listeners.remove(target, !!registration.options.capture, id);
394
+ this.#clearSequenceReset(id);
395
+ }
396
+ /**
397
+ * Clears a sequence's progress, and notifies subscribers, once its timeout
398
+ * passes without another step. Key presses check the timeout too, so this
399
+ * only keeps views such as `matchedStepCount` current.
400
+ */
401
+ #scheduleSequenceReset(id, delay) {
402
+ this.#clearSequenceReset(id);
403
+ this.#sequenceResetTimers.set(id, setTimeout(() => {
404
+ this.#sequenceResetTimers.delete(id);
405
+ const registration = this.registrations.state.get(id);
406
+ if (registration?.kind !== "sequence" || registration.currentIndex === 0) return;
407
+ const timeout = registration.options.timeout ?? 1e3;
408
+ const remaining = registration.lastKeyTime + timeout - Date.now();
409
+ if (remaining >= 0) {
410
+ this.#scheduleSequenceReset(id, remaining + 1);
411
+ return;
412
+ }
413
+ registration.currentIndex = 0;
414
+ registration.lastKeyTime = 0;
415
+ this.#notify();
416
+ }, delay));
417
+ }
418
+ #clearSequenceReset(id) {
419
+ const timer = this.#sequenceResetTimers.get(id);
420
+ if (timer === void 0) return;
421
+ clearTimeout(timer);
422
+ this.#sequenceResetTimers.delete(id);
265
423
  }
266
424
  /** Registrations of one kind in the listener phase, in registration order. */
267
425
  #registrationsIn(ids, capture, kind) {
@@ -298,16 +456,18 @@ var HotkeyManager = class HotkeyManager {
298
456
  const matches = /* @__PURE__ */ new Map();
299
457
  let bestScore = 0;
300
458
  for (const registration of registrations) {
301
- if (!registration.options.enabled || registration.options.eventType !== eventType || !isEventForTarget(event, target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target)) continue;
302
- const result = matchKeyboardEvent(event, registration.parsedHotkey, registration.options.platform);
459
+ if (!this.#isLive(registration) || registration.options.eventType !== eventType || !isEventForTarget(event, target) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target)) continue;
460
+ const result = matchKeyboardEvent(event, registration.parsedChord, registration.options.platform);
303
461
  if (result.matched) {
304
462
  matches.set(registration.id, result);
305
463
  bestScore = Math.max(bestScore, result.score);
306
464
  }
307
465
  }
466
+ let bestRank = -1;
467
+ for (const registration of registrations) if (matches.get(registration.id)?.score === bestScore) bestRank = Math.max(bestRank, this.#scopeRank(registration.options.scope));
308
468
  for (const snapshot of registrations) {
309
469
  const match = matches.get(snapshot.id);
310
- if (!match?.matched || match.score !== bestScore) continue;
470
+ if (!match?.matched || match.score !== bestScore || this.#scopeRank(snapshot.options.scope) !== bestRank) continue;
311
471
  const registration = this.#currentIn(snapshot, capture);
312
472
  if (!registration) continue;
313
473
  this.#consume(registration, event);
@@ -346,7 +506,7 @@ var HotkeyManager = class HotkeyManager {
346
506
  const path = event.composedPath();
347
507
  const now = Date.now();
348
508
  for (const registration of this.registrations.state.values()) {
349
- if (registration.kind !== "sequence" || registration.currentIndex === 0 || !registration.options.enabled || registration.options.eventType !== eventType || now - registration.lastKeyTime > (registration.options.timeout ?? 1e3)) continue;
509
+ if (registration.kind !== "sequence" || registration.currentIndex === 0 || !this.#isLive(registration) || registration.options.eventType !== eventType || now - registration.lastKeyTime > (registration.options.timeout ?? 1e3)) continue;
350
510
  const target = registration.target;
351
511
  if (path.includes(target.nodeType === 1 ? target : getTargetDocument(target)) && !(ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, target)) && matchKeyboardEvent(event, registration.parsedSequence[registration.currentIndex], registration.options.platform).matched) return true;
352
512
  }
@@ -358,6 +518,7 @@ var HotkeyManager = class HotkeyManager {
358
518
  let changed = false;
359
519
  for (const registration of registrations) if (registration.currentIndex > 0) {
360
520
  registration.currentIndex = 0;
521
+ this.#clearSequenceReset(registration.id);
361
522
  changed = true;
362
523
  }
363
524
  if (changed) this.#notify();
@@ -370,7 +531,7 @@ var HotkeyManager = class HotkeyManager {
370
531
  const candidates = /* @__PURE__ */ new Map();
371
532
  let bestScore = 0;
372
533
  for (const registration of registrations) {
373
- if (!isEventForTarget(event, target) || !registration.options.enabled || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target) || registration.options.eventType !== eventType) continue;
534
+ if (!isEventForTarget(event, target) || !this.#isLive(registration) || ignoresInputs(registration) && shouldIgnoreInputEvent(event, target, registration.target) || registration.options.eventType !== eventType) continue;
374
535
  const timeout = registration.options.timeout ?? 1e3;
375
536
  if (registration.currentIndex > 0 && now - registration.lastKeyTime > timeout) {
376
537
  registration.currentIndex = 0;
@@ -402,12 +563,19 @@ var HotkeyManager = class HotkeyManager {
402
563
  this.#consume(registration, event);
403
564
  registration.currentIndex = 0;
404
565
  completed.push(registration);
405
- }
566
+ } else if (registration.options.preventDefault) event.preventDefault();
406
567
  } else if (registration.currentIndex > 0) {
407
568
  registration.currentIndex = 0;
408
569
  changed = true;
409
570
  }
410
571
  }
572
+ for (const registration of registrations) {
573
+ if (!candidates.has(registration.id)) continue;
574
+ if (registration.currentIndex > 0) {
575
+ const timeout = registration.options.timeout ?? 1e3;
576
+ this.#scheduleSequenceReset(registration.id, timeout + 1);
577
+ } else this.#clearSequenceReset(registration.id);
578
+ }
411
579
  if (changed && completed.length === 0) this.#notify();
412
580
  for (const snapshot of completed) {
413
581
  const registration = this.#currentIn(snapshot, capture);
@@ -427,26 +595,31 @@ var HotkeyManager = class HotkeyManager {
427
595
  registration.callback(event, context);
428
596
  }
429
597
  #callbackContext(registration) {
430
- return {
598
+ return registration.kind === "chord" ? {
599
+ kind: "chord",
600
+ hotkey: registration.hotkey,
601
+ parsedChord: registration.parsedChord
602
+ } : {
603
+ kind: "sequence",
431
604
  hotkey: registration.hotkey,
432
- parsedHotkey: registration.kind === "chord" ? registration.parsedHotkey : registration.parsedSequence[registration.parsedSequence.length - 1]
605
+ parsedSequence: registration.parsedSequence
433
606
  };
434
607
  }
435
608
  /**
436
- * Finds an existing registration on the same target whose steps resolve to
437
- * the same identity. Each side keeps its own platform, so `Mod+S` on macOS
609
+ * Finds an existing registration on the same target and scope whose steps
610
+ * resolve to the same identity. Each side keeps its own platform, so `Mod+S` on macOS
438
611
  * and `Mod+S` on Windows are different bindings.
439
612
  */
440
613
  #findConflictingRegistration(candidate) {
441
614
  const steps = resolvedSteps(candidate);
442
- for (const registration of this.registrations.state.values()) if (registration.target === candidate.target && sameResolvedSteps(resolvedSteps(registration), steps)) return registration;
615
+ for (const registration of this.registrations.state.values()) if (registration.target === candidate.target && registration.options.scope === candidate.options.scope && sameResolvedSteps(resolvedSteps(registration), steps)) return registration;
443
616
  return null;
444
617
  }
445
618
  /**
446
619
  * Determines if a registration should be reset based on the keyup event.
447
620
  */
448
621
  #shouldResetRegistration(registration, event) {
449
- const parsed = registration.parsedHotkey;
622
+ const parsed = registration.parsedChord;
450
623
  const releasedKey = normalizeKeyName(event.key);
451
624
  if (registration.activeMatch?.code && event.code === registration.activeMatch.code) return true;
452
625
  return (parsed.code !== void 0 ? event.code === parsed.code : keysEqual(releasedKey, parsed.key)) || parsed.modifiers.includes(releasedKey);
@@ -459,6 +632,8 @@ var HotkeyManager = class HotkeyManager {
459
632
  registration.currentIndex = 0;
460
633
  registration.lastKeyTime = 0;
461
634
  }
635
+ for (const timer of this.#sequenceResetTimers.values()) clearTimeout(timer);
636
+ this.#sequenceResetTimers.clear();
462
637
  this.#notify();
463
638
  }
464
639
  /**
@@ -473,7 +648,7 @@ var HotkeyManager = class HotkeyManager {
473
648
  const registration = this.registrations.state.get(id);
474
649
  if (!registration) return false;
475
650
  const context = this.#callbackContext(registration);
476
- const parsed = context.parsedHotkey;
651
+ const parsed = registration.kind === "chord" ? registration.parsedChord : registration.parsedSequence[registration.parsedSequence.length - 1];
477
652
  const syntheticEvent = new KeyboardEvent(registration.options.eventType ?? "keydown", {
478
653
  ...parsed.code !== void 0 ? { code: parsed.code } : { key: parsed.key },
479
654
  ctrlKey: parsed.ctrl,
@@ -507,6 +682,8 @@ var HotkeyManager = class HotkeyManager {
507
682
  * Destroys the manager and removes all listeners.
508
683
  */
509
684
  destroy() {
685
+ for (const timer of this.#sequenceResetTimers.values()) clearTimeout(timer);
686
+ this.#sequenceResetTimers.clear();
510
687
  this.#listeners.destroy();
511
688
  this.registrations.setState(() => /* @__PURE__ */ new Map());
512
689
  this.appHotkeys.setState(() => null);
@@ -1,4 +1,4 @@
1
- import { Hotkey, HotkeyChord, HotkeySequence, ParsedHotkey } from "./hotkey.types.js";
1
+ import { Hotkey, HotkeyChord, HotkeySequence, ParsedHotkeyChord } from "./hotkey.types.js";
2
2
  import { HotkeyConflict, HotkeyConflictOptions } from "./conflicts.js";
3
3
  import { Store } from "@tanstack/store";
4
4
  //#region src/hotkey-recorder.d.ts
@@ -21,17 +21,27 @@ export interface RecorderOptions {
21
21
  /** Rejected candidates leave recording active. */
22
22
  onReject?: (rejection: RecorderRejection) => void;
23
23
  }
24
- /** Validation context for a recorded chord or sequence. */
25
- export interface HotkeyRecorderValidationContext {
26
- /** The event that recorded the last chord. */
24
+ /** Validation context for a recorded chord. */
25
+ export interface HotkeyChordRecorderValidationContext {
26
+ kind: 'chord';
27
+ /** The event that recorded the chord. */
27
28
  event: KeyboardEvent;
28
- /** The last chord, parsed. */
29
- parsedHotkey: ParsedHotkey;
29
+ /** The chord with `Mod` resolved for the recorder's platform. */
30
+ parsedChord: ParsedHotkeyChord;
31
+ }
32
+ /** Validation context for a recorded sequence. */
33
+ export interface HotkeySequenceRecorderValidationContext {
34
+ kind: 'sequence';
30
35
  /** The event that recorded each step. */
31
36
  events: ReadonlyArray<KeyboardEvent>;
32
- /** Every step, parsed. */
33
- parsedSequence: ReadonlyArray<ParsedHotkey>;
37
+ /** Each step with `Mod` resolved for the recorder's platform. */
38
+ parsedSequence: Array<ParsedHotkeyChord>;
34
39
  }
40
+ /**
41
+ * Validation context for a recorded chord or sequence. Narrow with `kind` to
42
+ * read `parsedChord` or `parsedSequence`.
43
+ */
44
+ export type HotkeyRecorderValidationContext = HotkeyChordRecorderValidationContext | HotkeySequenceRecorderValidationContext;
35
45
  /**
36
46
  * State interface for the HotkeyRecorder.
37
47
  */
@@ -65,7 +75,7 @@ export interface HotkeyChordRecorderOptions extends HotkeyRecorderBaseOptions {
65
75
  /** Maximum number of chords to record. Default: 1 */
66
76
  maxSteps?: 1;
67
77
  /** Return true to accept, or false/a message to reject while staying in recording mode. */
68
- validate?: (hotkey: HotkeyChord, context: HotkeyRecorderValidationContext) => boolean | string;
78
+ validate?: (hotkey: HotkeyChord, context: HotkeyChordRecorderValidationContext) => boolean | string;
69
79
  /** Callback when a chord is successfully recorded */
70
80
  onRecord: (hotkey: HotkeyChord) => void;
71
81
  }
@@ -113,7 +123,7 @@ export type HotkeyRecorderOptions = HotkeyChordRecorderOptions | HotkeySequenceR
113
123
  * })
114
124
  *
115
125
  * const sequenceRecorder = new HotkeyRecorder({
116
- * maxSteps: 3,
126
+ * maxSteps: Infinity,
117
127
  * onRecord: (hotkey) => {
118
128
  * console.log('Recorded:', hotkey) // 'Mod+S' or ['G', 'H']
119
129
  * },