| 1 |
"use client"; |
| 2 |
|
| 3 |
import React, { useState, useCallback, useRef } from "react"; |
| 4 |
import { WorkflowNode } from "./WorkflowNode"; |
| 5 |
import { Connections } from "./Connections"; |
| 6 |
|
| 7 |
export function WorkflowCanvas({ |
| 8 |
scale, |
| 9 |
setScale, |
| 10 |
nodes, |
| 11 |
setNodes, |
| 12 |
connections, |
| 13 |
setConnections, |
| 14 |
selectedNodeId, |
| 15 |
onNodeClick, |
| 16 |
}) { |
| 17 |
const [isPanning, setIsPanning] = useState(false); |
| 18 |
const [panOffset, setPanOffset] = useState({ x: 0, y: 0 }); |
| 19 |
const [draggedNode, setDraggedNode] = useState(null); |
| 20 |
const [dragOffset, setDragOffset] = useState({ x: 0, y: 0 }); |
| 21 |
const [connectingFrom, setConnectingFrom] = useState(null); |
| 22 |
const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); |
| 23 |
const canvasRef = useRef(null); |
| 24 |
|
| 25 |
const handleMouseDown = useCallback( |
| 26 |
(e) => { |
| 27 |
if (e.target === canvasRef.current || e.target.closest('[data-grid]')) { |
| 28 |
setIsPanning(true); |
| 29 |
} |
| 30 |
}, |
| 31 |
[] |
| 32 |
); |
| 33 |
|
| 34 |
const handleNodeDragStart = useCallback( |
| 35 |
(e, nodeId) => { |
| 36 |
e.stopPropagation(); |
| 37 |
setDraggedNode(nodeId); |
| 38 |
|
| 39 |
const node = nodes.find((n) => n.id === nodeId); |
| 40 |
if (node) { |
| 41 |
setDragOffset({ |
| 42 |
x: e.clientX - (node.position.x * scale + panOffset.x), |
| 43 |
y: e.clientY - (node.position.y * scale + panOffset.y), |
| 44 |
}); |
| 45 |
} |
| 46 |
}, |
| 47 |
[nodes, scale, panOffset] |
| 48 |
); |
| 49 |
|
| 50 |
const handleConnectionStart = useCallback( |
| 51 |
(nodeId, handle) => { |
| 52 |
setConnectingFrom({ nodeId, handle }); |
| 53 |
}, |
| 54 |
[] |
| 55 |
); |
| 56 |
|
| 57 |
const handleConnectionEnd = useCallback( |
| 58 |
(nodeId, handle) => { |
| 59 |
if (connectingFrom && connectingFrom.nodeId !== nodeId) { |
| 60 |
const newConnection = { |
| 61 |
id: `conn-${Date.now()}`, |
| 62 |
from: connectingFrom.handle === "right" ? connectingFrom.nodeId : nodeId, |
| 63 |
to: connectingFrom.handle === "right" ? nodeId : connectingFrom.nodeId, |
| 64 |
fromHandle: "right", |
| 65 |
toHandle: "left", |
| 66 |
}; |
| 67 |
setConnections((prev) => [...prev, newConnection]); |
| 68 |
} |
| 69 |
setConnectingFrom(null); |
| 70 |
}, |
| 71 |
[connectingFrom, setConnections] |
| 72 |
); |
| 73 |
|
| 74 |
const handleMouseMove = useCallback( |
| 75 |
(e) => { |
| 76 |
const rect = canvasRef.current?.getBoundingClientRect(); |
| 77 |
if (rect) { |
| 78 |
setMousePos({ |
| 79 |
x: e.clientX - rect.left, |
| 80 |
y: e.clientY - rect.top, |
| 81 |
}); |
| 82 |
} |
| 83 |
|
| 84 |
if (isPanning) { |
| 85 |
setPanOffset((prev) => ({ |
| 86 |
x: prev.x + e.movementX, |
| 87 |
y: prev.y + e.movementY, |
| 88 |
})); |
| 89 |
} else if (draggedNode) { |
| 90 |
const newX = (e.clientX - dragOffset.x - panOffset.x) / scale; |
| 91 |
const newY = (e.clientY - dragOffset.y - panOffset.y) / scale; |
| 92 |
|
| 93 |
setNodes((prev) => |
| 94 |
prev.map((node) => |
| 95 |
node.id === draggedNode |
| 96 |
? { ...node, position: { x: newX, y: newY } } |
| 97 |
: node |
| 98 |
) |
| 99 |
); |
| 100 |
} |
| 101 |
}, |
| 102 |
[isPanning, draggedNode, dragOffset, scale, panOffset, setNodes] |
| 103 |
); |
| 104 |
|
| 105 |
const handleMouseUp = useCallback(() => { |
| 106 |
setIsPanning(false); |
| 107 |
setDraggedNode(null); |
| 108 |
setConnectingFrom(null); |
| 109 |
}, []); |
| 110 |
|
| 111 |
const handleDrop = useCallback( |
| 112 |
(e) => { |
| 113 |
e.preventDefault(); |
| 114 |
const dataStr = e.dataTransfer.getData("application/json"); |
| 115 |
if (!dataStr) return; |
| 116 |
|
| 117 |
const app = JSON.parse(dataStr); |
| 118 |
const rect = canvasRef.current?.getBoundingClientRect(); |
| 119 |
if (!rect) return; |
| 120 |
|
| 121 |
const x = (e.clientX - rect.left - panOffset.x) / scale; |
| 122 |
const y = (e.clientY - rect.top - panOffset.y) / scale; |
| 123 |
|
| 124 |
// Type heuristic: |
| 125 |
// 1. If it's the first node, it's likely a trigger. |
| 126 |
// 2. If it's a tool (app.id starts with 't'), it's an action. |
| 127 |
// 3. For apps, if we already have a trigger, it's an action. |
| 128 |
let nodeType = "trigger"; |
| 129 |
if (app.id.startsWith('t')) { |
| 130 |
nodeType = "action"; |
| 131 |
} else if (nodes.some(n => n.type === 'trigger')) { |
| 132 |
nodeType = "action"; |
| 133 |
} |
| 134 |
|
| 135 |
const newNode = { |
| 136 |
id: `node-${Date.now()}`, |
| 137 |
type: nodeType, |
| 138 |
title: app.name, |
| 139 |
subtitle: "Configure action", |
| 140 |
position: { x, y }, |
| 141 |
icon: app.icon, |
| 142 |
iconBg: app.iconBg, |
| 143 |
actionNumber: nodes.length + 1, |
| 144 |
appData: app, // Store the full app data for actions |
| 145 |
}; |
| 146 |
|
| 147 |
setNodes((prev) => [...prev, newNode]); |
| 148 |
}, |
| 149 |
[panOffset, scale, nodes.length, setNodes] |
| 150 |
); |
| 151 |
|
| 152 |
const handleDragOver = useCallback((e) => { |
| 153 |
e.preventDefault(); |
| 154 |
e.dataTransfer.dropEffect = "copy"; |
| 155 |
}, []); |
| 156 |
|
| 157 |
const handleWheel = useCallback( |
| 158 |
(e) => { |
| 159 |
if (e.ctrlKey || e.metaKey) { |
| 160 |
e.preventDefault(); |
| 161 |
const delta = e.deltaY > 0 ? -0.1 : 0.1; |
| 162 |
const newScale = Math.min(Math.max(scale + delta, 0.5), 2); |
| 163 |
|
| 164 |
if (newScale !== scale) { |
| 165 |
const rect = canvasRef.current?.getBoundingClientRect(); |
| 166 |
if (rect) { |
| 167 |
const mouseX = e.clientX - rect.left; |
| 168 |
const mouseY = e.clientY - rect.top; |
| 169 |
|
| 170 |
// Calculate the mouse position relative to the content before zoom |
| 171 |
const contentMouseX = (mouseX - panOffset.x) / scale; |
| 172 |
const contentMouseY = (mouseY - panOffset.y) / scale; |
| 173 |
|
| 174 |
// Calculate the new pan offset to keep the content under the mouse at the same position |
| 175 |
const newPanX = mouseX - contentMouseX * newScale; |
| 176 |
const newPanY = mouseY - contentMouseY * newScale; |
| 177 |
|
| 178 |
setPanOffset({ x: newPanX, y: newPanY }); |
| 179 |
setScale(newScale); |
| 180 |
} |
| 181 |
} |
| 182 |
} |
| 183 |
}, |
| 184 |
[scale, setScale, panOffset] |
| 185 |
); |
| 186 |
|
| 187 |
const handleDeleteConnection = useCallback( |
| 188 |
(connectionId) => { |
| 189 |
setConnections((prev) => prev.filter((c) => c.id !== connectionId)); |
| 190 |
}, |
| 191 |
[setConnections] |
| 192 |
); |
| 193 |
|
| 194 |
const handleDeleteNode = useCallback( |
| 195 |
(nodeId) => { |
| 196 |
setNodes((prev) => prev.filter((n) => n.id !== nodeId)); |
| 197 |
setConnections((prev) => |
| 198 |
prev.filter((c) => c.from !== nodeId && c.to !== nodeId) |
| 199 |
); |
| 200 |
}, |
| 201 |
[setNodes, setConnections] |
| 202 |
); |
| 203 |
|
| 204 |
return ( |
| 205 |
<div |
| 206 |
ref={canvasRef} |
| 207 |
className="flex-1 bg-canvas relative overflow-hidden cursor-grab active:cursor-grabbing" |
| 208 |
onMouseDown={handleMouseDown} |
| 209 |
onMouseMove={handleMouseMove} |
| 210 |
onMouseUp={handleMouseUp} |
| 211 |
onMouseLeave={handleMouseUp} |
| 212 |
onDrop={handleDrop} |
| 213 |
onDragOver={handleDragOver} |
| 214 |
onWheel={handleWheel} |
| 215 |
> |
| 216 |
{/* Grid Pattern */} |
| 217 |
<div |
| 218 |
data-grid |
| 219 |
className="absolute inset-0 opacity-30 pointer-events-none" |
| 220 |
style={{ |
| 221 |
backgroundImage: `radial-gradient(circle, var(--sidebar-border) 1px, transparent 1px)`, |
| 222 |
backgroundSize: `${20 * scale}px ${20 * scale}px`, |
| 223 |
backgroundPosition: `${panOffset.x}px ${panOffset.y}px`, |
| 224 |
}} |
| 225 |
/> |
| 226 |
|
| 227 |
{/* Canvas Content */} |
| 228 |
<div |
| 229 |
className="absolute" |
| 230 |
style={{ |
| 231 |
transform: `translate(${panOffset.x}px, ${panOffset.y}px)`, |
| 232 |
}} |
| 233 |
> |
| 234 |
{/* Connections */} |
| 235 |
<Connections |
| 236 |
connections={connections} |
| 237 |
nodes={nodes} |
| 238 |
scale={scale} |
| 239 |
onDeleteConnection={handleDeleteConnection} |
| 240 |
connectingFrom={connectingFrom} |
| 241 |
mousePos={mousePos} |
| 242 |
panOffset={panOffset} |
| 243 |
/> |
| 244 |
|
| 245 |
{/* Nodes */} |
| 246 |
{nodes.map((node) => ( |
| 247 |
<WorkflowNode |
| 248 |
key={node.id} |
| 249 |
node={node} |
| 250 |
scale={scale} |
| 251 |
onDragStart={handleNodeDragStart} |
| 252 |
onConnectionStart={handleConnectionStart} |
| 253 |
onConnectionEnd={handleConnectionEnd} |
| 254 |
onDelete={handleDeleteNode} |
| 255 |
isSelected={node.id === selectedNodeId} |
| 256 |
onClick={() => onNodeClick(node.id)} |
| 257 |
/> |
| 258 |
))} |
| 259 |
</div> |
| 260 |
</div> |
| 261 |
); |
| 262 |
} |
| 263 |
|
| 264 |
export default WorkflowCanvas; |
| 265 |
|