@tanstack/hotkeys 0.7.1 → 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 +67 -11
  69. package/dist/hotkey-recorder.cjs.map +1 -1
  70. package/dist/hotkey-recorder.d.cts +6 -3
  71. package/dist/hotkey-recorder.d.ts +6 -3
  72. package/dist/hotkey-recorder.js +66 -10
  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 +2 -2
  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 +92 -16
  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,38 +1,3 @@
1
- import type { ParsedHotkey } from './hotkey'
2
-
3
- /**
4
- * Behavior when registering a hotkey/sequence that conflicts with an existing registration.
5
- *
6
- * - `'warn'` - Log a warning to the console but allow both registrations (default)
7
- * - `'error'` - Throw an error and prevent the new registration
8
- * - `'replace'` - Unregister the existing registration and register the new one
9
- * - `'allow'` - Allow multiple registrations without warning
10
- */
11
- export type ConflictBehavior = 'warn' | 'error' | 'replace' | 'allow'
12
-
13
- /**
14
- * Default options for hotkey/sequence registration.
15
- * Omitted: platform, target (resolved at registration), requireReset (HotkeyManager only).
16
- */
17
- export const defaultHotkeyOptions = {
18
- preventDefault: true,
19
- stopPropagation: true,
20
- eventType: 'keydown' as const,
21
- enabled: true,
22
- ignoreInputs: true,
23
- conflictBehavior: 'warn' as ConflictBehavior,
24
- }
25
-
26
- /**
27
- * Computes the default ignoreInputs value based on the hotkey.
28
- * Ctrl/Meta shortcuts and Escape fire in inputs; single keys and Shift/Alt combos are ignored.
29
- */
30
- export function getDefaultIgnoreInputs(parsedHotkey: ParsedHotkey): boolean {
31
- if (parsedHotkey.ctrl || parsedHotkey.meta) return false // Mod+S, Ctrl+C, etc.
32
- if (parsedHotkey.key === 'Escape') return false // Close modal, etc.
33
- return true // Single keys, Shift+key, Alt+key
34
- }
35
-
36
1
  /**
37
2
  * Checks if an element is an input-like element that should be ignored for hotkeys.
38
3
  *
@@ -190,42 +155,3 @@ export function isEventForTarget(
190
155
 
191
156
  return false
192
157
  }
193
-
194
- /**
195
- * Handles conflicts between registrations based on conflict behavior.
196
- *
197
- * @param conflictingId - The ID of the conflicting registration
198
- * @param keyDisplay - Display string for the conflicting key/sequence (for error messages)
199
- * @param conflictBehavior - How to handle the conflict
200
- * @param unregister - Function to unregister by ID
201
- */
202
- export function handleConflict(
203
- conflictingId: string,
204
- keyDisplay: string,
205
- conflictBehavior: ConflictBehavior,
206
- unregister: (id: string) => void,
207
- ): void {
208
- if (conflictBehavior === 'allow') {
209
- return
210
- }
211
-
212
- if (conflictBehavior === 'warn') {
213
- console.warn(
214
- `'${keyDisplay}' is already registered. Multiple handlers will be triggered. ` +
215
- `Use conflictBehavior: 'replace' to replace the existing handler, ` +
216
- `or conflictBehavior: 'allow' to suppress this warning.`,
217
- )
218
- return
219
- }
220
-
221
- if (conflictBehavior === 'error') {
222
- throw new Error(
223
- `'${keyDisplay}' is already registered. ` +
224
- `Use conflictBehavior: 'replace' to replace the existing handler, ` +
225
- `or conflictBehavior: 'allow' to allow multiple registrations.`,
226
- )
227
- }
228
-
229
- // At this point, conflictBehavior must be 'replace'
230
- unregister(conflictingId)
231
- }
@@ -0,0 +1,101 @@
1
+ import { PUNCTUATION_CODE_MAP, normalizeKeyName } from './constants'
2
+ import type { NormalizedKeyboardEvent } from './key.types'
3
+
4
+ /** Normalizes Unicode key names and modifier flags while retaining physical code and IME state. */
5
+ export function normalizeKeyboardEvent(
6
+ event: KeyboardEvent,
7
+ platform?: 'mac' | 'windows' | 'linux',
8
+ ): NormalizedKeyboardEvent {
9
+ let altGraph = false
10
+ try {
11
+ // WebKit reports AltGraph for Option on some macOS keyboard layouts. Option
12
+ // remains a normal Alt shortcut modifier there, so only use this signal on
13
+ // Windows/Linux where AltGraph synthesizes Control+Alt.
14
+ altGraph =
15
+ platform !== 'mac' &&
16
+ typeof event.getModifierState === 'function' &&
17
+ event.getModifierState('AltGraph')
18
+ } catch {
19
+ // Synthetic events and older browsers may not implement this reliably.
20
+ }
21
+
22
+ const key = (event.key || '').normalize('NFC')
23
+ return {
24
+ key: normalizeKeyName(key),
25
+ code: event.code || '',
26
+ ctrl: Boolean(event.ctrlKey),
27
+ shift: Boolean(event.shiftKey),
28
+ alt: Boolean(event.altKey),
29
+ meta: Boolean(event.metaKey),
30
+ altGraph,
31
+ location: event.location,
32
+ isComposing:
33
+ Boolean(event.isComposing) || key === 'Process' || key === 'Unidentified',
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Maps familiar positions to compatibility fallback keys for the logical matcher.
39
+ * This assumes conventional positions, not the active layout; never use it to
40
+ * turn recorded physical bindings into logical bindings or display labels.
41
+ */
42
+ export function codeToLogicalKey(code: string): string | undefined {
43
+ if (/^Key[A-Z]$/i.test(code)) return code.slice(3).toUpperCase()
44
+ if (/^Digit[0-9]$/i.test(code)) return code.slice(5)
45
+ if (/^Numpad[0-9]$/i.test(code)) return code.slice(6)
46
+
47
+ const punctuation = PUNCTUATION_CODE_MAP[code]
48
+ if (punctuation) return punctuation
49
+
50
+ const named: Record<string, string> = {
51
+ NumpadAdd: '+',
52
+ NumpadSubtract: '-',
53
+ NumpadMultiply: '*',
54
+ NumpadDivide: '/',
55
+ NumpadDecimal: '.',
56
+ NumpadEnter: 'Enter',
57
+ Space: 'Space',
58
+ }
59
+ return named[code]
60
+ }
61
+
62
+ /** Tests for one Unicode code point, including characters represented by a surrogate pair. */
63
+ export function isPrintableKey(key: string): boolean {
64
+ return key !== '' && Array.from(key).length === 1
65
+ }
66
+
67
+ /** Splits modifier tokens while retaining a trailing literal plus: `Control++` → `['Control', '+']`. */
68
+ export function splitHotkeyParts(hotkey: string): Array<string> {
69
+ if (!hotkey.endsWith('+')) return hotkey.split('+')
70
+ const parts = hotkey.slice(0, -1).split('+')
71
+ if (parts.at(-1) === '') parts.pop()
72
+ parts.push('+')
73
+ return parts
74
+ }
75
+
76
+ /** Identifies ASCII letters whose layout-produced value takes precedence over positional fallbacks. */
77
+ export function isAsciiLetter(key: string): boolean {
78
+ return /^[A-Za-z]$/.test(key)
79
+ }
80
+
81
+ /** Identifies a single Latin-script character, including accented letters. */
82
+ export function isLatinLetter(key: string): boolean {
83
+ return /^\p{Script_Extensions=Latin}$/u.test(key)
84
+ }
85
+
86
+ /** Compares letters without case, preserving accents and avoiding expansions such as ß → SS. */
87
+ export function keysEqual(left: string, right: string): boolean {
88
+ if (/^\p{Letter}$/u.test(left) && /^\p{Letter}$/u.test(right)) {
89
+ return left.localeCompare(right, undefined, { sensitivity: 'accent' }) === 0
90
+ }
91
+ return left === right
92
+ }
93
+
94
+ /** Rejects reserved physical-token syntax in logical object fields while allowing Unicode characters. */
95
+ export function assertLogicalKey(key: string): void {
96
+ if (/^\[.*\]$/s.test(key.trim())) {
97
+ throw new Error(
98
+ 'Logical key must not contain a bracketed physical code. Use the code field instead.',
99
+ )
100
+ }
101
+ }
package/src/_match.ts ADDED
@@ -0,0 +1,96 @@
1
+ import { detectPlatform } from './platform'
2
+ import {
3
+ codeToLogicalKey,
4
+ isAsciiLetter,
5
+ isLatinLetter,
6
+ isPrintableKey,
7
+ keysEqual,
8
+ normalizeKeyboardEvent,
9
+ } from './_keyboard-event'
10
+ import { parseHotkey } from './parse'
11
+ import type { KeyboardEventMatch } from './match'
12
+ import type { Hotkey, ParsedHotkey } from './hotkey.types'
13
+
14
+ /** Matches an event and reports the winning candidate and release identity. */
15
+ export function matchKeyboardEvent(
16
+ event: KeyboardEvent,
17
+ hotkey: Hotkey | ParsedHotkey,
18
+ platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
19
+ ): KeyboardEventMatch {
20
+ const parsed =
21
+ typeof hotkey === 'string' ? parseHotkey(hotkey, platform) : hotkey
22
+ const normalized = normalizeKeyboardEvent(event, platform)
23
+ const noMatch: KeyboardEventMatch = { matched: false, score: 0 }
24
+
25
+ if (
26
+ normalized.isComposing &&
27
+ (parsed.code !== undefined || isPrintableKey(parsed.key))
28
+ )
29
+ return noMatch
30
+ if (normalized.altGraph && (parsed.ctrl || parsed.alt)) return noMatch
31
+
32
+ if (parsed.code !== undefined) {
33
+ if (!modifiersMatch(normalized, parsed, false)) return noMatch
34
+ return normalized.code === parsed.code ? matched('code', 3) : noMatch
35
+ }
36
+
37
+ const direct = keysEqual(normalized.key, parsed.key)
38
+ const implicitShift =
39
+ direct &&
40
+ isPrintableKey(normalized.key) &&
41
+ !/^\p{Letter}$/u.test(normalized.key) &&
42
+ !parsed.shift
43
+
44
+ if (direct && modifiersMatch(normalized, parsed, implicitShift)) {
45
+ return matched('key', implicitShift ? 2 : 3)
46
+ }
47
+
48
+ // ASCII alphabetic output is authoritative for Dvorak/Colemak/AZERTY.
49
+ // Native Latin letters are also authoritative on Key* positions; Alt is the
50
+ // exception because macOS Option commonly transforms a shortcut's glyph.
51
+ if (
52
+ isAsciiLetter(normalized.key) ||
53
+ (isLatinLetter(normalized.key) &&
54
+ normalized.code.startsWith('Key') &&
55
+ !normalized.alt)
56
+ ) {
57
+ return noMatch
58
+ }
59
+
60
+ if (!modifiersMatch(normalized, parsed, false)) return noMatch
61
+ const fallbackKey = codeToLogicalKey(normalized.code)
62
+ return fallbackKey !== undefined && keysEqual(fallbackKey, parsed.key)
63
+ ? matched('fallback', 1)
64
+ : noMatch
65
+
66
+ // Preserve the triggering code so keyup can reset a shortcut even if its character changes.
67
+ function matched(
68
+ source: 'key' | 'code' | 'fallback',
69
+ score: 1 | 2 | 3,
70
+ ): KeyboardEventMatch {
71
+ return {
72
+ matched: true,
73
+ score,
74
+ source,
75
+ identity: { key: normalized.key, code: normalized.code },
76
+ }
77
+ }
78
+ }
79
+
80
+ /** Compares modifiers, excluding synthetic AltGr flags and optionally allowing Shift used to type a glyph. */
81
+ function modifiersMatch(
82
+ event: ReturnType<typeof normalizeKeyboardEvent>,
83
+ parsed: ParsedHotkey,
84
+ allowImplicitShift: boolean,
85
+ ): boolean {
86
+ const ctrl = event.altGraph ? false : event.ctrl
87
+ const alt = event.altGraph ? false : event.alt
88
+ if (
89
+ ctrl !== parsed.ctrl ||
90
+ alt !== parsed.alt ||
91
+ event.meta !== parsed.meta
92
+ ) {
93
+ return false
94
+ }
95
+ return event.shift === parsed.shift || (allowImplicitShift && event.shift)
96
+ }
@@ -0,0 +1,62 @@
1
+ /** Names defined for both KeyboardEvent.key and KeyboardEvent.code, beyond the basic key groups. */
2
+ export const SHARED_NAMED_KEYS = [
3
+ 'CapsLock',
4
+ 'ContextMenu',
5
+ 'Convert',
6
+ 'KanaMode',
7
+ 'NonConvert',
8
+ 'Help',
9
+ 'Insert',
10
+ 'NumLock',
11
+ 'Fn',
12
+ 'FnLock',
13
+ 'PrintScreen',
14
+ 'ScrollLock',
15
+ 'Pause',
16
+ 'BrowserBack',
17
+ 'BrowserFavorites',
18
+ 'BrowserForward',
19
+ 'BrowserHome',
20
+ 'BrowserRefresh',
21
+ 'BrowserSearch',
22
+ 'BrowserStop',
23
+ 'Eject',
24
+ 'LaunchMail',
25
+ 'MediaPlayPause',
26
+ 'MediaStop',
27
+ 'MediaTrackNext',
28
+ 'MediaTrackPrevious',
29
+ 'Power',
30
+ 'AudioVolumeDown',
31
+ 'AudioVolumeMute',
32
+ 'AudioVolumeUp',
33
+ 'WakeUp',
34
+ 'Hyper',
35
+ 'Super',
36
+ 'Again',
37
+ 'Copy',
38
+ 'Cut',
39
+ 'Find',
40
+ 'Open',
41
+ 'Paste',
42
+ 'Props',
43
+ 'Select',
44
+ 'Undo',
45
+ 'Hiragana',
46
+ 'Katakana',
47
+ ] as const
48
+
49
+ /** Logical counterparts whose physical positions use different names (for example Sleep and Lang1). */
50
+ export const LOGICAL_ONLY_NAMED_KEYS = [
51
+ 'Clear',
52
+ 'Cancel',
53
+ 'Standby',
54
+ 'LaunchApplication1',
55
+ 'LaunchApplication2',
56
+ 'LaunchMediaPlayer',
57
+ 'HangulMode',
58
+ 'HanjaMode',
59
+ 'Zenkaku',
60
+ 'Hankaku',
61
+ 'ZenkakuHankaku',
62
+ ] as const
@@ -0,0 +1,76 @@
1
+ import { normalizeKeyName } from './constants'
2
+ import { normalizeKeyboardEvent } from './_keyboard-event'
3
+ import {
4
+ isModifierKey,
5
+ normalizeHotkeyFromParsed,
6
+ parseKeyboardEvent,
7
+ } from './parse'
8
+ import type {
9
+ RecorderKeyMode,
10
+ RecorderOptions,
11
+ RecorderRejection,
12
+ } from './recorder-options'
13
+ import type { Hotkey } from './hotkey.types'
14
+
15
+ /**
16
+ * Converts a keydown to a replayable chord, preserving physical codes by default.
17
+ * Returns null for composition, modifier-only input, or unusable physical input.
18
+ * Key mode retains the produced character; neither mode guesses layout letters.
19
+ */
20
+ export function hotkeyChordFromKeydown(
21
+ event: KeyboardEvent,
22
+ platform: 'mac' | 'windows' | 'linux',
23
+ recordBy: RecorderKeyMode = 'code',
24
+ ): Hotkey | null {
25
+ const normalized = normalizeKeyboardEvent(event, platform)
26
+ if (
27
+ normalized.isComposing ||
28
+ isModifierKey(normalizeKeyName(event.key)) ||
29
+ event.key === 'AltGraph'
30
+ )
31
+ return null
32
+ if (
33
+ recordBy === 'code' &&
34
+ (!/^[A-Za-z][A-Za-z0-9]*$/.test(event.code) ||
35
+ event.code === 'Unidentified' ||
36
+ normalized.altGraph)
37
+ )
38
+ return null
39
+ const parsed = parseKeyboardEvent(event, platform)
40
+ if (recordBy === 'code') {
41
+ // Remove logical identity rather than placing a physical code in the key field.
42
+ const { key: _key, code: _code, ...modifiers } = parsed
43
+ return normalizeHotkeyFromParsed(
44
+ { ...modifiers, code: event.code },
45
+ platform,
46
+ )
47
+ }
48
+ return parsed.key ? normalizeHotkeyFromParsed(parsed, platform) : null
49
+ }
50
+
51
+ /**
52
+ * Explains why a physical chord cannot be recorded before syntax/policy checks.
53
+ * Logical mode deliberately accepts produced characters instead of requiring a code.
54
+ */
55
+ export function chordRejection(
56
+ event: KeyboardEvent,
57
+ options: RecorderOptions,
58
+ ): RecorderRejection | undefined {
59
+ if (options.recordBy === 'key') return undefined
60
+ // AltGr is character entry, often reported as synthetic Ctrl+Alt; macOS Option is handled separately.
61
+ if (normalizeKeyboardEvent(event, options.platform).altGraph)
62
+ return {
63
+ reason: 'alt-graph',
64
+ message:
65
+ 'AltGraph character entry cannot be recorded as a physical shortcut. Use key mode for characters.',
66
+ }
67
+ if (
68
+ !/^[A-Za-z][A-Za-z0-9]*$/.test(event.code) ||
69
+ event.code === 'Unidentified'
70
+ )
71
+ return {
72
+ reason: 'missing-code',
73
+ message: 'This event has no usable physical key code.',
74
+ }
75
+ return undefined
76
+ }
@@ -0,0 +1,63 @@
1
+ import { MODIFIER_KEYS, normalizeKeyName } from './constants'
2
+
3
+ // Shared only by recorders and dispatchers. Does not own bindings or preferences.
4
+ // Multiple recorder instances may be active; dispatch resumes after the last one ends.
5
+ const sessions = new Set<object>()
6
+ // Remember consumed event objects through the remainder of their DOM dispatch.
7
+ const captured = new WeakSet<KeyboardEvent>()
8
+ // Captured physical identities stay blocked through repeats and their eventual keyup.
9
+ const pending = new Map<string, string>()
10
+ let listening = false
11
+ // Prefer the stable physical identity when modifier release changes event.key.
12
+ const identity = (event: KeyboardEvent) => event.code || `key:${event.key}`
13
+
14
+ /** Consumes recorded key releases and removes guards for completed chords. */
15
+ function release(event: KeyboardEvent): void {
16
+ if (pending.delete(identity(event))) captured.add(event)
17
+ // macOS can swallow the main key's keyup while Command is held.
18
+ // Releasing a modifier ends that chord, as in KeyStateTracker.
19
+ if (MODIFIER_KEYS.has(normalizeKeyName(event.key))) {
20
+ for (const [id, key] of pending) {
21
+ if (!MODIFIER_KEYS.has(key)) pending.delete(id)
22
+ }
23
+ }
24
+ cleanup()
25
+ }
26
+ /** Clears pending releases when focus loss prevents the browser from delivering them. */
27
+ function blur(): void {
28
+ pending.clear()
29
+ cleanup()
30
+ }
31
+ /** Detaches shared listeners once neither active sessions nor pending releases need them. */
32
+ function cleanup(): void {
33
+ if (listening && sessions.size === 0 && pending.size === 0) {
34
+ document.removeEventListener('keyup', release, true)
35
+ window.removeEventListener('blur', blur)
36
+ listening = false
37
+ }
38
+ }
39
+ /** Pauses application shortcut dispatch and starts tracking releases for this recorder. */
40
+ export function beginRecording(owner: object): void {
41
+ sessions.add(owner)
42
+ if (!listening && typeof document !== 'undefined') {
43
+ document.addEventListener('keyup', release, true)
44
+ window.addEventListener('blur', blur)
45
+ listening = true
46
+ }
47
+ }
48
+ /** Ends a session while retaining guards for keys still held after commit or cancellation. */
49
+ export function endRecording(owner: object): void {
50
+ sessions.delete(owner)
51
+ cleanup()
52
+ }
53
+ /** Marks a consumed keydown and guards subsequent repeats and its release. */
54
+ export function captureRecordingEvent(event: KeyboardEvent): void {
55
+ captured.add(event)
56
+ pending.set(identity(event), normalizeKeyName(event.key))
57
+ }
58
+ /** Whether managers should suppress callbacks for this recording session or captured chord. */
59
+ export function isRecordingEvent(event: KeyboardEvent): boolean {
60
+ return (
61
+ sessions.size > 0 || captured.has(event) || pending.has(identity(event))
62
+ )
63
+ }
@@ -0,0 +1,79 @@
1
+ import type { ConflictBehavior, ParsedHotkey } from './hotkey.types'
2
+
3
+ /**
4
+ * Default options for hotkey/sequence registration.
5
+ * Omitted: platform, target (resolved at registration), requireReset (HotkeyManager only).
6
+ */
7
+ export const defaultHotkeyOptions = {
8
+ preventDefault: true,
9
+ stopPropagation: true,
10
+ eventType: 'keydown' as const,
11
+ enabled: true,
12
+ ignoreInputs: true,
13
+ conflictBehavior: 'warn' as ConflictBehavior,
14
+ }
15
+
16
+ /**
17
+ * Computes the default ignoreInputs value based on the hotkey.
18
+ * Ctrl/Meta shortcuts and Escape fire in inputs; single keys and Shift/Alt combos are ignored.
19
+ */
20
+ export function getDefaultIgnoreInputs(parsedHotkey: ParsedHotkey): boolean {
21
+ if (parsedHotkey.ctrl || parsedHotkey.meta) return false // Mod+S, Ctrl+C, etc.
22
+ if (parsedHotkey.key === 'Escape' || parsedHotkey.code === 'Escape')
23
+ return false // Close modal, etc.
24
+ return true // Single keys, Shift+key, Alt+key
25
+ }
26
+
27
+ /**
28
+ * Handles conflicts between registrations based on conflict behavior.
29
+ *
30
+ * @param conflictingId - The ID of the conflicting registration
31
+ * @param keyDisplay - Display string for the conflicting key/sequence (for error messages)
32
+ * @param conflictBehavior - How to handle the conflict
33
+ * @param unregister - Function to unregister by ID
34
+ */
35
+ export function handleConflict(
36
+ conflictingId: string,
37
+ keyDisplay: string,
38
+ conflictBehavior: ConflictBehavior,
39
+ unregister: (id: string) => void,
40
+ ): void {
41
+ if (conflictBehavior === 'allow') {
42
+ return
43
+ }
44
+
45
+ if (conflictBehavior === 'warn') {
46
+ console.warn(
47
+ `'${keyDisplay}' is already registered. Multiple handlers will be triggered. ` +
48
+ `Use conflictBehavior: 'replace' to replace the existing handler, ` +
49
+ `or conflictBehavior: 'allow' to suppress this warning.`,
50
+ )
51
+ return
52
+ }
53
+
54
+ if (conflictBehavior === 'error') {
55
+ throw new Error(
56
+ `'${keyDisplay}' is already registered. ` +
57
+ `Use conflictBehavior: 'replace' to replace the existing handler, ` +
58
+ `or conflictBehavior: 'allow' to allow multiple registrations.`,
59
+ )
60
+ }
61
+
62
+ // At this point, conflictBehavior must be 'replace'
63
+ unregister(conflictingId)
64
+ }
65
+
66
+ /** Compare updates without publishing unchanged options, including fresh metadata objects. */
67
+ export function optionsEqual(left: object, right: object): boolean {
68
+ const a = left as Record<string, unknown>
69
+ const b = right as Record<string, unknown>
70
+ const keys = Object.keys(a)
71
+ return (
72
+ keys.length === Object.keys(b).length &&
73
+ keys.every((key) =>
74
+ key === 'meta' && a[key] && b[key]
75
+ ? optionsEqual(a[key] as object, b[key] as object)
76
+ : Object.is(a[key], b[key]),
77
+ )
78
+ )
79
+ }
@@ -0,0 +1,125 @@
1
+ import { detectPlatform } from './platform'
2
+ import { parseRegisterableHotkey } from './parse'
3
+ import { keysEqual } from './_keyboard-event'
4
+ import { matchesKeyboardEvent } from './match'
5
+ import { getHotkeyManager, toHotkeyRegistrationView } from './hotkey-manager'
6
+ import { getSequenceManager } from './sequence-manager'
7
+ import type { HotkeyRegistrationView } from './hotkey-manager'
8
+ import type { SequenceRegistrationView } from './sequence-manager'
9
+ import type { RegisterableHotkey } from './hotkey.types'
10
+
11
+ export type HotkeyConflict =
12
+ | { type: 'hotkey'; registration: HotkeyRegistrationView }
13
+ | { type: 'sequence'; registration: SequenceRegistrationView }
14
+
15
+ export interface HotkeyConflictOptions {
16
+ /** Intended registration target. Defaults to document; disjoint targets are excluded. */
17
+ target?: HTMLElement | Document | Window
18
+ /** Check all targets instead of overlapping targets. Default: overlapping. */
19
+ scope?: 'overlapping' | 'all'
20
+ /** Intended event type. Default: keydown. */
21
+ eventType?: 'keydown' | 'keyup'
22
+ /** Include disabled registrations. Default: false. */
23
+ includeDisabled?: boolean
24
+ /** IDs of registrations being edited. */
25
+ excludeIds?: ReadonlyArray<string>
26
+ /** Additional app-specific exclusions, such as all instances of one action. */
27
+ exclude?: (
28
+ registration: HotkeyRegistrationView | SequenceRegistrationView,
29
+ ) => boolean
30
+ /** Platform used to resolve Mod in the candidate. Registrations retain their own platform. */
31
+ platform?: 'mac' | 'windows' | 'linux'
32
+ /** Source events allow detecting physical/logical overlap on the recorded layout. */
33
+ events?: ReadonlyArray<KeyboardEvent>
34
+ }
35
+
36
+ /** Whether two targets share a document and one contains the other (or is its window). */
37
+ function targetsOverlap(
38
+ a: HTMLElement | Document | Window,
39
+ b: HTMLElement | Document | Window,
40
+ ): boolean {
41
+ if (a === b) return true
42
+ // Resolve the owning document without relying on cross-frame instanceof checks.
43
+ const documentOf = (target: typeof a): Document | null => {
44
+ if ('document' in target) return target.document
45
+ if (target.nodeType === 9) return target as Document
46
+ return target.ownerDocument
47
+ }
48
+ if (documentOf(a) !== documentOf(b)) return false
49
+ if (!('contains' in a) || !('contains' in b)) return true
50
+ return a.contains(b as Node) || b.contains(a as Node)
51
+ }
52
+
53
+ /**
54
+ * Find equivalent bindings and sequence-prefix conflicts in the live registry.
55
+ * With source events, also checks observed logical/physical overlap. Does not
56
+ * discover unmounted handlers, external listeners, or other keyboard layouts.
57
+ */
58
+ export function findHotkeyConflicts(
59
+ candidate: RegisterableHotkey | ReadonlyArray<RegisterableHotkey>,
60
+ options: HotkeyConflictOptions = {},
61
+ ): Array<HotkeyConflict> {
62
+ const steps: ReadonlyArray<RegisterableHotkey> = Array.isArray(candidate)
63
+ ? candidate
64
+ : [candidate as RegisterableHotkey]
65
+ if (steps.length === 0) return []
66
+ const platform = options.platform ?? detectPlatform()
67
+ const parsed = steps.map((step) => parseRegisterableHotkey(step, platform))
68
+ const target =
69
+ options.target ?? (typeof document === 'undefined' ? undefined : document)
70
+ const conflicts: Array<HotkeyConflict> = []
71
+ // Apply scope policy before comparing equivalent bindings or observed event overlap.
72
+ const matches = (
73
+ registration: HotkeyRegistrationView | SequenceRegistrationView,
74
+ bindings: ReadonlyArray<RegisterableHotkey>,
75
+ ) => {
76
+ if (
77
+ (!options.includeDisabled && registration.options.enabled === false) ||
78
+ (registration.options.eventType ?? 'keydown') !==
79
+ (options.eventType ?? 'keydown') ||
80
+ options.excludeIds?.includes(registration.id) ||
81
+ options.exclude?.(registration) ||
82
+ (options.scope !== 'all' &&
83
+ target &&
84
+ !targetsOverlap(target, registration.target))
85
+ )
86
+ return false
87
+ // Compare only the common prefix: a shorter sequence can collide with a longer one.
88
+ return parsed
89
+ .slice(0, Math.min(parsed.length, bindings.length))
90
+ .every((a, index) => {
91
+ const b = parseRegisterableHotkey(
92
+ bindings[index]!,
93
+ registration.options.platform,
94
+ )
95
+ const same =
96
+ a.ctrl === b.ctrl &&
97
+ a.alt === b.alt &&
98
+ a.shift === b.shift &&
99
+ a.meta === b.meta &&
100
+ (a.code !== undefined || b.code !== undefined
101
+ ? a.code === b.code
102
+ : keysEqual(a.key, b.key))
103
+ // A source event can establish code/key overlap without guessing its layout.
104
+ const event = options.events?.[index]
105
+ return (
106
+ same ||
107
+ (event !== undefined &&
108
+ matchesKeyboardEvent(event, a, platform) &&
109
+ matchesKeyboardEvent(event, b, registration.options.platform))
110
+ )
111
+ })
112
+ }
113
+ for (const registration of getHotkeyManager().registrations.state.values()) {
114
+ if (matches(registration, [registration.hotkey]))
115
+ conflicts.push({
116
+ type: 'hotkey',
117
+ registration: toHotkeyRegistrationView(registration),
118
+ })
119
+ }
120
+ for (const registration of getSequenceManager().registrations.state.values()) {
121
+ if (matches(registration, registration.sequence))
122
+ conflicts.push({ type: 'sequence', registration })
123
+ }
124
+ return conflicts
125
+ }