components/WFlowVue.vue

<template>
  <div :style="`width:${widthInp}px; height:${heightInp}px;`">
  <FlowCanvas
    v-if="inited"
    ref="canvas"
    @canvas-mousedown="onCanvasMouseDown"
    @canvas-mousemove="onCanvasMouseMove"
    @canvas-mouseup="onCanvasMouseUp"
    @canvas-wheel="onCanvasWheel"
    @canvas-dblclick="onCanvasDblClick"
    @canvas-click="onCanvasClick"
    @canvas-contextmenu="onCanvasContextMenu"
  >
    <BackgroundLayer
      :variant="platformBackgroundPatternType"
      :gap="platformBackgroundPatternGap"
      :size="platformBackgroundPatternSize"
      :pattern-color="platformBackgroundPatternColor"
      :bg-color="platformBackgroundColor"
      :viewport="viewport"
    />

    <ViewportTransform
      :viewport="viewport"
    >
      <EdgeRenderer
        ref="edgeRenderer"
        :conns="conns"
        :nodes="nodes"
        :node-internals="nodeInternals"
        :selected-conn-ids="selectedConns"
        :interactive="elementsSelectable"
        :locked="locked"
        :settings-popup-background-color="settingsPopupBackgroundColor"
        :settings-popup-text-color="settingsPopupTextColor"
        :settings-popup-text-font-size="settingsPopupTextFontSize"
        :infor-popup-background-color="inforPopupBackgroundColor"
        :infor-popup-title-text-color="inforPopupTitleTextColor"
        :infor-popup-title-text-font-size="inforPopupTitleTextFontSize"
        :infor-popup-description-text-color="inforPopupDescriptionTextColor"
        :infor-popup-description-text-font-size="inforPopupDescriptionTextFontSize"
        :settings-enabled="connsSettingsEnabled"
        :settings-excludes="connsSettingsExcludes"
        @conn-click="onConnClick"
        @conn-double-click="onConnDoubleClick"
        @conn-context-menu="onConnContextMenu"
        @conn-mouseenter="onConnMouseEnter"
        @conn-mouseleave="onConnMouseLeave"
        @conn-settings-click="onConnSettingsClick"
        @conn-settings-update="onConnSettingsUpdate"
        @conn-settings-delete="onConnSettingsDelete"
      >
        <template v-if="$scopedSlots['conn-popup']" v-slot:conn-popup="scope">
          <slot name="conn-popup" v-bind="scope" />
        </template>
      </EdgeRenderer>

      <NodeRenderer
        ref="nodeRenderer"
        :nodes="nodes"
        :selected-node-ids="selectedNodes"
        :nodes-draggable="nodesDraggable"
        :nodes-connectable="nodesConnectable"
        :locked="locked"
        :nodes-resizable="nodesResizable"
        :settings-popup-background-color="settingsPopupBackgroundColor"
        :settings-popup-text-color="settingsPopupTextColor"
        :settings-popup-text-font-size="settingsPopupTextFontSize"
        :infor-popup-background-color="inforPopupBackgroundColor"
        :infor-popup-title-text-color="inforPopupTitleTextColor"
        :infor-popup-title-text-font-size="inforPopupTitleTextFontSize"
        :infor-popup-description-text-color="inforPopupDescriptionTextColor"
        :infor-popup-description-text-font-size="inforPopupDescriptionTextFontSize"
        :snap-grid-size="snapToGrid ? snapGridSize : null"
        :settings-enabled="nodesSettingsEnabled"
        :settings-excludes="nodesSettingsExcludes"
        @drag-start="onNodeDragStart"
        @node-click="onNodeClick"
        @node-double-click="onNodeDoubleClick"
        @node-context-menu="onNodeContextMenu"
        @node-settings-click="onNodeSettingsClick"
        @node-settings-update="onNodeSettingsUpdate"
        @node-settings-delete="onNodeSettingsDelete"
        @node-mouseenter="onNodeMouseEnter"
        @node-mouseleave="onNodeMouseLeave"
        @connect-start="onConnectStart"
        @dimensions="onNodeDimensions"
        @node-resize="onNodeResize"
        @node-resize-end="onNodeResizeEnd"
      >
        <template v-if="$scopedSlots['node-popup']" v-slot:node-popup="scope">
          <slot name="node-popup" v-bind="scope" />
        </template>
      </NodeRenderer>

      <ConnectionLine
        :active="isConnecting"
        :source-x="connLineFromX"
        :source-y="connLineFromY"
        :source-position="connLineFromPosition"
        :target-x="connLineToX"
        :target-y="connLineToY"
        :type="defConnCreatingType"
        :line-style="defConnCreatingStyle"
      />

      <slot name="viewport-overlay" />
    </ViewportTransform>

    <SelectionBox :box="selectionBox" />

    <Controls
      :locked="locked"
      :menu-y-shift="menuYShiftInp"
      position="top-left"
      @zoom-in="zoomIn"
      @zoom-out="zoomOut"
      @fit-view="fitView"
      @toggle-interactive="toggleInteractive"
    />

  </FlowCanvas>
  </div>
</template>

<script>
import FlowCanvas from './canvas/FlowCanvas.vue'
import ViewportTransform from './canvas/ViewportTransform.vue'
import BackgroundLayer from './canvas/BackgroundLayer.vue'
import SelectionBox from './canvas/SelectionBox.vue'
import NodeRenderer from './nodes/NodeRenderer.vue'
import EdgeRenderer from './edges/EdgeRenderer.vue'
import ConnectionLine from './edges/ConnectionLine.vue'
import Controls from './ui/Controls.vue'
import { getHandlePosition, getOverlappingNodes, snapPosition, clampPosition } from '../js/geometry'
import { clearStepCache } from '../js/edgePath'
import { isValidConnection, generateId } from '../js/graph'
import { NODE_DEFAULTS, CONN_DEFAULTS } from '../js/defaults'

/**
 * WFlowVue — Vue 2 flow/graph editor component.
 *
 * All configuration is passed via the `opt` prop object.
 *
 * @prop {Object} opt
 *
 * ─── Canvas ────────────────────────────────────────────────────────────
 * @prop {number}   [opt.width=800]                       Canvas width (px)
 * @prop {number}   [opt.height=600]                      Canvas height (px)
 * @prop {Array}    [opt.nodes=[]]                        Node data array
 * @prop {Array}    [opt.conns=[]]                        Connection data array
 *
 * ─── Interaction ───────────────────────────────────────────────────────
 * @prop {boolean}  [opt.nodesDraggable=true]             Allow node dragging
 * @prop {boolean}  [opt.nodesConnectable=true]           Allow creating connections
 * @prop {boolean}  [opt.nodesResizable=true]            Allow resizing nodes (per-node override: node.resizable)
 * @prop {boolean}  [opt.elementsSelectable=true]         Allow selecting nodes/conns
 * @prop {boolean}  [opt.locked=false]                    Initial interactive-lock state (afterwards toggled via Controls lock button, emits toggle-interactive)
 * @prop {boolean}  [opt.nodesSettingsEnabled=true]       Show built-in node settings popup (gear icon)
 * @prop {boolean}  [opt.connsSettingsEnabled=true]       Show built-in connection settings popup (gear icon)
 * @prop {Array}    [opt.nodesSettingsExcludes=[]]        Field keys hidden in node settings form (e.g. ['name','description'])
 * @prop {Array}    [opt.connsSettingsExcludes=[]]        Field keys hidden in connection settings form
 * @prop {boolean}  [opt.selectNodesOnDrag=true]          Select node when drag starts
 * @prop {boolean}  [opt.deleteKeyEnabled=false]           Enable keyboard deletion of selected elements
 * @prop {string}   [opt.deleteKeyCode='Backspace']       Key to delete selected elements (requires deleteKeyEnabled)
 * @prop {boolean}  [opt.multiSelectEnabled=true]          Enable multi-selection (box select + Shift+Click)
 * @prop {string}   [opt.boxSelectionKeyCode='Shift']     Key to hold for box selection (drag on canvas)
 * @prop {string}   [opt.multiSelectionKeyCode='Shift']   Key to hold for Shift+Click add/remove selection
 * @prop {boolean}  [opt.zoomOnScroll=true]               Zoom with mouse wheel
 * @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)
 * @prop {number}   [opt.zoomMax=2]                       Maximum zoom level
 * @prop {boolean}  [opt.panOnDrag=true]                  Pan canvas by dragging background
 * @prop {Array}    [opt.center=[0,0]]            Initial viewport center [x, y]
 * @prop {number}   [opt.zoom=1]                  Initial viewport zoom level
 * @prop {Array}    [opt.panLimits=null]                  Pan limits [[minX,minY],[maxX,maxY]]
 * @prop {boolean}  [opt.snapToGrid=false]                Snap node positions to grid
 * @prop {number}   [opt.snapGridSize=20]                  Grid cell size (px, used for both drag snap and resize snap)
 *
 * ─── Platform ────────────────────────────────────────────────────────
 * @prop {string}   [opt.platformBackgroundPatternType='dots']        Background pattern: 'dots' | 'lines' | 'cross'
 * @prop {number}   [opt.platformBackgroundPatternGap=20]                Pattern spacing (px)
 * @prop {number}   [opt.platformBackgroundPatternSize=1]                Pattern element size
 * @prop {string}   [opt.platformBackgroundPatternColor='#81818a'] Pattern color
 * @prop {string}   [opt.platformBackgroundColor='#fff']          Canvas background color
 *
 * ─── Settings Popup ────────────────────────────────────────────────────
 * @prop {string}   [opt.settingsPopupBackgroundColor='#fff'] Settings popup background
 * @prop {string}   [opt.settingsPopupTextColor='#333']       Settings popup text color
 * @prop {string}   [opt.settingsPopupTextFontSize='12px']    Settings popup font size
 *
 * ─── Infor Popup ────────────────────────────────────────────────────────
 * @prop {string}   [opt.inforPopupBackgroundColor='#fff']              Info popup background
 * @prop {string}   [opt.inforPopupTitleTextColor='#333']              Info popup title text color
 * @prop {string}   [opt.inforPopupTitleTextFontSize='12px']           Info popup title font size
 * @prop {string}   [opt.inforPopupDescriptionTextColor='#888']        Info popup description text color
 * @prop {string}   [opt.inforPopupDescriptionTextFontSize='10px']     Info popup description font size
 *
 * ─── Default Node ──────────────────────────────────────────
 * @prop {string}   [opt.defNodeType='basic']             Default node type: 'input' | 'basic' | 'output'
 * @prop {string}   [opt.defNodeShape='rectangle']        Default shape: 'rectangle' | 'diamond' | 'ellipse' | 'triangle' | ...
 * @prop {number}   [opt.defNodeWidth=100]                Default node width (px)
 * @prop {number}   [opt.defNodeHeight=40]                Default node height (px)
 * @prop {number}   [opt.defNodeFontSize=12]              Default node font size (px)
 * @prop {number}   [opt.defNodeFontSizeMin=1]            Min font size in settings
 * @prop {number}   [opt.defNodeFontSizeMax=72]           Max font size in settings
 * @prop {string}   [opt.defNodeFontColor='#333333']      Default node text color
 * @prop {string}   [opt.defNodeFaceColor='#ffffff']      Default node fill color
 * @prop {string}   [opt.defNodeEdgeColor='#bbbbbb']      Default node border color
 * @prop {number}   [opt.defNodeEdgeWidth=1]              Default node border width (px)
 * @prop {string}   [opt.defNodeToPosition='bottom']      Default outgoing handle position: 'top' | 'bottom' | 'left' | 'right'
 * @prop {string}   [opt.defNodeFromPosition='top']       Default incoming handle position
 * @prop {string}   [opt.defNodePopupDirection='right']   Default settings popup direction
 *
 * ─── Default Creating Connection ────────────────────────────────────────────────────────
 * @prop {string}   [opt.defConnCreatingType='bezier']     Drag-line type: 'bezier' | 'straight' | 'step' | 'smoothstep'
 * @prop {string}   [opt.defConnCreatingEdgeColor='#b1b1b7']  Drag-line color
 * @prop {number}   [opt.defConnCreatingEdgeWidth=1]          Drag-line width (px)
 * @prop {string}   [opt.defConnCreatingEdgeDasharray='5 5'] Drag-line dash pattern ('' for solid)
 * @prop {Function} [opt.funValidConnCreating=null]      Custom connection validator fn(connection) → boolean
 *
 * ─── Default Connection ────────────────────────────────────
 * @prop {string}   [opt.defConnType='bezier']            Default conn type: 'bezier' | 'straight' | 'step' | 'smoothstep'
 * @prop {number}   [opt.defConnFontSize=10]              Default conn label font size (px)
 * @prop {number}   [opt.defConnFontSizeMin=1]            Min font size in settings
 * @prop {number}   [opt.defConnFontSizeMax=72]           Max font size in settings
 * @prop {string}   [opt.defConnFontColor='#333333']      Default conn label text color
 * @prop {string}   [opt.defConnEdgeColor='#b1b1b7']      Default conn line color
 * @prop {number}   [opt.defConnEdgeWidth=1]              Default conn line width (px)
 * @prop {string}   [opt.defConnEdgeDasharray='']         Default conn dash pattern ('' for solid, '5 5' for dashed)
 * @prop {string}   [opt.defConnMarkerEnd='']             Default arrow marker: '' | 'arrow' | 'arrowclosed'
 * @prop {boolean}  [opt.defConnAnimated=false]           Default conn animation (dashed flow)
 * @prop {number}   [opt.defOffset=24]                    Step/smoothstep routing buffer (px)
 */
export default {
    components: {
        FlowCanvas,
        ViewportTransform,
        BackgroundLayer,
        SelectionBox,
        NodeRenderer,
        EdgeRenderer,
        ConnectionLine,
        Controls,
    },
    props: {
        opt: {
            type: Object,
            default: () => ({}),
        },
    },
    provide() {
        return {
            getDefNode: () => this.defNode,
            getDefConn: () => this.defConn,
            getConns: () => this.conns, //供節點元件推導實際使用之連接點方位(conn層級錨點)
            //拖曳/縮放ghost(細粒度): 回傳該節點進行中之暫時幾何({x,y}或{x,y,width,height}), 無則null
            getDragGhost: (id) => {
                return this.dragPositions[id] || null
            },
        }
    },
    data() {
        return {
            inited: false,

            // Viewport
            viewport: { x: 0, y: 0, zoom: 1 },

            // Selection
            selectedNodes: [],
            selectedConns: [],

            // UI state
            selectionBox: null,
            nodeInternals: {},

            // Interactive lock state (opt.locked sets the initial value only;
            // afterwards toggled via the Controls lock button)
            locked: this.opt.locked === true,

            // Drag state
            isDraggingNode: false,
            draggingNodeId: null,
            dragStartPos: null,
            dragNodeStartPositions: null,
            //拖曳/縮放ghost容器: 鍵隨nodes預建(值null=無ghost), 永不整包替換——
            //讀取端(Node/EdgeWrapper)依賴per-key, 拖曳開始/每步/結束皆僅通知被拖節點之讀者, 無全量重渲染
            dragPositions: {},

            // Pan state
            isPanning: false,
            panStartPos: null,

            // Connection state
            isConnecting: false,
            connectingFrom: null,
            connLineFromX: 0,
            connLineFromY: 0,
            connLineFromPosition: 'bottom',
            connLineToX: 0,
            connLineToY: 0,

            // Selection state
            isSelecting: false,
            selectionStartPos: null,

            // Key state
            keysPressed: {},

        }
    },
    watch: {
        //ghost鍵預建: 於節點首次渲染前備妥per-key反應式插槽, 讀者才能建立細粒度依賴
        nodes: {
            immediate: true,
            handler(ns) {
                for (const n of (ns || [])) {
                    if (!(n.id in this.dragPositions)) {
                        this.$set(this.dragPositions, n.id, null)
                    }
                }
            },
        },
        opt: {
            handler() {
                if (!this.inited) {
                    this.inited = true
                    let vc = this.center
                    if (vc) {
                        this.viewport.x = vc[0] || 0
                        this.viewport.y = vc[1] || 0
                    }
                    this.viewport.zoom = this.zoom
                    this.$emit('init')
                }
            },
            immediate: true,
        },
    },
    mounted() {
        document.addEventListener('keydown', this.onKeyDown)
        document.addEventListener('keyup', this.onKeyUp)
        document.addEventListener('mousemove', this.onDocMouseMove)
        document.addEventListener('mouseup', this.onDocMouseUp)
    },
    beforeDestroy() {
        if (this._panAnimId) {
            cancelAnimationFrame(this._panAnimId)
            this._panAnimId = null
        }
        document.removeEventListener('keydown', this.onKeyDown)
        document.removeEventListener('keyup', this.onKeyUp)
        document.removeEventListener('mousemove', this.onDocMouseMove)
        document.removeEventListener('mouseup', this.onDocMouseUp)
    },
    computed: {
        widthInp() {
            return this.opt.width || 800
        },
        heightInp() {
            return this.opt.height || 600
        },
        nodes() {
            return this.opt.nodes || []
        },
        conns() {
            return this.opt.conns || []
        },

        nodesDraggable() {
            return this.opt.nodesDraggable !== undefined ? this.opt.nodesDraggable : true
        },
        menuYShiftInp() {
            //垂直選單向下偏移量, 由宿主經opt設定(如宿主於左上角另置變更儲存鈕時, 令選單下移避讓)
            return typeof this.opt.menuYShift === 'number' ? this.opt.menuYShift : 0
        },
        nodesConnectable() {
            return this.opt.nodesConnectable !== undefined ? this.opt.nodesConnectable : true
        },
        nodesResizable() {
            return this.opt.nodesResizable !== undefined ? this.opt.nodesResizable : true
        },
        elementsSelectable() {
            return this.opt.elementsSelectable !== undefined ? this.opt.elementsSelectable : true
        },
        nodesSettingsEnabled() {
            return this.opt.nodesSettingsEnabled !== undefined ? this.opt.nodesSettingsEnabled : true
        },
        connsSettingsEnabled() {
            return this.opt.connsSettingsEnabled !== undefined ? this.opt.connsSettingsEnabled : true
        },
        nodesSettingsExcludes() {
            return this.opt.nodesSettingsExcludes || []
        },
        connsSettingsExcludes() {
            return this.opt.connsSettingsExcludes || []
        },
        selectNodesOnDrag() {
            return this.opt.selectNodesOnDrag !== undefined ? this.opt.selectNodesOnDrag : true
        },
        deleteKeyEnabled() {
            return this.opt.deleteKeyEnabled !== undefined ? this.opt.deleteKeyEnabled : false
        },
        deleteKeyCode() {
            return this.opt.deleteKeyCode || 'Backspace'
        },

        defConnCreatingType() {
            return this.opt.defConnCreatingType || 'bezier'
        },
        defConnCreatingEdgeColor() {
            return this.opt.defConnCreatingEdgeColor || '#b1b1b7'
        },
        defConnCreatingEdgeWidth() {
            return this.opt.defConnCreatingEdgeWidth !== undefined ? this.opt.defConnCreatingEdgeWidth : 1
        },
        defConnCreatingEdgeDasharray() {
            return this.opt.defConnCreatingEdgeDasharray || '5 5'
        },
        defConnCreatingStyle() {
            return {
                stroke: this.defConnCreatingEdgeColor,
                strokeWidth: this.defConnCreatingEdgeWidth,
                strokeDasharray: this.defConnCreatingEdgeDasharray,
            }
        },
        zoomOnScroll() {
            return this.opt.zoomOnScroll !== undefined ? this.opt.zoomOnScroll : true
        },
        panOnDrag() {
            return this.opt.panOnDrag !== undefined ? this.opt.panOnDrag : true
        },
        zoomMin() {
            return this.opt.zoomMin !== undefined ? this.opt.zoomMin : 0.5
        },
        zoomMax() {
            return this.opt.zoomMax !== undefined ? this.opt.zoomMax : 2
        },
        center() {
            return this.opt.center || [0, 0]
        },
        zoom() {
            return this.opt.zoom !== undefined ? this.opt.zoom : 1
        },
        panLimits() {
            return this.opt.panLimits || null
        },

        multiSelectEnabled() {
            return this.opt.multiSelectEnabled !== undefined ? this.opt.multiSelectEnabled : true
        },
        boxSelectionKeyCode() {
            return this.opt.boxSelectionKeyCode || 'Shift'
        },
        multiSelectionKeyCode() {
            return this.opt.multiSelectionKeyCode || 'Shift'
        },

        snapToGrid() {
            return this.opt.snapToGrid !== undefined ? this.opt.snapToGrid : false
        },
        snapGridSize() {
            return this.opt.snapGridSize || 20
        },

        platformBackgroundPatternType() {
            return this.opt.platformBackgroundPatternType || 'dots'
        },
        platformBackgroundPatternGap() {
            return this.opt.platformBackgroundPatternGap !== undefined ? this.opt.platformBackgroundPatternGap : 20
        },
        platformBackgroundPatternSize() {
            return this.opt.platformBackgroundPatternSize !== undefined ? this.opt.platformBackgroundPatternSize : 1
        },
        platformBackgroundPatternColor() {
            return this.opt.platformBackgroundPatternColor || '#81818a'
        },
        platformBackgroundColor() {
            return this.opt.platformBackgroundColor || '#fff'
        },

        // --- Settings popup styling ---
        settingsPopupBackgroundColor() {
            return this.opt.settingsPopupBackgroundColor || '#fff'
        },
        settingsPopupTextColor() {
            return this.opt.settingsPopupTextColor || '#333'
        },
        settingsPopupTextFontSize() {
            return this.opt.settingsPopupTextFontSize || '12px'
        },
        inforPopupBackgroundColor() {
            return this.opt.inforPopupBackgroundColor || '#fff'
        },
        inforPopupTitleTextColor() {
            return this.opt.inforPopupTitleTextColor || '#333'
        },
        inforPopupTitleTextFontSize() {
            return this.opt.inforPopupTitleTextFontSize || '12px'
        },
        inforPopupDescriptionTextColor() {
            return this.opt.inforPopupDescriptionTextColor || '#888'
        },
        inforPopupDescriptionTextFontSize() {
            return this.opt.inforPopupDescriptionTextFontSize || '10px'
        },

        funValidConnCreating() {
            return this.opt.funValidConnCreating || null
        },

        defNode() {
            let o = this.opt
            let d = NODE_DEFAULTS
            return {
                type: o.defNodeType || d.type,
                shape: o.defNodeShape || d.shape,
                width: o.defNodeWidth || d.width,
                height: o.defNodeHeight || d.height,
                fontSize: o.defNodeFontSize || d.fontSize,
                fontSizeMin: o.defNodeFontSizeMin || d.fontSizeMin,
                fontSizeMax: o.defNodeFontSizeMax || d.fontSizeMax,
                fontColor: o.defNodeFontColor || d.fontColor,
                faceColor: o.defNodeFaceColor || d.faceColor,
                edgeColor: o.defNodeEdgeColor || d.edgeColor,
                edgeWidth: o.defNodeEdgeWidth !== undefined ? o.defNodeEdgeWidth : d.edgeWidth,
                toPosition: o.defNodeToPosition || d.toPosition,
                fromPosition: o.defNodeFromPosition || d.fromPosition,
                popupDirection: o.defNodePopupDirection || d.popupDirection,
            }
        },
        defConn() {
            let o = this.opt
            let d = CONN_DEFAULTS
            return {
                type: o.defConnType || d.type,
                fontSize: o.defConnFontSize || d.fontSize,
                fontSizeMin: o.defConnFontSizeMin || d.fontSizeMin,
                fontSizeMax: o.defConnFontSizeMax || d.fontSizeMax,
                fontColor: o.defConnFontColor || d.fontColor,
                edgeColor: o.defConnEdgeColor || d.edgeColor,
                edgeWidth: o.defConnEdgeWidth !== undefined ? o.defConnEdgeWidth : d.edgeWidth,
                edgeDasharray: o.defConnEdgeDasharray || '',
                markerEnd: o.defConnMarkerEnd || d.markerEnd,
                animated: o.defConnAnimated !== undefined ? o.defConnAnimated : d.animated,
                defOffset: o.defOffset != null ? o.defOffset : d.defOffset,
            }
        },

        //(效能重構)拖曳/縮放ghost改由getDragGhost細粒度提供(per-node反應式鍵), 不再整包重建nodes陣列
        //why: 舊renderNodes每步mousemove產新陣列+新物件prop, 使兩Renderer與全部Node/EdgeWrapper(含WPopup/WTooltip子樹)
        //     每步全量重渲染(84節點+95邊實測~128ms/步); 細粒度後僅被拖節點與其相連邊重渲染
        isBoxSelectPressed() {
            return this.multiSelectEnabled && !!this.keysPressed[this.boxSelectionKeyCode]
        },
        isMultiSelectPressed() {
            return this.multiSelectEnabled && !!this.keysPressed[this.multiSelectionKeyCode]
        },
    },
    methods: {
    // --- Helpers (replace store methods) ---
        nodeById(id) {
            return this.nodes.find(n => n.id === id) || null
        },
        connById(id) {
            return this.conns.find(c => c.id === id) || null
        },
        setSelectedNodes(ids) {
            this.selectedNodes.splice(0, this.selectedNodes.length, ...ids)
        },
        setSelectedConns(ids) {
            this.selectedConns.splice(0, this.selectedConns.length, ...ids)
        },
        clearSelection() {
            this.selectedNodes.splice(0, this.selectedNodes.length)
            this.selectedConns.splice(0, this.selectedConns.length)
        },
        removeNode(id) {
            let nodes = this.nodes
            let idx = nodes.findIndex(n => n.id === id)
            if (idx === -1) return
            nodes.splice(idx, 1)
            // Remove connected conns
            let conns = this.conns
            for (let i = conns.length - 1; i >= 0; i--) {
                if (conns[i].from === id || conns[i].to === id) {
                    conns.splice(i, 1)
                }
            }
            let selIdx = this.selectedNodes.indexOf(id)
            if (selIdx !== -1) this.selectedNodes.splice(selIdx, 1)
        },
        removeConn(id) {
            let conns = this.conns
            let idx = conns.findIndex(c => c.id === id)
            if (idx === -1) return
            conns.splice(idx, 1)
            let selIdx = this.selectedConns.indexOf(id)
            if (selIdx !== -1) this.selectedConns.splice(selIdx, 1)
        },
        addConn(conn) {
            if (!conn.id || !conn.from || !conn.to) return
            if (this.connById(conn.id)) return
            if (!this.nodeById(conn.from) || !this.nodeById(conn.to)) return
            this.conns.push(conn)
        },
        updateNodeInternals(id, internals) {
            let existing = this.nodeInternals[id]
            if (existing && existing.width === internals.width && existing.height === internals.height) return
            this.$set(this.nodeInternals, id, internals)
        },
        setViewport({ x, y, zoom }) {
            if (x !== undefined) this.viewport.x = x
            if (y !== undefined) this.viewport.y = y
            if (zoom !== undefined) this.viewport.zoom = zoom
        },

        // --- Key handling ---
        onKeyDown(e) {
            this.keysPressed = { ...this.keysPressed, [e.key]: true }
            if (!this.locked && this.deleteKeyEnabled && (e.key === this.deleteKeyCode || e.key === 'Delete')) {
                this.deleteSelectedElements()
            }
        },
        onKeyUp(e) {
            const copy = { ...this.keysPressed }
            delete copy[e.key]
            this.keysPressed = copy
        },

        // --- Canvas events ---
        onCanvasClick(event) {
            if (event.target.closest('.vue-flow__popup')) return
            if (!event.target.closest('.vue-flow__node') && !event.target.closest('.vue-flow__edge')) {
                this.clearSelection()
                this.$emit('pane-click', event)
            }
        },
        onCanvasContextMenu(event) {
            this.$emit('pane-context-menu', event)
        },
        onCanvasDblClick(event) {
            // Calculate flow-space position from the click
            const rect = this.$refs.canvas.getContainerRect()
            if (!rect) return
            const vp = this.viewport
            const flowX = (event.clientX - rect.left - vp.x) / vp.zoom
            const flowY = (event.clientY - rect.top - vp.y) / vp.zoom

            this.$emit('canvas-dblclick', {
                event,
                flowX,
                flowY,
                clientX: event.clientX,
                clientY: event.clientY,
            })
        },
        onCanvasMouseDown(event) {
            if (event.target.closest && event.target.closest('.vue-flow__popup')) return
            if (!this.locked && this.isBoxSelectPressed) {
                this.startSelection(event)
                return
            }
            if (this.panOnDrag && event.button === 0) {
                const target = event.target
                const isOnNode = target.closest && target.closest('.vue-flow__node')
                const isOnHandle = target.closest && target.closest('.vue-flow__handle')
                //齒輪等UI元素/左上角控制選單/連線轉折點之mousedown不啟動平移(取代其@mousedown.stop, 使mousedown仍能冒泡至window做popup互斥關閉;
                // .vue-flow__panel=Controls垂直選單, .vue-flow__edge-waypoint=連線轉折點拖曳鈕, 否則按之再拖曳會誤觸發平移)
                const isOnUi = target.closest && target.closest('.vue-flow__node-settings, .vue-flow__edge-settings, .vue-flow__panel, .vue-flow__edge-waypoint')
                if (!isOnNode && !isOnHandle && !isOnUi) {
                    this.startPan(event)
                }
            }
        },
        onCanvasMouseMove(event) {
            // Handled by document-level listener
        },
        onCanvasMouseUp(event) {
            // Handled by document-level listener
        },
        onCanvasWheel(event) {
            if (!this.zoomOnScroll) return
            const delta = -event.deltaY * 0.001
            const currentZoom = this.viewport.zoom
            // fitView may set zoom below zoomMin; use the current level as the
            // effective lower bound so wheel zoom does not jump the view back.
            const zoomMinUse = Math.min(this.zoomMin, currentZoom)
            const newZoom = Math.max(zoomMinUse, Math.min(this.zoomMax, currentZoom + delta * currentZoom))

            const rect = this.$refs.canvas.getContainerRect()
            if (!rect) return
            const mouseX = event.clientX - rect.left
            const mouseY = event.clientY - rect.top

            const vp = this.viewport
            const scale = newZoom / currentZoom
            const newX = mouseX - (mouseX - vp.x) * scale
            const newY = mouseY - (mouseY - vp.y) * scale

            this.setViewport({ x: newX, y: newY, zoom: newZoom })
            this.emitViewportChange()
        },

        // --- Document-level mouse ---
        onDocMouseMove(event) {
            if (this.isPanning) {
                this.doPan(event)
            }
            else if (this.isDraggingNode) {
                this.doDrag(event)
            }
            else if (this.isConnecting) {
                this.doConnect(event)
            }
            else if (this.isSelecting) {
                this.doSelection(event)
            }
        },
        onDocMouseUp(event) {
            if (this.isPanning) {
                this.endPan()
            }
            if (this.isDraggingNode) {
                this.endDrag(event)
            }
            if (this.isConnecting) {
                this.endConnect(event)
            }
            if (this.isSelecting) {
                this.endSelection(event)
            }
        },

        // --- Pan ---
        startPan(event) {
            this.isPanning = true
            this.panStartPos = { x: event.clientX, y: event.clientY }
        },
        doPan(event) {
            const dx = event.clientX - this.panStartPos.x
            const dy = event.clientY - this.panStartPos.y
            this.panStartPos = { x: event.clientX, y: event.clientY }

            let x = this.viewport.x + dx
            let y = this.viewport.y + dy

            if (this.panLimits) {
                const clamped = clampPosition({ x, y }, this.panLimits)
                x = clamped.x
                y = clamped.y
            }

            this.viewport.x = x
            this.viewport.y = y
        },
        endPan() {
            this.isPanning = false
            this.panStartPos = null
            this.emitViewportChange()
        },

        // --- Node drag ---
        onNodeDragStart({ node, event }) {
            if (this.locked || !this.nodesDraggable) return
            this.isDraggingNode = true
            this.draggingNodeId = node.id
            this.dragStartPos = { x: event.clientX, y: event.clientY }

            if (this.selectNodesOnDrag && !this.isMultiSelectPressed) {
                this.setSelectedNodes([node.id])
                this.setSelectedConns([])
            }

            // Cache start positions for drag
            const starts = {}
            this.selectedNodes.forEach(id => {
                const n = this.nodeById(id)
                if (n) starts[id] = { x: n.position.x, y: n.position.y }
            })
            if (!starts[node.id]) {
                const n = this.nodeById(node.id)
                if (n) starts[node.id] = { x: n.position.x, y: n.position.y }
            }
            this.dragNodeStartPositions = starts

            //啟用被拖節點之ghost(per-key賦值, 僅通知讀該鍵之元件), 之後每步僅原地改x/y
            for (let id in starts) {
                if (!(id in this.dragPositions)) {
                    this.$set(this.dragPositions, id, null)
                }
                this.dragPositions[id] = { x: starts[id].x, y: starts[id].y }
            }

            this.$emit('node-drag-start', { node, event })
        },
        doDrag(event) {
            const zoom = this.viewport.zoom
            const dx = (event.clientX - this.dragStartPos.x) / zoom
            const dy = (event.clientY - this.dragStartPos.y) / zoom
            const snap = this.snapToGrid

            for (let id in this.dragNodeStartPositions) {
                const start = this.dragNodeStartPositions[id]
                let x = start.x + dx
                let y = start.y + dy
                if (snap) {
                    const s = snapPosition({ x, y }, this.snapGridSize)
                    x = s.x
                    y = s.y
                }
                const gg = this.dragPositions && this.dragPositions[id]
                if (gg) {
                    gg.x = x
                    gg.y = y
                }
            }
        },
        endDrag(event) {
            // Write final positions back to opt.nodes, 並關閉ghost(per-key設回null)
            if (this.dragNodeStartPositions) {
                for (let id in this.dragNodeStartPositions) {
                    let pos = this.dragPositions[id]
                    let node = this.nodeById(id)
                    if (node && pos) {
                        node.position.x = pos.x
                        node.position.y = pos.y
                    }
                    this.dragPositions[id] = null
                }
            }
            const dragNode = this.nodeById(this.draggingNodeId)
            this.isDraggingNode = false
            this.draggingNodeId = null
            this.dragStartPos = null
            this.dragNodeStartPositions = null
            clearStepCache()
            if (dragNode) {
                this.$emit('node-drag-stop', { node: dragNode, event })
            }
            this.emitNodesUpdate()
        },

        // --- Connection ---
        onConnectStart(payload) {
            if (this.locked || !this.nodesConnectable) return
            //建線只能自source(連出)型handle出發: target(連入)型handle拖曳不啟動建線, 維持方向語義
            if (payload.handleType !== 'source') return
            this.isConnecting = true
            this.connectingFrom = payload
            // Lock cursor to default during connecting, only handles show crosshair
            this._connectCursorStyle = document.createElement('style')
            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; }'
            document.head.appendChild(this._connectCursorStyle)

            const node = this.nodeById(payload.nodeId)
            if (!node) return
            const pos = getHandlePosition(
                node, payload.handlePosition,
                this.nodeInternals[payload.nodeId] || {}
            )
            this.connLineFromX = pos.x
            this.connLineFromY = pos.y
            this.connLineFromPosition = payload.handlePosition
            this.connLineToX = pos.x
            this.connLineToY = pos.y

            this.$emit('connect-start', {
                nodeId: payload.nodeId,
                handleId: payload.handleId,
                handleType: payload.handleType,
            })
        },
        doConnect(event) {
            const rect = this.$refs.canvas.getContainerRect()
            if (!rect) return
            const vp = this.viewport
            this.connLineToX = (event.clientX - rect.left - vp.x) / vp.zoom
            this.connLineToY = (event.clientY - rect.top - vp.y) / vp.zoom
        },
        endConnect(event) {
            // Find handle element under cursor
            const el = document.elementFromPoint(event.clientX, event.clientY)
            const handleEl = el && el.closest && el.closest('.vue-flow__handle')

            if (handleEl && this.connectingFrom && handleEl.dataset.handleType === 'target') {
                //落點限target(連入)型handle: 連出點不可被當作輸入端(如input節點僅有連出點, 不可作為連線終點)
                const toNodeEl = handleEl.closest('.vue-flow__node')
                const toNodeId = toNodeEl ? toNodeEl.dataset.id : null

                if (toNodeId) {
                    const connection = {
                        from: this.connectingFrom.nodeId,
                        to: toNodeId,
                        //逐邊錨點: 起點=實際拖曳出發之連接點方位, 迄點=落點handle之方位(缺則不設, 回退節點層級/預設)
                        ...(this.connectingFrom.handlePosition ? { fromPosition: this.connectingFrom.handlePosition } : {}),
                        ...(handleEl.dataset.handlePosition ? { toPosition: handleEl.dataset.handlePosition } : {}),
                    }

                    const valid = isValidConnection(
                        connection,
                        this.nodes,
                        this.conns,
                        this.funValidConnCreating
                    )

                    if (valid) {
                        const connId = `e${connection.from}-${connection.to}`
                        const conn = {
                            id: this.connById(connId) ? generateId() : connId,
                            ...connection,
                        }

                        this.addConn(conn)
                        this.emitConnsUpdate()
                        this.$emit('connect', connection)
                    }
                }
            }

            this.$emit('connect-end', event)
            this.isConnecting = false
            this.connectingFrom = null
            if (this._connectCursorStyle) {
                document.head.removeChild(this._connectCursorStyle)
                this._connectCursorStyle = null
            }
        },

        // --- Selection ---
        onNodeClick({ node, event }) {
            if (!this.elementsSelectable) return
            if (this.isMultiSelectPressed) {
                const idx = this.selectedNodes.indexOf(node.id)
                if (idx === -1) {
                    this.selectedNodes.push(node.id)
                }
                else {
                    this.selectedNodes.splice(idx, 1)
                }
            }
            else {
                this.setSelectedNodes([node.id])
                this.setSelectedConns([])
            }
            this.emitSelectionChange()

            this.$emit('node-click', { node, event })
        },
        onNodeDoubleClick(payload) {
            this.$emit('node-double-click', payload)
        },
        onNodeContextMenu(payload) {
            this.$emit('node-context-menu', payload)
        },
        onNodeSettingsClick(payload) {
            this.$emit('node-settings-click', payload)
        },
        onNodeSettingsUpdate({ node, key, value }) {
            let n = this.nodeById(node.id)
            if (!n) return
            let oldType = n.type
            this.$set(n, key, value)
            if (key === 'type' && oldType !== value) {
                let nodeId = n.id
                let hasTo = value === 'input' || value === 'basic'
                let hasFrom = value === 'output' || value === 'basic'
                let conns = this.conns
                for (let i = conns.length - 1; i >= 0; i--) {
                    let c = conns[i]
                    if ((!hasTo && c.from === nodeId) || (!hasFrom && c.to === nodeId)) {
                        conns.splice(i, 1)
                    }
                }
            }
            this.$emit('node-settings-update', { node: n, key, value })
        },
        onNodeSettingsDelete({ node }) {
            this.removeNode(node.id)
            clearStepCache()
            this.emitNodesUpdate()
            this.emitConnsUpdate()
            this.$emit('node-settings-delete', { node })
        },
        onNodeMouseEnter({ node, event }) {
            this.$emit('node-mouseenter', { node, event })
        },
        onNodeMouseLeave({ node, event }) {
            this.$emit('node-mouseleave', { node, event })
        },
        onConnClick({ conn, event }) {
            if (!this.elementsSelectable) return
            if (this.isMultiSelectPressed) {
                const idx = this.selectedConns.indexOf(conn.id)
                if (idx === -1) {
                    this.selectedConns.push(conn.id)
                }
                else {
                    this.selectedConns.splice(idx, 1)
                }
            }
            else {
                this.setSelectedConns([conn.id])
                this.setSelectedNodes([])
            }
            this.emitSelectionChange()

            this.$emit('conn-click', { conn, event })
        },
        onConnDoubleClick(payload) {
            this.$emit('conn-double-click', payload)
        },
        onConnContextMenu(payload) {
            this.$emit('conn-context-menu', payload)
        },
        onConnMouseEnter({ conn, event }) {
            this.$emit('conn-mouseenter', { conn, event })
        },
        onConnMouseLeave({ conn, event }) {
            this.$emit('conn-mouseleave', { conn, event })
        },
        onConnSettingsClick(payload) {
            this.$emit('conn-settings-click', payload)
        },
        onConnSettingsUpdate({ conn, key, value }) {
            let c = this.connById(conn.id)
            if (c) {
                this.$set(c, key, value)
                this.$emit('conn-settings-update', { conn: c, key, value })
            }
        },
        onConnSettingsDelete({ conn }) {
            this.removeConn(conn.id)
            clearStepCache()
            this.emitConnsUpdate()
            this.$emit('conn-settings-delete', { conn })
        },

        startSelection(event) {
            this.isSelecting = true
            const rect = this.$refs.canvas.getContainerRect()
            if (!rect) return
            this.selectionStartPos = {
                x: event.clientX - rect.left,
                y: event.clientY - rect.top,
            }
            this.selectionBox = {
                x: this.selectionStartPos.x,
                y: this.selectionStartPos.y,
                width: 0,
                height: 0,
            }
        },
        doSelection(event) {
            const rect = this.$refs.canvas.getContainerRect()
            if (!rect) return
            const currentX = event.clientX - rect.left
            const currentY = event.clientY - rect.top
            const x = Math.min(this.selectionStartPos.x, currentX)
            const y = Math.min(this.selectionStartPos.y, currentY)
            const width = Math.abs(currentX - this.selectionStartPos.x)
            const height = Math.abs(currentY - this.selectionStartPos.y)
            this.selectionBox = { x, y, width, height }
        },
        endSelection() {
            if (this.selectionBox) {
                const box = this.selectionBox
                const vp = this.viewport
                // Convert screen-space box to graph-space
                const graphBox = {
                    x: (box.x - vp.x) / vp.zoom,
                    y: (box.y - vp.y) / vp.zoom,
                    width: box.width / vp.zoom,
                    height: box.height / vp.zoom,
                }
                const overlapping = getOverlappingNodes(graphBox, this.nodes, this.nodeInternals)
                const nodeIds = overlapping.map(n => n.id)
                this.setSelectedNodes(nodeIds)
                // Auto-select conns whose both ends are within selected nodes
                let nodeIdSet = new Set(nodeIds)
                let connIds = this.conns
                    .filter(c => nodeIdSet.has(c.from) && nodeIdSet.has(c.to))
                    .map(c => c.id)
                this.setSelectedConns(connIds)
                this.emitSelectionChange()
            }
            this.isSelecting = false
            this.selectionStartPos = null
            this.selectionBox = null
        },

        // --- Delete ---
        deleteSelectedElements() {
            const { nodes, conns } = this.getSelectedElements()
            if (nodes.length === 0 && conns.length === 0) return

            nodes.forEach(n => {
                if (n.deletable !== false) this.removeNode(n.id)
            })
            conns.forEach(e => {
                if (e.deletable !== false) this.removeConn(e.id)
            })
            this.clearSelection()

            this.$emit('delete', { nodes, conns })
            this.emitNodesUpdate()
            this.emitConnsUpdate()
        },

        // --- Node dimensions ---
        onNodeDimensions({ nodeId, width, height }) {
            this.updateNodeInternals(nodeId, { width, height })
        },

        onNodeResize({ nodeId, width, height, x, y }) {
            if (this.locked) return
            //縮放ghost併入dragPositions同一per-key機制: 首步建物件, 之後原地改欄位
            if (!(nodeId in this.dragPositions)) {
                this.$set(this.dragPositions, nodeId, null)
            }
            const g = this.dragPositions[nodeId]
            if (!g) {
                this.dragPositions[nodeId] = { x, y, width, height }
            }
            else {
                g.x = x
                g.y = y
                g.width = width
                g.height = height
            }
            this.updateNodeInternals(nodeId, { width, height })
        },
        onNodeResizeEnd({ nodeId, width, height, x, y }) {
            let node = this.nodeById(nodeId)
            if (node) {
                node.width = width
                node.height = height
                node.position.x = x
                node.position.y = y
            }
            this.dragPositions[nodeId] = null
            clearStepCache()
            this.emitNodesUpdate()
        },

        // --- Helpers ---
        updateNodePosition(id, position) {
            let node = this.nodeById(id)
            if (!node) return
            node.position.x = position.x
            node.position.y = position.y
        },
        getSelectedElements() {
            return {
                nodes: this.nodes.filter(n => this.selectedNodes.includes(n.id)),
                conns: this.conns.filter(c => this.selectedConns.includes(c.id)),
            }
        },

        // --- Emit helpers ---
        emitNodesUpdate() {
            this.$emit('update:nodes', [...this.nodes])
        },
        emitConnsUpdate() {
            this.$emit('update:conns', [...this.conns])
        },
        emitViewportChange() {
            this.$emit('viewport-change', { ...this.viewport })
        },
        emitSelectionChange() {
            this.$emit('selection-change', this.getSelectedElements())
        },

        // --- Public API ---
        fitView(padding) {
            padding = padding || 50
            let nodes = this.nodes.filter(n => !n.hidden)
            if (nodes.length === 0) return
            let internals = this.nodeInternals
            let minX = Infinity
            let minY = Infinity
            let maxX = -Infinity
            let maxY = -Infinity
            nodes.forEach(n => {
                let w = (internals[n.id] && internals[n.id].width) || n.width || 150
                let h = (internals[n.id] && internals[n.id].height) || n.height || 40
                minX = Math.min(minX, n.position.x)
                minY = Math.min(minY, n.position.y)
                maxX = Math.max(maxX, n.position.x + w)
                maxY = Math.max(maxY, n.position.y + h)
            })
            let rect = this.$refs.canvas ? this.$refs.canvas.getContainerRect() : null
            let cw = rect ? rect.width : this.widthInp
            let ch = rect ? rect.height : this.heightInp
            let gw = maxX - minX + padding * 2
            let gh = maxY - minY + padding * 2
            let zoom = Math.min(cw / gw, ch / gh, 2)
            this.viewport.zoom = zoom
            this.viewport.x = (cw - (maxX + minX) * zoom) / 2
            this.viewport.y = (ch - (maxY + minY) * zoom) / 2
            this.emitViewportChange()
        },
        zoomAtCenter(factor) {
            const currentZoom = this.viewport.zoom
            const zoomMinUse = Math.min(this.zoomMin, currentZoom)
            const newZoom = Math.max(zoomMinUse, Math.min(this.zoomMax, currentZoom * factor))
            const rect = this.$refs.canvas ? this.$refs.canvas.getContainerRect() : null
            const cx = rect ? rect.width / 2 : this.widthInp / 2
            const cy = rect ? rect.height / 2 : this.heightInp / 2
            const vp = this.viewport
            const scale = newZoom / currentZoom
            this.setViewport({
                x: cx - (cx - vp.x) * scale,
                y: cy - (cy - vp.y) * scale,
                zoom: newZoom,
            })
            this.emitViewportChange()
        },
        zoomIn() {
            this.zoomAtCenter(1.2)
        },
        zoomOut() {
            this.zoomAtCenter(1 / 1.2)
        },
        toggleInteractive() {
            this.locked = !this.locked
            this.$emit('toggle-interactive', this.locked)
        },
        panToNode(nodeId, opt) {
            opt = opt || {}
            let node = this.nodeById(nodeId)
            if (!node) return false
            let internals = this.nodeInternals[nodeId]
            let w = (internals && internals.width) || node.width || 150
            let h = (internals && internals.height) || node.height || 40
            let cx = node.position.x + w / 2
            let cy = node.position.y + h / 2
            let rect = this.$refs.canvas ? this.$refs.canvas.getContainerRect() : null
            let cw = (rect && rect.width) || this.widthInp
            let ch = (rect && rect.height) || this.heightInp
            let zoom = opt.zoom !== undefined ? opt.zoom : this.viewport.zoom
            let duration = opt.duration !== undefined ? opt.duration : 400
            let target = { x: cw / 2 - cx * zoom, y: ch / 2 - cy * zoom, zoom }
            if (this._panAnimId) {
                cancelAnimationFrame(this._panAnimId)
                this._panAnimId = null
            }
            let finish = () => {
                this.setViewport(target)
                this.emitViewportChange()
                if (opt.openPopup === true) {
                    this.$nextTick(() => this.openNodeInfoPopup(nodeId))
                }
            }
            if (!(duration > 0)) {
                finish()
                return true
            }
            let from = { x: this.viewport.x, y: this.viewport.y, zoom: this.viewport.zoom }
            let easeInOutCubic = (t) => t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2
            let startTs = null
            let stepFrame = (ts) => {
                if (startTs === null) startTs = ts
                let t = Math.min((ts - startTs) / duration, 1)
                let k = easeInOutCubic(t)
                this.setViewport({
                    x: from.x + (target.x - from.x) * k,
                    y: from.y + (target.y - from.y) * k,
                    zoom: from.zoom + (target.zoom - from.zoom) * k,
                })
                if (t < 1) {
                    this._panAnimId = requestAnimationFrame(stepFrame)
                }
                else {
                    this._panAnimId = null
                    finish()
                }
            }
            this._panAnimId = requestAnimationFrame(stepFrame)
            return true
        },
        openNodeInfoPopup(nodeId) {
            let r = this.$refs.nodeRenderer
            if (!r) return false
            return r.openNodeInfoPopup(nodeId)
        },
        openConnInfoPopup(connId) {
            let r = this.$refs.edgeRenderer
            if (!r) return false
            return r.openConnInfoPopup(connId)
        },
        getFlowData() {
            return {
                nodes: JSON.parse(JSON.stringify(this.nodes)),
                conns: JSON.parse(JSON.stringify(this.conns)),
            }
        },
    },
}
</script>

<style scoped>


</style>