Skip to content

Commit a4a1f9a

Browse files
committed
feat(hotkeys): implement sequenceEndsWith function and refactor sequence handling in useHotkeys
1 parent 1fa6b9f commit a4a1f9a

File tree

2 files changed

+58
-100
lines changed

2 files changed

+58
-100
lines changed

packages/react-hotkeys-hook/src/lib/parseHotkeys.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,21 @@ export function parseKeysHookInput(keys: string, delimiter = ','): string[] {
3333
return keys.toLowerCase().split(delimiter)
3434
}
3535

36+
/**
37+
* Check if sequence ends with a given sub-sequence
38+
* @param sequence full sequence e.g. ['h', 'e', 'l', 'l', 'o']
39+
* @param subSequence sub-sequence to check e.g. ['l', 'l', 'o']
40+
*/
41+
export const sequenceEndsWith = (sequence: string[], subSequence: string[]): boolean => {
42+
if (sequence.length < subSequence.length) {
43+
return false;
44+
}
45+
46+
const endOfSequence = sequence.slice(-subSequence.length);
47+
48+
return subSequence.every((key, index) => key === endOfSequence[index]);
49+
};
50+
3651
export function parseHotkey(
3752
hotkey: string,
3853
splitKey = '+',

packages/react-hotkeys-hook/src/lib/useHotkeys.ts

Lines changed: 43 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import type { Hotkey, HotkeyCallback, Keys, Options, OptionsOrDependencyArray } from './types'
1+
import type { HotkeyCallback, Keys, Options, OptionsOrDependencyArray } from './types'
22
import { type DependencyList, useCallback, useEffect, useLayoutEffect, useRef } from 'react'
3-
import { mapCode, parseHotkey, parseKeysHookInput, isHotkeyModifier } from './parseHotkeys'
3+
import { mapCode, parseHotkey, parseKeysHookInput, isHotkeyModifier, sequenceEndsWith } from './parseHotkeys'
44
import {
55
isHotkeyEnabled,
66
isHotkeyEnabledOnTag,
@@ -62,38 +62,20 @@ export default function useHotkeys<T extends HTMLElement>(
6262
return
6363
}
6464

65-
const [comboHotkeys, sequenceHotkeys] = parseKeysHookInput(_keys, memoisedOptions?.delimiter)
66-
.reduce<[Array<{ key: string; hotkey: Hotkey }>, Array<{ key: string; hotkey: Hotkey }>]>((acc, key) => {
67-
const [_comboHotkey, _sequenceHotkey] = acc;
68-
69-
const hotkey = parseHotkey(
70-
key,
71-
memoisedOptions?.splitKey,
72-
memoisedOptions?.sequenceSplitKey,
73-
memoisedOptions?.useKey,
74-
memoisedOptions?.description,
75-
)
76-
77-
if (hotkey.isSequence) {
78-
_sequenceHotkey.push({ key, hotkey })
79-
} else {
80-
_comboHotkey.push({ key, hotkey })
81-
}
82-
return [_comboHotkey, _sequenceHotkey]
83-
}, [[], []])
84-
85-
const sequenceMaps = new Map(
86-
sequenceHotkeys
87-
.reduce<[string, { recordedKeys: string[]; sequenceTimer: NodeJS.Timeout | undefined }][]>(
88-
(acc, { key, hotkey }) => {
89-
if (hotkey.isSequence) {
90-
acc.push([key, { recordedKeys: [], sequenceTimer: void 0 }])
91-
}
92-
return acc
93-
},
94-
[]
95-
)
96-
);
65+
const hotkeys = parseKeysHookInput(_keys, memoisedOptions?.delimiter)
66+
.map((key) => parseHotkey(
67+
key,
68+
memoisedOptions?.splitKey,
69+
memoisedOptions?.sequenceSplitKey,
70+
memoisedOptions?.useKey,
71+
memoisedOptions?.description,
72+
));
73+
74+
const comboHotkeys = hotkeys.filter(hotkey => !hotkey.isSequence);
75+
const sequenceHotkeys = hotkeys.filter(hotkey => hotkey.isSequence);
76+
77+
let sequenceRecordedKeys: string[] = [];
78+
let sequenceTimer: NodeJS.Timeout | undefined = undefined;
9779

9880
const listener = (e: KeyboardEvent, isKeyUp = false) => {
9981
if (isKeyboardEventTriggeredByInput(e) && !isHotkeyEnabledOnTag(e, memoisedOptions?.enableOnFormTags)) {
@@ -120,7 +102,7 @@ export default function useHotkeys<T extends HTMLElement>(
120102
}
121103

122104
// ========== HANDLE COMBO HOTKEYS ==========
123-
for (const { hotkey } of comboHotkeys) {
105+
for (const hotkey of comboHotkeys) {
124106
if (
125107
isHotkeyMatchingKeyboardEvent(e, hotkey, memoisedOptions?.ignoreModifiers) ||
126108
hotkey.keys?.includes('*')
@@ -151,48 +133,32 @@ export default function useHotkeys<T extends HTMLElement>(
151133
}
152134

153135
// ========== HANDLE SEQUENCE HOTKEYS ==========
154-
for (const { key, hotkey } of sequenceHotkeys) {
155-
const sequenceMap = sequenceMaps.get(key)
156-
157-
if (!sequenceMap) {
158-
continue
159-
}
160-
161-
// Set a timeout to check post which the sequence should reset
162-
if (sequenceMap.sequenceTimer) {
163-
clearTimeout(sequenceMap.sequenceTimer)
164-
}
165-
166-
sequenceMap.sequenceTimer = setTimeout(() => {
167-
sequenceMap.recordedKeys = []
168-
}, memoisedOptions?.sequenceTimeoutMs ?? 1000)
169-
170-
const currentKey = hotkey.useKey ? e.key : mapCode(e.code)
171-
172-
if (isHotkeyModifier(currentKey.toLowerCase())) {
173-
continue
174-
}
136+
if (sequenceHotkeys.length > 0) {
137+
const currentKey = memoisedOptions?.useKey
138+
? e.key
139+
: mapCode(e.code)
140+
141+
if (!isHotkeyModifier(currentKey.toLowerCase())) {
142+
// clear the previous timer
143+
if (sequenceTimer) {
144+
clearTimeout(sequenceTimer);
145+
}
175146

176-
sequenceMap.recordedKeys.push(currentKey)
147+
sequenceTimer = setTimeout(() => {
148+
sequenceRecordedKeys = [];
149+
}, memoisedOptions?.sequenceTimeoutMs ?? 1000);
177150

178-
const expectedKey = hotkey.keys?.[sequenceMap.recordedKeys.length - 1]
179-
if (currentKey !== expectedKey) {
180-
sequenceMap.recordedKeys = []
181-
if (sequenceMap.sequenceTimer) {
182-
clearTimeout(sequenceMap.sequenceTimer)
183-
}
184-
continue
185-
}
151+
sequenceRecordedKeys.push(currentKey);
186152

187-
// If the sequence is complete, trigger the callback
188-
if (sequenceMap.recordedKeys.length === hotkey.keys?.length) {
189-
cbRef.current(e, hotkey)
153+
for (const hotkey of sequenceHotkeys) {
154+
if (!hotkey.keys) {
155+
continue
156+
}
190157

191-
if (sequenceMap.sequenceTimer) {
192-
clearTimeout(sequenceMap.sequenceTimer)
158+
if (sequenceEndsWith(sequenceRecordedKeys, [...hotkey.keys])) {
159+
cbRef.current(e, hotkey);
160+
}
193161
}
194-
195-
sequenceMap.recordedKeys = []
196162
}
197163
}
198164
}
@@ -233,17 +199,7 @@ export default function useHotkeys<T extends HTMLElement>(
233199
domNode.addEventListener('keydown', handleKeyDown, _options?.eventListenerOptions)
234200

235201
if (proxy) {
236-
parseKeysHookInput(_keys, memoisedOptions?.delimiter).forEach((key) =>
237-
proxy.addHotkey(
238-
parseHotkey(
239-
key,
240-
memoisedOptions?.splitKey,
241-
memoisedOptions?.sequenceSplitKey,
242-
memoisedOptions?.useKey,
243-
memoisedOptions?.description,
244-
),
245-
),
246-
)
202+
hotkeys.forEach(proxy.addHotkey)
247203
}
248204

249205
return () => {
@@ -253,25 +209,12 @@ export default function useHotkeys<T extends HTMLElement>(
253209
domNode.removeEventListener('keydown', handleKeyDown, _options?.eventListenerOptions)
254210

255211
if (proxy) {
256-
parseKeysHookInput(_keys, memoisedOptions?.delimiter).forEach((key) =>
257-
proxy.removeHotkey(
258-
parseHotkey(
259-
key,
260-
memoisedOptions?.splitKey,
261-
memoisedOptions?.sequenceSplitKey,
262-
memoisedOptions?.useKey,
263-
memoisedOptions?.description,
264-
),
265-
),
266-
)
212+
hotkeys.forEach(proxy.removeHotkey);
267213
}
268214

269-
// clear the recorded keys and timeout on unmount
270-
for (const [, sequenceMap] of sequenceMaps) {
271-
sequenceMap.recordedKeys = []
272-
if (sequenceMap.sequenceTimer) {
273-
clearTimeout(sequenceMap.sequenceTimer)
274-
}
215+
sequenceRecordedKeys = [];
216+
if (sequenceTimer) {
217+
clearTimeout(sequenceTimer);
275218
}
276219
}
277220
}, [_keys, memoisedOptions, activeScopes])

0 commit comments

Comments
 (0)