From 7ce97a616f81351d65b28cbbdaccbc4f3c25aa45 Mon Sep 17 00:00:00 2001 From: LTbinglingfeng Date: Fri, 2 Jan 2026 00:29:42 +0800 Subject: [PATCH] fix(transition): preserve scroll position during page animations --- src/components/common/PageTransition.tsx | 36 +++++++++++++++++++----- src/components/layout/MainLayout.tsx | 5 +++- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/components/common/PageTransition.tsx b/src/components/common/PageTransition.tsx index 3b32052..aa614bf 100644 --- a/src/components/common/PageTransition.tsx +++ b/src/components/common/PageTransition.tsx @@ -1,4 +1,4 @@ -import { ReactNode, useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { ReactNode, useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { useLocation, type Location } from 'react-router-dom'; import gsap from 'gsap'; import './PageTransition.scss'; @@ -6,6 +6,7 @@ import './PageTransition.scss'; interface PageTransitionProps { render: (location: Location) => ReactNode; getRouteOrder?: (pathname: string) => number | null; + scrollContainerRef?: React.RefObject; } const TRANSITION_DURATION = 0.65; @@ -20,10 +21,15 @@ type Layer = { type TransitionDirection = 'forward' | 'backward'; -export function PageTransition({ render, getRouteOrder }: PageTransitionProps) { +export function PageTransition({ + render, + getRouteOrder, + scrollContainerRef, +}: PageTransitionProps) { const location = useLocation(); const currentLayerRef = useRef(null); const exitingLayerRef = useRef(null); + const exitScrollOffsetRef = useRef(0); const [isAnimating, setIsAnimating] = useState(false); const [transitionDirection, setTransitionDirection] = useState('forward'); @@ -37,9 +43,17 @@ export function PageTransition({ render, getRouteOrder }: PageTransitionProps) { const currentLayerKey = layers[layers.length - 1]?.key ?? location.key; const currentLayerPathname = layers[layers.length - 1]?.location.pathname; + const resolveScrollContainer = useCallback(() => { + if (scrollContainerRef?.current) return scrollContainerRef.current; + if (typeof document === 'undefined') return null; + return document.scrollingElement as HTMLElement | null; + }, [scrollContainerRef]); + useEffect(() => { if (isAnimating) return; if (location.key === currentLayerKey) return; + const scrollContainer = resolveScrollContainer(); + exitScrollOffsetRef.current = scrollContainer?.scrollTop ?? 0; const resolveOrderIndex = (pathname?: string) => { if (!getRouteOrder || !pathname) return null; const index = getRouteOrder(pathname); @@ -70,6 +84,7 @@ export function PageTransition({ render, getRouteOrder }: PageTransitionProps) { currentLayerKey, currentLayerPathname, getRouteOrder, + resolveScrollContainer, ]); // Run GSAP animation when animating starts @@ -78,6 +93,12 @@ export function PageTransition({ render, getRouteOrder }: PageTransitionProps) { if (!currentLayerRef.current) return; + const scrollContainer = resolveScrollContainer(); + const scrollOffset = exitScrollOffsetRef.current; + if (scrollContainer && scrollOffset > 0) { + scrollContainer.scrollTo({ top: 0, left: 0, behavior: 'auto' }); + } + const tl = gsap.timeline({ onComplete: () => { setLayers((prev) => prev.filter((layer) => layer.status !== 'exiting')); @@ -87,11 +108,12 @@ export function PageTransition({ render, getRouteOrder }: PageTransitionProps) { // Exit animation: fly out to top (slow-to-fast) if (exitingLayerRef.current) { + gsap.set(exitingLayerRef.current, { y: scrollOffset ? -scrollOffset : 0 }); tl.fromTo( exitingLayerRef.current, - { y: 0, opacity: 1 }, + { yPercent: 0, opacity: 1 }, { - y: transitionDirection === 'forward' ? '-100%' : '100%', + yPercent: transitionDirection === 'forward' ? -100 : 100, opacity: 0, duration: TRANSITION_DURATION, ease: 'power3.in', // slow start, fast end @@ -103,9 +125,9 @@ export function PageTransition({ render, getRouteOrder }: PageTransitionProps) { // Enter animation: slide in from bottom (slow-to-fast) tl.fromTo( currentLayerRef.current, - { y: transitionDirection === 'forward' ? '100%' : '-100%', opacity: 0 }, + { yPercent: transitionDirection === 'forward' ? 100 : -100, opacity: 0 }, { - y: 0, + yPercent: 0, opacity: 1, duration: TRANSITION_DURATION, ease: 'power2.in', // slow start, fast end @@ -117,7 +139,7 @@ export function PageTransition({ render, getRouteOrder }: PageTransitionProps) { tl.kill(); gsap.killTweensOf([currentLayerRef.current, exitingLayerRef.current]); }; - }, [isAnimating, transitionDirection]); + }, [isAnimating, transitionDirection, resolveScrollContainer]); return (
diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 8ac7dd7..1a3b8bf 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -202,6 +202,7 @@ export function MainLayout() { const [requestLogDraft, setRequestLogDraft] = useState(false); const [requestLogTouched, setRequestLogTouched] = useState(false); const [requestLogSaving, setRequestLogSaving] = useState(false); + const contentRef = useRef(null); const brandCollapseTimer = useRef | null>(null); const headerRef = useRef(null); const versionTapCount = useRef(0); @@ -343,6 +344,7 @@ export function MainLayout() { }); }, [fetchConfig]); + const statusClass = connectionStatus === 'connected' ? 'success' @@ -522,11 +524,12 @@ export function MainLayout() {
-
+
} getRouteOrder={getRouteOrder} + scrollContainerRef={contentRef} />