Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions src/components/shared/ReactFlow/FlowCanvas/FlowCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { BlockStack } from "@/components/ui/layout";
import useComponentSpecToEdges from "@/hooks/useComponentSpecToEdges";
import useConfirmationDialog from "@/hooks/useConfirmationDialog";
import { useCopyPaste } from "@/hooks/useCopyPaste";
import { useFocusNodeFromParam } from "@/hooks/useFocusNodeFromParam";
import { useGhostNode } from "@/hooks/useGhostNode";
import { useIOSelectionPersistence } from "@/hooks/useIOSelectionPersistence";
import { useNodeCallbacks } from "@/hooks/useNodeCallbacks";
Expand All @@ -51,6 +52,7 @@ import {
} from "@/utils/subgraphUtils";

import { SuspenseWrapper } from "../../SuspenseWrapper";
import { FIT_VIEW_ANIMATION_DURATION, FIT_VIEW_MAX_ZOOM } from "../constants";
import { useNodesOverlay } from "../NodesOverlay/NodesOverlayProvider";
import { getBulkUpdateConfirmationDetails } from "./ConfirmationDialogs/BulkUpdateConfirmationDialog";
import { getDeleteConfirmationDetails } from "./ConfirmationDialogs/DeleteConfirmation";
Expand Down Expand Up @@ -269,6 +271,11 @@ const FlowCanvasContent = ({
const [reactFlowInstance, setReactFlowInstance] =
useState<ReactFlowInstance>();

const { clearFocusNode } = useFocusNodeFromParam(
reactFlowInstance,
nodes.length > 0,
);

const onInit: OnInit = (instance) => {
setReactFlowInstance(instance);
setReactFlowInstanceForOverlay(instance);
Expand Down Expand Up @@ -887,8 +894,8 @@ const FlowCanvasContent = ({

useEffect(() => {
reactFlowInstance?.fitView({
maxZoom: 1,
duration: 300,
maxZoom: FIT_VIEW_MAX_ZOOM,
duration: FIT_VIEW_ANIMATION_DURATION,
});
}, [currentSubgraphPath, reactFlowInstance]);

Expand All @@ -899,7 +906,7 @@ const FlowCanvasContent = ({

const fitView = () => {
reactFlowInstance?.fitView({
maxZoom: 1,
maxZoom: FIT_VIEW_MAX_ZOOM,
});
};

Expand Down Expand Up @@ -991,6 +998,9 @@ const FlowCanvasContent = ({

const onPaneClick = () => {
clearContent();
if (readOnly) {
clearFocusNode();
}
};

const getSelectionMode = () => {
Expand Down Expand Up @@ -1033,8 +1043,8 @@ const FlowCanvasContent = ({

requestAnimationFrame(() => {
reactFlowInstance?.fitView({
maxZoom: 1,
duration: 300,
maxZoom: FIT_VIEW_MAX_ZOOM,
duration: FIT_VIEW_ANIMATION_DURATION,
});
});
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { BlockStack, InlineStack } from "@/components/ui/layout";
import { QuickTooltip } from "@/components/ui/tooltip";
import { Text } from "@/components/ui/typography";
import { useEdgeSelectionHighlight } from "@/hooks/useEdgeSelectionHighlight";
import { useFocusNodeParam } from "@/hooks/useFocusNodeParam";
import { useIsMultiSelect } from "@/hooks/useIsMultiSelect";
import { buildExecutionUrl } from "@/hooks/useSubgraphBreadcrumbs";
import { cn } from "@/lib/utils";
Expand Down Expand Up @@ -65,6 +66,7 @@ const TaskNodeCard = () => {
const { dimensions, selected, highlighted, readOnly, isCollapsed } = state;

const { isMultiSelect, isMultiSelectRef } = useIsMultiSelect();
const { setFocusNode, clearFocusNode } = useFocusNodeParam();

const { isConnectedToSelectedEdge } = useEdgeSelectionHighlight(nodeId);

Expand Down Expand Up @@ -164,11 +166,19 @@ const TaskNodeCard = () => {
if (selected && !isMultiSelect) {
setContent(taskConfigMarkup);
setContextPanelOpen(true);

if (readOnly && taskId) {
setFocusNode(taskId);
}
}

return () => {
if (selected && !isMultiSelectRef.current) {
clearContent();

if (readOnly && taskId) {
clearFocusNode();
}
}
};
}, [
Expand All @@ -178,6 +188,10 @@ const TaskNodeCard = () => {
setContent,
clearContent,
setContextPanelOpen,
readOnly,
taskId,
setFocusNode,
clearFocusNode,
]);

if (!taskSpec) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import type {
TaskSpec,
} from "@/utils/componentSpec";

import { FIT_VIEW_MAX_ZOOM } from "../constants";

type RegisterNodeOptions = {
nodeId: string;
taskSpec: TaskSpec;
Expand Down Expand Up @@ -95,7 +97,7 @@ export const NodesOverlayProvider = ({ children }: PropsWithChildren<{}>) => {
(await instanceRef.current?.fitView({
nodes: [{ id: nodeId }],
duration: 200,
maxZoom: 1,
maxZoom: FIT_VIEW_MAX_ZOOM,
})) ?? false
);
}, []);
Expand Down
2 changes: 2 additions & 0 deletions src/components/shared/ReactFlow/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const FIT_VIEW_MAX_ZOOM = 1;
export const FIT_VIEW_ANIMATION_DURATION = 300;
50 changes: 50 additions & 0 deletions src/hooks/useFocusNodeFromParam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { ReactFlowInstance } from "@xyflow/react";
import { useEffect, useRef } from "react";

import {
FIT_VIEW_ANIMATION_DURATION,
FIT_VIEW_MAX_ZOOM,
} from "@/components/shared/ReactFlow/constants";
import { taskIdToNodeId } from "@/utils/nodes/nodeIdUtils";

import { useFocusNodeParam } from "./useFocusNodeParam";

/**
* Focuses a node on initial page load based on the `focus` query param.
* Selects the node and pans the viewport to center it.
*
* @param reactFlowInstance - The ReactFlow instance for node manipulation
* @param hasNodes - Whether the canvas has rendered nodes (use `nodes.length > 0`)
*/
export const useFocusNodeFromParam = (
reactFlowInstance: ReactFlowInstance | undefined,
hasNodes: boolean,
) => {
const { focusNodeId: focusTaskId, clearFocusNode } = useFocusNodeParam();
const hasAppliedFocus = useRef(false);

useEffect(() => {
if (hasAppliedFocus.current) return;
if (!hasNodes || !reactFlowInstance || !focusTaskId) return;

const targetNodeId = taskIdToNodeId(focusTaskId);
const node = reactFlowInstance.getNode(targetNodeId);
if (!node) return;

hasAppliedFocus.current = true;

reactFlowInstance.setNodes((nds) =>
nds.map((n) => ({ ...n, selected: n.id === targetNodeId })),
);

requestAnimationFrame(() => {
reactFlowInstance.fitView({
nodes: [node],
maxZoom: FIT_VIEW_MAX_ZOOM,
duration: FIT_VIEW_ANIMATION_DURATION,
});
});
}, [focusTaskId, reactFlowInstance, hasNodes]);

return { clearFocusNode };
};
37 changes: 37 additions & 0 deletions src/hooks/useFocusNodeParam.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useCallback, useSyncExternalStore } from "react";

const FOCUS_PARAM = "focus";

const getSnapshot = () => {
const params = new URLSearchParams(window.location.search);
return params.get(FOCUS_PARAM) ?? undefined;
};

const subscribe = (callback: () => void) => {
window.addEventListener("popstate", callback);
return () => window.removeEventListener("popstate", callback);
};

const updateSearchParam = (taskId: string | undefined) => {
const url = new URL(window.location.href);
if (taskId) {
url.searchParams.set(FOCUS_PARAM, taskId);
} else {
url.searchParams.delete(FOCUS_PARAM);
}
window.history.replaceState(window.history.state, "", url.toString());
};

export const useFocusNodeParam = () => {
const focusNodeId = useSyncExternalStore(subscribe, getSnapshot);

const setFocusNode = useCallback((taskId: string) => {
updateSearchParam(taskId);
}, []);

const clearFocusNode = useCallback(() => {
updateSearchParam(undefined);
}, []);

return { focusNodeId, setFocusNode, clearFocusNode };
};
Loading