diff --git a/src/layouts/SiteLayout.vue b/src/layouts/SiteLayout.vue index f17f69e..48fa134 100644 --- a/src/layouts/SiteLayout.vue +++ b/src/layouts/SiteLayout.vue @@ -463,13 +463,19 @@ const CURTAIN_DASH_FALLBACK = 7.2 /** 幕布露出这么多比例即起笔 */ const CURTAIN_DRAW_AT = 0.6 -/** 一划到底的生效范围:距页面底部不足这么多屏时,再一次向下滑动就直接滑到底。 - * 幕布末尾占位区是半屏高,所以"幕布刚露头"对应 remaining ≈ 0.5 屏; - * 这里取 0.75 留点余量 —— 滑到底后往上滑一点(幕布还没离开视线)再下滑,仍会一划到底。 +/** 幕布区翻页的三个距离阈值(都以"视口高的倍数"计,用距底距离 remaining 判断)。 + * 幕布末尾占位区是半屏高 → "幕布刚露头"对应 remaining ≈ 0.5 屏。 * 用距离判断而不是看 IntersectionObserver 的结果:滚动事件与观察器回调不在同一帧, * 靠标志位会出现"刚划到幕布时那一下不生效"的竞态 */ -const CURTAIN_GLIDE_ZONE = 0.75 -const CURTAIN_GLIDE_MS = 1100 +/** 幕布露头线 */ +const CURTAIN_REVEAL_SCREENS = 0.5 +/** 下滑接管范围:留余量,覆盖"滑到底后往上滑一点、幕布还在视线里"再下滑的情况 */ +const CURTAIN_FLIP_IN_SCREENS = 0.75 +/** 上滑要退到距底这么多屏:越过露头线把幕布藏掉,又仍留在下滑接管范围(0.75)内, + * 于是退出去之后再下滑能立刻回到幕布——正好凑成"翻进去 / 翻出来"一对动作 */ +const CURTAIN_FLIP_OUT_SCREENS = 0.7 +/** 一次翻页的时长 */ +const CURTAIN_FLIP_MS = 1100 const curtainSvg = ref(null) const curtainGroup = ref(null) @@ -484,7 +490,7 @@ function setLetterEl(el: Element | ComponentPublicInstance | null, index: number const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)') let curtainObserver: IntersectionObserver | null = null -let curtainGlideUntil = 0 +let curtainFlipUntil = 0 /** * 幕布初始化(挂载后跑一次): @@ -543,40 +549,53 @@ function watchCurtain(): void { curtainObserver.observe(spacer) } -/** 一划到底:距底不足 0.75 屏(幕布已露头,含滑到底后往上滑一点点)时, - * 再一次向下滑动就直接滑到底,省得用户在最后半屏里一点点蹭; - * 顺带让描边动画在整块幕布前完整播完 */ -function glideToBottom(): void { +/** + * 幕布区翻页(一次翻到底 / 一次翻出去,像翻书一样): + * · 下滑 → 直接滑到最底,幕布整块亮相,描边动画正好完整播完; + * · 上滑 → 直接退出幕布范围,幕布被页脚重新挡住。 + * 两个方向都只在幕布已经露头时接管,共用一段冷却时间,避免与惯性滚动来回抖。 + */ +function curtainFlip(direction: 'in' | 'out'): void { if (prefersReducedMotion.matches) return // 减弱动态效果下不接管滚动 const now = performance.now() - if (now < curtainGlideUntil) return + if (now < curtainFlipUntil) return const doc = document.documentElement - const remaining = doc.scrollHeight - window.scrollY - window.innerHeight - if (remaining <= 0 || remaining > window.innerHeight * CURTAIN_GLIDE_ZONE) return - curtainGlideUntil = now + CURTAIN_GLIDE_MS + 300 - const top = doc.scrollHeight + const vh = window.innerHeight + const remaining = doc.scrollHeight - window.scrollY - vh + let top: number + if (direction === 'in') { + // 已经在最底就没什么可滑;离幕布还远(正文里)也不插手 + if (remaining <= 0 || remaining > vh * CURTAIN_FLIP_IN_SCREENS) return + top = doc.scrollHeight + } else { + // 幕布还没露头,上滑交回给正常滚动 + if (remaining >= vh * CURTAIN_REVEAL_SCREENS) return + top = doc.scrollHeight - vh * (1 + CURTAIN_FLIP_OUT_SCREENS) + } + curtainFlipUntil = now + CURTAIN_FLIP_MS + 300 const lenis = lenisRef?.value - // 不能用 lock:Lenis 在 isLocked 期间会把滚动事件 preventDefault 掉(见 lenis.mjs onVirtualScroll - // 里 `if (this.isStopped || this.isLocked) { preventDefault(); return }`), - // 整段滑行都吞输入 —— 用户上滑也拦不住,就成了"上滑也直接划出"。 - // 不用 lock 时,滑行途中上滑会按 targetScroll - delta 重新定目标,页面马上跟着手指走,可随时打断。 - if (lenis) lenis.scrollTo(top, { duration: CURTAIN_GLIDE_MS / 1000 }) - else window.scrollTo({ top, behavior: 'smooth' }) + // 不加 lock:Lenis 在 isLocked 期间会把滚动事件 preventDefault 掉(lenis.mjs onVirtualScroll 里 + // `if (this.isStopped || this.isLocked) { preventDefault(); return }`),整段滑行都吞输入, + // 用户想反向也拦不住。不加 lock 时,滑行途中反向滚动会按 targetScroll ± delta 重新定目标,跟手可打断。 + if (lenis) lenis.scrollTo(Math.max(0, top), { duration: CURTAIN_FLIP_MS / 1000 }) + else window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }) } -/** 滚轮向下即触发;监听用 passive——不拦默认行为,交给 Lenis 处理滚动,我们只负责改目标 */ +/** 滚轮:只把方向告诉幕布翻页逻辑。监听用 passive——不拦默认行为,滚动仍交给 Lenis */ function onCurtainWheel(e: WheelEvent): void { - if (e.deltaY > 0) glideToBottom() + if (e.deltaY > 0) curtainFlip('in') + else if (e.deltaY < 0) curtainFlip('out') } -/** 触屏:手指上移 = 页面向下滚 */ +/** 触屏:手指上移 = 页面向下滚(翻进去),手指下移 = 页面向上滚(翻出来) */ let lastTouchY = 0 function onCurtainTouchStart(e: TouchEvent): void { lastTouchY = e.touches[0]?.clientY ?? 0 } function onCurtainTouchMove(e: TouchEvent): void { const y = e.touches[0]?.clientY ?? lastTouchY - if (y < lastTouchY) glideToBottom() + if (y < lastTouchY) curtainFlip('in') + else if (y > lastTouchY) curtainFlip('out') lastTouchY = y }