@tanstack/hotkeys 0.4.2 → 0.5.0

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.
@@ -15,17 +15,28 @@ interface SequenceOptions extends Omit<HotkeyOptions, 'requireReset'> {
15
15
  /**
16
16
  * A sequence of hotkeys for Vim-style shortcuts.
17
17
  *
18
+ * Each element is one step (a `Hotkey` string). Steps may include modifiers;
19
+ * the same modifier can appear on consecutive steps (e.g. `Shift+R` then
20
+ * `Shift+T`). Modifier-only key events do not advance or reset matching—see
21
+ * `SequenceManager`.
22
+ *
18
23
  * @example
19
24
  * ```ts
20
25
  * const gotoTop: HotkeySequence = ['G', 'G'] // gg
21
26
  * const deleteLine: HotkeySequence = ['D', 'D'] // dd
22
27
  * const deleteWord: HotkeySequence = ['D', 'I', 'W'] // diw
28
+ * const chainedShift: HotkeySequence = ['Shift+R', 'Shift+T']
23
29
  * ```
24
30
  */
25
31
  type HotkeySequence = Array<Hotkey>;
32
+ /**
33
+ * Default timeout between keys in a sequence (in milliseconds).
34
+ * Exported for consumers that display sequence state (e.g. devtools).
35
+ */
36
+ declare const DEFAULT_SEQUENCE_TIMEOUT = 1000;
26
37
  /**
27
38
  * View of a sequence registration for devtools display.
28
- * Excludes internal matching state (currentIndex, lastKeyTime).
39
+ * Progress fields reflect an in-progress match (between first key and completion or timeout).
29
40
  */
30
41
  interface SequenceRegistrationView {
31
42
  id: string;
@@ -33,6 +44,10 @@ interface SequenceRegistrationView {
33
44
  options: SequenceOptions;
34
45
  target: Target;
35
46
  triggerCount: number;
47
+ /** Steps matched in the current attempt (0 when idle or just completed). */
48
+ matchedStepCount: number;
49
+ /** `Date.now()` when the last step in the current attempt matched; 0 if none. */
50
+ partialMatchLastKeyTime: number;
36
51
  }
37
52
  /**
38
53
  * A handle returned from SequenceManager.register() that allows updating
@@ -136,5 +151,5 @@ declare function createSequenceMatcher(sequence: HotkeySequence, options?: {
136
151
  getProgress: () => number;
137
152
  };
138
153
  //#endregion
139
- export { HotkeySequence, SequenceManager, SequenceOptions, SequenceRegistrationHandle, SequenceRegistrationView, createSequenceMatcher, getSequenceManager };
154
+ export { DEFAULT_SEQUENCE_TIMEOUT, HotkeySequence, SequenceManager, SequenceOptions, SequenceRegistrationHandle, SequenceRegistrationView, createSequenceMatcher, getSequenceManager };
140
155
  //# sourceMappingURL=sequence-manager.d.ts.map
@@ -1,5 +1,5 @@
1
1
  import { detectPlatform } from "./constants.js";
2
- import { parseHotkey } from "./parse.js";
2
+ import { isModifierKey, parseHotkey } from "./parse.js";
3
3
  import { formatHotkeySequence } from "./format.js";
4
4
  import { matchesKeyboardEvent } from "./match.js";
5
5
  import { defaultHotkeyOptions, getDefaultIgnoreInputs, handleConflict, isEventForTarget, isInputElement } from "./manager.utils.js";
@@ -8,6 +8,7 @@ import { Store } from "@tanstack/store";
8
8
  //#region src/sequence-manager.ts
9
9
  /**
10
10
  * Default timeout between keys in a sequence (in milliseconds).
11
+ * Exported for consumers that display sequence state (e.g. devtools).
11
12
  */
12
13
  const DEFAULT_SEQUENCE_TIMEOUT = 1e3;
13
14
  let sequenceIdCounter = 0;
@@ -30,6 +31,11 @@ function sequenceKey(sequence) {
30
31
  * that trigger callbacks when the full sequence is pressed within
31
32
  * a configurable timeout.
32
33
  *
34
+ * Modifier-only `keydown` / `keyup` events (Shift, Control, Alt, or Meta
35
+ * alone) are not processed: they neither match a step nor reset progress.
36
+ * That supports chained modifier chords (e.g. `Shift+R` then `Shift+T`) when
37
+ * the user presses a modifier alone between steps.
38
+ *
33
39
  * @example
34
40
  * ```ts
35
41
  * const matcher = SequenceManager.getInstance()
@@ -47,12 +53,16 @@ function sequenceKey(sequence) {
47
53
  * Builds a devtools view from an internal registration.
48
54
  */
49
55
  function toRegistrationView(reg) {
56
+ const len = reg.parsedSequence.length;
57
+ const inProgress = reg.currentIndex > 0 && reg.currentIndex < len;
50
58
  return {
51
59
  id: reg.id,
52
60
  sequence: reg.sequence,
53
61
  options: reg.options,
54
62
  target: reg.target,
55
- triggerCount: reg.triggerCount
63
+ triggerCount: reg.triggerCount,
64
+ matchedStepCount: inProgress ? reg.currentIndex : 0,
65
+ partialMatchLastKeyTime: inProgress ? reg.lastKeyTime : 0
56
66
  };
57
67
  }
58
68
  var SequenceManager = class SequenceManager {
@@ -81,6 +91,9 @@ var SequenceManager = class SequenceManager {
81
91
  SequenceManager.#instance = null;
82
92
  }
83
93
  }
94
+ #syncRegistrationView(registration) {
95
+ this.registrations.setState((prev) => new Map(prev).set(registration.id, toRegistrationView(registration)));
96
+ }
84
97
  /**
85
98
  * Registers a hotkey sequence handler.
86
99
  *
@@ -218,6 +231,7 @@ var SequenceManager = class SequenceManager {
218
231
  * Processes keyboard events for a specific target and event type.
219
232
  */
220
233
  #processTargetEvent(event, target, eventType) {
234
+ if (isModifierKey(event)) return;
221
235
  const targetRegs = this.#targetRegistrations.get(target);
222
236
  if (!targetRegs) return;
223
237
  const now = Date.now();
@@ -232,8 +246,11 @@ var SequenceManager = class SequenceManager {
232
246
  }
233
247
  }
234
248
  if (registration.options.eventType !== eventType) continue;
235
- const timeout = registration.options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT;
236
- if (registration.currentIndex > 0 && now - registration.lastKeyTime > timeout) registration.currentIndex = 0;
249
+ const timeout = registration.options.timeout ?? 1e3;
250
+ if (registration.currentIndex > 0 && now - registration.lastKeyTime > timeout) {
251
+ registration.currentIndex = 0;
252
+ this.#syncRegistrationView(registration);
253
+ }
237
254
  const expectedHotkey = registration.parsedSequence[registration.currentIndex];
238
255
  if (!expectedHotkey) continue;
239
256
  if (matchesKeyboardEvent(event, expectedHotkey, registration.options.platform)) {
@@ -246,15 +263,18 @@ var SequenceManager = class SequenceManager {
246
263
  hotkey: registration.sequence.join(" "),
247
264
  parsedHotkey: registration.parsedSequence[registration.parsedSequence.length - 1]
248
265
  };
249
- registration.callback(event, context);
266
+ registration.triggerCount++;
250
267
  registration.currentIndex = 0;
251
- }
268
+ this.#syncRegistrationView(registration);
269
+ registration.callback(event, context);
270
+ } else this.#syncRegistrationView(registration);
252
271
  } else if (registration.currentIndex > 0) {
253
272
  const firstHotkey = registration.parsedSequence[0];
254
273
  if (matchesKeyboardEvent(event, firstHotkey, registration.options.platform)) {
255
274
  registration.currentIndex = 1;
256
275
  registration.lastKeyTime = now;
257
276
  } else registration.currentIndex = 0;
277
+ this.#syncRegistrationView(registration);
258
278
  }
259
279
  }
260
280
  }
@@ -273,6 +293,7 @@ var SequenceManager = class SequenceManager {
273
293
  for (const registration of this.#registrations.values()) {
274
294
  registration.currentIndex = 0;
275
295
  registration.lastKeyTime = 0;
296
+ this.#syncRegistrationView(registration);
276
297
  }
277
298
  }
278
299
  /**
@@ -297,7 +318,7 @@ var SequenceManager = class SequenceManager {
297
318
  cancelable: true
298
319
  });
299
320
  registration.triggerCount++;
300
- this.registrations.setState((prev) => new Map(prev).set(id, toRegistrationView(registration)));
321
+ this.#syncRegistrationView(registration);
301
322
  const context = {
302
323
  hotkey: registration.sequence.join(" "),
303
324
  parsedHotkey: lastParsed
@@ -351,7 +372,7 @@ function getSequenceManager() {
351
372
  */
352
373
  function createSequenceMatcher(sequence, options = {}) {
353
374
  const platform = options.platform ?? detectPlatform();
354
- const timeout = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT;
375
+ const timeout = options.timeout ?? 1e3;
355
376
  const parsedSequence = sequence.map((hotkey) => parseHotkey(hotkey, platform));
356
377
  let currentIndex = 0;
357
378
  let lastKeyTime = 0;
@@ -385,5 +406,5 @@ function createSequenceMatcher(sequence, options = {}) {
385
406
  }
386
407
 
387
408
  //#endregion
388
- export { SequenceManager, createSequenceMatcher, getSequenceManager };
409
+ export { DEFAULT_SEQUENCE_TIMEOUT, SequenceManager, createSequenceMatcher, getSequenceManager };
389
410
  //# sourceMappingURL=sequence-manager.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"sequence-manager.js","names":["#instance","#platform","#findConflictingSequence","#unregister","#registrations","#targetRegistrations","#ensureListenersForTarget","#removeListenersForTarget","#targetListeners","#createTargetKeyDownHandler","#createTargetKeyUpHandler","#processTargetEvent"],"sources":["../src/sequence-manager.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { formatHotkeySequence } from './format'\nimport { detectPlatform } from './constants'\nimport { parseHotkey } from './parse'\nimport { matchesKeyboardEvent } from './match'\nimport {\n defaultHotkeyOptions,\n getDefaultIgnoreInputs,\n handleConflict,\n isEventForTarget,\n isInputElement,\n} from './manager.utils'\nimport type { HotkeyOptions } from './hotkey-manager'\nimport type {\n Hotkey,\n HotkeyCallback,\n HotkeyCallbackContext,\n ParsedHotkey,\n} from './hotkey'\n\ntype Target = HTMLElement | Document | Window\n\n/**\n * Options for hotkey sequence matching.\n * Extends HotkeyOptions but excludes requireReset (not applicable to sequences).\n */\nexport interface SequenceOptions extends Omit<HotkeyOptions, 'requireReset'> {\n /** Timeout between keys in milliseconds. Default: 1000 */\n timeout?: number\n}\n\n/**\n * A sequence of hotkeys for Vim-style shortcuts.\n *\n * @example\n * ```ts\n * const gotoTop: HotkeySequence = ['G', 'G'] // gg\n * const deleteLine: HotkeySequence = ['D', 'D'] // dd\n * const deleteWord: HotkeySequence = ['D', 'I', 'W'] // diw\n * ```\n */\nexport type HotkeySequence = Array<Hotkey>\n\n/**\n * Default timeout between keys in a sequence (in milliseconds).\n */\nconst DEFAULT_SEQUENCE_TIMEOUT = 1000\n\nlet sequenceIdCounter = 0\n\n/**\n * Generates a unique ID for sequence registrations.\n */\nfunction generateSequenceId(): string {\n return `sequence_${++sequenceIdCounter}`\n}\n\n/**\n * Returns a canonical string for sequence conflict comparison.\n */\nfunction sequenceKey(sequence: HotkeySequence): string {\n return sequence.join('|')\n}\n\n/**\n * View of a sequence registration for devtools display.\n * Excludes internal matching state (currentIndex, lastKeyTime).\n */\nexport interface SequenceRegistrationView {\n id: string\n sequence: HotkeySequence\n options: SequenceOptions\n target: Target\n triggerCount: number\n}\n\n/**\n * Internal representation of a sequence registration.\n */\ninterface SequenceRegistration {\n id: string\n sequence: HotkeySequence\n parsedSequence: Array<ParsedHotkey>\n callback: HotkeyCallback\n options: SequenceOptions\n target: Target\n currentIndex: number\n lastKeyTime: number\n triggerCount: number\n}\n\n/**\n * A handle returned from SequenceManager.register() that allows updating\n * the callback and options without re-registering the sequence.\n *\n * @example\n * ```ts\n * const handle = manager.register(['G', 'G'], callback, options)\n *\n * handle.callback = newCallback\n * handle.setOptions({ timeout: 500 })\n * handle.unregister()\n * ```\n */\nexport interface SequenceRegistrationHandle {\n readonly id: string\n readonly isActive: boolean\n callback: HotkeyCallback\n setOptions: (options: Partial<SequenceOptions>) => void\n unregister: () => void\n}\n\n/**\n * Manages keyboard sequence matching for Vim-style shortcuts.\n *\n * This class allows registering multi-key sequences like 'g g' or 'd d'\n * that trigger callbacks when the full sequence is pressed within\n * a configurable timeout.\n *\n * @example\n * ```ts\n * const matcher = SequenceManager.getInstance()\n *\n * // Register 'g g' to go to top\n * const unregister = matcher.register(['G', 'G'], (event, context) => {\n * scrollToTop()\n * }, { timeout: 500 })\n *\n * // Later, to unregister:\n * unregister()\n * ```\n */\n/**\n * Builds a devtools view from an internal registration.\n */\nfunction toRegistrationView(\n reg: SequenceRegistration,\n): SequenceRegistrationView {\n return {\n id: reg.id,\n sequence: reg.sequence,\n options: reg.options,\n target: reg.target,\n triggerCount: reg.triggerCount,\n }\n}\n\nexport class SequenceManager {\n static #instance: SequenceManager | null = null\n\n /**\n * The TanStack Store containing sequence registration views for devtools.\n * Subscribe to this to observe registration changes.\n */\n readonly registrations: Store<Map<string, SequenceRegistrationView>> =\n new Store(new Map())\n\n #registrations: Map<string, SequenceRegistration> = new Map()\n #targetListeners: Map<\n Target,\n {\n keydown: (event: KeyboardEvent) => void\n keyup: (event: KeyboardEvent) => void\n }\n > = new Map()\n #targetRegistrations: Map<Target, Set<string>> = new Map()\n #platform: 'mac' | 'windows' | 'linux'\n\n private constructor() {\n this.#platform = detectPlatform()\n }\n\n /**\n * Gets the singleton instance of SequenceManager.\n */\n static getInstance(): SequenceManager {\n if (!SequenceManager.#instance) {\n SequenceManager.#instance = new SequenceManager()\n }\n return SequenceManager.#instance\n }\n\n /**\n * Resets the singleton instance. Useful for testing.\n */\n static resetInstance(): void {\n if (SequenceManager.#instance) {\n SequenceManager.#instance.destroy()\n SequenceManager.#instance = null\n }\n }\n\n /**\n * Registers a hotkey sequence handler.\n *\n * @param sequence - Array of hotkey strings that form the sequence\n * @param callback - Function to call when the sequence is completed\n * @param options - Options for the sequence behavior\n * @returns A handle to update or unregister the sequence\n */\n register(\n sequence: HotkeySequence,\n callback: HotkeyCallback,\n options: SequenceOptions = {},\n ): SequenceRegistrationHandle {\n if (sequence.length === 0) {\n throw new Error('Sequence must contain at least one hotkey')\n }\n\n const id = generateSequenceId()\n const platform = options.platform ?? this.#platform\n const parsedSequence = sequence.map((hotkey) =>\n parseHotkey(hotkey, platform),\n )\n\n // Resolve target: default to document if not provided or null\n const target: Target =\n options.target ??\n (typeof document !== 'undefined' ? document : ({} as Document))\n\n // Resolve conflict behavior\n const conflictBehavior = options.conflictBehavior ?? 'warn'\n\n // Check for existing registrations with the same sequence and target\n const conflictingRegistration = this.#findConflictingSequence(\n sequence,\n target,\n )\n\n if (conflictingRegistration) {\n handleConflict(\n conflictingRegistration.id,\n formatHotkeySequence(sequence),\n conflictBehavior,\n (regId) => this.#unregister(regId),\n )\n }\n\n const firstStep = parsedSequence[0]!\n const resolvedIgnoreInputs =\n options.ignoreInputs ?? getDefaultIgnoreInputs(firstStep)\n\n const baseOptions = {\n ...defaultHotkeyOptions,\n timeout: DEFAULT_SEQUENCE_TIMEOUT,\n ...options,\n platform,\n ignoreInputs: resolvedIgnoreInputs,\n }\n\n const registration: SequenceRegistration = {\n id,\n sequence,\n parsedSequence,\n callback,\n options: baseOptions,\n target,\n currentIndex: 0,\n lastKeyTime: 0,\n triggerCount: 0,\n }\n\n this.#registrations.set(id, registration)\n this.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(registration)),\n )\n\n // Track registration for this target\n if (!this.#targetRegistrations.has(target)) {\n this.#targetRegistrations.set(target, new Set())\n }\n this.#targetRegistrations.get(target)!.add(id)\n\n // Ensure listeners are attached for this target\n this.#ensureListenersForTarget(target)\n\n const manager = this\n const handle: SequenceRegistrationHandle = {\n get id() {\n return id\n },\n get isActive() {\n return manager.#registrations.has(id)\n },\n get callback() {\n const reg = manager.#registrations.get(id)\n return reg?.callback ?? callback\n },\n set callback(newCallback: HotkeyCallback) {\n const reg = manager.#registrations.get(id)\n if (reg) {\n reg.callback = newCallback\n }\n },\n setOptions: (newOptions: Partial<SequenceOptions>) => {\n const reg = manager.#registrations.get(id)\n if (reg) {\n reg.options = { ...reg.options, ...newOptions }\n manager.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(reg)),\n )\n }\n },\n unregister: () => {\n manager.#unregister(id)\n },\n }\n\n return handle\n }\n\n /**\n * Unregisters a sequence by its registration ID.\n */\n #unregister(id: string): void {\n const registration = this.#registrations.get(id)\n if (!registration) {\n return\n }\n\n const target = registration.target\n\n this.#registrations.delete(id)\n this.registrations.setState((prev) => {\n const next = new Map(prev)\n next.delete(id)\n return next\n })\n\n // Remove from target registrations tracking\n const targetRegs = this.#targetRegistrations.get(target)\n if (targetRegs) {\n targetRegs.delete(id)\n if (targetRegs.size === 0) {\n this.#removeListenersForTarget(target)\n }\n }\n }\n\n /**\n * Ensures event listeners are attached for a specific target.\n */\n #ensureListenersForTarget(target: Target): void {\n if (typeof document === 'undefined') {\n return // SSR safety\n }\n\n if (this.#targetListeners.has(target)) {\n return\n }\n\n const keydownHandler = this.#createTargetKeyDownHandler(target)\n const keyupHandler = this.#createTargetKeyUpHandler(target)\n\n target.addEventListener('keydown', keydownHandler as EventListener)\n target.addEventListener('keyup', keyupHandler as EventListener)\n\n this.#targetListeners.set(target, {\n keydown: keydownHandler,\n keyup: keyupHandler,\n })\n }\n\n /**\n * Removes event listeners for a specific target.\n */\n #removeListenersForTarget(target: Target): void {\n if (typeof document === 'undefined') {\n return\n }\n\n const listeners = this.#targetListeners.get(target)\n if (!listeners) {\n return\n }\n\n target.removeEventListener('keydown', listeners.keydown as EventListener)\n target.removeEventListener('keyup', listeners.keyup as EventListener)\n\n this.#targetListeners.delete(target)\n this.#targetRegistrations.delete(target)\n }\n\n /**\n * Creates a keydown handler for a specific target.\n */\n #createTargetKeyDownHandler(target: Target): (event: KeyboardEvent) => void {\n return (event: KeyboardEvent) => {\n this.#processTargetEvent(event, target, 'keydown')\n }\n }\n\n /**\n * Creates a keyup handler for a specific target.\n */\n #createTargetKeyUpHandler(target: Target): (event: KeyboardEvent) => void {\n return (event: KeyboardEvent) => {\n this.#processTargetEvent(event, target, 'keyup')\n }\n }\n\n /**\n * Processes keyboard events for a specific target and event type.\n */\n #processTargetEvent(\n event: KeyboardEvent,\n target: Target,\n eventType: 'keydown' | 'keyup',\n ): void {\n const targetRegs = this.#targetRegistrations.get(target)\n if (!targetRegs) {\n return\n }\n\n const now = Date.now()\n\n for (const id of targetRegs) {\n const registration = this.#registrations.get(id)\n if (!registration) {\n continue\n }\n\n if (!isEventForTarget(event, target)) {\n continue\n }\n\n if (!registration.options.enabled) {\n continue\n }\n\n // Check if we should ignore input elements (defaults to true)\n if (registration.options.ignoreInputs !== false) {\n if (isInputElement(event.target)) {\n // Don't ignore if the sequence is explicitly scoped to this input element\n if (event.target !== registration.target) {\n continue\n }\n }\n }\n\n // Only process registrations that listen for this event type\n if (registration.options.eventType !== eventType) {\n continue\n }\n\n const timeout = registration.options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT\n\n // Check if sequence has timed out\n if (\n registration.currentIndex > 0 &&\n now - registration.lastKeyTime > timeout\n ) {\n registration.currentIndex = 0\n }\n\n const expectedHotkey =\n registration.parsedSequence[registration.currentIndex]\n if (!expectedHotkey) {\n continue\n }\n\n if (\n matchesKeyboardEvent(\n event,\n expectedHotkey,\n registration.options.platform,\n )\n ) {\n registration.lastKeyTime = now\n registration.currentIndex++\n\n if (registration.currentIndex >= registration.parsedSequence.length) {\n if (registration.options.preventDefault) {\n event.preventDefault()\n }\n if (registration.options.stopPropagation) {\n event.stopPropagation()\n }\n\n const context: HotkeyCallbackContext = {\n hotkey: registration.sequence.join(' ') as Hotkey,\n parsedHotkey:\n registration.parsedSequence[\n registration.parsedSequence.length - 1\n ]!,\n }\n\n registration.callback(event, context)\n\n registration.currentIndex = 0\n }\n } else if (registration.currentIndex > 0) {\n const firstHotkey = registration.parsedSequence[0]!\n if (\n matchesKeyboardEvent(\n event,\n firstHotkey,\n registration.options.platform,\n )\n ) {\n registration.currentIndex = 1\n registration.lastKeyTime = now\n } else {\n registration.currentIndex = 0\n }\n }\n }\n }\n\n /**\n * Finds an existing registration with the same sequence and target.\n */\n #findConflictingSequence(\n sequence: HotkeySequence,\n target: Target,\n ): SequenceRegistration | null {\n const key = sequenceKey(sequence)\n for (const registration of this.#registrations.values()) {\n if (\n sequenceKey(registration.sequence) === key &&\n registration.target === target\n ) {\n return registration\n }\n }\n return null\n }\n\n /**\n * Resets all sequence progress.\n */\n resetAll(): void {\n for (const registration of this.#registrations.values()) {\n registration.currentIndex = 0\n registration.lastKeyTime = 0\n }\n }\n\n /**\n * Triggers a sequence's callback programmatically from devtools.\n * Creates a synthetic KeyboardEvent from the last key in the sequence.\n *\n * @param id - The registration ID to trigger\n * @returns True if the registration was found and triggered\n */\n triggerSequence(id: string): boolean {\n const registration = this.#registrations.get(id)\n if (!registration) {\n return false\n }\n\n const lastParsed =\n registration.parsedSequence[registration.parsedSequence.length - 1]\n if (!lastParsed) {\n return false\n }\n\n const syntheticEvent = new KeyboardEvent(\n registration.options.eventType ?? 'keydown',\n {\n key: lastParsed.key,\n ctrlKey: lastParsed.ctrl,\n shiftKey: lastParsed.shift,\n altKey: lastParsed.alt,\n metaKey: lastParsed.meta,\n bubbles: true,\n cancelable: true,\n },\n )\n\n registration.triggerCount++\n\n this.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(registration)),\n )\n\n const context: HotkeyCallbackContext = {\n hotkey: registration.sequence.join(' ') as Hotkey,\n parsedHotkey: lastParsed,\n }\n\n registration.callback(syntheticEvent, context)\n\n return true\n }\n\n /**\n * Gets the number of registered sequences.\n */\n getRegistrationCount(): number {\n return this.#registrations.size\n }\n\n /**\n * Destroys the manager and removes all listeners.\n */\n destroy(): void {\n for (const target of this.#targetListeners.keys()) {\n this.#removeListenersForTarget(target)\n }\n this.#registrations.clear()\n this.registrations.setState(() => new Map())\n }\n}\n\n/**\n * Gets the singleton SequenceManager instance.\n * Convenience function for accessing the manager.\n */\nexport function getSequenceManager(): SequenceManager {\n return SequenceManager.getInstance()\n}\n\n/**\n * Creates a simple sequence matcher for one-off use.\n *\n * This is a low-level helper that does not support ignoreInputs, target,\n * or other HotkeyOptions. Callers must handle input filtering themselves\n * if attaching to document.\n *\n * @param sequence - The sequence of hotkeys to match\n * @param options - Options including timeout\n * @returns An object with match() and reset() methods\n *\n * @example\n * ```ts\n * const matcher = createSequenceMatcher(['G', 'G'], { timeout: 500 })\n *\n * document.addEventListener('keydown', (event) => {\n * if (matcher.match(event)) {\n * console.log('Sequence matched!')\n * }\n * })\n * ```\n */\nexport function createSequenceMatcher(\n sequence: HotkeySequence,\n options: { timeout?: number; platform?: 'mac' | 'windows' | 'linux' } = {},\n): {\n match: (event: KeyboardEvent) => boolean\n reset: () => void\n getProgress: () => number\n} {\n const platform = options.platform ?? detectPlatform()\n const timeout = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT\n const parsedSequence = sequence.map((hotkey) => parseHotkey(hotkey, platform))\n\n let currentIndex = 0\n let lastKeyTime = 0\n\n return {\n match(event: KeyboardEvent): boolean {\n const now = Date.now()\n\n if (currentIndex > 0 && now - lastKeyTime > timeout) {\n currentIndex = 0\n }\n\n const expected = parsedSequence[currentIndex]\n if (!expected) {\n return false\n }\n\n if (matchesKeyboardEvent(event, expected, platform)) {\n lastKeyTime = now\n currentIndex++\n\n if (currentIndex >= parsedSequence.length) {\n currentIndex = 0\n return true\n }\n } else if (currentIndex > 0) {\n if (matchesKeyboardEvent(event, parsedSequence[0]!, platform)) {\n currentIndex = 1\n lastKeyTime = now\n } else {\n currentIndex = 0\n }\n }\n\n return false\n },\n\n reset(): void {\n currentIndex = 0\n lastKeyTime = 0\n },\n\n getProgress(): number {\n return currentIndex\n },\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"sequence-manager.js","names":["#instance","#platform","#findConflictingSequence","#unregister","#registrations","#targetRegistrations","#ensureListenersForTarget","#removeListenersForTarget","#targetListeners","#createTargetKeyDownHandler","#createTargetKeyUpHandler","#processTargetEvent","#syncRegistrationView"],"sources":["../src/sequence-manager.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { formatHotkeySequence } from './format'\nimport { detectPlatform } from './constants'\nimport { isModifierKey, parseHotkey } from './parse'\nimport { matchesKeyboardEvent } from './match'\nimport {\n defaultHotkeyOptions,\n getDefaultIgnoreInputs,\n handleConflict,\n isEventForTarget,\n isInputElement,\n} from './manager.utils'\nimport type { HotkeyOptions } from './hotkey-manager'\nimport type {\n Hotkey,\n HotkeyCallback,\n HotkeyCallbackContext,\n ParsedHotkey,\n} from './hotkey'\n\ntype Target = HTMLElement | Document | Window\n\n/**\n * Options for hotkey sequence matching.\n * Extends HotkeyOptions but excludes requireReset (not applicable to sequences).\n */\nexport interface SequenceOptions extends Omit<HotkeyOptions, 'requireReset'> {\n /** Timeout between keys in milliseconds. Default: 1000 */\n timeout?: number\n}\n\n/**\n * A sequence of hotkeys for Vim-style shortcuts.\n *\n * Each element is one step (a `Hotkey` string). Steps may include modifiers;\n * the same modifier can appear on consecutive steps (e.g. `Shift+R` then\n * `Shift+T`). Modifier-only key events do not advance or reset matching—see\n * `SequenceManager`.\n *\n * @example\n * ```ts\n * const gotoTop: HotkeySequence = ['G', 'G'] // gg\n * const deleteLine: HotkeySequence = ['D', 'D'] // dd\n * const deleteWord: HotkeySequence = ['D', 'I', 'W'] // diw\n * const chainedShift: HotkeySequence = ['Shift+R', 'Shift+T']\n * ```\n */\nexport type HotkeySequence = Array<Hotkey>\n\n/**\n * Default timeout between keys in a sequence (in milliseconds).\n * Exported for consumers that display sequence state (e.g. devtools).\n */\nexport const DEFAULT_SEQUENCE_TIMEOUT = 1000\n\nlet sequenceIdCounter = 0\n\n/**\n * Generates a unique ID for sequence registrations.\n */\nfunction generateSequenceId(): string {\n return `sequence_${++sequenceIdCounter}`\n}\n\n/**\n * Returns a canonical string for sequence conflict comparison.\n */\nfunction sequenceKey(sequence: HotkeySequence): string {\n return sequence.join('|')\n}\n\n/**\n * View of a sequence registration for devtools display.\n * Progress fields reflect an in-progress match (between first key and completion or timeout).\n */\nexport interface SequenceRegistrationView {\n id: string\n sequence: HotkeySequence\n options: SequenceOptions\n target: Target\n triggerCount: number\n /** Steps matched in the current attempt (0 when idle or just completed). */\n matchedStepCount: number\n /** `Date.now()` when the last step in the current attempt matched; 0 if none. */\n partialMatchLastKeyTime: number\n}\n\n/**\n * Internal representation of a sequence registration.\n */\ninterface SequenceRegistration {\n id: string\n sequence: HotkeySequence\n parsedSequence: Array<ParsedHotkey>\n callback: HotkeyCallback\n options: SequenceOptions\n target: Target\n currentIndex: number\n lastKeyTime: number\n triggerCount: number\n}\n\n/**\n * A handle returned from SequenceManager.register() that allows updating\n * the callback and options without re-registering the sequence.\n *\n * @example\n * ```ts\n * const handle = manager.register(['G', 'G'], callback, options)\n *\n * handle.callback = newCallback\n * handle.setOptions({ timeout: 500 })\n * handle.unregister()\n * ```\n */\nexport interface SequenceRegistrationHandle {\n readonly id: string\n readonly isActive: boolean\n callback: HotkeyCallback\n setOptions: (options: Partial<SequenceOptions>) => void\n unregister: () => void\n}\n\n/**\n * Manages keyboard sequence matching for Vim-style shortcuts.\n *\n * This class allows registering multi-key sequences like 'g g' or 'd d'\n * that trigger callbacks when the full sequence is pressed within\n * a configurable timeout.\n *\n * Modifier-only `keydown` / `keyup` events (Shift, Control, Alt, or Meta\n * alone) are not processed: they neither match a step nor reset progress.\n * That supports chained modifier chords (e.g. `Shift+R` then `Shift+T`) when\n * the user presses a modifier alone between steps.\n *\n * @example\n * ```ts\n * const matcher = SequenceManager.getInstance()\n *\n * // Register 'g g' to go to top\n * const unregister = matcher.register(['G', 'G'], (event, context) => {\n * scrollToTop()\n * }, { timeout: 500 })\n *\n * // Later, to unregister:\n * unregister()\n * ```\n */\n/**\n * Builds a devtools view from an internal registration.\n */\nfunction toRegistrationView(\n reg: SequenceRegistration,\n): SequenceRegistrationView {\n const len = reg.parsedSequence.length\n const inProgress = reg.currentIndex > 0 && reg.currentIndex < len\n return {\n id: reg.id,\n sequence: reg.sequence,\n options: reg.options,\n target: reg.target,\n triggerCount: reg.triggerCount,\n matchedStepCount: inProgress ? reg.currentIndex : 0,\n partialMatchLastKeyTime: inProgress ? reg.lastKeyTime : 0,\n }\n}\n\nexport class SequenceManager {\n static #instance: SequenceManager | null = null\n\n /**\n * The TanStack Store containing sequence registration views for devtools.\n * Subscribe to this to observe registration changes.\n */\n readonly registrations: Store<Map<string, SequenceRegistrationView>> =\n new Store(new Map())\n\n #registrations: Map<string, SequenceRegistration> = new Map()\n #targetListeners: Map<\n Target,\n {\n keydown: (event: KeyboardEvent) => void\n keyup: (event: KeyboardEvent) => void\n }\n > = new Map()\n #targetRegistrations: Map<Target, Set<string>> = new Map()\n #platform: 'mac' | 'windows' | 'linux'\n\n private constructor() {\n this.#platform = detectPlatform()\n }\n\n /**\n * Gets the singleton instance of SequenceManager.\n */\n static getInstance(): SequenceManager {\n if (!SequenceManager.#instance) {\n SequenceManager.#instance = new SequenceManager()\n }\n return SequenceManager.#instance\n }\n\n /**\n * Resets the singleton instance. Useful for testing.\n */\n static resetInstance(): void {\n if (SequenceManager.#instance) {\n SequenceManager.#instance.destroy()\n SequenceManager.#instance = null\n }\n }\n\n #syncRegistrationView(registration: SequenceRegistration): void {\n this.registrations.setState((prev) =>\n new Map(prev).set(registration.id, toRegistrationView(registration)),\n )\n }\n\n /**\n * Registers a hotkey sequence handler.\n *\n * @param sequence - Array of hotkey strings that form the sequence\n * @param callback - Function to call when the sequence is completed\n * @param options - Options for the sequence behavior\n * @returns A handle to update or unregister the sequence\n */\n register(\n sequence: HotkeySequence,\n callback: HotkeyCallback,\n options: SequenceOptions = {},\n ): SequenceRegistrationHandle {\n if (sequence.length === 0) {\n throw new Error('Sequence must contain at least one hotkey')\n }\n\n const id = generateSequenceId()\n const platform = options.platform ?? this.#platform\n const parsedSequence = sequence.map((hotkey) =>\n parseHotkey(hotkey, platform),\n )\n\n // Resolve target: default to document if not provided or null\n const target: Target =\n options.target ??\n (typeof document !== 'undefined' ? document : ({} as Document))\n\n // Resolve conflict behavior\n const conflictBehavior = options.conflictBehavior ?? 'warn'\n\n // Check for existing registrations with the same sequence and target\n const conflictingRegistration = this.#findConflictingSequence(\n sequence,\n target,\n )\n\n if (conflictingRegistration) {\n handleConflict(\n conflictingRegistration.id,\n formatHotkeySequence(sequence),\n conflictBehavior,\n (regId) => this.#unregister(regId),\n )\n }\n\n const firstStep = parsedSequence[0]!\n const resolvedIgnoreInputs =\n options.ignoreInputs ?? getDefaultIgnoreInputs(firstStep)\n\n const baseOptions = {\n ...defaultHotkeyOptions,\n timeout: DEFAULT_SEQUENCE_TIMEOUT,\n ...options,\n platform,\n ignoreInputs: resolvedIgnoreInputs,\n }\n\n const registration: SequenceRegistration = {\n id,\n sequence,\n parsedSequence,\n callback,\n options: baseOptions,\n target,\n currentIndex: 0,\n lastKeyTime: 0,\n triggerCount: 0,\n }\n\n this.#registrations.set(id, registration)\n this.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(registration)),\n )\n\n // Track registration for this target\n if (!this.#targetRegistrations.has(target)) {\n this.#targetRegistrations.set(target, new Set())\n }\n this.#targetRegistrations.get(target)!.add(id)\n\n // Ensure listeners are attached for this target\n this.#ensureListenersForTarget(target)\n\n const manager = this\n const handle: SequenceRegistrationHandle = {\n get id() {\n return id\n },\n get isActive() {\n return manager.#registrations.has(id)\n },\n get callback() {\n const reg = manager.#registrations.get(id)\n return reg?.callback ?? callback\n },\n set callback(newCallback: HotkeyCallback) {\n const reg = manager.#registrations.get(id)\n if (reg) {\n reg.callback = newCallback\n }\n },\n setOptions: (newOptions: Partial<SequenceOptions>) => {\n const reg = manager.#registrations.get(id)\n if (reg) {\n reg.options = { ...reg.options, ...newOptions }\n manager.registrations.setState((prev) =>\n new Map(prev).set(id, toRegistrationView(reg)),\n )\n }\n },\n unregister: () => {\n manager.#unregister(id)\n },\n }\n\n return handle\n }\n\n /**\n * Unregisters a sequence by its registration ID.\n */\n #unregister(id: string): void {\n const registration = this.#registrations.get(id)\n if (!registration) {\n return\n }\n\n const target = registration.target\n\n this.#registrations.delete(id)\n this.registrations.setState((prev) => {\n const next = new Map(prev)\n next.delete(id)\n return next\n })\n\n // Remove from target registrations tracking\n const targetRegs = this.#targetRegistrations.get(target)\n if (targetRegs) {\n targetRegs.delete(id)\n if (targetRegs.size === 0) {\n this.#removeListenersForTarget(target)\n }\n }\n }\n\n /**\n * Ensures event listeners are attached for a specific target.\n */\n #ensureListenersForTarget(target: Target): void {\n if (typeof document === 'undefined') {\n return // SSR safety\n }\n\n if (this.#targetListeners.has(target)) {\n return\n }\n\n const keydownHandler = this.#createTargetKeyDownHandler(target)\n const keyupHandler = this.#createTargetKeyUpHandler(target)\n\n target.addEventListener('keydown', keydownHandler as EventListener)\n target.addEventListener('keyup', keyupHandler as EventListener)\n\n this.#targetListeners.set(target, {\n keydown: keydownHandler,\n keyup: keyupHandler,\n })\n }\n\n /**\n * Removes event listeners for a specific target.\n */\n #removeListenersForTarget(target: Target): void {\n if (typeof document === 'undefined') {\n return\n }\n\n const listeners = this.#targetListeners.get(target)\n if (!listeners) {\n return\n }\n\n target.removeEventListener('keydown', listeners.keydown as EventListener)\n target.removeEventListener('keyup', listeners.keyup as EventListener)\n\n this.#targetListeners.delete(target)\n this.#targetRegistrations.delete(target)\n }\n\n /**\n * Creates a keydown handler for a specific target.\n */\n #createTargetKeyDownHandler(target: Target): (event: KeyboardEvent) => void {\n return (event: KeyboardEvent) => {\n this.#processTargetEvent(event, target, 'keydown')\n }\n }\n\n /**\n * Creates a keyup handler for a specific target.\n */\n #createTargetKeyUpHandler(target: Target): (event: KeyboardEvent) => void {\n return (event: KeyboardEvent) => {\n this.#processTargetEvent(event, target, 'keyup')\n }\n }\n\n /**\n * Processes keyboard events for a specific target and event type.\n */\n #processTargetEvent(\n event: KeyboardEvent,\n target: Target,\n eventType: 'keydown' | 'keyup',\n ): void {\n // Skip modifier-only events so pressing e.g. Shift before Shift+C does not reset the sequence.\n if (isModifierKey(event)) {\n return\n }\n\n const targetRegs = this.#targetRegistrations.get(target)\n if (!targetRegs) {\n return\n }\n\n const now = Date.now()\n\n for (const id of targetRegs) {\n const registration = this.#registrations.get(id)\n if (!registration) {\n continue\n }\n\n if (!isEventForTarget(event, target)) {\n continue\n }\n\n if (!registration.options.enabled) {\n continue\n }\n\n // Check if we should ignore input elements (defaults to true)\n if (registration.options.ignoreInputs !== false) {\n if (isInputElement(event.target)) {\n // Don't ignore if the sequence is explicitly scoped to this input element\n if (event.target !== registration.target) {\n continue\n }\n }\n }\n\n // Only process registrations that listen for this event type\n if (registration.options.eventType !== eventType) {\n continue\n }\n\n const timeout = registration.options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT\n\n // Check if sequence has timed out\n if (\n registration.currentIndex > 0 &&\n now - registration.lastKeyTime > timeout\n ) {\n registration.currentIndex = 0\n this.#syncRegistrationView(registration)\n }\n\n const expectedHotkey =\n registration.parsedSequence[registration.currentIndex]\n if (!expectedHotkey) {\n continue\n }\n\n if (\n matchesKeyboardEvent(\n event,\n expectedHotkey,\n registration.options.platform,\n )\n ) {\n registration.lastKeyTime = now\n registration.currentIndex++\n\n if (registration.currentIndex >= registration.parsedSequence.length) {\n if (registration.options.preventDefault) {\n event.preventDefault()\n }\n if (registration.options.stopPropagation) {\n event.stopPropagation()\n }\n\n const context: HotkeyCallbackContext = {\n hotkey: registration.sequence.join(' ') as Hotkey,\n parsedHotkey:\n registration.parsedSequence[\n registration.parsedSequence.length - 1\n ]!,\n }\n\n registration.triggerCount++\n registration.currentIndex = 0\n\n this.#syncRegistrationView(registration)\n\n registration.callback(event, context)\n } else {\n this.#syncRegistrationView(registration)\n }\n } else if (registration.currentIndex > 0) {\n const firstHotkey = registration.parsedSequence[0]!\n if (\n matchesKeyboardEvent(\n event,\n firstHotkey,\n registration.options.platform,\n )\n ) {\n registration.currentIndex = 1\n registration.lastKeyTime = now\n } else {\n registration.currentIndex = 0\n }\n this.#syncRegistrationView(registration)\n }\n }\n }\n\n /**\n * Finds an existing registration with the same sequence and target.\n */\n #findConflictingSequence(\n sequence: HotkeySequence,\n target: Target,\n ): SequenceRegistration | null {\n const key = sequenceKey(sequence)\n for (const registration of this.#registrations.values()) {\n if (\n sequenceKey(registration.sequence) === key &&\n registration.target === target\n ) {\n return registration\n }\n }\n return null\n }\n\n /**\n * Resets all sequence progress.\n */\n resetAll(): void {\n for (const registration of this.#registrations.values()) {\n registration.currentIndex = 0\n registration.lastKeyTime = 0\n this.#syncRegistrationView(registration)\n }\n }\n\n /**\n * Triggers a sequence's callback programmatically from devtools.\n * Creates a synthetic KeyboardEvent from the last key in the sequence.\n *\n * @param id - The registration ID to trigger\n * @returns True if the registration was found and triggered\n */\n triggerSequence(id: string): boolean {\n const registration = this.#registrations.get(id)\n if (!registration) {\n return false\n }\n\n const lastParsed =\n registration.parsedSequence[registration.parsedSequence.length - 1]\n if (!lastParsed) {\n return false\n }\n\n const syntheticEvent = new KeyboardEvent(\n registration.options.eventType ?? 'keydown',\n {\n key: lastParsed.key,\n ctrlKey: lastParsed.ctrl,\n shiftKey: lastParsed.shift,\n altKey: lastParsed.alt,\n metaKey: lastParsed.meta,\n bubbles: true,\n cancelable: true,\n },\n )\n\n registration.triggerCount++\n\n this.#syncRegistrationView(registration)\n\n const context: HotkeyCallbackContext = {\n hotkey: registration.sequence.join(' ') as Hotkey,\n parsedHotkey: lastParsed,\n }\n\n registration.callback(syntheticEvent, context)\n\n return true\n }\n\n /**\n * Gets the number of registered sequences.\n */\n getRegistrationCount(): number {\n return this.#registrations.size\n }\n\n /**\n * Destroys the manager and removes all listeners.\n */\n destroy(): void {\n for (const target of this.#targetListeners.keys()) {\n this.#removeListenersForTarget(target)\n }\n this.#registrations.clear()\n this.registrations.setState(() => new Map())\n }\n}\n\n/**\n * Gets the singleton SequenceManager instance.\n * Convenience function for accessing the manager.\n */\nexport function getSequenceManager(): SequenceManager {\n return SequenceManager.getInstance()\n}\n\n/**\n * Creates a simple sequence matcher for one-off use.\n *\n * This is a low-level helper that does not support ignoreInputs, target,\n * or other HotkeyOptions. Callers must handle input filtering themselves\n * if attaching to document.\n *\n * @param sequence - The sequence of hotkeys to match\n * @param options - Options including timeout\n * @returns An object with match() and reset() methods\n *\n * @example\n * ```ts\n * const matcher = createSequenceMatcher(['G', 'G'], { timeout: 500 })\n *\n * document.addEventListener('keydown', (event) => {\n * if (matcher.match(event)) {\n * console.log('Sequence matched!')\n * }\n * })\n * ```\n */\nexport function createSequenceMatcher(\n sequence: HotkeySequence,\n options: { timeout?: number; platform?: 'mac' | 'windows' | 'linux' } = {},\n): {\n match: (event: KeyboardEvent) => boolean\n reset: () => void\n getProgress: () => number\n} {\n const platform = options.platform ?? detectPlatform()\n const timeout = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT\n const parsedSequence = sequence.map((hotkey) => parseHotkey(hotkey, platform))\n\n let currentIndex = 0\n let lastKeyTime = 0\n\n return {\n match(event: KeyboardEvent): boolean {\n const now = Date.now()\n\n if (currentIndex > 0 && now - lastKeyTime > timeout) {\n currentIndex = 0\n }\n\n const expected = parsedSequence[currentIndex]\n if (!expected) {\n return false\n }\n\n if (matchesKeyboardEvent(event, expected, platform)) {\n lastKeyTime = now\n currentIndex++\n\n if (currentIndex >= parsedSequence.length) {\n currentIndex = 0\n return true\n }\n } else if (currentIndex > 0) {\n if (matchesKeyboardEvent(event, parsedSequence[0]!, platform)) {\n currentIndex = 1\n lastKeyTime = now\n } else {\n currentIndex = 0\n }\n }\n\n return false\n },\n\n reset(): void {\n currentIndex = 0\n lastKeyTime = 0\n },\n\n getProgress(): number {\n return currentIndex\n },\n }\n}\n"],"mappings":";;;;;;;;;;;;AAqDA,MAAa,2BAA2B;AAExC,IAAI,oBAAoB;;;;AAKxB,SAAS,qBAA6B;AACpC,QAAO,YAAY,EAAE;;;;;AAMvB,SAAS,YAAY,UAAkC;AACrD,QAAO,SAAS,KAAK,IAAI;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAmF3B,SAAS,mBACP,KAC0B;CAC1B,MAAM,MAAM,IAAI,eAAe;CAC/B,MAAM,aAAa,IAAI,eAAe,KAAK,IAAI,eAAe;AAC9D,QAAO;EACL,IAAI,IAAI;EACR,UAAU,IAAI;EACd,SAAS,IAAI;EACb,QAAQ,IAAI;EACZ,cAAc,IAAI;EAClB,kBAAkB,aAAa,IAAI,eAAe;EAClD,yBAAyB,aAAa,IAAI,cAAc;EACzD;;AAGH,IAAa,kBAAb,MAAa,gBAAgB;CAC3B,QAAOA,WAAoC;CAS3C,iCAAoD,IAAI,KAAK;CAC7D,mCAMI,IAAI,KAAK;CACb,uCAAiD,IAAI,KAAK;CAC1D;CAEA,AAAQ,cAAc;uBAbpB,IAAI,sBAAM,IAAI,KAAK,CAAC;AAcpB,QAAKC,WAAY,gBAAgB;;;;;CAMnC,OAAO,cAA+B;AACpC,MAAI,CAAC,iBAAgBD,SACnB,kBAAgBA,WAAY,IAAI,iBAAiB;AAEnD,SAAO,iBAAgBA;;;;;CAMzB,OAAO,gBAAsB;AAC3B,MAAI,iBAAgBA,UAAW;AAC7B,oBAAgBA,SAAU,SAAS;AACnC,oBAAgBA,WAAY;;;CAIhC,sBAAsB,cAA0C;AAC9D,OAAK,cAAc,UAAU,SAC3B,IAAI,IAAI,KAAK,CAAC,IAAI,aAAa,IAAI,mBAAmB,aAAa,CAAC,CACrE;;;;;;;;;;CAWH,SACE,UACA,UACA,UAA2B,EAAE,EACD;AAC5B,MAAI,SAAS,WAAW,EACtB,OAAM,IAAI,MAAM,4CAA4C;EAG9D,MAAM,KAAK,oBAAoB;EAC/B,MAAM,WAAW,QAAQ,YAAY,MAAKC;EAC1C,MAAM,iBAAiB,SAAS,KAAK,WACnC,YAAY,QAAQ,SAAS,CAC9B;EAGD,MAAM,SACJ,QAAQ,WACP,OAAO,aAAa,cAAc,WAAY,EAAE;EAGnD,MAAM,mBAAmB,QAAQ,oBAAoB;EAGrD,MAAM,0BAA0B,MAAKC,wBACnC,UACA,OACD;AAED,MAAI,wBACF,gBACE,wBAAwB,IACxB,qBAAqB,SAAS,EAC9B,mBACC,UAAU,MAAKC,WAAY,MAAM,CACnC;EAGH,MAAM,YAAY,eAAe;EACjC,MAAM,uBACJ,QAAQ,gBAAgB,uBAAuB,UAAU;EAU3D,MAAM,eAAqC;GACzC;GACA;GACA;GACA;GACA,SAbkB;IAClB,GAAG;IACH,SAAS;IACT,GAAG;IACH;IACA,cAAc;IACf;GAQC;GACA,cAAc;GACd,aAAa;GACb,cAAc;GACf;AAED,QAAKC,cAAe,IAAI,IAAI,aAAa;AACzC,OAAK,cAAc,UAAU,SAC3B,IAAI,IAAI,KAAK,CAAC,IAAI,IAAI,mBAAmB,aAAa,CAAC,CACxD;AAGD,MAAI,CAAC,MAAKC,oBAAqB,IAAI,OAAO,CACxC,OAAKA,oBAAqB,IAAI,wBAAQ,IAAI,KAAK,CAAC;AAElD,QAAKA,oBAAqB,IAAI,OAAO,CAAE,IAAI,GAAG;AAG9C,QAAKC,yBAA0B,OAAO;EAEtC,MAAM,UAAU;AAgChB,SA/B2C;GACzC,IAAI,KAAK;AACP,WAAO;;GAET,IAAI,WAAW;AACb,WAAO,SAAQF,cAAe,IAAI,GAAG;;GAEvC,IAAI,WAAW;AAEb,WADY,SAAQA,cAAe,IAAI,GAAG,EAC9B,YAAY;;GAE1B,IAAI,SAAS,aAA6B;IACxC,MAAM,MAAM,SAAQA,cAAe,IAAI,GAAG;AAC1C,QAAI,IACF,KAAI,WAAW;;GAGnB,aAAa,eAAyC;IACpD,MAAM,MAAM,SAAQA,cAAe,IAAI,GAAG;AAC1C,QAAI,KAAK;AACP,SAAI,UAAU;MAAE,GAAG,IAAI;MAAS,GAAG;MAAY;AAC/C,aAAQ,cAAc,UAAU,SAC9B,IAAI,IAAI,KAAK,CAAC,IAAI,IAAI,mBAAmB,IAAI,CAAC,CAC/C;;;GAGL,kBAAkB;AAChB,aAAQD,WAAY,GAAG;;GAE1B;;;;;CAQH,YAAY,IAAkB;EAC5B,MAAM,eAAe,MAAKC,cAAe,IAAI,GAAG;AAChD,MAAI,CAAC,aACH;EAGF,MAAM,SAAS,aAAa;AAE5B,QAAKA,cAAe,OAAO,GAAG;AAC9B,OAAK,cAAc,UAAU,SAAS;GACpC,MAAM,OAAO,IAAI,IAAI,KAAK;AAC1B,QAAK,OAAO,GAAG;AACf,UAAO;IACP;EAGF,MAAM,aAAa,MAAKC,oBAAqB,IAAI,OAAO;AACxD,MAAI,YAAY;AACd,cAAW,OAAO,GAAG;AACrB,OAAI,WAAW,SAAS,EACtB,OAAKE,yBAA0B,OAAO;;;;;;CAQ5C,0BAA0B,QAAsB;AAC9C,MAAI,OAAO,aAAa,YACtB;AAGF,MAAI,MAAKC,gBAAiB,IAAI,OAAO,CACnC;EAGF,MAAM,iBAAiB,MAAKC,2BAA4B,OAAO;EAC/D,MAAM,eAAe,MAAKC,yBAA0B,OAAO;AAE3D,SAAO,iBAAiB,WAAW,eAAgC;AACnE,SAAO,iBAAiB,SAAS,aAA8B;AAE/D,QAAKF,gBAAiB,IAAI,QAAQ;GAChC,SAAS;GACT,OAAO;GACR,CAAC;;;;;CAMJ,0BAA0B,QAAsB;AAC9C,MAAI,OAAO,aAAa,YACtB;EAGF,MAAM,YAAY,MAAKA,gBAAiB,IAAI,OAAO;AACnD,MAAI,CAAC,UACH;AAGF,SAAO,oBAAoB,WAAW,UAAU,QAAyB;AACzE,SAAO,oBAAoB,SAAS,UAAU,MAAuB;AAErE,QAAKA,gBAAiB,OAAO,OAAO;AACpC,QAAKH,oBAAqB,OAAO,OAAO;;;;;CAM1C,4BAA4B,QAAgD;AAC1E,UAAQ,UAAyB;AAC/B,SAAKM,mBAAoB,OAAO,QAAQ,UAAU;;;;;;CAOtD,0BAA0B,QAAgD;AACxE,UAAQ,UAAyB;AAC/B,SAAKA,mBAAoB,OAAO,QAAQ,QAAQ;;;;;;CAOpD,oBACE,OACA,QACA,WACM;AAEN,MAAI,cAAc,MAAM,CACtB;EAGF,MAAM,aAAa,MAAKN,oBAAqB,IAAI,OAAO;AACxD,MAAI,CAAC,WACH;EAGF,MAAM,MAAM,KAAK,KAAK;AAEtB,OAAK,MAAM,MAAM,YAAY;GAC3B,MAAM,eAAe,MAAKD,cAAe,IAAI,GAAG;AAChD,OAAI,CAAC,aACH;AAGF,OAAI,CAAC,iBAAiB,OAAO,OAAO,CAClC;AAGF,OAAI,CAAC,aAAa,QAAQ,QACxB;AAIF,OAAI,aAAa,QAAQ,iBAAiB,OACxC;QAAI,eAAe,MAAM,OAAO,EAE9B;SAAI,MAAM,WAAW,aAAa,OAChC;;;AAMN,OAAI,aAAa,QAAQ,cAAc,UACrC;GAGF,MAAM,UAAU,aAAa,QAAQ;AAGrC,OACE,aAAa,eAAe,KAC5B,MAAM,aAAa,cAAc,SACjC;AACA,iBAAa,eAAe;AAC5B,UAAKQ,qBAAsB,aAAa;;GAG1C,MAAM,iBACJ,aAAa,eAAe,aAAa;AAC3C,OAAI,CAAC,eACH;AAGF,OACE,qBACE,OACA,gBACA,aAAa,QAAQ,SACtB,EACD;AACA,iBAAa,cAAc;AAC3B,iBAAa;AAEb,QAAI,aAAa,gBAAgB,aAAa,eAAe,QAAQ;AACnE,SAAI,aAAa,QAAQ,eACvB,OAAM,gBAAgB;AAExB,SAAI,aAAa,QAAQ,gBACvB,OAAM,iBAAiB;KAGzB,MAAM,UAAiC;MACrC,QAAQ,aAAa,SAAS,KAAK,IAAI;MACvC,cACE,aAAa,eACX,aAAa,eAAe,SAAS;MAE1C;AAED,kBAAa;AACb,kBAAa,eAAe;AAE5B,WAAKA,qBAAsB,aAAa;AAExC,kBAAa,SAAS,OAAO,QAAQ;UAErC,OAAKA,qBAAsB,aAAa;cAEjC,aAAa,eAAe,GAAG;IACxC,MAAM,cAAc,aAAa,eAAe;AAChD,QACE,qBACE,OACA,aACA,aAAa,QAAQ,SACtB,EACD;AACA,kBAAa,eAAe;AAC5B,kBAAa,cAAc;UAE3B,cAAa,eAAe;AAE9B,UAAKA,qBAAsB,aAAa;;;;;;;CAQ9C,yBACE,UACA,QAC6B;EAC7B,MAAM,MAAM,YAAY,SAAS;AACjC,OAAK,MAAM,gBAAgB,MAAKR,cAAe,QAAQ,CACrD,KACE,YAAY,aAAa,SAAS,KAAK,OACvC,aAAa,WAAW,OAExB,QAAO;AAGX,SAAO;;;;;CAMT,WAAiB;AACf,OAAK,MAAM,gBAAgB,MAAKA,cAAe,QAAQ,EAAE;AACvD,gBAAa,eAAe;AAC5B,gBAAa,cAAc;AAC3B,SAAKQ,qBAAsB,aAAa;;;;;;;;;;CAW5C,gBAAgB,IAAqB;EACnC,MAAM,eAAe,MAAKR,cAAe,IAAI,GAAG;AAChD,MAAI,CAAC,aACH,QAAO;EAGT,MAAM,aACJ,aAAa,eAAe,aAAa,eAAe,SAAS;AACnE,MAAI,CAAC,WACH,QAAO;EAGT,MAAM,iBAAiB,IAAI,cACzB,aAAa,QAAQ,aAAa,WAClC;GACE,KAAK,WAAW;GAChB,SAAS,WAAW;GACpB,UAAU,WAAW;GACrB,QAAQ,WAAW;GACnB,SAAS,WAAW;GACpB,SAAS;GACT,YAAY;GACb,CACF;AAED,eAAa;AAEb,QAAKQ,qBAAsB,aAAa;EAExC,MAAM,UAAiC;GACrC,QAAQ,aAAa,SAAS,KAAK,IAAI;GACvC,cAAc;GACf;AAED,eAAa,SAAS,gBAAgB,QAAQ;AAE9C,SAAO;;;;;CAMT,uBAA+B;AAC7B,SAAO,MAAKR,cAAe;;;;;CAM7B,UAAgB;AACd,OAAK,MAAM,UAAU,MAAKI,gBAAiB,MAAM,CAC/C,OAAKD,yBAA0B,OAAO;AAExC,QAAKH,cAAe,OAAO;AAC3B,OAAK,cAAc,+BAAe,IAAI,KAAK,CAAC;;;;;;;AAQhD,SAAgB,qBAAsC;AACpD,QAAO,gBAAgB,aAAa;;;;;;;;;;;;;;;;;;;;;;;;AAyBtC,SAAgB,sBACd,UACA,UAAwE,EAAE,EAK1E;CACA,MAAM,WAAW,QAAQ,YAAY,gBAAgB;CACrD,MAAM,UAAU,QAAQ;CACxB,MAAM,iBAAiB,SAAS,KAAK,WAAW,YAAY,QAAQ,SAAS,CAAC;CAE9E,IAAI,eAAe;CACnB,IAAI,cAAc;AAElB,QAAO;EACL,MAAM,OAA+B;GACnC,MAAM,MAAM,KAAK,KAAK;AAEtB,OAAI,eAAe,KAAK,MAAM,cAAc,QAC1C,gBAAe;GAGjB,MAAM,WAAW,eAAe;AAChC,OAAI,CAAC,SACH,QAAO;AAGT,OAAI,qBAAqB,OAAO,UAAU,SAAS,EAAE;AACnD,kBAAc;AACd;AAEA,QAAI,gBAAgB,eAAe,QAAQ;AACzC,oBAAe;AACf,YAAO;;cAEA,eAAe,EACxB,KAAI,qBAAqB,OAAO,eAAe,IAAK,SAAS,EAAE;AAC7D,mBAAe;AACf,kBAAc;SAEd,gBAAe;AAInB,UAAO;;EAGT,QAAc;AACZ,kBAAe;AACf,iBAAc;;EAGhB,cAAsB;AACpB,UAAO;;EAEV"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tanstack/hotkeys",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "description": "Type-safe, framework-agnostic keyboard hotkey management for the browser",
5
5
  "author": "Tanner Linsley",
6
6
  "license": "MIT",
@@ -1,11 +1,6 @@
1
1
  import { Store } from '@tanstack/store'
2
2
  import { detectPlatform } from './constants'
3
- import {
4
- convertToModFormat,
5
- hasNonModifierKey,
6
- isModifierKey,
7
- keyboardEventToHotkey,
8
- } from './parse'
3
+ import { hotkeyChordFromKeydown } from './recorder-chord'
9
4
  import type { Hotkey } from './hotkey'
10
5
 
11
6
  /**
@@ -149,37 +144,28 @@ export class HotkeyRecorder {
149
144
  }
150
145
  }
151
146
 
152
- // Ignore pure modifier keys (wait for a non-modifier key)
153
- if (isModifierKey(event)) {
147
+ const finalHotkey = hotkeyChordFromKeydown(event, this.#platform)
148
+ if (finalHotkey === null) {
154
149
  return
155
150
  }
156
151
 
157
- // Convert event to hotkey string using library function
158
- const hotkey = keyboardEventToHotkey(event)
159
-
160
- // Always convert to Mod format for portability
161
- const finalHotkey = convertToModFormat(hotkey, this.#platform)
162
-
163
- // Validate: must have at least one non-modifier key
164
- if (hasNonModifierKey(finalHotkey, this.#platform)) {
165
- // Remove listener FIRST to prevent any additional events
166
- const handlerToRemove = this.#keydownHandler as
167
- | ((event: KeyboardEvent) => void)
168
- | null
169
- if (handlerToRemove) {
170
- this.#removeListener(handlerToRemove)
171
- this.#keydownHandler = null
172
- }
152
+ // Remove listener FIRST to prevent any additional events
153
+ const handlerToRemove = this.#keydownHandler as
154
+ | ((event: KeyboardEvent) => void)
155
+ | null
156
+ if (handlerToRemove) {
157
+ this.#removeListener(handlerToRemove)
158
+ this.#keydownHandler = null
159
+ }
173
160
 
174
- // Update store state immediately
175
- this.store.setState(() => ({
176
- isRecording: false,
177
- recordedHotkey: finalHotkey,
178
- }))
161
+ // Update store state immediately
162
+ this.store.setState(() => ({
163
+ isRecording: false,
164
+ recordedHotkey: finalHotkey,
165
+ }))
179
166
 
180
- // Call callback AFTER listener is removed and state is set
181
- this.#options.onRecord(finalHotkey)
182
- }
167
+ // Call callback AFTER listener is removed and state is set
168
+ this.#options.onRecord(finalHotkey)
183
169
  }
184
170
 
185
171
  this.#keydownHandler = handler