diff --git a/README.md b/README.md index ede20c9..da659a7 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,10 @@ A shortcut is defined using an object with at least two fields: `shortcut` and ` - an object of the form `{ code: string; ctrl?: boolean; shift?: boolean; alt?: boolean }`. Example: `{ code: 'Digit1', shift: true }`. - an array of such strings and/or objects. This allows to define aliases for the same handler. Example: `['/', { key: 'k', ctrl: true }]` - `handler` is the function that will be called when the shortcut is triggered. +- `meta` is an optional object that can be used to store any additional information about the shortcut. + It will be available in the data returned by `useKbsGlobalList()`. +- `maxFrequency` is an optional number that defines the maximum number of times the shortcut can be triggered per second. + This only affects repeated triggers of the same shortcut when a key is held down. Use [`key`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) when you want to refer to the character written by typing on the key and [`code`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code) when you want to use the physical key code (e.g. `Digit1` for the `1` key). diff --git a/src/app/Dashboard.tsx b/src/app/Dashboard.tsx index 865c7e4..24c3796 100644 --- a/src/app/Dashboard.tsx +++ b/src/app/Dashboard.tsx @@ -4,13 +4,13 @@ import Playground from './Playground'; import { useCounter } from './useCounter'; export default function Dashboard() { - const [counter, shortcuts] = useCounter(); + const [counter, shortcuts] = useCounter({ maxFrequency: 2 }); useKbsGlobal(shortcuts); return (

- Dashboard counter: {counter}. Press I or C to increment and D to - decrement. + Dashboard counter: {counter}. Press I or C to increment (max 2 per + second if held down) and D to decrement.

diff --git a/src/app/ProjectsZone2.tsx b/src/app/ProjectsZone2.tsx index 981dc4a..61fb091 100644 --- a/src/app/ProjectsZone2.tsx +++ b/src/app/ProjectsZone2.tsx @@ -4,7 +4,7 @@ import Playground from './Playground'; import { useCounter } from './useCounter'; export default function ProjectsZone2() { - const [counter, shortcuts] = useCounter(false); + const [counter, shortcuts] = useCounter({ allowC: false, maxFrequency: 2 }); const divProps = useKbs(shortcuts); return (

- Inner local counter: {counter}. Press I to increment and D to decrement. + Inner local counter: {counter}. Press I to increment (max 2 per second + if held down) and D to decrement.

diff --git a/src/app/useCounter.ts b/src/app/useCounter.ts index 8eab5dc..57bf52c 100644 --- a/src/app/useCounter.ts +++ b/src/app/useCounter.ts @@ -1,13 +1,19 @@ import { useState } from 'react'; -export function useCounter(allowC = true) { +import { KbsDefinition } from '../component'; + +export function useCounter( + options: { allowC?: boolean; maxFrequency?: number } = {}, +) { + const { allowC = true, maxFrequency } = options; const [counter, setCounter] = useState(0); - const shortcuts = [ + const shortcuts: KbsDefinition[] = [ { shortcut: allowC ? ['i', 'c'] : ['i'], handler() { setCounter((current) => current + 1); }, + maxFrequency, meta: { description: 'Increment counter' }, }, { diff --git a/src/component/KbsProvider.tsx b/src/component/KbsProvider.tsx index 8331640..40c04e1 100644 --- a/src/component/KbsProvider.tsx +++ b/src/component/KbsProvider.tsx @@ -10,8 +10,10 @@ import { import { KbsDefinition, KbsInternalShortcut } from './types'; import { cleanShortcuts } from './utils/cleanShortcuts'; import { combineShortcuts } from './utils/combineShortcuts'; -import { eventToKeyOrCode } from './utils/makeKey'; -import { shouldIgnoreElement } from './utils/shouldIgnoreElement'; +import { + getKeyDownHandler, + useLastTriggerRef, +} from './utils/getKeyDownHandler'; export interface KbsProviderProps { children: ReactNode; @@ -86,25 +88,17 @@ function kbsReducer(state: KbsState, action: KbsAction): KbsState { export function KbsProvider(props: KbsProviderProps) { const [kbsState, kbsDispatch] = useReducer(kbsReducer, initialKbsState); + const lastTrigger = useLastTriggerRef(); useEffect(() => { if (kbsState.disableCount !== 0) return; - function handleKeyDown(event: KeyboardEvent) { - if (shouldIgnoreElement(event.target as HTMLElement)) { - return; - } - const { key, code } = eventToKeyOrCode(event); - const shortcut = - kbsState.combinedShortcuts[key] ?? kbsState.combinedShortcuts[code]; - if (shortcut) { - event.stopPropagation(); - event.preventDefault(); - shortcut.handler(event); - } - } + const handleKeyDown = getKeyDownHandler( + lastTrigger, + kbsState.combinedShortcuts, + ); document.body.addEventListener('keydown', handleKeyDown); return () => document.body.removeEventListener('keydown', handleKeyDown); - }, [kbsState.disableCount, kbsState.combinedShortcuts]); + }, [kbsState.disableCount, kbsState.combinedShortcuts, lastTrigger]); return ( diff --git a/src/component/hooks/useKbs.ts b/src/component/hooks/useKbs.ts index 7cbfea5..987cbcf 100644 --- a/src/component/hooks/useKbs.ts +++ b/src/component/hooks/useKbs.ts @@ -1,30 +1,22 @@ -import { useCallback, useMemo, KeyboardEvent } from 'react'; +import { useMemo } from 'react'; import { KbsDefinition } from '../types'; import { cleanShortcuts } from '../utils/cleanShortcuts'; import { combineShortcuts } from '../utils/combineShortcuts'; -import { eventToKeyOrCode } from '../utils/makeKey'; -import { shouldIgnoreElement } from '../utils/shouldIgnoreElement'; +import { + getKeyDownHandler, + useLastTriggerRef, +} from '../utils/getKeyDownHandler'; export function useKbs(shortcuts: KbsDefinition[]) { + const lastTrigger = useLastTriggerRef(); const combinedShortcuts = useMemo( () => combineShortcuts(cleanShortcuts([shortcuts])), [shortcuts], ); - const handleKeyDown = useCallback( - function handleKeyDown(event: KeyboardEvent) { - if (shouldIgnoreElement(event.target as HTMLElement)) { - return; - } - const { key, code } = eventToKeyOrCode(event); - const shortcut = combinedShortcuts[key] ?? combinedShortcuts[code]; - if (shortcut) { - event.preventDefault(); - event.stopPropagation(); - shortcut.handler(event); - } - }, - [combinedShortcuts], + const handleKeyDown = useMemo( + () => getKeyDownHandler(lastTrigger, combinedShortcuts), + [lastTrigger, combinedShortcuts], ); return { tabIndex: 0, onKeyDown: handleKeyDown }; } diff --git a/src/component/types.ts b/src/component/types.ts index 23128cd..7d3cce2 100644 --- a/src/component/types.ts +++ b/src/component/types.ts @@ -26,9 +26,23 @@ export type KbsHandler = ( export interface KbsMetadata {} export interface KbsDefinition { + /** + * The definition of key(s) that will trigger the shortcut. + */ shortcut: string | KbsKeyDefinition | Array; + /** + * The handler function to call when the shortcut is triggered. + */ handler: KbsHandler; + /** + * Optional metadata to store with the shortcut. + */ meta?: KbsMetadata; + /** + * If specified, the shortcut will be triggered at most `maxFrequency` times + * per second when a key is held down. + */ + maxFrequency?: number; } export interface KbsShortcut { @@ -39,4 +53,5 @@ export interface KbsShortcut { export interface KbsInternalShortcut extends KbsShortcut { handler: KbsHandler; + maxFrequency: number; } diff --git a/src/component/utils/cleanShortcuts.ts b/src/component/utils/cleanShortcuts.ts index 94dd478..9b2540f 100644 --- a/src/component/utils/cleanShortcuts.ts +++ b/src/component/utils/cleanShortcuts.ts @@ -20,6 +20,7 @@ export function cleanShortcuts( aliases, handler: definition.handler, meta: definition.meta, + maxFrequency: definition.maxFrequency ?? 0, }); } } diff --git a/src/component/utils/getKeyDownHandler.ts b/src/component/utils/getKeyDownHandler.ts new file mode 100644 index 0000000..9e227f5 --- /dev/null +++ b/src/component/utils/getKeyDownHandler.ts @@ -0,0 +1,60 @@ +import { + KeyboardEvent as ReactKeyboardEvent, + MutableRefObject, + useRef, +} from 'react'; + +import { KbsInternalShortcut } from '../types'; + +import { eventToKeyOrCode } from './makeKey'; +import { shouldIgnoreElement } from './shouldIgnoreElement'; + +export interface LastTriggerData { + keyOrCode: string; + timestamp: number; +} + +export function useLastTriggerRef() { + return useRef({ keyOrCode: '', timestamp: 0 }); +} + +export function getKeyDownHandler( + lastTrigger: MutableRefObject, + combinedShortcuts: Record, +) { + return function handleKeyDown( + event: KeyboardEvent | ReactKeyboardEvent, + ) { + if (shouldIgnoreElement(event.target as HTMLElement)) { + return; + } + const { key, code } = eventToKeyOrCode(event); + let keyOrCode; + let shortcut; + if (combinedShortcuts[key]) { + shortcut = combinedShortcuts[key]; + keyOrCode = key; + } else { + shortcut = combinedShortcuts[code]; + keyOrCode = code; + } + if (shortcut) { + event.stopPropagation(); + event.preventDefault(); + + if (shortcut.maxFrequency > 0) { + const now = performance.now(); + if ( + event.repeat && + lastTrigger.current.keyOrCode === keyOrCode && + now - lastTrigger.current.timestamp < 1000 / shortcut.maxFrequency + ) { + return; + } + lastTrigger.current = { keyOrCode, timestamp: now }; + } + + shortcut.handler(event); + } + }; +}