/** * 门户滚动工具集 * * 设计原则(参考 Material Design 3 / iOS Safari 滚动模型 / Nuxt 3 page transition): * 1. **绝不劫持 wheel 事件** —— 浏览器原生滚动在 GPU 合成、滚动惯性、 * 触控板缩放、辅助滚动、屏幕阅读器等场景下都是最优的。手写 wheel handler * 累加 deltaY + setTimeout 锁会导致"滑动不稳 / 向下滑却向上滚"。 * 2. **分屏落点用 scrollIntoView + scrollend 判定** —— 让浏览器原生动画, * 只在 scrollend 时解锁 / 计算下一段。 * 3. **CSS overscroll-behavior: contain 隔离滚动冒泡** —— 子容器滚动到边界 * 时不再触发父级连锁滚动。 * 4. **deltaMode 区分鼠标/触控板** —— 阈值判断用 pixel 模式。 */ const SCROLL_END_TIMEOUT_MS = 120; /** * 桌面端全屏分屏滚轮(兼容旧调用方;新实现改为原生滚动 + scrollIntoView) * 现仅保留接口签名供外部 import,不主动 bind wheel。 */ export function shouldUseSectionWheelScroll() { if (typeof window === 'undefined') return true; return !window.matchMedia('(max-width: 767px), (pointer: coarse)').matches; } /** * @deprecated 老接口:不再劫持 wheel 事件。新逻辑请直接用 scrollIntoView + scrollend。 * 此函数保留仅为不破坏外部 import,运行时不绑定任何监听。 */ export function bindSectionWheelScroll(root, handler) { // 静默 no-op,提示调用方迁移 if (typeof console !== 'undefined') { // eslint-disable-next-line no-console console.warn( '[portal-scroll-mode] bindSectionWheelScroll 已废弃:不再劫持 wheel 事件。' + ' 请改用 scrollIntoView({ behavior: "smooth" }) + scrollend 判定。', ); } return () => {}; } /** * 门户主内容区滚回顶部(分页、筛选等页内操作) * @param {{ smooth?: boolean, selector?: string }} [options] */ export function scrollPortalContentToTop(options = {}) { if (typeof window === 'undefined') return; const { smooth = true, selector = '.content-wrap' } = options; const root = document.querySelector(selector); const behavior = smooth ? 'smooth' : 'auto'; if (root) { root.scrollTo({ top: 0, behavior }); return; } window.scrollTo({ top: 0, behavior }); } /** * Promise 化的 scrollend —— 等到滚动真正结束再 resolve。 * 兼容不支持 scrollend 的浏览器(Safari < 15.4),fallback 到 debounced scroll。 * @param {HTMLElement | Window} target * @param {number} timeoutMs * @returns {Promise} */ export function waitForScrollEnd(target = window, timeoutMs = SCROLL_END_TIMEOUT_MS) { return new Promise((resolve) => { if (typeof window === 'undefined') { resolve(); return; } let settled = false; const done = () => { if (settled) return; settled = true; target.removeEventListener && target.removeEventListener('scrollend', done); target.removeEventListener && target.removeEventListener('scroll', onScroll); resolve(); }; const onScroll = () => { if (settled) return; if (debounceTimer) clearTimeout(debounceTimer); debounceTimer = setTimeout(done, timeoutMs); }; let debounceTimer = setTimeout(done, timeoutMs); if ('onscrollend' in window) { target.addEventListener('scrollend', done, { once: true, passive: true }); } else { target.addEventListener('scroll', onScroll, { passive: true }); } }); } /** * 平滑滚动到指定 section 元素,等待滚动结束。 * @param {HTMLElement} el 目标元素 * @param {{ block?: ScrollLogicalPosition, offset?: number, scrollRoot?: HTMLElement | Window }} [options] * @returns {Promise} */ export async function smoothScrollTo(el, options = {}) { if (!el) return; const { block = 'start', offset = 0, scrollRoot = window } = options; const behavior = 'smooth'; // 处理 offset:scrollIntoView 不支持 offset,临时用 transform 占位再回滚 if (offset !== 0) { const orig = el.style.scrollMarginTop; el.style.scrollMarginTop = `${offset}px`; try { el.scrollIntoView({ behavior, block, inline: 'nearest' }); } finally { // 还原(nextTick 后给浏览器时间生效) setTimeout(() => { el.style.scrollMarginTop = orig; }, 0); } } else { el.scrollIntoView({ behavior, block, inline: 'nearest' }); } await waitForScrollEnd(scrollRoot); }