@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/format.ts CHANGED
@@ -1,12 +1,26 @@
1
1
  import {
2
2
  KEY_DISPLAY_SYMBOLS,
3
+ LINUX_MODIFIER_LABELS,
4
+ MAC_MODIFIER_LABELS,
3
5
  MAC_MODIFIER_SYMBOLS,
6
+ MODIFIER_ALIASES,
4
7
  MODIFIER_ORDER,
5
- STANDARD_MODIFIER_LABELS,
8
+ PUNCTUATION_KEY_DISPLAY_LABELS,
9
+ WINDOWS_MODIFIER_LABELS,
6
10
  detectPlatform,
7
11
  } from './constants'
8
- import { parseHotkey } from './parse'
9
- import type { FormatDisplayOptions, Hotkey, ParsedHotkey } from './hotkey'
12
+ import {
13
+ isModifierKey,
14
+ normalizeHotkeyFromParsed,
15
+ normalizeRegisterableHotkey,
16
+ } from './parse'
17
+ import type {
18
+ CanonicalModifier,
19
+ FormatDisplayOptions,
20
+ Hotkey,
21
+ ParsedHotkey,
22
+ RegisterableHotkey,
23
+ } from './hotkey'
10
24
 
11
25
  /**
12
26
  * Converts a hotkey sequence array to a display string.
@@ -55,8 +69,8 @@ export function formatHotkey(parsed: ParsedHotkey): string {
55
69
  /**
56
70
  * Formats a hotkey for display in a user interface.
57
71
  *
58
- * On macOS, uses symbols (⌘⇧S).
59
- * On Windows/Linux, uses text (Ctrl+Shift+S).
72
+ * On macOS, uses symbols (⌘⇧S) in the same modifier order as {@link normalizeHotkeyFromParsed}.
73
+ * On Windows/Linux, uses text (Ctrl+Shift+S) with `+` separators.
60
74
  *
61
75
  * @param hotkey - The hotkey string or ParsedHotkey to format
62
76
  * @param options - Formatting options
@@ -65,7 +79,7 @@ export function formatHotkey(parsed: ParsedHotkey): string {
65
79
  * @example
66
80
  * ```ts
67
81
  * formatForDisplay('Mod+Shift+S', { platform: 'mac' })
68
- * // Returns: '⇧⌘S'
82
+ * // Returns: '⌘ ⇧ S' (symbols separated by spaces on macOS)
69
83
  *
70
84
  * formatForDisplay('Mod+Shift+S', { platform: 'windows' })
71
85
  * // Returns: 'Ctrl+Shift+S'
@@ -75,203 +89,51 @@ export function formatHotkey(parsed: ParsedHotkey): string {
75
89
  * ```
76
90
  */
77
91
  export function formatForDisplay(
78
- hotkey: Hotkey | (string & {}) | ParsedHotkey,
92
+ hotkey: RegisterableHotkey | (string & {}),
79
93
  options: FormatDisplayOptions = {},
80
94
  ): string {
81
95
  const platform = options.platform ?? detectPlatform()
82
- const parsed =
83
- typeof hotkey === 'string' ? parseHotkey(hotkey, platform) : hotkey
84
-
85
- if (platform === 'mac') {
86
- return formatForMac(parsed)
87
- }
88
-
89
- return formatForStandard(parsed)
90
- }
91
-
92
- /**
93
- * Formats a hotkey for macOS display using symbols.
94
- */
95
- function formatForMac(parsed: ParsedHotkey): string {
96
- const parts: Array<string> = []
97
-
98
- // Add modifiers in macOS order (typically Control, Option, Shift, Command)
99
- // But we'll use our canonical order and just use symbols
100
- for (const modifier of MODIFIER_ORDER) {
101
- if (parsed.modifiers.includes(modifier)) {
102
- parts.push(MAC_MODIFIER_SYMBOLS[modifier])
103
- }
104
- }
105
-
106
- // Add the key (use symbol if available, otherwise the key itself)
107
- const keyDisplay = KEY_DISPLAY_SYMBOLS[parsed.key] ?? parsed.key
108
- parts.push(keyDisplay)
109
-
110
- // On Mac, modifiers are typically concatenated without separators
111
- return parts.join('')
112
- }
113
-
114
- /**
115
- * Formats a hotkey for Windows/Linux display using text labels.
116
- */
117
- function formatForStandard(parsed: ParsedHotkey): string {
118
- const parts: Array<string> = []
119
-
120
- // Add modifiers in canonical order
121
- for (const modifier of MODIFIER_ORDER) {
122
- if (parsed.modifiers.includes(modifier)) {
123
- parts.push(STANDARD_MODIFIER_LABELS[modifier])
124
- }
125
- }
126
-
127
- // Add the key (use symbol/short form if available)
128
- const keyDisplay = KEY_DISPLAY_SYMBOLS[parsed.key] ?? parsed.key
129
- parts.push(keyDisplay)
130
-
131
- // On Windows/Linux, use + as separator
132
- return parts.join('+')
96
+ const useSymbols = options.useSymbols ?? true
97
+ const normalizedHotkey = normalizeRegisterableHotkey(
98
+ hotkey as RegisterableHotkey,
99
+ platform,
100
+ )
101
+ return normalizedHotkey
102
+ .split('+')
103
+ .map((segment) => {
104
+ if (isModifierKey(segment)) {
105
+ const modifierToken = (MODIFIER_ALIASES[segment] ??
106
+ MODIFIER_ALIASES[segment.toLowerCase()]) as CanonicalModifier | 'Mod'
107
+ return platform === 'mac'
108
+ ? useSymbols
109
+ ? MAC_MODIFIER_SYMBOLS[modifierToken]
110
+ : MAC_MODIFIER_LABELS[modifierToken]
111
+ : platform === 'windows'
112
+ ? WINDOWS_MODIFIER_LABELS[modifierToken]
113
+ : LINUX_MODIFIER_LABELS[modifierToken]
114
+ } else {
115
+ const keyDisplaySymbol =
116
+ useSymbols &&
117
+ KEY_DISPLAY_SYMBOLS[segment as keyof typeof KEY_DISPLAY_SYMBOLS]
118
+ if (keyDisplaySymbol) return keyDisplaySymbol
119
+
120
+ const punctuationKeyDisplayLabel =
121
+ !useSymbols &&
122
+ PUNCTUATION_KEY_DISPLAY_LABELS[
123
+ segment as keyof typeof PUNCTUATION_KEY_DISPLAY_LABELS
124
+ ]
125
+ return punctuationKeyDisplayLabel || segment
126
+ }
127
+ })
128
+ .join(platform === 'mac' && useSymbols ? ' ' : '+')
133
129
  }
134
130
 
135
131
  /**
136
- * Formats a hotkey using platform-agnostic labels.
137
- * Uses 'Cmd' on Mac and 'Ctrl' for Control, etc.
138
- *
139
- * @param hotkey - The hotkey string or ParsedHotkey to format
140
- * @param platform - The target platform
141
- * @returns A formatted string with platform-appropriate labels
132
+ * @deprecated Use {@link formatForDisplay} instead with `useSymbols: false` option.
142
133
  */
143
134
  export function formatWithLabels(
144
- hotkey: Hotkey | (string & {}),
145
- platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
146
- ): string {
147
- const parsed =
148
- typeof hotkey === 'string' ? parseHotkey(hotkey, platform) : hotkey
149
- const parts: Array<string> = []
150
-
151
- // Custom labels for more readable output
152
- const labels: Record<string, string> = {
153
- Control: 'Ctrl',
154
- Alt: platform === 'mac' ? 'Option' : 'Alt',
155
- Shift: 'Shift',
156
- Meta: platform === 'mac' ? 'Cmd' : 'Win',
157
- }
158
-
159
- for (const modifier of MODIFIER_ORDER) {
160
- if (parsed.modifiers.includes(modifier)) {
161
- parts.push(labels[modifier] ?? modifier)
162
- }
163
- }
164
-
165
- // Add the key
166
- parts.push(parsed.key)
167
-
168
- return parts.join('+')
169
- }
170
-
171
- // =============================================================================
172
- // Debugging Display Labels
173
- // =============================================================================
174
-
175
- /**
176
- * Maps canonical modifier names to debugging-friendly labels per platform.
177
- */
178
- const MODIFIER_DEBUG_LABELS: Record<string, Record<string, string>> = {
179
- mac: { Meta: 'Mod (Cmd)', Control: 'Ctrl', Alt: 'Opt', Shift: 'Shift' },
180
- windows: { Control: 'Mod (Ctrl)', Meta: 'Win', Alt: 'Alt', Shift: 'Shift' },
181
- linux: { Control: 'Mod (Ctrl)', Meta: 'Super', Alt: 'Alt', Shift: 'Shift' },
182
- }
183
-
184
- /**
185
- * Options for formatting a single key for debugging display.
186
- */
187
- export interface FormatKeyDebuggingOptions {
188
- /** The target platform. Defaults to auto-detection. */
189
- platform?: 'mac' | 'windows' | 'linux'
190
- /**
191
- * Whether the input value comes from `event.key` or `event.code`.
192
- *
193
- * - `'key'` (default): Applies rich platform-aware formatting (modifier
194
- * labels, special-key symbols, etc.).
195
- * - `'code'`: Returns the value unchanged — physical key codes like
196
- * `"MetaLeft"` or `"KeyA"` are already descriptive for debugging.
197
- */
198
- source?: 'key' | 'code'
199
- }
200
-
201
- /**
202
- * Formats a single key name for debugging/devtools display.
203
- *
204
- * Unlike `formatForDisplay` which formats full hotkey strings for end-user UIs,
205
- * this function formats individual key names (from `event.key`) with rich
206
- * platform-aware labels suitable for debugging tools and developer-facing displays.
207
- *
208
- * Features:
209
- * - Modifier keys show their platform role (e.g., "Mod (Cmd)" for Meta on Mac)
210
- * - On macOS, modifier keys are prefixed with their symbol (e.g., "⌘ Mod (Cmd)")
211
- * - Special keys use display symbols (ArrowUp -> "↑", Escape -> "Esc")
212
- * - Regular keys pass through unchanged
213
- *
214
- * @param key - A single key name (e.g., "Meta", "Shift", "ArrowUp", "A")
215
- * @param options - Formatting options
216
- * @returns A formatted label suitable for debugging display
217
- *
218
- * @example
219
- * ```ts
220
- * // On macOS:
221
- * formatKeyForDebuggingDisplay('Meta') // '⌘ Mod (Cmd)'
222
- * formatKeyForDebuggingDisplay('Control') // '⌃ Ctrl'
223
- * formatKeyForDebuggingDisplay('Alt') // '⌥ Opt'
224
- * formatKeyForDebuggingDisplay('Shift') // '⇧ Shift'
225
- *
226
- * // On Windows:
227
- * formatKeyForDebuggingDisplay('Control') // 'Mod (Ctrl)'
228
- * formatKeyForDebuggingDisplay('Meta') // 'Win'
229
- *
230
- * // Special keys (all platforms):
231
- * formatKeyForDebuggingDisplay('ArrowUp') // '↑'
232
- * formatKeyForDebuggingDisplay('Escape') // 'Esc'
233
- * formatKeyForDebuggingDisplay('Space') // '␣'
234
- *
235
- * // Regular keys pass through:
236
- * formatKeyForDebuggingDisplay('A') // 'A'
237
- *
238
- * // With source: 'code', values pass through unchanged:
239
- * formatKeyForDebuggingDisplay('MetaLeft', { source: 'code' }) // 'MetaLeft'
240
- * formatKeyForDebuggingDisplay('KeyA', { source: 'code' }) // 'KeyA'
241
- * ```
242
- */
243
- export function formatKeyForDebuggingDisplay(
244
- key: string,
245
- options: FormatKeyDebuggingOptions = {},
135
+ hotkey: RegisterableHotkey,
136
+ options: Omit<FormatDisplayOptions, 'useSymbols'> = {},
246
137
  ): string {
247
- // For event.code values, pass through unchanged — they're already
248
- // descriptive for debugging (e.g. "MetaLeft", "KeyA", "ShiftRight").
249
- if (options.source === 'code') {
250
- return key
251
- }
252
-
253
- const platform = options.platform ?? detectPlatform()
254
-
255
- // Check if it's a modifier key
256
- const modLabel = MODIFIER_DEBUG_LABELS[platform]?.[key]
257
- if (modLabel) {
258
- // On Mac, prefix modifier labels with their symbol
259
- if (platform === 'mac') {
260
- const symbol =
261
- MAC_MODIFIER_SYMBOLS[key as keyof typeof MAC_MODIFIER_SYMBOLS]
262
- if (symbol) {
263
- return `${symbol} ${modLabel}`
264
- }
265
- }
266
- return modLabel
267
- }
268
-
269
- // Check if it's a special key with a display symbol
270
- const symbol = KEY_DISPLAY_SYMBOLS[key]
271
- if (symbol) {
272
- return symbol
273
- }
274
-
275
- // Regular key — pass through
276
- return key
138
+ return formatForDisplay(hotkey, { ...options, useSymbols: false })
277
139
  }
@@ -27,7 +27,11 @@ export type { ConflictBehavior }
27
27
  export interface HotkeyOptions {
28
28
  /** Behavior when this hotkey conflicts with an existing registration on the same target. Defaults to 'warn' */
29
29
  conflictBehavior?: ConflictBehavior
30
- /** Whether the hotkey is enabled. Defaults to true */
30
+ /**
31
+ * Soft-disable: when `false`, the callback does not run but the registration
32
+ * stays in `HotkeyManager` (and in devtools). Toggling this should update the
33
+ * existing handle via `setOptions` rather than unregistering. Defaults to `true`.
34
+ */
31
35
  enabled?: boolean
32
36
  /** The event type to listen for. Defaults to 'keydown' */
33
37
  eventType?: 'keydown' | 'keyup'
@@ -1,11 +1,6 @@
1
1
  import { Store } from '@tanstack/store'
2
2
  import { detectPlatform } from './constants'
3
- import {
4
- convertToModFormat,
5
- hasNonModifierKey,
6
- isModifierKey,
7
- keyboardEventToHotkey,
8
- } from './parse'
3
+ import { hotkeyChordFromKeydown } from './recorder-chord'
9
4
  import type { Hotkey } from './hotkey'
10
5
 
11
6
  /**
@@ -149,37 +144,28 @@ export class HotkeyRecorder {
149
144
  }
150
145
  }
151
146
 
152
- // Ignore pure modifier keys (wait for a non-modifier key)
153
- if (isModifierKey(event)) {
147
+ const finalHotkey = hotkeyChordFromKeydown(event, this.#platform)
148
+ if (finalHotkey === null) {
154
149
  return
155
150
  }
156
151
 
157
- // Convert event to hotkey string using library function
158
- const hotkey = keyboardEventToHotkey(event)
159
-
160
- // Always convert to Mod format for portability
161
- const finalHotkey = convertToModFormat(hotkey, this.#platform)
162
-
163
- // Validate: must have at least one non-modifier key
164
- if (hasNonModifierKey(finalHotkey, this.#platform)) {
165
- // Remove listener FIRST to prevent any additional events
166
- const handlerToRemove = this.#keydownHandler as
167
- | ((event: KeyboardEvent) => void)
168
- | null
169
- if (handlerToRemove) {
170
- this.#removeListener(handlerToRemove)
171
- this.#keydownHandler = null
172
- }
152
+ // Remove listener FIRST to prevent any additional events
153
+ const handlerToRemove = this.#keydownHandler as
154
+ | ((event: KeyboardEvent) => void)
155
+ | null
156
+ if (handlerToRemove) {
157
+ this.#removeListener(handlerToRemove)
158
+ this.#keydownHandler = null
159
+ }
173
160
 
174
- // Update store state immediately
175
- this.store.setState(() => ({
176
- isRecording: false,
177
- recordedHotkey: finalHotkey,
178
- }))
161
+ // Update store state immediately
162
+ this.store.setState(() => ({
163
+ isRecording: false,
164
+ recordedHotkey: finalHotkey,
165
+ }))
179
166
 
180
- // Call callback AFTER listener is removed and state is set
181
- this.#options.onRecord(finalHotkey)
182
- }
167
+ // Call callback AFTER listener is removed and state is set
168
+ this.#options.onRecord(finalHotkey)
183
169
  }
184
170
 
185
171
  this.#keydownHandler = handler
@@ -0,0 +1,308 @@
1
+ import { Store } from '@tanstack/store'
2
+ import { detectPlatform } from './constants'
3
+ import { hotkeyChordFromKeydown } from './recorder-chord'
4
+ import type { HotkeySequence } from './sequence-manager'
5
+
6
+ /**
7
+ * How the user can commit a recorded sequence from the keyboard.
8
+ * - `'enter'`: plain Enter (no modifiers) commits when at least one step exists.
9
+ * - `'none'`: only {@link HotkeySequenceRecorder.commit} finishes recording (or idle timeout if set).
10
+ */
11
+ export type HotkeySequenceRecorderCommitKeys = 'enter' | 'none'
12
+
13
+ /**
14
+ * State interface for the HotkeySequenceRecorder.
15
+ */
16
+ export interface HotkeySequenceRecorderState {
17
+ /** Whether recording is currently active */
18
+ isRecording: boolean
19
+ /** Chords captured so far in the current recording session */
20
+ steps: HotkeySequence
21
+ /** The last successfully committed sequence, or null if none / after starting a new session */
22
+ recordedSequence: HotkeySequence | null
23
+ }
24
+
25
+ /**
26
+ * Options for configuring a HotkeySequenceRecorder instance.
27
+ */
28
+ export interface HotkeySequenceRecorderOptions {
29
+ /** Callback when a sequence is successfully recorded (including empty array when cleared) */
30
+ onRecord: (sequence: HotkeySequence) => void
31
+ /** Optional callback when recording is cancelled (Escape pressed) */
32
+ onCancel?: () => void
33
+ /** Optional callback when the sequence is cleared (Backspace/Delete with no steps) */
34
+ onClear?: () => void
35
+ /**
36
+ * Whether plain Enter commits the current steps. Ignored when {@link commitKeys} is `'none'`.
37
+ * @default true
38
+ */
39
+ commitOnEnter?: boolean
40
+ /**
41
+ * Keyboard commit mode. When `'none'`, use {@link HotkeySequenceRecorder.commit} (and optional idle timeout).
42
+ * @default 'enter'
43
+ */
44
+ commitKeys?: HotkeySequenceRecorderCommitKeys
45
+ /**
46
+ * Milliseconds of inactivity after the **last completed chord** before auto-committing.
47
+ * The timer does not run while waiting for the first chord (`steps.length === 0`).
48
+ */
49
+ idleTimeoutMs?: number
50
+ }
51
+
52
+ const defaultHotkeySequenceRecorderOptions: Pick<
53
+ HotkeySequenceRecorderOptions,
54
+ 'commitOnEnter' | 'commitKeys'
55
+ > = {
56
+ commitOnEnter: true,
57
+ commitKeys: 'enter',
58
+ }
59
+
60
+ function resolvedCommitOnEnter(
61
+ options: HotkeySequenceRecorderOptions,
62
+ ): boolean {
63
+ if (options.commitKeys === 'none') {
64
+ return false
65
+ }
66
+ return options.commitOnEnter !== false
67
+ }
68
+
69
+ /**
70
+ * Framework-agnostic class for recording multi-chord sequences (Vim-style shortcuts).
71
+ *
72
+ * Each step is captured like a single hotkey chord. Press **Enter** (no modifiers) to commit
73
+ * when {@link HotkeySequenceRecorderOptions.commitKeys} is `'enter'` (default), **Escape** to cancel,
74
+ * **Backspace/Delete** to remove the last step or clear when empty.
75
+ */
76
+ export class HotkeySequenceRecorder {
77
+ readonly store: Store<HotkeySequenceRecorderState> =
78
+ new Store<HotkeySequenceRecorderState>({
79
+ isRecording: false,
80
+ steps: [],
81
+ recordedSequence: null,
82
+ })
83
+
84
+ #keydownHandler: ((event: KeyboardEvent) => void) | null = null
85
+ #options: HotkeySequenceRecorderOptions
86
+ #platform: 'mac' | 'windows' | 'linux'
87
+ #idleTimer: ReturnType<typeof setTimeout> | null = null
88
+
89
+ constructor(options: HotkeySequenceRecorderOptions) {
90
+ this.#options = {
91
+ ...defaultHotkeySequenceRecorderOptions,
92
+ ...options,
93
+ }
94
+ this.#platform = detectPlatform()
95
+ }
96
+
97
+ setOptions(options: Partial<HotkeySequenceRecorderOptions>): void {
98
+ this.#options = {
99
+ ...defaultHotkeySequenceRecorderOptions,
100
+ ...this.#options,
101
+ ...options,
102
+ }
103
+ }
104
+
105
+ #clearIdleTimer(): void {
106
+ if (this.#idleTimer !== null) {
107
+ clearTimeout(this.#idleTimer)
108
+ this.#idleTimer = null
109
+ }
110
+ }
111
+
112
+ #scheduleIdleTimer(): void {
113
+ this.#clearIdleTimer()
114
+ const ms = this.#options.idleTimeoutMs
115
+ if (ms === undefined || ms <= 0) {
116
+ return
117
+ }
118
+ const steps = this.store.state.steps
119
+ if (steps.length === 0) {
120
+ return
121
+ }
122
+ this.#idleTimer = setTimeout(() => {
123
+ this.#idleTimer = null
124
+ if (!this.#keydownHandler) {
125
+ return
126
+ }
127
+ if (this.store.state.steps.length >= 1) {
128
+ this.commit()
129
+ }
130
+ }, ms)
131
+ }
132
+
133
+ #patchSteps(updater: (prev: HotkeySequence) => HotkeySequence): void {
134
+ this.store.setState((s) => ({
135
+ ...s,
136
+ steps: updater(s.steps),
137
+ }))
138
+ }
139
+
140
+ start(): void {
141
+ if (this.#keydownHandler) {
142
+ return
143
+ }
144
+
145
+ this.#clearIdleTimer()
146
+ this.store.setState(() => ({
147
+ isRecording: true,
148
+ steps: [],
149
+ recordedSequence: null,
150
+ }))
151
+
152
+ const handler = (event: KeyboardEvent) => {
153
+ if (!this.#keydownHandler) {
154
+ return
155
+ }
156
+
157
+ event.preventDefault()
158
+ event.stopPropagation()
159
+
160
+ if (event.key === 'Escape') {
161
+ this.cancel()
162
+ return
163
+ }
164
+
165
+ if (event.key === 'Backspace' || event.key === 'Delete') {
166
+ if (
167
+ !event.ctrlKey &&
168
+ !event.shiftKey &&
169
+ !event.altKey &&
170
+ !event.metaKey
171
+ ) {
172
+ const steps = this.store.state.steps
173
+ if (steps.length === 0) {
174
+ this.#options.onClear?.()
175
+ this.#options.onRecord([])
176
+ this.#finishWithoutCommitCallback()
177
+ return
178
+ }
179
+ this.#patchSteps((prev) => prev.slice(0, -1))
180
+ const next = this.store.state.steps
181
+ if (next.length === 0) {
182
+ this.#clearIdleTimer()
183
+ } else {
184
+ this.#scheduleIdleTimer()
185
+ }
186
+ return
187
+ }
188
+ }
189
+
190
+ const enterCommits =
191
+ resolvedCommitOnEnter(this.#options) &&
192
+ event.key === 'Enter' &&
193
+ !event.ctrlKey &&
194
+ !event.shiftKey &&
195
+ !event.altKey &&
196
+ !event.metaKey
197
+
198
+ if (enterCommits && this.store.state.steps.length >= 1) {
199
+ this.commit()
200
+ return
201
+ }
202
+
203
+ if (enterCommits && this.store.state.steps.length === 0) {
204
+ return
205
+ }
206
+
207
+ const finalHotkey = hotkeyChordFromKeydown(event, this.#platform)
208
+ if (finalHotkey === null) {
209
+ return
210
+ }
211
+
212
+ this.#patchSteps((prev) => [...prev, finalHotkey])
213
+ this.#scheduleIdleTimer()
214
+ }
215
+
216
+ this.#keydownHandler = handler
217
+ this.#addListener(handler)
218
+ }
219
+
220
+ /**
221
+ * Commit the current steps as a sequence. No-op if fewer than one step.
222
+ */
223
+ commit(): void {
224
+ const steps = this.store.state.steps
225
+ if (steps.length < 1) {
226
+ return
227
+ }
228
+
229
+ const sequence = [...steps]
230
+
231
+ if (this.#keydownHandler) {
232
+ this.#removeListener(this.#keydownHandler)
233
+ this.#keydownHandler = null
234
+ }
235
+ this.#clearIdleTimer()
236
+
237
+ this.store.setState(() => ({
238
+ isRecording: false,
239
+ steps: [],
240
+ recordedSequence: sequence,
241
+ }))
242
+
243
+ this.#options.onRecord(sequence)
244
+ }
245
+
246
+ #finishWithoutCommitCallback(): void {
247
+ if (this.#keydownHandler) {
248
+ this.#removeListener(this.#keydownHandler)
249
+ this.#keydownHandler = null
250
+ }
251
+ this.#clearIdleTimer()
252
+ this.store.setState(() => ({
253
+ isRecording: false,
254
+ steps: [],
255
+ recordedSequence: null,
256
+ }))
257
+ }
258
+
259
+ stop(): void {
260
+ if (this.#keydownHandler) {
261
+ this.#removeListener(this.#keydownHandler)
262
+ this.#keydownHandler = null
263
+ }
264
+ this.#clearIdleTimer()
265
+ this.store.setState(() => ({
266
+ isRecording: false,
267
+ steps: [],
268
+ recordedSequence: null,
269
+ }))
270
+ }
271
+
272
+ cancel(): void {
273
+ if (this.#keydownHandler) {
274
+ this.#removeListener(this.#keydownHandler)
275
+ this.#keydownHandler = null
276
+ }
277
+ this.#clearIdleTimer()
278
+ this.store.setState(() => ({
279
+ isRecording: false,
280
+ steps: [],
281
+ recordedSequence: null,
282
+ }))
283
+ this.#options.onCancel?.()
284
+ }
285
+
286
+ #addListener(handler: (event: KeyboardEvent) => void): void {
287
+ if (typeof document === 'undefined') {
288
+ return
289
+ }
290
+ document.addEventListener('keydown', handler, true)
291
+ }
292
+
293
+ #removeListener(handler: (event: KeyboardEvent) => void): void {
294
+ if (typeof document === 'undefined') {
295
+ return
296
+ }
297
+ document.removeEventListener('keydown', handler, true)
298
+ }
299
+
300
+ destroy(): void {
301
+ this.stop()
302
+ this.store.setState(() => ({
303
+ isRecording: false,
304
+ steps: [],
305
+ recordedSequence: null,
306
+ }))
307
+ }
308
+ }
package/src/hotkey.ts CHANGED
@@ -368,6 +368,8 @@ export type RegisterableHotkey = Hotkey | RawHotkey
368
368
  export interface FormatDisplayOptions {
369
369
  /** The target platform. Defaults to auto-detection. */
370
370
  platform?: 'mac' | 'windows' | 'linux'
371
+ /** Whether to use symbols for the display. Defaults to true. */
372
+ useSymbols?: boolean
371
373
  }
372
374
 
373
375
  /**