feat(幕布): 上滑翻出幕布,与下滑一划到底凑成一对翻页动作

- 原先只处理下滑;现按用户口径:幕布可见时上滑直接退出幕布范围(退到距底 0.7 屏,越过 0.5 屏露头线把幕布藏掉)

- 0.7 仍落在下滑接管范围 0.75 内,所以退出后再下滑能立刻翻回来

- 两个方向共用冷却时间;滚轮与触屏各自转成 in/out
This commit is contained in:
TermiNexus committed 2026-09-29 23:56:34 +08:00
1 parent 09cb16ad98
commit 110a1241e3
1 file changed
+44 -25
+44 -25
View File
@@ -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<SVGSVGElement | null>(null)
const curtainGroup = ref<SVGGElement | null>(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
}