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.
650 lines
25 KiB
650 lines
25 KiB
"use client";
|
|
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react";
|
|
import { Spin } from "antd";
|
|
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 { 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";
|
|
|
|
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];
|
|
|
|
export interface ImageInputNodeViewProps {
|
|
id: string;
|
|
data: ImageInputNodeData;
|
|
selected?: boolean;
|
|
renderInputHandle?: boolean;
|
|
}
|
|
|
|
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 (
|
|
<span className="flex h-7 w-12 items-center justify-center rounded-full bg-sky-600 text-white shadow-lg shadow-sky-500/20">
|
|
<svg className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.9}>
|
|
<rect x="4" y="5" width="16" height="14" rx="2" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="m4 16 4.5-4.5 4 4L15 13l5 5" />
|
|
<circle cx="15.5" cy="9.5" r="1.5" />
|
|
</svg>
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function EmptyImageIcon() {
|
|
return (
|
|
<svg className="w-8 h-8 text-neutral-600" 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";
|
|
|
|
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>;
|
|
}
|
|
}
|
|
|
|
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 selectSingleNode = useWorkflowStore((state) => state.selectSingleNode);
|
|
const { setNodes } = useReactFlow();
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const prevImageRef = useRef<string | null>(null);
|
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
|
const [isAssetPickerOpen, setIsAssetPickerOpen] = useState(false);
|
|
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 = displayImage;
|
|
|
|
useEffect(() => {
|
|
setDisplayFallbackImage(null);
|
|
setImageLoadFailed(false);
|
|
setImageRetryNonce(0);
|
|
}, [preferredDisplayImage]);
|
|
|
|
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<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) => {
|
|
updateNodeData(id, {
|
|
image: uploaded.url,
|
|
imageRef: undefined,
|
|
assetId: undefined,
|
|
previewImage: undefined,
|
|
assetDetailLoading: false,
|
|
assetDetailError: null,
|
|
uploadStatus: "idle",
|
|
uploadError: null,
|
|
filename: uploaded.filename,
|
|
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, 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,
|
|
});
|
|
}, [id, updateNodeData]);
|
|
|
|
const handleSelectAsset = useCallback((asset: PopiAssetItem) => {
|
|
const preview = getPopiImageAssetPreview(asset);
|
|
if (!preview) return;
|
|
|
|
updateNodeData(id, {
|
|
image: preview.imageUrl,
|
|
imageRef: undefined,
|
|
assetId: preview.assetId,
|
|
previewImage: preview.previewImageUrl,
|
|
assetDetailLoading: true,
|
|
assetDetailError: null,
|
|
filename: preview.filename,
|
|
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;
|
|
|
|
updateNodeData(id, {
|
|
image: resolved.imageUrl,
|
|
imageRef: undefined,
|
|
assetId: resolved.assetId,
|
|
previewImage: resolved.previewImageUrl,
|
|
assetDetailLoading: false,
|
|
assetDetailError: null,
|
|
filename: resolved.filename,
|
|
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, updateNodeData]);
|
|
|
|
const title = getImageNodeTitle(id, nodeData, t("node.image"));
|
|
const hasImage = Boolean(displayImage);
|
|
const mediaActionVisibility = selected ? "opacity-100" : "opacity-0 group-hover:opacity-100";
|
|
|
|
return (
|
|
<BaseNode
|
|
id={id}
|
|
selected={selected}
|
|
minWidth={IMAGE_INPUT_DIMENSIONS.width}
|
|
minHeight={IMAGE_INPUT_DIMENSIONS.height}
|
|
contentClassName="flex-1 min-h-0"
|
|
aspectFitMedia={displayImage}
|
|
fullBleed
|
|
className={selected ? "!border-blue-500 !ring-0" : ""}
|
|
>
|
|
<input
|
|
ref={fileInputRef}
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/webp"
|
|
onChange={handleFileChange}
|
|
className="hidden"
|
|
/>
|
|
|
|
<div className="nodrag pointer-events-none absolute -top-10 left-0 z-[10001] flex items-center gap-2">
|
|
<ImageTypeIcon />
|
|
<span className="text-sm font-medium text-neutral-100 drop-shadow-sm">{title}</span>
|
|
</div>
|
|
|
|
{selected && !hasImage && (
|
|
<div className="nodrag nopan absolute left-1/2 -top-[86px] z-[10002] flex -translate-x-1/2 items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
selectSingleNode(id);
|
|
handleOpenFileDialog();
|
|
}}
|
|
aria-label={t("imageInput.localUpload")}
|
|
className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-200 shadow-xl shadow-black/30 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100"
|
|
>
|
|
<ImageToolbarIcon kind="upload" />
|
|
<span>{t("imageInput.localUpload")}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
selectSingleNode(id);
|
|
setIsAssetPickerOpen(true);
|
|
}}
|
|
aria-label={t("imageInput.fromAssets")}
|
|
className="flex h-10 items-center gap-2 whitespace-nowrap rounded-lg border border-neutral-600/70 bg-neutral-800 px-3 text-sm text-neutral-200 shadow-xl shadow-black/30 transition-colors hover:border-neutral-500 hover:bg-neutral-700 hover:text-neutral-100"
|
|
>
|
|
<ImageToolbarIcon kind="assets" />
|
|
<span>{t("imageInput.fromAssets")}</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{selected && hasImage && (
|
|
<div className="nodrag nopan absolute left-1/2 -top-[132px] z-[10002] flex -translate-x-1/2 items-center gap-2 rounded-xl border border-neutral-600/70 bg-neutral-800 px-4 py-3 text-xl text-neutral-300 shadow-2xl shadow-black/40">
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleOpenFileDialog();
|
|
}}
|
|
aria-label={t("imageInput.replace")}
|
|
title={t("imageInput.replace")}
|
|
className="flex h-10 items-center justify-center rounded-md px-2 text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
|
|
>
|
|
<ImageToolbarIcon kind="upload" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsAssetPickerOpen(true);
|
|
}}
|
|
aria-label={t("imageInput.fromAssets")}
|
|
title={t("imageInput.fromAssets")}
|
|
className="flex h-10 items-center justify-center rounded-md px-2 text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
|
|
>
|
|
<ImageToolbarIcon kind="assets" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
downloadMedia(nodeData.image!, "image");
|
|
}}
|
|
aria-label={t("imageInput.download")}
|
|
title={t("imageInput.download")}
|
|
className="flex h-10 items-center justify-center rounded-md px-2 text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
|
|
>
|
|
<ImageToolbarIcon kind="download" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setIsPreviewOpen(true);
|
|
}}
|
|
aria-label={t("imageInput.focusPreview")}
|
|
title={t("imageInput.focusPreview")}
|
|
className="flex h-10 items-center justify-center rounded-md px-2 text-neutral-300 transition-colors hover:bg-neutral-700 hover:text-neutral-100"
|
|
>
|
|
<ImageToolbarIcon kind="focus" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={(e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
handleRemove();
|
|
}}
|
|
aria-label={t("imageInput.remove")}
|
|
title={t("imageInput.remove")}
|
|
className="flex h-10 items-center justify-center rounded-md px-2 text-neutral-100 transition-colors hover:bg-red-600/80"
|
|
>
|
|
<ImageToolbarIcon kind="delete" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{nodeData.image ? (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={`${nodeData.filename || t("imageInput.uploadedAlt")} preview`}
|
|
onClick={handleSelect}
|
|
onDoubleClick={(e) => {
|
|
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"
|
|
>
|
|
<img
|
|
key={`${displayImage ?? "empty"}-${imageRetryNonce}`}
|
|
src={imageSrc ?? undefined}
|
|
alt={nodeData.filename || t("imageInput.uploadedAlt")}
|
|
onLoad={() => setImageLoadFailed(false)}
|
|
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="h-full w-full rounded-lg object-contain"
|
|
/>
|
|
{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>
|
|
)}
|
|
{selected && (
|
|
<>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsPreviewOpen(true);
|
|
}}
|
|
aria-label={t("imageInput.focusPreview")}
|
|
className="absolute bottom-3 right-3 flex h-11 w-11 items-center justify-center rounded-xl bg-black/40 text-white shadow-lg backdrop-blur-sm transition-colors hover:bg-black/60"
|
|
>
|
|
<ImageToolbarIcon kind="focus" />
|
|
</button>
|
|
</>
|
|
)}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsAssetPickerOpen(true);
|
|
}}
|
|
aria-label={t("imageInput.fromAssets")}
|
|
title={t("imageInput.fromAssets")}
|
|
className={`absolute top-2 right-[6.75rem] flex h-6 w-6 items-center justify-center rounded bg-black/60 text-xs text-white transition-all hover:bg-black/80 focus:opacity-100 [&_svg]:h-3.5 [&_svg]:w-3.5 ${mediaActionVisibility}`}
|
|
>
|
|
<ImageToolbarIcon kind="assets" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleOpenFileDialog();
|
|
}}
|
|
aria-label={t("imageInput.replace")}
|
|
title={t("imageInput.replace")}
|
|
className={`absolute top-2 right-[4.5rem] flex h-6 w-6 items-center justify-center rounded bg-black/60 text-xs text-white transition-all hover:bg-black/80 focus:opacity-100 [&_svg]:h-3.5 [&_svg]:w-3.5 ${mediaActionVisibility}`}
|
|
>
|
|
<ImageToolbarIcon kind="upload" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
downloadMedia(nodeData.image!, "image");
|
|
}}
|
|
aria-label={t("imageInput.download")}
|
|
title={t("imageInput.download")}
|
|
className={`absolute top-2 right-10 flex h-6 w-6 items-center justify-center rounded bg-black/60 text-xs text-white transition-all hover:bg-black/80 focus:opacity-100 [&_svg]:h-3.5 [&_svg]:w-3.5 ${mediaActionVisibility}`}
|
|
>
|
|
<ImageToolbarIcon kind="download" />
|
|
</button>
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleRemove();
|
|
}}
|
|
aria-label={t("imageInput.remove")}
|
|
title={t("imageInput.remove")}
|
|
className={`absolute top-2 right-2 flex h-6 w-6 items-center justify-center rounded bg-black/60 text-xs text-white transition-all hover:bg-red-600/80 focus:opacity-100 focus:ring-1 focus:ring-red-400 [&_svg]:h-3.5 [&_svg]:w-3.5 ${mediaActionVisibility}`}
|
|
>
|
|
<ImageToolbarIcon kind="delete" />
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center bg-neutral-900/20 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-neutral-700 bg-neutral-900/40 text-center transition-colors hover:border-neutral-500 hover:bg-neutral-900/60"
|
|
>
|
|
<EmptyImageIcon />
|
|
<span className="text-xs text-neutral-500 mt-2">拖入图片或点击</span>
|
|
{nodeData.isOptional && <span className="mt-4 text-xs text-neutral-500">{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 && (
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id={SINGLE_INPUT_HANDLE_ID}
|
|
data-handletype="generic-input"
|
|
style={{ opacity: 0, zIndex: 10 }}
|
|
/>
|
|
)}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id={SINGLE_OUTPUT_HANDLE_ID}
|
|
data-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}
|
|
/>
|
|
)}
|
|
</BaseNode>
|
|
);
|
|
}
|
|
|
|
export function ImageInputNode({ id, data, selected }: NodeProps<ImageInputNodeType>) {
|
|
return <ImageInputNodeView id={id} data={data} selected={selected} />;
|
|
}
|
|
|