diff --git a/src/choreography.js b/src/choreography.js index e69dd3a..d562888 100644 --- a/src/choreography.js +++ b/src/choreography.js @@ -220,6 +220,97 @@ export function initRail(lenis) { return { jump, setActive }; } +/* 滚动守卫:避免停在两轮之间只剩背景的中间态 */ +export function initRoundAutoFocus(lenis) { + const targets = rounds + .filter((round) => round.question && !round.flow) + .map((round) => ({ round, section: document.getElementById(`r-${round.id}`) })) + .filter((target) => target.section); + + if (!targets.length) return () => {}; + + let lockUntil = 0; + let currentTarget = ""; + + const readY = () => (typeof lenis?.scroll === "number" ? lenis.scroll : window.scrollY); + + const pinDistance = (round) => { + const match = String(round.pinLength || "").match(/^\+=([\d.]+)%$/); + const ratio = match ? Number(match[1]) / 100 : 1.6; + return ratio * (window.innerHeight || document.documentElement.clientHeight); + }; + + const typingOffset = (round) => { + const endProgress = Math.min(round.qEnd + 0.025, round.thinkEnd - 0.03); + return Math.max(112, Math.round(pinDistance(round) * endProgress)); + }; + + const focusRound = ({ round, section }, sectionStartY = null) => { + const now = performance.now(); + lockUntil = now + 1150; + currentTarget = section.id; + const baseY = sectionStartY ?? section.getBoundingClientRect().top + readY(); + const targetY = baseY + typingOffset(round); + + if (lenis) { + lenis.scrollTo(targetY, { + duration: 0.85, + easing: (t) => 1 - Math.pow(1 - t, 3), + }); + } else { + window.scrollTo({ top: targetY, behavior: "smooth" }); + } + }; + + const canFocus = ({ section }) => { + const now = performance.now(); + if (now < lockUntil) return false; + const rect = section.getBoundingClientRect(); + if (section.id === currentTarget && rect.top > -12 && rect.top < 12) return false; + return rect.top > 24; + }; + + const findHalfEnteredRound = () => { + const vh = window.innerHeight || document.documentElement.clientHeight; + const minTop = Math.max(36, vh * 0.08); + const maxTop = vh * 0.62; + + return targets.find((target) => { + const { section } = target; + const rect = section.getBoundingClientRect(); + if (!canFocus(target)) return false; + return rect.top > minTop && rect.top < maxTop && rect.bottom > vh * 0.55; + }); + }; + + const checkCurrent = () => { + const target = findHalfEnteredRound(); + if (target) focusRound(target); + }; + + const triggers = targets.map((section) => + ScrollTrigger.create({ + trigger: section.section, + start: "top 62%", + end: "top 8%", + onEnter: (self) => { + if (canFocus(section)) { + const vh = window.innerHeight || document.documentElement.clientHeight; + focusRound(section, self.start + vh * 0.62); + } + }, + }), + ); + + window.addEventListener("resize", checkCurrent); + window.setTimeout(checkCurrent, 250); + + return () => { + triggers.forEach((trigger) => trigger.kill()); + window.removeEventListener("resize", checkCurrent); + }; +} + /* 降级:reduced motion 直接展示全部内容 */ export function applyReducedMotion() { rounds diff --git a/src/main.js b/src/main.js index 61bfdd5..cde284c 100644 --- a/src/main.js +++ b/src/main.js @@ -5,6 +5,7 @@ import { applyReducedMotion, initChoreography, initRail, + initRoundAutoFocus, initSmoothScroll, playBoot, } from "./choreography"; @@ -28,6 +29,7 @@ renderRail(document.getElementById("session-rail"), rail.jump); if (!reduced) { initChoreography(mind, rail.setActive); + initRoundAutoFocus(lenis); playBoot(mind); } else { applyReducedMotion();