feat:添加孙中山场景

This commit is contained in:
zhongyidian committed 2026-01-02 23:32:26 +08:00
1 parent bf4a98bf9f
commit cad38e43df
55 files changed
+1704 -44

No files matched your search

@@ -0,0 +1,245 @@
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);
}
});
// 获取克隆后的 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>
);
};
@@ -0,0 +1,23 @@
import { flyToAnimationManager } from "./FlyToAnimationManager";
import type { FlyToAnimationOptions } from "./FlyToAnimationManager";
/**
* 飞行动画函数
* 将一个元素从起始位置飞向目标元素
*
* @example
* ```tsx
* const fromRef = useRef<HTMLDivElement>(null);
* const toRef = useRef<HTMLDivElement>(null);
*
* flyToAnimation({
* fromRef,
* toRef,
* targetPosition: "center",
* onComplete: () => console.log("动画完成"),
* });
* ```
*/
export const flyToAnimation = (options: FlyToAnimationOptions) => {
flyToAnimationManager.flyTo(options);
};
+4
View File
@@ -0,0 +1,4 @@
// 函数式 API
export { flyToAnimation } from "./flyToAnimation";
export type { FlyToAnimationOptions } from "./FlyToAnimationManager";
export type { FlyToPosition } from "./FlyToAnimationManager";