@tanstack/hotkeys 0.8.0 → 0.10.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 +15 -1
- package/dist/{manager.utils.js → _event-target.js} +2 -41
- package/dist/_keyboard-event.js +75 -0
- package/dist/_match.js +48 -0
- package/dist/_named-keys.d.ts +6 -0
- package/dist/_named-keys.js +65 -0
- package/dist/_recorder-chord.js +42 -0
- package/dist/_recording-guard.js +55 -0
- package/dist/_registration.js +49 -0
- package/dist/conflicts.d.ts +36 -0
- package/dist/conflicts.js +54 -0
- package/dist/constants.d.ts +16 -144
- package/dist/constants.js +55 -172
- package/dist/display-labels.d.ts +90 -0
- package/dist/display-labels.js +112 -0
- package/dist/format.d.ts +48 -12
- package/dist/format.js +35 -39
- package/dist/hint.d.ts +20 -0
- package/dist/hint.js +23 -0
- package/dist/hotkey-manager.d.ts +14 -12
- package/dist/hotkey-manager.js +70 -21
- package/dist/hotkey-recorder.d.ts +8 -8
- package/dist/hotkey-recorder.js +74 -15
- package/dist/hotkey-sequence-recorder.d.ts +13 -9
- package/dist/hotkey-sequence-recorder.js +90 -28
- package/dist/{hotkey.d.ts → hotkey.types.d.ts} +58 -126
- package/dist/index.d.ts +14 -9
- package/dist/index.js +10 -6
- package/dist/key-state-tracker.d.ts +4 -7
- package/dist/key-state-tracker.js +30 -27
- package/dist/key.types.d.ts +79 -0
- package/dist/match.d.ts +24 -11
- package/dist/match.js +19 -30
- package/dist/parse.d.ts +18 -15
- package/dist/parse.js +39 -22
- package/dist/platform.d.ts +41 -0
- package/dist/platform.js +52 -0
- package/dist/recorder-options.d.ts +31 -0
- package/dist/sequence-manager.d.ts +27 -15
- package/dist/sequence-manager.js +61 -30
- package/dist/validate.d.ts +16 -8
- package/dist/validate.js +8 -13
- package/package.json +8 -13
- package/dist/constants.cjs +0 -535
- package/dist/constants.cjs.map +0 -1
- package/dist/constants.d.cts +0 -290
- package/dist/constants.js.map +0 -1
- package/dist/format.cjs +0 -91
- package/dist/format.cjs.map +0 -1
- package/dist/format.d.cts +0 -60
- package/dist/format.js.map +0 -1
- package/dist/hotkey-manager.cjs +0 -383
- package/dist/hotkey-manager.cjs.map +0 -1
- package/dist/hotkey-manager.d.cts +0 -230
- package/dist/hotkey-manager.js.map +0 -1
- package/dist/hotkey-recorder.cjs +0 -179
- package/dist/hotkey-recorder.cjs.map +0 -1
- package/dist/hotkey-recorder.d.cts +0 -116
- package/dist/hotkey-recorder.js.map +0 -1
- package/dist/hotkey-sequence-recorder.cjs +0 -192
- package/dist/hotkey-sequence-recorder.cjs.map +0 -1
- package/dist/hotkey-sequence-recorder.d.cts +0 -79
- package/dist/hotkey-sequence-recorder.js.map +0 -1
- package/dist/hotkey.d.cts +0 -301
- package/dist/index.cjs +0 -63
- package/dist/index.d.cts +0 -13
- package/dist/key-state-tracker.cjs +0 -197
- package/dist/key-state-tracker.cjs.map +0 -1
- package/dist/key-state-tracker.d.cts +0 -107
- package/dist/key-state-tracker.js.map +0 -1
- package/dist/manager.utils.cjs +0 -115
- package/dist/manager.utils.cjs.map +0 -1
- package/dist/manager.utils.d.cts +0 -13
- package/dist/manager.utils.d.ts +0 -13
- package/dist/manager.utils.js.map +0 -1
- package/dist/match.cjs +0 -153
- package/dist/match.cjs.map +0 -1
- package/dist/match.d.cts +0 -85
- package/dist/match.js.map +0 -1
- package/dist/parse.cjs +0 -228
- package/dist/parse.cjs.map +0 -1
- package/dist/parse.d.cts +0 -128
- package/dist/parse.js.map +0 -1
- package/dist/recorder-chord.cjs +0 -18
- package/dist/recorder-chord.cjs.map +0 -1
- package/dist/recorder-chord.js +0 -18
- package/dist/recorder-chord.js.map +0 -1
- package/dist/sequence-manager.cjs +0 -415
- package/dist/sequence-manager.cjs.map +0 -1
- package/dist/sequence-manager.d.cts +0 -157
- package/dist/sequence-manager.js.map +0 -1
- package/dist/validate.cjs +0 -116
- package/dist/validate.cjs.map +0 -1
- package/dist/validate.d.cts +0 -56
- package/dist/validate.js.map +0 -1
- package/src/constants.ts +0 -603
- package/src/format.ts +0 -142
- package/src/hotkey-manager.ts +0 -759
- package/src/hotkey-recorder.ts +0 -272
- package/src/hotkey-sequence-recorder.ts +0 -328
- package/src/hotkey.ts +0 -436
- package/src/index.ts +0 -11
- package/src/key-state-tracker.ts +0 -249
- package/src/manager.utils.ts +0 -231
- package/src/match.ts +0 -259
- package/src/parse.ts +0 -328
- package/src/recorder-chord.ts +0 -25
- package/src/sequence-manager.ts +0 -734
- package/src/validate.ts +0 -171
package/src/manager.utils.ts
DELETED
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import type { ParsedHotkey } from './hotkey'
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Behavior when registering a hotkey/sequence that conflicts with an existing registration.
|
|
5
|
-
*
|
|
6
|
-
* - `'warn'` - Log a warning to the console but allow both registrations (default)
|
|
7
|
-
* - `'error'` - Throw an error and prevent the new registration
|
|
8
|
-
* - `'replace'` - Unregister the existing registration and register the new one
|
|
9
|
-
* - `'allow'` - Allow multiple registrations without warning
|
|
10
|
-
*/
|
|
11
|
-
export type ConflictBehavior = 'warn' | 'error' | 'replace' | 'allow'
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Default options for hotkey/sequence registration.
|
|
15
|
-
* Omitted: platform, target (resolved at registration), requireReset (HotkeyManager only).
|
|
16
|
-
*/
|
|
17
|
-
export const defaultHotkeyOptions = {
|
|
18
|
-
preventDefault: true,
|
|
19
|
-
stopPropagation: true,
|
|
20
|
-
eventType: 'keydown' as const,
|
|
21
|
-
enabled: true,
|
|
22
|
-
ignoreInputs: true,
|
|
23
|
-
conflictBehavior: 'warn' as ConflictBehavior,
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Computes the default ignoreInputs value based on the hotkey.
|
|
28
|
-
* Ctrl/Meta shortcuts and Escape fire in inputs; single keys and Shift/Alt combos are ignored.
|
|
29
|
-
*/
|
|
30
|
-
export function getDefaultIgnoreInputs(parsedHotkey: ParsedHotkey): boolean {
|
|
31
|
-
if (parsedHotkey.ctrl || parsedHotkey.meta) return false // Mod+S, Ctrl+C, etc.
|
|
32
|
-
if (parsedHotkey.key === 'Escape') return false // Close modal, etc.
|
|
33
|
-
return true // Single keys, Shift+key, Alt+key
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Checks if an element is an input-like element that should be ignored for hotkeys.
|
|
38
|
-
*
|
|
39
|
-
* This includes:
|
|
40
|
-
* - HTMLInputElement (all input types except button, submit, reset)
|
|
41
|
-
* - HTMLTextAreaElement
|
|
42
|
-
* - HTMLSelectElement
|
|
43
|
-
* - Elements with contentEditable enabled
|
|
44
|
-
*
|
|
45
|
-
* Button-type inputs (button, submit, reset) are excluded so hotkeys like
|
|
46
|
-
* Mod+S and Escape fire when the user has tabbed to a form button.
|
|
47
|
-
*/
|
|
48
|
-
export function isInputElement(element: EventTarget | null): boolean {
|
|
49
|
-
if (!element) {
|
|
50
|
-
return false
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
if (element instanceof HTMLInputElement) {
|
|
54
|
-
const type = element.type.toLowerCase()
|
|
55
|
-
if (type === 'button' || type === 'submit' || type === 'reset') {
|
|
56
|
-
return false
|
|
57
|
-
}
|
|
58
|
-
return true
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
element instanceof HTMLTextAreaElement ||
|
|
63
|
-
element instanceof HTMLSelectElement
|
|
64
|
-
) {
|
|
65
|
-
return true
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// Check for contenteditable elements (includes "true", "", "plaintext-only",
|
|
69
|
-
// and inherited contenteditable from ancestor elements)
|
|
70
|
-
if (element instanceof HTMLElement && element.isContentEditable) {
|
|
71
|
-
return true
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
return false
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Returns the focused element for the document associated with where hotkey
|
|
79
|
-
* listeners are attached (listener root). Use this instead of the global
|
|
80
|
-
* `document.activeElement` so registrations scoped to an iframe (or another
|
|
81
|
-
* document) read focus from the correct tree.
|
|
82
|
-
*/
|
|
83
|
-
export function getActiveElementForListenerTarget(
|
|
84
|
-
target: HTMLElement | Document | Window,
|
|
85
|
-
): Element | null {
|
|
86
|
-
if (typeof document === 'undefined') {
|
|
87
|
-
return null
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Document first (nodeType covers environments where `document instanceof Document` is false)
|
|
91
|
-
if (
|
|
92
|
-
(typeof Document !== 'undefined' && target instanceof Document) ||
|
|
93
|
-
(typeof Node !== 'undefined' &&
|
|
94
|
-
(target as Node).nodeType === Node.DOCUMENT_NODE)
|
|
95
|
-
) {
|
|
96
|
-
return (target as Document).activeElement
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
if (typeof HTMLElement !== 'undefined' && target instanceof HTMLElement) {
|
|
100
|
-
return target.ownerDocument.activeElement ?? null
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Window (global or iframe): avoid relying on `instanceof Window` alone (test DOM quirks)
|
|
104
|
-
return (target as Window).document.activeElement ?? null
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Returns whether an event should be ignored because it originated from an
|
|
109
|
-
* input-like element other than the registration target.
|
|
110
|
-
*
|
|
111
|
-
* This checks:
|
|
112
|
-
* - the currently focused element for the listener target
|
|
113
|
-
* - the event's composed path (for shadow DOM)
|
|
114
|
-
* - the event target as a final fallback
|
|
115
|
-
*/
|
|
116
|
-
export function shouldIgnoreInputEvent(
|
|
117
|
-
event: KeyboardEvent,
|
|
118
|
-
listenerTarget: HTMLElement | Document | Window,
|
|
119
|
-
registrationTarget: HTMLElement | Document | Window,
|
|
120
|
-
): boolean {
|
|
121
|
-
const focused = getActiveElementForListenerTarget(listenerTarget)
|
|
122
|
-
if (focused && isInputElement(focused) && focused !== registrationTarget) {
|
|
123
|
-
return true
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
if (
|
|
127
|
-
event
|
|
128
|
-
.composedPath()
|
|
129
|
-
.some(
|
|
130
|
-
(element) => isInputElement(element) && element !== registrationTarget,
|
|
131
|
-
)
|
|
132
|
-
) {
|
|
133
|
-
return true
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
return isInputElement(event.target) && event.target !== registrationTarget
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* Checks if an event is for the given target (originated from or bubbled to it).
|
|
141
|
-
*
|
|
142
|
-
* For document/window targets, also accepts document.documentElement as currentTarget
|
|
143
|
-
* to handle Brave and other browsers where currentTarget may be documentElement
|
|
144
|
-
* instead of document when listeners are attached to document.
|
|
145
|
-
*/
|
|
146
|
-
export function isEventForTarget(
|
|
147
|
-
event: KeyboardEvent,
|
|
148
|
-
target: HTMLElement | Document | Window,
|
|
149
|
-
): boolean {
|
|
150
|
-
// For Document and Window, verify that our handler was indeed called for this target.
|
|
151
|
-
//
|
|
152
|
-
// Browser compatibility note:
|
|
153
|
-
// Per the DOM spec, event.currentTarget should equal the element the listener was
|
|
154
|
-
// attached to. However, some Chromium-based browsers (notably Brave) exhibit
|
|
155
|
-
// non-standard behavior where event.currentTarget is set to document.documentElement
|
|
156
|
-
// (<html>) instead of document when a listener is attached to document.
|
|
157
|
-
// This may be related to privacy/fingerprinting protections.
|
|
158
|
-
//
|
|
159
|
-
// To ensure cross-browser compatibility, we accept both the expected target
|
|
160
|
-
// and document.documentElement as valid currentTarget values.
|
|
161
|
-
// See: https://dom.spec.whatwg.org/#dom-event-currenttarget
|
|
162
|
-
if (target === document || target === window) {
|
|
163
|
-
return (
|
|
164
|
-
event.currentTarget === target ||
|
|
165
|
-
event.currentTarget === document.documentElement
|
|
166
|
-
)
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// For Window, accept window, document, or document.documentElement (browser quirks)
|
|
170
|
-
if (target === window) {
|
|
171
|
-
return (
|
|
172
|
-
event.currentTarget === window ||
|
|
173
|
-
event.currentTarget === document ||
|
|
174
|
-
event.currentTarget === document.documentElement
|
|
175
|
-
)
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// For HTMLElement, check if event originated from or bubbled to the element
|
|
179
|
-
if (target instanceof HTMLElement) {
|
|
180
|
-
// Check if the event's currentTarget is the target (capturing/bubbling)
|
|
181
|
-
if (event.currentTarget === target) {
|
|
182
|
-
return true
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
// Check if the event's target is a descendant of our target
|
|
186
|
-
if (event.target instanceof Node && target.contains(event.target)) {
|
|
187
|
-
return true
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
return false
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
/**
|
|
195
|
-
* Handles conflicts between registrations based on conflict behavior.
|
|
196
|
-
*
|
|
197
|
-
* @param conflictingId - The ID of the conflicting registration
|
|
198
|
-
* @param keyDisplay - Display string for the conflicting key/sequence (for error messages)
|
|
199
|
-
* @param conflictBehavior - How to handle the conflict
|
|
200
|
-
* @param unregister - Function to unregister by ID
|
|
201
|
-
*/
|
|
202
|
-
export function handleConflict(
|
|
203
|
-
conflictingId: string,
|
|
204
|
-
keyDisplay: string,
|
|
205
|
-
conflictBehavior: ConflictBehavior,
|
|
206
|
-
unregister: (id: string) => void,
|
|
207
|
-
): void {
|
|
208
|
-
if (conflictBehavior === 'allow') {
|
|
209
|
-
return
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (conflictBehavior === 'warn') {
|
|
213
|
-
console.warn(
|
|
214
|
-
`'${keyDisplay}' is already registered. Multiple handlers will be triggered. ` +
|
|
215
|
-
`Use conflictBehavior: 'replace' to replace the existing handler, ` +
|
|
216
|
-
`or conflictBehavior: 'allow' to suppress this warning.`,
|
|
217
|
-
)
|
|
218
|
-
return
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
if (conflictBehavior === 'error') {
|
|
222
|
-
throw new Error(
|
|
223
|
-
`'${keyDisplay}' is already registered. ` +
|
|
224
|
-
`Use conflictBehavior: 'replace' to replace the existing handler, ` +
|
|
225
|
-
`or conflictBehavior: 'allow' to allow multiple registrations.`,
|
|
226
|
-
)
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// At this point, conflictBehavior must be 'replace'
|
|
230
|
-
unregister(conflictingId)
|
|
231
|
-
}
|
package/src/match.ts
DELETED
|
@@ -1,259 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
PUNCTUATION_CODE_MAP,
|
|
3
|
-
detectPlatform,
|
|
4
|
-
isSingleLetterKey,
|
|
5
|
-
normalizeKeyName,
|
|
6
|
-
} from './constants'
|
|
7
|
-
import { parseHotkey } from './parse'
|
|
8
|
-
import type {
|
|
9
|
-
Hotkey,
|
|
10
|
-
HotkeyCallback,
|
|
11
|
-
HotkeyCallbackContext,
|
|
12
|
-
ParsedHotkey,
|
|
13
|
-
} from './hotkey'
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* Checks if a KeyboardEvent matches a hotkey.
|
|
17
|
-
*
|
|
18
|
-
* Uses the `key` property from KeyboardEvent for matching, with a fallback to `code`
|
|
19
|
-
* for letter keys, digit keys (0-9), and punctuation keys when `key` produces special
|
|
20
|
-
* characters (e.g., macOS Option+letter, Shift+number, or Option+punctuation).
|
|
21
|
-
* Letter keys are matched case-insensitively.
|
|
22
|
-
*
|
|
23
|
-
* Also handles "dead key" events where `event.key` is `'Dead'` instead of the expected
|
|
24
|
-
* character. This commonly occurs on macOS with Option+letter combinations (e.g., Option+E,
|
|
25
|
-
* Option+I, Option+U, Option+N) and on Windows/Linux with international keyboard layouts.
|
|
26
|
-
* In these cases, `event.code` is used to determine the physical key.
|
|
27
|
-
*
|
|
28
|
-
* @param event - The KeyboardEvent to check
|
|
29
|
-
* @param hotkey - The hotkey string or ParsedHotkey to match against
|
|
30
|
-
* @param platform - The target platform for resolving 'Mod' (defaults to auto-detection)
|
|
31
|
-
* @returns True if the event matches the hotkey
|
|
32
|
-
*
|
|
33
|
-
* @example
|
|
34
|
-
* ```ts
|
|
35
|
-
* document.addEventListener('keydown', (event) => {
|
|
36
|
-
* if (matchesKeyboardEvent(event, 'Mod+S')) {
|
|
37
|
-
* event.preventDefault()
|
|
38
|
-
* handleSave()
|
|
39
|
-
* }
|
|
40
|
-
* })
|
|
41
|
-
* ```
|
|
42
|
-
*/
|
|
43
|
-
export function matchesKeyboardEvent(
|
|
44
|
-
event: KeyboardEvent,
|
|
45
|
-
hotkey: Hotkey | ParsedHotkey,
|
|
46
|
-
platform: 'mac' | 'windows' | 'linux' = detectPlatform(),
|
|
47
|
-
): 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
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Options for creating a hotkey handler.
|
|
131
|
-
*/
|
|
132
|
-
export interface CreateHotkeyHandlerOptions {
|
|
133
|
-
/** Prevent the default browser action when the hotkey matches. Defaults to true */
|
|
134
|
-
preventDefault?: boolean
|
|
135
|
-
/** Stop event propagation when the hotkey matches. Defaults to true */
|
|
136
|
-
stopPropagation?: boolean
|
|
137
|
-
/** The target platform for resolving 'Mod' */
|
|
138
|
-
platform?: 'mac' | 'windows' | 'linux'
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/**
|
|
142
|
-
* Creates a keyboard event handler that calls the callback when the hotkey matches.
|
|
143
|
-
*
|
|
144
|
-
* @param hotkey - The hotkey string or ParsedHotkey to match
|
|
145
|
-
* @param callback - The function to call when the hotkey matches
|
|
146
|
-
* @param options - Options for matching and handling
|
|
147
|
-
* @returns A function that can be used as an event handler
|
|
148
|
-
*
|
|
149
|
-
* @example
|
|
150
|
-
* ```ts
|
|
151
|
-
* const handler = createHotkeyHandler('Mod+S', (event, { hotkey, parsedHotkey }) => {
|
|
152
|
-
* console.log(`${hotkey} was pressed`)
|
|
153
|
-
* handleSave()
|
|
154
|
-
* })
|
|
155
|
-
*
|
|
156
|
-
* document.addEventListener('keydown', handler)
|
|
157
|
-
* ```
|
|
158
|
-
*/
|
|
159
|
-
export function createHotkeyHandler(
|
|
160
|
-
hotkey: Hotkey | ParsedHotkey,
|
|
161
|
-
callback: HotkeyCallback,
|
|
162
|
-
options: CreateHotkeyHandlerOptions = {},
|
|
163
|
-
): (event: KeyboardEvent) => void {
|
|
164
|
-
const { preventDefault = true, stopPropagation = true, platform } = options
|
|
165
|
-
const resolvedPlatform = platform ?? detectPlatform()
|
|
166
|
-
|
|
167
|
-
const hotkeyString: Hotkey =
|
|
168
|
-
typeof hotkey === 'string' ? hotkey : formatParsedHotkey(hotkey)
|
|
169
|
-
const parsed =
|
|
170
|
-
typeof hotkey === 'string' ? parseHotkey(hotkey, resolvedPlatform) : hotkey
|
|
171
|
-
|
|
172
|
-
const context: HotkeyCallbackContext = {
|
|
173
|
-
hotkey: hotkeyString,
|
|
174
|
-
parsedHotkey: parsed,
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
return (event: KeyboardEvent) => {
|
|
178
|
-
if (matchesKeyboardEvent(event, parsed, resolvedPlatform)) {
|
|
179
|
-
if (preventDefault) {
|
|
180
|
-
event.preventDefault()
|
|
181
|
-
}
|
|
182
|
-
if (stopPropagation) {
|
|
183
|
-
event.stopPropagation()
|
|
184
|
-
}
|
|
185
|
-
callback(event, context)
|
|
186
|
-
}
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
type MultiHotkeyHandler = { [K in Hotkey]?: HotkeyCallback }
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Creates a handler that matches multiple hotkeys.
|
|
194
|
-
*
|
|
195
|
-
* @param handlers - A map of hotkey strings to their handlers
|
|
196
|
-
* @param options - Options for matching and handling
|
|
197
|
-
* @returns A function that can be used as an event handler
|
|
198
|
-
*
|
|
199
|
-
* @example
|
|
200
|
-
* ```ts
|
|
201
|
-
* const handler = createMultiHotkeyHandler({
|
|
202
|
-
* 'Mod+S': (event, { hotkey }) => handleSave(),
|
|
203
|
-
* 'Mod+Z': (event, { hotkey }) => handleUndo(),
|
|
204
|
-
* 'Mod+Shift+Z': (event, { hotkey }) => handleRedo(),
|
|
205
|
-
* })
|
|
206
|
-
*
|
|
207
|
-
* document.addEventListener('keydown', handler)
|
|
208
|
-
* ```
|
|
209
|
-
*/
|
|
210
|
-
export function createMultiHotkeyHandler(
|
|
211
|
-
handlers: MultiHotkeyHandler,
|
|
212
|
-
options: CreateHotkeyHandlerOptions = {},
|
|
213
|
-
): (event: KeyboardEvent) => void {
|
|
214
|
-
const { preventDefault = true, stopPropagation = true, platform } = options
|
|
215
|
-
const resolvedPlatform = platform ?? detectPlatform()
|
|
216
|
-
|
|
217
|
-
// Pre-parse all hotkeys for efficiency
|
|
218
|
-
const parsedHandlers = Object.entries(handlers)
|
|
219
|
-
.filter((entry): entry is [string, HotkeyCallback] => Boolean(entry[1]))
|
|
220
|
-
.map(([hotkey, handler]) => {
|
|
221
|
-
const parsed = parseHotkey(hotkey as Hotkey, resolvedPlatform)
|
|
222
|
-
const context: HotkeyCallbackContext = {
|
|
223
|
-
hotkey: hotkey as Hotkey,
|
|
224
|
-
parsedHotkey: parsed,
|
|
225
|
-
}
|
|
226
|
-
return { parsed, handler, context }
|
|
227
|
-
})
|
|
228
|
-
|
|
229
|
-
return (event: KeyboardEvent) => {
|
|
230
|
-
for (const { parsed, handler, context } of parsedHandlers) {
|
|
231
|
-
if (matchesKeyboardEvent(event, parsed, resolvedPlatform)) {
|
|
232
|
-
if (preventDefault) {
|
|
233
|
-
event.preventDefault()
|
|
234
|
-
}
|
|
235
|
-
if (stopPropagation) {
|
|
236
|
-
event.stopPropagation()
|
|
237
|
-
}
|
|
238
|
-
handler(event, context)
|
|
239
|
-
return // Only handle the first match
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* Formats a ParsedHotkey back to a hotkey string.
|
|
247
|
-
* Used internally to provide the hotkey string in callback context.
|
|
248
|
-
*/
|
|
249
|
-
function formatParsedHotkey(parsed: ParsedHotkey): Hotkey {
|
|
250
|
-
const parts: Array<string> = []
|
|
251
|
-
|
|
252
|
-
if (parsed.ctrl) parts.push('Control')
|
|
253
|
-
if (parsed.alt) parts.push('Alt')
|
|
254
|
-
if (parsed.shift) parts.push('Shift')
|
|
255
|
-
if (parsed.meta) parts.push('Meta')
|
|
256
|
-
parts.push(parsed.key)
|
|
257
|
-
|
|
258
|
-
return parts.join('+') as Hotkey
|
|
259
|
-
}
|