From eb682dc75fefbe8e19c6bc601a78392a3defcbc5 Mon Sep 17 00:00:00 2001 From: hamster1963 <1410514192@qq.com> Date: Wed, 10 Jun 2026 13:23:10 +0800 Subject: [PATCH] refactor: remove framer-motion dependencies and implement custom active indicator for improved performance and animations --- package.json | 1 - pnpm-lock.yaml | 38 -------- src/components/GroupSwitch.tsx | 56 ++++++++---- src/components/Header.tsx | 21 ++--- src/components/MapTooltip.tsx | 94 +++++++++---------- src/components/NetworkChart.tsx | 56 ++++++++---- src/components/ServerDetailChart.tsx | 54 +++++++---- src/components/TabSwitch.tsx | 41 ++++++--- src/components/motion/framer-lazy-feature.ts | 1 - src/components/motion/motion-provider.tsx | 12 --- src/hooks/use-active-indicator.ts | 96 ++++++++++++++++++++ src/index.css | 47 ++++++++++ src/main.tsx | 59 ++++++------ 13 files changed, 359 insertions(+), 217 deletions(-) delete mode 100644 src/components/motion/framer-lazy-feature.ts delete mode 100644 src/components/motion/motion-provider.tsx create mode 100644 src/hooks/use-active-indicator.ts diff --git a/package.json b/package.json index 489dc97..deafb95 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,6 @@ "country-flag-icons": "1.6.17", "d3-geo": "3.1.1", "dayjs": "1.11.20", - "framer-motion": "12.40.0", "i18n-iso-countries": "7.14.0", "i18next": "26.2.0", "lucide-react": "1.16.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ad3aab3..b9bbdb4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,9 +83,6 @@ importers: dayjs: specifier: 1.11.20 version: 1.11.20 - framer-motion: - specifier: 12.40.0 - version: 12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6) i18n-iso-countries: specifier: 7.14.0 version: 7.14.0 @@ -1157,20 +1154,6 @@ packages: picomatch: optional: true - framer-motion@12.40.0: - resolution: {integrity: sha512-uaBd3qC1v3KQqBEjwTUd183K6PbS+j0yR9w9VmEOLWA/tnUcSn8Xa3uck7t4dgpDoUss8xQTcj8W2L07lrnLFg==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1302,12 +1285,6 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - motion-dom@12.40.0: - resolution: {integrity: sha512-HxU3ZaBwNPVQUBQf1xxgq+7JrPNZvjLVxgbpEZL7RrWJnsxOf0/OM+yrHG9ogLQ31Do/r57Oz2gQWPK+6q62mg==} - - motion-utils@12.39.0: - resolution: {integrity: sha512-8nadJAJjTtqRkmRF36FoJTrywK9nnFmnPwnSMyxaOCU7GDjN9RTMJIxx9De8ErM+vpPhMccr/6fo5WciyQLnMQ==} - nanoid@3.3.12: resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -2440,15 +2417,6 @@ snapshots: optionalDependencies: picomatch: 4.0.4 - framer-motion@12.40.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6): - dependencies: - motion-dom: 12.40.0 - motion-utils: 12.39.0 - tslib: 2.8.1 - optionalDependencies: - react: 19.2.6 - react-dom: 19.2.6(react@19.2.6) - fsevents@2.3.3: optional: true @@ -2537,12 +2505,6 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - motion-dom@12.40.0: - dependencies: - motion-utils: 12.39.0 - - motion-utils@12.39.0: {} - nanoid@3.3.12: {} picocolors@1.1.1: {} diff --git a/src/components/GroupSwitch.tsx b/src/components/GroupSwitch.tsx index b18af67..f898308 100644 --- a/src/components/GroupSwitch.tsx +++ b/src/components/GroupSwitch.tsx @@ -1,5 +1,5 @@ -import { m } from "framer-motion"; -import { createRef, useEffect, useRef } from "react"; +import { useEffect, useRef } from "react"; +import { useActiveIndicator } from "@/hooks/use-active-indicator"; import { cn } from "@/lib/utils"; export default function GroupSwitch({ @@ -17,7 +17,13 @@ export default function GroupSwitch({ : undefined; const scrollRef = useRef(null); - const tagRefs = useRef(tabs.map(() => createRef())); + const { + containerRef, + enableIndicatorAnimation, + indicator, + itemRefs, + setItemRef, + } = useActiveIndicator(tabs, currentTab); useEffect(() => { const container = scrollRef.current; @@ -50,16 +56,16 @@ export default function GroupSwitch({ }, [tabs, setCurrentTab]); useEffect(() => { - const currentTagRef = tagRefs.current[tabs.indexOf(currentTab)]; + const currentTagRef = itemRefs.current[tabs.indexOf(currentTab)]; - if (currentTagRef?.current) { - currentTagRef.current.scrollIntoView({ + if (currentTagRef) { + currentTagRef.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "center", }); } - }, [currentTab, tabs.indexOf]); + }, [currentTab, itemRefs, tabs]); if (tabs.length === 1 && tabs[0] === "All") { return null; @@ -71,18 +77,38 @@ export default function GroupSwitch({ className="scrollbar-hidden z-50 flex flex-col items-start overflow-x-scroll rounded-[50px]" >
+ {indicator && ( +
+ )} {tabs.map((tab: string, index: number) => (
setCurrentTab(tab)} + ref={setItemRef(index)} + onClick={() => { + if (currentTab !== tab) { + enableIndicatorAnimation(); + } + setCurrentTab(tab); + }} className={cn( "relative cursor-pointer rounded-3xl px-2.5 py-2 text-[13px] font-semibold transition-all duration-500 ease-in-out hover:text-stone-950 hover:dark:text-stone-50", currentTab === tab @@ -90,16 +116,6 @@ export default function GroupSwitch({ : "text-stone-400 dark:text-stone-500", )} > - {currentTab === tab && ( - - )}

{tab}

diff --git a/src/components/Header.tsx b/src/components/Header.tsx index a2acea7..95b48f8 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,5 +1,4 @@ import { useQuery } from "@tanstack/react-query"; -import { AnimatePresence, m } from "framer-motion"; import { ImageMinus } from "lucide-react"; import { DateTime } from "luxon"; import { useEffect, useRef, useState } from "react"; @@ -252,20 +251,12 @@ export function RefreshToast() { } return ( - - -
- -

{t("refreshing")}...

-
-
-
+
+
+ +

{t("refreshing")}...

+
+
); } diff --git a/src/components/MapTooltip.tsx b/src/components/MapTooltip.tsx index cbb90c3..bae2259 100644 --- a/src/components/MapTooltip.tsx +++ b/src/components/MapTooltip.tsx @@ -1,4 +1,3 @@ -import { AnimatePresence, m } from "framer-motion"; import { memo } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; @@ -12,58 +11,53 @@ const MapTooltip = memo(function MapTooltip() { if (!tooltipData) return null; return ( - - { + e.stopPropagation(); + }} + > +
+

+ {tooltipData.country === "China" + ? "Mainland China" + : tooltipData.country} +

+

+ {tooltipData.count} {t("map.Servers")} +

+
+
{ - e.stopPropagation(); + maxHeight: "200px", + overflowY: "auto", }} > -
-

- {tooltipData.country === "China" - ? "Mainland China" - : tooltipData.country} -

-

- {tooltipData.count} {t("map.Servers")} -

-
-
- {tooltipData.servers.map((server) => ( - - ))} -
- - + {tooltipData.servers.map((server) => ( + + ))} +
+
); }); diff --git a/src/components/NetworkChart.tsx b/src/components/NetworkChart.tsx index 166edd9..d3782fe 100644 --- a/src/components/NetworkChart.tsx +++ b/src/components/NetworkChart.tsx @@ -1,7 +1,6 @@ "use client"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; -import { m } from "framer-motion"; import * as React from "react"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; @@ -34,6 +33,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useActiveIndicator } from "@/hooks/use-active-indicator"; import { fetchLoginUser, fetchMonitor, @@ -250,11 +250,20 @@ export const NetworkChartClient = React.memo(function NetworkChart({ const [showPeriodLoading, setShowPeriodLoading] = React.useState(false); const loadingStartedAtRef = React.useRef(null); - const TIME_RANGE_OPTIONS: { value: MonitorPeriod; label: string }[] = [ - { value: "1d", label: t("monitor.period1d") }, - { value: "7d", label: t("monitor.period7d") }, - { value: "30d", label: t("monitor.period30d") }, - ]; + const TIME_RANGE_OPTIONS = useMemo<{ value: MonitorPeriod; label: string }[]>( + () => [ + { value: "1d", label: t("monitor.period1d") }, + { value: "7d", label: t("monitor.period7d") }, + { value: "30d", label: t("monitor.period30d") }, + ], + [t], + ); + const timeRangeValues = useMemo( + () => TIME_RANGE_OPTIONS.map((option) => option.value), + [TIME_RANGE_OPTIONS], + ); + const { containerRef, enableIndicatorAnimation, indicator, setItemRef } = + useActiveIndicator(timeRangeValues, period); React.useEffect(() => { let timeoutId: number | undefined; @@ -581,13 +590,33 @@ export const NetworkChartClient = React.memo(function NetworkChart({
-
- {TIME_RANGE_OPTIONS.map((option) => { +
+ {indicator && ( +
+ )} + {TIME_RANGE_OPTIONS.map((option, index) => { const isLocked = !isLogin && option.value !== "1d"; const optionItem = (
{ if (!isLocked) { + if (period !== option.value) { + enableIndicatorAnimation(); + } onPeriodChange(option.value); } }} @@ -599,17 +628,6 @@ export const NetworkChartClient = React.memo(function NetworkChart({ isLocked && "cursor-not-allowed opacity-40 grayscale", )} > - {period === option.value && ( - - )} {option.label}
); diff --git a/src/components/ServerDetailChart.tsx b/src/components/ServerDetailChart.tsx index 29eea49..21176ff 100644 --- a/src/components/ServerDetailChart.tsx +++ b/src/components/ServerDetailChart.tsx @@ -1,5 +1,4 @@ import { useQuery } from "@tanstack/react-query"; -import { m } from "framer-motion"; import { useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { @@ -24,6 +23,7 @@ import { TooltipProvider, TooltipTrigger, } from "@/components/ui/tooltip"; +import { useActiveIndicator } from "@/hooks/use-active-indicator"; import { useWebSocketContext } from "@/hooks/use-websocket-context"; import { formatBytes } from "@/lib/format"; import { @@ -106,17 +106,42 @@ function PeriodSelector({ }) { const { t } = useTranslation(); - const periods: { value: ChartPeriod; label: string }[] = [ - { value: "realtime", label: t("serverDetailChart.realtime") }, - { value: "1d", label: t("serverDetailChart.period1d") }, - { value: "7d", label: t("serverDetailChart.period7d") }, - { value: "30d", label: t("serverDetailChart.period30d") }, - ]; + const periods = useMemo<{ value: ChartPeriod; label: string }[]>( + () => [ + { value: "realtime", label: t("serverDetailChart.realtime") }, + { value: "1d", label: t("serverDetailChart.period1d") }, + { value: "7d", label: t("serverDetailChart.period7d") }, + { value: "30d", label: t("serverDetailChart.period30d") }, + ], + [t], + ); + const periodValues = useMemo( + () => periods.map((period) => period.value), + [periods], + ); + const { containerRef, enableIndicatorAnimation, indicator, setItemRef } = + useActiveIndicator(periodValues, selectedPeriod); return ( -
- {periods.map((period) => { +
+ {indicator && ( +
+ )} + {periods.map((period, index) => { const isHistoryPeriod = period.value !== "realtime"; const isLockedByTsdb = !isTsdbEnabled && isHistoryPeriod; // Only realtime and 1d are available for non-logged-in users @@ -129,8 +154,12 @@ function PeriodSelector({ const periodItem = (
{ if (!isLocked) { + if (selectedPeriod !== period.value) { + enableIndicatorAnimation(); + } onPeriodChange(period.value); } }} @@ -142,13 +171,6 @@ function PeriodSelector({ isLocked && "cursor-not-allowed opacity-40 grayscale", )} > - {selectedPeriod === period.value && ( - - )}
{period.value === "realtime" && ( diff --git a/src/components/TabSwitch.tsx b/src/components/TabSwitch.tsx index 1d1e168..6e52b5f 100644 --- a/src/components/TabSwitch.tsx +++ b/src/components/TabSwitch.tsx @@ -1,5 +1,5 @@ -import { m } from "framer-motion"; import { useTranslation } from "react-i18next"; +import { useActiveIndicator } from "@/hooks/use-active-indicator"; import { cn } from "@/lib/utils"; export default function TabSwitch({ @@ -12,6 +12,8 @@ export default function TabSwitch({ setCurrentTab: (tab: string) => void; }) { const { t } = useTranslation(); + const { containerRef, enableIndicatorAnimation, indicator, setItemRef } = + useActiveIndicator(tabs, currentTab); const customBackgroundImage = (window.CustomBackgroundImage as string) !== "" ? window.CustomBackgroundImage @@ -19,17 +21,38 @@ export default function TabSwitch({ return (
- {tabs.map((tab: string) => ( + {indicator && ( +
+ )} + {tabs.map((tab: string, index: number) => (
setCurrentTab(tab)} + ref={setItemRef(index)} + onClick={() => { + if (currentTab !== tab) { + enableIndicatorAnimation(); + } + setCurrentTab(tab); + }} className={cn( "relative cursor-pointer rounded-3xl px-2.5 py-2 text-[13px] font-semibold transition-all duration-500 ease-in-out hover:text-stone-950 hover:dark:text-stone-50", currentTab === tab @@ -37,16 +60,6 @@ export default function TabSwitch({ : "text-stone-400 dark:text-stone-500", )} > - {currentTab === tab && ( - - )}

{t(`tabSwitch.${tab}`)}

diff --git a/src/components/motion/framer-lazy-feature.ts b/src/components/motion/framer-lazy-feature.ts deleted file mode 100644 index a0bf32e..0000000 --- a/src/components/motion/framer-lazy-feature.ts +++ /dev/null @@ -1 +0,0 @@ -export { domMax as default } from "framer-motion"; diff --git a/src/components/motion/motion-provider.tsx b/src/components/motion/motion-provider.tsx deleted file mode 100644 index 7ba0b88..0000000 --- a/src/components/motion/motion-provider.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { LazyMotion } from "framer-motion"; - -const loadFeatures = () => - import("./framer-lazy-feature").then((res) => res.default); - -export const MotionProvider = ({ children }: { children: React.ReactNode }) => { - return ( - - {children} - - ); -}; diff --git a/src/hooks/use-active-indicator.ts b/src/hooks/use-active-indicator.ts new file mode 100644 index 0000000..a14cdfd --- /dev/null +++ b/src/hooks/use-active-indicator.ts @@ -0,0 +1,96 @@ +import { useCallback, useLayoutEffect, useRef, useState } from "react"; + +type ActiveIndicator = { + x: number; + y: number; + width: number; + height: number; + shouldAnimate: boolean; +}; + +export function useActiveIndicator(items: T[], activeItem: T) { + const containerRef = useRef(null); + const itemRefs = useRef<(HTMLDivElement | null)[]>([]); + const hasMeasuredRef = useRef(false); + const shouldAnimateNextRef = useRef(false); + const [indicator, setIndicator] = useState(null); + + const enableIndicatorAnimation = useCallback(() => { + shouldAnimateNextRef.current = true; + }, []); + + const setItemRef = useCallback( + (index: number) => (node: HTMLDivElement | null) => { + itemRefs.current[index] = node; + }, + [], + ); + + const updateIndicator = useCallback(() => { + const activeIndex = items.indexOf(activeItem); + const activeElement = itemRefs.current[activeIndex]; + + if (!activeElement || !containerRef.current) { + hasMeasuredRef.current = false; + shouldAnimateNextRef.current = false; + setIndicator((currentIndicator) => + currentIndicator === null ? currentIndicator : null, + ); + return; + } + + const nextIndicator = { + x: activeElement.offsetLeft, + y: activeElement.offsetTop, + width: activeElement.offsetWidth, + height: activeElement.offsetHeight, + shouldAnimate: hasMeasuredRef.current && shouldAnimateNextRef.current, + }; + hasMeasuredRef.current = true; + shouldAnimateNextRef.current = false; + + setIndicator((currentIndicator) => { + if ( + currentIndicator && + currentIndicator.x === nextIndicator.x && + currentIndicator.y === nextIndicator.y && + currentIndicator.width === nextIndicator.width && + currentIndicator.height === nextIndicator.height + ) { + return currentIndicator; + } + + return nextIndicator; + }); + }, [activeItem, items]); + + useLayoutEffect(() => { + updateIndicator(); + + const container = containerRef.current; + if (!container || typeof ResizeObserver === "undefined") { + return; + } + + const resizeObserver = new ResizeObserver(updateIndicator); + resizeObserver.observe(container); + + for (const item of itemRefs.current) { + if (item) { + resizeObserver.observe(item); + } + } + + return () => { + resizeObserver.disconnect(); + }; + }, [updateIndicator]); + + return { + containerRef, + enableIndicatorAnimation, + indicator, + itemRefs, + setItemRef, + }; +} diff --git a/src/index.css b/src/index.css index ae05441..fb1c456 100644 --- a/src/index.css +++ b/src/index.css @@ -334,6 +334,53 @@ display: none; /* Chrome, Safari 和 Opera */ } +.tooltip-animate { + opacity: 0; + filter: blur(5px); + animation: tooltip-fade-in 0.1s ease-in-out forwards; +} + +.refresh-toast-animate { + animation: refresh-toast-in 0.5s var(--timing) forwards; +} + +.active-indicator-fade-in { + animation: active-indicator-fade-in 0.18s var(--timing) forwards; +} + +@keyframes tooltip-fade-in { + from { + opacity: 0; + filter: blur(5px); + } + to { + opacity: 1; + filter: blur(0px); + } +} + +@keyframes active-indicator-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes refresh-toast-in { + from { + opacity: 0; + filter: blur(10px); + transform: translateX(-50%) scale(0.8); + } + to { + opacity: 1; + filter: blur(0px); + transform: translateX(-50%) scale(1); + } +} + /* Thanks to next.js. */ [data-issues-count-animation] { display: flex; diff --git a/src/main.tsx b/src/main.tsx index d991d8c..f328a2e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,7 +4,6 @@ import ReactDOM from "react-dom/client"; import { Toaster } from "sonner"; import App from "./App"; -import { MotionProvider } from "./components/motion/motion-provider"; import { ThemeColorManager } from "./components/ThemeColorManager"; import { ThemeProvider } from "./components/ThemeProvider"; import { CommandProvider } from "./context/command-provider"; @@ -23,34 +22,32 @@ if (!rootElement) { } ReactDOM.createRoot(rootElement).render( - - - - - - - - - - - - - - - - - - - - , + + + + + + + + + + + + + + + + + + , );