js/domGesture.mjs

/**
 * document 層滑鼠手勢工具 —— 節點拖曳/縮放、轉折點拖曳、label 位移追蹤共用同一份「掛/卸監聽 + 全域游標 + 終止原因」。
 *
 * 終止原因(reason): 'mouseup' | 'blur' | 'buttons-lost'(mousemove 時主鍵已放開: 視窗外放開未送達 mouseup)| 'dispose'(呼叫端主動收尾, 如銷毀/被新手勢取代)。
 * 契約(spec/流程_互動契約.md §5 終止列): 任一原因皆走同一收尾——卸 document/window 監聽、移除全域游標樣式; 提交/取消語義由呼叫端依 reason 決定。
 */

/** 拖曳啟動門檻(px): 畫布框選/平移、節點拖曳、label 位移判定共用 */
export const DRAG_THRESHOLD_PX = 3

/** 自 (x0,y0) 位移是否跨過門檻(任一軸 >= threshold) */
export function crossedThreshold(x0, y0, x1, y1, threshold = DRAG_THRESHOLD_PX) {
    return Math.abs(x1 - x0) >= threshold || Math.abs(y1 - y0) >= threshold
}

/**
 * 手勢啟動守衛(把手/四角/轉折點/節點共用之三段判準): 主鍵、非複選模式、無進行中手勢。
 * @returns {string|null} 拒絕原因 'button' | 'multiselect' | 'gesture', 可啟動回 null
 */
export function gestureBlockedReason({ button, multiSelectActive, activeGesture }) {
    if (multiSelectActive) return 'multiselect'
    if (activeGesture) return 'gesture'
    if (button !== undefined && button !== 0) return 'button'
    return null
}

/** 全域游標鎖定(手勢期間整頁同一游標); 回傳移除函式(可重複呼叫) */
export function installGlobalCursor(cursor) {
    if (!cursor || typeof document === 'undefined') return () => {}
    const el = document.createElement('style')
    el.setAttribute('data-vf-cursor', cursor)
    el.textContent = '* { cursor: ' + cursor + ' !important; }'
    document.head.appendChild(el)
    return () => {
        if (el.parentNode) el.parentNode.removeChild(el)
    }
}

/**
 * 啟動一段 document 層手勢。
 * @param {Object} o
 * @param {(e:MouseEvent)=>void} [o.onMove] 每次 mousemove(主鍵仍按著時)
 * @param {(reason:string, e?:Event)=>void} [o.onEnd] 終止(任一原因恰呼叫一次)
 * @param {string} [o.cursor] 期間鎖定之全域游標
 * @param {boolean} [o.requirePrimary=true] mousemove 時主鍵已放開即以 'buttons-lost' 終止
 * @returns {{ dispose: () => boolean, isActive: () => boolean }} dispose 回傳是否確實收掉一個進行中手勢(以 'dispose' 終止, 不呼叫 onEnd)
 */
export function startDocumentGesture(o) {
    const opt = o || {}
    const requirePrimary = opt.requirePrimary !== false
    let active = true
    const removeCursor = installGlobalCursor(opt.cursor)
    const teardown = () => {
        if (!active) return false
        active = false
        document.removeEventListener('mousemove', onMove)
        document.removeEventListener('mouseup', onUp)
        window.removeEventListener('blur', onBlur)
        removeCursor()
        return true
    }
    const finish = (reason, e) => {
        if (!teardown()) return
        if (opt.onEnd) opt.onEnd(reason, e)
    }
    const onMove = (e) => {
        if (requirePrimary && (e.buttons & 1) === 0) {
            finish('buttons-lost', e)
            return
        }
        if (opt.onMove) opt.onMove(e)
    }
    const onUp = (e) => finish('mouseup', e)
    const onBlur = (e) => finish('blur', e)
    document.addEventListener('mousemove', onMove)
    document.addEventListener('mouseup', onUp)
    window.addEventListener('blur', onBlur)
    return {
        dispose: () => teardown(),
        isActive: () => active,
    }
}