diff --git a/src/components/ConnectionDropMenu.tsx b/src/components/ConnectionDropMenu.tsx
index 8ed9fb9b..16ae7e6a 100644
--- a/src/components/ConnectionDropMenu.tsx
+++ b/src/components/ConnectionDropMenu.tsx
@@ -819,16 +819,33 @@ export function ConnectionDropMenu({
return () => document.removeEventListener("keydown", handleKeyDown);
}, [options, selectedIndex, onSelect, onClose]);
- // Close when clicking outside
+ // Close when clicking outside. Use capture-phase pointer events so React Flow
+ // canvas handlers cannot swallow the outside click before the menu sees it.
useEffect(() => {
- const handleClickOutside = (e: MouseEvent) => {
- if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
+ let lastPointerCloseAt = 0;
+
+ const closeIfOutside = (event: MouseEvent | PointerEvent) => {
+ if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose();
}
};
- document.addEventListener("mousedown", handleClickOutside);
- return () => document.removeEventListener("mousedown", handleClickOutside);
+ const handlePointerDownOutside = (event: PointerEvent) => {
+ lastPointerCloseAt = Date.now();
+ closeIfOutside(event);
+ };
+
+ const handleMouseDownOutside = (event: MouseEvent) => {
+ if (Date.now() - lastPointerCloseAt < 50) return;
+ closeIfOutside(event);
+ };
+
+ document.addEventListener("pointerdown", handlePointerDownOutside, true);
+ document.addEventListener("mousedown", handleMouseDownOutside, true);
+ return () => {
+ document.removeEventListener("pointerdown", handlePointerDownOutside, true);
+ document.removeEventListener("mousedown", handleMouseDownOutside, true);
+ };
}, [onClose]);
// Focus the menu when it opens
diff --git a/src/components/__tests__/ConnectionDropMenu.test.tsx b/src/components/__tests__/ConnectionDropMenu.test.tsx
index 8c0e2250..ada66a21 100644
--- a/src/components/__tests__/ConnectionDropMenu.test.tsx
+++ b/src/components/__tests__/ConnectionDropMenu.test.tsx
@@ -217,6 +217,24 @@ describe("ConnectionDropMenu", () => {
expect(mockOnClose).toHaveBeenCalled();
});
+ it("should close on outside pointerdown before bubbling handlers can stop the event", () => {
+ render(
+
+
event.stopPropagation()}
+ >
+ Outside
+
+
+
+ );
+
+ fireEvent.pointerDown(screen.getByTestId("outside"));
+
+ expect(mockOnClose).toHaveBeenCalledTimes(1);
+ });
+
it("should not call onClose when clicking inside the menu", () => {
render();
diff --git a/src/components/__tests__/GenerateImageNode.test.tsx b/src/components/__tests__/GenerateImageNode.test.tsx
index 12fa51c7..4fcf3262 100644
--- a/src/components/__tests__/GenerateImageNode.test.tsx
+++ b/src/components/__tests__/GenerateImageNode.test.tsx
@@ -304,6 +304,60 @@ describe("GenerateImageNode", () => {
const img = screen.getByAltText("Generated");
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute("src", "data:image/png;base64,abc123");
+ expect(img).toHaveClass("object-contain");
+ });
+
+ it("should resize the canvas node to the output image aspect ratio", async () => {
+ const originalImage = global.Image;
+ const originalRequestAnimationFrame = global.requestAnimationFrame;
+ global.requestAnimationFrame = ((callback: FrameRequestCallback) => {
+ callback(0);
+ return 0;
+ }) as typeof requestAnimationFrame;
+
+ class MockImage {
+ onload: (() => void) | null = null;
+ onerror: (() => void) | null = null;
+ naturalWidth: number = 1600;
+ naturalHeight: number = 900;
+ private _src: string = "";
+ get src() { return this._src; }
+ set src(value: string) {
+ this._src = value;
+ if (value) {
+ setTimeout(() => this.onload?.(), 0);
+ }
+ }
+ }
+ global.Image = MockImage as unknown as typeof Image;
+
+ try {
+ render(
+
+
+
+ );
+
+ await waitFor(() => expect(mockSetNodes).toHaveBeenCalled());
+
+ const resizeNodes = mockSetNodes.mock.calls.at(-1)?.[0] as (nodes: Array>) => Array>;
+ const resized = resizeNodes([
+ { id: "test-node-1", width: 300, height: 300, style: { width: 300, height: 300 } },
+ { id: "other-node", width: 300, height: 300, style: { width: 300, height: 300 } },
+ ]);
+
+ expect(resized[0]).toMatchObject({
+ width: 533,
+ height: 300,
+ style: { width: 533, height: 300 },
+ });
+ expect(resized[1]).toMatchObject({ id: "other-node", width: 300, height: 300 });
+ } finally {
+ global.Image = originalImage;
+ global.requestAnimationFrame = originalRequestAnimationFrame;
+ }
});
it("should render clear button when output image exists", () => {
diff --git a/src/components/__tests__/ImageInputNode.test.tsx b/src/components/__tests__/ImageInputNode.test.tsx
index b1cb53ee..f1a24046 100644
--- a/src/components/__tests__/ImageInputNode.test.tsx
+++ b/src/components/__tests__/ImageInputNode.test.tsx
@@ -5,6 +5,17 @@ import { ReactFlowProvider } from "@xyflow/react";
// Mock the workflow store
const mockUpdateNodeData = vi.fn();
+const mockSetNodes = vi.fn();
+
+vi.mock("@xyflow/react", async () => {
+ const actual = await vi.importActual("@xyflow/react");
+ return {
+ ...actual,
+ useReactFlow: () => ({
+ setNodes: mockSetNodes,
+ }),
+ };
+});
vi.mock("@/store/workflowStore", () => ({
useWorkflowStore: vi.fn((selector) => {
@@ -135,6 +146,7 @@ describe("ImageInputNode", () => {
const img = screen.getByAltText("test-image.png");
expect(img).toBeInTheDocument();
expect(img).toHaveAttribute("src", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==");
+ expect(img).toHaveClass("object-contain");
});
it("should not show drop zone when image is set", () => {
@@ -146,6 +158,36 @@ describe("ImageInputNode", () => {
expect(screen.queryByText("Drop image")).not.toBeInTheDocument();
});
+
+ it("should resize the canvas node to the stored image aspect ratio", async () => {
+ render(
+
+
+
+ );
+
+ await waitFor(() => expect(mockSetNodes).toHaveBeenCalled());
+
+ const resizeNodes = mockSetNodes.mock.calls.at(-1)?.[0] as (nodes: Array>) => Array>;
+ const resized = resizeNodes([
+ { id: "test-image-1", width: 300, height: 280, style: { width: 300, height: 280 } },
+ { id: "other-node", width: 300, height: 300, style: { width: 300, height: 300 } },
+ ]);
+
+ expect(resized[0]).toMatchObject({
+ width: 498,
+ height: 280,
+ style: { width: 498, height: 280 },
+ });
+ expect(resized[1]).toMatchObject({ id: "other-node", width: 300, height: 300 });
+ });
});
describe("File Input Change Handler", () => {
diff --git a/src/components/nodes/GenerateImageNode.tsx b/src/components/nodes/GenerateImageNode.tsx
index c8deba83..58a62e4c 100644
--- a/src/components/nodes/GenerateImageNode.tsx
+++ b/src/components/nodes/GenerateImageNode.tsx
@@ -10,7 +10,7 @@ import { NanoBananaNodeData, AspectRatio, Resolution, ModelType, ProviderType, S
import { ProviderModel, ModelCapability } from "@/lib/providers/types";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useToast } from "@/components/Toast";
-import { getImageDimensions, calculateNodeSizePreservingHeight } from "@/utils/nodeDimensions";
+import { getImageDimensions, calculateAspectFitSize } from "@/utils/nodeDimensions";
import { ProviderBadge } from "./ProviderBadge";
import { useInlineParameters } from "@/hooks/useInlineParameters";
import { InlineParameterPanel } from "./InlineParameterPanel";
@@ -539,14 +539,25 @@ export function GenerateImageNode({ id, data, selected }: NodeProps {
if (node.id !== id) return node;
- // Preserve user's manually set height if present
- const currentHeight = typeof node.style?.height === 'number'
- ? node.style.height
- : undefined;
-
- const newSize = calculateNodeSizePreservingHeight(aspectRatio, currentHeight);
-
- return { ...node, style: { ...node.style, width: newSize.width, height: newSize.height } };
+ const currentWidth = typeof node.width === "number"
+ ? node.width
+ : typeof node.style?.width === "number"
+ ? node.style.width
+ : 300;
+ const currentHeight = typeof node.height === "number"
+ ? node.height
+ : typeof node.style?.height === "number"
+ ? node.style.height
+ : 300;
+
+ const newSize = calculateAspectFitSize(aspectRatio, currentWidth, currentHeight, true);
+
+ return {
+ ...node,
+ width: newSize.width,
+ height: newSize.height,
+ style: { ...node.style, width: newSize.width, height: newSize.height },
+ };
})
);
});
@@ -758,7 +769,7 @@ export function GenerateImageNode({ id, data, selected }: NodeProps
{nodeData.__usedFallback && (
;
@@ -20,9 +21,49 @@ export function ImageInputNode({ id, data, selected }: NodeProps state.updateNodeData);
+ const { setNodes } = useReactFlow();
const fileInputRef = useRef(null);
+ const prevImageRef = useRef(null);
const showLabels = useShowHandleLabels(selected);
+ useEffect(() => {
+ if (!nodeData.image || !nodeData.dimensions || nodeData.image === prevImageRef.current) {
+ prevImageRef.current = nodeData.image ?? null;
+ return;
+ }
+ prevImageRef.current = nodeData.image;
+
+ const aspectRatio = nodeData.dimensions.width / nodeData.dimensions.height;
+ if (!Number.isFinite(aspectRatio) || aspectRatio <= 0) return;
+
+ requestAnimationFrame(() => {
+ setNodes((nodes) =>
+ nodes.map((node) => {
+ if (node.id !== id) return node;
+
+ const currentWidth = typeof node.width === "number"
+ ? node.width
+ : typeof node.style?.width === "number"
+ ? node.style.width
+ : 300;
+ const currentHeight = typeof node.height === "number"
+ ? node.height
+ : typeof node.style?.height === "number"
+ ? node.style.height
+ : 280;
+ const newSize = calculateAspectFitSize(aspectRatio, currentWidth, currentHeight, true);
+
+ return {
+ ...node,
+ width: newSize.width,
+ height: newSize.height,
+ style: { ...node.style, width: newSize.width, height: newSize.height },
+ };
+ })
+ );
+ });
+ }, [id, nodeData.dimensions, nodeData.image, setNodes]);
+
const handleFileChange = useCallback(
(e: React.ChangeEvent) => {
const file = e.target.files?.[0];
@@ -110,7 +151,7 @@ export function ImageInputNode({ id, data, selected }: NodeProps
{nodeData.isOptional && (