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
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>
|
|
);
|
|
}
|
|
|