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 /** 目标元素的 ref */ toRef: React.RefObject /** 目标位置(相对于目标元素) */ 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 = 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) => ( ))} ) } // 通知更新 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 ( 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})` } >
) }