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.
 
 

725 lines
27 KiB

"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<ImageInputNodeData, "imageInput">;
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 (
<svg className="w-8 h-8 text-[var(--text-disabled)]" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5} aria-hidden="true">
<rect x="3" y="5" width="18" height="14" rx="2" />
<path strokeLinecap="round" strokeLinejoin="round" d="m3 16 5-5 4.5 4.5L15 13l6 6" />
<circle cx="15.5" cy="9.5" r="1.5" />
</svg>
);
}
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 <svg {...commonProps}><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v11m0 0 4-4m-4 4-4-4M4 17v2h16v-2" /></svg>;
case "focus":
return <svg {...commonProps}><path strokeLinecap="round" strokeLinejoin="round" d="M8 4H4v4M16 4h4v4M8 20H4v-4M20 16v4h-4" /></svg>;
case "delete":
return <svg {...commonProps}><path strokeLinecap="round" strokeLinejoin="round" d="M6 7h12M9 7V5h6v2m-8 0 1 13h8l1-13" /></svg>;
case "upload":
return <svg {...commonProps}><path strokeLinecap="round" strokeLinejoin="round" d="M12 4v11m0-11 4 4m-4-4-4 4M4 17v2h16v-2" /></svg>;
case "assets":
return <svg {...commonProps}><path strokeLinecap="round" strokeLinejoin="round" d="M4 6.5A2.5 2.5 0 0 1 6.5 4h11A2.5 2.5 0 0 1 20 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 4 17.5z" /><path strokeLinecap="round" strokeLinejoin="round" d="M8 8h8M8 12h8M8 16h5" /></svg>;
case "crop":
return <svg {...commonProps}><path strokeLinecap="round" strokeLinejoin="round" d="M6 3v12a3 3 0 0 0 3 3h12" /><path strokeLinecap="round" strokeLinejoin="round" d="M3 6h12a3 3 0 0 1 3 3v12" /></svg>;
case "grid":
return <svg {...commonProps}><rect x="4" y="4" width="16" height="16" rx="2" /><path strokeLinecap="round" d="M9.33 4v16M14.67 4v16M4 9.33h16M4 14.67h16" /></svg>;
case "multiAngle":
return <svg {...commonProps}><path strokeLinecap="round" strokeLinejoin="round" d="M4 12a8 8 0 0 1 16 0" /><path strokeLinecap="round" strokeLinejoin="round" d="M8 12a4 4 0 0 1 8 0" /><path strokeLinecap="round" strokeLinejoin="round" d="M12 12l5-5M12 12l-5 5" /><circle cx="12" cy="12" r="1.5" /></svg>;
}
}
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<HTMLInputElement>(null);
const imageRef = useRef<HTMLImageElement>(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<MediaEditTool>(MEDIA_EDIT_TOOL.crop);
const [cropAspectRatio, setCropAspectRatio] = useState<MediaCropAspectRatio>("original");
const [cropPayload, setCropPayload] = useState<MediaCropConfirmPayload | null>(null);
const [isSplitGridRunning, setIsSplitGridRunning] = useState(false);
const [gridSelection, setGridSelection] = useState<SplitGridSelection | null>(null);
const [selectedGridCellKeys, setSelectedGridCellKeys] = useState<Set<string>>(() => new Set());
const [imageRetryNonce, setImageRetryNonce] = useState(0);
const [displayFallbackImage, setDisplayFallbackImage] = useState<string | null>(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<HTMLInputElement>) => {
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<Awaited<ReturnType<typeof resolvePopiImageAsset>>>,
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: <span className="[&_svg]:h-5 [&_svg]:w-5"><ImageToolbarIcon kind="crop" /></span>,
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: <span className="[&_svg]:h-5 [&_svg]:w-5"><ImageToolbarIcon kind="grid" /></span>,
label: t("imageInput.splitGrid"),
text: t("imageInput.splitGrid"),
section: "feature",
panel: ({ close }) => (
<SplitGridCapsuleMenu
busy={isSplitGridRunning}
onSelect={(selection) => {
close();
handleStartGridSelection(selection);
}}
/>
),
},
{
key: "multi-angle",
icon: <span className="[&_svg]:h-5 [&_svg]:w-5"><ImageToolbarIcon kind="multiAngle" /></span>,
label: t("imageInput.multiAngle"),
text: t("imageInput.multiAngle"),
section: "feature",
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
openInlinePanel(id, "multiAngle");
},
},
{
key: "save-to-assets",
icon: <CloudUploadOutlined className="text-[24px]" />,
label: t("assetLibrary.saveToLibrary"),
section: "basic",
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
if (nodeData.image) setIsSaveToAssetLibraryOpen(true);
},
},
{
key: "download",
icon: <ImageToolbarIcon kind="download" />,
label: t("imageInput.download"),
section: "basic",
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
downloadMedia(nodeData.image!, "image");
},
},
{
key: "preview",
icon: <ImageToolbarIcon kind="focus" />,
label: t("imageInput.focusPreview"),
section: "basic",
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
setIsPreviewOpen(true);
},
},
{
key: "remove",
icon: <ImageToolbarIcon kind="delete" />,
label: t("imageInput.remove"),
section: "basic",
tone: "danger",
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
handleRemove();
},
},
] satisfies NodeActionCapsuleAction[])
: [
{
key: "upload",
icon: <ImageToolbarIcon kind="upload" />,
label: t("imageInput.localUpload"),
text: t("imageInput.localUpload"),
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
selectSingleNode(id);
handleOpenFileDialog();
},
},
{
key: "assets",
icon: <ImageToolbarIcon kind="assets" />,
label: t("imageInput.fromAssets"),
text: t("imageInput.fromAssets"),
onClick: (e) => {
e.preventDefault();
e.stopPropagation();
selectSingleNode(id);
setIsAssetPickerOpen(true);
},
},
];
return (
<BaseNode
id={id}
selected={selected}
minWidth={IMAGE_INPUT_DIMENSIONS.width}
minHeight={IMAGE_INPUT_DIMENSIONS.height}
autoSizeContent={hasImage}
contentClassName={hasImage ? "relative h-full w-full" : "flex-1 min-h-0"}
fullBleed
className={selected ? "!border-[var(--node-selected-border)] !ring-0" : ""}
>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleFileChange}
className="hidden"
/>
{showSelectedActions && (
!isCropping && !gridSelection && <NodeActionCapsule actions={selectedActions} />
)}
{gridSelection && (
<SplitGridSelectionToolbar
selectedCount={selectedGridCellKeys.size}
busy={isSplitGridRunning}
onCancel={handleCancelGridSelection}
onConfirm={() => void handleConfirmSplitGrid()}
/>
)}
{isCropping && (
<MediaEditToolbar
activeTool={activeMediaTool}
tools={IMAGE_MEDIA_EDIT_TOOLS}
busy={false}
onCancel={() => setIsCropping(false)}
context={{
value: cropAspectRatio,
onChange: setCropAspectRatio,
onConfirm: () => {
if (cropPayload) void handleConfirmCrop(cropPayload);
},
}}
toolComponents={{
[MEDIA_EDIT_TOOL.crop]: CropRatioSelect,
}}
/>
)}
{nodeData.image ? (
<div
aria-label={`${nodeData.filename || t("imageInput.uploadedAlt")} preview`}
onClick={handleSelect}
className={getAutoMediaFrameClassName()}
>
<img
ref={imageRef}
key={`${displayImage ?? "empty"}-${imageRetryNonce}`}
src={imageSrc ?? undefined}
alt={nodeData.filename || t("imageInput.uploadedAlt")}
onLoad={(event) => {
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 && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 rounded-lg bg-neutral-900/80 p-4 text-center">
<EmptyImageIcon />
<span className="text-xs font-medium text-neutral-200">{t("imageInput.loadFailed")}</span>
<span className="max-w-full truncate text-[10px] text-neutral-500">{nodeData.filename || nodeData.image}</span>
</div>
)}
{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">
{t("common.optional")}
</span>
)}
{isCropping && (
<MediaCropOverlay
kind="image"
mediaElementRef={imageRef}
naturalWidth={nodeData.dimensions?.width}
naturalHeight={nodeData.dimensions?.height}
aspectRatio={cropAspectRatio}
busy={false}
onCropChange={setCropPayload}
/>
)}
{gridSelection && (
<SplitGridSelectionOverlay
mediaElementRef={imageRef}
naturalWidth={nodeData.dimensions?.width}
naturalHeight={nodeData.dimensions?.height}
rows={gridSelection.rows}
cols={gridSelection.cols}
selectedCellKeys={selectedGridCellKeys}
busy={isSplitGridRunning}
onToggleCell={handleToggleGridCell}
/>
)}
</div>
) : (
<div className="flex h-full w-full items-center justify-center bg-[var(--surface-1)] p-8">
<div
role="button"
tabIndex={0}
aria-label={t("imageInput.upload")}
onClick={(e) => {
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)]"
>
<EmptyImageIcon />
<span className="text-xs text-[var(--text-muted)] mt-2">{t("imageInput.dropImage")}</span>
{nodeData.isOptional && <span className="mt-4 text-xs text-[var(--text-muted)]">{t("common.optional")}</span>}
</div>
</div>
)}
{isUploading && (
<div className="nodrag nopan absolute inset-0 z-[10003] flex items-center justify-center rounded-lg bg-black/45 backdrop-blur-[1px]">
<Spin />
</div>
)}
{/* Handles rendered after visual content so they paint on top */}
{renderInputHandle && (
<NodeHandle
type="target"
position={Position.Left}
id={SINGLE_INPUT_HANDLE_ID}
handleType="generic-input"
style={{ opacity: 0, zIndex: 10 }}
/>
)}
<NodeHandle
type="source"
position={Position.Right}
id={SINGLE_OUTPUT_HANDLE_ID}
handleType="image"
data-tutorial="node-output-handle"
style={{ opacity: 0, zIndex: 10 }}
/>
{isPreviewOpen && (nodeData.image || displayImage) && (
<MediaPreviewModal
type="image"
src={nodeData.image || displayImage || ""}
downloadSrc={nodeData.image || displayImage || ""}
alt={`${nodeData.filename || t("imageInput.uploadedAlt")} preview`}
onClose={() => setIsPreviewOpen(false)}
/>
)}
{isAssetPickerOpen && (
<AssetPickerModal
kind="image"
onClose={() => setIsAssetPickerOpen(false)}
onSelect={handleSelectAsset}
/>
)}
<SaveToAssetLibraryModal
open={isSaveToAssetLibraryOpen}
mediaUrl={nodeData.image || null}
onClose={() => setIsSaveToAssetLibraryOpen(false)}
/>
</BaseNode>
);
}
export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeType>) {
return <ImageInputNodeView id={id} data={data} selected={selected} />;
}