@griddo/ax 12.9.0-rc.1 → 12.9.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/package.json +2 -2
- package/src/__tests__/components/Fields/TranslateButton/TranslateButton.test.tsx +36 -5
- package/src/__tests__/hooks/users.test.tsx +36 -1
- package/src/components/ConfigPanel/Form/ConnectedField/PageConnectedField/Field/index.tsx +1 -1
- package/src/components/ConfigPanel/Form/ConnectedField/PageConnectedField/TemplateManager/index.tsx +58 -52
- package/src/components/Fields/TranslateButton/index.tsx +47 -33
- package/src/hooks/users.tsx +18 -17
- package/src/__tests__/hooks/shortcuts/engine.test.ts +0 -344
- package/src/__tests__/hooks/shortcuts/hooks.test.tsx +0 -259
- package/src/hooks/shortcuts/README.md +0 -515
- package/src/hooks/shortcuts/engine.ts +0 -205
- package/src/hooks/shortcuts/index.ts +0 -48
- package/src/hooks/shortcuts/presets.ts +0 -144
- package/src/hooks/shortcuts/types.ts +0 -23
- package/src/hooks/shortcuts/useShortcuts.ts +0 -106
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
import type { KeyCombo, ModifierKey, ShortcutConfig } from "./types";
|
|
2
|
-
|
|
3
|
-
const CHORD_TIMEOUT_MS = 1000;
|
|
4
|
-
|
|
5
|
-
function isMac(): boolean {
|
|
6
|
-
if (typeof navigator === "undefined") return false;
|
|
7
|
-
if ("userAgentData" in navigator) {
|
|
8
|
-
return (navigator as Navigator & { userAgentData?: { platform?: string } }).userAgentData?.platform === "macOS";
|
|
9
|
-
}
|
|
10
|
-
return /Mac|iPod|iPhone|iPad/.test(navigator.platform);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
function isInputElement(el: EventTarget | null): boolean {
|
|
14
|
-
if (!(el instanceof HTMLElement)) return false;
|
|
15
|
-
const tag = el.tagName;
|
|
16
|
-
if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true;
|
|
17
|
-
if (el.isContentEditable) return true;
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
function normalizeCombos(keys: ShortcutConfig["keys"]): readonly KeyCombo[] {
|
|
22
|
-
return Array.isArray(keys) ? (keys as readonly KeyCombo[]) : [keys as KeyCombo];
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function matchesCombo(event: KeyboardEvent, combo: KeyCombo): boolean {
|
|
26
|
-
if (!event.key) return false;
|
|
27
|
-
if (event.key.toLowerCase() !== combo.key.toLowerCase()) return false;
|
|
28
|
-
|
|
29
|
-
const mac = isMac();
|
|
30
|
-
const hasModifier = (mod: ModifierKey): boolean => {
|
|
31
|
-
switch (mod) {
|
|
32
|
-
case "mod":
|
|
33
|
-
return mac ? event.metaKey : event.ctrlKey;
|
|
34
|
-
case "shift":
|
|
35
|
-
return event.shiftKey;
|
|
36
|
-
case "alt":
|
|
37
|
-
return event.altKey;
|
|
38
|
-
}
|
|
39
|
-
};
|
|
40
|
-
|
|
41
|
-
for (const mod of combo.modifiers) {
|
|
42
|
-
if (!hasModifier(mod)) return false;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
// No unexpected modifiers.
|
|
46
|
-
// For symbol keys (non-letter, single char) shift/alt may be required by the
|
|
47
|
-
// keyboard layout to produce that character — event.key already encodes the
|
|
48
|
-
// result, so we skip those modifier checks.
|
|
49
|
-
const isSymbolKey = combo.key.length === 1 && !/[a-z]/i.test(combo.key);
|
|
50
|
-
const required = new Set<ModifierKey>(combo.modifiers);
|
|
51
|
-
if (!isSymbolKey && !required.has("shift") && event.shiftKey) return false;
|
|
52
|
-
if (!isSymbolKey && !required.has("alt") && event.altKey) return false;
|
|
53
|
-
if (!required.has("mod")) {
|
|
54
|
-
if (mac && event.metaKey) return false;
|
|
55
|
-
if (!mac && event.ctrlKey) return false;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return true;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
interface InternalEntry {
|
|
62
|
-
readonly config: ShortcutConfig;
|
|
63
|
-
readonly combos: readonly KeyCombo[];
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
interface TargetState {
|
|
67
|
-
readonly listener: (event: Event) => void;
|
|
68
|
-
/** Sorted by priority descending — updated on register/unregister */
|
|
69
|
-
orderedIds: number[];
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/**
|
|
73
|
-
* Centralized shortcut dispatcher.
|
|
74
|
-
*
|
|
75
|
-
* Uses one `keydown` listener per target. When a key is pressed, all registered
|
|
76
|
-
* shortcuts for that target are checked in priority order (highest first).
|
|
77
|
-
* The first matching shortcut wins — lower-priority shortcuts are skipped.
|
|
78
|
-
* If a handler returns `false`, dispatch continues to the next match.
|
|
79
|
-
*/
|
|
80
|
-
class ShortcutEngine {
|
|
81
|
-
private nextId = 0;
|
|
82
|
-
private entries = new Map<number, InternalEntry>();
|
|
83
|
-
private targets = new Map<EventTarget, TargetState>();
|
|
84
|
-
private activeChords = new Map<string, number>();
|
|
85
|
-
private chordTimerId: ReturnType<typeof setTimeout> | null = null;
|
|
86
|
-
|
|
87
|
-
register(config: ShortcutConfig, target: EventTarget = window): () => void {
|
|
88
|
-
const id = this.nextId++;
|
|
89
|
-
const combos = normalizeCombos(config.keys);
|
|
90
|
-
this.entries.set(id, { config, combos });
|
|
91
|
-
|
|
92
|
-
if (!this.targets.has(target)) {
|
|
93
|
-
const listener = (event: Event) => this.dispatch(event as KeyboardEvent, target);
|
|
94
|
-
target.addEventListener("keydown", listener);
|
|
95
|
-
this.targets.set(target, { listener, orderedIds: [] });
|
|
96
|
-
}
|
|
97
|
-
const targetState = this.targets.get(target)!;
|
|
98
|
-
targetState.orderedIds.push(id);
|
|
99
|
-
this.sortTargetIds(targetState);
|
|
100
|
-
|
|
101
|
-
return () => {
|
|
102
|
-
this.entries.delete(id);
|
|
103
|
-
this.activeChords.delete(config.id);
|
|
104
|
-
const state = this.targets.get(target);
|
|
105
|
-
if (state) {
|
|
106
|
-
state.orderedIds = state.orderedIds.filter((eid) => eid !== id);
|
|
107
|
-
if (state.orderedIds.length === 0) {
|
|
108
|
-
target.removeEventListener("keydown", state.listener);
|
|
109
|
-
this.targets.delete(target);
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
private dispatch(event: KeyboardEvent, target: EventTarget): void {
|
|
116
|
-
const state = this.targets.get(target);
|
|
117
|
-
if (!state) return;
|
|
118
|
-
|
|
119
|
-
let chordAdvanced = false;
|
|
120
|
-
|
|
121
|
-
for (const id of state.orderedIds) {
|
|
122
|
-
const entry = this.entries.get(id);
|
|
123
|
-
if (!entry) continue;
|
|
124
|
-
const { config, combos } = entry;
|
|
125
|
-
if (config.enabled === false) continue;
|
|
126
|
-
if (!config.allowInInput && isInputElement(event.target) && event.key !== "Escape") continue;
|
|
127
|
-
|
|
128
|
-
if (combos.length === 1) {
|
|
129
|
-
// Don't fire single-key shortcuts while a chord is advancing
|
|
130
|
-
if (chordAdvanced) continue;
|
|
131
|
-
if (matchesCombo(event, combos[0])) {
|
|
132
|
-
if (this.execute(event, config)) return;
|
|
133
|
-
}
|
|
134
|
-
continue;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
// Chord sequence
|
|
138
|
-
const chordIdx = this.activeChords.get(config.id) ?? 0;
|
|
139
|
-
if (matchesCombo(event, combos[chordIdx])) {
|
|
140
|
-
const nextIdx = chordIdx + 1;
|
|
141
|
-
if (nextIdx >= combos.length) {
|
|
142
|
-
this.activeChords.clear();
|
|
143
|
-
this.clearChordTimer();
|
|
144
|
-
if (this.execute(event, config)) return;
|
|
145
|
-
} else {
|
|
146
|
-
this.activeChords.set(config.id, nextIdx);
|
|
147
|
-
this.resetChordTimer(config.id);
|
|
148
|
-
chordAdvanced = true;
|
|
149
|
-
}
|
|
150
|
-
} else if (chordIdx > 0) {
|
|
151
|
-
this.activeChords.delete(config.id);
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
if (chordAdvanced) {
|
|
156
|
-
event.preventDefault();
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** @returns `true` if handled (preventDefault was called) */
|
|
161
|
-
private execute(event: KeyboardEvent, config: ShortcutConfig): boolean {
|
|
162
|
-
const result = config.handler(event);
|
|
163
|
-
if (result !== false) {
|
|
164
|
-
event.preventDefault();
|
|
165
|
-
event.stopPropagation();
|
|
166
|
-
return true;
|
|
167
|
-
}
|
|
168
|
-
return false;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
private sortTargetIds(state: TargetState): void {
|
|
172
|
-
state.orderedIds.sort((a, b) => {
|
|
173
|
-
const pa = this.entries.get(a)?.config.priority ?? 0;
|
|
174
|
-
const pb = this.entries.get(b)?.config.priority ?? 0;
|
|
175
|
-
return pb - pa;
|
|
176
|
-
});
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
private resetChordTimer(id: string): void {
|
|
180
|
-
this.clearChordTimer();
|
|
181
|
-
this.chordTimerId = setTimeout(() => {
|
|
182
|
-
this.activeChords.delete(id);
|
|
183
|
-
}, CHORD_TIMEOUT_MS);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
private clearChordTimer(): void {
|
|
187
|
-
if (this.chordTimerId !== null) {
|
|
188
|
-
clearTimeout(this.chordTimerId);
|
|
189
|
-
this.chordTimerId = null;
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const shortcutEngine = new ShortcutEngine();
|
|
195
|
-
|
|
196
|
-
function parseShortcut(str: string): KeyCombo[] {
|
|
197
|
-
return str.split(" ").map((part) => {
|
|
198
|
-
const tokens = part.toLowerCase().split("+");
|
|
199
|
-
const key = tokens.pop()!;
|
|
200
|
-
const modifiers = tokens as ModifierKey[];
|
|
201
|
-
return { modifiers, key };
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
export { parseShortcut, shortcutEngine };
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Keyboard shortcuts module for Griddo AX.
|
|
3
|
-
*
|
|
4
|
-
* Provides a declarative way to register keyboard shortcuts in React components
|
|
5
|
-
* with automatic cleanup and support for chord sequences (multi-key shortcuts).
|
|
6
|
-
*
|
|
7
|
-
* @example Quick start
|
|
8
|
-
* ```tsx
|
|
9
|
-
* import { useShortcuts, shortcut } from "@ax/hooks";
|
|
10
|
-
*
|
|
11
|
-
* function MyComponent() {
|
|
12
|
-
* useShortcuts([
|
|
13
|
-
* shortcut("save", "mod+s", (e) => {
|
|
14
|
-
* e.preventDefault();
|
|
15
|
-
* handleSave();
|
|
16
|
-
* }),
|
|
17
|
-
* shortcut("delete", "del", handleDelete),
|
|
18
|
-
* ]);
|
|
19
|
-
*
|
|
20
|
-
* return <div>Press Ctrl/Cmd+S to save</div>;
|
|
21
|
-
* }
|
|
22
|
-
* ```
|
|
23
|
-
*
|
|
24
|
-
* Key features:
|
|
25
|
-
* - Automatic cleanup on unmount
|
|
26
|
-
* - Chord sequences: "g l" means press 'g' then 'l'
|
|
27
|
-
* - Mod key: "mod" maps to Cmd (Mac) or Ctrl (Windows/Linux)
|
|
28
|
-
* - Ref-based handlers: always use latest state values
|
|
29
|
-
* - Escape key works in inputs (for cancel/close actions)
|
|
30
|
-
*
|
|
31
|
-
* @module @ax/hooks/shortcuts
|
|
32
|
-
*/
|
|
33
|
-
export { parseShortcut, shortcutEngine } from "./engine";
|
|
34
|
-
export {
|
|
35
|
-
useEscapeShortcut,
|
|
36
|
-
useForwardShortcutKeys,
|
|
37
|
-
useGlobalShortcuts,
|
|
38
|
-
useIframeShortcutBridge,
|
|
39
|
-
useSaveShortcut,
|
|
40
|
-
useUndoRedoShortcuts,
|
|
41
|
-
} from "./presets";
|
|
42
|
-
export type {
|
|
43
|
-
KeyCombo,
|
|
44
|
-
ModifierKey,
|
|
45
|
-
ShortcutConfig,
|
|
46
|
-
UseShortcutsOptions,
|
|
47
|
-
} from "./types";
|
|
48
|
-
export { shortcut, useShortcuts } from "./useShortcuts";
|
|
@@ -1,144 +0,0 @@
|
|
|
1
|
-
import { useEffect } from "react";
|
|
2
|
-
|
|
3
|
-
import { shortcut, useShortcuts } from "./useShortcuts";
|
|
4
|
-
|
|
5
|
-
const GLOBAL_PRIORITY = -1;
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* `mod+s` shortcut with `allowInInput` enabled.
|
|
9
|
-
*
|
|
10
|
-
* @param handler - Called when the shortcut is triggered
|
|
11
|
-
* @param enabled - Whether the shortcut is active (defaults to true)
|
|
12
|
-
*/
|
|
13
|
-
function useSaveShortcut(handler: () => void, enabled = true): void {
|
|
14
|
-
useShortcuts([shortcut("save", "mod+s", handler, { allowInInput: true })], { enabled });
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* `Escape` shortcut. Works in inputs by default.
|
|
19
|
-
*
|
|
20
|
-
* @param handler - Called when Escape is pressed
|
|
21
|
-
* @param enabled - Whether the shortcut is active (defaults to true)
|
|
22
|
-
*/
|
|
23
|
-
function useEscapeShortcut(handler: () => void, enabled = true): void {
|
|
24
|
-
useShortcuts([shortcut("escape", "escape", handler, { allowInInput: true })], { enabled });
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* `mod+z` / `mod+shift+z` undo/redo pair.
|
|
29
|
-
*
|
|
30
|
-
* @param onUndo - Called on mod+z
|
|
31
|
-
* @param onRedo - Called on mod+shift+z
|
|
32
|
-
* @param enabled - Whether both shortcuts are active (defaults to true)
|
|
33
|
-
*/
|
|
34
|
-
function useUndoRedoShortcuts(onUndo: () => void, onRedo: () => void, enabled = true): void {
|
|
35
|
-
useShortcuts(
|
|
36
|
-
[
|
|
37
|
-
shortcut("undo", "mod+z", onUndo, { allowInInput: true }),
|
|
38
|
-
shortcut("redo", "mod+shift+z", onRedo, { allowInInput: true }),
|
|
39
|
-
],
|
|
40
|
-
{ enabled },
|
|
41
|
-
);
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
/**
|
|
45
|
-
* Global shortcut safety net. Prevents browser defaults (e.g. Save As on `mod+s`)
|
|
46
|
-
* across all routes. Component-level presets override these via higher priority.
|
|
47
|
-
*
|
|
48
|
-
* Call once at the app root.
|
|
49
|
-
*/
|
|
50
|
-
function useGlobalShortcuts(): void {
|
|
51
|
-
useShortcuts([
|
|
52
|
-
shortcut("global:save", "mod+s", () => {}, {
|
|
53
|
-
allowInInput: true,
|
|
54
|
-
priority: GLOBAL_PRIORITY,
|
|
55
|
-
}),
|
|
56
|
-
]);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/**
|
|
60
|
-
* Bridges shortcut keydown events forwarded from iframes (via `postMessage`)
|
|
61
|
-
* back into the parent window so the ShortcutEngine can handle them.
|
|
62
|
-
*
|
|
63
|
-
* Call once at the app root alongside `useGlobalShortcuts()`.
|
|
64
|
-
*/
|
|
65
|
-
function useIframeShortcutBridge(): void {
|
|
66
|
-
useEffect(() => {
|
|
67
|
-
// Only run in the top-level window, not inside iframes
|
|
68
|
-
if (window !== window.parent) return;
|
|
69
|
-
|
|
70
|
-
const handleMessage = (ev: MessageEvent) => {
|
|
71
|
-
if (ev.origin !== window.location.origin) return;
|
|
72
|
-
if (typeof ev.data !== "object" || ev.data?.type !== "shortcut-keydown") return;
|
|
73
|
-
const { key, code, metaKey, ctrlKey, shiftKey, altKey } = ev.data.message;
|
|
74
|
-
window.dispatchEvent(
|
|
75
|
-
new KeyboardEvent("keydown", {
|
|
76
|
-
key,
|
|
77
|
-
code,
|
|
78
|
-
metaKey,
|
|
79
|
-
ctrlKey,
|
|
80
|
-
shiftKey,
|
|
81
|
-
altKey,
|
|
82
|
-
bubbles: true,
|
|
83
|
-
cancelable: true,
|
|
84
|
-
}),
|
|
85
|
-
);
|
|
86
|
-
};
|
|
87
|
-
|
|
88
|
-
window.addEventListener("message", handleMessage);
|
|
89
|
-
return () => window.removeEventListener("message", handleMessage);
|
|
90
|
-
}, []);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/** Clipboard combos that must NOT be intercepted inside the iframe. */
|
|
94
|
-
const CLIPBOARD_KEYS = new Set(["c", "v", "x", "a"]);
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Forwards shortcut keydown events from inside an iframe to the parent window
|
|
98
|
-
* via `postMessage`. Pairs with `useIframeShortcutBridge` on the parent side.
|
|
99
|
-
*
|
|
100
|
-
* Call once inside the iframe entry component (e.g. `FramePreview`).
|
|
101
|
-
*/
|
|
102
|
-
function useForwardShortcutKeys(): void {
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
// Only run inside iframes
|
|
105
|
-
if (window === window.parent) return;
|
|
106
|
-
|
|
107
|
-
const handleKeyDown = (e: KeyboardEvent) => {
|
|
108
|
-
const hasModifier = e.metaKey || e.ctrlKey;
|
|
109
|
-
const isEscape = e.key === "Escape";
|
|
110
|
-
|
|
111
|
-
if (!hasModifier && !isEscape) return;
|
|
112
|
-
if (hasModifier && CLIPBOARD_KEYS.has(e.key.toLowerCase())) return;
|
|
113
|
-
|
|
114
|
-
e.preventDefault();
|
|
115
|
-
|
|
116
|
-
window.parent.postMessage(
|
|
117
|
-
{
|
|
118
|
-
type: "shortcut-keydown",
|
|
119
|
-
message: {
|
|
120
|
-
key: e.key,
|
|
121
|
-
code: e.code,
|
|
122
|
-
metaKey: e.metaKey,
|
|
123
|
-
ctrlKey: e.ctrlKey,
|
|
124
|
-
shiftKey: e.shiftKey,
|
|
125
|
-
altKey: e.altKey,
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
window.location.origin,
|
|
129
|
-
);
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
window.addEventListener("keydown", handleKeyDown, true);
|
|
133
|
-
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
134
|
-
}, []);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
export {
|
|
138
|
-
useEscapeShortcut,
|
|
139
|
-
useForwardShortcutKeys,
|
|
140
|
-
useGlobalShortcuts,
|
|
141
|
-
useIframeShortcutBridge,
|
|
142
|
-
useSaveShortcut,
|
|
143
|
-
useUndoRedoShortcuts,
|
|
144
|
-
};
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
import type { RefObject } from "react";
|
|
2
|
-
|
|
3
|
-
export type ModifierKey = "mod" | "shift" | "alt";
|
|
4
|
-
|
|
5
|
-
export interface KeyCombo {
|
|
6
|
-
readonly modifiers: readonly ModifierKey[];
|
|
7
|
-
readonly key: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface ShortcutConfig {
|
|
11
|
-
readonly id: string;
|
|
12
|
-
readonly keys: KeyCombo | readonly KeyCombo[];
|
|
13
|
-
readonly handler: (event: KeyboardEvent) => void | boolean;
|
|
14
|
-
readonly enabled?: boolean;
|
|
15
|
-
readonly allowInInput?: boolean;
|
|
16
|
-
readonly priority?: number;
|
|
17
|
-
readonly description?: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export interface UseShortcutsOptions {
|
|
21
|
-
readonly ref?: RefObject<HTMLElement>;
|
|
22
|
-
readonly enabled?: boolean;
|
|
23
|
-
}
|
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
import { useEffect, useLayoutEffect, useRef } from "react";
|
|
2
|
-
|
|
3
|
-
import { parseShortcut, shortcutEngine } from "./engine";
|
|
4
|
-
import type { ShortcutConfig, UseShortcutsOptions } from "./types";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Register keyboard shortcuts for a component.
|
|
8
|
-
*
|
|
9
|
-
* **Important:** The shortcuts array should be memoized:
|
|
10
|
-
*
|
|
11
|
-
* ```tsx
|
|
12
|
-
* const shortcuts = useMemo(
|
|
13
|
-
* () => [shortcut("id", "key", handler)],
|
|
14
|
-
* [handler]
|
|
15
|
-
* );
|
|
16
|
-
* useShortcuts(shortcuts);
|
|
17
|
-
* ```
|
|
18
|
-
*
|
|
19
|
-
* **For modals/overlays:** Always pass `enabled={isOpen}` to prevent conflicts
|
|
20
|
-
* when multiple modals are open:
|
|
21
|
-
*
|
|
22
|
-
* ```tsx
|
|
23
|
-
* useShortcuts(shortcuts, { enabled: isOpen });
|
|
24
|
-
* ```
|
|
25
|
-
*
|
|
26
|
-
* @param shortcuts - Memoized array of shortcut configurations
|
|
27
|
-
* @param options - Optional configuration
|
|
28
|
-
* @param options.ref - Target element for keydown events (defaults to window)
|
|
29
|
-
* @param options.enabled - Whether shortcuts are active (defaults to true)
|
|
30
|
-
*
|
|
31
|
-
* @example Basic usage with single shortcut
|
|
32
|
-
* ```tsx
|
|
33
|
-
* useShortcuts([
|
|
34
|
-
* shortcut("save", "mod+s", (e) => {
|
|
35
|
-
* e.preventDefault();
|
|
36
|
-
* saveDocument();
|
|
37
|
-
* }),
|
|
38
|
-
* ]);
|
|
39
|
-
*/
|
|
40
|
-
function useShortcuts(shortcuts: ShortcutConfig[], options: UseShortcutsOptions = {}): void {
|
|
41
|
-
const { ref, enabled = true } = options;
|
|
42
|
-
const latestHandlersRef = useRef(new Map<string, ShortcutConfig["handler"]>());
|
|
43
|
-
|
|
44
|
-
// Sync handlers to ref inside a layout effect (not during render)
|
|
45
|
-
// to avoid mutating refs in the render phase (Strict Mode / Concurrent safe)
|
|
46
|
-
useLayoutEffect(() => {
|
|
47
|
-
latestHandlersRef.current.clear();
|
|
48
|
-
shortcuts.forEach((s) => {
|
|
49
|
-
latestHandlersRef.current.set(s.id, s.handler);
|
|
50
|
-
});
|
|
51
|
-
});
|
|
52
|
-
|
|
53
|
-
useEffect(() => {
|
|
54
|
-
if (!enabled) return;
|
|
55
|
-
|
|
56
|
-
const target = ref?.current ?? window;
|
|
57
|
-
|
|
58
|
-
const cleanups = shortcuts.map((config) =>
|
|
59
|
-
shortcutEngine.register(
|
|
60
|
-
{
|
|
61
|
-
...config,
|
|
62
|
-
// Call latest handler from ref to avoid stale closures
|
|
63
|
-
handler: (event) => {
|
|
64
|
-
const latestHandler = latestHandlersRef.current.get(config.id);
|
|
65
|
-
if (latestHandler) {
|
|
66
|
-
latestHandler(event);
|
|
67
|
-
}
|
|
68
|
-
},
|
|
69
|
-
},
|
|
70
|
-
target,
|
|
71
|
-
),
|
|
72
|
-
);
|
|
73
|
-
|
|
74
|
-
return () =>
|
|
75
|
-
cleanups.forEach((cleanup) => {
|
|
76
|
-
cleanup();
|
|
77
|
-
});
|
|
78
|
-
}, [shortcuts, enabled, ref]);
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Create a shortcut configuration object.
|
|
83
|
-
*
|
|
84
|
-
* @param id - Unique identifier (e.g., "close:modal", "save:document")
|
|
85
|
-
* @param keys - Key combination: "mod+s", "ctrl+shift+p", or chords "g g"
|
|
86
|
-
* @param handler - Called when shortcut triggers. Return false to skip preventDefault
|
|
87
|
-
* @param overrides - Optional config
|
|
88
|
-
*
|
|
89
|
-
* @example
|
|
90
|
-
* ```tsx
|
|
91
|
-
* shortcut("save", "mod+s", (e) => {
|
|
92
|
-
* e.preventDefault();
|
|
93
|
-
* handleSave();
|
|
94
|
-
* })
|
|
95
|
-
* ```
|
|
96
|
-
*/
|
|
97
|
-
function shortcut(
|
|
98
|
-
id: string,
|
|
99
|
-
keys: string,
|
|
100
|
-
handler: ShortcutConfig["handler"],
|
|
101
|
-
overrides?: Partial<Pick<ShortcutConfig, "allowInInput" | "priority" | "description" | "enabled">>,
|
|
102
|
-
): ShortcutConfig {
|
|
103
|
-
return { id, keys: parseShortcut(keys), handler, ...overrides };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
export { shortcut, useShortcuts };
|