Преглед изворни кода

feat: 添加画布节点功能

jiaxing.liao пре 1 недеља
родитељ
комит
ab96410720

+ 1 - 0
package.json

@@ -19,6 +19,7 @@
     "node": ">=18"
   },
   "dependencies": {
+    "@iconify/vue": "^5.0.0",
     "element-plus": "^2.13.1"
   }
 }

+ 1 - 0
packages/ui/README.md

@@ -0,0 +1 @@
+通用UI控件

+ 9 - 0
packages/ui/components/Icon.vue

@@ -0,0 +1,9 @@
+<template>
+	<Icon v-bind="props" />
+</template>
+
+<script setup lang="ts">
+import { Icon, type IconProps } from '@iconify/vue'
+
+const props = defineProps<IconProps>()
+</script>

+ 3 - 0
packages/ui/index.ts

@@ -0,0 +1,3 @@
+import Icon from './components/Icon.vue'
+
+export { Icon }

+ 9 - 0
packages/ui/package.json

@@ -0,0 +1,9 @@
+{
+  "name": "@repo/ui",
+  "version": "1.0.0",
+  "type": "module",
+  "private": true,
+  "exports": {
+    ".": "./index.ts"
+  }
+}

+ 2 - 0
packages/workflow/package.json

@@ -17,6 +17,7 @@
     "@vue-flow/controls": "^1.1.3",
     "@vue-flow/core": "^1.48.1",
     "@vue-flow/minimap": "^1.5.4",
+    "@vue-flow/node-resizer": "^1.5.0",
     "@vue-flow/node-toolbar": "^1.1.1",
     "less": "^4.5.1",
     "less-loader": "^12.3.0",
@@ -26,6 +27,7 @@
   },
   "devDependencies": {
     "@repo/typescript-config": "workspace:*",
+    "@repo/ui": "workspace:*",
     "@types/node": "^24.10.1",
     "@vitejs/plugin-vue": "^6.0.1",
     "@vue/tsconfig": "^0.8.1",

+ 41 - 2
packages/workflow/src/components/Canvas.vue

@@ -1,10 +1,12 @@
 <script lang="ts" setup>
-import { VueFlow, useVueFlow, type NodeMouseEvent } from '@vue-flow/core'
+import { VueFlow, useVueFlow, type NodeMouseEvent, MarkerType } from '@vue-flow/core'
+import { MiniMap } from '@vue-flow/minimap'
 import type { IWorkflow, XYPosition } from '../Interface'
 
 import CanvasNode from './elements/CanvasNode.vue'
 import CanvasEdge from './elements/CanvasEdge.vue'
 import CanvasBackground from './elements/background/CanvasBackground.vue'
+import CanvasControlBar from './elements/CanvasControlBar.vue'
 
 defineOptions({
 	name: 'workflow-canvas'
@@ -56,7 +58,9 @@ const props = withDefaults(
 	}
 )
 
-const { viewport, viewportRef, project } = useVueFlow()
+const vueFlow = useVueFlow(props.id)
+
+const { viewport, viewportRef, project, zoomIn, zoomOut, fitView, zoomTo } = vueFlow
 
 /**
  * Returns the position of a mouse or touch event
@@ -87,6 +91,22 @@ function onDrop(event: DragEvent) {
 
 	emit('drag-and-drop', position, event)
 }
+
+const onZoomIn = () => {
+	zoomIn()
+}
+
+const onZoomOut = () => {
+	zoomOut()
+}
+
+const onZoomToFit = () => {
+	fitView()
+}
+
+const onResetZoom = () => {
+	zoomTo(1)
+}
 </script>
 
 <template>
@@ -94,6 +114,8 @@ function onDrop(event: DragEvent) {
 		:id="id"
 		:nodes="nodes"
 		:edges="edges"
+		:connection-line-options="{ markerEnd: MarkerType.ArrowClosed }"
+		:connection-radius="60"
 		@node-click="onNodeClick"
 		@drop="onDrop"
 		v-bind="$attrs"
@@ -110,6 +132,23 @@ function onDrop(event: DragEvent) {
 			<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
+		/>
+
+		<CanvasControlBar
+			@zoom-in="onZoomIn"
+			@zoom-out="onZoomOut"
+			@zoom-to-fit="onZoomToFit"
+			@reset-zoom="onResetZoom"
+		/>
+
 		<CanvasBackground :viewport="viewport" :striped="readOnly" />
 	</VueFlow>
 </template>

+ 62 - 0
packages/workflow/src/components/elements/CanvasControlBar.vue

@@ -0,0 +1,62 @@
+<script setup lang="ts">
+import { Controls } from '@vue-flow/controls'
+import { Icon } from '@iconify/vue'
+import { ElButton } from 'element-plus'
+
+const emit = defineEmits<{
+	'reset-zoom': []
+	'zoom-in': []
+	'zoom-out': []
+	'zoom-to-fit': []
+	'tidy-up': []
+	'toggle-zoom-mode': []
+}>()
+
+function onResetZoom() {
+	emit('reset-zoom')
+}
+
+function onZoomIn() {
+	emit('zoom-in')
+}
+
+function onZoomOut() {
+	emit('zoom-out')
+}
+
+function onZoomToFit() {
+	emit('zoom-to-fit')
+}
+
+// function onTidyUp() {
+// 	emit('tidy-up')
+// }
+</script>
+
+<template>
+	<Controls :show-fit-view="false" :show-zoom="false" :show-interactive="false">
+		<div class="flex gap-0px">
+			<ElButton @click="onZoomToFit" square>
+				<Icon icon="lucide:maximize" height="16" width="16" />
+			</ElButton>
+			<ElButton @click="onZoomIn">
+				<Icon icon="lucide:zoom-in" height="16" width="16" />
+			</ElButton>
+			<ElButton @click="onZoomOut">
+				<Icon icon="lucide:zoom-out" height="16" width="16" />
+			</ElButton>
+			<ElButton @click="onResetZoom">
+				<Icon icon="lucide:undo-2" height="16" width="16" />
+			</ElButton>
+			<!-- <ElButton @click="onTidyUp">
+				<Icon icon="lucide:brush-cleaning" height="16" width="16" />
+			</ElButton> -->
+		</div>
+	</Controls>
+</template>
+
+<style lang="less">
+.el-button {
+	padding: 8px;
+}
+</style>

+ 46 - 16
packages/workflow/src/components/elements/CanvasEdge.vue

@@ -1,5 +1,46 @@
+<script setup lang="ts">
+import { ref, watch, computed } from 'vue'
+import { BaseEdge, EdgeLabelRenderer, getBezierPath, type EdgeProps } from '@vue-flow/core'
+import { Icon } from '@repo/ui'
+
+defineOptions({
+	inheritAttrs: false
+})
+
+type CanvasEdgeProps = EdgeProps & {
+	readOnly?: boolean
+	hovered?: boolean
+	bringToFront?: boolean // Determines if entire edges layer should be brought to front
+}
+
+const props = defineProps<CanvasEdgeProps>()
+const path = computed(() => getBezierPath(props))
+
+const delayedHovered = ref(props.hovered)
+const delayedHoveredSetTimeoutRef = ref<ReturnType<typeof setTimeout> | null>(null)
+const delayedHoveredTimeout = 600
+
+watch(
+	() => props.hovered,
+	(isHovered) => {
+		if (isHovered) {
+			if (delayedHoveredSetTimeoutRef.value) clearTimeout(delayedHoveredSetTimeoutRef.value)
+			delayedHovered.value = true
+		} else {
+			delayedHoveredSetTimeoutRef.value = setTimeout(() => {
+				delayedHovered.value = false
+			}, delayedHoveredTimeout)
+		}
+	},
+	{ immediate: true }
+)
+
+const renderToolbar = computed(() => delayedHovered.value && !props.readOnly)
+</script>
+
 <template>
-	<BaseEdge :path="path[0]" />
+	<BaseEdge :path="path[0]" :interaction-width="40" :marker-end="markerEnd" />
+
 	<EdgeLabelRenderer>
 		<div
 			:style="{
@@ -9,21 +50,10 @@
 			}"
 			class="nodrag nopan"
 		>
-			{{ data.label }}
+			<div v-if="renderToolbar" class="flex gap-4px">
+				<Icon icon="lucide:plus" width="14" height="14" color="#6e6f6f" />
+				<Icon icon="lucide:brush-cleaning" width="14" height="14" color="#6e6f6f" />
+			</div>
 		</div>
 	</EdgeLabelRenderer>
 </template>
-
-<script setup lang="ts">
-import { computed } from 'vue'
-import { BaseEdge, EdgeLabelRenderer, getBezierPath, type EdgeProps } from '@vue-flow/core'
-
-defineOptions({
-	inheritAttrs: false
-})
-
-const props = defineProps<EdgeProps>()
-const path = computed(() => getBezierPath(props))
-</script>
-
-<style scoped></style>

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

@@ -2,14 +2,26 @@
 import { Position } from '@vue-flow/core'
 import type { NodeProps } from '@vue-flow/core'
 
+import { Icon } from '@repo/ui'
 import CanvasHandle from './handles/CanvasHandle.vue'
 
 const props = defineProps<NodeProps>()
 </script>
 
 <template>
-	<div class="w-full h-full box-border border-2 border-solid border-black rounded-4px">
-		<div>{{ data.label }}</div>
+	<div
+		class="w-full h-full bg-#fff box-border border-2 border-solid border-#dcdcdc rounded-4px relative"
+	>
+		<div className="w-full h-full relative flex items-center justify-center">
+			<Icon icon="lucide:zoom-out" height="40" width="40" color="#00bb88" />
+		</div>
+
+		<div className="absolute w-full bottom--22px text-12px text-center text-#222">
+			{{ data.label }}
+		</div>
+		<div className="absolute w-full bottom--38px text-12px text-center text-#999 truncate">
+			{{ data.subtitle }}
+		</div>
 
 		<CanvasHandle handle-id="1" handle-type="source" :position="Position.Right" />
 		<CanvasHandle handle-id="2" handle-type="target" :position="Position.Left" />

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

@@ -1,5 +1,6 @@
 <script lang="ts" setup>
 import { Handle, type Position, type ValidConnectionFunc } from '@vue-flow/core'
+import HandlePort from './HandlePort.vue'
 
 defineProps<{
 	handleId: string
@@ -17,7 +18,7 @@ defineProps<{
 	<Handle
 		v-bind="$attrs"
 		:id="handleId"
-		:class="handleClasses"
+		:class="$style.handle"
 		:type="handleType"
 		:position="position"
 		:style="offset"
@@ -25,6 +26,51 @@ defineProps<{
 		:connectable-end="isConnectableEnd"
 		:is-valid-connection="isValidConnection"
 	>
-		<div class="w-6px h-6px rounded box-border border-2 border-solid border-black"></div>
+		<HandlePort :position="position" :type="handleType" />
 	</Handle>
 </template>
+
+<style lang="less" module>
+:global(.vue-flow__handle).handle {
+	/* stylelint-disable-next-line @n8n/css-var-naming */
+	--handle--indicator--width: 16px;
+	/* stylelint-disable-next-line @n8n/css-var-naming */
+	--handle--indicator--height: 16px;
+
+	width: 16px;
+	height: 16px;
+	display: inline-flex;
+	justify-content: center;
+	align-items: center;
+	border: 0;
+	z-index: 1;
+	background: transparent;
+	border-radius: 0;
+
+	&.inputs.main {
+		cursor: default;
+	}
+}
+
+.renderType {
+	&.top {
+		margin-bottom: -16px;
+		transform: translate(0%, -50%);
+	}
+
+	&.right {
+		margin-left: -16px;
+		transform: translate(50%, 0%);
+	}
+
+	&.left {
+		margin-right: -16px;
+		transform: translate(-50%, 0%);
+	}
+
+	&.bottom {
+		margin-top: -16px;
+		transform: translate(0%, 50%);
+	}
+}
+</style>

+ 27 - 0
packages/workflow/src/components/elements/handles/HandlePort.vue

@@ -0,0 +1,27 @@
+<template>
+	<div :class="[position, type]" class="handlePort transition-transform duration-150"></div>
+</template>
+
+<script setup lang="ts">
+defineProps<{
+	position: string
+	type: 'source' | 'target'
+}>()
+</script>
+
+<style lang="less" scoped>
+.handlePort {
+	width: 12px;
+	height: 12px;
+	background-color: #fff;
+	border: 1px solid #989898;
+	border-radius: 50%;
+	cursor: default;
+}
+
+.handlePort.source:hover {
+	transform: scale(1.5);
+	border-width: 1.5px;
+	cursor: crosshair;
+}
+</style>

+ 34 - 0
pnpm-lock.yaml

@@ -8,6 +8,9 @@ importers:
 
   .:
     dependencies:
+      '@iconify/vue':
+        specifier: ^5.0.0
+        version: 5.0.0(vue@3.5.27(typescript@5.9.2))
       element-plus:
         specifier: ^2.13.1
         version: 2.13.1(vue@3.5.27(typescript@5.9.2))
@@ -239,6 +242,8 @@ importers:
 
   packages/typescript-config: {}
 
+  packages/ui: {}
+
   packages/workflow:
     dependencies:
       '@antv/x6':
@@ -256,6 +261,9 @@ importers:
       '@vue-flow/minimap':
         specifier: ^1.5.4
         version: 1.5.4(@vue-flow/core@1.48.1(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))
+      '@vue-flow/node-resizer':
+        specifier: ^1.5.0
+        version: 1.5.0(@vue-flow/core@1.48.1(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))
       '@vue-flow/node-toolbar':
         specifier: ^1.1.1
         version: 1.1.1(@vue-flow/core@1.48.1(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))
@@ -278,6 +286,9 @@ importers:
       '@repo/typescript-config':
         specifier: workspace:*
         version: link:../typescript-config
+      '@repo/ui':
+        specifier: workspace:*
+        version: link:../ui
       '@types/node':
         specifier: ^24.10.1
         version: 24.10.9
@@ -1679,6 +1690,11 @@ packages:
   '@iconify/utils@3.1.0':
     resolution: {integrity: sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw==}
 
+  '@iconify/vue@5.0.0':
+    resolution: {integrity: sha512-C+KuEWIF5nSBrobFJhT//JS87OZ++QDORB6f2q2Wm6fl2mueSTpFBeBsveK0KW9hWiZ4mNiPjsh6Zs4jjdROSg==}
+    peerDependencies:
+      vue: '>=3'
+
   '@inversifyjs/common@1.4.0':
     resolution: {integrity: sha512-qfRJ/3iOlCL/VfJq8+4o5X4oA14cZSBbpAmHsYj8EsIit1xDndoOl0xKOyglKtQD4u4gdNVxMHx4RWARk/I4QA==}
 
@@ -2542,6 +2558,12 @@ packages:
       '@vue-flow/core': ^1.23.0
       vue: ^3.3.0
 
+  '@vue-flow/node-resizer@1.5.0':
+    resolution: {integrity: sha512-FmvOZ6+yVrBEf+8oJcCU20PUZ105QsyM01iiP4vTKHGJ01hzoh9d0/wP9iJkxkIpvBU59CyOHyTKQZlDr4qDhA==}
+    peerDependencies:
+      '@vue-flow/core': ^1.23.0
+      vue: ^3.3.0
+
   '@vue-flow/node-toolbar@1.1.1':
     resolution: {integrity: sha512-vgSnGMLd3FXstdpvC721qcBDzGkPsudmyA8urEP55/EMikCp59klgzPvVULTDxxsew5MdXtTBCumsqOi+PXJdg==}
     peerDependencies:
@@ -7779,6 +7801,11 @@ snapshots:
       '@iconify/types': 2.0.0
       mlly: 1.8.0
 
+  '@iconify/vue@5.0.0(vue@3.5.27(typescript@5.9.2))':
+    dependencies:
+      '@iconify/types': 2.0.0
+      vue: 3.5.27(typescript@5.9.2)
+
   '@inversifyjs/common@1.4.0': {}
 
   '@inversifyjs/core@1.3.5(reflect-metadata@0.2.2)':
@@ -8856,6 +8883,13 @@ snapshots:
       d3-zoom: 3.0.0
       vue: 3.5.27(typescript@5.9.3)
 
+  '@vue-flow/node-resizer@1.5.0(@vue-flow/core@1.48.1(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))':
+    dependencies:
+      '@vue-flow/core': 1.48.1(vue@3.5.27(typescript@5.9.3))
+      d3-drag: 3.0.0
+      d3-selection: 3.0.0
+      vue: 3.5.27(typescript@5.9.3)
+
   '@vue-flow/node-toolbar@1.1.1(@vue-flow/core@1.48.1(vue@3.5.27(typescript@5.9.3)))(vue@3.5.27(typescript@5.9.3))':
     dependencies:
       '@vue-flow/core': 1.48.1(vue@3.5.27(typescript@5.9.3))