diff --git a/README.md b/README.md index 5fda607..1b1fca8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@
+ {JSON.stringify( + hotkeys.map(({ ref: element, ...rest }) => rest), + null, + 2 + )} ++
+ {JSON.stringify( + hotkeys.map(({ ref: element, disabled, ...rest }) => rest), + null, + 2 + )} ++
{JSON.stringify( @@ -83,24 +117,19 @@ export const Multiple = () => { }; const NestedComponent = () => { - useHotkeys([ - { name: 'Test 2', keys: 'mod+b', callback: () => alert('baller') }, - ]); + useHotkeys([{ name: 'Child', keys: ['META+B'], callback: () => alert('META + B (child)') }]); - returnPress MOD + b
; + returnPress META + B
; }; export const Nested = () => { - const hotkeys = useHotkeys([ - { name: 'Test', keys: 'SHIFT+A', callback: () => alert('holla') }, - ]); + const hotkeys = useHotkeys([{ name: 'Parent', keys: ['SHIFT+A'], callback: () => alert('SHIFT + A (parent)') }]); return (Press SHIFT + A
-
{JSON.stringify( hotkeys.map(({ ref: element, ...rest }) => rest), @@ -119,17 +148,14 @@ export const Focus = () => { const hotkeys = useHotkeys([ { - name: 'Test 3', - keys: 'SHIFT+C', + name: 'Focus A', + keys: ['SHIFT+C'], callback: () => alert(`first, counter: ${counter}`), ref: elmRef, }, - ]); - - useHotkeys([ { - name: 'Test 3', - keys: 'SHIFT+C', + name: 'Focus b', + keys: ['SHIFT+C'], callback: () => alert(`second, counter: ${counter}`), ref: elmRef2, }, @@ -137,17 +163,11 @@ export const Focus = () => { return (-); }; const ModalComponent = ({ onClose }: { onClose: () => void }) => { - const hotkeys = useHotkeys([ + useHotkeys([ { - name: 'Modal shortcut', - keys: 'g', + name: 'ModalComponent', + keys: ['G'], callback: () => alert('This shortcut is bound through the modal'), }, ]); @@ -297,8 +305,6 @@ const ModalComponent = ({ onClose }: { onClose: () => void }) => {
Press g
-
-{JSON.stringify(hotkeys, null, 2)}
{JSON.stringify( hotkeys.map(({ ref: element, ...rest }) => rest), diff --git a/src/index.ts b/src/index.ts index d1bf4c7..0031f23 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,2 @@ export * from './useHotkeys'; -export * from './useHotkeyState'; export * from './utils'; diff --git a/src/useHotkeyState.ts b/src/useHotkeyState.ts deleted file mode 100644 index 9443409..0000000 --- a/src/useHotkeyState.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { RefObject, useEffect, useState } from 'react'; -import Mousetrap, { - ExtendedKeyboardEvent, - MousetrapInstance, - MousetrapStatic, -} from 'mousetrap'; - -export interface HotkeyShortcuts { - name: string; - category?: string; - description?: string; - keys: string | string[]; - ref?: RefObject; - hidden?: boolean; - disabled?: boolean; - callback: (e: ExtendedKeyboardEvent, combo: string) => void; - action?: 'keypress' | 'keydown' | 'keyup'; -} - -/** - * Creates a global state singleton. - */ -const createStateHook = () => { - const mousetraps = new Map< - HTMLElement | undefined, - MousetrapStatic | MousetrapInstance - >(); - let keys: HotkeyShortcuts[] = []; - - const bindKeys = (nextKeys: HotkeyShortcuts[]) => { - nextKeys.forEach((k) => { - if (k.disabled) { - return; - } - - if (k.ref) { - if (!k.ref.current) { - // exit early if ref is provided but null - // we do not want to attach global event handlers in this case - return; - } - - const element = k.ref.current; - - if (!mousetraps.has(element)) { - mousetraps.set(element, new Mousetrap(element)); - } - - mousetraps.get(element)!.bind(k.keys, k.callback, k.action); - } else { - if (!mousetraps.get(undefined)) { - mousetraps.set(undefined, Mousetrap); - } - - mousetraps.get(undefined)!.bind(k.keys, k.callback, k.action); - } - }); - }; - - const addKeys = (nextKeys: HotkeyShortcuts[]) => { - keys = [...keys, ...nextKeys]; - - bindKeys(nextKeys); - }; - - const removeKeys = (nextKeys: HotkeyShortcuts[]) => { - // remove keys from the array - keys = keys.filter((k) => !nextKeys.includes(k)); - - // unbind mousetrap events - nextKeys.forEach((k) => { - if (k.ref) { - if (!k.ref.current) { - return; - } - - mousetraps.get(k.ref.current)?.unbind(k.keys, k.action); - } else { - mousetraps.get(undefined)?.unbind(k.keys, k.action); - } - }); - - // drop mousetrap instances that have no keys attached anymore - for (const [element] of mousetraps) { - if (element === undefined) { - if (keys.some((k) => k.ref === undefined)) { - continue; - } - } else { - if (keys.some((k) => k.ref?.current === element)) { - continue; - } - } - - mousetraps.delete(element); - } - - // re-bind keys to restore listeners that were overwritten by the ones we just removed - bindKeys(keys); - }; - - return () => { - const [state, setState] = useState ([]); - - useEffect(() => { - setState(keys); - }, []); - - return [state, addKeys, removeKeys] as const; - }; -}; - -export const useHotkeyState = createStateHook(); diff --git a/src/useHotkeys.ts b/src/useHotkeys.ts index 47c1cc5..2635b83 100644 --- a/src/useHotkeys.ts +++ b/src/useHotkeys.ts @@ -1,29 +1,140 @@ -import { useLayoutEffect, useMemo } from 'react'; -import { HotkeyShortcuts, useHotkeyState } from './useHotkeyState'; +import { RefObject, useEffect, useLayoutEffect, useState } from 'react'; +import keys, { Callback, Handler, Key } from 'ctrl-keys'; -export const useHotkeys = (shortcuts?: HotkeyShortcuts[]) => { - const [keys, addKeys, removeKeys] = useHotkeyState(); +type Keys = [Key] | [Key, Key] | [Key, Key, Key] | [Key, Key, Key, Key]; +export interface HotkeyShortcuts { + name: string; + keys: string | string[]; + ref?: RefObject ; + disabled?: boolean; + callback: Callback; + action?: 'keypress' | 'keydown' | 'keyup'; + description?: string; + category?: string; + hidden?: boolean; +} + +let isGlobalListenersBinded = false; + +const keypressGlobalHandler = keys(); +const keyupGlobalHandler = keys(); +const keydownGlobalHandler = keys(); + +/** + * Map of specific elements handlers + */ +const handlers = new Map (); +let hotkeys: HotkeyShortcuts[] = []; + +const extractKeys = (keys: string | string[]): Keys => { + return (Array.isArray(keys) ? keys.map((key) => key.toLowerCase()) : [keys.toLowerCase()]) as Keys; +}; + +const focusInputWrapper = (callback: Callback) => (event: any) => { + const target = event.target; + + const isInput = target.tagName === 'INPUT' && !['checkbox', 'radio', 'range', 'button', 'file', 'reset', 'submit', 'color'].includes(target.type); + if (target.isContentEditable || ((isInput || target.tagName === 'TEXTAREA' || target.tagName === 'SELECT') && !target.readOnly)) { + return; + } + + return callback(event); +}; + +const registerGlobalShortcut = (shortcut: HotkeyShortcuts) => { + if (!shortcut.action || shortcut.action === 'keypress') { + keypressGlobalHandler.add(...extractKeys(shortcut.keys), shortcut.callback); + } + if (shortcut.action === 'keyup') { + keyupGlobalHandler.add(...extractKeys(shortcut.keys), shortcut.callback); + } + if (shortcut.action === 'keydown') { + keydownGlobalHandler.add(...extractKeys(shortcut.keys), shortcut.callback); + } +}; + +const removeGlobalShortcut = (shortcut: HotkeyShortcuts) => { + if (!shortcut.action || shortcut.action === 'keypress') { + keypressGlobalHandler.remove(...extractKeys(shortcut.keys), shortcut.callback); + } + if (shortcut.action === 'keyup') { + keyupGlobalHandler.remove(...extractKeys(shortcut.keys), shortcut.callback); + } + if (shortcut.action === 'keydown') { + keydownGlobalHandler.remove(...extractKeys(shortcut.keys), shortcut.callback); + } +}; + +const registerElementShortcut = (shortcut: HotkeyShortcuts) => { + const handler = keys(); + + handler.add(...extractKeys(shortcut.keys), shortcut.callback); + + shortcut.ref?.current?.addEventListener(shortcut.action ?? 'keypress', handler.handle); + + handlers.set(shortcut.ref?.current as HTMLElement, handler); +}; + +const removeElementShortcut = (shortcut: HotkeyShortcuts) => { + if (shortcut.ref?.current && !shortcut.disabled) { + const handler = handlers.get(shortcut.ref?.current) as Handler; + + handler.remove(...extractKeys(shortcut.keys), shortcut.callback); + + shortcut.ref?.current?.removeEventListener(shortcut.action ?? 'keypress', handler.handle); + } +}; + +export const useHotkeys = (shortcuts: HotkeyShortcuts[] = []) => { + const [registered, setRegistered] = useState ([]); + /** + * Register global listeners for "keypress", "keyup" and "keydown" events + */ useLayoutEffect(() => { - if (shortcuts) { - addKeys(shortcuts); + if (!isGlobalListenersBinded && window !== undefined) { + window.addEventListener('keypress', keypressGlobalHandler.handle); + window.addEventListener('keyup', keyupGlobalHandler.handle); + window.addEventListener('keydown', keydownGlobalHandler.handle); + + isGlobalListenersBinded = true; } + }, []); - return () => { - if (shortcuts) { - removeKeys(shortcuts); + /** + * Register shortcuts + */ + useLayoutEffect(() => { + shortcuts.map((shortcut) => { + if (shortcut.disabled) { + return; + } + + // Wrap callback in input focus wrapper to avoid trigger shortcut for input + shortcut.callback = focusInputWrapper(shortcut.callback); + + if (shortcut.ref?.current) { + registerElementShortcut(shortcut); + hotkeys = [...hotkeys, shortcut]; + } else if (!shortcut.ref) { + registerGlobalShortcut(shortcut); + hotkeys = [...hotkeys, shortcut]; } + }); + + // Remove all shortcuts on destroy + return () => { + shortcuts.map((shortcut) => { + removeElementShortcut(shortcut); + removeGlobalShortcut(shortcut); + hotkeys = hotkeys.filter((hotkey) => shortcut !== hotkey); + }); }; - }, [addKeys, removeKeys, shortcuts]); - - return useMemo( - () => - keys.reduce ((agg, cur) => { - if (!agg.find((a) => a.keys === cur.keys && a.ref && cur.ref)) { - agg.push(cur); - } - return agg; - }, []), - [keys] - ); + }, [shortcuts]); + + useEffect(() => { + setRegistered(hotkeys); + }, []); + + return registered; }; diff --git a/yarn.lock b/yarn.lock index 3796bb1..fc2fb06 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3148,11 +3148,6 @@ resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-5.1.2.tgz#07508b45797cb81ec3f273011b054cd0755eddca" integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== -"@types/mousetrap@^1.6.11": - version "1.6.11" - resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.11.tgz#ef9620160fdcefcb85bccda8aaa3e84d7429376d" - integrity sha512-F0oAily9Q9QQpv9JKxKn0zMKfOo36KHCW7myYsmUyf2t0g+sBTbG3UleTPoguHdE1z3GLFr3p7/wiOio52QFjQ== - "@types/ms@*": version "0.7.31" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.31.tgz#31b7ca6407128a3d2bbc27fe2d21b345397f6197" @@ -3176,6 +3171,13 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-16.18.34.tgz#62d2099b30339dec4b1b04a14c96266459d7c8b2" integrity sha512-VmVm7gXwhkUimRfBwVI1CHhwp86jDWR04B5FGebMMyxV90SlCmFujwUHrxTD4oO+SOYU86SoxvhgeRQJY7iXFg== +"@types/node@^18.15.11": + version "18.19.21" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.19.21.tgz#f4ca1ac8ffb05ee4b89163c2d6fac9a1a59ee149" + integrity sha512-2Q2NeB6BmiTFQi4DHBzncSoq/cJMLDdhPaAoJFnFCyD9a8VPZRf7a1GAwp1Edb7ROaZc5Jz/tnZyL6EsWMRaqw== + dependencies: + undici-types "~5.26.4" + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -4485,6 +4487,13 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.3.tgz#2b410bbeba38ba9633353aff34b05d9755d065f8" integrity sha512-jPl+wbWPOWJ7SXsWyqGRk3lGecbar0Cb0OvZF/r/ZU011R4YqiRehgkQ9p4eQfo9DSDLqLL3wHwfxeJiuIsNag== +ctrl-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ctrl-keys/-/ctrl-keys-1.0.1.tgz#7d59a84a8175c11dd590371c1430e328630271a3" + integrity sha512-0cfxE8pQDSAl9pkkbksf0RlHJDcreK1b/wUuPtmDGtbVF8z39mNb6+UcXxSjrdYTAqncjShEz8KgtDX+EiRN7A== + dependencies: + just-types "^2.0.0-alpha.2" + date-time@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/date-time/-/date-time-3.1.0.tgz#0d1e934d170579f481ed8df1e2b8ff70ee845e1e" @@ -6511,6 +6520,15 @@ jsonfile@^6.0.1: array-includes "^3.1.5" object.assign "^4.1.3" +just-types@^2.0.0-alpha.2: + version "2.0.0-alpha.2" + resolved "https://registry.yarnpkg.com/just-types/-/just-types-2.0.0-alpha.2.tgz#672d4ab31b4124afd43f7f9f74b278133979403e" + integrity sha512-RvudbWEC0Eo8uijI3HUb0EVehSPxrxmWVUPzU/KPZDXoQosnDo0A9ZeynBFGOJbI1QqYvz4ZC4XXL4yAexrtvw== + dependencies: + "@types/node" "^18.15.11" + tslib "^2.5.0" + typescript "^5.0.3" + kind-of@^6.0.2: version "6.0.3" resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" @@ -7369,11 +7387,6 @@ mlly@^1.2.0: pkg-types "^1.0.3" ufo "^1.1.2" -mousetrap@^1.6.5: - version "1.6.5" - resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.5.tgz#8a766d8c272b08393d5f56074e0b5ec183485bf9" - integrity sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA== - mri@^1.1.0, mri@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mri/-/mri-1.2.0.tgz#6721480fec2a11a4889861115a48b6cbe7cc8f0b" @@ -9195,6 +9208,11 @@ tslib@^2.1.0, tslib@^2.4.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.5.2.tgz#1b6f07185c881557b0ffa84b111a0106989e8338" integrity sha512-5svOrSA2w3iGFDs1HibEVBGbDrAY82bFQ3HZ3ixB+88nsbsWQoKqDRb5UBYAUPEzbBn6dAp5gRNXglySbx1MlA== +tslib@^2.5.0: + version "2.6.2" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" + integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== + tsutils@^3.21.0: version "3.21.0" resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.21.0.tgz#b48717d394cea6c1e096983eed58e9d61715b623" @@ -9278,6 +9296,11 @@ typescript@*, typescript@~5.0.4: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b" integrity sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== +typescript@^5.0.3: + version "5.3.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" + integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== + ufo@^1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.1.2.tgz#d0d9e0fa09dece0c31ffd57bd363f030a35cfe76" @@ -9298,6 +9321,11 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + unfetch@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/unfetch/-/unfetch-4.2.0.tgz#7e21b0ef7d363d8d9af0fb929a5555f6ef97a3be"