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