diff --git a/src/components/nodes/OutputGalleryNode.tsx b/src/components/nodes/OutputGalleryNode.tsx new file mode 100644 index 00000000..48e809e6 --- /dev/null +++ b/src/components/nodes/OutputGalleryNode.tsx @@ -0,0 +1,228 @@ +"use client"; + +import { useState, useCallback, useMemo, useEffect } from "react"; +import { createPortal } from "react-dom"; +import { Handle, Position, NodeProps, Node } from "@xyflow/react"; +import { BaseNode } from "./BaseNode"; +import { useCommentNavigation } from "@/hooks/useCommentNavigation"; +import { useWorkflowStore } from "@/store/workflowStore"; +import { OutputGalleryNodeData } from "@/types"; + +type OutputGalleryNodeType = Node; + +export function OutputGalleryNode({ id, data, selected }: NodeProps) { + const nodeData = data; + const commentNavigation = useCommentNavigation(id); + const updateNodeData = useWorkflowStore((state) => state.updateNodeData); + const edges = useWorkflowStore((state) => state.edges); + const nodes = useWorkflowStore((state) => state.nodes); + const [lightboxIndex, setLightboxIndex] = useState(null); + + // Collect images in real-time from connected nodes (not just during execution) + const displayImages = useMemo(() => { + // Start with images from execution (data.images) + const executionImages = [...(nodeData.images || [])]; + + // Also collect images from currently connected nodes + const connectedImages: string[] = []; + edges + .filter((edge) => edge.target === id) + .forEach((edge) => { + const sourceNode = nodes.find((n) => n.id === edge.source); + if (!sourceNode) return; + + let image: string | null = null; + + // Extract image from different node types + if (sourceNode.type === "imageInput") { + image = (sourceNode.data as any).image; + } else if (sourceNode.type === "annotation") { + image = (sourceNode.data as any).outputImage; + } else if (sourceNode.type === "nanoBanana") { + image = (sourceNode.data as any).outputImage; + } + + if (image) { + connectedImages.push(image); + } + }); + + // Combine both sources, removing duplicates + const allImages = [...new Set([...executionImages, ...connectedImages])]; + return allImages; + }, [nodeData.images, edges, nodes, id]); + + 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 < displayImages.length - 1) { + setLightboxIndex(lightboxIndex + 1); + } + }, + [lightboxIndex, displayImages.length] + ); + + const downloadImage = useCallback(() => { + if (lightboxIndex === null) return; + + const image = displayImages[lightboxIndex]; + if (!image) return; + + const link = document.createElement("a"); + link.href = image; + link.download = `gallery-image-${lightboxIndex + 1}.png`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + }, [lightboxIndex, displayImages]); + + // 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]); + + return ( + <> + updateNodeData(id, { customTitle: title || undefined })} + onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} + selected={selected} + className="min-w-[200px]" + commentNavigation={commentNavigation ?? undefined} + > + + + {displayImages.length === 0 ? ( +
+ + Connect image nodes to view gallery + +
+ ) : ( +
+
+ {displayImages.map((img, idx) => ( + + ))} +
+
+ )} +
+ + {/* Lightbox Portal */} + {lightboxIndex !== null && typeof document !== "undefined" && + createPortal( +
+
e.stopPropagation()}> + {`Gallery + + {/* Close button */} + + + {/* Download button */} + + + {/* Left arrow */} + {lightboxIndex > 0 && ( + + )} + + {/* Right arrow */} + {lightboxIndex < displayImages.length - 1 && ( + + )} + + {/* Image counter */} +
+ {lightboxIndex + 1} / {displayImages.length} +
+
+
, + document.body + )} + + ); +} diff --git a/src/lib/quickstart/validation.ts b/src/lib/quickstart/validation.ts index 4c24a2ae..149f33dd 100644 --- a/src/lib/quickstart/validation.ts +++ b/src/lib/quickstart/validation.ts @@ -34,6 +34,7 @@ const DEFAULT_DIMENSIONS: Record = llmGenerate: { width: 320, height: 360 }, splitGrid: { width: 300, height: 320 }, output: { width: 320, height: 320 }, + outputGallery: { width: 320, height: 360 }, }; /** @@ -264,6 +265,10 @@ function createDefaultNodeData(type: NodeType): WorkflowNodeData { return { image: null, }; + case "outputGallery": + return { + images: [], + }; } }