flnxi-Wu/lib/ui-animations.ts

346 lines
11 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 统一的动画工具类
* 面向后台工作台的低干扰动画与状态类
*/
// 基础动画类
export const animations = {
// 淡入淡出
fadeIn: 'animate-in fade-in duration-500',
fadeOut: 'animate-out fade-out duration-300',
fadeInUp: 'animate-in fade-in duration-300',
fadeInDown: 'animate-in fade-in duration-300',
fadeInLeft: 'animate-in fade-in duration-300',
fadeInRight: 'animate-in fade-in duration-300',
// 缩放
scaleIn: 'animate-in zoom-in-95 duration-300',
scaleOut: 'animate-out zoom-out-95 duration-200',
scaleUp: 'active:scale-[0.99] transition-transform duration-150',
// 旋转
spin: 'animate-spin',
spinSlow: 'animate-spin-slow',
// 脉冲
pulse: 'animate-pulse',
pulseSlow: 'animate-pulse-slow',
// 弹跳
bounce: 'animate-bounce',
bounceIn: 'animate-in fade-in duration-300',
// 滑动
slideInFromLeft: 'animate-in fade-in duration-300',
slideInFromRight: 'animate-in fade-in duration-300',
slideInFromTop: 'animate-in fade-in duration-300',
slideInFromBottom: 'animate-in fade-in duration-300',
// 交错动画延迟
stagger: {
'0': 'delay-0',
'75': 'delay-75',
'100': 'delay-100',
'150': 'delay-150',
'200': 'delay-200',
'300': 'delay-300',
'500': 'delay-500',
},
} as const
// 背景与边框状态
export const gradientBackgrounds = {
appleLight: 'bg-slate-50 dark:bg-slate-900',
appleDark: 'bg-slate-900 dark:bg-slate-950',
// 兼容旧调用的普通后台背景(CSS linear-gradient 格式,用于内联样式)
heroBase: {
light: '#f8fafc',
dark: '#0f172a',
},
rainbow: 'bg-slate-100 dark:bg-slate-800',
rainbowSubtle: 'bg-slate-50 dark:bg-slate-900',
rainbowVibrant: 'bg-slate-100 dark:bg-slate-800',
neon: 'bg-slate-100 dark:bg-slate-800',
neonSubtle: 'bg-slate-50 dark:bg-slate-900',
metallic: 'bg-slate-100 dark:bg-slate-800',
glass: 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700',
glassStrong: 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700',
glassNav: 'bg-white dark:bg-slate-900 border-b border-slate-200 dark:border-slate-700',
glassNavLight: 'bg-white border-b border-slate-200',
glassNavDark: 'bg-slate-900 border-b border-slate-700',
} as const
// 阴影效果
export const glowEffects = {
glow: 'shadow-sm',
glowStrong: 'shadow-md',
glowRainbow: 'shadow-sm',
glowPink: 'shadow-sm',
glowPurple: 'shadow-sm',
glowBlue: 'shadow-sm',
glowCyan: 'shadow-sm',
glowHover: 'hover:shadow-sm transition-shadow duration-150',
glowHoverRainbow: 'hover:shadow-sm transition-shadow duration-150',
} as const
// 边框动画
export const borderAnimations = {
gradientBorder: 'border border-slate-200 dark:border-slate-700',
shimmer: 'relative overflow-hidden',
neon: 'border border-slate-300 dark:border-slate-600',
neonHover: 'hover:border-slate-400 dark:hover:border-slate-500 transition-colors duration-150',
} as const
// 文本动画
export const textAnimations = {
gradientText: 'text-slate-900 dark:text-slate-100',
gradientTextRainbow: 'text-slate-900 dark:text-slate-100',
shimmerText: 'text-slate-500 dark:text-slate-400',
glowText: 'text-slate-700 dark:text-slate-200',
glowTextRainbow: 'text-slate-700 dark:text-slate-200',
} as const
// 卡片动画
export const cardAnimations = {
// 悬停提升
lift: 'transition-colors duration-150',
liftStrong: 'transition-colors duration-150',
tilt: 'transition-colors duration-150',
// 闪烁效果
shimmer: 'relative overflow-hidden',
// 光晕跟随鼠标
glowFollow: 'transition-colors duration-150',
} as const
// 按钮动画
export const buttonAnimations = {
// 基础按钮动画
base: 'transition-colors duration-150',
rainbow: 'bg-slate-900 text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white transition-colors duration-150',
neon: 'bg-slate-900 text-white hover:bg-slate-800 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-white transition-colors duration-150',
glass: 'bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors duration-150',
pulse: 'transition-colors duration-150',
} as const
// 组合动画工具函数
export function combineAnimations(...animations: string[]): string {
return animations.filter(Boolean).join(' ')
}
// 条件动画
export function conditionalAnimation(condition: boolean, animation: string, fallback = ''): string {
return condition ? animation : fallback
}
// 滚动动画
export const scrollAnimations = {
// 平滑滚动
smoothScroll: 'scroll-smooth',
// 滚动时淡入淡出
fadeOnScroll: 'transition-opacity duration-300 ease-out',
// 滚动时缩放
scaleOnScroll: 'transition-transform duration-300 ease-out',
// 滚动时位移
translateOnScroll: 'transition-transform duration-300 ease-out',
spring: 'transition-colors duration-150 ease-out',
springSmooth: 'transition-colors duration-150 ease-out',
// 滚动时隐藏/显示
hideOnScroll: 'transition-all duration-300 ease-out opacity-0 -translate-y-2',
showOnScroll: 'transition-all duration-300 ease-out opacity-100 translate-y-0',
elasticScroll: 'scroll-smooth',
} as const
// 平滑的弹性缓动函数(减少弹性效果,更柔和)
function easeOutElastic(t: number): number {
if (t === 0) return 0
if (t === 1) return 1
// 进一步减少弹性效果:降低振幅和频率
const c4 = (2 * Math.PI) / 3
// 使用更小的衰减系数和更低的频率,让动画更平滑
return Math.pow(2, -6 * t) * Math.sin((t * 4 - 0.3) * c4) + 1
}
function easeInOutCubic(t: number): number {
return t < 0.5
? 4 * t * t * t
: 1 - Math.pow(-2 * t + 2, 3) / 2
}
export function scrollToElementWithSpring(
element: HTMLElement | null,
container: HTMLElement | null = null,
options: {
offset?: number
duration?: number
retryCount?: number
easing?: 'elastic' | 'ease-in-out'
} = {}
): void {
if (!element) return
const {
offset = 24,
duration = 600,
retryCount = 0,
easing = 'elastic',
} = options
// 如果没有提供容器,尝试查找滚动容器
let targetContainer = container || element.closest('[data-scroll-container]') as HTMLElement
// 如果仍然没有找到容器,使用页面级别的滚动(window)
if (!targetContainer) {
// 使用 window 作为滚动容器,需要特殊处理
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const elementRect = element.getBoundingClientRect()
const startScrollTop = window.pageYOffset || document.documentElement.scrollTop
const targetScrollTop = startScrollTop + elementRect.top - offset
const maxScrollTop = Math.max(0, document.documentElement.scrollHeight - window.innerHeight)
const clampedTargetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop))
const scrollDifference = Math.abs(clampedTargetScrollTop - startScrollTop)
if (scrollDifference < 1) {
return
}
const startTime = performance.now()
const easingFunc = easing === 'ease-in-out' ? easeInOutCubic : easeOutElastic
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
const eased = easingFunc(progress)
const currentScrollTop = startScrollTop + (clampedTargetScrollTop - startScrollTop) * eased
window.scrollTo(0, currentScrollTop)
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
})
})
return
}
// 使用双重 requestAnimationFrame 确保 DOM 完全更新
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const startScrollTop = targetContainer.scrollTop
// 使用 getBoundingClientRect 计算元素相对于容器的位置
const containerRect = targetContainer.getBoundingClientRect()
const elementRect = element.getBoundingClientRect()
// 元素相对于容器可视区域顶部的位置
const elementTopRelativeToViewport = elementRect.top
const containerTopRelativeToViewport = containerRect.top
const elementTopRelativeToContainer = elementTopRelativeToViewport - containerTopRelativeToViewport
// 计算目标滚动位置:当前滚动位置 + 元素在可视区域的位置 - offset
const targetScrollTop = startScrollTop + elementTopRelativeToContainer - offset
// 确保目标位置在有效范围内
const maxScrollTop = Math.max(0, targetContainer.scrollHeight - targetContainer.clientHeight)
console.log('[scrollToElementWithSpring] Scroll calculation:', {
startScrollTop,
elementTopRelativeToContainer,
targetScrollTop,
maxScrollTop,
scrollHeight: targetContainer.scrollHeight,
clientHeight: targetContainer.clientHeight,
offset,
retryCount
})
// 如果容器不可滚动(maxScrollTop === 0),但目标位置需要滚动(targetScrollTop > 0)
// 说明容器还没有完全渲染,需要延迟重试
if (maxScrollTop === 0 && targetScrollTop > 0 && retryCount < 5) {
console.log('[scrollToElementWithSpring] Container not ready, retrying...', { retryCount })
setTimeout(() => {
scrollToElementWithSpring(element, container, { ...options, retryCount: retryCount + 1 })
}, 150)
return
}
// 如果重试多次后仍然不可滚动,使用 scrollIntoView 作为后备
if (maxScrollTop === 0 && targetScrollTop > 0) {
console.log('[scrollToElementWithSpring] Container not scrollable after retries, using scrollIntoView fallback')
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
return
}
const clampedTargetScrollTop = Math.max(0, Math.min(targetScrollTop, maxScrollTop))
// 如果已经在目标位置,跳过动画
const scrollDifference = Math.abs(clampedTargetScrollTop - startScrollTop)
if (scrollDifference < 1) {
console.log('[scrollToElementWithSpring] Already at target position, skipping animation')
return
}
console.log('[scrollToElementWithSpring] Starting animation', {
from: startScrollTop,
to: clampedTargetScrollTop,
difference: scrollDifference
})
// 开始弹性滚动动画
const startTime = performance.now()
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// 根据选项选择缓动函数
const eased = easing === 'ease-in-out'
? easeInOutCubic(progress)
: easeOutElastic(progress)
const currentScrollTop = startScrollTop + (clampedTargetScrollTop - startScrollTop) * eased
targetContainer.scrollTop = currentScrollTop
if (progress < 1) {
requestAnimationFrame(animate)
} else {
console.log('[scrollToElementWithSpring] Animation completed')
}
}
requestAnimationFrame(animate)
})
})
}