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.
 
 

517 lines
18 KiB

"use client";
import { useCallback, useState, useRef, useEffect } from "react";
import { useViewport, ViewportPortal } from "@xyflow/react";
import { useWorkflowStore, GROUP_COLORS } from "@/store/workflowStore";
import { GroupColor } from "@/types";
const COLOR_OPTIONS: { color: GroupColor; label: string }[] = [
{ color: "neutral", label: "Gray" },
{ color: "blue", label: "Blue" },
{ color: "green", label: "Green" },
{ color: "purple", label: "Purple" },
{ color: "orange", label: "Orange" },
{ color: "red", label: "Red" },
];
// Brighter preview colors for the color picker (more saturated/vivid)
const PICKER_PREVIEW_COLORS: Record<GroupColor, string> = {
neutral: "#525252",
blue: "#3b82f6",
green: "#22c55e",
purple: "#8b5cf6",
orange: "#f97316",
red: "#ef4444",
};
interface GroupBackgroundProps {
groupId: string;
}
// Renders just the group background - displayed below nodes (z-index 1)
function GroupBackground({ groupId }: GroupBackgroundProps) {
const { groups } = useWorkflowStore();
const group = groups[groupId];
if (!group) return null;
const bgColor = GROUP_COLORS[group.color];
return (
<div
className="absolute rounded-xl"
style={{
left: group.position.x,
top: group.position.y,
width: group.size.width,
height: group.size.height,
backgroundColor: `${bgColor}60`,
border: `1px solid ${bgColor}`,
pointerEvents: "none",
}}
/>
);
}
interface GroupControlsProps {
groupId: string;
zoom: number;
}
// Renders the group header and resize handles - displayed above nodes (z-index 5)
function GroupControls({ groupId, zoom }: GroupControlsProps) {
const { groups, updateGroup, deleteGroup, moveGroupNodes, toggleGroupLock } = useWorkflowStore();
const group = groups[groupId];
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(group?.name || "");
const [showColorPicker, setShowColorPicker] = useState(false);
const [isDragging, setIsDragging] = useState(false);
const [isResizing, setIsResizing] = useState(false);
const [resizeHandle, setResizeHandle] = useState<string | null>(null);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const resizeStartRef = useRef<{ x: number; y: number; width: number; height: number; posX: number; posY: number } | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const colorPickerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (group?.name && !isEditing) {
setEditName(group.name);
}
}, [group?.name, isEditing]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (colorPickerRef.current && !colorPickerRef.current.contains(e.target as Node)) {
setShowColorPicker(false);
}
};
if (showColorPicker) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showColorPicker]);
const handleNameSubmit = useCallback(() => {
if (editName.trim() && editName !== group?.name) {
updateGroup(groupId, { name: editName.trim() });
} else {
setEditName(group?.name || "");
}
setIsEditing(false);
}, [editName, group?.name, groupId, updateGroup]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter") {
handleNameSubmit();
} else if (e.key === "Escape") {
setEditName(group?.name || "");
setIsEditing(false);
}
},
[handleNameSubmit, group?.name]
);
const handleColorChange = useCallback(
(color: GroupColor) => {
updateGroup(groupId, { color });
setShowColorPicker(false);
},
[groupId, updateGroup]
);
const handleDelete = useCallback(() => {
deleteGroup(groupId);
}, [groupId, deleteGroup]);
const handleToggleLock = useCallback(() => {
toggleGroupLock(groupId);
}, [groupId, toggleGroupLock]);
// Header drag handlers
const handleHeaderMouseDown = useCallback(
(e: React.MouseEvent) => {
if (
(e.target as HTMLElement).closest("button") ||
(e.target as HTMLElement).closest("input")
) {
return;
}
e.stopPropagation();
e.preventDefault();
setIsDragging(true);
dragStartRef.current = { x: e.clientX, y: e.clientY };
},
[]
);
// Resize handlers
const handleResizeMouseDown = useCallback(
(e: React.MouseEvent, handle: string) => {
e.stopPropagation();
e.preventDefault();
setIsResizing(true);
setResizeHandle(handle);
resizeStartRef.current = {
x: e.clientX,
y: e.clientY,
width: group.size.width,
height: group.size.height,
posX: group.position.x,
posY: group.position.y,
};
},
[group?.size, group?.position]
);
useEffect(() => {
if (!isDragging) return;
const handleMouseMove = (e: MouseEvent) => {
if (!dragStartRef.current) return;
const deltaX = (e.clientX - dragStartRef.current.x) / zoom;
const deltaY = (e.clientY - dragStartRef.current.y) / zoom;
if (Math.abs(deltaX) > 2 || Math.abs(deltaY) > 2) {
// Move the group position
updateGroup(groupId, {
position: {
x: group.position.x + deltaX,
y: group.position.y + deltaY,
},
});
// Move all nodes in the group
moveGroupNodes(groupId, { x: deltaX, y: deltaY });
dragStartRef.current = { x: e.clientX, y: e.clientY };
}
};
const handleMouseUp = () => {
setIsDragging(false);
dragStartRef.current = null;
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isDragging, groupId, group?.position, moveGroupNodes, updateGroup, zoom]);
useEffect(() => {
if (!isResizing || !resizeHandle) return;
const handleMouseMove = (e: MouseEvent) => {
if (!resizeStartRef.current) return;
const deltaX = (e.clientX - resizeStartRef.current.x) / zoom;
const deltaY = (e.clientY - resizeStartRef.current.y) / zoom;
let newWidth = resizeStartRef.current.width;
let newHeight = resizeStartRef.current.height;
let newPosX = resizeStartRef.current.posX;
let newPosY = resizeStartRef.current.posY;
// Handle based on which corner/edge is being dragged
if (resizeHandle.includes("e")) {
newWidth = Math.max(200, resizeStartRef.current.width + deltaX);
}
if (resizeHandle.includes("w")) {
const widthDelta = Math.min(deltaX, resizeStartRef.current.width - 200);
newWidth = resizeStartRef.current.width - widthDelta;
newPosX = resizeStartRef.current.posX + widthDelta;
}
if (resizeHandle.includes("s")) {
newHeight = Math.max(100, resizeStartRef.current.height + deltaY);
}
if (resizeHandle.includes("n")) {
const heightDelta = Math.min(deltaY, resizeStartRef.current.height - 100);
newHeight = resizeStartRef.current.height - heightDelta;
newPosY = resizeStartRef.current.posY + heightDelta;
}
updateGroup(groupId, {
size: { width: newWidth, height: newHeight },
position: { x: newPosX, y: newPosY },
});
};
const handleMouseUp = () => {
setIsResizing(false);
setResizeHandle(null);
resizeStartRef.current = null;
};
window.addEventListener("mousemove", handleMouseMove);
window.addEventListener("mouseup", handleMouseUp);
return () => {
window.removeEventListener("mousemove", handleMouseMove);
window.removeEventListener("mouseup", handleMouseUp);
};
}, [isResizing, resizeHandle, groupId, updateGroup, zoom]);
if (!group) return null;
const bgColor = GROUP_COLORS[group.color];
return (
<div
className="absolute"
style={{
left: group.position.x,
top: group.position.y,
width: group.size.width,
height: group.size.height,
pointerEvents: "none",
overflow: "visible",
}}
>
{/* Floating group name label - top-left, viewport-scaled */}
{/* Outer wrapper: zero-height anchor at the top edge of the group */}
<div
className="absolute left-0"
style={{ top: 0, height: 0, overflow: "visible" }}
>
{/* Inner scaled element: bottom-anchored so it grows upward, scale keeps bottom-left fixed */}
<div
className="absolute left-0 pointer-events-auto cursor-grab active:cursor-grabbing select-none"
style={{
bottom: 0,
transform: `scale(${1 / zoom})`,
transformOrigin: "bottom left",
whiteSpace: "nowrap",
}}
onMouseDown={handleHeaderMouseDown}
>
<div
className="flex items-center rounded-md px-2 py-0.5 mb-1"
style={{ backgroundColor: bgColor }}
>
{isEditing ? (
<input
ref={inputRef}
type="text"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleNameSubmit}
onKeyDown={handleKeyDown}
className="bg-transparent border-none outline-none text-xs font-medium text-white px-0 py-0"
style={{ minWidth: 60, maxWidth: 200, width: `${Math.max(60, editName.length * 7)}px` }}
/>
) : (
<span
className="text-xs font-medium text-white truncate"
style={{ maxWidth: 200 }}
onDoubleClick={(e) => { e.stopPropagation(); setIsEditing(true); }}
>
{group.name}
</span>
)}
</div>
</div>
</div>
{/* Floating controls - top-right, scales naturally with canvas zoom */}
<div
className="absolute right-0 pointer-events-auto"
style={{
top: -28,
}}
>
<div className="flex items-center gap-1 px-1.5 py-0.5">
{/* Color Picker */}
<div className="relative flex items-center" ref={colorPickerRef}>
<button
onClick={() => setShowColorPicker(!showColorPicker)}
className="w-4 h-4 rounded border border-white/30 hover:border-white/60 transition-colors"
style={{ backgroundColor: bgColor }}
title="Change color"
/>
{showColorPicker && (
<>
{/* Invisible backdrop to catch clicks outside */}
<div
className="fixed inset-0 z-40"
onClick={() => setShowColorPicker(false)}
/>
<div className="absolute bottom-full left-1/2 mb-2 z-50 pointer-events-auto" style={{ transform: "translateX(-50%)" }}>
{COLOR_OPTIONS.map(({ color, label }, index) => {
// Fan out in an arc above the button
const totalItems = COLOR_OPTIONS.length;
const arcSpread = 180; // degrees of arc spread (wider)
const startAngle = -90 - arcSpread / 2; // start from top-left
const angleStep = arcSpread / (totalItems - 1);
const angle = startAngle + index * angleStep;
const radius = 55; // distance from center (larger)
const rad = (angle * Math.PI) / 180;
const x = Math.cos(rad) * radius;
const y = Math.sin(rad) * radius;
const finalX = x - 12;
const finalY = y - 12;
return (
<button
key={color}
onClick={() => handleColorChange(color)}
className={`absolute w-6 h-6 rounded-full border-2 transition-[transform,border-color] duration-150 hover:scale-110 ${
group.color === color
? "border-white"
: "border-transparent hover:border-white/50"
}`}
style={{
backgroundColor: PICKER_PREVIEW_COLORS[color],
transform: `translate(${finalX}px, ${finalY}px)`,
animation: `colorFanIn-${index} 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards`,
animationDelay: `${index * 0.025}s`,
opacity: 0,
// Use CSS custom properties to pass the final position to the animation
["--final-x" as string]: `${finalX}px`,
["--final-y" as string]: `${finalY}px`,
}}
title={label}
>
<style>{`
@keyframes colorFanIn-${index} {
0% {
opacity: 0;
transform: translate(-12px, 0px) scale(0.3);
}
100% {
opacity: 1;
transform: translate(${finalX}px, ${finalY}px) scale(1);
}
}
`}</style>
</button>
);
})}
</div>
</>
)}
</div>
{/* Lock/Unlock Button */}
<button
onClick={handleToggleLock}
className="p-0.5 rounded hover:bg-white/20 text-white/70 hover:text-white transition-colors"
title={group.locked ? "Unlock group" : "Lock group"}
>
{group.locked ? (
<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="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
</svg>
) : (
<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="M8 11V7a4 4 0 118 0m-4 8v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2z" />
</svg>
)}
</button>
{/* Delete Button */}
<button
onClick={handleDelete}
className="p-0.5 rounded hover:bg-white/20 text-white/70 hover:text-white transition-colors"
title="Delete group"
>
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
{/* Resize handles - interactive */}
<div
className="absolute top-0 left-0 w-3 h-3 cursor-nw-resize pointer-events-auto"
onMouseDown={(e) => handleResizeMouseDown(e, "nw")}
/>
<div
className="absolute top-0 right-0 w-3 h-3 cursor-ne-resize pointer-events-auto"
onMouseDown={(e) => handleResizeMouseDown(e, "ne")}
/>
<div
className="absolute bottom-0 left-0 w-3 h-3 cursor-sw-resize pointer-events-auto"
onMouseDown={(e) => handleResizeMouseDown(e, "sw")}
/>
<div
className="absolute bottom-0 right-0 w-3 h-3 cursor-se-resize pointer-events-auto"
onMouseDown={(e) => handleResizeMouseDown(e, "se")}
/>
<div
className="absolute top-0 left-3 right-3 h-2 cursor-n-resize pointer-events-auto"
onMouseDown={(e) => handleResizeMouseDown(e, "n")}
/>
<div
className="absolute bottom-0 left-3 right-3 h-2 cursor-s-resize pointer-events-auto"
onMouseDown={(e) => handleResizeMouseDown(e, "s")}
/>
<div
className="absolute left-0 top-3 bottom-3 w-2 cursor-w-resize pointer-events-auto"
onMouseDown={(e) => handleResizeMouseDown(e, "w")}
/>
<div
className="absolute right-0 top-3 bottom-3 w-2 cursor-e-resize pointer-events-auto"
onMouseDown={(e) => handleResizeMouseDown(e, "e")}
/>
</div>
);
}
// Renders group backgrounds inside ReactFlow's viewport using ViewportPortal
// This participates in React Flow's stacking context so z-index works properly
export function GroupBackgroundsPortal() {
const { groups } = useWorkflowStore();
const groupIds = Object.keys(groups);
if (groupIds.length === 0) return null;
return (
<ViewportPortal>
<div style={{ position: "absolute", top: 0, left: 0, zIndex: -1, pointerEvents: "none" }}>
{groupIds.map((groupId) => (
<GroupBackground key={groupId} groupId={groupId} />
))}
</div>
</ViewportPortal>
);
}
// Renders group controls (headers, resize handles) using ViewportPortal above nodes
export function GroupControlsOverlay() {
const { groups } = useWorkflowStore();
const { zoom } = useViewport();
const groupIds = Object.keys(groups);
if (groupIds.length === 0) return null;
return (
<ViewportPortal>
<div style={{ position: "absolute", top: 0, left: 0, zIndex: 1000, pointerEvents: "none" }}>
{groupIds.map((groupId) => (
<GroupControls key={groupId} groupId={groupId} zoom={zoom} />
))}
</div>
</ViewportPortal>
);
}
// Legacy export for backwards compatibility - combines both overlays
// Note: For proper z-index behavior, use GroupBackgroundsPortal inside ReactFlow
// and GroupControlsOverlay outside ReactFlow
export function GroupsOverlay() {
return <GroupControlsOverlay />;
}