@tanstack/angular-hotkeys 0.6.0 → 0.8.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.
@@ -1,18 +1,27 @@
1
- import { effect } from '@angular/core'
2
- import { getSequenceManager } from '@tanstack/hotkeys'
1
+ import { DestroyRef, effect, inject } from '@angular/core'
2
+ import { formatHotkeySequence, getSequenceManager } from '@tanstack/hotkeys'
3
3
  import { injectDefaultHotkeysOptions } from './hotkeys-provider'
4
4
  import type {
5
5
  HotkeyCallback,
6
6
  HotkeySequence,
7
7
  SequenceOptions,
8
+ SequenceRegistrationHandle,
8
9
  } from '@tanstack/hotkeys'
9
10
 
11
+ type SequenceTarget = Document | HTMLElement | Window
12
+
10
13
  export interface InjectHotkeySequenceOptions extends Omit<
11
14
  SequenceOptions,
12
- 'enabled'
15
+ 'enabled' | 'target'
13
16
  > {
14
17
  /** Whether the sequence is enabled. Defaults to true. */
15
18
  enabled?: boolean
19
+ /**
20
+ * The DOM element to attach the event listener to.
21
+ * Can be a direct DOM element, an accessor target, or null.
22
+ * Defaults to document when omitted.
23
+ */
24
+ target?: HTMLElement | Document | Window | null
16
25
  }
17
26
 
18
27
  /**
@@ -28,7 +37,8 @@ export interface InjectHotkeySequenceOptions extends Omit<
28
37
  *
29
38
  * @param sequence - Array of hotkey strings that form the sequence (or getter function)
30
39
  * @param callback - Function to call when the sequence is completed
31
- * @param options - Options for the sequence behavior (or getter function)
40
+ * @param options - Options for the sequence behavior (or getter function). `enabled: false` still registers
41
+ * the sequence (visible in devtools); only execution is suppressed.
32
42
  *
33
43
  * @example
34
44
  * ```ts
@@ -53,8 +63,23 @@ export function injectHotkeySequence(
53
63
  | (() => InjectHotkeySequenceOptions) = {},
54
64
  ): void {
55
65
  const defaultOptions = injectDefaultHotkeysOptions()
66
+ const manager = getSequenceManager()
67
+ const destroyRef = inject(DestroyRef)
68
+
69
+ let handle: SequenceRegistrationHandle | null = null
70
+ let lastSequenceKey: string | null = null
71
+ let lastTarget: SequenceTarget | null = null
56
72
 
57
- effect((onCleanup) => {
73
+ destroyRef.onDestroy(() => {
74
+ if (handle?.isActive) {
75
+ handle.unregister()
76
+ handle = null
77
+ }
78
+ lastSequenceKey = null
79
+ lastTarget = null
80
+ })
81
+
82
+ effect(() => {
58
83
  // Resolve reactive values
59
84
  const resolvedSequence =
60
85
  typeof sequence === 'function' ? sequence() : sequence
@@ -67,27 +92,49 @@ export function injectHotkeySequence(
67
92
 
68
93
  const { enabled = true, ...sequenceOptions } = mergedOptions
69
94
 
70
- if (!enabled || resolvedSequence.length === 0) {
95
+ const resolvedTarget =
96
+ 'target' in sequenceOptions
97
+ ? (sequenceOptions.target ?? null)
98
+ : typeof document !== 'undefined'
99
+ ? document
100
+ : null
101
+
102
+ if (resolvedSequence.length === 0 || !resolvedTarget) {
103
+ if (handle?.isActive) {
104
+ handle.unregister()
105
+ handle = null
106
+ }
107
+ lastSequenceKey = null
108
+ lastTarget = null
71
109
  return
72
110
  }
73
111
 
74
- const manager = getSequenceManager()
112
+ const sequenceKey = formatHotkeySequence(resolvedSequence)
75
113
 
76
- // Pass through options; default target to document when not provided
77
- const registerOptions: SequenceOptions = {
114
+ const registerPayload: SequenceOptions = {
78
115
  ...sequenceOptions,
79
- enabled: true,
80
- target:
81
- sequenceOptions.target ??
82
- (typeof document !== 'undefined' ? document : undefined),
116
+ enabled,
117
+ target: resolvedTarget,
83
118
  }
119
+ const { target: _t, ...optionsWithoutTarget } = registerPayload
84
120
 
85
- const handle = manager.register(resolvedSequence, callback, registerOptions)
121
+ if (
122
+ handle?.isActive &&
123
+ lastSequenceKey === sequenceKey &&
124
+ lastTarget === resolvedTarget
125
+ ) {
126
+ handle.callback = callback
127
+ handle.setOptions(optionsWithoutTarget)
128
+ return
129
+ }
86
130
 
87
- onCleanup(() => {
88
- if (handle.isActive) {
89
- handle.unregister()
90
- }
91
- })
131
+ if (handle?.isActive) {
132
+ handle.unregister()
133
+ handle = null
134
+ }
135
+
136
+ handle = manager.register(resolvedSequence, callback, registerPayload)
137
+ lastSequenceKey = sequenceKey
138
+ lastTarget = resolvedTarget
92
139
  })
93
140
  }
@@ -0,0 +1,176 @@
1
+ import { DestroyRef, effect, inject } from '@angular/core'
2
+ import { formatHotkeySequence, getSequenceManager } from '@tanstack/hotkeys'
3
+ import { injectDefaultHotkeysOptions } from './hotkeys-provider'
4
+ import type { InjectHotkeySequenceOptions } from './injectHotkeySequence'
5
+ import type {
6
+ HotkeyCallback,
7
+ HotkeySequence,
8
+ SequenceRegistrationHandle,
9
+ } from '@tanstack/hotkeys'
10
+
11
+ /**
12
+ * A single sequence definition for use with `injectHotkeySequences`.
13
+ */
14
+ export interface InjectHotkeySequenceDefinition {
15
+ /** Array of hotkey strings that form the sequence */
16
+ sequence: HotkeySequence | (() => HotkeySequence)
17
+ /** The function to call when the sequence is completed */
18
+ callback: HotkeyCallback
19
+ /** Per-sequence options (merged on top of commonOptions) */
20
+ options?: InjectHotkeySequenceOptions | (() => InjectHotkeySequenceOptions)
21
+ }
22
+
23
+ /**
24
+ * Angular inject-based API for registering multiple keyboard shortcut sequences at once (Vim-style).
25
+ *
26
+ * Uses the singleton SequenceManager. Call in an injection context (e.g. constructor).
27
+ * Uses `effect()` to track reactive dependencies when definitions or options are getters.
28
+ *
29
+ * Options are merged in this order:
30
+ * provideHotkeys defaults < commonOptions < per-definition options
31
+ *
32
+ * Definitions with an empty `sequence` are skipped. Disabled sequences (`enabled: false`)
33
+ * remain registered so they stay visible in devtools; the core manager suppresses execution.
34
+ *
35
+ * @param sequences - Array of sequence definitions, or getter returning them
36
+ * @param commonOptions - Shared options for all sequences, or getter
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * @Component({ ... })
41
+ * export class VimComponent {
42
+ * constructor() {
43
+ * injectHotkeySequences([
44
+ * { sequence: ['G', 'G'], callback: () => this.goTop() },
45
+ * { sequence: ['D', 'D'], callback: () => this.deleteLine() },
46
+ * ])
47
+ * }
48
+ * }
49
+ * ```
50
+ */
51
+ export function injectHotkeySequences(
52
+ sequences:
53
+ | Array<InjectHotkeySequenceDefinition>
54
+ | (() => Array<InjectHotkeySequenceDefinition>),
55
+ commonOptions:
56
+ | InjectHotkeySequenceOptions
57
+ | (() => InjectHotkeySequenceOptions) = {},
58
+ ): void {
59
+ type RegistrationRecord = {
60
+ handle: SequenceRegistrationHandle
61
+ target: Document | HTMLElement | Window
62
+ }
63
+
64
+ const defaultOptions = injectDefaultHotkeysOptions()
65
+ const manager = getSequenceManager()
66
+ const destroyRef = inject(DestroyRef)
67
+
68
+ const registrations = new Map<string, RegistrationRecord>()
69
+
70
+ destroyRef.onDestroy(() => {
71
+ for (const { handle } of registrations.values()) {
72
+ if (handle.isActive) {
73
+ handle.unregister()
74
+ }
75
+ }
76
+ registrations.clear()
77
+ })
78
+
79
+ effect(() => {
80
+ const resolvedSequences =
81
+ typeof sequences === 'function' ? sequences() : sequences
82
+ const resolvedCommonOptions =
83
+ typeof commonOptions === 'function' ? commonOptions() : commonOptions
84
+
85
+ const nextKeys = new Set<string>()
86
+ const prepared: Array<{
87
+ registrationKey: string
88
+ def: InjectHotkeySequenceDefinition
89
+ resolvedSequence: HotkeySequence
90
+ enabled: boolean
91
+ sequenceOpts: Omit<InjectHotkeySequenceOptions, 'enabled'>
92
+ resolvedTarget: Document | HTMLElement | Window
93
+ }> = []
94
+
95
+ for (let i = 0; i < resolvedSequences.length; i++) {
96
+ const def = resolvedSequences[i]!
97
+ const resolvedSequence =
98
+ typeof def.sequence === 'function' ? def.sequence() : def.sequence
99
+ const resolvedDefOptions =
100
+ typeof def.options === 'function' ? def.options() : (def.options ?? {})
101
+
102
+ const mergedOptions = {
103
+ ...defaultOptions.hotkeySequence,
104
+ ...resolvedCommonOptions,
105
+ ...resolvedDefOptions,
106
+ } as InjectHotkeySequenceOptions
107
+
108
+ const { enabled = true, ...sequenceOpts } = mergedOptions
109
+
110
+ if (resolvedSequence.length === 0) {
111
+ continue
112
+ }
113
+
114
+ const resolvedTarget =
115
+ 'target' in sequenceOpts
116
+ ? (sequenceOpts.target ?? null)
117
+ : typeof document !== 'undefined'
118
+ ? document
119
+ : null
120
+
121
+ if (!resolvedTarget) {
122
+ continue
123
+ }
124
+
125
+ const registrationKey = `${i}:${formatHotkeySequence(resolvedSequence)}`
126
+ nextKeys.add(registrationKey)
127
+ prepared.push({
128
+ registrationKey,
129
+ def,
130
+ resolvedSequence,
131
+ enabled,
132
+ sequenceOpts,
133
+ resolvedTarget,
134
+ })
135
+ }
136
+
137
+ for (const [key, record] of [...registrations.entries()]) {
138
+ if (!nextKeys.has(key)) {
139
+ if (record.handle.isActive) {
140
+ record.handle.unregister()
141
+ }
142
+ registrations.delete(key)
143
+ }
144
+ }
145
+
146
+ for (const p of prepared) {
147
+ const existing = registrations.get(p.registrationKey)
148
+ if (existing?.handle.isActive && existing.target === p.resolvedTarget) {
149
+ existing.handle.callback = p.def.callback
150
+ const { target: _target, ...optionsWithoutTarget } = p.sequenceOpts
151
+ existing.handle.setOptions({
152
+ ...optionsWithoutTarget,
153
+ enabled: p.enabled,
154
+ })
155
+ continue
156
+ }
157
+
158
+ if (existing) {
159
+ if (existing.handle.isActive) {
160
+ existing.handle.unregister()
161
+ }
162
+ registrations.delete(p.registrationKey)
163
+ }
164
+
165
+ const handle = manager.register(p.resolvedSequence, p.def.callback, {
166
+ ...p.sequenceOpts,
167
+ enabled: p.enabled,
168
+ target: p.resolvedTarget,
169
+ })
170
+ registrations.set(p.registrationKey, {
171
+ handle,
172
+ target: p.resolvedTarget,
173
+ })
174
+ }
175
+ })
176
+ }
@@ -1,9 +1,8 @@
1
1
  import { DestroyRef, effect, inject } from '@angular/core'
2
2
  import {
3
3
  detectPlatform,
4
- formatHotkey,
5
4
  getHotkeyManager,
6
- rawHotkeyToParsedHotkey,
5
+ normalizeRegisterableHotkey,
7
6
  } from '@tanstack/hotkeys'
8
7
  import { injectDefaultHotkeysOptions } from './hotkeys-provider'
9
8
  import type { InjectHotkeyOptions } from './injectHotkey'
@@ -127,12 +126,7 @@ export function injectHotkeys(
127
126
  } as InjectHotkeyOptions
128
127
 
129
128
  const platform = mergedOptions.platform ?? detectPlatform()
130
- const hotkeyString: Hotkey =
131
- typeof resolvedHotkey === 'string'
132
- ? resolvedHotkey
133
- : (formatHotkey(
134
- rawHotkeyToParsedHotkey(resolvedHotkey, platform),
135
- ) as Hotkey)
129
+ const hotkeyString = normalizeRegisterableHotkey(resolvedHotkey, platform)
136
130
 
137
131
  const resolvedTarget =
138
132
  'target' in mergedOptions