@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/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
- ShortcutResult,
5
+ ShortcutBuilder as IShortcutBuilder,
6
+ ShortcutScope,
16
7
  UseShortcutOptions,
17
8
  ExceptPreset,
18
9
  ExceptPredicate,
19
- ShortcutBuilder as IShortcutBuilder,
20
- ShortcutScope,
21
- ShortcutConflict,
22
- ParsedShortcut,
23
- ShortcutRecordingOptions,
10
+ ShortcutHandler,
24
11
  } from "./types"
25
12
 
26
- const MODIFIER_KEYS = new Set(["ctrl", "shift", "alt", "cmd", "mod"])
27
- const IGNORED_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"])
28
-
29
- const EXCEPT_PREDICATES: Record<ExceptPreset, ExceptPredicate> = {
30
- input: (e) => {
31
- const target = e.target as HTMLElement
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
- console.warn(
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 createShortcutBuilder(options: UseShortcutOptions = {}): {
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(normalizeScopes(options.activeScopes)),
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
- debugLog(options.debug, "Builder created with options:", options)
38
+ _debugLog(options.debug, "Builder created with options:", options)
478
39
 
479
- function createProxy(currentState: BuilderState): IShortcutBuilder {
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 (MODIFIER_KEYS.has(prop)) {
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
- debugLog(currentState.options.debug, `Chain: +${prop} →`, newState.modifiers)
56
+ _debugLog(currentState.options.debug, `Chain: +${prop} →`, newState.modifiers)
496
57
 
497
- return createProxy(newState)
58
+ return _createProxy(newState)
498
59
  }
499
60
 
500
61
  if (prop === "in") {
501
62
  return (scopes: ShortcutScope) => {
502
- const nextScopes = [...normalizeScopes(currentState.scopes), ...normalizeScopes(scopes)]
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 createProxy(newState)
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(normalizeScopes(scopes))
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 createRecorder(registry.options)
102
+ return _createRecorder(registry.options)
542
103
  }
543
104
 
544
105
  if (prop === "key") {
545
106
  return (key: ActionKey) => {
546
- const nextStep = buildComboString(currentState.modifiers, key)
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
- debugLog(currentState.options.debug, `Chain: .key("${key}")`)
114
+ _debugLog(currentState.options.debug, `Chain: .key("${key}")`)
554
115
 
555
- return createProxy(newState)
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
- debugLog(currentState.options.debug, `Chain: .then("${nextStep}")`)
132
+ _debugLog(currentState.options.debug, `Chain: .then("${nextStep}")`)
572
133
 
573
- return createProxy(newState)
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
- debugLog(currentState.options.debug, "Chain: .except()", condition)
145
+ _debugLog(currentState.options.debug, "Chain: .except()", condition)
585
146
 
586
- return createProxy(newState)
147
+ return _createProxy(newState)
587
148
  }
588
149
  }
589
150
 
590
151
  if (prop === "on") {
591
152
  return (handler: ShortcutHandler, handlerOptions?: HandlerOptions) => {
592
- return createBinding(currentState, handler, handlerOptions, registry)
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 createBinding(currentState, handler, rest, registry)
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: createProxy(initialState),
176
+ builder: _createProxy(initialState),
616
177
  registry,
617
178
  }
618
179
  }