import get from 'lodash-es/get.js'
import throttle from 'lodash-es/throttle.js'
import isnum from './isnum.mjs'
import cdbl from './cdbl.mjs'
import evem from './evem.mjs'
import isfun from './isfun.mjs'
import domIsRendered from './domIsRendered.mjs'
import optNum from './_optNum.mjs'
//2020年曾以IntersectionObserver與ResizeObserver實作(詳細請查git紀錄), 因實作缺陷致WTextSelect下拉選單不穩而改為輪詢:
//1.元素尺寸為0(隱藏或移出DOM)時仍發出resize, 使用端讀到高度0而算錯版面
//2.clear未防空值, 元素尚未取得即clear時拋錯, 且已建立之觀察器與已排定之事件未解除
//3.觀察器只綁首次取得之元素, 元素被換成新節點後即失去偵測
//現已恢復並用以ResizeObserver偵測尺寸, 並以共用MutationObserver於[元素取不到、不在頁面中或尺寸為0]時重新取得元素, 逐一對應上述缺陷; 無ResizeObserver或MutationObserver時退回輪詢
//waiting, 等待重新取得元素之偵測器; mo, 共用之MutationObserver, 僅於有偵測器等待時啟用
let waiting = new Set()
let mo = null
//RUN_MAX, 同一軸容許誤差內同向連續判定為變化之上限: 使用端於事件內使尺寸同向再變1px之單向回授(例如以含框線之offsetHeight設定撐開自身之子元素高)至此中斷, 否則逐幀無限增長
// 持續之逐px變化於達上限後之下一次量測即因累積超過容許誤差而發出, 故只略過1次; 代價為同向逐px之變化恰於連續第RUN_MAX+1次結束時停在差1px之舊值
let RUN_MAX = 10
function waitAdd(fn) {
waiting.add(fn)
if (!mo) {
mo = new window.MutationObserver(() => {
for (let g of Array.from(waiting)) {
g()
}
})
mo.observe(document.documentElement, { childList: true, subtree: true })
}
}
function waitRemove(fn) {
waiting.delete(fn)
if (waiting.size === 0 && mo) {
mo.disconnect()
mo = null
}
}
//getTimeInterval, 正整數(可為數字字串), 超過計時器上限(含Infinity)者夾至上限, 無效時20
function getTimeInterval(opt) {
return optNum(opt, 'timeInterval', 20, { int: true, min: 0, minOpen: true, timer: true })
}
//getTolerancePixel, 不小於0之整數(可為數字字串), 可給0表示任何變化皆發出, Infinity表示任何變化皆不發出, 無效時1
function getTolerancePixel(opt) {
return optNum(opt, 'tolerancePixel', 1, { int: true, min: 0, inf: 'keep' })
}
//getToleranceBounce, 容許誤差內來回跳動之次數, 不小於0之整數(可為數字字串), 0表示容許誤差內之變化一律不發出(舊版行為), Infinity表示容許誤差內之變化一律發出(不阻斷回授), 無效時3
function getToleranceBounce(opt) {
return optNum(opt, 'toleranceBounce', 3, { int: true, min: 0, inf: 'keep' })
}
//getThrottle, 正整數(可為數字字串), 超過計時器上限(含Infinity)者夾至上限, 未給或0表示不節流
function getThrottle(opt) {
return optNum(opt, 'throttle', 0, { int: true, min: 0, minOpen: true, timer: true })
}
function getFun(opt, key) {
let f = get(opt, key, null)
return isfun(f) ? f : null
}
//getEle, f()拋錯或回傳非元素時視為取不到, 不中斷偵測
function getEle(f) {
let p = null
try {
p = f()
}
catch (err) {
p = null
}
return (p && p.nodeType === 1) ? p : null
}
//toNum, 非數字(含拋錯、未給)者為null
function toNum(v) {
return isnum(v) ? cdbl(v) : null
}
function sizeZero() {
return {
offsetWidth: 0,
offsetHeight: 0,
clientWidth: 0,
clientHeight: 0,
windowWidth: 0,
windowHeight: 0,
width: 0,
height: 0,
}
}
//measure, width與height為比較用尺寸: 預設同offsetWidth與offsetHeight(border-box), 有給getSize時取其回傳(例如使用端依內容區繪製者)
function measure(p, getSize) {
let r = {
offsetWidth: p.offsetWidth,
offsetHeight: p.offsetHeight,
clientWidth: p.clientWidth,
clientHeight: p.clientHeight,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
width: p.offsetWidth,
height: p.offsetHeight,
}
if (getSize) {
let s = null
try {
s = getSize(p)
}
catch (err) {
s = null
}
r.width = toNum(get(s, 'width', null)) || 0
r.height = toNum(get(s, 'height', null)) || 0
}
return r
}
//isShown, 量測時元素可見(外框非0)且比較用尺寸非0
function isShown(s) {
return s.offsetWidth > 0 && s.offsetHeight > 0 && s.width > 0 && s.height > 0
}
//createCore, 輪詢與ResizeObserver兩種模式共用之比較、發出、視窗事件與清除, 使兩者之規則只寫一處
// 比較基準預設為上次判定變化時之尺寸sb, 判定為變化時才更新; 若每次量測皆更新基準, 未判定之小變化(因回授而停止, 或toleranceBounce為0時容許誤差內之變化)累積再大也不發出
// 容許誤差內之變化照常判定為變化而發出: 同向之逐px變化(如動畫或拖曳之末段)若略過, 最後一次發出之尺寸會停在過期值(例如高度動畫逐幀+1px時末段恰差1px而不發出);
// 惟使用端於事件內改變尺寸所形成之回授須阻斷(見judgeAxis), 容許誤差即界定此類小變化之範圍
// 尺寸為0時差值必超過容許誤差而使基準歸0, 故由隱藏恢復顯示時必發出
// 有給getBase時比較基準改為使用端目前套用之尺寸(例如圖表目前寬高), 故掛載時尺寸已一致者不發出, 且與偵測器之回報次序無關
// onReobserve為延後發出因元素已不被繪製而略過時, 請觀察層重新觀察目前元素並登記等待之函數(輪詢模式不需要, 下一次取樣即重新比較)
function createCore(ev, opt, onReobserve) {
//tolerancePixel, toleranceBounce
let tolerancePixel = getTolerancePixel(opt)
let toleranceBounce = getToleranceBounce(opt)
//bounce, 各軸之判定狀態: dir為相鄰兩次量測之差之方向(1或-1, 0為未知), n為連續反向次數, m為容許誤差內同向連續判定為變化之次數
// 方向以相鄰兩次量測判定而非相對比較基準: 已不判定而比較基準未更新時, 仍能判斷是否持續來回跳動
let bounceInit = () => {
return {
width: { dir: 0, n: 0, m: 0 },
height: { dir: 0, n: 0, m: 0 },
}
}
let bounce = bounceInit()
//sync, 於ResizeObserver回呼內同步發出事件(繪製前), 供須於同一幀更新版面之使用端(例如圖表重繪); 預設以setTimeout脫勾
let sync = get(opt, 'sync', false) === true
//getSize, getBase, throttleTime
let getSize = getFun(opt, 'getSize')
let getBase = getFun(opt, 'getBase')
let throttleTime = getThrottle(opt)
//cleared, sd, sb, pLast, timers, seqCheck, seqPost
let cleared = false
let sd = sizeZero() //最新量測
let sb = sizeZero() //上次判定變化時之尺寸
let pLast = null //最新量測之元素, 供視窗事件確認其當下是否被繪製
let timers = new Set()
let seqCheck = 0 //判定變化之序號
let seqPost = 0 //已處理(發出, 或因尺寸0、不被繪製、使用端已同步而不發出)之最新序號
//readBase, 比較基準: 有給getBase時取其回傳(該軸非數字表示該軸不比較, 例如固定寬度), 否則為sb
// pending為判定變化時: 有給getBase且尚有未處理之延後發出者, 使用端尺寸將被其更新, 故比較之軸改以其量測(sb)比較;
// 否則之後量得與使用端當下相近之尺寸會被判為未變化, 待延後發出套用後使用端即停在過期尺寸; 發出前之再比較(pending為false)則以使用端當下之尺寸比較
let readBase = (pending) => {
if (!getBase) {
return {
width: sb.width,
height: sb.height,
}
}
let b = null
try {
b = getBase()
}
catch (err) {
b = null
}
let width = toNum(get(b, 'width', null))
let height = toNum(get(b, 'height', null))
if (pending && seqPost < seqCheck) {
return {
width: width === null ? null : sb.width,
height: height === null ? null : sb.height,
}
}
return {
width,
height,
}
}
//diff, 各軸相對比較基準之差與是否超過容許誤差
let diff = (snew, pending) => {
let base = readBase(pending)
let dw = base.width === null ? 0 : base.width - snew.width
let dh = base.height === null ? 0 : base.height - snew.height
return {
dw,
dh,
bw: Math.abs(dw) > tolerancePixel,
bh: Math.abs(dh) > tolerancePixel,
}
}
//judgeAxis, 該軸是否判定為變化, dAxis為相對比較基準之差, exceed為是否超過容許誤差, step為相鄰兩次量測之差(判定方向用, 不相鄰時為0):
// 超過容許誤差者必是, 並使計次歸0; 差為0者否; 容許誤差為Infinity者否(任何變化皆不發出);
// 容許誤差內之非0差照常判定, 惟以下兩種使用端於事件內改變尺寸所形成之回授不判定, toleranceBounce為0時容許誤差內一律不判定(舊版行為), 為Infinity時一律判定(不阻斷):
// (1)來回回授: 該軸連續反向達toleranceBounce次後不判定, 直到同向變化或超過容許誤差
// (2)單向回授: 該軸同向連續判定達RUN_MAX次後不判定, 直到反向或超過容許誤差
// 每週期含超過容許誤差之變化者(如+1、+1、-2之鋸齒)不在此列, 同超過容許誤差之回授屬使用端版面缺陷
let judgeAxis = (key, dAxis, exceed, step) => {
let s = bounce[key]
let dir = step > 0 ? 1 : (step < 0 ? -1 : 0)
if (dir !== 0) {
let reverse = s.dir !== 0 && dir !== s.dir
s.n = reverse ? s.n + 1 : 0
if (reverse) {
s.m = 0
}
s.dir = dir
}
if (exceed) {
s.n = 0
s.m = 0
return true
}
if (dAxis === 0 || tolerancePixel === Infinity) {
return false
}
if (toleranceBounce === Infinity) {
return true
}
if (s.n >= toleranceBounce || s.m >= RUN_MAX) {
return false
}
if (dir !== 0) {
s.m++
}
return true
}
//mode, 方向只於該軸判定為變化時給: 未判定之軸(差為0, 或容許誤差內而不判定)視同未變化, 若仍給方向, 另一軸觸發之事件會帶出該軸之殘餘方向
let mode = (d, q) => {
return {
width: q.cw && d.dw !== 0 ? (d.dw > 0 ? 'smaller' : 'larger') : '',
height: q.ch && d.dh !== 0 ? (d.dh > 0 ? 'smaller' : 'larger') : '',
}
}
//skip, 延後發出前元素已不被繪製而不發出:
// 若此為最近一次判定之變化(之後未再判定), 比較基準與最新量測歸0, 並請觀察層重新觀察目前元素; 否則元素於下一幀前恢復顯示且尺寸與隱藏前相同時ResizeObserver不再回報(上次回報之尺寸未變), 此次變化即永不發出
// 之後已再判定變化者由該次發出處理, 不得歸0, 否則覆蓋較新之比較基準
// 最新量測歸0後之下一次量測與其不相鄰, 判定狀態隨之歸0(見check)
let skip = (snew) => {
if (sb !== snew) {
return
}
sb = sizeZero()
sd = sizeZero()
if (onReobserve) {
onReobserve()
}
}
//post, 發出事件
// deferred為延後發出(setTimeout、節流之後續呼叫或refresh): 自量測至發出之間元素可能已隱藏或移出頁面, 若照發則使用端讀到尺寸0而算錯版面(即上方2020年缺陷1), 故發出前確認元素當下仍被繪製;
// 有給getBase者亦再比較一次, 期間使用端已自行同步尺寸時不發出, 方向亦依當下比較重給
let post = (sold, snew, sm, p, seq, deferred) => {
//cleared, clear後不再發出已排定之事件
if (cleared) {
return
}
//seq, 已處理較新之量測者不發出較舊者: sync時refresh或節流之後續發出為延後發出, 可能晚於其後已同步發出之事件, 照發則使用端最後收到過期尺寸
if (seq < seqPost) {
return
}
seqPost = seq
//尺寸為0(隱藏或移出DOM)不發出
if (!isShown(snew)) {
return
}
//deferred
if (deferred) {
if (!domIsRendered(p)) {
skip(snew)
return
}
if (getBase) {
//再比較: 保留超過容許誤差之軸, 以及判定時已判定為變化(有方向)且與使用端當下尺寸仍不同之軸; toleranceBounce為0時容許誤差內一律不發出(同舊版), 只保留前者
let d = diff(snew, false)
let within = toleranceBounce > 0
let q = {
cw: d.bw || (within && sm.width !== '' && d.dw !== 0),
ch: d.bh || (within && sm.height !== '' && d.dh !== 0),
}
if (!q.cw && !q.ch) {
return
}
sm = mode(d, q)
}
}
ev.emit('resize', {
sold,
snew,
smode: sm,
ele: p,
})
//cleared, resize之監聽器內可能呼叫clear
if (cleared) {
return
}
ev.emit('resizeWithWindow', {
sold,
snew,
smode: sm,
ele: p,
from: 'dom',
})
}
//fire, 於ResizeObserver回呼內同步發出(inSync)或延後發出
// 延後發出各自一個task: 前一個監聽器於microtask之DOM異動(如Vue之非同步更新)於下一個發出前已套用, 且監聽器不集中於同一個task; 發出前之確認只需樣式(domIsRendered, 只計算樣式不讀外框), 故不因此強制重排
let inSync = false
let fire = (sold, snew, sm, p, seq) => {
if (inSync) {
post(sold, snew, sm, p, seq, false)
return
}
let t = setTimeout(() => { //emit觸發事件為同步, 用setTimeout脫勾
timers.delete(t)
post(sold, snew, sm, p, seq, true)
}, 1)
timers.add(t)
}
//fireThrottled, 節流: 首次立即(同步或延後), 其後throttleTime內至多一次並以最後一次之量測延後發出
let fireThrottled = null
if (throttleTime > 0) {
fireThrottled = throttle(fire, throttleTime)
}
//deliver, now為於ResizeObserver回呼內同步發出
let deliver = (sold, snew, sm, p, seq, now) => {
inSync = now
try {
if (fireThrottled) {
fireThrottled(sold, snew, sm, p, seq)
}
else {
fire(sold, snew, sm, p, seq)
}
}
finally {
inSync = false
}
}
//check, 量測並與比較基準比較
let check = (p, allowSync) => {
if (cleared) {
return
}
//取不到元素: 最新量測歸0, 視窗事件不發出(同移出頁面); 比較基準不動, 同尺寸之元素再出現時與既有行為相同不發出dom事件
if (!p) {
sd = sizeZero()
pLast = null
return
}
//new size
let snew = measure(p, getSize)
//adjacent, 與上次量測是否相鄰: 同一元素且前後皆可見(尺寸非0), 否則判定狀態歸0且不比較方向
// 元素換新節點、曾取不到(pLast為null)、隱藏(量得0)或延後發出被略過(最新量測已歸0)皆使回授中斷, 其前之方向與計次不延續
let adjacent = p === pLast && isShown(sd) && isShown(snew)
if (!adjacent) {
bounce = bounceInit()
}
let stepWidth = adjacent ? snew.width - sd.width : 0
let stepHeight = adjacent ? snew.height - sd.height : 0
//save
sd = snew
pLast = p
//diff, judge, 兩軸皆須判定(各自更新判定狀態), 不得短路
let d = diff(snew, true)
let q = {
cw: judgeAxis('width', d.dw, d.bw, stepWidth),
ch: judgeAxis('height', d.dh, d.bh, stepHeight),
}
if (!q.cw && !q.ch) {
return
}
//sold, sb
let sold = sb
sb = snew
//deliver
seqCheck++
deliver(sold, snew, mode(d, q), p, seqCheck, allowSync && sync)
}
//fWindowResize, 視窗尺寸取當下值, 否則元素尺寸未變時會一直帶著上次量測時之視窗尺寸
// sold為比較基準, 與dom事件同義; 任何超過容許誤差之變化皆已立即更新比較基準, 故最新量測與比較基準之差必在容許誤差內, 方向恆為空
// 元素隱藏、移出頁面、取不到或尚未量得尺寸(最新量測為0)時不發出, 與dom事件之規則一致, 否則使用端於視窗改變時讀到尺寸0而算錯版面
// 視窗事件於同一幀之ResizeObserver回報前派發, 視窗改變本身使元素隱藏時(如媒體查詢)最新量測仍為隱藏前之尺寸, 故另確認元素當下仍被繪製(只需樣式, 不強制重排)
let fWindowResize = (e) => {
if (cleared) {
return
}
sd = {
...sd,
windowWidth: window.innerWidth,
windowHeight: window.innerHeight,
}
if (!isShown(sd) || !domIsRendered(pLast)) {
return
}
ev.emit('resizeWithWindow', {
sold: sb,
snew: sd,
smode: {
width: '',
height: '',
},
from: 'window',
})
}
window.addEventListener('resize', fWindowResize)
//clear
let clear = () => {
cleared = true
for (let t of timers) {
clearTimeout(t)
}
timers.clear()
if (fireThrottled) {
fireThrottled.cancel()
}
window.removeEventListener('resize', fWindowResize)
pLast = null
}
return {
check,
clear,
isCleared: () => cleared,
isVisible: () => sd.offsetWidth > 0 && sd.offsetHeight > 0,
}
}
function domDetectByPolling(f, opt = {}) {
//check
if (!isfun(f)) {
console.log('invalid f', f)
return null
}
//ev, core
let ev = evem()
let core = createCore(ev, opt)
//setInterval, 取不到元素時亦交check處理(最新量測歸0)
let timer = setInterval(() => {
core.check(getEle(f), false)
}, getTimeInterval(opt))
//refresh, 立即重新取得元素並比較(延後發出); clear後不再呼叫f, 同ResizeObserver模式
ev.refresh = () => {
if (core.isCleared()) {
return
}
core.check(getEle(f), false)
}
//clear
ev.clear = () => {
clearInterval(timer)
core.clear()
}
return ev
}
function domDetectByObserver(f, opt = {}) {
//check
if (!isfun(f)) {
console.log('invalid f', f)
return null
}
//watchIdentity, 元素可見時亦持續於DOM變動時重新取得元素, 供f()可能於舊元素仍可見時改回傳他元素之使用端
let watchIdentity = get(opt, 'watchIdentity', false) === true
//timeInterval, 行內元素改以定期量測時使用
let timeInterval = getTimeInterval(opt)
//ev, core
// 延後發出略過時重新觀察目前元素, 並依[尺寸為0時等待DOM變動]登記等待(最新量測已歸0), 不依賴重新觀察之首次回報(舊版Safari對0尺寸之新觀察不回報)
let ev = evem()
let core = createCore(ev, opt, () => {
reobserve()
updateWaiting()
})
//ele, timerInline, inlineConn, eleInline, roBorder, roContent
let ele = null
let timerInline = null
let inlineConn = false //上次判定是否為行內時元素是否在頁面中
let eleInline = false //目前元素是否曾判定為行內, 換成新節點時歸零
let roBorder = null
let roContent = null
//observe, 同時觀察border-box與content-box: 比較用之offsetWidth即border-box, 只觀察content-box會漏掉只改padding或border之變化; content-box則使捲軸出現或消失時clientWidth保持最新, 且getSize依內容區量測時只改padding之變化亦會回報
let observe = () => {
roBorder.observe(ele, { box: 'border-box' })
roContent.observe(ele)
}
//reobserve, 重新觀察目前元素: 新建之觀察於下一幀回報一次, 供延後發出被略過後重新比較
// 須先unobserve: 瀏覽器對已以同一盒觀察之元素再observe直接返回, 不重建觀察(Chromium實測)
let reobserve = () => {
if (core.isCleared() || !ele) {
return
}
roBorder.unobserve(ele)
roContent.unobserve(ele)
observe()
}
//syncInline, 非替換之行內元素ResizeObserver不回報(規範明定), 取得之元素為display:inline時改以定期量測, 不為inline時停止
// 元素自身隱藏(display:none, 如v-show)時看不出顯示後之型別: 曾判定為行內者持續定期量測, 否則顯示時ResizeObserver會回報; 不在頁面中時display為空字串, 停止量測, 待插入頁面時(onDomChange)重判
let syncInline = () => {
let display = ''
if (ele) {
try {
display = window.getComputedStyle(ele).display
}
catch (err) {
display = ''
}
}
inlineConn = !!(ele && ele.isConnected)
if (display === 'inline') {
eleInline = true
}
else if (display !== 'none' && display !== '') {
eleInline = false
}
let inline = display === 'inline' || (display === 'none' && eleInline)
if (inline && timerInline === null) {
timerInline = setInterval(onResize, timeInterval)
}
else if (!inline && timerInline !== null) {
clearInterval(timerInline)
timerInline = null
}
}
//retarget, 重新取得元素: 換成新節點時改觀察新節點, 新節點之首次回報由check比較
let retarget = () => {
if (core.isCleared()) {
return
}
let p = getEle(f)
if (p === ele) {
return
}
if (ele) {
roBorder.unobserve(ele)
roContent.unobserve(ele)
}
ele = p
eleInline = false
if (ele) {
observe()
}
syncInline()
}
//updateWaiting, 元素取不到、不在頁面中或尺寸為0時登記等待DOM變動:
// 元素消失(移除或換新節點)時ResizeObserver無從得知新節點, 且已隱藏(尺寸0)之元素再被移除時尺寸0→0不會回報, 故尺寸為0時亦須等待
let updateWaiting = () => {
if (core.isCleared()) {
return
}
let visible = ele && ele.isConnected && core.isVisible()
if (visible && !watchIdentity) {
waitRemove(onDomChange)
}
else {
waitAdd(onDomChange)
}
}
//onDomChange, 僅重新取得元素不量測, 避免強制重排版; 換新節點後由ResizeObserver之首次回報觸發check
// 元素插入或移出頁面時重判是否為行內: 不在頁面中之元素取不到display而判為非行內(如Vue指令之bind時元素尚未插入, 或行內元素移出後定期量測已停止), 行內元素ResizeObserver又不回報, 不於插入後重判則永不量測
// 僅於在頁面與否改變時重判, 等待中之其他DOM變動不重複讀取樣式
let onDomChange = () => {
retarget()
if (ele && ele.isConnected !== inlineConn) {
syncInline()
}
updateWaiting()
}
//onResize
let onResize = () => {
retarget()
try {
core.check(ele, true)
}
finally {
//sync模式下監聽器拋錯時仍須更新行內與等待狀態
syncInline()
updateWaiting()
}
}
//roBorder, roContent
roBorder = new window.ResizeObserver(onResize)
roContent = new window.ResizeObserver(onResize)
//initialize
retarget()
updateWaiting()
//refresh, 立即重新取得元素並比較(延後發出), 供比較基準因尺寸以外之原因改變時使用(例如getBase之固定寬度被取消)
ev.refresh = () => {
retarget()
core.check(ele, false)
syncInline()
updateWaiting()
}
//clear, 可於任何時點呼叫(含元素尚未取得), 並取消已排定之事件
ev.clear = () => {
if (core.isCleared()) {
return
}
core.clear()
waitRemove(onDomChange)
roBorder.disconnect()
roContent.disconnect()
if (timerInline !== null) {
clearInterval(timerInline)
timerInline = null
}
ele = null
}
return ev
}
/**
* 前端針對DOM元素監聽resize、resizeWithWindow事件,其中resizeWithWindow為dom resize與window resize皆會觸發的事件
*
* 瀏覽器支援ResizeObserver與MutationObserver時以其偵測(即時且閒置時不耗資源),否則退回定期輪詢,兩者之比較規則與事件相同:以比較用尺寸(預設offsetWidth、offsetHeight)與比較基準之差超過容許誤差即發出,比較基準預設為上次發出事件時之尺寸,故緩慢之連續小變化累積超過容許誤差亦會發出;容許誤差內之變化亦照常發出(同向逐px之變化如動畫或拖曳之末段不被略過),惟為阻斷使用端於事件內改變尺寸所形成之回授,某軸之量測來回跳動(相鄰兩次量測之變化方向連續反向)達toleranceBounce次後,該軸容許誤差內之變化不再發出,直到該軸出現同向變化或超過容許誤差,且某軸容許誤差內之同向變化連續發出10次後,不再發出容許誤差內之同向變化,直到該軸反向或超過容許誤差(持續之逐px變化於下一次即累積超過容許誤差而照發,單向回授則至此中斷);尺寸為0(隱藏或移出DOM)不發出,由隱藏恢復顯示時會發出,首次取得非0尺寸時會發出;延後發出(非同步、節流或refresh)時,發出前元素已不被繪製(移出頁面,或自身或祖先為display:none)者不發出,待其再顯示時重新比較(同由隱藏恢復顯示,sold為0、smode兩軸為'larger'),此確認只計算樣式而不讀外框,故元素仍被繪製而尺寸於空檔內縮為0者照發;sync為true時refresh與節流之後續發出仍為延後發出,其量測早於已處理之事件者不發出
*
* 元素可取不到、中途消失或重建為新節點:取不到、不在頁面中或尺寸為0時,於DOM變動時重新以f取得元素並改觀察之。行內元素(display:inline)ResizeObserver不回報,該偵測器改以定期量測,元素自身隱藏(display:none,如v-show)期間亦持續,顯示後即可量得;是否為行內於取得元素、ResizeObserver回報、定期量測及元素插入或移出頁面時判定
*
* 事件內容:sold為上次判定變化時之量測,snew為本次量測(另含width、height為比較用尺寸),smode為寬與高相對比較基準之變化方向('larger'、'smaller'或''),僅該軸判定為變化時(超過容許誤差,或容許誤差內且未因回授而停止)給方向,否則為'',有給getBase者延後發出時依發出當下與使用端尺寸之比較重給,ele為元素;from為'window'之事件無ele,其snew為最新量測,其中視窗尺寸取事件當下之值,其smode寬與高恆為''(視窗事件不代表元素尺寸變化,且最新量測與比較基準之差必在容許誤差內);視窗事件於最新量測之任一維為0(隱藏、移出頁面、取不到元素、尚未量得尺寸,或寬高其一為0之元素),或元素當下不被繪製(例如媒體查詢隨視窗改變而隱藏)時不發出,與dom事件之規則一致
*
* 已知限制:ResizeObserver模式下,僅屬性變化(如class)使f改指他元素、或Shadow DOM內之節點被替換時不會跟隨,行內元素自取得起即自身為display:none(未曾以行內顯示過)、之後僅以style或class改為顯示者不會開始量測,此類使用端請用mode:'polling';sync為true時監聽器不得使所監聽元素之尺寸於同一幀內再變,否則瀏覽器回報ResizeObserver loop錯誤;Safari 15.4以前不支援觀察border-box,只改padding或border之變化於該處不會發出;回授之阻斷僅限容許誤差內之來回跳動與單向兩種,每週期含超過容許誤差之變化者(例如+1、+1、-2之鋸齒)與超過容許誤差之回授不阻斷,此類屬使用端版面缺陷(例如以含框線之offsetHeight設定撐開自身之子元素高);因回授而停止時,以及同向逐px之變化恰於連續第11次結束時,最後一次發出之尺寸可與實際差在容許誤差內
*
* Unit Test: {@link https://github.com/yuda-lyu/wsemi/blob/master/test/domDetect.test.mjs Github}
* @memberOf wsemi
* @param {Function} f 輸入取得dom函數,可回傳null表示目前取不到
* @param {Object} [opt={}] 輸入設定物件,預設{}
* @param {String} [opt.mode=''] 輸入偵測模式字串,給'polling'則強制使用定期輪詢,預設''
* @param {Integer} [opt.timeInterval=20] 輸入定期偵測時間整數,單位毫秒,用於輪詢模式與行內元素,須為正整數(可為數字字串),超過計時器上限(含Infinity)者夾至上限,無效時用預設,預設20
* @param {Integer} [opt.tolerancePixel=1] 輸入容許誤差整數,單位px,差值不超過此值之變化為容許誤差內之變化(是否發出見toleranceBounce),可給0表示任何變化皆發出(無回授之阻斷),須為不小於0之整數(可為數字字串,Infinity表示任何變化皆不發出),無效時用預設,預設1
* @param {Integer} [opt.toleranceBounce=3] 輸入容許誤差內來回跳動之次數整數,容許誤差內之變化照常發出,惟某軸相鄰兩次量測(同一元素之前後兩次可見量測,元素換新、取不到或隱藏後重新計數)之變化方向連續反向達此次數後(視為使用端於事件內改變尺寸所形成之回授),該軸容許誤差內之變化不再發出,直到該軸出現同向變化或超過容許誤差;另某軸容許誤差內之同向變化連續發出10次後,不再發出容許誤差內之同向變化,直到該軸反向或超過容許誤差(阻斷單向回授);給0表示容許誤差內之變化一律不發出(舊版行為),給Infinity表示容許誤差內之變化一律發出(不阻斷回授),須為不小於0之整數(可為數字字串),無效時用預設,預設3
* @param {Boolean} [opt.sync=false] 輸入是否於ResizeObserver回呼內同步發出事件布林值,true時使用端可於瀏覽器繪製前更新版面(例如圖表重繪),僅ResizeObserver模式有效,預設false
* @param {Boolean} [opt.watchIdentity=false] 輸入元素可見時是否仍於DOM變動時重新取得元素布林值,f可能於舊元素仍可見時改回傳另一元素者給true,僅ResizeObserver模式有效,預設false
* @param {Function} [opt.getSize=null] 輸入比較用尺寸函數,傳入元素,回傳{width,height},供依內容區或特定量測方式繪製之使用端(例如圖表依clientWidth扣除padding),給予時只改padding、元素內出現或消失捲軸等外框不變之變化亦會發出,回傳非數字或拋錯之軸視為0(不發出),預設null表示使用offsetWidth、offsetHeight
* @param {Function} [opt.getBase=null] 輸入比較基準函數,回傳使用端目前套用之尺寸{width,height},該軸非數字(含拋錯)表示該軸不比較(例如固定寬度),給予時以其取代上次發出事件時之尺寸,故掛載時尺寸已一致者不發出(其後事件之sold於首次判定變化前為0),由隱藏恢復顯示時若與使用端尺寸相同亦不發出;使用端須於事件內套用新尺寸(getBase之回傳隨之更新),否則之後每次回報皆判定為變化而發出,預設null
* @param {Integer} [opt.throttle=0] 輸入節流時間整數,單位毫秒,首次立即發出,其後每throttle毫秒至多發出一次並以最後一次之量測發出(其sold為最後一次判定變化前之量測,可能未曾發出),clear時取消待發出者,僅作用於元素尺寸之事件(resize與from為'dom'之resizeWithWindow),視窗事件不節流,須為正整數(可為數字字串),超過計時器上限(含Infinity)者夾至上限,其餘視為0,預設0表示不節流
* @returns {Object} 回傳物件,可使用on、refresh與clear函數,on可監聽resize與resizeWithWindow事件,refresh為立即重新量測並比較(延後發出,給throttle時併入節流而至多延後throttle毫秒),供比較基準因尺寸以外之原因改變時使用,clear為釋放監聽,可於任何時點呼叫(含元素尚未取得),並取消已排定之事件
* @example
* need test in browser
*
* //監聽dom
* let de = domDetect(() => {
* return document.querySelector('#id')
* })
* de.on('resize', (s) => {
* console.log('resize', s)
* })
* de.on('resizeWithWindow', (s) => {
* console.log('resizeWithWindow', s)
* })
*
* //釋放監聽
* de.clear()
*
*/
function domDetect(f, opt = {}) {
let useObserver = typeof window !== 'undefined' && !!window.ResizeObserver && !!window.MutationObserver && get(opt, 'mode', '') !== 'polling'
if (useObserver) {
return domDetectByObserver(f, opt)
}
return domDetectByPolling(f, opt)
}
export default domDetect