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.
352 lines
14 KiB
352 lines
14 KiB
"use client";
|
|
|
|
import { useState, useCallback, useMemo, useEffect } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react";
|
|
import { BaseNode } from "./BaseNode";
|
|
import { useWorkflowStore } from "@/store/workflowStore";
|
|
import { OutputGalleryNodeData } from "@/types";
|
|
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
|
|
import { defaultNodeDimensions } from "@/store/utils/nodeDefaults";
|
|
import { downloadMedia as downloadMediaUtil } from "@/utils/downloadMedia";
|
|
import { useShowHandleLabels } from "@/hooks/useShowHandleLabels";
|
|
import { HandleLabel } from "./HandleLabel";
|
|
|
|
type MediaItem = { type: "image" | "video"; src: string };
|
|
|
|
function AdaptiveGalleryThumbnail({ src, alt, nodeId }: { src: string; alt: string; nodeId: string }) {
|
|
const adaptiveSrc = useAdaptiveImageSrc(src, nodeId);
|
|
return (
|
|
<img
|
|
src={adaptiveSrc ?? undefined}
|
|
alt={alt}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
);
|
|
}
|
|
|
|
type OutputGalleryNodeType = Node<OutputGalleryNodeData, "outputGallery">;
|
|
|
|
export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGalleryNodeType>) {
|
|
const nodeData = data;
|
|
const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
|
|
const addNode = useWorkflowStore((state) => state.addNode);
|
|
const { getNodes, setNodes } = useReactFlow();
|
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
const showLabels = useShowHandleLabels(selected);
|
|
|
|
// Display stored media only — items are accumulated during workflow execution
|
|
const displayMedia = useMemo(() => {
|
|
const media: MediaItem[] = [
|
|
...(nodeData.images || []).map((src): MediaItem => ({ type: "image", src })),
|
|
...(nodeData.videos || []).map((src): MediaItem => ({ type: "video", src })),
|
|
];
|
|
return media;
|
|
}, [nodeData.images, nodeData.videos]);
|
|
|
|
const openLightbox = useCallback((index: number) => {
|
|
setLightboxIndex(index);
|
|
}, []);
|
|
|
|
const closeLightbox = useCallback(() => {
|
|
setLightboxIndex(null);
|
|
}, []);
|
|
|
|
const navigateLightbox = useCallback(
|
|
(direction: "prev" | "next") => {
|
|
if (lightboxIndex === null) return;
|
|
|
|
if (direction === "prev" && lightboxIndex > 0) {
|
|
setLightboxIndex(lightboxIndex - 1);
|
|
} else if (direction === "next" && lightboxIndex < displayMedia.length - 1) {
|
|
setLightboxIndex(lightboxIndex + 1);
|
|
}
|
|
},
|
|
[lightboxIndex, displayMedia.length]
|
|
);
|
|
|
|
const downloadMedia = useCallback(() => {
|
|
if (lightboxIndex === null) return;
|
|
|
|
const item = displayMedia[lightboxIndex];
|
|
if (!item) return;
|
|
|
|
downloadMediaUtil(item.src, item.type).catch((err) =>
|
|
console.error("Gallery download failed:", err)
|
|
);
|
|
}, [lightboxIndex, displayMedia]);
|
|
|
|
const removeMedia = useCallback((index: number) => {
|
|
const item = displayMedia[index];
|
|
if (!item) return;
|
|
|
|
if (item.type === "image") {
|
|
const images = [...(nodeData.images || [])];
|
|
const imageRefs = [...(nodeData.imageRefs || [])];
|
|
const imgIndex = images.indexOf(item.src);
|
|
if (imgIndex !== -1) {
|
|
images.splice(imgIndex, 1);
|
|
if (imgIndex < imageRefs.length) imageRefs.splice(imgIndex, 1);
|
|
}
|
|
updateNodeData(id, { images, imageRefs });
|
|
} else {
|
|
const videos = [...(nodeData.videos || [])];
|
|
const videoRefs = [...(nodeData.videoRefs || [])];
|
|
const vidIndex = videos.indexOf(item.src);
|
|
if (vidIndex !== -1) {
|
|
videos.splice(vidIndex, 1);
|
|
if (vidIndex < videoRefs.length) videoRefs.splice(vidIndex, 1);
|
|
}
|
|
updateNodeData(id, { videos, videoRefs });
|
|
}
|
|
|
|
// Adjust lightbox after removal
|
|
if (lightboxIndex !== null) {
|
|
const newLength = displayMedia.length - 1;
|
|
if (newLength <= 0) {
|
|
setLightboxIndex(null);
|
|
} else if (lightboxIndex >= newLength) {
|
|
setLightboxIndex(newLength - 1);
|
|
}
|
|
}
|
|
}, [displayMedia, nodeData.images, nodeData.imageRefs, nodeData.videos, nodeData.videoRefs, updateNodeData, id, lightboxIndex]);
|
|
|
|
const handleExtractToInputNodes = useCallback(() => {
|
|
const galleryNode = getNodes().find((n) => n.id === id);
|
|
if (!galleryNode) return;
|
|
|
|
const galleryWidth = galleryNode.measured?.width ?? defaultNodeDimensions.outputGallery.width;
|
|
const startX = galleryNode.position.x + galleryWidth + 100;
|
|
let currentY = galleryNode.position.y;
|
|
const gap = 20;
|
|
|
|
const newNodeIds: string[] = [];
|
|
const images = nodeData.images || [];
|
|
const videos = nodeData.videos || [];
|
|
|
|
// Reverse so oldest items (end of array) appear at top, newest at bottom
|
|
const reversedImages = [...images].reverse();
|
|
const reversedVideos = [...videos].reverse();
|
|
|
|
for (let i = 0; i < reversedImages.length; i++) {
|
|
const nodeId = addNode("imageInput", { x: startX, y: currentY }, { image: reversedImages[i], filename: `gallery-image-${i + 1}.png` });
|
|
newNodeIds.push(nodeId);
|
|
currentY += defaultNodeDimensions.imageInput.height + gap;
|
|
}
|
|
|
|
for (let i = 0; i < reversedVideos.length; i++) {
|
|
const nodeId = addNode("videoInput", { x: startX, y: currentY }, { video: reversedVideos[i], filename: `gallery-video-${i + 1}.mp4` });
|
|
newNodeIds.push(nodeId);
|
|
currentY += defaultNodeDimensions.videoInput.height + gap;
|
|
}
|
|
|
|
if (newNodeIds.length > 0) {
|
|
setNodes((nodes) =>
|
|
nodes.map((n) => ({
|
|
...n,
|
|
selected: newNodeIds.includes(n.id),
|
|
}))
|
|
);
|
|
}
|
|
}, [id, nodeData.images, nodeData.videos, getNodes, addNode, setNodes]);
|
|
|
|
// Keyboard navigation for lightbox
|
|
useEffect(() => {
|
|
if (lightboxIndex === null) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
switch (e.key) {
|
|
case "Escape":
|
|
closeLightbox();
|
|
break;
|
|
case "ArrowLeft":
|
|
navigateLightbox("prev");
|
|
break;
|
|
case "ArrowRight":
|
|
navigateLightbox("next");
|
|
break;
|
|
}
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown);
|
|
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
}, [lightboxIndex, closeLightbox, navigateLightbox]);
|
|
|
|
const currentItem = lightboxIndex !== null ? displayMedia[lightboxIndex] : null;
|
|
|
|
return (
|
|
<>
|
|
<BaseNode
|
|
id={id}
|
|
selected={selected}
|
|
className="min-w-[200px]"
|
|
>
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id="image"
|
|
data-handletype="image"
|
|
style={{ top: "40%" }}
|
|
/>
|
|
<HandleLabel label="Image" side="target" color="rgb(59, 130, 246)" top="calc(40% - 18px)" visible={showLabels} />
|
|
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id="video"
|
|
data-handletype="video"
|
|
style={{ top: "60%" }}
|
|
/>
|
|
<HandleLabel label="Video" side="target" color="var(--handle-color-video)" top="calc(60% - 18px)" visible={showLabels} />
|
|
|
|
{displayMedia.length > 0 && (
|
|
<div className="flex items-center justify-between px-2 py-1">
|
|
<span className="text-neutral-400 text-[10px]">
|
|
{displayMedia.length} {displayMedia.length === 1 ? "item" : "items"}
|
|
</span>
|
|
<button
|
|
onClick={handleExtractToInputNodes}
|
|
className="nodrag nopan flex items-center gap-1 px-1.5 py-0.5 text-[10px] text-neutral-400 hover:text-white hover:bg-neutral-700 rounded transition-colors"
|
|
title="Extract each item as an input node"
|
|
>
|
|
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
</svg>
|
|
Extract
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{displayMedia.length === 0 ? (
|
|
<div className="w-full flex-1 min-h-[200px] border border-dashed border-neutral-600 rounded flex items-center justify-center">
|
|
<span className="text-neutral-500 text-[10px] text-center px-4">
|
|
Connect image or video nodes to view gallery
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<div className="flex-1 overflow-y-auto nodrag nopan nowheel">
|
|
<div className="grid grid-cols-3 gap-1.5 p-1">
|
|
{displayMedia.map((item, idx) => (
|
|
<button
|
|
key={idx}
|
|
onClick={() => openLightbox(idx)}
|
|
aria-label={item.type === "video" ? `Open video ${idx + 1}` : `Open image ${idx + 1}`}
|
|
className="aspect-square rounded border border-neutral-700 hover:border-neutral-500 overflow-hidden transition-colors relative"
|
|
>
|
|
{item.type === "video" ? (
|
|
<>
|
|
<video
|
|
src={item.src}
|
|
className="w-full h-full object-cover"
|
|
muted
|
|
playsInline
|
|
preload="metadata"
|
|
/>
|
|
{/* Video play icon overlay */}
|
|
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
|
|
<svg className="w-5 h-5 text-white drop-shadow" fill="currentColor" viewBox="0 0 24 24">
|
|
<path d="M8 5v14l11-7z" />
|
|
</svg>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<AdaptiveGalleryThumbnail src={item.src} alt={`Image ${idx + 1}`} nodeId={id} />
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</BaseNode>
|
|
|
|
{/* Lightbox Portal */}
|
|
{lightboxIndex !== null && currentItem && typeof document !== "undefined" &&
|
|
createPortal(
|
|
<div
|
|
className="fixed inset-0 bg-black/90 z-[100] flex items-center justify-center p-8"
|
|
onClick={closeLightbox}
|
|
>
|
|
<div className="relative max-w-full max-h-full" onClick={(e) => e.stopPropagation()}>
|
|
{currentItem.type === "video" ? (
|
|
<video
|
|
src={currentItem.src}
|
|
className="max-w-full max-h-[90vh] object-contain rounded"
|
|
controls
|
|
autoPlay
|
|
playsInline
|
|
/>
|
|
) : (
|
|
<img
|
|
src={currentItem.src}
|
|
alt={`Gallery image ${lightboxIndex + 1}`}
|
|
className="max-w-full max-h-[90vh] object-contain rounded"
|
|
/>
|
|
)}
|
|
|
|
{/* Close button */}
|
|
<button
|
|
onClick={closeLightbox}
|
|
className="absolute top-4 right-4 w-8 h-8 bg-white/10 hover:bg-white/20 rounded text-white text-sm transition-colors flex items-center justify-center"
|
|
>
|
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
|
|
{/* Download + Remove buttons */}
|
|
<div className="absolute top-4 left-4 flex gap-1.5">
|
|
<button
|
|
onClick={downloadMedia}
|
|
className="px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded text-white text-xs font-medium transition-colors flex items-center gap-1.5"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
|
|
</svg>
|
|
Download
|
|
</button>
|
|
<button
|
|
onClick={() => removeMedia(lightboxIndex)}
|
|
className="px-3 py-1.5 bg-white/10 hover:bg-red-600/80 rounded text-white text-xs font-medium transition-colors flex items-center gap-1.5"
|
|
>
|
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
|
|
</svg>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
|
|
{/* Left arrow */}
|
|
{lightboxIndex > 0 && (
|
|
<button
|
|
onClick={() => navigateLightbox("prev")}
|
|
className="absolute left-4 top-1/2 -translate-y-1/2 w-10 h-10 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors flex items-center justify-center"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
{/* Right arrow */}
|
|
{lightboxIndex < displayMedia.length - 1 && (
|
|
<button
|
|
onClick={() => navigateLightbox("next")}
|
|
className="absolute right-4 top-1/2 -translate-y-1/2 w-10 h-10 bg-white/10 hover:bg-white/20 rounded-full text-white transition-colors flex items-center justify-center"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
|
</svg>
|
|
</button>
|
|
)}
|
|
|
|
{/* Media counter */}
|
|
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 px-3 py-1.5 bg-black/50 rounded text-white text-xs font-medium">
|
|
{lightboxIndex + 1} / {displayMedia.length}
|
|
</div>
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|