@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/constants.ts CHANGED
@@ -1,19 +1,61 @@
1
- export const Platform = {
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 Platform)[keyof typeof Platform]
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 (typeof navigator === "undefined") return Platform.WINDOWS
11
- const platform = navigator.platform.toLowerCase()
12
- if (platform.includes("mac")) return Platform.MAC
13
- if (platform.includes("linux")) return Platform.LINUX
14
- return Platform.WINDOWS
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
- [Platform.MAC]: {
135
+ [OS.MAC]: {
91
136
  [ModifierKey.META]: "⌘",
92
137
  [ModifierKey.CTRL]: "⌃",
93
138
  [ModifierKey.ALT]: "⌥",
94
139
  [ModifierKey.SHIFT]: "⇧",
95
140
  },
96
- [Platform.WINDOWS]: {
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
- [Platform.LINUX]: {
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
- [Platform.MAC]: [ModifierKey.CTRL, ModifierKey.ALT, ModifierKey.SHIFT, ModifierKey.META],
112
- [Platform.WINDOWS]: [ModifierKey.META, ModifierKey.ALT, ModifierKey.SHIFT, ModifierKey.CTRL],
113
- [Platform.LINUX]: [ModifierKey.META, ModifierKey.ALT, ModifierKey.SHIFT, ModifierKey.CTRL],
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
- Platform,
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 = formatKey(parsed.key, targetPlatform)
69
+ const displayKey = _formatKey(parsed.key, targetPlatform)
39
70
  parts.push(displayKey)
40
71
 
41
- const separator = targetPlatform === Platform.MAC ? "" : "+"
72
+ const separator = targetPlatform === OS.MAC ? "" : "+"
42
73
 
43
74
  return parts.join(separator)
44
75
  }
45
76
 
46
- function formatKey(key: string, platform: PlatformType): string {
47
- const displayNames: Record<string, string> = {
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 { createShortcutBuilder } from "./builder"
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
- function normalizeShortcutMapKeys(keys: ShortcutMapEntry["keys"]): string[] {
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 applyStep(builder: any, step: string): any {
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 = normalizeShortcutMapKeys(entry.keys)
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 = applyStep(builder, steps[0])
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 createShortcutBuilder(optionsRef.current)
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
- * Bulk registration helper for shortcut maps.
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
- return registerShortcutMap($, shortcutMap)
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
- * Create a shortcut builder for non-React usage
274
+ * Creates an imperative group controller for many shortcut registrations.
155
275
  *
156
- * Unlike `useShortcut`, this does not auto-cleanup - you must call `.unbind()` manually.
276
+ * @returns A `ShortcutGroup` that can add and unbind multiple shortcuts together
157
277
  *
158
- * @param options - Configuration options
159
- * @returns A chainable shortcut builder
160
- */
161
- export function createShortcut(options: UseShortcutOptions = {}): ShortcutBuilder {
162
- const { builder } = createShortcutBuilder(options)
163
- return builder as ShortcutBuilder
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, getModifierSymbols } from "./formatter"
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.toLowerCase()
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.toLowerCase()
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
+