@lacspace/hotkeys 1.0.0
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 +51 -0
- package/README.md +173 -0
- package/dist/index.cjs +292 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +205 -0
- package/dist/index.d.ts +205 -0
- package/dist/index.js +281 -0
- package/dist/index.js.map +1 -0
- package/package.json +23 -0
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @lacspace/hotkeys — ergonomic keyboard shortcuts for React.
|
|
5
|
+
*
|
|
6
|
+
* Combos (`mod+k`), key sequences (`g then d`), scopes, and pretty display
|
|
7
|
+
* formatting (`⌘K`). SSR-safe, respects form fields, zero-dependency, fully typed.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A parsed hotkey combo: the resolved key plus each modifier requirement. */
|
|
13
|
+
interface ParsedHotkey {
|
|
14
|
+
/** Normalized key (e.g. `"k"`, `"escape"`, `" "`, `"arrowup"`). */
|
|
15
|
+
key: string;
|
|
16
|
+
/** `mod` — Cmd on mac, Ctrl elsewhere. */
|
|
17
|
+
mod: boolean;
|
|
18
|
+
/** Control key. */
|
|
19
|
+
ctrl: boolean;
|
|
20
|
+
/** Alt / Option key. */
|
|
21
|
+
alt: boolean;
|
|
22
|
+
/** Shift key. */
|
|
23
|
+
shift: boolean;
|
|
24
|
+
/** Meta / Cmd / Win key. */
|
|
25
|
+
meta: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** The handler invoked when a hotkey (or the final step of a sequence) fires. */
|
|
28
|
+
type HotkeyHandler = (event: KeyboardEvent, combo: string) => void;
|
|
29
|
+
/** Where to attach the key listener. */
|
|
30
|
+
type HotkeyTarget = Window | HTMLElement | RefObject<HTMLElement | null>;
|
|
31
|
+
/** Options for {@link useHotkeys}. */
|
|
32
|
+
interface HotkeyOptions {
|
|
33
|
+
/** Master switch. When `false`, nothing fires. @default true */
|
|
34
|
+
enabled?: boolean;
|
|
35
|
+
/** Call `event.preventDefault()` when a hotkey matches. @default true */
|
|
36
|
+
preventDefault?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Allow firing while an `input` / `textarea` / `select` / `contentEditable`
|
|
39
|
+
* element is the event source. @default false
|
|
40
|
+
*/
|
|
41
|
+
enableOnFormTags?: boolean;
|
|
42
|
+
/** Which key event to listen for. @default "keydown" */
|
|
43
|
+
eventType?: "keydown" | "keyup";
|
|
44
|
+
/** Where to bind the listener. @default window */
|
|
45
|
+
target?: HotkeyTarget;
|
|
46
|
+
/**
|
|
47
|
+
* Scope name(s). The hotkey only fires when at least one is active
|
|
48
|
+
* (see {@link enableScope}). Omit to always fire.
|
|
49
|
+
*/
|
|
50
|
+
scopes?: string | string[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Detects whether the current platform is a Mac (or iOS device).
|
|
54
|
+
*
|
|
55
|
+
* SSR-safe: always returns `false` when there is no `navigator`.
|
|
56
|
+
*
|
|
57
|
+
* @returns `true` on macOS / iOS, `false` otherwise (and on the server).
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* const symbol = isMac() ? "⌘" : "Ctrl";
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
declare function isMac(): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Parses a combo string like `"mod+shift+k"` into modifier flags and a key.
|
|
67
|
+
*
|
|
68
|
+
* Tokens split on `"+"`, case-insensitive. Modifiers: `mod` (Cmd on mac / Ctrl
|
|
69
|
+
* elsewhere), `ctrl`/`control`, `alt`/`option`, `shift`, `meta`/`cmd`/`command`/`win`.
|
|
70
|
+
* The remaining token is the key (`esc`→`escape`, `space`→`" "`, arrows→`arrowup`…,
|
|
71
|
+
* single letters lowercased).
|
|
72
|
+
*
|
|
73
|
+
* @param str - The combo string, e.g. `"mod+k"` or `"ctrl+shift+escape"`.
|
|
74
|
+
* @returns The parsed combo.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```ts
|
|
78
|
+
* parseHotkey("mod+shift+k");
|
|
79
|
+
* // { key: "k", mod: true, ctrl: false, alt: false, shift: true, meta: false }
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
declare function parseHotkey(str: string): ParsedHotkey;
|
|
83
|
+
/**
|
|
84
|
+
* Returns `true` when a keyboard event satisfies a combo string.
|
|
85
|
+
*
|
|
86
|
+
* `mod` resolves to `metaKey` on mac and `ctrlKey` elsewhere. Modifiers must
|
|
87
|
+
* match exactly (so `"ctrl+k"` does not fire when `Ctrl+Shift+K` is pressed).
|
|
88
|
+
*
|
|
89
|
+
* @param event - The keyboard event.
|
|
90
|
+
* @param combo - The combo string, e.g. `"mod+k"`.
|
|
91
|
+
* @returns Whether the event satisfies the combo.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* window.addEventListener("keydown", (e) => {
|
|
96
|
+
* if (matchesHotkey(e, "mod+k")) openPalette();
|
|
97
|
+
* });
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
declare function matchesHotkey(event: KeyboardEvent, combo: string): boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Formats a combo for display, e.g. mac → `"⌘⇧K"`, non-mac → `"Ctrl+Shift+K"`.
|
|
103
|
+
*
|
|
104
|
+
* Auto-detects the platform when `opts.mac` is omitted.
|
|
105
|
+
*
|
|
106
|
+
* @param combo - The combo string, e.g. `"mod+shift+k"`.
|
|
107
|
+
* @param opts - Optional overrides.
|
|
108
|
+
* @param opts.mac - Force mac (`true`) or non-mac (`false`) rendering.
|
|
109
|
+
* @returns A human-friendly label.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```tsx
|
|
113
|
+
* <kbd>{formatHotkey("mod+k")}</kbd> // "⌘K" on mac, "Ctrl+K" elsewhere
|
|
114
|
+
* formatHotkey("ctrl+shift+k", { mac: false }); // "Ctrl+Shift+K"
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
declare function formatHotkey(combo: string, opts?: {
|
|
118
|
+
mac?: boolean;
|
|
119
|
+
}): string;
|
|
120
|
+
/**
|
|
121
|
+
* Activates a scope. Hotkeys bound to this scope will start firing.
|
|
122
|
+
*
|
|
123
|
+
* @param name - The scope name.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* enableScope("editor"); // now editor hotkeys are live
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
declare function enableScope(name: string): void;
|
|
131
|
+
/**
|
|
132
|
+
* Deactivates a scope. Hotkeys bound only to this scope stop firing.
|
|
133
|
+
*
|
|
134
|
+
* @param name - The scope name.
|
|
135
|
+
*/
|
|
136
|
+
declare function disableScope(name: string): void;
|
|
137
|
+
/**
|
|
138
|
+
* Toggles a scope on or off.
|
|
139
|
+
*
|
|
140
|
+
* @param name - The scope name.
|
|
141
|
+
*/
|
|
142
|
+
declare function toggleScope(name: string): void;
|
|
143
|
+
/** Returns whether a scope is currently active. */
|
|
144
|
+
declare function isScopeActive(name: string): boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Subscribes to the set of active scopes and exposes controls.
|
|
147
|
+
*
|
|
148
|
+
* The component re-renders whenever scopes change (backed by
|
|
149
|
+
* `useSyncExternalStore`, so it is concurrent-safe and SSR-safe).
|
|
150
|
+
*
|
|
151
|
+
* @returns `{ activeScopes, enableScope, disableScope, toggleScope }`.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```tsx
|
|
155
|
+
* function ScopeBadge() {
|
|
156
|
+
* const { activeScopes, toggleScope } = useHotkeysScopes();
|
|
157
|
+
* return (
|
|
158
|
+
* <button onClick={() => toggleScope("editor")}>
|
|
159
|
+
* {activeScopes.includes("editor") ? "Editor on" : "Editor off"}
|
|
160
|
+
* </button>
|
|
161
|
+
* );
|
|
162
|
+
* }
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
declare function useHotkeysScopes(): {
|
|
166
|
+
activeScopes: readonly string[];
|
|
167
|
+
enableScope: (name: string) => void;
|
|
168
|
+
disableScope: (name: string) => void;
|
|
169
|
+
toggleScope: (name: string) => void;
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Binds keyboard shortcut(s) — combos and/or sequences — for the lifetime of a component.
|
|
173
|
+
*
|
|
174
|
+
* Supports:
|
|
175
|
+
* - **Combos**: `"mod+k"`, `"ctrl+shift+p"`.
|
|
176
|
+
* - **Multiple combos**: pass an array — any match fires the handler.
|
|
177
|
+
* - **Sequences**: `"g then d"` or `"g d"` — press keys in order within ~1s.
|
|
178
|
+
* - **Scopes**: only fire while a named scope is active (see {@link enableScope}).
|
|
179
|
+
*
|
|
180
|
+
* The handler is kept in a ref, so `deps` are optional — the latest closure is
|
|
181
|
+
* always used without re-binding the listener.
|
|
182
|
+
*
|
|
183
|
+
* @param keys - A combo/sequence string, or an array of them.
|
|
184
|
+
* @param handler - Called with the event and the matched combo string.
|
|
185
|
+
* @param options - See {@link HotkeyOptions}.
|
|
186
|
+
* @param deps - Optional dependency list (rarely needed thanks to the latest-ref).
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* ```tsx
|
|
190
|
+
* // Open a command palette with ⌘K / Ctrl+K
|
|
191
|
+
* useHotkeys("mod+k", (e) => {
|
|
192
|
+
* e.preventDefault();
|
|
193
|
+
* setPaletteOpen(true);
|
|
194
|
+
* });
|
|
195
|
+
*
|
|
196
|
+
* // Navigate with a sequence: press "g" then "d"
|
|
197
|
+
* useHotkeys("g then d", () => router.push("/dashboard"));
|
|
198
|
+
*
|
|
199
|
+
* // Scoped: only while the "editor" scope is active
|
|
200
|
+
* useHotkeys("mod+b", toggleBold, { scopes: "editor" });
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
declare function useHotkeys(keys: string | string[], handler: HotkeyHandler, options?: HotkeyOptions, deps?: unknown[]): void;
|
|
204
|
+
|
|
205
|
+
export { type HotkeyHandler, type HotkeyOptions, type HotkeyTarget, type ParsedHotkey, disableScope, enableScope, formatHotkey, isMac, isScopeActive, matchesHotkey, parseHotkey, toggleScope, useHotkeys, useHotkeysScopes };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import { RefObject } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @lacspace/hotkeys — ergonomic keyboard shortcuts for React.
|
|
5
|
+
*
|
|
6
|
+
* Combos (`mod+k`), key sequences (`g then d`), scopes, and pretty display
|
|
7
|
+
* formatting (`⌘K`). SSR-safe, respects form fields, zero-dependency, fully typed.
|
|
8
|
+
*
|
|
9
|
+
* @packageDocumentation
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** A parsed hotkey combo: the resolved key plus each modifier requirement. */
|
|
13
|
+
interface ParsedHotkey {
|
|
14
|
+
/** Normalized key (e.g. `"k"`, `"escape"`, `" "`, `"arrowup"`). */
|
|
15
|
+
key: string;
|
|
16
|
+
/** `mod` — Cmd on mac, Ctrl elsewhere. */
|
|
17
|
+
mod: boolean;
|
|
18
|
+
/** Control key. */
|
|
19
|
+
ctrl: boolean;
|
|
20
|
+
/** Alt / Option key. */
|
|
21
|
+
alt: boolean;
|
|
22
|
+
/** Shift key. */
|
|
23
|
+
shift: boolean;
|
|
24
|
+
/** Meta / Cmd / Win key. */
|
|
25
|
+
meta: boolean;
|
|
26
|
+
}
|
|
27
|
+
/** The handler invoked when a hotkey (or the final step of a sequence) fires. */
|
|
28
|
+
type HotkeyHandler = (event: KeyboardEvent, combo: string) => void;
|
|
29
|
+
/** Where to attach the key listener. */
|
|
30
|
+
type HotkeyTarget = Window | HTMLElement | RefObject<HTMLElement | null>;
|
|
31
|
+
/** Options for {@link useHotkeys}. */
|
|
32
|
+
interface HotkeyOptions {
|
|
33
|
+
/** Master switch. When `false`, nothing fires. @default true */
|
|
34
|
+
enabled?: boolean;
|
|
35
|
+
/** Call `event.preventDefault()` when a hotkey matches. @default true */
|
|
36
|
+
preventDefault?: boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Allow firing while an `input` / `textarea` / `select` / `contentEditable`
|
|
39
|
+
* element is the event source. @default false
|
|
40
|
+
*/
|
|
41
|
+
enableOnFormTags?: boolean;
|
|
42
|
+
/** Which key event to listen for. @default "keydown" */
|
|
43
|
+
eventType?: "keydown" | "keyup";
|
|
44
|
+
/** Where to bind the listener. @default window */
|
|
45
|
+
target?: HotkeyTarget;
|
|
46
|
+
/**
|
|
47
|
+
* Scope name(s). The hotkey only fires when at least one is active
|
|
48
|
+
* (see {@link enableScope}). Omit to always fire.
|
|
49
|
+
*/
|
|
50
|
+
scopes?: string | string[];
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Detects whether the current platform is a Mac (or iOS device).
|
|
54
|
+
*
|
|
55
|
+
* SSR-safe: always returns `false` when there is no `navigator`.
|
|
56
|
+
*
|
|
57
|
+
* @returns `true` on macOS / iOS, `false` otherwise (and on the server).
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* const symbol = isMac() ? "⌘" : "Ctrl";
|
|
62
|
+
* ```
|
|
63
|
+
*/
|
|
64
|
+
declare function isMac(): boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Parses a combo string like `"mod+shift+k"` into modifier flags and a key.
|
|
67
|
+
*
|
|
68
|
+
* Tokens split on `"+"`, case-insensitive. Modifiers: `mod` (Cmd on mac / Ctrl
|
|
69
|
+
* elsewhere), `ctrl`/`control`, `alt`/`option`, `shift`, `meta`/`cmd`/`command`/`win`.
|
|
70
|
+
* The remaining token is the key (`esc`→`escape`, `space`→`" "`, arrows→`arrowup`…,
|
|
71
|
+
* single letters lowercased).
|
|
72
|
+
*
|
|
73
|
+
* @param str - The combo string, e.g. `"mod+k"` or `"ctrl+shift+escape"`.
|
|
74
|
+
* @returns The parsed combo.
|
|
75
|
+
*
|
|
76
|
+
* @example
|
|
77
|
+
* ```ts
|
|
78
|
+
* parseHotkey("mod+shift+k");
|
|
79
|
+
* // { key: "k", mod: true, ctrl: false, alt: false, shift: true, meta: false }
|
|
80
|
+
* ```
|
|
81
|
+
*/
|
|
82
|
+
declare function parseHotkey(str: string): ParsedHotkey;
|
|
83
|
+
/**
|
|
84
|
+
* Returns `true` when a keyboard event satisfies a combo string.
|
|
85
|
+
*
|
|
86
|
+
* `mod` resolves to `metaKey` on mac and `ctrlKey` elsewhere. Modifiers must
|
|
87
|
+
* match exactly (so `"ctrl+k"` does not fire when `Ctrl+Shift+K` is pressed).
|
|
88
|
+
*
|
|
89
|
+
* @param event - The keyboard event.
|
|
90
|
+
* @param combo - The combo string, e.g. `"mod+k"`.
|
|
91
|
+
* @returns Whether the event satisfies the combo.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* window.addEventListener("keydown", (e) => {
|
|
96
|
+
* if (matchesHotkey(e, "mod+k")) openPalette();
|
|
97
|
+
* });
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
declare function matchesHotkey(event: KeyboardEvent, combo: string): boolean;
|
|
101
|
+
/**
|
|
102
|
+
* Formats a combo for display, e.g. mac → `"⌘⇧K"`, non-mac → `"Ctrl+Shift+K"`.
|
|
103
|
+
*
|
|
104
|
+
* Auto-detects the platform when `opts.mac` is omitted.
|
|
105
|
+
*
|
|
106
|
+
* @param combo - The combo string, e.g. `"mod+shift+k"`.
|
|
107
|
+
* @param opts - Optional overrides.
|
|
108
|
+
* @param opts.mac - Force mac (`true`) or non-mac (`false`) rendering.
|
|
109
|
+
* @returns A human-friendly label.
|
|
110
|
+
*
|
|
111
|
+
* @example
|
|
112
|
+
* ```tsx
|
|
113
|
+
* <kbd>{formatHotkey("mod+k")}</kbd> // "⌘K" on mac, "Ctrl+K" elsewhere
|
|
114
|
+
* formatHotkey("ctrl+shift+k", { mac: false }); // "Ctrl+Shift+K"
|
|
115
|
+
* ```
|
|
116
|
+
*/
|
|
117
|
+
declare function formatHotkey(combo: string, opts?: {
|
|
118
|
+
mac?: boolean;
|
|
119
|
+
}): string;
|
|
120
|
+
/**
|
|
121
|
+
* Activates a scope. Hotkeys bound to this scope will start firing.
|
|
122
|
+
*
|
|
123
|
+
* @param name - The scope name.
|
|
124
|
+
*
|
|
125
|
+
* @example
|
|
126
|
+
* ```ts
|
|
127
|
+
* enableScope("editor"); // now editor hotkeys are live
|
|
128
|
+
* ```
|
|
129
|
+
*/
|
|
130
|
+
declare function enableScope(name: string): void;
|
|
131
|
+
/**
|
|
132
|
+
* Deactivates a scope. Hotkeys bound only to this scope stop firing.
|
|
133
|
+
*
|
|
134
|
+
* @param name - The scope name.
|
|
135
|
+
*/
|
|
136
|
+
declare function disableScope(name: string): void;
|
|
137
|
+
/**
|
|
138
|
+
* Toggles a scope on or off.
|
|
139
|
+
*
|
|
140
|
+
* @param name - The scope name.
|
|
141
|
+
*/
|
|
142
|
+
declare function toggleScope(name: string): void;
|
|
143
|
+
/** Returns whether a scope is currently active. */
|
|
144
|
+
declare function isScopeActive(name: string): boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Subscribes to the set of active scopes and exposes controls.
|
|
147
|
+
*
|
|
148
|
+
* The component re-renders whenever scopes change (backed by
|
|
149
|
+
* `useSyncExternalStore`, so it is concurrent-safe and SSR-safe).
|
|
150
|
+
*
|
|
151
|
+
* @returns `{ activeScopes, enableScope, disableScope, toggleScope }`.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ```tsx
|
|
155
|
+
* function ScopeBadge() {
|
|
156
|
+
* const { activeScopes, toggleScope } = useHotkeysScopes();
|
|
157
|
+
* return (
|
|
158
|
+
* <button onClick={() => toggleScope("editor")}>
|
|
159
|
+
* {activeScopes.includes("editor") ? "Editor on" : "Editor off"}
|
|
160
|
+
* </button>
|
|
161
|
+
* );
|
|
162
|
+
* }
|
|
163
|
+
* ```
|
|
164
|
+
*/
|
|
165
|
+
declare function useHotkeysScopes(): {
|
|
166
|
+
activeScopes: readonly string[];
|
|
167
|
+
enableScope: (name: string) => void;
|
|
168
|
+
disableScope: (name: string) => void;
|
|
169
|
+
toggleScope: (name: string) => void;
|
|
170
|
+
};
|
|
171
|
+
/**
|
|
172
|
+
* Binds keyboard shortcut(s) — combos and/or sequences — for the lifetime of a component.
|
|
173
|
+
*
|
|
174
|
+
* Supports:
|
|
175
|
+
* - **Combos**: `"mod+k"`, `"ctrl+shift+p"`.
|
|
176
|
+
* - **Multiple combos**: pass an array — any match fires the handler.
|
|
177
|
+
* - **Sequences**: `"g then d"` or `"g d"` — press keys in order within ~1s.
|
|
178
|
+
* - **Scopes**: only fire while a named scope is active (see {@link enableScope}).
|
|
179
|
+
*
|
|
180
|
+
* The handler is kept in a ref, so `deps` are optional — the latest closure is
|
|
181
|
+
* always used without re-binding the listener.
|
|
182
|
+
*
|
|
183
|
+
* @param keys - A combo/sequence string, or an array of them.
|
|
184
|
+
* @param handler - Called with the event and the matched combo string.
|
|
185
|
+
* @param options - See {@link HotkeyOptions}.
|
|
186
|
+
* @param deps - Optional dependency list (rarely needed thanks to the latest-ref).
|
|
187
|
+
*
|
|
188
|
+
* @example
|
|
189
|
+
* ```tsx
|
|
190
|
+
* // Open a command palette with ⌘K / Ctrl+K
|
|
191
|
+
* useHotkeys("mod+k", (e) => {
|
|
192
|
+
* e.preventDefault();
|
|
193
|
+
* setPaletteOpen(true);
|
|
194
|
+
* });
|
|
195
|
+
*
|
|
196
|
+
* // Navigate with a sequence: press "g" then "d"
|
|
197
|
+
* useHotkeys("g then d", () => router.push("/dashboard"));
|
|
198
|
+
*
|
|
199
|
+
* // Scoped: only while the "editor" scope is active
|
|
200
|
+
* useHotkeys("mod+b", toggleBold, { scopes: "editor" });
|
|
201
|
+
* ```
|
|
202
|
+
*/
|
|
203
|
+
declare function useHotkeys(keys: string | string[], handler: HotkeyHandler, options?: HotkeyOptions, deps?: unknown[]): void;
|
|
204
|
+
|
|
205
|
+
export { type HotkeyHandler, type HotkeyOptions, type HotkeyTarget, type ParsedHotkey, disableScope, enableScope, formatHotkey, isMac, isScopeActive, matchesHotkey, parseHotkey, toggleScope, useHotkeys, useHotkeysScopes };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
import { useSyncExternalStore, useRef, useEffect } from 'react';
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
function isMac() {
|
|
5
|
+
if (typeof navigator === "undefined") return false;
|
|
6
|
+
const uaData = navigator.userAgentData;
|
|
7
|
+
if (uaData && typeof uaData.platform === "string" && uaData.platform) {
|
|
8
|
+
return /mac/i.test(uaData.platform);
|
|
9
|
+
}
|
|
10
|
+
const platform = navigator.platform || "";
|
|
11
|
+
if (platform) return /mac|iphone|ipad|ipod/i.test(platform);
|
|
12
|
+
return /mac|iphone|ipad|ipod/i.test(navigator.userAgent || "");
|
|
13
|
+
}
|
|
14
|
+
var MOD_TOKENS = {
|
|
15
|
+
mod: "mod",
|
|
16
|
+
ctrl: "ctrl",
|
|
17
|
+
control: "ctrl",
|
|
18
|
+
alt: "alt",
|
|
19
|
+
option: "alt",
|
|
20
|
+
opt: "alt",
|
|
21
|
+
shift: "shift",
|
|
22
|
+
meta: "meta",
|
|
23
|
+
cmd: "meta",
|
|
24
|
+
command: "meta",
|
|
25
|
+
win: "meta",
|
|
26
|
+
super: "meta"
|
|
27
|
+
};
|
|
28
|
+
var KEY_ALIASES = {
|
|
29
|
+
esc: "escape",
|
|
30
|
+
space: " ",
|
|
31
|
+
spacebar: " ",
|
|
32
|
+
up: "arrowup",
|
|
33
|
+
down: "arrowdown",
|
|
34
|
+
left: "arrowleft",
|
|
35
|
+
right: "arrowright",
|
|
36
|
+
return: "enter",
|
|
37
|
+
del: "delete",
|
|
38
|
+
ins: "insert",
|
|
39
|
+
pgup: "pageup",
|
|
40
|
+
pgdn: "pagedown"
|
|
41
|
+
};
|
|
42
|
+
function normalizeKey(raw) {
|
|
43
|
+
const k = raw.toLowerCase();
|
|
44
|
+
const aliased = KEY_ALIASES[k];
|
|
45
|
+
if (aliased !== void 0) return aliased;
|
|
46
|
+
return k;
|
|
47
|
+
}
|
|
48
|
+
function parseHotkey(str) {
|
|
49
|
+
const parsed = {
|
|
50
|
+
key: "",
|
|
51
|
+
mod: false,
|
|
52
|
+
ctrl: false,
|
|
53
|
+
alt: false,
|
|
54
|
+
shift: false,
|
|
55
|
+
meta: false
|
|
56
|
+
};
|
|
57
|
+
const tokens = str.split("+");
|
|
58
|
+
for (const token of tokens) {
|
|
59
|
+
const t = token.trim().toLowerCase();
|
|
60
|
+
if (t === "") continue;
|
|
61
|
+
const modKey = MOD_TOKENS[t];
|
|
62
|
+
if (modKey) {
|
|
63
|
+
parsed[modKey] = true;
|
|
64
|
+
} else {
|
|
65
|
+
parsed.key = normalizeKey(token.trim());
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return parsed;
|
|
69
|
+
}
|
|
70
|
+
function matchesParsed(event, parsed, mac) {
|
|
71
|
+
const wantMeta = parsed.meta || mac && parsed.mod;
|
|
72
|
+
const wantCtrl = parsed.ctrl || !mac && parsed.mod;
|
|
73
|
+
if (event.metaKey !== wantMeta) return false;
|
|
74
|
+
if (event.ctrlKey !== wantCtrl) return false;
|
|
75
|
+
if (event.altKey !== parsed.alt) return false;
|
|
76
|
+
if (event.shiftKey !== parsed.shift) return false;
|
|
77
|
+
return event.key.toLowerCase() === parsed.key;
|
|
78
|
+
}
|
|
79
|
+
function matchesHotkey(event, combo) {
|
|
80
|
+
return matchesParsed(event, parseHotkey(combo), isMac());
|
|
81
|
+
}
|
|
82
|
+
function formatKeyLabel(key, mac) {
|
|
83
|
+
if (!key) return "";
|
|
84
|
+
switch (key) {
|
|
85
|
+
case " ":
|
|
86
|
+
return "Space";
|
|
87
|
+
case "escape":
|
|
88
|
+
return "Esc";
|
|
89
|
+
case "enter":
|
|
90
|
+
return mac ? "\u21B5" : "Enter";
|
|
91
|
+
case "arrowup":
|
|
92
|
+
return mac ? "\u2191" : "Up";
|
|
93
|
+
case "arrowdown":
|
|
94
|
+
return mac ? "\u2193" : "Down";
|
|
95
|
+
case "arrowleft":
|
|
96
|
+
return mac ? "\u2190" : "Left";
|
|
97
|
+
case "arrowright":
|
|
98
|
+
return mac ? "\u2192" : "Right";
|
|
99
|
+
case "backspace":
|
|
100
|
+
return mac ? "\u232B" : "Backspace";
|
|
101
|
+
case "delete":
|
|
102
|
+
return mac ? "\u2326" : "Del";
|
|
103
|
+
case "tab":
|
|
104
|
+
return mac ? "\u21E5" : "Tab";
|
|
105
|
+
}
|
|
106
|
+
if (key.length === 1) return key.toUpperCase();
|
|
107
|
+
return key.charAt(0).toUpperCase() + key.slice(1);
|
|
108
|
+
}
|
|
109
|
+
function formatHotkey(combo, opts = {}) {
|
|
110
|
+
const mac = opts.mac ?? isMac();
|
|
111
|
+
const p = parseHotkey(combo);
|
|
112
|
+
const parts = [];
|
|
113
|
+
if (mac) {
|
|
114
|
+
if (p.meta || p.mod) parts.push("\u2318");
|
|
115
|
+
if (p.ctrl) parts.push("\u2303");
|
|
116
|
+
if (p.alt) parts.push("\u2325");
|
|
117
|
+
if (p.shift) parts.push("\u21E7");
|
|
118
|
+
parts.push(formatKeyLabel(p.key, true));
|
|
119
|
+
return parts.join("");
|
|
120
|
+
}
|
|
121
|
+
if (p.ctrl || p.mod) parts.push("Ctrl");
|
|
122
|
+
if (p.alt) parts.push("Alt");
|
|
123
|
+
if (p.shift) parts.push("Shift");
|
|
124
|
+
if (p.meta) parts.push("Win");
|
|
125
|
+
parts.push(formatKeyLabel(p.key, false));
|
|
126
|
+
return parts.join("+");
|
|
127
|
+
}
|
|
128
|
+
var activeScopeSet = /* @__PURE__ */ new Set();
|
|
129
|
+
var scopeListeners = /* @__PURE__ */ new Set();
|
|
130
|
+
var EMPTY_SCOPES = Object.freeze([]);
|
|
131
|
+
var scopesSnapshot = [];
|
|
132
|
+
function refreshScopesSnapshot() {
|
|
133
|
+
scopesSnapshot = Array.from(activeScopeSet);
|
|
134
|
+
}
|
|
135
|
+
function emitScopeChange() {
|
|
136
|
+
for (const listener of scopeListeners) listener();
|
|
137
|
+
}
|
|
138
|
+
function enableScope(name) {
|
|
139
|
+
if (!activeScopeSet.has(name)) {
|
|
140
|
+
activeScopeSet.add(name);
|
|
141
|
+
refreshScopesSnapshot();
|
|
142
|
+
emitScopeChange();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function disableScope(name) {
|
|
146
|
+
if (activeScopeSet.delete(name)) {
|
|
147
|
+
refreshScopesSnapshot();
|
|
148
|
+
emitScopeChange();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function toggleScope(name) {
|
|
152
|
+
if (activeScopeSet.has(name)) disableScope(name);
|
|
153
|
+
else enableScope(name);
|
|
154
|
+
}
|
|
155
|
+
function isScopeActive(name) {
|
|
156
|
+
return activeScopeSet.has(name);
|
|
157
|
+
}
|
|
158
|
+
function subscribeScopes(callback) {
|
|
159
|
+
scopeListeners.add(callback);
|
|
160
|
+
return () => {
|
|
161
|
+
scopeListeners.delete(callback);
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
function getScopesSnapshot() {
|
|
165
|
+
return scopesSnapshot;
|
|
166
|
+
}
|
|
167
|
+
function getServerScopesSnapshot() {
|
|
168
|
+
return EMPTY_SCOPES;
|
|
169
|
+
}
|
|
170
|
+
function useHotkeysScopes() {
|
|
171
|
+
const active = useSyncExternalStore(
|
|
172
|
+
subscribeScopes,
|
|
173
|
+
getScopesSnapshot,
|
|
174
|
+
getServerScopesSnapshot
|
|
175
|
+
);
|
|
176
|
+
return { activeScopes: active, enableScope, disableScope, toggleScope };
|
|
177
|
+
}
|
|
178
|
+
var SEQUENCE_TIMEOUT = 1e3;
|
|
179
|
+
function isModifierKey(key) {
|
|
180
|
+
return key === "Control" || key === "Shift" || key === "Alt" || key === "Meta" || key === "OS" || key === "AltGraph";
|
|
181
|
+
}
|
|
182
|
+
function isFromFormField(target) {
|
|
183
|
+
if (typeof HTMLElement === "undefined") return false;
|
|
184
|
+
if (!(target instanceof HTMLElement)) return false;
|
|
185
|
+
const tag = target.tagName;
|
|
186
|
+
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
187
|
+
return target.isContentEditable;
|
|
188
|
+
}
|
|
189
|
+
function resolveTarget(target) {
|
|
190
|
+
if (typeof window === "undefined") return null;
|
|
191
|
+
if (!target) return window;
|
|
192
|
+
if (target === window) return window;
|
|
193
|
+
if (typeof HTMLElement !== "undefined" && target instanceof HTMLElement) {
|
|
194
|
+
return target;
|
|
195
|
+
}
|
|
196
|
+
return target.current;
|
|
197
|
+
}
|
|
198
|
+
function toEntries(keys) {
|
|
199
|
+
const list = Array.isArray(keys) ? keys : [keys];
|
|
200
|
+
return list.map((raw) => {
|
|
201
|
+
const steps = raw.replace(/\s+then\s+/gi, " ").trim().split(/\s+/).filter(Boolean).map(parseHotkey);
|
|
202
|
+
return { raw, steps, isSequence: steps.length > 1 };
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
function useHotkeys(keys, handler, options = {}, deps) {
|
|
206
|
+
const handlerRef = useRef(handler);
|
|
207
|
+
handlerRef.current = handler;
|
|
208
|
+
const optionsRef = useRef(options);
|
|
209
|
+
optionsRef.current = options;
|
|
210
|
+
const entriesRef = useRef([]);
|
|
211
|
+
entriesRef.current = toEntries(keys);
|
|
212
|
+
const progressRef = useRef(/* @__PURE__ */ new Map());
|
|
213
|
+
const eventType = options.eventType ?? "keydown";
|
|
214
|
+
const depList = deps ?? [];
|
|
215
|
+
useEffect(() => {
|
|
216
|
+
const el = resolveTarget(optionsRef.current.target);
|
|
217
|
+
if (!el) return;
|
|
218
|
+
const listener = (rawEvent) => {
|
|
219
|
+
const event = rawEvent;
|
|
220
|
+
const o = optionsRef.current;
|
|
221
|
+
if (o.enabled === false) return;
|
|
222
|
+
const enableOnFormTags = o.enableOnFormTags ?? false;
|
|
223
|
+
if (!enableOnFormTags && isFromFormField(event.target)) return;
|
|
224
|
+
const rawScopes = o.scopes;
|
|
225
|
+
const scopeArr = rawScopes == null ? [] : Array.isArray(rawScopes) ? rawScopes : [rawScopes];
|
|
226
|
+
if (scopeArr.length > 0 && !scopeArr.some((s) => activeScopeSet.has(s))) {
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const mac = isMac();
|
|
230
|
+
const preventDefault = o.preventDefault ?? true;
|
|
231
|
+
const lone = isModifierKey(event.key);
|
|
232
|
+
const progress = progressRef.current;
|
|
233
|
+
for (const entry of entriesRef.current) {
|
|
234
|
+
if (entry.isSequence) {
|
|
235
|
+
if (lone) continue;
|
|
236
|
+
let state = progress.get(entry.raw);
|
|
237
|
+
if (!state) {
|
|
238
|
+
state = { index: 0, time: 0 };
|
|
239
|
+
progress.set(entry.raw, state);
|
|
240
|
+
}
|
|
241
|
+
const now = Date.now();
|
|
242
|
+
if (state.index > 0 && now - state.time > SEQUENCE_TIMEOUT) {
|
|
243
|
+
state.index = 0;
|
|
244
|
+
}
|
|
245
|
+
const expected = entry.steps[state.index];
|
|
246
|
+
if (expected && matchesParsed(event, expected, mac)) {
|
|
247
|
+
state.index += 1;
|
|
248
|
+
state.time = now;
|
|
249
|
+
if (state.index >= entry.steps.length) {
|
|
250
|
+
state.index = 0;
|
|
251
|
+
if (preventDefault) event.preventDefault();
|
|
252
|
+
handlerRef.current(event, entry.raw);
|
|
253
|
+
}
|
|
254
|
+
} else {
|
|
255
|
+
const first = entry.steps[0];
|
|
256
|
+
if (first && matchesParsed(event, first, mac)) {
|
|
257
|
+
state.index = 1;
|
|
258
|
+
state.time = now;
|
|
259
|
+
} else {
|
|
260
|
+
state.index = 0;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
const combo = entry.steps[0];
|
|
265
|
+
if (combo && matchesParsed(event, combo, mac)) {
|
|
266
|
+
if (preventDefault) event.preventDefault();
|
|
267
|
+
handlerRef.current(event, entry.raw);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
el.addEventListener(eventType, listener);
|
|
273
|
+
return () => {
|
|
274
|
+
el.removeEventListener(eventType, listener);
|
|
275
|
+
};
|
|
276
|
+
}, [eventType, ...depList]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export { disableScope, enableScope, formatHotkey, isMac, isScopeActive, matchesHotkey, parseHotkey, toggleScope, useHotkeys, useHotkeysScopes };
|
|
280
|
+
//# sourceMappingURL=index.js.map
|
|
281
|
+
//# sourceMappingURL=index.js.map
|