diff --git a/studio/frontend/src/features/recipe-studio/components/controls/layout-controls.tsx b/studio/frontend/src/features/recipe-studio/components/controls/layout-controls.tsx index a4f039d6d1..3208688c04 100644 --- a/studio/frontend/src/features/recipe-studio/components/controls/layout-controls.tsx +++ b/studio/frontend/src/features/recipe-studio/components/controls/layout-controls.tsx @@ -5,6 +5,7 @@ import { useUpdateNodeInternals, } from "@xyflow/react"; import { Button } from "@/components/ui/button"; +import { getFitNodeIdsIgnoringNotes } from "../../utils/graph/fit-view"; type LayoutControlsProps = { direction: "LR" | "TB"; @@ -32,20 +33,29 @@ export function LayoutControls({ requestAnimationFrame(() => { refreshNodeInternals(); requestAnimationFrame(() => { - fitView({ duration: 250 }); + fitView({ + duration: 250, + nodes: getFitNodeIdsIgnoringNotes(getNodes()), + }); }); }); - }, [fitView, onLayout, refreshNodeInternals]); + }, [fitView, getNodes, onLayout, refreshNodeInternals]); const handleToggleDirection = useCallback(() => { onToggleDirection(); requestAnimationFrame(() => { - refreshNodeInternals(); + onLayout(); requestAnimationFrame(() => { refreshNodeInternals(); + requestAnimationFrame(() => { + fitView({ + duration: 250, + nodes: getFitNodeIdsIgnoringNotes(getNodes()), + }); + }); }); }); - }, [onToggleDirection, refreshNodeInternals]); + }, [fitView, getNodes, onLayout, onToggleDirection, refreshNodeInternals]); return ( diff --git a/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx b/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx index 27e225f39e..e104841100 100644 --- a/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx +++ b/studio/frontend/src/features/recipe-studio/components/controls/viewport-controls.tsx @@ -2,6 +2,7 @@ import { type ReactElement, useCallback } from "react"; import { Lock, LockOpen, Maximize2, Minus, Plus } from "lucide-react"; import { Panel, useReactFlow } from "@xyflow/react"; import { Button } from "@/components/ui/button"; +import { getFitNodeIdsIgnoringNotes } from "../../utils/graph/fit-view"; import { RECIPE_FLOATING_ICON_BUTTON_CLASS } from "../recipe-floating-icon-button-class"; type ViewportControlsProps = { @@ -13,7 +14,7 @@ export function ViewportControls({ interactive, onToggleInteractive, }: ViewportControlsProps): ReactElement { - const { zoomIn, zoomOut, fitView } = useReactFlow(); + const { zoomIn, zoomOut, fitView, getNodes } = useReactFlow(); const handleZoomIn = useCallback(() => { zoomIn({ duration: 150 }); @@ -24,8 +25,11 @@ export function ViewportControls({ }, [zoomOut]); const handleFitView = useCallback(() => { - fitView({ duration: 250 }); - }, [fitView]); + fitView({ + duration: 250, + nodes: getFitNodeIdsIgnoringNotes(getNodes()), + }); + }, [fitView, getNodes]); return ( diff --git a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx index c83bd1fb28..f225dc2846 100644 --- a/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx +++ b/studio/frontend/src/features/recipe-studio/recipe-studio-page.tsx @@ -48,6 +48,7 @@ import type { RecipeNodeData, } from "./types"; import { deriveDisplayGraph } from "./utils/graph/derive-display-graph"; +import { getFitNodeIdsIgnoringNotes } from "./utils/graph/fit-view"; import { buildRecipePayload } from "./utils/payload"; import type { RecipePayload } from "./utils/payload/types"; import { buildDefaultSchemaTransform } from "./utils/processors"; @@ -370,7 +371,10 @@ export function RecipeStudioPage({ let frame2 = 0; const frame1 = window.requestAnimationFrame(() => { frame2 = window.requestAnimationFrame(() => { - reactFlowInstance.fitView({ duration: 250 }); + reactFlowInstance.fitView({ + duration: 250, + nodes: getFitNodeIdsIgnoringNotes(reactFlowInstance.getNodes()), + }); }); }); return () => { @@ -421,7 +425,7 @@ export function RecipeStudioPage({ nodesDraggable={interactive} nodesConnectable={interactive} elementsSelectable={interactive} - fitView={true} + fitView={false} className="h-full w-full rounded-t-none" > ((set, get) => ({ applyLayout: () => set((state) => { const isTopBottom = state.layoutDirection === "TB"; + const noteNodeIds = new Set( + Object.values(state.configs) + .filter((config) => config.kind === "markdown_note") + .map((config) => config.id), + ); + const displayGraph = deriveDisplayGraph({ nodes: state.nodes, edges: state.edges, @@ -266,7 +272,14 @@ export const useRecipeStudioStore = create((set, get) => ({ auxNodePositions: {}, llmAuxVisibility: state.llmAuxVisibility, }); - const { nodes } = getLayoutedElements(displayGraph.nodes, displayGraph.edges, { + const layoutNodes = displayGraph.nodes.filter( + (node) => !noteNodeIds.has(node.id), + ); + const layoutNodeIds = new Set(layoutNodes.map((node) => node.id)); + const layoutEdges = displayGraph.edges.filter( + (edge) => layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target), + ); + const { nodes } = getLayoutedElements(layoutNodes, layoutEdges, { direction: state.layoutDirection, nodesep: isTopBottom ? 120 : 80, ranksep: isTopBottom ? 140 : 80, @@ -275,6 +288,9 @@ export const useRecipeStudioStore = create((set, get) => ({ nodes.map((node) => [node.id, node.position] as const), ); const nextNodes = state.nodes.map((node) => { + if (noteNodeIds.has(node.id)) { + return node; + } const position = layoutedPositions.get(node.id); if (!position) { return node; diff --git a/studio/frontend/src/features/recipe-studio/utils/graph/fit-view.ts b/studio/frontend/src/features/recipe-studio/utils/graph/fit-view.ts new file mode 100644 index 0000000000..8b0175a1df --- /dev/null +++ b/studio/frontend/src/features/recipe-studio/utils/graph/fit-view.ts @@ -0,0 +1,17 @@ +import type { Node } from "@xyflow/react"; + +function isMarkdownNoteNode(node: Node): boolean { + if (node.type !== "builder") { + return false; + } + if (!node.data || typeof node.data !== "object") { + return false; + } + return (node.data as { kind?: string }).kind === "note"; +} + +export function getFitNodeIdsIgnoringNotes(nodes: Node[]): Array<{ id: string }> { + const nodesWithoutNotes = nodes.filter((node) => !isMarkdownNoteNode(node)); + const targetNodes = nodesWithoutNotes.length > 0 ? nodesWithoutNotes : nodes; + return targetNodes.map((node) => ({ id: node.id })); +}