406 lines
12 KiB
TypeScript
406 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import * as React from "react";
|
|
import { Handle, Position } from "@xyflow/react";
|
|
import type { NodeProps } from "@xyflow/react";
|
|
import type { ReactNode } from "react";
|
|
|
|
// Custom node component with handles for connections
|
|
export function CustomNode({ data, selected }: NodeProps) {
|
|
const nodeType = data.nodeType;
|
|
const isRunning = data.isRunning;
|
|
const executionStatus = data.executionStatus;
|
|
|
|
// Note node state - MUST be declared before any conditional returns
|
|
// This ensures hooks are called in the same order every render
|
|
const noteText = String((data as any).noteText || 'Double-click to edit note');
|
|
const [isEditing, setIsEditing] = React.useState(false);
|
|
const [editText, setEditText] = React.useState<string>(noteText);
|
|
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
|
|
|
// Determine border and background based on state
|
|
const getBorderStyle = () => {
|
|
// Note nodes have no border
|
|
if (nodeType === 'note') return 'none';
|
|
if (isRunning) return '1px solid #22C55E';
|
|
if (executionStatus === 'completed') return '1px solid #9ca3af';
|
|
if (executionStatus === 'failed') return '1px solid #eb3424';
|
|
if (selected) return '1px solid #22C55E';
|
|
return '1px solid #e5e7eb';
|
|
};
|
|
|
|
const getBackgroundColor = () => {
|
|
// Note nodes get yellow/gold background
|
|
if (nodeType === 'note') return '#ca8a04';
|
|
// All nodes have white background
|
|
return 'white';
|
|
};
|
|
|
|
const getOutlineStyle = () => {
|
|
if (isRunning) return '2px solid rgba(34, 197, 94, 0.32)';
|
|
if (selected) return '2px solid rgba(24, 24, 27, 0.18)';
|
|
return '2px solid transparent';
|
|
};
|
|
|
|
// Note nodes have different styling
|
|
const isNoteNode = nodeType === 'note';
|
|
|
|
// Determine text color based on background
|
|
const getTextColor = () => {
|
|
if (isNoteNode) return '#854d0e'; // Dark yellow text for note nodes
|
|
if (nodeType === 'if-else' || nodeType === 'while') {
|
|
return '#18181b'; // Dark text for orange background nodes
|
|
}
|
|
return '#18181b'; // Default dark text
|
|
};
|
|
|
|
// Update editText when noteText changes (for different notes)
|
|
React.useEffect(() => {
|
|
if (isNoteNode) {
|
|
setEditText(noteText);
|
|
}
|
|
}, [noteText, isNoteNode]);
|
|
|
|
// Note nodes are visual-only sticky notes with inline editing
|
|
if (isNoteNode) {
|
|
|
|
React.useEffect(() => {
|
|
if (isEditing && textareaRef.current) {
|
|
textareaRef.current.focus();
|
|
textareaRef.current.select();
|
|
}
|
|
}, [isEditing]);
|
|
|
|
const handleSave = () => {
|
|
setIsEditing(false);
|
|
// Update the node data
|
|
if ((data as any).onUpdate) {
|
|
(data as any).onUpdate({ noteText: editText });
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className="relative"
|
|
onDoubleClick={(e) => {
|
|
e.stopPropagation();
|
|
setIsEditing(true);
|
|
}}
|
|
style={{
|
|
padding: '10px',
|
|
fontSize: '11px',
|
|
backgroundColor: '#fef9c3', // Light yellow
|
|
border: selected ? '2px solid #eab308' : 'none',
|
|
outline: selected ? '2px solid rgba(234, 179, 8, 0.2)' : 'none',
|
|
outlineOffset: 0,
|
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
|
transition: 'all 0.2s ease-out',
|
|
borderRadius: '6px',
|
|
minWidth: '140px',
|
|
maxWidth: '200px',
|
|
width: 'fit-content',
|
|
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
|
color: '#854d0e',
|
|
lineHeight: '1.4',
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-word',
|
|
cursor: isEditing ? 'text' : 'move',
|
|
}}
|
|
>
|
|
{/* Sticky note "tape" effect */}
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: '-6px',
|
|
left: '50%',
|
|
transform: 'translateX(-50%)',
|
|
width: '30px',
|
|
height: '12px',
|
|
backgroundColor: 'rgba(234, 179, 8, 0.3)',
|
|
borderRadius: '2px',
|
|
}}
|
|
/>
|
|
|
|
{isEditing ? (
|
|
<textarea
|
|
ref={textareaRef}
|
|
value={editText}
|
|
onChange={(e) => setEditText(e.target.value)}
|
|
onBlur={handleSave}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape') {
|
|
setEditText(noteText);
|
|
setIsEditing(false);
|
|
}
|
|
// Save on Ctrl/Cmd+Enter
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
|
handleSave();
|
|
}
|
|
// Don't propagate to prevent ReactFlow shortcuts
|
|
e.stopPropagation();
|
|
}}
|
|
className="nodrag"
|
|
style={{
|
|
width: '100%',
|
|
minHeight: '40px',
|
|
padding: '0',
|
|
border: 'none',
|
|
outline: 'none',
|
|
backgroundColor: 'transparent',
|
|
fontSize: '11px',
|
|
fontFamily: 'ui-sans-serif, system-ui, sans-serif',
|
|
color: '#854d0e',
|
|
lineHeight: '1.4',
|
|
resize: 'vertical',
|
|
}}
|
|
/>
|
|
) : (
|
|
<div
|
|
style={{
|
|
minHeight: '20px',
|
|
cursor: 'move',
|
|
}}
|
|
>
|
|
{noteText || 'Double-click to edit note'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="relative"
|
|
style={{
|
|
padding: '10px 16px',
|
|
fontSize: '13px',
|
|
backgroundColor: getBackgroundColor(),
|
|
border: getBorderStyle(),
|
|
outline: getOutlineStyle(),
|
|
outlineOffset: 0,
|
|
boxShadow: 'none',
|
|
transition: 'outline 0.2s ease-out, border 0.2s ease-out',
|
|
borderRadius: '16px',
|
|
minWidth: '140px',
|
|
maxWidth: '240px',
|
|
width: 'fit-content',
|
|
}}
|
|
>
|
|
{/* Input handle (left) - all nodes except 'start' and 'note' */}
|
|
{nodeType !== 'start' && nodeType !== 'note' && (
|
|
<Handle
|
|
type="target"
|
|
position={Position.Left}
|
|
id="input"
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
background: '#9ca3af',
|
|
border: '2px solid white',
|
|
left: -5,
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Render the label (icon + text) */}
|
|
<div style={{ whiteSpace: 'nowrap', color: getTextColor() }}>
|
|
{data.label as ReactNode}
|
|
</div>
|
|
|
|
{/* Output handles - special cases for branching nodes */}
|
|
{nodeType === 'if-else' ? (
|
|
<>
|
|
{/* If branch (left bottom) */}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="if"
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
background: '#FA5D19',
|
|
border: '2px solid white',
|
|
right: -5,
|
|
top: '35%',
|
|
transform: 'translateY(-50%)',
|
|
}}
|
|
/>
|
|
{/* Else branch (right bottom) */}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="else"
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
background: '#18181b',
|
|
border: '2px solid white',
|
|
right: -5,
|
|
top: '65%',
|
|
transform: 'translateY(-50%)',
|
|
}}
|
|
/>
|
|
{/* Branch labels */}
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: '35%',
|
|
right: -50,
|
|
transform: 'translateY(-50%)',
|
|
fontSize: '10px',
|
|
color: '#FA5D19',
|
|
fontWeight: 600,
|
|
}}>If</div>
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: '65%',
|
|
right: -55,
|
|
transform: 'translateY(-50%)',
|
|
fontSize: '10px',
|
|
color: '#18181b',
|
|
fontWeight: 600,
|
|
}}>Else</div>
|
|
</>
|
|
) : nodeType === 'user-approval' ? (
|
|
<>
|
|
{/* Approve branch (left bottom) */}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="approve"
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
background: '#10b981',
|
|
border: '2px solid white',
|
|
right: -5,
|
|
top: '35%',
|
|
transform: 'translateY(-50%)',
|
|
}}
|
|
/>
|
|
{/* Reject branch (right bottom) */}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="reject"
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
background: '#ef4444',
|
|
border: '2px solid white',
|
|
right: -5,
|
|
top: '65%',
|
|
transform: 'translateY(-50%)',
|
|
}}
|
|
/>
|
|
{/* Branch labels */}
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: '35%',
|
|
right: -70,
|
|
transform: 'translateY(-50%)',
|
|
fontSize: '10px',
|
|
color: '#10b981',
|
|
fontWeight: 600,
|
|
}}>Approve</div>
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: '65%',
|
|
right: -60,
|
|
transform: 'translateY(-50%)',
|
|
fontSize: '10px',
|
|
color: '#ef4444',
|
|
fontWeight: 600,
|
|
}}>Reject</div>
|
|
</>
|
|
) : nodeType === 'while' ? (
|
|
<>
|
|
{/* Continue branch (top) */}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="continue"
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
background: '#FA5D19',
|
|
border: '2px solid white',
|
|
right: -5,
|
|
top: '35%',
|
|
transform: 'translateY(-50%)',
|
|
}}
|
|
/>
|
|
{/* Break branch (bottom) */}
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="break"
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
background: '#18181b',
|
|
border: '2px solid white',
|
|
right: -5,
|
|
top: '65%',
|
|
transform: 'translateY(-50%)',
|
|
}}
|
|
/>
|
|
{/* Branch labels */}
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: '35%',
|
|
right: -70,
|
|
transform: 'translateY(-50%)',
|
|
fontSize: '10px',
|
|
color: '#FA5D19',
|
|
fontWeight: 600,
|
|
}}>Continue</div>
|
|
<div style={{
|
|
position: 'absolute',
|
|
top: '65%',
|
|
right: -55,
|
|
transform: 'translateY(-50%)',
|
|
fontSize: '10px',
|
|
color: '#18181b',
|
|
fontWeight: 600,
|
|
}}>Break</div>
|
|
</>
|
|
) : nodeType !== 'end' && nodeType !== 'note' ? (
|
|
<Handle
|
|
type="source"
|
|
position={Position.Right}
|
|
id="output"
|
|
style={{
|
|
width: 10,
|
|
height: 10,
|
|
background: '#9ca3af',
|
|
border: '2px solid white',
|
|
right: -5,
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
}}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Create all node types by reusing the same component
|
|
export const nodeTypes = {
|
|
start: CustomNode,
|
|
agent: CustomNode,
|
|
mcp: CustomNode,
|
|
extract: CustomNode,
|
|
end: CustomNode,
|
|
note: CustomNode,
|
|
logic: CustomNode,
|
|
data: CustomNode,
|
|
http: CustomNode,
|
|
transform: CustomNode,
|
|
'if-else': CustomNode,
|
|
'while': CustomNode,
|
|
'user-approval': CustomNode,
|
|
'set-state': CustomNode,
|
|
};
|