"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react"; import { BaseNode } from "./BaseNode"; import { useWorkflowStore } from "@/store/workflowStore"; import { ImageInputNodeData } from "@/types"; import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; import { downloadMedia } from "@/utils/downloadMedia"; import { useI18n } from "@/i18n"; import { calculateAspectFitSize } from "@/utils/nodeDimensions"; import { MediaPreviewModal } from "@/components/MediaPreviewModal"; import { AssetPickerModal, PopiAssetItem } from "@/components/AssetPickerModal"; import { defaultNodeDimensions } from "@/constants/nodeDimensions"; type ImageInputNodeType = Node; const IMAGE_INPUT_DIMENSIONS = defaultNodeDimensions.imageInput; function getImageNodeTitle(id: string, data: ImageInputNodeData, imageLabel: string) { if (data.customTitle) return data.customTitle; if (data.label) return data.label; const match = id.match(/(\d+)$/); return match ? `${imageLabel} ${match[1]}` : imageLabel; } function ImageTypeIcon() { return ( ); } function EmptyImageIcon() { return ( ); } type ImageToolbarIconKind = | "download" | "focus" | "delete" | "upload" | "assets"; function ImageToolbarIcon({ kind }: { kind: ImageToolbarIconKind }) { const commonProps = { className: "h-6 w-6", fill: "none", viewBox: "0 0 24 24", stroke: "currentColor", strokeWidth: 1.8, }; switch (kind) { case "download": return ; case "focus": return ; case "delete": return ; case "upload": return ; case "assets": return ; } } export function ImageInputNode({ id, data, selected }: NodeProps) { const { t } = useI18n(); const nodeData = data; const adaptiveImage = useAdaptiveImageSrc(nodeData.image, id); const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); const { setNodes } = useReactFlow(); const fileInputRef = useRef(null); const prevImageRef = useRef(null); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); 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 : IMAGE_INPUT_DIMENSIONS.width; const currentHeight = typeof node.height === "number" ? node.height : typeof node.style?.height === "number" ? node.style.height : IMAGE_INPUT_DIMENSIONS.height; 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]; if (!file) return; if (!file.type.match(/^image\/(png|jpeg|webp)$/)) { alert(t("upload.imageUnsupported")); return; } // if (file.size > 10 * 1024 * 1024) { // alert(t("upload.imageTooLarge")); // return; // } const reader = new FileReader(); reader.onload = (event) => { const base64 = event.target?.result as string; const img = new Image(); img.onload = () => { updateNodeData(id, { image: base64, imageRef: undefined, filename: file.name, dimensions: { width: img.width, height: img.height }, }); }; img.src = base64; }; reader.readAsDataURL(file); }, [id, t, updateNodeData] ); const handleDrop = useCallback( (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); const file = e.dataTransfer.files?.[0]; if (!file) return; const dt = new DataTransfer(); dt.items.add(file); if (fileInputRef.current) { fileInputRef.current.files = dt.files; fileInputRef.current.dispatchEvent(new Event("change", { bubbles: true })); } }, [] ); const handleDragOver = useCallback((e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); }, []); const handleOpenFileDialog = useCallback(() => { fileInputRef.current?.click(); }, []); const handleSelect = useCallback(() => { selectSingleNode(id); }, [id, selectSingleNode]); const handleRemove = useCallback(() => { updateNodeData(id, { image: null, imageRef: undefined, filename: null, dimensions: null, }); }, [id, updateNodeData]); const handleSelectAsset = useCallback((asset: PopiAssetItem) => { const imageUrl = asset.images?.find(Boolean); if (!imageUrl) return; const applyAsset = (dimensions: { width: number; height: number } | null) => { updateNodeData(id, { image: imageUrl, imageRef: undefined, filename: asset.title || `asset-${asset.id}`, dimensions, }); setIsAssetPickerOpen(false); }; if (asset.width && asset.height) { applyAsset({ width: asset.width, height: asset.height }); return; } const img = new Image(); img.onload = () => applyAsset({ width: img.width, height: img.height }); img.onerror = () => applyAsset(null); img.src = imageUrl; }, [id, updateNodeData]); const title = getImageNodeTitle(id, nodeData, t("node.image")); const hasImage = Boolean(nodeData.image); return (
{title}
{selected && !hasImage && (
)} {selected && hasImage && (
)} {nodeData.image ? (
{ e.preventDefault(); e.stopPropagation(); setIsPreviewOpen(true); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setIsPreviewOpen(true); } }} className="relative group h-full w-full cursor-zoom-in overflow-clip rounded-lg bg-neutral-800" > {nodeData.filename {nodeData.isOptional && ( {t("common.optional")} )} {selected && ( <> )}
) : (
{ e.preventDefault(); e.stopPropagation(); selectSingleNode(id); handleOpenFileDialog(); }} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); selectSingleNode(id); handleOpenFileDialog(); } }} onDrop={handleDrop} onDragOver={handleDragOver} className="flex h-full min-h-28 w-full cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-neutral-700 bg-neutral-900/40 text-center transition-colors hover:border-neutral-500 hover:bg-neutral-900/60" > 拖入图片或点击 {nodeData.isOptional && {t("common.optional")}}
)} {/* Handles rendered after visual content so they paint on top */} {isPreviewOpen && nodeData.image && ( setIsPreviewOpen(false)} /> )} {isAssetPickerOpen && ( setIsAssetPickerOpen(false)} onSelect={handleSelectAsset} /> )}
); }