@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.
@@ -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 };
@@ -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