Browse Source
- Create OutputGalleryNode.tsx with BaseNode wrapper - Single image input handle (type target, id image) - Real-time image collection from connected nodes (imageInput, annotation, nanoBanana) - 3-column scrollable thumbnail grid with nowheel for scroll isolation - Full-screen lightbox via createPortal with: - Left/right arrow navigation - Download button (saves as gallery-image-N.png) - Close button (X icon, top-right) - Click backdrop to close - Keyboard support (Escape, ArrowLeft, ArrowRight) - Image counter display (N / Total) - Empty state placeholder when no images connected - Fix validation.ts: add outputGallery dimensions and createDefaultNodeData case - Build passes with zero errors Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>handoff-20260429-1057
2 changed files with 233 additions and 0 deletions
@ -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<OutputGalleryNodeData, "outputGallery">; |
|||
|
|||
export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGalleryNodeType>) { |
|||
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<number | null>(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 ( |
|||
<> |
|||
<BaseNode |
|||
id={id} |
|||
title="Output Gallery" |
|||
customTitle={nodeData.customTitle} |
|||
comment={nodeData.comment} |
|||
onCustomTitleChange={(title) => updateNodeData(id, { customTitle: title || undefined })} |
|||
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })} |
|||
selected={selected} |
|||
className="min-w-[200px]" |
|||
commentNavigation={commentNavigation ?? undefined} |
|||
> |
|||
<Handle |
|||
type="target" |
|||
position={Position.Left} |
|||
id="image" |
|||
data-handletype="image" |
|||
/> |
|||
|
|||
{displayImages.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 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"> |
|||
{displayImages.map((img, idx) => ( |
|||
<button |
|||
key={idx} |
|||
onClick={() => openLightbox(idx)} |
|||
className="aspect-square rounded border border-neutral-700 hover:border-neutral-500 overflow-hidden transition-colors" |
|||
> |
|||
<img |
|||
src={img} |
|||
alt={`Image ${idx + 1}`} |
|||
className="w-full h-full object-cover" |
|||
/> |
|||
</button> |
|||
))} |
|||
</div> |
|||
</div> |
|||
)} |
|||
</BaseNode> |
|||
|
|||
{/* Lightbox Portal */} |
|||
{lightboxIndex !== null && 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()}> |
|||
<img |
|||
src={displayImages[lightboxIndex]} |
|||
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 button */} |
|||
<button |
|||
onClick={downloadImage} |
|||
className="absolute top-4 left-4 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> |
|||
|
|||
{/* 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 < displayImages.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> |
|||
)} |
|||
|
|||
{/* Image 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} / {displayImages.length} |
|||
</div> |
|||
</div> |
|||
</div>, |
|||
document.body |
|||
)} |
|||
</> |
|||
); |
|||
} |
|||
Loading…
Reference in new issue