From 038a4f76880f984885e7e39dcf2f22364fa25ff1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=BE=F0=9D=92=96=F0=9D=92=99=F0=9D=92=89?= Date: Fri, 12 Sep 2025 10:24:27 +0800 Subject: [PATCH 1/8] feat(hotkeys): enhance hotkey parsing and sequence handling --- .../react-hotkeys-hook/src/lib/useHotkeys.ts | 109 +++++++++++------- 1 file changed, 69 insertions(+), 40 deletions(-) diff --git a/packages/react-hotkeys-hook/src/lib/useHotkeys.ts b/packages/react-hotkeys-hook/src/lib/useHotkeys.ts index 48efe48d..d151cdb3 100644 --- a/packages/react-hotkeys-hook/src/lib/useHotkeys.ts +++ b/packages/react-hotkeys-hook/src/lib/useHotkeys.ts @@ -1,4 +1,4 @@ -import type { HotkeyCallback, Keys, Options, OptionsOrDependencyArray } from './types' +import type { Hotkey, HotkeyCallback, Keys, Options, OptionsOrDependencyArray } from './types' import { type DependencyList, useCallback, useEffect, useLayoutEffect, useRef } from 'react' import { mapCode, parseHotkey, parseKeysHookInput, isHotkeyModifier } from './parseHotkeys' import { @@ -62,8 +62,39 @@ export default function useHotkeys( return } - let recordedKeys: string[] = [] - let sequenceTimer: NodeJS.Timeout | undefined + const hotkeys = parseKeysHookInput(_keys, memoisedOptions?.delimiter) + .reduce<{ key: string; hotkey: Hotkey }[]>((acc, key) => { + const splitKey = memoisedOptions?.splitKey ?? '+' + const sequenceSplitKey = memoisedOptions?.sequenceSplitKey ?? '>' + + if (key.includes(splitKey) && key.includes(sequenceSplitKey)) { + console.warn(`Hotkey ${key} contains both ${splitKey} and ${sequenceSplitKey} which is not supported.`) + return acc + } + + const hotkey = parseHotkey( + key, + splitKey, + sequenceSplitKey, + memoisedOptions?.useKey, + memoisedOptions?.description, + ) + + return acc.concat({ key, hotkey }) + }, []) + + const sequenceMaps = new Map( + hotkeys + .reduce<[string, { recordedKeys: string[]; sequenceTimer: NodeJS.Timeout | undefined }][]>( + (acc, { key, hotkey }) => { + if (hotkey.isSequence) { + acc.push([key, { recordedKeys: [], sequenceTimer: void 0 }]) + } + return acc + }, + [] + ) + ); const listener = (e: KeyboardEvent, isKeyUp = false) => { if (isKeyboardEventTriggeredByInput(e) && !isHotkeyEnabledOnTag(e, memoisedOptions?.enableOnFormTags)) { @@ -89,67 +120,62 @@ export default function useHotkeys( return } - parseKeysHookInput(_keys, memoisedOptions?.delimiter).forEach((key) => { - if (key.includes(memoisedOptions?.splitKey ?? '+') && key.includes(memoisedOptions?.sequenceSplitKey ?? '>')) { - console.warn( - `Hotkey ${key} contains both ${memoisedOptions?.splitKey ?? '+'} and ${memoisedOptions?.sequenceSplitKey ?? '>'} which is not supported.`, - ) - return - } + for (const { key, hotkey } of hotkeys) { + if (hotkey.isSequence) { + const sequenceMap = sequenceMaps.get(key) - const hotkey = parseHotkey( - key, - memoisedOptions?.splitKey, - memoisedOptions?.sequenceSplitKey, - memoisedOptions?.useKey, - memoisedOptions?.description, - ) + if (!sequenceMap) { + continue + } - if (hotkey.isSequence) { // Set a timeout to check post which the sequence should reset - sequenceTimer = setTimeout(() => { - recordedKeys = [] + if (sequenceMap.sequenceTimer) { + clearTimeout(sequenceMap.sequenceTimer) + } + + sequenceMap.sequenceTimer = setTimeout(() => { + sequenceMap.recordedKeys = [] }, memoisedOptions?.sequenceTimeoutMs ?? 1000) const currentKey = hotkey.useKey ? e.key : mapCode(e.code) - // TODO: Make modifiers work with sequences if (isHotkeyModifier(currentKey.toLowerCase())) { - return + continue } - recordedKeys.push(currentKey) + sequenceMap.recordedKeys.push(currentKey) - const expectedKey = hotkey.keys?.[recordedKeys.length - 1] + const expectedKey = hotkey.keys?.[sequenceMap.recordedKeys.length - 1] if (currentKey !== expectedKey) { - recordedKeys = [] - if (sequenceTimer) { - clearTimeout(sequenceTimer) + sequenceMap.recordedKeys = [] + if (sequenceMap.sequenceTimer) { + clearTimeout(sequenceMap.sequenceTimer) } - return + continue } // If the sequence is complete, trigger the callback - if (recordedKeys.length === hotkey.keys?.length) { + if (sequenceMap.recordedKeys.length === hotkey.keys?.length) { cbRef.current(e, hotkey) - if (sequenceTimer) { - clearTimeout(sequenceTimer) + if (sequenceMap.sequenceTimer) { + clearTimeout(sequenceMap.sequenceTimer) } - recordedKeys = [] + sequenceMap.recordedKeys = [] } - } else { + } + else { if ( isHotkeyMatchingKeyboardEvent(e, hotkey, memoisedOptions?.ignoreModifiers) || hotkey.keys?.includes('*') ) { if (memoisedOptions?.ignoreEventWhen?.(e)) { - return + continue } if (isKeyUp && hasTriggeredRef.current) { - return + continue } maybePreventDefault(e, hotkey, memoisedOptions?.preventDefault) @@ -157,7 +183,7 @@ export default function useHotkeys( if (!isHotkeyEnabled(e, hotkey, memoisedOptions?.enabled)) { stopPropagation(e) - return + continue } // Execute the user callback for that hotkey @@ -168,7 +194,7 @@ export default function useHotkeys( } } } - }) + } } const handleKeyDown = (event: KeyboardEvent) => { @@ -240,9 +266,12 @@ export default function useHotkeys( ) } - recordedKeys = [] - if (sequenceTimer) { - clearTimeout(sequenceTimer) + // clear the recorded keys and timeout on unmount + for (const [, sequenceMap] of sequenceMaps) { + sequenceMap.recordedKeys = [] + if (sequenceMap.sequenceTimer) { + clearTimeout(sequenceMap.sequenceTimer) + } } } }, [_keys, memoisedOptions, activeScopes]) From f4ada223330f43273c1ff8a760b4283642fbdbed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=BE=F0=9D=92=96=F0=9D=92=99=F0=9D=92=89?= Date: Fri, 12 Sep 2025 10:39:31 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=A7=AA=20test:=20unit=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/test/useHotkeys.test.tsx | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx b/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx index 53521f50..49398fd4 100644 --- a/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx +++ b/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx @@ -561,6 +561,60 @@ test('should not trigger sequence without useKey', async () => { expect(callback).toHaveBeenCalledTimes(0) }) +test('should trigger callback for each sequence in array', async () => { + const user = userEvent.setup() + const callback = vi.fn() + + renderHook(() => useHotkeys(['h>i', 'o>k'], callback)) + + await user.keyboard('h') + vi.advanceTimersByTime(200) + await user.keyboard('i') + + expect(callback).toHaveBeenCalledTimes(1) + + await user.keyboard('o') + vi.advanceTimersByTime(200) + await user.keyboard('k') + + expect(callback).toHaveBeenCalledTimes(2) +}) + +test('should trigger callback for each overlapping sequence in array', async () => { + const user = userEvent.setup() + const callback = vi.fn() + + renderHook(() => useHotkeys(['h>i', 'i>k'], callback)) + + await user.keyboard('h') + vi.advanceTimersByTime(200) + await user.keyboard('i') + expect(callback).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(200) + await user.keyboard('k') + expect(callback).toHaveBeenCalledTimes(2) +}) + +test('should trigger callback for overlapping substring sequences', async () => { + const user = userEvent.setup() + const callback = vi.fn() + + renderHook(() => useHotkeys(['h>e>l>l>o', 'l>l>o'], callback)) + + await user.keyboard('h') + vi.advanceTimersByTime(100) + await user.keyboard('e') + vi.advanceTimersByTime(100) + await user.keyboard('l') + vi.advanceTimersByTime(100) + await user.keyboard('l') + vi.advanceTimersByTime(100) + await user.keyboard('o') + + expect(callback).toHaveBeenCalledTimes(2) +}) + test('should reflect set delimiter character', async () => { const user = userEvent.setup() const callback = vi.fn() From 1fcdd2f0b14e7a59f6e179c0dcaf6ee2695e6ad8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=BE=F0=9D=92=96=F0=9D=92=99=F0=9D=92=89?= Date: Fri, 12 Sep 2025 13:55:24 +0800 Subject: [PATCH 3/8] feat(hotkeys): refactor hotkey parsing to separate combo and sequence handling --- .../react-hotkeys-hook/src/lib/useHotkeys.ts | 128 +++++++++--------- 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/packages/react-hotkeys-hook/src/lib/useHotkeys.ts b/packages/react-hotkeys-hook/src/lib/useHotkeys.ts index d151cdb3..f83718c7 100644 --- a/packages/react-hotkeys-hook/src/lib/useHotkeys.ts +++ b/packages/react-hotkeys-hook/src/lib/useHotkeys.ts @@ -62,29 +62,28 @@ export default function useHotkeys( return } - const hotkeys = parseKeysHookInput(_keys, memoisedOptions?.delimiter) - .reduce<{ key: string; hotkey: Hotkey }[]>((acc, key) => { - const splitKey = memoisedOptions?.splitKey ?? '+' - const sequenceSplitKey = memoisedOptions?.sequenceSplitKey ?? '>' - - if (key.includes(splitKey) && key.includes(sequenceSplitKey)) { - console.warn(`Hotkey ${key} contains both ${splitKey} and ${sequenceSplitKey} which is not supported.`) - return acc - } + const [comboHotkeys, sequenceHotkeys] = parseKeysHookInput(_keys, memoisedOptions?.delimiter) + .reduce<[Array<{ key: string; hotkey: Hotkey }>, Array<{ key: string; hotkey: Hotkey }>]>((acc, key) => { + const [_comboHotkey, _sequenceHotkey] = acc; const hotkey = parseHotkey( key, - splitKey, - sequenceSplitKey, + memoisedOptions?.splitKey, + memoisedOptions?.sequenceSplitKey, memoisedOptions?.useKey, memoisedOptions?.description, ) - return acc.concat({ key, hotkey }) - }, []) + if (hotkey.isSequence) { + _sequenceHotkey.push({ key, hotkey }) + } else { + _comboHotkey.push({ key, hotkey }) + } + return [_comboHotkey, _sequenceHotkey] + }, [[], []]) const sequenceMaps = new Map( - hotkeys + sequenceHotkeys .reduce<[string, { recordedKeys: string[]; sequenceTimer: NodeJS.Timeout | undefined }][]>( (acc, { key, hotkey }) => { if (hotkey.isSequence) { @@ -120,79 +119,80 @@ export default function useHotkeys( return } - for (const { key, hotkey } of hotkeys) { - if (hotkey.isSequence) { - const sequenceMap = sequenceMaps.get(key) - - if (!sequenceMap) { + // ========== HANDLE COMBO HOTKEYS ========== + for (const { hotkey } of comboHotkeys) { + if ( + isHotkeyMatchingKeyboardEvent(e, hotkey, memoisedOptions?.ignoreModifiers) || + hotkey.keys?.includes('*') + ) { + if (memoisedOptions?.ignoreEventWhen?.(e)) { continue } - // Set a timeout to check post which the sequence should reset - if (sequenceMap.sequenceTimer) { - clearTimeout(sequenceMap.sequenceTimer) + if (isKeyUp && hasTriggeredRef.current) { + continue } - sequenceMap.sequenceTimer = setTimeout(() => { - sequenceMap.recordedKeys = [] - }, memoisedOptions?.sequenceTimeoutMs ?? 1000) + maybePreventDefault(e, hotkey, memoisedOptions?.preventDefault) - const currentKey = hotkey.useKey ? e.key : mapCode(e.code) + if (!isHotkeyEnabled(e, hotkey, memoisedOptions?.enabled)) { + stopPropagation(e) - if (isHotkeyModifier(currentKey.toLowerCase())) { continue } - sequenceMap.recordedKeys.push(currentKey) + // Execute the user callback for that hotkey + cbRef.current(e, hotkey) - const expectedKey = hotkey.keys?.[sequenceMap.recordedKeys.length - 1] - if (currentKey !== expectedKey) { - sequenceMap.recordedKeys = [] - if (sequenceMap.sequenceTimer) { - clearTimeout(sequenceMap.sequenceTimer) - } - continue + if (!isKeyUp) { + hasTriggeredRef.current = true } + } + } - // If the sequence is complete, trigger the callback - if (sequenceMap.recordedKeys.length === hotkey.keys?.length) { - cbRef.current(e, hotkey) + // ========== HANDLE SEQUENCE HOTKEYS ========== + for (const { key, hotkey } of sequenceHotkeys) { + const sequenceMap = sequenceMaps.get(key) - if (sequenceMap.sequenceTimer) { - clearTimeout(sequenceMap.sequenceTimer) - } + if (!sequenceMap) { + continue + } - sequenceMap.recordedKeys = [] - } + // Set a timeout to check post which the sequence should reset + if (sequenceMap.sequenceTimer) { + clearTimeout(sequenceMap.sequenceTimer) } - else { - if ( - isHotkeyMatchingKeyboardEvent(e, hotkey, memoisedOptions?.ignoreModifiers) || - hotkey.keys?.includes('*') - ) { - if (memoisedOptions?.ignoreEventWhen?.(e)) { - continue - } - if (isKeyUp && hasTriggeredRef.current) { - continue - } + sequenceMap.sequenceTimer = setTimeout(() => { + sequenceMap.recordedKeys = [] + }, memoisedOptions?.sequenceTimeoutMs ?? 1000) + + const currentKey = hotkey.useKey ? e.key : mapCode(e.code) - maybePreventDefault(e, hotkey, memoisedOptions?.preventDefault) + if (isHotkeyModifier(currentKey.toLowerCase())) { + continue + } - if (!isHotkeyEnabled(e, hotkey, memoisedOptions?.enabled)) { - stopPropagation(e) + sequenceMap.recordedKeys.push(currentKey) - continue - } + const expectedKey = hotkey.keys?.[sequenceMap.recordedKeys.length - 1] + if (currentKey !== expectedKey) { + sequenceMap.recordedKeys = [] + if (sequenceMap.sequenceTimer) { + clearTimeout(sequenceMap.sequenceTimer) + } + continue + } - // Execute the user callback for that hotkey - cbRef.current(e, hotkey) + // If the sequence is complete, trigger the callback + if (sequenceMap.recordedKeys.length === hotkey.keys?.length) { + cbRef.current(e, hotkey) - if (!isKeyUp) { - hasTriggeredRef.current = true - } + if (sequenceMap.sequenceTimer) { + clearTimeout(sequenceMap.sequenceTimer) } + + sequenceMap.recordedKeys = [] } } } From 1fa6b9fff59d864cdde46f50dc24b0a6acbc90ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=BE=F0=9D=92=96=F0=9D=92=99=F0=9D=92=89?= Date: Fri, 12 Sep 2025 13:56:33 +0800 Subject: [PATCH 4/8] chore: update unit test --- .../src/test/useHotkeys.test.tsx | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx b/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx index 49398fd4..7c34287c 100644 --- a/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx +++ b/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx @@ -422,7 +422,7 @@ test('should not trigger when sequence is incomplete', async () => { expect(callback).not.toHaveBeenCalled() }) -test('should not trigger when sequence and combination are mixed', async () => { +test.skip('should not trigger when sequence and combination are mixed', async () => { console.warn = vi.fn() const user = userEvent.setup() const callback = vi.fn() @@ -444,6 +444,27 @@ test('should not trigger when sequence and combination are mixed', async () => { expect(callback).not.toHaveBeenCalled() }) +test('should trigger both combination and sequence hotkeys when passed as array', async () => { + const user = userEvent.setup() + const callback = vi.fn() + + renderHook(() => useHotkeys(['ctrl+a', 'y>e>e>t'], callback)) + + await user.keyboard('{Control>}A{/Control}') + expect(callback).toHaveBeenCalledTimes(1) + expect(callback.mock.calls[0][1].isSequence).toBe(false) + + await user.keyboard('y') + vi.advanceTimersByTime(200) + await user.keyboard('e') + vi.advanceTimersByTime(200) + await user.keyboard('e') + vi.advanceTimersByTime(200) + await user.keyboard('t') + expect(callback).toHaveBeenCalledTimes(2) + expect(callback.mock.calls[1][1].isSequence).toBe(true) +}) + test('should work with sequences and other hotkeys together', async () => { const user = userEvent.setup() const callback = vi.fn() From a4a1f9adfae80cbc69ab176ab5fe60e8769b7a30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=BE=F0=9D=92=96=F0=9D=92=99=F0=9D=92=89?= Date: Fri, 12 Sep 2025 14:24:06 +0800 Subject: [PATCH 5/8] feat(hotkeys): implement sequenceEndsWith function and refactor sequence handling in useHotkeys --- .../src/lib/parseHotkeys.ts | 15 ++ .../react-hotkeys-hook/src/lib/useHotkeys.ts | 143 ++++++------------ 2 files changed, 58 insertions(+), 100 deletions(-) diff --git a/packages/react-hotkeys-hook/src/lib/parseHotkeys.ts b/packages/react-hotkeys-hook/src/lib/parseHotkeys.ts index 369e89ae..e9c4a9ec 100644 --- a/packages/react-hotkeys-hook/src/lib/parseHotkeys.ts +++ b/packages/react-hotkeys-hook/src/lib/parseHotkeys.ts @@ -33,6 +33,21 @@ export function parseKeysHookInput(keys: string, delimiter = ','): string[] { return keys.toLowerCase().split(delimiter) } +/** + * Check if sequence ends with a given sub-sequence + * @param sequence full sequence e.g. ['h', 'e', 'l', 'l', 'o'] + * @param subSequence sub-sequence to check e.g. ['l', 'l', 'o'] + */ +export const sequenceEndsWith = (sequence: string[], subSequence: string[]): boolean => { + if (sequence.length < subSequence.length) { + return false; + } + + const endOfSequence = sequence.slice(-subSequence.length); + + return subSequence.every((key, index) => key === endOfSequence[index]); +}; + export function parseHotkey( hotkey: string, splitKey = '+', diff --git a/packages/react-hotkeys-hook/src/lib/useHotkeys.ts b/packages/react-hotkeys-hook/src/lib/useHotkeys.ts index f83718c7..d26679aa 100644 --- a/packages/react-hotkeys-hook/src/lib/useHotkeys.ts +++ b/packages/react-hotkeys-hook/src/lib/useHotkeys.ts @@ -1,6 +1,6 @@ -import type { Hotkey, HotkeyCallback, Keys, Options, OptionsOrDependencyArray } from './types' +import type { HotkeyCallback, Keys, Options, OptionsOrDependencyArray } from './types' import { type DependencyList, useCallback, useEffect, useLayoutEffect, useRef } from 'react' -import { mapCode, parseHotkey, parseKeysHookInput, isHotkeyModifier } from './parseHotkeys' +import { mapCode, parseHotkey, parseKeysHookInput, isHotkeyModifier, sequenceEndsWith } from './parseHotkeys' import { isHotkeyEnabled, isHotkeyEnabledOnTag, @@ -62,38 +62,20 @@ export default function useHotkeys( return } - const [comboHotkeys, sequenceHotkeys] = parseKeysHookInput(_keys, memoisedOptions?.delimiter) - .reduce<[Array<{ key: string; hotkey: Hotkey }>, Array<{ key: string; hotkey: Hotkey }>]>((acc, key) => { - const [_comboHotkey, _sequenceHotkey] = acc; - - const hotkey = parseHotkey( - key, - memoisedOptions?.splitKey, - memoisedOptions?.sequenceSplitKey, - memoisedOptions?.useKey, - memoisedOptions?.description, - ) - - if (hotkey.isSequence) { - _sequenceHotkey.push({ key, hotkey }) - } else { - _comboHotkey.push({ key, hotkey }) - } - return [_comboHotkey, _sequenceHotkey] - }, [[], []]) - - const sequenceMaps = new Map( - sequenceHotkeys - .reduce<[string, { recordedKeys: string[]; sequenceTimer: NodeJS.Timeout | undefined }][]>( - (acc, { key, hotkey }) => { - if (hotkey.isSequence) { - acc.push([key, { recordedKeys: [], sequenceTimer: void 0 }]) - } - return acc - }, - [] - ) - ); + const hotkeys = parseKeysHookInput(_keys, memoisedOptions?.delimiter) + .map((key) => parseHotkey( + key, + memoisedOptions?.splitKey, + memoisedOptions?.sequenceSplitKey, + memoisedOptions?.useKey, + memoisedOptions?.description, + )); + + const comboHotkeys = hotkeys.filter(hotkey => !hotkey.isSequence); + const sequenceHotkeys = hotkeys.filter(hotkey => hotkey.isSequence); + + let sequenceRecordedKeys: string[] = []; + let sequenceTimer: NodeJS.Timeout | undefined = undefined; const listener = (e: KeyboardEvent, isKeyUp = false) => { if (isKeyboardEventTriggeredByInput(e) && !isHotkeyEnabledOnTag(e, memoisedOptions?.enableOnFormTags)) { @@ -120,7 +102,7 @@ export default function useHotkeys( } // ========== HANDLE COMBO HOTKEYS ========== - for (const { hotkey } of comboHotkeys) { + for (const hotkey of comboHotkeys) { if ( isHotkeyMatchingKeyboardEvent(e, hotkey, memoisedOptions?.ignoreModifiers) || hotkey.keys?.includes('*') @@ -151,48 +133,32 @@ export default function useHotkeys( } // ========== HANDLE SEQUENCE HOTKEYS ========== - for (const { key, hotkey } of sequenceHotkeys) { - const sequenceMap = sequenceMaps.get(key) - - if (!sequenceMap) { - continue - } - - // Set a timeout to check post which the sequence should reset - if (sequenceMap.sequenceTimer) { - clearTimeout(sequenceMap.sequenceTimer) - } - - sequenceMap.sequenceTimer = setTimeout(() => { - sequenceMap.recordedKeys = [] - }, memoisedOptions?.sequenceTimeoutMs ?? 1000) - - const currentKey = hotkey.useKey ? e.key : mapCode(e.code) - - if (isHotkeyModifier(currentKey.toLowerCase())) { - continue - } + if (sequenceHotkeys.length > 0) { + const currentKey = memoisedOptions?.useKey + ? e.key + : mapCode(e.code) + + if (!isHotkeyModifier(currentKey.toLowerCase())) { + // clear the previous timer + if (sequenceTimer) { + clearTimeout(sequenceTimer); + } - sequenceMap.recordedKeys.push(currentKey) + sequenceTimer = setTimeout(() => { + sequenceRecordedKeys = []; + }, memoisedOptions?.sequenceTimeoutMs ?? 1000); - const expectedKey = hotkey.keys?.[sequenceMap.recordedKeys.length - 1] - if (currentKey !== expectedKey) { - sequenceMap.recordedKeys = [] - if (sequenceMap.sequenceTimer) { - clearTimeout(sequenceMap.sequenceTimer) - } - continue - } + sequenceRecordedKeys.push(currentKey); - // If the sequence is complete, trigger the callback - if (sequenceMap.recordedKeys.length === hotkey.keys?.length) { - cbRef.current(e, hotkey) + for (const hotkey of sequenceHotkeys) { + if (!hotkey.keys) { + continue + } - if (sequenceMap.sequenceTimer) { - clearTimeout(sequenceMap.sequenceTimer) + if (sequenceEndsWith(sequenceRecordedKeys, [...hotkey.keys])) { + cbRef.current(e, hotkey); + } } - - sequenceMap.recordedKeys = [] } } } @@ -233,17 +199,7 @@ export default function useHotkeys( domNode.addEventListener('keydown', handleKeyDown, _options?.eventListenerOptions) if (proxy) { - parseKeysHookInput(_keys, memoisedOptions?.delimiter).forEach((key) => - proxy.addHotkey( - parseHotkey( - key, - memoisedOptions?.splitKey, - memoisedOptions?.sequenceSplitKey, - memoisedOptions?.useKey, - memoisedOptions?.description, - ), - ), - ) + hotkeys.forEach(proxy.addHotkey) } return () => { @@ -253,25 +209,12 @@ export default function useHotkeys( domNode.removeEventListener('keydown', handleKeyDown, _options?.eventListenerOptions) if (proxy) { - parseKeysHookInput(_keys, memoisedOptions?.delimiter).forEach((key) => - proxy.removeHotkey( - parseHotkey( - key, - memoisedOptions?.splitKey, - memoisedOptions?.sequenceSplitKey, - memoisedOptions?.useKey, - memoisedOptions?.description, - ), - ), - ) + hotkeys.forEach(proxy.removeHotkey); } - // clear the recorded keys and timeout on unmount - for (const [, sequenceMap] of sequenceMaps) { - sequenceMap.recordedKeys = [] - if (sequenceMap.sequenceTimer) { - clearTimeout(sequenceMap.sequenceTimer) - } + sequenceRecordedKeys = []; + if (sequenceTimer) { + clearTimeout(sequenceTimer); } } }, [_keys, memoisedOptions, activeScopes]) From 17e811892ec698af98a3b15b97e71a7f0175ca09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=BE=F0=9D=92=96=F0=9D=92=99=F0=9D=92=89?= Date: Fri, 12 Sep 2025 14:39:24 +0800 Subject: [PATCH 6/8] feat(hotkeys): refactor hotkey parsing to handle combo and sequence hotkeys separately --- .../react-hotkeys-hook/src/lib/useHotkeys.ts | 43 +++++++++++++------ .../src/test/useHotkeys.test.tsx | 2 +- 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/packages/react-hotkeys-hook/src/lib/useHotkeys.ts b/packages/react-hotkeys-hook/src/lib/useHotkeys.ts index d26679aa..3063c4d0 100644 --- a/packages/react-hotkeys-hook/src/lib/useHotkeys.ts +++ b/packages/react-hotkeys-hook/src/lib/useHotkeys.ts @@ -1,4 +1,4 @@ -import type { HotkeyCallback, Keys, Options, OptionsOrDependencyArray } from './types' +import type { Hotkey, HotkeyCallback, Keys, Options, OptionsOrDependencyArray } from './types' import { type DependencyList, useCallback, useEffect, useLayoutEffect, useRef } from 'react' import { mapCode, parseHotkey, parseKeysHookInput, isHotkeyModifier, sequenceEndsWith } from './parseHotkeys' import { @@ -62,21 +62,38 @@ export default function useHotkeys( return } - const hotkeys = parseKeysHookInput(_keys, memoisedOptions?.delimiter) - .map((key) => parseHotkey( - key, - memoisedOptions?.splitKey, - memoisedOptions?.sequenceSplitKey, - memoisedOptions?.useKey, - memoisedOptions?.description, - )); + const [comboHotkeys, sequenceHotkeys] = parseKeysHookInput(_keys, memoisedOptions?.delimiter) + .reduce<[Hotkey[], Hotkey[]]>((acc, key) => { + const [_comboHotkey, _sequenceHotkey] = acc; - const comboHotkeys = hotkeys.filter(hotkey => !hotkey.isSequence); - const sequenceHotkeys = hotkeys.filter(hotkey => hotkey.isSequence); + const splitKey = memoisedOptions?.splitKey ?? '+' + const sequenceSplitKey = memoisedOptions?.sequenceSplitKey ?? '>' + + if (key.includes(splitKey) && key.includes(sequenceSplitKey)) { + console.warn(`Hotkey ${key} contains both ${splitKey} and ${sequenceSplitKey} which is not supported.`); + } + + const hotkey = parseHotkey( + key, + splitKey, + sequenceSplitKey, + memoisedOptions?.useKey, + memoisedOptions?.description, + ) + + if (hotkey.isSequence) { + _sequenceHotkey.push(hotkey) + } else { + _comboHotkey.push(hotkey) + } + return [_comboHotkey, _sequenceHotkey] + }, [[], []]); let sequenceRecordedKeys: string[] = []; let sequenceTimer: NodeJS.Timeout | undefined = undefined; + const combinedHotkeys = [...comboHotkeys, ...sequenceHotkeys] + const listener = (e: KeyboardEvent, isKeyUp = false) => { if (isKeyboardEventTriggeredByInput(e) && !isHotkeyEnabledOnTag(e, memoisedOptions?.enableOnFormTags)) { return @@ -199,7 +216,7 @@ export default function useHotkeys( domNode.addEventListener('keydown', handleKeyDown, _options?.eventListenerOptions) if (proxy) { - hotkeys.forEach(proxy.addHotkey) + combinedHotkeys.forEach(proxy.addHotkey) } return () => { @@ -209,7 +226,7 @@ export default function useHotkeys( domNode.removeEventListener('keydown', handleKeyDown, _options?.eventListenerOptions) if (proxy) { - hotkeys.forEach(proxy.removeHotkey); + combinedHotkeys.forEach(proxy.removeHotkey); } sequenceRecordedKeys = []; diff --git a/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx b/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx index 7c34287c..7576b0b8 100644 --- a/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx +++ b/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx @@ -422,7 +422,7 @@ test('should not trigger when sequence is incomplete', async () => { expect(callback).not.toHaveBeenCalled() }) -test.skip('should not trigger when sequence and combination are mixed', async () => { +test('should not trigger when sequence and combination are mixed', async () => { console.warn = vi.fn() const user = userEvent.setup() const callback = vi.fn() From f746d7dfb8fe8e30b6bbfac2a14f04df01f0df87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=BE=F0=9D=92=96=F0=9D=92=99=F0=9D=92=89?= Date: Fri, 12 Sep 2025 14:43:50 +0800 Subject: [PATCH 7/8] chore: code style --- .../src/lib/parseHotkeys.ts | 8 ++--- .../react-hotkeys-hook/src/lib/useHotkeys.ts | 30 +++++++++---------- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/packages/react-hotkeys-hook/src/lib/parseHotkeys.ts b/packages/react-hotkeys-hook/src/lib/parseHotkeys.ts index e9c4a9ec..10fab284 100644 --- a/packages/react-hotkeys-hook/src/lib/parseHotkeys.ts +++ b/packages/react-hotkeys-hook/src/lib/parseHotkeys.ts @@ -40,13 +40,13 @@ export function parseKeysHookInput(keys: string, delimiter = ','): string[] { */ export const sequenceEndsWith = (sequence: string[], subSequence: string[]): boolean => { if (sequence.length < subSequence.length) { - return false; + return false } - const endOfSequence = sequence.slice(-subSequence.length); + const endOfSequence = sequence.slice(-subSequence.length) - return subSequence.every((key, index) => key === endOfSequence[index]); -}; + return subSequence.every((key, index) => key === endOfSequence[index]) +} export function parseHotkey( hotkey: string, diff --git a/packages/react-hotkeys-hook/src/lib/useHotkeys.ts b/packages/react-hotkeys-hook/src/lib/useHotkeys.ts index 3063c4d0..3711e30c 100644 --- a/packages/react-hotkeys-hook/src/lib/useHotkeys.ts +++ b/packages/react-hotkeys-hook/src/lib/useHotkeys.ts @@ -62,15 +62,18 @@ export default function useHotkeys( return } + let recordedKeys: string[] = [] + let sequenceTimer: NodeJS.Timeout | undefined + const [comboHotkeys, sequenceHotkeys] = parseKeysHookInput(_keys, memoisedOptions?.delimiter) .reduce<[Hotkey[], Hotkey[]]>((acc, key) => { - const [_comboHotkey, _sequenceHotkey] = acc; + const [_comboHotkey, _sequenceHotkey] = acc const splitKey = memoisedOptions?.splitKey ?? '+' const sequenceSplitKey = memoisedOptions?.sequenceSplitKey ?? '>' if (key.includes(splitKey) && key.includes(sequenceSplitKey)) { - console.warn(`Hotkey ${key} contains both ${splitKey} and ${sequenceSplitKey} which is not supported.`); + console.warn(`Hotkey ${key} contains both ${splitKey} and ${sequenceSplitKey} which is not supported.`) } const hotkey = parseHotkey( @@ -87,10 +90,7 @@ export default function useHotkeys( _comboHotkey.push(hotkey) } return [_comboHotkey, _sequenceHotkey] - }, [[], []]); - - let sequenceRecordedKeys: string[] = []; - let sequenceTimer: NodeJS.Timeout | undefined = undefined; + }, [[], []]) const combinedHotkeys = [...comboHotkeys, ...sequenceHotkeys] @@ -158,22 +158,22 @@ export default function useHotkeys( if (!isHotkeyModifier(currentKey.toLowerCase())) { // clear the previous timer if (sequenceTimer) { - clearTimeout(sequenceTimer); + clearTimeout(sequenceTimer) } sequenceTimer = setTimeout(() => { - sequenceRecordedKeys = []; - }, memoisedOptions?.sequenceTimeoutMs ?? 1000); + recordedKeys = [] + }, memoisedOptions?.sequenceTimeoutMs ?? 1000) - sequenceRecordedKeys.push(currentKey); + recordedKeys.push(currentKey) for (const hotkey of sequenceHotkeys) { if (!hotkey.keys) { continue } - if (sequenceEndsWith(sequenceRecordedKeys, [...hotkey.keys])) { - cbRef.current(e, hotkey); + if (sequenceEndsWith(recordedKeys, [...hotkey.keys])) { + cbRef.current(e, hotkey) } } } @@ -226,12 +226,12 @@ export default function useHotkeys( domNode.removeEventListener('keydown', handleKeyDown, _options?.eventListenerOptions) if (proxy) { - combinedHotkeys.forEach(proxy.removeHotkey); + combinedHotkeys.forEach(proxy.removeHotkey) } - sequenceRecordedKeys = []; + recordedKeys = [] if (sequenceTimer) { - clearTimeout(sequenceTimer); + clearTimeout(sequenceTimer) } } }, [_keys, memoisedOptions, activeScopes]) From fcf4e62979bdfa5199f9abe86bfa137a082a6e77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9D=91=BE=F0=9D=92=96=F0=9D=92=99=F0=9D=92=89?= Date: Fri, 12 Sep 2025 14:50:41 +0800 Subject: [PATCH 8/8] update unit test --- packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx b/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx index 7576b0b8..79752eca 100644 --- a/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx +++ b/packages/react-hotkeys-hook/src/test/useHotkeys.test.tsx @@ -634,6 +634,14 @@ test('should trigger callback for overlapping substring sequences', async () => await user.keyboard('o') expect(callback).toHaveBeenCalledTimes(2) + + vi.advanceTimersByTime(100) + await user.keyboard('l') + vi.advanceTimersByTime(100) + await user.keyboard('l') + vi.advanceTimersByTime(100) + await user.keyboard('o') + expect(callback).toHaveBeenCalledTimes(3) }) test('should reflect set delimiter character', async () => {