components/nodes/Handle.vue

<template>
  <div
    v-if="!locked"
    :class="classes"
    :style="handleStyle"
    :data-handle-position="position"
    @mousedown.stop="onMouseDown"
    @click.stop
  />
</template>

<script>
import { handlePlacementStyle } from '../../js/nodeStyle.mjs'
import { gestureBlockedReason } from '../../js/domGesture.mjs'

/**
 * 連接點(把手): 節點四邊各一, 無連出/連入之分——任一把手皆可作為建線出發點與落點(spec/流程_互動契約.md §3-§4)。
 * 幾何契約: 圓心落在節點外框盒上該邊之連接點(nodeStyle.handlePlacementStyle 與 geometry.getHandlePosition 同一 fraction);
 * 定位含節點外框寬之外推, translate(-50%,-50%) 置中, hover 放大時圓心不動。
 * 樣式(面色/框線色/框線寬/尺寸)由 opt.defHandle* 經 defNode 解析, NodePorts 算一次以 styleVars 下傳(單一組, 四把手相同)。
 * 建線期間之三態視覺由節點/把手之 data-connect-* 標記驅動。
 * @click.stop: 把手為建線手勢之出發/落點, 點擊(按下放開不拖)不得冒泡至 NodeWrapper 之 WPopup trigger
 * 誤開節點資訊 popup; mousedown 早已 .stop 故節點亦不發 node-click, 與之對稱
 */
export default {
    name: 'FlowHandle',
    inject: {
        //複選模式(縱深最早邊界): 模式中把手已隱藏(CSS pointer-events:none, 真實點擊到不了這裡),
        //此守衛擋synthetic/程式化mousedown不啟動建線(模板@mousedown.stop仍生效, 僅阻手勢不改傳播語義)
        getMultiSelectActive: { default: () => () => false },
        //進行中手勢(一次一手勢): 已有手勢時不啟動建線
        getActiveGesture: { default: () => () => null },
    },
    props: {
        position: { type: String, required: true }, // 'top' | 'right' | 'bottom' | 'left'
        shape: { type: String, default: 'rectangle' },
        //把手樣式 CSS 變數(nodeStyle.handleStyleVars, 由 NodePorts 算一次下傳)
        styleVars: { type: Object, default: () => ({}) },
        connectable: { type: Boolean, default: true },
        locked: { type: Boolean, default: false },
        //所屬節點之外框寬(px): 定位外推量, 由節點元件以 nodeBorderWidth 解析後傳入
        nodeEdgeWidth: { type: Number, default: 1 },
    },
    computed: {
        handleStyle() {
            return { ...this.styleVars, ...handlePlacementStyle(this.shape, this.position, this.nodeEdgeWidth) }
        },
        classes() {
            return [
                'vue-flow__handle',
                `vue-flow__handle--${this.position}`,
                { 'vue-flow__handle--not-connectable': !this.connectable },
            ]
        },
    },
    methods: {
        onMouseDown(event) {
            if (!this.connectable) return
            //啟動守衛(domGesture.gestureBlockedReason 單一來源): 複選模式 / 進行中手勢 / 非主鍵皆不啟動(先於 preventDefault, 不吞事件語義)
            if (gestureBlockedReason({ button: event.button, multiSelectActive: this.getMultiSelectActive(), activeGesture: this.getActiveGesture() })) return
            //阻止文字選取隨拖線啟動(把手上按下拖曳屬建線手勢, 非選字)
            event.preventDefault()
            this.$emit('connect-start', { event, handlePosition: this.position })
        },
    },
}
</script>

<style scoped>
/* 配色/尺寸全由 CSS 變數驅動(Handle 之 inline style 注入; 無變數時之 fallback 即預設):
   --vf-hs 外徑(border-box, 含框線), --vf-hface 面色, --vf-hedge 框線色, --vf-hew 框線寬
   定位(left/top/transform)由 inline style 給定(nodeStyle.handlePlacementStyle) */
.vue-flow__handle {
  position: absolute;
  width: var(--vf-hs, 10px);
  height: var(--vf-hs, 10px);
  border-radius: 50%;
  background: var(--vf-hface, #555);
  border: var(--vf-hew, 1px) solid var(--vf-hedge, #fff);
  box-sizing: border-box;
  pointer-events: all;
  cursor: crosshair;
  z-index: 3;
}
/* 不可連把手: 無 affordance 亦不可 hit(設計限制, spec §8) */
.vue-flow__handle--not-connectable {
  cursor: default;
  pointer-events: none;
}
/* hover 放大 2px, 圓心不動(translate 置中) */
.vue-flow__handle:hover {
  background: #0041d0;
  width: calc(var(--vf-hs, 10px) + 2px);
  height: calc(var(--vf-hs, 10px) + 2px);
}

/* ─── 手勢進行中(祖先 .vue-flow--connecting 或 .vue-flow--gesturing)通用 hover 抑制 ───
   建線: 可連性只由精確判定之 data-connect-* 標記指示; 其他手勢(拖曳/縮放/轉折點/框選/平移): 途經之把手不反應 */
.vue-flow--connecting .vue-flow__handle:hover,
.vue-flow--gesturing .vue-flow__handle:hover {
  background: var(--vf-hface, #555);
  width: var(--vf-hs, 10px);
  height: var(--vf-hs, 10px);
}
/* 建線期間永不可為落點者一律淡化(不需 hover 判定即刻正確, spec §4): 出發節點之其他把手(自我連線) */
.vue-flow--connecting .vue-flow__node[data-connect-origin-node] .vue-flow__handle:not([data-connect-role="origin"]) {
  opacity: 0.4;
}
/* 出發把手(origin): 保持強調, 標示線之來源 */
.vue-flow__handle[data-connect-role="origin"] {
  box-shadow: 0 0 0 3px rgba(0, 65, 208, 0.35);
}
/* 游標下之精確判定(僅建線期間由 adapter 標記): 合法=主題藍 ring, 不合法=danger ring
   採 ring(box-shadow)不改 width/height, 避免尺寸變更造成 hit box 幾何抖動 */
.vue-flow--connecting .vue-flow__handle[data-connect-status="valid"] {
  background: #0041d0;
  box-shadow: 0 0 0 4px rgba(0, 65, 208, 0.45);
}
.vue-flow--connecting .vue-flow__handle[data-connect-status="invalid"] {
  background: #d14343;
  box-shadow: 0 0 0 4px rgba(209, 67, 67, 0.45);
  opacity: 1;
}
</style>