diff --git a/src/providers/DialogProvider/hooks/useDialog.test.tsx b/src/providers/DialogProvider/hooks/useDialog.test.tsx index 0b3f89b12..538a4713a 100644 --- a/src/providers/DialogProvider/hooks/useDialog.test.tsx +++ b/src/providers/DialogProvider/hooks/useDialog.test.tsx @@ -107,7 +107,10 @@ describe("useDialog", () => { expect(result.current).toHaveProperty("open"); expect(result.current).toHaveProperty("close"); + expect(result.current).toHaveProperty("cancel"); expect(result.current).toHaveProperty("closeAll"); + expect(result.current).toHaveProperty("stack"); + expect(Array.isArray(result.current.stack)).toBe(true); expect(typeof result.current.open).toBe("function"); }); diff --git a/src/providers/DialogProvider/hooks/useDialog.ts b/src/providers/DialogProvider/hooks/useDialog.ts index bf9f30721..10cc50682 100644 --- a/src/providers/DialogProvider/hooks/useDialog.ts +++ b/src/providers/DialogProvider/hooks/useDialog.ts @@ -11,6 +11,8 @@ export function useDialog() { return { open: context.open, close: context.close, + cancel: context.cancel, closeAll: context.closeAll, + stack: context.stack, }; } diff --git a/src/routes/v2/pages/Editor/EditorV2.tsx b/src/routes/v2/pages/Editor/EditorV2.tsx index a9a0b1631..8495b249b 100644 --- a/src/routes/v2/pages/Editor/EditorV2.tsx +++ b/src/routes/v2/pages/Editor/EditorV2.tsx @@ -33,6 +33,7 @@ import { EditorMenuBar } from "./components/EditorMenuBar/EditorMenuBar"; import { EmptyEditorState } from "./components/EmptyEditorState"; import { FlowCanvas } from "./components/FlowCanvas/FlowCanvas"; import { useComponentLibraryWindow } from "./hooks/useComponentLibraryWindow"; +import { useEditorEscapeShortcut } from "./hooks/useEditorEscapeShortcut"; import { useHistoryWindow } from "./hooks/useHistoryWindow"; import { useLinkedWindowCleanup } from "./hooks/useLinkedWindowCleanup"; import { useLoadSpec } from "./hooks/useLoadSpec"; @@ -82,6 +83,7 @@ const PipelineEditor = withSuspenseWrapper( useUndoRedoKeyboard(); useFocusMode(); useShortcutListener(); + useEditorEscapeShortcut(); useDebugPanelWindow(); const activeSpec = navigation.activeSpec; diff --git a/src/routes/v2/pages/Editor/hooks/useEditorEscapeShortcut.ts b/src/routes/v2/pages/Editor/hooks/useEditorEscapeShortcut.ts new file mode 100644 index 000000000..a6f168f93 --- /dev/null +++ b/src/routes/v2/pages/Editor/hooks/useEditorEscapeShortcut.ts @@ -0,0 +1,61 @@ +import { useReactFlow } from "@xyflow/react"; +import { useEffect } from "react"; + +import { useDialog } from "@/providers/DialogProvider/hooks/useDialog"; +import { ESCAPE } from "@/routes/v2/shared/shortcuts/keys"; +import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; + +function hasReactFlowSelection( + reactFlow: ReturnType, +): boolean { + return ( + reactFlow.getNodes().some((n) => n.selected) || + reactFlow.getEdges().some((e) => e.selected) + ); +} + +/** + * Centralized ESC handler for the editor. Priority: + * 1. If a dialog is open, return false so Radix's portal handler closes it. + * 2. Restore the front-most maximized window (if any). + * 3. Otherwise clear ReactFlow + editor selection state. + * Returns false when nothing applies, so the event keeps propagating. + */ +export function useEditorEscapeShortcut(): void { + const { stack } = useDialog(); + const { editor, keyboard, windows } = useSharedStores(); + const reactFlow = useReactFlow(); + + useEffect(() => { + const unregister = keyboard.registerShortcut({ + id: "editor-escape", + keys: [ESCAPE], + label: "Dismiss / clear selection", + allowInEditable: true, + action: () => { + if (stack.length > 0) return false; + + const front = windows.getFrontMaximizedWindow(); + if (front) { + front.restore(); + return; + } + + const rfSelected = hasReactFlowSelection(reactFlow); + const editorSelected = editor.hasAnySelection; + if (!rfSelected && !editorSelected) return false; + + editor.clearSelection(); + if (rfSelected) { + reactFlow.setNodes((ns) => + ns.map((n) => (n.selected ? { ...n, selected: false } : n)), + ); + reactFlow.setEdges((es) => + es.map((e) => (e.selected ? { ...e, selected: false } : e)), + ); + } + }, + }); + return unregister; + }, [editor, keyboard, reactFlow, stack, windows]); +} diff --git a/src/routes/v2/pages/Editor/nodes/ConduitNode/hooks/useConduitEdgeMode.ts b/src/routes/v2/pages/Editor/nodes/ConduitNode/hooks/useConduitEdgeMode.ts index 9070389fd..76857f6c7 100644 --- a/src/routes/v2/pages/Editor/nodes/ConduitNode/hooks/useConduitEdgeMode.ts +++ b/src/routes/v2/pages/Editor/nodes/ConduitNode/hooks/useConduitEdgeMode.ts @@ -1,5 +1,4 @@ import type { Edge } from "@xyflow/react"; -import { useEffect } from "react"; import type { ComponentSpec } from "@/models/componentSpec"; import { @@ -7,7 +6,6 @@ import { toggleEdgeOnConduit, } from "@/routes/v2/pages/Editor/nodes/ConduitNode/conduits.actions"; import { useEditorSession } from "@/routes/v2/pages/Editor/store/EditorSessionContext"; -import { ESCAPE } from "@/routes/v2/shared/shortcuts/keys"; import type { EditorStore } from "@/routes/v2/shared/store/editorStore"; import { useSharedStores } from "@/routes/v2/shared/store/SharedStoreContext"; @@ -61,7 +59,8 @@ function useConduitSelectionMode( * Encapsulates conduit-specific canvas behaviour: * - styles edges when a conduit is selected (assignment-mode highlighting) * - provides an `onEdgeClick` handler that toggles edge assignment - * - registers an Escape-key shortcut that deselects the conduit + * + * ESC deselect for conduit is handled by `useEditorEscapeShortcut`. */ export function useConduitEdgeMode( edges: Edge[], @@ -72,25 +71,10 @@ export function useConduitEdgeMode( | ((event: React.MouseEvent, edge: { id: string }) => void) | undefined; } { - const { editor, keyboard } = useSharedStores(); + const { editor } = useSharedStores(); const { undo } = useEditorSession(); const isConduitSelected = editor.selectedNodeType === "conduit"; - useEffect(() => { - const unregister = keyboard.registerShortcut({ - id: "conduit-escape", - keys: [ESCAPE], - label: "Deselect conduit", - action: () => { - if (editor.selectedNodeType === "conduit" && editor.selectedNodeId) { - editor.clearSelection(); - } - }, - }); - - return unregister; - }, [editor, keyboard, undo]); - const styledEdges = useConduitSelectionMode(edges, spec, editor); const handleEdgeClick = (_event: React.MouseEvent, edge: { id: string }) => { diff --git a/src/routes/v2/shared/shortcuts/useShortcutListener.ts b/src/routes/v2/shared/shortcuts/useShortcutListener.ts index a5935eabd..21ccadc18 100644 --- a/src/routes/v2/shared/shortcuts/useShortcutListener.ts +++ b/src/routes/v2/shared/shortcuts/useShortcutListener.ts @@ -34,12 +34,16 @@ export function useShortcutListener(): void { for (const shortcut of keyboard.shortcuts.values()) { if (editable && !shortcut.allowInEditable) continue; - if (keyboard.matchesPressed(shortcut.keys)) { - event.preventDefault(); - keyboard.clearPressed(); - shortcut.action(event); - return; + if (!keyboard.matchesPressed(shortcut.keys)) continue; + + const handled = shortcut.action(event); + keyboard.clearPressed(); + + if (handled === false) { + break; } + event.preventDefault(); + return; } }; diff --git a/src/routes/v2/shared/store/editorStore.ts b/src/routes/v2/shared/store/editorStore.ts index 25a39a9c6..8c7570e64 100644 --- a/src/routes/v2/shared/store/editorStore.ts +++ b/src/routes/v2/shared/store/editorStore.ts @@ -1,4 +1,4 @@ -import { action, makeObservable, observable } from "mobx"; +import { action, computed, makeObservable, observable } from "mobx"; import type { ValidationIssue } from "@/models/componentSpec"; @@ -73,6 +73,16 @@ export class EditorStore { this.selectedValidationIssue = null; } + @computed get hasAnySelection(): boolean { + return ( + this.selectedNodeId !== null || + this.selectedNodeType !== null || + this.multiSelection.length > 0 || + this.selectedValidationIssue !== null || + this.focusedArgumentName !== null + ); + } + @action setMultiSelection(nodes: SelectedNode[]) { this.multiSelection = nodes; if (nodes.length > 1) { diff --git a/src/routes/v2/shared/store/keyboardStore.ts b/src/routes/v2/shared/store/keyboardStore.ts index 705f64b3f..ddc989356 100644 --- a/src/routes/v2/shared/store/keyboardStore.ts +++ b/src/routes/v2/shared/store/keyboardStore.ts @@ -20,7 +20,14 @@ interface ShortcutDefinition { allowInEditable?: boolean; // todo: add DOM element as a scope for the shortcut // todo: add enabled: boolean; - action: (event: KeyboardEvent, params?: ShortcutParams) => void; + /** + * Return `false` to allow the native event to propagate: + * the listener skips preventDefault so the + * browser / Radix portal handlers (e.g. dialog ESC) can run. + * The shortcut registry holds at most one handler per combo, + * so this is NOT a fallthrough to another shortcut. + */ + action: (event: KeyboardEvent, params?: ShortcutParams) => void | false; } export class KeyboardStore { diff --git a/src/routes/v2/shared/windows/windowStore.ts b/src/routes/v2/shared/windows/windowStore.ts index ae7e3a39b..54e6fc422 100644 --- a/src/routes/v2/shared/windows/windowStore.ts +++ b/src/routes/v2/shared/windows/windowStore.ts @@ -153,6 +153,16 @@ export class WindowStoreImpl implements WindowStoreRef { return this.windowOrder.map((id) => this.windows[id]); } + /** Top-most maximized window (last in z-order among maximized). */ + getFrontMaximizedWindow(): WindowModel | undefined { + for (let i = this.windowOrder.length - 1; i >= 0; i--) { + const id = this.windowOrder[i]; + const win = this.windows[id]; + if (win?.isMaximized) return win; + } + return undefined; + } + /** Close all windows linked to a specific entity */ @action closeWindowsByLinkedEntity(entityId: string): void { const windowsToClose = this.windowOrder.filter(