domDrag.mjs

import get from 'lodash-es/get.js'
import isNumber from 'lodash-es/isNumber.js'
import genID from './genID.mjs'
import isestr from './isestr.mjs'
import isnum from './isnum.mjs'
import isfun from './isfun.mjs'
import isEle from './isEle.mjs'
import cint from './cint.mjs'
import evem from './evem.mjs'
import domGetBoudRectRefSelf from './domGetBoudRectRefSelf.mjs'
import domDragPreview from './domDragPreview.mjs'
import optNum from './_optNum.mjs'


//kpDgs, 各群組: 群組字串 → { gid, members: Map(元素→實例), isActive, drag, teardown }; 用Map使任何字串皆可為群組名, 不與物件原型之鍵衝突
//  成員表以元素為鍵: 命中判定直接查表, 不讀屬性, 故預覽之拷貝、已解除之元素自然不在表中; 事件依元素查目前之實例送達, 宿主重繪後之新實例即收到後續事件
let kpDgs = new Map()

//active, 目前拖曳中之群組, 同時只有一個拖曳(跨群組亦同)
let active = null

//touchEndAt, 最近一次觸控拖曳因手指放開而結束之時間; 觸控輕點後瀏覽器補發之相容滑鼠事件於COMPAT_MOUSE_MS內不起手(無sourceCapabilities可判定時)
let touchEndAt = -Infinity
let COMPAT_MOUSE_MS = 800


function now() {
    return (typeof performance !== 'undefined' && isfun(performance.now)) ? performance.now() : Date.now()
}


//attr, 讀屬性; 熱路徑不經domGetAttr, 因其isEle(lodash isElement)對DOM物件每次約數µs
function attr(ele, k) {
    try {
        return ele.getAttribute(k)
    }
    catch (err) {
        return null
    }
}


//getIndex, 元素之順序指標(四捨五入為整數); 未帶指標或非有限數字者為null, 不可作為拖曳起點或落點
function getIndex(ele, attIndex) {
    let s = attr(ele, attIndex)
    if (!isnum(s) || !Number.isFinite(Number(s))) {
        return null
    }
    return cint(s)
}


//findTouch, 於觸點清單內找指定手指; id為null(合成事件未帶identifier)時清單須恰一筆
function findTouch(list, id) {
    let n = get(list, 'length', 0)
    if (id === null) {
        return n === 1 ? list[0] : null
    }
    for (let i = 0; i < n; i++) {
        if (get(list[i], 'identifier', null) === id) {
            return list[i]
        }
    }
    return null
}


//toPoint, 取clientX/Y, 非數字者回傳null
function toPoint(o) {
    let x = get(o, 'clientX', null)
    let y = get(o, 'clientY', null)
    if (!isNumber(x) || !isNumber(y)) {
        return null
    }
    return { clientX: x, clientY: y }
}


//listen, 掛監聽並回傳移除函數(帶相同options)
function listen(target, name, fun, options) {
    target.addEventListener(name, fun, options)
    return () => {
        target.removeEventListener(name, fun, options)
    }
}


//runAll, 依登記之相反順序呼叫
function runAll(offs) {
    while (offs.length > 0) {
        let f = offs.pop()
        try {
            f()
        }
        catch (err) {}
    }
}


//deliver, 送達事件至元素目前之實例, 先change後該事件(同原本之順序); 無實例(已解除且未重建)則不發; change之監聽器內解除該實例時不再送該事件
function deliver(g, ele, mode, build) {
    let inst = ele ? g.members.get(ele) : null
    if (!inst) {
        return
    }
    let msg = build(inst)
    inst.ev.emit('change', { mode, ...msg })
    if (g.members.get(ele) !== inst) {
        return
    }
    inst.ev.emit(mode, msg)
}


//hitTest, 指標下方之落點, 經過與放下共用: 以指標座標取該點全部元素(由上而下, 含元素自身與其子元素, 觸控亦同), 取第一個本群組之成員且帶有效順序指標者
//  不屬本群組之元素(遮罩、預覽等)與無效指標之成員不阻擋其下之成員; 巢狀時為最內層
function hitTest(g, p) {
    let els = []
    try {
        els = document.elementsFromPoint(p.clientX, p.clientY)
    }
    catch (err) {
        els = []
    }
    for (let el of els) {
        let inst = g.members.get(el)
        if (!inst) {
            continue
        }
        let ind = getIndex(el, inst.o.attIndex)
        if (ind === null) {
            continue
        }
        return { ele: el, ind }
    }
    return null
}


//endDrag, 結束拖曳之唯一寫法: 先清狀態、計時器、會話監聽與預覽, 再發事件, 監聽器拋錯或於監聽器內再開始拖曳皆不受殘留狀態影響
//  hit有值為放下: 經過中之項目與放下之項目不同時先對前者發leave, 再對落點發drop; 其餘結束(放開於項目外、取消、停用、解除等)若正經過某項目則發leave, 使enter必以leave或drop收尾
function endDrag(g, e = null, hit = null) {
    let d = g.drag
    if (!d) {
        return
    }

    //clear
    g.drag = null
    if (active === g) {
        active = null
    }
    if (d.timer !== null) {
        clearTimeout(d.timer)
        d.timer = null
    }
    runAll(d.offs)
    if (d.pv) {
        d.pv.clear()
        d.pv = null
    }

    //leave
    let endEle = d.endEle
    let endInd = d.endInd
    if (endEle && (!hit || endInd !== hit.ind)) {
        deliver(g, endEle, 'leave', (inst) => ({
            event: e,
            startInd: d.startInd,
            startEle: d.startEle,
            endInd,
            endEle,
            tarInd: endInd,
            tarEid: inst.eid,
        }))
    }

    //drop, 若拖曳至原拖曳項目上也要能觸發, 否則外部收不到滑鼠放掉訊息, 僅收得到拖曳至非拖曳項目的leave事件
    if (hit) {
        deliver(g, hit.ele, 'drop', (inst) => ({
            event: e,
            startInd: d.startInd,
            startEle: d.startEle,
            endInd: hit.ind,
            endEle: hit.ele,
            tarInd: hit.ind,
            tarEid: inst.eid,
        }))
    }

}


//scheduleTeardown, 群組最後一員離開時延後一個microtask再刪除: 同一輪內有成員加入(宿主重繪時先解除再重建)即沿用群組與進行中之拖曳
function scheduleTeardown(g) {
    if (g.teardown) {
        return
    }
    g.teardown = true
    Promise.resolve().then(() => {
        g.teardown = false
        if (g.members.size > 0) {
            return
        }
        endDrag(g)
        if (kpDgs.get(g.gid) === g) {
            kpDgs.delete(g.gid)
        }
    })
}


//checkSourceLater, 拖曳來源之實例解除後延後一個microtask再確認: 來源元素已無實例(未重建)才取消, 預覽即時移除; 重建者拖曳延續
function checkSourceLater(g, d) {
    Promise.resolve().then(() => {
        if (g.drag === d && !g.members.has(d.startEle)) {
            endDrag(g)
        }
    })
}


//onMove, 經過: 判定與事件皆以順序指標比較是否同一項目(同原本)
function onMove(g, d, e) {
    if (g.drag !== d) {
        return
    }

    //p, 觸控只看起手手指, 他指之移動不影響
    let p = null
    if (!d.touch) {
        //主鍵已放開(於視窗外放開等收不到mouseup)即結束; 只看真實事件, 以new MouseEvent合成之移動其buttons預設為0, 既有以合成事件模擬拖曳者不因此中斷
        if (e.isTrusted === true && isNumber(e.buttons) && (e.buttons & 1) === 0) {
            endDrag(g, e)
            return
        }
        p = toPoint(e)
    }
    else {
        p = toPoint(findTouch(get(e, 'changedTouches', null), d.touchId))
    }
    if (!p) {
        return
    }

    //check, 來源已解除且未重建即結束; 解除後立即重建(宿主重繪時先解除再重建)者拖曳延續
    if (!g.members.has(d.startEle)) {
        endDrag(g, e)
        return
    }

    //預覽跟隨, 觸控之起手節點被移出DOM時視窗收不到事件, 由此處跟隨
    d.lastP = p
    if (d.pv) {
        d.pv.updateDragPreview(p.clientX, p.clientY, 'domDrag')
    }

    //hit
    let hit = hitTest(g, p)
    let o = d.o

    //emitLeave, 先清再發, 監聽器拋錯或於監聽器內結束拖曳時不留下半套狀態
    let emitLeave = () => {
        let endEle = d.endEle
        let endInd = d.endInd
        d.endEle = null
        d.endInd = null
        deliver(g, endEle, 'leave', (inst) => ({
            event: e,
            startInd: d.startInd,
            startEle: d.startEle,
            endInd,
            endEle,
            tarInd: endInd,
            tarEid: inst.eid,
        }))
        if (d.pv) {
            d.pv.setContainerStyle({ opacity: o.previewDisabledOpacity })
        }
    }

    //check, 不在任何項目上
    if (!hit) {
        if (d.endInd !== null) {
            emitLeave()
        }
        return
    }

    //check, 拖曳至原拖曳項目: 由其他項目回到原項目需觸發leave
    if (hit.ind === d.startInd) {
        if (d.endInd !== null) {
            emitLeave()
        }
        return
    }

    //enter, 拖曳至不同於上一個項目之項目
    if (hit.ind !== d.endInd) {

        //由其他項目過來需先觸發leave, 其監聽器內結束拖曳者不再發enter
        if (d.endInd !== null) {
            emitLeave()
            if (g.drag !== d) {
                return
            }
        }

        d.endInd = hit.ind
        d.endEle = hit.ele
        deliver(g, hit.ele, 'enter', (inst) => ({
            event: e,
            startInd: d.startInd,
            startEle: d.startEle,
            endInd: hit.ind,
            endEle: hit.ele,
            tarInd: hit.ind,
            tarEid: inst.eid,
        }))
        if (d.pv) {
            d.pv.setContainerStyle({ opacity: o.previewOpacity })
        }
        return
    }

    //move, 於上一個項目內拖曳; 同順序指標之元素可能已被替換(重繪時換新元素), 以當下之元素為準
    d.endEle = hit.ele
    let rl = domGetBoudRectRefSelf(p, hit.ele)
    if (!rl) {
        return
    }
    let rx = rl.w > 0 ? rl.x / rl.w : 0
    let ry = rl.h > 0 ? rl.y / rl.h : 0
    if (rx >= 0 && rx <= 1 && ry >= 0 && ry <= 1) {
        deliver(g, hit.ele, 'move', (inst) => ({
            event: e,
            startInd: d.startInd,
            startEle: d.startEle,
            endInd: d.endInd,
            endEle: d.endEle,
            tarInd: d.endInd,
            tarEid: inst.eid,
            ...rl,
            rx,
            ry,
        }))
        if (d.pv) {
            d.pv.setContainerStyle({ opacity: o.previewOpacity })
        }
    }

}


//onUp, 放開: 滑鼠只看主鍵(其他鍵之放開不影響), 觸控只看起手手指(他指之放開不影響); 不論是否落於項目皆結束拖曳
function onUp(g, d, e) {
    if (g.drag !== d) {
        return
    }

    //src
    let src = e
    if (d.touch) {
        src = findTouch(get(e, 'changedTouches', null), d.touchId)
        if (!src) {
            return
        }
        touchEndAt = now()
    }
    else if (('button' in e) && e.button !== 0) {
        return
    }

    //check, 來源已解除且未重建者不發drop
    if (!g.members.has(d.startEle)) {
        endDrag(g, e)
        return
    }

    //預覽先移除再判定落點(同原本)
    if (d.pv) {
        d.pv.clear()
        d.pv = null
    }

    //hit
    let p = toPoint(src)
    let hit = p ? hitTest(g, p) : null
    endDrag(g, e, hit)
}


//onCancel, touchcancel(手勢被系統中斷, 例如瀏覽器接手捲動、長按選單): 起手手指被取消即結束, 不發drop
function onCancel(g, d, e) {
    if (g.drag !== d) {
        return
    }
    if (d.touchId !== null && findTouch(get(e, 'changedTouches', null), d.touchId) === null) {
        return
    }
    endDrag(g, e)
}


//onPress, 按下起手
function onPress(inst, e, touch) {
    let g = inst.g

    //check
    if (!inst.alive || !g.isActive) {
        return
    }

    //check, 同一按下事件冒泡至外層成員(巢狀)或同元素之另一實例時, 只由最先起手者(最內層)處理
    if (active && active.drag && active.drag.pressEvent === e) {
        return
    }

    //check, 滑鼠只處理主鍵, 中鍵與右鍵不得起手(無button屬性之合成事件可起手); 觸控輕點後補發之相容滑鼠事件不起手
    if (!touch) {
        if (('button' in e) && e.button !== 0) {
            return
        }
        let fte = get(e, 'sourceCapabilities.firesTouchEvents', null)
        if (fte === true) {
            return
        }
        if (fte !== false && now() - touchEndAt < COMPAT_MOUSE_MS) {
            return
        }
    }

    //t, touchId, 觸控以起手手指鎖定
    let t = touch ? get(e, 'changedTouches[0]', null) : e
    let touchId = touch ? get(t, 'identifier', null) : null
    if (touchId === undefined) {
        touchId = null
    }

    //check, 已在拖曳時之接手規則: 滑鼠一律接手(前一次必為放開事件遺失, 或為觸控之殘留); 觸控不接手滑鼠, 觸控僅於起手手指已離開(放開事件遺失)時接手
    let d0 = active ? active.drag : null
    if (d0 && touch) {
        if (!d0.touch) {
            return
        }
        if (d0.touchId !== null && findTouch(get(e, 'touches', null), d0.touchId) !== null) {
            return
        }
    }

    //check, 起點須帶有效順序指標
    let o = inst.o
    let kitem = getIndex(inst.ele, o.attIndex)
    if (kitem === null) {
        return
    }

    //前一次拖曳未收尾者先結束
    if (active) {
        endDrag(active, e)
    }

    //d, 本次拖曳之會話
    let d = {
        touch,
        touchId,
        pressEvent: e,
        o, //起手實例之選項(預覽與延遲)
        startInd: kitem,
        startEle: inst.ele,
        endInd: null,
        endEle: null,
        timer: null, //延遲建立預覽之計時器
        pv: null, //預覽, 每次拖曳建立、結束即清
        offs: [], //會話期間之監聽之移除函數
        lastEv: null, //最近一次處理之事件, 同一事件經視窗與觸控之起手節點兩處送達時只處理一次
        lastP: null, //最近一次之指標座標
    }
    g.drag = d
    active = g

    //會話監聽: 視窗之移動與放開掛capture(內層元素stopPropagation時仍收得到); 觸控另掛於起手節點(其被移出DOM時觸控事件只送達該節點);
    //  觸控之起手節點於拖曳中阻止touchmove之預設行為(否則會變成捲動螢幕, 由window阻止無效), 未拖曳時不擋, 保留一般捲動
    let once = (f) => (ev) => {
        if (ev === d.lastEv) {
            return
        }
        d.lastEv = ev
        f(g, d, ev)
    }
    let fMove = once(onMove)
    let fUp = once(onUp)
    let fCancel = once(onCancel)
    if (!touch) {
        d.offs.push(listen(window, 'mousemove', fMove, true))
        d.offs.push(listen(window, 'mouseup', fUp, true))
    }
    else {
        d.offs.push(listen(window, 'touchmove', fMove, true))
        d.offs.push(listen(window, 'touchend', fUp, true))
        d.offs.push(listen(window, 'touchcancel', fCancel, true))
        let tg = get(e, 'target', null)
        if (tg && isfun(tg.addEventListener)) {
            d.offs.push(listen(tg, 'touchmove', (ev) => {
                if (ev.cancelable) { //window捲動中時事件為禁止取消(cancelable=false)狀態
                    ev.preventDefault()
                }
                fMove(ev)
            }, { passive: false }))
            d.offs.push(listen(tg, 'touchend', fUp))
            d.offs.push(listen(tg, 'touchcancel', fCancel))
        }
    }

    //視窗失焦即取消; 不掛capture, 否則會收到頁內元素(如輸入框)之blur, blur不冒泡故非capture只收到視窗本身者
    d.offs.push(listen(window, 'blur', (ev) => {
        if (g.drag === d) {
            endDrag(g, ev)
        }
    }))

    //start
    deliver(g, d.startEle, 'start', (i) => ({
        event: e,
        startInd: d.startInd,
        startEle: d.startEle,
        tarInd: d.startInd,
        tarEid: i.eid,
    }))

    //check, 監聽器內已結束本次拖曳(例如解除、停用)
    if (g.drag !== d) {
        return
    }

    //p
    let p = toPoint(t)
    if (!p) {
        return
    }

    //setTimeout, preview不能太快出現導致原本元素例如click事件失效; 計時器屬於本次拖曳, 拖曳結束即取消
    d.timer = setTimeout(() => {
        d.timer = null
        if (g.drag !== d) {
            return
        }
        d.pv = domDragPreview({
            attIdentify: o.attIdentify,
            containerOpacity: o.previewOpacity,
            containerBackground: o.previewBackground,
            containerBorderWidth: o.previewBorderWidth,
            containerBorderColor: o.previewBorderColor,
        })
        d.pv.createPreview(d.startEle, p.clientX, p.clientY) //以按下點計算抓取之位移
        if (d.lastP) {
            d.pv.updateDragPreview(d.lastP.clientX, d.lastP.clientY, 'domDrag') //再移至目前之指標
        }
        d.pv.setContainerStyle({ opacity: d.endEle ? o.previewOpacity : o.previewDisabledOpacity })
    }, o.timeDragStartDelay)

}


/**
 * 前端針對DOM元素設定監聽拖曳事件
 *
 * 同群組(group)之元素間拖放;須帶順序指標屬性(attIndex,有限數字,同群組須唯一)者才可為拖曳起點或落點,是否同一項目以順序指標判定
 *
 * 落點以指標座標判定(經過與放下相同,滑鼠與觸控相同):取該點由上而下第一個本群組之已綁定元素,含指標位於其子元素上,但子元素超出元素框外之部分不算;不屬本群組之元素(如遮罩)不阻擋其下之元素;巢狀時為最內層者,按下亦由最內層者起手;同時只有一個拖曳(跨群組亦同)
 *
 * 滑鼠僅主鍵可起手,拖曳中其他鍵之按放不影響;移動時主鍵已放開(於視窗外放開而收不到mouseup,限真實事件)即結束。觸控以起手手指鎖定,他指之落下、移動、放開不影響,起手手指已離開(放開事件遺失)時新觸控可接手;滑鼠按下一律接手;觸控輕點後瀏覽器補發之相容滑鼠事件不起手
 *
 * 拖曳於放開時結束(於項目上放開則發drop,放回原項目亦發);touchcancel、視窗失焦、群組停用時取消拖曳(不發drop)。拖曳中來源元素解除且未重新綁定者取消;先解除再立即重新綁定者(例如宿主重繪時重建)拖曳延續,後續事件送達新實例。結束時若正經過某項目而未於其上放下,對其發leave,使enter必以leave或drop收尾
 *
 * 拖曳中阻止本元素起手之瀏覽器原生拖放(如項目內之文字、圖片、連結)與觸控捲動,未拖曳時不干涉;監聽器拋錯時拖曳狀態已先清除
 *
 * 解除(unbind或clear)後本元素不再為起點或落點、不再收到事件,並於下一個microtask移除綁定時設定之屬性(同元素已由其他實例重新綁定時不動),可重複呼叫;群組最後一個元素解除後(同一輪內未再加入)群組刪除
 *
 * 各元素之選項各自有效:預覽樣式與延遲取起手元素者,落點之順序指標取落點元素者
 *
 * Unit Test: {@link https://github.com/yuda-lyu/wsemi/blob/master/test/domDrag.test.mjs Github}
 * @memberOf wsemi
 * @param {HTMLElement} ele 輸入元素
 * @param {Object} [opt={}] 輸入設定物件,預設{}
 * @param {String} [opt.attIdentify='dragid'] 輸入預覽元素唯一識別用之屬性名稱字串,預設'dragid'
 * @param {String} [opt.attIndex='dragindex'] 輸入預覽元素順序指標之屬性名稱字串,預設'dragindex'
 * @param {String} [opt.attGroup='draggroup'] 輸入預覽元素群組之屬性名稱字串,預設'draggroup'
 * @param {String} [opt.group='group'] 輸入預覽元素群組字串,預設'group'
 * @param {Number} [opt.timeDragStartDelay=120] 輸入預覽元素由點擊後延遲出現的時間數字,單位為毫秒ms,須為數字(可為數字字串),負數視為0,超過計時器上限(含Infinity)者夾至上限,無效時用預設,預設120。使用pointerEvents會導致游標樣式失效,故延遲顯示可用來讓點擊事件穿透
 * @param {Number} [opt.previewOpacity=0.4] 輸入預覽元素透明度數字,須為有限數字(可為數字字串),無效時用預設,預設0.4
 * @param {Number} [opt.previewDisabledOpacity=0.2] 輸入無效時(位於非可拖曳元素內)預覽元素透明度數字,須為有限數字(可為數字字串),無效時用預設,預設0.2
 * @param {String} [opt.previewBackground='white'] 輸入預覽元素背景顏色字串,預設'white'
 * @param {Number} [opt.previewBorderWidth=1] 輸入預覽元素邊框寬度數字,須為有限數字(可為數字字串),無效時用預設,預設1
 * @param {String} [opt.previewBorderColor='#f26'] 輸入預覽元素邊框顏色字串,預設'#f26'
 * @returns {Object} 回傳物件,可使用on、unbind、clear與setIsActive函數,on可監聽change、start、move、enter、leave、drop事件,unbind與clear相同,為釋放監聽,setIsActive為設定群組是否可拖曳(群組層級,停用時取消進行中之拖曳,解除後呼叫無作用)
 * @example
 * need test in browser
 *
 * //監聽dom
 * let dd = domDrag(document.querySelector('#id'), { group:'group', attIndex: 'dragindex' })
 *
 * //change
 * dd.on('change', (msg) => {
 *     console.log('change', msg)
 * })
 * dd.on('start', (msg) => {
 *     console.log('start', msg)
 * })
 * dd.on('move', (msg) => {
 *     console.log('move', msg)
 * })
 * dd.on('enter', (msg) => {
 *     console.log('enter', msg)
 * })
 * dd.on('leave', (msg) => {
 *     console.log('leave', msg)
 * })
 * dd.on('drop', (msg) => {
 *     console.log('drop', msg)
 * })
 *
 * //釋放監聽
 * dd.clear()
 *
 */
function domDrag(ele, opt = {}) {

    //check
    if (!isEle(ele)) {
        console.log('ele is not a HTMLElement', ele)
        return
    }

    //str
    let str = (k, def) => {
        let v = get(opt, k, null)
        return isestr(v) ? v : def
    }

    //o, 數字選項可為數字或數字字串(如網頁輸入、表格轉存之資料), 非有限者用預設
    let o = {
        attIdentify: str('attIdentify', 'dragid'),
        attIndex: str('attIndex', 'dragindex'),
        attGroup: str('attGroup', 'draggroup'),
        group: str('group', 'group'),
        previewOpacity: optNum(opt, 'previewOpacity', 0.4),
        previewDisabledOpacity: optNum(opt, 'previewDisabledOpacity', 0.2),
        previewBackground: str('previewBackground', 'white'),
        previewBorderWidth: optNum(opt, 'previewBorderWidth', 1),
        previewBorderColor: str('previewBorderColor', '#f26'),
        timeDragStartDelay: optNum(opt, 'timeDragStartDelay', 120, { min: 0, below: 'clamp', timer: true }),
    }

    //gid, eid
    let gid = o.group
    let eid = `c${genID(8)}`

    //g
    let g = kpDgs.get(gid)
    if (!g) {
        g = {
            gid,
            members: new Map(),
            isActive: true,
            drag: null,
            teardown: false,
        }
        kpDgs.set(gid, g)
    }

    //ev, inst
    let ev = evem()
    let inst = { eid, ele, ev, o, g, alive: true }

    //setAttribute, 群組屬性值未變者不重寫(每次寫入皆產生MutationRecord, 宿主每次重繪皆重建時避免加倍)
    if (attr(ele, o.attGroup) !== gid) {
        ele.setAttribute(o.attGroup, gid)
    }
    ele.setAttribute(o.attIdentify, eid)

    //members
    g.members.set(ele, inst)

    //元素監聽: 按下維持冒泡, 內層元素stopPropagation可排除自己起手
    let offs = []
    offs.push(listen(ele, 'mousedown', (e) => {
        onPress(inst, e, false)
    }))
    offs.push(listen(ele, 'touchstart', (e) => {
        onPress(inst, e, true)
    }))
    offs.push(listen(ele, 'dragstart', (e) => {
        //本元素起手之拖曳中阻止瀏覽器原生拖放(例如項目內被選取之文字、圖片、連結), 否則之後只有原生拖放事件而無mouseup, 拖曳無法收尾
        if (g.drag && g.drag.startEle === ele) {
            e.preventDefault()
        }
    }))

    //unbind, 可重複呼叫
    function unbind() {
        if (!inst.alive) {
            return
        }
        inst.alive = false

        //元素監聽
        runAll(offs)

        //members
        if (g.members.get(ele) === inst) {
            g.members.delete(ele)
        }

        //屬性, 延後一個microtask移除且識別屬性仍為本實例所設者才移除: 同一輪內重新綁定(宿主重繪)者不移除, 避免每次重繪之屬性異動加倍
        Promise.resolve().then(() => {
            if (attr(ele, o.attIdentify) === eid) {
                ele.removeAttribute(o.attIdentify)
                if (attr(ele, o.attGroup) === gid) {
                    ele.removeAttribute(o.attGroup)
                }
            }
        })

        //拖曳來源
        if (g.drag && g.drag.startEle === ele) {
            checkSourceLater(g, g.drag)
        }

        //群組
        if (g.members.size === 0) {
            scheduleTeardown(g)
        }

    }

    //setIsActive, 群組層級, 停用時取消進行中之拖曳; 解除後呼叫無作用
    function setIsActive(isActive) {
        if (!inst.alive) {
            return
        }
        g.isActive = isActive
        if (!isActive) {
            endDrag(g)
        }
    }

    //add setIsActive
    ev.setIsActive = setIsActive

    //add unbind, clear
    ev.unbind = unbind
    ev.clear = unbind

    return ev
}


export default domDrag