"use client"; import { useReactFlow } from "@xyflow/react"; import { useWorkflowStore } from "@/store/workflowStore"; import { useMemo, useCallback } from "react"; import JSZip from "jszip"; import { useI18n } from "@/i18n"; import type { ImageInputNodeData, AnnotationNodeData, NanoBananaNodeData, OutputNodeData, } from "@/types"; const STACK_GAP = 20; export function MultiSelectToolbar() { const { t } = useI18n(); const { nodes, onNodesChange, createGroup, removeNodesFromGroup } = useWorkflowStore(); const { getViewport } = useReactFlow(); const selectedNodes = useMemo( () => nodes.filter((node) => node.selected), [nodes] ); // Check if any selected nodes are in a group const selectedNodeGroups = useMemo(() => { const groupIds = new Set(selectedNodes.map((n) => n.groupId).filter(Boolean)); return [...groupIds]; }, [selectedNodes]); const someInGroup = selectedNodeGroups.length > 0; // Calculate toolbar position (centered above selected nodes) const toolbarPosition = useMemo(() => { if (selectedNodes.length < 2) return null; const viewport = getViewport(); // Find bounding box of selected nodes let minX = Infinity; let minY = Infinity; let maxX = -Infinity; selectedNodes.forEach((node) => { const nodeWidth = (node.style?.width as number) || node.measured?.width || 220; minX = Math.min(minX, node.position.x); minY = Math.min(minY, node.position.y); maxX = Math.max(maxX, node.position.x + nodeWidth); }); // Convert flow coordinates to screen coordinates const centerX = (minX + maxX) / 2; const screenX = centerX * viewport.zoom + viewport.x; const screenY = minY * viewport.zoom + viewport.y - 50; // 50px above the top return { x: screenX, y: screenY }; }, [selectedNodes, getViewport]); const handleStackHorizontally = () => { if (selectedNodes.length < 2) return; // Sort by current x position to maintain relative order const sortedNodes = [...selectedNodes].sort((a, b) => a.position.x - b.position.x); // Use the topmost y position as the alignment point const alignY = Math.min(...sortedNodes.map((n) => n.position.y)); let currentX = sortedNodes[0].position.x; sortedNodes.forEach((node) => { const nodeWidth = (node.style?.width as number) || node.measured?.width || 220; onNodesChange([ { type: "position", id: node.id, position: { x: currentX, y: alignY }, }, ]); currentX += nodeWidth + STACK_GAP; }); }; const handleStackVertically = () => { if (selectedNodes.length < 2) return; // Sort by current y position to maintain relative order const sortedNodes = [...selectedNodes].sort((a, b) => a.position.y - b.position.y); // Use the leftmost x position as the alignment point const alignX = Math.min(...sortedNodes.map((n) => n.position.x)); let currentY = sortedNodes[0].position.y; sortedNodes.forEach((node) => { const nodeHeight = (node.style?.height as number) || node.measured?.height || 200; onNodesChange([ { type: "position", id: node.id, position: { x: alignX, y: currentY }, }, ]); currentY += nodeHeight + STACK_GAP; }); }; const handleArrangeAsGrid = () => { if (selectedNodes.length < 2) return; // Calculate optimal grid dimensions (as square as possible) const count = selectedNodes.length; const cols = Math.ceil(Math.sqrt(count)); // Sort nodes by their current position (top-to-bottom, left-to-right) const sortedNodes = [...selectedNodes].sort((a, b) => { const rowA = Math.floor(a.position.y / 100); const rowB = Math.floor(b.position.y / 100); if (rowA !== rowB) return rowA - rowB; return a.position.x - b.position.x; }); // Find the starting position (top-left of bounding box) const startX = Math.min(...sortedNodes.map((n) => n.position.x)); const startY = Math.min(...sortedNodes.map((n) => n.position.y)); // Get max node dimensions for consistent spacing const maxWidth = Math.max( ...sortedNodes.map((n) => (n.style?.width as number) || n.measured?.width || 220) ); const maxHeight = Math.max( ...sortedNodes.map((n) => (n.style?.height as number) || n.measured?.height || 200) ); // Position each node in the grid sortedNodes.forEach((node, index) => { const col = index % cols; const row = Math.floor(index / cols); onNodesChange([ { type: "position", id: node.id, position: { x: startX + col * (maxWidth + STACK_GAP), y: startY + row * (maxHeight + STACK_GAP), }, }, ]); }); }; const handleCreateGroup = () => { const nodeIds = selectedNodes.map((n) => n.id); createGroup(nodeIds); }; const handleUngroup = () => { const nodeIds = selectedNodes.map((n) => n.id); removeNodesFromGroup(nodeIds); }; const handleDownloadImages = useCallback(async () => { // Extract images from selected nodes based on node type const images: { data: string; name: string }[] = []; selectedNodes.forEach((node, index) => { let imageData: string | null = null; switch (node.type) { case "imageInput": imageData = (node.data as ImageInputNodeData).image; break; case "annotation": imageData = (node.data as AnnotationNodeData).outputImage; break; case "nanoBanana": imageData = (node.data as NanoBananaNodeData).outputImage; break; case "output": imageData = (node.data as OutputNodeData).image; break; } if (imageData) { images.push({ data: imageData, name: `image-${index + 1}.png`, }); } }); if (images.length === 0) return; // Create ZIP file const zip = new JSZip(); images.forEach(({ data, name }) => { // Remove data URL prefix to get raw base64 const base64Data = data.replace(/^data:image\/\w+;base64,/, ""); zip.file(name, base64Data, { base64: true }); }); // Generate and download const blob = await zip.generateAsync({ type: "blob" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `images-${Date.now()}.zip`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }, [selectedNodes]); if (!toolbarPosition || selectedNodes.length < 2) return null; return (
{/* Separator */}
{/* Group/Ungroup buttons */} {someInGroup ? ( ) : ( )} {/* Separator */}
{/* Download images button */}
); }