From e50eb44030497a3cdfb3ec52b4f7474862edb841 Mon Sep 17 00:00:00 2001 From: jiajia Date: Fri, 15 May 2026 12:06:05 +0800 Subject: [PATCH] Preserve visible media intent on canvas interactions The canvas needs to respect what users can see: media nodes should reflect the actual image ratio, and the connection menu should dismiss when the user clicks away instead of forcing a node choice. The fix stays inside the affected node/menu components and their regression tests. Constraint: Windows pointer/canvas interactions can prevent bubble-phase outside-click handlers from seeing the click Rejected: Add a modal/backdrop around the connection menu | heavier behavior change and unnecessary for a transient menu Rejected: Add user-facing aspect-ratio controls | the media already exposes dimensions and should not need extra configuration Confidence: high Scope-risk: narrow Directive: Keep connection-menu dismissal as capture-phase pointer handling unless Windows canvas behavior is revalidated Tested: npm test -- --run src/components/__tests__/ConnectionDropMenu.test.tsx src/components/__tests__/ImageInputNode.test.tsx src/components/__tests__/GenerateImageNode.test.tsx Tested: npm run build Not-tested: Manual Windows PC browser run --- src/components/ConnectionDropMenu.tsx | 27 ++++++++-- .../__tests__/ConnectionDropMenu.test.tsx | 18 +++++++ .../__tests__/GenerateImageNode.test.tsx | 54 +++++++++++++++++++ .../__tests__/ImageInputNode.test.tsx | 42 +++++++++++++++ src/components/nodes/GenerateImageNode.tsx | 31 +++++++---- src/components/nodes/ImageInputNode.tsx | 47 ++++++++++++++-- 6 files changed, 201 insertions(+), 18 deletions(-) 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 && (