@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.
- package/README.md +15 -1
- package/dist/{manager.utils.js → _event-target.cjs} +5 -41
- package/dist/_event-target.cjs.map +1 -0
- package/dist/{manager.utils.cjs → _event-target.js} +3 -47
- package/dist/_event-target.js.map +1 -0
- package/dist/_keyboard-event.cjs +83 -0
- package/dist/_keyboard-event.cjs.map +1 -0
- package/dist/_keyboard-event.js +76 -0
- package/dist/_keyboard-event.js.map +1 -0
- package/dist/_match.cjs +49 -0
- package/dist/_match.cjs.map +1 -0
- package/dist/_match.js +49 -0
- package/dist/_match.js.map +1 -0
- package/dist/_named-keys.cjs +68 -0
- package/dist/_named-keys.cjs.map +1 -0
- package/dist/_named-keys.d.cts +8 -0
- package/dist/_named-keys.d.ts +8 -0
- package/dist/_named-keys.js +66 -0
- package/dist/_named-keys.js.map +1 -0
- package/dist/_recorder-chord.cjs +44 -0
- package/dist/_recorder-chord.cjs.map +1 -0
- package/dist/_recorder-chord.js +43 -0
- package/dist/_recorder-chord.js.map +1 -0
- package/dist/_recording-guard.cjs +59 -0
- package/dist/_recording-guard.cjs.map +1 -0
- package/dist/_recording-guard.js +56 -0
- package/dist/_recording-guard.js.map +1 -0
- package/dist/_registration.cjs +54 -0
- package/dist/_registration.cjs.map +1 -0
- package/dist/_registration.js +50 -0
- package/dist/_registration.js.map +1 -0
- package/dist/conflicts.cjs +55 -0
- package/dist/conflicts.cjs.map +1 -0
- package/dist/conflicts.d.cts +39 -0
- package/dist/conflicts.d.ts +39 -0
- package/dist/conflicts.js +55 -0
- package/dist/conflicts.js.map +1 -0
- package/dist/constants.cjs +46 -171
- package/dist/constants.cjs.map +1 -1
- package/dist/constants.d.cts +4 -129
- package/dist/constants.d.ts +4 -129
- package/dist/constants.js +48 -164
- package/dist/constants.js.map +1 -1
- package/dist/display-labels.cjs +119 -0
- package/dist/display-labels.cjs.map +1 -0
- package/dist/display-labels.d.cts +93 -0
- package/dist/display-labels.d.ts +93 -0
- package/dist/display-labels.js +113 -0
- package/dist/display-labels.js.map +1 -0
- package/dist/format.cjs +30 -36
- package/dist/format.cjs.map +1 -1
- package/dist/format.d.cts +46 -7
- package/dist/format.d.ts +46 -7
- package/dist/format.js +31 -37
- package/dist/format.js.map +1 -1
- package/dist/hint.cjs +24 -0
- package/dist/hint.cjs.map +1 -0
- package/dist/hint.d.cts +23 -0
- package/dist/hint.d.ts +23 -0
- package/dist/hint.js +24 -0
- package/dist/hint.js.map +1 -0
- package/dist/hotkey-manager.cjs +53 -22
- package/dist/hotkey-manager.cjs.map +1 -1
- package/dist/hotkey-manager.d.cts +6 -2
- package/dist/hotkey-manager.d.ts +6 -2
- package/dist/hotkey-manager.js +49 -18
- package/dist/hotkey-manager.js.map +1 -1
- package/dist/hotkey-recorder.cjs +66 -10
- package/dist/hotkey-recorder.cjs.map +1 -1
- package/dist/hotkey-recorder.d.cts +5 -2
- package/dist/hotkey-recorder.d.ts +5 -2
- package/dist/hotkey-recorder.js +65 -9
- package/dist/hotkey-recorder.js.map +1 -1
- package/dist/hotkey-sequence-recorder.cjs +85 -22
- package/dist/hotkey-sequence-recorder.cjs.map +1 -1
- package/dist/hotkey-sequence-recorder.d.cts +9 -2
- package/dist/hotkey-sequence-recorder.d.ts +9 -2
- package/dist/hotkey-sequence-recorder.js +84 -21
- package/dist/hotkey-sequence-recorder.js.map +1 -1
- package/dist/{hotkey.d.ts → hotkey.types.d.cts} +55 -120
- package/dist/{hotkey.d.cts → hotkey.types.d.ts} +55 -120
- package/dist/index.cjs +18 -10
- package/dist/index.d.cts +14 -9
- package/dist/index.d.ts +14 -9
- package/dist/index.js +10 -6
- package/dist/key-state-tracker.cjs +24 -24
- package/dist/key-state-tracker.cjs.map +1 -1
- package/dist/key-state-tracker.js +24 -24
- package/dist/key-state-tracker.js.map +1 -1
- package/dist/key.types.d.cts +82 -0
- package/dist/key.types.d.ts +82 -0
- package/dist/match.cjs +21 -30
- package/dist/match.cjs.map +1 -1
- package/dist/match.d.cts +19 -3
- package/dist/match.d.ts +19 -3
- package/dist/match.js +19 -29
- package/dist/match.js.map +1 -1
- package/dist/parse.cjs +43 -26
- package/dist/parse.cjs.map +1 -1
- package/dist/parse.d.cts +10 -4
- package/dist/parse.d.ts +10 -4
- package/dist/parse.js +35 -19
- package/dist/parse.js.map +1 -1
- package/dist/platform.cjs +55 -0
- package/dist/platform.cjs.map +1 -0
- package/dist/platform.d.cts +44 -0
- package/dist/platform.d.ts +44 -0
- package/dist/platform.js +53 -0
- package/dist/platform.js.map +1 -0
- package/dist/recorder-options.d.cts +34 -0
- package/dist/recorder-options.d.ts +34 -0
- package/dist/sequence-manager.cjs +54 -28
- package/dist/sequence-manager.cjs.map +1 -1
- package/dist/sequence-manager.d.cts +18 -3
- package/dist/sequence-manager.d.ts +18 -3
- package/dist/sequence-manager.js +49 -23
- package/dist/sequence-manager.js.map +1 -1
- package/dist/validate.cjs +6 -10
- package/dist/validate.cjs.map +1 -1
- package/dist/validate.d.cts +13 -2
- package/dist/validate.d.ts +13 -2
- package/dist/validate.js +7 -11
- package/dist/validate.js.map +1 -1
- package/package.json +1 -1
- package/src/{manager.utils.ts → _event-target.ts} +0 -74
- package/src/_keyboard-event.ts +101 -0
- package/src/_match.ts +96 -0
- package/src/_named-keys.ts +62 -0
- package/src/_recorder-chord.ts +76 -0
- package/src/_recording-guard.ts +63 -0
- package/src/_registration.ts +79 -0
- package/src/conflicts.ts +125 -0
- package/src/constants.ts +48 -196
- package/src/display-labels.ts +123 -0
- package/src/format.ts +111 -52
- package/src/hint.ts +42 -0
- package/src/hotkey-manager.ts +95 -40
- package/src/hotkey-recorder.ts +91 -15
- package/src/hotkey-sequence-recorder.ts +110 -28
- package/src/{hotkey.ts → hotkey.types.ts} +57 -224
- package/src/index.ts +11 -5
- package/src/key-state-tracker.ts +36 -24
- package/src/key.types.ts +268 -0
- package/src/match.ts +41 -90
- package/src/parse.ts +67 -26
- package/src/platform.ts +66 -0
- package/src/recorder-options.ts +34 -0
- package/src/sequence-manager.ts +77 -42
- package/src/validate.ts +29 -21
- package/dist/manager.utils.cjs.map +0 -1
- package/dist/manager.utils.d.cts +0 -13
- package/dist/manager.utils.d.ts +0 -13
- package/dist/manager.utils.js.map +0 -1
- package/dist/recorder-chord.cjs +0 -18
- package/dist/recorder-chord.cjs.map +0 -1
- package/dist/recorder-chord.js +0 -18
- package/dist/recorder-chord.js.map +0 -1
- package/src/recorder-chord.ts +0 -25
package/src/hotkey-manager.ts
CHANGED
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
import { Store } from '@tanstack/store'
|
|
2
|
-
import {
|
|
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 {
|
|
8
|
+
import { matchKeyboardEvent } from './_match'
|
|
6
9
|
import {
|
|
7
10
|
defaultHotkeyOptions,
|
|
8
11
|
getDefaultIgnoreInputs,
|
|
9
12
|
handleConflict,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from './
|
|
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 (
|
|
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
|
-
|
|
513
|
-
const matches = matchesKeyboardEvent(
|
|
514
|
-
event,
|
|
515
|
-
registration.parsedHotkey,
|
|
516
|
-
registration.options.platform,
|
|
517
|
-
)
|
|
561
|
+
const match = matches.get(id)
|
|
518
562
|
|
|
519
|
-
if (
|
|
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
|
-
|
|
544
|
-
|
|
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 (
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
package/src/hotkey-recorder.ts
CHANGED
|
@@ -1,8 +1,21 @@
|
|
|
1
1
|
import { Store } from '@tanstack/store'
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
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 (
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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 {
|
|
3
|
-
import {
|
|
4
|
-
|
|
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
|
-
/**
|
|
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 (
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|