You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

300 lines
11 KiB

"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 (
<div
className="fixed z-[100] flex items-center gap-1 bg-neutral-800 border border-neutral-600 rounded-lg shadow-xl p-1"
style={{
left: toolbarPosition.x,
top: toolbarPosition.y,
transform: "translateX(-50%)",
}}
>
<button
onClick={handleStackHorizontally}
className="p-1.5 rounded hover:bg-neutral-700 text-neutral-400 hover:text-neutral-100 transition-colors"
title={t("multiSelect.stackHorizontal")}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 4h4v16H6zM14 4h4v16h-4z" />
</svg>
</button>
<button
onClick={handleStackVertically}
className="p-1.5 rounded hover:bg-neutral-700 text-neutral-400 hover:text-neutral-100 transition-colors"
title={t("multiSelect.stackVertical")}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M4 6h16v4H4zM4 14h16v4H4z" />
</svg>
</button>
<button
onClick={handleArrangeAsGrid}
className="p-1.5 rounded hover:bg-neutral-700 text-neutral-400 hover:text-neutral-100 transition-colors"
title={t("multiSelect.arrangeGrid")}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3.75 6A2.25 2.25 0 016 3.75h2.25A2.25 2.25 0 0110.5 6v2.25a2.25 2.25 0 01-2.25 2.25H6a2.25 2.25 0 01-2.25-2.25V6zM3.75 15.75A2.25 2.25 0 016 13.5h2.25a2.25 2.25 0 012.25 2.25V18a2.25 2.25 0 01-2.25 2.25H6A2.25 2.25 0 013.75 18v-2.25zM13.5 6a2.25 2.25 0 012.25-2.25H18A2.25 2.25 0 0120.25 6v2.25A2.25 2.25 0 0118 10.5h-2.25a2.25 2.25 0 01-2.25-2.25V6zM13.5 15.75a2.25 2.25 0 012.25-2.25H18a2.25 2.25 0 012.25 2.25V18A2.25 2.25 0 0118 20.25h-2.25A2.25 2.25 0 0113.5 18v-2.25z" />
</svg>
</button>
{/* Separator */}
<div className="w-px h-4 bg-neutral-600 mx-0.5" />
{/* Group/Ungroup buttons */}
{someInGroup ? (
<button
onClick={handleUngroup}
className="p-1.5 rounded hover:bg-neutral-700 text-neutral-400 hover:text-neutral-100 transition-colors"
title={t("multiSelect.removeGroup")}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 9V4.5M9 9H4.5M9 9L3.75 3.75M9 15v4.5M9 15H4.5M9 15l-5.25 5.25M15 9h4.5M15 9V4.5M15 9l5.25-5.25M15 15h4.5M15 15v4.5m0-4.5l5.25 5.25" />
</svg>
</button>
) : (
<button
onClick={handleCreateGroup}
className="p-1.5 rounded hover:bg-neutral-700 text-neutral-400 hover:text-neutral-100 transition-colors"
title={t("multiSelect.createGroup")}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M2.25 7.125C2.25 6.504 2.754 6 3.375 6h6c.621 0 1.125.504 1.125 1.125v3.75c0 .621-.504 1.125-1.125 1.125h-6a1.125 1.125 0 01-1.125-1.125v-3.75zM14.25 8.625c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v8.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-8.25zM3.75 16.125c0-.621.504-1.125 1.125-1.125h5.25c.621 0 1.125.504 1.125 1.125v2.25c0 .621-.504 1.125-1.125 1.125h-5.25a1.125 1.125 0 01-1.125-1.125v-2.25z" />
</svg>
</button>
)}
{/* Separator */}
<div className="w-px h-4 bg-neutral-600 mx-0.5" />
{/* Download images button */}
<button
onClick={handleDownloadImages}
className="p-1.5 rounded hover:bg-neutral-700 text-neutral-400 hover:text-neutral-100 transition-colors"
title={t("multiSelect.downloadZip")}
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<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>
</button>
</div>
);
}