@taylorvance/tv-shared-runtime
Advanced tools
+13
-6
| import { type HotkeyCallback, type Keys, type Options } from 'react-hotkeys-hook'; | ||
| import type { DependencyList, MutableRefObject } from 'react'; | ||
| import type { DependencyList, RefObject } from 'react'; | ||
| export type FormTags = 'input' | 'textarea' | 'select' | 'INPUT' | 'TEXTAREA' | 'SELECT'; | ||
@@ -8,3 +8,3 @@ export interface HotkeyBinding { | ||
| } | ||
| export interface KonamiOptions { | ||
| export interface KeySequenceOptions { | ||
| document?: Document; | ||
@@ -17,7 +17,14 @@ enableOnContentEditable?: boolean; | ||
| } | ||
| export interface KeySequenceBinding { | ||
| callback: (event: KeyboardEvent) => void; | ||
| sequence: readonly string[]; | ||
| } | ||
| export type KonamiOptions = KeySequenceOptions; | ||
| declare const KONAMI_CODE_SEQUENCE: readonly ["up", "up", "down", "down", "left", "right", "left", "right", "b", "a"]; | ||
| export { KONAMI_CODE_SEQUENCE }; | ||
| export declare function useHotkeys<T extends HTMLElement>(keys: Keys, callback: HotkeyCallback, options?: Options, deps?: DependencyList): MutableRefObject<T | null>; | ||
| export declare function useHotkeys<T extends HTMLElement>(bindings: readonly HotkeyBinding[], options?: Options, deps?: DependencyList): MutableRefObject<T | null>; | ||
| export declare function useKonami<T extends HTMLElement>(callback: (event: KeyboardEvent) => void, options?: KonamiOptions, deps?: DependencyList): MutableRefObject<T | null>; | ||
| export { KONAMI_CODE_SEQUENCE, }; | ||
| export declare function useHotkeys<T extends HTMLElement>(keys: Keys, callback: HotkeyCallback, options?: Options, deps?: DependencyList): RefObject<T | null>; | ||
| export declare function useHotkeys<T extends HTMLElement>(bindings: readonly HotkeyBinding[], options?: Options, deps?: DependencyList): RefObject<T | null>; | ||
| export declare function useKonami<T extends HTMLElement>(callback: (event: KeyboardEvent) => void, options?: KonamiOptions, deps?: DependencyList): RefObject<T | null>; | ||
| export declare function useKeySequence<T extends HTMLElement>(sequence: readonly string[], callback: (event: KeyboardEvent) => void, options?: KeySequenceOptions, deps?: DependencyList): RefObject<T | null>; | ||
| export declare function useKeySequence<T extends HTMLElement>(bindings: readonly KeySequenceBinding[], options?: KeySequenceOptions, deps?: DependencyList): RefObject<T | null>; | ||
| //# sourceMappingURL=hotkeys.d.ts.map |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"hotkeys.d.ts","sourceRoot":"","sources":["../src/hotkeys.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,IAAI,EACT,KAAK,OAAO,EACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,cAAc,EAAE,gBAAgB,EAAE,MAAM,OAAO,CAAC;AAE9D,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,CAAC;AAEzF,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,cAAc,CAAC;IACzB,IAAI,EAAE,IAAI,CAAC;CACZ;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,gBAAgB,CAAC,EAAE,SAAS,QAAQ,EAAE,GAAG,OAAO,CAAC;IACjD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAgCD,QAAA,MAAM,oBAAoB,mFAWhB,CAAC;AAyHX,OAAO,EAAE,oBAAoB,EAAE,CAAC;AAEhC,wBAAgB,UAAU,CAAC,CAAC,SAAS,WAAW,EAC9C,IAAI,EAAE,IAAI,EACV,QAAQ,EAAE,cAAc,EACxB,OAAO,CAAC,EAAE,OAAO,EACjB,IAAI,CAAC,EAAE,cAAc,GACpB,gBAAgB,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;AAC9B,wBAAgB,UAAU,CAAC,CAAC,SAAS,WAAW,EAC9C,QAAQ,EAAE,SAAS,aAAa,EAAE,EAClC,OAAO,CAAC,EAAE,OAAO,EACjB,IAAI,CAAC,EAAE,cAAc,GACpB,gBAAgB,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;AAyD9B,wBAAgB,SAAS,CAAC,CAAC,SAAS,WAAW,EAC7C,QAAQ,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,EACxC,OAAO,CAAC,EAAE,aAAa,EACvB,IAAI,CAAC,EAAE,cAAc,GACpB,gBAAgB,CAAC,CAAC,GAAG,IAAI,CAAC,CAwF5B"} | ||
| {"version":3,"file":"hotkeys.d.ts","sourceRoot":"","sources":["../src/hotkeys.ts"],"names":[],"mappings":"AACA,OAAO,EAEL,KAAK,cAAc,EACnB,KAAK,IAAI,EACT,KAAK,OAAO,EACb,MAAM,oBAAoB,CAAC;AAC5B,OAAO,KAAK,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,OAAO,CAAC;AAEvD,MAAM,MAAM,QAAQ,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,UAAU,GAAG,QAAQ,CAAC;AAEzF,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,cAAc,CAAC;IACzB,IAAI,EAAE,IAAI,CAAC;CACZ;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,EAAE,QAAQ,CAAC;IACpB,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,gBAAgB,CAAC,EAAE,SAAS,QAAQ,EAAE,GAAG,OAAO,CAAC;IACjD,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,CAAC;IACzC,QAAQ,EAAE,SAAS,MAAM,EAAE,CAAC;CAC7B;AAED,MAAM,MAAM,aAAa,GAAG,kBAAkB,CAAC;AAgC/C,QAAA,MAAM,oBAAoB,mFAWhB,CAAC;AAgIX,OAAO,EACL,oBAAoB,GACrB,CAAC;AAEF,wBAAgB,UAAU,CAAC,CAAC,SAAS,WAAW,EAC9C,IAAI,EAAE,IAAI,EACV,QAAQ,EAAE,cAAc,EACxB,OAAO,CAAC,EAAE,OAAO,EACjB,IAAI,CAAC,EAAE,cAAc,GACpB,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;AACvB,wBAAgB,UAAU,CAAC,CAAC,SAAS,WAAW,EAC9C,QAAQ,EAAE,SAAS,aAAa,EAAE,EAClC,OAAO,CAAC,EAAE,OAAO,EACjB,IAAI,CAAC,EAAE,cAAc,GACpB,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;AAyDvB,wBAAgB,SAAS,CAAC,CAAC,SAAS,WAAW,EAC7C,QAAQ,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,EACxC,OAAO,CAAC,EAAE,aAAa,EACvB,IAAI,CAAC,EAAE,cAAc,GACpB,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAErB;AAED,wBAAgB,cAAc,CAAC,CAAC,SAAS,WAAW,EAClD,QAAQ,EAAE,SAAS,MAAM,EAAE,EAC3B,QAAQ,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,IAAI,EACxC,OAAO,CAAC,EAAE,kBAAkB,EAC5B,IAAI,CAAC,EAAE,cAAc,GACpB,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC;AACvB,wBAAgB,cAAc,CAAC,CAAC,SAAS,WAAW,EAClD,QAAQ,EAAE,SAAS,kBAAkB,EAAE,EACvC,OAAO,CAAC,EAAE,kBAAkB,EAC5B,IAAI,CAAC,EAAE,cAAc,GACpB,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAC"} |
+72
-29
@@ -5,3 +5,3 @@ import { useCallback, useEffect, useRef } from 'react'; | ||
| const DEFAULT_SPLIT_KEY = ','; | ||
| const KONAMI_TIMEOUT_MS = 1000; | ||
| const KEY_SEQUENCE_TIMEOUT_MS = 1000; | ||
| const hotkeyKeyAliases = { | ||
@@ -47,2 +47,4 @@ ' ': 'space', | ||
| && value.every((item) => typeof item === 'object' && item !== null && 'callback' in item && 'keys' in item)); | ||
| const isKeySequenceBindingArray = (value) => (Array.isArray(value) | ||
| && value.every((item) => typeof item === 'object' && item !== null && 'callback' in item && 'sequence' in item)); | ||
| const isDependencyList = (value) => (Array.isArray(value)); | ||
@@ -110,3 +112,3 @@ const normalizeKey = (key) => { | ||
| }; | ||
| export { KONAMI_CODE_SEQUENCE }; | ||
| export { KONAMI_CODE_SEQUENCE, }; | ||
| export function useHotkeys(keysOrBindings, callbackOrOptions, maybeOptions, maybeDeps) { | ||
@@ -146,9 +148,35 @@ if (!isBindingArray(keysOrBindings)) { | ||
| export function useKonami(callback, options, deps) { | ||
| return useKeySequence(KONAMI_CODE_SEQUENCE, callback, options, deps); | ||
| } | ||
| export function useKeySequence(sequenceOrBindings, callbackOrOptions, maybeOptions, maybeDeps) { | ||
| const scopeRef = useRef(null); | ||
| const callbackRef = useRef(callback); | ||
| const progressRef = useRef(0); | ||
| const lastKeyTimestampRef = useRef(0); | ||
| const memoizedCallback = useCallback(callback, deps ?? []); | ||
| const timeoutMs = options?.timeoutMs ?? KONAMI_TIMEOUT_MS; | ||
| callbackRef.current = deps ? memoizedCallback : callback; | ||
| const bindingsRef = useRef([]); | ||
| const stateRef = useRef([]); | ||
| const deps = (typeof callbackOrOptions === 'function' | ||
| ? maybeDeps | ||
| : isDependencyList(maybeOptions) | ||
| ? maybeOptions | ||
| : maybeDeps); | ||
| const options = (typeof callbackOrOptions === 'function' | ||
| ? isDependencyList(maybeOptions) | ||
| ? undefined | ||
| : maybeOptions | ||
| : isDependencyList(callbackOrOptions) | ||
| ? undefined | ||
| : callbackOrOptions); | ||
| const timeoutMs = options?.timeoutMs ?? KEY_SEQUENCE_TIMEOUT_MS; | ||
| const memoizedCallback = useCallback(typeof callbackOrOptions === 'function' ? callbackOrOptions : () => { }, deps ?? []); | ||
| bindingsRef.current = isKeySequenceBindingArray(sequenceOrBindings) | ||
| ? sequenceOrBindings.map((binding) => ({ | ||
| callback: binding.callback, | ||
| sequence: binding.sequence.map((key) => normalizeKey(key)), | ||
| })) | ||
| : [{ | ||
| callback: deps ? memoizedCallback : callbackOrOptions, | ||
| sequence: sequenceOrBindings.map((key) => normalizeKey(key)), | ||
| }]; | ||
| stateRef.current = bindingsRef.current.map((_, index) => ({ | ||
| lastKeyTimestamp: stateRef.current[index]?.lastKeyTimestamp ?? null, | ||
| progress: stateRef.current[index]?.progress ?? 0, | ||
| })); | ||
| useEffect(() => { | ||
@@ -173,27 +201,42 @@ if (options?.enabled === false) { | ||
| const normalizedKey = normalizeKey(event.key); | ||
| const expectedKey = KONAMI_CODE_SEQUENCE[progressRef.current]; | ||
| if (!expectedKey) { | ||
| progressRef.current = 0; | ||
| lastKeyTimestampRef.current = 0; | ||
| return; | ||
| } | ||
| if (lastKeyTimestampRef.current > 0 | ||
| && Date.now() - lastKeyTimestampRef.current > timeoutMs) { | ||
| progressRef.current = 0; | ||
| } | ||
| if (normalizedKey === expectedKey) { | ||
| progressRef.current += 1; | ||
| lastKeyTimestampRef.current = Date.now(); | ||
| if (progressRef.current === KONAMI_CODE_SEQUENCE.length) { | ||
| if (options?.preventDefault) { | ||
| event.preventDefault(); | ||
| const now = Date.now(); | ||
| const matchedBindings = bindingsRef.current.filter((binding, index) => { | ||
| const bindingState = stateRef.current[index] ?? { | ||
| lastKeyTimestamp: null, | ||
| progress: 0, | ||
| }; | ||
| stateRef.current[index] = bindingState; | ||
| const expectedKey = binding.sequence[bindingState.progress]; | ||
| if (!expectedKey) { | ||
| bindingState.progress = 0; | ||
| bindingState.lastKeyTimestamp = null; | ||
| return false; | ||
| } | ||
| if (bindingState.lastKeyTimestamp !== null | ||
| && now - bindingState.lastKeyTimestamp > timeoutMs) { | ||
| bindingState.progress = 0; | ||
| } | ||
| if (normalizedKey === binding.sequence[bindingState.progress]) { | ||
| bindingState.progress += 1; | ||
| bindingState.lastKeyTimestamp = now; | ||
| if (bindingState.progress === binding.sequence.length) { | ||
| bindingState.progress = 0; | ||
| bindingState.lastKeyTimestamp = null; | ||
| return true; | ||
| } | ||
| callbackRef.current(event); | ||
| progressRef.current = 0; | ||
| lastKeyTimestampRef.current = 0; | ||
| return false; | ||
| } | ||
| bindingState.progress = normalizedKey === binding.sequence[0] ? 1 : 0; | ||
| bindingState.lastKeyTimestamp = bindingState.progress > 0 ? now : null; | ||
| return false; | ||
| }); | ||
| if (matchedBindings.length === 0) { | ||
| return; | ||
| } | ||
| progressRef.current = normalizedKey === KONAMI_CODE_SEQUENCE[0] ? 1 : 0; | ||
| lastKeyTimestampRef.current = progressRef.current > 0 ? Date.now() : 0; | ||
| if (options?.preventDefault) { | ||
| event.preventDefault(); | ||
| } | ||
| for (const binding of matchedBindings) { | ||
| binding.callback(event); | ||
| } | ||
| }; | ||
@@ -200,0 +243,0 @@ hotkeyDocument.addEventListener('keydown', handleKeyDown); |
+1
-1
@@ -5,3 +5,3 @@ export { BrandBadge, brandBadgeClassNames, type BrandBadgeProps, } from './BrandBadge.js'; | ||
| export { createProjectStorage, type ProjectStorageEntry, type ProjectStorage, type ProjectStorageOptions, type StorageKeyPart, type StorageLike, } from './storage.js'; | ||
| export { KONAMI_CODE_SEQUENCE, useHotkeys, useKonami, type HotkeyBinding, type KonamiOptions, } from './hotkeys.js'; | ||
| export { KONAMI_CODE_SEQUENCE, useHotkeys, useKonami, type HotkeyBinding, type KonamiOptions, type KeySequenceBinding, type KeySequenceOptions, useKeySequence, } from './hotkeys.js'; | ||
| //# sourceMappingURL=index.d.ts.map |
@@ -1,1 +0,1 @@ | ||
| {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,oBAAoB,EACpB,KAAK,eAAe,GACrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,cAAc,GACf,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,cAAc,EACd,KAAK,mBAAmB,GACzB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,oBAAoB,EACpB,KAAK,mBAAmB,EACxB,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,cAAc,EACnB,KAAK,WAAW,GACjB,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,oBAAoB,EACpB,UAAU,EACV,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,aAAa,GACnB,MAAM,cAAc,CAAC"} | ||
| {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,UAAU,EACV,oBAAoB,EACpB,KAAK,eAAe,GACrB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,wBAAwB,EACxB,mBAAmB,EACnB,cAAc,GACf,MAAM,gBAAgB,CAAC;AACxB,OAAO,EACL,cAAc,EACd,KAAK,mBAAmB,GACzB,MAAM,qBAAqB,CAAC;AAC7B,OAAO,EACL,oBAAoB,EACpB,KAAK,mBAAmB,EACxB,KAAK,cAAc,EACnB,KAAK,qBAAqB,EAC1B,KAAK,cAAc,EACnB,KAAK,WAAW,GACjB,MAAM,cAAc,CAAC;AACtB,OAAO,EACL,oBAAoB,EACpB,UAAU,EACV,SAAS,EACT,KAAK,aAAa,EAClB,KAAK,aAAa,EAClB,KAAK,kBAAkB,EACvB,KAAK,kBAAkB,EACvB,cAAc,GACf,MAAM,cAAc,CAAC"} |
+1
-1
@@ -5,2 +5,2 @@ export { BrandBadge, brandBadgeClassNames, } from './BrandBadge.js'; | ||
| export { createProjectStorage, } from './storage.js'; | ||
| export { KONAMI_CODE_SEQUENCE, useHotkeys, useKonami, } from './hotkeys.js'; | ||
| export { KONAMI_CODE_SEQUENCE, useHotkeys, useKonami, useKeySequence, } from './hotkeys.js'; |
+1
-1
| { | ||
| "name": "@taylorvance/tv-shared-runtime", | ||
| "version": "0.5.0", | ||
| "version": "0.6.0", | ||
| "description": "Shared React runtime primitives for Taylor Vance portfolio projects.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+18
-0
@@ -19,2 +19,3 @@ # `@taylorvance/tv-shared-runtime` | ||
| - `useHotkeys` | ||
| - `useKeySequence` | ||
| - `useKonami` | ||
@@ -165,2 +166,19 @@ - `KONAMI_CODE_SEQUENCE` | ||
| For hidden multi-key sequences, `useKeySequence` supports either one sequence or multiple bindings with shared timeout and scope rules: | ||
| ```tsx | ||
| import { useKeySequence } from '@taylorvance/tv-shared-runtime'; | ||
| export function DebugPanel() { | ||
| useKeySequence([ | ||
| { sequence: ['d', 'e', 'b', 'u', 'g'], callback: () => setDebugMode(true) }, | ||
| { sequence: ['r', 'g', 'b'], callback: () => setRainbowMode(true) }, | ||
| ], { timeoutMs: 1_500 }); | ||
| return null; | ||
| } | ||
| ``` | ||
| `timeoutMs` defaults to `1000` and applies between each correct key press, not across the whole sequence. | ||
| For an easy easter-egg path, `useKonami` exposes a built-in Konami listener with the same optional scoping model: | ||
@@ -167,0 +185,0 @@ |
57655
7.11%944
5.59%201
9.84%