/** * useSpotlight - 滚动 spotlight / 边缘渐隐 * * 行为: * - opacity 等于「元素在视口中可见的比例」 * - 元素完全在视口内 → 1 * - 元素被滚出一半 → 0.5 * - 元素完全在视口外 → 0 * - 元素比视口高、且完全覆盖视口 → 1 * * 关键修复(针对 app-plus): * - app-plus 的 是原生 UIScrollView,WebView 内容本身不滚动。 * 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 ≈ 20fps,SelectorQuery 开销小但不为零,频率不用太高 } 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", } }