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);
+ }
+ };
+}