You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
255 lines
9.4 KiB
255 lines
9.4 KiB
"use client";
|
|
|
|
import { useCallback, useRef } from "react";
|
|
import { Handle, Position, NodeProps, Node } from "@xyflow/react";
|
|
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">;
|
|
|
|
export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeType>) {
|
|
const nodeData = data;
|
|
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);
|
|
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.");
|
|
}
|
|
},
|
|
[addNode, id, nodes, onConnect, writeLoadedImage]
|
|
);
|
|
|
|
const handleFileChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const input = e.currentTarget;
|
|
const file = input.files?.[0];
|
|
if (!file) return;
|
|
|
|
void applyImageFile(file);
|
|
input.value = "";
|
|
},
|
|
[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();
|
|
e.stopPropagation();
|
|
|
|
const file = e.dataTransfer.files?.[0];
|
|
if (!file) return;
|
|
|
|
void applyImageFile(file);
|
|
},
|
|
[applyImageFile]
|
|
);
|
|
|
|
const handleDragOver = useCallback((e: React.DragEvent) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
}, []);
|
|
|
|
const handleRemove = useCallback(() => {
|
|
updateNodeData(id, {
|
|
image: null,
|
|
imageRef: undefined,
|
|
filename: null,
|
|
dimensions: null,
|
|
});
|
|
}, [id, updateNodeData]);
|
|
|
|
return (
|
|
<BaseNode
|
|
id={id}
|
|
selected={selected}
|
|
contentClassName="flex-1 min-h-0"
|
|
aspectFitMedia={nodeData.image}
|
|
fullBleed
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/webp"
|
|
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">
|
|
<img
|
|
src={adaptiveImage ?? undefined}
|
|
alt={nodeData.filename || "Uploaded image"}
|
|
className="w-full h-full object-cover rounded-lg"
|
|
/>
|
|
{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">
|
|
Optional
|
|
</span>
|
|
)}
|
|
<button
|
|
onClick={() => downloadMedia(nodeData.image!, "image")}
|
|
aria-label="Download image"
|
|
className="absolute top-2 right-10 w-6 h-6 bg-black/60 hover:bg-black/80 text-white rounded text-xs opacity-0 group-hover:opacity-100 focus:opacity-100 transition-all flex items-center justify-center"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
</svg>
|
|
</button>
|
|
<button
|
|
onClick={handleRemove}
|
|
aria-label="Remove image"
|
|
className="absolute top-2 right-2 w-6 h-6 bg-black/60 hover:bg-red-600/80 text-white rounded text-xs opacity-0 group-hover:opacity-100 focus:opacity-100 focus:ring-1 focus:ring-red-400 transition-all flex items-center justify-center"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div
|
|
onDrop={handleDrop}
|
|
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-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>
|
|
{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>
|
|
)}
|
|
|
|
{/* Handles rendered after visual content so they paint on top */}
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id="reference"
|
|
data-handletype="reference"
|
|
data-tutorial="node-input-handle"
|
|
className="!bg-gray-500"
|
|
/>
|
|
<HandleLabel label="Ref" side="target" color="#6b7280" visible={showLabels} />
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="image"
|
|
data-handletype="image"
|
|
data-tutorial="node-output-handle"
|
|
/>
|
|
<HandleLabel label="Image" side="source" color="var(--handle-color-image)" visible={showLabels} />
|
|
</BaseNode>
|
|
);
|
|
}
|
|
|