Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 77 additions & 1 deletion client/src/components/PlayCanvas/PlayCanvas.css
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,80 @@

.playcanvas.grabbing {
cursor: grabbing;
}
}

.execute-btn {
position: absolute;
top: 20px;
left: 20px;
padding: 8px 16px;
border-radius: 4px;
border: none;
background-color: var(--color-primary);
color: var(--color-bg-1);
font-size: 14px;
}

.execute-btn:disabled {
opacity: 0.6;
cursor: default;
}

.last-result-summary {
position: absolute;
top: 60px;
left: 20px;
padding: 4px 8px;
background-color: rgba(0, 0, 0, 0.4);
color: var(--color-bg-1);
border-radius: 4px;
font-size: 12px;
}

.node {
position: absolute;
width: 120px;
padding: 8px;
background-color: var(--color-bg-2);
border: 1px solid var(--color-border-grey);
border-radius: 6px;
cursor: grab;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}

.node-title {
font-size: 12px;
color: var(--color-text-grey);
margin-bottom: 4px;
}

.node-ports {
display: flex;
justify-content: space-between;
align-items: center;
}

.node-port {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--color-primary);
}

.node-port-in {
background-color: var(--color-accent);
}

.edges-layer {
position: absolute;
top: 0;
left: 0;
width: 2000px;
height: 2000px;
pointer-events: none;
}

.edge-line {
stroke: var(--color-primary);
stroke-width: 2;
}
289 changes: 239 additions & 50 deletions client/src/components/PlayCanvas/PlayCanvas.tsx
Original file line number Diff line number Diff line change
@@ -1,71 +1,260 @@
import { useState } from "react"
import type React from "react"
import { useMemo, useState } from "react"

import {
type ExecutionResult,
type DataFrame,
type WorkflowDefinition,
type NodeType,
type WorkflowNode,
WorkflowExecutionEngine
} from "../../engine/workflow"

import "./PlayCanvas.css"

const PlayCanvas = () => {
interface PlayCanvasProps {
selectedNodeType: NodeType | null
onExecutionResultChange?: (result: ExecutionResult | null) => void
onNodeTypeConsumed?: () => void
}

const [ isGrabbing, setGrabbing ] = useState<boolean>(false)
const PlayCanvas = ({
selectedNodeType,
onExecutionResultChange,
onNodeTypeConsumed
}: PlayCanvasProps) => {
const [isGrabbing, setGrabbing] = useState<boolean>(false)
const [lastResult, setLastResult] = useState<ExecutionResult | null>(null)
const [isRunning, setIsRunning] = useState(false)
const [draggingNodeId, setDraggingNodeId] = useState<string | null>(null)
const [dragOffset, setDragOffset] = useState<{ x: number; y: number } | null>(null)
const [connectingFromNodeId, setConnectingFromNodeId] = useState<string | null>(null)

const handleMouseDown = () => {
setGrabbing(true)
}
const initialDefinition: WorkflowDefinition = useMemo(
() => ({
nodes: [],
edges: []
}),
[]
)

const handleMouseMove = (e) => {
if (isGrabbing) {
const board = e.target
const docWidth = document.documentElement.clientWidth
const docHeight = document.documentElement.clientHeight
const [definition, setDefinition] = useState<WorkflowDefinition>(initialDefinition)

// A very complicated logic to prevent the board from being dragged out of the viewport
if (board.offsetLeft <= 0) {
board.style.left = `${e.movementX + board.offsetLeft}px`
}
else {
board.style.left = "0px"
}
const handleCanvasMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
if (selectedNodeType) {
const rect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - rect.left
const y = e.clientY - rect.top
const newNode: WorkflowNode = {
id: `n-${Date.now()}`,
type: selectedNodeType,
data: {},
position: { x, y }
}
setDefinition(prev => ({
...prev,
nodes: [...prev.nodes, newNode]
}))
if (onNodeTypeConsumed) {
onNodeTypeConsumed()
}
return
}
setGrabbing(true)
}

if (board.offsetLeft >= docWidth - 2000) {
board.style.left = `${e.movementX + board.offsetLeft}px`
}
else {
board.style.left = `${docWidth - 2000}px`
}
const handleNodeMouseDown = (
e: React.MouseEvent<HTMLDivElement>,
nodeId: string
) => {
e.stopPropagation()
const rect = e.currentTarget.getBoundingClientRect()
const offsetX = e.clientX - rect.left
const offsetY = e.clientY - rect.top
setDraggingNodeId(nodeId)
setDragOffset({ x: offsetX, y: offsetY })
}

const handleCanvasMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (draggingNodeId && dragOffset) {
const canvasRect = e.currentTarget.getBoundingClientRect()
const x = e.clientX - canvasRect.left - dragOffset.x
const y = e.clientY - canvasRect.top - dragOffset.y
setDefinition(prev => ({
...prev,
nodes: prev.nodes.map(node =>
node.id === draggingNodeId ? { ...node, position: { x, y } } : node
)
}))
return
}

if (board.offsetTop <= 0) {
board.style.top = `${e.movementY + board.offsetTop}px`
}
else {
board.style.top = "0px"
}
if (!isGrabbing) {
return
}
const board = e.currentTarget
const docWidth = document.documentElement.clientWidth
const docHeight = document.documentElement.clientHeight

if (board.offsetTop > docHeight - 2000) {
board.style.top = `${e.movementY + board.offsetTop}px`
}
else {
board.style.top = `${docHeight - 2000}px`
}
if (board.offsetLeft <= 0) {
board.style.left = `${e.movementX + board.offsetLeft}px`
} else {
board.style.left = "0px"
}

if (board.offsetLeft >= docWidth - 2000) {
board.style.left = `${e.movementX + board.offsetLeft}px`
} else {
board.style.left = `${docWidth - 2000}px`
}

if (board.offsetTop <= 0) {
board.style.top = `${e.movementY + board.offsetTop}px`
} else {
board.style.top = "0px"
}

}
if (board.offsetTop > docHeight - 2000) {
board.style.top = `${e.movementY + board.offsetTop}px`
} else {
board.style.top = `${docHeight - 2000}px`
}
}

const handleMouseUp = () => {
setGrabbing(false)
const handleCanvasMouseUp = () => {
setGrabbing(false)
setDraggingNodeId(null)
setDragOffset(null)
}

const handlePortMouseDown = (
e: React.MouseEvent<HTMLDivElement>,
nodeId: string,
kind: "source" | "target"
) => {
e.stopPropagation()
if (kind === "source") {
setConnectingFromNodeId(nodeId)
return
}
if (kind === "target" && connectingFromNodeId && connectingFromNodeId !== nodeId) {
const edgeId = `e-${connectingFromNodeId}-${nodeId}-${Date.now()}`
setDefinition(prev => ({
...prev,
edges: [...prev.edges, { id: edgeId, source: connectingFromNodeId, target: nodeId }]
}))
setConnectingFromNodeId(null)
}
}

return (
<div className="playcanvas-container">
<div
className={ isGrabbing ? "playcanvas grabbing" : "playcanvas"}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
>
const handleExecute = async () => {
setIsRunning(true)
try {
const engine = new WorkflowExecutionEngine()
const logs: string[] = []
const ctx = {
log: (message: string) => {
logs.push(message)
}
}
const result = await engine.execute(definition, ctx)
const outputNodeIds = definition.nodes
.filter(n => n.type === "output")
.map(n => n.id)
const outputs: Record<string, DataFrame> = {}
for (const nodeId of outputNodeIds) {
const frames = result.nodeOutputs[nodeId]
if (frames && frames.length > 0) {
outputs[nodeId] = frames[0]
}
}
const enriched: ExecutionResult = {
nodeOutputs: result.nodeOutputs
}
setLastResult(enriched)
if (onExecutionResultChange) {
onExecutionResultChange(enriched)
}
console.log("Workflow logs:", logs)
console.log("Workflow outputs:", outputs)
} catch (error) {
console.error("Workflow execution failed", error)
setLastResult(null)
if (onExecutionResultChange) {
onExecutionResultChange(null)
}
} finally {
setIsRunning(false)
}
}

return (
<div className="playcanvas-container">
<div
className={isGrabbing ? "playcanvas grabbing" : "playcanvas"}
onMouseDown={handleCanvasMouseDown}
onMouseMove={handleCanvasMouseMove}
onMouseUp={handleCanvasMouseUp}
>
<button
type="button"
className="execute-btn"
onClick={handleExecute}
disabled={isRunning}
>
{isRunning ? "Executing..." : "Execute Workflow"}
</button>
{lastResult && (
<div className="last-result-summary">
<span>Executed nodes: {Object.keys(lastResult.nodeOutputs).length}</span>
</div>
)}
<svg className="edges-layer">
{definition.edges.map(edge => {
const source = definition.nodes.find(n => n.id === edge.source)
const target = definition.nodes.find(n => n.id === edge.target)
if (!source || !target) {
return null
}
const x1 = source.position.x + 100
const y1 = source.position.y + 20
const x2 = target.position.x
const y2 = target.position.y + 20
return (
<line
key={edge.id}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
className="edge-line"
/>
)
})}
</svg>
{definition.nodes.map(node => (
<div
key={node.id}
className="node"
style={{ left: node.position.x, top: node.position.y }}
onMouseDown={e => handleNodeMouseDown(e, node.id)}
>
<div className="node-title">{node.type}</div>
<div className="node-ports">
<div
className="node-port node-port-out"
onMouseDown={e => handlePortMouseDown(e, node.id, "source")}
/>
<div
className="node-port node-port-in"
onMouseDown={e => handlePortMouseDown(e, node.id, "target")}
/>
</div>
</div>
)
</div>
))}
</div>
</div>
)
}

export default PlayCanvas
export default PlayCanvas
Loading