10 changed files with 578 additions and 49 deletions
@ -0,0 +1,127 @@ |
|||||
|
"use client"; |
||||
|
|
||||
|
import { useReactFlow } from "@xyflow/react"; |
||||
|
import { useWorkflowStore } from "@/store/workflowStore"; |
||||
|
import { useMemo } from "react"; |
||||
|
|
||||
|
const STACK_GAP = 20; |
||||
|
|
||||
|
export function MultiSelectToolbar() { |
||||
|
const { nodes, onNodesChange } = useWorkflowStore(); |
||||
|
const { getViewport } = useReactFlow(); |
||||
|
|
||||
|
const selectedNodes = useMemo( |
||||
|
() => nodes.filter((node) => node.selected), |
||||
|
[nodes] |
||||
|
); |
||||
|
|
||||
|
// 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; |
||||
|
}); |
||||
|
}; |
||||
|
|
||||
|
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="Stack horizontally (H)" |
||||
|
> |
||||
|
<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="Stack vertically (V)" |
||||
|
> |
||||
|
<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> |
||||
|
</div> |
||||
|
); |
||||
|
} |
||||
Loading…
Reference in new issue