"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { CloudUploadOutlined } from "@ant-design/icons"; import { Position, NodeProps, Node } from "@xyflow/react"; import { Spin } from "antd"; import { BaseNode } from "./BaseNode"; import { NodeHandle } from "./NodeHandle"; import { useWorkflowStore } from "@/store/workflowStore"; import { useNodeInlinePanelStore } from "@/store/nodeInlinePanelStore"; import { ImageInputNodeData } from "@/types"; import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; import { downloadMedia } from "@/utils/downloadMedia"; import { useI18n } from "@/i18n"; import { MediaPreviewModal } from "@/components/MediaPreviewModal"; import { SINGLE_INPUT_HANDLE_ID, SINGLE_OUTPUT_HANDLE_ID } from "@/utils/nodeHandles"; import { AssetPickerModal, PopiAssetItem } from "@/components/AssetPickerModal"; import { defaultNodeDimensions } from "@/constants/nodeDimensions"; import { uploadFileToGatewayMedia } from "@/utils/gatewayMediaUpload"; import { getPopiImageAssetPreview, resolvePopiImageAsset } from "@/lib/popiAssetApi"; import { useSelectedNodeCount } from "@/hooks/useSelectedNodeCount"; import { NodeActionCapsule, type NodeActionCapsuleAction } from "./NodeActionCapsule"; import { SaveToAssetLibraryModal } from "@/components/SaveToAssetLibraryModal"; import { MediaCropOverlay, type MediaCropAspectRatio, type MediaCropConfirmPayload } from "@/components/media/MediaCropOverlay"; import { CropRatioSelect, MEDIA_EDIT_TOOL, MediaEditToolbar, type MediaEditTool, type MediaEditToolOption } from "@/components/media/MediaEditToolbar"; import { SplitGridCapsuleMenu, type SplitGridSelection } from "@/components/media/SplitGridCapsuleMenu"; import { createPendingDerivedImageNode } from "@/utils/derivedImageNodes"; import { createSplitGridImageNodes } from "@/utils/splitGridNodes"; import { SplitGridSelectionOverlay } from "@/components/media/SplitGridSelectionOverlay"; import { SplitGridSelectionToolbar } from "@/components/media/SplitGridSelectionToolbar"; import { getAutoMediaElementClassName, getAutoMediaFrameClassName } from "./mediaAutoSize"; type ImageInputNodeType = Node; const IMAGE_INPUT_DIMENSIONS = defaultNodeDimensions.imageInput; const IMAGE_DIMENSION_TIMEOUT_MS = 10_000; const IMAGE_LOAD_RETRY_DELAYS_MS = [600, 1600]; const IMAGE_MEDIA_EDIT_TOOLS: MediaEditToolOption[] = [ { key: MEDIA_EDIT_TOOL.crop, label: "裁剪" }, ]; export interface ImageInputNodeViewProps { id: string; data: ImageInputNodeData; selected?: boolean; renderInputHandle?: boolean; } function EmptyImageIcon() { return ( ); } type ImageToolbarIconKind = | "download" | "focus" | "delete" | "upload" | "assets" | "crop" | "grid" | "multiAngle"; 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 ; case "crop": return ; case "grid": return ; case "multiAngle": return ; } } export function ImageInputNodeView({ id, data, selected, renderInputHandle = false }: ImageInputNodeViewProps) { const { t } = useI18n(); const nodeData = data; const adaptiveImage = useAdaptiveImageSrc(nodeData.image, id); const preferredDisplayImage = nodeData.previewImage || adaptiveImage || nodeData.image; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const updateMediaNodeData = useWorkflowStore((state) => state.updateMediaNodeData); const addNode = useWorkflowStore((state) => state.addNode); const onConnect = useWorkflowStore((state) => state.onConnect); const getNodeById = useWorkflowStore((state) => state.getNodeById); const regenerateNode = useWorkflowStore((state) => state.regenerateNode); const selectSingleNode = useWorkflowStore((state) => state.selectSingleNode); const openInlinePanel = useNodeInlinePanelStore((state) => state.openPanel); const fileInputRef = useRef(null); const imageRef = useRef(null); const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false); const [isSaveToAssetLibraryOpen, setIsSaveToAssetLibraryOpen] = useState(false); const [isCropping, setIsCropping] = useState(false); const [activeMediaTool, setActiveMediaTool] = useState(MEDIA_EDIT_TOOL.crop); const [cropAspectRatio, setCropAspectRatio] = useState("original"); const [cropPayload, setCropPayload] = useState(null); const [isSplitGridRunning, setIsSplitGridRunning] = useState(false); const [gridSelection, setGridSelection] = useState(null); const [selectedGridCellKeys, setSelectedGridCellKeys] = useState>(() => new Set()); const [imageRetryNonce, setImageRetryNonce] = useState(0); const [displayFallbackImage, setDisplayFallbackImage] = useState(null); const [imageLoadFailed, setImageLoadFailed] = useState(false); const isUploading = nodeData.uploadStatus === "loading"; const displayImage = displayFallbackImage || preferredDisplayImage; const imageSrc = isCropping ? nodeData.image || displayImage : displayImage; useEffect(() => { setDisplayFallbackImage(null); setImageLoadFailed(false); setImageRetryNonce(0); }, [preferredDisplayImage]); const handleFileChange = useCallback( (e: React.ChangeEvent) => { const file = e.target.files?.[0]; e.target.value = ""; 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; // } updateNodeData(id, { uploadStatus: "loading", uploadError: null }); const uploadImage = (dimensions: { width: number; height: number } | null) => { uploadFileToGatewayMedia(file, "image") .then((uploaded) => { updateMediaNodeData(id, { image: uploaded.url, imageRef: undefined, assetId: undefined, previewImage: uploaded.previewUrl, assetDetailLoading: false, assetDetailError: null, uploadStatus: "idle", uploadError: null, filename: uploaded.filename, dimensions, }, dimensions); }) .catch((error) => { const message = error instanceof Error ? error.message : "Media upload failed"; updateNodeData(id, { uploadStatus: "idle", uploadError: message }); alert(message); }); }; const objectUrl = URL.createObjectURL(file); const img = new Image(); let settled = false; let timeout: number; const finishDimensionRead = (dimensions: { width: number; height: number } | null) => { if (settled) return; settled = true; clearTimeout(timeout); URL.revokeObjectURL(objectUrl); uploadImage(dimensions); }; timeout = window.setTimeout(() => { finishDimensionRead(null); }, IMAGE_DIMENSION_TIMEOUT_MS); img.onload = () => { finishDimensionRead({ width: img.width, height: img.height }); }; img.onerror = () => { finishDimensionRead(null); }; img.src = objectUrl; }, [id, t, updateMediaNodeData, 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(() => { if (isUploading) return; fileInputRef.current?.click(); }, [isUploading]); const handleSelect = useCallback(() => { selectSingleNode(id); }, [id, selectSingleNode]); const handleRemove = useCallback(() => { updateNodeData(id, { image: null, imageRef: undefined, assetId: undefined, previewImage: undefined, assetDetailLoading: false, assetDetailError: null, filename: null, dimensions: null, derivedImage: undefined, status: "idle", error: null, }); }, [id, updateNodeData]); const handleConfirmCrop = useCallback(async (payload: MediaCropConfirmPayload) => { const currentNode = getNodeById(id); if (!currentNode) return; const currentPosition = currentNode.position ?? { x: 0, y: 0 }; const currentWidth = typeof currentNode.width === "number" ? currentNode.width : typeof currentNode.style?.width === "number" ? currentNode.style.width : IMAGE_INPUT_DIMENSIONS.width; const filename = `crop-${Date.now()}.png`; const nodeId = createPendingDerivedImageNode({ sourceNodeId: id, position: { x: currentPosition.x + currentWidth + 220, y: currentPosition.y, }, filename, customTitle: t("imageInput.crop"), operation: "crop", task: { cropRect: payload.cropRect, outputWidth: payload.outputWidth, outputHeight: payload.outputHeight, aspectRatio: payload.aspectRatio, }, addNode, onConnect, selectSingleNode, }); setIsCropping(false); setCropPayload(null); void regenerateNode(nodeId); }, [addNode, getNodeById, id, onConnect, regenerateNode, selectSingleNode, t]); const handleStartGridSelection = useCallback((selection: SplitGridSelection) => { setIsCropping(false); setCropPayload(null); setGridSelection(selection); setSelectedGridCellKeys(new Set()); }, []); const handleToggleGridCell = useCallback((cellKey: string) => { setSelectedGridCellKeys((current) => { const next = new Set(current); if (next.has(cellKey)) { next.delete(cellKey); } else { next.add(cellKey); } return next; }); }, []); const handleCancelGridSelection = useCallback(() => { setGridSelection(null); setSelectedGridCellKeys(new Set()); }, []); const handleConfirmSplitGrid = useCallback(async () => { const sourceNode = getNodeById(id); if (!sourceNode || !gridSelection || selectedGridCellKeys.size === 0) return; setIsSplitGridRunning(true); try { const createdNodeIds = createSplitGridImageNodes({ sourceNode, rows: gridSelection.rows, cols: gridSelection.cols, selectedCellKeys: selectedGridCellKeys, addNode, onConnect, selectSingleNode, }); handleCancelGridSelection(); createdNodeIds.forEach((nodeId) => void regenerateNode(nodeId)); } catch (error) { alert(t("workflow.splitGrid.failed", { error: error instanceof Error ? error.message : "Unknown error" })); } finally { setIsSplitGridRunning(false); } }, [addNode, getNodeById, gridSelection, handleCancelGridSelection, id, onConnect, regenerateNode, selectSingleNode, selectedGridCellKeys, t]); const handleSelectAsset = useCallback((asset: PopiAssetItem) => { const preview = getPopiImageAssetPreview(asset); if (!preview) return; updateMediaNodeData(id, { image: preview.imageUrl, imageRef: undefined, assetId: preview.assetId, previewImage: preview.previewImageUrl, assetDetailLoading: true, assetDetailError: null, filename: preview.filename, dimensions: preview.dimensions, }, preview.dimensions); setIsAssetPickerOpen(false); const applyResolvedAsset = ( resolved: NonNullable>>, dimensions: { width: number; height: number } | null ) => { const currentNode = useWorkflowStore.getState().getNodeById(id); const currentData = currentNode?.data as ImageInputNodeData | undefined; if (currentData?.assetId !== preview.assetId) return; updateMediaNodeData(id, { image: resolved.imageUrl, imageRef: undefined, assetId: resolved.assetId, previewImage: resolved.previewImageUrl, assetDetailLoading: false, assetDetailError: null, filename: resolved.filename, dimensions, }, dimensions); }; void resolvePopiImageAsset(asset) .then((resolved) => { if (!resolved?.imageUrl) return; if (resolved.dimensions) { applyResolvedAsset(resolved, resolved.dimensions); return; } const img = new Image(); img.onload = () => applyResolvedAsset(resolved, { width: img.width, height: img.height }); img.onerror = () => applyResolvedAsset(resolved, preview.dimensions); img.src = resolved.imageUrl; }) .catch((error) => { const currentNode = useWorkflowStore.getState().getNodeById(id); const currentData = currentNode?.data as ImageInputNodeData | undefined; if (currentData?.assetId !== preview.assetId) return; updateNodeData(id, { assetDetailLoading: false, assetDetailError: error instanceof Error ? error.message : "Failed to load asset detail", }); }); }, [id, updateMediaNodeData, updateNodeData]); const hasImage = Boolean(displayImage); const selectedNodeCount = useSelectedNodeCount(); const showSelectedActions = Boolean(selected && selectedNodeCount === 1); const selectedActions: NodeActionCapsuleAction[] = hasImage ? ([ { key: "crop", icon: , label: t("imageInput.crop"), text: t("imageInput.crop"), section: "feature", onClick: (e) => { e.preventDefault(); e.stopPropagation(); setActiveMediaTool(MEDIA_EDIT_TOOL.crop); setCropAspectRatio("original"); setCropPayload(null); setIsCropping(true); }, }, { key: "split-grid", icon: , label: t("imageInput.splitGrid"), text: t("imageInput.splitGrid"), section: "feature", panel: ({ close }) => ( { close(); handleStartGridSelection(selection); }} /> ), }, { key: "multi-angle", icon: , label: t("imageInput.multiAngle"), text: t("imageInput.multiAngle"), section: "feature", onClick: (e) => { e.preventDefault(); e.stopPropagation(); openInlinePanel(id, "multiAngle"); }, }, { key: "save-to-assets", icon: , label: t("assetLibrary.saveToLibrary"), section: "basic", onClick: (e) => { e.preventDefault(); e.stopPropagation(); if (nodeData.image) setIsSaveToAssetLibraryOpen(true); }, }, { key: "download", icon: , label: t("imageInput.download"), section: "basic", onClick: (e) => { e.preventDefault(); e.stopPropagation(); downloadMedia(nodeData.image!, "image"); }, }, { key: "preview", icon: , label: t("imageInput.focusPreview"), section: "basic", onClick: (e) => { e.preventDefault(); e.stopPropagation(); setIsPreviewOpen(true); }, }, { key: "remove", icon: , label: t("imageInput.remove"), section: "basic", tone: "danger", onClick: (e) => { e.preventDefault(); e.stopPropagation(); handleRemove(); }, }, ] satisfies NodeActionCapsuleAction[]) : [ { key: "upload", icon: , label: t("imageInput.localUpload"), text: t("imageInput.localUpload"), onClick: (e) => { e.preventDefault(); e.stopPropagation(); selectSingleNode(id); handleOpenFileDialog(); }, }, { key: "assets", icon: , label: t("imageInput.fromAssets"), text: t("imageInput.fromAssets"), onClick: (e) => { e.preventDefault(); e.stopPropagation(); selectSingleNode(id); setIsAssetPickerOpen(true); }, }, ]; return ( {showSelectedActions && ( !isCropping && !gridSelection && )} {gridSelection && ( void handleConfirmSplitGrid()} /> )} {isCropping && ( setIsCropping(false)} context={{ value: cropAspectRatio, onChange: setCropAspectRatio, onConfirm: () => { if (cropPayload) void handleConfirmCrop(cropPayload); }, }} toolComponents={{ [MEDIA_EDIT_TOOL.crop]: CropRatioSelect, }} /> )} {nodeData.image ? (
{nodeData.filename { setImageLoadFailed(false); const image = event.currentTarget; if (imageSrc === nodeData.image && image.naturalWidth > 0 && image.naturalHeight > 0) { const dimensions = { width: image.naturalWidth, height: image.naturalHeight }; if (nodeData.dimensions?.width !== dimensions.width || nodeData.dimensions?.height !== dimensions.height) { updateMediaNodeData(id, { dimensions }, dimensions); } } }} onError={() => { if (nodeData.previewImage && displayImage === nodeData.previewImage && nodeData.image && nodeData.image !== nodeData.previewImage) { setDisplayFallbackImage(nodeData.image); setImageLoadFailed(false); setImageRetryNonce(0); return; } const retryIndex = imageRetryNonce; const retryDelay = IMAGE_LOAD_RETRY_DELAYS_MS[retryIndex]; if (retryDelay !== undefined && displayImage && /^https?:\/\//i.test(displayImage)) { window.setTimeout(() => { setImageRetryNonce((current) => current === retryIndex ? current + 1 : current); }, retryDelay); return; } setImageLoadFailed(true); }} className={getAutoMediaElementClassName(nodeData.dimensions)} /> {imageLoadFailed && (
{t("imageInput.loadFailed")} {nodeData.filename || nodeData.image}
)} {nodeData.isOptional && ( {t("common.optional")} )} {isCropping && ( )} {gridSelection && ( )}
) : (
{ 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-[var(--border-subtle)] bg-[var(--surface-2)] text-center transition-colors hover:border-[var(--border-strong)] hover:bg-[var(--surface-hover)]" > {t("imageInput.dropImage")} {nodeData.isOptional && {t("common.optional")}}
)} {isUploading && (
)} {/* Handles rendered after visual content so they paint on top */} {renderInputHandle && ( )} {isPreviewOpen && (nodeData.image || displayImage) && ( setIsPreviewOpen(false)} /> )} {isAssetPickerOpen && ( setIsAssetPickerOpen(false)} onSelect={handleSelectAsset} /> )} setIsSaveToAssetLibraryOpen(false)} />
); } export function ImageInputNode({ id, data, selected }: NodeProps) { return ; }