@tanstack/hotkeys 0.9.0 → 0.10.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.
Files changed (148) hide show
  1. package/dist/_event-target.js +1 -2
  2. package/dist/_keyboard-event.js +1 -2
  3. package/dist/_match.js +1 -2
  4. package/dist/_named-keys.d.ts +3 -5
  5. package/dist/_named-keys.js +1 -2
  6. package/dist/_recorder-chord.js +1 -2
  7. package/dist/_recording-guard.js +1 -2
  8. package/dist/_registration.js +1 -2
  9. package/dist/conflicts.d.ts +4 -7
  10. package/dist/conflicts.js +1 -2
  11. package/dist/constants.d.ts +14 -17
  12. package/dist/constants.js +8 -9
  13. package/dist/display-labels.d.ts +7 -10
  14. package/dist/display-labels.js +1 -2
  15. package/dist/format.d.ts +12 -14
  16. package/dist/format.js +17 -7
  17. package/dist/hint.d.ts +3 -6
  18. package/dist/hint.js +1 -2
  19. package/dist/hotkey-manager.d.ts +8 -10
  20. package/dist/hotkey-manager.js +21 -3
  21. package/dist/hotkey-recorder.d.ts +4 -7
  22. package/dist/hotkey-recorder.js +9 -6
  23. package/dist/hotkey-sequence-recorder.d.ts +5 -8
  24. package/dist/hotkey-sequence-recorder.js +6 -7
  25. package/dist/hotkey.types.d.ts +15 -18
  26. package/dist/key-state-tracker.d.ts +4 -7
  27. package/dist/key-state-tracker.js +6 -3
  28. package/dist/key.types.d.ts +17 -20
  29. package/dist/match.d.ts +8 -11
  30. package/dist/match.js +1 -2
  31. package/dist/parse.d.ts +11 -14
  32. package/dist/parse.js +5 -4
  33. package/dist/platform.d.ts +3 -6
  34. package/dist/platform.js +1 -2
  35. package/dist/recorder-options.d.ts +6 -9
  36. package/dist/sequence-manager.d.ts +11 -14
  37. package/dist/sequence-manager.js +12 -7
  38. package/dist/validate.d.ts +5 -8
  39. package/dist/validate.js +1 -2
  40. package/package.json +8 -13
  41. package/dist/_event-target.cjs +0 -73
  42. package/dist/_event-target.cjs.map +0 -1
  43. package/dist/_event-target.js.map +0 -1
  44. package/dist/_keyboard-event.cjs +0 -83
  45. package/dist/_keyboard-event.cjs.map +0 -1
  46. package/dist/_keyboard-event.js.map +0 -1
  47. package/dist/_match.cjs +0 -49
  48. package/dist/_match.cjs.map +0 -1
  49. package/dist/_match.js.map +0 -1
  50. package/dist/_named-keys.cjs +0 -68
  51. package/dist/_named-keys.cjs.map +0 -1
  52. package/dist/_named-keys.d.cts +0 -8
  53. package/dist/_named-keys.js.map +0 -1
  54. package/dist/_recorder-chord.cjs +0 -44
  55. package/dist/_recorder-chord.cjs.map +0 -1
  56. package/dist/_recorder-chord.js.map +0 -1
  57. package/dist/_recording-guard.cjs +0 -59
  58. package/dist/_recording-guard.cjs.map +0 -1
  59. package/dist/_recording-guard.js.map +0 -1
  60. package/dist/_registration.cjs +0 -54
  61. package/dist/_registration.cjs.map +0 -1
  62. package/dist/_registration.js.map +0 -1
  63. package/dist/conflicts.cjs +0 -55
  64. package/dist/conflicts.cjs.map +0 -1
  65. package/dist/conflicts.d.cts +0 -39
  66. package/dist/conflicts.js.map +0 -1
  67. package/dist/constants.cjs +0 -410
  68. package/dist/constants.cjs.map +0 -1
  69. package/dist/constants.d.cts +0 -165
  70. package/dist/constants.js.map +0 -1
  71. package/dist/display-labels.cjs +0 -119
  72. package/dist/display-labels.cjs.map +0 -1
  73. package/dist/display-labels.d.cts +0 -93
  74. package/dist/display-labels.js.map +0 -1
  75. package/dist/format.cjs +0 -85
  76. package/dist/format.cjs.map +0 -1
  77. package/dist/format.d.cts +0 -99
  78. package/dist/format.js.map +0 -1
  79. package/dist/hint.cjs +0 -24
  80. package/dist/hint.cjs.map +0 -1
  81. package/dist/hint.d.cts +0 -23
  82. package/dist/hint.js.map +0 -1
  83. package/dist/hotkey-manager.cjs +0 -414
  84. package/dist/hotkey-manager.cjs.map +0 -1
  85. package/dist/hotkey-manager.d.cts +0 -234
  86. package/dist/hotkey-manager.js.map +0 -1
  87. package/dist/hotkey-recorder.cjs +0 -235
  88. package/dist/hotkey-recorder.cjs.map +0 -1
  89. package/dist/hotkey-recorder.d.cts +0 -119
  90. package/dist/hotkey-recorder.js.map +0 -1
  91. package/dist/hotkey-sequence-recorder.cjs +0 -255
  92. package/dist/hotkey-sequence-recorder.cjs.map +0 -1
  93. package/dist/hotkey-sequence-recorder.d.cts +0 -86
  94. package/dist/hotkey-sequence-recorder.js.map +0 -1
  95. package/dist/hotkey.types.d.cts +0 -236
  96. package/dist/index.cjs +0 -71
  97. package/dist/index.d.cts +0 -18
  98. package/dist/key-state-tracker.cjs +0 -197
  99. package/dist/key-state-tracker.cjs.map +0 -1
  100. package/dist/key-state-tracker.d.cts +0 -107
  101. package/dist/key-state-tracker.js.map +0 -1
  102. package/dist/key.types.d.cts +0 -82
  103. package/dist/match.cjs +0 -144
  104. package/dist/match.cjs.map +0 -1
  105. package/dist/match.d.cts +0 -101
  106. package/dist/match.js.map +0 -1
  107. package/dist/parse.cjs +0 -245
  108. package/dist/parse.cjs.map +0 -1
  109. package/dist/parse.d.cts +0 -134
  110. package/dist/parse.js.map +0 -1
  111. package/dist/platform.cjs +0 -55
  112. package/dist/platform.cjs.map +0 -1
  113. package/dist/platform.d.cts +0 -44
  114. package/dist/platform.js.map +0 -1
  115. package/dist/recorder-options.d.cts +0 -34
  116. package/dist/sequence-manager.cjs +0 -441
  117. package/dist/sequence-manager.cjs.map +0 -1
  118. package/dist/sequence-manager.d.cts +0 -172
  119. package/dist/sequence-manager.js.map +0 -1
  120. package/dist/validate.cjs +0 -112
  121. package/dist/validate.cjs.map +0 -1
  122. package/dist/validate.d.cts +0 -67
  123. package/dist/validate.js.map +0 -1
  124. package/src/_event-target.ts +0 -157
  125. package/src/_keyboard-event.ts +0 -101
  126. package/src/_match.ts +0 -96
  127. package/src/_named-keys.ts +0 -62
  128. package/src/_recorder-chord.ts +0 -76
  129. package/src/_recording-guard.ts +0 -63
  130. package/src/_registration.ts +0 -79
  131. package/src/conflicts.ts +0 -125
  132. package/src/constants.ts +0 -455
  133. package/src/display-labels.ts +0 -123
  134. package/src/format.ts +0 -201
  135. package/src/hint.ts +0 -42
  136. package/src/hotkey-manager.ts +0 -814
  137. package/src/hotkey-recorder.ts +0 -348
  138. package/src/hotkey-sequence-recorder.ts +0 -410
  139. package/src/hotkey.types.ts +0 -269
  140. package/src/index.ts +0 -17
  141. package/src/key-state-tracker.ts +0 -261
  142. package/src/key.types.ts +0 -268
  143. package/src/match.ts +0 -210
  144. package/src/parse.ts +0 -369
  145. package/src/platform.ts +0 -66
  146. package/src/recorder-options.ts +0 -34
  147. package/src/sequence-manager.ts +0 -769
  148. package/src/validate.ts +0 -179
@@ -1,769 +0,0 @@
1
- import { Store } from '@tanstack/store'
2
- import { isRecordingEvent } from './_recording-guard'
3
- import { formatHotkeySequence } from './format'
4
- import { detectPlatform } from './platform'
5
- import { normalizeKeyName } from './constants'
6
- import { isModifierKey, parseHotkey } from './parse'
7
- import { areHotkeysEqual, matchesKeyboardEvent } from './match'
8
- import { matchKeyboardEvent } from './_match'
9
- import { normalizeKeyboardEvent } from './_keyboard-event'
10
- import {
11
- defaultHotkeyOptions,
12
- getDefaultIgnoreInputs,
13
- handleConflict,
14
- optionsEqual,
15
- } from './_registration'
16
- import { isEventForTarget, shouldIgnoreInputEvent } from './_event-target'
17
- import type { HotkeyOptions } from './hotkey-manager'
18
- import type {
19
- Hotkey,
20
- HotkeyCallback,
21
- HotkeyCallbackContext,
22
- ParsedHotkey,
23
- } from './hotkey.types'
24
-
25
- export type Target = HTMLElement | Document | Window
26
-
27
- /**
28
- * Options for hotkey sequence matching.
29
- * Extends HotkeyOptions but excludes requireReset (not applicable to sequences).
30
- */
31
- export interface SequenceOptions extends Omit<HotkeyOptions, 'requireReset'> {
32
- /** Timeout between keys in milliseconds. Default: 1000 */
33
- timeout?: number
34
- }
35
-
36
- /**
37
- * A sequence of hotkeys for Vim-style shortcuts.
38
- *
39
- * Each element is one step (a `Hotkey` string). Steps may include modifiers;
40
- * the same modifier can appear on consecutive steps (e.g. `Shift+R` then
41
- * `Shift+T`). Modifier-only key events do not advance or reset matching—see
42
- * `SequenceManager`. Automatic keydown repeats and IME composition are also ignored.
43
- *
44
- * @example
45
- * ```ts
46
- * const gotoTop: HotkeySequence = ['G', 'G'] // gg
47
- * const deleteLine: HotkeySequence = ['D', 'D'] // dd
48
- * const deleteWord: HotkeySequence = ['D', 'I', 'W'] // diw
49
- * const chainedShift: HotkeySequence = ['Shift+R', 'Shift+T']
50
- * ```
51
- */
52
- export type HotkeySequence = Array<Hotkey>
53
-
54
- /**
55
- * Default timeout between keys in a sequence (in milliseconds).
56
- * Exported for consumers that display sequence state (e.g. devtools).
57
- */
58
- export const DEFAULT_SEQUENCE_TIMEOUT = 1000
59
-
60
- let sequenceIdCounter = 0
61
-
62
- /**
63
- * Generates a unique ID for sequence registrations.
64
- */
65
- function generateSequenceId(): string {
66
- return `sequence_${++sequenceIdCounter}`
67
- }
68
-
69
- /**
70
- * View of a sequence registration for devtools display.
71
- * Progress fields reflect an in-progress match (between first key and completion or timeout).
72
- */
73
- export interface SequenceRegistrationView {
74
- id: string
75
- sequence: HotkeySequence
76
- options: SequenceOptions
77
- target: Target
78
- triggerCount: number
79
- /** Whether this sequence has been triggered at least once. */
80
- hasFired: boolean
81
- /** Steps matched in the current attempt (0 when idle or just completed). */
82
- matchedStepCount: number
83
- /** `Date.now()` when the last step in the current attempt matched; 0 if none. */
84
- partialMatchLastKeyTime: number
85
- }
86
-
87
- /**
88
- * Internal representation of a sequence registration.
89
- */
90
- export interface SequenceRegistration {
91
- id: string
92
- sequence: HotkeySequence
93
- parsedSequence: Array<ParsedHotkey>
94
- callback: HotkeyCallback
95
- options: SequenceOptions
96
- target: Target
97
- currentIndex: number
98
- lastKeyTime: number
99
- triggerCount: number
100
- hasFired: boolean
101
- }
102
-
103
- /**
104
- * A handle returned from SequenceManager.register() that allows updating
105
- * the callback and options without re-registering the sequence.
106
- *
107
- * @example
108
- * ```ts
109
- * const handle = manager.register(['G', 'G'], callback, options)
110
- *
111
- * handle.callback = newCallback
112
- * handle.setOptions({ timeout: 500 })
113
- * handle.unregister()
114
- * ```
115
- */
116
- export interface SequenceRegistrationHandle {
117
- readonly id: string
118
- readonly isActive: boolean
119
- callback: HotkeyCallback
120
- setOptions: (options: Partial<SequenceOptions>) => void
121
- unregister: () => void
122
- }
123
-
124
- /**
125
- * Manages keyboard sequence matching for Vim-style shortcuts.
126
- *
127
- * This class allows registering multi-key sequences like 'g g' or 'd d'
128
- * that trigger callbacks when the full sequence is pressed within
129
- * a configurable timeout.
130
- *
131
- * Modifier-only `keydown` / `keyup` events (Shift, Control, Alt, or Meta
132
- * alone) are not processed: they neither match a step nor reset progress.
133
- * That supports chained modifier chords (e.g. `Shift+R` then `Shift+T`) when
134
- * the user presses a modifier alone between steps.
135
- *
136
- * @example
137
- * ```ts
138
- * const matcher = SequenceManager.getInstance()
139
- *
140
- * // Register 'g g' to go to top
141
- * const unregister = matcher.register(['G', 'G'], (event, context) => {
142
- * scrollToTop()
143
- * }, { timeout: 500 })
144
- *
145
- * // Later, to unregister:
146
- * unregister()
147
- * ```
148
- */
149
- /**
150
- * Builds a devtools view from an internal registration.
151
- */
152
- function toRegistrationView(
153
- reg: SequenceRegistration,
154
- ): SequenceRegistrationView {
155
- const len = reg.parsedSequence.length
156
- const inProgress = reg.currentIndex > 0 && reg.currentIndex < len
157
- return {
158
- id: reg.id,
159
- sequence: reg.sequence,
160
- options: reg.options,
161
- target: reg.target,
162
- triggerCount: reg.triggerCount,
163
- hasFired: reg.hasFired,
164
- matchedStepCount: inProgress ? reg.currentIndex : 0,
165
- partialMatchLastKeyTime: inProgress ? reg.lastKeyTime : 0,
166
- }
167
- }
168
-
169
- export class SequenceManager {
170
- static #instance: SequenceManager | null = null
171
-
172
- /**
173
- * The TanStack Store containing sequence registration views for devtools.
174
- * Subscribe to this to observe registration changes.
175
- */
176
- readonly registrations: Store<Map<string, SequenceRegistrationView>> =
177
- new Store(new Map())
178
-
179
- #registrations: Map<string, SequenceRegistration> = new Map()
180
- #targetListeners: Map<
181
- Target,
182
- {
183
- keydown: (event: KeyboardEvent) => void
184
- keyup: (event: KeyboardEvent) => void
185
- }
186
- > = new Map()
187
- #targetRegistrations: Map<Target, Set<string>> = new Map()
188
- #platform: 'mac' | 'windows' | 'linux'
189
-
190
- private constructor() {
191
- this.#platform = detectPlatform()
192
- }
193
-
194
- /**
195
- * Gets the singleton instance of SequenceManager.
196
- */
197
- static getInstance(): SequenceManager {
198
- if (!SequenceManager.#instance) {
199
- SequenceManager.#instance = new SequenceManager()
200
- }
201
- return SequenceManager.#instance
202
- }
203
-
204
- /**
205
- * Resets the singleton instance. Useful for testing.
206
- */
207
- static resetInstance(): void {
208
- if (SequenceManager.#instance) {
209
- SequenceManager.#instance.destroy()
210
- SequenceManager.#instance = null
211
- }
212
- }
213
-
214
- #syncRegistrationView(registration: SequenceRegistration): void {
215
- this.registrations.setState((prev) =>
216
- new Map(prev).set(registration.id, toRegistrationView(registration)),
217
- )
218
- }
219
-
220
- /**
221
- * Registers a hotkey sequence handler.
222
- *
223
- * @param sequence - Array of hotkey strings that form the sequence
224
- * @param callback - Function to call when the sequence is completed
225
- * @param options - Options for the sequence behavior
226
- * @returns A handle to update or unregister the sequence
227
- */
228
- register(
229
- sequence: HotkeySequence,
230
- callback: HotkeyCallback,
231
- options: SequenceOptions = {},
232
- ): SequenceRegistrationHandle {
233
- if (sequence.length === 0) {
234
- throw new Error('Sequence must contain at least one hotkey')
235
- }
236
-
237
- const id = generateSequenceId()
238
- const platform = options.platform ?? this.#platform
239
- const parsedSequence = sequence.map((hotkey) =>
240
- parseHotkey(hotkey, platform),
241
- )
242
-
243
- // Resolve target: default to document if not provided or null
244
- const target: Target =
245
- options.target ??
246
- (typeof document !== 'undefined' ? document : ({} as Document))
247
-
248
- // Resolve conflict behavior
249
- const conflictBehavior = options.conflictBehavior ?? 'warn'
250
-
251
- // Check for existing registrations with the same sequence and target
252
- const conflictingRegistration = this.#findConflictingSequence(
253
- parsedSequence,
254
- target,
255
- )
256
-
257
- if (conflictingRegistration) {
258
- handleConflict(
259
- conflictingRegistration.id,
260
- formatHotkeySequence(sequence),
261
- conflictBehavior,
262
- (regId) => this.#unregister(regId),
263
- )
264
- }
265
-
266
- const firstStep = parsedSequence[0]!
267
- const resolvedIgnoreInputs =
268
- options.ignoreInputs ?? getDefaultIgnoreInputs(firstStep)
269
-
270
- const baseOptions = {
271
- ...defaultHotkeyOptions,
272
- timeout: DEFAULT_SEQUENCE_TIMEOUT,
273
- ...options,
274
- platform,
275
- ignoreInputs: resolvedIgnoreInputs,
276
- }
277
-
278
- const registration: SequenceRegistration = {
279
- id,
280
- sequence,
281
- parsedSequence,
282
- callback,
283
- options: baseOptions,
284
- target,
285
- currentIndex: 0,
286
- lastKeyTime: 0,
287
- triggerCount: 0,
288
- hasFired: false,
289
- }
290
-
291
- this.#registrations.set(id, registration)
292
- this.registrations.setState((prev) =>
293
- new Map(prev).set(id, toRegistrationView(registration)),
294
- )
295
-
296
- // Track registration for this target
297
- if (!this.#targetRegistrations.has(target)) {
298
- this.#targetRegistrations.set(target, new Set())
299
- }
300
- this.#targetRegistrations.get(target)!.add(id)
301
-
302
- // Ensure listeners are attached for this target
303
- this.#ensureListenersForTarget(target)
304
-
305
- const manager = this
306
- const handle: SequenceRegistrationHandle = {
307
- get id() {
308
- return id
309
- },
310
- get isActive() {
311
- return manager.#registrations.has(id)
312
- },
313
- get callback() {
314
- const reg = manager.#registrations.get(id)
315
- return reg?.callback ?? callback
316
- },
317
- set callback(newCallback: HotkeyCallback) {
318
- const reg = manager.#registrations.get(id)
319
- if (reg) {
320
- reg.callback = newCallback
321
- }
322
- },
323
- setOptions: (newOptions: Partial<SequenceOptions>) => {
324
- const reg = manager.#registrations.get(id)
325
- if (
326
- reg &&
327
- !optionsEqual(reg.options, { ...reg.options, ...newOptions })
328
- ) {
329
- reg.options = { ...reg.options, ...newOptions }
330
- manager.registrations.setState((prev) =>
331
- new Map(prev).set(id, toRegistrationView(reg)),
332
- )
333
- }
334
- },
335
- unregister: () => {
336
- manager.#unregister(id)
337
- },
338
- }
339
-
340
- return handle
341
- }
342
-
343
- /**
344
- * Unregisters a sequence by its registration ID.
345
- */
346
- #unregister(id: string): void {
347
- const registration = this.#registrations.get(id)
348
- if (!registration) {
349
- return
350
- }
351
-
352
- const target = registration.target
353
-
354
- this.#registrations.delete(id)
355
- this.registrations.setState((prev) => {
356
- const next = new Map(prev)
357
- next.delete(id)
358
- return next
359
- })
360
-
361
- // Remove from target registrations tracking
362
- const targetRegs = this.#targetRegistrations.get(target)
363
- if (targetRegs) {
364
- targetRegs.delete(id)
365
- if (targetRegs.size === 0) {
366
- this.#removeListenersForTarget(target)
367
- }
368
- }
369
- }
370
-
371
- /**
372
- * Ensures event listeners are attached for a specific target.
373
- */
374
- #ensureListenersForTarget(target: Target): void {
375
- if (typeof document === 'undefined') {
376
- return // SSR safety
377
- }
378
-
379
- if (this.#targetListeners.has(target)) {
380
- return
381
- }
382
-
383
- const keydownHandler = this.#createTargetKeyDownHandler(target)
384
- const keyupHandler = this.#createTargetKeyUpHandler(target)
385
-
386
- target.addEventListener('keydown', keydownHandler as EventListener)
387
- target.addEventListener('keyup', keyupHandler as EventListener)
388
-
389
- this.#targetListeners.set(target, {
390
- keydown: keydownHandler,
391
- keyup: keyupHandler,
392
- })
393
- }
394
-
395
- /**
396
- * Removes event listeners for a specific target.
397
- */
398
- #removeListenersForTarget(target: Target): void {
399
- if (typeof document === 'undefined') {
400
- return
401
- }
402
-
403
- const listeners = this.#targetListeners.get(target)
404
- if (!listeners) {
405
- return
406
- }
407
-
408
- target.removeEventListener('keydown', listeners.keydown as EventListener)
409
- target.removeEventListener('keyup', listeners.keyup as EventListener)
410
-
411
- this.#targetListeners.delete(target)
412
- this.#targetRegistrations.delete(target)
413
- }
414
-
415
- /**
416
- * Creates a keydown handler for a specific target.
417
- */
418
- #createTargetKeyDownHandler(target: Target): (event: KeyboardEvent) => void {
419
- return (event: KeyboardEvent) => {
420
- this.#processTargetEvent(event, target, 'keydown')
421
- }
422
- }
423
-
424
- /**
425
- * Creates a keyup handler for a specific target.
426
- */
427
- #createTargetKeyUpHandler(target: Target): (event: KeyboardEvent) => void {
428
- return (event: KeyboardEvent) => {
429
- this.#processTargetEvent(event, target, 'keyup')
430
- }
431
- }
432
-
433
- /**
434
- * Processes keyboard events for a specific target and event type.
435
- */
436
- #processTargetEvent(
437
- event: KeyboardEvent,
438
- target: Target,
439
- eventType: 'keydown' | 'keyup',
440
- ): void {
441
- if (isRecordingEvent(event)) {
442
- for (const id of this.#targetRegistrations.get(target) ?? []) {
443
- const registration = this.#registrations.get(id)
444
- if (registration && registration.currentIndex > 0) {
445
- registration.currentIndex = 0
446
- this.#syncRegistrationView(registration)
447
- }
448
- }
449
- return
450
- }
451
- if (
452
- (event.type === 'keydown' && event.repeat) ||
453
- normalizeKeyboardEvent(event).isComposing
454
- ) {
455
- return
456
- }
457
- // Skip modifier-only events so pressing e.g. Shift before Shift+C does not reset the sequence.
458
- if (isModifierKey(normalizeKeyName(event.key))) {
459
- return
460
- }
461
-
462
- const targetRegs = this.#targetRegistrations.get(target)
463
- if (!targetRegs) {
464
- return
465
- }
466
-
467
- const now = Date.now()
468
- // Score eligible next steps (or restarts) before advancing any sequence.
469
- const candidates = new Map<string, { score: number; nextIndex: number }>()
470
- let bestScore = 0
471
-
472
- registrationIds: for (const id of targetRegs) {
473
- const registration = this.#registrations.get(id)
474
- if (!registration) {
475
- continue
476
- }
477
-
478
- if (!isEventForTarget(event, target)) {
479
- continue
480
- }
481
-
482
- if (!registration.options.enabled) {
483
- continue
484
- }
485
-
486
- // Check if we should ignore input elements (defaults to true)
487
- if (registration.options.ignoreInputs !== false) {
488
- if (shouldIgnoreInputEvent(event, target, registration.target)) {
489
- continue registrationIds
490
- }
491
- }
492
-
493
- // Only process registrations that listen for this event type
494
- if (registration.options.eventType !== eventType) {
495
- continue
496
- }
497
-
498
- const timeout = registration.options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT
499
-
500
- // Check if sequence has timed out
501
- if (
502
- registration.currentIndex > 0 &&
503
- now - registration.lastKeyTime > timeout
504
- ) {
505
- registration.currentIndex = 0
506
- this.#syncRegistrationView(registration)
507
- }
508
-
509
- const expectedHotkey =
510
- registration.parsedSequence[registration.currentIndex]
511
- if (!expectedHotkey) {
512
- continue
513
- }
514
-
515
- let match = matchKeyboardEvent(
516
- event,
517
- expectedHotkey,
518
- registration.options.platform,
519
- )
520
- let nextIndex = registration.currentIndex + 1
521
- if (!match.matched && registration.currentIndex > 0) {
522
- match = matchKeyboardEvent(
523
- event,
524
- registration.parsedSequence[0]!,
525
- registration.options.platform,
526
- )
527
- nextIndex = 1
528
- }
529
- candidates.set(id, { score: match.score, nextIndex })
530
- bestScore = Math.max(bestScore, match.score)
531
- }
532
-
533
- for (const [id, candidate] of candidates) {
534
- const registration = this.#registrations.get(id)
535
- if (!registration || !registration.options.enabled) continue
536
- if (candidate.score > 0 && candidate.score === bestScore) {
537
- registration.lastKeyTime = now
538
- registration.currentIndex = candidate.nextIndex
539
-
540
- if (registration.currentIndex >= registration.parsedSequence.length) {
541
- if (registration.options.preventDefault) {
542
- event.preventDefault()
543
- }
544
- if (registration.options.stopPropagation) {
545
- event.stopPropagation()
546
- }
547
-
548
- const context: HotkeyCallbackContext = {
549
- hotkey: registration.sequence.join(' ') as Hotkey,
550
- parsedHotkey:
551
- registration.parsedSequence[
552
- registration.parsedSequence.length - 1
553
- ]!,
554
- }
555
-
556
- registration.triggerCount++
557
- registration.hasFired = true
558
- registration.currentIndex = 0
559
-
560
- this.#syncRegistrationView(registration)
561
-
562
- registration.callback(event, context)
563
- } else {
564
- this.#syncRegistrationView(registration)
565
- }
566
- } else if (registration.currentIndex > 0) {
567
- // A mismatched or weaker candidate cannot carry stale progress forward.
568
- registration.currentIndex = 0
569
- this.#syncRegistrationView(registration)
570
- }
571
- }
572
- }
573
-
574
- /**
575
- * Compares resolved step identities without changing the supplied sequence strings.
576
- */
577
- #findConflictingSequence(
578
- parsedSequence: Array<ParsedHotkey>,
579
- target: Target,
580
- ): SequenceRegistration | null {
581
- for (const registration of this.#registrations.values()) {
582
- if (
583
- registration.target === target &&
584
- registration.parsedSequence.length === parsedSequence.length &&
585
- parsedSequence.every((step, index) =>
586
- areHotkeysEqual(step, registration.parsedSequence[index]!),
587
- )
588
- ) {
589
- return registration
590
- }
591
- }
592
- return null
593
- }
594
-
595
- /**
596
- * Resets all sequence progress.
597
- */
598
- resetAll(): void {
599
- for (const registration of this.#registrations.values()) {
600
- registration.currentIndex = 0
601
- registration.lastKeyTime = 0
602
- this.#syncRegistrationView(registration)
603
- }
604
- }
605
-
606
- /**
607
- * Triggers a sequence's callback programmatically from devtools.
608
- * Creates a synthetic KeyboardEvent from the last key in the sequence.
609
- *
610
- * @param id - The registration ID to trigger
611
- * @returns True if the registration was found and triggered
612
- */
613
- triggerSequence(id: string): boolean {
614
- const registration = this.#registrations.get(id)
615
- if (!registration) {
616
- return false
617
- }
618
-
619
- const lastParsed =
620
- registration.parsedSequence[registration.parsedSequence.length - 1]
621
- if (!lastParsed) {
622
- return false
623
- }
624
-
625
- const syntheticEvent = new KeyboardEvent(
626
- registration.options.eventType ?? 'keydown',
627
- {
628
- ...(lastParsed.code !== undefined
629
- ? { code: lastParsed.code }
630
- : { key: lastParsed.key }),
631
- ctrlKey: lastParsed.ctrl,
632
- shiftKey: lastParsed.shift,
633
- altKey: lastParsed.alt,
634
- metaKey: lastParsed.meta,
635
- bubbles: true,
636
- cancelable: true,
637
- },
638
- )
639
-
640
- registration.triggerCount++
641
- registration.hasFired = true
642
-
643
- this.#syncRegistrationView(registration)
644
-
645
- const context: HotkeyCallbackContext = {
646
- hotkey: registration.sequence.join(' ') as Hotkey,
647
- parsedHotkey: lastParsed,
648
- }
649
-
650
- registration.callback(syntheticEvent, context)
651
-
652
- return true
653
- }
654
-
655
- /**
656
- * Gets the number of registered sequences.
657
- */
658
- getRegistrationCount(): number {
659
- return this.#registrations.size
660
- }
661
-
662
- /**
663
- * Destroys the manager and removes all listeners.
664
- */
665
- destroy(): void {
666
- for (const target of this.#targetListeners.keys()) {
667
- this.#removeListenersForTarget(target)
668
- }
669
- this.#registrations.clear()
670
- this.registrations.setState(() => new Map())
671
- }
672
- }
673
-
674
- /**
675
- * Gets the singleton SequenceManager instance.
676
- * Convenience function for accessing the manager.
677
- */
678
- export function getSequenceManager(): SequenceManager {
679
- return SequenceManager.getInstance()
680
- }
681
-
682
- /**
683
- * Creates a simple sequence matcher for one-off use.
684
- *
685
- * This is a low-level helper that does not support ignoreInputs, target,
686
- * or other HotkeyOptions. Callers must handle input filtering themselves
687
- * if attaching to document.
688
- *
689
- * @param sequence - The sequence of hotkeys to match
690
- * @param options - Options including timeout
691
- * @returns An object with match() and reset() methods
692
- *
693
- * @example
694
- * ```ts
695
- * const matcher = createSequenceMatcher(['G', 'G'], { timeout: 500 })
696
- *
697
- * document.addEventListener('keydown', (event) => {
698
- * if (matcher.match(event)) {
699
- * console.log('Sequence matched!')
700
- * }
701
- * })
702
- * ```
703
- */
704
- export function createSequenceMatcher(
705
- sequence: HotkeySequence,
706
- options: { timeout?: number; platform?: 'mac' | 'windows' | 'linux' } = {},
707
- ): {
708
- match: (event: KeyboardEvent) => boolean
709
- reset: () => void
710
- getProgress: () => number
711
- } {
712
- const platform = options.platform ?? detectPlatform()
713
- const timeout = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT
714
- const parsedSequence = sequence.map((hotkey) => parseHotkey(hotkey, platform))
715
-
716
- let currentIndex = 0
717
- let lastKeyTime = 0
718
-
719
- return {
720
- match(event: KeyboardEvent): boolean {
721
- // These events are not sequence steps and must not refresh the timeout.
722
- if (
723
- (event.type === 'keydown' && event.repeat) ||
724
- normalizeKeyboardEvent(event, platform).isComposing ||
725
- isModifierKey(normalizeKeyName(event.key))
726
- ) {
727
- return false
728
- }
729
- const now = Date.now()
730
-
731
- if (currentIndex > 0 && now - lastKeyTime > timeout) {
732
- currentIndex = 0
733
- }
734
-
735
- const expected = parsedSequence[currentIndex]
736
- if (!expected) {
737
- return false
738
- }
739
-
740
- if (matchesKeyboardEvent(event, expected, platform)) {
741
- lastKeyTime = now
742
- currentIndex++
743
-
744
- if (currentIndex >= parsedSequence.length) {
745
- currentIndex = 0
746
- return true
747
- }
748
- } else if (currentIndex > 0) {
749
- if (matchesKeyboardEvent(event, parsedSequence[0]!, platform)) {
750
- currentIndex = 1
751
- lastKeyTime = now
752
- } else {
753
- currentIndex = 0
754
- }
755
- }
756
-
757
- return false
758
- },
759
-
760
- reset(): void {
761
- currentIndex = 0
762
- lastKeyTime = 0
763
- },
764
-
765
- getProgress(): number {
766
- return currentIndex
767
- },
768
- }
769
- }