@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 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';
@@ -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
+ }
@@ -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
+ }