import get from 'lodash-es/get.js'
import isNumber from 'lodash-es/isNumber.js'
import isbol from './isbol.mjs'
import isfun from './isfun.mjs'
import evem from './evem.mjs'
import domCancelEvent from './domCancelEvent.mjs'
/**
* 前端針對DOM元素監聽指標(滑鼠、觸控、觸控筆)之按下、拖曳與放開
*
* 以Pointer Events單一模型處理滑鼠、觸控與觸控筆,不另掛mouse與touch事件;本函數追蹤的是指標之按下→拖曳→放開,與HTML Drag and Drop無關。pointerdown掛於元素(可給多個元素,子孫冒泡亦算,同一事件經冒泡送達多個元素時只處理一次),pointermove、pointerup與pointercancel掛於window之capture階段,故指標移出元素或瀏覽器視窗仍持續追蹤,內層元素stopPropagation亦不會漏掉放開。同時只服務一個指標,press與free恆成對,drag只出現於兩者之間;狀態一律先於emit異動,監聽器拋錯時不會留下半套狀態
*
* 滑鼠僅主鍵可起手,觸控筆之筆尖與橡皮擦端可起手、按住筆身側鍵接觸(相當於右鍵)不可,合成事件無button屬性者維持可起手。拖曳中以buttons判定接觸是否仍在:接觸位元(筆尖或主鍵為1、橡皮擦端為32)消失即解鎖,故拖出瀏覽器視窗外放開而收不到pointerup者,回到視窗內移動時自我解鎖;已按住主鍵再按下或放開其他鍵不影響拖曳(Pointer Events對和弦按鍵只發pointermove,故會多發座標相同之drag);以物件模擬之事件未帶buttons者視為仍按住,以`new PointerEvent`合成時須自行給buttons,其預設值0會被視為已放開。已上鎖時滑鼠再按下主鍵視為接手,先替既有之鎖補發free再發press;滑鼠之鎖不被觸控或觸控筆接手,觸控與觸控筆亦不互相接手
*
* 觸控以起手指之pointerId與pointerType鎖定,其餘手指之落下、移動與放開皆不影響本次拖曳;手勢被系統中斷(pointercancel,如瀏覽器接手捲動、Android長按選單、拖曳中啟動原生拖放)時只解鎖不定值。起手元素於拖曳途中被移出DOM時,瀏覽器會把後續之pointer事件改派給指標下之元素,window仍收得到,故不會殘鎖(touch事件模型則會)。若仍遺失放開事件,下一次同型別之pointerdown其isPrimary為true(isPrimary依pointerType各自計算)即代表落下時無其他同型指標在場、原指標早已離開,視為殘鎖而先補發free再重新上鎖;isPrimary為false者為他指落下,不得重新定錨;未帶isPrimary之合成事件無從證明有效,一律視為殘鎖。Pointer Events無「當前全部觸點」清單可查,放開事件遺失後若他指先落於元素外、再有手指落於元素(isPrimary為false),本函數無從判定殘鎖,須待滑鼠接手;此情形於window收取事件之模型下僅合成事件可達
*
* 呼叫端須自備樣式:元素需`user-select:none`避免拖曳時選取文字,`touch-action:none`避免瀏覽器接手捲動(此為契約,Pointer Events規範上無法取消原生捲動),`-webkit-touch-callout:none`避免iOS長按選單。元素未設touch-action者,由本函數於觸控或觸控筆上鎖時取消eleTouchMove之touchmove(非被動監聽)保住手勢,見cancelTouchMove,此為Chromium實測可行之退路而非跨瀏覽器保證;觸控或觸控筆上鎖時另抑制元素之contextmenu(僅非滑鼠來源者),避免Android長按選單以pointercancel中斷手勢,滑鼠右鍵選單不受影響
*
* 本函數不對pointerdown與touchstart呼叫preventDefault亦不stopPropagation,故相容之mousedown、mouseup與click照常發出(輕點後瀏覽器仍產生click),內層元素可自行stopPropagation pointerdown以排除自己。pointerType未知(合成事件或瀏覽器無法判定裝置)者視為滑鼠
*
* 已知限制:拖曳中呼叫clear只停止而不補發free;clear只移除DOM監聽,不移除以on掛上之監聽器;已按住其他鍵時再按下主鍵,瀏覽器只發pointermove不發pointerdown,故無法起手;需瀏覽器支援Pointer Events(Safari與iOS 13以上、Chrome 55以上、Firefox 59以上、IE11),不支援者收不到事件亦不拋錯
*
* Unit Test: {@link https://github.com/yuda-lyu/wsemi/blob/master/test/domDragPointer.test.mjs Github}
* @memberOf wsemi
* @param {HTMLElement|Array} ele 輸入元素或元素陣列
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {Function} [opt.canPress=()=>true] 輸入是否可按下之判斷函數,於每次pointerdown時(含已上鎖時)呼叫一次並帶入事件,回傳false即忽略該次按下,既不上鎖亦不影響既有之鎖,供呼叫端判斷可編輯狀態或排除特定元素,須為純判斷且拖曳中不重新評估,拋錯時不上鎖且錯誤外拋,預設()=>true
* @param {Boolean|Function} [opt.cancelTouchMove=true] 輸入是否於觸控或觸控筆上鎖時取消eleTouchMove之touchmove事件布林值或函數,函數時帶入事件並回傳布林值,為false時不掛touchmove監聽,預設true
* @param {HTMLElement|Array} [opt.eleTouchMove=ele] 輸入掛touchmove監聽之元素或元素陣列,預設為ele;把手位於大面板內時可指定把手,使非被動監聽不掛於整個面板而拖慢其內容之觸控捲動,預設ele
* @returns {Object} 回傳物件,可使用on與clear函數,on可監聽press、drag、free事件,clear為釋放監聽。press與drag帶{clientX,clientY,pointerType,event},free帶{pointerType,reason,event},pointerType為'mouse'、'touch'或'pen',reason為'up'(放開)、'cancel'(被系統取消)、'buttonsLost'(接觸位元消失之自癒)、'takeover'(被新按下接手)之一,event為觸發之原始事件
* @example
* need test in browser
*
* //監聽dom
* let dp = domDragPointer(document.querySelector('#id'), { canPress: () => editable })
* dp.on('press', (msg) => {})
* dp.on('drag', (msg) => {})
* dp.on('free', (msg) => {})
*
* //釋放監聽
* dp.clear()
*
*/
function domDragPointer(ele, opt = {}) {
//toEles, 元素或元素陣列轉陣列, 去除無addEventListener者
function toEles(v) {
let vs = Array.isArray(v) ? v : [v]
return vs.filter((e) => e && isfun(e.addEventListener))
}
//eles
let eles = toEles(ele)
//canPress
let canPress = get(opt, 'canPress', null)
if (!isfun(canPress)) {
canPress = () => true
}
//cancelTouchMove, 布林轉函數; false時為null代表不掛監聽
let cancelTouchMove = get(opt, 'cancelTouchMove', null)
if (isbol(cancelTouchMove)) {
cancelTouchMove = cancelTouchMove ? () => true : null
}
else if (!isfun(cancelTouchMove)) {
cancelTouchMove = () => true
}
//elesTouchMove
let elesTouchMove = toEles(get(opt, 'eleTouchMove', null))
if (elesTouchMove.length === 0) {
elesTouchMove = eles
}
//ev
let ev = evem()
//pressType, 目前上鎖之指標型別, 為'mouse'、'touch'、'pen'之一, 未上鎖時為''
let pressType = ''
//pressId, 上鎖指標之pointerId; 起手事件未帶pointerId時為null
let pressId = null
//lastPointerdown, 最近一次處理之pointerdown事件; 多元素時同一事件經冒泡會送達多個元素, 以物件身分去重
let lastPointerdown = null
//listeners, 已掛上之監聽, clear時逐一移除, 使掛上與移除天然成對; options須一併保存, capture階段之監聽移除時須給相同之capture
let listeners = []
//isLocked
function isLocked() {
return pressType !== ''
}
//isTouchLike, 觸控與觸控筆之處置相同(捲動抑制、長按選單、以isPrimary判殘鎖)
function isTouchLike(type) {
return type === 'touch' || type === 'pen'
}
//getType, 事件未帶pointerType(合成事件)或為空字串(規範: 無法判定裝置)時視為滑鼠
function getType(e) {
let t = get(e, 'pointerType', '')
if (t !== 'touch' && t !== 'pen') {
t = 'mouse'
}
return t
}
//getId
function getId(e) {
let id = get(e, 'pointerId', null)
if (id === undefined) {
id = null
}
return id
}
//isOwn, 事件是否屬於上鎖之指標; 事件帶有pointerType時型別須相同, 任一方無pointerId時無從比對, 視為同一指標(無資訊採解鎖側, 寧可解鎖也不留殘鎖)
function isOwn(e) {
if (('pointerType' in e) && getType(e) !== pressType) {
return false
}
let id = getId(e)
if (pressId === null || id === null) {
return true
}
return id === pressId
}
//isStale, 已上鎖時新落下之同型別指標是否代表既有之鎖為殘留
function isStale(e) {
//同pointerId再落下: 同時接觸之指標其id不重複, 故原指標早已離開
let id = getId(e)
if (id !== null && pressId !== null && id === pressId) {
return true
}
//isPrimary為true代表落下時無其他同型指標在場, 原指標早已離開; 為false者為他指落下, 不得重新定錨
let isPrimary = get(e, 'isPrimary', null)
if (isPrimary === true) {
return true
}
if (isPrimary === false) {
return false
}
//合成事件未帶isPrimary, 無從證明有效者視為殘留, 寧可重新上鎖也不讓指標就此失效
return true
}
//isButtonAllowed, 滑鼠只處理主鍵, 中鍵與右鍵不得起手; 觸控筆之筆尖(0)與橡皮擦端(5)可起手, 按住筆身側鍵接觸(2, 相當於右鍵)不得起手; 觸控之button恆為0
// 以'button' in e判定使無button屬性之合成事件維持可起手
function isButtonAllowed(e) {
if (!('button' in e)) {
return true
}
if (getType(e) === 'pen') {
return e.button === 0 || e.button === 5
}
return e.button === 0
}
//isContactLost, 接觸位元(筆尖或主鍵為1、橡皮擦端為32)是否已消失; buttons非數字(合成事件)視為仍按住
function isContactLost(e) {
if (!isNumber(e.buttons)) {
return false
}
return (e.buttons & 33) === 0
}
//lock, 上鎖之唯一寫法
function lock(type, id) {
pressType = type
pressId = id
}
//unlock, 解鎖之唯一寫法, 全部狀態成套歸零
function unlock() {
pressType = ''
pressId = null
}
//getMsg
function getMsg(e) {
return { clientX: e.clientX, clientY: e.clientY, pointerType: getType(e), event: e }
}
//press, 上鎖並發press之唯一寫法
// 已上鎖時為接手既有之鎖, 先替該鎖補發free, 使press與free恆成對; 狀態一律先於emit異動
function press(e) {
let taken = isLocked()
let prevType = pressType
lock(getType(e), getId(e))
if (taken) {
ev.emit('free', { pointerType: prevType, reason: 'takeover', event: e })
//check, free之監聽器內呼叫clear時已解鎖, 不得再發press
if (!isLocked()) {
return
}
}
ev.emit('press', getMsg(e))
}
//free, 解鎖並發free之唯一寫法
function free(e, reason) {
let prevType = pressType
unlock()
ev.emit('free', { pointerType: prevType, reason, event: e })
}
//listen, 掛監聽之唯一寫法, 同步記錄供clear移除
function listen(target, type, fun, options) {
target.addEventListener(type, fun, options)
listeners.push({ target, type, fun, options })
}
//funPointerdown
let funPointerdown = (e) => {
//check, 同一事件只處理一次
if (e === lastPointerdown) {
return
}
lastPointerdown = e
//check
if (!isButtonAllowed(e)) {
return
}
//check, 呼叫端政策; 於上鎖前評估, 拋錯時不留任何狀態
if (!canPress(e)) {
return
}
//type
let type = getType(e)
//check, 已上鎖時之接手規則
if (isLocked()) {
if (type === 'mouse') {
//滑鼠一律接手: 滑鼠之鎖此時必為殘留(pointerup遺失且其間無pointermove), 觸控之鎖則以滑鼠為其救援通道
}
else if (pressType !== type) {
//觸控或觸控筆不得接手滑鼠或另一型別之鎖
return
}
else if (!isStale(e)) {
//他指落下不得重新定錨
return
}
}
//press
press(e)
}
//funPointermove
let funPointermove = (e) => {
//check
if (!isLocked()) {
return
}
//check, 非上鎖指標之移動不得搶值
if (!isOwn(e)) {
return
}
//check, 接觸位元消失即自我解鎖: 拖出瀏覽器視窗外放開時收不到pointerup, 回到視窗內移動時解鎖; 主鍵已放而其他鍵仍按住時亦解鎖
if (isContactLost(e)) {
free(e, 'buttonsLost')
return
}
//emit
ev.emit('drag', getMsg(e))
}
//funPointerup, 與pointercancel共用, 兩者皆只解鎖不定值; pointercancel為手勢被系統中斷, 其座標不可信
let funPointerup = (e) => {
//check
if (!isLocked()) {
return
}
//check, 非上鎖指標之放開或取消不終止本次拖曳
if (!isOwn(e)) {
return
}
free(e, (get(e, 'type', '') === 'pointercancel') ? 'cancel' : 'up')
}
//funTouchmove, 捲動抑制墊片, 不含任何狀態邏輯; 非touch類上鎖時不擋, 否則元素上起手之一般頁面捲動會失效
let funTouchmove = (e) => {
if (!isTouchLike(pressType)) {
return
}
if (!cancelTouchMove(e)) {
return
}
domCancelEvent(e) //事件不可取消(瀏覽器已開始捲動)時其內直接跳出
}
//funContextmenu, 僅於觸控或觸控筆上鎖時抑制長按選單, 且事件自述為滑鼠來源者不抑制(contextmenu於現代瀏覽器為PointerEvent, 帶pointerType);
// contextmenu為獨立事件, 抑制它不會連帶抑制mousedown/mouseup與click
let funContextmenu = (e) => {
if (!isTouchLike(pressType)) {
return
}
if (get(e, 'pointerType', '') === 'mouse') {
return
}
e.preventDefault()
}
function add() {
//eles
for (let el of eles) {
listen(el, 'pointerdown', funPointerdown)
listen(el, 'contextmenu', funContextmenu)
}
//elesTouchMove
if (cancelTouchMove !== null) {
for (let el of elesTouchMove) {
listen(el, 'touchmove', funTouchmove, { passive: false }) //必須使用passive=false否則無法cancel
}
}
//window, capture階段: 內層元素stopPropagation時仍收得到, 不需於元素另掛pointerup當救援
listen(window, 'pointermove', funPointermove, { capture: true })
listen(window, 'pointerup', funPointerup, { capture: true })
listen(window, 'pointercancel', funPointerup, { capture: true })
}
function clear() {
//unlock, 釋放監聽時一併歸零狀態; 拖曳中呼叫時只停止, 不補發free
unlock()
lastPointerdown = null
//removeEventListener, 須帶相同之options, 否則capture階段之監聽永遠移不掉
for (let l of listeners) {
l.target.removeEventListener(l.type, l.fun, l.options)
}
listeners = []
}
//add
add()
//save
ev.clear = clear
return ev
}
export default domDragPointer