@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.
- package/README.md +1 -1
- package/dist/constants.cjs +70 -10
- package/dist/constants.cjs.map +1 -1
- package/dist/constants.d.cts +57 -4
- package/dist/constants.d.ts +57 -4
- package/dist/constants.js +67 -10
- package/dist/constants.js.map +1 -1
- package/dist/format.cjs +20 -125
- package/dist/format.cjs.map +1 -1
- package/dist/format.d.cts +8 -72
- package/dist/format.d.ts +8 -72
- package/dist/format.js +23 -127
- package/dist/format.js.map +1 -1
- package/dist/hotkey-manager.cjs.map +1 -1
- package/dist/hotkey-manager.d.cts +5 -1
- package/dist/hotkey-manager.d.ts +5 -1
- package/dist/hotkey-manager.js.map +1 -1
- package/dist/{recorder.cjs → hotkey-recorder.cjs} +14 -16
- package/dist/hotkey-recorder.cjs.map +1 -0
- package/dist/{recorder.d.cts → hotkey-recorder.d.cts} +2 -2
- package/dist/{recorder.d.ts → hotkey-recorder.d.ts} +2 -2
- package/dist/{recorder.js → hotkey-recorder.js} +14 -16
- package/dist/hotkey-recorder.js.map +1 -0
- package/dist/hotkey-sequence-recorder.cjs +188 -0
- package/dist/hotkey-sequence-recorder.cjs.map +1 -0
- package/dist/hotkey-sequence-recorder.d.cts +71 -0
- package/dist/hotkey-sequence-recorder.d.ts +71 -0
- package/dist/hotkey-sequence-recorder.js +188 -0
- package/dist/hotkey-sequence-recorder.js.map +1 -0
- package/dist/hotkey.d.cts +2 -0
- package/dist/hotkey.d.ts +2 -0
- package/dist/index.cjs +12 -7
- package/dist/index.d.cts +7 -6
- package/dist/index.d.ts +7 -6
- package/dist/index.js +7 -6
- package/dist/parse.cjs +53 -91
- package/dist/parse.cjs.map +1 -1
- package/dist/parse.d.cts +31 -72
- package/dist/parse.d.ts +31 -72
- package/dist/parse.js +51 -89
- package/dist/parse.js.map +1 -1
- package/dist/recorder-chord.cjs +18 -0
- package/dist/recorder-chord.cjs.map +1 -0
- package/dist/recorder-chord.js +18 -0
- package/dist/recorder-chord.js.map +1 -0
- package/dist/sequence-manager.cjs +24 -8
- package/dist/sequence-manager.cjs.map +1 -1
- package/dist/sequence-manager.d.cts +11 -2
- package/dist/sequence-manager.d.ts +11 -2
- package/dist/sequence-manager.js +25 -10
- package/dist/sequence-manager.js.map +1 -1
- package/package.json +1 -1
- package/src/constants.ts +75 -21
- package/src/format.ts +58 -196
- package/src/hotkey-manager.ts +5 -1
- package/src/{recorder.ts → hotkey-recorder.ts} +18 -32
- package/src/hotkey-sequence-recorder.ts +308 -0
- package/src/hotkey.ts +2 -0
- package/src/index.ts +2 -1
- package/src/parse.ts +91 -131
- package/src/recorder-chord.ts +25 -0
- package/src/sequence-manager.ts +30 -9
- package/dist/recorder.cjs.map +0 -1
- 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
|
-
|
|
8
|
+
PUNCTUATION_KEY_DISPLAY_LABELS,
|
|
9
|
+
WINDOWS_MODIFIER_LABELS,
|
|
6
10
|
detectPlatform,
|
|
7
11
|
} from './constants'
|
|
8
|
-
import {
|
|
9
|
-
|
|
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: '
|
|
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:
|
|
92
|
+
hotkey: RegisterableHotkey | (string & {}),
|
|
79
93
|
options: FormatDisplayOptions = {},
|
|
80
94
|
): string {
|
|
81
95
|
const platform = options.platform ?? detectPlatform()
|
|
82
|
-
const
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
*
|
|
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:
|
|
145
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/hotkey-manager.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
153
|
-
if (
|
|
147
|
+
const finalHotkey = hotkeyChordFromKeydown(event, this.#platform)
|
|
148
|
+
if (finalHotkey === null) {
|
|
154
149
|
return
|
|
155
150
|
}
|
|
156
151
|
|
|
157
|
-
//
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
161
|
+
// Update store state immediately
|
|
162
|
+
this.store.setState(() => ({
|
|
163
|
+
isRecording: false,
|
|
164
|
+
recordedHotkey: finalHotkey,
|
|
165
|
+
}))
|
|
179
166
|
|
|
180
|
-
|
|
181
|
-
|
|
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
|
/**
|