Browse Source

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
feature/canvas-chatbot-copilot
jiajia 2 months ago
parent
commit
e50eb44030
  1. 27
      src/components/ConnectionDropMenu.tsx
  2. 18
      src/components/__tests__/ConnectionDropMenu.test.tsx
  3. 54
      src/components/__tests__/GenerateImageNode.test.tsx
  4. 42
      src/components/__tests__/ImageInputNode.test.tsx
  5. 31
      src/components/nodes/GenerateImageNode.tsx
  6. 47
      src/components/nodes/ImageInputNode.tsx

27
src/components/ConnectionDropMenu.tsx

@ -819,16 +819,33 @@ export function ConnectionDropMenu({
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [options, selectedIndex, onSelect, onClose]); }, [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(() => { useEffect(() => {
const handleClickOutside = (e: MouseEvent) => { let lastPointerCloseAt = 0;
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
const closeIfOutside = (event: MouseEvent | PointerEvent) => {
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
onClose(); onClose();
} }
}; };
document.addEventListener("mousedown", handleClickOutside); const handlePointerDownOutside = (event: PointerEvent) => {
return () => document.removeEventListener("mousedown", handleClickOutside); 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]); }, [onClose]);
// Focus the menu when it opens // Focus the menu when it opens

18
src/components/__tests__/ConnectionDropMenu.test.tsx

@ -217,6 +217,24 @@ describe("ConnectionDropMenu", () => {
expect(mockOnClose).toHaveBeenCalled(); expect(mockOnClose).toHaveBeenCalled();
}); });
it("should close on outside pointerdown before bubbling handlers can stop the event", () => {
render(
<div>
<div
data-testid="outside"
onPointerDown={(event) => event.stopPropagation()}
>
Outside
</div>
<ConnectionDropMenu {...defaultProps} />
</div>
);
fireEvent.pointerDown(screen.getByTestId("outside"));
expect(mockOnClose).toHaveBeenCalledTimes(1);
});
it("should not call onClose when clicking inside the menu", () => { it("should not call onClose when clicking inside the menu", () => {
render(<ConnectionDropMenu {...defaultProps} />); render(<ConnectionDropMenu {...defaultProps} />);

54
src/components/__tests__/GenerateImageNode.test.tsx

@ -304,6 +304,60 @@ describe("GenerateImageNode", () => {
const img = screen.getByAltText("Generated"); const img = screen.getByAltText("Generated");
expect(img).toBeInTheDocument(); expect(img).toBeInTheDocument();
expect(img).toHaveAttribute("src", "data:image/png;base64,abc123"); 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(
<TestWrapper>
<GenerateImageNode {...createNodeProps({
outputImage: "data:image/png;base64,wide",
})} />
</TestWrapper>
);
await waitFor(() => expect(mockSetNodes).toHaveBeenCalled());
const resizeNodes = mockSetNodes.mock.calls.at(-1)?.[0] as (nodes: Array<Record<string, unknown>>) => Array<Record<string, unknown>>;
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", () => { it("should render clear button when output image exists", () => {

42
src/components/__tests__/ImageInputNode.test.tsx

@ -5,6 +5,17 @@ import { ReactFlowProvider } from "@xyflow/react";
// Mock the workflow store // Mock the workflow store
const mockUpdateNodeData = vi.fn(); 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", () => ({ vi.mock("@/store/workflowStore", () => ({
useWorkflowStore: vi.fn((selector) => { useWorkflowStore: vi.fn((selector) => {
@ -135,6 +146,7 @@ describe("ImageInputNode", () => {
const img = screen.getByAltText("test-image.png"); const img = screen.getByAltText("test-image.png");
expect(img).toBeInTheDocument(); expect(img).toBeInTheDocument();
expect(img).toHaveAttribute("src", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="); expect(img).toHaveAttribute("src", "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg==");
expect(img).toHaveClass("object-contain");
}); });
it("should not show drop zone when image is set", () => { it("should not show drop zone when image is set", () => {
@ -146,6 +158,36 @@ describe("ImageInputNode", () => {
expect(screen.queryByText("Drop image")).not.toBeInTheDocument(); expect(screen.queryByText("Drop image")).not.toBeInTheDocument();
}); });
it("should resize the canvas node to the stored image aspect ratio", async () => {
render(
<TestWrapper>
<ImageInputNode
{...defaultProps}
data={{
image: "data:image/png;base64,wide",
filename: "wide.png",
dimensions: { width: 1600, height: 900 },
}}
/>
</TestWrapper>
);
await waitFor(() => expect(mockSetNodes).toHaveBeenCalled());
const resizeNodes = mockSetNodes.mock.calls.at(-1)?.[0] as (nodes: Array<Record<string, unknown>>) => Array<Record<string, unknown>>;
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", () => { describe("File Input Change Handler", () => {

31
src/components/nodes/GenerateImageNode.tsx

@ -10,7 +10,7 @@ import { NanoBananaNodeData, AspectRatio, Resolution, ModelType, ProviderType, S
import { ProviderModel, ModelCapability } from "@/lib/providers/types"; import { ProviderModel, ModelCapability } from "@/lib/providers/types";
import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog"; import { ModelSearchDialog } from "@/components/modals/ModelSearchDialog";
import { useToast } from "@/components/Toast"; import { useToast } from "@/components/Toast";
import { getImageDimensions, calculateNodeSizePreservingHeight } from "@/utils/nodeDimensions"; import { getImageDimensions, calculateAspectFitSize } from "@/utils/nodeDimensions";
import { ProviderBadge } from "./ProviderBadge"; import { ProviderBadge } from "./ProviderBadge";
import { useInlineParameters } from "@/hooks/useInlineParameters"; import { useInlineParameters } from "@/hooks/useInlineParameters";
import { InlineParameterPanel } from "./InlineParameterPanel"; import { InlineParameterPanel } from "./InlineParameterPanel";
@ -539,14 +539,25 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
nodes.map((node) => { nodes.map((node) => {
if (node.id !== id) return node; if (node.id !== id) return node;
// Preserve user's manually set height if present const currentWidth = typeof node.width === "number"
const currentHeight = typeof node.style?.height === 'number' ? node.width
? node.style.height : typeof node.style?.width === "number"
: undefined; ? node.style.width
: 300;
const newSize = calculateNodeSizePreservingHeight(aspectRatio, currentHeight); const currentHeight = typeof node.height === "number"
? node.height
return { ...node, style: { ...node.style, width: newSize.width, height: newSize.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<NanoBananaNo
<img <img
src={adaptiveOutputImage ?? undefined} src={adaptiveOutputImage ?? undefined}
alt="Generated" alt="Generated"
className="w-full h-full object-cover" className="w-full h-full object-contain"
/> />
{nodeData.__usedFallback && ( {nodeData.__usedFallback && (
<div <div

47
src/components/nodes/ImageInputNode.tsx

@ -1,7 +1,7 @@
"use client"; "use client";
import { useCallback, useRef } from "react"; import { useCallback, useEffect, useRef } from "react";
import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react";
import { BaseNode } from "./BaseNode"; import { BaseNode } from "./BaseNode";
import { useCommentNavigation } from "@/hooks/useCommentNavigation"; import { useCommentNavigation } from "@/hooks/useCommentNavigation";
import { useWorkflowStore } from "@/store/workflowStore"; import { useWorkflowStore } from "@/store/workflowStore";
@ -11,6 +11,7 @@ import { downloadMedia } from "@/utils/downloadMedia";
import { useShowHandleLabels } from "@/hooks/useShowHandleLabels"; import { useShowHandleLabels } from "@/hooks/useShowHandleLabels";
import { HandleLabel } from "./HandleLabel"; import { HandleLabel } from "./HandleLabel";
import { useI18n } from "@/i18n"; import { useI18n } from "@/i18n";
import { calculateAspectFitSize } from "@/utils/nodeDimensions";
type ImageInputNodeType = Node<ImageInputNodeData, "imageInput">; type ImageInputNodeType = Node<ImageInputNodeData, "imageInput">;
@ -20,9 +21,49 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
const adaptiveImage = useAdaptiveImageSrc(nodeData.image, id); const adaptiveImage = useAdaptiveImageSrc(nodeData.image, id);
const commentNavigation = useCommentNavigation(id); const commentNavigation = useCommentNavigation(id);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const { setNodes } = useReactFlow();
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const prevImageRef = useRef<string | null>(null);
const showLabels = useShowHandleLabels(selected); 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( const handleFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => { (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@ -110,7 +151,7 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
<img <img
src={adaptiveImage ?? undefined} src={adaptiveImage ?? undefined}
alt={nodeData.filename || "Uploaded image"} alt={nodeData.filename || "Uploaded image"}
className="w-full h-full object-cover rounded-lg" className="w-full h-full object-contain rounded-lg"
/> />
{nodeData.isOptional && ( {nodeData.isOptional && (
<span className="absolute bottom-2 left-2 text-[9px] font-medium text-neutral-300 bg-black/50 px-1.5 py-0.5 rounded"> <span className="absolute bottom-2 left-2 text-[9px] font-medium text-neutral-300 bg-black/50 px-1.5 py-0.5 rounded">

Loading…
Cancel
Save