feat: implement drag-and-drop functionality for nodes in WorkflowEditor
This commit is contained in:
@@ -21,7 +21,7 @@ import {
|
|||||||
type ReactFlowInstance,
|
type ReactFlowInstance,
|
||||||
} from "@xyflow/react"
|
} from "@xyflow/react"
|
||||||
import { AlertCircleIcon, CheckCircle2Icon, PlusIcon } from "lucide-react"
|
import { AlertCircleIcon, CheckCircle2Icon, PlusIcon } from "lucide-react"
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||||
|
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -50,8 +50,6 @@ import {
|
|||||||
} from "./workflow-utils"
|
} from "./workflow-utils"
|
||||||
import { NodeConfigPanel } from "./node-config-panel"
|
import { NodeConfigPanel } from "./node-config-panel"
|
||||||
|
|
||||||
const workflowDragType = "application/agent-desk-workflow-node"
|
|
||||||
|
|
||||||
type WorkflowNodeData = Record<string, unknown> & {
|
type WorkflowNodeData = Record<string, unknown> & {
|
||||||
nodeType?: string
|
nodeType?: string
|
||||||
name?: string
|
name?: string
|
||||||
@@ -68,6 +66,15 @@ type WorkflowNodeData = Record<string, unknown> & {
|
|||||||
type WorkflowFlowNode = Node<WorkflowNodeData>
|
type WorkflowFlowNode = Node<WorkflowNodeData>
|
||||||
type WorkflowFlowEdge = Edge
|
type WorkflowFlowEdge = Edge
|
||||||
|
|
||||||
|
type PendingNodeDrag = {
|
||||||
|
spec: AIWorkflowNodeSpec
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
active: boolean
|
||||||
|
}
|
||||||
|
|
||||||
const nodeTypes = {
|
const nodeTypes = {
|
||||||
workflowNode: WorkflowCanvasNode,
|
workflowNode: WorkflowCanvasNode,
|
||||||
}
|
}
|
||||||
@@ -151,6 +158,10 @@ export function WorkflowEditor({
|
|||||||
)
|
)
|
||||||
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<WorkflowFlowNode, WorkflowFlowEdge> | null>(null)
|
const [flowInstance, setFlowInstance] = useState<ReactFlowInstance<WorkflowFlowNode, WorkflowFlowEdge> | null>(null)
|
||||||
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null)
|
||||||
|
const [pendingNodeDrag, setPendingNodeDrag] = useState<PendingNodeDrag | null>(null)
|
||||||
|
const canvasRef = useRef<HTMLElement | null>(null)
|
||||||
|
const pendingNodeDragRef = useRef<PendingNodeDrag | null>(null)
|
||||||
|
const suppressNextClickRef = useRef(false)
|
||||||
const selectedNode = useMemo(
|
const selectedNode = useMemo(
|
||||||
() => nodes.find((node) => node.id === selectedNodeId) ?? null,
|
() => nodes.find((node) => node.id === selectedNodeId) ?? null,
|
||||||
[nodes, selectedNodeId]
|
[nodes, selectedNodeId]
|
||||||
@@ -232,37 +243,70 @@ export function WorkflowEditor({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const onNodeDragStart = (event: React.DragEvent<HTMLButtonElement>, spec: AIWorkflowNodeSpec) => {
|
const dropNodeOnCanvas = useCallback(
|
||||||
event.dataTransfer.setData(workflowDragType, spec.type)
|
(spec: AIWorkflowNodeSpec, x: number, y: number) => {
|
||||||
event.dataTransfer.effectAllowed = "copy"
|
if (!flowInstance || !canvasRef.current) {
|
||||||
}
|
return false
|
||||||
|
}
|
||||||
|
const rect = canvasRef.current.getBoundingClientRect()
|
||||||
|
if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const position = flowInstance.screenToFlowPosition({ x, y })
|
||||||
|
setNodes((current) => [
|
||||||
|
...current,
|
||||||
|
createWorkflowNodeFromSpec(spec, current, position) as WorkflowFlowNode,
|
||||||
|
])
|
||||||
|
return true
|
||||||
|
},
|
||||||
|
[flowInstance, setNodes]
|
||||||
|
)
|
||||||
|
|
||||||
const onCanvasDragOver = (event: React.DragEvent) => {
|
const onNodePointerDown = (event: React.PointerEvent<HTMLButtonElement>, spec: AIWorkflowNodeSpec) => {
|
||||||
if (!event.dataTransfer.types.includes(workflowDragType)) {
|
if (event.button !== 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
event.preventDefault()
|
const initialDrag = {
|
||||||
event.dataTransfer.dropEffect = "copy"
|
spec,
|
||||||
}
|
startX: event.clientX,
|
||||||
|
startY: event.clientY,
|
||||||
const onCanvasDrop = (event: React.DragEvent) => {
|
|
||||||
event.preventDefault()
|
|
||||||
const nodeType = event.dataTransfer.getData(workflowDragType)
|
|
||||||
if (!nodeType || !flowInstance) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const spec = nodeSpecs.find((item) => item.type === nodeType)
|
|
||||||
if (!spec) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const position = flowInstance.screenToFlowPosition({
|
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
y: event.clientY,
|
y: event.clientY,
|
||||||
})
|
active: false,
|
||||||
setNodes((current) => [
|
}
|
||||||
...current,
|
pendingNodeDragRef.current = initialDrag
|
||||||
createWorkflowNodeFromSpec(spec, current, position) as WorkflowFlowNode,
|
setPendingNodeDrag(initialDrag)
|
||||||
])
|
|
||||||
|
const handlePointerMove = (event: PointerEvent) => {
|
||||||
|
const current = pendingNodeDragRef.current
|
||||||
|
if (!current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const moved = Math.hypot(event.clientX - current.startX, event.clientY - current.startY)
|
||||||
|
const nextDrag = {
|
||||||
|
...current,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
active: current.active || moved > 6,
|
||||||
|
}
|
||||||
|
pendingNodeDragRef.current = nextDrag
|
||||||
|
setPendingNodeDrag(nextDrag)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePointerUp = (event: PointerEvent) => {
|
||||||
|
window.removeEventListener("pointermove", handlePointerMove)
|
||||||
|
window.removeEventListener("pointerup", handlePointerUp)
|
||||||
|
const current = pendingNodeDragRef.current
|
||||||
|
pendingNodeDragRef.current = null
|
||||||
|
setPendingNodeDrag(null)
|
||||||
|
if (current?.active) {
|
||||||
|
suppressNextClickRef.current = true
|
||||||
|
dropNodeOnCanvas(current.spec, event.clientX, event.clientY)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", handlePointerMove)
|
||||||
|
window.addEventListener("pointerup", handlePointerUp)
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateNodeData = (nodeId: string, data: WorkflowNodeData) => {
|
const updateNodeData = (nodeId: string, data: WorkflowNodeData) => {
|
||||||
@@ -291,9 +335,14 @@ export function WorkflowEditor({
|
|||||||
<button
|
<button
|
||||||
key={spec.type}
|
key={spec.type}
|
||||||
type="button"
|
type="button"
|
||||||
draggable
|
onPointerDown={(event) => onNodePointerDown(event, spec)}
|
||||||
onDragStart={(event) => onNodeDragStart(event, spec)}
|
onClick={() => {
|
||||||
onClick={() => addNode(spec)}
|
if (suppressNextClickRef.current) {
|
||||||
|
suppressNextClickRef.current = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
addNode(spec)
|
||||||
|
}}
|
||||||
className="flex w-full cursor-grab items-start gap-2 rounded-md border bg-background px-3 py-2 text-left text-sm hover:bg-muted active:cursor-grabbing"
|
className="flex w-full cursor-grab items-start gap-2 rounded-md border bg-background px-3 py-2 text-left text-sm hover:bg-muted active:cursor-grabbing"
|
||||||
>
|
>
|
||||||
<PlusIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
<PlusIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||||
@@ -316,9 +365,11 @@ export function WorkflowEditor({
|
|||||||
<ResizablePanel defaultSize="56%" minSize="30%" className="min-h-0">
|
<ResizablePanel defaultSize="56%" minSize="30%" className="min-h-0">
|
||||||
<section
|
<section
|
||||||
data-workflow-canvas
|
data-workflow-canvas
|
||||||
className="relative h-full min-h-0"
|
ref={canvasRef}
|
||||||
onDragOver={onCanvasDragOver}
|
className={[
|
||||||
onDrop={onCanvasDrop}
|
"relative h-full min-h-0",
|
||||||
|
pendingNodeDrag?.active ? "ring-2 ring-primary/30" : "",
|
||||||
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={renderedNodes}
|
nodes={renderedNodes}
|
||||||
@@ -346,6 +397,17 @@ export function WorkflowEditor({
|
|||||||
<div className="pointer-events-none absolute bottom-3 left-3 rounded-md border bg-background/95 px-3 py-2 text-xs text-muted-foreground shadow-sm">
|
<div className="pointer-events-none absolute bottom-3 left-3 rounded-md border bg-background/95 px-3 py-2 text-xs text-muted-foreground shadow-sm">
|
||||||
从节点右侧圆点拖到下一个节点,或依次点击两个连接点完成连线。
|
从节点右侧圆点拖到下一个节点,或依次点击两个连接点完成连线。
|
||||||
</div>
|
</div>
|
||||||
|
{pendingNodeDrag?.active ? (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed z-50 rounded-md border bg-background px-3 py-2 text-sm font-medium shadow-lg"
|
||||||
|
style={{
|
||||||
|
left: pendingNodeDrag.x + 12,
|
||||||
|
top: pendingNodeDrag.y + 12,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pendingNodeDrag.spec.title}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle withHandle />
|
<ResizableHandle withHandle />
|
||||||
|
|||||||
Reference in New Issue
Block a user