@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/builder.ts
CHANGED
|
@@ -1,489 +1,50 @@
|
|
|
1
|
-
import {
|
|
2
|
-
detectPlatform,
|
|
3
|
-
Platform,
|
|
4
|
-
ModifierDisplaySymbols,
|
|
5
|
-
ModifierKey,
|
|
6
|
-
ModifierDisplayOrder,
|
|
7
|
-
} from "./constants"
|
|
8
|
-
import { formatShortcut } from "./formatter"
|
|
9
|
-
import { parseShortcut, matchesShortcut } from "./parser"
|
|
1
|
+
import { detectPlatform, Platform } from "./constants"
|
|
10
2
|
import type {
|
|
11
3
|
ActionKey,
|
|
12
|
-
ModifierFlags,
|
|
13
|
-
ShortcutHandler,
|
|
14
4
|
HandlerOptions,
|
|
15
|
-
|
|
5
|
+
ShortcutBuilder as IShortcutBuilder,
|
|
6
|
+
ShortcutScope,
|
|
16
7
|
UseShortcutOptions,
|
|
17
8
|
ExceptPreset,
|
|
18
9
|
ExceptPredicate,
|
|
19
|
-
|
|
20
|
-
ShortcutScope,
|
|
21
|
-
ShortcutConflict,
|
|
22
|
-
ParsedShortcut,
|
|
23
|
-
ShortcutRecordingOptions,
|
|
10
|
+
ShortcutHandler,
|
|
24
11
|
} from "./types"
|
|
25
12
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return IGNORED_TAGS.has(target.tagName)
|
|
33
|
-
},
|
|
34
|
-
editable: (e) => {
|
|
35
|
-
const target = e.target as HTMLElement
|
|
36
|
-
return target.isContentEditable
|
|
37
|
-
},
|
|
38
|
-
typing: (e) => {
|
|
39
|
-
const target = e.target as HTMLElement
|
|
40
|
-
return IGNORED_TAGS.has(target.tagName) || target.isContentEditable
|
|
41
|
-
},
|
|
42
|
-
modal: () => {
|
|
43
|
-
return document.querySelector('[data-modal="true"], [role="dialog"]') !== null
|
|
44
|
-
},
|
|
45
|
-
disabled: (e) => {
|
|
46
|
-
const target = e.target as HTMLElement
|
|
47
|
-
return target.hasAttribute("disabled") || target.getAttribute("aria-disabled") === "true"
|
|
48
|
-
},
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function shouldExcept(event: KeyboardEvent, except?: ExceptPreset | ExceptPreset[] | ExceptPredicate): boolean {
|
|
52
|
-
if (!except) return false
|
|
53
|
-
|
|
54
|
-
if (typeof except === "function") {
|
|
55
|
-
return except(event)
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (Array.isArray(except)) {
|
|
59
|
-
return except.some((preset) => EXCEPT_PREDICATES[preset]?.(event))
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
return EXCEPT_PREDICATES[except]?.(event) ?? false
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
type BuilderState = {
|
|
66
|
-
modifiers: Partial<ModifierFlags>
|
|
67
|
-
steps: string[]
|
|
68
|
-
options: UseShortcutOptions
|
|
69
|
-
except?: ExceptPreset | ExceptPreset[] | ExceptPredicate
|
|
70
|
-
scopes?: ShortcutScope
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
type RegistryEntry = {
|
|
74
|
-
id: number
|
|
75
|
-
userHandler: ShortcutHandler
|
|
76
|
-
isEnabled: boolean
|
|
77
|
-
attemptCallbacks: Set<(matched: boolean, event: KeyboardEvent) => void>
|
|
78
|
-
parsedSteps: ParsedShortcut[]
|
|
79
|
-
scopes: Set<string>
|
|
80
|
-
progress: number
|
|
81
|
-
lastMatchedAt: number
|
|
82
|
-
except?: ExceptPreset | ExceptPreset[] | ExceptPredicate
|
|
83
|
-
delay: number
|
|
84
|
-
sequenceTimeout: number
|
|
85
|
-
preventDefault: boolean
|
|
86
|
-
stopPropagation: boolean
|
|
87
|
-
stopOnMatch: boolean
|
|
88
|
-
priority: number
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
type ComboListener = {
|
|
92
|
-
listener: (e: KeyboardEvent) => void
|
|
93
|
-
entries: RegistryEntry[]
|
|
94
|
-
unbind: () => void
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
type ShortcutRegistry = {
|
|
98
|
-
listeners: Map<string, ComboListener>
|
|
99
|
-
options: UseShortcutOptions
|
|
100
|
-
activeScopes: Set<string>
|
|
101
|
-
nextId: number
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function normalizeScopes(scopes?: ShortcutScope): string[] {
|
|
105
|
-
if (!scopes) return []
|
|
106
|
-
return (Array.isArray(scopes) ? scopes : [scopes])
|
|
107
|
-
.map((scope) => scope.trim())
|
|
108
|
-
.filter(Boolean)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function scopeMatch(requiredScopes: Set<string>, activeScopes: Set<string>): boolean {
|
|
112
|
-
if (requiredScopes.size === 0) return true
|
|
113
|
-
for (const required of requiredScopes) {
|
|
114
|
-
if (activeScopes.has(required)) return true
|
|
115
|
-
}
|
|
116
|
-
return false
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function getActiveModifierTokens(modifiers: Partial<ModifierFlags>): string[] {
|
|
120
|
-
const platform = detectPlatform()
|
|
121
|
-
const order = ModifierDisplayOrder[platform]
|
|
122
|
-
|
|
123
|
-
return order
|
|
124
|
-
.filter((key) => {
|
|
125
|
-
if (key === ModifierKey.CTRL) return modifiers.ctrl
|
|
126
|
-
if (key === ModifierKey.ALT) return modifiers.alt
|
|
127
|
-
if (key === ModifierKey.SHIFT) return modifiers.shift
|
|
128
|
-
if (key === ModifierKey.META) return modifiers.cmd
|
|
129
|
-
return false
|
|
130
|
-
})
|
|
131
|
-
.map((key) => {
|
|
132
|
-
if (key === ModifierKey.CTRL) return "ctrl"
|
|
133
|
-
if (key === ModifierKey.ALT) return "alt"
|
|
134
|
-
if (key === ModifierKey.SHIFT) return "shift"
|
|
135
|
-
if (key === ModifierKey.META) return "cmd"
|
|
136
|
-
return ""
|
|
137
|
-
})
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function buildComboString(modifiers: Partial<ModifierFlags>, key: string): string {
|
|
141
|
-
const tokens = getActiveModifierTokens(modifiers)
|
|
142
|
-
return [...tokens, key].join("+")
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function formatSequenceDisplay(steps: string[]): string {
|
|
146
|
-
return steps.map((step) => formatShortcut(step)).join(" then ")
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function debugLog(debug: boolean | undefined, ...args: unknown[]) {
|
|
150
|
-
if (debug) {
|
|
151
|
-
console.log("[useShortcut]", ...args)
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
function canonicalizeParsed(parsed: ParsedShortcut): string {
|
|
156
|
-
const modifiers: string[] = []
|
|
157
|
-
if (parsed.modifiers.ctrl) modifiers.push("ctrl")
|
|
158
|
-
if (parsed.modifiers.alt) modifiers.push("alt")
|
|
159
|
-
if (parsed.modifiers.shift) modifiers.push("shift")
|
|
160
|
-
if (parsed.modifiers.meta) modifiers.push("cmd")
|
|
161
|
-
return [...modifiers, parsed.key.toLowerCase()].join("+")
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function isPureModifier(event: KeyboardEvent): boolean {
|
|
165
|
-
const key = event.key.toLowerCase()
|
|
166
|
-
return key === "shift" || key === "control" || key === "alt" || key === "meta"
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function eventToCombo(event: KeyboardEvent): string {
|
|
170
|
-
const platform = detectPlatform()
|
|
171
|
-
const symbols = ModifierDisplaySymbols[platform]
|
|
172
|
-
|
|
173
|
-
const modifiers: string[] = []
|
|
174
|
-
if (event.ctrlKey) modifiers.push(symbols[ModifierKey.CTRL] === "⌃" ? "ctrl" : "ctrl")
|
|
175
|
-
if (event.altKey) modifiers.push("alt")
|
|
176
|
-
if (event.shiftKey) modifiers.push("shift")
|
|
177
|
-
if (event.metaKey) modifiers.push("cmd")
|
|
178
|
-
|
|
179
|
-
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key.toLowerCase()
|
|
180
|
-
return [...modifiers, key].join("+")
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
function isPrefix(a: ParsedShortcut[], b: ParsedShortcut[]): boolean {
|
|
184
|
-
if (a.length > b.length) return false
|
|
185
|
-
for (let i = 0; i < a.length; i += 1) {
|
|
186
|
-
if (canonicalizeParsed(a[i]) !== canonicalizeParsed(b[i])) {
|
|
187
|
-
return false
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
return true
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function detectConflict(newSteps: ParsedShortcut[], existingSteps: ParsedShortcut[]): ShortcutConflict["reason"] | null {
|
|
194
|
-
const newCombo = newSteps.map(canonicalizeParsed).join(" ")
|
|
195
|
-
const existingCombo = existingSteps.map(canonicalizeParsed).join(" ")
|
|
196
|
-
|
|
197
|
-
if (newCombo === existingCombo) return "exact"
|
|
198
|
-
if (isPrefix(newSteps, existingSteps) || isPrefix(existingSteps, newSteps)) {
|
|
199
|
-
return "sequence-prefix"
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
return null
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function emitConflict(registry: ShortcutRegistry, conflict: ShortcutConflict) {
|
|
206
|
-
const conflictWarnings = registry.options.conflictWarnings ?? true
|
|
207
|
-
|
|
208
|
-
if (registry.options.onConflict) {
|
|
209
|
-
registry.options.onConflict(conflict)
|
|
210
|
-
return
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
if (!conflictWarnings) return
|
|
13
|
+
import { _createBinding } from "./runtime/binding"
|
|
14
|
+
import { _debugLog } from "./runtime/debug"
|
|
15
|
+
import { _normalizeScopes } from "./runtime/guards"
|
|
16
|
+
import { _buildComboString } from "./runtime/keys"
|
|
17
|
+
import { _createRecorder } from "./runtime/recording"
|
|
18
|
+
import type { BuilderState, ShortcutRegistry } from "./runtime/types"
|
|
214
19
|
|
|
215
|
-
|
|
216
|
-
`[useShortcut] Conflict detected (${conflict.reason}) between "${conflict.combo}" and "${conflict.existingCombo}"`,
|
|
217
|
-
)
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function sortEntries(entries: RegistryEntry[]): RegistryEntry[] {
|
|
221
|
-
return [...entries].sort((a, b) => {
|
|
222
|
-
if (b.priority !== a.priority) return b.priority - a.priority
|
|
223
|
-
return a.id - b.id
|
|
224
|
-
})
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function createBinding(
|
|
228
|
-
state: BuilderState,
|
|
229
|
-
handler: ShortcutHandler,
|
|
230
|
-
handlerOptions: HandlerOptions = {},
|
|
231
|
-
registry: ShortcutRegistry,
|
|
232
|
-
): ShortcutResult {
|
|
233
|
-
const { options, except: stateExcept } = state
|
|
234
|
-
|
|
235
|
-
const rawSteps = state.steps
|
|
236
|
-
|
|
237
|
-
if (rawSteps.length === 0) {
|
|
238
|
-
throw new Error('[useShortcut] No key specified. Use .key() to set the action key.')
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
const parsedSteps = rawSteps.map((step) => parseShortcut(step))
|
|
242
|
-
const combo = parsedSteps.map(canonicalizeParsed).join(" ")
|
|
243
|
-
const display = formatSequenceDisplay(rawSteps)
|
|
244
|
-
const debug = options.debug ?? false
|
|
245
|
-
const except = stateExcept ?? handlerOptions.except
|
|
246
|
-
|
|
247
|
-
for (const [existingCombo, listener] of registry.listeners.entries()) {
|
|
248
|
-
for (const existing of listener.entries) {
|
|
249
|
-
if (existingCombo === combo) continue
|
|
250
|
-
const reason = detectConflict(parsedSteps, existing.parsedSteps)
|
|
251
|
-
if (!reason) continue
|
|
252
|
-
emitConflict(registry, { combo, existingCombo, reason })
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
const isEnabled = !handlerOptions.disabled && !options.disabled
|
|
257
|
-
const delay = handlerOptions.delay ?? options.delay ?? 0
|
|
258
|
-
const sequenceTimeout = handlerOptions.sequenceTimeout ?? options.sequenceTimeout ?? 800
|
|
259
|
-
const requiredScopes = new Set(normalizeScopes(state.scopes ?? handlerOptions.scopes))
|
|
260
|
-
const attemptCallbacks = new Set<(matched: boolean, event: KeyboardEvent) => void>()
|
|
261
|
-
|
|
262
|
-
debugLog(debug, "Registering:", combo, "→", display, {
|
|
263
|
-
parsedSteps,
|
|
264
|
-
except: !!except,
|
|
265
|
-
scopes: [...requiredScopes],
|
|
266
|
-
})
|
|
267
|
-
|
|
268
|
-
const entry: RegistryEntry = {
|
|
269
|
-
id: registry.nextId++,
|
|
270
|
-
userHandler: handler,
|
|
271
|
-
isEnabled,
|
|
272
|
-
attemptCallbacks,
|
|
273
|
-
parsedSteps,
|
|
274
|
-
scopes: requiredScopes,
|
|
275
|
-
progress: 0,
|
|
276
|
-
lastMatchedAt: 0,
|
|
277
|
-
except,
|
|
278
|
-
delay,
|
|
279
|
-
sequenceTimeout,
|
|
280
|
-
preventDefault: handlerOptions.preventDefault !== false,
|
|
281
|
-
stopPropagation: handlerOptions.stopPropagation ?? false,
|
|
282
|
-
stopOnMatch: handlerOptions.stopOnMatch ?? false,
|
|
283
|
-
priority: handlerOptions.priority ?? 0,
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
let comboListener = registry.listeners.get(combo)
|
|
287
|
-
|
|
288
|
-
if (!comboListener) {
|
|
289
|
-
const target = options.target ?? (typeof window !== "undefined" ? window : null)
|
|
290
|
-
const eventType = options.eventType ?? "keydown"
|
|
291
|
-
|
|
292
|
-
const listener = (event: KeyboardEvent) => {
|
|
293
|
-
const runtimeOptions = registry.options
|
|
294
|
-
if (runtimeOptions.disabled) return
|
|
295
|
-
if (runtimeOptions.eventFilter && !runtimeOptions.eventFilter(event)) return
|
|
296
|
-
|
|
297
|
-
const current = registry.listeners.get(combo)
|
|
298
|
-
if (!current) return
|
|
299
|
-
|
|
300
|
-
const orderedEntries = sortEntries(current.entries)
|
|
301
|
-
|
|
302
|
-
for (const item of orderedEntries) {
|
|
303
|
-
if (!item.isEnabled) continue
|
|
304
|
-
|
|
305
|
-
if (!scopeMatch(item.scopes, registry.activeScopes)) {
|
|
306
|
-
continue
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
if (runtimeOptions.ignoreInputs !== false && !item.except) {
|
|
310
|
-
const targetEl = event.target as HTMLElement
|
|
311
|
-
if (targetEl && (IGNORED_TAGS.has(targetEl.tagName) || targetEl.isContentEditable)) {
|
|
312
|
-
continue
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
if (shouldExcept(event, item.except)) {
|
|
317
|
-
debugLog(debug, "Skipped due to except condition:", combo)
|
|
318
|
-
continue
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
const expected = item.parsedSteps[item.progress]
|
|
322
|
-
const now = Date.now()
|
|
323
|
-
|
|
324
|
-
if (item.progress > 0 && now - item.lastMatchedAt > item.sequenceTimeout) {
|
|
325
|
-
item.progress = 0
|
|
326
|
-
}
|
|
327
|
-
|
|
328
|
-
let matched = false
|
|
329
|
-
|
|
330
|
-
if (matchesShortcut(event, expected)) {
|
|
331
|
-
item.progress += 1
|
|
332
|
-
item.lastMatchedAt = now
|
|
333
|
-
|
|
334
|
-
if (item.progress === item.parsedSteps.length) {
|
|
335
|
-
matched = true
|
|
336
|
-
item.progress = 0
|
|
337
|
-
}
|
|
338
|
-
} else if (item.progress > 0 && matchesShortcut(event, item.parsedSteps[0])) {
|
|
339
|
-
item.progress = 1
|
|
340
|
-
item.lastMatchedAt = now
|
|
341
|
-
} else {
|
|
342
|
-
item.progress = 0
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
item.attemptCallbacks.forEach((cb) => cb(matched, event))
|
|
346
|
-
|
|
347
|
-
if (!matched) continue
|
|
348
|
-
|
|
349
|
-
debugLog(debug, "MATCHED:", combo, "→", display)
|
|
350
|
-
|
|
351
|
-
if (item.preventDefault) {
|
|
352
|
-
event.preventDefault()
|
|
353
|
-
}
|
|
354
|
-
|
|
355
|
-
if (item.stopPropagation) {
|
|
356
|
-
event.stopPropagation()
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
const executeHandler = () => item.userHandler(event)
|
|
360
|
-
|
|
361
|
-
if (item.delay > 0) {
|
|
362
|
-
debugLog(debug, "Delaying execution by", item.delay, "ms")
|
|
363
|
-
setTimeout(executeHandler, item.delay)
|
|
364
|
-
} else {
|
|
365
|
-
executeHandler()
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
if (item.stopOnMatch) {
|
|
369
|
-
break
|
|
370
|
-
}
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
if (target) {
|
|
375
|
-
target.addEventListener(eventType, listener as EventListener)
|
|
376
|
-
debugLog(debug, "Listener attached for:", combo)
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const unbind = () => {
|
|
380
|
-
if (target) {
|
|
381
|
-
target.removeEventListener(eventType, listener as EventListener)
|
|
382
|
-
registry.listeners.delete(combo)
|
|
383
|
-
debugLog(debug, "Unregistered:", combo)
|
|
384
|
-
}
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
comboListener = {
|
|
388
|
-
listener,
|
|
389
|
-
entries: [],
|
|
390
|
-
unbind,
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
registry.listeners.set(combo, comboListener)
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
comboListener.entries.push(entry)
|
|
397
|
-
|
|
398
|
-
const unbindEntry = () => {
|
|
399
|
-
const current = registry.listeners.get(combo)
|
|
400
|
-
if (!current) return
|
|
401
|
-
|
|
402
|
-
current.entries = current.entries.filter((item) => item.id !== entry.id)
|
|
403
|
-
|
|
404
|
-
if (current.entries.length === 0) {
|
|
405
|
-
current.unbind()
|
|
406
|
-
}
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
return {
|
|
410
|
-
unbind: unbindEntry,
|
|
411
|
-
display,
|
|
412
|
-
combo,
|
|
413
|
-
trigger: () => handler(new KeyboardEvent(registry.options.eventType ?? "keydown")),
|
|
414
|
-
get isEnabled() {
|
|
415
|
-
return entry.isEnabled
|
|
416
|
-
},
|
|
417
|
-
enable: () => {
|
|
418
|
-
entry.isEnabled = true
|
|
419
|
-
},
|
|
420
|
-
disable: () => {
|
|
421
|
-
entry.isEnabled = false
|
|
422
|
-
},
|
|
423
|
-
onAttempt: (callback) => {
|
|
424
|
-
entry.attemptCallbacks.add(callback)
|
|
425
|
-
return () => entry.attemptCallbacks.delete(callback)
|
|
426
|
-
},
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
function createRecorder(options: UseShortcutOptions) {
|
|
431
|
-
return (recordingOptions: ShortcutRecordingOptions = {}): Promise<string> => {
|
|
432
|
-
return new Promise((resolve, reject) => {
|
|
433
|
-
const target = recordingOptions.target ?? options.target ?? (typeof window !== "undefined" ? window : null)
|
|
434
|
-
const eventType = recordingOptions.eventType ?? options.eventType ?? "keydown"
|
|
435
|
-
|
|
436
|
-
if (!target) {
|
|
437
|
-
reject(new Error("[useShortcut] Cannot record shortcut without a target."))
|
|
438
|
-
return
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
let timeout: ReturnType<typeof setTimeout> | undefined
|
|
442
|
-
|
|
443
|
-
const listener = (event: Event) => {
|
|
444
|
-
const keyboardEvent = event as KeyboardEvent
|
|
445
|
-
if (isPureModifier(keyboardEvent)) return
|
|
446
|
-
|
|
447
|
-
keyboardEvent.preventDefault()
|
|
448
|
-
target.removeEventListener(eventType, listener as EventListener)
|
|
449
|
-
if (timeout) clearTimeout(timeout)
|
|
450
|
-
resolve(eventToCombo(keyboardEvent))
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
target.addEventListener(eventType, listener as EventListener, { once: false })
|
|
454
|
-
|
|
455
|
-
const timeoutMs = recordingOptions.timeoutMs
|
|
456
|
-
if (timeoutMs && timeoutMs > 0) {
|
|
457
|
-
timeout = setTimeout(() => {
|
|
458
|
-
target.removeEventListener(eventType, listener as EventListener)
|
|
459
|
-
reject(new Error(`[useShortcut] Recording timed out after ${timeoutMs}ms.`))
|
|
460
|
-
}, timeoutMs)
|
|
461
|
-
}
|
|
462
|
-
})
|
|
463
|
-
}
|
|
464
|
-
}
|
|
20
|
+
const _MODIFIER_KEYS = new Set(["ctrl", "shift", "alt", "cmd", "mod"])
|
|
465
21
|
|
|
466
|
-
export function
|
|
22
|
+
export function _createShortcutBuilder(options: UseShortcutOptions = {}): {
|
|
467
23
|
builder: IShortcutBuilder
|
|
468
24
|
registry: ShortcutRegistry
|
|
469
25
|
} {
|
|
470
26
|
const registry: ShortcutRegistry = {
|
|
471
27
|
listeners: new Map(),
|
|
28
|
+
firstStepIndex: new Map(),
|
|
29
|
+
activeSequenceCombos: new Set(),
|
|
472
30
|
options,
|
|
473
|
-
activeScopes: new Set(
|
|
31
|
+
activeScopes: new Set(_normalizeScopes(options.activeScopes)),
|
|
474
32
|
nextId: 1,
|
|
33
|
+
listener: null,
|
|
34
|
+
listenerTarget: null,
|
|
35
|
+
listenerEventType: options.eventType ?? "keydown",
|
|
475
36
|
}
|
|
476
37
|
|
|
477
|
-
|
|
38
|
+
_debugLog(options.debug, "Builder created with options:", options)
|
|
478
39
|
|
|
479
|
-
function
|
|
40
|
+
function _createProxy(currentState: BuilderState): IShortcutBuilder {
|
|
480
41
|
return new Proxy({} as IShortcutBuilder, {
|
|
481
42
|
get(_, prop: string) {
|
|
482
43
|
if (prop === "__debug") {
|
|
483
44
|
return currentState.options.debug
|
|
484
45
|
}
|
|
485
46
|
|
|
486
|
-
if (
|
|
47
|
+
if (_MODIFIER_KEYS.has(prop)) {
|
|
487
48
|
const platform = detectPlatform()
|
|
488
49
|
const modKey = prop === "mod" ? (platform === Platform.MAC ? "cmd" : "ctrl") : prop
|
|
489
50
|
|
|
@@ -492,26 +53,26 @@ export function createShortcutBuilder(options: UseShortcutOptions = {}): {
|
|
|
492
53
|
modifiers: { ...currentState.modifiers, [modKey]: true },
|
|
493
54
|
}
|
|
494
55
|
|
|
495
|
-
|
|
56
|
+
_debugLog(currentState.options.debug, `Chain: +${prop} →`, newState.modifiers)
|
|
496
57
|
|
|
497
|
-
return
|
|
58
|
+
return _createProxy(newState)
|
|
498
59
|
}
|
|
499
60
|
|
|
500
61
|
if (prop === "in") {
|
|
501
62
|
return (scopes: ShortcutScope) => {
|
|
502
|
-
const nextScopes = [...
|
|
63
|
+
const nextScopes = [..._normalizeScopes(currentState.scopes), ..._normalizeScopes(scopes)]
|
|
503
64
|
const newState: BuilderState = {
|
|
504
65
|
...currentState,
|
|
505
66
|
scopes: nextScopes,
|
|
506
67
|
}
|
|
507
68
|
|
|
508
|
-
return
|
|
69
|
+
return _createProxy(newState)
|
|
509
70
|
}
|
|
510
71
|
}
|
|
511
72
|
|
|
512
73
|
if (prop === "setScopes") {
|
|
513
74
|
return (scopes: ShortcutScope) => {
|
|
514
|
-
registry.activeScopes = new Set(
|
|
75
|
+
registry.activeScopes = new Set(_normalizeScopes(scopes))
|
|
515
76
|
}
|
|
516
77
|
}
|
|
517
78
|
|
|
@@ -538,21 +99,21 @@ export function createShortcutBuilder(options: UseShortcutOptions = {}): {
|
|
|
538
99
|
}
|
|
539
100
|
|
|
540
101
|
if (prop === "record") {
|
|
541
|
-
return
|
|
102
|
+
return _createRecorder(registry.options)
|
|
542
103
|
}
|
|
543
104
|
|
|
544
105
|
if (prop === "key") {
|
|
545
106
|
return (key: ActionKey) => {
|
|
546
|
-
const nextStep =
|
|
107
|
+
const nextStep = _buildComboString(currentState.modifiers, key)
|
|
547
108
|
const newState: BuilderState = {
|
|
548
109
|
...currentState,
|
|
549
110
|
modifiers: {},
|
|
550
111
|
steps: [...currentState.steps, nextStep],
|
|
551
112
|
}
|
|
552
113
|
|
|
553
|
-
|
|
114
|
+
_debugLog(currentState.options.debug, `Chain: .key("${key}")`)
|
|
554
115
|
|
|
555
|
-
return
|
|
116
|
+
return _createProxy(newState)
|
|
556
117
|
}
|
|
557
118
|
}
|
|
558
119
|
|
|
@@ -568,9 +129,9 @@ export function createShortcutBuilder(options: UseShortcutOptions = {}): {
|
|
|
568
129
|
steps: [...currentState.steps, nextStep],
|
|
569
130
|
}
|
|
570
131
|
|
|
571
|
-
|
|
132
|
+
_debugLog(currentState.options.debug, `Chain: .then("${nextStep}")`)
|
|
572
133
|
|
|
573
|
-
return
|
|
134
|
+
return _createProxy(newState)
|
|
574
135
|
}
|
|
575
136
|
}
|
|
576
137
|
|
|
@@ -581,22 +142,22 @@ export function createShortcutBuilder(options: UseShortcutOptions = {}): {
|
|
|
581
142
|
except: condition,
|
|
582
143
|
}
|
|
583
144
|
|
|
584
|
-
|
|
145
|
+
_debugLog(currentState.options.debug, "Chain: .except()", condition)
|
|
585
146
|
|
|
586
|
-
return
|
|
147
|
+
return _createProxy(newState)
|
|
587
148
|
}
|
|
588
149
|
}
|
|
589
150
|
|
|
590
151
|
if (prop === "on") {
|
|
591
152
|
return (handler: ShortcutHandler, handlerOptions?: HandlerOptions) => {
|
|
592
|
-
return
|
|
153
|
+
return _createBinding(currentState, handler, handlerOptions, registry)
|
|
593
154
|
}
|
|
594
155
|
}
|
|
595
156
|
|
|
596
157
|
if (prop === "handle") {
|
|
597
158
|
return (opts: HandlerOptions & { handler: ShortcutHandler }) => {
|
|
598
159
|
const { handler, ...rest } = opts
|
|
599
|
-
return
|
|
160
|
+
return _createBinding(currentState, handler, rest, registry)
|
|
600
161
|
}
|
|
601
162
|
}
|
|
602
163
|
|
|
@@ -612,7 +173,7 @@ export function createShortcutBuilder(options: UseShortcutOptions = {}): {
|
|
|
612
173
|
}
|
|
613
174
|
|
|
614
175
|
return {
|
|
615
|
-
builder:
|
|
176
|
+
builder: _createProxy(initialState),
|
|
616
177
|
registry,
|
|
617
178
|
}
|
|
618
179
|
}
|