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.
 
 

540 lines
21 KiB

"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<HTMLInputElement>(null);
const commentPopoverRef = useRef<HTMLDivElement>(null);
const commentButtonRef = useRef<HTMLButtonElement>(null);
const tooltipRef = useRef<HTMLDivElement>(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 (
<div
className="absolute pointer-events-none transition-opacity duration-200"
style={{
left: `${position.x}px`,
top: `${position.y - 26}px`,
width: `${width}px`,
zIndex: selected ? 10000 : 9000,
}}
>
<div
className="px-1 py-1 flex items-center justify-between w-full pointer-events-auto cursor-grab"
onMouseEnter={() => setIsHeaderHovered(true)}
onMouseLeave={() => setIsHeaderHovered(false)}
onPointerDown={handleHeaderPointerDown}
>
{/* Title Section */}
<div className="flex-1 min-w-0 flex items-center gap-1.5 pl-2">
{titlePrefix}
{isEditingTitle ? (
<input
ref={titleInputRef}
type="text"
value={editTitleValue}
onChange={(e) => 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"
/>
) : (
<span
className="nodrag text-xs font-semibold uppercase tracking-wide text-neutral-400 cursor-text truncate"
onClick={() => setIsEditingTitle(true)}
title="Click to edit title"
>
{customTitle ? `${customTitle} - ${title}` : title}
</span>
)}
{headerAction}
</div>
{/* Controls - right-aligned, fade in on hover/selected */}
<div className={`shrink-0 flex items-center gap-1 pr-1 transition-opacity duration-200 -translate-y-1 ${showControls ? 'opacity-100' : 'opacity-0'}`}>
{/* Lock Badge for nodes in locked groups */}
{isInLockedGroup && (
<div className="shrink-0 flex items-center" title="This node is in a locked group and will be skipped during execution">
<svg className="w-3.5 h-3.5 text-yellow-500" 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>
</div>
)}
{/* Custom Header Buttons */}
{headerButtons}
{/* Comment Icon */}
<div className="relative shrink-0 flex items-center gap-1" ref={commentPopoverRef}>
<button
ref={commentButtonRef}
onClick={() => setIsEditingComment(!isEditingComment)}
onMouseEnter={() => comment && !isCommentFocused && setShowCommentTooltip(true)}
onMouseLeave={() => setShowCommentTooltip(false)}
className={`nodrag nopan p-0.5 rounded transition-colors ${
comment
? "text-blue-400 hover:text-blue-200"
: "text-neutral-500 hover:text-neutral-200 border border-neutral-600"
}`}
title={comment ? "Edit comment" : "Add comment"}
>
{comment ? (
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor">
<path fillRule="evenodd" d="M4.848 2.771A49.144 49.144 0 0112 2.25c2.43 0 4.817.178 7.152.52 1.978.292 3.348 2.024 3.348 3.97v6.02c0 1.946-1.37 3.678-3.348 3.97a48.901 48.901 0 01-3.476.383.39.39 0 00-.297.17l-2.755 4.133a.75.75 0 01-1.248 0l-2.755-4.133a.39.39 0 00-.297-.17 48.9 48.9 0 01-3.476-.384c-1.978-.29-3.348-2.024-3.348-3.97V6.741c0-1.946 1.37-3.68 3.348-3.97z" clipRule="evenodd" />
</svg>
) : (
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 20.25c4.97 0 9-3.694 9-8.25s-4.03-8.25-9-8.25S3 7.444 3 12c0 2.104.859 4.023 2.273 5.48.432.447.74 1.04.586 1.641a4.483 4.483 0 01-.923 1.785A5.969 5.969 0 006 21c1.282 0 2.47-.402 3.445-1.087.81.22 1.668.337 2.555.337z" />
</svg>
)}
</button>
{/* Comment Tooltip with Navigation */}
{(showCommentTooltip || isCommentFocused) && comment && !isEditingComment && tooltipPosition && createPortal(
<div
ref={tooltipRef}
className="fixed z-[9999] p-3 text-sm text-neutral-200 bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl"
style={{
top: tooltipPosition.top,
left: tooltipPosition.left,
transform: "translateY(-100%) translateX(-50%)",
}}
>
{isCommentFocused && commentNavigation && (
<div className="flex items-center justify-center gap-3 mb-2 pb-2 border-b border-neutral-700">
<button
onClick={(e) => {
e.stopPropagation();
commentNavigation.onPrevious();
}}
className="nodrag nopan w-6 h-6 flex items-center justify-center text-neutral-400 hover:text-neutral-100 hover:bg-neutral-700 rounded transition-colors"
title="Previous comment"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
</button>
<span className="text-xs text-neutral-400 min-w-[32px] text-center">
{commentNavigation.currentIndex}/{commentNavigation.totalCount}
</span>
<button
onClick={(e) => {
e.stopPropagation();
commentNavigation.onNext();
}}
className="nodrag nopan w-6 h-6 flex items-center justify-center text-neutral-400 hover:text-neutral-100 hover:bg-neutral-700 rounded transition-colors"
title="Next comment"
>
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
</svg>
</button>
</div>
)}
<div className="max-w-[240px] whitespace-pre-wrap break-words">
{comment}
</div>
</div>,
document.body
)}
{/* Comment Edit Popover */}
{isEditingComment && (
<div className="absolute z-[60] right-0 top-full mt-1 w-64 p-2 bg-neutral-800 border border-neutral-600 rounded shadow-lg">
<textarea
value={editCommentValue}
onChange={(e) => setEditCommentValue(e.target.value)}
onKeyDown={handleCommentKeyDown}
placeholder="Add a comment..."
autoFocus
className="nodrag nopan nowheel w-full h-20 p-2 text-xs text-neutral-100 bg-neutral-900/50 border border-neutral-700 rounded resize-none focus:outline-none focus:ring-1 focus:ring-neutral-600"
/>
<div className="flex justify-end gap-2 mt-2">
<button
onClick={() => {
setEditCommentValue(comment || "");
setIsEditingComment(false);
}}
className="px-2 py-1 text-xs text-neutral-400 hover:text-neutral-200 transition-colors"
>
Cancel
</button>
<button
onClick={handleCommentSubmit}
className="px-2 py-1 text-xs text-white bg-blue-600 hover:bg-blue-500 rounded transition-colors"
>
Save
</button>
</div>
</div>
)}
</div>
{/* Expand Button */}
{onExpand && (
<div className="relative shrink-0 group">
<button
onClick={onExpand}
className="nodrag nopan p-0.5 rounded transition-all duration-200 ease-in-out text-neutral-500 group-hover:text-neutral-200 border border-neutral-600 flex items-center overflow-hidden group-hover:pr-2"
title="Expand editor"
>
<svg
className="w-3.5 h-3.5 flex-shrink-0"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
viewBox="0 0 24 24"
>
<polyline points="15 3 21 3 21 9" />
<polyline points="9 21 3 21 3 15" />
<line x1="21" y1="3" x2="14" y2="10" />
<line x1="3" y1="21" x2="10" y2="14" />
</svg>
<span className="max-w-0 opacity-0 whitespace-nowrap text-[10px] transition-all duration-200 ease-in-out overflow-hidden group-hover:max-w-[60px] group-hover:opacity-100 group-hover:ml-1">
Expand
</span>
</button>
</div>
)}
{/* Run Button */}
{onRun && (
<div className="relative shrink-0 group">
<button
onClick={onRun}
disabled={isExecuting}
className="nodrag nopan p-0.5 rounded transition-all duration-200 ease-in-out text-neutral-500 group-hover:text-neutral-200 border border-neutral-600 flex items-center overflow-hidden group-hover:pr-2 disabled:opacity-50 disabled:cursor-not-allowed"
title="Run this node"
>
<svg className="w-3.5 h-3.5 flex-shrink-0" fill="currentColor" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
<span className="max-w-0 opacity-0 whitespace-nowrap text-[10px] transition-all duration-200 ease-in-out overflow-hidden group-hover:max-w-[60px] group-hover:opacity-100 group-hover:ml-1">
Run node
</span>
</button>
</div>
)}
</div>
</div>
</div>
);
}