Browse Source

增加图像节点图生图入口

xuzhijie-popiart-node-canvas
xuzhijie 2 months ago
parent
commit
a57d3a8615
  1. 26
      popiart-node-interaction-enhancement-implementation.md
  2. 83
      src/components/__tests__/ImageInputNode.test.tsx
  3. 112
      src/components/nodes/ImageInputNode.tsx

26
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 提示,不影响本次功能通过。

83
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(
<TestWrapper>
<ImageInputNode {...defaultProps} />
</TestWrapper>
);
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", () => {
</TestWrapper>
);
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<FileReader>) => void) | null = null;
result: string = "data:image/png;base64,img2img";
readAsDataURL() {
setTimeout(() => {
this.onload?.({ target: { result: this.result } } as ProgressEvent<FileReader>);
}, 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(
<TestWrapper>
<ImageInputNode {...defaultProps} />
</TestWrapper>
);
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", () => {
</TestWrapper>
);
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", () => {
</TestWrapper>
);
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();

112
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<ImageInputNodeData, "imageInput">;
@ -19,24 +21,64 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
const adaptiveImage = useAdaptiveImageSrc(nodeData.image, id);
const commentNavigation = useCommentNavigation(id);
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const addNode = useWorkflowStore((state) => state.addNode);
const onConnect = useWorkflowStore((state) => state.onConnect);
const nodes = useWorkflowStore((state) => state.nodes);
const fileInputRef = useRef<HTMLInputElement>(null);
const imageToImageInputRef = useRef<HTMLInputElement>(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<ImageInputNodeT
[applyImageFile]
);
const handleImageToImageChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
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<ImageInputNodeT
onChange={handleFileChange}
className="hidden"
/>
<input
ref={imageToImageInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleImageToImageChange}
className="hidden"
aria-label="Upload image for image to image"
/>
{nodeData.image ? (
<div className="relative group w-full h-full overflow-clip rounded-lg">
@ -131,10 +193,42 @@ export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeT
onDragOver={handleDragOver}
className={`w-full h-full bg-neutral-900/40 flex flex-col items-center justify-center transition-colors ${nodeData.isOptional ? "border-2 border-dashed border-neutral-600" : ""}`}
>
<svg className="w-8 h-8 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5" />
<svg className="w-12 h-12 text-neutral-600" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.4}>
<path strokeLinecap="round" strokeLinejoin="round" d="m3 16 4.5-4.5a2.1 2.1 0 0 1 3 0L14 15m-1-1 1.5-1.5a2.1 2.1 0 0 1 3 0L21 16m-15 4h12a3 3 0 0 0 3-3V7a3 3 0 0 0-3-3H6a3 3 0 0 0-3 3v10a3 3 0 0 0 3 3Zm11-11.5h.01" />
</svg>
<span className="text-xs text-neutral-500 mt-2">{nodeData.isOptional ? "Optional" : "Drop image"}</span>
{nodeData.isOptional ? (
<span className="text-xs text-neutral-500 mt-2">Optional</span>
) : (
<div className="mt-8 w-full max-w-[160px] self-start ml-7">
<div className="mb-3 text-sm text-neutral-400">:</div>
<button
type="button"
className="nodrag nopan mb-4 flex items-center gap-2 text-sm font-semibold text-white transition-colors hover:text-blue-200 focus:outline-none focus:text-blue-200"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
imageToImageInputRef.current?.click();
}}
>
<svg className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.8}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 16V5m0 0 4 4m-4-4-4 4M5 19h14" />
</svg>
</button>
<button
type="button"
disabled
aria-label="图片高清"
title="图片高清功能待接入"
className="nodrag nopan flex cursor-not-allowed items-center gap-2 text-sm font-semibold text-white/85"
>
<span className="flex h-4 min-w-4 items-center justify-center rounded border border-white/80 px-0.5 text-[10px] font-bold leading-none">
HD
</span>
</button>
</div>
)}
</div>
)}

Loading…
Cancel
Save