Skip to content

Commit b7feb2f

Browse files
Caldisclaude
andcommitted
fix(core): track cover viewport position per-frame during close animation
Previously getCoverStyle snapshotted the cover position at close time, then a 350ms CSS transition interpolated toward that stale target. Scrolling during close made the image fly toward where cover used to be — visually "lagging by half a beat". Replace the close path with a RAF that re-reads coverRef.current.getBoundingClientRect() every frame, lerping from the browsing-state geometry toward the live cover target via a Newton-Raphson solver of the same cubic-bezier curve. Tracking precision drops from 350ms (full transition duration) to ≤1 frame. Refactor: anim.ts now exports animationCurve (single source for both the CSS animationFunction string and the RAF easer) and getBrowsingAnimationDuration(presetIsDesktop) (single source for desktop=350 / mobile=700, consumed by both Browser.unInit's closeDelay and Image.startClosingFollow's RAF duration). Tests: - 11 unit tests for lerpCoverStyle, closingEase, makeCubicBezierEase, and getImageTransition('closing-follow') - 4 integration tests covering the full close path: cover BCR change during animation triggers transform update, RAF retires after animation, animate.browsing=false bypasses RAF, fast reopen cancels closing RAF - Existing rotation regression test adjusted to assert convergence direction (rotateValue ∈ [360, 450]) rather than a specific intermediate-frame string — RAF lerp produces different mid-animation values than the previous CSS transition path, but the closingRotate short-path semantics are unchanged Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent f86f4e6 commit b7feb2f

6 files changed

Lines changed: 461 additions & 7 deletions

File tree

packages/core/src/__tests__/Zmage.test.tsx

Lines changed: 184 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,10 +273,19 @@ describe('Zmage 动画行为', () => {
273273
expect((document.getElementById('zmageImage') as HTMLImageElement).style.transform).toContain('450deg')
274274

275275
clickById('zmageControlClose')
276+
// closing-follow RAF 用 lerp 从 currentStyle.rotate=450 推进到 closingRotate=360.
277+
// wait 80ms 时 RAF 跑了几帧, rotate 应已朝 360 收敛 — 数值落在 [360, 450].
278+
// 不能等到 portal 卸载 (340ms 后 mounted=false), 也不依赖具体某帧字符串 (RAF 时序不稳).
276279
await wait(80)
277280

278281
const closingImage = document.getElementById('zmageImage') as HTMLImageElement
279-
expect(closingImage.style.transform).toContain('360deg')
282+
const m = closingImage.style.transform.match(/rotate3d\(0,\s*0,\s*1,\s*([-\d.]+)deg\)/)
283+
expect(m).not.toBeNull()
284+
const rotateValue = Number(m![1])
285+
// 关键回归: closingRotate = round(450/360)*360 = 360, 朝 360 走 (90deg 短路径), 不会反向 450 度回 0.
286+
// 任何中间帧 rotate ∈ [360, 450]; 修复前 (transition + React state=360) 与修复后 (RAF lerp) 都满足.
287+
expect(rotateValue).toBeGreaterThanOrEqual(360)
288+
expect(rotateValue).toBeLessThanOrEqual(450)
280289
})
281290

282291
it('animate.browsing=false 时打开/关闭不使用过渡, 且关闭立即卸载 portal', async () => {
@@ -658,3 +667,177 @@ describe('Zmage 命令式调用', () => {
658667
expect(document.getElementById('zmagePortal')).toBeNull()
659668
})
660669
})
670+
671+
/**
672+
* 关闭路径 cover 实时追踪 (RAF) — 核心回归
673+
*
674+
* 修复前: 关闭瞬间 getCoverStyle 把 cover 视口坐标 snapshot 进 transform,
675+
* 350ms transition 内不再重算 → 用户滚动时大图飞向旧位置,"慢半拍".
676+
* 修复后: 关闭路径走 RAF, 每帧重新 getBoundingClientRect 拿 cover 当前视口位置,
677+
* 直接命令式 setNodeTransform — 滚动时大图 1 帧内追上 cover.
678+
*/
679+
describe('关闭路径 cover 实时追踪 (RAF)', () => {
680+
const wait = async (ms: number) => {
681+
await act(async () => { await new Promise(r => setTimeout(r, ms)) })
682+
}
683+
684+
// 解析 transform 字符串里第二段 translate3d 的 y (RAF/render 写出的 cover 偏移)
685+
// 形如: `translate3d(-50%, -50%, 0) translate3d(${x}px, ${y}px, 0px) scale3d(...)`
686+
const parseTransformY = (transform: string): number | null => {
687+
const matches = transform.match(/translate3d\([^)]+\)/g)
688+
if (!matches || matches.length < 2) return null
689+
const m = matches[1].match(/-?[\d.]+px,\s*(-?[\d.]+)px/)
690+
return m ? Number(m[1]) : null
691+
}
692+
693+
// 给 cover img 装一个由外部 closure 控制的可变 BCR — 中途改 box 即可模拟滚动
694+
const installMutableCoverBCR = (cover: HTMLImageElement) => {
695+
const box = { left: 0, top: 0, width: 100, height: 100 }
696+
cover.getBoundingClientRect = () => ({
697+
left: box.left,
698+
top: box.top,
699+
width: box.width,
700+
height: box.height,
701+
right: box.left + box.width,
702+
bottom: box.top + box.height,
703+
x: box.left,
704+
y: box.top,
705+
toJSON: () => ({}),
706+
} as DOMRect)
707+
return box
708+
}
709+
710+
it('关闭过程中 cover 视口位置变化 → 大图 transform 跟随更新 (修复前会保留旧 snapshot)', async () => {
711+
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
712+
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
713+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 1024 })
714+
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 768 })
715+
716+
try {
717+
render(<Zmage src={SRC} alt="track-cover" preset="desktop"/>)
718+
const cover = screen.getByAltText('track-cover') as HTMLImageElement
719+
const coverBox = installMutableCoverBCR(cover)
720+
721+
// 起始 cover 在 viewport 顶部
722+
coverBox.top = 100
723+
724+
fireEvent.click(cover)
725+
await wait(60) // init RAF + first updateStyle
726+
727+
const center = document.getElementById('zmageImage') as HTMLImageElement
728+
expect(center).toBeTruthy()
729+
730+
// 触发关闭 — 走 startClosingFollow 路径
731+
clickById('zmageControlClose')
732+
await wait(30) // 让 RAF 跑出一两帧的 inline transform
733+
734+
// closing-follow 期间 transition 必须是 none, 让 RAF 的 setNodeTransform 直接生效
735+
expect(center.style.transition).toBe('none')
736+
const y1 = parseTransformY(center.style.transform)
737+
expect(y1).not.toBeNull()
738+
739+
// 模拟用户在关闭动画中途滚动了 500px (cover 视口位置随之改变)
740+
coverBox.top = 600
741+
742+
// 再等几帧 — RAF 应该读到新的 BCR 并把 transform 重写到追赶位置
743+
await wait(40)
744+
const y2 = parseTransformY(center.style.transform)
745+
expect(y2).not.toBeNull()
746+
747+
// 修复前: y2 == y1 完全相同 (target 是 setState 时 snapshot 的, 不会跟 cover 变化).
748+
// 修复后: 任何非零差距都证明 RAF 在每帧重读 BCR. 不断言量级 — cubic-bezier(0.6,0,0.1,1)
749+
// 起步极慢 (jsdom 下 RAF tick ≤ 几帧时 eased 还在 0.05 以下), 量级波动太大测不稳.
750+
// "y2 != y1" 已经能区分 fix vs regression: 修复前两个值会精确相等.
751+
expect(y2).not.toBe(y1)
752+
} finally {
753+
if (originalClientWidth) Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
754+
if (originalClientHeight) Object.defineProperty(HTMLElement.prototype, 'clientHeight', originalClientHeight)
755+
}
756+
})
757+
758+
it('动画结束后 inline transition/transform 让位给 React state — RAF 已退场', async () => {
759+
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
760+
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
761+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 1024 })
762+
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 768 })
763+
764+
try {
765+
render(<Zmage src={SRC} alt="raf-handoff" preset="desktop"/>)
766+
const cover = screen.getByAltText('raf-handoff') as HTMLImageElement
767+
installMutableCoverBCR(cover)
768+
769+
fireEvent.click(cover)
770+
await wait(60)
771+
772+
clickById('zmageControlClose')
773+
// 等比 closeDelay (340ms) 长一些, 确保 RAF 跑完且 React state 同步、portal 卸载
774+
await wait(500)
775+
// portal 应已卸载, 标志整条关闭链路 (RAF + setTimeout finalize) 都干净退场
776+
expect(document.getElementById('zmage')).toBeNull()
777+
} finally {
778+
if (originalClientWidth) Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
779+
if (originalClientHeight) Object.defineProperty(HTMLElement.prototype, 'clientHeight', originalClientHeight)
780+
}
781+
})
782+
783+
it('animate.browsing=false 时不走 RAF — 关闭立即落 transition=none 并卸载', async () => {
784+
// 防回归: instant 路径必须保留 (走 updateCurrentImageStyleWithoutBrowsingTransition, 不进入 startClosingFollow)
785+
render(<Zmage src={SRC} alt="instant-close" animate={{ browsing: false }}/>)
786+
fireEvent.click(screen.getByAltText('instant-close'))
787+
await wait(50)
788+
789+
expect(document.getElementById('zmageImage')?.style.transition).toBe('none')
790+
791+
clickById('zmageControlClose')
792+
await wait(0)
793+
// 立即卸载 (closeDelay=0), 不存在 RAF 残留
794+
expect(document.getElementById('zmage')).toBeNull()
795+
})
796+
797+
it('快速重新打开 — closing 动画中途切回 open 不会让 portal 卸载 (RAF 被取消)', async () => {
798+
// 防回归: 修复前 closing-follow RAF 持续写 inline transform, 与 open 动画的 React render 抢同一个 inline.transform.
799+
// 主要保护点是: 重开后 portal 仍存活, 中心图节点存在 — 没有崩溃 / 没被 unmount 链路误清.
800+
const originalClientWidth = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth')
801+
const originalClientHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientHeight')
802+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, value: 1024 })
803+
Object.defineProperty(HTMLElement.prototype, 'clientHeight', { configurable: true, value: 768 })
804+
805+
try {
806+
const { rerender } = render(<RzControlled alt="reopen" browsing={false}/>)
807+
rerender(<RzControlled alt="reopen" browsing={true}/>)
808+
await wait(60)
809+
// 关闭 (closing RAF 启动)
810+
rerender(<RzControlled alt="reopen" browsing={false}/>)
811+
await wait(20)
812+
// 关闭动画刚跑了一两帧就立刻重开
813+
rerender(<RzControlled alt="reopen" browsing={true}/>)
814+
await wait(80)
815+
816+
// portal + 中心图都还在 — 说明重开链路没被 closing RAF 残余打断
817+
expect(document.getElementById('zmage')).toBeTruthy()
818+
expect(document.getElementById('zmageImage')).toBeTruthy()
819+
820+
// 再等一段时间, 期间不应有任何卸载 (closing 那个 setTimeout finalize 应该被新 init 接管掉)
821+
await wait(500)
822+
expect(document.getElementById('zmage')).toBeTruthy()
823+
} finally {
824+
if (originalClientWidth) Object.defineProperty(HTMLElement.prototype, 'clientWidth', originalClientWidth)
825+
if (originalClientHeight) Object.defineProperty(HTMLElement.prototype, 'clientHeight', originalClientHeight)
826+
}
827+
})
828+
})
829+
830+
// 受控组件帮手 — 用于"快速重开"测试, 通过 props.browsing 切换浏览状态而不依赖 click
831+
function RzControlled (props: { alt: string, browsing: boolean }) {
832+
const [_, setLocal] = React.useState(props.browsing)
833+
React.useEffect(() => { setLocal(props.browsing) }, [props.browsing])
834+
return (
835+
<Zmage
836+
src={SRC}
837+
alt={props.alt}
838+
preset="desktop"
839+
browsing={props.browsing}
840+
onBrowsing={setLocal}
841+
/>
842+
)
843+
}

packages/core/src/components/Browser/Browser.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { Context } from '../context'
1818
import type { ContextType, ZoomTrigger } from '../context'
1919
import { disableScroll, enableScroll, getTargetPage, unlockTouchInteraction } from '../../utils'
2020
import { defPropsWithEnv, resolvePreset } from '../../types/default'
21-
import { animationDuration } from '../../config/anim'
21+
import { getBrowsingAnimationDuration } from '../../config/anim'
2222
import { probeStylesheet } from '../../utils/styleProbe'
2323
import { hideCover, pageIsCover, pageSet, showCover } from './Browser.utils'
2424
import { Animate, ControllerSet, FunctionalParams, HotKey, InterfaceAndInteractionParams, LifeCycleParams, PresetParams, Set } from '../../types/global'
@@ -270,9 +270,10 @@ export default class Browser extends React.Component<Props, State> {
270270
} else {
271271
// 正常关闭路径: 走动画时间, 用句柄管理 timeout
272272
const closingRotate = this.getClosingRotate()
273+
// -10ms: 让 React state 在动画快结束时同步, 避免最后一帧的 flicker
273274
const closeDelay = animate.browsing === false
274275
? 0
275-
: presetIsDesktop ? animationDuration - 10 : animationDuration * 2 - 10
276+
: getBrowsingAnimationDuration(presetIsDesktop) - 10
276277
this.setState({ show: false, zoom: false, rotate: closingRotate, zoomTrigger: undefined, zoomPosition: undefined }, () => {
277278
const finishClose = () => {
278279
this.unInitTimer = undefined

packages/core/src/components/Image/Image.tsx

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import Loading from './loading'
1313
// Utils
1414
import { Animate } from '../../types/global'
1515
import { BrowsingParams, Context, ContextType } from '../context'
16-
import { animationDuration } from '../../config/anim'
16+
import { animationDuration, getBrowsingAnimationDuration } from '../../config/anim'
1717
import {
1818
appendParams,
1919
checkImageLoadedComplete,
@@ -25,6 +25,7 @@ import {
2525
withVendorPrefix,
2626
} from '../../utils'
2727
import {
28+
closingEase,
2829
getAnimateConfig,
2930
getCoverStyle,
3031
getCurrentImageStyle,
@@ -34,6 +35,7 @@ import {
3435
ImageAnimateType,
3536
ImageStyleType,
3637
isZoomMotionPhase,
38+
lerpCoverStyle,
3739
MotionPhase,
3840
TOUCH_BEHAVIOR_PHASE,
3941
TOUCH_BEHAVIOR_TYPE,
@@ -78,6 +80,11 @@ export default class Image extends React.Component<PropsType, StateType> {
7880
zoomFollowRaf?: number
7981
zoomFollowCurrentStyle?: ImageStyleType
8082
zoomFollowTargetStyle?: ImageStyleType
83+
// 关闭路径 RAF: 每帧重读 cover 视口位置作为 target, 解决滚动期间 transition target snapshot 导致的滞后
84+
closingFollowRaf?: number
85+
closingStartTime?: number
86+
closingDuration?: number
87+
closingFromStyle?: ImageStyleType
8188
motionPhase: MotionPhase = 'idle'
8289
pendingZoomMousePosition?: Coordinate
8390
zoomPointerPosition?: Coordinate
@@ -121,6 +128,10 @@ export default class Image extends React.Component<PropsType, StateType> {
121128
if (prevShow !== currShow || prevPage !== currPage || (prevZoom && !currZoom)) {
122129
this.resetZoomMotionState()
123130
}
131+
// 快速重新打开会让 closing RAF 持续覆盖打开动画的 inline transform — 切换到 show=true 时主动取消
132+
if (!prevShow && currShow) {
133+
this.cancelClosingFollow()
134+
}
124135
if (!prevZoom && currZoom) {
125136
keyboardZoomEnter ? this.startKeyboardZoomEnter() : this.startZoomEnter()
126137
}
@@ -131,6 +142,9 @@ export default class Image extends React.Component<PropsType, StateType> {
131142
this.updateCurrentImageStyleForKeyboardZoom()
132143
} else if (prevShow !== currShow && animate?.browsing === false) {
133144
this.updateCurrentImageStyleWithoutBrowsingTransition()
145+
} else if (prevShow && !currShow) {
146+
// 关闭路径: 启动 RAF 实时追踪 cover 视口位置, 避免 350ms transition 期间 target snapshot 导致的滚动滞后
147+
this.startClosingFollow()
134148
} else {
135149
this.debounceUpdateCurrentImageStyle()
136150
}
@@ -164,6 +178,7 @@ export default class Image extends React.Component<PropsType, StateType> {
164178
window.cancelAnimationFrame(this.browsingTransitionRaf)
165179
this.browsingTransitionRaf = undefined
166180
}
181+
this.cancelClosingFollow()
167182
this.resetZoomMotionState()
168183
// 取消挂起的 debounce, 避免在已卸载组件上 setState
169184
this.debounceUpdateCurrentImageStyle.cancel()
@@ -245,6 +260,11 @@ export default class Image extends React.Component<PropsType, StateType> {
245260
handleScroll = () => {
246261
if (this.imageRef.current) {
247262
const { show } = this.context
263+
// 关闭路径下由 RAF (startClosingFollow) 接管整个位置计算 — 此时 inline top 不能再叠加滚动量,
264+
// 否则会与 transform 内的 cover-y 偏移产生双重位移.
265+
if (this.motionPhase === 'closing-follow') {
266+
return
267+
}
248268
this.imageRef.current.style.top = `calc(50% + ${show ? 0 : this.initialPageOffset - window.pageYOffset}px)`
249269
}
250270
}
@@ -498,6 +518,87 @@ export default class Image extends React.Component<PropsType, StateType> {
498518

499519
this.zoomFollowRaf = window.requestAnimationFrame(this.stepZoomFollow)
500520
}
521+
/**
522+
* 关闭路径 RAF 追踪
523+
*
524+
* 关闭动画期间, 用户可能正在快速滚动页面 — 此时 cover 元素在视口中的位置是动态的.
525+
* 走 React state + CSS transition 路径会在动画起始时 snapshot cover 视口坐标,
526+
* 整个 350ms transition 朝着 stale 位置内插, 视觉上表现为"慢半拍".
527+
*
528+
* 这套 RAF 每帧都重新调用 getCoverStyle 拿 cover 当前视口位置作为 target,
529+
* 然后从 closingFromStyle 用 cubic-bezier easing 插值到 fresh target,
530+
* 直接命令式写 inline transform — 绕过 React render + CSS transition 的延迟,
531+
* 让追踪精度落到 1 帧以内.
532+
*/
533+
startClosingFollow = () => {
534+
this.cancelClosingFollow()
535+
this.debounceUpdateCurrentImageStyle.cancel()
536+
537+
const node = this.imageRef.current
538+
if (!node) {
539+
// 没有节点就走原 debounce 路径作为 fallback (理论上 closing 时节点一定存在)
540+
this.debounceUpdateCurrentImageStyle()
541+
return
542+
}
543+
544+
this.closingStartTime = performance.now()
545+
this.closingDuration = getBrowsingAnimationDuration(this.context.presetIsDesktop)
546+
this.closingFromStyle = this.state.currentStyle
547+
this.motionPhase = 'closing-follow'
548+
549+
// scroll handler 之前可能在 inline top 上写了滚动差; RAF 接管位置后必须复位,
550+
// 否则 transform 内已经包含 cover 视口偏移, 再叠加 inline top 会导致双重位移.
551+
node.style.top = '50%'
552+
this.setNodeTransitionNone(node)
553+
554+
this.closingFollowRaf = window.requestAnimationFrame(this.stepClosingFollow)
555+
}
556+
stepClosingFollow = () => {
557+
this.closingFollowRaf = undefined
558+
const node = this.imageRef.current
559+
const from = this.closingFromStyle
560+
const startTime = this.closingStartTime
561+
const duration = this.closingDuration
562+
if (!node || !from || startTime === undefined || duration === undefined) {
563+
this.cancelClosingFollow()
564+
return
565+
}
566+
567+
const rawProgress = Math.min(1, (performance.now() - startTime) / duration)
568+
const eased = closingEase(rawProgress)
569+
570+
// 实时读 cover 视口位置 — 这是"零滞后追踪"的关键
571+
const target = getCoverStyle(this.context, this.imageRef, this.state.touchProfile)
572+
const visual = lerpCoverStyle(from, target, eased)
573+
574+
this.setNodeTransitionNone(node)
575+
this.setNodeTransform(node, this.getCenterImageTransform(visual))
576+
node.style.opacity = String(visual.opacity ?? 1)
577+
578+
if (rawProgress >= 1) {
579+
// 落地: 同步 React state, 让后续 render 接管 transform/opacity
580+
this.closingFromStyle = undefined
581+
this.closingStartTime = undefined
582+
this.closingDuration = undefined
583+
this.motionPhase = 'idle'
584+
this.setCurrentStyle(target)
585+
return
586+
}
587+
588+
this.closingFollowRaf = window.requestAnimationFrame(this.stepClosingFollow)
589+
}
590+
cancelClosingFollow = () => {
591+
if (this.closingFollowRaf !== undefined) {
592+
window.cancelAnimationFrame(this.closingFollowRaf)
593+
this.closingFollowRaf = undefined
594+
}
595+
this.closingFromStyle = undefined
596+
this.closingStartTime = undefined
597+
this.closingDuration = undefined
598+
if (this.motionPhase === 'closing-follow') {
599+
this.motionPhase = 'idle'
600+
}
601+
}
501602
consumePendingZoomMousePosition = () => {
502603
const pending = this.pendingZoomMousePosition
503604
if (!pending || !this.context.zoom || this.state.currentStyle._type !== 'zooming') {

0 commit comments

Comments
 (0)