@tanstack/hotkeys 0.2.0 → 0.3.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/sequence.ts DELETED
@@ -1,391 +0,0 @@
1
- import { detectPlatform } from './constants'
2
- import { parseHotkey } from './parse'
3
- import { matchesKeyboardEvent } from './match'
4
- import type { HotkeyOptions } from './hotkey-manager'
5
- import type {
6
- Hotkey,
7
- HotkeyCallback,
8
- HotkeyCallbackContext,
9
- ParsedHotkey,
10
- } from './hotkey'
11
-
12
- /**
13
- * Options for hotkey sequence matching.
14
- */
15
- export interface SequenceOptions extends HotkeyOptions {
16
- /** Timeout between keys in milliseconds. Default: 1000 */
17
- timeout?: number
18
- }
19
-
20
- /**
21
- * A sequence of hotkeys for Vim-style shortcuts.
22
- *
23
- * @example
24
- * ```ts
25
- * const gotoTop: HotkeySequence = ['G', 'G'] // gg
26
- * const deleteLine: HotkeySequence = ['D', 'D'] // dd
27
- * const deleteWord: HotkeySequence = ['D', 'I', 'W'] // diw
28
- * ```
29
- */
30
- export type HotkeySequence = Array<Hotkey>
31
-
32
- /**
33
- * Default timeout between keys in a sequence (in milliseconds).
34
- */
35
- const DEFAULT_SEQUENCE_TIMEOUT = 1000
36
-
37
- let sequenceIdCounter = 0
38
-
39
- /**
40
- * Generates a unique ID for sequence registrations.
41
- */
42
- function generateSequenceId(): string {
43
- return `sequence_${++sequenceIdCounter}`
44
- }
45
-
46
- /**
47
- * Internal representation of a sequence registration.
48
- */
49
- interface SequenceRegistration {
50
- id: string
51
- sequence: HotkeySequence
52
- parsedSequence: Array<ParsedHotkey>
53
- callback: HotkeyCallback
54
- options: SequenceOptions
55
- currentIndex: number
56
- lastKeyTime: number
57
- }
58
-
59
- /**
60
- * Manages keyboard sequence matching for Vim-style shortcuts.
61
- *
62
- * This class allows registering multi-key sequences like 'g g' or 'd d'
63
- * that trigger callbacks when the full sequence is pressed within
64
- * a configurable timeout.
65
- *
66
- * @example
67
- * ```ts
68
- * const matcher = SequenceManager.getInstance()
69
- *
70
- * // Register 'g g' to go to top
71
- * const unregister = matcher.register(['G', 'G'], (event, context) => {
72
- * scrollToTop()
73
- * }, { timeout: 500 })
74
- *
75
- * // Later, to unregister:
76
- * unregister()
77
- * ```
78
- */
79
- export class SequenceManager {
80
- static #instance: SequenceManager | null = null
81
-
82
- #registrations: Map<string, SequenceRegistration> = new Map()
83
- #keydownListener: ((event: KeyboardEvent) => void) | null = null
84
- #platform: 'mac' | 'windows' | 'linux'
85
-
86
- private constructor() {
87
- this.#platform = detectPlatform()
88
- }
89
-
90
- /**
91
- * Gets the singleton instance of SequenceManager.
92
- */
93
- static getInstance(): SequenceManager {
94
- if (!SequenceManager.#instance) {
95
- SequenceManager.#instance = new SequenceManager()
96
- }
97
- return SequenceManager.#instance
98
- }
99
-
100
- /**
101
- * Resets the singleton instance. Useful for testing.
102
- */
103
- static resetInstance(): void {
104
- if (SequenceManager.#instance) {
105
- SequenceManager.#instance.destroy()
106
- SequenceManager.#instance = null
107
- }
108
- }
109
-
110
- /**
111
- * Registers a hotkey sequence handler.
112
- *
113
- * @param sequence - Array of hotkey strings that form the sequence
114
- * @param callback - Function to call when the sequence is completed
115
- * @param options - Options for the sequence behavior
116
- * @returns A function to unregister the sequence
117
- */
118
- register(
119
- sequence: HotkeySequence,
120
- callback: HotkeyCallback,
121
- options: SequenceOptions = {},
122
- ): () => void {
123
- if (sequence.length === 0) {
124
- throw new Error('Sequence must contain at least one hotkey')
125
- }
126
-
127
- const id = generateSequenceId()
128
- const platform = options.platform ?? this.#platform
129
- const parsedSequence = sequence.map((hotkey) =>
130
- parseHotkey(hotkey, platform),
131
- )
132
-
133
- const registration: SequenceRegistration = {
134
- id,
135
- sequence,
136
- parsedSequence,
137
- callback,
138
- options: {
139
- timeout: DEFAULT_SEQUENCE_TIMEOUT,
140
- preventDefault: true,
141
- stopPropagation: true,
142
- enabled: true,
143
- ...options,
144
- platform,
145
- },
146
- currentIndex: 0,
147
- lastKeyTime: 0,
148
- }
149
-
150
- this.#registrations.set(id, registration)
151
- this.#ensureListener()
152
-
153
- return () => {
154
- this.#unregister(id)
155
- }
156
- }
157
-
158
- /**
159
- * Unregisters a sequence by its registration ID.
160
- */
161
- #unregister(id: string): void {
162
- this.#registrations.delete(id)
163
-
164
- if (this.#registrations.size === 0) {
165
- this.#removeListener()
166
- }
167
- }
168
-
169
- /**
170
- * Ensures the keydown listener is attached.
171
- */
172
- #ensureListener(): void {
173
- if (typeof document === 'undefined') {
174
- return // SSR safety
175
- }
176
-
177
- if (!this.#keydownListener) {
178
- this.#keydownListener = this.#handleKeyDown.bind(this)
179
- document.addEventListener('keydown', this.#keydownListener)
180
- }
181
- }
182
-
183
- /**
184
- * Removes the keydown listener.
185
- */
186
- #removeListener(): void {
187
- if (typeof document === 'undefined') {
188
- return
189
- }
190
-
191
- if (this.#keydownListener) {
192
- document.removeEventListener('keydown', this.#keydownListener)
193
- this.#keydownListener = null
194
- }
195
- }
196
-
197
- /**
198
- * Handles keydown events for sequence matching.
199
- */
200
- #handleKeyDown(event: KeyboardEvent): void {
201
- const now = Date.now()
202
-
203
- for (const registration of this.#registrations.values()) {
204
- if (!registration.options.enabled) {
205
- continue
206
- }
207
-
208
- const timeout = registration.options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT
209
-
210
- // Check if sequence has timed out
211
- if (
212
- registration.currentIndex > 0 &&
213
- now - registration.lastKeyTime > timeout
214
- ) {
215
- // Reset the sequence
216
- registration.currentIndex = 0
217
- }
218
-
219
- const expectedHotkey =
220
- registration.parsedSequence[registration.currentIndex]
221
- if (!expectedHotkey) {
222
- continue
223
- }
224
-
225
- // Check if current key matches the expected key in sequence
226
- if (
227
- matchesKeyboardEvent(
228
- event,
229
- expectedHotkey,
230
- registration.options.platform,
231
- )
232
- ) {
233
- registration.lastKeyTime = now
234
- registration.currentIndex++
235
-
236
- // Check if sequence is complete
237
- if (registration.currentIndex >= registration.parsedSequence.length) {
238
- // Sequence complete!
239
- if (registration.options.preventDefault) {
240
- event.preventDefault()
241
- }
242
- if (registration.options.stopPropagation) {
243
- event.stopPropagation()
244
- }
245
-
246
- const context: HotkeyCallbackContext = {
247
- hotkey: registration.sequence.join(' ') as Hotkey,
248
- parsedHotkey:
249
- registration.parsedSequence[
250
- registration.parsedSequence.length - 1
251
- ]!,
252
- }
253
-
254
- registration.callback(event, context)
255
-
256
- // Reset for next sequence
257
- registration.currentIndex = 0
258
- }
259
- } else if (registration.currentIndex > 0) {
260
- // Key didn't match and we were in the middle of a sequence
261
- // Check if it matches the start of the sequence (for overlapping sequences)
262
- const firstHotkey = registration.parsedSequence[0]!
263
- if (
264
- matchesKeyboardEvent(
265
- event,
266
- firstHotkey,
267
- registration.options.platform,
268
- )
269
- ) {
270
- registration.currentIndex = 1
271
- registration.lastKeyTime = now
272
- } else {
273
- // Reset the sequence
274
- registration.currentIndex = 0
275
- }
276
- }
277
- }
278
- }
279
-
280
- /**
281
- * Resets all sequence progress.
282
- */
283
- resetAll(): void {
284
- for (const registration of this.#registrations.values()) {
285
- registration.currentIndex = 0
286
- registration.lastKeyTime = 0
287
- }
288
- }
289
-
290
- /**
291
- * Gets the number of registered sequences.
292
- */
293
- getRegistrationCount(): number {
294
- return this.#registrations.size
295
- }
296
-
297
- /**
298
- * Destroys the manager and removes all listeners.
299
- */
300
- destroy(): void {
301
- this.#removeListener()
302
- this.#registrations.clear()
303
- }
304
- }
305
-
306
- /**
307
- * Gets the singleton SequenceManager instance.
308
- * Convenience function for accessing the manager.
309
- */
310
- export function getSequenceManager(): SequenceManager {
311
- return SequenceManager.getInstance()
312
- }
313
-
314
- /**
315
- * Creates a simple sequence matcher for one-off use.
316
- *
317
- * @param sequence - The sequence of hotkeys to match
318
- * @param options - Options including timeout
319
- * @returns An object with match() and reset() methods
320
- *
321
- * @example
322
- * ```ts
323
- * const matcher = createSequenceMatcher(['G', 'G'], { timeout: 500 })
324
- *
325
- * document.addEventListener('keydown', (event) => {
326
- * if (matcher.match(event)) {
327
- * console.log('Sequence matched!')
328
- * }
329
- * })
330
- * ```
331
- */
332
- export function createSequenceMatcher(
333
- sequence: HotkeySequence,
334
- options: { timeout?: number; platform?: 'mac' | 'windows' | 'linux' } = {},
335
- ): {
336
- match: (event: KeyboardEvent) => boolean
337
- reset: () => void
338
- getProgress: () => number
339
- } {
340
- const platform = options.platform ?? detectPlatform()
341
- const timeout = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT
342
- const parsedSequence = sequence.map((hotkey) => parseHotkey(hotkey, platform))
343
-
344
- let currentIndex = 0
345
- let lastKeyTime = 0
346
-
347
- return {
348
- match(event: KeyboardEvent): boolean {
349
- const now = Date.now()
350
-
351
- // Check timeout
352
- if (currentIndex > 0 && now - lastKeyTime > timeout) {
353
- currentIndex = 0
354
- }
355
-
356
- const expected = parsedSequence[currentIndex]
357
- if (!expected) {
358
- return false
359
- }
360
-
361
- if (matchesKeyboardEvent(event, expected, platform)) {
362
- lastKeyTime = now
363
- currentIndex++
364
-
365
- if (currentIndex >= parsedSequence.length) {
366
- currentIndex = 0
367
- return true
368
- }
369
- } else if (currentIndex > 0) {
370
- // Check if it matches start of sequence
371
- if (matchesKeyboardEvent(event, parsedSequence[0]!, platform)) {
372
- currentIndex = 1
373
- lastKeyTime = now
374
- } else {
375
- currentIndex = 0
376
- }
377
- }
378
-
379
- return false
380
- },
381
-
382
- reset(): void {
383
- currentIndex = 0
384
- lastKeyTime = 0
385
- },
386
-
387
- getProgress(): number {
388
- return currentIndex
389
- },
390
- }
391
- }