diff --git a/public/banana_icon.png b/public/banana_icon.png index faf17e21..dec9aa99 100644 Binary files a/public/banana_icon.png and b/public/banana_icon.png differ diff --git a/src/app/icon.png b/src/app/icon.png index feb398a5..dec9aa99 100644 Binary files a/src/app/icon.png and b/src/app/icon.png differ diff --git a/src/components/media/MediaOutpaintingOverlay.tsx b/src/components/media/MediaOutpaintingOverlay.tsx index a858823b..55748789 100644 --- a/src/components/media/MediaOutpaintingOverlay.tsx +++ b/src/components/media/MediaOutpaintingOverlay.tsx @@ -57,7 +57,7 @@ function clamp(value: number, min: number, max: number) { } function roundRatio(value: number) { - return Math.round(value * 1000) / 1000; + return Math.round(value * 10) / 10; } function frameFromBounds(bounds: DisplayRect): OutpaintingFrame { diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx index 273593f3..f391f3cc 100644 --- a/src/components/nodes/GenerateImageNode.tsx +++ b/src/components/nodes/GenerateImageNode.tsx @@ -136,7 +136,7 @@ export function GenerateImageNodeView({ id, data, selected, renderInputHandle = const [isBrowseDialogOpen, setIsBrowseDialogOpen] = useState(false); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false); - const [activeEditMenuTool, setActiveEditMenuTool] = useState("edit"); + const [activeEditMenuTool, setActiveEditMenuTool] = useState("crop"); const [cropAspectRatio, setCropAspectRatio] = useState("original"); const [cropPayload, setCropPayload] = useState(null); const [cropFrameRect, setCropFrameRect] = useState(null); diff --git a/src/components/nodes/ImageInputNode.tsx b/src/components/nodes/ImageInputNode.tsx index 41cf4e1a..06bfa74a 100644 --- a/src/components/nodes/ImageInputNode.tsx +++ b/src/components/nodes/ImageInputNode.tsx @@ -133,7 +133,7 @@ export function ImageInputNodeView({ id, data, selected, renderInputHandle = fal const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false); - const [activeEditMenuTool, setActiveEditMenuTool] = useState("edit"); + const [activeEditMenuTool, setActiveEditMenuTool] = useState("crop"); const [splitGridSelection, setSplitGridSelection] = useState(null); const [imageRetryNonce, setImageRetryNonce] = useState(0); const [displayFallbackImage, setDisplayFallbackImage] = useState(null); diff --git a/src/utils/__tests__/canvasAutoLayout.test.ts b/src/utils/__tests__/canvasAutoLayout.test.ts index 7bd93fd3..bd1348ed 100644 --- a/src/utils/__tests__/canvasAutoLayout.test.ts +++ b/src/utils/__tests__/canvasAutoLayout.test.ts @@ -101,7 +101,7 @@ describe("calculateCanvasAutoLayout", () => { expect(positions.get("left-2")?.y).toBe(244); }); - it("uses global columns for separate workflows instead of separate component blocks", () => { + it("keeps separate workflows on separate rows", () => { const positions = positionMap(calculateCanvasAutoLayout( [ node("input-a", 100, 100), @@ -114,7 +114,26 @@ describe("calculateCanvasAutoLayout", () => { expect(positions.get("input-a")?.x).toBe(positions.get("input-b")?.x); expect(positions.get("output-a")?.x).toBe(positions.get("output-b")?.x); + expect(positions.get("input-a")?.y).toBe(positions.get("output-a")?.y); + expect(positions.get("input-b")?.y).toBe(positions.get("output-b")?.y); expect(positions.get("input-a")?.y).toBeLessThan(positions.get("input-b")?.y ?? 0); - expect(positions.get("output-a")?.y).toBeLessThan(positions.get("output-b")?.y ?? 0); + }); + + it("keeps isolated nodes as their own workflow rows", () => { + const positions = positionMap(calculateCanvasAutoLayout( + [ + node("image-input", 100, 100), + node("image-output", 600, 100), + node("text", 100, 350), + node("video-input", 100, 700), + node("video-output", 600, 700), + ], + [edge("image-input", "image-output"), edge("video-input", "video-output")] + )); + + expect(positions.get("image-input")?.y).toBe(positions.get("image-output")?.y); + expect(positions.get("video-input")?.y).toBe(positions.get("video-output")?.y); + expect(positions.get("image-input")?.y).toBeLessThan(positions.get("text")?.y ?? 0); + expect(positions.get("text")?.y).toBeLessThan(positions.get("video-input")?.y ?? 0); }); }); diff --git a/src/utils/canvasAutoLayout.ts b/src/utils/canvasAutoLayout.ts index 6dfd523c..08cf1159 100644 --- a/src/utils/canvasAutoLayout.ts +++ b/src/utils/canvasAutoLayout.ts @@ -18,6 +18,16 @@ type LayoutNode = WorkflowNode & { type: NonNullable; }; +interface LayoutSegment { + nodes: LayoutNode[]; + edges: WorkflowEdge[]; +} + +interface SegmentLayout { + positions: CanvasAutoLayoutPosition[]; + height: number; +} + function isLayoutNode(node: WorkflowNode): node is LayoutNode { return Boolean(node.type) && node.type !== "group" && !node.parentId; } @@ -118,29 +128,75 @@ function orderLayerNodes( }); } -export function calculateCanvasAutoLayout( - nodes: WorkflowNode[], - edges: WorkflowEdge[], - options: CanvasAutoLayoutOptions = {} -): CanvasAutoLayoutPosition[] { - const layoutNodes = nodes.filter(isLayoutNode); - if (layoutNodes.length === 0) return []; +function splitWorkflowSegments(nodes: LayoutNode[], edges: WorkflowEdge[]): LayoutSegment[] { + const nodeById = new Map(nodes.map((node) => [node.id, node])); + const adjacency = new Map(nodes.map((node) => [node.id, [] as string[]])); - const columnGap = options.columnGap ?? DEFAULT_COLUMN_GAP; - const rowGap = options.rowGap ?? DEFAULT_ROW_GAP; - const layoutNodeIds = new Set(layoutNodes.map((node) => node.id)); - const layoutEdges = edges.filter((edge) => { - const edgeData = edge.data as { isLoop?: boolean } | undefined; - return layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target) && !edgeData?.isLoop; + edges.forEach((edge) => { + if (!nodeById.has(edge.source) || !nodeById.has(edge.target)) return; + adjacency.get(edge.source)?.push(edge.target); + adjacency.get(edge.target)?.push(edge.source); + }); + + const visited = new Set(); + const segments: LayoutSegment[] = []; + + nodes + .slice() + .sort(compareByCurrentPosition) + .forEach((startNode) => { + if (visited.has(startNode.id)) return; + + const queue = [startNode.id]; + const segmentNodeIds = new Set(); + + while (queue.length > 0) { + const nodeId = queue.shift(); + if (!nodeId || visited.has(nodeId)) continue; + + visited.add(nodeId); + segmentNodeIds.add(nodeId); + + (adjacency.get(nodeId) ?? []).forEach((neighborId) => { + if (!visited.has(neighborId)) queue.push(neighborId); + }); + } + + const segmentNodes = nodes + .filter((node) => segmentNodeIds.has(node.id)) + .sort(compareByCurrentPosition); + const segmentEdges = edges.filter((edge) => segmentNodeIds.has(edge.source) && segmentNodeIds.has(edge.target)); + + segments.push({ nodes: segmentNodes, edges: segmentEdges }); + }); + + return segments.sort((a, b) => { + const aTop = Math.min(...a.nodes.map((node) => node.position.y)); + const bTop = Math.min(...b.nodes.map((node) => node.position.y)); + if (aTop !== bTop) return aTop - bTop; + + const aLeft = Math.min(...a.nodes.map((node) => node.position.x)); + const bLeft = Math.min(...b.nodes.map((node) => node.position.x)); + if (aLeft !== bLeft) return aLeft - bLeft; + + return a.nodes[0]?.id.localeCompare(b.nodes[0]?.id ?? "") ?? 0; }); +} + +function layoutSegment( + nodes: LayoutNode[], + edges: WorkflowEdge[], + startX: number, + startY: number, + columnGap: number, + rowGap: number +): SegmentLayout { const positions: CanvasAutoLayoutPosition[] = []; - const layerById = computeLayers(layoutNodes, layoutEdges); - const minX = Math.min(...layoutNodes.map((node) => node.position.x)); - const minY = Math.min(...layoutNodes.map((node) => node.position.y)); + const layerById = computeLayers(nodes, edges); const layers = new Map(); const yById = new Map(); - layoutNodes.forEach((node) => { + nodes.forEach((node) => { const layer = layerById.get(node.id) ?? 0; layers.set(layer, [...(layers.get(layer) ?? []), node]); }); @@ -148,10 +204,10 @@ export function calculateCanvasAutoLayout( const sortedLayers = Array.from(layers.keys()).sort((a, b) => a - b); const orderedNodesByLayer = new Map(); const layerXByLayer = new Map(); - let currentX = minX; + let currentX = startX; sortedLayers.forEach((layer) => { - const layerNodes = orderLayerNodes(layers.get(layer) ?? [], layoutEdges, yById); + const layerNodes = orderLayerNodes(layers.get(layer) ?? [], edges, yById); orderedNodesByLayer.set(layer, layerNodes); const maxLayerWidth = Math.max( DEFAULT_NODE_DIMENSIONS.width, @@ -163,7 +219,8 @@ export function calculateCanvasAutoLayout( const maxRowCount = Math.max(0, ...Array.from(orderedNodesByLayer.values()).map((layerNodes) => layerNodes.length)); const rowYByIndex = new Map(); - let currentY = minY; + const rowHeightByIndex = new Map(); + let currentY = startY; for (let rowIndex = 0; rowIndex < maxRowCount; rowIndex += 1) { rowYByIndex.set(rowIndex, currentY); @@ -173,6 +230,7 @@ export function calculateCanvasAutoLayout( return node ? getNodeDimensions(node).height : 0; }) ); + rowHeightByIndex.set(rowIndex, maxRowHeight); currentY += maxRowHeight + rowGap; } @@ -180,11 +238,11 @@ export function calculateCanvasAutoLayout( const layerNodes = orderedNodesByLayer.get(layer) ?? []; layerNodes.forEach((node, rowIndex) => { - const rowY = rowYByIndex.get(rowIndex) ?? minY; + const rowY = rowYByIndex.get(rowIndex) ?? startY; positions.push({ id: node.id, position: { - x: layerXByLayer.get(layer) ?? minX, + x: layerXByLayer.get(layer) ?? startX, y: rowY, }, }); @@ -194,5 +252,39 @@ export function calculateCanvasAutoLayout( }); }); + const height = Array.from(rowHeightByIndex.values()).reduce( + (sum, rowHeight, index) => sum + rowHeight + (index === rowHeightByIndex.size - 1 ? 0 : rowGap), + 0 + ); + + return { positions, height }; +} + +export function calculateCanvasAutoLayout( + nodes: WorkflowNode[], + edges: WorkflowEdge[], + options: CanvasAutoLayoutOptions = {} +): CanvasAutoLayoutPosition[] { + const layoutNodes = nodes.filter(isLayoutNode); + if (layoutNodes.length === 0) return []; + + const columnGap = options.columnGap ?? DEFAULT_COLUMN_GAP; + const rowGap = options.rowGap ?? DEFAULT_ROW_GAP; + const layoutNodeIds = new Set(layoutNodes.map((node) => node.id)); + const layoutEdges = edges.filter((edge) => { + const edgeData = edge.data as { isLoop?: boolean } | undefined; + return layoutNodeIds.has(edge.source) && layoutNodeIds.has(edge.target) && !edgeData?.isLoop; + }); + const positions: CanvasAutoLayoutPosition[] = []; + const minX = Math.min(...layoutNodes.map((node) => node.position.x)); + const minY = Math.min(...layoutNodes.map((node) => node.position.y)); + let currentY = minY; + + splitWorkflowSegments(layoutNodes, layoutEdges).forEach((segment) => { + const segmentLayout = layoutSegment(segment.nodes, segment.edges, minX, currentY, columnGap, rowGap); + positions.push(...segmentLayout.positions); + currentY += segmentLayout.height + rowGap; + }); + return positions; }