Files
shanghai-residences/src/components/animation/FlyToAnimationManager.tsx
T
zhongyidian 76c67b492e feat: 更新 ESLint 和 Prettier 配置,增强代码质量管理
- 在 .gitignore 中添加 public/audio/* 以忽略音频文件。
- 更新 bun.lock,添加 eslint-config-prettier 和 eslint-plugin-prettier 依赖,确保 ESLint 与 Prettier 的兼容性。
- 修改 eslint.config.ts,整合 Prettier 规则,优化代码风格一致性。
- 在 package.json 中添加 lint:fix 和 format 脚本,简化代码格式化和修复流程。
- 更新 tailwind.config.ts 和其他组件文件,统一代码风格,提升可读性。
2026-03-05 13:50:47 +08:00

239 lines
6.7 KiB
TypeScript

import { createRoot, Root } from 'react-dom/client'
import { motion } from 'framer-motion'
import React from 'react'
export type FlyToPosition = 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
export interface FlyToAnimationOptions {
/** 起始元素的 ref */
fromRef: React.RefObject<HTMLElement | null>
/** 目标元素的 ref */
toRef: React.RefObject<HTMLElement | null>
/** 目标位置(相对于目标元素) */
targetPosition?: FlyToPosition
/** 动画持续时间(毫秒) */
duration?: number
/** 动画缓动函数 */
ease?: string | number[]
/** 动画完成回调 */
onComplete?: () => void
/** 自定义样式 */
className?: string
/** 缩放比例(从起始到目标) */
scale?: { from: number; to: number }
/** 透明度(从起始到目标) */
opacity?: number[]
}
interface AnimationInstance extends FlyToAnimationOptions {
id: string
startPos: { x: number; y: number }
targetPos: { x: number; y: number }
elementHtml: string
}
class FlyToAnimationManagerClass {
private animations: Map<string, AnimationInstance> = new Map()
private containerRoot: Root | null = null
// 初始化容器
private initContainer() {
if (typeof window === 'undefined') return
if (this.containerRoot) return
const container = document.createElement('div')
container.id = 'fly-to-animation-container'
document.body.appendChild(container)
this.containerRoot = createRoot(container)
this.render()
}
// 渲染动画容器
private render() {
if (!this.containerRoot) return
const animations = Array.from(this.animations.values())
this.containerRoot.render(
<>
{animations.map((animation) => (
<FlyToAnimationItem key={animation.id} animation={animation} />
))}
</>
)
}
// 通知更新
private notify() {
this.render()
}
// 创建动画
flyTo(options: FlyToAnimationOptions) {
const { fromRef, toRef } = options
if (!fromRef?.current || !toRef?.current) {
console.warn('FlyToAnimation: fromRef 或 toRef 未设置')
return
}
this.initContainer()
// 使用 requestAnimationFrame 确保在下一帧获取准确的位置
requestAnimationFrame(() => {
const fromRect = fromRef.current!.getBoundingClientRect()
const toRect = toRef.current!.getBoundingClientRect()
// 计算起始位置(起始元素的中心)
const startX = fromRect.left + fromRect.width / 2
const startY = fromRect.top + fromRect.height / 2
// 计算目标位置
const targetPosition = options.targetPosition || 'center'
let targetX = 0
let targetY = 0
switch (targetPosition) {
case 'center':
targetX = toRect.left + toRect.width / 2
targetY = toRect.top + toRect.height / 2
break
case 'top-left':
targetX = toRect.left
targetY = toRect.top
break
case 'top-right':
targetX = toRect.right
targetY = toRect.top
break
case 'bottom-left':
targetX = toRect.left
targetY = toRect.bottom
break
case 'bottom-right':
targetX = toRect.right
targetY = toRect.bottom
break
}
// 克隆起始元素的 HTML 内容和样式
// 使用 cloneNode 深度克隆,包括所有子元素
const clonedElement = fromRef.current!.cloneNode(true) as HTMLElement
// 复制计算后的样式到克隆元素,确保尺寸正确
const computedStyle = window.getComputedStyle(fromRef.current!)
// 复制所有样式属性
const allStyles = Array.from(computedStyle)
allStyles.forEach((prop) => {
const value = computedStyle.getPropertyValue(prop)
if (value) {
clonedElement.style.setProperty(prop, value)
}
})
// 确保克隆元素的尺寸与原始元素一致
clonedElement.style.width = `${fromRect.width}px`
clonedElement.style.height = `${fromRect.height}px`
clonedElement.style.position = 'absolute'
clonedElement.style.left = '0'
clonedElement.style.top = '0'
clonedElement.style.margin = '0'
clonedElement.style.padding = '0'
// 获取克隆后的 HTML(只获取内部内容,不包含外层元素)
const elementHtml = clonedElement.innerHTML
// 创建动画实例
const id = `fly-to-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`
const instance: AnimationInstance = {
id,
...options,
startPos: { x: startX, y: startY },
targetPos: { x: targetX, y: targetY },
elementHtml,
}
this.animations.set(id, instance)
this.notify()
// 动画完成后自动清理
const duration = options.duration || 1200
setTimeout(() => {
this.remove(id)
options.onComplete?.()
}, duration)
})
}
// 移除动画
remove(id: string) {
this.animations.delete(id)
this.notify()
}
}
// 创建全局单例
export const flyToAnimationManager = new FlyToAnimationManagerClass()
// 单个动画项组件
const FlyToAnimationItem = ({ animation }: { animation: AnimationInstance }) => {
const {
startPos,
targetPos,
elementHtml,
duration = 1200,
ease = 'easeInOut',
className = '',
scale = { from: 1, to: 0.3 },
opacity = [1, 0.8, 0],
} = animation
// 计算 opacity 的 times 数组
const opacityTimes = opacity.length > 1 ? opacity.map((_, i) => i / (opacity.length - 1)) : [0, 1]
// 如果 opacity 是数组,使用整个数组;否则使用单个值
const animateOpacity = opacity.length > 1 ? opacity : (opacity[opacity.length - 1] ?? 0)
return (
<motion.div
initial={{
x: startPos.x,
y: startPos.y,
scale: scale.from,
opacity: opacity[0] ?? 1,
}}
animate={{
x: targetPos.x,
y: targetPos.y,
scale: scale.to,
opacity: animateOpacity,
}}
transition={{
duration: duration / 1000,
ease: ease as any,
opacity:
opacity.length > 1
? {
times: opacityTimes,
duration: duration / 1000,
}
: {
duration: duration / 1000,
},
}}
className={`fixed pointer-events-none z-[99999] ${className}`}
style={{
left: 0,
top: 0,
}}
transformTemplate={({ x, y, scale }) =>
`translate(-50%, -50%) translateX(${x}) translateY(${y}) scale(${scale})`
}
>
<div dangerouslySetInnerHTML={{ __html: elementHtml }} style={{ pointerEvents: 'none' }} />
</motion.div>
)
}