w-flow-vue 1.0.20 → 1.0.21

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.
@@ -1,1305 +1,1390 @@
1
- <template>
2
- <div :style="`width:${widthInp}px; height:${heightInp}px;`">
3
- <FlowCanvas
4
- v-if="inited"
5
- ref="canvas"
6
- @canvas-mousedown="onCanvasMouseDown"
7
- @canvas-mousemove="onCanvasMouseMove"
8
- @canvas-mouseup="onCanvasMouseUp"
9
- @canvas-wheel="onCanvasWheel"
10
- @canvas-dblclick="onCanvasDblClick"
11
- @canvas-click="onCanvasClick"
12
- @canvas-contextmenu="onCanvasContextMenu"
13
- >
14
- <BackgroundLayer
15
- :variant="platformBackgroundPatternType"
16
- :gap="platformBackgroundPatternGap"
17
- :size="platformBackgroundPatternSize"
18
- :pattern-color="platformBackgroundPatternColor"
19
- :bg-color="platformBackgroundColor"
20
- :viewport="viewport"
21
- />
22
-
23
- <ViewportTransform
24
- :viewport="viewport"
25
- >
26
- <EdgeRenderer
27
- ref="edgeRenderer"
28
- :conns="conns"
29
- :nodes="nodes"
30
- :node-internals="nodeInternals"
31
- :selected-conn-ids="selectedConns"
32
- :interactive="elementsSelectable"
33
- :locked="locked"
34
- :settings-popup-background-color="settingsPopupBackgroundColor"
35
- :settings-popup-text-color="settingsPopupTextColor"
36
- :settings-popup-text-font-size="settingsPopupTextFontSize"
37
- :infor-popup-background-color="inforPopupBackgroundColor"
38
- :infor-popup-title-text-color="inforPopupTitleTextColor"
39
- :infor-popup-title-text-font-size="inforPopupTitleTextFontSize"
40
- :infor-popup-description-text-color="inforPopupDescriptionTextColor"
41
- :infor-popup-description-text-font-size="inforPopupDescriptionTextFontSize"
42
- :settings-enabled="connsSettingsEnabled"
43
- :settings-excludes="connsSettingsExcludes"
44
- @conn-click="onConnClick"
45
- @conn-double-click="onConnDoubleClick"
46
- @conn-context-menu="onConnContextMenu"
47
- @conn-mouseenter="onConnMouseEnter"
48
- @conn-mouseleave="onConnMouseLeave"
49
- @conn-settings-click="onConnSettingsClick"
50
- @conn-settings-update="onConnSettingsUpdate"
51
- @conn-settings-delete="onConnSettingsDelete"
52
- >
53
- <template v-if="$scopedSlots['conn-popup']" v-slot:conn-popup="scope">
54
- <slot name="conn-popup" v-bind="scope" />
55
- </template>
56
- </EdgeRenderer>
57
-
58
- <NodeRenderer
59
- ref="nodeRenderer"
60
- :nodes="nodes"
61
- :selected-node-ids="selectedNodes"
62
- :nodes-draggable="nodesDraggable"
63
- :nodes-connectable="nodesConnectable"
64
- :locked="locked"
65
- :nodes-resizable="nodesResizable"
66
- :settings-popup-background-color="settingsPopupBackgroundColor"
67
- :settings-popup-text-color="settingsPopupTextColor"
68
- :settings-popup-text-font-size="settingsPopupTextFontSize"
69
- :infor-popup-background-color="inforPopupBackgroundColor"
70
- :infor-popup-title-text-color="inforPopupTitleTextColor"
71
- :infor-popup-title-text-font-size="inforPopupTitleTextFontSize"
72
- :infor-popup-description-text-color="inforPopupDescriptionTextColor"
73
- :infor-popup-description-text-font-size="inforPopupDescriptionTextFontSize"
74
- :snap-grid-size="snapToGrid ? snapGridSize : null"
75
- :settings-enabled="nodesSettingsEnabled"
76
- :settings-excludes="nodesSettingsExcludes"
77
- @drag-start="onNodeDragStart"
78
- @node-click="onNodeClick"
79
- @node-double-click="onNodeDoubleClick"
80
- @node-context-menu="onNodeContextMenu"
81
- @node-settings-click="onNodeSettingsClick"
82
- @node-settings-update="onNodeSettingsUpdate"
83
- @node-settings-delete="onNodeSettingsDelete"
84
- @node-mouseenter="onNodeMouseEnter"
85
- @node-mouseleave="onNodeMouseLeave"
86
- @connect-start="onConnectStart"
87
- @dimensions="onNodeDimensions"
88
- @node-resize="onNodeResize"
89
- @node-resize-end="onNodeResizeEnd"
90
- >
91
- <template v-if="$scopedSlots['node-popup']" v-slot:node-popup="scope">
92
- <slot name="node-popup" v-bind="scope" />
93
- </template>
94
- </NodeRenderer>
95
-
96
- <ConnectionLine
97
- :active="isConnecting"
98
- :source-x="connLineFromX"
99
- :source-y="connLineFromY"
100
- :source-position="connLineFromPosition"
101
- :target-x="connLineToX"
102
- :target-y="connLineToY"
103
- :type="defConnCreatingType"
104
- :line-style="defConnCreatingStyle"
105
- />
106
-
107
- <slot name="viewport-overlay" />
108
- </ViewportTransform>
109
-
110
- <SelectionBox :box="selectionBox" />
111
-
112
- <Controls
113
- :locked="locked"
114
- :menu-y-shift="menuYShiftInp"
115
- position="top-left"
116
- @zoom-in="zoomIn"
117
- @zoom-out="zoomOut"
118
- @fit-view="fitView"
119
- @toggle-interactive="toggleInteractive"
120
- />
121
-
122
- </FlowCanvas>
123
- </div>
124
- </template>
125
-
126
- <script>
127
- import FlowCanvas from './canvas/FlowCanvas.vue'
128
- import ViewportTransform from './canvas/ViewportTransform.vue'
129
- import BackgroundLayer from './canvas/BackgroundLayer.vue'
130
- import SelectionBox from './canvas/SelectionBox.vue'
131
- import NodeRenderer from './nodes/NodeRenderer.vue'
132
- import EdgeRenderer from './edges/EdgeRenderer.vue'
133
- import ConnectionLine from './edges/ConnectionLine.vue'
134
- import Controls from './ui/Controls.vue'
135
- import { getHandlePosition, getOverlappingNodes, snapPosition, clampPosition } from '../js/geometry'
136
- import { clearStepCache } from '../js/edgePath'
137
- import { isValidConnection, generateId } from '../js/graph'
138
- import { NODE_DEFAULTS, CONN_DEFAULTS } from '../js/defaults'
139
-
140
- /**
141
- * WFlowVue — Vue 2 flow/graph editor component.
142
- *
143
- * All configuration is passed via the `opt` prop object.
144
- *
145
- * @prop {Object} opt
146
- *
147
- * ─── Canvas ────────────────────────────────────────────────────────────
148
- * @prop {number} [opt.width=800] Canvas width (px)
149
- * @prop {number} [opt.height=600] Canvas height (px)
150
- * @prop {Array} [opt.nodes=[]] Node data array
151
- * @prop {Array} [opt.conns=[]] Connection data array
152
- *
153
- * ─── Interaction ───────────────────────────────────────────────────────
154
- * @prop {boolean} [opt.nodesDraggable=true] Allow node dragging
155
- * @prop {boolean} [opt.nodesConnectable=true] Allow creating connections
156
- * @prop {boolean} [opt.nodesResizable=true] Allow resizing nodes (per-node override: node.resizable)
157
- * @prop {boolean} [opt.elementsSelectable=true] Allow selecting nodes/conns
158
- * @prop {boolean} [opt.locked=false] Initial interactive-lock state (afterwards toggled via Controls lock button, emits toggle-interactive)
159
- * @prop {boolean} [opt.nodesSettingsEnabled=true] Show built-in node settings popup (gear icon)
160
- * @prop {boolean} [opt.connsSettingsEnabled=true] Show built-in connection settings popup (gear icon)
161
- * @prop {Array} [opt.nodesSettingsExcludes=[]] Field keys hidden in node settings form (e.g. ['name','description'])
162
- * @prop {Array} [opt.connsSettingsExcludes=[]] Field keys hidden in connection settings form
163
- * @prop {boolean} [opt.selectNodesOnDrag=true] Select node when drag starts
164
- * @prop {boolean} [opt.deleteKeyEnabled=false] Enable keyboard deletion of selected elements
165
- * @prop {string} [opt.deleteKeyCode='Backspace'] Key to delete selected elements (requires deleteKeyEnabled)
166
- * @prop {boolean} [opt.multiSelectEnabled=true] Enable multi-selection (box select + Shift+Click)
167
- * @prop {string} [opt.boxSelectionKeyCode='Shift'] Key to hold for box selection (drag on canvas)
168
- * @prop {string} [opt.multiSelectionKeyCode='Shift'] Key to hold for Shift+Click add/remove selection
169
- * @prop {boolean} [opt.zoomOnScroll=true] Zoom with mouse wheel
170
- * @prop {number} [opt.zoomMin=0.5] Minimum zoom level (fitView may go below it; wheel zoom then keeps the current level as its lower bound instead of jumping back)
171
- * @prop {number} [opt.zoomMax=2] Maximum zoom level
172
- * @prop {boolean} [opt.panOnDrag=true] Pan canvas by dragging background
173
- * @prop {Array} [opt.center=[0,0]] Initial viewport center [x, y]
174
- * @prop {number} [opt.zoom=1] Initial viewport zoom level
175
- * @prop {Array} [opt.panLimits=null] Pan limits [[minX,minY],[maxX,maxY]]
176
- * @prop {boolean} [opt.snapToGrid=false] Snap node positions to grid
177
- * @prop {number} [opt.snapGridSize=20] Grid cell size (px, used for both drag snap and resize snap)
178
- *
179
- * ─── Platform ────────────────────────────────────────────────────────
180
- * @prop {string} [opt.platformBackgroundPatternType='dots'] Background pattern: 'dots' | 'lines' | 'cross'
181
- * @prop {number} [opt.platformBackgroundPatternGap=20] Pattern spacing (px)
182
- * @prop {number} [opt.platformBackgroundPatternSize=1] Pattern element size
183
- * @prop {string} [opt.platformBackgroundPatternColor='#81818a'] Pattern color
184
- * @prop {string} [opt.platformBackgroundColor='#fff'] Canvas background color
185
- *
186
- * ─── Settings Popup ────────────────────────────────────────────────────
187
- * @prop {string} [opt.settingsPopupBackgroundColor='#fff'] Settings popup background
188
- * @prop {string} [opt.settingsPopupTextColor='#333'] Settings popup text color
189
- * @prop {string} [opt.settingsPopupTextFontSize='12px'] Settings popup font size
190
- *
191
- * ─── Infor Popup ────────────────────────────────────────────────────────
192
- * @prop {string} [opt.inforPopupBackgroundColor='#fff'] Info popup background
193
- * @prop {string} [opt.inforPopupTitleTextColor='#333'] Info popup title text color
194
- * @prop {string} [opt.inforPopupTitleTextFontSize='12px'] Info popup title font size
195
- * @prop {string} [opt.inforPopupDescriptionTextColor='#888'] Info popup description text color
196
- * @prop {string} [opt.inforPopupDescriptionTextFontSize='10px'] Info popup description font size
197
- *
198
- * ─── Default Node ──────────────────────────────────────────
199
- * @prop {string} [opt.defNodeType='basic'] Default node type: 'input' | 'basic' | 'output'
200
- * @prop {string} [opt.defNodeShape='rectangle'] Default shape: 'rectangle' | 'diamond' | 'ellipse' | 'triangle' | ...
201
- * @prop {number} [opt.defNodeWidth=100] Default node width (px)
202
- * @prop {number} [opt.defNodeHeight=40] Default node height (px)
203
- * @prop {number} [opt.defNodeFontSize=12] Default node font size (px)
204
- * @prop {number} [opt.defNodeFontSizeMin=1] Min font size in settings
205
- * @prop {number} [opt.defNodeFontSizeMax=72] Max font size in settings
206
- * @prop {string} [opt.defNodeFontColor='#333333'] Default node text color
207
- * @prop {string} [opt.defNodeFaceColor='#ffffff'] Default node fill color
208
- * @prop {string} [opt.defNodeEdgeColor='#bbbbbb'] Default node border color
209
- * @prop {number} [opt.defNodeEdgeWidth=1] Default node border width (px)
210
- * @prop {string} [opt.defNodeToPosition='bottom'] Default outgoing handle position: 'top' | 'bottom' | 'left' | 'right'
211
- * @prop {string} [opt.defNodeFromPosition='top'] Default incoming handle position
212
- * @prop {string} [opt.defNodePopupDirection='right'] Default settings popup direction
213
- *
214
- * ─── Default Creating Connection ────────────────────────────────────────────────────────
215
- * @prop {string} [opt.defConnCreatingType='bezier'] Drag-line type: 'bezier' | 'straight' | 'step' | 'smoothstep'
216
- * @prop {string} [opt.defConnCreatingEdgeColor='#b1b1b7'] Drag-line color
217
- * @prop {number} [opt.defConnCreatingEdgeWidth=1] Drag-line width (px)
218
- * @prop {string} [opt.defConnCreatingEdgeDasharray='5 5'] Drag-line dash pattern ('' for solid)
219
- * @prop {Function} [opt.funValidConnCreating=null] Custom connection validator fn(connection) → boolean
220
- *
221
- * ─── Default Connection ────────────────────────────────────
222
- * @prop {string} [opt.defConnType='bezier'] Default conn type: 'bezier' | 'straight' | 'step' | 'smoothstep'
223
- * @prop {number} [opt.defConnFontSize=10] Default conn label font size (px)
224
- * @prop {number} [opt.defConnFontSizeMin=1] Min font size in settings
225
- * @prop {number} [opt.defConnFontSizeMax=72] Max font size in settings
226
- * @prop {string} [opt.defConnFontColor='#333333'] Default conn label text color
227
- * @prop {string} [opt.defConnEdgeColor='#b1b1b7'] Default conn line color
228
- * @prop {number} [opt.defConnEdgeWidth=1] Default conn line width (px)
229
- * @prop {string} [opt.defConnEdgeDasharray=''] Default conn dash pattern ('' for solid, '5 5' for dashed)
230
- * @prop {string} [opt.defConnMarkerEnd=''] Default arrow marker: '' | 'arrow' | 'arrowclosed'
231
- * @prop {boolean} [opt.defConnAnimated=false] Default conn animation (dashed flow)
232
- * @prop {number} [opt.defOffset=24] Step/smoothstep routing buffer (px)
233
- */
234
- export default {
235
- components: {
236
- FlowCanvas,
237
- ViewportTransform,
238
- BackgroundLayer,
239
- SelectionBox,
240
- NodeRenderer,
241
- EdgeRenderer,
242
- ConnectionLine,
243
- Controls,
244
- },
245
- props: {
246
- opt: {
247
- type: Object,
248
- default: () => ({}),
249
- },
250
- },
251
- provide() {
252
- return {
253
- getDefNode: () => this.defNode,
254
- getDefConn: () => this.defConn,
255
- getConns: () => this.conns, //供節點元件推導實際使用之連接點方位(conn層級錨點)
256
- //拖曳/縮放ghost(細粒度): 回傳該節點進行中之暫時幾何({x,y}或{x,y,width,height}), 無則null
257
- getDragGhost: (id) => {
258
- return this.dragPositions[id] || null
259
- },
260
- }
261
- },
262
- data() {
263
- return {
264
- inited: false,
265
-
266
- // Viewport
267
- viewport: { x: 0, y: 0, zoom: 1 },
268
-
269
- // Selection
270
- selectedNodes: [],
271
- selectedConns: [],
272
-
273
- // UI state
274
- selectionBox: null,
275
- nodeInternals: {},
276
-
277
- // Interactive lock state (opt.locked sets the initial value only;
278
- // afterwards toggled via the Controls lock button)
279
- locked: this.opt.locked === true,
280
-
281
- // Drag state
282
- isDraggingNode: false,
283
- draggingNodeId: null,
284
- dragStartPos: null,
285
- dragNodeStartPositions: null,
286
- //拖曳/縮放ghost容器: 鍵隨nodes預建(值null=無ghost), 永不整包替換——
287
- //讀取端(Node/EdgeWrapper)依賴per-key, 拖曳開始/每步/結束皆僅通知被拖節點之讀者, 無全量重渲染
288
- dragPositions: {},
289
-
290
- // Pan state
291
- isPanning: false,
292
- panStartPos: null,
293
-
294
- // Connection state
295
- isConnecting: false,
296
- connectingFrom: null,
297
- connLineFromX: 0,
298
- connLineFromY: 0,
299
- connLineFromPosition: 'bottom',
300
- connLineToX: 0,
301
- connLineToY: 0,
302
-
303
- // Selection state
304
- isSelecting: false,
305
- selectionStartPos: null,
306
-
307
- // Key state
308
- keysPressed: {},
309
-
310
- }
311
- },
312
- watch: {
313
- //ghost鍵預建: 於節點首次渲染前備妥per-key反應式插槽, 讀者才能建立細粒度依賴
314
- nodes: {
315
- immediate: true,
316
- handler(ns) {
317
- for (const n of (ns || [])) {
318
- if (!(n.id in this.dragPositions)) {
319
- this.$set(this.dragPositions, n.id, null)
320
- }
321
- }
322
- },
323
- },
324
- opt: {
325
- handler() {
326
- if (!this.inited) {
327
- this.inited = true
328
- let vc = this.center
329
- if (vc) {
330
- this.viewport.x = vc[0] || 0
331
- this.viewport.y = vc[1] || 0
332
- }
333
- this.viewport.zoom = this.zoom
334
- this.$emit('init')
335
- }
336
- },
337
- immediate: true,
338
- },
339
- },
340
- mounted() {
341
- document.addEventListener('keydown', this.onKeyDown)
342
- document.addEventListener('keyup', this.onKeyUp)
343
- document.addEventListener('mousemove', this.onDocMouseMove)
344
- document.addEventListener('mouseup', this.onDocMouseUp)
345
- },
346
- beforeDestroy() {
347
- if (this._panAnimId) {
348
- cancelAnimationFrame(this._panAnimId)
349
- this._panAnimId = null
350
- }
351
- document.removeEventListener('keydown', this.onKeyDown)
352
- document.removeEventListener('keyup', this.onKeyUp)
353
- document.removeEventListener('mousemove', this.onDocMouseMove)
354
- document.removeEventListener('mouseup', this.onDocMouseUp)
355
- },
356
- computed: {
357
- widthInp() {
358
- return this.opt.width || 800
359
- },
360
- heightInp() {
361
- return this.opt.height || 600
362
- },
363
- nodes() {
364
- return this.opt.nodes || []
365
- },
366
- conns() {
367
- return this.opt.conns || []
368
- },
369
-
370
- nodesDraggable() {
371
- return this.opt.nodesDraggable !== undefined ? this.opt.nodesDraggable : true
372
- },
373
- menuYShiftInp() {
374
- //垂直選單向下偏移量, 由宿主經opt設定(如宿主於左上角另置變更儲存鈕時, 令選單下移避讓)
375
- return typeof this.opt.menuYShift === 'number' ? this.opt.menuYShift : 0
376
- },
377
- nodesConnectable() {
378
- return this.opt.nodesConnectable !== undefined ? this.opt.nodesConnectable : true
379
- },
380
- nodesResizable() {
381
- return this.opt.nodesResizable !== undefined ? this.opt.nodesResizable : true
382
- },
383
- elementsSelectable() {
384
- return this.opt.elementsSelectable !== undefined ? this.opt.elementsSelectable : true
385
- },
386
- nodesSettingsEnabled() {
387
- return this.opt.nodesSettingsEnabled !== undefined ? this.opt.nodesSettingsEnabled : true
388
- },
389
- connsSettingsEnabled() {
390
- return this.opt.connsSettingsEnabled !== undefined ? this.opt.connsSettingsEnabled : true
391
- },
392
- nodesSettingsExcludes() {
393
- return this.opt.nodesSettingsExcludes || []
394
- },
395
- connsSettingsExcludes() {
396
- return this.opt.connsSettingsExcludes || []
397
- },
398
- selectNodesOnDrag() {
399
- return this.opt.selectNodesOnDrag !== undefined ? this.opt.selectNodesOnDrag : true
400
- },
401
- deleteKeyEnabled() {
402
- return this.opt.deleteKeyEnabled !== undefined ? this.opt.deleteKeyEnabled : false
403
- },
404
- deleteKeyCode() {
405
- return this.opt.deleteKeyCode || 'Backspace'
406
- },
407
-
408
- defConnCreatingType() {
409
- return this.opt.defConnCreatingType || 'bezier'
410
- },
411
- defConnCreatingEdgeColor() {
412
- return this.opt.defConnCreatingEdgeColor || '#b1b1b7'
413
- },
414
- defConnCreatingEdgeWidth() {
415
- return this.opt.defConnCreatingEdgeWidth !== undefined ? this.opt.defConnCreatingEdgeWidth : 1
416
- },
417
- defConnCreatingEdgeDasharray() {
418
- return this.opt.defConnCreatingEdgeDasharray || '5 5'
419
- },
420
- defConnCreatingStyle() {
421
- return {
422
- stroke: this.defConnCreatingEdgeColor,
423
- strokeWidth: this.defConnCreatingEdgeWidth,
424
- strokeDasharray: this.defConnCreatingEdgeDasharray,
425
- }
426
- },
427
- zoomOnScroll() {
428
- return this.opt.zoomOnScroll !== undefined ? this.opt.zoomOnScroll : true
429
- },
430
- panOnDrag() {
431
- return this.opt.panOnDrag !== undefined ? this.opt.panOnDrag : true
432
- },
433
- zoomMin() {
434
- return this.opt.zoomMin !== undefined ? this.opt.zoomMin : 0.5
435
- },
436
- zoomMax() {
437
- return this.opt.zoomMax !== undefined ? this.opt.zoomMax : 2
438
- },
439
- center() {
440
- return this.opt.center || [0, 0]
441
- },
442
- zoom() {
443
- return this.opt.zoom !== undefined ? this.opt.zoom : 1
444
- },
445
- panLimits() {
446
- return this.opt.panLimits || null
447
- },
448
-
449
- multiSelectEnabled() {
450
- return this.opt.multiSelectEnabled !== undefined ? this.opt.multiSelectEnabled : true
451
- },
452
- boxSelectionKeyCode() {
453
- return this.opt.boxSelectionKeyCode || 'Shift'
454
- },
455
- multiSelectionKeyCode() {
456
- return this.opt.multiSelectionKeyCode || 'Shift'
457
- },
458
-
459
- snapToGrid() {
460
- return this.opt.snapToGrid !== undefined ? this.opt.snapToGrid : false
461
- },
462
- snapGridSize() {
463
- return this.opt.snapGridSize || 20
464
- },
465
-
466
- platformBackgroundPatternType() {
467
- return this.opt.platformBackgroundPatternType || 'dots'
468
- },
469
- platformBackgroundPatternGap() {
470
- return this.opt.platformBackgroundPatternGap !== undefined ? this.opt.platformBackgroundPatternGap : 20
471
- },
472
- platformBackgroundPatternSize() {
473
- return this.opt.platformBackgroundPatternSize !== undefined ? this.opt.platformBackgroundPatternSize : 1
474
- },
475
- platformBackgroundPatternColor() {
476
- return this.opt.platformBackgroundPatternColor || '#81818a'
477
- },
478
- platformBackgroundColor() {
479
- return this.opt.platformBackgroundColor || '#fff'
480
- },
481
-
482
- // --- Settings popup styling ---
483
- settingsPopupBackgroundColor() {
484
- return this.opt.settingsPopupBackgroundColor || '#fff'
485
- },
486
- settingsPopupTextColor() {
487
- return this.opt.settingsPopupTextColor || '#333'
488
- },
489
- settingsPopupTextFontSize() {
490
- return this.opt.settingsPopupTextFontSize || '12px'
491
- },
492
- inforPopupBackgroundColor() {
493
- return this.opt.inforPopupBackgroundColor || '#fff'
494
- },
495
- inforPopupTitleTextColor() {
496
- return this.opt.inforPopupTitleTextColor || '#333'
497
- },
498
- inforPopupTitleTextFontSize() {
499
- return this.opt.inforPopupTitleTextFontSize || '12px'
500
- },
501
- inforPopupDescriptionTextColor() {
502
- return this.opt.inforPopupDescriptionTextColor || '#888'
503
- },
504
- inforPopupDescriptionTextFontSize() {
505
- return this.opt.inforPopupDescriptionTextFontSize || '10px'
506
- },
507
-
508
- funValidConnCreating() {
509
- return this.opt.funValidConnCreating || null
510
- },
511
-
512
- defNode() {
513
- let o = this.opt
514
- let d = NODE_DEFAULTS
515
- return {
516
- type: o.defNodeType || d.type,
517
- shape: o.defNodeShape || d.shape,
518
- width: o.defNodeWidth || d.width,
519
- height: o.defNodeHeight || d.height,
520
- fontSize: o.defNodeFontSize || d.fontSize,
521
- fontSizeMin: o.defNodeFontSizeMin || d.fontSizeMin,
522
- fontSizeMax: o.defNodeFontSizeMax || d.fontSizeMax,
523
- fontColor: o.defNodeFontColor || d.fontColor,
524
- faceColor: o.defNodeFaceColor || d.faceColor,
525
- edgeColor: o.defNodeEdgeColor || d.edgeColor,
526
- edgeWidth: o.defNodeEdgeWidth !== undefined ? o.defNodeEdgeWidth : d.edgeWidth,
527
- toPosition: o.defNodeToPosition || d.toPosition,
528
- fromPosition: o.defNodeFromPosition || d.fromPosition,
529
- popupDirection: o.defNodePopupDirection || d.popupDirection,
530
- }
531
- },
532
- defConn() {
533
- let o = this.opt
534
- let d = CONN_DEFAULTS
535
- return {
536
- type: o.defConnType || d.type,
537
- fontSize: o.defConnFontSize || d.fontSize,
538
- fontSizeMin: o.defConnFontSizeMin || d.fontSizeMin,
539
- fontSizeMax: o.defConnFontSizeMax || d.fontSizeMax,
540
- fontColor: o.defConnFontColor || d.fontColor,
541
- edgeColor: o.defConnEdgeColor || d.edgeColor,
542
- edgeWidth: o.defConnEdgeWidth !== undefined ? o.defConnEdgeWidth : d.edgeWidth,
543
- edgeDasharray: o.defConnEdgeDasharray || '',
544
- markerEnd: o.defConnMarkerEnd || d.markerEnd,
545
- animated: o.defConnAnimated !== undefined ? o.defConnAnimated : d.animated,
546
- defOffset: o.defOffset != null ? o.defOffset : d.defOffset,
547
- }
548
- },
549
-
550
- //(效能重構)拖曳/縮放ghost改由getDragGhost細粒度提供(per-node反應式鍵), 不再整包重建nodes陣列
551
- //why: 舊renderNodes每步mousemove產新陣列+新物件prop, 使兩Renderer與全部Node/EdgeWrapper(含WPopup/WTooltip子樹)
552
- // 每步全量重渲染(84節點+95邊實測~128ms/步); 細粒度後僅被拖節點與其相連邊重渲染
553
- isBoxSelectPressed() {
554
- return this.multiSelectEnabled && !!this.keysPressed[this.boxSelectionKeyCode]
555
- },
556
- isMultiSelectPressed() {
557
- return this.multiSelectEnabled && !!this.keysPressed[this.multiSelectionKeyCode]
558
- },
559
- },
560
- methods: {
561
- // --- Helpers (replace store methods) ---
562
- nodeById(id) {
563
- return this.nodes.find(n => n.id === id) || null
564
- },
565
- connById(id) {
566
- return this.conns.find(c => c.id === id) || null
567
- },
568
- setSelectedNodes(ids) {
569
- this.selectedNodes.splice(0, this.selectedNodes.length, ...ids)
570
- },
571
- setSelectedConns(ids) {
572
- this.selectedConns.splice(0, this.selectedConns.length, ...ids)
573
- },
574
- clearSelection() {
575
- this.selectedNodes.splice(0, this.selectedNodes.length)
576
- this.selectedConns.splice(0, this.selectedConns.length)
577
- },
578
- removeNode(id) {
579
- let nodes = this.nodes
580
- let idx = nodes.findIndex(n => n.id === id)
581
- if (idx === -1) return
582
- nodes.splice(idx, 1)
583
- // Remove connected conns
584
- let conns = this.conns
585
- for (let i = conns.length - 1; i >= 0; i--) {
586
- if (conns[i].from === id || conns[i].to === id) {
587
- conns.splice(i, 1)
588
- }
589
- }
590
- let selIdx = this.selectedNodes.indexOf(id)
591
- if (selIdx !== -1) this.selectedNodes.splice(selIdx, 1)
592
- },
593
- removeConn(id) {
594
- let conns = this.conns
595
- let idx = conns.findIndex(c => c.id === id)
596
- if (idx === -1) return
597
- conns.splice(idx, 1)
598
- let selIdx = this.selectedConns.indexOf(id)
599
- if (selIdx !== -1) this.selectedConns.splice(selIdx, 1)
600
- },
601
- addConn(conn) {
602
- if (!conn.id || !conn.from || !conn.to) return
603
- if (this.connById(conn.id)) return
604
- if (!this.nodeById(conn.from) || !this.nodeById(conn.to)) return
605
- this.conns.push(conn)
606
- },
607
- updateNodeInternals(id, internals) {
608
- let existing = this.nodeInternals[id]
609
- if (existing && existing.width === internals.width && existing.height === internals.height) return
610
- this.$set(this.nodeInternals, id, internals)
611
- },
612
- setViewport({ x, y, zoom }) {
613
- if (x !== undefined) this.viewport.x = x
614
- if (y !== undefined) this.viewport.y = y
615
- if (zoom !== undefined) this.viewport.zoom = zoom
616
- },
617
-
618
- // --- Key handling ---
619
- onKeyDown(e) {
620
- this.keysPressed = { ...this.keysPressed, [e.key]: true }
621
- if (!this.locked && this.deleteKeyEnabled && (e.key === this.deleteKeyCode || e.key === 'Delete')) {
622
- this.deleteSelectedElements()
623
- }
624
- },
625
- onKeyUp(e) {
626
- const copy = { ...this.keysPressed }
627
- delete copy[e.key]
628
- this.keysPressed = copy
629
- },
630
-
631
- // --- Canvas events ---
632
- onCanvasClick(event) {
633
- if (event.target.closest('.vue-flow__popup')) return
634
- if (!event.target.closest('.vue-flow__node') && !event.target.closest('.vue-flow__edge')) {
635
- this.clearSelection()
636
- this.$emit('pane-click', event)
637
- }
638
- },
639
- onCanvasContextMenu(event) {
640
- this.$emit('pane-context-menu', event)
641
- },
642
- onCanvasDblClick(event) {
643
- // Calculate flow-space position from the click
644
- const rect = this.$refs.canvas.getContainerRect()
645
- if (!rect) return
646
- const vp = this.viewport
647
- const flowX = (event.clientX - rect.left - vp.x) / vp.zoom
648
- const flowY = (event.clientY - rect.top - vp.y) / vp.zoom
649
-
650
- this.$emit('canvas-dblclick', {
651
- event,
652
- flowX,
653
- flowY,
654
- clientX: event.clientX,
655
- clientY: event.clientY,
656
- })
657
- },
658
- onCanvasMouseDown(event) {
659
- if (event.target.closest && event.target.closest('.vue-flow__popup')) return
660
- if (!this.locked && this.isBoxSelectPressed) {
661
- this.startSelection(event)
662
- return
663
- }
664
- if (this.panOnDrag && event.button === 0) {
665
- const target = event.target
666
- const isOnNode = target.closest && target.closest('.vue-flow__node')
667
- const isOnHandle = target.closest && target.closest('.vue-flow__handle')
668
- //齒輪等UI元素/左上角控制選單/連線轉折點之mousedown不啟動平移(取代其@mousedown.stop, 使mousedown仍能冒泡至window做popup互斥關閉;
669
- // .vue-flow__panel=Controls垂直選單, .vue-flow__edge-waypoint=連線轉折點拖曳鈕, 否則按之再拖曳會誤觸發平移)
670
- const isOnUi = target.closest && target.closest('.vue-flow__node-settings, .vue-flow__edge-settings, .vue-flow__panel, .vue-flow__edge-waypoint')
671
- if (!isOnNode && !isOnHandle && !isOnUi) {
672
- this.startPan(event)
673
- }
674
- }
675
- },
676
- onCanvasMouseMove(event) {
677
- // Handled by document-level listener
678
- },
679
- onCanvasMouseUp(event) {
680
- // Handled by document-level listener
681
- },
682
- onCanvasWheel(event) {
683
- if (!this.zoomOnScroll) return
684
- const delta = -event.deltaY * 0.001
685
- const currentZoom = this.viewport.zoom
686
- // fitView may set zoom below zoomMin; use the current level as the
687
- // effective lower bound so wheel zoom does not jump the view back.
688
- const zoomMinUse = Math.min(this.zoomMin, currentZoom)
689
- const newZoom = Math.max(zoomMinUse, Math.min(this.zoomMax, currentZoom + delta * currentZoom))
690
-
691
- const rect = this.$refs.canvas.getContainerRect()
692
- if (!rect) return
693
- const mouseX = event.clientX - rect.left
694
- const mouseY = event.clientY - rect.top
695
-
696
- const vp = this.viewport
697
- const scale = newZoom / currentZoom
698
- const newX = mouseX - (mouseX - vp.x) * scale
699
- const newY = mouseY - (mouseY - vp.y) * scale
700
-
701
- this.setViewport({ x: newX, y: newY, zoom: newZoom })
702
- this.emitViewportChange()
703
- },
704
-
705
- // --- Document-level mouse ---
706
- onDocMouseMove(event) {
707
- if (this.isPanning) {
708
- this.doPan(event)
709
- }
710
- else if (this.isDraggingNode) {
711
- this.doDrag(event)
712
- }
713
- else if (this.isConnecting) {
714
- this.doConnect(event)
715
- }
716
- else if (this.isSelecting) {
717
- this.doSelection(event)
718
- }
719
- },
720
- onDocMouseUp(event) {
721
- if (this.isPanning) {
722
- this.endPan()
723
- }
724
- if (this.isDraggingNode) {
725
- this.endDrag(event)
726
- }
727
- if (this.isConnecting) {
728
- this.endConnect(event)
729
- }
730
- if (this.isSelecting) {
731
- this.endSelection(event)
732
- }
733
- },
734
-
735
- // --- Pan ---
736
- startPan(event) {
737
- this.isPanning = true
738
- this.panStartPos = { x: event.clientX, y: event.clientY }
739
- },
740
- doPan(event) {
741
- const dx = event.clientX - this.panStartPos.x
742
- const dy = event.clientY - this.panStartPos.y
743
- this.panStartPos = { x: event.clientX, y: event.clientY }
744
-
745
- let x = this.viewport.x + dx
746
- let y = this.viewport.y + dy
747
-
748
- if (this.panLimits) {
749
- const clamped = clampPosition({ x, y }, this.panLimits)
750
- x = clamped.x
751
- y = clamped.y
752
- }
753
-
754
- this.viewport.x = x
755
- this.viewport.y = y
756
- },
757
- endPan() {
758
- this.isPanning = false
759
- this.panStartPos = null
760
- this.emitViewportChange()
761
- },
762
-
763
- // --- Node drag ---
764
- onNodeDragStart({ node, event }) {
765
- if (this.locked || !this.nodesDraggable) return
766
- this.isDraggingNode = true
767
- this.draggingNodeId = node.id
768
- this.dragStartPos = { x: event.clientX, y: event.clientY }
769
-
770
- if (this.selectNodesOnDrag && !this.isMultiSelectPressed) {
771
- this.setSelectedNodes([node.id])
772
- this.setSelectedConns([])
773
- }
774
-
775
- // Cache start positions for drag
776
- const starts = {}
777
- this.selectedNodes.forEach(id => {
778
- const n = this.nodeById(id)
779
- if (n) starts[id] = { x: n.position.x, y: n.position.y }
780
- })
781
- if (!starts[node.id]) {
782
- const n = this.nodeById(node.id)
783
- if (n) starts[node.id] = { x: n.position.x, y: n.position.y }
784
- }
785
- this.dragNodeStartPositions = starts
786
-
787
- //啟用被拖節點之ghost(per-key賦值, 僅通知讀該鍵之元件), 之後每步僅原地改x/y
788
- for (let id in starts) {
789
- if (!(id in this.dragPositions)) {
790
- this.$set(this.dragPositions, id, null)
791
- }
792
- this.dragPositions[id] = { x: starts[id].x, y: starts[id].y }
793
- }
794
-
795
- this.$emit('node-drag-start', { node, event })
796
- },
797
- doDrag(event) {
798
- const zoom = this.viewport.zoom
799
- const dx = (event.clientX - this.dragStartPos.x) / zoom
800
- const dy = (event.clientY - this.dragStartPos.y) / zoom
801
- const snap = this.snapToGrid
802
-
803
- for (let id in this.dragNodeStartPositions) {
804
- const start = this.dragNodeStartPositions[id]
805
- let x = start.x + dx
806
- let y = start.y + dy
807
- if (snap) {
808
- const s = snapPosition({ x, y }, this.snapGridSize)
809
- x = s.x
810
- y = s.y
811
- }
812
- const gg = this.dragPositions && this.dragPositions[id]
813
- if (gg) {
814
- gg.x = x
815
- gg.y = y
816
- }
817
- }
818
- },
819
- endDrag(event) {
820
- // Write final positions back to opt.nodes, 並關閉ghost(per-key設回null)
821
- if (this.dragNodeStartPositions) {
822
- for (let id in this.dragNodeStartPositions) {
823
- let pos = this.dragPositions[id]
824
- let node = this.nodeById(id)
825
- if (node && pos) {
826
- node.position.x = pos.x
827
- node.position.y = pos.y
828
- }
829
- this.dragPositions[id] = null
830
- }
831
- }
832
- const dragNode = this.nodeById(this.draggingNodeId)
833
- this.isDraggingNode = false
834
- this.draggingNodeId = null
835
- this.dragStartPos = null
836
- this.dragNodeStartPositions = null
837
- clearStepCache()
838
- if (dragNode) {
839
- this.$emit('node-drag-stop', { node: dragNode, event })
840
- }
841
- this.emitNodesUpdate()
842
- },
843
-
844
- // --- Connection ---
845
- onConnectStart(payload) {
846
- if (this.locked || !this.nodesConnectable) return
847
- //建線只能自source(連出)型handle出發: target(連入)型handle拖曳不啟動建線, 維持方向語義
848
- if (payload.handleType !== 'source') return
849
- this.isConnecting = true
850
- this.connectingFrom = payload
851
- // Lock cursor to default during connecting, only handles show crosshair
852
- this._connectCursorStyle = document.createElement('style')
853
- this._connectCursorStyle.textContent = '* { cursor: default !important; } .vue-flow__handle { cursor: crosshair !important; } .vue-flow__node-settings, .vue-flow__edge-settings, .vue-flow__resize { opacity: 0 !important; pointer-events: none !important; }'
854
- document.head.appendChild(this._connectCursorStyle)
855
-
856
- const node = this.nodeById(payload.nodeId)
857
- if (!node) return
858
- const pos = getHandlePosition(
859
- node, payload.handlePosition,
860
- this.nodeInternals[payload.nodeId] || {}
861
- )
862
- this.connLineFromX = pos.x
863
- this.connLineFromY = pos.y
864
- this.connLineFromPosition = payload.handlePosition
865
- this.connLineToX = pos.x
866
- this.connLineToY = pos.y
867
-
868
- this.$emit('connect-start', {
869
- nodeId: payload.nodeId,
870
- handleId: payload.handleId,
871
- handleType: payload.handleType,
872
- })
873
- },
874
- doConnect(event) {
875
- const rect = this.$refs.canvas.getContainerRect()
876
- if (!rect) return
877
- const vp = this.viewport
878
- this.connLineToX = (event.clientX - rect.left - vp.x) / vp.zoom
879
- this.connLineToY = (event.clientY - rect.top - vp.y) / vp.zoom
880
- },
881
- endConnect(event) {
882
- // Find handle element under cursor
883
- const el = document.elementFromPoint(event.clientX, event.clientY)
884
- const handleEl = el && el.closest && el.closest('.vue-flow__handle')
885
-
886
- if (handleEl && this.connectingFrom && handleEl.dataset.handleType === 'target') {
887
- //落點限target(連入)型handle: 連出點不可被當作輸入端(如input節點僅有連出點, 不可作為連線終點)
888
- const toNodeEl = handleEl.closest('.vue-flow__node')
889
- const toNodeId = toNodeEl ? toNodeEl.dataset.id : null
890
-
891
- if (toNodeId) {
892
- const connection = {
893
- from: this.connectingFrom.nodeId,
894
- to: toNodeId,
895
- //逐邊錨點: 起點=實際拖曳出發之連接點方位, 迄點=落點handle之方位(缺則不設, 回退節點層級/預設)
896
- ...(this.connectingFrom.handlePosition ? { fromPosition: this.connectingFrom.handlePosition } : {}),
897
- ...(handleEl.dataset.handlePosition ? { toPosition: handleEl.dataset.handlePosition } : {}),
898
- }
899
-
900
- const valid = isValidConnection(
901
- connection,
902
- this.nodes,
903
- this.conns,
904
- this.funValidConnCreating
905
- )
906
-
907
- if (valid) {
908
- const connId = `e${connection.from}-${connection.to}`
909
- const conn = {
910
- id: this.connById(connId) ? generateId() : connId,
911
- ...connection,
912
- }
913
-
914
- this.addConn(conn)
915
- this.emitConnsUpdate()
916
- this.$emit('connect', connection)
917
- }
918
- }
919
- }
920
-
921
- this.$emit('connect-end', event)
922
- this.isConnecting = false
923
- this.connectingFrom = null
924
- if (this._connectCursorStyle) {
925
- document.head.removeChild(this._connectCursorStyle)
926
- this._connectCursorStyle = null
927
- }
928
- },
929
-
930
- // --- Selection ---
931
- onNodeClick({ node, event }) {
932
- if (!this.elementsSelectable) return
933
- if (this.isMultiSelectPressed) {
934
- const idx = this.selectedNodes.indexOf(node.id)
935
- if (idx === -1) {
936
- this.selectedNodes.push(node.id)
937
- }
938
- else {
939
- this.selectedNodes.splice(idx, 1)
940
- }
941
- }
942
- else {
943
- this.setSelectedNodes([node.id])
944
- this.setSelectedConns([])
945
- }
946
- this.emitSelectionChange()
947
-
948
- this.$emit('node-click', { node, event })
949
- },
950
- onNodeDoubleClick(payload) {
951
- this.$emit('node-double-click', payload)
952
- },
953
- onNodeContextMenu(payload) {
954
- this.$emit('node-context-menu', payload)
955
- },
956
- onNodeSettingsClick(payload) {
957
- this.$emit('node-settings-click', payload)
958
- },
959
- onNodeSettingsUpdate({ node, key, value }) {
960
- let n = this.nodeById(node.id)
961
- if (!n) return
962
- let oldType = n.type
963
- this.$set(n, key, value)
964
- if (key === 'type' && oldType !== value) {
965
- let nodeId = n.id
966
- let hasTo = value === 'input' || value === 'basic'
967
- let hasFrom = value === 'output' || value === 'basic'
968
- let conns = this.conns
969
- for (let i = conns.length - 1; i >= 0; i--) {
970
- let c = conns[i]
971
- if ((!hasTo && c.from === nodeId) || (!hasFrom && c.to === nodeId)) {
972
- conns.splice(i, 1)
973
- }
974
- }
975
- }
976
- this.$emit('node-settings-update', { node: n, key, value })
977
- },
978
- onNodeSettingsDelete({ node }) {
979
- this.removeNode(node.id)
980
- clearStepCache()
981
- this.emitNodesUpdate()
982
- this.emitConnsUpdate()
983
- this.$emit('node-settings-delete', { node })
984
- },
985
- onNodeMouseEnter({ node, event }) {
986
- this.$emit('node-mouseenter', { node, event })
987
- },
988
- onNodeMouseLeave({ node, event }) {
989
- this.$emit('node-mouseleave', { node, event })
990
- },
991
- onConnClick({ conn, event }) {
992
- if (!this.elementsSelectable) return
993
- if (this.isMultiSelectPressed) {
994
- const idx = this.selectedConns.indexOf(conn.id)
995
- if (idx === -1) {
996
- this.selectedConns.push(conn.id)
997
- }
998
- else {
999
- this.selectedConns.splice(idx, 1)
1000
- }
1001
- }
1002
- else {
1003
- this.setSelectedConns([conn.id])
1004
- this.setSelectedNodes([])
1005
- }
1006
- this.emitSelectionChange()
1007
-
1008
- this.$emit('conn-click', { conn, event })
1009
- },
1010
- onConnDoubleClick(payload) {
1011
- this.$emit('conn-double-click', payload)
1012
- },
1013
- onConnContextMenu(payload) {
1014
- this.$emit('conn-context-menu', payload)
1015
- },
1016
- onConnMouseEnter({ conn, event }) {
1017
- this.$emit('conn-mouseenter', { conn, event })
1018
- },
1019
- onConnMouseLeave({ conn, event }) {
1020
- this.$emit('conn-mouseleave', { conn, event })
1021
- },
1022
- onConnSettingsClick(payload) {
1023
- this.$emit('conn-settings-click', payload)
1024
- },
1025
- onConnSettingsUpdate({ conn, key, value }) {
1026
- let c = this.connById(conn.id)
1027
- if (c) {
1028
- this.$set(c, key, value)
1029
- this.$emit('conn-settings-update', { conn: c, key, value })
1030
- }
1031
- },
1032
- onConnSettingsDelete({ conn }) {
1033
- this.removeConn(conn.id)
1034
- clearStepCache()
1035
- this.emitConnsUpdate()
1036
- this.$emit('conn-settings-delete', { conn })
1037
- },
1038
-
1039
- startSelection(event) {
1040
- this.isSelecting = true
1041
- const rect = this.$refs.canvas.getContainerRect()
1042
- if (!rect) return
1043
- this.selectionStartPos = {
1044
- x: event.clientX - rect.left,
1045
- y: event.clientY - rect.top,
1046
- }
1047
- this.selectionBox = {
1048
- x: this.selectionStartPos.x,
1049
- y: this.selectionStartPos.y,
1050
- width: 0,
1051
- height: 0,
1052
- }
1053
- },
1054
- doSelection(event) {
1055
- const rect = this.$refs.canvas.getContainerRect()
1056
- if (!rect) return
1057
- const currentX = event.clientX - rect.left
1058
- const currentY = event.clientY - rect.top
1059
- const x = Math.min(this.selectionStartPos.x, currentX)
1060
- const y = Math.min(this.selectionStartPos.y, currentY)
1061
- const width = Math.abs(currentX - this.selectionStartPos.x)
1062
- const height = Math.abs(currentY - this.selectionStartPos.y)
1063
- this.selectionBox = { x, y, width, height }
1064
- },
1065
- endSelection() {
1066
- if (this.selectionBox) {
1067
- const box = this.selectionBox
1068
- const vp = this.viewport
1069
- // Convert screen-space box to graph-space
1070
- const graphBox = {
1071
- x: (box.x - vp.x) / vp.zoom,
1072
- y: (box.y - vp.y) / vp.zoom,
1073
- width: box.width / vp.zoom,
1074
- height: box.height / vp.zoom,
1075
- }
1076
- const overlapping = getOverlappingNodes(graphBox, this.nodes, this.nodeInternals)
1077
- const nodeIds = overlapping.map(n => n.id)
1078
- this.setSelectedNodes(nodeIds)
1079
- // Auto-select conns whose both ends are within selected nodes
1080
- let nodeIdSet = new Set(nodeIds)
1081
- let connIds = this.conns
1082
- .filter(c => nodeIdSet.has(c.from) && nodeIdSet.has(c.to))
1083
- .map(c => c.id)
1084
- this.setSelectedConns(connIds)
1085
- this.emitSelectionChange()
1086
- }
1087
- this.isSelecting = false
1088
- this.selectionStartPos = null
1089
- this.selectionBox = null
1090
- },
1091
-
1092
- // --- Delete ---
1093
- deleteSelectedElements() {
1094
- const { nodes, conns } = this.getSelectedElements()
1095
- if (nodes.length === 0 && conns.length === 0) return
1096
-
1097
- nodes.forEach(n => {
1098
- if (n.deletable !== false) this.removeNode(n.id)
1099
- })
1100
- conns.forEach(e => {
1101
- if (e.deletable !== false) this.removeConn(e.id)
1102
- })
1103
- this.clearSelection()
1104
-
1105
- this.$emit('delete', { nodes, conns })
1106
- this.emitNodesUpdate()
1107
- this.emitConnsUpdate()
1108
- },
1109
-
1110
- // --- Node dimensions ---
1111
- onNodeDimensions({ nodeId, width, height }) {
1112
- this.updateNodeInternals(nodeId, { width, height })
1113
- },
1114
-
1115
- onNodeResize({ nodeId, width, height, x, y }) {
1116
- if (this.locked) return
1117
- //縮放ghost併入dragPositions同一per-key機制: 首步建物件, 之後原地改欄位
1118
- if (!(nodeId in this.dragPositions)) {
1119
- this.$set(this.dragPositions, nodeId, null)
1120
- }
1121
- const g = this.dragPositions[nodeId]
1122
- if (!g) {
1123
- this.dragPositions[nodeId] = { x, y, width, height }
1124
- }
1125
- else {
1126
- g.x = x
1127
- g.y = y
1128
- g.width = width
1129
- g.height = height
1130
- }
1131
- this.updateNodeInternals(nodeId, { width, height })
1132
- },
1133
- onNodeResizeEnd({ nodeId, width, height, x, y }) {
1134
- let node = this.nodeById(nodeId)
1135
- if (node) {
1136
- node.width = width
1137
- node.height = height
1138
- node.position.x = x
1139
- node.position.y = y
1140
- }
1141
- this.dragPositions[nodeId] = null
1142
- clearStepCache()
1143
- this.emitNodesUpdate()
1144
- },
1145
-
1146
- // --- Helpers ---
1147
- updateNodePosition(id, position) {
1148
- let node = this.nodeById(id)
1149
- if (!node) return
1150
- node.position.x = position.x
1151
- node.position.y = position.y
1152
- },
1153
- getSelectedElements() {
1154
- return {
1155
- nodes: this.nodes.filter(n => this.selectedNodes.includes(n.id)),
1156
- conns: this.conns.filter(c => this.selectedConns.includes(c.id)),
1157
- }
1158
- },
1159
-
1160
- // --- Emit helpers ---
1161
- emitNodesUpdate() {
1162
- this.$emit('update:nodes', [...this.nodes])
1163
- },
1164
- emitConnsUpdate() {
1165
- this.$emit('update:conns', [...this.conns])
1166
- },
1167
- emitViewportChange() {
1168
- this.$emit('viewport-change', { ...this.viewport })
1169
- },
1170
- emitSelectionChange() {
1171
- this.$emit('selection-change', this.getSelectedElements())
1172
- },
1173
-
1174
- // --- Public API ---
1175
- fitView(padding) {
1176
- padding = padding || 50
1177
- let nodes = this.nodes.filter(n => !n.hidden)
1178
- if (nodes.length === 0) return
1179
- let internals = this.nodeInternals
1180
- let minX = Infinity
1181
- let minY = Infinity
1182
- let maxX = -Infinity
1183
- let maxY = -Infinity
1184
- nodes.forEach(n => {
1185
- let w = (internals[n.id] && internals[n.id].width) || n.width || 150
1186
- let h = (internals[n.id] && internals[n.id].height) || n.height || 40
1187
- minX = Math.min(minX, n.position.x)
1188
- minY = Math.min(minY, n.position.y)
1189
- maxX = Math.max(maxX, n.position.x + w)
1190
- maxY = Math.max(maxY, n.position.y + h)
1191
- })
1192
- let rect = this.$refs.canvas ? this.$refs.canvas.getContainerRect() : null
1193
- let cw = rect ? rect.width : this.widthInp
1194
- let ch = rect ? rect.height : this.heightInp
1195
- let gw = maxX - minX + padding * 2
1196
- let gh = maxY - minY + padding * 2
1197
- let zoom = Math.min(cw / gw, ch / gh, 2)
1198
- this.viewport.zoom = zoom
1199
- this.viewport.x = (cw - (maxX + minX) * zoom) / 2
1200
- this.viewport.y = (ch - (maxY + minY) * zoom) / 2
1201
- this.emitViewportChange()
1202
- },
1203
- zoomAtCenter(factor) {
1204
- const currentZoom = this.viewport.zoom
1205
- const zoomMinUse = Math.min(this.zoomMin, currentZoom)
1206
- const newZoom = Math.max(zoomMinUse, Math.min(this.zoomMax, currentZoom * factor))
1207
- const rect = this.$refs.canvas ? this.$refs.canvas.getContainerRect() : null
1208
- const cx = rect ? rect.width / 2 : this.widthInp / 2
1209
- const cy = rect ? rect.height / 2 : this.heightInp / 2
1210
- const vp = this.viewport
1211
- const scale = newZoom / currentZoom
1212
- this.setViewport({
1213
- x: cx - (cx - vp.x) * scale,
1214
- y: cy - (cy - vp.y) * scale,
1215
- zoom: newZoom,
1216
- })
1217
- this.emitViewportChange()
1218
- },
1219
- zoomIn() {
1220
- this.zoomAtCenter(1.2)
1221
- },
1222
- zoomOut() {
1223
- this.zoomAtCenter(1 / 1.2)
1224
- },
1225
- toggleInteractive() {
1226
- this.locked = !this.locked
1227
- this.$emit('toggle-interactive', this.locked)
1228
- },
1229
- panToNode(nodeId, opt) {
1230
- opt = opt || {}
1231
- let node = this.nodeById(nodeId)
1232
- if (!node) return false
1233
- let internals = this.nodeInternals[nodeId]
1234
- let w = (internals && internals.width) || node.width || 150
1235
- let h = (internals && internals.height) || node.height || 40
1236
- let cx = node.position.x + w / 2
1237
- let cy = node.position.y + h / 2
1238
- let rect = this.$refs.canvas ? this.$refs.canvas.getContainerRect() : null
1239
- let cw = (rect && rect.width) || this.widthInp
1240
- let ch = (rect && rect.height) || this.heightInp
1241
- let zoom = opt.zoom !== undefined ? opt.zoom : this.viewport.zoom
1242
- let duration = opt.duration !== undefined ? opt.duration : 400
1243
- let target = { x: cw / 2 - cx * zoom, y: ch / 2 - cy * zoom, zoom }
1244
- if (this._panAnimId) {
1245
- cancelAnimationFrame(this._panAnimId)
1246
- this._panAnimId = null
1247
- }
1248
- let finish = () => {
1249
- this.setViewport(target)
1250
- this.emitViewportChange()
1251
- if (opt.openPopup === true) {
1252
- this.$nextTick(() => this.openNodeInfoPopup(nodeId))
1253
- }
1254
- }
1255
- if (!(duration > 0)) {
1256
- finish()
1257
- return true
1258
- }
1259
- let from = { x: this.viewport.x, y: this.viewport.y, zoom: this.viewport.zoom }
1260
- let easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
1261
- let startTs = null
1262
- let stepFrame = (ts) => {
1263
- if (startTs === null) startTs = ts
1264
- let t = Math.min((ts - startTs) / duration, 1)
1265
- let k = easeInOutCubic(t)
1266
- this.setViewport({
1267
- x: from.x + (target.x - from.x) * k,
1268
- y: from.y + (target.y - from.y) * k,
1269
- zoom: from.zoom + (target.zoom - from.zoom) * k,
1270
- })
1271
- if (t < 1) {
1272
- this._panAnimId = requestAnimationFrame(stepFrame)
1273
- }
1274
- else {
1275
- this._panAnimId = null
1276
- finish()
1277
- }
1278
- }
1279
- this._panAnimId = requestAnimationFrame(stepFrame)
1280
- return true
1281
- },
1282
- openNodeInfoPopup(nodeId) {
1283
- let r = this.$refs.nodeRenderer
1284
- if (!r) return false
1285
- return r.openNodeInfoPopup(nodeId)
1286
- },
1287
- openConnInfoPopup(connId) {
1288
- let r = this.$refs.edgeRenderer
1289
- if (!r) return false
1290
- return r.openConnInfoPopup(connId)
1291
- },
1292
- getFlowData() {
1293
- return {
1294
- nodes: JSON.parse(JSON.stringify(this.nodes)),
1295
- conns: JSON.parse(JSON.stringify(this.conns)),
1296
- }
1297
- },
1298
- },
1299
- }
1300
- </script>
1301
-
1302
- <style scoped>
1303
-
1304
-
1305
- </style>
1
+ <template>
2
+ <div :style="`width:${widthInp}px; height:${heightInp}px;`">
3
+ <FlowCanvas
4
+ v-if="inited"
5
+ ref="canvas"
6
+ @canvas-mousedown="onCanvasMouseDown"
7
+ @canvas-mousemove="onCanvasMouseMove"
8
+ @canvas-mouseup="onCanvasMouseUp"
9
+ @canvas-wheel="onCanvasWheel"
10
+ @canvas-dblclick="onCanvasDblClick"
11
+ @canvas-click="onCanvasClick"
12
+ @canvas-contextmenu="onCanvasContextMenu"
13
+ >
14
+ <BackgroundLayer
15
+ :variant="platformBackgroundPatternType"
16
+ :gap="platformBackgroundPatternGap"
17
+ :size="platformBackgroundPatternSize"
18
+ :pattern-color="platformBackgroundPatternColor"
19
+ :bg-color="platformBackgroundColor"
20
+ :viewport="viewport"
21
+ />
22
+
23
+ <ViewportTransform
24
+ :viewport="viewport"
25
+ >
26
+ <EdgeRenderer
27
+ ref="edgeRenderer"
28
+ :conns="conns"
29
+ :nodes="nodes"
30
+ :node-internals="nodeInternals"
31
+ :selected-conn-ids="selectedConns"
32
+ :interactive="elementsSelectable"
33
+ :locked="locked"
34
+ :settings-popup-background-color="settingsPopupBackgroundColor"
35
+ :settings-popup-text-color="settingsPopupTextColor"
36
+ :settings-popup-text-font-size="settingsPopupTextFontSize"
37
+ :infor-popup-background-color="inforPopupBackgroundColor"
38
+ :infor-popup-title-text-color="inforPopupTitleTextColor"
39
+ :infor-popup-title-text-font-size="inforPopupTitleTextFontSize"
40
+ :infor-popup-description-text-color="inforPopupDescriptionTextColor"
41
+ :infor-popup-description-text-font-size="inforPopupDescriptionTextFontSize"
42
+ :settings-enabled="connsSettingsEnabled"
43
+ :settings-excludes="connsSettingsExcludes"
44
+ @conn-click="onConnClick"
45
+ @conn-double-click="onConnDoubleClick"
46
+ @conn-context-menu="onConnContextMenu"
47
+ @conn-mouseenter="onConnMouseEnter"
48
+ @conn-mouseleave="onConnMouseLeave"
49
+ @conn-settings-click="onConnSettingsClick"
50
+ @conn-settings-update="onConnSettingsUpdate"
51
+ @conn-settings-delete="onConnSettingsDelete"
52
+ >
53
+ <template v-if="$scopedSlots['conn-popup']" v-slot:conn-popup="scope">
54
+ <slot name="conn-popup" v-bind="scope" />
55
+ </template>
56
+ </EdgeRenderer>
57
+
58
+ <NodeRenderer
59
+ ref="nodeRenderer"
60
+ :nodes="nodes"
61
+ :selected-node-ids="selectedNodes"
62
+ :nodes-draggable="nodesDraggable"
63
+ :nodes-connectable="nodesConnectable"
64
+ :locked="locked"
65
+ :nodes-resizable="nodesResizable"
66
+ :settings-popup-background-color="settingsPopupBackgroundColor"
67
+ :settings-popup-text-color="settingsPopupTextColor"
68
+ :settings-popup-text-font-size="settingsPopupTextFontSize"
69
+ :infor-popup-background-color="inforPopupBackgroundColor"
70
+ :infor-popup-title-text-color="inforPopupTitleTextColor"
71
+ :infor-popup-title-text-font-size="inforPopupTitleTextFontSize"
72
+ :infor-popup-description-text-color="inforPopupDescriptionTextColor"
73
+ :infor-popup-description-text-font-size="inforPopupDescriptionTextFontSize"
74
+ :snap-grid-size="snapToGrid ? snapGridSize : null"
75
+ :settings-enabled="nodesSettingsEnabled"
76
+ :settings-excludes="nodesSettingsExcludes"
77
+ @drag-start="onNodeDragStart"
78
+ @node-click="onNodeClick"
79
+ @node-double-click="onNodeDoubleClick"
80
+ @node-context-menu="onNodeContextMenu"
81
+ @node-settings-click="onNodeSettingsClick"
82
+ @node-settings-update="onNodeSettingsUpdate"
83
+ @node-settings-delete="onNodeSettingsDelete"
84
+ @node-mouseenter="onNodeMouseEnter"
85
+ @node-mouseleave="onNodeMouseLeave"
86
+ @connect-start="onConnectStart"
87
+ @dimensions="onNodeDimensions"
88
+ @node-resize="onNodeResize"
89
+ @node-resize-end="onNodeResizeEnd"
90
+ >
91
+ <template v-if="$scopedSlots['node-popup']" v-slot:node-popup="scope">
92
+ <slot name="node-popup" v-bind="scope" />
93
+ </template>
94
+ </NodeRenderer>
95
+
96
+ <ConnectionLine
97
+ :active="isConnecting"
98
+ :source-x="connLineFromX"
99
+ :source-y="connLineFromY"
100
+ :source-position="connLineFromPosition"
101
+ :target-x="connLineToX"
102
+ :target-y="connLineToY"
103
+ :type="defConnCreatingType"
104
+ :line-style="defConnCreatingStyle"
105
+ />
106
+
107
+ <slot name="viewport-overlay" />
108
+ </ViewportTransform>
109
+
110
+ <SelectionBox :box="selectionBox" />
111
+
112
+ <Controls
113
+ :locked="locked"
114
+ :menu="menuInp"
115
+ @zoom-in="zoomIn"
116
+ @zoom-out="zoomOut"
117
+ @fit-view="fitView"
118
+ @toggle-interactive="toggleInteractive"
119
+ />
120
+
121
+ </FlowCanvas>
122
+ </div>
123
+ </template>
124
+
125
+ <script>
126
+ import FlowCanvas from './canvas/FlowCanvas.vue'
127
+ import ViewportTransform from './canvas/ViewportTransform.vue'
128
+ import BackgroundLayer from './canvas/BackgroundLayer.vue'
129
+ import SelectionBox from './canvas/SelectionBox.vue'
130
+ import NodeRenderer from './nodes/NodeRenderer.vue'
131
+ import EdgeRenderer from './edges/EdgeRenderer.vue'
132
+ import ConnectionLine from './edges/ConnectionLine.vue'
133
+ import Controls from './ui/Controls.vue'
134
+ import { getHandlePosition, getOverlappingNodes, snapPosition, clampPosition } from '../js/geometry'
135
+ import { clearStepCache } from '../js/edgePath'
136
+ import { isValidConnection, generateId } from '../js/graph'
137
+ import { NODE_DEFAULTS, CONN_DEFAULTS } from '../js/defaults'
138
+
139
+ /**
140
+ * WFlowVue — Vue 2 flow/graph editor component.
141
+ *
142
+ * All configuration is passed via the `opt` prop object.
143
+ *
144
+ * @prop {Object} opt
145
+ *
146
+ * ─── Canvas ────────────────────────────────────────────────────────────
147
+ * @prop {number} [opt.width=800] Canvas width (px)
148
+ * @prop {number} [opt.height=600] Canvas height (px)
149
+ * @prop {Array} [opt.nodes=[]] Node data array
150
+ * @prop {Array} [opt.conns=[]] Connection data array
151
+ *
152
+ * ─── Interaction ───────────────────────────────────────────────────────
153
+ * @prop {boolean} [opt.nodesDraggable=true] Allow node dragging
154
+ * @prop {boolean} [opt.nodesConnectable=true] Allow creating connections
155
+ * @prop {boolean} [opt.nodesResizable=true] Allow resizing nodes (per-node override: node.resizable)
156
+ * @prop {boolean} [opt.elementsSelectable=true] Allow selecting nodes/conns
157
+ * @prop {boolean} [opt.locked=false] Initial interactive-lock state (afterwards toggled via Controls lock button, emits toggle-interactive)
158
+ * @prop {boolean} [opt.nodesSettingsEnabled=true] Show built-in node settings popup (gear icon)
159
+ * @prop {boolean} [opt.connsSettingsEnabled=true] Show built-in connection settings popup (gear icon)
160
+ * @prop {Array} [opt.nodesSettingsExcludes=[]] Field keys hidden in node settings form (e.g. ['name','description'])
161
+ * @prop {Array} [opt.connsSettingsExcludes=[]] Field keys hidden in connection settings form
162
+ * @prop {boolean} [opt.selectNodesOnDrag=true] Select node when drag starts
163
+ * @prop {boolean} [opt.deleteKeyEnabled=false] Enable keyboard deletion of selected elements
164
+ * @prop {string} [opt.deleteKeyCode='Backspace'] Key to delete selected elements (requires deleteKeyEnabled)
165
+ * @prop {boolean} [opt.multiSelectEnabled=true] Enable multi-selection (box select + Shift+Click)
166
+ * @prop {string} [opt.boxSelectionKeyCode='Shift'] Key to hold for box selection (drag on canvas)
167
+ * @prop {string} [opt.multiSelectionKeyCode='Shift'] Key to hold for Shift+Click add/remove selection
168
+ * @prop {boolean} [opt.zoomOnScroll=true] Zoom with mouse wheel
169
+ * @prop {number} [opt.zoom=1] Initial viewport zoom level
170
+ * @prop {number} [opt.zoomMin=0.5] Minimum zoom level (fitView may go below it; wheel zoom then keeps the current level as its lower bound instead of jumping back)
171
+ * @prop {number} [opt.zoomMax=2] Maximum zoom level
172
+ * @prop {Array} [opt.center=[0,0]] Initial viewport center [x, y]
173
+ * @prop {boolean} [opt.panOnDrag=true] Pan canvas by dragging background
174
+ * @prop {Array} [opt.panLimits=null] Pan limits [[minX,minY],[maxX,maxY]]
175
+ * @prop {boolean} [opt.snapToGrid=false] Snap node positions to grid
176
+ * @prop {number} [opt.snapGridSize=20] Grid cell size (px, used for both drag snap and resize snap)
177
+ *
178
+ * ─── Platform ────────────────────────────────────────────────────────
179
+ * @prop {string} [opt.platformBackgroundPatternType='dots'] Background pattern: 'dots' | 'lines' | 'cross'
180
+ * @prop {number} [opt.platformBackgroundPatternGap=20] Pattern spacing (px)
181
+ * @prop {number} [opt.platformBackgroundPatternSize=1] Pattern element size
182
+ * @prop {string} [opt.platformBackgroundPatternColor='#81818a'] Pattern color
183
+ * @prop {string} [opt.platformBackgroundColor='#fff'] Canvas background color
184
+ *
185
+ * ─── Menu ──────────────────────────────────────────────────────────────
186
+ * Top-left toolbar. Every option is opt-in: omit them all and the toolbar is identical to before.
187
+ * Any value of the wrong type (or an empty string) falls back to the default listed here.
188
+ * @prop {boolean} [opt.useMenu=true] Show the whole toolbar
189
+ * @prop {string} [opt.menuPosition='top-left'] Toolbar corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
190
+ * @prop {number} [opt.menuYShift=0] Toolbar vertical shift (px); positive moves it away from its anchored edge (down for top-*, up for bottom-*)
191
+ * @prop {boolean} [opt.useSetting=true] Initial expanded state (afterwards toggled via the gear button)
192
+ * @prop {string} [opt.menuSettingIcon=mdiCogOutline] Gear (collapse/expand) button icon, SVG path string
193
+ * @prop {string} [opt.menuSettingTooltip='Settings'] Gear button tooltip
194
+ * @prop {boolean} [opt.useMenuItemZoomIn=true] Show zoom-in button
195
+ * @prop {string} [opt.menuZoomInIcon=mdiMagnifyPlusOutline] Zoom-in icon, SVG path string
196
+ * @prop {string} [opt.menuZoomInTooltip='Zoom In'] Zoom-in tooltip
197
+ * @prop {boolean} [opt.useMenuItemZoomOut=true] Show zoom-out button
198
+ * @prop {string} [opt.menuZoomOutIcon=mdiMagnifyMinusOutline] Zoom-out icon, SVG path string
199
+ * @prop {string} [opt.menuZoomOutTooltip='Zoom Out'] Zoom-out tooltip
200
+ * @prop {boolean} [opt.useMenuItemFitView=true] Show fit-view button
201
+ * @prop {string} [opt.menuFitViewIcon=mdiFitToPageOutline] Fit-view icon, SVG path string
202
+ * @prop {string} [opt.menuFitViewTooltip='Fit View'] Fit-view tooltip
203
+ * @prop {boolean} [opt.useMenuItemLock=true] Show lock button
204
+ * @prop {string} [opt.menuLockIcon=mdiLockOpenVariantOutline] Lock button icon while unlocked
205
+ * @prop {string} [opt.menuLockTooltip='Lock'] Lock button tooltip while unlocked (names the action the click performs)
206
+ * @prop {string} [opt.menuLockIconLocked=mdiLockOutline] Lock button icon while locked
207
+ * @prop {string} [opt.menuLockTooltipLocked='Unlock'] Lock button tooltip while locked
208
+ * @prop {string} [opt.menuIconColor='#555'] Toolbar icon color
209
+ * @prop {string} [opt.menuIconColorHover='#222'] Toolbar icon color on hover
210
+ * @prop {string} [opt.menuIconColorFocus='#222'] Toolbar icon color on focus
211
+ * @prop {number} [opt.menuIconSize=22] Toolbar icon size (px)
212
+ * @prop {string} [opt.menuBackgroundColor='#fefefe'] Toolbar button background
213
+ * @prop {string} [opt.menuBackgroundColorHover='#f0f0f0'] Toolbar button background on hover
214
+ * @prop {string} [opt.menuBackgroundColorFocus='#f0f0f0'] Toolbar button background on focus
215
+ * @prop {string} [opt.menuSeparatorColor='#e6e6e6'] Toolbar separator line color
216
+ * @prop {boolean} [opt.menuShadow=true] Toolbar drop shadow
217
+ * @prop {string} [opt.menuTooltipTextColor='white'] Toolbar tooltip text color
218
+ * @prop {string} [opt.menuTooltipTextFontSize='0.7rem'] Toolbar tooltip font size
219
+ * @prop {string} [opt.menuTooltipBackgroundColor='rgba(60,60,60,0.75)'] Toolbar tooltip background
220
+ *
221
+ * ─── Settings Popup ────────────────────────────────────────────────────
222
+ * @prop {string} [opt.settingsPopupBackgroundColor='#fff'] Settings popup background
223
+ * @prop {string} [opt.settingsPopupTextColor='#333'] Settings popup text color
224
+ * @prop {string} [opt.settingsPopupTextFontSize='12px'] Settings popup font size
225
+ *
226
+ * ─── Infor Popup ────────────────────────────────────────────────────────
227
+ * @prop {string} [opt.inforPopupBackgroundColor='#fff'] Info popup background
228
+ * @prop {string} [opt.inforPopupTitleTextColor='#333'] Info popup title text color
229
+ * @prop {string} [opt.inforPopupTitleTextFontSize='12px'] Info popup title font size
230
+ * @prop {string} [opt.inforPopupDescriptionTextColor='#888'] Info popup description text color
231
+ * @prop {string} [opt.inforPopupDescriptionTextFontSize='10px'] Info popup description font size
232
+ *
233
+ * ─── Default Node ──────────────────────────────────────────
234
+ * @prop {string} [opt.defNodeType='basic'] Default node type: 'input' | 'basic' | 'output'
235
+ * @prop {string} [opt.defNodeShape='rectangle'] Default shape: 'rectangle' | 'diamond' | 'ellipse' | 'triangle' | ...
236
+ * @prop {number} [opt.defNodeWidth=100] Default node width (px)
237
+ * @prop {number} [opt.defNodeHeight=40] Default node height (px)
238
+ * @prop {number} [opt.defNodeFontSize=12] Default node font size (px)
239
+ * @prop {number} [opt.defNodeFontSizeMin=1] Min font size in settings
240
+ * @prop {number} [opt.defNodeFontSizeMax=72] Max font size in settings
241
+ * @prop {string} [opt.defNodeFontColor='#333333'] Default node text color
242
+ * @prop {string} [opt.defNodeFaceColor='#ffffff'] Default node fill color
243
+ * @prop {string} [opt.defNodeEdgeColor='#bbbbbb'] Default node border color
244
+ * @prop {number} [opt.defNodeEdgeWidth=1] Default node border width (px)
245
+ * @prop {string} [opt.defNodeToPosition='bottom'] Default outgoing handle position: 'top' | 'bottom' | 'left' | 'right'
246
+ * @prop {string} [opt.defNodeFromPosition='top'] Default incoming handle position
247
+ * @prop {string} [opt.defNodePopupDirection='right'] Default settings popup direction
248
+ *
249
+ * ─── Default Creating Connection ────────────────────────────────────────────────────────
250
+ * @prop {string} [opt.defConnCreatingType='bezier'] Drag-line type: 'bezier' | 'straight' | 'step' | 'smoothstep'
251
+ * @prop {string} [opt.defConnCreatingEdgeColor='#b1b1b7'] Drag-line color
252
+ * @prop {number} [opt.defConnCreatingEdgeWidth=1] Drag-line width (px)
253
+ * @prop {string} [opt.defConnCreatingEdgeDasharray='5 5'] Drag-line dash pattern ('' for solid)
254
+ * @prop {Function} [opt.funValidConnCreating=null] Custom connection validator fn(connection) → boolean
255
+ *
256
+ * ─── Default Connection ────────────────────────────────────
257
+ * @prop {string} [opt.defConnType='bezier'] Default conn type: 'bezier' | 'straight' | 'step' | 'smoothstep'
258
+ * @prop {number} [opt.defConnFontSize=10] Default conn label font size (px)
259
+ * @prop {number} [opt.defConnFontSizeMin=1] Min font size in settings
260
+ * @prop {number} [opt.defConnFontSizeMax=72] Max font size in settings
261
+ * @prop {string} [opt.defConnFontColor='#333333'] Default conn label text color
262
+ * @prop {string} [opt.defConnEdgeColor='#b1b1b7'] Default conn line color
263
+ * @prop {number} [opt.defConnEdgeWidth=1] Default conn line width (px)
264
+ * @prop {string} [opt.defConnEdgeDasharray=''] Default conn dash pattern ('' for solid, '5 5' for dashed)
265
+ * @prop {string} [opt.defConnMarkerEnd=''] Default arrow marker: '' | 'arrow' | 'arrowclosed'
266
+ * @prop {boolean} [opt.defConnAnimated=false] Default conn animation (dashed flow)
267
+ * @prop {number} [opt.defOffset=24] Step/smoothstep routing buffer (px)
268
+ */
269
+ export default {
270
+ components: {
271
+ FlowCanvas,
272
+ ViewportTransform,
273
+ BackgroundLayer,
274
+ SelectionBox,
275
+ NodeRenderer,
276
+ EdgeRenderer,
277
+ ConnectionLine,
278
+ Controls,
279
+ },
280
+ props: {
281
+ opt: {
282
+ type: Object,
283
+ default: () => ({}),
284
+ },
285
+ },
286
+ provide() {
287
+ return {
288
+ getDefNode: () => this.defNode,
289
+ getDefConn: () => this.defConn,
290
+ getConns: () => this.conns, //供節點元件推導實際使用之連接點方位(conn層級錨點)
291
+ //拖曳/縮放ghost(細粒度): 回傳該節點進行中之暫時幾何({x,y}或{x,y,width,height}), 無則null
292
+ getDragGhost: (id) => {
293
+ return this.dragPositions[id] || null
294
+ },
295
+ }
296
+ },
297
+ data() {
298
+ return {
299
+ inited: false,
300
+
301
+ // Viewport
302
+ viewport: { x: 0, y: 0, zoom: 1 },
303
+
304
+ // Selection
305
+ selectedNodes: [],
306
+ selectedConns: [],
307
+
308
+ // UI state
309
+ selectionBox: null,
310
+ nodeInternals: {},
311
+
312
+ // Interactive lock state (opt.locked sets the initial value only;
313
+ // afterwards toggled via the Controls lock button)
314
+ locked: this.opt.locked === true,
315
+
316
+ // Drag state
317
+ isDraggingNode: false,
318
+ draggingNodeId: null,
319
+ dragStartPos: null,
320
+ dragNodeStartPositions: null,
321
+ //拖曳/縮放ghost容器: 鍵隨nodes預建(值null=無ghost), 永不整包替換——
322
+ //讀取端(Node/EdgeWrapper)依賴per-key, 拖曳開始/每步/結束皆僅通知被拖節點之讀者, 無全量重渲染
323
+ dragPositions: {},
324
+
325
+ // Pan state
326
+ isPanning: false,
327
+ panStartPos: null,
328
+
329
+ // Connection state
330
+ isConnecting: false,
331
+ connectingFrom: null,
332
+ connLineFromX: 0,
333
+ connLineFromY: 0,
334
+ connLineFromPosition: 'bottom',
335
+ connLineToX: 0,
336
+ connLineToY: 0,
337
+
338
+ // Selection state
339
+ isSelecting: false,
340
+ selectionStartPos: null,
341
+
342
+ // Key state
343
+ keysPressed: {},
344
+
345
+ }
346
+ },
347
+ watch: {
348
+ //ghost鍵預建: 於節點首次渲染前備妥per-key反應式插槽, 讀者才能建立細粒度依賴
349
+ nodes: {
350
+ immediate: true,
351
+ handler(ns) {
352
+ for (const n of (ns || [])) {
353
+ if (!(n.id in this.dragPositions)) {
354
+ this.$set(this.dragPositions, n.id, null)
355
+ }
356
+ }
357
+ },
358
+ },
359
+ opt: {
360
+ handler() {
361
+ if (!this.inited) {
362
+ this.inited = true
363
+ let vc = this.center
364
+ if (vc) {
365
+ this.viewport.x = vc[0] || 0
366
+ this.viewport.y = vc[1] || 0
367
+ }
368
+ this.viewport.zoom = this.zoom
369
+ this.$emit('init')
370
+ }
371
+ },
372
+ immediate: true,
373
+ },
374
+ },
375
+ mounted() {
376
+ document.addEventListener('keydown', this.onKeyDown)
377
+ document.addEventListener('keyup', this.onKeyUp)
378
+ document.addEventListener('mousemove', this.onDocMouseMove)
379
+ document.addEventListener('mouseup', this.onDocMouseUp)
380
+ },
381
+ beforeDestroy() {
382
+ if (this._panAnimId) {
383
+ cancelAnimationFrame(this._panAnimId)
384
+ this._panAnimId = null
385
+ }
386
+ document.removeEventListener('keydown', this.onKeyDown)
387
+ document.removeEventListener('keyup', this.onKeyUp)
388
+ document.removeEventListener('mousemove', this.onDocMouseMove)
389
+ document.removeEventListener('mouseup', this.onDocMouseUp)
390
+ },
391
+ computed: {
392
+ widthInp() {
393
+ return this.opt.width || 800
394
+ },
395
+ heightInp() {
396
+ return this.opt.height || 600
397
+ },
398
+ nodes() {
399
+ return this.opt.nodes || []
400
+ },
401
+ conns() {
402
+ return this.opt.conns || []
403
+ },
404
+
405
+ nodesDraggable() {
406
+ return this.opt.nodesDraggable !== undefined ? this.opt.nodesDraggable : true
407
+ },
408
+ menuInp() {
409
+ //垂直選單之全部設定, 自opt原樣取出後交Controls解析; 各項未給或型別不符即由Controls回退預設,
410
+ //故此處不套預設值, 預設值統一定義於Controls.vue之menuDef(icon預設需用到@mdi/js之常數)
411
+ let o = this.opt
412
+ return {
413
+
414
+ //整體
415
+ useMenu: o.useMenu,
416
+ menuPosition: o.menuPosition,
417
+ menuYShift: o.menuYShift,
418
+ useSetting: o.useSetting,
419
+
420
+ //設定鈕(收合/展開)
421
+ menuSettingIcon: o.menuSettingIcon,
422
+ menuSettingTooltip: o.menuSettingTooltip,
423
+
424
+ //放大
425
+ useMenuItemZoomIn: o.useMenuItemZoomIn,
426
+ menuZoomInIcon: o.menuZoomInIcon,
427
+ menuZoomInTooltip: o.menuZoomInTooltip,
428
+
429
+ //縮小
430
+ useMenuItemZoomOut: o.useMenuItemZoomOut,
431
+ menuZoomOutIcon: o.menuZoomOutIcon,
432
+ menuZoomOutTooltip: o.menuZoomOutTooltip,
433
+
434
+ //全圖
435
+ useMenuItemFitView: o.useMenuItemFitView,
436
+ menuFitViewIcon: o.menuFitViewIcon,
437
+ menuFitViewTooltip: o.menuFitViewTooltip,
438
+
439
+ //鎖定(雙態: 無後綴為未鎖態, Locked後綴為已鎖態)
440
+ useMenuItemLock: o.useMenuItemLock,
441
+ menuLockIcon: o.menuLockIcon,
442
+ menuLockTooltip: o.menuLockTooltip,
443
+ menuLockIconLocked: o.menuLockIconLocked,
444
+ menuLockTooltipLocked: o.menuLockTooltipLocked,
445
+
446
+ //樣式
447
+ menuIconColor: o.menuIconColor,
448
+ menuIconColorHover: o.menuIconColorHover,
449
+ menuIconColorFocus: o.menuIconColorFocus,
450
+ menuIconSize: o.menuIconSize,
451
+ menuBackgroundColor: o.menuBackgroundColor,
452
+ menuBackgroundColorHover: o.menuBackgroundColorHover,
453
+ menuBackgroundColorFocus: o.menuBackgroundColorFocus,
454
+ menuSeparatorColor: o.menuSeparatorColor,
455
+ menuShadow: o.menuShadow,
456
+ menuTooltipTextColor: o.menuTooltipTextColor,
457
+ menuTooltipTextFontSize: o.menuTooltipTextFontSize,
458
+ menuTooltipBackgroundColor: o.menuTooltipBackgroundColor,
459
+
460
+ }
461
+ },
462
+ nodesConnectable() {
463
+ return this.opt.nodesConnectable !== undefined ? this.opt.nodesConnectable : true
464
+ },
465
+ nodesResizable() {
466
+ return this.opt.nodesResizable !== undefined ? this.opt.nodesResizable : true
467
+ },
468
+ elementsSelectable() {
469
+ return this.opt.elementsSelectable !== undefined ? this.opt.elementsSelectable : true
470
+ },
471
+ nodesSettingsEnabled() {
472
+ return this.opt.nodesSettingsEnabled !== undefined ? this.opt.nodesSettingsEnabled : true
473
+ },
474
+ connsSettingsEnabled() {
475
+ return this.opt.connsSettingsEnabled !== undefined ? this.opt.connsSettingsEnabled : true
476
+ },
477
+ nodesSettingsExcludes() {
478
+ return this.opt.nodesSettingsExcludes || []
479
+ },
480
+ connsSettingsExcludes() {
481
+ return this.opt.connsSettingsExcludes || []
482
+ },
483
+ selectNodesOnDrag() {
484
+ return this.opt.selectNodesOnDrag !== undefined ? this.opt.selectNodesOnDrag : true
485
+ },
486
+ deleteKeyEnabled() {
487
+ return this.opt.deleteKeyEnabled !== undefined ? this.opt.deleteKeyEnabled : false
488
+ },
489
+ deleteKeyCode() {
490
+ return this.opt.deleteKeyCode || 'Backspace'
491
+ },
492
+
493
+ defConnCreatingType() {
494
+ return this.opt.defConnCreatingType || 'bezier'
495
+ },
496
+ defConnCreatingEdgeColor() {
497
+ return this.opt.defConnCreatingEdgeColor || '#b1b1b7'
498
+ },
499
+ defConnCreatingEdgeWidth() {
500
+ return this.opt.defConnCreatingEdgeWidth !== undefined ? this.opt.defConnCreatingEdgeWidth : 1
501
+ },
502
+ defConnCreatingEdgeDasharray() {
503
+ return this.opt.defConnCreatingEdgeDasharray || '5 5'
504
+ },
505
+ defConnCreatingStyle() {
506
+ return {
507
+ stroke: this.defConnCreatingEdgeColor,
508
+ strokeWidth: this.defConnCreatingEdgeWidth,
509
+ strokeDasharray: this.defConnCreatingEdgeDasharray,
510
+ }
511
+ },
512
+ zoomOnScroll() {
513
+ return this.opt.zoomOnScroll !== undefined ? this.opt.zoomOnScroll : true
514
+ },
515
+ panOnDrag() {
516
+ return this.opt.panOnDrag !== undefined ? this.opt.panOnDrag : true
517
+ },
518
+ zoomMin() {
519
+ return this.opt.zoomMin !== undefined ? this.opt.zoomMin : 0.5
520
+ },
521
+ zoomMax() {
522
+ return this.opt.zoomMax !== undefined ? this.opt.zoomMax : 2
523
+ },
524
+ center() {
525
+ return this.opt.center || [0, 0]
526
+ },
527
+ zoom() {
528
+ return this.opt.zoom !== undefined ? this.opt.zoom : 1
529
+ },
530
+ panLimits() {
531
+ return this.opt.panLimits || null
532
+ },
533
+
534
+ multiSelectEnabled() {
535
+ return this.opt.multiSelectEnabled !== undefined ? this.opt.multiSelectEnabled : true
536
+ },
537
+ boxSelectionKeyCode() {
538
+ return this.opt.boxSelectionKeyCode || 'Shift'
539
+ },
540
+ multiSelectionKeyCode() {
541
+ return this.opt.multiSelectionKeyCode || 'Shift'
542
+ },
543
+
544
+ snapToGrid() {
545
+ return this.opt.snapToGrid !== undefined ? this.opt.snapToGrid : false
546
+ },
547
+ snapGridSize() {
548
+ return this.opt.snapGridSize || 20
549
+ },
550
+
551
+ platformBackgroundPatternType() {
552
+ return this.opt.platformBackgroundPatternType || 'dots'
553
+ },
554
+ platformBackgroundPatternGap() {
555
+ return this.opt.platformBackgroundPatternGap !== undefined ? this.opt.platformBackgroundPatternGap : 20
556
+ },
557
+ platformBackgroundPatternSize() {
558
+ return this.opt.platformBackgroundPatternSize !== undefined ? this.opt.platformBackgroundPatternSize : 1
559
+ },
560
+ platformBackgroundPatternColor() {
561
+ return this.opt.platformBackgroundPatternColor || '#81818a'
562
+ },
563
+ platformBackgroundColor() {
564
+ return this.opt.platformBackgroundColor || '#fff'
565
+ },
566
+
567
+ // --- Settings popup styling ---
568
+ settingsPopupBackgroundColor() {
569
+ return this.opt.settingsPopupBackgroundColor || '#fff'
570
+ },
571
+ settingsPopupTextColor() {
572
+ return this.opt.settingsPopupTextColor || '#333'
573
+ },
574
+ settingsPopupTextFontSize() {
575
+ return this.opt.settingsPopupTextFontSize || '12px'
576
+ },
577
+ inforPopupBackgroundColor() {
578
+ return this.opt.inforPopupBackgroundColor || '#fff'
579
+ },
580
+ inforPopupTitleTextColor() {
581
+ return this.opt.inforPopupTitleTextColor || '#333'
582
+ },
583
+ inforPopupTitleTextFontSize() {
584
+ return this.opt.inforPopupTitleTextFontSize || '12px'
585
+ },
586
+ inforPopupDescriptionTextColor() {
587
+ return this.opt.inforPopupDescriptionTextColor || '#888'
588
+ },
589
+ inforPopupDescriptionTextFontSize() {
590
+ return this.opt.inforPopupDescriptionTextFontSize || '10px'
591
+ },
592
+
593
+ funValidConnCreating() {
594
+ return this.opt.funValidConnCreating || null
595
+ },
596
+
597
+ defNode() {
598
+ let o = this.opt
599
+ let d = NODE_DEFAULTS
600
+ return {
601
+ type: o.defNodeType || d.type,
602
+ shape: o.defNodeShape || d.shape,
603
+ width: o.defNodeWidth || d.width,
604
+ height: o.defNodeHeight || d.height,
605
+ fontSize: o.defNodeFontSize || d.fontSize,
606
+ fontSizeMin: o.defNodeFontSizeMin || d.fontSizeMin,
607
+ fontSizeMax: o.defNodeFontSizeMax || d.fontSizeMax,
608
+ fontColor: o.defNodeFontColor || d.fontColor,
609
+ faceColor: o.defNodeFaceColor || d.faceColor,
610
+ edgeColor: o.defNodeEdgeColor || d.edgeColor,
611
+ edgeWidth: o.defNodeEdgeWidth !== undefined ? o.defNodeEdgeWidth : d.edgeWidth,
612
+ toPosition: o.defNodeToPosition || d.toPosition,
613
+ fromPosition: o.defNodeFromPosition || d.fromPosition,
614
+ popupDirection: o.defNodePopupDirection || d.popupDirection,
615
+ }
616
+ },
617
+ defConn() {
618
+ let o = this.opt
619
+ let d = CONN_DEFAULTS
620
+ return {
621
+ type: o.defConnType || d.type,
622
+ fontSize: o.defConnFontSize || d.fontSize,
623
+ fontSizeMin: o.defConnFontSizeMin || d.fontSizeMin,
624
+ fontSizeMax: o.defConnFontSizeMax || d.fontSizeMax,
625
+ fontColor: o.defConnFontColor || d.fontColor,
626
+ edgeColor: o.defConnEdgeColor || d.edgeColor,
627
+ edgeWidth: o.defConnEdgeWidth !== undefined ? o.defConnEdgeWidth : d.edgeWidth,
628
+ edgeDasharray: o.defConnEdgeDasharray || '',
629
+ markerEnd: o.defConnMarkerEnd || d.markerEnd,
630
+ animated: o.defConnAnimated !== undefined ? o.defConnAnimated : d.animated,
631
+ defOffset: o.defOffset != null ? o.defOffset : d.defOffset,
632
+ }
633
+ },
634
+
635
+ //(效能重構)拖曳/縮放ghost改由getDragGhost細粒度提供(per-node反應式鍵), 不再整包重建nodes陣列
636
+ //why: 舊renderNodes每步mousemove產新陣列+新物件prop, 使兩Renderer與全部Node/EdgeWrapper(含WPopup/WTooltip子樹)
637
+ // 每步全量重渲染(84節點+95邊實測~128ms/步); 細粒度後僅被拖節點與其相連邊重渲染
638
+ isBoxSelectPressed() {
639
+ return this.multiSelectEnabled && !!this.keysPressed[this.boxSelectionKeyCode]
640
+ },
641
+ isMultiSelectPressed() {
642
+ return this.multiSelectEnabled && !!this.keysPressed[this.multiSelectionKeyCode]
643
+ },
644
+ },
645
+ methods: {
646
+ // --- Helpers (replace store methods) ---
647
+ nodeById(id) {
648
+ return this.nodes.find(n => n.id === id) || null
649
+ },
650
+ connById(id) {
651
+ return this.conns.find(c => c.id === id) || null
652
+ },
653
+ setSelectedNodes(ids) {
654
+ this.selectedNodes.splice(0, this.selectedNodes.length, ...ids)
655
+ },
656
+ setSelectedConns(ids) {
657
+ this.selectedConns.splice(0, this.selectedConns.length, ...ids)
658
+ },
659
+ clearSelection() {
660
+ this.selectedNodes.splice(0, this.selectedNodes.length)
661
+ this.selectedConns.splice(0, this.selectedConns.length)
662
+ },
663
+ removeNode(id) {
664
+ let nodes = this.nodes
665
+ let idx = nodes.findIndex(n => n.id === id)
666
+ if (idx === -1) return
667
+ nodes.splice(idx, 1)
668
+ // Remove connected conns
669
+ let conns = this.conns
670
+ for (let i = conns.length - 1; i >= 0; i--) {
671
+ if (conns[i].from === id || conns[i].to === id) {
672
+ conns.splice(i, 1)
673
+ }
674
+ }
675
+ let selIdx = this.selectedNodes.indexOf(id)
676
+ if (selIdx !== -1) this.selectedNodes.splice(selIdx, 1)
677
+ },
678
+ removeConn(id) {
679
+ let conns = this.conns
680
+ let idx = conns.findIndex(c => c.id === id)
681
+ if (idx === -1) return
682
+ conns.splice(idx, 1)
683
+ let selIdx = this.selectedConns.indexOf(id)
684
+ if (selIdx !== -1) this.selectedConns.splice(selIdx, 1)
685
+ },
686
+ addConn(conn) {
687
+ if (!conn.id || !conn.from || !conn.to) return
688
+ if (this.connById(conn.id)) return
689
+ if (!this.nodeById(conn.from) || !this.nodeById(conn.to)) return
690
+ this.conns.push(conn)
691
+ },
692
+ updateNodeInternals(id, internals) {
693
+ let existing = this.nodeInternals[id]
694
+ if (existing && existing.width === internals.width && existing.height === internals.height) return
695
+ this.$set(this.nodeInternals, id, internals)
696
+ },
697
+ setViewport({ x, y, zoom }) {
698
+ if (x !== undefined) this.viewport.x = x
699
+ if (y !== undefined) this.viewport.y = y
700
+ if (zoom !== undefined) this.viewport.zoom = zoom
701
+ },
702
+
703
+ // --- Key handling ---
704
+ onKeyDown(e) {
705
+ this.keysPressed = { ...this.keysPressed, [e.key]: true }
706
+ if (!this.locked && this.deleteKeyEnabled && (e.key === this.deleteKeyCode || e.key === 'Delete')) {
707
+ this.deleteSelectedElements()
708
+ }
709
+ },
710
+ onKeyUp(e) {
711
+ const copy = { ...this.keysPressed }
712
+ delete copy[e.key]
713
+ this.keysPressed = copy
714
+ },
715
+
716
+ // --- Canvas events ---
717
+ onCanvasClick(event) {
718
+ if (event.target.closest('.vue-flow__popup')) return
719
+ if (!event.target.closest('.vue-flow__node') && !event.target.closest('.vue-flow__edge')) {
720
+ this.clearSelection()
721
+ this.$emit('pane-click', event)
722
+ }
723
+ },
724
+ onCanvasContextMenu(event) {
725
+ this.$emit('pane-context-menu', event)
726
+ },
727
+ onCanvasDblClick(event) {
728
+ // Calculate flow-space position from the click
729
+ const rect = this.$refs.canvas.getContainerRect()
730
+ if (!rect) return
731
+ const vp = this.viewport
732
+ const flowX = (event.clientX - rect.left - vp.x) / vp.zoom
733
+ const flowY = (event.clientY - rect.top - vp.y) / vp.zoom
734
+
735
+ this.$emit('canvas-dblclick', {
736
+ event,
737
+ flowX,
738
+ flowY,
739
+ clientX: event.clientX,
740
+ clientY: event.clientY,
741
+ })
742
+ },
743
+ onCanvasMouseDown(event) {
744
+ if (event.target.closest && event.target.closest('.vue-flow__popup')) return
745
+ if (!this.locked && this.isBoxSelectPressed) {
746
+ this.startSelection(event)
747
+ return
748
+ }
749
+ if (this.panOnDrag && event.button === 0) {
750
+ const target = event.target
751
+ const isOnNode = target.closest && target.closest('.vue-flow__node')
752
+ const isOnHandle = target.closest && target.closest('.vue-flow__handle')
753
+ //齒輪等UI元素/左上角控制選單/連線轉折點之mousedown不啟動平移(取代其@mousedown.stop, 使mousedown仍能冒泡至window做popup互斥關閉;
754
+ // .vue-flow__panel=Controls垂直選單, .vue-flow__edge-waypoint=連線轉折點拖曳鈕, 否則按之再拖曳會誤觸發平移)
755
+ const isOnUi = target.closest && target.closest('.vue-flow__node-settings, .vue-flow__edge-settings, .vue-flow__panel, .vue-flow__edge-waypoint')
756
+ if (!isOnNode && !isOnHandle && !isOnUi) {
757
+ this.startPan(event)
758
+ }
759
+ }
760
+ },
761
+ onCanvasMouseMove(event) {
762
+ // Handled by document-level listener
763
+ },
764
+ onCanvasMouseUp(event) {
765
+ // Handled by document-level listener
766
+ },
767
+ onCanvasWheel(event) {
768
+ if (!this.zoomOnScroll) return
769
+ const delta = -event.deltaY * 0.001
770
+ const currentZoom = this.viewport.zoom
771
+ // fitView may set zoom below zoomMin; use the current level as the
772
+ // effective lower bound so wheel zoom does not jump the view back.
773
+ const zoomMinUse = Math.min(this.zoomMin, currentZoom)
774
+ const newZoom = Math.max(zoomMinUse, Math.min(this.zoomMax, currentZoom + delta * currentZoom))
775
+
776
+ const rect = this.$refs.canvas.getContainerRect()
777
+ if (!rect) return
778
+ const mouseX = event.clientX - rect.left
779
+ const mouseY = event.clientY - rect.top
780
+
781
+ const vp = this.viewport
782
+ const scale = newZoom / currentZoom
783
+ const newX = mouseX - (mouseX - vp.x) * scale
784
+ const newY = mouseY - (mouseY - vp.y) * scale
785
+
786
+ this.setViewport({ x: newX, y: newY, zoom: newZoom })
787
+ this.emitViewportChange()
788
+ },
789
+
790
+ // --- Document-level mouse ---
791
+ onDocMouseMove(event) {
792
+ if (this.isPanning) {
793
+ this.doPan(event)
794
+ }
795
+ else if (this.isDraggingNode) {
796
+ this.doDrag(event)
797
+ }
798
+ else if (this.isConnecting) {
799
+ this.doConnect(event)
800
+ }
801
+ else if (this.isSelecting) {
802
+ this.doSelection(event)
803
+ }
804
+ },
805
+ onDocMouseUp(event) {
806
+ if (this.isPanning) {
807
+ this.endPan()
808
+ }
809
+ if (this.isDraggingNode) {
810
+ this.endDrag(event)
811
+ }
812
+ if (this.isConnecting) {
813
+ this.endConnect(event)
814
+ }
815
+ if (this.isSelecting) {
816
+ this.endSelection(event)
817
+ }
818
+ },
819
+
820
+ // --- Pan ---
821
+ startPan(event) {
822
+ this.isPanning = true
823
+ this.panStartPos = { x: event.clientX, y: event.clientY }
824
+ },
825
+ doPan(event) {
826
+ const dx = event.clientX - this.panStartPos.x
827
+ const dy = event.clientY - this.panStartPos.y
828
+ this.panStartPos = { x: event.clientX, y: event.clientY }
829
+
830
+ let x = this.viewport.x + dx
831
+ let y = this.viewport.y + dy
832
+
833
+ if (this.panLimits) {
834
+ const clamped = clampPosition({ x, y }, this.panLimits)
835
+ x = clamped.x
836
+ y = clamped.y
837
+ }
838
+
839
+ this.viewport.x = x
840
+ this.viewport.y = y
841
+ },
842
+ endPan() {
843
+ this.isPanning = false
844
+ this.panStartPos = null
845
+ this.emitViewportChange()
846
+ },
847
+
848
+ // --- Node drag ---
849
+ onNodeDragStart({ node, event }) {
850
+ if (this.locked || !this.nodesDraggable) return
851
+ this.isDraggingNode = true
852
+ this.draggingNodeId = node.id
853
+ this.dragStartPos = { x: event.clientX, y: event.clientY }
854
+
855
+ if (this.selectNodesOnDrag && !this.isMultiSelectPressed) {
856
+ this.setSelectedNodes([node.id])
857
+ this.setSelectedConns([])
858
+ }
859
+
860
+ // Cache start positions for drag
861
+ const starts = {}
862
+ this.selectedNodes.forEach(id => {
863
+ const n = this.nodeById(id)
864
+ if (n) starts[id] = { x: n.position.x, y: n.position.y }
865
+ })
866
+ if (!starts[node.id]) {
867
+ const n = this.nodeById(node.id)
868
+ if (n) starts[node.id] = { x: n.position.x, y: n.position.y }
869
+ }
870
+ this.dragNodeStartPositions = starts
871
+
872
+ //啟用被拖節點之ghost(per-key賦值, 僅通知讀該鍵之元件), 之後每步僅原地改x/y
873
+ for (let id in starts) {
874
+ if (!(id in this.dragPositions)) {
875
+ this.$set(this.dragPositions, id, null)
876
+ }
877
+ this.dragPositions[id] = { x: starts[id].x, y: starts[id].y }
878
+ }
879
+
880
+ this.$emit('node-drag-start', { node, event })
881
+ },
882
+ doDrag(event) {
883
+ const zoom = this.viewport.zoom
884
+ const dx = (event.clientX - this.dragStartPos.x) / zoom
885
+ const dy = (event.clientY - this.dragStartPos.y) / zoom
886
+ const snap = this.snapToGrid
887
+
888
+ for (let id in this.dragNodeStartPositions) {
889
+ const start = this.dragNodeStartPositions[id]
890
+ let x = start.x + dx
891
+ let y = start.y + dy
892
+ if (snap) {
893
+ const s = snapPosition({ x, y }, this.snapGridSize)
894
+ x = s.x
895
+ y = s.y
896
+ }
897
+ const gg = this.dragPositions && this.dragPositions[id]
898
+ if (gg) {
899
+ gg.x = x
900
+ gg.y = y
901
+ }
902
+ }
903
+ },
904
+ endDrag(event) {
905
+ // Write final positions back to opt.nodes, 並關閉ghost(per-key設回null)
906
+ if (this.dragNodeStartPositions) {
907
+ for (let id in this.dragNodeStartPositions) {
908
+ let pos = this.dragPositions[id]
909
+ let node = this.nodeById(id)
910
+ if (node && pos) {
911
+ node.position.x = pos.x
912
+ node.position.y = pos.y
913
+ }
914
+ this.dragPositions[id] = null
915
+ }
916
+ }
917
+ const dragNode = this.nodeById(this.draggingNodeId)
918
+ this.isDraggingNode = false
919
+ this.draggingNodeId = null
920
+ this.dragStartPos = null
921
+ this.dragNodeStartPositions = null
922
+ clearStepCache()
923
+ if (dragNode) {
924
+ this.$emit('node-drag-stop', { node: dragNode, event })
925
+ }
926
+ this.emitNodesUpdate()
927
+ },
928
+
929
+ // --- Connection ---
930
+ onConnectStart(payload) {
931
+ if (this.locked || !this.nodesConnectable) return
932
+ //建線只能自source(連出)型handle出發: target(連入)型handle拖曳不啟動建線, 維持方向語義
933
+ if (payload.handleType !== 'source') return
934
+ this.isConnecting = true
935
+ this.connectingFrom = payload
936
+ // Lock cursor to default during connecting, only handles show crosshair
937
+ this._connectCursorStyle = document.createElement('style')
938
+ this._connectCursorStyle.textContent = '* { cursor: default !important; } .vue-flow__handle { cursor: crosshair !important; } .vue-flow__node-settings, .vue-flow__edge-settings, .vue-flow__resize { opacity: 0 !important; pointer-events: none !important; }'
939
+ document.head.appendChild(this._connectCursorStyle)
940
+
941
+ const node = this.nodeById(payload.nodeId)
942
+ if (!node) return
943
+ const pos = getHandlePosition(
944
+ node, payload.handlePosition,
945
+ this.nodeInternals[payload.nodeId] || {}
946
+ )
947
+ this.connLineFromX = pos.x
948
+ this.connLineFromY = pos.y
949
+ this.connLineFromPosition = payload.handlePosition
950
+ this.connLineToX = pos.x
951
+ this.connLineToY = pos.y
952
+
953
+ this.$emit('connect-start', {
954
+ nodeId: payload.nodeId,
955
+ handleId: payload.handleId,
956
+ handleType: payload.handleType,
957
+ })
958
+ },
959
+ doConnect(event) {
960
+ const rect = this.$refs.canvas.getContainerRect()
961
+ if (!rect) return
962
+ const vp = this.viewport
963
+ this.connLineToX = (event.clientX - rect.left - vp.x) / vp.zoom
964
+ this.connLineToY = (event.clientY - rect.top - vp.y) / vp.zoom
965
+ },
966
+ endConnect(event) {
967
+ // Find handle element under cursor
968
+ const el = document.elementFromPoint(event.clientX, event.clientY)
969
+ const handleEl = el && el.closest && el.closest('.vue-flow__handle')
970
+
971
+ if (handleEl && this.connectingFrom && handleEl.dataset.handleType === 'target') {
972
+ //落點限target(連入)型handle: 連出點不可被當作輸入端(如input節點僅有連出點, 不可作為連線終點)
973
+ const toNodeEl = handleEl.closest('.vue-flow__node')
974
+ const toNodeId = toNodeEl ? toNodeEl.dataset.id : null
975
+
976
+ if (toNodeId) {
977
+ const connection = {
978
+ from: this.connectingFrom.nodeId,
979
+ to: toNodeId,
980
+ //逐邊錨點: 起點=實際拖曳出發之連接點方位, 迄點=落點handle之方位(缺則不設, 回退節點層級/預設)
981
+ ...(this.connectingFrom.handlePosition ? { fromPosition: this.connectingFrom.handlePosition } : {}),
982
+ ...(handleEl.dataset.handlePosition ? { toPosition: handleEl.dataset.handlePosition } : {}),
983
+ }
984
+
985
+ const valid = isValidConnection(
986
+ connection,
987
+ this.nodes,
988
+ this.conns,
989
+ this.funValidConnCreating
990
+ )
991
+
992
+ if (valid) {
993
+ const connId = `e${connection.from}-${connection.to}`
994
+ const conn = {
995
+ id: this.connById(connId) ? generateId() : connId,
996
+ ...connection,
997
+ }
998
+
999
+ this.addConn(conn)
1000
+ this.emitConnsUpdate()
1001
+ this.$emit('connect', connection)
1002
+ }
1003
+ }
1004
+ }
1005
+
1006
+ this.$emit('connect-end', event)
1007
+ this.isConnecting = false
1008
+ this.connectingFrom = null
1009
+ if (this._connectCursorStyle) {
1010
+ document.head.removeChild(this._connectCursorStyle)
1011
+ this._connectCursorStyle = null
1012
+ }
1013
+ },
1014
+
1015
+ // --- Selection ---
1016
+ onNodeClick({ node, event }) {
1017
+ if (!this.elementsSelectable) return
1018
+ if (this.isMultiSelectPressed) {
1019
+ const idx = this.selectedNodes.indexOf(node.id)
1020
+ if (idx === -1) {
1021
+ this.selectedNodes.push(node.id)
1022
+ }
1023
+ else {
1024
+ this.selectedNodes.splice(idx, 1)
1025
+ }
1026
+ }
1027
+ else {
1028
+ this.setSelectedNodes([node.id])
1029
+ this.setSelectedConns([])
1030
+ }
1031
+ this.emitSelectionChange()
1032
+
1033
+ this.$emit('node-click', { node, event })
1034
+ },
1035
+ onNodeDoubleClick(payload) {
1036
+ this.$emit('node-double-click', payload)
1037
+ },
1038
+ onNodeContextMenu(payload) {
1039
+ this.$emit('node-context-menu', payload)
1040
+ },
1041
+ onNodeSettingsClick(payload) {
1042
+ this.$emit('node-settings-click', payload)
1043
+ },
1044
+ onNodeSettingsUpdate({ node, key, value }) {
1045
+ let n = this.nodeById(node.id)
1046
+ if (!n) return
1047
+ let oldType = n.type
1048
+ this.$set(n, key, value)
1049
+ if (key === 'type' && oldType !== value) {
1050
+ let nodeId = n.id
1051
+ let hasTo = value === 'input' || value === 'basic'
1052
+ let hasFrom = value === 'output' || value === 'basic'
1053
+ let conns = this.conns
1054
+ for (let i = conns.length - 1; i >= 0; i--) {
1055
+ let c = conns[i]
1056
+ if ((!hasTo && c.from === nodeId) || (!hasFrom && c.to === nodeId)) {
1057
+ conns.splice(i, 1)
1058
+ }
1059
+ }
1060
+ }
1061
+ this.$emit('node-settings-update', { node: n, key, value })
1062
+ },
1063
+ onNodeSettingsDelete({ node }) {
1064
+ this.removeNode(node.id)
1065
+ clearStepCache()
1066
+ this.emitNodesUpdate()
1067
+ this.emitConnsUpdate()
1068
+ this.$emit('node-settings-delete', { node })
1069
+ },
1070
+ onNodeMouseEnter({ node, event }) {
1071
+ this.$emit('node-mouseenter', { node, event })
1072
+ },
1073
+ onNodeMouseLeave({ node, event }) {
1074
+ this.$emit('node-mouseleave', { node, event })
1075
+ },
1076
+ onConnClick({ conn, event }) {
1077
+ if (!this.elementsSelectable) return
1078
+ if (this.isMultiSelectPressed) {
1079
+ const idx = this.selectedConns.indexOf(conn.id)
1080
+ if (idx === -1) {
1081
+ this.selectedConns.push(conn.id)
1082
+ }
1083
+ else {
1084
+ this.selectedConns.splice(idx, 1)
1085
+ }
1086
+ }
1087
+ else {
1088
+ this.setSelectedConns([conn.id])
1089
+ this.setSelectedNodes([])
1090
+ }
1091
+ this.emitSelectionChange()
1092
+
1093
+ this.$emit('conn-click', { conn, event })
1094
+ },
1095
+ onConnDoubleClick(payload) {
1096
+ this.$emit('conn-double-click', payload)
1097
+ },
1098
+ onConnContextMenu(payload) {
1099
+ this.$emit('conn-context-menu', payload)
1100
+ },
1101
+ onConnMouseEnter({ conn, event }) {
1102
+ this.$emit('conn-mouseenter', { conn, event })
1103
+ },
1104
+ onConnMouseLeave({ conn, event }) {
1105
+ this.$emit('conn-mouseleave', { conn, event })
1106
+ },
1107
+ onConnSettingsClick(payload) {
1108
+ this.$emit('conn-settings-click', payload)
1109
+ },
1110
+ onConnSettingsUpdate({ conn, key, value }) {
1111
+ let c = this.connById(conn.id)
1112
+ if (c) {
1113
+ this.$set(c, key, value)
1114
+ this.$emit('conn-settings-update', { conn: c, key, value })
1115
+ }
1116
+ },
1117
+ onConnSettingsDelete({ conn }) {
1118
+ this.removeConn(conn.id)
1119
+ clearStepCache()
1120
+ this.emitConnsUpdate()
1121
+ this.$emit('conn-settings-delete', { conn })
1122
+ },
1123
+
1124
+ startSelection(event) {
1125
+ this.isSelecting = true
1126
+ const rect = this.$refs.canvas.getContainerRect()
1127
+ if (!rect) return
1128
+ this.selectionStartPos = {
1129
+ x: event.clientX - rect.left,
1130
+ y: event.clientY - rect.top,
1131
+ }
1132
+ this.selectionBox = {
1133
+ x: this.selectionStartPos.x,
1134
+ y: this.selectionStartPos.y,
1135
+ width: 0,
1136
+ height: 0,
1137
+ }
1138
+ },
1139
+ doSelection(event) {
1140
+ const rect = this.$refs.canvas.getContainerRect()
1141
+ if (!rect) return
1142
+ const currentX = event.clientX - rect.left
1143
+ const currentY = event.clientY - rect.top
1144
+ const x = Math.min(this.selectionStartPos.x, currentX)
1145
+ const y = Math.min(this.selectionStartPos.y, currentY)
1146
+ const width = Math.abs(currentX - this.selectionStartPos.x)
1147
+ const height = Math.abs(currentY - this.selectionStartPos.y)
1148
+ this.selectionBox = { x, y, width, height }
1149
+ },
1150
+ endSelection() {
1151
+ if (this.selectionBox) {
1152
+ const box = this.selectionBox
1153
+ const vp = this.viewport
1154
+ // Convert screen-space box to graph-space
1155
+ const graphBox = {
1156
+ x: (box.x - vp.x) / vp.zoom,
1157
+ y: (box.y - vp.y) / vp.zoom,
1158
+ width: box.width / vp.zoom,
1159
+ height: box.height / vp.zoom,
1160
+ }
1161
+ const overlapping = getOverlappingNodes(graphBox, this.nodes, this.nodeInternals)
1162
+ const nodeIds = overlapping.map(n => n.id)
1163
+ this.setSelectedNodes(nodeIds)
1164
+ // Auto-select conns whose both ends are within selected nodes
1165
+ let nodeIdSet = new Set(nodeIds)
1166
+ let connIds = this.conns
1167
+ .filter(c => nodeIdSet.has(c.from) && nodeIdSet.has(c.to))
1168
+ .map(c => c.id)
1169
+ this.setSelectedConns(connIds)
1170
+ this.emitSelectionChange()
1171
+ }
1172
+ this.isSelecting = false
1173
+ this.selectionStartPos = null
1174
+ this.selectionBox = null
1175
+ },
1176
+
1177
+ // --- Delete ---
1178
+ deleteSelectedElements() {
1179
+ const { nodes, conns } = this.getSelectedElements()
1180
+ if (nodes.length === 0 && conns.length === 0) return
1181
+
1182
+ nodes.forEach(n => {
1183
+ if (n.deletable !== false) this.removeNode(n.id)
1184
+ })
1185
+ conns.forEach(e => {
1186
+ if (e.deletable !== false) this.removeConn(e.id)
1187
+ })
1188
+ this.clearSelection()
1189
+
1190
+ this.$emit('delete', { nodes, conns })
1191
+ this.emitNodesUpdate()
1192
+ this.emitConnsUpdate()
1193
+ },
1194
+
1195
+ // --- Node dimensions ---
1196
+ onNodeDimensions({ nodeId, width, height }) {
1197
+ this.updateNodeInternals(nodeId, { width, height })
1198
+ },
1199
+
1200
+ onNodeResize({ nodeId, width, height, x, y }) {
1201
+ if (this.locked) return
1202
+ //縮放ghost併入dragPositions同一per-key機制: 首步建物件, 之後原地改欄位
1203
+ if (!(nodeId in this.dragPositions)) {
1204
+ this.$set(this.dragPositions, nodeId, null)
1205
+ }
1206
+ const g = this.dragPositions[nodeId]
1207
+ if (!g) {
1208
+ this.dragPositions[nodeId] = { x, y, width, height }
1209
+ }
1210
+ else {
1211
+ g.x = x
1212
+ g.y = y
1213
+ g.width = width
1214
+ g.height = height
1215
+ }
1216
+ this.updateNodeInternals(nodeId, { width, height })
1217
+ },
1218
+ onNodeResizeEnd({ nodeId, width, height, x, y }) {
1219
+ let node = this.nodeById(nodeId)
1220
+ if (node) {
1221
+ node.width = width
1222
+ node.height = height
1223
+ node.position.x = x
1224
+ node.position.y = y
1225
+ }
1226
+ this.dragPositions[nodeId] = null
1227
+ clearStepCache()
1228
+ this.emitNodesUpdate()
1229
+ },
1230
+
1231
+ // --- Helpers ---
1232
+ updateNodePosition(id, position) {
1233
+ let node = this.nodeById(id)
1234
+ if (!node) return
1235
+ node.position.x = position.x
1236
+ node.position.y = position.y
1237
+ },
1238
+ getSelectedElements() {
1239
+ return {
1240
+ nodes: this.nodes.filter(n => this.selectedNodes.includes(n.id)),
1241
+ conns: this.conns.filter(c => this.selectedConns.includes(c.id)),
1242
+ }
1243
+ },
1244
+
1245
+ // --- Emit helpers ---
1246
+ emitNodesUpdate() {
1247
+ this.$emit('update:nodes', [...this.nodes])
1248
+ },
1249
+ emitConnsUpdate() {
1250
+ this.$emit('update:conns', [...this.conns])
1251
+ },
1252
+ emitViewportChange() {
1253
+ this.$emit('viewport-change', { ...this.viewport })
1254
+ },
1255
+ emitSelectionChange() {
1256
+ this.$emit('selection-change', this.getSelectedElements())
1257
+ },
1258
+
1259
+ // --- Public API ---
1260
+ fitView(padding) {
1261
+ padding = padding || 50
1262
+ let nodes = this.nodes.filter(n => !n.hidden)
1263
+ if (nodes.length === 0) return
1264
+ let internals = this.nodeInternals
1265
+ let minX = Infinity
1266
+ let minY = Infinity
1267
+ let maxX = -Infinity
1268
+ let maxY = -Infinity
1269
+ nodes.forEach(n => {
1270
+ let w = (internals[n.id] && internals[n.id].width) || n.width || 150
1271
+ let h = (internals[n.id] && internals[n.id].height) || n.height || 40
1272
+ minX = Math.min(minX, n.position.x)
1273
+ minY = Math.min(minY, n.position.y)
1274
+ maxX = Math.max(maxX, n.position.x + w)
1275
+ maxY = Math.max(maxY, n.position.y + h)
1276
+ })
1277
+ let rect = this.$refs.canvas ? this.$refs.canvas.getContainerRect() : null
1278
+ let cw = rect ? rect.width : this.widthInp
1279
+ let ch = rect ? rect.height : this.heightInp
1280
+ let gw = maxX - minX + padding * 2
1281
+ let gh = maxY - minY + padding * 2
1282
+ let zoom = Math.min(cw / gw, ch / gh, 2)
1283
+ this.viewport.zoom = zoom
1284
+ this.viewport.x = (cw - (maxX + minX) * zoom) / 2
1285
+ this.viewport.y = (ch - (maxY + minY) * zoom) / 2
1286
+ this.emitViewportChange()
1287
+ },
1288
+ zoomAtCenter(factor) {
1289
+ const currentZoom = this.viewport.zoom
1290
+ const zoomMinUse = Math.min(this.zoomMin, currentZoom)
1291
+ const newZoom = Math.max(zoomMinUse, Math.min(this.zoomMax, currentZoom * factor))
1292
+ const rect = this.$refs.canvas ? this.$refs.canvas.getContainerRect() : null
1293
+ const cx = rect ? rect.width / 2 : this.widthInp / 2
1294
+ const cy = rect ? rect.height / 2 : this.heightInp / 2
1295
+ const vp = this.viewport
1296
+ const scale = newZoom / currentZoom
1297
+ this.setViewport({
1298
+ x: cx - (cx - vp.x) * scale,
1299
+ y: cy - (cy - vp.y) * scale,
1300
+ zoom: newZoom,
1301
+ })
1302
+ this.emitViewportChange()
1303
+ },
1304
+ zoomIn() {
1305
+ this.zoomAtCenter(1.2)
1306
+ },
1307
+ zoomOut() {
1308
+ this.zoomAtCenter(1 / 1.2)
1309
+ },
1310
+ toggleInteractive() {
1311
+ this.locked = !this.locked
1312
+ this.$emit('toggle-interactive', this.locked)
1313
+ },
1314
+ panToNode(nodeId, opt) {
1315
+ opt = opt || {}
1316
+ let node = this.nodeById(nodeId)
1317
+ if (!node) return false
1318
+ let internals = this.nodeInternals[nodeId]
1319
+ let w = (internals && internals.width) || node.width || 150
1320
+ let h = (internals && internals.height) || node.height || 40
1321
+ let cx = node.position.x + w / 2
1322
+ let cy = node.position.y + h / 2
1323
+ let rect = this.$refs.canvas ? this.$refs.canvas.getContainerRect() : null
1324
+ let cw = (rect && rect.width) || this.widthInp
1325
+ let ch = (rect && rect.height) || this.heightInp
1326
+ let zoom = opt.zoom !== undefined ? opt.zoom : this.viewport.zoom
1327
+ let duration = opt.duration !== undefined ? opt.duration : 400
1328
+ let target = { x: cw / 2 - cx * zoom, y: ch / 2 - cy * zoom, zoom }
1329
+ if (this._panAnimId) {
1330
+ cancelAnimationFrame(this._panAnimId)
1331
+ this._panAnimId = null
1332
+ }
1333
+ let finish = () => {
1334
+ this.setViewport(target)
1335
+ this.emitViewportChange()
1336
+ if (opt.openPopup === true) {
1337
+ this.$nextTick(() => this.openNodeInfoPopup(nodeId))
1338
+ }
1339
+ }
1340
+ if (!(duration > 0)) {
1341
+ finish()
1342
+ return true
1343
+ }
1344
+ let from = { x: this.viewport.x, y: this.viewport.y, zoom: this.viewport.zoom }
1345
+ let easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
1346
+ let startTs = null
1347
+ let stepFrame = (ts) => {
1348
+ if (startTs === null) startTs = ts
1349
+ let t = Math.min((ts - startTs) / duration, 1)
1350
+ let k = easeInOutCubic(t)
1351
+ this.setViewport({
1352
+ x: from.x + (target.x - from.x) * k,
1353
+ y: from.y + (target.y - from.y) * k,
1354
+ zoom: from.zoom + (target.zoom - from.zoom) * k,
1355
+ })
1356
+ if (t < 1) {
1357
+ this._panAnimId = requestAnimationFrame(stepFrame)
1358
+ }
1359
+ else {
1360
+ this._panAnimId = null
1361
+ finish()
1362
+ }
1363
+ }
1364
+ this._panAnimId = requestAnimationFrame(stepFrame)
1365
+ return true
1366
+ },
1367
+ openNodeInfoPopup(nodeId) {
1368
+ let r = this.$refs.nodeRenderer
1369
+ if (!r) return false
1370
+ return r.openNodeInfoPopup(nodeId)
1371
+ },
1372
+ openConnInfoPopup(connId) {
1373
+ let r = this.$refs.edgeRenderer
1374
+ if (!r) return false
1375
+ return r.openConnInfoPopup(connId)
1376
+ },
1377
+ getFlowData() {
1378
+ return {
1379
+ nodes: JSON.parse(JSON.stringify(this.nodes)),
1380
+ conns: JSON.parse(JSON.stringify(this.conns)),
1381
+ }
1382
+ },
1383
+ },
1384
+ }
1385
+ </script>
1386
+
1387
+ <style scoped>
1388
+
1389
+
1390
+ </style>