@tanstack/hotkeys 0.2.0 → 0.3.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.
@@ -0,0 +1,693 @@
1
+ import { Store } from '@tanstack/store'
2
+ import { formatHotkeySequence } from './format'
3
+ import { detectPlatform } from './constants'
4
+ import { parseHotkey } from './parse'
5
+ import { matchesKeyboardEvent } from './match'
6
+ import {
7
+ defaultHotkeyOptions,
8
+ getDefaultIgnoreInputs,
9
+ handleConflict,
10
+ isEventForTarget,
11
+ isInputElement,
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
+ * @example
36
+ * ```ts
37
+ * const gotoTop: HotkeySequence = ['G', 'G'] // gg
38
+ * const deleteLine: HotkeySequence = ['D', 'D'] // dd
39
+ * const deleteWord: HotkeySequence = ['D', 'I', 'W'] // diw
40
+ * ```
41
+ */
42
+ export type HotkeySequence = Array<Hotkey>
43
+
44
+ /**
45
+ * Default timeout between keys in a sequence (in milliseconds).
46
+ */
47
+ const DEFAULT_SEQUENCE_TIMEOUT = 1000
48
+
49
+ let sequenceIdCounter = 0
50
+
51
+ /**
52
+ * Generates a unique ID for sequence registrations.
53
+ */
54
+ function generateSequenceId(): string {
55
+ return `sequence_${++sequenceIdCounter}`
56
+ }
57
+
58
+ /**
59
+ * Returns a canonical string for sequence conflict comparison.
60
+ */
61
+ function sequenceKey(sequence: HotkeySequence): string {
62
+ return sequence.join('|')
63
+ }
64
+
65
+ /**
66
+ * View of a sequence registration for devtools display.
67
+ * Excludes internal matching state (currentIndex, lastKeyTime).
68
+ */
69
+ export interface SequenceRegistrationView {
70
+ id: string
71
+ sequence: HotkeySequence
72
+ options: SequenceOptions
73
+ target: Target
74
+ triggerCount: number
75
+ }
76
+
77
+ /**
78
+ * Internal representation of a sequence registration.
79
+ */
80
+ interface SequenceRegistration {
81
+ id: string
82
+ sequence: HotkeySequence
83
+ parsedSequence: Array<ParsedHotkey>
84
+ callback: HotkeyCallback
85
+ options: SequenceOptions
86
+ target: Target
87
+ currentIndex: number
88
+ lastKeyTime: number
89
+ triggerCount: number
90
+ }
91
+
92
+ /**
93
+ * A handle returned from SequenceManager.register() that allows updating
94
+ * the callback and options without re-registering the sequence.
95
+ *
96
+ * @example
97
+ * ```ts
98
+ * const handle = manager.register(['G', 'G'], callback, options)
99
+ *
100
+ * handle.callback = newCallback
101
+ * handle.setOptions({ timeout: 500 })
102
+ * handle.unregister()
103
+ * ```
104
+ */
105
+ export interface SequenceRegistrationHandle {
106
+ readonly id: string
107
+ readonly isActive: boolean
108
+ callback: HotkeyCallback
109
+ setOptions: (options: Partial<SequenceOptions>) => void
110
+ unregister: () => void
111
+ }
112
+
113
+ /**
114
+ * Manages keyboard sequence matching for Vim-style shortcuts.
115
+ *
116
+ * This class allows registering multi-key sequences like 'g g' or 'd d'
117
+ * that trigger callbacks when the full sequence is pressed within
118
+ * a configurable timeout.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * const matcher = SequenceManager.getInstance()
123
+ *
124
+ * // Register 'g g' to go to top
125
+ * const unregister = matcher.register(['G', 'G'], (event, context) => {
126
+ * scrollToTop()
127
+ * }, { timeout: 500 })
128
+ *
129
+ * // Later, to unregister:
130
+ * unregister()
131
+ * ```
132
+ */
133
+ /**
134
+ * Builds a devtools view from an internal registration.
135
+ */
136
+ function toRegistrationView(
137
+ reg: SequenceRegistration,
138
+ ): SequenceRegistrationView {
139
+ return {
140
+ id: reg.id,
141
+ sequence: reg.sequence,
142
+ options: reg.options,
143
+ target: reg.target,
144
+ triggerCount: reg.triggerCount,
145
+ }
146
+ }
147
+
148
+ export class SequenceManager {
149
+ static #instance: SequenceManager | null = null
150
+
151
+ /**
152
+ * The TanStack Store containing sequence registration views for devtools.
153
+ * Subscribe to this to observe registration changes.
154
+ */
155
+ readonly registrations: Store<Map<string, SequenceRegistrationView>> =
156
+ new Store(new Map())
157
+
158
+ #registrations: Map<string, SequenceRegistration> = new Map()
159
+ #targetListeners: Map<
160
+ Target,
161
+ {
162
+ keydown: (event: KeyboardEvent) => void
163
+ keyup: (event: KeyboardEvent) => void
164
+ }
165
+ > = new Map()
166
+ #targetRegistrations: Map<Target, Set<string>> = new Map()
167
+ #platform: 'mac' | 'windows' | 'linux'
168
+
169
+ private constructor() {
170
+ this.#platform = detectPlatform()
171
+ }
172
+
173
+ /**
174
+ * Gets the singleton instance of SequenceManager.
175
+ */
176
+ static getInstance(): SequenceManager {
177
+ if (!SequenceManager.#instance) {
178
+ SequenceManager.#instance = new SequenceManager()
179
+ }
180
+ return SequenceManager.#instance
181
+ }
182
+
183
+ /**
184
+ * Resets the singleton instance. Useful for testing.
185
+ */
186
+ static resetInstance(): void {
187
+ if (SequenceManager.#instance) {
188
+ SequenceManager.#instance.destroy()
189
+ SequenceManager.#instance = null
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Registers a hotkey sequence handler.
195
+ *
196
+ * @param sequence - Array of hotkey strings that form the sequence
197
+ * @param callback - Function to call when the sequence is completed
198
+ * @param options - Options for the sequence behavior
199
+ * @returns A handle to update or unregister the sequence
200
+ */
201
+ register(
202
+ sequence: HotkeySequence,
203
+ callback: HotkeyCallback,
204
+ options: SequenceOptions = {},
205
+ ): SequenceRegistrationHandle {
206
+ if (sequence.length === 0) {
207
+ throw new Error('Sequence must contain at least one hotkey')
208
+ }
209
+
210
+ const id = generateSequenceId()
211
+ const platform = options.platform ?? this.#platform
212
+ const parsedSequence = sequence.map((hotkey) =>
213
+ parseHotkey(hotkey, platform),
214
+ )
215
+
216
+ // Resolve target: default to document if not provided or null
217
+ const target: Target =
218
+ options.target ??
219
+ (typeof document !== 'undefined' ? document : ({} as Document))
220
+
221
+ // Resolve conflict behavior
222
+ const conflictBehavior = options.conflictBehavior ?? 'warn'
223
+
224
+ // Check for existing registrations with the same sequence and target
225
+ const conflictingRegistration = this.#findConflictingSequence(
226
+ sequence,
227
+ target,
228
+ )
229
+
230
+ if (conflictingRegistration) {
231
+ handleConflict(
232
+ conflictingRegistration.id,
233
+ formatHotkeySequence(sequence),
234
+ conflictBehavior,
235
+ (regId) => this.#unregister(regId),
236
+ )
237
+ }
238
+
239
+ const firstStep = parsedSequence[0]!
240
+ const resolvedIgnoreInputs =
241
+ options.ignoreInputs ?? getDefaultIgnoreInputs(firstStep)
242
+
243
+ const baseOptions = {
244
+ ...defaultHotkeyOptions,
245
+ timeout: DEFAULT_SEQUENCE_TIMEOUT,
246
+ ...options,
247
+ platform,
248
+ ignoreInputs: resolvedIgnoreInputs,
249
+ }
250
+
251
+ const registration: SequenceRegistration = {
252
+ id,
253
+ sequence,
254
+ parsedSequence,
255
+ callback,
256
+ options: baseOptions,
257
+ target,
258
+ currentIndex: 0,
259
+ lastKeyTime: 0,
260
+ triggerCount: 0,
261
+ }
262
+
263
+ this.#registrations.set(id, registration)
264
+ this.registrations.setState((prev) =>
265
+ new Map(prev).set(id, toRegistrationView(registration)),
266
+ )
267
+
268
+ // Track registration for this target
269
+ if (!this.#targetRegistrations.has(target)) {
270
+ this.#targetRegistrations.set(target, new Set())
271
+ }
272
+ this.#targetRegistrations.get(target)!.add(id)
273
+
274
+ // Ensure listeners are attached for this target
275
+ this.#ensureListenersForTarget(target)
276
+
277
+ const manager = this
278
+ const handle: SequenceRegistrationHandle = {
279
+ get id() {
280
+ return id
281
+ },
282
+ get isActive() {
283
+ return manager.#registrations.has(id)
284
+ },
285
+ get callback() {
286
+ const reg = manager.#registrations.get(id)
287
+ return reg?.callback ?? callback
288
+ },
289
+ set callback(newCallback: HotkeyCallback) {
290
+ const reg = manager.#registrations.get(id)
291
+ if (reg) {
292
+ reg.callback = newCallback
293
+ }
294
+ },
295
+ setOptions: (newOptions: Partial<SequenceOptions>) => {
296
+ const reg = manager.#registrations.get(id)
297
+ if (reg) {
298
+ reg.options = { ...reg.options, ...newOptions }
299
+ manager.registrations.setState((prev) =>
300
+ new Map(prev).set(id, toRegistrationView(reg)),
301
+ )
302
+ }
303
+ },
304
+ unregister: () => {
305
+ manager.#unregister(id)
306
+ },
307
+ }
308
+
309
+ return handle
310
+ }
311
+
312
+ /**
313
+ * Unregisters a sequence by its registration ID.
314
+ */
315
+ #unregister(id: string): void {
316
+ const registration = this.#registrations.get(id)
317
+ if (!registration) {
318
+ return
319
+ }
320
+
321
+ const target = registration.target
322
+
323
+ this.#registrations.delete(id)
324
+ this.registrations.setState((prev) => {
325
+ const next = new Map(prev)
326
+ next.delete(id)
327
+ return next
328
+ })
329
+
330
+ // Remove from target registrations tracking
331
+ const targetRegs = this.#targetRegistrations.get(target)
332
+ if (targetRegs) {
333
+ targetRegs.delete(id)
334
+ if (targetRegs.size === 0) {
335
+ this.#removeListenersForTarget(target)
336
+ }
337
+ }
338
+ }
339
+
340
+ /**
341
+ * Ensures event listeners are attached for a specific target.
342
+ */
343
+ #ensureListenersForTarget(target: Target): void {
344
+ if (typeof document === 'undefined') {
345
+ return // SSR safety
346
+ }
347
+
348
+ if (this.#targetListeners.has(target)) {
349
+ return
350
+ }
351
+
352
+ const keydownHandler = this.#createTargetKeyDownHandler(target)
353
+ const keyupHandler = this.#createTargetKeyUpHandler(target)
354
+
355
+ target.addEventListener('keydown', keydownHandler as EventListener)
356
+ target.addEventListener('keyup', keyupHandler as EventListener)
357
+
358
+ this.#targetListeners.set(target, {
359
+ keydown: keydownHandler,
360
+ keyup: keyupHandler,
361
+ })
362
+ }
363
+
364
+ /**
365
+ * Removes event listeners for a specific target.
366
+ */
367
+ #removeListenersForTarget(target: Target): void {
368
+ if (typeof document === 'undefined') {
369
+ return
370
+ }
371
+
372
+ const listeners = this.#targetListeners.get(target)
373
+ if (!listeners) {
374
+ return
375
+ }
376
+
377
+ target.removeEventListener('keydown', listeners.keydown as EventListener)
378
+ target.removeEventListener('keyup', listeners.keyup as EventListener)
379
+
380
+ this.#targetListeners.delete(target)
381
+ this.#targetRegistrations.delete(target)
382
+ }
383
+
384
+ /**
385
+ * Creates a keydown handler for a specific target.
386
+ */
387
+ #createTargetKeyDownHandler(target: Target): (event: KeyboardEvent) => void {
388
+ return (event: KeyboardEvent) => {
389
+ this.#processTargetEvent(event, target, 'keydown')
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Creates a keyup handler for a specific target.
395
+ */
396
+ #createTargetKeyUpHandler(target: Target): (event: KeyboardEvent) => void {
397
+ return (event: KeyboardEvent) => {
398
+ this.#processTargetEvent(event, target, 'keyup')
399
+ }
400
+ }
401
+
402
+ /**
403
+ * Processes keyboard events for a specific target and event type.
404
+ */
405
+ #processTargetEvent(
406
+ event: KeyboardEvent,
407
+ target: Target,
408
+ eventType: 'keydown' | 'keyup',
409
+ ): void {
410
+ const targetRegs = this.#targetRegistrations.get(target)
411
+ if (!targetRegs) {
412
+ return
413
+ }
414
+
415
+ const now = Date.now()
416
+
417
+ for (const id of targetRegs) {
418
+ const registration = this.#registrations.get(id)
419
+ if (!registration) {
420
+ continue
421
+ }
422
+
423
+ if (!isEventForTarget(event, target)) {
424
+ continue
425
+ }
426
+
427
+ if (!registration.options.enabled) {
428
+ continue
429
+ }
430
+
431
+ // Check if we should ignore input elements (defaults to true)
432
+ if (registration.options.ignoreInputs !== false) {
433
+ if (isInputElement(event.target)) {
434
+ // Don't ignore if the sequence is explicitly scoped to this input element
435
+ if (event.target !== registration.target) {
436
+ continue
437
+ }
438
+ }
439
+ }
440
+
441
+ // Only process registrations that listen for this event type
442
+ if (registration.options.eventType !== eventType) {
443
+ continue
444
+ }
445
+
446
+ const timeout = registration.options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT
447
+
448
+ // Check if sequence has timed out
449
+ if (
450
+ registration.currentIndex > 0 &&
451
+ now - registration.lastKeyTime > timeout
452
+ ) {
453
+ registration.currentIndex = 0
454
+ }
455
+
456
+ const expectedHotkey =
457
+ registration.parsedSequence[registration.currentIndex]
458
+ if (!expectedHotkey) {
459
+ continue
460
+ }
461
+
462
+ if (
463
+ matchesKeyboardEvent(
464
+ event,
465
+ expectedHotkey,
466
+ registration.options.platform,
467
+ )
468
+ ) {
469
+ registration.lastKeyTime = now
470
+ registration.currentIndex++
471
+
472
+ if (registration.currentIndex >= registration.parsedSequence.length) {
473
+ if (registration.options.preventDefault) {
474
+ event.preventDefault()
475
+ }
476
+ if (registration.options.stopPropagation) {
477
+ event.stopPropagation()
478
+ }
479
+
480
+ const context: HotkeyCallbackContext = {
481
+ hotkey: registration.sequence.join(' ') as Hotkey,
482
+ parsedHotkey:
483
+ registration.parsedSequence[
484
+ registration.parsedSequence.length - 1
485
+ ]!,
486
+ }
487
+
488
+ registration.callback(event, context)
489
+
490
+ registration.currentIndex = 0
491
+ }
492
+ } else if (registration.currentIndex > 0) {
493
+ const firstHotkey = registration.parsedSequence[0]!
494
+ if (
495
+ matchesKeyboardEvent(
496
+ event,
497
+ firstHotkey,
498
+ registration.options.platform,
499
+ )
500
+ ) {
501
+ registration.currentIndex = 1
502
+ registration.lastKeyTime = now
503
+ } else {
504
+ registration.currentIndex = 0
505
+ }
506
+ }
507
+ }
508
+ }
509
+
510
+ /**
511
+ * Finds an existing registration with the same sequence and target.
512
+ */
513
+ #findConflictingSequence(
514
+ sequence: HotkeySequence,
515
+ target: Target,
516
+ ): SequenceRegistration | null {
517
+ const key = sequenceKey(sequence)
518
+ for (const registration of this.#registrations.values()) {
519
+ if (
520
+ sequenceKey(registration.sequence) === key &&
521
+ registration.target === target
522
+ ) {
523
+ return registration
524
+ }
525
+ }
526
+ return null
527
+ }
528
+
529
+ /**
530
+ * Resets all sequence progress.
531
+ */
532
+ resetAll(): void {
533
+ for (const registration of this.#registrations.values()) {
534
+ registration.currentIndex = 0
535
+ registration.lastKeyTime = 0
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Triggers a sequence's callback programmatically from devtools.
541
+ * Creates a synthetic KeyboardEvent from the last key in the sequence.
542
+ *
543
+ * @param id - The registration ID to trigger
544
+ * @returns True if the registration was found and triggered
545
+ */
546
+ triggerSequence(id: string): boolean {
547
+ const registration = this.#registrations.get(id)
548
+ if (!registration) {
549
+ return false
550
+ }
551
+
552
+ const lastParsed =
553
+ registration.parsedSequence[registration.parsedSequence.length - 1]
554
+ if (!lastParsed) {
555
+ return false
556
+ }
557
+
558
+ const syntheticEvent = new KeyboardEvent(
559
+ registration.options.eventType ?? 'keydown',
560
+ {
561
+ key: lastParsed.key,
562
+ ctrlKey: lastParsed.ctrl,
563
+ shiftKey: lastParsed.shift,
564
+ altKey: lastParsed.alt,
565
+ metaKey: lastParsed.meta,
566
+ bubbles: true,
567
+ cancelable: true,
568
+ },
569
+ )
570
+
571
+ registration.triggerCount++
572
+
573
+ this.registrations.setState((prev) =>
574
+ new Map(prev).set(id, toRegistrationView(registration)),
575
+ )
576
+
577
+ const context: HotkeyCallbackContext = {
578
+ hotkey: registration.sequence.join(' ') as Hotkey,
579
+ parsedHotkey: lastParsed,
580
+ }
581
+
582
+ registration.callback(syntheticEvent, context)
583
+
584
+ return true
585
+ }
586
+
587
+ /**
588
+ * Gets the number of registered sequences.
589
+ */
590
+ getRegistrationCount(): number {
591
+ return this.#registrations.size
592
+ }
593
+
594
+ /**
595
+ * Destroys the manager and removes all listeners.
596
+ */
597
+ destroy(): void {
598
+ for (const target of this.#targetListeners.keys()) {
599
+ this.#removeListenersForTarget(target)
600
+ }
601
+ this.#registrations.clear()
602
+ this.registrations.setState(() => new Map())
603
+ }
604
+ }
605
+
606
+ /**
607
+ * Gets the singleton SequenceManager instance.
608
+ * Convenience function for accessing the manager.
609
+ */
610
+ export function getSequenceManager(): SequenceManager {
611
+ return SequenceManager.getInstance()
612
+ }
613
+
614
+ /**
615
+ * Creates a simple sequence matcher for one-off use.
616
+ *
617
+ * This is a low-level helper that does not support ignoreInputs, target,
618
+ * or other HotkeyOptions. Callers must handle input filtering themselves
619
+ * if attaching to document.
620
+ *
621
+ * @param sequence - The sequence of hotkeys to match
622
+ * @param options - Options including timeout
623
+ * @returns An object with match() and reset() methods
624
+ *
625
+ * @example
626
+ * ```ts
627
+ * const matcher = createSequenceMatcher(['G', 'G'], { timeout: 500 })
628
+ *
629
+ * document.addEventListener('keydown', (event) => {
630
+ * if (matcher.match(event)) {
631
+ * console.log('Sequence matched!')
632
+ * }
633
+ * })
634
+ * ```
635
+ */
636
+ export function createSequenceMatcher(
637
+ sequence: HotkeySequence,
638
+ options: { timeout?: number; platform?: 'mac' | 'windows' | 'linux' } = {},
639
+ ): {
640
+ match: (event: KeyboardEvent) => boolean
641
+ reset: () => void
642
+ getProgress: () => number
643
+ } {
644
+ const platform = options.platform ?? detectPlatform()
645
+ const timeout = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT
646
+ const parsedSequence = sequence.map((hotkey) => parseHotkey(hotkey, platform))
647
+
648
+ let currentIndex = 0
649
+ let lastKeyTime = 0
650
+
651
+ return {
652
+ match(event: KeyboardEvent): boolean {
653
+ const now = Date.now()
654
+
655
+ if (currentIndex > 0 && now - lastKeyTime > timeout) {
656
+ currentIndex = 0
657
+ }
658
+
659
+ const expected = parsedSequence[currentIndex]
660
+ if (!expected) {
661
+ return false
662
+ }
663
+
664
+ if (matchesKeyboardEvent(event, expected, platform)) {
665
+ lastKeyTime = now
666
+ currentIndex++
667
+
668
+ if (currentIndex >= parsedSequence.length) {
669
+ currentIndex = 0
670
+ return true
671
+ }
672
+ } else if (currentIndex > 0) {
673
+ if (matchesKeyboardEvent(event, parsedSequence[0]!, platform)) {
674
+ currentIndex = 1
675
+ lastKeyTime = now
676
+ } else {
677
+ currentIndex = 0
678
+ }
679
+ }
680
+
681
+ return false
682
+ },
683
+
684
+ reset(): void {
685
+ currentIndex = 0
686
+ lastKeyTime = 0
687
+ },
688
+
689
+ getProgress(): number {
690
+ return currentIndex
691
+ },
692
+ }
693
+ }