@michael_home/workflow-engine-vue 1.0.4 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,759 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
3
+ import {
4
+ PortDirection,
5
+ buildPreviewRoute,
6
+ getPortPoint,
7
+ resolveLinkCompletion,
8
+ resolveLinkSource,
9
+ resolveNodeDragPosition,
10
+ shouldActivateLinkDrag
11
+ } from '@michael_home/workflow-engine-core'
12
+ import type { GraphEdge, GraphNode, Point, Port } from '@michael_home/workflow-engine-core'
13
+ import { renderSvgEdge } from '@michael_home/workflow-engine-svg-renderer'
14
+
15
+ const props = withDefaults(
16
+ defineProps<{
17
+ nodes: GraphNode<unknown>[]
18
+ edges: GraphEdge<unknown>[]
19
+ selectedNodeId?: string
20
+ selectedEdgeId?: string
21
+ width?: number
22
+ height?: number
23
+ panX?: number
24
+ panY?: number
25
+ zoom?: number
26
+ snapToGrid?: boolean
27
+ gridSize?: number
28
+ alignThreshold?: number
29
+ portHitRadius?: number
30
+ portDragThreshold?: number
31
+ }>(),
32
+ {
33
+ panX: 0,
34
+ panY: 0,
35
+ zoom: 1,
36
+ snapToGrid: true,
37
+ gridSize: 10,
38
+ alignThreshold: 10,
39
+ portHitRadius: 14,
40
+ portDragThreshold: 4
41
+ }
42
+ )
43
+
44
+ const emit = defineEmits<{
45
+ selectionChange: [selection: { nodeId?: string; edgeId?: string }]
46
+ nodePositionChange: [payload: { nodeId: string; position: Point }]
47
+ edgeCreate: [payload: { sourceNodeId: string; sourcePortId: string; targetNodeId: string; targetPortId: string }]
48
+ edgeDelete: [payload: { edgeId: string }]
49
+ nodeDelete: [payload: { nodeId: string }]
50
+ }>()
51
+
52
+ const zoomLevel = ref(props.zoom)
53
+
54
+ const clampZoom = (value: number) => Math.min(2, Math.max(0.4, Number(value.toFixed(2))))
55
+
56
+ const zoomIn = () => {
57
+ zoomLevel.value = clampZoom(zoomLevel.value + 0.1)
58
+ }
59
+
60
+ const zoomOut = () => {
61
+ zoomLevel.value = clampZoom(zoomLevel.value - 0.1)
62
+ }
63
+
64
+ watch(
65
+ () => props.zoom,
66
+ (value) => {
67
+ zoomLevel.value = clampZoom(value)
68
+ }
69
+ )
70
+
71
+ const viewportTransform = computed(
72
+ () => `translate(${props.panX} ${props.panY}) scale(${zoomLevel.value})`
73
+ )
74
+
75
+ const containerRef = ref<HTMLElement>()
76
+ const svgRef = ref<SVGSVGElement>()
77
+ const sizeState = reactive({ width: 700, height: 240 })
78
+ let resizeObserver: ResizeObserver | undefined
79
+
80
+ const canvasWidth = computed(() => props.width ?? sizeState.width)
81
+ const canvasHeight = computed(() => props.height ?? sizeState.height)
82
+
83
+ const dragState = reactive({
84
+ active: false,
85
+ nodeId: '',
86
+ offsetX: 0,
87
+ offsetY: 0,
88
+ latestClientX: 0,
89
+ latestClientY: 0
90
+ })
91
+
92
+ const linkState = reactive({
93
+ active: false,
94
+ sourceNodeId: '',
95
+ sourcePortId: '',
96
+ pendingClick: false,
97
+ pressedClientX: 0,
98
+ pressedClientY: 0,
99
+ currentX: 0,
100
+ currentY: 0
101
+ })
102
+
103
+ const frameState = reactive({ rafId: 0, scheduled: false })
104
+
105
+ const resolveEdgeLabel = (edge: GraphEdge<unknown>): string => {
106
+ const data = edge.data as { text?: string } | undefined
107
+ return data?.text ?? ''
108
+ }
109
+
110
+ const resolveEdgeLineColor = (edge: GraphEdge<unknown>): string => {
111
+ const data = edge.data as { lineColor?: string } | undefined
112
+ return data?.lineColor ?? '#1c7ed6'
113
+ }
114
+
115
+ const resolveEdgeFontColor = (edge: GraphEdge<unknown>): string => {
116
+ const data = edge.data as { fontColor?: string } | undefined
117
+ return data?.fontColor ?? '#0f172a'
118
+ }
119
+
120
+ const resolveEdgeLineDasharray = (edge: GraphEdge<unknown>): string | undefined => {
121
+ const data = edge.data as { lineStyle?: 'solid' | 'dashed' } | undefined
122
+ return data?.lineStyle === 'dashed' ? '6 4' : undefined
123
+ }
124
+
125
+ const edgeRuntimeStatusOf = (edge: GraphEdge<unknown>): 'idle' | 'pending' | 'completed' | 'disabled' => {
126
+ const data = edge.data as { runtimeStatus?: 'idle' | 'pending' | 'completed' | 'disabled' } | undefined
127
+ return data?.runtimeStatus ?? 'idle'
128
+ }
129
+
130
+ const isFlowingEdge = (edge: GraphEdge<unknown>): boolean => edgeRuntimeStatusOf(edge) === 'pending'
131
+
132
+ const getEdgeLabelPoint = (points: Point[]): Point | undefined => {
133
+ if (points.length < 2) return undefined
134
+
135
+ const segments: Array<{ start: Point; end: Point; length: number }> = []
136
+ let totalLength = 0
137
+
138
+ for (let i = 1; i < points.length; i += 1) {
139
+ const start = points[i - 1]
140
+ const end = points[i]
141
+ const length = Math.hypot(end.x - start.x, end.y - start.y)
142
+ if (length === 0) continue
143
+ segments.push({ start, end, length })
144
+ totalLength += length
145
+ }
146
+
147
+ if (segments.length === 0 || totalLength === 0) return undefined
148
+
149
+ const half = totalLength / 2
150
+ let acc = 0
151
+
152
+ for (const segment of segments) {
153
+ if (acc + segment.length >= half) {
154
+ const ratio = (half - acc) / segment.length
155
+ return {
156
+ x: segment.start.x + (segment.end.x - segment.start.x) * ratio,
157
+ y: segment.start.y + (segment.end.y - segment.start.y) * ratio
158
+ }
159
+ }
160
+ acc += segment.length
161
+ }
162
+
163
+ const last = segments[segments.length - 1]
164
+ return { x: last.end.x, y: last.end.y }
165
+ }
166
+
167
+ const renderedEdges = computed(() =>
168
+ props.edges.map((edge) => {
169
+ const points: Point[] = edge.points ?? []
170
+ const render = renderSvgEdge(points, { pixelSnap: true })
171
+ return {
172
+ edge,
173
+ edgeId: edge.id,
174
+ path: render.path,
175
+ arrow: render.arrow,
176
+ label: resolveEdgeLabel(edge),
177
+ lineColor: resolveEdgeLineColor(edge),
178
+ lineDasharray: resolveEdgeLineDasharray(edge),
179
+ labelColor: resolveEdgeFontColor(edge),
180
+ labelPoint: getEdgeLabelPoint(points),
181
+ flowing: isFlowingEdge(edge)
182
+ }
183
+ })
184
+ )
185
+
186
+ const titleOf = (node: GraphNode<unknown>): string => {
187
+ const data = node.data as { title?: string } | undefined
188
+ return data?.title ?? node.id
189
+ }
190
+
191
+ const iconOf = (node: GraphNode<unknown>): string => {
192
+ const data = node.data as { icon?: string } | undefined
193
+ return data?.icon ?? ''
194
+ }
195
+
196
+ const iconSymbolHrefOf = (node: GraphNode<unknown>): string => {
197
+ const icon = iconOf(node)
198
+ if (!icon || !icon.startsWith('icon-')) return ''
199
+ return `#${icon}`
200
+ }
201
+
202
+ const resolveNodeFillColor = (node: GraphNode<unknown>): string => {
203
+ const data = node.data as
204
+ | { nodeColor?: string; runtimeStatus?: 'idle' | 'active' | 'completed'; completedFillColor?: string }
205
+ | undefined
206
+ if (data?.runtimeStatus === 'completed') {
207
+ return data.completedFillColor ?? '#bbf7d0'
208
+ }
209
+ if (data?.runtimeStatus === 'active') {
210
+ return '#dcfce7'
211
+ }
212
+ return data?.nodeColor ?? '#ffffff'
213
+ }
214
+
215
+ const resolveNodeFontColor = (node: GraphNode<unknown>): string => {
216
+ const data = node.data as { fontColor?: string } | undefined
217
+ return data?.fontColor ?? '#0f172a'
218
+ }
219
+
220
+ const resolveNodeShape = (node: GraphNode<unknown>): 'rect' | 'ellipse' | 'diamond' => {
221
+ const data = node.data as { nodeShape?: 'rect' | 'ellipse' | 'diamond' } | undefined
222
+ return data?.nodeShape ?? 'rect'
223
+ }
224
+
225
+ const diamondPointsOf = (node: GraphNode<unknown>): string => {
226
+ const halfW = node.size.width / 2
227
+ const halfH = node.size.height / 2
228
+ return `0,${-halfH} ${halfW},0 0,${halfH} ${-halfW},0`
229
+ }
230
+
231
+ const portsOf = (node: GraphNode<unknown>) => {
232
+ const byDirection = new Map(node.ports.map((port) => [port.direction, port]))
233
+
234
+ return [
235
+ { direction: PortDirection.Top, dx: 0, dy: -node.size.height / 2 },
236
+ { direction: PortDirection.Right, dx: node.size.width / 2, dy: 0 },
237
+ { direction: PortDirection.Bottom, dx: 0, dy: node.size.height / 2 },
238
+ { direction: PortDirection.Left, dx: -node.size.width / 2, dy: 0 }
239
+ ]
240
+ .map((entry) => ({
241
+ ...entry,
242
+ port: byDirection.get(entry.direction)
243
+ }))
244
+ .filter((entry): entry is typeof entry & { port: Port } => Boolean(entry.port))
245
+ }
246
+
247
+ const toCanvasPoint = (event: MouseEvent): Point => {
248
+ const svg = svgRef.value
249
+ if (!svg) {
250
+ return { x: event.clientX, y: event.clientY }
251
+ }
252
+ const rect = svg.getBoundingClientRect()
253
+ return {
254
+ x: event.clientX - rect.left,
255
+ y: event.clientY - rect.top
256
+ }
257
+ }
258
+
259
+ const toViewportPoint = (point: Point): Point => ({
260
+ x: (point.x - props.panX) / zoomLevel.value,
261
+ y: (point.y - props.panY) / zoomLevel.value
262
+ })
263
+
264
+ const applyDragFrame = () => {
265
+ frameState.scheduled = false
266
+
267
+ if (!dragState.active) return
268
+ const currentNode = props.nodes.find((node) => node.id === dragState.nodeId)
269
+ if (!currentNode) return
270
+
271
+ const position = resolveNodeDragPosition({
272
+ node: currentNode,
273
+ nodes: props.nodes,
274
+ alignThreshold: props.alignThreshold,
275
+ clientX: dragState.latestClientX,
276
+ clientY: dragState.latestClientY,
277
+ offsetX: dragState.offsetX,
278
+ offsetY: dragState.offsetY,
279
+ canvasWidth: canvasWidth.value / zoomLevel.value,
280
+ canvasHeight: canvasHeight.value / zoomLevel.value,
281
+ gridSize: props.gridSize,
282
+ snapToGrid: props.snapToGrid
283
+ })
284
+
285
+ emit('nodePositionChange', { nodeId: dragState.nodeId, position })
286
+ }
287
+
288
+ const resetLinkState = () => {
289
+ linkState.active = false
290
+ linkState.pendingClick = false
291
+ linkState.sourceNodeId = ''
292
+ linkState.sourcePortId = ''
293
+ }
294
+
295
+ const stopDrag = () => {
296
+ dragState.active = false
297
+ dragState.nodeId = ''
298
+
299
+ if (frameState.rafId) {
300
+ window.cancelAnimationFrame(frameState.rafId)
301
+ frameState.rafId = 0
302
+ }
303
+ frameState.scheduled = false
304
+ }
305
+
306
+ const stopLink = () => {
307
+ if (!linkState.active && !linkState.pendingClick) return
308
+
309
+ const source = resolveLinkSource({
310
+ sourceNodeId: linkState.sourceNodeId,
311
+ sourcePortId: linkState.sourcePortId,
312
+ nodes: props.nodes
313
+ })
314
+
315
+ if (source) {
316
+ const completion = resolveLinkCompletion({
317
+ sourceNodeId: source.sourceNode.id,
318
+ sourcePortId: source.sourcePort.id,
319
+ sourceNode: source.sourceNode,
320
+ sourcePort: source.sourcePort,
321
+ nodes: props.nodes,
322
+ mode: linkState.pendingClick ? 'click' : 'drag',
323
+ currentPoint: { x: linkState.currentX, y: linkState.currentY },
324
+ hitRadius: props.portHitRadius
325
+ })
326
+
327
+ if (completion.targetNodeId && completion.targetPortId) {
328
+ emit('edgeCreate', {
329
+ sourceNodeId: completion.sourceNodeId,
330
+ sourcePortId: completion.sourcePortId,
331
+ targetNodeId: completion.targetNodeId,
332
+ targetPortId: completion.targetPortId
333
+ })
334
+ }
335
+ }
336
+
337
+ resetLinkState()
338
+ }
339
+
340
+ const previewPoints = computed(() => {
341
+ if (!linkState.active) return []
342
+
343
+ const source = resolveLinkSource({
344
+ sourceNodeId: linkState.sourceNodeId,
345
+ sourcePortId: linkState.sourcePortId,
346
+ nodes: props.nodes
347
+ })
348
+ if (!source) return []
349
+
350
+ return buildPreviewRoute({
351
+ sourceNode: source.sourceNode,
352
+ sourcePort: source.sourcePort,
353
+ nodes: props.nodes,
354
+ currentPoint: { x: linkState.currentX, y: linkState.currentY },
355
+ hitRadius: props.portHitRadius
356
+ }).points
357
+ })
358
+
359
+ const renderedPreview = computed(() => renderSvgEdge(previewPoints.value, { pixelSnap: true }))
360
+
361
+ const onNodeMousedown = (node: GraphNode<unknown>, event: MouseEvent) => {
362
+ if (linkState.active) return
363
+
364
+ event.preventDefault()
365
+ emit('selectionChange', { nodeId: node.id })
366
+ dragState.active = true
367
+ dragState.nodeId = node.id
368
+
369
+ const viewportPoint = toViewportPoint(toCanvasPoint(event))
370
+ dragState.offsetX = viewportPoint.x - node.position.x
371
+ dragState.offsetY = viewportPoint.y - node.position.y
372
+ dragState.latestClientX = viewportPoint.x
373
+ dragState.latestClientY = viewportPoint.y
374
+ }
375
+
376
+ const onPortMousedown = (node: GraphNode<unknown>, port: Port, event: MouseEvent) => {
377
+ event.preventDefault()
378
+ emit('selectionChange', { nodeId: node.id })
379
+
380
+ const start = getPortPoint(node, port)
381
+ linkState.active = false
382
+ linkState.pendingClick = true
383
+ linkState.pressedClientX = event.clientX
384
+ linkState.pressedClientY = event.clientY
385
+ linkState.sourceNodeId = node.id
386
+ linkState.sourcePortId = port.id
387
+ linkState.currentX = start.x
388
+ linkState.currentY = start.y
389
+ }
390
+
391
+ const onEdgeMousedown = (edge: GraphEdge<unknown>, event: MouseEvent) => {
392
+ event.preventDefault()
393
+ event.stopPropagation()
394
+ emit('selectionChange', { edgeId: edge.id })
395
+ }
396
+
397
+ const onCanvasMousedown = () => {
398
+ if (linkState.active || dragState.active) return
399
+ emit('selectionChange', {})
400
+ }
401
+
402
+ const onWindowMouseMove = (event: MouseEvent) => {
403
+ const canvasPoint = toCanvasPoint(event)
404
+
405
+ if (linkState.pendingClick && !linkState.active) {
406
+ const shouldActivate = shouldActivateLinkDrag({
407
+ pressedClientX: linkState.pressedClientX,
408
+ pressedClientY: linkState.pressedClientY,
409
+ currentClientX: event.clientX,
410
+ currentClientY: event.clientY,
411
+ threshold: props.portDragThreshold
412
+ })
413
+
414
+ if (shouldActivate) {
415
+ linkState.active = true
416
+ linkState.pendingClick = false
417
+ }
418
+ }
419
+
420
+ if (linkState.active) {
421
+ const viewportPoint = toViewportPoint(canvasPoint)
422
+ linkState.currentX = viewportPoint.x
423
+ linkState.currentY = viewportPoint.y
424
+ return
425
+ }
426
+
427
+ if (!dragState.active) return
428
+ const viewportPoint = toViewportPoint(canvasPoint)
429
+ dragState.latestClientX = viewportPoint.x
430
+ dragState.latestClientY = viewportPoint.y
431
+
432
+ if (frameState.scheduled) return
433
+ frameState.scheduled = true
434
+ frameState.rafId = window.requestAnimationFrame(applyDragFrame)
435
+ }
436
+
437
+ const onWindowMouseUp = () => {
438
+ stopDrag()
439
+ stopLink()
440
+ }
441
+
442
+ const onWindowKeyDown = (event: KeyboardEvent) => {
443
+ if (event.key !== 'Delete' && event.key !== 'Backspace') return
444
+
445
+ const target = event.target as HTMLElement | null
446
+ if (target) {
447
+ const tagName = target.tagName
448
+ if (tagName === 'INPUT' || tagName === 'TEXTAREA' || target.isContentEditable) {
449
+ return
450
+ }
451
+ }
452
+
453
+ if (props.selectedEdgeId) {
454
+ event.preventDefault()
455
+ emit('edgeDelete', { edgeId: props.selectedEdgeId })
456
+ return
457
+ }
458
+
459
+ if (props.selectedNodeId) {
460
+ event.preventDefault()
461
+ emit('nodeDelete', { nodeId: props.selectedNodeId })
462
+ }
463
+ }
464
+
465
+ onMounted(() => {
466
+ const updateSize = () => {
467
+ const host = containerRef.value
468
+ if (!host) return
469
+ const rect = host.getBoundingClientRect()
470
+ if (rect.width > 0) sizeState.width = rect.width
471
+ if (rect.height > 0) sizeState.height = rect.height
472
+ }
473
+
474
+ updateSize()
475
+ resizeObserver = new ResizeObserver(updateSize)
476
+ if (containerRef.value) resizeObserver.observe(containerRef.value)
477
+
478
+ window.addEventListener('mousemove', onWindowMouseMove)
479
+ window.addEventListener('mouseup', onWindowMouseUp)
480
+ window.addEventListener('keydown', onWindowKeyDown)
481
+ })
482
+
483
+ onUnmounted(() => {
484
+ stopDrag()
485
+ resizeObserver?.disconnect()
486
+ window.removeEventListener('mousemove', onWindowMouseMove)
487
+ window.removeEventListener('mouseup', onWindowMouseUp)
488
+ window.removeEventListener('keydown', onWindowKeyDown)
489
+ })
490
+ </script>
491
+
492
+ <template>
493
+ <section ref="containerRef" class="canvas-view" :style="{ width: '100%', height: '100%' }">
494
+ <div class="zoom-controls">
495
+ <button type="button" class="zoom-btn" @click="zoomOut">-</button>
496
+ <span class="zoom-text">{{ Math.round(zoomLevel * 100) }}%</span>
497
+ <button type="button" class="zoom-btn" @click="zoomIn">+</button>
498
+ </div>
499
+ <svg
500
+ ref="svgRef"
501
+ class="canvas-svg"
502
+ xmlns="http://www.w3.org/2000/svg"
503
+ :viewBox="`0 0 ${canvasWidth} ${canvasHeight}`"
504
+ @mousedown.self="onCanvasMousedown"
505
+ >
506
+ <g :transform="viewportTransform">
507
+ <g class="edge-layer">
508
+ <template v-for="item in renderedEdges" :key="item.edgeId">
509
+ <path
510
+ :d="item.path"
511
+ fill="none"
512
+ stroke="transparent"
513
+ stroke-width="15"
514
+ vector-effect="non-scaling-stroke"
515
+ class="edge-hit"
516
+ @mousedown.stop="(event: any) => onEdgeMousedown(item.edge, event)"
517
+ />
518
+ <path
519
+ :d="item.path"
520
+ :stroke="selectedEdgeId === item.edgeId ? '#2563eb' : item.lineColor"
521
+ stroke-width="1"
522
+ fill="none"
523
+ stroke-linecap="round"
524
+ stroke-linejoin="round"
525
+ vector-effect="non-scaling-stroke"
526
+ shape-rendering="geometricPrecision"
527
+ class="edge-path"
528
+ :class="{ 'edge-flowing': item.flowing }"
529
+ :stroke-dasharray="item.lineDasharray ?? (item.flowing ? '7 6' : undefined)"
530
+ @mousedown.stop="(event: any) => onEdgeMousedown(item.edge, event)"
531
+ />
532
+ <polygon
533
+ :points="item.arrow"
534
+ :fill="selectedEdgeId === item.edgeId ? '#2563eb' : item.lineColor"
535
+ :stroke="selectedEdgeId === item.edgeId ? '#2563eb' : item.lineColor"
536
+ vector-effect="non-scaling-stroke"
537
+ shape-rendering="geometricPrecision"
538
+ class="edge-arrow"
539
+ @mousedown.stop="(event: any) => onEdgeMousedown(item.edge, event)"
540
+ />
541
+ <text
542
+ v-if="item.label && item.labelPoint"
543
+ :x="item.labelPoint.x"
544
+ :y="item.labelPoint.y"
545
+ class="edge-label"
546
+ :fill="item.labelColor"
547
+ text-anchor="middle"
548
+ dominant-baseline="central"
549
+ >
550
+ {{ item.label }}
551
+ </text>
552
+ </template>
553
+
554
+ <template v-if="previewPoints.length > 1">
555
+ <path
556
+ :d="renderedPreview.path"
557
+ stroke="#94a3b8"
558
+ stroke-width="2"
559
+ fill="none"
560
+ stroke-linecap="round"
561
+ stroke-linejoin="round"
562
+ vector-effect="non-scaling-stroke"
563
+ shape-rendering="geometricPrecision"
564
+ />
565
+ <polygon
566
+ :points="renderedPreview.arrow"
567
+ fill="#94a3b8"
568
+ stroke="#94a3b8"
569
+ vector-effect="non-scaling-stroke"
570
+ shape-rendering="geometricPrecision"
571
+ />
572
+ </template>
573
+ </g>
574
+
575
+ <g class="node-layer">
576
+ <g
577
+ v-for="node in nodes"
578
+ :key="node.id"
579
+ class="node-group"
580
+ :transform="`translate(${node.position.x + node.size.width / 2} ${node.position.y + node.size.height / 2})`"
581
+ >
582
+ <rect
583
+ v-if="resolveNodeShape(node) === 'rect'"
584
+ :x="-node.size.width / 2"
585
+ :y="-node.size.height / 2"
586
+ :width="node.size.width"
587
+ :height="node.size.height"
588
+ rx="4"
589
+ ry="4"
590
+ :fill="resolveNodeFillColor(node)"
591
+ stroke="#0f172a"
592
+ :class="{ selected: selectedNodeId === node.id }"
593
+ @mousedown.stop="(event: any) => onNodeMousedown(node, event)"
594
+ />
595
+
596
+ <rect
597
+ v-else-if="resolveNodeShape(node) === 'ellipse'"
598
+ :x="-node.size.width / 2"
599
+ :y="-node.size.height / 2"
600
+ :width="node.size.width"
601
+ :height="node.size.height"
602
+ :rx="node.size.height / 2"
603
+ :ry="node.size.height / 2"
604
+ :fill="resolveNodeFillColor(node)"
605
+ stroke="#0f172a"
606
+ :class="{ selected: selectedNodeId === node.id }"
607
+ @mousedown.stop="(event: any) => onNodeMousedown(node, event)"
608
+ />
609
+
610
+ <polygon
611
+ v-else
612
+ :points="diamondPointsOf(node)"
613
+ :fill="resolveNodeFillColor(node)"
614
+ stroke="#0f172a"
615
+ :class="{ selected: selectedNodeId === node.id }"
616
+ @mousedown.stop="(event: any) => onNodeMousedown(node, event)"
617
+ />
618
+
619
+ <foreignObject
620
+ :x="-node.size.width / 2"
621
+ :y="-node.size.height / 2"
622
+ :width="node.size.width"
623
+ :height="node.size.height"
624
+ class="node-title-foreign"
625
+ >
626
+ <div xmlns="http://www.w3.org/1999/xhtml" class="node-title-dom" :style="{ color: resolveNodeFontColor(node) }">
627
+ <svg v-if="iconSymbolHrefOf(node)" width="14" height="14" class="node-icon-dom" aria-hidden="true">
628
+ <use :href="iconSymbolHrefOf(node)" :fill="resolveNodeFontColor(node)" />
629
+ </svg>
630
+ <span v-else-if="iconOf(node)" class="node-icon-fallback">{{ iconOf(node) }}</span>
631
+ <span class="node-title-text">{{ titleOf(node) }}</span>
632
+ </div>
633
+ </foreignObject>
634
+
635
+ <circle
636
+ v-for="item in portsOf(node)"
637
+ v-show="selectedNodeId === node.id"
638
+ :key="item.port.id"
639
+ :cx="item.dx"
640
+ :cy="item.dy"
641
+ r="5"
642
+ fill="#ffffff"
643
+ stroke="#1d4ed8"
644
+ class="port-dot"
645
+ @mousedown.stop="(event: any) => onPortMousedown(node, item.port, event)"
646
+ />
647
+ </g>
648
+ </g>
649
+ </g>
650
+ </svg>
651
+ </section>
652
+ </template>
653
+
654
+ <style scoped>
655
+ .canvas-view {
656
+ position: relative;
657
+ overflow: hidden;
658
+ border: 1px solid #dee2e6;
659
+ background: #f8f9fa;
660
+ }
661
+
662
+ .zoom-controls {
663
+ position: absolute;
664
+ top: 8px;
665
+ right: 8px;
666
+ z-index: 2;
667
+ display: inline-flex;
668
+ align-items: center;
669
+ gap: 4px;
670
+ background: rgba(255, 255, 255, 0.95);
671
+ border: 1px solid #cbd5e1;
672
+ border-radius: 6px;
673
+ padding: 4px 6px;
674
+ }
675
+
676
+ .zoom-btn {
677
+ width: 24px;
678
+ height: 24px;
679
+ border: 1px solid #cbd5e1;
680
+ border-radius: 4px;
681
+ background: #f8fafc;
682
+ cursor: pointer;
683
+ line-height: 1;
684
+ }
685
+
686
+ .zoom-text {
687
+ min-width: 44px;
688
+ text-align: center;
689
+ font-size: 12px;
690
+ color: #334155;
691
+ }
692
+
693
+ .canvas-svg {
694
+ width: 100%;
695
+ height: 100%;
696
+ }
697
+
698
+ rect.selected,
699
+ polygon.selected {
700
+ stroke: #2563eb;
701
+ stroke-width: 2;
702
+ }
703
+
704
+ .port-dot {
705
+ cursor: crosshair;
706
+ }
707
+
708
+ .edge-path,
709
+ .edge-arrow {
710
+ cursor: pointer;
711
+ }
712
+
713
+ .edge-flowing {
714
+ animation: edge-flow 0.8s linear infinite;
715
+ }
716
+
717
+ @keyframes edge-flow {
718
+ from {
719
+ stroke-dashoffset: 0;
720
+ }
721
+ to {
722
+ stroke-dashoffset: -13;
723
+ }
724
+ }
725
+
726
+ .edge-label {
727
+ font-size: 12px;
728
+ pointer-events: none;
729
+ user-select: none;
730
+ }
731
+
732
+ .node-title-foreign {
733
+ pointer-events: none;
734
+ }
735
+
736
+ .node-title-dom {
737
+ width: 100%;
738
+ height: 100%;
739
+ display: flex;
740
+ align-items: center;
741
+ justify-content: center;
742
+ gap: 6px;
743
+ font-size: 13px;
744
+ user-select: none;
745
+ white-space: nowrap;
746
+ overflow: hidden;
747
+ text-overflow: ellipsis;
748
+ }
749
+
750
+ .node-icon-dom,
751
+ .node-icon-fallback {
752
+ flex: 0 0 auto;
753
+ }
754
+
755
+ .node-title-text {
756
+ overflow: hidden;
757
+ text-overflow: ellipsis;
758
+ }
759
+ </style>