Browse Source

feat: add Extract button to OutputGalleryNode for batch input node creation

Adds a toolbar button that extracts all gallery items (images and videos) into
individual input nodes, stacked vertically to the right of the gallery node.
New nodes are auto-selected for easy repositioning.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 3 months ago
parent
commit
1998031038
  1. 253
      src/components/nodes/OutputGalleryNode.tsx

253
src/components/nodes/OutputGalleryNode.tsx

@ -2,11 +2,14 @@
import { useState, useCallback, useMemo, useEffect } from "react"; import { useState, useCallback, useMemo, useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { Handle, Position, NodeProps, Node } from "@xyflow/react"; import { Handle, Position, NodeProps, Node, useReactFlow } from "@xyflow/react";
import { BaseNode } from "./BaseNode"; import { BaseNode } from "./BaseNode";
import { useWorkflowStore } from "@/store/workflowStore"; import { useWorkflowStore } from "@/store/workflowStore";
import { OutputGalleryNodeData } from "@/types"; import { OutputGalleryNodeData } from "@/types";
import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc"; import { useAdaptiveImageSrc } from "@/hooks/useAdaptiveImageSrc";
import { defaultNodeDimensions } from "@/store/utils/nodeDefaults";
type MediaItem = { type: "image" | "video"; src: string };
function AdaptiveGalleryThumbnail({ src, alt, nodeId }: { src: string; alt: string; nodeId: string }) { function AdaptiveGalleryThumbnail({ src, alt, nodeId }: { src: string; alt: string; nodeId: string }) {
const adaptiveSrc = useAdaptiveImageSrc(src, nodeId); const adaptiveSrc = useAdaptiveImageSrc(src, nodeId);
@ -24,43 +27,18 @@ type OutputGalleryNodeType = Node<OutputGalleryNodeData, "outputGallery">;
export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGalleryNodeType>) { export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGalleryNodeType>) {
const nodeData = data; const nodeData = data;
const updateNodeData = useWorkflowStore((state) => state.updateNodeData); const updateNodeData = useWorkflowStore((state) => state.updateNodeData);
const edges = useWorkflowStore((state) => state.edges); const addNode = useWorkflowStore((state) => state.addNode);
const nodes = useWorkflowStore((state) => state.nodes); const { getNodes, setNodes } = useReactFlow();
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
// Collect images in real-time from connected nodes (not just during execution) // Display stored media only — items are accumulated during workflow execution
const displayImages = useMemo(() => { const displayMedia = useMemo(() => {
// Start with images from execution (data.images) const media: MediaItem[] = [
const executionImages = [...(nodeData.images || [])]; ...(nodeData.images || []).map((src): MediaItem => ({ type: "image", src })),
...(nodeData.videos || []).map((src): MediaItem => ({ type: "video", src })),
// Also collect images from currently connected nodes ];
const connectedImages: string[] = []; return media;
edges }, [nodeData.images, nodeData.videos]);
.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) => { const openLightbox = useCallback((index: number) => {
setLightboxIndex(index); setLightboxIndex(index);
@ -76,26 +54,85 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGaller
if (direction === "prev" && lightboxIndex > 0) { if (direction === "prev" && lightboxIndex > 0) {
setLightboxIndex(lightboxIndex - 1); setLightboxIndex(lightboxIndex - 1);
} else if (direction === "next" && lightboxIndex < displayImages.length - 1) { } else if (direction === "next" && lightboxIndex < displayMedia.length - 1) {
setLightboxIndex(lightboxIndex + 1); setLightboxIndex(lightboxIndex + 1);
} }
}, },
[lightboxIndex, displayImages.length] [lightboxIndex, displayMedia.length]
); );
const downloadImage = useCallback(() => { const downloadMedia = useCallback(() => {
if (lightboxIndex === null) return; if (lightboxIndex === null) return;
const image = displayImages[lightboxIndex]; const item = displayMedia[lightboxIndex];
if (!image) return; if (!item) return;
const link = document.createElement("a"); const link = document.createElement("a");
link.href = image; link.href = item.src;
link.download = `gallery-image-${lightboxIndex + 1}.png`; link.download = item.type === "video"
? `gallery-video-${lightboxIndex + 1}.mp4`
: `gallery-image-${lightboxIndex + 1}.png`;
document.body.appendChild(link); document.body.appendChild(link);
link.click(); link.click();
document.body.removeChild(link); document.body.removeChild(link);
}, [lightboxIndex, displayImages]); }, [lightboxIndex, displayMedia]);
const removeMedia = useCallback((index: number) => {
const item = displayMedia[index];
if (!item) return;
if (item.type === "image") {
const filtered = (nodeData.images || []).filter((img) => img !== item.src);
updateNodeData(id, { images: filtered });
} else {
const filtered = (nodeData.videos || []).filter((vid) => vid !== item.src);
updateNodeData(id, { videos: filtered });
}
// Adjust lightbox after removal
if (lightboxIndex !== null) {
if (displayMedia.length <= 1) {
setLightboxIndex(null);
} else if (lightboxIndex >= displayMedia.length - 1) {
setLightboxIndex(displayMedia.length - 2);
}
}
}, [displayMedia, nodeData.images, nodeData.videos, 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 || [];
for (let i = 0; i < images.length; i++) {
const nodeId = addNode("imageInput", { x: startX, y: currentY }, { image: images[i], filename: `gallery-image-${i + 1}.png` });
newNodeIds.push(nodeId);
currentY += defaultNodeDimensions.imageInput.height + gap;
}
for (let i = 0; i < videos.length; i++) {
const nodeId = addNode("videoInput", { x: startX, y: currentY }, { video: videos[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 // Keyboard navigation for lightbox
useEffect(() => { useEffect(() => {
@ -119,6 +156,8 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGaller
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [lightboxIndex, closeLightbox, navigateLightbox]); }, [lightboxIndex, closeLightbox, navigateLightbox]);
const currentItem = lightboxIndex !== null ? displayMedia[lightboxIndex] : null;
return ( return (
<> <>
<BaseNode <BaseNode
@ -131,24 +170,81 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGaller
position={Position.Left} position={Position.Left}
id="image" id="image"
data-handletype="image" data-handletype="image"
style={{ top: "40%" }}
/> />
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{ right: "calc(100% + 8px)", top: "calc(40% - 18px)", color: "rgb(59, 130, 246)" }}
>
Image
</div>
{displayImages.length === 0 ? ( <Handle
type="target"
position={Position.Left}
id="video"
data-handletype="video"
style={{ top: "60%" }}
/>
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{ right: "calc(100% + 8px)", top: "calc(60% - 18px)", color: "rgb(168, 85, 247)" }}
>
Video
</div>
{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"> <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"> <span className="text-neutral-500 text-[10px] text-center px-4">
Connect image nodes to view gallery Connect image or video nodes to view gallery
</span> </span>
</div> </div>
) : ( ) : (
<div className="flex-1 overflow-y-auto nodrag nopan nowheel"> <div className="flex-1 overflow-y-auto nodrag nopan nowheel">
<div className="grid grid-cols-3 gap-1.5 p-1"> <div className="grid grid-cols-3 gap-1.5 p-1">
{displayImages.map((img, idx) => ( {displayMedia.map((item, idx) => (
<button <button
key={idx} key={idx}
onClick={() => openLightbox(idx)} onClick={() => openLightbox(idx)}
className="aspect-square rounded border border-neutral-700 hover:border-neutral-500 overflow-hidden transition-colors" className="aspect-square rounded border border-neutral-700 hover:border-neutral-500 overflow-hidden transition-colors relative"
> >
<AdaptiveGalleryThumbnail src={img} alt={`Image ${idx + 1}`} nodeId={id} /> {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> </button>
))} ))}
</div> </div>
@ -157,18 +253,28 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGaller
</BaseNode> </BaseNode>
{/* Lightbox Portal */} {/* Lightbox Portal */}
{lightboxIndex !== null && typeof document !== "undefined" && {lightboxIndex !== null && currentItem && typeof document !== "undefined" &&
createPortal( createPortal(
<div <div
className="fixed inset-0 bg-black/90 z-[100] flex items-center justify-center p-8" className="fixed inset-0 bg-black/90 z-[100] flex items-center justify-center p-8"
onClick={closeLightbox} onClick={closeLightbox}
> >
<div className="relative max-w-full max-h-full" onClick={(e) => e.stopPropagation()}> <div className="relative max-w-full max-h-full" onClick={(e) => e.stopPropagation()}>
<img {currentItem.type === "video" ? (
src={displayImages[lightboxIndex]} <video
alt={`Gallery image ${lightboxIndex + 1}`} src={currentItem.src}
className="max-w-full max-h-[90vh] object-contain rounded" 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 */} {/* Close button */}
<button <button
@ -180,16 +286,27 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGaller
</svg> </svg>
</button> </button>
{/* Download button */} {/* Download + Remove buttons */}
<button <div className="absolute top-4 left-4 flex gap-1.5">
onClick={downloadImage} <button
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" 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 className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
</svg> <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" />
Download </svg>
</button> 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 */} {/* Left arrow */}
{lightboxIndex > 0 && ( {lightboxIndex > 0 && (
@ -204,7 +321,7 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGaller
)} )}
{/* Right arrow */} {/* Right arrow */}
{lightboxIndex < displayImages.length - 1 && ( {lightboxIndex < displayMedia.length - 1 && (
<button <button
onClick={() => navigateLightbox("next")} 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" 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"
@ -215,9 +332,9 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps<OutputGaller
</button> </button>
)} )}
{/* Image counter */} {/* 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"> <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} {lightboxIndex + 1} / {displayMedia.length}
</div> </div>
</div> </div>
</div>, </div>,

Loading…
Cancel
Save