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