@tanstack/hotkeys 0.4.3 → 0.6.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 (64) hide show
  1. package/README.md +1 -1
  2. package/dist/constants.cjs +70 -10
  3. package/dist/constants.cjs.map +1 -1
  4. package/dist/constants.d.cts +57 -4
  5. package/dist/constants.d.ts +57 -4
  6. package/dist/constants.js +67 -10
  7. package/dist/constants.js.map +1 -1
  8. package/dist/format.cjs +20 -125
  9. package/dist/format.cjs.map +1 -1
  10. package/dist/format.d.cts +8 -72
  11. package/dist/format.d.ts +8 -72
  12. package/dist/format.js +23 -127
  13. package/dist/format.js.map +1 -1
  14. package/dist/hotkey-manager.cjs.map +1 -1
  15. package/dist/hotkey-manager.d.cts +5 -1
  16. package/dist/hotkey-manager.d.ts +5 -1
  17. package/dist/hotkey-manager.js.map +1 -1
  18. package/dist/{recorder.cjs → hotkey-recorder.cjs} +14 -16
  19. package/dist/hotkey-recorder.cjs.map +1 -0
  20. package/dist/{recorder.d.cts → hotkey-recorder.d.cts} +2 -2
  21. package/dist/{recorder.d.ts → hotkey-recorder.d.ts} +2 -2
  22. package/dist/{recorder.js → hotkey-recorder.js} +14 -16
  23. package/dist/hotkey-recorder.js.map +1 -0
  24. package/dist/hotkey-sequence-recorder.cjs +188 -0
  25. package/dist/hotkey-sequence-recorder.cjs.map +1 -0
  26. package/dist/hotkey-sequence-recorder.d.cts +71 -0
  27. package/dist/hotkey-sequence-recorder.d.ts +71 -0
  28. package/dist/hotkey-sequence-recorder.js +188 -0
  29. package/dist/hotkey-sequence-recorder.js.map +1 -0
  30. package/dist/hotkey.d.cts +2 -0
  31. package/dist/hotkey.d.ts +2 -0
  32. package/dist/index.cjs +12 -7
  33. package/dist/index.d.cts +7 -6
  34. package/dist/index.d.ts +7 -6
  35. package/dist/index.js +7 -6
  36. package/dist/parse.cjs +53 -91
  37. package/dist/parse.cjs.map +1 -1
  38. package/dist/parse.d.cts +31 -72
  39. package/dist/parse.d.ts +31 -72
  40. package/dist/parse.js +51 -89
  41. package/dist/parse.js.map +1 -1
  42. package/dist/recorder-chord.cjs +18 -0
  43. package/dist/recorder-chord.cjs.map +1 -0
  44. package/dist/recorder-chord.js +18 -0
  45. package/dist/recorder-chord.js.map +1 -0
  46. package/dist/sequence-manager.cjs +24 -8
  47. package/dist/sequence-manager.cjs.map +1 -1
  48. package/dist/sequence-manager.d.cts +11 -2
  49. package/dist/sequence-manager.d.ts +11 -2
  50. package/dist/sequence-manager.js +25 -10
  51. package/dist/sequence-manager.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/constants.ts +75 -21
  54. package/src/format.ts +58 -196
  55. package/src/hotkey-manager.ts +5 -1
  56. package/src/{recorder.ts → hotkey-recorder.ts} +18 -32
  57. package/src/hotkey-sequence-recorder.ts +308 -0
  58. package/src/hotkey.ts +2 -0
  59. package/src/index.ts +2 -1
  60. package/src/parse.ts +91 -131
  61. package/src/recorder-chord.ts +25 -0
  62. package/src/sequence-manager.ts +30 -9
  63. package/dist/recorder.cjs.map +0 -1
  64. package/dist/recorder.js.map +0 -1
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export * from './hotkey-manager'
5
5
  export * from './key-state-tracker'
6
6
  export * from './match'
7
7
  export * from './parse'
8
- export * from './recorder'
8
+ export * from './hotkey-recorder'
9
+ export * from './hotkey-sequence-recorder'
9
10
  export * from './sequence-manager'
10
11
  export * from './validate'
package/src/parse.ts CHANGED
@@ -11,6 +11,7 @@ import type {
11
11
  Key,
12
12
  ParsedHotkey,
13
13
  RawHotkey,
14
+ RegisterableHotkey,
14
15
  } from './hotkey'
15
16
 
16
17
  /**
@@ -137,53 +138,110 @@ export function rawHotkeyToParsedHotkey(
137
138
  }
138
139
  }
139
140
 
141
+ /**
142
+ * Serializes a parsed hotkey to the canonical string form used for registration
143
+ * and storage: `Mod` first when the platform allows, then `Alt`, then `Shift`;
144
+ * otherwise full `Control+Alt+Shift+Meta` order.
145
+ */
146
+ function normalizedHotkeyStringFromParsed(
147
+ parsed: ParsedHotkey,
148
+ platform: 'mac' | 'windows' | 'linux',
149
+ ): Hotkey {
150
+ const canUseMod =
151
+ platform === 'mac'
152
+ ? parsed.meta && !parsed.ctrl
153
+ : parsed.ctrl && !parsed.meta
154
+
155
+ const parts: Array<string> = []
156
+
157
+ if (canUseMod) {
158
+ parts.push('Mod')
159
+ if (parsed.alt) {
160
+ parts.push('Alt')
161
+ }
162
+ if (parsed.shift) {
163
+ parts.push('Shift')
164
+ }
165
+ } else {
166
+ for (const modifier of MODIFIER_ORDER) {
167
+ if (parsed.modifiers.includes(modifier)) {
168
+ parts.push(modifier)
169
+ }
170
+ }
171
+ }
172
+
173
+ parts.push(normalizeKeyName(parsed.key))
174
+ return parts.join('+') as Hotkey
175
+ }
176
+
140
177
  /**
141
178
  * Normalizes a hotkey string to its canonical form.
142
179
  *
143
- * The canonical form uses:
144
- * - Full modifier names (Control, Alt, Shift, Meta)
145
- * - Modifiers in order: Control+Alt+Shift+Meta
146
- * - Uppercase letters for single-character keys
147
- * - Proper casing for special keys (Escape, not escape)
180
+ * - When `Mod` is allowed for the platform (Command on Mac without Control;
181
+ * Control on Windows/Linux without Meta): emits `Mod`, then `Alt`, then `Shift`,
182
+ * then the key (e.g. `Mod+Shift+E`, `Mod+S`).
183
+ * - Otherwise: literal modifiers in `Control+Alt+Shift+Meta` order, then the key.
184
+ * - Resolves aliases and normalizes key casing (e.g. `esc` → `Escape`, `a` → `A`).
148
185
  *
149
186
  * @param hotkey - The hotkey string to normalize
150
- * @param platform - The target platform for resolving 'Mod' (defaults to auto-detection)
187
+ * @param platform - The target platform for resolving `Mod` (defaults to auto-detection)
151
188
  * @returns The normalized hotkey string
152
189
  *
153
190
  * @example
154
191
  * ```ts
155
- * normalizeHotkey('mod+shift+s') // On Mac: 'Shift+Meta+S'
156
- * normalizeHotkey('ctrl+a') // 'Control+A'
192
+ * normalizeHotkey('shift+meta+e', 'mac') // 'Mod+Shift+E'
193
+ * normalizeHotkey('ctrl+a', 'windows') // 'Mod+A'
157
194
  * normalizeHotkey('esc') // 'Escape'
158
195
  * ```
159
196
  */
160
197
  export function normalizeHotkey(
161
198
  hotkey: Key | (string & {}),
162
199
  platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
163
- ): string {
164
- const parsed = parseHotkey(hotkey, platform)
165
- const parts: Array<string> = []
166
-
167
- // Add modifiers in canonical order
168
- for (const modifier of MODIFIER_ORDER) {
169
- if (parsed.modifiers.includes(modifier)) {
170
- parts.push(modifier)
171
- }
172
- }
200
+ ): Hotkey {
201
+ return normalizedHotkeyStringFromParsed(
202
+ parseHotkey(hotkey, platform),
203
+ platform,
204
+ )
205
+ }
173
206
 
174
- // Add the key
175
- parts.push(parsed.key)
207
+ /**
208
+ * Same canonical string as {@link normalizeHotkey}, but from an already-parsed hotkey.
209
+ */
210
+ export function normalizeHotkeyFromParsed(
211
+ parsed: ParsedHotkey,
212
+ platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
213
+ ): Hotkey {
214
+ return normalizedHotkeyStringFromParsed(parsed, platform)
215
+ }
176
216
 
177
- return parts.join('+')
217
+ /**
218
+ * Normalizes a string or {@link RawHotkey} object to the same canonical hotkey string.
219
+ * Use this in framework adapters instead of branching on `formatHotkey(rawHotkeyToParsedHotkey(...))`.
220
+ */
221
+ export function normalizeRegisterableHotkey(
222
+ hotkey: RegisterableHotkey,
223
+ platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
224
+ ): Hotkey {
225
+ return typeof hotkey === 'string'
226
+ ? normalizeHotkey(hotkey, platform)
227
+ : normalizeHotkeyFromParsed(
228
+ rawHotkeyToParsedHotkey(hotkey, platform),
229
+ platform,
230
+ )
178
231
  }
179
232
 
180
233
  /**
181
- * Checks if a string represents a modifier key.
234
+ * Checks if a string is a recognized modifier token (including aliases).
235
+ *
236
+ * For a `KeyboardEvent`, use `isModifier(normalizeKeyName(event.key))` so DOM
237
+ * spellings like `OS` / `Win` match the same alias table.
182
238
  *
183
- * @param key - The string to check
239
+ * @param key - Key name or alias (e.g. from a hotkey string or `event.key`)
184
240
  * @returns True if the string is a recognized modifier
185
241
  */
186
- export function isModifier(key: string): boolean {
242
+ export function isModifierKey(
243
+ key: Key | (string & {}),
244
+ ): key is keyof typeof MODIFIER_ALIASES {
187
245
  return key in MODIFIER_ALIASES || key.toLowerCase() in MODIFIER_ALIASES
188
246
  }
189
247
 
@@ -226,72 +284,16 @@ export function parseKeyboardEvent(event: KeyboardEvent): ParsedHotkey {
226
284
  }
227
285
 
228
286
  /**
229
- * Converts a KeyboardEvent directly to a hotkey string.
230
- *
231
- * This is a convenience function that combines `parseKeyboardEvent()` and formatting.
232
- * The resulting hotkey string uses canonical modifier names (Control, Alt, Shift, Meta)
233
- * and is suitable for use with `useHotkey()` and other hotkey functions.
234
- *
235
- * @param event - The KeyboardEvent to convert
236
- * @param platform - The target platform (defaults to auto-detection)
237
- * @returns A hotkey string in canonical form (e.g., 'Control+Shift+S')
287
+ * Normalizes a keyboard event to the same canonical hotkey string as {@link normalizeHotkey}.
238
288
  *
239
- * @example
240
- * ```ts
241
- * document.addEventListener('keydown', (event) => {
242
- * const hotkey = keyboardEventToHotkey(event)
243
- * console.log(hotkey) // 'Control+Shift+S'
244
- * useHotkey(hotkey, () => console.log('Shortcut triggered'))
245
- * })
246
- * ```
247
- */
248
- export function keyboardEventToHotkey(event: KeyboardEvent): Hotkey {
249
- const parsed = parseKeyboardEvent(event)
250
-
251
- // Build hotkey string in canonical order (same as formatHotkey)
252
- const parts: Array<string> = []
253
- for (const modifier of MODIFIER_ORDER) {
254
- if (parsed.modifiers.includes(modifier)) {
255
- parts.push(modifier)
256
- }
257
- }
258
- parts.push(parsed.key)
259
-
260
- return parts.join('+') as Hotkey
261
- }
262
-
263
- /**
264
- * Checks if a KeyboardEvent represents a modifier-only key press.
265
- *
266
- * Modifier-only keys are keys like 'Control', 'Shift', 'Alt', 'Meta', etc.
267
- * that don't have an associated character or action key. This is useful
268
- * for filtering out modifier key presses when recording shortcuts.
269
- *
270
- * @param event - The KeyboardEvent to check
271
- * @returns True if the event represents a modifier-only key
272
- *
273
- * @example
274
- * ```ts
275
- * document.addEventListener('keydown', (event) => {
276
- * if (isModifierKey(event)) {
277
- * console.log('Modifier key pressed, waiting for action key...')
278
- * return
279
- * }
280
- * // Process non-modifier key
281
- * })
282
- * ```
289
+ * @param event - The keyboard event (typically `keydown`)
290
+ * @param platform - Target platform for `Mod` eligibility
283
291
  */
284
- export function isModifierKey(event: KeyboardEvent): boolean {
285
- const key = event.key
286
- return (
287
- key === 'Control' ||
288
- key === 'Shift' ||
289
- key === 'Alt' ||
290
- key === 'Meta' ||
291
- key === 'Command' ||
292
- key === 'OS' ||
293
- key === 'Win'
294
- )
292
+ export function normalizeHotkeyFromEvent(
293
+ event: KeyboardEvent,
294
+ platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
295
+ ): Hotkey {
296
+ return normalizedHotkeyStringFromParsed(parseKeyboardEvent(event), platform)
295
297
  }
296
298
 
297
299
  /**
@@ -319,50 +321,8 @@ export function hasNonModifierKey(
319
321
  typeof hotkey === 'string' ? parseHotkey(hotkey, platform) : hotkey
320
322
 
321
323
  // Check if the key part is actually a modifier
322
- const keyIsModifier = isModifier(parsed.key)
324
+ const keyIsModifier = isModifierKey(parsed.key)
323
325
 
324
326
  // A valid hotkey must have a non-modifier key
325
327
  return !keyIsModifier && parsed.key.length > 0
326
328
  }
327
-
328
- /**
329
- * Converts a hotkey string to use 'Mod' format for portability.
330
- *
331
- * On macOS, converts 'Meta' to 'Mod'. On Windows/Linux, converts 'Control' to 'Mod'.
332
- * This enables cross-platform hotkey definitions that work consistently.
333
- *
334
- * @param hotkey - The hotkey string to convert
335
- * @param platform - The target platform (defaults to auto-detection)
336
- * @returns The hotkey string with 'Mod' format applied
337
- *
338
- * @example
339
- * ```ts
340
- * convertToModFormat('Meta+S', 'mac') // 'Mod+S'
341
- * convertToModFormat('Control+S', 'windows') // 'Mod+S'
342
- * convertToModFormat('Control+Meta+S', 'mac') // 'Control+Meta+S' (both present, no conversion)
343
- * ```
344
- */
345
- export function convertToModFormat(
346
- hotkey: Hotkey | (string & {}),
347
- platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
348
- ): Hotkey {
349
- const parsed = parseHotkey(hotkey, platform)
350
-
351
- // Only convert if we have exactly one primary modifier
352
- if (platform === 'mac' && parsed.meta && !parsed.ctrl) {
353
- // Convert Meta to Mod on Mac
354
- const parts = hotkey.split('+')
355
- return parts
356
- .map((part) => (part === 'Meta' ? 'Mod' : part))
357
- .join('+') as Hotkey
358
- } else if (platform !== 'mac' && parsed.ctrl && !parsed.meta) {
359
- // Convert Control to Mod on Windows/Linux
360
- const parts = hotkey.split('+')
361
- return parts
362
- .map((part) => (part === 'Control' ? 'Mod' : part))
363
- .join('+') as Hotkey
364
- }
365
-
366
- // No conversion needed
367
- return hotkey as Hotkey
368
- }
@@ -0,0 +1,25 @@
1
+ import { normalizeKeyName } from './constants'
2
+ import {
3
+ hasNonModifierKey,
4
+ isModifierKey,
5
+ normalizeHotkeyFromEvent,
6
+ } from './parse'
7
+ import type { Hotkey } from './hotkey'
8
+
9
+ /**
10
+ * Parses a keydown event into a single normalized hotkey chord, or null if
11
+ * the event should be ignored (modifier-only or invalid).
12
+ */
13
+ export function hotkeyChordFromKeydown(
14
+ event: KeyboardEvent,
15
+ platform: 'mac' | 'windows' | 'linux',
16
+ ): Hotkey | null {
17
+ if (isModifierKey(normalizeKeyName(event.key))) {
18
+ return null
19
+ }
20
+ const finalHotkey = normalizeHotkeyFromEvent(event, platform)
21
+ if (!hasNonModifierKey(finalHotkey, platform)) {
22
+ return null
23
+ }
24
+ return finalHotkey
25
+ }
@@ -1,6 +1,6 @@
1
1
  import { Store } from '@tanstack/store'
2
2
  import { formatHotkeySequence } from './format'
3
- import { detectPlatform } from './constants'
3
+ import { detectPlatform, normalizeKeyName } from './constants'
4
4
  import { isModifierKey, parseHotkey } from './parse'
5
5
  import { matchesKeyboardEvent } from './match'
6
6
  import {
@@ -49,8 +49,9 @@ export type HotkeySequence = Array<Hotkey>
49
49
 
50
50
  /**
51
51
  * Default timeout between keys in a sequence (in milliseconds).
52
+ * Exported for consumers that display sequence state (e.g. devtools).
52
53
  */
53
- const DEFAULT_SEQUENCE_TIMEOUT = 1000
54
+ export const DEFAULT_SEQUENCE_TIMEOUT = 1000
54
55
 
55
56
  let sequenceIdCounter = 0
56
57
 
@@ -70,7 +71,7 @@ function sequenceKey(sequence: HotkeySequence): string {
70
71
 
71
72
  /**
72
73
  * View of a sequence registration for devtools display.
73
- * Excludes internal matching state (currentIndex, lastKeyTime).
74
+ * Progress fields reflect an in-progress match (between first key and completion or timeout).
74
75
  */
75
76
  export interface SequenceRegistrationView {
76
77
  id: string
@@ -78,6 +79,10 @@ export interface SequenceRegistrationView {
78
79
  options: SequenceOptions
79
80
  target: Target
80
81
  triggerCount: number
82
+ /** Steps matched in the current attempt (0 when idle or just completed). */
83
+ matchedStepCount: number
84
+ /** `Date.now()` when the last step in the current attempt matched; 0 if none. */
85
+ partialMatchLastKeyTime: number
81
86
  }
82
87
 
83
88
  /**
@@ -147,12 +152,16 @@ export interface SequenceRegistrationHandle {
147
152
  function toRegistrationView(
148
153
  reg: SequenceRegistration,
149
154
  ): SequenceRegistrationView {
155
+ const len = reg.parsedSequence.length
156
+ const inProgress = reg.currentIndex > 0 && reg.currentIndex < len
150
157
  return {
151
158
  id: reg.id,
152
159
  sequence: reg.sequence,
153
160
  options: reg.options,
154
161
  target: reg.target,
155
162
  triggerCount: reg.triggerCount,
163
+ matchedStepCount: inProgress ? reg.currentIndex : 0,
164
+ partialMatchLastKeyTime: inProgress ? reg.lastKeyTime : 0,
156
165
  }
157
166
  }
158
167
 
@@ -201,6 +210,12 @@ export class SequenceManager {
201
210
  }
202
211
  }
203
212
 
213
+ #syncRegistrationView(registration: SequenceRegistration): void {
214
+ this.registrations.setState((prev) =>
215
+ new Map(prev).set(registration.id, toRegistrationView(registration)),
216
+ )
217
+ }
218
+
204
219
  /**
205
220
  * Registers a hotkey sequence handler.
206
221
  *
@@ -419,7 +434,7 @@ export class SequenceManager {
419
434
  eventType: 'keydown' | 'keyup',
420
435
  ): void {
421
436
  // Skip modifier-only events so pressing e.g. Shift before Shift+C does not reset the sequence.
422
- if (isModifierKey(event)) {
437
+ if (isModifierKey(normalizeKeyName(event.key))) {
423
438
  return
424
439
  }
425
440
 
@@ -467,6 +482,7 @@ export class SequenceManager {
467
482
  now - registration.lastKeyTime > timeout
468
483
  ) {
469
484
  registration.currentIndex = 0
485
+ this.#syncRegistrationView(registration)
470
486
  }
471
487
 
472
488
  const expectedHotkey =
@@ -501,9 +517,14 @@ export class SequenceManager {
501
517
  ]!,
502
518
  }
503
519
 
504
- registration.callback(event, context)
505
-
520
+ registration.triggerCount++
506
521
  registration.currentIndex = 0
522
+
523
+ this.#syncRegistrationView(registration)
524
+
525
+ registration.callback(event, context)
526
+ } else {
527
+ this.#syncRegistrationView(registration)
507
528
  }
508
529
  } else if (registration.currentIndex > 0) {
509
530
  const firstHotkey = registration.parsedSequence[0]!
@@ -519,6 +540,7 @@ export class SequenceManager {
519
540
  } else {
520
541
  registration.currentIndex = 0
521
542
  }
543
+ this.#syncRegistrationView(registration)
522
544
  }
523
545
  }
524
546
  }
@@ -549,6 +571,7 @@ export class SequenceManager {
549
571
  for (const registration of this.#registrations.values()) {
550
572
  registration.currentIndex = 0
551
573
  registration.lastKeyTime = 0
574
+ this.#syncRegistrationView(registration)
552
575
  }
553
576
  }
554
577
 
@@ -586,9 +609,7 @@ export class SequenceManager {
586
609
 
587
610
  registration.triggerCount++
588
611
 
589
- this.registrations.setState((prev) =>
590
- new Map(prev).set(id, toRegistrationView(registration)),
591
- )
612
+ this.#syncRegistrationView(registration)
592
613
 
593
614
  const context: HotkeyCallbackContext = {
594
615
  hotkey: registration.sequence.join(' ') as Hotkey,
@@ -1 +0,0 @@
1
- {"version":3,"file":"recorder.cjs","names":["Store","#options","#platform","detectPlatform","#keydownHandler","isModifierKey","convertToModFormat","keyboardEventToHotkey","hasNonModifierKey","#removeListener","#addListener"],"sources":["../src/recorder.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { detectPlatform } from './constants'\nimport {\n convertToModFormat,\n hasNonModifierKey,\n isModifierKey,\n keyboardEventToHotkey,\n} from './parse'\nimport type { Hotkey } from './hotkey'\n\n/**\n * State interface for the HotkeyRecorder.\n */\nexport interface HotkeyRecorderState {\n /** Whether recording is currently active */\n isRecording: boolean\n /** The currently recorded hotkey (for live preview) */\n recordedHotkey: Hotkey | null\n}\n\n/**\n * Options for configuring a HotkeyRecorder instance.\n */\nexport interface HotkeyRecorderOptions {\n /** Callback when a hotkey is successfully recorded */\n onRecord: (hotkey: Hotkey) => void\n /** Optional callback when recording is cancelled (Escape pressed) */\n onCancel?: () => void\n /** Optional callback when shortcut is cleared (Backspace/Delete pressed) */\n onClear?: () => void\n}\n\n/**\n * Framework-agnostic class for recording keyboard shortcuts.\n *\n * This class handles all the complexity of capturing keyboard events,\n * converting them to hotkey strings, and handling edge cases like\n * Escape to cancel or Backspace/Delete to clear.\n *\n * State Management:\n * - Uses TanStack Store for reactive state management\n * - State can be accessed via `recorder.store.state` when using the class directly\n * - When using framework adapters (React), use `useStore` hooks for reactive state\n *\n * @example\n * ```ts\n * const recorder = new HotkeyRecorder({\n * onRecord: (hotkey) => {\n * console.log('Recorded:', hotkey)\n * },\n * onCancel: () => {\n * console.log('Recording cancelled')\n * },\n * })\n *\n * // Start recording\n * recorder.start()\n *\n * // Access state directly\n * console.log(recorder.store.state.isRecording) // true\n *\n * // Subscribe to changes with TanStack Store\n * const unsubscribe = recorder.store.subscribe(() => {\n * console.log('Recording:', recorder.store.state.isRecording)\n * })\n *\n * // Cleanup\n * recorder.destroy()\n * unsubscribe()\n * ```\n */\nexport class HotkeyRecorder {\n /**\n * The TanStack Store instance containing the recorder state.\n * Use this to subscribe to state changes or access current state.\n */\n readonly store: Store<HotkeyRecorderState> = new Store<HotkeyRecorderState>({\n isRecording: false,\n recordedHotkey: null,\n })\n\n #keydownHandler: ((event: KeyboardEvent) => void) | null = null\n #options: HotkeyRecorderOptions\n #platform: 'mac' | 'windows' | 'linux'\n\n constructor(options: HotkeyRecorderOptions) {\n this.#options = options\n this.#platform = detectPlatform()\n }\n\n /**\n * Updates the recorder options, including callbacks.\n * This allows framework adapters to sync callback changes without recreating the recorder.\n */\n setOptions(options: Partial<HotkeyRecorderOptions>): void {\n this.#options = {\n ...this.#options,\n ...options,\n }\n }\n\n /**\n * Start recording a new hotkey.\n *\n * Sets up a keydown event listener that captures keyboard events\n * and converts them to hotkey strings. Recording continues until\n * a valid hotkey is recorded, Escape is pressed, or stop/cancel is called.\n */\n start(): void {\n // Prevent starting recording if already recording\n if (this.#keydownHandler) {\n return\n }\n\n // Update store state\n this.store.setState(() => ({\n isRecording: true,\n recordedHotkey: null,\n }))\n\n // Create keydown handler\n const handler = (event: KeyboardEvent) => {\n // Check if we're still recording (handler might be called after stop/cancel)\n if (!this.#keydownHandler) {\n return\n }\n\n event.preventDefault()\n event.stopPropagation()\n\n // Handle Escape to cancel\n if (event.key === 'Escape') {\n this.cancel()\n return\n }\n\n // Handle Backspace/Delete to clear shortcut\n if (event.key === 'Backspace' || event.key === 'Delete') {\n if (\n !event.ctrlKey &&\n !event.shiftKey &&\n !event.altKey &&\n !event.metaKey\n ) {\n this.#options.onClear?.()\n this.#options.onRecord('' as Hotkey)\n this.stop()\n return\n }\n }\n\n // Ignore pure modifier keys (wait for a non-modifier key)\n if (isModifierKey(event)) {\n return\n }\n\n // Convert event to hotkey string using library function\n const hotkey = keyboardEventToHotkey(event)\n\n // Always convert to Mod format for portability\n const finalHotkey = convertToModFormat(hotkey, this.#platform)\n\n // Validate: must have at least one non-modifier key\n if (hasNonModifierKey(finalHotkey, this.#platform)) {\n // Remove listener FIRST to prevent any additional events\n const handlerToRemove = this.#keydownHandler as\n | ((event: KeyboardEvent) => void)\n | null\n if (handlerToRemove) {\n this.#removeListener(handlerToRemove)\n this.#keydownHandler = null\n }\n\n // Update store state immediately\n this.store.setState(() => ({\n isRecording: false,\n recordedHotkey: finalHotkey,\n }))\n\n // Call callback AFTER listener is removed and state is set\n this.#options.onRecord(finalHotkey)\n }\n }\n\n this.#keydownHandler = handler\n this.#addListener(handler)\n }\n\n /**\n * Stop recording (same as cancel, but doesn't call onCancel).\n *\n * Removes the event listener and resets the recording state.\n */\n stop(): void {\n // Remove event listener immediately\n if (this.#keydownHandler) {\n this.#removeListener(this.#keydownHandler)\n this.#keydownHandler = null\n }\n\n // Update store state\n this.store.setState(() => ({\n isRecording: false,\n recordedHotkey: null,\n }))\n }\n\n /**\n * Cancel recording without saving.\n *\n * Removes the event listener, resets the recording state, and calls\n * the onCancel callback if provided.\n */\n cancel(): void {\n // Remove event listener immediately\n if (this.#keydownHandler) {\n this.#removeListener(this.#keydownHandler)\n this.#keydownHandler = null\n }\n\n // Update store state\n this.store.setState(() => ({\n isRecording: false,\n recordedHotkey: null,\n }))\n\n // Call cancel callback\n this.#options.onCancel?.()\n }\n\n /**\n * Adds the keydown event listener to the document.\n */\n #addListener(handler: (event: KeyboardEvent) => void): void {\n if (typeof document === 'undefined') {\n return // SSR safety\n }\n\n document.addEventListener('keydown', handler, true)\n }\n\n /**\n * Removes the keydown event listener from the document.\n */\n #removeListener(handler: (event: KeyboardEvent) => void): void {\n if (typeof document === 'undefined') {\n return\n }\n\n document.removeEventListener('keydown', handler, true)\n }\n\n /**\n * Clean up event listeners and reset state.\n *\n * Call this when you're done with the recorder to ensure\n * all event listeners are properly removed.\n */\n destroy(): void {\n this.stop()\n this.store.setState(() => ({\n isRecording: false,\n recordedHotkey: null,\n }))\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuEA,IAAa,iBAAb,MAA4B;CAU1B,kBAA2D;CAC3D;CACA;CAEA,YAAY,SAAgC;eATC,IAAIA,sBAA2B;GAC1E,aAAa;GACb,gBAAgB;GACjB,CAAC;AAOA,QAAKC,UAAW;AAChB,QAAKC,WAAYC,kCAAgB;;;;;;CAOnC,WAAW,SAA+C;AACxD,QAAKF,UAAW;GACd,GAAG,MAAKA;GACR,GAAG;GACJ;;;;;;;;;CAUH,QAAc;AAEZ,MAAI,MAAKG,eACP;AAIF,OAAK,MAAM,gBAAgB;GACzB,aAAa;GACb,gBAAgB;GACjB,EAAE;EAGH,MAAM,WAAW,UAAyB;AAExC,OAAI,CAAC,MAAKA,eACR;AAGF,SAAM,gBAAgB;AACtB,SAAM,iBAAiB;AAGvB,OAAI,MAAM,QAAQ,UAAU;AAC1B,SAAK,QAAQ;AACb;;AAIF,OAAI,MAAM,QAAQ,eAAe,MAAM,QAAQ,UAC7C;QACE,CAAC,MAAM,WACP,CAAC,MAAM,YACP,CAAC,MAAM,UACP,CAAC,MAAM,SACP;AACA,WAAKH,QAAS,WAAW;AACzB,WAAKA,QAAS,SAAS,GAAa;AACpC,UAAK,MAAM;AACX;;;AAKJ,OAAII,4BAAc,MAAM,CACtB;GAOF,MAAM,cAAcC,iCAHLC,oCAAsB,MAAM,EAGI,MAAKL,SAAU;AAG9D,OAAIM,gCAAkB,aAAa,MAAKN,SAAU,EAAE;IAElD,MAAM,kBAAkB,MAAKE;AAG7B,QAAI,iBAAiB;AACnB,WAAKK,eAAgB,gBAAgB;AACrC,WAAKL,iBAAkB;;AAIzB,SAAK,MAAM,gBAAgB;KACzB,aAAa;KACb,gBAAgB;KACjB,EAAE;AAGH,UAAKH,QAAS,SAAS,YAAY;;;AAIvC,QAAKG,iBAAkB;AACvB,QAAKM,YAAa,QAAQ;;;;;;;CAQ5B,OAAa;AAEX,MAAI,MAAKN,gBAAiB;AACxB,SAAKK,eAAgB,MAAKL,eAAgB;AAC1C,SAAKA,iBAAkB;;AAIzB,OAAK,MAAM,gBAAgB;GACzB,aAAa;GACb,gBAAgB;GACjB,EAAE;;;;;;;;CASL,SAAe;AAEb,MAAI,MAAKA,gBAAiB;AACxB,SAAKK,eAAgB,MAAKL,eAAgB;AAC1C,SAAKA,iBAAkB;;AAIzB,OAAK,MAAM,gBAAgB;GACzB,aAAa;GACb,gBAAgB;GACjB,EAAE;AAGH,QAAKH,QAAS,YAAY;;;;;CAM5B,aAAa,SAA+C;AAC1D,MAAI,OAAO,aAAa,YACtB;AAGF,WAAS,iBAAiB,WAAW,SAAS,KAAK;;;;;CAMrD,gBAAgB,SAA+C;AAC7D,MAAI,OAAO,aAAa,YACtB;AAGF,WAAS,oBAAoB,WAAW,SAAS,KAAK;;;;;;;;CASxD,UAAgB;AACd,OAAK,MAAM;AACX,OAAK,MAAM,gBAAgB;GACzB,aAAa;GACb,gBAAgB;GACjB,EAAE"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"recorder.js","names":["#options","#platform","#keydownHandler","#removeListener","#addListener"],"sources":["../src/recorder.ts"],"sourcesContent":["import { Store } from '@tanstack/store'\nimport { detectPlatform } from './constants'\nimport {\n convertToModFormat,\n hasNonModifierKey,\n isModifierKey,\n keyboardEventToHotkey,\n} from './parse'\nimport type { Hotkey } from './hotkey'\n\n/**\n * State interface for the HotkeyRecorder.\n */\nexport interface HotkeyRecorderState {\n /** Whether recording is currently active */\n isRecording: boolean\n /** The currently recorded hotkey (for live preview) */\n recordedHotkey: Hotkey | null\n}\n\n/**\n * Options for configuring a HotkeyRecorder instance.\n */\nexport interface HotkeyRecorderOptions {\n /** Callback when a hotkey is successfully recorded */\n onRecord: (hotkey: Hotkey) => void\n /** Optional callback when recording is cancelled (Escape pressed) */\n onCancel?: () => void\n /** Optional callback when shortcut is cleared (Backspace/Delete pressed) */\n onClear?: () => void\n}\n\n/**\n * Framework-agnostic class for recording keyboard shortcuts.\n *\n * This class handles all the complexity of capturing keyboard events,\n * converting them to hotkey strings, and handling edge cases like\n * Escape to cancel or Backspace/Delete to clear.\n *\n * State Management:\n * - Uses TanStack Store for reactive state management\n * - State can be accessed via `recorder.store.state` when using the class directly\n * - When using framework adapters (React), use `useStore` hooks for reactive state\n *\n * @example\n * ```ts\n * const recorder = new HotkeyRecorder({\n * onRecord: (hotkey) => {\n * console.log('Recorded:', hotkey)\n * },\n * onCancel: () => {\n * console.log('Recording cancelled')\n * },\n * })\n *\n * // Start recording\n * recorder.start()\n *\n * // Access state directly\n * console.log(recorder.store.state.isRecording) // true\n *\n * // Subscribe to changes with TanStack Store\n * const unsubscribe = recorder.store.subscribe(() => {\n * console.log('Recording:', recorder.store.state.isRecording)\n * })\n *\n * // Cleanup\n * recorder.destroy()\n * unsubscribe()\n * ```\n */\nexport class HotkeyRecorder {\n /**\n * The TanStack Store instance containing the recorder state.\n * Use this to subscribe to state changes or access current state.\n */\n readonly store: Store<HotkeyRecorderState> = new Store<HotkeyRecorderState>({\n isRecording: false,\n recordedHotkey: null,\n })\n\n #keydownHandler: ((event: KeyboardEvent) => void) | null = null\n #options: HotkeyRecorderOptions\n #platform: 'mac' | 'windows' | 'linux'\n\n constructor(options: HotkeyRecorderOptions) {\n this.#options = options\n this.#platform = detectPlatform()\n }\n\n /**\n * Updates the recorder options, including callbacks.\n * This allows framework adapters to sync callback changes without recreating the recorder.\n */\n setOptions(options: Partial<HotkeyRecorderOptions>): void {\n this.#options = {\n ...this.#options,\n ...options,\n }\n }\n\n /**\n * Start recording a new hotkey.\n *\n * Sets up a keydown event listener that captures keyboard events\n * and converts them to hotkey strings. Recording continues until\n * a valid hotkey is recorded, Escape is pressed, or stop/cancel is called.\n */\n start(): void {\n // Prevent starting recording if already recording\n if (this.#keydownHandler) {\n return\n }\n\n // Update store state\n this.store.setState(() => ({\n isRecording: true,\n recordedHotkey: null,\n }))\n\n // Create keydown handler\n const handler = (event: KeyboardEvent) => {\n // Check if we're still recording (handler might be called after stop/cancel)\n if (!this.#keydownHandler) {\n return\n }\n\n event.preventDefault()\n event.stopPropagation()\n\n // Handle Escape to cancel\n if (event.key === 'Escape') {\n this.cancel()\n return\n }\n\n // Handle Backspace/Delete to clear shortcut\n if (event.key === 'Backspace' || event.key === 'Delete') {\n if (\n !event.ctrlKey &&\n !event.shiftKey &&\n !event.altKey &&\n !event.metaKey\n ) {\n this.#options.onClear?.()\n this.#options.onRecord('' as Hotkey)\n this.stop()\n return\n }\n }\n\n // Ignore pure modifier keys (wait for a non-modifier key)\n if (isModifierKey(event)) {\n return\n }\n\n // Convert event to hotkey string using library function\n const hotkey = keyboardEventToHotkey(event)\n\n // Always convert to Mod format for portability\n const finalHotkey = convertToModFormat(hotkey, this.#platform)\n\n // Validate: must have at least one non-modifier key\n if (hasNonModifierKey(finalHotkey, this.#platform)) {\n // Remove listener FIRST to prevent any additional events\n const handlerToRemove = this.#keydownHandler as\n | ((event: KeyboardEvent) => void)\n | null\n if (handlerToRemove) {\n this.#removeListener(handlerToRemove)\n this.#keydownHandler = null\n }\n\n // Update store state immediately\n this.store.setState(() => ({\n isRecording: false,\n recordedHotkey: finalHotkey,\n }))\n\n // Call callback AFTER listener is removed and state is set\n this.#options.onRecord(finalHotkey)\n }\n }\n\n this.#keydownHandler = handler\n this.#addListener(handler)\n }\n\n /**\n * Stop recording (same as cancel, but doesn't call onCancel).\n *\n * Removes the event listener and resets the recording state.\n */\n stop(): void {\n // Remove event listener immediately\n if (this.#keydownHandler) {\n this.#removeListener(this.#keydownHandler)\n this.#keydownHandler = null\n }\n\n // Update store state\n this.store.setState(() => ({\n isRecording: false,\n recordedHotkey: null,\n }))\n }\n\n /**\n * Cancel recording without saving.\n *\n * Removes the event listener, resets the recording state, and calls\n * the onCancel callback if provided.\n */\n cancel(): void {\n // Remove event listener immediately\n if (this.#keydownHandler) {\n this.#removeListener(this.#keydownHandler)\n this.#keydownHandler = null\n }\n\n // Update store state\n this.store.setState(() => ({\n isRecording: false,\n recordedHotkey: null,\n }))\n\n // Call cancel callback\n this.#options.onCancel?.()\n }\n\n /**\n * Adds the keydown event listener to the document.\n */\n #addListener(handler: (event: KeyboardEvent) => void): void {\n if (typeof document === 'undefined') {\n return // SSR safety\n }\n\n document.addEventListener('keydown', handler, true)\n }\n\n /**\n * Removes the keydown event listener from the document.\n */\n #removeListener(handler: (event: KeyboardEvent) => void): void {\n if (typeof document === 'undefined') {\n return\n }\n\n document.removeEventListener('keydown', handler, true)\n }\n\n /**\n * Clean up event listeners and reset state.\n *\n * Call this when you're done with the recorder to ensure\n * all event listeners are properly removed.\n */\n destroy(): void {\n this.stop()\n this.store.setState(() => ({\n isRecording: false,\n recordedHotkey: null,\n }))\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAuEA,IAAa,iBAAb,MAA4B;CAU1B,kBAA2D;CAC3D;CACA;CAEA,YAAY,SAAgC;eATC,IAAI,MAA2B;GAC1E,aAAa;GACb,gBAAgB;GACjB,CAAC;AAOA,QAAKA,UAAW;AAChB,QAAKC,WAAY,gBAAgB;;;;;;CAOnC,WAAW,SAA+C;AACxD,QAAKD,UAAW;GACd,GAAG,MAAKA;GACR,GAAG;GACJ;;;;;;;;;CAUH,QAAc;AAEZ,MAAI,MAAKE,eACP;AAIF,OAAK,MAAM,gBAAgB;GACzB,aAAa;GACb,gBAAgB;GACjB,EAAE;EAGH,MAAM,WAAW,UAAyB;AAExC,OAAI,CAAC,MAAKA,eACR;AAGF,SAAM,gBAAgB;AACtB,SAAM,iBAAiB;AAGvB,OAAI,MAAM,QAAQ,UAAU;AAC1B,SAAK,QAAQ;AACb;;AAIF,OAAI,MAAM,QAAQ,eAAe,MAAM,QAAQ,UAC7C;QACE,CAAC,MAAM,WACP,CAAC,MAAM,YACP,CAAC,MAAM,UACP,CAAC,MAAM,SACP;AACA,WAAKF,QAAS,WAAW;AACzB,WAAKA,QAAS,SAAS,GAAa;AACpC,UAAK,MAAM;AACX;;;AAKJ,OAAI,cAAc,MAAM,CACtB;GAOF,MAAM,cAAc,mBAHL,sBAAsB,MAAM,EAGI,MAAKC,SAAU;AAG9D,OAAI,kBAAkB,aAAa,MAAKA,SAAU,EAAE;IAElD,MAAM,kBAAkB,MAAKC;AAG7B,QAAI,iBAAiB;AACnB,WAAKC,eAAgB,gBAAgB;AACrC,WAAKD,iBAAkB;;AAIzB,SAAK,MAAM,gBAAgB;KACzB,aAAa;KACb,gBAAgB;KACjB,EAAE;AAGH,UAAKF,QAAS,SAAS,YAAY;;;AAIvC,QAAKE,iBAAkB;AACvB,QAAKE,YAAa,QAAQ;;;;;;;CAQ5B,OAAa;AAEX,MAAI,MAAKF,gBAAiB;AACxB,SAAKC,eAAgB,MAAKD,eAAgB;AAC1C,SAAKA,iBAAkB;;AAIzB,OAAK,MAAM,gBAAgB;GACzB,aAAa;GACb,gBAAgB;GACjB,EAAE;;;;;;;;CASL,SAAe;AAEb,MAAI,MAAKA,gBAAiB;AACxB,SAAKC,eAAgB,MAAKD,eAAgB;AAC1C,SAAKA,iBAAkB;;AAIzB,OAAK,MAAM,gBAAgB;GACzB,aAAa;GACb,gBAAgB;GACjB,EAAE;AAGH,QAAKF,QAAS,YAAY;;;;;CAM5B,aAAa,SAA+C;AAC1D,MAAI,OAAO,aAAa,YACtB;AAGF,WAAS,iBAAiB,WAAW,SAAS,KAAK;;;;;CAMrD,gBAAgB,SAA+C;AAC7D,MAAI,OAAO,aAAa,YACtB;AAGF,WAAS,oBAAoB,WAAW,SAAS,KAAK;;;;;;;;CASxD,UAAgB;AACd,OAAK,MAAM;AACX,OAAK,MAAM,gBAAgB;GACzB,aAAa;GACb,gBAAgB;GACjB,EAAE"}