@remcostoeten/use-shortcut 1.3.0 → 2.0.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/CHANGELOG.md +8 -0
- package/README.md +33 -313
- package/dist/cli/index.mjs +88 -216
- package/dist/index.d.mts +39 -68
- package/dist/index.d.ts +39 -68
- package/dist/index.js +350 -361
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +351 -354
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -1
- package/src/__tests__/features.test.ts +43 -12
- package/src/builder.ts +37 -476
- package/src/constants.ts +59 -13
- package/src/formatter.ts +37 -22
- package/src/hook.ts +150 -31
- package/src/index.ts +1 -12
- package/src/parser.ts +6 -3
- package/src/runtime/binding.ts +136 -0
- package/src/runtime/conflicts.ts +43 -0
- package/src/runtime/debug.ts +6 -0
- package/src/runtime/guards.ts +82 -0
- package/src/runtime/keys.ts +63 -0
- package/src/runtime/listener.ts +142 -0
- package/src/runtime/recording.ts +39 -0
- package/src/runtime/types.ts +48 -0
- package/src/types.ts +16 -19
package/src/constants.ts
CHANGED
|
@@ -1,19 +1,61 @@
|
|
|
1
|
-
|
|
1
|
+
/** Supported runtime OS identifiers used by formatter and parser normalization. */
|
|
2
|
+
export const OS = {
|
|
2
3
|
MAC: "mac",
|
|
3
4
|
WINDOWS: "windows",
|
|
4
5
|
LINUX: "linux",
|
|
5
6
|
} as const
|
|
6
7
|
|
|
7
|
-
export type PlatformType = (typeof
|
|
8
|
+
export type PlatformType = (typeof OS)[keyof typeof OS]
|
|
8
9
|
|
|
10
|
+
/** Public platform constant alias (`Platform.MAC`, `Platform.WINDOWS`, `Platform.LINUX`). */
|
|
11
|
+
export const Platform = OS
|
|
12
|
+
|
|
13
|
+
let _cachedPlatform: PlatformType | null = null
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Detect the current OS platform for modifier normalization and display formatting.
|
|
17
|
+
* Result is memoized for the page lifecycle.
|
|
18
|
+
*/
|
|
9
19
|
export function detectPlatform(): PlatformType {
|
|
10
|
-
if (
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
20
|
+
if (_cachedPlatform) return _cachedPlatform
|
|
21
|
+
if (typeof navigator === "undefined") {
|
|
22
|
+
_cachedPlatform = OS.WINDOWS
|
|
23
|
+
return _cachedPlatform
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const uaPlatform = (
|
|
27
|
+
navigator as Navigator & {
|
|
28
|
+
userAgentData?: { platform?: string }
|
|
29
|
+
}
|
|
30
|
+
).userAgentData?.platform?.toLowerCase()
|
|
31
|
+
|
|
32
|
+
const platform = (uaPlatform ?? navigator.platform ?? navigator.userAgent ?? "").toLowerCase()
|
|
33
|
+
|
|
34
|
+
if (
|
|
35
|
+
platform.includes("mac")
|
|
36
|
+
|| platform.includes("iphone")
|
|
37
|
+
|| platform.includes("ipad")
|
|
38
|
+
|| platform.includes("ipod")
|
|
39
|
+
) {
|
|
40
|
+
_cachedPlatform = OS.MAC
|
|
41
|
+
return _cachedPlatform
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (platform.includes("linux") || platform.includes("android")) {
|
|
45
|
+
_cachedPlatform = OS.LINUX
|
|
46
|
+
return _cachedPlatform
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (platform.includes("win")) {
|
|
50
|
+
_cachedPlatform = OS.WINDOWS
|
|
51
|
+
return _cachedPlatform
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
_cachedPlatform = OS.WINDOWS
|
|
55
|
+
return _cachedPlatform
|
|
15
56
|
}
|
|
16
57
|
|
|
58
|
+
/** Canonical modifier token names used internally across parsing/formatting. */
|
|
17
59
|
export const ModifierKey = {
|
|
18
60
|
META: "meta",
|
|
19
61
|
CTRL: "ctrl",
|
|
@@ -23,6 +65,7 @@ export const ModifierKey = {
|
|
|
23
65
|
|
|
24
66
|
export type ModifierKeyType = (typeof ModifierKey)[keyof typeof ModifierKey]
|
|
25
67
|
|
|
68
|
+
/** Alias map from user-facing modifier tokens to canonical modifier keys. */
|
|
26
69
|
export const ModifierAliases: Record<string, ModifierKeyType> = {
|
|
27
70
|
command: ModifierKey.META,
|
|
28
71
|
cmd: ModifierKey.META,
|
|
@@ -45,6 +88,7 @@ export const ModifierAliases: Record<string, ModifierKeyType> = {
|
|
|
45
88
|
shft: ModifierKey.SHIFT,
|
|
46
89
|
} as const
|
|
47
90
|
|
|
91
|
+
/** Alias map from human shortcut key tokens to `KeyboardEvent.key`-compatible values. */
|
|
48
92
|
export const SpecialKeyMap: Record<string, string> = {
|
|
49
93
|
up: "ArrowUp",
|
|
50
94
|
down: "ArrowDown",
|
|
@@ -86,20 +130,21 @@ export const SpecialKeyMap: Record<string, string> = {
|
|
|
86
130
|
closebracket: "]",
|
|
87
131
|
} as const
|
|
88
132
|
|
|
133
|
+
/** Platform-specific display labels/symbols for modifier keys. */
|
|
89
134
|
export const ModifierDisplaySymbols: Record<PlatformType, Record<ModifierKeyType, string>> = {
|
|
90
|
-
[
|
|
135
|
+
[OS.MAC]: {
|
|
91
136
|
[ModifierKey.META]: "⌘",
|
|
92
137
|
[ModifierKey.CTRL]: "⌃",
|
|
93
138
|
[ModifierKey.ALT]: "⌥",
|
|
94
139
|
[ModifierKey.SHIFT]: "⇧",
|
|
95
140
|
},
|
|
96
|
-
[
|
|
141
|
+
[OS.WINDOWS]: {
|
|
97
142
|
[ModifierKey.META]: "Ctrl",
|
|
98
143
|
[ModifierKey.CTRL]: "Ctrl",
|
|
99
144
|
[ModifierKey.ALT]: "Alt",
|
|
100
145
|
[ModifierKey.SHIFT]: "Shift",
|
|
101
146
|
},
|
|
102
|
-
[
|
|
147
|
+
[OS.LINUX]: {
|
|
103
148
|
[ModifierKey.META]: "Super",
|
|
104
149
|
[ModifierKey.CTRL]: "Ctrl",
|
|
105
150
|
[ModifierKey.ALT]: "Alt",
|
|
@@ -107,8 +152,9 @@ export const ModifierDisplaySymbols: Record<PlatformType, Record<ModifierKeyType
|
|
|
107
152
|
},
|
|
108
153
|
} as const
|
|
109
154
|
|
|
155
|
+
/** Platform-specific canonical order for modifier rendering and combo normalization. */
|
|
110
156
|
export const ModifierDisplayOrder: Record<PlatformType, ModifierKeyType[]> = {
|
|
111
|
-
[
|
|
112
|
-
[
|
|
113
|
-
[
|
|
157
|
+
[OS.MAC]: [ModifierKey.CTRL, ModifierKey.ALT, ModifierKey.SHIFT, ModifierKey.META],
|
|
158
|
+
[OS.WINDOWS]: [ModifierKey.META, ModifierKey.ALT, ModifierKey.SHIFT, ModifierKey.CTRL],
|
|
159
|
+
[OS.LINUX]: [ModifierKey.META, ModifierKey.ALT, ModifierKey.SHIFT, ModifierKey.CTRL],
|
|
114
160
|
} as const
|
package/src/formatter.ts
CHANGED
|
@@ -1,13 +1,44 @@
|
|
|
1
1
|
import {
|
|
2
2
|
ModifierDisplayOrder,
|
|
3
3
|
ModifierDisplaySymbols,
|
|
4
|
-
|
|
4
|
+
OS,
|
|
5
5
|
detectPlatform,
|
|
6
6
|
type ModifierKeyType,
|
|
7
7
|
type PlatformType,
|
|
8
8
|
} from "./constants"
|
|
9
9
|
import { parseShortcut } from "./parser"
|
|
10
10
|
|
|
11
|
+
const _BASE_DISPLAY_NAMES: Record<string, string> = {
|
|
12
|
+
ArrowUp: "↑",
|
|
13
|
+
ArrowDown: "↓",
|
|
14
|
+
ArrowLeft: "←",
|
|
15
|
+
ArrowRight: "→",
|
|
16
|
+
Home: "Home",
|
|
17
|
+
End: "End",
|
|
18
|
+
PageUp: "PgUp",
|
|
19
|
+
PageDown: "PgDn",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const _MAC_DISPLAY_NAMES: Record<string, string> = {
|
|
23
|
+
..._BASE_DISPLAY_NAMES,
|
|
24
|
+
Enter: "↩",
|
|
25
|
+
Tab: "⇥",
|
|
26
|
+
Escape: "⎋",
|
|
27
|
+
Backspace: "⌫",
|
|
28
|
+
Delete: "⌦",
|
|
29
|
+
" ": "␣",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const _NON_MAC_DISPLAY_NAMES: Record<string, string> = {
|
|
33
|
+
..._BASE_DISPLAY_NAMES,
|
|
34
|
+
Enter: "Enter",
|
|
35
|
+
Tab: "Tab",
|
|
36
|
+
Escape: "Esc",
|
|
37
|
+
Backspace: "Backspace",
|
|
38
|
+
Delete: "Del",
|
|
39
|
+
" ": "Space",
|
|
40
|
+
}
|
|
41
|
+
|
|
11
42
|
/**
|
|
12
43
|
* Format a shortcut string for display with platform-aware symbols
|
|
13
44
|
*
|
|
@@ -35,31 +66,16 @@ export function formatShortcut(shortcut: string, platform?: PlatformType): strin
|
|
|
35
66
|
}
|
|
36
67
|
}
|
|
37
68
|
|
|
38
|
-
const displayKey =
|
|
69
|
+
const displayKey = _formatKey(parsed.key, targetPlatform)
|
|
39
70
|
parts.push(displayKey)
|
|
40
71
|
|
|
41
|
-
const separator = targetPlatform ===
|
|
72
|
+
const separator = targetPlatform === OS.MAC ? "" : "+"
|
|
42
73
|
|
|
43
74
|
return parts.join(separator)
|
|
44
75
|
}
|
|
45
76
|
|
|
46
|
-
function
|
|
47
|
-
const displayNames
|
|
48
|
-
ArrowUp: "↑",
|
|
49
|
-
ArrowDown: "↓",
|
|
50
|
-
ArrowLeft: "←",
|
|
51
|
-
ArrowRight: "→",
|
|
52
|
-
Enter: platform === Platform.MAC ? "↩" : "Enter",
|
|
53
|
-
Tab: platform === Platform.MAC ? "⇥" : "Tab",
|
|
54
|
-
Escape: platform === Platform.MAC ? "⎋" : "Esc",
|
|
55
|
-
Backspace: platform === Platform.MAC ? "⌫" : "Backspace",
|
|
56
|
-
Delete: platform === Platform.MAC ? "⌦" : "Del",
|
|
57
|
-
" ": platform === Platform.MAC ? "␣" : "Space",
|
|
58
|
-
Home: "Home",
|
|
59
|
-
End: "End",
|
|
60
|
-
PageUp: "PgUp",
|
|
61
|
-
PageDown: "PgDn",
|
|
62
|
-
}
|
|
77
|
+
function _formatKey(key: string, platform: PlatformType): string {
|
|
78
|
+
const displayNames = platform === OS.MAC ? _MAC_DISPLAY_NAMES : _NON_MAC_DISPLAY_NAMES
|
|
63
79
|
|
|
64
80
|
return displayNames[key] || key.toUpperCase()
|
|
65
81
|
}
|
|
@@ -73,10 +89,9 @@ function formatKey(key: string, platform: PlatformType): string {
|
|
|
73
89
|
* @example
|
|
74
90
|
* ```ts
|
|
75
91
|
* getModifierSymbols("mac") // { meta: "⌘", ctrl: "⌃", alt: "⌥", shift: "⇧" }
|
|
76
|
-
*
|
|
92
|
+
* ```-
|
|
77
93
|
*/
|
|
78
94
|
export function getModifierSymbols(platform?: PlatformType): Record<ModifierKeyType, string> {
|
|
79
95
|
const targetPlatform = platform ?? detectPlatform()
|
|
80
96
|
return ModifierDisplaySymbols[targetPlatform]
|
|
81
97
|
}
|
|
82
|
-
|
package/src/hook.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useMemo } from "react"
|
|
4
|
-
import {
|
|
4
|
+
import { _createShortcutBuilder } from "./builder"
|
|
5
5
|
import type {
|
|
6
6
|
ShortcutBuilder,
|
|
7
7
|
UseShortcutOptions,
|
|
@@ -10,9 +10,59 @@ import type {
|
|
|
10
10
|
ShortcutMapEntry,
|
|
11
11
|
ShortcutGroup,
|
|
12
12
|
ShortcutResult,
|
|
13
|
+
ShortcutHandler,
|
|
14
|
+
HandlerOptions,
|
|
15
|
+
ActionKey,
|
|
13
16
|
} from "./types"
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
type ShortcutMapSequenceChain = {
|
|
19
|
+
then: (step: string) => ShortcutMapSequenceChain
|
|
20
|
+
on: (handler: ShortcutHandler, options?: HandlerOptions) => ShortcutResult
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type ShortcutMapChain = {
|
|
24
|
+
ctrl: ShortcutMapChain
|
|
25
|
+
shift: ShortcutMapChain
|
|
26
|
+
alt: ShortcutMapChain
|
|
27
|
+
cmd: ShortcutMapChain
|
|
28
|
+
mod: ShortcutMapChain
|
|
29
|
+
key: (key: ActionKey) => ShortcutMapSequenceChain
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function _areShortcutMapKeysEqual(a: ShortcutMapEntry["keys"], b: ShortcutMapEntry["keys"]): boolean {
|
|
33
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
34
|
+
if (a.length !== b.length) return false
|
|
35
|
+
for (let i = 0; i < a.length; i += 1) {
|
|
36
|
+
if (a[i] !== b[i]) return false
|
|
37
|
+
}
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!Array.isArray(a) && !Array.isArray(b)) {
|
|
42
|
+
return a === b
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return false
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function _areShortcutMapsEquivalent(a: ShortcutMap, b: ShortcutMap): boolean {
|
|
49
|
+
const aKeys = Object.keys(a)
|
|
50
|
+
const bKeys = Object.keys(b)
|
|
51
|
+
if (aKeys.length !== bKeys.length) return false
|
|
52
|
+
|
|
53
|
+
for (const key of aKeys) {
|
|
54
|
+
const aEntry = a[key]
|
|
55
|
+
const bEntry = b[key]
|
|
56
|
+
if (!bEntry) return false
|
|
57
|
+
if (!_areShortcutMapKeysEqual(aEntry.keys, bEntry.keys)) return false
|
|
58
|
+
if (aEntry.handler !== bEntry.handler) return false
|
|
59
|
+
if (aEntry.options !== bEntry.options) return false
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return true
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _normalizeShortcutMapKeys(keys: ShortcutMapEntry["keys"]): string[] {
|
|
16
66
|
if (Array.isArray(keys)) {
|
|
17
67
|
return keys.map((key) => key.trim()).filter(Boolean)
|
|
18
68
|
}
|
|
@@ -31,7 +81,7 @@ function normalizeShortcutMapKeys(keys: ShortcutMapEntry["keys"]): string[] {
|
|
|
31
81
|
return [trimmed]
|
|
32
82
|
}
|
|
33
83
|
|
|
34
|
-
function
|
|
84
|
+
function _applyStep(builder: ShortcutMapChain, step: string): ShortcutMapSequenceChain {
|
|
35
85
|
const tokens = step
|
|
36
86
|
.toLowerCase()
|
|
37
87
|
.split("+")
|
|
@@ -74,9 +124,25 @@ function applyStep(builder: any, step: string): any {
|
|
|
74
124
|
throw new Error(`[useShortcutMap] Unsupported modifier token "${token}" in step "${step}"`)
|
|
75
125
|
}
|
|
76
126
|
|
|
77
|
-
return chain.key(key)
|
|
127
|
+
return chain.key(key as ActionKey)
|
|
78
128
|
}
|
|
79
129
|
|
|
130
|
+
/**
|
|
131
|
+
* Registers an object-based shortcut map in one call and returns per-action handles.
|
|
132
|
+
*
|
|
133
|
+
* @param builder - Builder returned by `useShortcut()`
|
|
134
|
+
* @param shortcutMap - Record of action ids to key bindings, handlers, and options
|
|
135
|
+
* @returns A result map with one `ShortcutResult` per shortcut id
|
|
136
|
+
*
|
|
137
|
+
* @example
|
|
138
|
+
* ```ts
|
|
139
|
+
* const $ = useShortcut()
|
|
140
|
+
* const results = registerShortcutMap($, {
|
|
141
|
+
* save: { keys: "mod+s", handler: onSave },
|
|
142
|
+
* nav: { keys: ["g", "d"], handler: onGoDashboard },
|
|
143
|
+
* })
|
|
144
|
+
* ```
|
|
145
|
+
*/
|
|
80
146
|
export function registerShortcutMap<T extends ShortcutMap>(
|
|
81
147
|
builder: ShortcutBuilder,
|
|
82
148
|
shortcutMap: T,
|
|
@@ -85,13 +151,13 @@ export function registerShortcutMap<T extends ShortcutMap>(
|
|
|
85
151
|
|
|
86
152
|
for (const id of Object.keys(shortcutMap) as Array<keyof T>) {
|
|
87
153
|
const entry = shortcutMap[id]
|
|
88
|
-
const steps =
|
|
154
|
+
const steps = _normalizeShortcutMapKeys(entry.keys)
|
|
89
155
|
|
|
90
156
|
if (steps.length === 0) {
|
|
91
157
|
throw new Error(`[useShortcutMap] Shortcut "${String(id)}" has no key steps`)
|
|
92
158
|
}
|
|
93
159
|
|
|
94
|
-
let chain =
|
|
160
|
+
let chain = _applyStep(builder, steps[0])
|
|
95
161
|
|
|
96
162
|
for (const step of steps.slice(1)) {
|
|
97
163
|
chain = chain.then(step)
|
|
@@ -108,13 +174,22 @@ export function registerShortcutMap<T extends ShortcutMap>(
|
|
|
108
174
|
*
|
|
109
175
|
* @param options - Configuration options for the hook
|
|
110
176
|
* @returns A chainable shortcut builder (`$`)
|
|
177
|
+
*
|
|
178
|
+
* @example
|
|
179
|
+
* ```ts
|
|
180
|
+
* const $ = useShortcut({ activeScopes: ["editor"] })
|
|
181
|
+
* $.mod.key("s").on((event) => {
|
|
182
|
+
* event.preventDefault()
|
|
183
|
+
* saveDocument()
|
|
184
|
+
* })
|
|
185
|
+
* ```
|
|
111
186
|
*/
|
|
112
187
|
export function useShortcut(options: UseShortcutOptions = {}): ShortcutBuilder {
|
|
113
188
|
const optionsRef = useRef(options)
|
|
114
189
|
optionsRef.current = options
|
|
115
190
|
|
|
116
191
|
const { builder, registry } = useMemo(() => {
|
|
117
|
-
return
|
|
192
|
+
return _createShortcutBuilder(optionsRef.current)
|
|
118
193
|
}, [])
|
|
119
194
|
|
|
120
195
|
useEffect(() => {
|
|
@@ -127,12 +202,19 @@ export function useShortcut(options: UseShortcutOptions = {}): ShortcutBuilder {
|
|
|
127
202
|
|
|
128
203
|
registry.activeScopes = new Set(scopes.map((scope) => scope.trim()).filter(Boolean))
|
|
129
204
|
}
|
|
130
|
-
})
|
|
205
|
+
}, [registry, options])
|
|
131
206
|
|
|
132
207
|
useEffect(() => {
|
|
133
208
|
return () => {
|
|
134
|
-
registry.listeners.forEach((entry) => entry.unbind())
|
|
135
209
|
registry.listeners.clear()
|
|
210
|
+
registry.firstStepIndex.clear()
|
|
211
|
+
registry.activeSequenceCombos.clear()
|
|
212
|
+
|
|
213
|
+
if (registry.listener && registry.listenerTarget) {
|
|
214
|
+
registry.listenerTarget.removeEventListener(registry.listenerEventType, registry.listener as EventListener)
|
|
215
|
+
registry.listener = null
|
|
216
|
+
registry.listenerTarget = null
|
|
217
|
+
}
|
|
136
218
|
}
|
|
137
219
|
}, [registry])
|
|
138
220
|
|
|
@@ -140,40 +222,67 @@ export function useShortcut(options: UseShortcutOptions = {}): ShortcutBuilder {
|
|
|
140
222
|
}
|
|
141
223
|
|
|
142
224
|
/**
|
|
143
|
-
*
|
|
225
|
+
* React hook that registers a shortcut map and automatically unbinds on cleanup.
|
|
226
|
+
*
|
|
227
|
+
* @param shortcutMap - Record of action ids to key bindings, handlers, and options
|
|
228
|
+
* @param options - Same options as `useShortcut()`
|
|
229
|
+
* @returns A map of `ShortcutResult` keyed by your shortcut ids
|
|
230
|
+
*
|
|
231
|
+
* @example
|
|
232
|
+
* ```ts
|
|
233
|
+
* const mapResults = useShortcutMap({
|
|
234
|
+
* save: { keys: "mod+s", handler: onSave },
|
|
235
|
+
* close: { keys: "escape", handler: onClose },
|
|
236
|
+
* })
|
|
237
|
+
* ```
|
|
144
238
|
*/
|
|
145
239
|
export function useShortcutMap<T extends ShortcutMap>(
|
|
146
240
|
shortcutMap: T,
|
|
147
241
|
options: UseShortcutOptions = {},
|
|
148
242
|
): ShortcutMapResult<T> {
|
|
149
243
|
const $ = useShortcut(options)
|
|
150
|
-
|
|
244
|
+
const stableShortcutMapRef = useRef(shortcutMap)
|
|
245
|
+
if (!_areShortcutMapsEquivalent(stableShortcutMapRef.current, shortcutMap)) {
|
|
246
|
+
stableShortcutMapRef.current = shortcutMap
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const stableShortcutMap = stableShortcutMapRef.current
|
|
250
|
+
const resultsRef = useRef<ShortcutMapResult<T>>({} as ShortcutMapResult<T>)
|
|
251
|
+
|
|
252
|
+
useEffect(() => {
|
|
253
|
+
const registrations = registerShortcutMap($, stableShortcutMap)
|
|
254
|
+
const results = resultsRef.current
|
|
255
|
+
for (const key of Object.keys(results)) {
|
|
256
|
+
delete (results as Record<string, unknown>)[key]
|
|
257
|
+
}
|
|
258
|
+
Object.assign(results, registrations)
|
|
259
|
+
|
|
260
|
+
return () => {
|
|
261
|
+
for (const result of Object.values(registrations)) {
|
|
262
|
+
result.unbind()
|
|
263
|
+
}
|
|
264
|
+
for (const key of Object.keys(results)) {
|
|
265
|
+
delete (results as Record<string, unknown>)[key]
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}, [$, stableShortcutMap])
|
|
269
|
+
|
|
270
|
+
return resultsRef.current
|
|
151
271
|
}
|
|
152
272
|
|
|
153
273
|
/**
|
|
154
|
-
*
|
|
274
|
+
* Creates an imperative group controller for many shortcut registrations.
|
|
155
275
|
*
|
|
156
|
-
*
|
|
276
|
+
* @returns A `ShortcutGroup` that can add and unbind multiple shortcuts together
|
|
157
277
|
*
|
|
158
|
-
* @
|
|
159
|
-
*
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Bulk registration helper for non-React usage.
|
|
278
|
+
* @example
|
|
279
|
+
* ```ts
|
|
280
|
+
* const group = createShortcutGroup()
|
|
281
|
+
* group.add($.mod.key("s").on(onSave))
|
|
282
|
+
* group.add($.key("escape").on(onClose))
|
|
283
|
+
* group.unbindAll()
|
|
284
|
+
* ```
|
|
168
285
|
*/
|
|
169
|
-
export function createShortcutMap<T extends ShortcutMap>(
|
|
170
|
-
shortcutMap: T,
|
|
171
|
-
options: UseShortcutOptions = {},
|
|
172
|
-
): ShortcutMapResult<T> {
|
|
173
|
-
const builder = createShortcut(options)
|
|
174
|
-
return registerShortcutMap(builder, shortcutMap)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
286
|
export function createShortcutGroup(): ShortcutGroup {
|
|
178
287
|
const results: ShortcutResult[] = []
|
|
179
288
|
|
|
@@ -202,6 +311,16 @@ export function createShortcutGroup(): ShortcutGroup {
|
|
|
202
311
|
}
|
|
203
312
|
}
|
|
204
313
|
|
|
314
|
+
/**
|
|
315
|
+
* React hook that returns a stable `ShortcutGroup` instance.
|
|
316
|
+
*
|
|
317
|
+
* @returns A memoized `ShortcutGroup` tied to the component lifecycle
|
|
318
|
+
*
|
|
319
|
+
* @example
|
|
320
|
+
* ```ts
|
|
321
|
+
* const group = useShortcutGroup()
|
|
322
|
+
* ```
|
|
323
|
+
*/
|
|
205
324
|
export function useShortcutGroup(): ShortcutGroup {
|
|
206
325
|
const groupRef = useRef<ShortcutGroup | null>(null)
|
|
207
326
|
|
package/src/index.ts
CHANGED
|
@@ -31,29 +31,18 @@ export type {
|
|
|
31
31
|
ExceptPredicate,
|
|
32
32
|
ShortcutScope,
|
|
33
33
|
ShortcutConflict,
|
|
34
|
-
ShortcutMap,
|
|
35
|
-
ShortcutMapEntry,
|
|
36
|
-
ShortcutMapResult,
|
|
37
34
|
ShortcutRecordingOptions,
|
|
38
|
-
ShortcutGroup,
|
|
39
35
|
} from "./types"
|
|
40
36
|
|
|
41
37
|
export {
|
|
42
38
|
parseShortcut,
|
|
43
39
|
parseShortcuts,
|
|
44
|
-
getModifiersFromEvent,
|
|
45
40
|
matchesShortcut,
|
|
46
41
|
matchesAnyShortcut,
|
|
47
42
|
} from "./parser"
|
|
48
43
|
|
|
49
|
-
export { formatShortcut
|
|
44
|
+
export { formatShortcut } from "./formatter"
|
|
50
45
|
|
|
51
46
|
export {
|
|
52
47
|
useShortcut,
|
|
53
|
-
createShortcut,
|
|
54
|
-
useShortcutMap,
|
|
55
|
-
createShortcutMap,
|
|
56
|
-
registerShortcutMap,
|
|
57
|
-
createShortcutGroup,
|
|
58
|
-
useShortcutGroup,
|
|
59
48
|
} from "./hook"
|
package/src/parser.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { ModifierAliases, SpecialKeyMap, detectPlatform, Platform } from "./constants"
|
|
2
2
|
import type { ModifierState, ParsedShortcut } from "./types"
|
|
3
3
|
|
|
4
|
+
function _normalizeKeyToken(key: string): string {
|
|
5
|
+
return key === " " ? "space" : key.toLowerCase()
|
|
6
|
+
}
|
|
7
|
+
|
|
4
8
|
/**
|
|
5
9
|
* Parse a shortcut string into its components
|
|
6
10
|
*
|
|
@@ -93,7 +97,7 @@ export function getModifiersFromEvent(event: KeyboardEvent): ModifierState {
|
|
|
93
97
|
*/
|
|
94
98
|
export function matchesShortcut(event: KeyboardEvent, parsed: ParsedShortcut): boolean {
|
|
95
99
|
const eventModifiers = getModifiersFromEvent(event)
|
|
96
|
-
const eventKey = event.key
|
|
100
|
+
const eventKey = _normalizeKeyToken(event.key)
|
|
97
101
|
|
|
98
102
|
const modifiersMatch =
|
|
99
103
|
eventModifiers.meta === parsed.modifiers.meta &&
|
|
@@ -101,7 +105,7 @@ export function matchesShortcut(event: KeyboardEvent, parsed: ParsedShortcut): b
|
|
|
101
105
|
eventModifiers.alt === parsed.modifiers.alt &&
|
|
102
106
|
eventModifiers.shift === parsed.modifiers.shift
|
|
103
107
|
|
|
104
|
-
const keyMatches = eventKey === parsed.key
|
|
108
|
+
const keyMatches = eventKey === _normalizeKeyToken(parsed.key)
|
|
105
109
|
|
|
106
110
|
return modifiersMatch && keyMatches
|
|
107
111
|
}
|
|
@@ -116,4 +120,3 @@ export function matchesShortcut(event: KeyboardEvent, parsed: ParsedShortcut): b
|
|
|
116
120
|
export function matchesAnyShortcut(event: KeyboardEvent, parsedShortcuts: ParsedShortcut[]): boolean {
|
|
117
121
|
return parsedShortcuts.some((parsed) => matchesShortcut(event, parsed))
|
|
118
122
|
}
|
|
119
|
-
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { parseShortcut } from "../parser"
|
|
2
|
+
import type { HandlerOptions, ShortcutHandler, ShortcutResult } from "../types"
|
|
3
|
+
|
|
4
|
+
import { _debugLog } from "./debug"
|
|
5
|
+
import { _detectConflict, _emitConflict } from "./conflicts"
|
|
6
|
+
import { _canonicalizeParsed, _formatSequenceDisplay } from "./keys"
|
|
7
|
+
import { _normalizeScopes } from "./guards"
|
|
8
|
+
import { _attachRegistryListener, _detachRegistryListener } from "./listener"
|
|
9
|
+
import type { BuilderState, RegistryEntry, ShortcutRegistry } from "./types"
|
|
10
|
+
|
|
11
|
+
export function _createBinding(
|
|
12
|
+
state: BuilderState,
|
|
13
|
+
handler: ShortcutHandler,
|
|
14
|
+
handlerOptions: HandlerOptions = {},
|
|
15
|
+
registry: ShortcutRegistry,
|
|
16
|
+
): ShortcutResult {
|
|
17
|
+
const { options, except: stateExcept } = state
|
|
18
|
+
|
|
19
|
+
const rawSteps = state.steps
|
|
20
|
+
|
|
21
|
+
if (rawSteps.length === 0) {
|
|
22
|
+
throw new Error('[useShortcut] No key specified. Use .key() to set the action key.')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const parsedSteps = rawSteps.map((step) => parseShortcut(step))
|
|
26
|
+
const combo = parsedSteps.map(_canonicalizeParsed).join(" ")
|
|
27
|
+
const display = _formatSequenceDisplay(rawSteps)
|
|
28
|
+
const debug = options.debug ?? false
|
|
29
|
+
const except = stateExcept ?? handlerOptions.except
|
|
30
|
+
|
|
31
|
+
for (const [existingCombo, entries] of registry.listeners.entries()) {
|
|
32
|
+
for (const existing of entries) {
|
|
33
|
+
if (existingCombo === combo) continue
|
|
34
|
+
const reason = _detectConflict(parsedSteps, existing.parsedSteps)
|
|
35
|
+
if (!reason) continue
|
|
36
|
+
_emitConflict(registry, { combo, existingCombo, reason })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const isEnabled = !handlerOptions.disabled && !options.disabled
|
|
41
|
+
const delay = handlerOptions.delay ?? options.delay ?? 0
|
|
42
|
+
const sequenceTimeout = handlerOptions.sequenceTimeout ?? options.sequenceTimeout ?? 800
|
|
43
|
+
const requiredScopes = new Set(_normalizeScopes(state.scopes ?? handlerOptions.scopes))
|
|
44
|
+
const attemptCallbacks = new Set<(matched: boolean, event: KeyboardEvent) => void>()
|
|
45
|
+
|
|
46
|
+
_debugLog(debug, "Registering:", combo, "→", display, {
|
|
47
|
+
parsedSteps,
|
|
48
|
+
except: !!except,
|
|
49
|
+
scopes: [...requiredScopes],
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
const entry: RegistryEntry = {
|
|
53
|
+
id: registry.nextId++,
|
|
54
|
+
userHandler: handler,
|
|
55
|
+
isEnabled,
|
|
56
|
+
attemptCallbacks,
|
|
57
|
+
parsedSteps,
|
|
58
|
+
scopes: requiredScopes,
|
|
59
|
+
progress: 0,
|
|
60
|
+
lastMatchedAt: 0,
|
|
61
|
+
except,
|
|
62
|
+
delay,
|
|
63
|
+
sequenceTimeout,
|
|
64
|
+
preventDefault: handlerOptions.preventDefault !== false,
|
|
65
|
+
stopPropagation: handlerOptions.stopPropagation ?? false,
|
|
66
|
+
stopOnMatch: handlerOptions.stopOnMatch ?? false,
|
|
67
|
+
priority: handlerOptions.priority ?? 0,
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const comboEntries = registry.listeners.get(combo)
|
|
71
|
+
if (comboEntries) {
|
|
72
|
+
comboEntries.push(entry)
|
|
73
|
+
} else {
|
|
74
|
+
registry.listeners.set(combo, [entry])
|
|
75
|
+
|
|
76
|
+
const firstStep = _canonicalizeParsed(parsedSteps[0])
|
|
77
|
+
const indexedCombos = registry.firstStepIndex.get(firstStep)
|
|
78
|
+
if (indexedCombos) {
|
|
79
|
+
indexedCombos.add(combo)
|
|
80
|
+
} else {
|
|
81
|
+
registry.firstStepIndex.set(firstStep, new Set([combo]))
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
_attachRegistryListener(registry)
|
|
86
|
+
|
|
87
|
+
const unbindEntry = () => {
|
|
88
|
+
const currentEntries = registry.listeners.get(combo)
|
|
89
|
+
if (!currentEntries) return
|
|
90
|
+
|
|
91
|
+
const nextEntries = currentEntries.filter((item) => item.id !== entry.id)
|
|
92
|
+
|
|
93
|
+
if (nextEntries.length === 0) {
|
|
94
|
+
registry.listeners.delete(combo)
|
|
95
|
+
registry.activeSequenceCombos.delete(combo)
|
|
96
|
+
|
|
97
|
+
const firstStep = _canonicalizeParsed(parsedSteps[0])
|
|
98
|
+
const indexedCombos = registry.firstStepIndex.get(firstStep)
|
|
99
|
+
if (indexedCombos) {
|
|
100
|
+
indexedCombos.delete(combo)
|
|
101
|
+
if (indexedCombos.size === 0) {
|
|
102
|
+
registry.firstStepIndex.delete(firstStep)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
_debugLog(debug, "Unregistered:", combo)
|
|
107
|
+
} else {
|
|
108
|
+
registry.listeners.set(combo, nextEntries)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (registry.listeners.size === 0) {
|
|
112
|
+
_detachRegistryListener(registry)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
unbind: unbindEntry,
|
|
118
|
+
display,
|
|
119
|
+
combo,
|
|
120
|
+
trigger: () => handler(new KeyboardEvent(registry.options.eventType ?? "keydown")),
|
|
121
|
+
get isEnabled() {
|
|
122
|
+
return entry.isEnabled
|
|
123
|
+
},
|
|
124
|
+
enable: () => {
|
|
125
|
+
entry.isEnabled = true
|
|
126
|
+
},
|
|
127
|
+
disable: () => {
|
|
128
|
+
entry.isEnabled = false
|
|
129
|
+
},
|
|
130
|
+
onAttempt: (callback) => {
|
|
131
|
+
entry.attemptCallbacks.add(callback)
|
|
132
|
+
return () => entry.attemptCallbacks.delete(callback)
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|