diff --git a/popiart-node-interaction-enhancement-implementation.md b/popiart-node-interaction-enhancement-implementation.md index 68a31ab4..af81b0bb 100644 --- a/popiart-node-interaction-enhancement-implementation.md +++ b/popiart-node-interaction-enhancement-implementation.md @@ -1958,3 +1958,29 @@ npm.cmd run test:run -- WorkflowCanvas.test.tsx ImageNodeComposerPanel.test.tsx ``` 结果:5 个测试文件通过,109 个测试通过。测试日志里仍有已有的 React `act(...)` 警告和 jsdom 视频 `pause()` 未实现提示,不影响本次功能通过。 + +## 2026-04-30 更新:空图像节点尝试入口 + +状态:已完成,可进入手动测试。 + +本次实现补齐第一个空图像节点中间的两个尝试入口: + +1. 空 `imageInput` 节点中间显示 `尝试:`、`图生图`、`图片高清`。 +2. 点击 `图生图` 会打开本地图片选择器。 +3. 选择图片后会把图片上传到当前 `imageInput` 节点。 +4. 上传成功后自动在右侧创建 `Generate Image` / `nanoBanana` 节点。 +5. 自动连接当前图片节点 `image` 输出到新生成图片节点的 `image` 输入。 +6. `图片高清` 目前先作为占位按钮展示,禁用状态,后续接入高清能力时再实现真实执行逻辑。 + +涉及文件: + +- `src/components/nodes/ImageInputNode.tsx` +- `src/components/__tests__/ImageInputNode.test.tsx` + +验证: + +```powershell +npm.cmd run test:run -- ImageInputNode.test.tsx WorkflowCanvas.test.tsx ImageNodeComposerPanel.test.tsx GenerateImageNode.test.tsx +``` + +结果:4 个测试文件通过,95 个测试通过。测试日志里仍有已有的 React `act(...)` 警告、jsdom canvas/getContext 提示和测试环境相对 URL 提示,不影响本次功能通过。 diff --git a/src/components/__tests__/ImageInputNode.test.tsx b/src/components/__tests__/ImageInputNode.test.tsx index 18020da2..4235bc4a 100644 --- a/src/components/__tests__/ImageInputNode.test.tsx +++ b/src/components/__tests__/ImageInputNode.test.tsx @@ -5,14 +5,27 @@ import { ReactFlowProvider } from "@xyflow/react"; // Mock the workflow store const mockUpdateNodeData = vi.fn(); +const mockAddNode = vi.fn(() => "nanoBanana-2"); +const mockOnConnect = vi.fn(); vi.mock("@/store/workflowStore", () => ({ useWorkflowStore: vi.fn((selector) => { const state = { updateNodeData: mockUpdateNodeData, + addNode: mockAddNode, + onConnect: mockOnConnect, currentNodeIds: [], groups: {}, - nodes: [], + nodes: [ + { + id: "test-image-1", + type: "imageInput", + position: { x: 100, y: 120 }, + measured: { width: 300, height: 280 }, + style: { width: 300, height: 280 }, + data: {}, + }, + ], getNodesWithComments: vi.fn(() => []), markCommentViewed: vi.fn(), setNavigationTarget: vi.fn(), @@ -82,14 +95,16 @@ describe("ImageInputNode", () => { }; describe("Basic Rendering", () => { - it("should render drop zone when no image is set", () => { + it("should render image-to-image and upscale actions when no image is set", () => { render( ); - expect(screen.getByText("Drop image")).toBeInTheDocument(); + expect(screen.getByText("尝试:")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "图生图" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "图片高清" })).toBeDisabled(); }); it("should not render the upload button inside the node body", () => { @@ -155,7 +170,7 @@ describe("ImageInputNode", () => { ); - expect(screen.queryByText("Drop image")).not.toBeInTheDocument(); + expect(screen.queryByText("图生图")).not.toBeInTheDocument(); }); }); @@ -347,6 +362,60 @@ describe("ImageInputNode", () => { expect(mockAlert).not.toHaveBeenCalled(); expect(mockReadAsDataURL).toHaveBeenCalledWith(file); }); + + it("should upload an image and create a connected generate image node from the image-to-image action", async () => { + class MockFileReader { + onload: ((event: ProgressEvent) => void) | null = null; + result: string = "data:image/png;base64,img2img"; + readAsDataURL() { + setTimeout(() => { + this.onload?.({ target: { result: this.result } } as ProgressEvent); + }, 0); + } + } + global.FileReader = MockFileReader as unknown as typeof FileReader; + + class MockImage { + onload: (() => void) | null = null; + width: number = 640; + height: number = 480; + set src(_value: string) { + setTimeout(() => { + this.onload?.(); + }, 0); + } + } + global.Image = MockImage as unknown as typeof Image; + + render( + + + + ); + + fireEvent.click(screen.getByRole("button", { name: "图生图" })); + + const fileInput = screen.getByLabelText("Upload image for image to image") as HTMLInputElement; + const file = new File(["test"], "reference.png", { type: "image/png" }); + Object.defineProperty(fileInput, "files", { value: [file] }); + fireEvent.change(fileInput); + + await waitFor(() => { + expect(mockUpdateNodeData).toHaveBeenCalledWith("test-image-1", { + image: "data:image/png;base64,img2img", + imageRef: undefined, + filename: "reference.png", + dimensions: { width: 640, height: 480 }, + }); + }); + expect(mockAddNode).toHaveBeenCalledWith("nanoBanana", { x: 580, y: 120 }); + expect(mockOnConnect).toHaveBeenCalledWith({ + source: "test-image-1", + sourceHandle: "image", + target: "nanoBanana-2", + targetHandle: "image", + }); + }); }); describe("Remove Button", () => { @@ -407,7 +476,7 @@ describe("ImageInputNode", () => { ); - const dropZone = screen.getByText("Drop image").parentElement!; + const dropZone = screen.getByText("尝试:").parentElement!.parentElement!; const dragOverEvent = new Event("dragover", { bubbles: true }); Object.assign(dragOverEvent, { preventDefault: vi.fn(), @@ -430,7 +499,7 @@ describe("ImageInputNode", () => { ); - const dropZone = screen.getByText("Drop image").parentElement!; + const dropZone = screen.getByText("尝试:").parentElement!.parentElement!; // Drop with empty files array (no file to process) const dataTransfer = { @@ -457,7 +526,7 @@ describe("ImageInputNode", () => { const fileInput = document.querySelector('input[type="file"]') as HTMLInputElement; const clickSpy = vi.spyOn(fileInput, "click"); - const dropZone = screen.getByText("Drop image").parentElement!; + const dropZone = screen.getByText("尝试:").parentElement!.parentElement!; fireEvent.click(dropZone); expect(clickSpy).not.toHaveBeenCalled(); diff --git a/src/components/nodes/ImageInputNode.tsx b/src/components/nodes/ImageInputNode.tsx index 14da9fa8..3ee15ed6 100644 --- a/src/components/nodes/ImageInputNode.tsx +++ b/src/components/nodes/ImageInputNode.tsx @@ -6,11 +6,13 @@ import { BaseNode } from "./BaseNode"; import { useCommentNavigation } from "@/hooks/useCommentNavigation"; import { useWorkflowStore } from "@/store/workflowStore"; import { ImageInputNodeData } from "@/types"; +import { defaultNodeDimensions } from "@/store/utils/nodeDefaults"; import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; import { downloadMedia } from "@/utils/downloadMedia"; import { useShowHandleLabels } from "@/hooks/useShowHandleLabels"; import { HandleLabel } from "./HandleLabel"; import { readImageFile } from "@/utils/imageFile"; +import type { LoadedImageFile } from "@/utils/imageFile"; type ImageInputNodeType = Node; @@ -19,24 +21,64 @@ export function ImageInputNode({ id, data, selected }: NodeProps state.updateNodeData); + const addNode = useWorkflowStore((state) => state.addNode); + const onConnect = useWorkflowStore((state) => state.onConnect); + const nodes = useWorkflowStore((state) => state.nodes); const fileInputRef = useRef(null); + const imageToImageInputRef = useRef(null); const showLabels = useShowHandleLabels(selected); + const writeLoadedImage = useCallback( + (loaded: LoadedImageFile) => { + updateNodeData(id, { + image: loaded.image, + imageRef: undefined, + filename: loaded.filename, + dimensions: loaded.dimensions, + }); + }, + [id, updateNodeData] + ); + const applyImageFile = useCallback( async (file: File) => { try { const loaded = await readImageFile(file); - updateNodeData(id, { - image: loaded.image, - imageRef: undefined, - filename: loaded.filename, - dimensions: loaded.dimensions, + writeLoadedImage(loaded); + } catch (error) { + alert(error instanceof Error ? error.message : "Failed to load image file."); + } + }, + [writeLoadedImage] + ); + + const handleImageToImageFile = useCallback( + async (file: File) => { + try { + const loaded = await readImageFile(file); + writeLoadedImage(loaded); + + const currentNode = nodes.find((node) => node.id === id); + const currentWidth = + currentNode?.measured?.width ?? + (typeof currentNode?.style?.width === "number" ? currentNode.style.width : defaultNodeDimensions.imageInput.width); + const currentPosition = currentNode?.position ?? { x: 0, y: 0 }; + const nextNodeId = addNode("nanoBanana", { + x: currentPosition.x + currentWidth + 180, + y: currentPosition.y, + }); + + onConnect({ + source: id, + sourceHandle: "image", + target: nextNodeId, + targetHandle: "image", }); } catch (error) { alert(error instanceof Error ? error.message : "Failed to load image file."); } }, - [id, updateNodeData] + [addNode, id, nodes, onConnect, writeLoadedImage] ); const handleFileChange = useCallback( @@ -51,6 +93,18 @@ export function ImageInputNode({ id, data, selected }: NodeProps) => { + const input = e.currentTarget; + const file = input.files?.[0]; + if (!file) return; + + void handleImageToImageFile(file); + input.value = ""; + }, + [handleImageToImageFile] + ); + const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); @@ -93,6 +147,14 @@ export function ImageInputNode({ id, data, selected }: NodeProps + {nodeData.image ? ( @@ -131,10 +193,42 @@ export function ImageInputNode({ id, data, selected }: NodeProps - - + + - {nodeData.isOptional ? "Optional" : "Drop image"} + {nodeData.isOptional ? ( + Optional + ) : ( + + 尝试: + { + event.preventDefault(); + event.stopPropagation(); + imageToImageInputRef.current?.click(); + }} + > + + + + 图生图 + + + + HD + + 图片高清 + + + )} )}