diff --git a/src/App.tsx b/src/App.tsx index 78ad697..84c7d24 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ // App.js -import { BrowserRouter as Router, RouterProvider, useRoutes } from 'react-router-dom'; +import { RouterProvider } from 'react-router-dom'; import "./App.css" import routes from "@/Routes"; import { useCallback, useEffect, useState } from "react"; @@ -7,6 +7,7 @@ import appApi from "@/api/app"; import { useStore } from "@/store"; import { parsePageConfig } from "@/utils/parsePageConfig"; import type { I18nData, SupportLocale } from "@/type"; +import { destroyLenis, initLenis } from "@/utils/lenis"; // function AppRoutes() { // return useRoutes(routes); @@ -79,6 +80,11 @@ function App() { getAppConfig(); }, []); + useEffect(() => { + initLenis(); + return () => destroyLenis(); + }, []); + useEffect(() => { const shortName = (appConfig as { company?: { config?: { shortName?: string } } })?.company?.config?.shortName; if (shortName) document.title = shortName; @@ -93,4 +99,4 @@ function App() { ); } -export default App; \ No newline at end of file +export default App; diff --git a/src/components/layout/RowAccordion/index.tsx b/src/components/layout/RowAccordion/index.tsx index f1f5edf..395319d 100644 --- a/src/components/layout/RowAccordion/index.tsx +++ b/src/components/layout/RowAccordion/index.tsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import { motion, useInView, type Variants } from 'motion/react'; import styles from './index.module.css'; import { Link } from 'react-router-dom'; +import { scrollToWithLenis } from '@/utils/lenis'; const contentItemVariants: Variants = { hidden: { opacity: 0, x: 80 }, @@ -11,27 +12,6 @@ const contentItemVariants: Variants = { const FALLBACK_GRADIENT = "linear-gradient(0deg, rgba(0, 0, 0, 0.5) 0%, rgba(0, 0, 0, 0.5) 100%)"; -function smoothScrollTo(targetY: number, duration = 1200) { - const startY = window.scrollY; - const diff = targetY - startY; - if (Math.abs(diff) < 1) return; - let startTime: number | null = null; - - function easeInOutCubic(t: number) { - return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2; - } - - function step(timestamp: number) { - if (!startTime) startTime = timestamp; - const elapsed = timestamp - startTime; - const progress = Math.min(elapsed / duration, 1); - window.scrollTo(0, startY + diff * easeInOutCubic(progress)); - if (progress < 1) requestAnimationFrame(step); - } - - requestAnimationFrame(step); -} - type Data = { title?: string; items: { @@ -56,12 +36,12 @@ type Props = { export default function RowAccordion({ data, placement='bottom' }: Props) { const [activeIndex, setActiveIndex] = useState(0); const containerRef = useRef(null); - const isInView = useInView(containerRef, { once: true, margin: "0px 0px -20% 0px" }); + const isInView = useInView(containerRef, { once: true, margin: "0px 0px -30% 0px" }); useEffect(() => { if (isInView && containerRef.current) { const rect = containerRef.current.getBoundingClientRect(); - smoothScrollTo(window.scrollY + rect.top, 800); + scrollToWithLenis(window.scrollY + rect.top, { duration: 3 }); } }, [isInView]); @@ -132,4 +112,4 @@ export default function RowAccordion({ data, placement='bottom' }: Props) { ); -} \ No newline at end of file +} diff --git a/src/components/layout/SwiperCardSection/index.tsx b/src/components/layout/SwiperCardSection/index.tsx index 53b93ca..fe017ad 100644 --- a/src/components/layout/SwiperCardSection/index.tsx +++ b/src/components/layout/SwiperCardSection/index.tsx @@ -22,7 +22,7 @@ type Data = { export default function SwiperCardSection({ data }: { data: Data }) { const location = useLocation(); const hash = location.hash; - const id = decodeURIComponent(hash.replace('#', '')); + const hashId = decodeURIComponent(hash.replace('#', '')); const [swiperRef, setSwiperRef] = useState(null); const [activeIndex, setActiveIndex] = useState(0); @@ -31,17 +31,15 @@ export default function SwiperCardSection({ data }: { data: Data }) { }, []) useEffect(() => { - if (id && data.cardItems && swiperRef) { - const index = data.cardItems.findIndex((item) => item.title === id); + if (hashId && data.cardItems && swiperRef) { + const index = data.cardItems.findIndex((item) => item.title === hashId); if (index !== -1) { - setTimeout(() => { - swiperRef.slideTo(index); - }, 500); + swiperRef.slideTo(index, 0); } } - }, [id, data.cardItems, swiperRef]) + }, [hashId, data.cardItems, swiperRef]) return ( -
+
{activeIndex > 0 && ( @@ -90,4 +88,4 @@ export default function SwiperCardSection({ data }: { data: Data }) {
); -} \ No newline at end of file +} diff --git a/src/components/layout/TopTabsSection/index.tsx b/src/components/layout/TopTabsSection/index.tsx index 11d6d0d..89e6cb9 100644 --- a/src/components/layout/TopTabsSection/index.tsx +++ b/src/components/layout/TopTabsSection/index.tsx @@ -25,21 +25,24 @@ type Data = { export default function TopTabsSection({ data, className }: { data: Data, className?: string }) { const location = useLocation(); const hash = location.hash; - const id = decodeURIComponent(hash.replace('#', '')); + const hashId = decodeURIComponent(hash.replace('#', '')); const [activeIndex, setActiveIndex] = useState(0); useEffect(() => { - if (id && data.tabItems) { - setTimeout(() => { - const index = data.tabItems.findIndex((item) => item.tabName === id); - if (index !== -1) { - setActiveIndex(index); - } - }, 300) + if (hashId && data.tabItems) { + const index = data.tabItems.findIndex((item) => item.tabName === hashId); + if (index !== -1) { + setActiveIndex(index); + } } - }, [id, data.tabItems]) + }, [hashId, data.tabItems]) return ( -
+
+
) -} \ No newline at end of file +} diff --git a/src/hooks/useHashScroll.ts b/src/hooks/useHashScroll.ts index e4c3653..f10bc31 100644 --- a/src/hooks/useHashScroll.ts +++ b/src/hooks/useHashScroll.ts @@ -1,32 +1,42 @@ import { useEffect } from "react"; import { useLocation } from "react-router-dom"; +import { scrollToWithLenis } from "@/utils/lenis"; const useHashScroll = () => { const location = useLocation(); useEffect(() => { - if (location.hash) { - setTimeout(() => { - const element = document.querySelector(decodeURIComponent(location.hash)); - if (element) { - const header = document.querySelector("header"); - const headerHeight = header?.getBoundingClientRect().height ?? 7.5 * 16; - const elementTop = element.getBoundingClientRect().top + window.scrollY; - const offsetPosition = elementTop - headerHeight; - - window.scrollTo({ - top: offsetPosition, - behavior: "smooth", - }); - } - }) - } else { - window.scrollTo({ - top: 0, - behavior: "smooth", - }); + if (!location.hash) { + scrollToWithLenis(0, { immediate: true }); + return; } - }, [location]); + + let rafId = 0; + let attempts = 0; + const maxAttempts = 120; + const targetId = decodeURIComponent(location.hash.slice(1)); + + const tryScrollToHash = () => { + const element = targetId ? document.getElementById(targetId) : null; + if (element) { + const header = document.querySelector("header"); + const headerHeight = header?.getBoundingClientRect().height ?? 7.5 * 16; + scrollToWithLenis(element, { + offset: -headerHeight, + duration: 1.1, + }); + return; + } + + if (attempts < maxAttempts) { + attempts += 1; + rafId = window.requestAnimationFrame(tryScrollToHash); + } + }; + + rafId = window.requestAnimationFrame(tryScrollToHash); + return () => window.cancelAnimationFrame(rafId); + }, [location.pathname, location.hash]); }; -export default useHashScroll; \ No newline at end of file +export default useHashScroll; diff --git a/src/index.css b/src/index.css index b28918f..f74d940 100644 --- a/src/index.css +++ b/src/index.css @@ -49,6 +49,19 @@ body { overflow-x: hidden; } +html.lenis, +html.lenis body { + height: auto; +} + +.lenis.lenis-stopped { + overflow: hidden; +} + +.lenis.lenis-smooth iframe { + pointer-events: none; +} + code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; @@ -61,4 +74,4 @@ code { @font-face { font-family: 'Source Han Sans'; src: url('/public/ttf/SourceHanSansCN-Normal.otf'); -} \ No newline at end of file +} diff --git a/src/layouts/MainLayout.tsx b/src/layouts/MainLayout.tsx index 75e3fac..7e3adc7 100644 --- a/src/layouts/MainLayout.tsx +++ b/src/layouts/MainLayout.tsx @@ -1,5 +1,4 @@ -import { Outlet, useLocation } from "react-router-dom"; -import { useEffect } from "react"; +import { Outlet } from "react-router-dom"; import Header from "./Header"; import Footer from "./Footer"; import useHashScroll from "@/hooks/useHashScroll"; @@ -7,11 +6,6 @@ import { AliveScope } from "react-activation"; export default function MainLayout() { useHashScroll() - const { pathname } = useLocation(); - - useEffect(() => { - window.scrollTo(0, 0); - }, [pathname]); return (
diff --git a/src/pages/About/History.tsx b/src/pages/About/History.tsx index 1710b53..2adb201 100644 --- a/src/pages/About/History.tsx +++ b/src/pages/About/History.tsx @@ -5,6 +5,7 @@ import { useState, useRef, useLayoutEffect, useEffect, useMemo } from "react"; import { useStore } from "@/store"; import appApi from "@/api/app"; import ScrollReveal from "@/components/ScrollReveal"; +import { scrollToWithLenis } from "@/utils/lenis"; type TimelineItem = { year: number; content: string, lang: string }; export default function AboutHistory() { @@ -18,7 +19,12 @@ export default function AboutHistory() { setYear(year); const yearEl = document.querySelector(`#year-${year}`); if (yearEl) { - yearEl.scrollIntoView({ behavior: "smooth" }); + const header = document.querySelector("header"); + const headerHeight = header?.getBoundingClientRect().height ?? 7.5 * 16; + scrollToWithLenis(yearEl as HTMLElement, { + offset: -headerHeight, + duration: 1, + }); } }; diff --git a/src/utils/lenis.ts b/src/utils/lenis.ts new file mode 100644 index 0000000..a177253 --- /dev/null +++ b/src/utils/lenis.ts @@ -0,0 +1,72 @@ +import Lenis from "lenis"; + +let lenisInstance: Lenis | null = null; +let rafId: number | null = null; + +type ScrollTarget = number | string | HTMLElement; +type ScrollOptions = { + offset?: number; + immediate?: boolean; + duration?: number; +}; + +const isBrowser = typeof window !== "undefined"; + +const raf = (time: number) => { + lenisInstance?.raf(time); + rafId = window.requestAnimationFrame(raf); +}; + +const resolveTarget = (target: ScrollTarget): HTMLElement | number | null => { + if (typeof target === "number") return target; + if (target instanceof HTMLElement) return target; + if (typeof target === "string") return document.querySelector(target); + return null; +}; + +export const initLenis = () => { + if (!isBrowser || lenisInstance) return lenisInstance; + if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return null; + + lenisInstance = new Lenis({ + duration: 1.1, + smoothWheel: true, + wheelMultiplier: 0.95, + touchMultiplier: 1.1, + }); + + rafId = window.requestAnimationFrame(raf); + return lenisInstance; +}; + +export const destroyLenis = () => { + if (rafId !== null) { + window.cancelAnimationFrame(rafId); + rafId = null; + } + lenisInstance?.destroy(); + lenisInstance = null; +}; + +export const getLenis = () => lenisInstance; + +export const scrollToWithLenis = (target: ScrollTarget, options: ScrollOptions = {}) => { + const resolved = resolveTarget(target); + const { offset = 0 } = options; + const current = getLenis(); + + if (current && resolved !== null) { + current.scrollTo(resolved as never, options as never); + return; + } + + if (!isBrowser || resolved === null) return; + + if (typeof resolved === "number") { + window.scrollTo({ top: resolved + offset, behavior: "auto" }); + return; + } + + const top = resolved.getBoundingClientRect().top + window.scrollY + offset; + window.scrollTo({ top, behavior: "auto" }); +};