Explorar el Código

feat: 添加连线功能

jiaxing.liao hace 1 semana
padre
commit
acfa41d1ed

+ 268 - 213
apps/web/src/views/Editor.vue

@@ -1,245 +1,300 @@
 <template>
-    <div class="w-full h-full">
-        <Workflow :workflow="workflow" @click:node="handleNodeClick" @create:node="handleNodeCreate" @drop="handleDrop"
-            @run="handleRunWorkflow" />
-        <RunWork v-model:visible="runVisible" />
-        <Setter :data="nodeID" v-model:visible="setterVisible" />
-    </div>
+	<div class="w-full h-full">
+		<Workflow
+			:workflow="workflow"
+			@click:node="handleNodeClick"
+			@create:node="handleNodeCreate"
+			@create:connection="onCreateConnection"
+			@drop="handleDrop"
+			@run="handleRunWorkflow"
+		/>
+		<RunWork v-model:visible="runVisible" />
+		<Setter :data="nodeID" v-model:visible="setterVisible" />
+	</div>
 </template>
 
 <script setup lang="ts">
-import { startNode, endNode, httpNode, conditionNode, databaseNode, codeNode, } from '@repo/nodes'
-import { Workflow, type IWorkflow, type XYPosition } from '@repo/workflow'
-import Setter from "@/components/setter/index.vue"
+import { startNode, endNode, httpNode, conditionNode, databaseNode, codeNode } from '@repo/nodes'
+import { Workflow, type IWorkflow, type XYPosition, type Connection } from '@repo/workflow'
+import Setter from '@/components/setter/index.vue'
 import RunWork from '@/components/RunWork.vue'
 import type { SourceType } from '@repo/nodes'
 import { ref } from 'vue'
 
 const workflow = ref<IWorkflow>({
-    id: '1',
-    nodes: [
-        startNode, // 初始化节点,
-        endNode, // 初始化节点,
-        // httpNode,
-        // conditionNode,
-        // databaseNode,
-        // codeNode
+	id: '1',
+	nodes: [
+		startNode, // 初始化节点,
+		endNode, // 初始化节点,
+		// httpNode,
+		// conditionNode,
+		// databaseNode,
+		// codeNode
+		{
+			id: 'node-1',
+			type: 'canvas-node',
+			position: { x: 100, y: 100 },
+			width: 96,
+			height: 96,
+			data: {
+				version: ['1.0.0'],
+				displayName: '用户输入',
+				name: 'chart',
+				description: '通过用户输入开启流程处理',
+				icon: 'fluent:comment-multiple-28-regular',
+				iconColor: '#296dff',
+				inputs: [],
+				outputs: [
 					{
-						id: 'node-1',
-						type: 'canvas-node',
-						position: { x: 100, y: 100 },
-						width: 96,
-						height: 96,
-						data: {
-							version: ['1.0.0'],
-							displayName: '用户输入',
-							name: 'chart',
-							description: '通过用户输入开启流程处理',
-							icon: 'fluent:comment-multiple-28-regular',
-							iconColor: '#296dff',
-							inputs: [],
-							outputs: [
-								{
-									index: 0,
-									type: 'main'
-								}
-							]
-						}
+						index: 0,
+						type: 'main'
+					}
+				]
+			}
+		},
+		{
+			id: 'node-2',
+			type: 'canvas-node',
+			width: 96,
+			height: 96,
+			position: { x: 400, y: 100 },
+			data: {
+				version: ['1.0.0'],
+				displayName: '条件判断',
+				name: 'if',
+				description: '通过条件判断拆分多个流程分支',
+				icon: 'roentgen:guidepost',
+				iconColor: '#108e49',
+				inputs: [
+					{
+						index: 0,
+						type: 'main'
+					}
+				],
+				outputs: [
+					{
+						index: 0,
+						type: 'main',
+						label: 'true'
 					},
 					{
-						id: 'node-2',
-						type: 'canvas-node',
-						width: 96,
-						height: 96,
-						position: { x: 400, y: 100 },
-						data: {
-							version: ['1.0.0'],
-							displayName: '条件判断',
-							name: 'if',
-							description: '通过条件判断拆分多个流程分支',
-							icon: 'roentgen:guidepost',
-							iconColor: '#108e49',
-							inputs: [
-								{
-									index: 0,
-									type: 'main'
-								}
-							],
-							outputs: [
-								{
-									index: 0,
-									type: 'main',
-									label: 'true'
-								},
-								{
-									index: 1,
-									type: 'main',
-									label: 'false'
-								}
-							],
-							outputNames: ['true', 'false']
-						}
+						index: 1,
+						type: 'main',
+						label: 'false'
 					}
 				],
-    edges: [
-			{
-				id: 'edge-1-2',
-				source: 'node-1',
-				target: 'node-2',
-				type: 'canvas-edge',
-				data: {
-					label: 'Edge 1-2'
-				}
+				outputNames: ['true', 'false']
 			}
-        // {
-        //     id: 'edge-1-2',
-        //     source: 'start-node',
-        //     target: 'http-node',
-        //     type: 'canvas-edge',
-        //     data: {
-        //         label: 'Edge 1-2'
-        //     }
-        // },
-        // {
-        //     id: 'edge-1-6',
-        //     source: 'http-node',
-        //     target: 'condition-node',
-        //     type: 'canvas-edge',
-        //     data: {
-        //         label: 'Edge 1-2'
-        //     }
-        // },
-        // {
-        //     id: 'edge-1-5',
-        //     source: 'condition-node',
-        //     target: 'data-node',
-        //     type: 'canvas-edge',
-        //     data: {
-        //         label: 'Edge 1-2'
-        //     }
-        // },
-        // {
-        //     id: 'edge-1-3',
-        //     source: 'database-node',
-        //     target: 'code-node',
-        //     type: 'canvas-edge',
-        //     data: {
-        //         label: 'Edge 1-2'
-        //     }
-        // },
+		},
+		{
+			id: 'node-3',
+			type: 'canvas-node',
+			width: 96,
+			height: 96,
+			position: { x: 600, y: 300 },
+			data: {
+				version: ['1.0.0'],
+				displayName: '条件判断',
+				name: 'if',
+				description: '通过条件判断拆分多个流程分支',
+				icon: 'roentgen:guidepost',
+				iconColor: '#108e49',
+				inputs: [
+					{
+						index: 0,
+						type: 'main'
+					}
+				],
+				outputs: [
+					{
+						index: 0,
+						type: 'main',
+						label: 'true'
+					},
+					{
+						index: 1,
+						type: 'main',
+						label: 'false'
+					}
+				],
+				outputNames: ['true', 'false']
+			}
+		}
+	],
+	edges: [
+		{
+			id: 'edge-1-2',
+			source: 'node-1',
+			target: 'node-2',
+			type: 'canvas-edge',
+			data: {
+				label: 'Edge 1-2'
+			}
+		}
+		// {
+		//     id: 'edge-1-2',
+		//     source: 'start-node',
+		//     target: 'http-node',
+		//     type: 'canvas-edge',
+		//     data: {
+		//         label: 'Edge 1-2'
+		//     }
+		// },
+		// {
+		//     id: 'edge-1-6',
+		//     source: 'http-node',
+		//     target: 'condition-node',
+		//     type: 'canvas-edge',
+		//     data: {
+		//         label: 'Edge 1-2'
+		//     }
+		// },
+		// {
+		//     id: 'edge-1-5',
+		//     source: 'condition-node',
+		//     target: 'data-node',
+		//     type: 'canvas-edge',
+		//     data: {
+		//         label: 'Edge 1-2'
+		//     }
+		// },
+		// {
+		//     id: 'edge-1-3',
+		//     source: 'database-node',
+		//     target: 'code-node',
+		//     type: 'canvas-edge',
+		//     data: {
+		//         label: 'Edge 1-2'
+		//     }
+		// },
 
-        // {
-        //     id: 'edge-1-4',
-        //     source: 'code-node',
-        //     target: 'end-node',
-        //     type: 'canvas-edge',
-        //     data: {
-        //         label: 'Edge 1-2'
-        //     }
-        // }
-    ]
-    // 	id: '1',
-    // 	nodes: [
-    // 		{
-    // 			id: 'node-1',
-    // 			type: 'canvas-node',
-    // 			position: { x: 100, y: 100 },
-    // 			width: 96,
-    // 			height: 96,
-    // 			data: {
-    // 				version: ['1.0.0'],
-    // 				displayName: '用户输入',
-    // 				name: 'chart',
-    // 				description: '通过用户输入开启流程处理',
-    // 				icon: 'fluent:comment-multiple-28-regular',
-    // 				iconColor: '#296dff',
-    // 				inputs: [],
-    // 				outputs: [
-    // 					{
-    // 						index: 0,
-    // 						type: 'main'
-    // 					}
-    // 				]
-    // 			}
-    // 		},
-    // 		{
-    // 			id: 'node-2',
-    // 			type: 'canvas-node',
-    // 			width: 96,
-    // 			height: 96,
-    // 			position: { x: 400, y: 100 },
-    // 			data: {
-    // 				version: ['1.0.0'],
-    // 				displayName: '条件判断',
-    // 				name: 'if',
-    // 				description: '通过条件判断拆分多个流程分支',
-    // 				icon: 'roentgen:guidepost',
-    // 				iconColor: '#108e49',
-    // 				inputs: [
-    // 					{
-    // 						index: 0,
-    // 						type: 'main'
-    // 					}
-    // 				],
-    // 				outputs: [
-    // 					{
-    // 						index: 0,
-    // 						type: 'main',
-    // 						label: 'true'
-    // 					},
-    // 					{
-    // 						index: 1,
-    // 						type: 'main',
-    // 						label: 'false'
-    // 					}
-    // 				],
-    // 				outputNames: ['true', 'false']
-    // 			}
-    // 		}
-    // 	],
-    // 	edges: [
-    // 		{
-    // 			id: 'edge-1-2',
-    // 			source: 'node-1',
-    // 			target: 'node-2',
-    // 			type: 'canvas-edge',
-    // 			data: {
-    // 				label: 'Edge 1-2'
-    // 			}
-    // 		}
-    // 	]
+		// {
+		//     id: 'edge-1-4',
+		//     source: 'code-node',
+		//     target: 'end-node',
+		//     type: 'canvas-edge',
+		//     data: {
+		//         label: 'Edge 1-2'
+		//     }
+		// }
+	]
+	// 	id: '1',
+	// 	nodes: [
+	// 		{
+	// 			id: 'node-1',
+	// 			type: 'canvas-node',
+	// 			position: { x: 100, y: 100 },
+	// 			width: 96,
+	// 			height: 96,
+	// 			data: {
+	// 				version: ['1.0.0'],
+	// 				displayName: '用户输入',
+	// 				name: 'chart',
+	// 				description: '通过用户输入开启流程处理',
+	// 				icon: 'fluent:comment-multiple-28-regular',
+	// 				iconColor: '#296dff',
+	// 				inputs: [],
+	// 				outputs: [
+	// 					{
+	// 						index: 0,
+	// 						type: 'main'
+	// 					}
+	// 				]
+	// 			}
+	// 		},
+	// 		{
+	// 			id: 'node-2',
+	// 			type: 'canvas-node',
+	// 			width: 96,
+	// 			height: 96,
+	// 			position: { x: 400, y: 100 },
+	// 			data: {
+	// 				version: ['1.0.0'],
+	// 				displayName: '条件判断',
+	// 				name: 'if',
+	// 				description: '通过条件判断拆分多个流程分支',
+	// 				icon: 'roentgen:guidepost',
+	// 				iconColor: '#108e49',
+	// 				inputs: [
+	// 					{
+	// 						index: 0,
+	// 						type: 'main'
+	// 					}
+	// 				],
+	// 				outputs: [
+	// 					{
+	// 						index: 0,
+	// 						type: 'main',
+	// 						label: 'true'
+	// 					},
+	// 					{
+	// 						index: 1,
+	// 						type: 'main',
+	// 						label: 'false'
+	// 					}
+	// 				],
+	// 				outputNames: ['true', 'false']
+	// 			}
+	// 		}
+	// 	],
+	// 	edges: [
+	// 		{
+	// 			id: 'edge-1-2',
+	// 			source: 'node-1',
+	// 			target: 'node-2',
+	// 			type: 'canvas-edge',
+	// 			data: {
+	// 				label: 'Edge 1-2'
+	// 			}
+	// 		}
+	// 	]
 })
 const nodeID = ref('')
 const setterVisible = ref(false)
 const runVisible = ref(false)
 const handleRunWorkflow = () => {
-    runVisible.value = true
-    console.log('run workflow')
+	runVisible.value = true
+	console.log('run workflow')
 }
 const handleNodeCreate = (value: SourceType) => {
-    console.log(value)
+	console.log(value)
 
-    const nodeMap: Record<string, any> = {
-        'http': httpNode,
-        'condition': conditionNode,
-        'code': codeNode,
-        'database': databaseNode
-    }
-    const nodeToAdd = nodeMap[value.type]
+	const nodeMap: Record<string, any> = {
+		http: httpNode,
+		condition: conditionNode,
+		code: codeNode,
+		database: databaseNode
+	}
+	const nodeToAdd = nodeMap[value.type]
 
-    // 如果存在对应节点则添加
-    if (nodeToAdd) {
-        workflow.value.nodes.push(nodeToAdd)
-    }
-    console.log(workflow.value.nodes, 'workflow.nodes')
+	// 如果存在对应节点则添加
+	if (nodeToAdd) {
+		workflow.value.nodes.push(nodeToAdd)
+	}
+	console.log(workflow.value.nodes, 'workflow.nodes')
 }
 const handleNodeClick = (id: string, position: XYPosition) => {
-    console.log('click node', id, position)
-    nodeID.value = id
-    setterVisible.value = true
+	console.log('click node', id, position)
+	nodeID.value = id
+	setterVisible.value = true
 }
 
 const handleDrop = (position: XYPosition, event: DragEvent) => {
-    console.log('drag and drop at', position, event)
+	console.log('drag and drop at', position, event)
+}
+
+const onCreateConnection = (connection: Connection) => {
+	console.log('create connection', connection)
+	const { source, target } = connection
+
+	if (!workflow.value.edges.some((edge) => edge.source === source && edge.target === target)) {
+		workflow.value.edges.push({
+			id: `edge-${source}-${target}`,
+			source,
+			target,
+			type: 'canvas-edge',
+			data: {}
+		})
+	}
 }
 </script>

+ 11 - 11
biome.jsonc

@@ -6,14 +6,14 @@
 		"useIgnoreFile": true
 	},
 	"files": {
-		"ignore": [
-			"**/.turbo",
-			"**/components.d.ts",
-			"**/coverage",
-			"**/dist",
-			"**/package.json",
-			"**/pnpm-lock.yaml",
-			"**/CHANGELOG.md"
+		"includes": [
+			"!**/.turbo",
+			"!**/components.d.ts",
+			"!**/coverage",
+			"!**/dist",
+			"!**/package.json",
+			"!**/pnpm-lock.yaml",
+			"!**/CHANGELOG.md"
 		]
 	},
 	"formatter": {
@@ -24,12 +24,12 @@
 		"lineEnding": "lf",
 		"lineWidth": 100,
 		"attributePosition": "auto",
-		"ignore": [
+		"useEditorconfig": true,
+		"includes": [
 			// Handled by prettier
-			"**/*.vue"
+			"!**/*.vue"
 		]
 	},
-	"organizeImports": { "enabled": false },
 	"linter": {
 		"enabled": false
 	},

+ 2 - 1
package.json

@@ -6,7 +6,8 @@
     "dev": "turbo run dev",
     "lint": "turbo run lint",
     "format": "prettier --write \"**/*.{ts,tsx,md}\"",
-    "check-types": "turbo run check-types"
+    "check-types": "turbo run check-types",
+    "format:biome": "biome format --write"
   },
   "devDependencies": {
     "@biomejs/biome": "^2.3.11",

+ 0 - 52
packages/workflow/biome.jsonc

@@ -1,52 +0,0 @@
-{
-	"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
-	"vcs": {
-		"clientKind": "git",
-		"enabled": true,
-		"useIgnoreFile": true
-	},
-	"files": {
-		"ignore": [
-			"**/.turbo",
-			"**/components.d.ts",
-			"**/coverage",
-			"**/dist",
-			"**/package.json",
-			"**/pnpm-lock.yaml",
-			"**/CHANGELOG.md"
-		]
-	},
-	"formatter": {
-		"enabled": true,
-		"formatWithErrors": false,
-		"indentStyle": "tab",
-		"indentWidth": 2,
-		"lineEnding": "lf",
-		"lineWidth": 100,
-		"attributePosition": "auto",
-		"ignore": [
-			// Handled by prettier
-			"**/*.vue"
-		]
-	},
-	"organizeImports": { "enabled": false },
-	"linter": {
-		"enabled": false
-	},
-	"javascript": {
-		"parser": {
-			"unsafeParameterDecoratorsEnabled": true
-		},
-		"formatter": {
-			"jsxQuoteStyle": "double",
-			"quoteProperties": "asNeeded",
-			"trailingCommas": "all",
-			"semicolons": "always",
-			"arrowParentheses": "always",
-			"bracketSpacing": true,
-			"bracketSameLine": false,
-			"quoteStyle": "single",
-			"attributePosition": "auto"
-		}
-	}
-}

+ 8 - 1
packages/workflow/src/Interface.ts

@@ -1,4 +1,7 @@
-import { type Node, type DefaultEdge, Position } from '@vue-flow/core'
+import { Position } from '@vue-flow/core'
+import type { OnConnectStartParams, Node, DefaultEdge } from '@vue-flow/core'
+
+export * from '@vue-flow/core'
 
 export type CanvasConnectionPort = {
 	node?: string
@@ -43,4 +46,8 @@ export interface XYPosition {
 	y: number
 }
 
+export type ConnectStartEvent = {
+	event?: MouseEvent | undefined
+} & OnConnectStartParams
+
 export { Position }

+ 194 - 100
packages/workflow/src/components/Canvas.vue

@@ -1,11 +1,15 @@
 <script lang="ts" setup>
-import { VueFlow, useVueFlow, type NodeMouseEvent, MarkerType } from '@vue-flow/core'
-import { MiniMap } from '@vue-flow/minimap'
-import type { IWorkflow, XYPosition } from '../Interface'
+import type { IWorkflow, XYPosition, ConnectStartEvent } from '../Interface'
 import type { SourceType } from '@repo/nodes'
+import type { NodeMouseEvent, Connection } from '@vue-flow/core'
+
+import { ref } from 'vue'
+import { VueFlow, useVueFlow, MarkerType } from '@vue-flow/core'
+import { MiniMap } from '@vue-flow/minimap'
 
-import CanvasNode from './elements/CanvasNode.vue'
-import CanvasEdge from './elements/CanvasEdge.vue'
+import CanvasNode from './elements/nodes/CanvasNode.vue'
+import CanvasEdge from './elements/edges/CanvasEdge.vue'
+import CanvasArrowHeadMarker from './elements/edges/CanvasArrowHeadMarker.vue'
 import CanvasBackground from './elements/background/CanvasBackground.vue'
 import CanvasControlBar from './elements/CanvasControlBar.vue'
 import ConditionNode from './elements/node-temp/ConditionNode.vue'
@@ -16,54 +20,64 @@ import CodeNode from './elements/node-temp/CodeNode.vue'
 import DataBaseNode from './elements/node-temp/DataBaseNode.vue'
 
 defineOptions({
-    name: 'workflow-canvas'
+	name: 'workflow-canvas'
 })
 
 const emit = defineEmits<{
-    'update:node:position': [id: string, position: XYPosition]
-    'update:node:activated': [id: string, event?: MouseEvent]
-    'update:node:deactivated': [id: string]
-    'update:node:enabled': [id: string]
-    'update:node:selected': [id?: string]
-    'update:node:name': [id: string]
-    'update:node:parameters': [id: string, parameters: Record<string, unknown>]
-    'update:node:inputs': [id: string]
-    'update:node:outputs': [id: string]
-    'update:logs-open': [open?: boolean]
-    'update:logs:input-open': [open?: boolean]
-    'update:logs:output-open': [open?: boolean]
-    'update:has-range-selection': [isActive: boolean]
-    'click:node': [id: string, position: XYPosition]
-    'click:node:add': [id: string, handle: string]
-    'run:node': [id: string]
-    'copy:production:url': [id: string]
-    'copy:test:url': [id: string]
-    'delete:node': [id: string]
-    'replace:node': [id: string]
-    'create:node': [source: any]
-    'create:sticky': []
-    'delete:nodes': [ids: string[]]
-    'update:nodes:enabled': [ids: string[]]
-    'copy:nodes': [ids: string[]]
-    'duplicate:nodes': [ids: string[]]
-    'cut:nodes': [ids: string[]]
-    'drag-and-drop': [position: XYPosition, event: DragEvent]
-    'run': []
+	'update:node:position': [id: string, position: XYPosition]
+	'update:node:activated': [id: string, event?: MouseEvent]
+	'update:node:deactivated': [id: string]
+	'update:node:enabled': [id: string]
+	'update:node:selected': [id?: string]
+	'update:node:name': [id: string]
+	'update:node:parameters': [id: string, parameters: Record<string, unknown>]
+	'update:node:inputs': [id: string]
+	'update:node:outputs': [id: string]
+	'update:logs-open': [open?: boolean]
+	'update:logs:input-open': [open?: boolean]
+	'update:logs:output-open': [open?: boolean]
+	'update:has-range-selection': [isActive: boolean]
+	'click:node': [id: string, position: XYPosition]
+	'click:node:add': [id: string, handle: string]
+	'run:node': [id: string]
+	'copy:production:url': [id: string]
+	'copy:test:url': [id: string]
+	'delete:node': [id: string]
+	'replace:node': [id: string]
+	'create:node': [source: any]
+	'create:sticky': []
+	'delete:nodes': [ids: string[]]
+	'update:nodes:enabled': [ids: string[]]
+	'copy:nodes': [ids: string[]]
+	'duplicate:nodes': [ids: string[]]
+	'cut:nodes': [ids: string[]]
+	'drag-and-drop': [position: XYPosition, event: DragEvent]
+	'delete:connection': [connection: Connection]
+	'create:connection:start': [handle: ConnectStartEvent]
+	'create:connection': [connection: Connection]
+	'create:connection:end': [connection: Connection, event?: MouseEvent]
+	'create:connection:cancelled': [
+		handle: ConnectStartEvent,
+		position: XYPosition,
+		event?: MouseEvent
+	]
+	'click:connection:add': [connection: Connection]
+	run: []
 }>()
 
 const props = withDefaults(
-    defineProps<{
-        id?: string
-        nodes: IWorkflow['nodes']
-        edges: IWorkflow['edges']
-        readOnly?: boolean
-    }>(),
-    {
-        id: 'canvas',
-        readOnly: false,
-        nodes: () => [],
-        edges: () => []
-    }
+	defineProps<{
+		id?: string
+		nodes: IWorkflow['nodes']
+		edges: IWorkflow['edges']
+		readOnly?: boolean
+	}>(),
+	{
+		id: 'canvas',
+		readOnly: false,
+		nodes: () => [],
+		edges: () => []
+	}
 )
 
 const vueFlow = useVueFlow(props.id)
@@ -74,103 +88,183 @@ const { viewport, viewportRef, project, zoomIn, zoomOut, fitView, zoomTo } = vue
  * Returns the position of a mouse or touch event
  */
 const getMousePosition = (event: MouseEvent | TouchEvent): XYPosition => {
-    const x = (event && 'clientX' in event ? event.clientX : event?.touches?.[0]?.clientX) ?? 0
-    const y = (event && 'clientY' in event ? event.clientY : event?.touches?.[0]?.clientY) ?? 0
+	const x = (event && 'clientX' in event ? event.clientX : event?.touches?.[0]?.clientX) ?? 0
+	const y = (event && 'clientY' in event ? event.clientY : event?.touches?.[0]?.clientY) ?? 0
 
-    return { x, y }
+	return { x, y }
 }
 
 function getProjectedPosition(event?: MouseEvent | TouchEvent) {
-    const bounds = viewportRef.value?.getBoundingClientRect() ?? { left: 0, top: 0 }
-    const { x, y } = event ? getMousePosition(event) : { x: 0, y: 0 }
+	const bounds = viewportRef.value?.getBoundingClientRect() ?? { left: 0, top: 0 }
+	const { x, y } = event ? getMousePosition(event) : { x: 0, y: 0 }
 
-    return project({
-        x: x - bounds.left,
-        y: y - bounds.top
-    })
+	return project({
+		x: x - bounds.left,
+		y: y - bounds.top
+	})
 }
 
+/**
+ * Events
+ */
+
 const onNodeClick = ({ node, event }: NodeMouseEvent) => {
-    emit('click:node', node.id, getProjectedPosition(event))
+	emit('click:node', node.id, getProjectedPosition(event))
 }
 
 function onDrop(event: DragEvent) {
-    const position = getProjectedPosition(event)
+	const position = getProjectedPosition(event)
 
-    emit('drag-and-drop', position, event)
+	emit('drag-and-drop', position, event)
 }
 
 const onZoomIn = () => {
-    zoomIn()
+	zoomIn()
 }
 
 const onZoomOut = () => {
-    zoomOut()
+	zoomOut()
 }
 
 const onZoomToFit = () => {
-    fitView()
+	fitView()
 }
 
 const onResetZoom = () => {
-    zoomTo(1)
+	zoomTo(1)
 }
 const onAddNode = (value: SourceType) => {
-    emit('create:node', value)
+	emit('create:node', value)
+}
+
+/**
+ * Connections / Edges
+ */
+
+const connectionCreated = ref(false)
+const connectingHandle = ref<ConnectStartEvent>()
+const connectedHandle = ref<Connection>()
+
+function onConnectStart(handle: ConnectStartEvent) {
+	emit('create:connection:start', handle)
+
+	connectingHandle.value = handle
+	connectionCreated.value = false
 }
+
+function onConnect(connection: Connection) {
+	emit('create:connection', connection)
+
+	connectedHandle.value = connection
+	connectionCreated.value = true
+}
+
+function onConnectEnd(event?: MouseEvent) {
+	if (connectedHandle.value) {
+		emit('create:connection:end', connectedHandle.value, event)
+	} else if (connectingHandle.value) {
+		emit('create:connection:cancelled', connectingHandle.value, getProjectedPosition(event), event)
+	}
+
+	connectedHandle.value = undefined
+	connectingHandle.value = undefined
+}
+
+function onDeleteConnection(connection: Connection) {
+	emit('delete:connection', connection)
+}
+
+function onClickConnectionAdd(connection: Connection) {
+	emit('click:connection:add', connection)
+}
+
+/**
+ * Handle
+ */
 const handleRun = () => {
-    emit('run')
+	emit('run')
 }
 console.log(props.nodes)
 </script>
 
 <template>
-    <VueFlow :id="id" :nodes="nodes" :edges="edges" :connection-line-options="{ markerEnd: MarkerType.ArrowClosed }"
-        :connection-radius="60" @node-click="onNodeClick" @drop="onDrop" v-bind="$attrs">
-        <template #node-canvas-node="nodeProps">
-            <CanvasNode v-bind="nodeProps" />
-        </template>
+	<VueFlow
+		:id="id"
+		:nodes="nodes"
+		:edges="edges"
+		:connection-line-options="{ markerEnd: MarkerType.ArrowClosed }"
+		:connection-radius="60"
+		@node-click="onNodeClick"
+		@drop="onDrop"
+		@connect="onConnect"
+		@connect-start="onConnectStart"
+		@connect-end="onConnectEnd"
+		v-bind="$attrs"
+	>
+		<template #node-canvas-node="nodeProps">
+			<CanvasNode v-bind="nodeProps" />
+		</template>
+
+		<template #node-start-node="nodeProps">
+			<StartNode v-bind="nodeProps" />
+		</template>
 
-        <template #node-start-node="nodeProps">
-            <StartNode v-bind="nodeProps" />
-        </template>
+		<template #node-end-node="nodeProps">
+			<EndNode v-bind="nodeProps" />
+		</template>
 
-        <template #node-end-node="nodeProps">
-            <EndNode v-bind="nodeProps" />
-        </template>
+		<template #node-http-node="nodeProps">
+			<HttpNode v-bind="nodeProps" />
+		</template>
 
-        <template #node-http-node="nodeProps">
-            <HttpNode v-bind="nodeProps" />
-        </template>
+		<template #node-code-node="nodeProps">
+			<CodeNode v-bind="nodeProps" />
+		</template>
 
-        <template #node-code-node="nodeProps">
-            <CodeNode v-bind="nodeProps" />
-        </template>
+		<template #node-database-node="nodeProps">
+			<DataBaseNode v-bind="nodeProps" />
+		</template>
 
-        <template #node-database-node="nodeProps">
-            <DataBaseNode v-bind="nodeProps" />
-        </template>
+		<template #node-condition-node="nodeProps">
+			<ConditionNode v-bind="nodeProps" />
+		</template>
 
-        <template #node-condition-node="nodeProps">
-            <ConditionNode v-bind="nodeProps" />
-        </template>
+		<template #edge-canvas-edge="edgeProps">
+			<CanvasEdge
+				v-bind="edgeProps"
+				marker-end="url(#custom-arrow-head-marker)"
+				@add="onClickConnectionAdd"
+				@delete="onDeleteConnection"
+			/>
+		</template>
 
-        <template #edge-canvas-edge="edgeProps">
-            <CanvasEdge v-bind="edgeProps" />
-        </template>
+		<template #background>
+			<rect width="100%" height="100%" fill="#f0f0f0" />
+		</template>
 
-        <template #background>
-            <rect width="100%" height="100%" fill="#f0f0f0" />
-        </template>
+		<MiniMap
+			:height="120"
+			:width="180"
+			:node-border-radius="16"
+			class="bg-white bottom-40px!"
+			position="bottom-left"
+			pannable
+			zoomable
+		/>
 
-        <MiniMap :height="120" :width="180" :node-border-radius="16" class="bg-white bottom-40px!"
-            position="bottom-left" pannable zoomable />
+		<CanvasControlBar
+			@zoom-in="onZoomIn"
+			@zoom-out="onZoomOut"
+			@zoom-to-fit="onZoomToFit"
+			@reset-zoom="onResetZoom"
+			@add-node="onAddNode"
+			@run="handleRun"
+		/>
 
-        <CanvasControlBar @zoom-in="onZoomIn" @zoom-out="onZoomOut" @zoom-to-fit="onZoomToFit" @reset-zoom="onResetZoom"
-            @add-node="onAddNode" @run="handleRun" />
+		<CanvasBackground :viewport="viewport" :striped="readOnly" />
 
-        <CanvasBackground :viewport="viewport" :striped="readOnly" />
-    </VueFlow>
+		<CanvasArrowHeadMarker id="custom-arrow-head-marker" />
+	</VueFlow>
 </template>
 
 <style>

+ 29 - 0
packages/workflow/src/components/elements/edges/CanvasArrowHeadMarker.vue

@@ -0,0 +1,29 @@
+<script lang="ts" setup>
+defineProps<{ id: string }>()
+</script>
+
+<template>
+	<svg>
+		<defs>
+			<marker
+				:id="id"
+				viewBox="-10 -10 20 20"
+				refX="0"
+				refY="0"
+				markerWidth="12.5"
+				markerHeight="12.5"
+				markerUnits="strokeWidth"
+				orient="auto-start-reverse"
+			>
+				<polyline
+					stroke-linecap="round"
+					stroke-linejoin="round"
+					points="-5,-4 0,0 -5,4 -5,-4"
+					stroke-width="2"
+					stroke="context-stroke"
+					fill="context-stroke"
+				/>
+			</marker>
+		</defs>
+	</svg>
+</template>

packages/workflow/src/components/elements/CanvasEdge.vue → packages/workflow/src/components/elements/edges/CanvasEdge.vue


+ 1 - 1
packages/workflow/src/components/elements/handles/CanvasHandle.vue

@@ -44,7 +44,7 @@ const isConnectableEnd = computed(() => {
 		:connectable-end="isConnectableEnd"
 		:is-valid-connection="isValidConnection"
 	>
-		<HandlePort :position="position" :type="type" />
+		<HandlePort :position="position" :type="type" :label="label" />
 	</Handle>
 </template>
 

+ 30 - 4
packages/workflow/src/components/elements/handles/HandlePort.vue

@@ -1,11 +1,15 @@
 <template>
-	<div :class="[position, type]" class="handlePort transition-transform duration-150"></div>
+	<div :class="[position, type]" class="renderType flex items-center">
+		<div :class="type" class="handlePort transition-transform duration-150 relative"></div>
+		<div v-if="label" class="ml-8px text-12px text-#666">{{ label }}</div>
+	</div>
 </template>
 
 <script setup lang="ts">
 defineProps<{
 	position: string
 	type: 'source' | 'target'
+	label?: string
 }>()
 </script>
 
@@ -19,9 +23,31 @@ defineProps<{
 	cursor: default;
 }
 
-.handlePort.source:hover {
-	transform: scale(1.5);
-	border-width: 1.5px;
+.source:hover {
+	transform: scale(1.2);
+	border-width: 1.2px;
 	cursor: crosshair;
 }
+
+.renderType {
+	&.top {
+		margin-bottom: -12px;
+		transform: translate(0%, -50%);
+	}
+
+	&.right {
+		margin-left: -12px;
+		transform: translate(50%, 0%);
+	}
+
+	&.left {
+		margin-right: -12px;
+		transform: translate(-50%, 0%);
+	}
+
+	&.bottom {
+		margin-top: -12px;
+		transform: translate(0%, 50%);
+	}
+}
 </style>

+ 2 - 2
packages/workflow/src/components/elements/CanvasNode.vue

@@ -6,10 +6,10 @@ import type {
 	IWorkflowNode,
 	CanvasConnectionPort,
 	CanvasElementPortWithRenderData
-} from '../../Interface'
+} from '../../../Interface'
 
 import { Icon } from '@repo/ui'
-import CanvasHandle from './handles/CanvasHandle.vue'
+import CanvasHandle from '../handles/CanvasHandle.vue'
 
 type Props = NodeProps<IWorkflowNode['data']> & {
 	readOnly?: boolean