"use client"; import { ReactNode, useState, useEffect, useRef, useCallback } from "react"; import { createPortal } from "react-dom"; import { useReactFlow } from "@xyflow/react"; import { NodeType } from "@/types"; import { useWorkflowStore } from "@/store/workflowStore"; import { defaultNodeDimensions } from "@/store/utils/nodeDefaults"; export interface CommentNavigationProps { currentIndex: number; totalCount: number; onPrevious: () => void; onNext: () => void; } interface FloatingNodeHeaderProps { id: string; type: NodeType; data: any; position: { x: number; y: number }; width: number; selected: boolean; onExpand?: () => void; onRun?: () => void; headerAction?: ReactNode; headerButtons?: ReactNode; titlePrefix?: ReactNode; title: string; customTitle?: string; comment?: string; onCustomTitleChange?: (title: string) => void; onCommentChange?: (comment: string) => void; commentNavigation?: CommentNavigationProps; } export function FloatingNodeHeader({ id, type, data, position, width, selected, onExpand, onRun, headerAction, headerButtons, titlePrefix, title, customTitle, comment, onCustomTitleChange, onCommentChange, commentNavigation, }: FloatingNodeHeaderProps) { const [isHeaderHovered, setIsHeaderHovered] = useState(false); const isBodyHovered = useWorkflowStore((state) => state.hoveredNodeId === id); const isHovered = isHeaderHovered || isBodyHovered; const [isEditingTitle, setIsEditingTitle] = useState(false); const [editTitleValue, setEditTitleValue] = useState(customTitle || ""); const [isEditingComment, setIsEditingComment] = useState(false); const [editCommentValue, setEditCommentValue] = useState(comment || ""); const [showCommentTooltip, setShowCommentTooltip] = useState(false); const [tooltipPosition, setTooltipPosition] = useState<{ top: number; left: number } | null>(null); const titleInputRef = useRef(null); const commentPopoverRef = useRef(null); const commentButtonRef = useRef(null); const tooltipRef = useRef(null); // Check if this node is in a locked group const isInLockedGroup = data?.isInLockedGroup || false; // Check if comment is focused for navigation const isCommentFocused = data?.focusedCommentNodeId === id; // Check if node is executing const isExecuting = data?.isExecuting || false; // Sync state with props useEffect(() => { if (!isEditingTitle) { setEditTitleValue(customTitle || ""); } }, [customTitle, isEditingTitle]); useEffect(() => { if (!isEditingComment) { setEditCommentValue(comment || ""); } }, [comment, isEditingComment]); // Focus input on edit mode useEffect(() => { if (isEditingTitle && titleInputRef.current) { titleInputRef.current.focus(); titleInputRef.current.select(); } }, [isEditingTitle]); // Continuously update tooltip position while showing useEffect(() => { if (!(showCommentTooltip || isCommentFocused) || !commentButtonRef.current) { setTooltipPosition(null); return; } const updatePosition = () => { if (commentButtonRef.current) { const rect = commentButtonRef.current.getBoundingClientRect(); setTooltipPosition({ top: rect.top - 8, left: rect.left + rect.width / 2, }); } }; updatePosition(); let animationId: number; const trackPosition = () => { updatePosition(); animationId = requestAnimationFrame(trackPosition); }; animationId = requestAnimationFrame(trackPosition); return () => { cancelAnimationFrame(animationId); }; }, [showCommentTooltip, isCommentFocused]); // Title handlers const handleTitleSubmit = useCallback(() => { const trimmed = editTitleValue.trim(); if (trimmed !== (customTitle || "")) { onCustomTitleChange?.(trimmed); } setIsEditingTitle(false); }, [editTitleValue, customTitle, onCustomTitleChange]); const handleTitleKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Enter") { handleTitleSubmit(); } else if (e.key === "Escape") { setEditTitleValue(customTitle || ""); setIsEditingTitle(false); } }, [handleTitleSubmit, customTitle] ); // Comment handlers const handleCommentSubmit = useCallback(() => { const trimmed = editCommentValue.trim(); if (trimmed !== (comment || "")) { onCommentChange?.(trimmed); } setIsEditingComment(false); }, [editCommentValue, comment, onCommentChange]); const handleCommentKeyDown = useCallback( (e: React.KeyboardEvent) => { if (e.key === "Escape") { setEditCommentValue(comment || ""); setIsEditingComment(false); } }, [comment] ); // Click outside handler for comment popover useEffect(() => { const handleClickOutside = (e: MouseEvent) => { if (commentPopoverRef.current && !commentPopoverRef.current.contains(e.target as Node)) { handleCommentSubmit(); } }; if (isEditingComment) { document.addEventListener("mousedown", handleClickOutside); } return () => document.removeEventListener("mousedown", handleClickOutside); }, [isEditingComment, handleCommentSubmit]); // Click outside handler for focused comment tooltip useEffect(() => { const handleClickOutsideTooltip = (e: MouseEvent) => { if (tooltipRef.current && !tooltipRef.current.contains(e.target as Node)) { // Clear focused comment (would need to be passed as a callback) } }; if (isCommentFocused && !isEditingComment) { const timer = setTimeout(() => { document.addEventListener("mousedown", handleClickOutsideTooltip); }, 100); return () => { clearTimeout(timer); document.removeEventListener("mousedown", handleClickOutsideTooltip); }; } return () => document.removeEventListener("mousedown", handleClickOutsideTooltip); }, [isCommentFocused, isEditingComment]); // Determine if controls should be visible const showControls = isHovered || selected; // Drag-to-move: allow repositioning nodes by dragging the header const { setNodes, getNodes, getViewport } = useReactFlow(); const isDraggingRef = useRef(false); const handleHeaderPointerDown = useCallback((e: React.PointerEvent) => { // Don't drag from interactive elements if ((e.target as HTMLElement).closest('.nodrag, button, input, textarea, a')) return; if (e.button !== 0) return; e.preventDefault(); e.stopPropagation(); const startX = e.clientX; const startY = e.clientY; const allNodes = getNodes(); const targetNode = allNodes.find(n => n.id === id); if (!targetNode) return; // Select this node if not already selected if (!targetNode.selected) { setNodes(nodes => nodes.map(n => ({ ...n, selected: n.id === id, }))); } // Capture starting positions of all nodes that will move const movingIds = targetNode.selected ? new Set(allNodes.filter(n => n.selected).map(n => n.id)) : new Set([id]); const startPositions = new Map( allNodes.filter(n => movingIds.has(n.id)).map(n => [n.id, { x: n.position.x, y: n.position.y }]) ); isDraggingRef.current = false; const handlePointerMove = (e: PointerEvent) => { const screenDx = e.clientX - startX; const screenDy = e.clientY - startY; if (!isDraggingRef.current && (Math.abs(screenDx) > 5 || Math.abs(screenDy) > 5)) { isDraggingRef.current = true; } if (isDraggingRef.current) { const { zoom } = getViewport(); const dx = screenDx / zoom; const dy = screenDy / zoom; setNodes(nodes => nodes.map(n => { const startPos = startPositions.get(n.id); if (!startPos) return n; return { ...n, position: { x: startPos.x + dx, y: startPos.y + dy }, }; })); } }; const handlePointerUp = (e: PointerEvent) => { const wasDragging = isDraggingRef.current; document.removeEventListener('pointermove', handlePointerMove); document.removeEventListener('pointerup', handlePointerUp); isDraggingRef.current = false; // Check group membership for ALL moved nodes if (wasDragging) { const store = useWorkflowStore.getState(); const { zoom } = getViewport(); const dx = (e.clientX - startX) / zoom; const dy = (e.clientY - startY) / zoom; for (const [nodeId, startPos] of startPositions) { // Calculate final position deterministically from drag delta const finalX = startPos.x + dx; const finalY = startPos.y + dy; // Get node dimensions from store (always fresh) const storeNode = store.nodes.find(n => n.id === nodeId); if (!storeNode) continue; const nodeType = storeNode.type as NodeType; const defaults = defaultNodeDimensions[nodeType] || { width: 300, height: 280 }; const nodeWidth = storeNode.measured?.width || (storeNode.style?.width as number) || defaults.width; const nodeHeight = storeNode.measured?.height || (storeNode.style?.height as number) || defaults.height; // Calculate node center const nodeCenterX = finalX + nodeWidth / 2; const nodeCenterY = finalY + nodeHeight / 2; // Check if node center is inside any group let targetGroupId: string | undefined; for (const group of Object.values(store.groups)) { const inBoundsX = nodeCenterX >= group.position.x && nodeCenterX <= group.position.x + group.size.width; const inBoundsY = nodeCenterY >= group.position.y && nodeCenterY <= group.position.y + group.size.height; if (inBoundsX && inBoundsY) { targetGroupId = group.id; break; } } // Update groupId if it changed const currentGroupId = storeNode.groupId; if (targetGroupId !== currentGroupId) { store.setNodeGroupId(nodeId, targetGroupId); } } } }; document.addEventListener('pointermove', handlePointerMove); document.addEventListener('pointerup', handlePointerUp); }, [id, getNodes, getViewport, setNodes]); return (
setIsHeaderHovered(true)} onMouseLeave={() => setIsHeaderHovered(false)} onPointerDown={handleHeaderPointerDown} > {/* Title Section */}
{titlePrefix} {isEditingTitle ? ( setEditTitleValue(e.target.value)} onBlur={handleTitleSubmit} onKeyDown={handleTitleKeyDown} placeholder="Custom title..." className="nodrag nopan w-full bg-transparent border-none outline-none text-xs font-semibold tracking-wide text-neutral-300 placeholder:text-neutral-500 uppercase" /> ) : ( setIsEditingTitle(true)} title="Click to edit title" > {customTitle ? `${customTitle} - ${title}` : title} )} {headerAction}
{/* Controls - right-aligned, fade in on hover/selected */}
{/* Lock Badge for nodes in locked groups */} {isInLockedGroup && (
)} {/* Custom Header Buttons */} {headerButtons} {/* Comment Icon */}
{/* Comment Tooltip with Navigation */} {(showCommentTooltip || isCommentFocused) && comment && !isEditingComment && tooltipPosition && createPortal(
{isCommentFocused && commentNavigation && (
{commentNavigation.currentIndex}/{commentNavigation.totalCount}
)}
{comment}
, document.body )} {/* Comment Edit Popover */} {isEditingComment && (