refactor(animation): replace GSAP with motion mini

This commit is contained in:
Supra4E8C
2026-02-28 15:47:03 +08:00
parent 62587c9a83
commit 37da584dd8
4 changed files with 290 additions and 158 deletions
+75 -7
View File
@@ -13,8 +13,8 @@
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
"gsap": "^3.14.2",
"i18next": "^25.7.1",
"motion": "^12.34.3",
"react": "^19.2.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.1",
@@ -3113,6 +3113,33 @@
"node": ">= 6"
}
},
"node_modules/framer-motion": {
"version": "12.34.3",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.34.3.tgz",
"integrity": "sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.34.3",
"motion-utils": "^12.29.2",
"tslib": "^2.4.0"
},
"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
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -3210,12 +3237,6 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/gsap": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.14.2.tgz",
"integrity": "sha512-P8/mMxVLU7o4+55+1TCnQrPmgjPKnwkzkXOK1asnR9Jg2lna4tEY5qBJjMmAaOBDDZWtlRjBXjLa0w53G/uBLA==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/has-flag": {
"version": "4.0.0",
"dev": true,
@@ -3524,6 +3545,47 @@
"node": "*"
}
},
"node_modules/motion": {
"version": "12.34.3",
"resolved": "https://registry.npmjs.org/motion/-/motion-12.34.3.tgz",
"integrity": "sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw==",
"license": "MIT",
"dependencies": {
"framer-motion": "^12.34.3",
"tslib": "^2.4.0"
},
"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
}
}
},
"node_modules/motion-dom": {
"version": "12.34.3",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.34.3.tgz",
"integrity": "sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.29.2"
}
},
"node_modules/motion-utils": {
"version": "12.29.2",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.29.2.tgz",
"integrity": "sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==",
"license": "MIT"
},
"node_modules/ms": {
"version": "2.1.3",
"dev": true,
@@ -4025,6 +4087,12 @@
"node": ">=8.0"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/type-check": {
"version": "0.4.0",
"dev": true,
+1 -1
View File
@@ -17,8 +17,8 @@
"@uiw/react-codemirror": "^4.25.3",
"axios": "^1.13.2",
"chart.js": "^4.5.1",
"gsap": "^3.14.2",
"i18next": "^25.7.1",
"motion": "^12.34.3",
"react": "^19.2.1",
"react-chartjs-2": "^5.3.1",
"react-dom": "^19.2.1",
+139 -121
View File
@@ -1,12 +1,7 @@
import {
ReactNode,
useCallback,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { ReactNode, useCallback, useLayoutEffect, useRef, useState } from 'react';
import { useLocation, type Location } from 'react-router-dom';
import gsap from 'gsap';
import { animate } from 'motion/mini';
import type { AnimationPlaybackControlsWithThen } from 'motion-dom';
import { PageTransitionLayerContext, type LayerStatus } from './PageTransitionLayer';
import './PageTransition.scss';
@@ -25,6 +20,20 @@ const IOS_EXIT_TO_X_PERCENT_FORWARD = -30;
const IOS_EXIT_TO_X_PERCENT_BACKWARD = 100;
const IOS_ENTER_FROM_X_PERCENT_BACKWARD = -30;
const IOS_EXIT_DIM_OPACITY = 0.72;
const IOS_SHADOW_VALUE = '-14px 0 24px rgba(0, 0, 0, 0.16)';
const easePower2Out = (progress: number) => 1 - (1 - progress) ** 3;
const easeCircOut = (progress: number) => Math.sqrt(1 - (progress - 1) ** 2);
const buildVerticalTransform = (y: number) => `translate3d(0px, ${y}px, 0px)`;
const buildIosTransform = (xPercent: number, y: number) => `translate3d(${xPercent}%, ${y}px, 0px)`;
const clearLayerStyles = (element: HTMLElement | null) => {
if (!element) return;
element.style.removeProperty('transform');
element.style.removeProperty('opacity');
element.style.removeProperty('box-shadow');
};
type Layer = {
key: string;
@@ -150,16 +159,14 @@ export function PageTransition({
const targetIndex = prev.findIndex((layer) => layer.key === location.key);
if (targetIndex !== -1) {
const targetStack: Layer[] = prev
.slice(0, targetIndex + 1)
.map((layer, idx): Layer => {
const isTarget = idx === targetIndex;
return {
...layer,
location: isTarget ? location : layer.location,
status: isTarget ? 'current' : 'stacked',
};
});
const targetStack: Layer[] = prev.slice(0, targetIndex + 1).map((layer, idx): Layer => {
const isTarget = idx === targetIndex;
return {
...layer,
location: isTarget ? location : layer.location,
status: isTarget ? 'current' : 'stacked',
};
});
if (shouldSkipExitLayer) {
nextLayersRef.current = targetStack;
@@ -194,7 +201,7 @@ export function PageTransition({
layers,
]);
// Run GSAP animation when animating starts
// Run Motion animation when animating starts
useLayoutEffect(() => {
if (!isAnimating) return;
@@ -204,10 +211,8 @@ export function PageTransition({
const exitingLayerEl = exitingLayerRef.current;
const transitionVariant = transitionVariantRef.current;
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
clearLayerStyles(currentLayerEl);
clearLayerStyles(exitingLayerEl);
const scrollContainer = resolveScrollContainer();
const exitScrollOffset = exitScrollOffsetRef.current;
@@ -221,22 +226,21 @@ export function PageTransition({
const enterFromY = isForward ? VERTICAL_TRAVEL_DISTANCE : -VERTICAL_TRAVEL_DISTANCE;
const exitToY = isForward ? -VERTICAL_TRAVEL_DISTANCE : VERTICAL_TRAVEL_DISTANCE;
const exitBaseY = enterScrollOffset - exitScrollOffset;
const activeAnimations: AnimationPlaybackControlsWithThen[] = [];
let cancelled = false;
let completed = false;
const completeTransition = () => {
if (completed) return;
completed = true;
const tl = gsap.timeline({
onComplete: () => {
const nextLayers = nextLayersRef.current;
nextLayersRef.current = null;
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
setIsAnimating(false);
const nextLayers = nextLayersRef.current;
nextLayersRef.current = null;
setLayers((prev) => nextLayers ?? prev.filter((layer) => layer.status !== 'exiting'));
setIsAnimating(false);
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { clearProps: 'transform,opacity,boxShadow' });
}
},
});
clearLayerStyles(currentLayerEl);
clearLayerStyles(exitingLayerEl);
};
if (transitionVariant === 'ios') {
const exitToXPercent = isForward
@@ -247,90 +251,104 @@ export function PageTransition({
: IOS_ENTER_FROM_X_PERCENT_BACKWARD;
if (exitingLayerEl) {
gsap.set(exitingLayerEl, {
y: exitBaseY,
xPercent: 0,
opacity: 1,
});
exitingLayerEl.style.transform = buildIosTransform(0, exitBaseY);
exitingLayerEl.style.opacity = '1';
}
gsap.set(currentLayerEl, {
xPercent: enterFromXPercent,
opacity: 1,
});
const shadowValue = '-14px 0 24px rgba(0, 0, 0, 0.16)';
currentLayerEl.style.transform = buildIosTransform(enterFromXPercent, 0);
currentLayerEl.style.opacity = '1';
const topLayerEl = isForward ? currentLayerEl : exitingLayerEl;
if (topLayerEl) {
gsap.set(topLayerEl, { boxShadow: shadowValue });
topLayerEl.style.boxShadow = IOS_SHADOW_VALUE;
}
if (exitingLayerEl) {
tl.to(
exitingLayerEl,
{
xPercent: exitToXPercent,
opacity: isForward ? IOS_EXIT_DIM_OPACITY : 1,
duration: IOS_TRANSITION_DURATION,
ease: 'power2.out',
force3D: true,
},
0
activeAnimations.push(
animate(
exitingLayerEl,
{
transform: [
buildIosTransform(0, exitBaseY),
buildIosTransform(exitToXPercent, exitBaseY),
],
opacity: [1, isForward ? IOS_EXIT_DIM_OPACITY : 1],
},
{
duration: IOS_TRANSITION_DURATION,
ease: easePower2Out,
}
)
);
}
tl.to(
currentLayerEl,
{
xPercent: 0,
opacity: 1,
duration: IOS_TRANSITION_DURATION,
ease: 'power2.out',
force3D: true,
},
0
activeAnimations.push(
animate(
currentLayerEl,
{
transform: [buildIosTransform(enterFromXPercent, 0), buildIosTransform(0, 0)],
opacity: [1, 1],
},
{
duration: IOS_TRANSITION_DURATION,
ease: easePower2Out,
}
)
);
} else {
// Exit animation: fade out with slight movement (runs simultaneously)
if (exitingLayerEl) {
gsap.set(exitingLayerEl, { y: exitBaseY });
tl.to(
exitingLayerEl,
{
y: exitBaseY + exitToY,
opacity: 0,
duration: VERTICAL_TRANSITION_DURATION,
ease: 'circ.out',
force3D: true,
},
0
exitingLayerEl.style.transform = buildVerticalTransform(exitBaseY);
activeAnimations.push(
animate(
exitingLayerEl,
{
transform: [
buildVerticalTransform(exitBaseY),
buildVerticalTransform(exitBaseY + exitToY),
],
opacity: [1, 0],
},
{
duration: VERTICAL_TRANSITION_DURATION,
ease: easeCircOut,
}
)
);
}
// Enter animation: fade in with slight movement (runs simultaneously)
tl.fromTo(
currentLayerEl,
{ y: enterFromY, opacity: 0 },
{
y: 0,
opacity: 1,
duration: VERTICAL_TRANSITION_DURATION,
ease: 'circ.out',
force3D: true,
onComplete: () => {
if (currentLayerEl) {
gsap.set(currentLayerEl, { clearProps: 'transform,opacity' });
}
currentLayerEl.style.transform = buildVerticalTransform(enterFromY);
currentLayerEl.style.opacity = '0';
activeAnimations.push(
animate(
currentLayerEl,
{
transform: [buildVerticalTransform(enterFromY), buildVerticalTransform(0)],
opacity: [0, 1],
},
},
0
{
duration: VERTICAL_TRANSITION_DURATION,
ease: easeCircOut,
}
)
);
}
if (!activeAnimations.length) {
completeTransition();
} else {
void Promise.all(
activeAnimations.map((animation) => animation.finished.catch(() => undefined))
).then(() => {
if (cancelled) return;
completeTransition();
});
}
return () => {
tl.kill();
gsap.killTweensOf([currentLayerEl, exitingLayerEl]);
cancelled = true;
activeAnimations.forEach((animation) => animation.stop());
};
}, [isAnimating, resolveScrollContainer]);
@@ -348,30 +366,30 @@ export function PageTransition({
return layers.map((layer, index) => {
const shouldKeepStacked = layer.status === 'stacked' && index === keepStackedIndex;
return (
<div
key={layer.key}
className={[
'page-transition__layer',
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
]
.filter(Boolean)
.join(' ')}
aria-hidden={layer.status !== 'current'}
inert={layer.status !== 'current'}
ref={
layer.status === 'exiting'
? exitingLayerRef
: layer.status === 'current'
? currentLayerRef
: undefined
}
>
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
{render(layer.location)}
</PageTransitionLayerContext.Provider>
</div>
<div
key={layer.key}
className={[
'page-transition__layer',
layer.status === 'exiting' ? 'page-transition__layer--exit' : '',
layer.status === 'stacked' ? 'page-transition__layer--stacked' : '',
shouldKeepStacked ? 'page-transition__layer--stacked-keep' : '',
]
.filter(Boolean)
.join(' ')}
aria-hidden={layer.status !== 'current'}
inert={layer.status !== 'current'}
ref={
layer.status === 'exiting'
? exitingLayerRef
: layer.status === 'current'
? currentLayerRef
: undefined
}
>
<PageTransitionLayerContext.Provider value={{ status: layer.status }}>
{render(layer.location)}
</PageTransitionLayerContext.Provider>
</div>
);
});
})()}
+75 -29
View File
@@ -1,8 +1,17 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ChangeEvent } from 'react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
} from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import gsap from 'gsap';
import { animate } from 'motion/mini';
import type { AnimationPlaybackControlsWithThen } from 'motion-dom';
import { useInterval } from '@/hooks/useInterval';
import { useHeaderRefresh } from '@/hooks/useHeaderRefresh';
import { usePageTransitionLayer } from '@/components/common/PageTransitionLayer';
@@ -21,7 +30,7 @@ import {
isRuntimeOnlyAuthFile,
normalizeProviderKey,
type QuotaProviderType,
type ResolvedTheme
type ResolvedTheme,
} from '@/features/authFiles/constants';
import { AuthFileCard } from '@/features/authFiles/components/AuthFileCard';
import { AuthFileDetailModal } from '@/features/authFiles/components/AuthFileDetailModal';
@@ -40,6 +49,11 @@ import { useAuthStore, useNotificationStore, useThemeStore } from '@/stores';
import type { AuthFileItem } from '@/types';
import styles from './AuthFilesPage.module.scss';
const easePower3Out = (progress: number) => 1 - (1 - progress) ** 4;
const easePower2In = (progress: number) => progress ** 3;
const BATCH_BAR_BASE_TRANSFORM = 'translateX(-50%)';
const BATCH_BAR_HIDDEN_TRANSFORM = 'translateX(-50%) translateY(56px)';
export function AuthFilesPage() {
const { t } = useTranslation();
const showNotification = useNotificationStore((state) => state.showNotification);
@@ -59,6 +73,7 @@ export function AuthFilesPage() {
const [viewMode, setViewMode] = useState<'diagram' | 'list'>('list');
const [batchActionBarVisible, setBatchActionBarVisible] = useState(false);
const floatingBatchActionsRef = useRef<HTMLDivElement>(null);
const batchActionAnimationRef = useRef<AnimationPlaybackControlsWithThen | null>(null);
const previousSelectionCountRef = useRef(0);
const selectionCountRef = useRef(0);
@@ -85,7 +100,7 @@ export function AuthFilesPage() {
selectAllVisible,
deselectAll,
batchSetStatus,
batchDelete
batchDelete,
} = useAuthFilesData({ refreshKeyStats });
const statusBarCache = useAuthFilesStatusBarCache(files, usageDetails);
@@ -104,7 +119,7 @@ export function AuthFilesPage() {
handleDeleteLink,
handleToggleFork,
handleRenameAlias,
handleDeleteAlias
handleDeleteAlias,
} = useAuthFilesOauth({ viewMode, files });
const {
@@ -115,7 +130,7 @@ export function AuthFilesPage() {
modelsFileType,
modelsError,
showModels,
closeModelsModal
closeModelsModal,
} = useAuthFilesModels();
const {
@@ -125,11 +140,11 @@ export function AuthFilesPage() {
openPrefixProxyEditor,
closePrefixProxyEditor,
handlePrefixProxyChange,
handlePrefixProxySave
handlePrefixProxySave,
} = useAuthFilesPrefixProxyEditor({
disableControls: connectionStatus !== 'connected',
loadFiles,
loadKeyStats: refreshKeyStats
loadKeyStats: refreshKeyStats,
});
const disableControls = connectionStatus !== 'connected';
@@ -292,7 +307,7 @@ export function AuthFilesPage() {
}
const nextSearch = params.toString();
navigate(`/auth-files/oauth-excluded${nextSearch ? `?${nextSearch}` : ''}`, {
state: { fromAuthFiles: true }
state: { fromAuthFiles: true },
});
},
[filter, navigate]
@@ -307,7 +322,7 @@ export function AuthFilesPage() {
}
const nextSearch = params.toString();
navigate(`/auth-files/oauth-model-alias${nextSearch ? `?${nextSearch}` : ''}`, {
state: { fromAuthFiles: true }
state: { fromAuthFiles: true },
});
},
[filter, navigate]
@@ -354,31 +369,55 @@ export function AuthFilesPage() {
const actionsEl = floatingBatchActionsRef.current;
if (!actionsEl) return;
gsap.killTweensOf(actionsEl);
batchActionAnimationRef.current?.stop();
batchActionAnimationRef.current = null;
if (currentCount > 0 && previousCount === 0) {
gsap.fromTo(
batchActionAnimationRef.current = animate(
actionsEl,
{ y: 56, autoAlpha: 0 },
{ y: 0, autoAlpha: 1, duration: 0.28, ease: 'power3.out' }
{
transform: [BATCH_BAR_HIDDEN_TRANSFORM, BATCH_BAR_BASE_TRANSFORM],
opacity: [0, 1],
},
{
duration: 0.28,
ease: easePower3Out,
onComplete: () => {
actionsEl.style.transform = BATCH_BAR_BASE_TRANSFORM;
actionsEl.style.opacity = '1';
},
}
);
} else if (currentCount === 0 && previousCount > 0) {
gsap.to(actionsEl, {
y: 56,
autoAlpha: 0,
duration: 0.22,
ease: 'power2.in',
onComplete: () => {
if (selectionCountRef.current === 0) {
setBatchActionBarVisible(false);
}
batchActionAnimationRef.current = animate(
actionsEl,
{
transform: [BATCH_BAR_BASE_TRANSFORM, BATCH_BAR_HIDDEN_TRANSFORM],
opacity: [1, 0],
},
{
duration: 0.22,
ease: easePower2In,
onComplete: () => {
if (selectionCountRef.current === 0) {
setBatchActionBarVisible(false);
}
},
}
});
);
}
previousSelectionCountRef.current = currentCount;
}, [batchActionBarVisible, selectionCount]);
useEffect(
() => () => {
batchActionAnimationRef.current?.stop();
batchActionAnimationRef.current = null;
},
[]
);
const renderFilterTags = () => (
<div className={styles.filterTags}>
{existingTypes.map((type) => {
@@ -395,7 +434,7 @@ export function AuthFilesPage() {
style={{
backgroundColor: isActive ? color.text : color.bg,
color: isActive ? activeTextColor : color.text,
borderColor: color.text
borderColor: color.text,
}}
onClick={() => {
setFilter(type);
@@ -442,7 +481,9 @@ export function AuthFilesPage() {
<Button
variant="danger"
size="sm"
onClick={() => handleDeleteAll({ filter, onResetFilterToAll: () => setFilter('all') })}
onClick={() =>
handleDeleteAll({ filter, onResetFilterToAll: () => setFilter('all') })
}
disabled={disableControls || loading || deletingAll}
loading={deletingAll}
>
@@ -502,9 +543,14 @@ export function AuthFilesPage() {
{loading ? (
<div className={styles.hint}>{t('common.loading')}</div>
) : pageItems.length === 0 ? (
<EmptyState title={t('auth_files.search_empty_title')} description={t('auth_files.search_empty_desc')} />
<EmptyState
title={t('auth_files.search_empty_title')}
description={t('auth_files.search_empty_desc')}
/>
) : (
<div className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''}`}>
<div
className={`${styles.fileGrid} ${quotaFilterType ? styles.fileGridQuotaManaged : ''}`}
>
{pageItems.map((file) => (
<AuthFileCard
key={file.name}
@@ -543,7 +589,7 @@ export function AuthFilesPage() {
{t('auth_files.pagination_info', {
current: currentPage,
total: totalPages,
count: filtered.length
count: filtered.length,
})}
</div>
<Button