@tanstack/vue-hotkeys 0.5.1 → 0.7.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,5 +1,5 @@
1
1
  import { onUnmounted, unref, watch } from 'vue'
2
- import { getSequenceManager } from '@tanstack/hotkeys'
2
+ import { formatHotkeySequence, getSequenceManager } from '@tanstack/hotkeys'
3
3
  import { useDefaultHotkeysOptions } from './HotkeysProviderContext'
4
4
  import type { MaybeRefOrGetter } from 'vue'
5
5
  import type {
@@ -82,6 +82,8 @@ export function useHotkeySequence(
82
82
  const manager = getSequenceManager()
83
83
 
84
84
  let registration: SequenceRegistrationHandle | null = null
85
+ let lastSequenceKey: string | null = null
86
+ let lastTarget: HTMLElement | Document | Window | null = null
85
87
 
86
88
  // Watch for changes to reactive dependencies
87
89
  const stopWatcher = watch(
@@ -109,23 +111,24 @@ export function useHotkeySequence(
109
111
  }
110
112
  },
111
113
  ({ resolvedSequence, mergedOptions, resolvedEnabled, resolvedTarget }) => {
112
- if (resolvedEnabled === false || resolvedSequence.length === 0) {
113
- return
114
- }
115
-
116
- // Resolve target
117
114
  const finalTarget =
118
- resolvedTarget ?? (typeof document !== 'undefined' ? document : null)
115
+ resolvedTarget === undefined
116
+ ? typeof document !== 'undefined'
117
+ ? document
118
+ : null
119
+ : resolvedTarget
119
120
 
120
- if (!finalTarget) {
121
+ if (resolvedSequence.length === 0 || !finalTarget) {
122
+ if (registration?.isActive) {
123
+ registration.unregister()
124
+ registration = null
125
+ }
126
+ lastSequenceKey = null
127
+ lastTarget = null
121
128
  return
122
129
  }
123
130
 
124
- // Unregister previous registration if it exists
125
- if (registration?.isActive) {
126
- registration.unregister()
127
- registration = null
128
- }
131
+ const sequenceKey = formatHotkeySequence(resolvedSequence)
129
132
 
130
133
  // Extract options without target (target is handled separately)
131
134
  const {
@@ -138,17 +141,33 @@ export function useHotkeySequence(
138
141
  ...(resolvedEnabled === undefined ? {} : { enabled: resolvedEnabled }),
139
142
  }
140
143
 
141
- // Register the sequence
144
+ if (
145
+ registration?.isActive &&
146
+ lastSequenceKey === sequenceKey &&
147
+ lastTarget === finalTarget
148
+ ) {
149
+ registration.callback = callback
150
+ registration.setOptions(optionsWithoutTarget)
151
+ return
152
+ }
153
+
154
+ if (registration?.isActive) {
155
+ registration.unregister()
156
+ registration = null
157
+ }
158
+
142
159
  registration = manager.register(resolvedSequence, callback, {
143
160
  ...optionsWithoutTarget,
144
161
  target: finalTarget,
145
162
  })
146
163
 
147
- // Update callback and options
148
164
  if (registration.isActive) {
149
165
  registration.callback = callback
150
166
  registration.setOptions(optionsWithoutTarget)
151
167
  }
168
+
169
+ lastSequenceKey = sequenceKey
170
+ lastTarget = finalTarget
152
171
  },
153
172
  { immediate: true },
154
173
  )
@@ -0,0 +1,75 @@
1
+ import { onUnmounted, unref, watch } from 'vue'
2
+ import { useStore } from '@tanstack/vue-store'
3
+ import { HotkeySequenceRecorder } from '@tanstack/hotkeys'
4
+ import { useDefaultHotkeysOptions } from './HotkeysProviderContext'
5
+ import type { MaybeRefOrGetter, Ref } from 'vue'
6
+ import type {
7
+ HotkeySequence,
8
+ HotkeySequenceRecorderOptions,
9
+ } from '@tanstack/hotkeys'
10
+
11
+ export interface VueHotkeySequenceRecorder {
12
+ isRecording: Ref<boolean>
13
+ steps: Ref<HotkeySequence>
14
+ recordedSequence: Ref<HotkeySequence | null>
15
+ startRecording: () => void
16
+ stopRecording: () => void
17
+ cancelRecording: () => void
18
+ commitRecording: () => void
19
+ }
20
+
21
+ /**
22
+ * Vue composable for recording multi-chord sequences (Vim-style shortcuts).
23
+ */
24
+ export function useHotkeySequenceRecorder(
25
+ options: MaybeRefOrGetter<HotkeySequenceRecorderOptions>,
26
+ ): VueHotkeySequenceRecorder {
27
+ const defaultOptions = useDefaultHotkeysOptions()
28
+ const recorder = new HotkeySequenceRecorder(
29
+ resolveHotkeySequenceRecorderOptions(options, defaultOptions),
30
+ )
31
+
32
+ const isRecording = useStore(recorder.store, (state) => state.isRecording)
33
+ const steps = useStore(recorder.store, (state) => state.steps)
34
+ const recordedSequence = useStore(
35
+ recorder.store,
36
+ (state) => state.recordedSequence,
37
+ )
38
+
39
+ const stopWatcher = watch(
40
+ () => resolveHotkeySequenceRecorderOptions(options, defaultOptions),
41
+ (resolvedOptions) => {
42
+ recorder.setOptions(resolvedOptions)
43
+ },
44
+ { immediate: true },
45
+ )
46
+
47
+ onUnmounted(() => {
48
+ stopWatcher()
49
+ recorder.destroy()
50
+ })
51
+
52
+ return {
53
+ isRecording: isRecording as Ref<boolean>,
54
+ steps: steps as Ref<HotkeySequence>,
55
+ recordedSequence: recordedSequence as Ref<HotkeySequence | null>,
56
+ startRecording: () => recorder.start(),
57
+ stopRecording: () => recorder.stop(),
58
+ cancelRecording: () => recorder.cancel(),
59
+ commitRecording: () => recorder.commit(),
60
+ }
61
+ }
62
+
63
+ function resolveHotkeySequenceRecorderOptions(
64
+ options: MaybeRefOrGetter<HotkeySequenceRecorderOptions>,
65
+ defaultOptions: ReturnType<typeof useDefaultHotkeysOptions>,
66
+ ): HotkeySequenceRecorderOptions {
67
+ return {
68
+ ...defaultOptions.hotkeySequenceRecorder,
69
+ ...resolveMaybeRefOrGetter(options),
70
+ } as HotkeySequenceRecorderOptions
71
+ }
72
+
73
+ function resolveMaybeRefOrGetter<T>(value: MaybeRefOrGetter<T>): T {
74
+ return typeof value === 'function' ? (value as () => T)() : unref(value)
75
+ }
@@ -0,0 +1,221 @@
1
+ import { onUnmounted, unref, watch } from 'vue'
2
+ import { formatHotkeySequence, getSequenceManager } from '@tanstack/hotkeys'
3
+ import { useDefaultHotkeysOptions } from './HotkeysProviderContext'
4
+ import type { UseHotkeySequenceOptions } from './useHotkeySequence'
5
+ import type {
6
+ HotkeyCallback,
7
+ HotkeySequence,
8
+ SequenceRegistrationHandle,
9
+ } from '@tanstack/hotkeys'
10
+ import type { MaybeRefOrGetter } from 'vue'
11
+
12
+ /**
13
+ * A single sequence definition for use with `useHotkeySequences`.
14
+ */
15
+ export interface UseHotkeySequenceDefinition {
16
+ /** Array of hotkey strings that form the sequence */
17
+ sequence: MaybeRefOrGetter<HotkeySequence>
18
+ /** The function to call when the sequence is completed */
19
+ callback: HotkeyCallback
20
+ /** Per-sequence options (merged on top of commonOptions) */
21
+ options?: MaybeRefOrGetter<UseHotkeySequenceOptions>
22
+ }
23
+
24
+ /**
25
+ * Vue composable for registering multiple keyboard shortcut sequences at once (Vim-style).
26
+ *
27
+ * Uses the singleton SequenceManager. Accepts a dynamic array of definitions, or a getter/ref
28
+ * that returns one, so you can register variable-length lists safely.
29
+ *
30
+ * Options are merged in this order:
31
+ * HotkeysProvider defaults < commonOptions < per-definition options
32
+ *
33
+ * Definitions with an empty `sequence` are skipped (no registration).
34
+ *
35
+ * @param definitions - Array of sequence definitions, or a getter/ref
36
+ * @param commonOptions - Shared options applied to all sequences (overridden by per-definition options)
37
+ *
38
+ * @example
39
+ * ```vue
40
+ * <script setup>
41
+ * import { useHotkeySequences } from '@tanstack/vue-hotkeys'
42
+ *
43
+ * useHotkeySequences([
44
+ * { sequence: ['G', 'G'], callback: () => scrollToTop() },
45
+ * { sequence: ['D', 'D'], callback: () => deleteLine() },
46
+ * ])
47
+ * </script>
48
+ * ```
49
+ *
50
+ * @example
51
+ * ```vue
52
+ * <script setup>
53
+ * import { computed } from 'vue'
54
+ * import { useHotkeySequences } from '@tanstack/vue-hotkeys'
55
+ *
56
+ * const items = computed(() => [...])
57
+ * useHotkeySequences(
58
+ * () => items.value.map((item) => ({
59
+ * sequence: item.chords,
60
+ * callback: item.action,
61
+ * options: { enabled: item.enabled },
62
+ * })),
63
+ * { preventDefault: true },
64
+ * )
65
+ * </script>
66
+ * ```
67
+ */
68
+ export function useHotkeySequences(
69
+ definitions: MaybeRefOrGetter<Array<UseHotkeySequenceDefinition>>,
70
+ commonOptions: MaybeRefOrGetter<UseHotkeySequenceOptions> = {},
71
+ ): void {
72
+ type RegistrationRecord = {
73
+ handle: SequenceRegistrationHandle
74
+ target: Document | HTMLElement | Window
75
+ }
76
+
77
+ const defaultOptions = useDefaultHotkeysOptions()
78
+ const manager = getSequenceManager()
79
+
80
+ const registrations = new Map<string, RegistrationRecord>()
81
+
82
+ const stopWatcher = watch(
83
+ () => {
84
+ const resolvedDefinitions = resolveMaybeRefOrGetter(definitions)
85
+ const resolvedCommonOptions = resolveMaybeRefOrGetter(commonOptions)
86
+
87
+ return resolvedDefinitions.map((def, i) => {
88
+ const resolvedSequence = resolveMaybeRefOrGetter(def.sequence)
89
+ const resolvedDefOptions = def.options
90
+ ? resolveMaybeRefOrGetter(def.options)
91
+ : {}
92
+
93
+ const mergedOptions = {
94
+ ...defaultOptions.hotkeySequence,
95
+ ...resolvedCommonOptions,
96
+ ...resolvedDefOptions,
97
+ } as UseHotkeySequenceOptions
98
+
99
+ const sequenceString = formatHotkeySequence(resolvedSequence)
100
+
101
+ const resolvedEnabled =
102
+ mergedOptions.enabled === undefined
103
+ ? undefined
104
+ : resolveMaybeRefOrGetter(mergedOptions.enabled)
105
+ const resolvedTarget =
106
+ mergedOptions.target === undefined
107
+ ? undefined
108
+ : resolveMaybeRefOrGetter(mergedOptions.target as any)
109
+
110
+ return {
111
+ index: i,
112
+ resolvedSequence,
113
+ sequenceString,
114
+ callback: def.callback,
115
+ mergedOptions,
116
+ resolvedEnabled,
117
+ resolvedTarget,
118
+ }
119
+ })
120
+ },
121
+ (resolved) => {
122
+ const nextKeys = new Set<string>()
123
+ const prepared: Array<{
124
+ registrationKey: string
125
+ entry: (typeof resolved)[number]
126
+ finalTarget: Document | HTMLElement | Window
127
+ }> = []
128
+
129
+ for (const entry of resolved) {
130
+ if (entry.resolvedSequence.length === 0) {
131
+ continue
132
+ }
133
+
134
+ const finalTarget =
135
+ entry.resolvedTarget ??
136
+ (typeof document !== 'undefined' ? document : null)
137
+
138
+ if (!finalTarget) {
139
+ continue
140
+ }
141
+
142
+ const registrationKey = `${entry.index}:${entry.sequenceString}`
143
+ nextKeys.add(registrationKey)
144
+ prepared.push({ registrationKey, entry, finalTarget })
145
+ }
146
+
147
+ for (const [key, record] of [...registrations.entries()]) {
148
+ if (!nextKeys.has(key)) {
149
+ if (record.handle.isActive) {
150
+ record.handle.unregister()
151
+ }
152
+ registrations.delete(key)
153
+ }
154
+ }
155
+
156
+ for (const { registrationKey, entry, finalTarget } of prepared) {
157
+ const existing = registrations.get(registrationKey)
158
+ if (existing?.handle.isActive && existing.target === finalTarget) {
159
+ existing.handle.callback = entry.callback
160
+ const {
161
+ target: _target,
162
+ enabled: _enabled,
163
+ ...restOptions
164
+ } = entry.mergedOptions
165
+ const optionsWithoutTarget = {
166
+ ...restOptions,
167
+ ...(entry.resolvedEnabled === undefined
168
+ ? {}
169
+ : { enabled: entry.resolvedEnabled }),
170
+ }
171
+ existing.handle.setOptions(optionsWithoutTarget)
172
+ continue
173
+ }
174
+
175
+ if (existing) {
176
+ if (existing.handle.isActive) {
177
+ existing.handle.unregister()
178
+ }
179
+ registrations.delete(registrationKey)
180
+ }
181
+
182
+ const {
183
+ target: _target,
184
+ enabled: _enabled,
185
+ ...restOptions
186
+ } = entry.mergedOptions
187
+ const optionsWithoutTarget = {
188
+ ...restOptions,
189
+ ...(entry.resolvedEnabled === undefined
190
+ ? {}
191
+ : { enabled: entry.resolvedEnabled }),
192
+ }
193
+
194
+ const handle = manager.register(
195
+ entry.resolvedSequence,
196
+ entry.callback,
197
+ {
198
+ ...optionsWithoutTarget,
199
+ target: finalTarget,
200
+ },
201
+ )
202
+ registrations.set(registrationKey, { handle, target: finalTarget })
203
+ }
204
+ },
205
+ { immediate: true },
206
+ )
207
+
208
+ onUnmounted(() => {
209
+ stopWatcher()
210
+ for (const { handle } of registrations.values()) {
211
+ if (handle.isActive) {
212
+ handle.unregister()
213
+ }
214
+ }
215
+ registrations.clear()
216
+ })
217
+ }
218
+
219
+ function resolveMaybeRefOrGetter<T>(value: MaybeRefOrGetter<T>): T {
220
+ return typeof value === 'function' ? (value as () => T)() : unref(value)
221
+ }