Skip to content

Commit

Permalink
feat: add maxFrequency option (#18)
Browse files Browse the repository at this point in the history
  • Loading branch information
targos authored Oct 29, 2022
1 parent 2bb6ef7 commit f9b5d2f
Show file tree
Hide file tree
Showing 9 changed files with 113 additions and 40 deletions.
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 3 additions & 3 deletions src/app/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="p-4 space-y-8">
<p>
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.
</p>
<Playground />
</div>
Expand Down
5 changes: 3 additions & 2 deletions src/app/ProjectsZone2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,16 @@ 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 (
<div
{...divProps}
className="flex-1 m-4 bg-blue-100 focus:outline-none focus:ring-1 focus:ring-blue-600"
>
<p>
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.
</p>
<Playground />
</div>
Expand Down
10 changes: 8 additions & 2 deletions src/app/useCounter.ts
Original file line number Diff line number Diff line change
@@ -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' },
},
{
Expand Down
26 changes: 10 additions & 16 deletions src/component/KbsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
<kbsContext.Provider value={kbsState}>
Expand Down
26 changes: 9 additions & 17 deletions src/component/hooks/useKbs.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement>) {
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 };
}
15 changes: 15 additions & 0 deletions src/component/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | KbsKeyDefinition>;
/**
* 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 {
Expand All @@ -39,4 +53,5 @@ export interface KbsShortcut {

export interface KbsInternalShortcut extends KbsShortcut {
handler: KbsHandler;
maxFrequency: number;
}
1 change: 1 addition & 0 deletions src/component/utils/cleanShortcuts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export function cleanShortcuts(
aliases,
handler: definition.handler,
meta: definition.meta,
maxFrequency: definition.maxFrequency ?? 0,
});
}
}
Expand Down
60 changes: 60 additions & 0 deletions src/component/utils/getKeyDownHandler.ts
Original file line number Diff line number Diff line change
@@ -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<LastTriggerData>({ keyOrCode: '', timestamp: 0 });
}

export function getKeyDownHandler(
lastTrigger: MutableRefObject<LastTriggerData>,
combinedShortcuts: Record<string, KbsInternalShortcut>,
) {
return function handleKeyDown(
event: KeyboardEvent | ReactKeyboardEvent<HTMLDivElement>,
) {
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);
}
};
}

0 comments on commit f9b5d2f

Please sign in to comment.