@octanejs/tanstack-hotkeys 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +9 -0
- package/package.json +48 -0
- package/src/HotkeysProvider.tsrx +28 -0
- package/src/HotkeysProvider.tsrx.d.ts +12 -0
- package/src/context.ts +26 -0
- package/src/index.ts +24 -0
- package/src/internal.ts +31 -0
- package/src/useHeldKeyCodes.ts +35 -0
- package/src/useHeldKeys.ts +31 -0
- package/src/useHotkey.ts +145 -0
- package/src/useHotkeyRecorder.ts +108 -0
- package/src/useHotkeyRegistrations.ts +67 -0
- package/src/useHotkeySequence.ts +163 -0
- package/src/useHotkeySequenceRecorder.ts +79 -0
- package/src/useHotkeySequences.ts +187 -0
- package/src/useHotkeys.ts +186 -0
- package/src/useKeyHold.ts +39 -0
- package/src/utils.ts +14 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Dominic Gannaway
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
# @octanejs/tanstack-hotkeys
|
|
2
|
+
|
|
3
|
+
Octane port of `@tanstack/react-hotkeys` — keyboard hotkeys, chord sequences,
|
|
4
|
+
held-key tracking, and shortcut recording. Re-exports the framework-agnostic
|
|
5
|
+
`@tanstack/hotkeys` core unchanged and implements the full hook surface
|
|
6
|
+
(`useHotkey`, `useHotkeys`, `useHeldKeys`, `useHeldKeyCodes`, `useKeyHold`,
|
|
7
|
+
`useHotkeySequence`, `useHotkeySequences`, `useHotkeyRecorder`,
|
|
8
|
+
`useHotkeySequenceRecorder`, `useHotkeyRegistrations`) plus `HotkeysProvider`
|
|
9
|
+
on Octane hooks, with store subscriptions via `@octanejs/tanstack-store`.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@octanejs/tanstack-hotkeys",
|
|
3
|
+
"version": "0.0.5",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"description": "TanStack Hotkeys bindings for Octane — reuses the framework-agnostic @tanstack/hotkeys core with an Octane hook adapter.",
|
|
10
|
+
"author": {
|
|
11
|
+
"name": "Dominic Gannaway",
|
|
12
|
+
"email": "dg@domgan.com"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/octanejs/octane.git",
|
|
20
|
+
"directory": "packages/tanstack-hotkeys"
|
|
21
|
+
},
|
|
22
|
+
"main": "src/index.ts",
|
|
23
|
+
"module": "src/index.ts",
|
|
24
|
+
"types": "src/index.ts",
|
|
25
|
+
"files": [
|
|
26
|
+
"src",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"exports": {
|
|
30
|
+
".": "./src/index.ts"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"@tanstack/hotkeys": "0.8.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@octanejs/tanstack-store": "0.0.10",
|
|
37
|
+
"octane": "0.1.16"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"vitest": "^4.1.10",
|
|
41
|
+
"octane": "0.1.16",
|
|
42
|
+
"@octanejs/tanstack-store": "0.0.10"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"test": "vitest run --project tanstack-hotkeys",
|
|
46
|
+
"typecheck": "tsrx-tsc --noEmit"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { useMemo } from 'octane';
|
|
2
|
+
import type { OctaneNode } from 'octane';
|
|
3
|
+
import { HotkeysContext } from './context.ts';
|
|
4
|
+
import type { HotkeysContextValue, HotkeysProviderOptions } from './context.ts';
|
|
5
|
+
|
|
6
|
+
export interface HotkeysProviderProps {
|
|
7
|
+
children: OctaneNode;
|
|
8
|
+
defaultOptions?: HotkeysProviderOptions;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const DEFAULT_OPTIONS: HotkeysProviderOptions = {};
|
|
12
|
+
|
|
13
|
+
// HotkeysProvider — provides shared default options for the hotkey hooks. It's
|
|
14
|
+
// authored in .tsrx because it's a COMPONENT that renders a context Provider
|
|
15
|
+
// with children (the binding's hooks stay plain TS).
|
|
16
|
+
export function HotkeysProvider({
|
|
17
|
+
children,
|
|
18
|
+
defaultOptions = DEFAULT_OPTIONS,
|
|
19
|
+
}: HotkeysProviderProps) @{
|
|
20
|
+
const contextValue: HotkeysContextValue = useMemo(
|
|
21
|
+
() => ({
|
|
22
|
+
defaultOptions,
|
|
23
|
+
}),
|
|
24
|
+
[defaultOptions],
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
<HotkeysContext.Provider value={contextValue}>{children}</HotkeysContext.Provider>
|
|
28
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Type declaration for the .tsrx provider component (HotkeysProvider.tsrx).
|
|
2
|
+
// It's a SPECIFIC module declaration (resolved by relative path), not an ambient
|
|
3
|
+
// `declare module '*.tsrx'` — so it types only this module and doesn't pollute a
|
|
4
|
+
// consumer's own .tsrx imports. The runtime resolves the real compiled .tsrx.
|
|
5
|
+
import type { HotkeysProviderOptions } from './context';
|
|
6
|
+
|
|
7
|
+
export interface HotkeysProviderProps {
|
|
8
|
+
children: unknown;
|
|
9
|
+
defaultOptions?: HotkeysProviderOptions;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export declare function HotkeysProvider(props: HotkeysProviderProps): unknown;
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { createContext, useContext } from 'octane';
|
|
2
|
+
import type { HotkeyRecorderOptions, HotkeySequenceRecorderOptions } from '@tanstack/hotkeys';
|
|
3
|
+
import type { UseHotkeyOptions } from './useHotkey';
|
|
4
|
+
import type { UseHotkeySequenceOptions } from './useHotkeySequence';
|
|
5
|
+
|
|
6
|
+
export interface HotkeysProviderOptions {
|
|
7
|
+
hotkey?: Partial<UseHotkeyOptions>;
|
|
8
|
+
hotkeyRecorder?: Partial<HotkeyRecorderOptions>;
|
|
9
|
+
hotkeySequence?: Partial<UseHotkeySequenceOptions>;
|
|
10
|
+
hotkeySequenceRecorder?: Partial<HotkeySequenceRecorderOptions>;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface HotkeysContextValue {
|
|
14
|
+
defaultOptions: HotkeysProviderOptions;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const HotkeysContext = createContext<HotkeysContextValue | null>(null);
|
|
18
|
+
|
|
19
|
+
export function useHotkeysContext() {
|
|
20
|
+
return useContext(HotkeysContext);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function useDefaultHotkeysOptions() {
|
|
24
|
+
const context = useContext(HotkeysContext);
|
|
25
|
+
return context?.defaultOptions ?? {};
|
|
26
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Octane port of @tanstack/react-hotkeys@0.10.0 — thin hook wrappers over the
|
|
2
|
+
// framework-agnostic @tanstack/hotkeys core, mirroring the upstream module
|
|
3
|
+
// layout (src/index.ts).
|
|
4
|
+
|
|
5
|
+
// Re-export everything from the core package
|
|
6
|
+
export * from '@tanstack/hotkeys';
|
|
7
|
+
|
|
8
|
+
// provider
|
|
9
|
+
export { HotkeysProvider } from './HotkeysProvider.tsrx';
|
|
10
|
+
export type { HotkeysProviderProps } from './HotkeysProvider.tsrx';
|
|
11
|
+
export { useHotkeysContext, useDefaultHotkeysOptions } from './context';
|
|
12
|
+
export type { HotkeysProviderOptions } from './context';
|
|
13
|
+
|
|
14
|
+
// Octane-specific exports (upstream: "React-specific exports")
|
|
15
|
+
export * from './useHotkey';
|
|
16
|
+
export * from './useHotkeys';
|
|
17
|
+
export * from './useHeldKeys';
|
|
18
|
+
export * from './useHeldKeyCodes';
|
|
19
|
+
export * from './useKeyHold';
|
|
20
|
+
export * from './useHotkeySequence';
|
|
21
|
+
export * from './useHotkeySequences';
|
|
22
|
+
export * from './useHotkeyRecorder';
|
|
23
|
+
export * from './useHotkeyRegistrations';
|
|
24
|
+
export * from './useHotkeySequenceRecorder';
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useSelector } from '@octanejs/tanstack-store';
|
|
2
|
+
|
|
3
|
+
// Slot mechanics for the binding's plain-`.ts` hooks. The octane compiler
|
|
4
|
+
// wraps custom-hook CALLS made from compiled `.tsrx`/`.tsx` modules in
|
|
5
|
+
// `withSlot`, but calls made from plain `.ts` modules (this binding's hooks
|
|
6
|
+
// composing `useSelector` from @octanejs/tanstack-store) are not wrapped —
|
|
7
|
+
// so a hook that composes `useSelector` more than once must hand each call
|
|
8
|
+
// site its own slot symbol. `useSelector` reads the slot off its last
|
|
9
|
+
// argument; the public overloads don't declare it, hence the cast here.
|
|
10
|
+
|
|
11
|
+
type SelectionSource<T> = {
|
|
12
|
+
get: () => T;
|
|
13
|
+
subscribe: (listener: (value: T) => void) => {
|
|
14
|
+
unsubscribe: () => void;
|
|
15
|
+
};
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function useSelectorSlot<TSource, TSelected>(
|
|
19
|
+
source: SelectionSource<TSource>,
|
|
20
|
+
selector: (snapshot: TSource) => TSelected,
|
|
21
|
+
slot: symbol,
|
|
22
|
+
): TSelected {
|
|
23
|
+
return (
|
|
24
|
+
useSelector as (
|
|
25
|
+
source: SelectionSource<TSource>,
|
|
26
|
+
selector: (snapshot: TSource) => TSelected,
|
|
27
|
+
options: undefined,
|
|
28
|
+
slot: symbol,
|
|
29
|
+
) => TSelected
|
|
30
|
+
)(source, selector, undefined, slot);
|
|
31
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { getKeyStateTracker } from '@tanstack/hotkeys';
|
|
2
|
+
import { useSelectorSlot } from './internal';
|
|
3
|
+
|
|
4
|
+
const heldKeyCodesSlot = Symbol.for('@octanejs/tanstack-hotkeys:useHeldKeyCodes');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Octane hook that returns a map of currently held key names to their physical `event.code` values.
|
|
8
|
+
*
|
|
9
|
+
* This is useful for debugging which physical key was pressed (e.g. distinguishing
|
|
10
|
+
* left vs right Shift via "ShiftLeft" / "ShiftRight").
|
|
11
|
+
*
|
|
12
|
+
* @returns Record mapping normalized key names to their `event.code` values
|
|
13
|
+
*
|
|
14
|
+
* @example
|
|
15
|
+
* ```tsx
|
|
16
|
+
* function KeyDebugDisplay() {
|
|
17
|
+
* const heldKeys = useHeldKeys()
|
|
18
|
+
* const heldCodes = useHeldKeyCodes()
|
|
19
|
+
*
|
|
20
|
+
* return (
|
|
21
|
+
* <div>
|
|
22
|
+
* {heldKeys.map((key) => (
|
|
23
|
+
* <kbd key={key}>
|
|
24
|
+
* {key} <small>{heldCodes[key]}</small>
|
|
25
|
+
* </kbd>
|
|
26
|
+
* ))}
|
|
27
|
+
* </div>
|
|
28
|
+
* )
|
|
29
|
+
* }
|
|
30
|
+
* ```
|
|
31
|
+
*/
|
|
32
|
+
export function useHeldKeyCodes(): Record<string, string> {
|
|
33
|
+
const tracker = getKeyStateTracker();
|
|
34
|
+
return useSelectorSlot(tracker.store, (state) => state.heldCodes, heldKeyCodesSlot);
|
|
35
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { getKeyStateTracker } from '@tanstack/hotkeys';
|
|
2
|
+
import { useSelectorSlot } from './internal';
|
|
3
|
+
|
|
4
|
+
const heldKeysSlot = Symbol.for('@octanejs/tanstack-hotkeys:useHeldKeys');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Octane hook that returns an array of currently held keyboard keys.
|
|
8
|
+
*
|
|
9
|
+
* This hook uses `useSelector` from `@octanejs/tanstack-store` to subscribe
|
|
10
|
+
* to the global KeyStateTracker and updates whenever keys are pressed
|
|
11
|
+
* or released.
|
|
12
|
+
*
|
|
13
|
+
* @returns Array of currently held key names
|
|
14
|
+
*
|
|
15
|
+
* @example
|
|
16
|
+
* ```tsx
|
|
17
|
+
* function KeyDisplay() {
|
|
18
|
+
* const heldKeys = useHeldKeys()
|
|
19
|
+
*
|
|
20
|
+
* return (
|
|
21
|
+
* <div>
|
|
22
|
+
* Currently pressed: {heldKeys.join(' + ') || 'None'}
|
|
23
|
+
* </div>
|
|
24
|
+
* )
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*/
|
|
28
|
+
export function useHeldKeys(): Array<string> {
|
|
29
|
+
const tracker = getKeyStateTracker();
|
|
30
|
+
return useSelectorSlot(tracker.store, (state) => state.heldKeys, heldKeysSlot);
|
|
31
|
+
}
|
package/src/useHotkey.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'octane';
|
|
2
|
+
import { detectPlatform, getHotkeyManager, normalizeRegisterableHotkey } from '@tanstack/hotkeys';
|
|
3
|
+
import { useDefaultHotkeysOptions } from './context';
|
|
4
|
+
import { isRef } from './utils';
|
|
5
|
+
import type { RefObjectLike } from './utils';
|
|
6
|
+
import type {
|
|
7
|
+
HotkeyCallback,
|
|
8
|
+
HotkeyOptions,
|
|
9
|
+
HotkeyRegistrationHandle,
|
|
10
|
+
RegisterableHotkey,
|
|
11
|
+
} from '@tanstack/hotkeys';
|
|
12
|
+
|
|
13
|
+
export interface UseHotkeyOptions extends Omit<HotkeyOptions, 'target'> {
|
|
14
|
+
/**
|
|
15
|
+
* The DOM element to attach the event listener to.
|
|
16
|
+
* Can be a ref object, direct DOM element, or null.
|
|
17
|
+
* Defaults to document.
|
|
18
|
+
*/
|
|
19
|
+
target?: RefObjectLike<HTMLElement | null> | HTMLElement | Document | Window | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Octane hook for registering a keyboard hotkey.
|
|
24
|
+
*
|
|
25
|
+
* Uses the singleton HotkeyManager for efficient event handling.
|
|
26
|
+
* The callback receives both the keyboard event and a context object
|
|
27
|
+
* containing the hotkey string and parsed hotkey.
|
|
28
|
+
*
|
|
29
|
+
* This hook syncs the callback and options on every render to avoid
|
|
30
|
+
* stale closures. This means callbacks that reference component state will
|
|
31
|
+
* always have access to the latest values.
|
|
32
|
+
*
|
|
33
|
+
* @param hotkey - The hotkey string (e.g., 'Mod+S', 'Escape') or RawHotkey object (supports `mod` for cross-platform)
|
|
34
|
+
* @param callback - The function to call when the hotkey is pressed
|
|
35
|
+
* @param options - Options for the hotkey behavior. `enabled: false` keeps the registration (visible in devtools)
|
|
36
|
+
* and only suppresses firing; the hook updates the existing handle instead of unregistering.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* function SaveButton() {
|
|
41
|
+
* const [count, setCount] = useState(0)
|
|
42
|
+
*
|
|
43
|
+
* // Callback always has access to latest count value
|
|
44
|
+
* useHotkey('Mod+S', (event, { hotkey }) => {
|
|
45
|
+
* console.log(`Save triggered, count is ${count}`)
|
|
46
|
+
* handleSave()
|
|
47
|
+
* })
|
|
48
|
+
*
|
|
49
|
+
* return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
|
|
50
|
+
* }
|
|
51
|
+
* ```
|
|
52
|
+
*/
|
|
53
|
+
export function useHotkey(
|
|
54
|
+
hotkey: RegisterableHotkey,
|
|
55
|
+
callback: HotkeyCallback,
|
|
56
|
+
options: UseHotkeyOptions = {},
|
|
57
|
+
): void {
|
|
58
|
+
const mergedOptions = {
|
|
59
|
+
...useDefaultHotkeysOptions().hotkey,
|
|
60
|
+
...options,
|
|
61
|
+
} as UseHotkeyOptions;
|
|
62
|
+
|
|
63
|
+
const manager = getHotkeyManager();
|
|
64
|
+
|
|
65
|
+
// Stable ref for registration handle
|
|
66
|
+
const registrationRef = useRef<HotkeyRegistrationHandle | null>(null);
|
|
67
|
+
|
|
68
|
+
// Refs to capture current values for use in effect without adding dependencies
|
|
69
|
+
const callbackRef = useRef(callback);
|
|
70
|
+
const optionsRef = useRef(mergedOptions);
|
|
71
|
+
const managerRef = useRef(manager);
|
|
72
|
+
|
|
73
|
+
// Update refs on every render
|
|
74
|
+
callbackRef.current = callback;
|
|
75
|
+
optionsRef.current = mergedOptions;
|
|
76
|
+
managerRef.current = manager;
|
|
77
|
+
|
|
78
|
+
// Track previous target and hotkey to detect changes requiring re-registration
|
|
79
|
+
const prevTargetRef = useRef<HTMLElement | Document | Window | null>(null);
|
|
80
|
+
const prevHotkeyRef = useRef<string | null>(null);
|
|
81
|
+
|
|
82
|
+
// Normalize to hotkey string
|
|
83
|
+
const platform = mergedOptions.platform ?? detectPlatform();
|
|
84
|
+
const hotkeyString = normalizeRegisterableHotkey(hotkey, platform);
|
|
85
|
+
|
|
86
|
+
// Extract options without target (target is handled separately)
|
|
87
|
+
const { target: _target, ...optionsWithoutTarget } = mergedOptions;
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
// Resolve target inside the effect so refs are already attached after mount
|
|
91
|
+
const resolvedTarget = isRef(optionsRef.current.target)
|
|
92
|
+
? optionsRef.current.target.current
|
|
93
|
+
: (optionsRef.current.target ?? (typeof document !== 'undefined' ? document : null));
|
|
94
|
+
|
|
95
|
+
// Skip if no valid target (SSR or ref still null)
|
|
96
|
+
if (!resolvedTarget) {
|
|
97
|
+
if (registrationRef.current?.isActive) {
|
|
98
|
+
registrationRef.current.unregister();
|
|
99
|
+
registrationRef.current = null;
|
|
100
|
+
}
|
|
101
|
+
prevTargetRef.current = null;
|
|
102
|
+
prevHotkeyRef.current = null;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// Check if we need to re-register (target or hotkey changed)
|
|
107
|
+
const targetChanged =
|
|
108
|
+
prevTargetRef.current !== null && prevTargetRef.current !== resolvedTarget;
|
|
109
|
+
const hotkeyChanged = prevHotkeyRef.current !== null && prevHotkeyRef.current !== hotkeyString;
|
|
110
|
+
|
|
111
|
+
// If we have an active registration and target/hotkey changed, unregister first
|
|
112
|
+
if (registrationRef.current?.isActive && (targetChanged || hotkeyChanged)) {
|
|
113
|
+
registrationRef.current.unregister();
|
|
114
|
+
registrationRef.current = null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Register if needed (no active registration)
|
|
118
|
+
// Use refs to access current values without adding them to dependencies
|
|
119
|
+
if (!registrationRef.current || !registrationRef.current.isActive) {
|
|
120
|
+
registrationRef.current = managerRef.current.register(hotkeyString, callbackRef.current, {
|
|
121
|
+
...optionsRef.current,
|
|
122
|
+
target: resolvedTarget,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Update tracking refs
|
|
127
|
+
prevTargetRef.current = resolvedTarget;
|
|
128
|
+
prevHotkeyRef.current = hotkeyString;
|
|
129
|
+
|
|
130
|
+
// Cleanup on unmount
|
|
131
|
+
return () => {
|
|
132
|
+
if (registrationRef.current?.isActive) {
|
|
133
|
+
registrationRef.current.unregister();
|
|
134
|
+
registrationRef.current = null;
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
}, [hotkeyString]);
|
|
138
|
+
|
|
139
|
+
// Sync callback and options on EVERY render (outside useEffect)
|
|
140
|
+
// This avoids stale closures - the callback always has access to latest state
|
|
141
|
+
if (registrationRef.current?.isActive) {
|
|
142
|
+
registrationRef.current.callback = callback;
|
|
143
|
+
registrationRef.current.setOptions(optionsWithoutTarget);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'octane';
|
|
2
|
+
import { HotkeyRecorder } from '@tanstack/hotkeys';
|
|
3
|
+
import { useDefaultHotkeysOptions } from './context';
|
|
4
|
+
import { useSelectorSlot } from './internal';
|
|
5
|
+
import type { Hotkey, HotkeyRecorderOptions } from '@tanstack/hotkeys';
|
|
6
|
+
|
|
7
|
+
const isRecordingSlot = Symbol.for('@octanejs/tanstack-hotkeys:useHotkeyRecorder:isRecording');
|
|
8
|
+
const recordedHotkeySlot = Symbol.for(
|
|
9
|
+
'@octanejs/tanstack-hotkeys:useHotkeyRecorder:recordedHotkey',
|
|
10
|
+
);
|
|
11
|
+
|
|
12
|
+
// Upstream export name kept verbatim ("React"-prefixed) so ports from
|
|
13
|
+
// @tanstack/react-hotkeys only need to change the import specifier.
|
|
14
|
+
export interface ReactHotkeyRecorder {
|
|
15
|
+
/** Whether recording is currently active */
|
|
16
|
+
isRecording: boolean;
|
|
17
|
+
/** The currently recorded hotkey (for live preview) */
|
|
18
|
+
recordedHotkey: Hotkey | null;
|
|
19
|
+
/** Start recording a new hotkey */
|
|
20
|
+
startRecording: () => void;
|
|
21
|
+
/** Stop recording (same as cancel) */
|
|
22
|
+
stopRecording: () => void;
|
|
23
|
+
/** Cancel recording without saving */
|
|
24
|
+
cancelRecording: () => void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Octane hook for recording keyboard shortcuts.
|
|
29
|
+
*
|
|
30
|
+
* This hook provides a thin wrapper around the framework-agnostic `HotkeyRecorder`
|
|
31
|
+
* class, managing all the complexity of capturing keyboard events, converting them
|
|
32
|
+
* to hotkey strings, and handling edge cases like Escape to cancel or Backspace/Delete
|
|
33
|
+
* to clear.
|
|
34
|
+
*
|
|
35
|
+
* @param options - Configuration options for the recorder
|
|
36
|
+
* @returns An object with recording state and control functions
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* ```tsx
|
|
40
|
+
* function ShortcutSettings() {
|
|
41
|
+
* const [shortcut, setShortcut] = useState<Hotkey>('Mod+S')
|
|
42
|
+
*
|
|
43
|
+
* const recorder = useHotkeyRecorder({
|
|
44
|
+
* onRecord: (hotkey) => {
|
|
45
|
+
* setShortcut(hotkey)
|
|
46
|
+
* },
|
|
47
|
+
* onCancel: () => {
|
|
48
|
+
* console.log('Recording cancelled')
|
|
49
|
+
* },
|
|
50
|
+
* })
|
|
51
|
+
*
|
|
52
|
+
* return (
|
|
53
|
+
* <div>
|
|
54
|
+
* <button onClick={recorder.startRecording}>
|
|
55
|
+
* {recorder.isRecording ? 'Recording...' : 'Edit Shortcut'}
|
|
56
|
+
* </button>
|
|
57
|
+
* {recorder.recordedHotkey && (
|
|
58
|
+
* <div>Recording: {recorder.recordedHotkey}</div>
|
|
59
|
+
* )}
|
|
60
|
+
* </div>
|
|
61
|
+
* )
|
|
62
|
+
* }
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
export function useHotkeyRecorder(options: HotkeyRecorderOptions): ReactHotkeyRecorder {
|
|
66
|
+
const mergedOptions = {
|
|
67
|
+
...useDefaultHotkeysOptions().hotkeyRecorder,
|
|
68
|
+
...options,
|
|
69
|
+
} as HotkeyRecorderOptions;
|
|
70
|
+
|
|
71
|
+
const recorderRef = useRef<HotkeyRecorder | null>(null);
|
|
72
|
+
|
|
73
|
+
// Create recorder instance once
|
|
74
|
+
if (!recorderRef.current) {
|
|
75
|
+
recorderRef.current = new HotkeyRecorder(mergedOptions);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Sync options on every render (same pattern as useHotkey)
|
|
79
|
+
// This ensures callbacks always have access to latest values
|
|
80
|
+
recorderRef.current.setOptions(mergedOptions);
|
|
81
|
+
|
|
82
|
+
// Subscribe to recorder state using useSelector (same pattern as useHeldKeys)
|
|
83
|
+
const isRecording = useSelectorSlot(
|
|
84
|
+
recorderRef.current.store,
|
|
85
|
+
(state) => state.isRecording,
|
|
86
|
+
isRecordingSlot,
|
|
87
|
+
);
|
|
88
|
+
const recordedHotkey = useSelectorSlot(
|
|
89
|
+
recorderRef.current.store,
|
|
90
|
+
(state) => state.recordedHotkey,
|
|
91
|
+
recordedHotkeySlot,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// Cleanup on unmount
|
|
95
|
+
useEffect(() => {
|
|
96
|
+
return () => {
|
|
97
|
+
recorderRef.current?.destroy();
|
|
98
|
+
};
|
|
99
|
+
}, []);
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
isRecording,
|
|
103
|
+
recordedHotkey,
|
|
104
|
+
startRecording: () => recorderRef.current?.start(),
|
|
105
|
+
stopRecording: () => recorderRef.current?.stop(),
|
|
106
|
+
cancelRecording: () => recorderRef.current?.cancel(),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { getHotkeyManager, getSequenceManager, toHotkeyRegistrationView } from '@tanstack/hotkeys';
|
|
2
|
+
import type { HotkeyRegistrationView, SequenceRegistrationView } from '@tanstack/hotkeys';
|
|
3
|
+
import { useSelectorSlot } from './internal';
|
|
4
|
+
|
|
5
|
+
const hotkeysSlot = Symbol.for('@octanejs/tanstack-hotkeys:useHotkeyRegistrations:hotkeys');
|
|
6
|
+
const sequencesSlot = Symbol.for('@octanejs/tanstack-hotkeys:useHotkeyRegistrations:sequences');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Return type for useHotkeyRegistrations.
|
|
10
|
+
*/
|
|
11
|
+
export interface HotkeyRegistrationsResult {
|
|
12
|
+
/** All registered hotkeys (public view, no callbacks) */
|
|
13
|
+
hotkeys: Array<HotkeyRegistrationView>;
|
|
14
|
+
/** All registered sequences */
|
|
15
|
+
sequences: Array<SequenceRegistrationView>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Octane hook that reactively reads all hotkey and sequence registrations
|
|
20
|
+
* from the singleton managers.
|
|
21
|
+
*
|
|
22
|
+
* This is a standalone hook that does NOT require the HotkeysProvider.
|
|
23
|
+
* It subscribes to both HotkeyManager and SequenceManager stores and
|
|
24
|
+
* re-renders when registrations change.
|
|
25
|
+
*
|
|
26
|
+
* @returns Object with `hotkeys` and `sequences` arrays
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* ```tsx
|
|
30
|
+
* function ShortcutPalette() {
|
|
31
|
+
* const { hotkeys, sequences } = useHotkeyRegistrations()
|
|
32
|
+
*
|
|
33
|
+
* return (
|
|
34
|
+
* <ul>
|
|
35
|
+
* {hotkeys.map((reg) => (
|
|
36
|
+
* <li key={reg.id}>
|
|
37
|
+
* {reg.options.meta?.name ?? reg.hotkey}
|
|
38
|
+
* </li>
|
|
39
|
+
* ))}
|
|
40
|
+
* {sequences.map((reg) => (
|
|
41
|
+
* <li key={reg.id}>
|
|
42
|
+
* {reg.options.meta?.name ?? reg.sequence.join(' ')}
|
|
43
|
+
* </li>
|
|
44
|
+
* ))}
|
|
45
|
+
* </ul>
|
|
46
|
+
* )
|
|
47
|
+
* }
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function useHotkeyRegistrations(): HotkeyRegistrationsResult {
|
|
51
|
+
const hotkeyManager = getHotkeyManager();
|
|
52
|
+
const sequenceManager = getSequenceManager();
|
|
53
|
+
|
|
54
|
+
const hotkeys = useSelectorSlot(
|
|
55
|
+
hotkeyManager.registrations,
|
|
56
|
+
(state) => Array.from(state.values()).map(toHotkeyRegistrationView),
|
|
57
|
+
hotkeysSlot,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const sequences = useSelectorSlot(
|
|
61
|
+
sequenceManager.registrations,
|
|
62
|
+
(state) => Array.from(state.values()),
|
|
63
|
+
sequencesSlot,
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
return { hotkeys, sequences };
|
|
67
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'octane';
|
|
2
|
+
import { formatHotkeySequence, getSequenceManager } from '@tanstack/hotkeys';
|
|
3
|
+
import { useDefaultHotkeysOptions } from './context';
|
|
4
|
+
import { isRef } from './utils';
|
|
5
|
+
import type { RefObjectLike } from './utils';
|
|
6
|
+
import type {
|
|
7
|
+
HotkeyCallback,
|
|
8
|
+
HotkeyCallbackContext,
|
|
9
|
+
HotkeySequence,
|
|
10
|
+
SequenceOptions,
|
|
11
|
+
SequenceRegistrationHandle,
|
|
12
|
+
} from '@tanstack/hotkeys';
|
|
13
|
+
|
|
14
|
+
export interface UseHotkeySequenceOptions extends Omit<SequenceOptions, 'target'> {
|
|
15
|
+
/**
|
|
16
|
+
* The DOM element to attach the event listener to.
|
|
17
|
+
* Can be a ref object, direct DOM element, or null.
|
|
18
|
+
* Defaults to document.
|
|
19
|
+
*/
|
|
20
|
+
target?: RefObjectLike<HTMLElement | null> | HTMLElement | Document | Window | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Octane hook for registering a keyboard shortcut sequence (Vim-style).
|
|
25
|
+
*
|
|
26
|
+
* This hook allows you to register multi-key sequences like 'g g' or 'd d'
|
|
27
|
+
* that trigger when the full sequence is pressed within a timeout.
|
|
28
|
+
*
|
|
29
|
+
* Each step may include modifiers. You can chain the same modifier across
|
|
30
|
+
* steps (e.g. `Shift+R` then `Shift+T`). Modifier-only keydown events (Shift,
|
|
31
|
+
* Control, Alt, or Meta pressed alone) are ignored while matching—they do not
|
|
32
|
+
* advance the sequence or reset progress.
|
|
33
|
+
*
|
|
34
|
+
* @param sequence - Array of hotkey strings that form the sequence
|
|
35
|
+
* @param callback - Function to call when the sequence is completed
|
|
36
|
+
* @param options - Options for the sequence behavior. `enabled: false` keeps the registration (visible in devtools)
|
|
37
|
+
* and only suppresses firing; the hook updates the existing handle instead of unregistering.
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* function VimEditor() {
|
|
42
|
+
* // 'g g' to go to top
|
|
43
|
+
* useHotkeySequence(['G', 'G'], () => {
|
|
44
|
+
* scrollToTop()
|
|
45
|
+
* })
|
|
46
|
+
*
|
|
47
|
+
* // 'd i w' to delete inner word
|
|
48
|
+
* useHotkeySequence(['D', 'I', 'W'], () => {
|
|
49
|
+
* deleteInnerWord()
|
|
50
|
+
* }, { timeout: 500 })
|
|
51
|
+
*
|
|
52
|
+
* return <div>...</div>
|
|
53
|
+
* }
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function useHotkeySequence(
|
|
57
|
+
sequence: HotkeySequence,
|
|
58
|
+
callback: HotkeyCallback,
|
|
59
|
+
options: UseHotkeySequenceOptions = {},
|
|
60
|
+
): void {
|
|
61
|
+
const mergedOptions = {
|
|
62
|
+
...useDefaultHotkeysOptions().hotkeySequence,
|
|
63
|
+
...options,
|
|
64
|
+
} as UseHotkeySequenceOptions;
|
|
65
|
+
|
|
66
|
+
const manager = getSequenceManager();
|
|
67
|
+
|
|
68
|
+
// Stable ref for registration handle
|
|
69
|
+
const registrationRef = useRef<SequenceRegistrationHandle | null>(null);
|
|
70
|
+
|
|
71
|
+
// Refs to capture current values for use in effect without adding dependencies
|
|
72
|
+
const callbackRef = useRef(callback);
|
|
73
|
+
const optionsRef = useRef(mergedOptions);
|
|
74
|
+
const managerRef = useRef(manager);
|
|
75
|
+
const sequenceRef = useRef(sequence);
|
|
76
|
+
|
|
77
|
+
// Update refs on every render
|
|
78
|
+
callbackRef.current = callback;
|
|
79
|
+
optionsRef.current = mergedOptions;
|
|
80
|
+
managerRef.current = manager;
|
|
81
|
+
sequenceRef.current = sequence;
|
|
82
|
+
|
|
83
|
+
// Track previous target and sequence to detect changes requiring re-registration
|
|
84
|
+
const prevTargetRef = useRef<HTMLElement | Document | Window | null>(null);
|
|
85
|
+
const prevSequenceRef = useRef<string | null>(null);
|
|
86
|
+
|
|
87
|
+
// Normalize to hotkey sequence string (join with spaces)
|
|
88
|
+
const hotkeySequenceString = formatHotkeySequence(sequence);
|
|
89
|
+
|
|
90
|
+
// Extract options without target (target is handled separately)
|
|
91
|
+
const { target: _target, ...optionsWithoutTarget } = mergedOptions;
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (sequenceRef.current.length === 0) {
|
|
95
|
+
if (registrationRef.current?.isActive) {
|
|
96
|
+
registrationRef.current.unregister();
|
|
97
|
+
registrationRef.current = null;
|
|
98
|
+
}
|
|
99
|
+
prevTargetRef.current = null;
|
|
100
|
+
prevSequenceRef.current = null;
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Resolve target inside the effect so refs are already attached after mount
|
|
105
|
+
const resolvedTarget = isRef(optionsRef.current.target)
|
|
106
|
+
? optionsRef.current.target.current
|
|
107
|
+
: (optionsRef.current.target ?? (typeof document !== 'undefined' ? document : null));
|
|
108
|
+
|
|
109
|
+
// Skip if no valid target (SSR or ref still null)
|
|
110
|
+
if (!resolvedTarget) {
|
|
111
|
+
if (registrationRef.current?.isActive) {
|
|
112
|
+
registrationRef.current.unregister();
|
|
113
|
+
registrationRef.current = null;
|
|
114
|
+
}
|
|
115
|
+
prevTargetRef.current = null;
|
|
116
|
+
prevSequenceRef.current = null;
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Check if we need to re-register (target or sequence changed)
|
|
121
|
+
const targetChanged =
|
|
122
|
+
prevTargetRef.current !== null && prevTargetRef.current !== resolvedTarget;
|
|
123
|
+
const sequenceChanged =
|
|
124
|
+
prevSequenceRef.current !== null && prevSequenceRef.current !== hotkeySequenceString;
|
|
125
|
+
|
|
126
|
+
// If we have an active registration and target/sequence changed, unregister first
|
|
127
|
+
if (registrationRef.current?.isActive && (targetChanged || sequenceChanged)) {
|
|
128
|
+
registrationRef.current.unregister();
|
|
129
|
+
registrationRef.current = null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Register if needed (no active registration)
|
|
133
|
+
if (!registrationRef.current || !registrationRef.current.isActive) {
|
|
134
|
+
registrationRef.current = managerRef.current.register(
|
|
135
|
+
sequenceRef.current,
|
|
136
|
+
(event, context) => callbackRef.current(event, context),
|
|
137
|
+
{
|
|
138
|
+
...optionsRef.current,
|
|
139
|
+
target: resolvedTarget,
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Update tracking refs
|
|
145
|
+
prevTargetRef.current = resolvedTarget;
|
|
146
|
+
prevSequenceRef.current = hotkeySequenceString;
|
|
147
|
+
|
|
148
|
+
// Cleanup on unmount
|
|
149
|
+
return () => {
|
|
150
|
+
if (registrationRef.current?.isActive) {
|
|
151
|
+
registrationRef.current.unregister();
|
|
152
|
+
registrationRef.current = null;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
}, [hotkeySequenceString]);
|
|
156
|
+
|
|
157
|
+
// Sync callback and options on EVERY render (outside useEffect)
|
|
158
|
+
if (registrationRef.current?.isActive) {
|
|
159
|
+
registrationRef.current.callback = (event: KeyboardEvent, context: HotkeyCallbackContext) =>
|
|
160
|
+
callbackRef.current(event, context);
|
|
161
|
+
registrationRef.current.setOptions(optionsWithoutTarget);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'octane';
|
|
2
|
+
import { HotkeySequenceRecorder } from '@tanstack/hotkeys';
|
|
3
|
+
import { useDefaultHotkeysOptions } from './context';
|
|
4
|
+
import { useSelectorSlot } from './internal';
|
|
5
|
+
import type { HotkeySequence, HotkeySequenceRecorderOptions } from '@tanstack/hotkeys';
|
|
6
|
+
|
|
7
|
+
const isRecordingSlot = Symbol.for(
|
|
8
|
+
'@octanejs/tanstack-hotkeys:useHotkeySequenceRecorder:isRecording',
|
|
9
|
+
);
|
|
10
|
+
const stepsSlot = Symbol.for('@octanejs/tanstack-hotkeys:useHotkeySequenceRecorder:steps');
|
|
11
|
+
const recordedSequenceSlot = Symbol.for(
|
|
12
|
+
'@octanejs/tanstack-hotkeys:useHotkeySequenceRecorder:recordedSequence',
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
// Upstream export name kept verbatim ("React"-prefixed) so ports from
|
|
16
|
+
// @tanstack/react-hotkeys only need to change the import specifier.
|
|
17
|
+
export interface ReactHotkeySequenceRecorder {
|
|
18
|
+
/** Whether recording is currently active */
|
|
19
|
+
isRecording: boolean;
|
|
20
|
+
/** Chords captured in the current session */
|
|
21
|
+
steps: HotkeySequence;
|
|
22
|
+
/** Last committed sequence */
|
|
23
|
+
recordedSequence: HotkeySequence | null;
|
|
24
|
+
startRecording: () => void;
|
|
25
|
+
stopRecording: () => void;
|
|
26
|
+
cancelRecording: () => void;
|
|
27
|
+
/** Commit current steps (no-op if empty) */
|
|
28
|
+
commitRecording: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Octane hook for recording multi-chord sequences (Vim-style shortcuts).
|
|
33
|
+
*
|
|
34
|
+
* @param options - Configuration options for the hotkey sequence recorder
|
|
35
|
+
*/
|
|
36
|
+
export function useHotkeySequenceRecorder(
|
|
37
|
+
options: HotkeySequenceRecorderOptions,
|
|
38
|
+
): ReactHotkeySequenceRecorder {
|
|
39
|
+
const mergedOptions = {
|
|
40
|
+
...useDefaultHotkeysOptions().hotkeySequenceRecorder,
|
|
41
|
+
...options,
|
|
42
|
+
} as HotkeySequenceRecorderOptions;
|
|
43
|
+
|
|
44
|
+
const recorderRef = useRef<HotkeySequenceRecorder | null>(null);
|
|
45
|
+
|
|
46
|
+
if (!recorderRef.current) {
|
|
47
|
+
recorderRef.current = new HotkeySequenceRecorder(mergedOptions);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
recorderRef.current.setOptions(mergedOptions);
|
|
51
|
+
|
|
52
|
+
const isRecording = useSelectorSlot(
|
|
53
|
+
recorderRef.current.store,
|
|
54
|
+
(state) => state.isRecording,
|
|
55
|
+
isRecordingSlot,
|
|
56
|
+
);
|
|
57
|
+
const steps = useSelectorSlot(recorderRef.current.store, (state) => state.steps, stepsSlot);
|
|
58
|
+
const recordedSequence = useSelectorSlot(
|
|
59
|
+
recorderRef.current.store,
|
|
60
|
+
(state) => state.recordedSequence,
|
|
61
|
+
recordedSequenceSlot,
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
return () => {
|
|
66
|
+
recorderRef.current?.destroy();
|
|
67
|
+
};
|
|
68
|
+
}, []);
|
|
69
|
+
|
|
70
|
+
return {
|
|
71
|
+
isRecording,
|
|
72
|
+
steps,
|
|
73
|
+
recordedSequence,
|
|
74
|
+
startRecording: () => recorderRef.current?.start(),
|
|
75
|
+
stopRecording: () => recorderRef.current?.stop(),
|
|
76
|
+
cancelRecording: () => recorderRef.current?.cancel(),
|
|
77
|
+
commitRecording: () => recorderRef.current?.commit(),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'octane';
|
|
2
|
+
import { formatHotkeySequence, getSequenceManager } from '@tanstack/hotkeys';
|
|
3
|
+
import { useDefaultHotkeysOptions } from './context';
|
|
4
|
+
import { isRef } from './utils';
|
|
5
|
+
import type { UseHotkeySequenceOptions } from './useHotkeySequence';
|
|
6
|
+
import type { HotkeyCallback, HotkeySequence, SequenceRegistrationHandle } from '@tanstack/hotkeys';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A single sequence definition for use with `useHotkeySequences`.
|
|
10
|
+
*/
|
|
11
|
+
export interface UseHotkeySequenceDefinition {
|
|
12
|
+
/** Array of hotkey strings that form the sequence */
|
|
13
|
+
sequence: HotkeySequence;
|
|
14
|
+
/** The function to call when the sequence is completed */
|
|
15
|
+
callback: HotkeyCallback;
|
|
16
|
+
/** Per-sequence options (merged on top of commonOptions) */
|
|
17
|
+
options?: UseHotkeySequenceOptions;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Octane hook for registering multiple keyboard shortcut sequences at once (Vim-style).
|
|
22
|
+
*
|
|
23
|
+
* Uses the singleton SequenceManager. Accepts a dynamic array of definitions so you can
|
|
24
|
+
* register variable-length lists.
|
|
25
|
+
*
|
|
26
|
+
* Options are merged in this order:
|
|
27
|
+
* HotkeysProvider defaults < commonOptions < per-definition options
|
|
28
|
+
*
|
|
29
|
+
* Callbacks and options are synced on every render to avoid stale closures.
|
|
30
|
+
*
|
|
31
|
+
* Definitions with an empty `sequence` are skipped (no registration).
|
|
32
|
+
*
|
|
33
|
+
* @param definitions - Array of sequence definitions to register
|
|
34
|
+
* @param commonOptions - Shared options applied to all sequences (overridden by per-definition options).
|
|
35
|
+
* Per-row `enabled: false` still registers that sequence: `SequenceManager` suppresses execution only (the row
|
|
36
|
+
* stays in the store and appears in TanStack Hotkeys devtools). Toggling `enabled` updates the existing handle
|
|
37
|
+
* via `setOptions` (no unregister/re-register churn).
|
|
38
|
+
*
|
|
39
|
+
* @example
|
|
40
|
+
* ```tsx
|
|
41
|
+
* function VimPalette() {
|
|
42
|
+
* useHotkeySequences([
|
|
43
|
+
* { sequence: ['G', 'G'], callback: () => scrollToTop() },
|
|
44
|
+
* { sequence: ['D', 'D'], callback: () => deleteLine() },
|
|
45
|
+
* { sequence: ['C', 'I', 'W'], callback: () => changeInnerWord(), options: { timeout: 500 } },
|
|
46
|
+
* ])
|
|
47
|
+
* }
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
export function useHotkeySequences(
|
|
51
|
+
definitions: Array<UseHotkeySequenceDefinition>,
|
|
52
|
+
commonOptions: UseHotkeySequenceOptions = {},
|
|
53
|
+
): void {
|
|
54
|
+
type RegistrationRecord = {
|
|
55
|
+
handle: SequenceRegistrationHandle;
|
|
56
|
+
target: Document | HTMLElement | Window;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const defaultOptions = useDefaultHotkeysOptions().hotkeySequence;
|
|
60
|
+
const manager = getSequenceManager();
|
|
61
|
+
|
|
62
|
+
const registrationsRef = useRef<Map<string, RegistrationRecord>>(new Map());
|
|
63
|
+
const definitionsRef = useRef(definitions);
|
|
64
|
+
const sequenceStringsRef = useRef<Array<string>>([]);
|
|
65
|
+
const commonOptionsRef = useRef(commonOptions);
|
|
66
|
+
const defaultOptionsRef = useRef(defaultOptions);
|
|
67
|
+
const managerRef = useRef(manager);
|
|
68
|
+
|
|
69
|
+
const sequenceStrings = definitions.map((def) => formatHotkeySequence(def.sequence));
|
|
70
|
+
|
|
71
|
+
definitionsRef.current = definitions;
|
|
72
|
+
sequenceStringsRef.current = sequenceStrings;
|
|
73
|
+
commonOptionsRef.current = commonOptions;
|
|
74
|
+
defaultOptionsRef.current = defaultOptions;
|
|
75
|
+
managerRef.current = manager;
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
const prevRegistrations = registrationsRef.current;
|
|
79
|
+
const nextRegistrations = new Map<string, RegistrationRecord>();
|
|
80
|
+
|
|
81
|
+
const rows: Array<{
|
|
82
|
+
registrationKey: string;
|
|
83
|
+
def: (typeof definitionsRef.current)[number];
|
|
84
|
+
seq: HotkeySequence;
|
|
85
|
+
seqStr: string;
|
|
86
|
+
mergedOptions: UseHotkeySequenceOptions;
|
|
87
|
+
resolvedTarget: Document | HTMLElement | Window;
|
|
88
|
+
}> = [];
|
|
89
|
+
|
|
90
|
+
for (let i = 0; i < definitionsRef.current.length; i++) {
|
|
91
|
+
const def = definitionsRef.current[i]!;
|
|
92
|
+
const seqStr = sequenceStringsRef.current[i]!;
|
|
93
|
+
const seq = def.sequence;
|
|
94
|
+
if (seq.length === 0) {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const mergedOptions = {
|
|
99
|
+
...defaultOptionsRef.current,
|
|
100
|
+
...commonOptionsRef.current,
|
|
101
|
+
...def.options,
|
|
102
|
+
} as UseHotkeySequenceOptions;
|
|
103
|
+
|
|
104
|
+
const resolvedTarget = isRef(mergedOptions.target)
|
|
105
|
+
? mergedOptions.target.current
|
|
106
|
+
: (mergedOptions.target ?? (typeof document !== 'undefined' ? document : null));
|
|
107
|
+
|
|
108
|
+
if (!resolvedTarget) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const registrationKey = `${i}:${seqStr}`;
|
|
113
|
+
rows.push({
|
|
114
|
+
registrationKey,
|
|
115
|
+
def,
|
|
116
|
+
seq,
|
|
117
|
+
seqStr,
|
|
118
|
+
mergedOptions,
|
|
119
|
+
resolvedTarget,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const nextKeys = new Set(rows.map((r) => r.registrationKey));
|
|
124
|
+
|
|
125
|
+
for (const [key, record] of prevRegistrations) {
|
|
126
|
+
if (!nextKeys.has(key) && record.handle.isActive) {
|
|
127
|
+
record.handle.unregister();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const row of rows) {
|
|
132
|
+
const { registrationKey, def, seq, mergedOptions, resolvedTarget } = row;
|
|
133
|
+
|
|
134
|
+
const existing = prevRegistrations.get(registrationKey);
|
|
135
|
+
if (existing?.handle.isActive && existing.target === resolvedTarget) {
|
|
136
|
+
nextRegistrations.set(registrationKey, existing);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (existing?.handle.isActive) {
|
|
141
|
+
existing.handle.unregister();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const handle = managerRef.current.register(seq, def.callback, {
|
|
145
|
+
...mergedOptions,
|
|
146
|
+
target: resolvedTarget,
|
|
147
|
+
});
|
|
148
|
+
nextRegistrations.set(registrationKey, {
|
|
149
|
+
handle,
|
|
150
|
+
target: resolvedTarget,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
registrationsRef.current = nextRegistrations;
|
|
155
|
+
// Upstream passes NO dependency array (sync registrations after every
|
|
156
|
+
// render). Octane infers omitted arrays, so `null` spells that out.
|
|
157
|
+
}, null);
|
|
158
|
+
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
return () => {
|
|
161
|
+
for (const { handle } of registrationsRef.current.values()) {
|
|
162
|
+
if (handle.isActive) {
|
|
163
|
+
handle.unregister();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
registrationsRef.current = new Map();
|
|
167
|
+
};
|
|
168
|
+
}, []);
|
|
169
|
+
|
|
170
|
+
for (let i = 0; i < definitions.length; i++) {
|
|
171
|
+
const def = definitions[i]!;
|
|
172
|
+
const seqStr = sequenceStrings[i]!;
|
|
173
|
+
const registrationKey = `${i}:${seqStr}`;
|
|
174
|
+
const handle = registrationsRef.current.get(registrationKey)?.handle;
|
|
175
|
+
|
|
176
|
+
if (handle?.isActive && def.sequence.length > 0) {
|
|
177
|
+
handle.callback = def.callback;
|
|
178
|
+
const mergedOptions = {
|
|
179
|
+
...defaultOptions,
|
|
180
|
+
...commonOptions,
|
|
181
|
+
...def.options,
|
|
182
|
+
} as UseHotkeySequenceOptions;
|
|
183
|
+
const { target: _target, ...optionsWithoutTarget } = mergedOptions;
|
|
184
|
+
handle.setOptions(optionsWithoutTarget);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'octane';
|
|
2
|
+
import { detectPlatform, getHotkeyManager, normalizeRegisterableHotkey } from '@tanstack/hotkeys';
|
|
3
|
+
import { useDefaultHotkeysOptions } from './context';
|
|
4
|
+
import { isRef } from './utils';
|
|
5
|
+
import type { UseHotkeyOptions } from './useHotkey';
|
|
6
|
+
import type {
|
|
7
|
+
Hotkey,
|
|
8
|
+
HotkeyCallback,
|
|
9
|
+
HotkeyRegistrationHandle,
|
|
10
|
+
RegisterableHotkey,
|
|
11
|
+
} from '@tanstack/hotkeys';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A single hotkey definition for use with `useHotkeys`.
|
|
15
|
+
*/
|
|
16
|
+
export interface UseHotkeyDefinition {
|
|
17
|
+
/** The hotkey string (e.g., 'Mod+S', 'Escape') or RawHotkey object */
|
|
18
|
+
hotkey: RegisterableHotkey;
|
|
19
|
+
/** The function to call when the hotkey is pressed */
|
|
20
|
+
callback: HotkeyCallback;
|
|
21
|
+
/** Per-hotkey options (merged on top of commonOptions) */
|
|
22
|
+
options?: UseHotkeyOptions;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Octane hook for registering multiple keyboard hotkeys at once.
|
|
27
|
+
*
|
|
28
|
+
* Uses the singleton HotkeyManager for efficient event handling.
|
|
29
|
+
* Accepts a dynamic array of hotkey definitions, making it safe to use
|
|
30
|
+
* with variable-length lists.
|
|
31
|
+
*
|
|
32
|
+
* Options are merged in this order:
|
|
33
|
+
* HotkeysProvider defaults < commonOptions < per-definition options
|
|
34
|
+
*
|
|
35
|
+
* Callbacks and options are synced on every render to avoid stale closures.
|
|
36
|
+
*
|
|
37
|
+
* @param hotkeys - Array of hotkey definitions to register
|
|
38
|
+
* @param commonOptions - Shared options applied to all hotkeys (overridden by per-definition options).
|
|
39
|
+
* Per-row `enabled: false` still registers that hotkey: `HotkeyManager` suppresses execution only (the row
|
|
40
|
+
* stays in the store and appears in TanStack Hotkeys devtools). Toggling `enabled` updates the existing handle
|
|
41
|
+
* via `setOptions` (no unregister/re-register churn).
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```tsx
|
|
45
|
+
* function Editor() {
|
|
46
|
+
* useHotkeys([
|
|
47
|
+
* { hotkey: 'Mod+S', callback: () => save() },
|
|
48
|
+
* { hotkey: 'Mod+Z', callback: () => undo() },
|
|
49
|
+
* { hotkey: 'Escape', callback: () => close() },
|
|
50
|
+
* ])
|
|
51
|
+
* }
|
|
52
|
+
* ```
|
|
53
|
+
*/
|
|
54
|
+
export function useHotkeys(
|
|
55
|
+
hotkeys: Array<UseHotkeyDefinition>,
|
|
56
|
+
commonOptions: UseHotkeyOptions = {},
|
|
57
|
+
): void {
|
|
58
|
+
type RegistrationRecord = {
|
|
59
|
+
handle: HotkeyRegistrationHandle;
|
|
60
|
+
target: Document | HTMLElement | Window;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const defaultOptions = useDefaultHotkeysOptions().hotkey;
|
|
64
|
+
const manager = getHotkeyManager();
|
|
65
|
+
const platform = commonOptions.platform ?? defaultOptions?.platform ?? detectPlatform();
|
|
66
|
+
|
|
67
|
+
const registrationsRef = useRef<Map<string, RegistrationRecord>>(new Map());
|
|
68
|
+
const hotkeysRef = useRef(hotkeys);
|
|
69
|
+
const hotkeyStringsRef = useRef<Array<Hotkey>>([]);
|
|
70
|
+
const commonOptionsRef = useRef(commonOptions);
|
|
71
|
+
const defaultOptionsRef = useRef(defaultOptions);
|
|
72
|
+
const managerRef = useRef(manager);
|
|
73
|
+
|
|
74
|
+
const hotkeyStrings = hotkeys.map((def) => normalizeRegisterableHotkey(def.hotkey, platform));
|
|
75
|
+
|
|
76
|
+
hotkeysRef.current = hotkeys;
|
|
77
|
+
hotkeyStringsRef.current = hotkeyStrings;
|
|
78
|
+
commonOptionsRef.current = commonOptions;
|
|
79
|
+
defaultOptionsRef.current = defaultOptions;
|
|
80
|
+
managerRef.current = manager;
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
const prevRegistrations = registrationsRef.current;
|
|
84
|
+
const nextRegistrations = new Map<string, RegistrationRecord>();
|
|
85
|
+
|
|
86
|
+
const rows: Array<{
|
|
87
|
+
registrationKey: string;
|
|
88
|
+
def: (typeof hotkeysRef.current)[number];
|
|
89
|
+
hotkeyStr: Hotkey;
|
|
90
|
+
mergedOptions: UseHotkeyOptions;
|
|
91
|
+
resolvedTarget: Document | HTMLElement | Window;
|
|
92
|
+
}> = [];
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < hotkeysRef.current.length; i++) {
|
|
95
|
+
const def = hotkeysRef.current[i]!;
|
|
96
|
+
const hotkeyStr = hotkeyStringsRef.current[i]!;
|
|
97
|
+
const mergedOptions = {
|
|
98
|
+
...defaultOptionsRef.current,
|
|
99
|
+
...commonOptionsRef.current,
|
|
100
|
+
...def.options,
|
|
101
|
+
} as UseHotkeyOptions;
|
|
102
|
+
|
|
103
|
+
const resolvedTarget = isRef(mergedOptions.target)
|
|
104
|
+
? mergedOptions.target.current
|
|
105
|
+
: (mergedOptions.target ?? (typeof document !== 'undefined' ? document : null));
|
|
106
|
+
|
|
107
|
+
if (!resolvedTarget) {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const registrationKey = `${i}:${hotkeyStr}`;
|
|
112
|
+
rows.push({
|
|
113
|
+
registrationKey,
|
|
114
|
+
def,
|
|
115
|
+
hotkeyStr,
|
|
116
|
+
mergedOptions,
|
|
117
|
+
resolvedTarget,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const nextKeys = new Set(rows.map((r) => r.registrationKey));
|
|
122
|
+
|
|
123
|
+
for (const [key, record] of prevRegistrations) {
|
|
124
|
+
if (!nextKeys.has(key) && record.handle.isActive) {
|
|
125
|
+
record.handle.unregister();
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
for (const row of rows) {
|
|
130
|
+
const { registrationKey, def, hotkeyStr, mergedOptions, resolvedTarget } = row;
|
|
131
|
+
|
|
132
|
+
const existing = prevRegistrations.get(registrationKey);
|
|
133
|
+
if (existing?.handle.isActive && existing.target === resolvedTarget) {
|
|
134
|
+
nextRegistrations.set(registrationKey, existing);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (existing?.handle.isActive) {
|
|
139
|
+
existing.handle.unregister();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const handle = managerRef.current.register(hotkeyStr, def.callback, {
|
|
143
|
+
...mergedOptions,
|
|
144
|
+
target: resolvedTarget,
|
|
145
|
+
});
|
|
146
|
+
nextRegistrations.set(registrationKey, {
|
|
147
|
+
handle,
|
|
148
|
+
target: resolvedTarget,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
registrationsRef.current = nextRegistrations;
|
|
153
|
+
// Upstream passes NO dependency array (sync registrations after every
|
|
154
|
+
// render). Octane infers omitted arrays, so `null` spells that out.
|
|
155
|
+
}, null);
|
|
156
|
+
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
return () => {
|
|
159
|
+
for (const { handle } of registrationsRef.current.values()) {
|
|
160
|
+
if (handle.isActive) {
|
|
161
|
+
handle.unregister();
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
registrationsRef.current = new Map();
|
|
165
|
+
};
|
|
166
|
+
}, []);
|
|
167
|
+
|
|
168
|
+
// Sync callbacks and options on EVERY render (outside useEffect)
|
|
169
|
+
for (let i = 0; i < hotkeys.length; i++) {
|
|
170
|
+
const def = hotkeys[i]!;
|
|
171
|
+
const hotkeyStr = hotkeyStrings[i]!;
|
|
172
|
+
const registrationKey = `${i}:${hotkeyStr}`;
|
|
173
|
+
const handle = registrationsRef.current.get(registrationKey)?.handle;
|
|
174
|
+
|
|
175
|
+
if (handle?.isActive) {
|
|
176
|
+
handle.callback = def.callback;
|
|
177
|
+
const mergedOptions = {
|
|
178
|
+
...defaultOptions,
|
|
179
|
+
...commonOptions,
|
|
180
|
+
...def.options,
|
|
181
|
+
} as UseHotkeyOptions;
|
|
182
|
+
const { target: _target, ...optionsWithoutTarget } = mergedOptions;
|
|
183
|
+
handle.setOptions(optionsWithoutTarget);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { getKeyStateTracker } from '@tanstack/hotkeys';
|
|
2
|
+
import type { IndividualKey } from '@tanstack/hotkeys';
|
|
3
|
+
import { useSelectorSlot } from './internal';
|
|
4
|
+
|
|
5
|
+
const keyHoldSlot = Symbol.for('@octanejs/tanstack-hotkeys:useKeyHold');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Octane hook that returns whether a specific key is currently being held.
|
|
9
|
+
*
|
|
10
|
+
* This hook uses `useSelector` from `@octanejs/tanstack-store` to subscribe
|
|
11
|
+
* to the global KeyStateTracker and uses a selector to determine if
|
|
12
|
+
* the specified key is held.
|
|
13
|
+
*
|
|
14
|
+
* @param key - The key to check (e.g., 'Shift', 'Control', 'A')
|
|
15
|
+
* @returns True if the key is currently held down
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* ```tsx
|
|
19
|
+
* function ShiftIndicator() {
|
|
20
|
+
* const isShiftHeld = useKeyHold('Shift')
|
|
21
|
+
*
|
|
22
|
+
* return (
|
|
23
|
+
* <div style={{ opacity: isShiftHeld ? 1 : 0.5 }}>
|
|
24
|
+
* {isShiftHeld ? 'Shift is pressed!' : 'Press Shift'}
|
|
25
|
+
* </div>
|
|
26
|
+
* )
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export function useKeyHold(key: IndividualKey): boolean {
|
|
31
|
+
const tracker = getKeyStateTracker();
|
|
32
|
+
const normalizedKey = key.toLowerCase();
|
|
33
|
+
|
|
34
|
+
return useSelectorSlot(
|
|
35
|
+
tracker.store,
|
|
36
|
+
(state) => state.heldKeys.some((heldKey) => heldKey.toLowerCase() === normalizedKey),
|
|
37
|
+
keyHoldSlot,
|
|
38
|
+
);
|
|
39
|
+
}
|
package/src/utils.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A React-19-style ref object shape (Octane refs are ordinary objects with a
|
|
3
|
+
* mutable `current`).
|
|
4
|
+
*/
|
|
5
|
+
export interface RefObjectLike<T> {
|
|
6
|
+
current: T;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Type guard to check if a value is a ref-like object.
|
|
11
|
+
*/
|
|
12
|
+
export function isRef(value: unknown): value is RefObjectLike<HTMLElement | null> {
|
|
13
|
+
return value !== null && typeof value === 'object' && 'current' in value;
|
|
14
|
+
}
|