@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
@@ -0,0 +1,268 @@
1
+ import type { LOGICAL_ONLY_NAMED_KEYS, SHARED_NAMED_KEYS } from './_named-keys'
2
+
3
+ /**
4
+ * All supported modifier key names, including aliases.
5
+ * - Control/Ctrl: The Control key
6
+ * - Shift: The Shift key
7
+ * - Alt/Option: The Alt key (Option on macOS)
8
+ * - Command/Cmd: The Command key (macOS only)
9
+ * - CommandOrControl/Mod: Command on macOS, Control on other platforms
10
+ */
11
+ export type Modifier =
12
+ | 'Control'
13
+ | 'Ctrl'
14
+ | 'Shift'
15
+ | 'Alt'
16
+ | 'Option'
17
+ | 'Command'
18
+ | 'Cmd'
19
+ | 'CommandOrControl'
20
+ | 'Mod'
21
+
22
+ /**
23
+ * Canonical modifier names that map to KeyboardEvent properties.
24
+ */
25
+ export type CanonicalModifier = 'Control' | 'Shift' | 'Alt' | 'Meta'
26
+
27
+ /**
28
+ * Letter keys A-Z (case-insensitive in matching).
29
+ */
30
+ export type LetterKey =
31
+ | 'A'
32
+ | 'B'
33
+ | 'C'
34
+ | 'D'
35
+ | 'E'
36
+ | 'F'
37
+ | 'G'
38
+ | 'H'
39
+ | 'I'
40
+ | 'J'
41
+ | 'K'
42
+ | 'L'
43
+ | 'M'
44
+ | 'N'
45
+ | 'O'
46
+ | 'P'
47
+ | 'Q'
48
+ | 'R'
49
+ | 'S'
50
+ | 'T'
51
+ | 'U'
52
+ | 'V'
53
+ | 'W'
54
+ | 'X'
55
+ | 'Y'
56
+ | 'Z'
57
+
58
+ /**
59
+ * Number keys 0-9.
60
+ */
61
+ export type NumberKey =
62
+ | '0'
63
+ | '1'
64
+ | '2'
65
+ | '3'
66
+ | '4'
67
+ | '5'
68
+ | '6'
69
+ | '7'
70
+ | '8'
71
+ | '9'
72
+
73
+ /**
74
+ * Function keys F1-F24, supported as both logical keys and physical codes.
75
+ */
76
+ export type FunctionKey =
77
+ | 'F1'
78
+ | 'F2'
79
+ | 'F3'
80
+ | 'F4'
81
+ | 'F5'
82
+ | 'F6'
83
+ | 'F7'
84
+ | 'F8'
85
+ | 'F9'
86
+ | 'F10'
87
+ | 'F11'
88
+ | 'F12'
89
+ | 'F13'
90
+ | 'F14'
91
+ | 'F15'
92
+ | 'F16'
93
+ | 'F17'
94
+ | 'F18'
95
+ | 'F19'
96
+ | 'F20'
97
+ | 'F21'
98
+ | 'F22'
99
+ | 'F23'
100
+ | 'F24'
101
+
102
+ /**
103
+ * Navigation keys for cursor movement.
104
+ */
105
+ export type NavigationKey =
106
+ | 'ArrowUp'
107
+ | 'ArrowDown'
108
+ | 'ArrowLeft'
109
+ | 'ArrowRight'
110
+ | 'Home'
111
+ | 'End'
112
+ | 'PageUp'
113
+ | 'PageDown'
114
+
115
+ /**
116
+ * Editing and special keys.
117
+ */
118
+ export type EditingKey =
119
+ | 'Enter'
120
+ | 'Escape'
121
+ | 'Space'
122
+ | 'Tab'
123
+ | 'Backspace'
124
+ | 'Delete'
125
+
126
+ /**
127
+ * Printable punctuation glyphs used in keyboard shortcuts. Matching uses the
128
+ * final KeyboardEvent.key glyph, independent of which layout produced it.
129
+ */
130
+ export type PunctuationKey =
131
+ | '/'
132
+ | '['
133
+ | ']'
134
+ | '\\'
135
+ | '='
136
+ | '-'
137
+ | ','
138
+ | '.'
139
+ | ';'
140
+ | '`'
141
+ | "'"
142
+ | '+'
143
+ | '?'
144
+ | '!'
145
+ | '@'
146
+ | '#'
147
+ | '$'
148
+ | '%'
149
+ | '^'
150
+ | '&'
151
+ | '*'
152
+ | '('
153
+ | ')'
154
+ | '_'
155
+ | '{'
156
+ | '}'
157
+ | '|'
158
+ | ':'
159
+ | '"'
160
+ | '<'
161
+ | '>'
162
+ | '~'
163
+
164
+ /** Additional named logical keys, including media, browser, lock, and input-mode keys. */
165
+ export type NamedKey =
166
+ | (typeof SHARED_NAMED_KEYS)[number]
167
+ | (typeof LOGICAL_ONLY_NAMED_KEYS)[number]
168
+
169
+ /** Supported logical key names other than printable punctuation. */
170
+ export type NonPunctuationKey =
171
+ | LetterKey
172
+ | NumberKey
173
+ | EditingKey
174
+ | NavigationKey
175
+ | FunctionKey
176
+ | NamedKey
177
+
178
+ /**
179
+ * Supported physical KeyboardEvent.code names, independent of keyboard layout.
180
+ * Letter, digit, numpad, and sided modifier names use finite template unions.
181
+ * `Unidentified` is excluded because it cannot identify a physical key.
182
+ *
183
+ * @see https://www.w3.org/TR/uievents-code/
184
+ */
185
+ export type PhysicalKeyCode =
186
+ | `Key${LetterKey}`
187
+ | `Digit${NumberKey}`
188
+ | `Numpad${NumberKey}`
189
+ | `${'Alt' | 'Control' | 'Meta' | 'Shift'}${'Left' | 'Right'}`
190
+ | EditingKey
191
+ | NavigationKey
192
+ | FunctionKey
193
+ | (typeof SHARED_NAMED_KEYS)[number]
194
+ | 'Backquote'
195
+ | 'Backslash'
196
+ | 'BracketLeft'
197
+ | 'BracketRight'
198
+ | 'Comma'
199
+ | 'Equal'
200
+ | 'IntlBackslash'
201
+ | 'IntlRo'
202
+ | 'IntlYen'
203
+ | 'Minus'
204
+ | 'Period'
205
+ | 'Quote'
206
+ | 'Semicolon'
207
+ | 'Slash'
208
+ | `Lang${1 | 2 | 3 | 4 | 5}`
209
+ | `Numpad${
210
+ | 'Add'
211
+ | 'Backspace'
212
+ | 'Clear'
213
+ | 'ClearEntry'
214
+ | 'Comma'
215
+ | 'Decimal'
216
+ | 'Divide'
217
+ | 'Enter'
218
+ | 'Equal'
219
+ | 'Hash'
220
+ | 'MemoryAdd'
221
+ | 'MemoryClear'
222
+ | 'MemoryRecall'
223
+ | 'MemoryStore'
224
+ | 'MemorySubtract'
225
+ | 'Multiply'
226
+ | 'ParenLeft'
227
+ | 'ParenRight'
228
+ | 'Star'
229
+ | 'Subtract'}`
230
+ | 'LaunchApp1'
231
+ | 'LaunchApp2'
232
+ | 'MediaSelect'
233
+ | 'Sleep'
234
+ | 'Turbo'
235
+ | 'Abort'
236
+ | 'Resume'
237
+ | 'Suspend'
238
+
239
+ /**
240
+ * An explicit physical key token. All codes use brackets, including names such
241
+ * as `[Enter]` that also exist as logical keys. Invalid code names are rejected
242
+ * by TypeScript, and supported names are available in autocomplete.
243
+ */
244
+ export type PhysicalKey = `[${PhysicalKeyCode}]`
245
+
246
+ /** Supported logical key names, excluding bracketed physical-code syntax. */
247
+ export type LogicalKey = NonPunctuationKey | PunctuationKey
248
+
249
+ /** All supported logical keys and bracketed physical key tokens. */
250
+ export type Key = LogicalKey | PhysicalKey
251
+
252
+ /**
253
+ * Includes both modifier keys and regular keys.
254
+ */
255
+ export type IndividualKey = CanonicalModifier | Key
256
+
257
+ /** Keyboard event properties normalized for internal matching and recording. */
258
+ export interface NormalizedKeyboardEvent {
259
+ key: string
260
+ code: string
261
+ ctrl: boolean
262
+ shift: boolean
263
+ alt: boolean
264
+ meta: boolean
265
+ altGraph: boolean
266
+ location: number
267
+ isComposing: boolean
268
+ }
package/src/match.ts CHANGED
@@ -1,21 +1,27 @@
1
- import {
2
- PUNCTUATION_CODE_MAP,
3
- detectPlatform,
4
- isSingleLetterKey,
5
- normalizeKeyName,
6
- } from './constants'
7
- import { parseHotkey } from './parse'
1
+ import { matchKeyboardEvent } from './_match'
2
+ import { parseHotkey, parseRegisterableHotkey } from './parse'
3
+ import { detectPlatform } from './platform'
4
+ import { keysEqual } from './_keyboard-event'
8
5
  import type {
9
6
  Hotkey,
10
7
  HotkeyCallback,
11
8
  HotkeyCallbackContext,
12
9
  ParsedHotkey,
13
- } from './hotkey'
10
+ RegisterableHotkey,
11
+ } from './hotkey.types'
12
+
13
+ export interface KeyboardEventMatch {
14
+ matched: boolean
15
+ score: 0 | 1 | 2 | 3
16
+ source?: 'key' | 'code' | 'fallback'
17
+ identity?: { key: string; code: string }
18
+ }
14
19
 
15
20
  /**
16
21
  * Checks if a KeyboardEvent matches a hotkey.
17
22
  *
18
- * Uses the `key` property from KeyboardEvent for matching, with a fallback to `code`
23
+ * Physical bindings such as `Mod+[KeyS]` match `event.code` exactly.
24
+ * Logical bindings use `event.key`, with a fallback to `code`
19
25
  * for letter keys, digit keys (0-9), and punctuation keys when `key` produces special
20
26
  * characters (e.g., macOS Option+letter, Shift+number, or Option+punctuation).
21
27
  * Letter keys are matched case-insensitively.
@@ -45,85 +51,7 @@ export function matchesKeyboardEvent(
45
51
  hotkey: Hotkey | ParsedHotkey,
46
52
  platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
47
53
  ): boolean {
48
- const parsed =
49
- typeof hotkey === 'string' ? parseHotkey(hotkey, platform) : hotkey
50
-
51
- // Check modifiers
52
- if (event.ctrlKey !== parsed.ctrl) {
53
- return false
54
- }
55
- if (event.shiftKey !== parsed.shift) {
56
- return false
57
- }
58
- if (event.altKey !== parsed.alt) {
59
- return false
60
- }
61
- if (event.metaKey !== parsed.meta) {
62
- return false
63
- }
64
-
65
- // Check key (case-insensitive for letters)
66
- const eventKey = normalizeKeyName(event.key)
67
- const hotkeyKey = parsed.key
68
-
69
- // For single-character keys (not dead keys), try direct event.key match first
70
- if (eventKey !== 'Dead' && eventKey.length === 1 && hotkeyKey.length === 1) {
71
- if (eventKey.toUpperCase() === hotkeyKey.toUpperCase()) {
72
- return true
73
- }
74
-
75
- // If event.key is a letter, we usually trust the keyboard layout.
76
- // For ASCII letters: always trust layout (Dvorak, Colemak, AZERTY support).
77
- // For non-ASCII letters without Alt: trust layout (e.g., Cyrillic keyboards).
78
- //
79
- // For non-ASCII letters WITH Alt held down: fall through to the event.code
80
- // fallback. macOS Option+letter combinations produce non-ASCII letters as
81
- // event.key (e.g., Option+A → 'å', Option+' → 'æ', Option+P → 'π'), but
82
- // event.code still reflects the physical key. We want Alt+A to match even
83
- // when Option+A fires event.key='å'.
84
- if (
85
- isSingleLetterKey(eventKey) &&
86
- (/^[A-Za-z]$/.test(eventKey) || !event.altKey)
87
- ) {
88
- return false
89
- }
90
- }
91
-
92
- // Fallback to event.code for dead keys or single-char mismatches where
93
- // event.key is a non-letter special character.
94
- // Dead keys: Option+letter on macOS, international layouts produce event.key === 'Dead'
95
- // Single-char mismatches: Cmd+Option+T gives '†' instead of 'T', Shift+4 gives '$'
96
- if (
97
- event.code &&
98
- (eventKey === 'Dead' || (eventKey.length === 1 && hotkeyKey.length === 1))
99
- ) {
100
- // fallback for letter keys (common with mac option + letter)
101
- if (event.code.startsWith('Key')) {
102
- const codeLetter = event.code.slice(3)
103
- if (codeLetter.length === 1 && /^[A-Za-z]$/.test(codeLetter)) {
104
- return codeLetter.toUpperCase() === hotkeyKey.toUpperCase()
105
- }
106
- }
107
-
108
- // fallback for number keys (common with mac option + num)
109
- if (event.code.startsWith('Digit')) {
110
- const codeDigit = event.code.slice(5)
111
- if (codeDigit.length === 1 && /^[0-9]$/.test(codeDigit)) {
112
- return codeDigit === hotkeyKey
113
- }
114
- }
115
- // Fallback for punctuation keys (e.g., Minus, Slash, BracketLeft).
116
- // On macOS, Option+punctuation produces composed characters (e.g., Option+- → '–'),
117
- // but event.code still reports the physical key.
118
- if (event.code in PUNCTUATION_CODE_MAP) {
119
- return PUNCTUATION_CODE_MAP[event.code] === hotkeyKey
120
- }
121
-
122
- return false
123
- }
124
-
125
- // For special keys, compare exactly (after normalization)
126
- return eventKey === hotkeyKey
54
+ return matchKeyboardEvent(event, hotkey, platform).matched
127
55
  }
128
56
 
129
57
  /**
@@ -187,7 +115,7 @@ export function createHotkeyHandler(
187
115
  }
188
116
  }
189
117
 
190
- type MultiHotkeyHandler = { [K in Hotkey]?: HotkeyCallback }
118
+ export type MultiHotkeyHandler = { [K in Hotkey]?: HotkeyCallback }
191
119
 
192
120
  /**
193
121
  * Creates a handler that matches multiple hotkeys.
@@ -253,7 +181,30 @@ function formatParsedHotkey(parsed: ParsedHotkey): Hotkey {
253
181
  if (parsed.alt) parts.push('Alt')
254
182
  if (parsed.shift) parts.push('Shift')
255
183
  if (parsed.meta) parts.push('Meta')
256
- parts.push(parsed.key)
184
+ parts.push(parsed.code !== undefined ? `[${parsed.code}]` : parsed.key)
257
185
 
258
186
  return parts.join('+') as Hotkey
259
187
  }
188
+
189
+ /**
190
+ * Compares normalized binding identity, including all four modifiers.
191
+ * Aliases resolve on the supplied platform. A physical code and a logical key
192
+ * remain distinct even when one keyboard event could match both.
193
+ */
194
+ export function areHotkeysEqual(
195
+ left: RegisterableHotkey,
196
+ right: RegisterableHotkey,
197
+ platform = detectPlatform(),
198
+ ): boolean {
199
+ const a = parseRegisterableHotkey(left, platform)
200
+ const b = parseRegisterableHotkey(right, platform)
201
+ return (
202
+ a.ctrl === b.ctrl &&
203
+ a.alt === b.alt &&
204
+ a.shift === b.shift &&
205
+ a.meta === b.meta &&
206
+ (a.code !== undefined || b.code !== undefined
207
+ ? a.code === b.code
208
+ : keysEqual(a.key, b.key))
209
+ )
210
+ }
package/src/parse.ts CHANGED
@@ -1,25 +1,24 @@
1
1
  import {
2
- MODIFIER_ALIASES,
3
- MODIFIER_ORDER,
4
- detectPlatform,
5
- normalizeKeyName,
6
- resolveModifier,
7
- } from './constants'
2
+ assertLogicalKey,
3
+ normalizeKeyboardEvent,
4
+ splitHotkeyParts,
5
+ } from './_keyboard-event'
6
+ import { MODIFIER_ALIASES, MODIFIER_ORDER, normalizeKeyName } from './constants'
7
+ import { detectPlatform, resolveModifier } from './platform'
8
+ import type { CanonicalModifier, Key } from './key.types'
8
9
  import type {
9
- CanonicalModifier,
10
10
  Hotkey,
11
- Key,
12
11
  ParsedHotkey,
13
12
  RawHotkey,
14
13
  RegisterableHotkey,
15
- } from './hotkey'
14
+ } from './hotkey.types'
16
15
 
17
16
  /**
18
17
  * Parses a hotkey string into its component parts.
19
18
  *
20
19
  * @param hotkey - The hotkey string to parse (e.g., 'Mod+Shift+S')
21
20
  * @param platform - The target platform for resolving 'Mod' (defaults to auto-detection)
22
- * @returns A ParsedHotkey object with the key and modifier flags
21
+ * @returns A ParsedHotkey with either logical key or physical code and modifier flags
23
22
  *
24
23
  * @example
25
24
  * ```ts
@@ -32,7 +31,7 @@ export function parseHotkey(
32
31
  hotkey: Hotkey | (string & {}),
33
32
  platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
34
33
  ): ParsedHotkey {
35
- const parts = hotkey.split('+')
34
+ const parts = splitHotkeyParts(hotkey)
36
35
  const modifiers: Set<CanonicalModifier> = new Set()
37
36
  let key = ''
38
37
 
@@ -65,8 +64,9 @@ export function parseHotkey(
65
64
  key = normalizeKeyName(parts[parts.length - 1]!.trim())
66
65
  }
67
66
 
67
+ const code = /^\[([A-Za-z][A-Za-z0-9]*)\]$/.exec(key)?.[1]
68
68
  return {
69
- key,
69
+ ...(code !== undefined ? { code } : { key }),
70
70
  ctrl: modifiers.has('Control'),
71
71
  shift: modifiers.has('Shift'),
72
72
  alt: modifiers.has('Alt'),
@@ -100,6 +100,7 @@ export function rawHotkeyToParsedHotkey(
100
100
  raw: RawHotkey,
101
101
  platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
102
102
  ): ParsedHotkey {
103
+ if (raw.code === undefined) assertLogicalKey(raw.key)
103
104
  let ctrl = raw.ctrl ?? false
104
105
  const shift = raw.shift ?? false
105
106
  const alt = raw.alt ?? false
@@ -129,7 +130,9 @@ export function rawHotkeyToParsedHotkey(
129
130
  }
130
131
  })
131
132
  return {
132
- key: raw.key,
133
+ ...(raw.code !== undefined
134
+ ? { code: raw.code }
135
+ : { key: normalizeKeyName(raw.key) }),
133
136
  ctrl,
134
137
  shift,
135
138
  alt,
@@ -147,6 +150,7 @@ function normalizedHotkeyStringFromParsed(
147
150
  parsed: ParsedHotkey,
148
151
  platform: 'mac' | 'windows' | 'linux',
149
152
  ): Hotkey {
153
+ if (parsed.code === undefined) assertLogicalKey(parsed.key)
150
154
  const canUseMod =
151
155
  platform === 'mac'
152
156
  ? parsed.meta && !parsed.ctrl
@@ -170,7 +174,11 @@ function normalizedHotkeyStringFromParsed(
170
174
  }
171
175
  }
172
176
 
173
- parts.push(normalizeKeyName(parsed.key))
177
+ parts.push(
178
+ parsed.code !== undefined
179
+ ? `[${parsed.code}]`
180
+ : normalizeKeyName(parsed.key),
181
+ )
174
182
  return parts.join('+') as Hotkey
175
183
  }
176
184
 
@@ -263,22 +271,31 @@ export function isModifierKey(
263
271
  * })
264
272
  * ```
265
273
  */
266
- export function parseKeyboardEvent(event: KeyboardEvent): ParsedHotkey {
267
- const normalizedKey = normalizeKeyName(event.key)
274
+ export function parseKeyboardEvent(
275
+ event: KeyboardEvent,
276
+ platform?: 'mac' | 'windows' | 'linux',
277
+ ): ParsedHotkey {
278
+ const normalized = normalizeKeyboardEvent(event, platform)
279
+ // AltGraph is a character-production modifier, not an ordinary Control+Alt
280
+ // shortcut. Record the produced glyph instead of a synthetic Ctrl+Alt chord.
281
+ const ctrl = normalized.altGraph ? false : normalized.ctrl
282
+ const alt = normalized.altGraph ? false : normalized.alt
283
+ // Shift is a real modifier even during AltGraph input; preserve it for replay.
284
+ const shift = normalized.shift
268
285
 
269
286
  // Build modifiers array in canonical order
270
287
  const modifiers: Array<CanonicalModifier> = []
271
- if (event.ctrlKey) modifiers.push('Control')
272
- if (event.altKey) modifiers.push('Alt')
273
- if (event.shiftKey) modifiers.push('Shift')
274
- if (event.metaKey) modifiers.push('Meta')
288
+ if (ctrl) modifiers.push('Control')
289
+ if (alt) modifiers.push('Alt')
290
+ if (shift) modifiers.push('Shift')
291
+ if (normalized.meta) modifiers.push('Meta')
275
292
 
276
293
  return {
277
- key: normalizedKey,
278
- ctrl: event.ctrlKey,
279
- shift: event.shiftKey,
280
- alt: event.altKey,
281
- meta: event.metaKey,
294
+ key: normalized.key,
295
+ ctrl,
296
+ shift,
297
+ alt,
298
+ meta: normalized.meta,
282
299
  modifiers,
283
300
  }
284
301
  }
@@ -293,7 +310,10 @@ export function normalizeHotkeyFromEvent(
293
310
  event: KeyboardEvent,
294
311
  platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
295
312
  ): Hotkey {
296
- return normalizedHotkeyStringFromParsed(parseKeyboardEvent(event), platform)
313
+ return normalizedHotkeyStringFromParsed(
314
+ parseKeyboardEvent(event, platform),
315
+ platform,
316
+ )
297
317
  }
298
318
 
299
319
  /**
@@ -320,9 +340,30 @@ export function hasNonModifierKey(
320
340
  const parsed =
321
341
  typeof hotkey === 'string' ? parseHotkey(hotkey, platform) : hotkey
322
342
 
343
+ if (parsed.code !== undefined) {
344
+ // Physical modifier codes identify a side, but still are not action keys.
345
+ return (
346
+ parsed.code.length > 0 &&
347
+ !/^(Control|Shift|Alt|Meta)(Left|Right)$/.test(parsed.code)
348
+ )
349
+ }
350
+
323
351
  // Check if the key part is actually a modifier
324
352
  const keyIsModifier = isModifierKey(parsed.key)
325
353
 
326
354
  // A valid hotkey must have a non-modifier key
327
355
  return !keyIsModifier && parsed.key.length > 0
328
356
  }
357
+
358
+ /**
359
+ * Parses a string or raw binding, resolving Mod for the supplied platform.
360
+ * Physical tokens such as `[KeyQ]` retain `code`; they never become logical Q.
361
+ */
362
+ export function parseRegisterableHotkey(
363
+ hotkey: RegisterableHotkey,
364
+ platform = detectPlatform(),
365
+ ): ParsedHotkey {
366
+ return typeof hotkey === 'string'
367
+ ? parseHotkey(hotkey, platform)
368
+ : rawHotkeyToParsedHotkey(hotkey, platform)
369
+ }
@@ -0,0 +1,66 @@
1
+ import type { CanonicalModifier } from './key.types'
2
+
3
+ /**
4
+ * Detects the current platform based on browser navigator properties.
5
+ *
6
+ * Used internally to resolve platform-adaptive modifiers like 'Mod' (Command on Mac,
7
+ * Control elsewhere) and for platform-specific hotkey formatting.
8
+ *
9
+ * @returns The detected platform: 'mac', 'windows', or 'linux'
10
+ * @remarks Defaults to 'linux' in SSR environments where navigator is undefined
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * const platform = detectPlatform() // 'mac' | 'windows' | 'linux'
15
+ * const modifier = resolveModifier('Mod', platform) // 'Meta' on Mac, 'Control' elsewhere
16
+ * ```
17
+ */
18
+ export function detectPlatform(): 'mac' | 'windows' | 'linux' {
19
+ if (typeof navigator === 'undefined') {
20
+ return 'linux' // Default for SSR
21
+ }
22
+
23
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
24
+ const platform = navigator.platform?.toLowerCase() ?? ''
25
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
26
+ const userAgent = navigator.userAgent?.toLowerCase() ?? ''
27
+
28
+ if (platform.includes('mac') || userAgent.includes('mac')) {
29
+ return 'mac'
30
+ }
31
+ if (platform.includes('win') || userAgent.includes('win')) {
32
+ return 'windows'
33
+ }
34
+ return 'linux'
35
+ }
36
+
37
+ /**
38
+ * Resolves the platform-adaptive 'Mod' modifier to the appropriate canonical modifier.
39
+ *
40
+ * The 'Mod' token represents the "primary modifier" on each platform:
41
+ * - macOS: 'Meta' (Command key ⌘)
42
+ * - Windows/Linux: 'Control' (Ctrl key)
43
+ *
44
+ * This enables cross-platform hotkey definitions like 'Mod+S' that automatically
45
+ * map to Command+S on Mac and Ctrl+S on Windows/Linux.
46
+ *
47
+ * @param modifier - The modifier to resolve. If 'Mod', resolves based on platform.
48
+ * @param platform - The target platform. Defaults to auto-detection.
49
+ * @returns The canonical modifier name ('Control', 'Shift', 'Alt', or 'Meta')
50
+ *
51
+ * @example
52
+ * ```ts
53
+ * resolveModifier('Mod', 'mac') // 'Meta'
54
+ * resolveModifier('Mod', 'windows') // 'Control'
55
+ * resolveModifier('Control', 'mac') // 'Control' (unchanged)
56
+ * ```
57
+ */
58
+ export function resolveModifier(
59
+ modifier: CanonicalModifier | 'Mod',
60
+ platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
61
+ ): CanonicalModifier {
62
+ if (modifier === 'Mod') {
63
+ return platform === 'mac' ? 'Meta' : 'Control'
64
+ }
65
+ return modifier
66
+ }