"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 = { 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 (
); } 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(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(null); const colorPickerRef = useRef(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 (
{/* Floating group name label - top-left, viewport-scaled */} {/* Outer wrapper: zero-height anchor at the top edge of the group */}
{/* Inner scaled element: bottom-anchored so it grows upward, scale keeps bottom-left fixed */}
{isEditing ? ( 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` }} /> ) : ( { e.stopPropagation(); setIsEditing(true); }} > {group.name} )}
{/* Floating controls - top-right, scales naturally with canvas zoom */}
{/* Color Picker */}
); })}
)}
{/* Lock/Unlock Button */} {/* Delete Button */}
{/* Resize handles - interactive */}
handleResizeMouseDown(e, "nw")} />
handleResizeMouseDown(e, "ne")} />
handleResizeMouseDown(e, "sw")} />
handleResizeMouseDown(e, "se")} />
handleResizeMouseDown(e, "n")} />
handleResizeMouseDown(e, "s")} />
handleResizeMouseDown(e, "w")} />
handleResizeMouseDown(e, "e")} />
); } // 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 (
{groupIds.map((groupId) => ( ))}
); } // 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 (
{groupIds.map((groupId) => ( ))}
); } // 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 ; }