@griddo/ax 12.9.0 → 12.9.1-rc.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 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 };
@@ -0,0 +1,48 @@
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";
@@ -0,0 +1,144 @@
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
+ };
@@ -0,0 +1,23 @@
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
+ }
@@ -0,0 +1,106 @@
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 };