@tanstack/hotkeys 0.8.0 → 0.9.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 (158) hide show
  1. package/README.md +15 -1
  2. package/dist/{manager.utils.js → _event-target.cjs} +5 -41
  3. package/dist/_event-target.cjs.map +1 -0
  4. package/dist/{manager.utils.cjs → _event-target.js} +3 -47
  5. package/dist/_event-target.js.map +1 -0
  6. package/dist/_keyboard-event.cjs +83 -0
  7. package/dist/_keyboard-event.cjs.map +1 -0
  8. package/dist/_keyboard-event.js +76 -0
  9. package/dist/_keyboard-event.js.map +1 -0
  10. package/dist/_match.cjs +49 -0
  11. package/dist/_match.cjs.map +1 -0
  12. package/dist/_match.js +49 -0
  13. package/dist/_match.js.map +1 -0
  14. package/dist/_named-keys.cjs +68 -0
  15. package/dist/_named-keys.cjs.map +1 -0
  16. package/dist/_named-keys.d.cts +8 -0
  17. package/dist/_named-keys.d.ts +8 -0
  18. package/dist/_named-keys.js +66 -0
  19. package/dist/_named-keys.js.map +1 -0
  20. package/dist/_recorder-chord.cjs +44 -0
  21. package/dist/_recorder-chord.cjs.map +1 -0
  22. package/dist/_recorder-chord.js +43 -0
  23. package/dist/_recorder-chord.js.map +1 -0
  24. package/dist/_recording-guard.cjs +59 -0
  25. package/dist/_recording-guard.cjs.map +1 -0
  26. package/dist/_recording-guard.js +56 -0
  27. package/dist/_recording-guard.js.map +1 -0
  28. package/dist/_registration.cjs +54 -0
  29. package/dist/_registration.cjs.map +1 -0
  30. package/dist/_registration.js +50 -0
  31. package/dist/_registration.js.map +1 -0
  32. package/dist/conflicts.cjs +55 -0
  33. package/dist/conflicts.cjs.map +1 -0
  34. package/dist/conflicts.d.cts +39 -0
  35. package/dist/conflicts.d.ts +39 -0
  36. package/dist/conflicts.js +55 -0
  37. package/dist/conflicts.js.map +1 -0
  38. package/dist/constants.cjs +46 -171
  39. package/dist/constants.cjs.map +1 -1
  40. package/dist/constants.d.cts +4 -129
  41. package/dist/constants.d.ts +4 -129
  42. package/dist/constants.js +48 -164
  43. package/dist/constants.js.map +1 -1
  44. package/dist/display-labels.cjs +119 -0
  45. package/dist/display-labels.cjs.map +1 -0
  46. package/dist/display-labels.d.cts +93 -0
  47. package/dist/display-labels.d.ts +93 -0
  48. package/dist/display-labels.js +113 -0
  49. package/dist/display-labels.js.map +1 -0
  50. package/dist/format.cjs +30 -36
  51. package/dist/format.cjs.map +1 -1
  52. package/dist/format.d.cts +46 -7
  53. package/dist/format.d.ts +46 -7
  54. package/dist/format.js +31 -37
  55. package/dist/format.js.map +1 -1
  56. package/dist/hint.cjs +24 -0
  57. package/dist/hint.cjs.map +1 -0
  58. package/dist/hint.d.cts +23 -0
  59. package/dist/hint.d.ts +23 -0
  60. package/dist/hint.js +24 -0
  61. package/dist/hint.js.map +1 -0
  62. package/dist/hotkey-manager.cjs +53 -22
  63. package/dist/hotkey-manager.cjs.map +1 -1
  64. package/dist/hotkey-manager.d.cts +6 -2
  65. package/dist/hotkey-manager.d.ts +6 -2
  66. package/dist/hotkey-manager.js +49 -18
  67. package/dist/hotkey-manager.js.map +1 -1
  68. package/dist/hotkey-recorder.cjs +66 -10
  69. package/dist/hotkey-recorder.cjs.map +1 -1
  70. package/dist/hotkey-recorder.d.cts +5 -2
  71. package/dist/hotkey-recorder.d.ts +5 -2
  72. package/dist/hotkey-recorder.js +65 -9
  73. package/dist/hotkey-recorder.js.map +1 -1
  74. package/dist/hotkey-sequence-recorder.cjs +85 -22
  75. package/dist/hotkey-sequence-recorder.cjs.map +1 -1
  76. package/dist/hotkey-sequence-recorder.d.cts +9 -2
  77. package/dist/hotkey-sequence-recorder.d.ts +9 -2
  78. package/dist/hotkey-sequence-recorder.js +84 -21
  79. package/dist/hotkey-sequence-recorder.js.map +1 -1
  80. package/dist/{hotkey.d.ts → hotkey.types.d.cts} +55 -120
  81. package/dist/{hotkey.d.cts → hotkey.types.d.ts} +55 -120
  82. package/dist/index.cjs +18 -10
  83. package/dist/index.d.cts +14 -9
  84. package/dist/index.d.ts +14 -9
  85. package/dist/index.js +10 -6
  86. package/dist/key-state-tracker.cjs +24 -24
  87. package/dist/key-state-tracker.cjs.map +1 -1
  88. package/dist/key-state-tracker.js +24 -24
  89. package/dist/key-state-tracker.js.map +1 -1
  90. package/dist/key.types.d.cts +82 -0
  91. package/dist/key.types.d.ts +82 -0
  92. package/dist/match.cjs +21 -30
  93. package/dist/match.cjs.map +1 -1
  94. package/dist/match.d.cts +19 -3
  95. package/dist/match.d.ts +19 -3
  96. package/dist/match.js +19 -29
  97. package/dist/match.js.map +1 -1
  98. package/dist/parse.cjs +43 -26
  99. package/dist/parse.cjs.map +1 -1
  100. package/dist/parse.d.cts +10 -4
  101. package/dist/parse.d.ts +10 -4
  102. package/dist/parse.js +35 -19
  103. package/dist/parse.js.map +1 -1
  104. package/dist/platform.cjs +55 -0
  105. package/dist/platform.cjs.map +1 -0
  106. package/dist/platform.d.cts +44 -0
  107. package/dist/platform.d.ts +44 -0
  108. package/dist/platform.js +53 -0
  109. package/dist/platform.js.map +1 -0
  110. package/dist/recorder-options.d.cts +34 -0
  111. package/dist/recorder-options.d.ts +34 -0
  112. package/dist/sequence-manager.cjs +54 -28
  113. package/dist/sequence-manager.cjs.map +1 -1
  114. package/dist/sequence-manager.d.cts +18 -3
  115. package/dist/sequence-manager.d.ts +18 -3
  116. package/dist/sequence-manager.js +49 -23
  117. package/dist/sequence-manager.js.map +1 -1
  118. package/dist/validate.cjs +6 -10
  119. package/dist/validate.cjs.map +1 -1
  120. package/dist/validate.d.cts +13 -2
  121. package/dist/validate.d.ts +13 -2
  122. package/dist/validate.js +7 -11
  123. package/dist/validate.js.map +1 -1
  124. package/package.json +1 -1
  125. package/src/{manager.utils.ts → _event-target.ts} +0 -74
  126. package/src/_keyboard-event.ts +101 -0
  127. package/src/_match.ts +96 -0
  128. package/src/_named-keys.ts +62 -0
  129. package/src/_recorder-chord.ts +76 -0
  130. package/src/_recording-guard.ts +63 -0
  131. package/src/_registration.ts +79 -0
  132. package/src/conflicts.ts +125 -0
  133. package/src/constants.ts +48 -196
  134. package/src/display-labels.ts +123 -0
  135. package/src/format.ts +111 -52
  136. package/src/hint.ts +42 -0
  137. package/src/hotkey-manager.ts +95 -40
  138. package/src/hotkey-recorder.ts +91 -15
  139. package/src/hotkey-sequence-recorder.ts +110 -28
  140. package/src/{hotkey.ts → hotkey.types.ts} +57 -224
  141. package/src/index.ts +11 -5
  142. package/src/key-state-tracker.ts +36 -24
  143. package/src/key.types.ts +268 -0
  144. package/src/match.ts +41 -90
  145. package/src/parse.ts +67 -26
  146. package/src/platform.ts +66 -0
  147. package/src/recorder-options.ts +34 -0
  148. package/src/sequence-manager.ts +77 -42
  149. package/src/validate.ts +29 -21
  150. package/dist/manager.utils.cjs.map +0 -1
  151. package/dist/manager.utils.d.cts +0 -13
  152. package/dist/manager.utils.d.ts +0 -13
  153. package/dist/manager.utils.js.map +0 -1
  154. package/dist/recorder-chord.cjs +0 -18
  155. package/dist/recorder-chord.cjs.map +0 -1
  156. package/dist/recorder-chord.js +0 -18
  157. package/dist/recorder-chord.js.map +0 -1
  158. package/src/recorder-chord.ts +0 -25
@@ -1,24 +1,27 @@
1
1
  import { Store } from '@tanstack/store'
2
- import { detectPlatform, normalizeKeyName } from './constants'
2
+ import { isRecordingEvent } from './_recording-guard'
3
+ import { areHotkeysEqual } from './match'
4
+ import { detectPlatform } from './platform'
5
+ import { normalizeKeyName } from './constants'
3
6
  import { formatHotkey } from './format'
4
7
  import { parseHotkey, rawHotkeyToParsedHotkey } from './parse'
5
- import { matchesKeyboardEvent } from './match'
8
+ import { matchKeyboardEvent } from './_match'
6
9
  import {
7
10
  defaultHotkeyOptions,
8
11
  getDefaultIgnoreInputs,
9
12
  handleConflict,
10
- isEventForTarget,
11
- shouldIgnoreInputEvent,
12
- } from './manager.utils'
13
- import type { ConflictBehavior } from './manager.utils'
13
+ optionsEqual,
14
+ } from './_registration'
15
+ import { isEventForTarget, shouldIgnoreInputEvent } from './_event-target'
14
16
  import type {
17
+ ConflictBehavior,
15
18
  Hotkey,
16
19
  HotkeyCallback,
17
20
  HotkeyCallbackContext,
18
21
  HotkeyMeta,
19
22
  ParsedHotkey,
20
23
  RegisterableHotkey,
21
- } from './hotkey'
24
+ } from './hotkey.types'
22
25
 
23
26
  export type { ConflictBehavior }
24
27
 
@@ -60,6 +63,8 @@ export interface HotkeyRegistration {
60
63
  callback: HotkeyCallback
61
64
  /** Whether this registration has fired and needs reset (for requireReset) */
62
65
  hasFired: boolean
66
+ /** The concrete key/code that activated a requireReset registration. */
67
+ activeMatch?: { key: string; code: string }
63
68
  /** The original hotkey string */
64
69
  hotkey: Hotkey
65
70
  /** Unique identifier for this registration */
@@ -378,7 +383,10 @@ export class HotkeyManager {
378
383
  setOptions: (newOptions: Partial<HotkeyOptions>) => {
379
384
  manager.registrations.setState((prev) => {
380
385
  const reg = prev.get(id)
381
- if (reg) {
386
+ if (
387
+ reg &&
388
+ !optionsEqual(reg.options, { ...reg.options, ...newOptions })
389
+ ) {
382
390
  const next = new Map(prev)
383
391
  next.set(id, { ...reg, options: { ...reg.options, ...newOptions } })
384
392
  return next
@@ -476,11 +484,52 @@ export class HotkeyManager {
476
484
  target: HTMLElement | Document | Window,
477
485
  eventType: 'keydown' | 'keyup',
478
486
  ): void {
487
+ // Releases clear latches even when focus or enabled state now prevents callbacks.
488
+ if (eventType === 'keyup') {
489
+ for (const id of this.#targetRegistrations.get(target) ?? []) {
490
+ const registration = this.registrations.state.get(id)
491
+ if (
492
+ registration?.hasFired &&
493
+ this.#shouldResetRegistration(registration, event)
494
+ ) {
495
+ registration.hasFired = false
496
+ registration.activeMatch = undefined
497
+ }
498
+ }
499
+ }
500
+ if (isRecordingEvent(event)) return
479
501
  const targetRegs = this.#targetRegistrations.get(target)
480
502
  if (!targetRegs) {
481
503
  return
482
504
  }
483
505
 
506
+ const matches = new Map<string, ReturnType<typeof matchKeyboardEvent>>()
507
+ let bestScore = 0
508
+
509
+ // Exact logical/explicit-code matches outrank compatibility fallbacks.
510
+ for (const id of targetRegs) {
511
+ const registration = this.registrations.state.get(id)
512
+ if (
513
+ !registration ||
514
+ !registration.options.enabled ||
515
+ registration.options.eventType !== eventType ||
516
+ !isEventForTarget(event, target) ||
517
+ (registration.options.ignoreInputs !== false &&
518
+ shouldIgnoreInputEvent(event, target, registration.target))
519
+ ) {
520
+ continue
521
+ }
522
+ const result = matchKeyboardEvent(
523
+ event,
524
+ registration.parsedHotkey,
525
+ registration.options.platform,
526
+ )
527
+ if (result.matched) {
528
+ matches.set(id, result)
529
+ bestScore = Math.max(bestScore, result.score)
530
+ }
531
+ }
532
+
484
533
  for (const id of targetRegs) {
485
534
  const registration = this.registrations.state.get(id)
486
535
  if (!registration) {
@@ -509,14 +558,9 @@ export class HotkeyManager {
509
558
  continue
510
559
  }
511
560
 
512
- // Check if the hotkey matches first
513
- const matches = matchesKeyboardEvent(
514
- event,
515
- registration.parsedHotkey,
516
- registration.options.platform,
517
- )
561
+ const match = matches.get(id)
518
562
 
519
- if (matches) {
563
+ if (match?.matched && match.score === bestScore) {
520
564
  // Always apply preventDefault/stopPropagation if the hotkey matches,
521
565
  // even when requireReset is active and has already fired
522
566
  if (registration.options.preventDefault) {
@@ -533,6 +577,7 @@ export class HotkeyManager {
533
577
  // Mark as fired if requireReset is enabled
534
578
  if (registration.options.requireReset) {
535
579
  registration.hasFired = true
580
+ registration.activeMatch = match.identity
536
581
  }
537
582
  }
538
583
  }
@@ -540,23 +585,11 @@ export class HotkeyManager {
540
585
  // Handle keyup events
541
586
  else {
542
587
  if (registration.options.eventType === 'keyup') {
543
- if (
544
- matchesKeyboardEvent(
545
- event,
546
- registration.parsedHotkey,
547
- registration.options.platform,
548
- )
549
- ) {
588
+ const match = matches.get(id)
589
+ if (match?.matched && match.score === bestScore) {
550
590
  this.#executeHotkeyCallback(registration, event)
551
591
  }
552
592
  }
553
-
554
- // Reset hasFired when any key in the hotkey is released
555
- if (registration.options.requireReset && registration.hasFired) {
556
- if (this.#shouldResetRegistration(registration, event)) {
557
- registration.hasFired = false
558
- }
559
- }
560
593
  }
561
594
  }
562
595
  }
@@ -620,7 +653,14 @@ export class HotkeyManager {
620
653
  target: HTMLElement | Document | Window,
621
654
  ): HotkeyRegistration | null {
622
655
  for (const registration of this.registrations.state.values()) {
623
- if (registration.hotkey === hotkey && registration.target === target) {
656
+ if (
657
+ areHotkeysEqual(
658
+ registration.hotkey,
659
+ hotkey,
660
+ registration.options.platform,
661
+ ) &&
662
+ registration.target === target
663
+ ) {
624
664
  return registration
625
665
  }
626
666
  }
@@ -637,17 +677,24 @@ export class HotkeyManager {
637
677
  const parsed = registration.parsedHotkey
638
678
  const releasedKey = normalizeKeyName(event.key)
639
679
 
640
- // Reset if the main key is released
641
- // Compare case-insensitively for single-letter keys
642
- const parsedKeyNormalized =
643
- parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key
644
- const releasedKeyNormalized =
645
- releasedKey.length === 1 ? releasedKey.toUpperCase() : releasedKey
646
-
647
- if (releasedKeyNormalized === parsedKeyNormalized) {
680
+ if (
681
+ registration.activeMatch?.code &&
682
+ event.code === registration.activeMatch.code
683
+ ) {
648
684
  return true
649
685
  }
650
686
 
687
+ // Compare the release against the identity this binding actually stores.
688
+ if (parsed.code !== undefined) {
689
+ if (event.code === parsed.code) return true
690
+ } else {
691
+ const parsedKeyNormalized =
692
+ parsed.key.length === 1 ? parsed.key.toUpperCase() : parsed.key
693
+ const releasedKeyNormalized =
694
+ releasedKey.length === 1 ? releasedKey.toUpperCase() : releasedKey
695
+ if (releasedKeyNormalized === parsedKeyNormalized) return true
696
+ }
697
+
651
698
  // Reset if any required modifier is released
652
699
  // Use normalized key names and check against canonical modifier names
653
700
  if (parsed.ctrl && releasedKey === 'Control') {
@@ -683,7 +730,9 @@ export class HotkeyManager {
683
730
  const syntheticEvent = new KeyboardEvent(
684
731
  registration.options.eventType ?? 'keydown',
685
732
  {
686
- key: parsed.key,
733
+ ...(parsed.code !== undefined
734
+ ? { code: parsed.code }
735
+ : { key: parsed.key }),
687
736
  ctrlKey: parsed.ctrl,
688
737
  shiftKey: parsed.shift,
689
738
  altKey: parsed.alt,
@@ -725,7 +774,13 @@ export class HotkeyManager {
725
774
  target?: HTMLElement | Document | Window,
726
775
  ): boolean {
727
776
  for (const registration of this.registrations.state.values()) {
728
- if (registration.hotkey === hotkey) {
777
+ if (
778
+ areHotkeysEqual(
779
+ registration.hotkey,
780
+ hotkey,
781
+ registration.options.platform,
782
+ )
783
+ ) {
729
784
  // If target is specified, both must match
730
785
  if (target === undefined || registration.target === target) {
731
786
  return true
@@ -1,8 +1,21 @@
1
1
  import { Store } from '@tanstack/store'
2
- import { detectPlatform } from './constants'
3
- import { isInputElement } from './manager.utils'
4
- import { hotkeyChordFromKeydown } from './recorder-chord'
5
- import type { Hotkey } from './hotkey'
2
+ import { findHotkeyConflicts } from './conflicts'
3
+ import {
4
+ beginRecording,
5
+ captureRecordingEvent,
6
+ endRecording,
7
+ } from './_recording-guard'
8
+ import { chordRejection, hotkeyChordFromKeydown } from './_recorder-chord'
9
+ import { normalizeKeyboardEvent } from './_keyboard-event'
10
+ import { isModifierKey, parseHotkey } from './parse'
11
+ import { validateHotkey } from './validate'
12
+ import { detectPlatform } from './platform'
13
+ import { shouldIgnoreInputEvent } from './_event-target'
14
+ import type {
15
+ HotkeyRecorderValidationContext,
16
+ RecorderOptions,
17
+ } from './recorder-options'
18
+ import type { Hotkey } from './hotkey.types'
6
19
 
7
20
  /**
8
21
  * State interface for the HotkeyRecorder.
@@ -17,7 +30,12 @@ export interface HotkeyRecorderState {
17
30
  /**
18
31
  * Options for configuring a HotkeyRecorder instance.
19
32
  */
20
- export interface HotkeyRecorderOptions {
33
+ export interface HotkeyRecorderOptions extends RecorderOptions {
34
+ /** Return true to accept, or false/a message to reject while staying in recording mode. */
35
+ validate?: (
36
+ hotkey: Hotkey,
37
+ context: HotkeyRecorderValidationContext,
38
+ ) => boolean | string
21
39
  /** Callback when a hotkey is successfully recorded */
22
40
  onRecord: (hotkey: Hotkey) => void
23
41
  /** Optional callback when recording is cancelled (Escape pressed) */
@@ -116,6 +134,7 @@ export class HotkeyRecorder {
116
134
  return
117
135
  }
118
136
 
137
+ beginRecording(this)
119
138
  // Update store state
120
139
  this.store.setState(() => ({
121
140
  isRecording: true,
@@ -132,16 +151,19 @@ export class HotkeyRecorder {
132
151
  // If ignoreInputs is enabled (default) and focus is in an input element,
133
152
  // let the event pass through so the user can type normally.
134
153
  // Escape is the exception — it should always cancel recording.
135
- if (this.#options.ignoreInputs !== false) {
136
- const activeEl =
137
- typeof document !== 'undefined' ? document.activeElement : null
138
- if (isInputElement(activeEl) && event.key !== 'Escape') {
139
- return
140
- }
141
- }
154
+ if (
155
+ this.#options.ignoreInputs !== false &&
156
+ event.key !== 'Escape' &&
157
+ shouldIgnoreInputEvent(event, document, document)
158
+ )
159
+ return
142
160
 
161
+ const platform = this.#options.platform ?? this.#platform
162
+ if (normalizeKeyboardEvent(event, platform).isComposing) return
163
+ captureRecordingEvent(event)
143
164
  event.preventDefault()
144
165
  event.stopPropagation()
166
+ if (event.repeat) return
145
167
 
146
168
  // Handle Escape to cancel
147
169
  if (event.key === 'Escape') {
@@ -157,18 +179,70 @@ export class HotkeyRecorder {
157
179
  !event.altKey &&
158
180
  !event.metaKey
159
181
  ) {
160
- this.#options.onClear?.()
161
- this.#options.onRecord('' as Hotkey)
162
182
  this.stop()
183
+ this.#options.onClear?.()
163
184
  return
164
185
  }
165
186
  }
166
187
 
167
- const finalHotkey = hotkeyChordFromKeydown(event, this.#platform)
188
+ if (isModifierKey(event.key) || event.key === 'AltGraph') return
189
+ const rejection = chordRejection(event, { ...this.#options, platform })
190
+ if (rejection) {
191
+ this.#options.onReject?.(rejection)
192
+ return
193
+ }
194
+ const finalHotkey = hotkeyChordFromKeydown(
195
+ event,
196
+ platform,
197
+ this.#options.recordBy,
198
+ )
168
199
  if (finalHotkey === null) {
169
200
  return
170
201
  }
171
202
 
203
+ const validation = validateHotkey(finalHotkey)
204
+ if (!validation.valid) {
205
+ this.#options.onReject?.({
206
+ reason: 'invalid',
207
+ message: validation.errors.join('; '),
208
+ hotkey: finalHotkey,
209
+ })
210
+ return
211
+ }
212
+ const accepted = this.#options.validate?.(finalHotkey, {
213
+ event,
214
+ parsedHotkey: parseHotkey(finalHotkey, platform),
215
+ })
216
+ if (accepted !== undefined && accepted !== true) {
217
+ this.#options.onReject?.({
218
+ reason: 'validation',
219
+ message:
220
+ typeof accepted === 'string'
221
+ ? accepted
222
+ : 'This shortcut is not allowed.',
223
+ hotkey: finalHotkey,
224
+ })
225
+ return
226
+ }
227
+ if (this.#options.detectConflicts) {
228
+ const conflicts = findHotkeyConflicts(finalHotkey, {
229
+ ...(typeof this.#options.detectConflicts === 'object'
230
+ ? this.#options.detectConflicts
231
+ : {}),
232
+ platform,
233
+ events: [event],
234
+ })
235
+ if (conflicts.length) {
236
+ this.#options.onReject?.({
237
+ reason: 'conflict',
238
+ message: 'This shortcut conflicts with a registered binding.',
239
+ hotkey: finalHotkey,
240
+ conflicts,
241
+ })
242
+ return
243
+ }
244
+ }
245
+ endRecording(this)
172
246
  // Remove listener FIRST to prevent any additional events
173
247
  const handlerToRemove = this.#keydownHandler as
174
248
  | ((event: KeyboardEvent) => void)
@@ -198,6 +272,7 @@ export class HotkeyRecorder {
198
272
  * Removes the event listener and resets the recording state.
199
273
  */
200
274
  stop(): void {
275
+ endRecording(this)
201
276
  // Remove event listener immediately
202
277
  if (this.#keydownHandler) {
203
278
  this.#removeListener(this.#keydownHandler)
@@ -218,6 +293,7 @@ export class HotkeyRecorder {
218
293
  * the onCancel callback if provided.
219
294
  */
220
295
  cancel(): void {
296
+ endRecording(this)
221
297
  // Remove event listener immediately
222
298
  if (this.#keydownHandler) {
223
299
  this.#removeListener(this.#keydownHandler)
@@ -1,7 +1,20 @@
1
1
  import { Store } from '@tanstack/store'
2
- import { detectPlatform } from './constants'
3
- import { isInputElement } from './manager.utils'
4
- import { hotkeyChordFromKeydown } from './recorder-chord'
2
+ import { findHotkeyConflicts } from './conflicts'
3
+ import {
4
+ beginRecording,
5
+ captureRecordingEvent,
6
+ endRecording,
7
+ } from './_recording-guard'
8
+ import { chordRejection, hotkeyChordFromKeydown } from './_recorder-chord'
9
+ import { normalizeKeyboardEvent } from './_keyboard-event'
10
+ import { isModifierKey, parseHotkey } from './parse'
11
+ import { validateHotkey } from './validate'
12
+ import { detectPlatform } from './platform'
13
+ import { shouldIgnoreInputEvent } from './_event-target'
14
+ import type {
15
+ HotkeySequenceRecorderValidationContext,
16
+ RecorderOptions,
17
+ } from './recorder-options'
5
18
  import type { HotkeySequence } from './sequence-manager'
6
19
 
7
20
  /**
@@ -26,8 +39,13 @@ export interface HotkeySequenceRecorderState {
26
39
  /**
27
40
  * Options for configuring a HotkeySequenceRecorder instance.
28
41
  */
29
- export interface HotkeySequenceRecorderOptions {
30
- /** Callback when a sequence is successfully recorded (including empty array when cleared) */
42
+ export interface HotkeySequenceRecorderOptions extends RecorderOptions {
43
+ /** Validate the completed sequence. Rejection preserves the steps for editing. */
44
+ validate?: (
45
+ sequence: HotkeySequence,
46
+ context: HotkeySequenceRecorderValidationContext,
47
+ ) => boolean | string
48
+ /** Callback when a sequence is successfully recorded */
31
49
  onRecord: (sequence: HotkeySequence) => void
32
50
  /** Optional callback when recording is cancelled (Escape pressed) */
33
51
  onCancel?: () => void
@@ -66,6 +84,7 @@ const defaultHotkeySequenceRecorderOptions: Pick<
66
84
  commitKeys: 'enter',
67
85
  }
68
86
 
87
+ /** Resolves the Enter policy, with commitKeys: none overriding the legacy boolean. */
69
88
  function resolvedCommitOnEnter(
70
89
  options: HotkeySequenceRecorderOptions,
71
90
  ): boolean {
@@ -93,6 +112,7 @@ export class HotkeySequenceRecorder {
93
112
  #keydownHandler: ((event: KeyboardEvent) => void) | null = null
94
113
  #options: HotkeySequenceRecorderOptions
95
114
  #platform: 'mac' | 'windows' | 'linux'
115
+ #events: Array<KeyboardEvent> = []
96
116
  #idleTimer: ReturnType<typeof setTimeout> | null = null
97
117
 
98
118
  constructor(options: HotkeySequenceRecorderOptions) {
@@ -103,6 +123,7 @@ export class HotkeySequenceRecorder {
103
123
  this.#platform = detectPlatform()
104
124
  }
105
125
 
126
+ /** Merges current callbacks and options without discarding recorded steps. */
106
127
  setOptions(options: Partial<HotkeySequenceRecorderOptions>): void {
107
128
  this.#options = {
108
129
  ...defaultHotkeySequenceRecorderOptions,
@@ -111,6 +132,7 @@ export class HotkeySequenceRecorder {
111
132
  }
112
133
  }
113
134
 
135
+ /** Cancels a pending idle commit so it cannot fire after editing or stopping. */
114
136
  #clearIdleTimer(): void {
115
137
  if (this.#idleTimer !== null) {
116
138
  clearTimeout(this.#idleTimer)
@@ -118,6 +140,7 @@ export class HotkeySequenceRecorder {
118
140
  }
119
141
  }
120
142
 
143
+ /** Restarts optional idle commit after a step is recorded or edited. */
121
144
  #scheduleIdleTimer(): void {
122
145
  this.#clearIdleTimer()
123
146
  const ms = this.#options.idleTimeoutMs
@@ -139,6 +162,7 @@ export class HotkeySequenceRecorder {
139
162
  }, ms)
140
163
  }
141
164
 
165
+ /** Publishes edited steps while preserving the remaining recorder state. */
142
166
  #patchSteps(updater: (prev: HotkeySequence) => HotkeySequence): void {
143
167
  this.store.setState((s) => ({
144
168
  ...s,
@@ -146,12 +170,15 @@ export class HotkeySequenceRecorder {
146
170
  }))
147
171
  }
148
172
 
173
+ /** Starts a fresh recording; repeated starts during an active session are ignored. */
149
174
  start(): void {
150
175
  if (this.#keydownHandler) {
151
176
  return
152
177
  }
153
178
 
154
179
  this.#clearIdleTimer()
180
+ this.#events = []
181
+ beginRecording(this)
155
182
  this.store.setState(() => ({
156
183
  isRecording: true,
157
184
  steps: [],
@@ -166,16 +193,19 @@ export class HotkeySequenceRecorder {
166
193
  // If ignoreInputs is enabled (default) and focus is in an input element,
167
194
  // let the event pass through so the user can type normally.
168
195
  // Escape is the exception — it should always cancel recording.
169
- if (this.#options.ignoreInputs !== false) {
170
- const activeEl =
171
- typeof document !== 'undefined' ? document.activeElement : null
172
- if (isInputElement(activeEl) && event.key !== 'Escape') {
173
- return
174
- }
175
- }
196
+ if (
197
+ this.#options.ignoreInputs !== false &&
198
+ event.key !== 'Escape' &&
199
+ shouldIgnoreInputEvent(event, document, document)
200
+ )
201
+ return
176
202
 
203
+ const platform = this.#options.platform ?? this.#platform
204
+ if (normalizeKeyboardEvent(event, platform).isComposing) return
205
+ captureRecordingEvent(event)
177
206
  event.preventDefault()
178
207
  event.stopPropagation()
208
+ if (event.repeat) return
179
209
 
180
210
  if (event.key === 'Escape') {
181
211
  this.cancel()
@@ -191,11 +221,11 @@ export class HotkeySequenceRecorder {
191
221
  ) {
192
222
  const steps = this.store.state.steps
193
223
  if (steps.length === 0) {
224
+ this.stop()
194
225
  this.#options.onClear?.()
195
- this.#options.onRecord([])
196
- this.#finishWithoutCommitCallback()
197
226
  return
198
227
  }
228
+ this.#events.pop()
199
229
  this.#patchSteps((prev) => prev.slice(0, -1))
200
230
  const next = this.store.state.steps
201
231
  if (next.length === 0) {
@@ -224,11 +254,31 @@ export class HotkeySequenceRecorder {
224
254
  return
225
255
  }
226
256
 
227
- const finalHotkey = hotkeyChordFromKeydown(event, this.#platform)
257
+ if (isModifierKey(event.key) || event.key === 'AltGraph') return
258
+ const rejection = chordRejection(event, { ...this.#options, platform })
259
+ if (rejection) {
260
+ this.#options.onReject?.(rejection)
261
+ return
262
+ }
263
+ const finalHotkey = hotkeyChordFromKeydown(
264
+ event,
265
+ platform,
266
+ this.#options.recordBy,
267
+ )
228
268
  if (finalHotkey === null) {
229
269
  return
230
270
  }
231
271
 
272
+ const validation = validateHotkey(finalHotkey)
273
+ if (!validation.valid) {
274
+ this.#options.onReject?.({
275
+ reason: 'invalid',
276
+ message: validation.errors.join('; '),
277
+ hotkey: finalHotkey,
278
+ })
279
+ return
280
+ }
281
+ this.#events.push(event)
232
282
  this.#patchSteps((prev) => [...prev, finalHotkey])
233
283
  this.#scheduleIdleTimer()
234
284
  }
@@ -247,6 +297,43 @@ export class HotkeySequenceRecorder {
247
297
  }
248
298
 
249
299
  const sequence = [...steps]
300
+ const platform = this.#options.platform ?? this.#platform
301
+ this.#clearIdleTimer()
302
+ const accepted = this.#options.validate?.(sequence, {
303
+ events: [...this.#events],
304
+ parsedSequence: sequence.map((step) => parseHotkey(step, platform)),
305
+ })
306
+ if (accepted !== undefined && accepted !== true) {
307
+ this.#options.onReject?.({
308
+ reason: 'validation',
309
+ message:
310
+ typeof accepted === 'string'
311
+ ? accepted
312
+ : 'This sequence is not allowed.',
313
+ sequence,
314
+ })
315
+ return
316
+ }
317
+ if (this.#options.detectConflicts) {
318
+ const conflicts = findHotkeyConflicts(sequence, {
319
+ ...(typeof this.#options.detectConflicts === 'object'
320
+ ? this.#options.detectConflicts
321
+ : {}),
322
+ platform,
323
+ events: this.#events,
324
+ })
325
+ if (conflicts.length) {
326
+ this.#options.onReject?.({
327
+ reason: 'conflict',
328
+ message: 'This sequence conflicts with a registered binding.',
329
+ sequence,
330
+ conflicts,
331
+ })
332
+ return
333
+ }
334
+ }
335
+ endRecording(this)
336
+ this.#events = []
250
337
 
251
338
  if (this.#keydownHandler) {
252
339
  this.#removeListener(this.#keydownHandler)
@@ -263,20 +350,10 @@ export class HotkeySequenceRecorder {
263
350
  this.#options.onRecord(sequence)
264
351
  }
265
352
 
266
- #finishWithoutCommitCallback(): void {
267
- if (this.#keydownHandler) {
268
- this.#removeListener(this.#keydownHandler)
269
- this.#keydownHandler = null
270
- }
271
- this.#clearIdleTimer()
272
- this.store.setState(() => ({
273
- isRecording: false,
274
- steps: [],
275
- recordedSequence: null,
276
- }))
277
- }
278
-
353
+ /** Stops and discards in-progress steps without invoking onCancel. */
279
354
  stop(): void {
355
+ endRecording(this)
356
+ this.#events = []
280
357
  if (this.#keydownHandler) {
281
358
  this.#removeListener(this.#keydownHandler)
282
359
  this.#keydownHandler = null
@@ -289,7 +366,10 @@ export class HotkeySequenceRecorder {
289
366
  }))
290
367
  }
291
368
 
369
+ /** Stops, discards in-progress steps, and notifies onCancel. */
292
370
  cancel(): void {
371
+ endRecording(this)
372
+ this.#events = []
293
373
  if (this.#keydownHandler) {
294
374
  this.#removeListener(this.#keydownHandler)
295
375
  this.#keydownHandler = null
@@ -303,6 +383,7 @@ export class HotkeySequenceRecorder {
303
383
  this.#options.onCancel?.()
304
384
  }
305
385
 
386
+ /** Captures keydowns before application handlers, with an SSR-safe document check. */
306
387
  #addListener(handler: (event: KeyboardEvent) => void): void {
307
388
  if (typeof document === 'undefined') {
308
389
  return
@@ -310,6 +391,7 @@ export class HotkeySequenceRecorder {
310
391
  document.addEventListener('keydown', handler, true)
311
392
  }
312
393
 
394
+ /** Detaches the capture listener when recording ends. */
313
395
  #removeListener(handler: (event: KeyboardEvent) => void): void {
314
396
  if (typeof document === 'undefined') {
315
397
  return