"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 ( {alt} ); } type OutputGalleryNodeType = Node; export function OutputGalleryNode({ id, data, selected }: NodeProps) { const nodeData = data; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const addNode = useWorkflowStore((state) => state.addNode); const { getNodes, setNodes } = useReactFlow(); const [lightboxIndex, setLightboxIndex] = useState(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 ( <> {displayMedia.length > 0 && (
{displayMedia.length} {displayMedia.length === 1 ? "item" : "items"}
)} {displayMedia.length === 0 ? (
Connect image or video nodes to view gallery
) : (
{displayMedia.map((item, idx) => ( ))}
)}
{/* Lightbox Portal */} {lightboxIndex !== null && currentItem && typeof document !== "undefined" && createPortal(
e.stopPropagation()}> {currentItem.type === "video" ? (
, document.body )} ); }