From 19980310381fb3a3afcddb005f777a5caccc808b Mon Sep 17 00:00:00 2001 From: shrimbly Date: Fri, 3 Apr 2026 20:14:33 +1300 Subject: [PATCH] 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 --- src/components/nodes/OutputGalleryNode.tsx | 253 +++++++++++++++------ 1 file changed, 185 insertions(+), 68 deletions(-) diff --git a/src/components/nodes/OutputGalleryNode.tsx b/src/components/nodes/OutputGalleryNode.tsx index ab2e9e89..d6d3a1db 100644 --- a/src/components/nodes/OutputGalleryNode.tsx +++ b/src/components/nodes/OutputGalleryNode.tsx @@ -2,11 +2,14 @@ import { useState, useCallback, useMemo, useEffect } from "react"; 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 { useWorkflowStore } from "@/store/workflowStore"; import { OutputGalleryNodeData } from "@/types"; 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 }) { const adaptiveSrc = useAdaptiveImageSrc(src, nodeId); @@ -24,43 +27,18 @@ type OutputGalleryNodeType = Node; export function OutputGalleryNode({ id, data, selected }: NodeProps) { const nodeData = data; const updateNodeData = useWorkflowStore((state) => state.updateNodeData); - const edges = useWorkflowStore((state) => state.edges); - const nodes = useWorkflowStore((state) => state.nodes); + const addNode = useWorkflowStore((state) => state.addNode); + const { getNodes, setNodes } = useReactFlow(); 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]); + // 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); @@ -76,26 +54,85 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps 0) { setLightboxIndex(lightboxIndex - 1); - } else if (direction === "next" && lightboxIndex < displayImages.length - 1) { + } else if (direction === "next" && lightboxIndex < displayMedia.length - 1) { setLightboxIndex(lightboxIndex + 1); } }, - [lightboxIndex, displayImages.length] + [lightboxIndex, displayMedia.length] ); - const downloadImage = useCallback(() => { + const downloadMedia = useCallback(() => { if (lightboxIndex === null) return; - const image = displayImages[lightboxIndex]; - if (!image) return; + const item = displayMedia[lightboxIndex]; + if (!item) return; const link = document.createElement("a"); - link.href = image; - link.download = `gallery-image-${lightboxIndex + 1}.png`; + link.href = item.src; + link.download = item.type === "video" + ? `gallery-video-${lightboxIndex + 1}.mp4` + : `gallery-image-${lightboxIndex + 1}.png`; document.body.appendChild(link); link.click(); 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 useEffect(() => { @@ -119,6 +156,8 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps window.removeEventListener("keydown", handleKeyDown); }, [lightboxIndex, closeLightbox, navigateLightbox]); + const currentItem = lightboxIndex !== null ? displayMedia[lightboxIndex] : null; + return ( <> +
+ Image +
- {displayImages.length === 0 ? ( + +
+ Video +
+ + {displayMedia.length > 0 && ( +
+ + {displayMedia.length} {displayMedia.length === 1 ? "item" : "items"} + + +
+ )} + + {displayMedia.length === 0 ? (
- Connect image nodes to view gallery + Connect image or video nodes to view gallery
) : (
- {displayImages.map((img, idx) => ( + {displayMedia.map((item, idx) => ( ))}
@@ -157,18 +253,28 @@ export function OutputGalleryNode({ id, data, selected }: NodeProps {/* Lightbox Portal */} - {lightboxIndex !== null && typeof document !== "undefined" && + {lightboxIndex !== null && currentItem && typeof document !== "undefined" && createPortal(
e.stopPropagation()}> - {`Gallery + {currentItem.type === "video" ? ( +
,