topfans/frontend/pages/square/composables/useSpotlight.js

152 lines
4.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* useSpotlight - 滚动 spotlight / 边缘渐隐
*
* 行为:
* - opacity 等于「元素在视口中可见的比例」
* - 元素完全在视口内 → 1
* - 元素被滚出一半 → 0.5
* - 元素完全在视口外 → 0
* - 元素比视口高、且完全覆盖视口 → 1
*
* 关键修复(针对 app-plus
* - app-plus 的 <scroll-view> 是原生 UIScrollViewWebView 内容本身不滚动。
* getBoundingClientRect() 永远返回初始 layout 位置(不随原生滚动更新),
* 所以基于 rAF/setTimeout + getBoundingClientRect 的方案在 app-plus 上完全无效。
* - 用 uni.createSelectorQuery() 取位置 —— uni-app 官方 API会桥到原生层
* 在 app-plus / H5 / 小程序上都能拿到当前可视位置。
* - 异步结果用 reqId 防止乱序(连续触发时丢弃旧结果)。
*
* 用法square.vue
* const { start, stop } = useSpotlight({ getElements })
* onMounted(start); onUnmounted(stop)
* // @scroll 事件也调一次 update(),作为双保险
*/
const SEEN_THRESHOLD = 0.5 // 透明度高于此值记为"首次出现"
export function useSpotlight(options = {}) {
const { getElements = () => [] } = options
const seenSet = typeof WeakSet !== "undefined" ? new WeakSet() : new Set()
const computeOpacity = (rect, vh) => {
if (!rect) return 1
if (rect.bottom <= 0 || rect.top >= vh) return 0
if (rect.height >= vh && rect.top <= 0 && rect.bottom >= vh) return 1
const visibleTop = Math.max(0, rect.top)
const visibleBottom = Math.min(vh, rect.bottom)
const visibleHeight = Math.max(0, visibleBottom - visibleTop)
const totalHeight = rect.height
if (totalHeight <= 0) return 1
return Math.min(1, visibleHeight / totalHeight)
}
// 给每个 ref 对应的 DOM 节点加 data-spotlight-id方便 SelectorQuery 结果映射回节点
const idToNode = new Map()
let nextId = 1
const tagNodes = () => {
const elements = getElements()
elements.forEach((ref) => {
if (!ref) return
const node = ref.$el || ref
if (!node || node.nodeType !== 1) return
if (node.dataset && node.dataset.spotlightId) return
const id = `sl${nextId++}`
node.setAttribute("data-spotlight-id", id)
idToNode.set(id, node)
})
}
// SelectorQuery 异步:连续触发时用 reqId 丢弃旧结果
let reqId = 0
const applyRects = (rects, vh) => {
if (!rects || !rects.length) return
rects.forEach((rect) => {
const id = rect && rect.dataset && rect.dataset.spotlightId
if (!id) return
const node = idToNode.get(id) || (typeof document !== "undefined" && document.querySelector(`[data-spotlight-id="${id}"]`))
if (!node) return
const opacity = computeOpacity(rect, vh)
try {
node.style.opacity = opacity.toFixed(3)
} catch (e) {}
if (opacity > SEEN_THRESHOLD && !seenSet.has(node)) {
seenSet.add(node)
try { node.classList.add("first-seen") } catch (e) {}
}
})
}
// 测高度
const getVH = () => {
try {
if (typeof window !== "undefined" && window.innerHeight) return window.innerHeight
} catch (e) {}
try {
const info = uni.getSystemInfoSync()
return info.windowHeight || info.screenHeight || 667
} catch (e) {
return 667
}
}
const update = () => {
if (typeof uni === "undefined" || typeof uni.createSelectorQuery !== "function") return
tagNodes()
if (!idToNode.size) return
const myReq = ++reqId
const vh = getVH()
try {
const query = uni.createSelectorQuery()
query.selectAll("[data-spotlight-id]").boundingClientRect()
query.exec((res) => {
if (myReq !== reqId) return // 旧请求,丢弃
if (!res || !res[0]) return
applyRects(res[0], vh)
})
} catch (e) {
// SelectorQuery 不可用时不做事
}
}
// setTimeout 递归轮询:兜底,@scroll 漏触发时仍能跑
let timerId = null
const tick = () => {
update()
timerId = setTimeout(tick, 50) // 50ms ≈ 20fpsSelectorQuery 开销小但不为零,频率不用太高
}
const start = () => {
if (timerId) return
timerId = setTimeout(tick, 50)
}
const stop = () => {
if (timerId) {
clearTimeout(timerId)
timerId = null
}
}
// 兼容:@scroll 触发时直接调一次双保险scroll 事件比 50ms 轮询更密)
const bindScroll = () => {
update()
}
return {
update,
bindScroll,
start,
stop,
isH5: typeof window !== "undefined" && typeof document !== "undefined",
}
}