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
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} />;
|
|
}
|
|
|