Skip to content

Commit 0f4a672

Browse files
committed
feat: v2 - consolidated esc behavior in editor
1 parent 1f79b8f commit 0f4a672

9 files changed

Lines changed: 109 additions & 26 deletions

File tree

src/providers/DialogProvider/hooks/useDialog.test.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,10 @@ describe("useDialog", () => {
107107

108108
expect(result.current).toHaveProperty("open");
109109
expect(result.current).toHaveProperty("close");
110+
expect(result.current).toHaveProperty("cancel");
110111
expect(result.current).toHaveProperty("closeAll");
112+
expect(result.current).toHaveProperty("stack");
113+
expect(Array.isArray(result.current.stack)).toBe(true);
111114
expect(typeof result.current.open).toBe("function");
112115
});
113116

src/providers/DialogProvider/hooks/useDialog.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ export function useDialog() {
1111
return {
1212
open: context.open,
1313
close: context.close,
14+
cancel: context.cancel,
1415
closeAll: context.closeAll,
16+
stack: context.stack,
1517
};
1618
}

src/routes/v2/pages/Editor/EditorV2.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import { EditorMenuBar } from "./components/EditorMenuBar/EditorMenuBar";
3333
import { EmptyEditorState } from "./components/EmptyEditorState";
3434
import { FlowCanvas } from "./components/FlowCanvas/FlowCanvas";
3535
import { useComponentLibraryWindow } from "./hooks/useComponentLibraryWindow";
36+
import { useEditorEscapeShortcut } from "./hooks/useEditorEscapeShortcut";
3637
import { useHistoryWindow } from "./hooks/useHistoryWindow";
3738
import { useLinkedWindowCleanup } from "./hooks/useLinkedWindowCleanup";
3839
import { useLoadSpec } from "./hooks/useLoadSpec";
@@ -82,6 +83,7 @@ const PipelineEditor = withSuspenseWrapper(
8283
useUndoRedoKeyboard();
8384
useFocusMode();
8485
useShortcutListener();
86+
useEditorEscapeShortcut();
8587
useDebugPanelWindow();
8688

8789
const activeSpec = navigation.activeSpec;
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { useReactFlow } from "@xyflow/react";
2+
import { useEffect } from "react";
3+
4+
import { useDialog } from "@/providers/DialogProvider/hooks/useDialog";
5+
import { ESCAPE } from "@/routes/v2/shared/shortcuts/keys";
6+
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";
7+
8+
function hasReactFlowSelection(
9+
reactFlow: ReturnType<typeof useReactFlow>,
10+
): boolean {
11+
return (
12+
reactFlow.getNodes().some((n) => n.selected) ||
13+
reactFlow.getEdges().some((e) => e.selected)
14+
);
15+
}
16+
17+
/**
18+
* Centralized ESC handler for the editor. Priority:
19+
* 1. If a dialog is open, return false so Radix's portal handler closes it.
20+
* 2. Restore the front-most maximized window (if any).
21+
* 3. Otherwise clear ReactFlow + editor selection state.
22+
* Returns false when nothing applies, so the event keeps propagating.
23+
*/
24+
export function useEditorEscapeShortcut(): void {
25+
const { stack } = useDialog();
26+
const { editor, keyboard, windows } = useSharedStores();
27+
const reactFlow = useReactFlow();
28+
29+
useEffect(() => {
30+
const unregister = keyboard.registerShortcut({
31+
id: "editor-escape",
32+
keys: [ESCAPE],
33+
label: "Dismiss / clear selection",
34+
allowInEditable: true,
35+
action: () => {
36+
if (stack.length > 0) return false;
37+
38+
const front = windows.getFrontMaximizedWindow();
39+
if (front) {
40+
front.restore();
41+
return;
42+
}
43+
44+
const rfSelected = hasReactFlowSelection(reactFlow);
45+
const editorSelected = editor.hasAnySelection;
46+
if (!rfSelected && !editorSelected) return false;
47+
48+
editor.clearSelection();
49+
if (rfSelected) {
50+
reactFlow.setNodes((ns) =>
51+
ns.map((n) => (n.selected ? { ...n, selected: false } : n)),
52+
);
53+
reactFlow.setEdges((es) =>
54+
es.map((e) => (e.selected ? { ...e, selected: false } : e)),
55+
);
56+
}
57+
},
58+
});
59+
return unregister;
60+
}, [editor, keyboard, reactFlow, stack, windows]);
61+
}

src/routes/v2/pages/Editor/nodes/ConduitNode/hooks/useConduitEdgeMode.ts

Lines changed: 3 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,11 @@
11
import type { Edge } from "@xyflow/react";
2-
import { useEffect } from "react";
32

43
import type { ComponentSpec } from "@/models/componentSpec";
54
import {
65
getConduits,
76
toggleEdgeOnConduit,
87
} from "@/routes/v2/pages/Editor/nodes/ConduitNode/conduits.actions";
98
import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext";
10-
import { ESCAPE } from "@/routes/v2/shared/shortcuts/keys";
119
import type { EditorStore } from "@/routes/v2/shared/store/editorStore";
1210
import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext";
1311

@@ -61,7 +59,8 @@ function useConduitSelectionMode(
6159
* Encapsulates conduit-specific canvas behaviour:
6260
* - styles edges when a conduit is selected (assignment-mode highlighting)
6361
* - provides an `onEdgeClick` handler that toggles edge assignment
64-
* - registers an Escape-key shortcut that deselects the conduit
62+
*
63+
* ESC deselect for conduit is handled by `useEditorEscapeShortcut`.
6564
*/
6665
export function useConduitEdgeMode(
6766
edges: Edge[],
@@ -72,25 +71,10 @@ export function useConduitEdgeMode(
7271
| ((event: React.MouseEvent, edge: { id: string }) => void)
7372
| undefined;
7473
} {
75-
const { editor, keyboard } = useSharedStores();
74+
const { editor } = useSharedStores();
7675
const { undo } = useEditorSession();
7776
const isConduitSelected = editor.selectedNodeType === "conduit";
7877

79-
useEffect(() => {
80-
const unregister = keyboard.registerShortcut({
81-
id: "conduit-escape",
82-
keys: [ESCAPE],
83-
label: "Deselect conduit",
84-
action: () => {
85-
if (editor.selectedNodeType === "conduit" && editor.selectedNodeId) {
86-
editor.clearSelection();
87-
}
88-
},
89-
});
90-
91-
return unregister;
92-
}, [editor, keyboard, undo]);
93-
9478
const styledEdges = useConduitSelectionMode(edges, spec, editor);
9579

9680
const handleEdgeClick = (_event: React.MouseEvent, edge: { id: string }) => {

src/routes/v2/shared/shortcuts/useShortcutListener.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,16 @@ export function useShortcutListener(): void {
3434

3535
for (const shortcut of keyboard.shortcuts.values()) {
3636
if (editable && !shortcut.allowInEditable) continue;
37-
if (keyboard.matchesPressed(shortcut.keys)) {
38-
event.preventDefault();
39-
keyboard.clearPressed();
40-
shortcut.action(event);
41-
return;
37+
if (!keyboard.matchesPressed(shortcut.keys)) continue;
38+
39+
const handled = shortcut.action(event);
40+
keyboard.clearPressed();
41+
42+
if (handled === false) {
43+
break;
4244
}
45+
event.preventDefault();
46+
return;
4347
}
4448
};
4549

src/routes/v2/shared/store/editorStore.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { action, makeObservable, observable } from "mobx";
1+
import { action, computed, makeObservable, observable } from "mobx";
22

33
import type { ValidationIssue } from "@/models/componentSpec";
44

@@ -73,6 +73,16 @@ export class EditorStore {
7373
this.selectedValidationIssue = null;
7474
}
7575

76+
@computed get hasAnySelection(): boolean {
77+
return (
78+
this.selectedNodeId !== null ||
79+
this.selectedNodeType !== null ||
80+
this.multiSelection.length > 0 ||
81+
this.selectedValidationIssue !== null ||
82+
this.focusedArgumentName !== null
83+
);
84+
}
85+
7686
@action setMultiSelection(nodes: SelectedNode[]) {
7787
this.multiSelection = nodes;
7888
if (nodes.length > 1) {

src/routes/v2/shared/store/keyboardStore.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,14 @@ interface ShortcutDefinition {
2020
allowInEditable?: boolean;
2121
// todo: add DOM element as a scope for the shortcut
2222
// todo: add enabled: boolean;
23-
action: (event: KeyboardEvent, params?: ShortcutParams) => void;
23+
/**
24+
* Return `false` to allow the native event to propagate:
25+
* the listener skips preventDefault so the
26+
* browser / Radix portal handlers (e.g. dialog ESC) can run.
27+
* The shortcut registry holds at most one handler per combo,
28+
* so this is NOT a fallthrough to another shortcut.
29+
*/
30+
action: (event: KeyboardEvent, params?: ShortcutParams) => void | false;
2431
}
2532

2633
export class KeyboardStore {

src/routes/v2/shared/windows/windowStore.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,16 @@ export class WindowStoreImpl implements WindowStoreRef {
153153
return this.windowOrder.map((id) => this.windows[id]);
154154
}
155155

156+
/** Top-most maximized window (last in z-order among maximized). */
157+
getFrontMaximizedWindow(): WindowModel | undefined {
158+
for (let i = this.windowOrder.length - 1; i >= 0; i--) {
159+
const id = this.windowOrder[i];
160+
const win = this.windows[id];
161+
if (win?.isMaximized) return win;
162+
}
163+
return undefined;
164+
}
165+
156166
/** Close all windows linked to a specific entity */
157167
@action closeWindowsByLinkedEntity(entityId: string): void {
158168
const windowsToClose = this.windowOrder.filter(

0 commit comments

Comments
 (0)