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.
 
 

619 lines
23 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, updateGroup, moveGroupNodes } = useWorkflowStore();
const group = groups[groupId];
const { zoom } = useViewport();
const [isDragging, setIsDragging] = useState(false);
const dragStartRef = useRef<{ x: number; y: number } | null>(null);
const handleBackgroundMouseDown = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0) return;
e.stopPropagation();
e.preventDefault();
setIsDragging(true);
dragStartRef.current = { x: e.clientX, y: e.clientY };
},
[]
);
useEffect(() => {
if (!isDragging || !group) 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) {
updateGroup(groupId, {
position: {
x: group.position.x + deltaX,
y: group.position.y + deltaY,
},
});
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, group, groupId, moveGroupNodes, updateGroup, zoom]);
if (!group) return null;
const bgColor = GROUP_COLORS[group.color];
return (
<div
className="absolute rounded-xl cursor-grab active:cursor-grabbing"
onMouseDown={handleBackgroundMouseDown}
style={{
left: group.position.x,
top: group.position.y,
width: group.size.width,
height: group.size.height,
backgroundColor: `${bgColor}60`,
border: group.isNbpInput ? `3px dashed rgba(255,255,255,0.25)` : `1px solid ${bgColor}`,
pointerEvents: "auto",
}}
/>
);
}
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 [showMenu, setShowMenu] = 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);
const menuRef = useRef<HTMLDivElement>(null);
// Reset color picker when menu closes
useEffect(() => {
if (!showMenu) {
setShowColorPicker(false);
}
}, [showMenu]);
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 (menuRef.current && !menuRef.current.contains(e.target as Node)) {
setShowMenu(false);
}
};
if (showColorPicker || showMenu) {
document.addEventListener("mousedown", handleClickOutside);
}
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showColorPicker, showMenu]);
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",
}}
>
{/* Group title label + three-dot menu - 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
ref={menuRef}
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 gap-0.5 mb-1"
>
{/* Title pill */}
<div
className="flex items-center rounded-md px-2 py-0.5"
style={{ backgroundColor: bgColor }}
>
{group.locked && (
<svg className="w-3 h-3 text-white/70 mr-1 flex-shrink-0" 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>
)}
{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>
{/* Three-dot menu toggle - always visible */}
<div className="relative">
<button
onClick={(e) => { e.stopPropagation(); setShowMenu(!showMenu); }}
className="w-6 h-6 rounded-md flex flex-col items-center justify-center gap-[2px] hover:bg-white/20 transition-colors"
title="Group options"
>
<div className="w-[3px] h-[3px] rounded-full bg-white/70" />
<div className="w-[3px] h-[3px] rounded-full bg-white/70" />
<div className="w-[3px] h-[3px] rounded-full bg-white/70" />
</button>
{/* Vertical context menu - appears above the three-dot button */}
{showMenu && (
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-1 bg-neutral-800/90 backdrop-blur rounded-lg py-1 min-w-[130px] shadow-lg shadow-black/30" ref={colorPickerRef}>
{/* Color fan - anchored to top-left corner of menu */}
{showColorPicker && (
<>
<div
className="fixed inset-0 z-40"
onClick={() => setShowColorPicker(false)}
/>
<div className="absolute top-0 left-0 z-50 pointer-events-auto">
{COLOR_OPTIONS.map(({ color, label }, index) => {
const totalItems = COLOR_OPTIONS.length;
const arcSpread = 180;
const startAngle = -130 - arcSpread / 2;
const angleStep = arcSpread / (totalItems - 1);
const angle = startAngle + index * angleStep;
const radius = 55;
const rad = (angle * Math.PI) / 180;
const x = Math.cos(rad) * radius;
const y = Math.sin(rad) * radius;
const finalX = x;
const finalY = y;
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-125 ${
group.color === color
? "border-white"
: "border-transparent hover:border-white/50"
}`}
style={{
backgroundColor: PICKER_PREVIEW_COLORS[color],
left: finalX - 12,
top: finalY - 12,
animation: `colorFanIn-${index} 0.25s cubic-bezier(0.34, 1.56, 0.64, 1) forwards`,
animationDelay: `${index * 0.025}s`,
opacity: 0,
}}
title={label}
>
<style>{`
@keyframes colorFanIn-${index} {
0% {
opacity: 0;
left: -12px;
top: -12px;
transform: scale(0.3);
}
100% {
opacity: 1;
left: ${finalX - 12}px;
top: ${finalY - 12}px;
transform: scale(1);
}
}
`}</style>
</button>
);
})}
</div>
</>
)}
{/* Background color row */}
<button
onClick={(e) => { e.stopPropagation(); setShowColorPicker(!showColorPicker); }}
className="flex items-center gap-2 px-3 py-1.5 w-full hover:bg-white/10 text-xs text-white/80 transition-colors"
>
<div
className="w-3 h-3 rounded-full border border-white/30"
style={{ backgroundColor: bgColor }}
/>
<span>Background</span>
</button>
{/* Lock/Unlock row */}
<button
onClick={(e) => { e.stopPropagation(); handleToggleLock(); setShowMenu(false); }}
className="flex items-center gap-2 px-3 py-1.5 w-full hover:bg-white/10 text-xs text-white/80 transition-colors"
>
{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>
)}
<span>{group.locked ? "Unlock" : "Lock"}</span>
</button>
{/* NBP Input toggle row */}
<button
onClick={(e) => { e.stopPropagation(); updateGroup(groupId, { isNbpInput: !group.isNbpInput }); setShowMenu(false); }}
className="flex items-center gap-2 px-3 py-1.5 w-full hover:bg-white/10 text-xs text-white/80 transition-colors"
>
<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="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1" />
</svg>
<span>NBP Input</span>
{group.isNbpInput && (
<svg className="w-3 h-3 ml-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)}
</button>
{/* Delete row */}
<button
onClick={(e) => { e.stopPropagation(); handleDelete(); }}
className="flex items-center gap-2 px-3 py-1.5 w-full hover:bg-white/10 text-xs text-white/80 transition-colors"
>
<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>
<span>Delete</span>
</button>
</div>
)}
</div>
</div>
</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: "auto" }}>
{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 />;
}