components/WFlowVue.vue

<template>
  <!-- data-flow-id: 建線 DOM adapter 之 flow 歸屬檢查錨點(頁面可能有多個 flow 實例);
       vue-flow--connecting: 建線期間之根 class, 靜態 CSS 據此切換游標/把手三態/齒輪縮放把手隱藏
       (取代原 document.head 注入全域樣式——全域 * 選擇器會污染整頁與其他 flow 實例) -->
  <div
    :style="`width:${widthInp}px; height:${heightInp}px;`"
    :data-flow-id="flowId"
    :class="{ 'vue-flow--connecting': isConnecting, 'vue-flow--multiselecting': multiSelectMode, 'vue-flow--gesturing': gesturing, 'vue-flow--pending': viewPending }"
    :aria-busy="viewPending ? 'true' : null"
    @dragstart.prevent
  >
  <!-- vue-flow--pending: 初始視口尚未定案(首幀量測節點尺寸並 fit 之前): 圖面內容 visibility:hidden、根 pointer-events:none,
       使用者不會看到 fit 前之一幀, 亦不能於此期間操作; init 事件於解除後之下一 tick 發出 -->
  <!-- @dragstart.prevent(根層): 圖台內任何原生拖曳(選取文字之文字圖層、圖片)一律取消——圖台之拖曳語義只有節點/轉折點/建線/平移,
       原生文字拖曳會接管滑鼠事件流使手勢中斷(NodeWrapper 另於節點層處理, 此為全域縱深) -->
  <FlowCanvas
    v-if="inited"
    ref="canvas"
    @canvas-mousedown="onCanvasMouseDown"
    @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"
      :pattern-id="`vf-bg-${flowId}`"
      :viewport="viewport"
    />

    <ViewportTransform
      :viewport="viewport"
    >
      <EdgeRenderer
        ref="edgeRenderer"
        :conns="conns"
        :nodes="nodes"
        :def-node="defNode"
        :def-conn="defConn"
        :node-internals="nodeInternals"
        :selected-conn-ids="selectedConns"
        :popup-slot-fn="$scopedSlots['conn-popup'] || null"
        :interactive="elementsSelectable"
        :locked="locked"
        :settings-popup-background-color="settingsPopupBackgroundColor"
        :settings-popup-text-color="settingsPopupTextColor"
        :settings-popup-text-font-size="settingsPopupTextFontSize"
        :settings-popup-max-height="settingsPopupMaxHeight"
        :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-trigger="connsSettingsTrigger"
        :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-delete-request="onConnSettingsDelete"
        @conn-activate="onConnActivate"
        @conn-waypoint-start="onConnWaypointStart"
        @conn-waypoint-end="onConnWaypointEnd"
      />

      <NodeRenderer
        ref="nodeRenderer"
        :def-node="defNode"
        :nodes="nodes"
        :selected-node-ids="selectedNodes"
        :dragging-node-map="isDraggingNode ? dragNodeStartPositions : null"
        :popup-slot-fn="$scopedSlots['node-popup'] || null"
        :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"
        :settings-popup-max-height="settingsPopupMaxHeight"
        :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-trigger="nodesSettingsTrigger"
        :settings-excludes="nodesSettingsExcludes"
        @drag-prepare="onNodeDragPrepare"
        @drag-start="onNodeDragStart"
        @node-click="onNodeClick"
        @node-double-click="onNodeDoubleClick"
        @node-context-menu="onNodeContextMenu"
        @node-settings-click="onNodeSettingsClick"
        @node-settings-update="onNodeSettingsUpdate"
        @node-delete-request="onNodeSettingsDelete"
        @node-mouseenter="onNodeMouseEnter"
        @node-mouseleave="onNodeMouseLeave"
        @connect-start="onConnectStart"
        @dimensions="onNodeDimensions"
        @node-resize="onNodeResize"
        @node-resize-end="onNodeResizeEnd"
        @resize-start="onNodeResizeStart"
        @node-resize-cancel="onNodeResizeCancel"
        @node-activate="onNodeActivate"
      />

      <ConnectionLine
        :state="connectionVisual"
        :type="defConnCreatingType"
        :line-style="defConnCreatingStyle"
        :offset="defConn.defOffset"
      />

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

    <SelectionBox :state="selectionVisual" />

    <Controls
      :locked="locked"
      :menu="menuInp"
      @zoom-in="zoomIn"
      @zoom-out="zoomOut"
      @fit-view="fitView"
      @toggle-interactive="toggleInteractive"
    />

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

<script>
import FlowCanvas from './canvas/FlowCanvas.vue'
import { classifyHit, isCanvasBlank } from '../js/hitTest.mjs'
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, resolveNodeSize } from '../js/geometry.mjs'
import { generateId } from '../js/graph.mjs'
import { crossedThreshold } from '../js/domGesture.mjs'
import { nodesBounds, resolveContainerSize, computeFitView, resolvePadding, screenToFlow, clientToLocal, zoomAroundPoint, clampZoom, computeCenterView, recenterForResize, easeInOutCubic } from '../js/viewport.mjs'
import { assessConnection } from '../js/connectPolicy.mjs'
import { findHandleElAt, describeHandleEndpoint, setHandleConnectStatus, setHandleConnectRole, setDomFlag } from '../js/handleDom.mjs'
import { isSide, oppositeSide, SOURCE_FALLBACK, TARGET_FALLBACK } from '../js/anchorPolicy.mjs'
import { NODE_SETTING_KEYS, CONN_SETTING_KEYS } from '../js/defaults.mjs'
import { optComputeds, pickMenuOpt, resolveSettingsText, resolveDefNode, resolveDefConn } from '../js/resolveOpt.mjs'
import { previewDelete, applyDelete, findDuplicateIds, snapshotDeep } from '../js/graphMutation.mjs'

/**
 * 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). Changing it after init keeps the content at the old canvas
 *   center at the new center (zoom unchanged) and emits 'resize' — see @event resize.
 * @prop {number}   [opt.height=600]                      Canvas height (px). Same resize behavior as opt.width.
 * @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 {string}   [opt.nodesSettingsTrigger='dblclick'] How the node settings popup is opened: 'hover' (gear icon appears at the
 *   node's top-right on hover; click it) | 'click' | 'dblclick' (that action on the node body opens the settings popup directly, no gear icon;
 *   in 'click' mode the info popup yields to the settings popup).
 * @prop {string}   [opt.connsSettingsTrigger='dblclick'] Same for connections ('hover': gear at the right of the label).
 * @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
 *
 * ─── Interaction contract(互動契約, 全文見 spec/流程_互動契約.md)───
 * 不變量(invariants):
 * - 一次一手勢: activeGesture ∈ null|pan|drag|resize|connect|waypoint|boxselect; 啟動即關閉全部 popup,
 *   期間根 class vue-flow--gesturing 抑制非擁有者元素之 hover affordance, 任何 popup 開啟入口(含公開 API)拒開。
 * - 建線: 節點四邊連接點無連出/連入之分, 任一把手皆可出發, 落點為他節點之任一把手; 方向 = 出發 → 落點,
 *   候選 { from, to, fromPosition, toPosition }(兩端方位即各自所在之邊), validator / connect 事件收同形狀。
 *   自我連線一律 not-allowed(出發節點之其他把手於建線期間淡化)。
 * - affordance(齒輪/四角/把手/轉折點/工具列)之 click/dblclick/contextmenu 不代表宿主元素或畫布發事件。
 * - 主鍵限制對所有手勢一致; 終止路徑(mouseup/blur/視窗外放開/destroy/上鎖)走同一收尾。
 * - 上鎖切換時: 進行中之 connect/boxselect 取消, drag/resize/waypoint 取消提交(ghost 復原不寫回)。
 * 事件形狀: connect-start { nodeId, handlePosition }; connect { from, to, fromPosition, toPosition };
 *   connect-end(event, { valid, reason, connection }), reason ∈ no-endpoint | unknown-handle | self |
 *   not-connectable | missing-node | duplicate | custom | cancelled。
 *   節點齒輪/把手/四角上之雙擊與右鍵不發 node-double-click / node-context-menu; 縮放四角與轉折點只認主鍵;
 *   按住連線本體拖曳不平移畫布; 工具列上之雙擊/右鍵不發 canvas-dblclick / pane-context-menu。
 *
 * ─── Multi-select mode(複選模式契約)───
 * multiSelectionKey 按住(且 multiSelectEnabled、elementsSelectable、未鎖定)期間為「複選模式」:
 * - 統一隱藏所有元素專屬操作 affordance: 節點設定齒輪/四角縮放把手/連出入把手/邊設定齒輪/邊轉折點;
 *   模式中不得啟動建線/縮放/轉折點拖曳, 亦不得開啟任何 popup; 已開之資訊/設定 popup 於進入模式時關閉。
 * - 節點本體點擊 = toggle 加入/移除選取(不清除既有連線選取——連線不參與複選, 點邊不變更選取)。
 * - 拖曳/框選/平移/縮放畫布/Delete/工具列不受模式影響; dblclick 因兩次 click 而 toggle 兩次(淨零)後照發事件。
 * - 手勢優先序: 建線進行中按住複選鍵, 建線續行且落點把手不隱藏; 進行中之縮放/拖曳手勢照常完成。
 * - 鍵盤作用域: 於 input/textarea/select/contenteditable 內按鍵不觸發畫布快捷鍵(含複選鍵與 Delete);
 *   同頁多個 flow 實例監聽同一 document, 按住複選鍵時各實例同時進入模式(全域行為, 刻意)。
 * @prop {boolean}  [opt.zoomOnScroll=true]               Zoom with mouse wheel
 * @prop {boolean}  [opt.fitViewOnInit=true]              Fit all nodes into view on init: nodes are measured hidden, fitted, then revealed
 *   (no pre-fit frame). While true, opt.center / opt.zoom do not take part in the initial viewport. If nodes are empty at init,
 *   the first non-empty population is fitted once (unless the viewport was already changed). `init` is emitted only after
 *   the initial viewport is settled and the flow is interactive.
 * @prop {number|object} [opt.fitViewPadding=50]          Fit whitespace in **CSS pixels**, independent of zoom (same as Leaflet
 *   fitBounds padding / OpenLayers View.fit padding). Used by the initial fit and by fitView() when no padding is passed.
 *   Either a number (all four sides) or an asymmetric object `{ top, right, bottom, left }` whose missing/invalid sides fall back to 50.
 *   0 is legal and means flush to the edges — nodes may then sit under the Controls menu; fit never reserves space for the menu
 *   automatically (matching Leaflet/OpenLayers/React Flow, where padding is the caller's tool for that). The default 50 keeps the
 *   default menu (right edge at 40px) clear of nodes. If you enlarge the menu (menuIconSize etc.), reserve its side yourself,
 *   e.g. `{ left: 60 }`. An object passed to fitView(padding) is a per-side patch on top of the resolved opt value. On the
 *   constrained axis the margin equals the padding exactly, unless zoomMax caps the zoom (small graphs stay centered with
 *   larger margins). Infeasible padding (opposite sides summing past the container axis) is scaled down proportionally.
 * @prop {number}   [opt.zoom=1]                  Initial viewport zoom level (only when fitViewOnInit is false)
 * @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 {Array}    [opt.center=[0,0]]            Initial viewport offset [x, y] (only when fitViewOnInit is false)
 * @prop {boolean}  [opt.panOnDrag=true]                  Pan canvas by dragging background
 * @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)
 *
 * Anchor contract: every node has four connection points (top/right/bottom/left side midpoints,
 * no source/target distinction). A conn owns the direction of both of its ends:
 *   from end: conn.fromPosition → opt.defConnFromPosition → 'bottom'
 *   to end:   conn.toPosition   → opt.defConnToPosition   → 'top'
 * The ray direction is always the normal of that side of the node's bounding box (a triangle's
 * slanted-edge point still leaves horizontally/vertically). Nodes carry no type or direction fields.
 *
 * Node-surface input contract: dragging on a node moves the node — text selection and native
 * HTML5 drag are suppressed there (a formed selection would let the browser's text-layer drag
 * take over the gesture and freeze the node). Interactive elements (input/textarea/select/button/
 * a[href]/label/contenteditable) and any region marked with the `vue-flow__nodrag` class opt out:
 * native behavior (focus/click/text selection) is preserved and node dragging never starts there.
 *
 * ─── 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
 *
 * ─── Menu ──────────────────────────────────────────────────────────────
 * Top-left toolbar. Every option is opt-in: omit them all and the toolbar is identical to before.
 * Any value of the wrong type (or an empty string) falls back to the default listed here.
 * @prop {boolean}  [opt.useMenu=true]                    Show the whole toolbar
 * @prop {string}   [opt.menuPosition='top-left']         Toolbar corner: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
 * @prop {number}   [opt.menuYShift=0]                    Toolbar vertical shift (px); positive moves it away from its anchored edge (down for top-*, up for bottom-*)
 * @prop {boolean}  [opt.useSetting=true]                 Initial expanded state (afterwards toggled via the gear button)
 * @prop {string}   [opt.menuSettingIcon=mdiCogOutline]   Gear (collapse/expand) button icon, SVG path string
 * @prop {string}   [opt.menuSettingTooltip='Settings']   Gear button tooltip
 * @prop {boolean}  [opt.useMenuItemZoomIn=true]          Show zoom-in button
 * @prop {string}   [opt.menuZoomInIcon=mdiMagnifyPlusOutline]   Zoom-in icon, SVG path string
 * @prop {string}   [opt.menuZoomInTooltip='Zoom In']     Zoom-in tooltip
 * @prop {boolean}  [opt.useMenuItemZoomOut=true]         Show zoom-out button
 * @prop {string}   [opt.menuZoomOutIcon=mdiMagnifyMinusOutline] Zoom-out icon, SVG path string
 * @prop {string}   [opt.menuZoomOutTooltip='Zoom Out']   Zoom-out tooltip
 * @prop {boolean}  [opt.useMenuItemFitView=true]         Show fit-view button
 * @prop {string}   [opt.menuFitViewIcon=mdiFitToPageOutline]    Fit-view icon, SVG path string
 * @prop {string}   [opt.menuFitViewTooltip='Fit View']   Fit-view tooltip
 * @prop {boolean}  [opt.useMenuItemLock=true]            Show lock button
 * @prop {string}   [opt.menuLockIcon=mdiLockOpenVariantOutline] Lock button icon while unlocked
 * @prop {string}   [opt.menuLockTooltip='Lock']          Lock button tooltip while unlocked (names the action the click performs)
 * @prop {string}   [opt.menuLockIconLocked=mdiLockOutline]      Lock button icon while locked
 * @prop {string}   [opt.menuLockTooltipLocked='Unlock']  Lock button tooltip while locked
 * @prop {string}   [opt.menuIconColor='#555']            Toolbar icon color
 * @prop {string}   [opt.menuIconColorHover='#222']       Toolbar icon color on hover
 * @prop {string}   [opt.menuIconColorFocus='#222']       Toolbar icon color on focus
 * @prop {number}   [opt.menuIconSize=22]                 Toolbar icon size (px)
 * @prop {string}   [opt.menuBackgroundColor='#fefefe']   Toolbar button background
 * @prop {string}   [opt.menuBackgroundColorHover='#f0f0f0']     Toolbar button background on hover
 * @prop {string}   [opt.menuBackgroundColorFocus='#f0f0f0']     Toolbar button background on focus
 * @prop {string}   [opt.menuSeparatorColor='#e6e6e6']    Toolbar separator line color
 * @prop {boolean}  [opt.menuShadow=true]                 Toolbar drop shadow
 * @prop {string}   [opt.menuTooltipTextColor='white']    Toolbar tooltip text color
 * @prop {string}   [opt.menuTooltipTextFontSize='0.7rem']       Toolbar tooltip font size
 * @prop {string}   [opt.menuTooltipBackgroundColor='rgba(60,60,60,0.75)'] Toolbar tooltip background
 *
 * ─── 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
 * @prop {string}   [opt.settingsPopupMaxHeight='400px']      Settings form max height (CSS length); scrolls inside the form when exceeded
 * @prop {string}   [opt.nodesSettingsDeleteText='Delete']    Node settings form delete-button text
 * @prop {string}   [opt.connsSettingsDeleteText='Delete']    Connection settings form delete-button text
 * @prop {string}   [opt.settingsColorConfirmText='Confirm']  Confirm-button text of the color pickers inside settings forms
 *
 * ─── 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.defNodeShape='rectangle']        Default shape: 'rectangle' | 'diamond' | 'ellipse' | 'triangle-up' | ...
 * @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.defNodePopupDirection='right']   Default settings popup direction
 *
 * ─── Default Handle(連接點)───
 * 每節點四邊各一把手(無連出/連入之分, 同一組樣式); 圓心落在節點外框盒上該邊之連接點(與連線端點同一基準); hover 放大 2px 圓心不動。
 * @prop {string}   [opt.defHandleFaceColor='#555555']   把手背景色
 * @prop {string}   [opt.defHandleEdgeColor='#ffffff']   把手框線色
 * @prop {number}   [opt.defHandleEdgeWidth=1]           把手框線寬(px)
 * @prop {number}   [opt.defHandleSize=10]               把手大小(px, 外徑含框線)
 *
 * ─── Default Creating Connection ────────────────────────────────────────────────────────
 * @prop {string}   [opt.defConnCreatingType='bezier']     Drag-line type: 'bezier' | 'straight' | 'step' | 'smoothstep'
 * @prop {string}   [opt.defConnCreatingEdgeColor='#b1b1b1']  Drag-line color
 * @prop {number}   [opt.defConnCreatingEdgeWidth=1]          Drag-line width (px)
 * @prop {string}   [opt.defConnCreatingEdgeDasharray='5 5'] Drag-line dash pattern ('' for solid)
 * ─── Deleting confirmation(刪除確認契約)───
 * @prop {Function} [opt.funConfirmDeleting=null]        Async delete confirmation fn(payload) → Promise<boolean>.
 *   套件不內建二次確認 UI:未提供此 callback 時,刪除按鈕/刪除鍵一律立即刪除;
 *   提供時,全部刪除入口(節點設定表單、連線設定表單、刪除鍵)一律先 await 此 callback,
 *   **嚴格回傳 true 才真的刪除**——回傳 false / undefined / 其他值 / 拋錯一律不刪。
 *   payload = { nodes, conns, from }:
 *     - nodes:即將刪除之節點(已排除 deletable:false 者)
 *     - conns:即將刪除之連線,含「因節點被刪而連帶刪除」者(供宿主組出確認訊息)
 *     - from:'node-settings' | 'conn-settings' | 'delete-key'
 *   確認進行中(await 尚未回覆)不再受理新的刪除請求,避免重複刪除與多重確認視窗;
 *   await 期間圖若已變動(宿主抽換資料、他途已刪),僅刪除仍存在之目標,`elements-deleted` 之
 *   payload 亦只含實際被刪者;若 await 期間出現「確認 payload 未涵蓋」之新連帶邊(如期間新增了
 *   相鄰連線),整筆刪除以 stale 放棄,不默默擴大刪除範圍。
 *   from 亦可能為 'api'(公開刪除方法)或宿主自訂字串。
 *
 * ─── Deleting(刪除機制契約:核心 / coordinator / 入口三層)───
 * 圖之刪除一律經 src/js/graphMutation.mjs(純函式:解析/去重/連帶/deletable 政策/就地套用)+
 * 本元件之 runDelete(coordinator:確認單飛、await 後重 preview、提交、選取與輔助狀態回收、事件排序)。
 * 四個會移除元素之入口——節點設定表單、連線設定表單、刪除鍵、公開方法——共用同一核心與政策:
 *   - 刪節點時其相鄰連線不論 deletable 一律連帶移除(參照完整性優先,不留孤兒邊);
 *   - deletable:false 之元素不可被「直接指定」刪除:刪除鍵與公開方法將其歸入 excluded,
 *     設定表單之刪除鈕 disabled(1.0.36 起,舊版設定表單可刪 deletable:false 元素屬 bug);
 *   - id 為 opaque identity(嚴格相等),不強制字串,只要求各集合內唯一(重複時 console.warn)。
 *
 * @method deleteElements({ nodeIds=[], connIds=[] }, opt={}) → Promise<result>
 *   公開刪除方法。opt.confirm(預設 true)是否經 funConfirmDeleting;opt.from(預設 'api')透傳至閘門與事件。
 *   locked 為 UI 手勢鎖,不擋程式化呼叫。
 *   result:{ ok:true, ...elements-deleted payload } 或
 *           { ok:false, reason, notFound?, excluded? },reason ∈
 *           'empty'(未指定任何 id)|'not-found'(全數不存在)|'excluded'(存在但全被 deletable:false 排除)|
 *           'busy'(確認進行中)|'cancelled'(閘門否決)|'confirm-error'(閘門拋錯)|'destroyed'(await 期間元件銷毀)|
 *           'stale'(await 期間出現未經確認之新連帶邊)
 * @method deleteNodes(nodeIds, opt) / deleteConns(connIds, opt) — deleteElements 之便捷包裝
 *
 * @event resize 圖台尺寸(opt.width / opt.height)變更時發出(於視口補正後), payload { width, height, oldWidth, oldHeight, viewport }.
 *   視口補正: 舊圖台中心之內容於新圖台仍居中(zoom 不變; 地圖庫慣例, 同 Leaflet invalidateSize 之 pan).
 * @event init 初始 viewport 已定案且圖台可互動時發出(首幀後; fitViewOnInit 時於 fit 完成、解除隱藏後之下一 tick), 只發一次.
 * @event elements-deleted 所有刪除路徑完成後皆發出(於 update:* 之後、最後發出),payload:
 *   { from, requested:{nodeIds,connIds}, deleted:{nodeIds,connIds,nodes,conns}, cascades:[{nodeId,connIds}],
 *     notFound:{nodeIds,connIds}, excluded:{nodeIds,connIds} }
 *   - deleted.nodes/conns 為被刪當下之深複製快照(JSON 往返,同 getFlowData);id 清單供輕量記帳;
 *   - cascades 只描述因果(哪條邊因哪個節點而刪),記帳以 deleted.*Ids 為準(已去重);
 *   - 不含刪後全量快照(remain):需要者呼叫 getFlowData() 或沿用 update:nodes / update:conns。
 *   刪除只有這一個完成事件。所有入口同一序列:(僅實際變動之集合)update:nodes → update:conns → elements-deleted。
 * @prop {Function} [opt.funValidConnCreating=null]      Custom connection validator fn(connection) → boolean.
 *   須為同步純函式(無副作用): 除放開(commit)外, 拖線中游標移入把手(hover 目標變更)時亦會被呼叫一次,
 *   以即時標示落點可否連線(valid/invalid); 兩處收到之 connection 形狀完全相同({ from, to, fromPosition, toPosition })。
 *   connect-end 事件之第二參數為判定結果 { valid, reason, connection }(第一參數仍為原生 event),
 *   reason ∈ 'no-endpoint'|'unknown-handle'|'self'|'not-connectable'|'missing-node'|'duplicate'|'custom'|'cancelled'|null
 *
 * ─── 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='#b1b1b1']      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.defConnFromPosition='bottom']   Default from-end side when conn.fromPosition is absent: 'top' | 'right' | 'bottom' | 'left'
 * @prop {string}   [opt.defConnToPosition='top']        Default to-end side when conn.toPosition is absent
 * @prop {string}   [opt.defConnMarkerFrom='']          Default start arrow: '' (none) | 'arrow' (open) | 'arrowclosed' (filled)
 * @prop {number}   [opt.defConnMarkerFromSize=10]      Default start arrow size (px, absolute; clamped 4–40)
 * @prop {string}   [opt.defConnMarkerFromFaceColor=''] Default start arrow fill (arrowclosed only; '' = line color darkened 20%)
 * @prop {string}   [opt.defConnMarkerFromEdgeColor=''] Default start arrow outline colour (arrow and arrowclosed; '' = follows line colour)
 * @prop {string}   [opt.defConnMarkerTo='']            Default end arrow: '' | 'arrow' | 'arrowclosed'
 * @prop {number}   [opt.defConnMarkerToSize=10]        Default end arrow size (px)
 * @prop {string}   [opt.defConnMarkerToFaceColor='']   Default end arrow fill (arrowclosed only; '' = line color darkened 20%)
 * @prop {string}   [opt.defConnMarkerToEdgeColor='']   Default end arrow outline colour (arrow and arrowclosed; '' = follows line colour)
 * @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 {
            //設定表單文字(刪除鈕/色票確認鈕): getter 注入, 表單直接讀取, 不逐層傳 props
            getSettingsText: () => this.settingsText,
            //視口縮放(手勢換算 client 位移 → 畫布位移): getter 注入(高頻手勢狀態, 不進渲染面), 子元件不再自 DOM transform 反解析
            getViewportZoom: () => this.viewport.zoom,
            //拖曳/縮放ghost(細粒度): 回傳該節點進行中之暫時幾何({x,y}或{x,y,width,height}), 無則null
            getDragGhost: (id) => {
                return this.dragPositions[id] || null
            },
            //複選鍵是否生效(行為判準, 非渲染狀態): 供Node/EdgeWrapper之事件handler呼叫;
            //以getter注入而非prop下傳——此值只影響行為不影響渲染輸出, prop形式會使按/放複選鍵時全部wrapper白重渲染一輪
            getMultiSelectActive: () => this.isMultiSelectActive,
            //進行中手勢(一次一手勢)與 popup 開啟閘門(getter注入, 同上慣例; 契約見 spec/流程_互動契約.md §5-§6)
            getActiveGesture: () => this.activeGesture,
            getCanOpenPopup: () => this.canOpenPopup(),
            //刪除確認進行中(getter注入, 同上慣例): 供設定表單之刪除鈕呈現 pending
            getDeleteConfirming: () => this.deleteConfirming,
        }
    },
    data() {
        return {
            inited: false,

            // Viewport
            viewport: { x: 0, y: 0, zoom: 1 },
            //初始視口待定(見根 class vue-flow--pending)
            viewPending: false,

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

            // UI state
            //框選視覺狀態容器: 容器本身永不替換, 只改box欄位——主模板只讀容器參照,
            //SelectionBox自行依賴box, 拉框每步僅該元件重渲染, WFlowVue不重渲染(與dragPositions同一細粒度模式)
            selectionVisual: { box: null },
            selectionCrossedThreshold: false,
            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
            //建線視覺狀態容器: 同selectionVisual之細粒度模式, active兼作邏輯旗標(單一事實來源,
            //經computed isConnecting供既有守衛/測試讀取); 欄位全數預宣告, 只改欄位不換容器
            connectionVisual: {
                active: false,
                fromX: 0,
                fromY: 0,
                fromPosition: SOURCE_FALLBACK,
                toX: 0,
                toY: 0,
                //游標懸於把手時之曲線進入方位(把手之 data-handle-position; 離開把手回復出發邊之對邊)
                toPosition: TARGET_FALLBACK,
                //游標下落點之即時判定: 'none'(非把手)|'valid'|'invalid'; 僅 ConnectionLine 讀取(細粒度)
                dropStatus: 'none',
            },

            //複選模式(渲染面scalar, 由isMultiSelectActive之watcher維護; 契約見JSDoc「Multi-select mode」)
            multiSelectMode: false,

            //進行中手勢(單值, 一次一手勢): null | 'pan' | 'drag' | 'resize' | 'connect' | 'waypoint' | 'boxselect'
            //(非渲染面; 供守衛與 inject getter 讀取)
            activeGesture: null,
            //根 class 用之渲染面 scalar(僅於 null↔非 null 翻轉時寫入, 與 multiSelectMode 同一慣例)
            gesturing: false,

            //刪除確認進行中(等待宿主 funConfirmDeleting 回覆): 供設定表單之刪除鈕呈現 pending 並防連點
            deleteConfirming: false,

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

            // Key state
            keysPressed: {},

        }
    },
    watch: {
        //複選模式之transition-only scalar: watcher僅於值真正翻轉時寫入(watcher具值相等檢查),
        //根class綁定此scalar而非isMultiSelectActive推導鏈——keysPressed物件於「任意鍵首按」時整包替換,
        //直接綁computed會使根渲染於無關按鍵時被排程; scalar只在false↔true轉移時觸發一次根渲染
        isMultiSelectActive(v) {
            this.multiSelectMode = v
        },
        //圖台尺寸變更(opt.width/height): 視口補正使舊中心之內容仍居中(zoom 不變), 再發 resize 事件(地圖庫慣例).
        //初始化 pending 中不補正(初始 fit 逕以新尺寸計算); 程式化補正不視為使用者改動 viewport(不影響首次填入節點之初始 fit)
        canvasSize(nv, ov) {
            if (!this.inited || this.viewPending) return
            if (!ov || (nv.width === ov.width && nv.height === ov.height)) return
            this.cancelViewportAnimation()
            const touched = this._viewportTouched
            this.setViewport(recenterForResize(this.viewport, ov, nv))
            this.emitViewportChange()
            this._viewportTouched = touched
            this.$emit('resize', { width: nv.width, height: nv.height, oldWidth: ov.width, oldHeight: ov.height, viewport: { ...this.viewport } })
        },
        //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)
                    }
                }
                this.warnDuplicateIds('nodes', ns)
                this.maybeDeferredInitialFit()
            },
        },
        conns: {
            immediate: true,
            handler(cs) {
                this.warnDuplicateIds('conns', cs)
            },
        },
        opt: {
            handler() {
                if (!this.inited) {
                    this.inited = true
                    //fitViewOnInit 時初始視口由 settleInitialView 於首幀後決定; 否則沿用 center/zoom
                    if (!this.fitViewOnInit) {
                        let vc = this.center
                        this.viewport.x = (vc && vc[0]) || 0
                        this.viewport.y = (vc && vc[1]) || 0
                        this.viewport.zoom = this.zoom
                    }
                    this.viewPending = true
                }
            },
            immediate: true,
        },
    },
    mounted() {
        this.settleInitialView()
        document.addEventListener('keydown', this.onKeyDown)
        document.addEventListener('keyup', this.onKeyUp)
        document.addEventListener('mousemove', this.onDocMouseMove)
        document.addEventListener('mouseup', this.onDocMouseUp)
        window.addEventListener('blur', this.onWindowBlur)
    },
    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)
        window.removeEventListener('blur', this.onWindowBlur)
        //建線進行中被銷毀: 把手上的 data-connect-* 暫態標記為 DOM 屬性, 不隨元件狀態消失,
        //須顯式清理(根 class 隨元素移除自然消失); 此處不發connect-end(元件已在銷毀流程中)
        this.resetConnectGesture()
    },
    computed: {
        //flow 實例識別: DOM adapter 據此檢查 elementFromPoint 撿到的把手是否屬於本實例
        flowId() {
            return `wf-${this._uid}`
        },
        //opt 解析: 單一來源 resolveOpt.mjs(OPT_SPEC 每鍵一個 computed, 名稱即鍵名; 群組型以具名解析函式)
        ...optComputeds(),
        nodes() {
            return this.opt.nodes || []
        },
        conns() {
            return this.opt.conns || []
        },
        menuInp() {
            return pickMenuOpt(this.opt)
        },
        //圖台尺寸(供 resize 補正之 watcher; 物件形式使寬高同時變更只觸發一次)
        canvasSize() {
            return { width: this.widthInp, height: this.heightInp }
        },
        defConnCreatingStyle() {
            return {
                stroke: this.defConnCreatingEdgeColor,
                strokeWidth: this.defConnCreatingEdgeWidth,
                strokeDasharray: this.defConnCreatingEdgeDasharray,
            }
        },
        settingsText() {
            return resolveSettingsText(this.opt)
        },
        defNode() {
            return resolveDefNode(this.opt)
        },
        defConn() {
            return resolveDefConn(this.opt)
        },

        //(效能重構)拖曳/縮放ghost改由getDragGhost細粒度提供(per-node反應式鍵), 不再整包重建nodes陣列
        //why: 舊renderNodes每步mousemove產新陣列+新物件prop, 使兩Renderer與全部Node/EdgeWrapper(含WPopup/WTooltip子樹)
        //     每步全量重渲染(84節點+95邊實測~128ms/步); 細粒度後僅被拖節點與其相連邊重渲染
        //「複選鍵生效中」而非單純「鍵被按下」: 鎖定 或 宿主關閉multiSelectEnabled 時, 該鍵不具複選語義, 等同沒按
        //why: 單選之active有對應效果(開資訊popup, 宿主據node-click同步外部清單之目前項目), 檢視模式本就該保留;
        //     複選之active則無任何對應效果——選了不能整組拖曳/刪除, 只是一片亮起的框, 反而使人誤認功能故障.
        //     故此處只擋複選與框選, 不動單選; 且該鍵無效時點擊須完整退回單選路徑(照常取得active與popup)
        isBoxSelectActive() {
            return this.can('multiselect') && !!this.keysPressed[this.boxSelectionKeyCode]
        },
        isMultiSelectActive() {
            //elementsSelectable=false 時該鍵無複選語義(等同鎖定之判準): 不進入複選模式, 點擊照常開popup
            return this.can('multiselect') && this.elementsSelectable && !!this.keysPressed[this.multiSelectionKeyCode]
        },
        //建線進行中旗標: 單一事實來源為connectionVisual.active(渲染由ConnectionLine細粒度讀取);
        //此computed供既有守衛與外部測試以原名讀取, 不建立主模板渲染依賴
        isConnecting() {
            return this.connectionVisual.active
        },
    },
    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)
        },
        //重複 id 防呆: 一切查找/對帳/Vue key 皆以 id 為鍵, 重複即宿主資料錯誤, 須及早暴露; 同一組重複只警告一次
        warnDuplicateIds(kind, list) {
            const dup = findDuplicateIds(list)
            if (dup.length === 0) return
            const ids = dup.map(String).join(', ')
            const sig = kind + ':' + ids
            if (this._dupIdWarned === sig) return
            this._dupIdWarned = sig
            console.warn('[w-flow-vue] duplicate ' + kind + ' id detected: ' + ids)
        },
        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
        },

        // --- Initial view(init = 初始 viewport 已定案且可互動) ---
        //首幀(隱藏)已渲染全部節點 → 主動拉取每個節點之實測尺寸(不依賴子元件回報之 tick 數)→ fit → 解除隱藏 → 下一 tick 發 init
        settleInitialView() {
            this.$nextTick(() => {
                if (this.fitViewOnInit && this.hasVisibleNodes()) {
                    this.pullNodeDimensions()
                    this.fitView()
                    this._initialFitDone = true
                }
                this.viewPending = false
                this.$nextTick(() => this.$emit('init'))
            })
        },
        hasVisibleNodes() {
            return this.nodes.some(n => n && !n.hidden)
        },
        pullNodeDimensions() {
            const ws = (this.$refs.nodeRenderer && this.$refs.nodeRenderer.$refs.wrappers) || []
            for (const w of ws) if (w && w.reportDimensions) w.reportDimensions()
        },
        //初始化時無節點者(宿主 mounted 後才填入): 首次由空變非空且使用者尚未改動 viewport → 仍屬初始化, fit 一次
        maybeDeferredInitialFit() {
            if (!this.inited || !this.fitViewOnInit || this._initialFitDone || this._viewportTouched) return
            if (!this.hasVisibleNodes()) return
            this._initialFitDone = true
            this.viewPending = true
            this.$nextTick(() => {
                this.pullNodeDimensions()
                this.fitView()
                this.viewPending = false
            })
        },

        //能力閘門(locked 與各 opt 旗標之組合, 單一表; 各入口不再各自拼寫):
        //  drag / connect / resize / delete-key / multiselect 各對應其 opt 旗標; 其餘動作只看 locked
        can(action) {
            if (this.locked) return false
            switch (action) {
            case 'drag': return this.nodesDraggable
            case 'connect': return this.nodesConnectable
            case 'resize': return this.nodesResizable
            case 'delete-key': return this.deleteKeyEnabled
            case 'multiselect': return this.multiSelectEnabled
            default: return true
            }
        },

        // --- Key handling ---
        //可編輯目標排除: 於輸入框/表單內打字(如設定表單輸入大寫時按Shift)不得被當成畫布快捷鍵——
        //否則Shift會引擎複選模式而關閉使用者正在打字的表單, Delete會誤刪選取節點
        isEditableKeyTarget(e) {
            const t = e.target
            if (!t || !t.tagName) return false
            const tag = t.tagName.toUpperCase()
            //BUTTON 亦排除: 焦點停在設定表單按鈕(如刪除鈕)時按 Delete 不得誤刪畫布選取
            return tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || tag === 'BUTTON' || t.isContentEditable === true
        },
        onKeyDown(e) {
            if (this.isEditableKeyTarget(e)) return
            //key-repeat不重建keysPressed: 按住修飾鍵時OS以repeat連發keydown(Windows之Shift亦repeat),
            //每次重建物件會使依賴它之computed失效而重渲染(實測80節點+90邊場景, 20次repeat=全樹渲染20輪);
            //僅首次按下才更新物件; 不early return——Delete長按之連刪語義須保留
            if (!this.keysPressed[e.key]) {
                this.keysPressed = { ...this.keysPressed, [e.key]: true }
            }
            if (this.can('delete-key') && (e.key === this.deleteKeyCode || e.key === 'Delete')) {
                //刪除須經確認閘門(async): 此處不等待其完成, 鍵盤事件不因宿主確認流程而阻塞;
                //長按連發之重複請求由閘門之進行中旗標擋下
                this.deleteSelectedElements()
            }
        },
        onKeyUp(e) {
            //keyup不做編輯目標排除: 若按下發生於畫布而放開時焦點已入輸入框, 仍須清除該鍵避免殘留
            //無此鍵即不重建(如視窗外按下回到視窗才放開), 避免多餘之全樹渲染
            if (!(e.key in this.keysPressed)) return
            const copy = { ...this.keysPressed }
            delete copy[e.key]
            this.keysPressed = copy
        },

        // --- Canvas events ---
        onCanvasClick(event) {
            //只有畫布空白之點擊才代表畫布(分類單一來源 hitTest.classifyHit, 與 onCanvasMouseDown 同一判準)
            if (!isCanvasBlank(event.target, this.$el)) return
            //本次手勢已是框選(於mousedown鎖定)者不清空選取: 是否提交新選取交由endSelection依門檻決定
            //why: canvas-click由FlowCanvas元素層mouseup同步emit, 必先於document層之onDocMouseUp→endSelection,
            //     此處若逕自clearSelection, 框選手勢中途放開會先清掉既有選取
            if (this.isSelecting) {
                this.$emit('pane-click', event)
                return
            }
            this.clearSelection()
            this.$emit('pane-click', event)
        },
        onCanvasContextMenu(event) {
            //工具列(panel)上之右鍵不代表畫布(affordance 不代表宿主, spec §3); 節點/邊自身已 .stop 不會到此
            if (classifyHit(event.target, this.$el) === 'panel') return
            this.$emit('pane-context-menu', event)
        },
        onCanvasDblClick(event) {
            if (classifyHit(event.target, this.$el) === 'panel') return
            // Calculate flow-space position from the click
            const rect = this.$refs.canvas.getContainerRect()
            if (!rect) return
            const fp = screenToFlow(clientToLocal(event.clientX, event.clientY, rect), this.viewport)

            this.$emit('canvas-dblclick', {
                event,
                flowX: fp.x,
                flowY: fp.y,
                clientX: event.clientX,
                clientY: event.clientY,
            })
        },
        onCanvasMouseDown(event) {
            //非主鍵不啟動任何畫布層手勢: 判準對齊NodeWrapper.onMouseDown之event.button !== 0
            if (event.button !== 0) return
            //只有按在畫布空白才啟動畫布層手勢(框選/平移); 節點/邊/把手/齒輪/四角/轉折點/工具列各有自己的手勢語義,
            //它們刻意不用@mousedown.stop(stopPropagation會擋掉window層WPopup互斥協調), 故mousedown必定冒泡至此
            if (!isCanvasBlank(event.target, this.$el)) return
            if (this.elementsSelectable && this.isBoxSelectActive) {
                this.startSelection(event)
                return
            }
            if (this.panOnDrag) {
                this.startPan(event)
            }
        },
        onCanvasWheel(event) {
            if (!this.zoomOnScroll) return
            //滾輪縮放亦為viewport寫入者, 先取消程式動畫避免二者互相覆寫
            this.cancelViewportAnimation()
            const delta = -event.deltaY * 0.001
            const vp = this.viewport
            //fitView 可低於 zoomMin: 以當前值為下界, 滾輪不得把視圖跳回(viewport.clampZoom)
            const newZoom = clampZoom(vp.zoom + delta * vp.zoom, this.zoomMin, this.zoomMax, vp.zoom)
            const rect = this.$refs.canvas.getContainerRect()
            if (!rect) return
            this.setViewport(zoomAroundPoint(vp, clientToLocal(event.clientX, event.clientY, rect), newZoom))
            this.emitViewportChange()
        },

        // --- Document-level mouse ---
        onDocMouseMove(event) {
            //按鍵已放開卻仍處於進行中狀態: 代表收尾事件未送達(於瀏覽器視窗外放開/視窗失焦/手勢被原生拖曳接管),
            //此時須主動走既有收尾路徑清除狀態; 只return不清狀態無效, 旗標續留為真下次移動仍會誤判
            if ((event.buttons & 1) === 0) {
                //建線不併入onDocMouseUp: endConnect以事件座標做drop落點判定, 而此處是「回到文件後的第一次移動」,
                //其座標已非放開當下位置, 交給endConnect會把途經之target handle誤判為落點, 建立使用者從未放開過的連線
                //(已重現: 回來後第一次mousemove落在另一節點之target handle即產生conn並發update:conns)
                if (this.isConnecting) {
                    this.cancelConnect(event)
                }
                if (this.isPanning || this.isDraggingNode || this.isSelecting) {
                    this.onDocMouseUp(event)
                }
                return
            }
            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)
            }
        },
        onWindowBlur(event) {
            //視窗失焦後不會再收到mouseup與keyup, 於此統一收尾避免狀態黏住(終止分派見 endActiveGesture)
            this.endActiveGesture('blur', event)
            this.keysPressed = {}
        },
        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)
            }
        },

        // --- Gesture lifecycle(一次一手勢, spec/流程_互動契約.md §5) ---
        //啟動: 設 activeGesture、根 class(渲染面 scalar 僅於翻轉時寫入)、擁有者標記(dataset, 不觸發重渲染)、關閉全部 popup。
        //why 關 popup: 把手/四角之 mousedown 帶 .stop, WPopup 掛在 window 之互斥關閉收不到(實測 A 之 popup 於自 B 拉線/縮放 B
        //期間整段不關); 統一於手勢啟動處關閉, 各手勢入口不再各自為政
        //回傳是否取得手勢: 已有進行中手勢即拒絕(一次一手勢之唯一斷言, 各入口先 acquire 再建立副作用)
        beginGesture(type, ownerEl) {
            if (this.activeGesture) return false
            this.activeGesture = type
            if (!this.gesturing) this.gesturing = true
            this._gestureOwnerEl = ownerEl || null
            setDomFlag(this._gestureOwnerEl, 'data-gesture-owner', true)
            this.closeAllPopups()
            return true
        },
        //結束: 只有同型手勢才清除(防他手勢之收尾誤清), 收尾路徑(mouseup/blur/destroy/上鎖)共用
        endGesture(type) {
            if (this.activeGesture !== type) return
            this.activeGesture = null
            if (this.gesturing) this.gesturing = false
            setDomFlag(this._gestureOwnerEl, 'data-gesture-owner', null)
            this._gestureOwnerEl = null
        },
        //popup 開啟閘門(overlay 規則 §6): 供 Node/EdgeWrapper 經 inject 讀取, 含公開 API 與 panToNode 之延遲開啟
        canOpenPopup() {
            return !this.isMultiSelectActive && !this.activeGesture
        },
        closeAllPopups() {
            const ns = (this.$refs.nodeRenderer && this.$refs.nodeRenderer.$refs.wrappers) || []
            const es = (this.$refs.edgeRenderer && this.$refs.edgeRenderer.$refs.wrappers) || []
            for (const w of ns) if (w && w.closePopups) w.closePopups()
            for (const w of es) if (w && w.closePopups) w.closePopups()
        },
        //上鎖切換時之手勢政策(§5): connect/boxselect/pan 取消; drag 取消提交(ghost 復原不寫回);
        //resize/waypoint 由子元件持有監聽, 子元件監看 locked prop 即時取消(node-resize-cancel / conn-waypoint-end cancelled),
        //其收尾事件若仍到達亦依 locked 拒絕提交(onNodeResizeEnd / onConnSettingsUpdate, 縱深第二層)
        cancelGesturesForLock() {
            this.endActiveGesture('lock')
        },
        //WFlowVue 持有之手勢於 blur / lock 之終止分派(契約 §5 終止列; 單一表, 新增手勢於此登記):
        //  pan:       blur 提交(平移無提交語義, 只發 viewport-change) / lock 取消
        //  drag:      blur 以最後 ghost 提交(event 為 blur 事件, endDrag 不讀座標) / lock 取消提交(ghost 復原不寫回)
        //  boxselect: 皆取消(失焦後回到視窗之過期框不得覆寫選取)
        //  connect:   皆取消(失焦無「放開當下」之有效座標, 不做落點判定)
        //  resize / waypoint: 由子元件持有 document 監聽(blur 經 domGesture 收尾; lock 經 locked prop watcher 取消), 此處不涉
        endActiveGesture(reason, event) {
            switch (this.activeGesture) {
            case 'pan':
                if (reason === 'blur') this.endPan()
                else this.cancelPan()
                break
            case 'drag':
                if (reason === 'blur') this.endDrag(event)
                else this.cancelDrag()
                break
            case 'boxselect':
                this.cancelSelection()
                break
            case 'connect':
                this.cancelConnect(event)
                break
            default:
                break
            }
        },
        cancelDrag() {
            if (this.dragNodeStartPositions) {
                //ghost 插槽於節點被刪時已移除, 只復原仍存在者
                for (let id in this.dragNodeStartPositions) {
                    if (id in this.dragPositions) this.dragPositions[id] = null
                }
            }
            this.isDraggingNode = false
            this.draggingNodeId = null
            this.dragStartPos = null
            this.dragNodeStartPositions = null
            this.endGesture('drag')
        },
        onNodeResizeStart({ el }) {
            this.beginGesture('resize', el)
        },
        onNodeResizeCancel({ nodeId }) {
            if (nodeId in this.dragPositions) this.dragPositions[nodeId] = null
            this.endGesture('resize')
        },
        onConnWaypointStart({ el }) {
            this.beginGesture('waypoint', el)
        },
        onConnWaypointEnd() {
            this.endGesture('waypoint')
        },

        // --- Pan ---
        startPan(event) {
            //手動平移優先於程式動畫, 否則二者同時寫viewport而互相覆寫
            if (!this.beginGesture('pan', null)) return
            this.cancelViewportAnimation()
            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.endGesture('pan')
            this.emitViewportChange()
        },
        //取消平移(上鎖切換): 已平移之 viewport 不回退(平移無提交語義), 只收手勢、不再發 viewport-change
        cancelPan() {
            this.isPanning = false
            this.panStartPos = null
            this.endGesture('pan')
        },

        // --- Node drag ---
        //於節點mousedown當下先行選取(拖曳本身延後至跨越位移門檻才由onNodeDragStart啟動)。
        //守衛與選取條件皆與修正前之onNodeDragStart一致, 故選取時機不因拖曳延後而改變。
        onNodeDragPrepare({ node }) {
            if (!this.can('drag')) return
            //已在選取集合內者不塌陷選取, 否則框選一組後直接拖其中一顆會只搬動該顆
            //(單純點擊仍由onNodeClick收斂為單選, 故單擊改選這顆之行為不變)
            if (this.selectNodesOnDrag && !this.isMultiSelectActive && !this.selectedNodes.includes(node.id)) {
                this.setSelectedNodes([node.id])
                this.setSelectedConns([])
            }
        },
        onNodeDragStart({ node, event, moveEvent }) {
            if (!this.can('drag')) return

            // Cache start positions for drag
            //拖曳成員過濾: hidden(未渲染)與draggable:false之節點不得被同組節點連帶搬走, 否則該旗標語義被破壞
            //(過濾與早退須在設isDraggingNode之前, 否則早退會留下isDraggingNode為真之黏住狀態)
            const canMove = (n) => !!n && !n.hidden && n.draggable !== false
            const starts = {}
            this.selectedNodes.forEach(id => {
                const n = this.nodeById(id)
                if (canMove(n)) starts[id] = { x: n.position.x, y: n.position.y }
            })
            if (!starts[node.id]) {
                const n = this.nodeById(node.id)
                if (canMove(n)) starts[node.id] = { x: n.position.x, y: n.position.y }
            }
            if (!starts[node.id]) return
            //一次一手勢(縱深第二層: NodeWrapper.onMouseDown 已擋): 先取得手勢再建立狀態
            if (!this.beginGesture('drag', this.$el.querySelector(`.vue-flow__node[data-id="${node.id}"]`))) return

            this.isDraggingNode = true
            this.draggingNodeId = node.id
            this.dragStartPos = { x: event.clientX, y: event.clientY }
            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 })

            //立即套用跨門檻的這一次mousemove: document層之onDocMouseMove於本輪事件已先看過
            //(當時isDraggingNode仍為false而返回), 不補這一次則跨門檻後立刻放開將完全沒有位移
            if (moveEvent && this.isDraggingNode) {
                this.doDrag(moveEvent)
            }
        },
        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) {
            //上鎖中之拖曳取消提交(§5): ghost 復原, 不寫回不發 update:nodes
            if (this.locked) {
                this.cancelDrag()
                return
            }
            // 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
            this.endGesture('drag')
            if (dragNode) {
                this.$emit('node-drag-stop', { node: dragNode, event })
            }
            this.emitNodesUpdate()
        },

        // --- Connection ---
        onConnectStart(payload) {
            if (!this.can('connect')) return
            //複選模式中不啟動建線(把手已隱藏, 此為縱深第二層; 守衛先於任何狀態/標記之設定)
            if (this.isMultiSelectActive) return
            //任一邊之把手皆可出發(spec §4); 方位非四值即不啟動
            if (!isSide(payload.handlePosition)) return
            //重入守衛(縱深第二層, Handle已擋非主鍵): 拉線途中他途再送connect-start不得重跑啟動流程,
            //否則出發把手標記/狀態被改寫而失去清理參照; 一次一手勢: 他手勢進行中亦不啟動
            if (this.isConnecting) return
            //節點不存在即不啟動: 此檢查須先於狀態設定, 否則早退會留下isConnecting與把手標記
            const node = this.nodeById(payload.nodeId)
            if (!node) return

            //出發 endpoint(preview/commit 共用之判定輸入; element 供暫態視覺標記與清理)
            const originEl = payload.event
                ? (payload.event.currentTarget || (payload.event.target && payload.event.target.closest && payload.event.target.closest('.vue-flow__handle')))
                : null
            //出發節點元素: 標 data-connect-origin-node 使其全部其他把手立即呈 not-allowed(自我連線, 純 CSS 不需 hover 判定)
            const originNodeEl = originEl && originEl.closest ? originEl.closest('.vue-flow__node') : null
            //一次一手勢: 先取得手勢, 再設出發端點與 DOM 標記(否則被拒時留下標記無人清理)
            if (!this.beginGesture('connect', originNodeEl)) return
            this._connectOrigin = {
                nodeId: payload.nodeId,
                position: payload.handlePosition,
                connectable: true,
                element: originEl || null,
                nodeElement: originNodeEl,
            }
            this._connectHoverEl = null
            setHandleConnectRole(originEl, 'origin')
            setDomFlag(originNodeEl, 'data-connect-origin-node', true)

            this.connectionVisual.active = true

            //起點 = 出發邊之連接點(與把手渲染同一 fraction 基準)
            const pos = getHandlePosition(node, payload.handlePosition, this.nodeInternals[payload.nodeId] || {}, this.defNode)
            this.connectionVisual.fromX = pos.x
            this.connectionVisual.fromY = pos.y
            this.connectionVisual.fromPosition = payload.handlePosition
            this.connectionVisual.toX = pos.x
            this.connectionVisual.toY = pos.y
            this.connectionVisual.toPosition = this.defaultFarPosition()
            this.connectionVisual.dropStatus = 'none'

            this.$emit('connect-start', {
                nodeId: payload.nodeId,
                handlePosition: payload.handlePosition,
            })
        },
        doConnect(event) {
            const rect = this.$refs.canvas.getContainerRect()
            if (!rect) return
            const vp = this.viewport
            const fp = screenToFlow(clientToLocal(event.clientX, event.clientY, rect), vp)
            this.connectionVisual.toX = fp.x
            this.connectionVisual.toY = fp.y
            //游標下落點之即時判定(對齊 React Flow/Vue Flow: 拖曳中逐 hover 目標評估, 非只在放開時):
            //僅於「游標下把手 identity 改變」時判定一次並標記, 不逐幀重算(validator 呼叫紀律)
            const handleEl = findHandleElAt(event.clientX, event.clientY)
            if (handleEl !== this._connectHoverEl) {
                setHandleConnectStatus(this._connectHoverEl, null)
                this._connectHoverEl = handleEl
                let status = 'none'
                let toPosition = this.defaultFarPosition()
                if (handleEl) {
                    const target = describeHandleEndpoint(handleEl, this.flowId)
                    if (target) {
                        //preview 與 commit 共用 assessConnection(相同 endpoint 對必得相同結論);
                        //宿主 validator 拋錯視為 invalid(hover 屬預覽, 不得讓 mousemove listener 逐次拋錯;
                        //commit 路徑之拋錯由 endConnect 之 finally 保證清理後原樣上拋)
                        let r
                        try {
                            r = assessConnection(this._connectOrigin, target, {
                                nodes: this.nodes, conns: this.conns, validator: this.funValidConnCreating,
                            })
                        }
                        catch (e) {
                            r = { valid: false }
                        }
                        status = r.valid ? 'valid' : 'invalid'
                        if (target.position) toPosition = target.position
                        setHandleConnectStatus(handleEl, status)
                    }
                    //他 flow 實例之把手(describe 回 null): 不標記不反應, status 維持 'none'
                }
                if (this.connectionVisual.dropStatus !== status) this.connectionVisual.dropStatus = status
                if (this.connectionVisual.toPosition !== toPosition) this.connectionVisual.toPosition = toPosition
            }
        },
        endConnect(event) {
            //落點判定與 commit: 與 doConnect 之 preview 共用 describeHandleEndpoint + assessConnection,
            //不另手組 connection(preview/commit 同源, 不會分家)。
            //候選 { from, to, fromPosition, toPosition }: 出發端為 from, 落點端為 to, 兩端方位即各自所在之邊
            let result = { valid: false, reason: 'no-endpoint', connection: null }
            try {
                const handleEl = findHandleElAt(event.clientX, event.clientY)
                const target = describeHandleEndpoint(handleEl, this.flowId)
                if (target && this._connectOrigin) {
                    result = assessConnection(this._connectOrigin, target, {
                        nodes: this.nodes, conns: this.conns, validator: this.funValidConnCreating,
                    })
                    if (result.valid) {
                        const connection = result.connection
                        const connId = `e${connection.from}-${connection.to}`
                        //拖曳建線之新邊: to 端自動帶實心箭頭(方向可見; 宿主可於表單改為 None/線式)
                        const conn = {
                            id: this.connById(connId) ? generateId() : connId,
                            ...connection,
                            markerTo: 'arrowclosed',
                        }
                        this.addConn(conn)
                        this.emitConnsUpdate()
                        this.$emit('connect', connection)
                    }
                }
            }
            finally {
                //清理入 finally: validator/addConn/宿主事件handler拋錯時不得留下建線狀態與把手標記
                //(原版清理接在 commit 之後循序執行, 中途拋錯即黏死 isConnecting 與全域樣式)
                //connect-end 第二參數為判定結果(additive): 宿主可據 reason 說明為何未建線
                this.$emit('connect-end', event, { valid: result.valid, reason: result.reason, connection: result.connection })
                this.resetConnectGesture()
            }
        },
        //取消建線: 不做落點判定亦不建立連線, 供視窗失焦與buttons補收尾使用——二者皆無「放開當下」之有效座標,
        //交由endConnect會以錯誤座標做drop hit-test; 仍發connect-end使宿主能收尾自身UI(與endDrag於失焦時照發node-drag-stop同理)
        cancelConnect(event) {
            if (!this.isConnecting) return
            this.$emit('connect-end', event, { valid: false, reason: 'cancelled', connection: null })
            this.resetConnectGesture()
        },
        //建線手勢之統一清理(正常放開/取消/銷毀共用): 重置視覺容器欄位、清除出發與hover把手之暫態標記。
        //把手標記為 DOM dataset(非反應式), 必須顯式清除; 重複呼叫安全(setHandleConnectXxx 對 null 無操作)
        resetConnectGesture() {
            this.connectionVisual.active = false
            this.connectionVisual.dropStatus = 'none'
            this.connectionVisual.toPosition = 'top'
            setHandleConnectStatus(this._connectHoverEl, null)
            this._connectHoverEl = null
            if (this._connectOrigin) {
                setHandleConnectRole(this._connectOrigin.element, null)
                setDomFlag(this._connectOrigin.nodeElement, 'data-connect-origin-node', null)
            }
            this._connectOrigin = null
            this.endGesture('connect')
        },
        //建線中無 hover 把手時之遠端預設方位: 出發邊之對邊(自 right 出發即預覽線以 left 進入游標)
        defaultFarPosition() {
            return oppositeSide(this.connectionVisual.fromPosition)
        },

        // --- Selection ---
        onNodeClick({ node, event }) {
            //本次手勢已被接受為拖曳者不再視為點擊: NodeWrapper之@mouseup綁在節點元素上,
            //必先於document層之onDocMouseUp→endDrag觸發, 故此刻isDraggingNode仍為true即代表拖曳成立。
            //(涵蓋「跨門檻拖出後又移回原點放開」之情形——該情形最終位移為0, 僅靠距離判準無法辨識)
            if (this.isDraggingNode) return
            if (!this.elementsSelectable) return
            //複選鍵未生效時(含鎖定/宿主關閉複選)一律走單選路徑: 該節點取得active, 宿主據node-click同步外部清單
            if (this.isMultiSelectActive) {
                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)
        },
        //設定更新入口只受理節點 schema 內之欄位(NODE_SETTING_KEYS): 已移除之欄位(type/toPosition/fromPosition)與未知鍵一律拒絕,
        //不寫回亦不發事件(不支援舊欄位; 契約 §4.1)
        onNodeSettingsUpdate({ node, key, value }) {
            const n = this.nodeById(node.id)
            if (!n) return
            this.applySettingUpdate(n, key, value, NODE_SETTING_KEYS, 'node-settings-update', 'node')
        },
        //設定更新之唯一寫入路徑(節點/連線共用): 白名單(schema allowlist)→ 反應式寫入 → 發出事件
        applySettingUpdate(target, key, value, allowedKeys, eventName, payloadKey) {
            if (allowedKeys.indexOf(key) < 0) {
                console.warn(`[w-flow-vue] ${eventName}: unknown field "${key}" rejected`)
                return false
            }
            this.$set(target, key, value)
            this.$emit(eventName, { [payloadKey]: target, key, value })
            return true
        },
        //節點設定表單之刪除入口: 與其他入口共用 runDelete(確認閘門/連帶/政策/事件皆同一核心)
        async onNodeSettingsDelete({ node }) {
            const r = await this.runDelete({ nodeIds: [node.id] }, { from: 'node-settings', selection: 'ids' })
            return r.ok
        },
        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.isMultiSelectActive) {
                this.$emit('conn-click', { conn, event })
                return
            }
            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 }) {
            //上鎖中之轉折點拖曳取消提交(§5): 拖曳中上鎖後放開, 不寫回 points
            if (key === 'points' && this.locked && this.activeGesture === 'waypoint') return
            const c = this.connById(conn.id)
            if (!c) return
            this.applySettingUpdate(c, key, value, CONN_SETTING_KEYS, 'conn-settings-update', 'conn')
        },
        async onConnSettingsDelete({ conn }) {
            const r = await this.runDelete({ connIds: [conn.id] }, { from: 'conn-settings', selection: 'ids' })
            return r.ok
        },

        // --- Activate(單元素active轉移) ---
        //齒輪/縮放把手等「元素專屬操作」使該元素成為唯一選取(active)
        //why: 此類操作之作用對象只有該元素, 不沿用拖曳之「已選不塌陷」——沿用會使視覺選取(A+B)與
        //     實際作用對象(B)不一致, 且已在集合內時不發事件, 宿主據以同步之外部清單將停留在舊項目;
        //     按住多選鍵時控制項語義優先, 仍單選不做toggle
        onNodeActivate({ node }) {
            if (!this.elementsSelectable) return
            //複選模式中不做sole-select(齒輪/縮放把手已隱藏點不到, 此為縱深invariant):
            //模式中之選取變更只能走onNodeClick之toggle路徑, 程式化選取應走選取API而非借用UI activate
            if (this.isMultiSelectActive) return
            //已是唯一選取即不重發: 重複點同一齒輪不應連發selection-change
            if (this.selectedNodes.length === 1 && this.selectedNodes[0] === node.id && this.selectedConns.length === 0) return
            this.setSelectedNodes([node.id])
            this.setSelectedConns([])
            this.emitSelectionChange()
        },
        onConnActivate({ conn }) {
            if (!this.elementsSelectable) return
            //同onNodeActivate: 複選模式中不做sole-select
            if (this.isMultiSelectActive) return
            if (this.selectedConns.length === 1 && this.selectedConns[0] === conn.id && this.selectedNodes.length === 0) return
            this.setSelectedConns([conn.id])
            this.setSelectedNodes([])
            this.emitSelectionChange()
        },

        startSelection(event) {
            const rect = this.$refs.canvas.getContainerRect()
            if (!rect) return //rect取不到即不進入框選態, 否則留下isSelecting為真之殘留狀態
            if (!this.beginGesture('boxselect', null)) return
            this.isSelecting = true
            this.selectionCrossedThreshold = false
            this.selectionStartPos = {
                x: event.clientX - rect.left,
                y: event.clientY - rect.top,
            }
            this.selectionVisual.box = {
                x: this.selectionStartPos.x,
                y: this.selectionStartPos.y,
                width: 0,
                height: 0,
            }
        },
        doSelection(event) {
            const rect = this.$refs.canvas.getContainerRect()
            if (!rect || !this.selectionStartPos) return
            const currentX = event.clientX - rect.left
            const currentY = event.clientY - rect.top
            const width = Math.abs(currentX - this.selectionStartPos.x)
            const height = Math.abs(currentY - this.selectionStartPos.y)
            //跨過門檻即鎖住: 供endSelection判定本次是真的拉了框, 或只是原地按放(後者不得覆寫既有選取)
            if (crossedThreshold(this.selectionStartPos.x, this.selectionStartPos.y, currentX, currentY)) {
                this.selectionCrossedThreshold = true
            }
            const x = Math.min(this.selectionStartPos.x, currentX)
            const y = Math.min(this.selectionStartPos.y, currentY)
            this.selectionVisual.box = { x, y, width, height }
        },
        //只清框選手勢狀態, 不提交選取(供視窗失焦與未跨門檻之收尾)
        cancelSelection() {
            this.isSelecting = false
            this.selectionStartPos = null
            this.selectionVisual.box = null
            this.selectionCrossedThreshold = false
            this.endGesture('boxselect')
        },
        endSelection() {
            //未跨門檻(原地按放)不提交選取: 否則零面積框恰落於游標下元素內時會取代既有選取,
            //使Shift+點擊之累加被覆寫, 且Shift+點空白處會清空整組選取
            if (this.selectionVisual.box && this.selectionCrossedThreshold) {
                const box = this.selectionVisual.box
                const vp = this.viewport
                //螢幕框 → 畫布框(viewport.screenToFlow)
                const o = screenToFlow({ x: box.x, y: box.y }, vp)
                const graphBox = { x: o.x, y: o.y, width: box.width / vp.zoom, height: box.height / vp.zoom }
                //hidden節點未渲染於畫布, 不應被框選選入
                const nodesVisible = this.nodes.filter(n => !n.hidden)
                const overlapping = getOverlappingNodes(graphBox, nodesVisible, this.nodeInternals, this.defNode)
                const nodeIds = overlapping.map(n => n.id)
                this.setSelectedNodes(nodeIds)
                //連線不參與框選複選: 連線為起訖節點錨點/轉折點/自身設定推得之衍生物, 節點移動時重繪即可,
                //不視為可被複選之項目, 故此處只清空而不依兩端是否入框自動選取
                this.setSelectedConns([])
                this.emitSelectionChange()
            }
            this.cancelSelection()
        },

        // --- Delete ---
        //刪除確認閘門: 全部刪除入口(節點設定表單/連線設定表單/刪除鍵)共用之單一 gate。
        //宿主未提供 opt.funConfirmDeleting 即直接刪除(套件不再內建二次確認UI);
        //提供則 await 其結果, 嚴格為 true 才真的刪除——回傳 false/undefined/拋錯一律不刪(不可預設為准)。
        //閘門之辨識性結果: { ok, reason }, reason ∈ 'busy' | 'cancelled' | 'confirm-error'
        //(舊寫法三者皆壓成 false, 公開 API 呼叫端無從分辨「宿主否決」與「確認進行中」)
        async confirmDeletingResult(payload) {
            const fn = this.funConfirmDeleting
            if (!fn) return { ok: true }
            //確認進行中不再受理新的刪除請求: 避免同一目標連點兩次而重複刪除/重複發事件,
            //或同時開出多個確認流程(宿主之modal通常為單例)
            if (this.deleteConfirming) return { ok: false, reason: 'busy' }
            //反應式旗標(非 this._ 私有屬性): 設定表單之刪除鈕據此呈現 pending——
            //宿主確認若為慢流程(遠端查詢), 按鈕毫無回饋會被當成沒反應而連點
            this.deleteConfirming = true
            try {
                const ok = await fn(payload)
                return ok === true ? { ok: true } : { ok: false, reason: 'cancelled' }
            }
            catch (e) {
                //宿主callback拋錯=無從確認 → 不刪; 不靜默吞掉, 使宿主能發現自身錯誤
                console.error('[w-flow-vue] funConfirmDeleting threw, deletion aborted:', e)
                return { ok: false, reason: 'confirm-error' }
            }
            finally {
                this.deleteConfirming = false
            }
        },
        //刪除鍵入口: 目標=目前選取; deletable:false 者由核心歸入 excluded; 刪除後清空全部選取
        async deleteSelectedElements() {
            const r = await this.runDelete({ nodeIds: this.selectedNodes.slice(), connIds: this.selectedConns.slice() }, {
                from: 'delete-key',
                selection: 'clear',
            })
            return r.ok
        },

        // --- Delete transaction coordinator ---
        //所有刪除入口之唯一提交路徑: preview → (確認閘門) → await 後重 preview 與 stale 判定 → 提交 →
        //選取/輔助狀態/手勢回收 → 清路由 cache → update:nodes / update:conns(僅實際變動之集合) → elements-deleted(最後)。
        //opt.from: 來源標記; opt.selection: 'ids'(僅移除被刪id)|'clear'(清空全部, 刪除鍵語義);
        //opt.confirm: 是否經閘門(預設 true); opt.cascadeNodeId: type 變更路徑之連帶歸因節點
        async runDelete(target, opt) {
            opt = opt || {}
            const from = opt.from || 'api'
            const confirm = opt.confirm !== false
            const graph = () => ({ nodes: this.nodes, conns: this.conns })

            let plan = previewDelete(graph(), target)
            if (plan.empty) return { ok: false, reason: 'empty' }
            if (plan.nodeIds.length === 0 && plan.connIds.length === 0) {
                //全數不存在 → not-found; 至少一個存在但全被 deletable:false 排除 → excluded
                const allMissing = plan.excluded.nodeIds.length === 0 && plan.excluded.connIds.length === 0
                return { ok: false, reason: allMissing ? 'not-found' : 'excluded', notFound: plan.notFound, excluded: plan.excluded }
            }

            if (confirm) {
                //閘門 payload 與既有契約一致: { nodes, conns, from }, conns 含連帶邊
                const gate = await this.confirmDeletingResult({ nodes: plan.nodes, conns: plan.conns, from })
                if (!gate.ok) return { ok: false, reason: gate.reason }
                //元件於await期間被銷毀即不再操作狀態
                if (this._isDestroyed) return { ok: false, reason: 'destroyed' }
                //await 後以「已確認之集合」(節點+連帶邊, 皆為明確目標)重新解析:
                //- 他途已刪者剔除(歸入 notFound);
                //- 已確認之連帶邊即使其節點已被他途移除仍刪除(不留孤兒邊: 使用者確認的就是這組);
                //- 期間新增之相鄰邊不在確認集合內 → stale, 不得默默擴大刪除。
                //因果(cascades)沿用確認時之歸屬, 僅保留仍被刪之邊; requested 沿用原始請求
                const confirmedNodeSet = new Set(plan.nodeIds)
                const confirmedConnSet = new Set(plan.connIds)
                const plan2 = previewDelete(graph(), { nodeIds: plan.nodeIds, connIds: plan.connIds })
                if (plan2.nodeIds.some(id => !confirmedNodeSet.has(id)) || plan2.connIds.some(id => !confirmedConnSet.has(id))) {
                    return { ok: false, reason: 'stale' }
                }
                if (plan2.nodeIds.length === 0 && plan2.connIds.length === 0) {
                    return { ok: false, reason: 'not-found', notFound: plan2.notFound, excluded: plan2.excluded }
                }
                const kept = new Set(plan2.connIds)
                plan2.cascades = plan.cascades
                    .map(c => ({ nodeId: c.nodeId, connIds: c.connIds.filter(id => kept.has(id)) }))
                    .filter(c => c.connIds.length > 0)
                plan2.requested = plan.requested
                //對帳沿用確認時之結果並併入 await 期間新消失者; excluded 於確認時已決定
                const uniq = (a, b) => [...new Set([...a, ...b])]
                plan2.notFound = {
                    nodeIds: uniq(plan.notFound.nodeIds, plan2.notFound.nodeIds),
                    connIds: uniq(plan.notFound.connIds, plan2.notFound.connIds),
                }
                plan2.excluded = plan.excluded
                plan = plan2
            }

            //提交(以 id 重新解析後 splice)
            const removed = applyDelete(graph(), { nodeIds: plan.nodeIds, connIds: plan.connIds })
            this.cleanupAfterDelete(removed, { selection: opt.selection || 'ids' })

            //type 變更路徑: 被移除之邊全部歸因於該節點(直接指定之 connIds 於 previewDelete 不算連帶)
            if (opt.cascadeNodeId !== undefined && removed.conns.length > 0) {
                plan.requested = { nodeIds: [], connIds: [] }
                plan.cascades = [{ nodeId: opt.cascadeNodeId, connIds: removed.conns.map(c => c.id) }]
            }
            const payload = this.buildDeletedPayload(plan, removed, from)
            //所有入口同一事件序列: 僅對實際變動之集合發 update:*, 最後發 elements-deleted
            if (removed.nodes.length > 0) this.emitNodesUpdate()
            if (removed.conns.length > 0) this.emitConnsUpdate()
            this.$emit('elements-deleted', payload)
            return { ok: true, ...payload }
        },
        //提交後之元件本地狀態回收(非 graph domain, 故留在 coordinator):
        //選取清單、nodeInternals/dragPositions(舊寫法從不回收, 長駐 map 隨刪除累積)、進行中之拖曳/建線手勢
        //刪除後之協調層清理: 選取(graph domain)/ 內部 map / 進行中手勢, 三者分開
        cleanupAfterDelete(removed, opt) {
            const nodeIds = new Set(removed.nodes.map(n => n.id))
            const connIds = new Set(removed.conns.map(c => c.id))
            this.pruneSelectionAfterDelete(nodeIds, connIds, opt && opt.selection)
            for (const id of nodeIds) {
                if (id in this.nodeInternals) this.$delete(this.nodeInternals, id)
                if (id in this.dragPositions) this.$delete(this.dragPositions, id)
            }
            this.cancelGesturesForDeleted(nodeIds)
        },
        //選取清理: mode 'clear' 全清, 否則只剔除被刪者(保留其餘選取)
        pruneSelectionAfterDelete(nodeIds, connIds, mode) {
            if (mode === 'clear') {
                this.clearSelection()
                return
            }
            for (let i = this.selectedNodes.length - 1; i >= 0; i--) {
                if (nodeIds.has(this.selectedNodes[i])) this.selectedNodes.splice(i, 1)
            }
            for (let i = this.selectedConns.length - 1; i >= 0; i--) {
                if (connIds.has(this.selectedConns[i])) this.selectedConns.splice(i, 1)
            }
        },
        //被刪節點涉及之進行中手勢一律取消(不提交): 拖曳成員被刪 → cancelDrag; 建線出發節點被刪 → cancelConnect(發 connect-end cancelled)
        cancelGesturesForDeleted(nodeIds) {
            if (this.isDraggingNode && this.dragNodeStartPositions) {
                const hit = Object.keys(this.dragNodeStartPositions).some(id => nodeIds.has(id)) || nodeIds.has(this.draggingNodeId)
                if (hit) this.cancelDrag()
            }
            if (this.isConnecting && this._connectOrigin && nodeIds.has(this._connectOrigin.nodeId)) {
                this.cancelConnect(null)
            }
        },
        //elements-deleted payload: 以 change record 投影; 快照深複製與內部脫鉤
        buildDeletedPayload(plan, removed, from) {
            return {
                from,
                requested: { nodeIds: plan.requested.nodeIds.slice(), connIds: plan.requested.connIds.slice() },
                deleted: {
                    nodeIds: removed.nodes.map(n => n.id),
                    connIds: removed.conns.map(c => c.id),
                    nodes: snapshotDeep(removed.nodes),
                    conns: snapshotDeep(removed.conns),
                },
                cascades: plan.cascades.map(c => ({ nodeId: c.nodeId, connIds: c.connIds.slice() })),
                notFound: { nodeIds: plan.notFound.nodeIds.slice(), connIds: plan.notFound.connIds.slice() },
                excluded: { nodeIds: plan.excluded.nodeIds.slice(), connIds: plan.excluded.connIds.slice() },
            }
        },

        // --- 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 }) {
            //上鎖中之縮放取消提交(§5; 與 onNodeResize 之 locked 守衛對稱——預覽已被擋, 不得提交使用者未見之尺寸)
            if (this.locked) {
                this.onNodeResizeCancel({ nodeId })
                return
            }
            this.endGesture('resize')
            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
            this.emitNodesUpdate()
        },

        // --- Helpers ---
        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() {
            //任何 viewport 變動(含程式化)即視為「使用者已有視圖」: 之後節點首次填入不再自動 fit
            this._viewportTouched = true
            this.$emit('viewport-change', { ...this.viewport })
        },
        getViewport() {
            //即時視口: viewport-change僅於手勢結束才發出, 平移途中呼叫端需要當下值時走此方法
            //(呼叫端若以viewport-change快取之值為基準回寫setViewport, 會於平移途中以過期值覆寫使用者正在進行的平移)
            let vp = this.viewport
            return { x: vp.x, y: vp.y, zoom: vp.zoom }
        },
        cancelViewportAnimation() {
            //取消進行中之視口動畫(panToNode), 否則動畫與手動手勢會同時寫入同一個viewport
            if (this._panAnimId) {
                cancelAnimationFrame(this._panAnimId)
                this._panAnimId = null
            }
        },
        emitSelectionChange() {
            this.$emit('selection-change', this.getSelectedElements())
        },

        // --- Public API ---
        //padding(CSS 像素; 數值或 { top,right,bottom,left } 物件, 物件為對 opt 解析值之逐邊 patch)
        //未給/非法之邊回退 opt.fitViewPadding; 0 為合法值 = 貼邊, 節點可進入工具區選單底下——
        //留白一律由 padding 決定, 不為選單自動讓位(同 Leaflet fitBounds / OpenLayers View.fit / React Flow fitView);
        //無可見節點不改 viewport; zoom 上限 zoomMax(下限不設, 見 clampZoom)
        fitView(padding) {
            const bounds = nodesBounds(this.nodes, this.nodeInternals, this.defNode)
            if (!bounds) return
            this.cancelViewportAnimation()
            const pad = resolvePadding(padding, this.fitViewPadding)
            const vp = computeFitView(bounds, this.containerSize(), { padding: pad, maxZoom: this.zoomMax })
            if (!vp) return
            this.setViewport(vp)
            this.emitViewportChange()
        },
        //容器尺寸: 逐軸取實測 rect, 未佈局(0)之軸回退 opt 寬高
        containerSize() {
            const rect = this.$refs.canvas ? this.$refs.canvas.getContainerRect() : null
            return resolveContainerSize(rect, { width: this.widthInp, height: this.heightInp })
        },
        zoomAtCenter(factor) {
            this.cancelViewportAnimation()
            const vp = this.viewport
            const newZoom = clampZoom(vp.zoom * factor, this.zoomMin, this.zoomMax, vp.zoom)
            const c = this.containerSize()
            this.setViewport(zoomAroundPoint(vp, { x: c.width / 2, y: c.height / 2 }, newZoom))
            this.emitViewportChange()
        },
        zoomIn() {
            this.zoomAtCenter(1.2)
        },
        zoomOut() {
            this.zoomAtCenter(1 / 1.2)
        },
        toggleInteractive() {
            this.locked = !this.locked
            //進入上鎖: 進行中手勢依 §5 政策取消(原版只翻旗標, 拉線/框選會帶著上鎖態繼續)
            if (this.locked) this.cancelGesturesForLock()
            this.$emit('toggle-interactive', this.locked)
        },
        panToNode(nodeId, opt) {
            opt = opt || {}
            let node = this.nodeById(nodeId)
            if (!node) return false
            let { width: w, height: h } = resolveNodeSize(node, this.nodeInternals[nodeId], this.defNode)
            let zoom = opt.zoom !== undefined ? opt.zoom : this.viewport.zoom
            let duration = opt.duration !== undefined ? opt.duration : 400
            let target = computeCenterView({ x: node.position.x + w / 2, y: node.position.y + h / 2 }, this.containerSize(), 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 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)),
            }
        },
        //公開刪除方法(契約見檔頭 Deleting 節): 與 UI 入口共用 runDelete; locked 為手勢鎖不擋程式化呼叫
        deleteElements(target, opt) {
            opt = opt || {}
            const t = target || {}
            return this.runDelete({
                nodeIds: Array.isArray(t.nodeIds) ? t.nodeIds : [],
                connIds: Array.isArray(t.connIds) ? t.connIds : [],
            }, {
                from: typeof opt.from === 'string' && opt.from ? opt.from : 'api',
                confirm: opt.confirm !== false,
                selection: 'ids',
            })
        },
        deleteNodes(nodeIds, opt) {
            return this.deleteElements({ nodeIds }, opt)
        },
        deleteConns(connIds, opt) {
            return this.deleteElements({ connIds }, opt)
        },
    },
}
</script>

<style scoped>


</style>

<!-- 建線期間之全域規則(非scoped: 目標元素位於深層子元件, scoped 之 data-v 屬性搆不到);
     一律錨定於根 class .vue-flow--connecting 之下, 只影響建線中的 flow 實例,
     取代原 document.head 注入之 * 全域選擇器(污染整頁與其他 flow 實例, 且拋錯時殘留) -->
<style>
/* 設定表單(節點/連線)之共用版面樣式已移至 components/ui/settingsForm.css,
   由兩個表單元件各自 import —— 使表單被外部單獨引用時亦自帶完整版面。 */
/* 元素 affordance 之淡入淡出(節點四角/齒輪/連線齒輪共用) */
.vue-flow__fade-enter-active,
.vue-flow__fade-leave-active {
  transition: opacity 0.15s ease;
}
.vue-flow__fade-enter,
.vue-flow__fade-leave-to {
  opacity: 0;
}
/* click/dblclick 模式: 齒輪錨區僅供 popup 定位, icon 不顯示 */
.vue-flow__node-settings-anchor--silent .vue-flow__node-settings,
.vue-flow__edge-settings-anchor--silent .vue-flow__edge-settings {
  visibility: hidden;
  pointer-events: none;
}
/* 初始視口待定: 圖面內容不可見(保留佈局供量測)且不可操作, 直到首幀 fit 完成 */
.vue-flow--pending {
  pointer-events: none;
}
.vue-flow--pending .vue-flow__viewport,
.vue-flow--pending .vue-flow__background,
.vue-flow--pending .vue-flow__panel {
  visibility: hidden;
}
/* 鎖游標: 建線期間畫布內一律 default, 僅依把手判定狀態顯示 crosshair/not-allowed */
.vue-flow--connecting,
.vue-flow--connecting * {
  cursor: default !important;
}
/* 出發把手與判定合法之落點: crosshair(可連) */
.vue-flow--connecting .vue-flow__handle[data-connect-role="origin"],
.vue-flow--connecting .vue-flow__handle[data-connect-status="valid"] {
  cursor: crosshair !important;
}
/* 判定不合法之落點、出發節點之其他把手(自我連線, 不需 hover 判定即刻正確, spec §4): not-allowed */
.vue-flow--connecting .vue-flow__handle[data-connect-status="invalid"],
.vue-flow--connecting .vue-flow__node[data-connect-origin-node] .vue-flow__handle:not([data-connect-role="origin"]) {
  cursor: not-allowed !important;
}
/* 建線期間隱藏齒輪/縮放把手/邊轉折點(避免遮擋落點與誤觸; waypoint與齒輪縮放同屬元素專屬操作) */
.vue-flow--connecting .vue-flow__node-settings,
.vue-flow--connecting .vue-flow__edge-settings,
.vue-flow--connecting .vue-flow__resize,
.vue-flow--connecting .vue-flow__edge-waypoint {
  opacity: 0 !important;
  pointer-events: none !important;
}

/* 非建線之手勢進行中: 把手不可作為出發點(一次一手勢), 游標不承諾 crosshair(契約 §5) */
.vue-flow--gesturing:not(.vue-flow--connecting) .vue-flow__handle {
  cursor: default;
}
/* ─── 任何手勢進行中(根 class .vue-flow--gesturing; 契約 §5): 非擁有者元素之 hover affordance 抑制 ───
   實測: 拖曳 A 經過 B 時 B 亮起齒輪/四角/陰影, 邊加深; 手勢中途經之元素不得反應。
   擁有者(data-gesture-owner, 被拖/被縮放之節點或被拖轉折點之邊)保留其手勢所需之 affordance */
.vue-flow--gesturing .vue-flow__node:not([data-gesture-owner]) .vue-flow__node-settings-anchor,
.vue-flow--gesturing .vue-flow__node:not([data-gesture-owner]) .vue-flow__resize-group,
.vue-flow--gesturing .vue-flow__edge:not([data-gesture-owner]) .vue-flow__edge-settings-anchor {
  opacity: 0 !important;
  pointer-events: none !important;
}
.vue-flow--gesturing .vue-flow__node:not([data-gesture-owner]):not(.vue-flow__node--selected):hover {
  box-shadow: none !important;
}
.vue-flow--gesturing .vue-flow__edge--hovered:not([data-gesture-owner]):not(.vue-flow__edge--selected) > path {
  stroke: #b1b1b1;
}
/* 建線中亦為手勢: 齒輪/四角已由上方規則隱藏; 拖曳中之擁有者節點不顯示齒輪與四角(正在移動, 非設定時機) */
.vue-flow--gesturing .vue-flow__node--dragging .vue-flow__node-settings-anchor,
.vue-flow--gesturing .vue-flow__node--dragging .vue-flow__resize-group {
  opacity: 0 !important;
  pointer-events: none !important;
}

/* ─── 複選模式(根class .vue-flow--multiselecting): 按住複選鍵=進行複選操作 ───
   統一隱藏所有「元素專屬操作」affordance(節點齒輪/四角縮放/連出入把手/邊齒輪/邊轉折點):
   複選中點擊之語義一律為選取, 不得依點中部位給出不同反應(sole-select/開popup/啟動手勢)。
   visibility:hidden 併用: pointer-events:none 擋不住鍵盤焦點(WPopup trigger帶tabindex)與程式化click,
   visibility同時將其移出tab order; 程式層另有守衛為縱深。
   建線手勢進行中(.vue-flow--connecting)把手讓位不隱藏——拖線中按住複選鍵不得使落點把手消失 */
.vue-flow--multiselecting .vue-flow__node-settings-anchor,
.vue-flow--multiselecting .vue-flow__edge-settings-anchor,
.vue-flow--multiselecting .vue-flow__resize-group,
.vue-flow--multiselecting .vue-flow__edge-waypoint,
.vue-flow--multiselecting:not(.vue-flow--connecting) .vue-flow__handle {
  opacity: 0 !important;
  visibility: hidden !important;
  pointer-events: none !important;
}
</style>