fix(recipe-studio): preserve note positions during auto-layout and fit workflow only

This commit is contained in:
Shine1i 2026-02-26 11:30:24 +01:00
commit a53d8cb272
5 changed files with 61 additions and 10 deletions

View file

@ -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 (
<Panel position="top-left" className="m-3 flex items-center gap-2">

View file

@ -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 (
<Panel position="bottom-left" className="m-3 flex items-center gap-2">

View file

@ -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"
>
<LayoutControls

View file

@ -258,6 +258,12 @@ export const useRecipeStudioStore = create<RecipeStudioState>((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<RecipeStudioState>((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<RecipeStudioState>((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;

View file

@ -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 }));
}