feat: 添加 Leafer 动画

This commit is contained in:
2026-06-27 15:15:24 +08:00
parent 82236dafe6
commit b7805a0966
6 changed files with 128 additions and 15 deletions
+2
View File
@@ -10,6 +10,8 @@
"preview": "vite preview"
},
"dependencies": {
"@leafer-in/animate": "^2.1.8",
"@leafer-in/color": "^2.1.8",
"@leafer-ui/core": "^2.1.8",
"@leafer-ui/draw": "^2.1.8",
"@tailwindcss/vite": "^4.3.1",
+34
View File
@@ -8,6 +8,12 @@ importers:
.:
dependencies:
'@leafer-in/animate':
specifier: ^2.1.8
version: 2.1.8(@leafer-in/color@2.1.8(@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8))(@leafer-ui/draw@2.1.8)(@leafer-ui/interface@2.1.8))(@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8))(@leafer-ui/draw@2.1.8)(@leafer-ui/interface@2.1.8)
'@leafer-in/color':
specifier: ^2.1.8
version: 2.1.8(@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8))(@leafer-ui/draw@2.1.8)(@leafer-ui/interface@2.1.8)
'@leafer-ui/core':
specifier: ^2.1.8
version: 2.1.8
@@ -220,6 +226,21 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@leafer-in/animate@2.1.8':
resolution: {integrity: sha512-vxlmuzOet+yV0N6COlv9SnhvbXVbdGwOua2/T2HBKwiQ4HIjdKdVzezlU2ZBwoytM/CsOlGEWxYeW/Ajrg/8hA==}
peerDependencies:
'@leafer-in/color': ^2.1.8
'@leafer-in/interface': ^2.1.8
'@leafer-ui/draw': ^2.1.8
'@leafer-ui/interface': ^2.1.8
'@leafer-in/color@2.1.8':
resolution: {integrity: sha512-Nf3IsQkBcErEs71zsEBkR0IRQH+2yl2e5AX+o85DGi8DjJyTD0J3mO+QfeoRmKKHfRZWzfapMrpU25yfJFP+zA==}
peerDependencies:
'@leafer-in/interface': ^2.1.8
'@leafer-ui/draw': ^2.1.8
'@leafer-ui/interface': ^2.1.8
'@leafer-in/interface@2.1.8':
resolution: {integrity: sha512-+sjo7e+orseQs6+q7D0DDu31J6jMjl6Ei+M0nqznO8ln5kDYazaOLomb2OmG72WGFqhd28kOxoa65eurjS3P3w==}
peerDependencies:
@@ -1392,6 +1413,19 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@leafer-in/animate@2.1.8(@leafer-in/color@2.1.8(@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8))(@leafer-ui/draw@2.1.8)(@leafer-ui/interface@2.1.8))(@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8))(@leafer-ui/draw@2.1.8)(@leafer-ui/interface@2.1.8)':
dependencies:
'@leafer-in/color': 2.1.8(@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8))(@leafer-ui/draw@2.1.8)(@leafer-ui/interface@2.1.8)
'@leafer-in/interface': 2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8)
'@leafer-ui/draw': 2.1.8
'@leafer-ui/interface': 2.1.8
'@leafer-in/color@2.1.8(@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8))(@leafer-ui/draw@2.1.8)(@leafer-ui/interface@2.1.8)':
dependencies:
'@leafer-in/interface': 2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8)
'@leafer-ui/draw': 2.1.8
'@leafer-ui/interface': 2.1.8
'@leafer-in/interface@2.1.8(@leafer-ui/interface@2.1.8)(@leafer/interface@2.1.8)':
dependencies:
'@leafer-ui/interface': 2.1.8
+51 -2
View File
@@ -1,8 +1,16 @@
import { useCallback, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { Box, Text } from "leafer-ui";
import { InfiniteCanvas } from "./components/InfiniteCanvas";
import type { VisibleCard } from "./components/InfiniteCanvas";
// 入场动画配置(单位:秒)。Leafer 的 animate() 时间以「秒」计(内部按 elapsedMs/1000
// 累加并与 duration 比较),不是毫秒——传毫秒会把动画排到几百秒后,卡片一直不显示
// 时长预算 ≤1s:基础延迟随“离屏幕中心的距离”增大,再加少量随机抖动
const ANIMATION_SEC = 0.7; // 总时长预算
const FADE_SEC = 0.26; // 单张淡入时长
const SPREAD_SEC = 0.44; // 中心→四周的最大延迟(距离主导)
const JITTER_SEC = 0.16; // 每张的随机抖动
interface Card {
id: number;
color: string;
@@ -41,14 +49,28 @@ function App() {
setVisible(v);
}, []);
// 入场动画:进入页面后 ANIMATION_SEC 秒内完成;完成前禁用拖拽
const [ready, setReady] = useState(false);
const entranceRef = useRef(true); // 仅首屏卡片走淡入;之后(如 resize 重建)直接可见
useEffect(() => {
const t = setTimeout(() => {
entranceRef.current = false;
setReady(true);
}, ANIMATION_SEC * 1000);
return () => clearTimeout(t);
}, []);
// renderCard 用第三参 size 绘制,保证卡片随响应式尺寸缩放
const renderCard = useCallback(
(item: Card, _index: number, size: { width: number; height: number }) => {
return new Box({
const entering = entranceRef.current;
const node = new Box({
width: size.width,
height: size.height,
fill: item.color,
cornerRadius: 16,
opacity: entering ? 0 : 1,
children: [
new Text({
width: size.width,
@@ -62,6 +84,32 @@ function App() {
})
]
});
// 位置由布局在 renderCard 返回后同步赋值,故延后一帧再按“离中心距离”算延迟
if (entering) {
requestAnimationFrame(() => {
if (node.destroyed) return;
const cx = window.innerWidth / 2;
const cy = window.innerHeight / 2;
const nodeCx = (node.x ?? 0) + size.width / 2;
const nodeCy = (node.y ?? 0) + size.height / 2;
const dist = Math.hypot(nodeCx - cx, nodeCy - cy);
const maxDist = Math.hypot(cx, cy) || 1; // 中心到角的距离,用于归一化
const t = Math.min(1, dist / maxDist); // 0=中心 1=最远
const delay = t * SPREAD_SEC + Math.random() * JITTER_SEC; // 单位:秒
// 从节点当前 opacity(0) 补间到 1。duration/delay 均为「秒」
node.animate(
{ opacity: 1 },
{
duration: FADE_SEC,
delay,
easing: "ease-out"
}
);
});
}
return node;
},
[]
);
@@ -77,6 +125,7 @@ function App() {
gapX={32}
gapY={32}
friction={0.97}
draggable={ready}
background="#171717"
onCardClick={(item) => console.log("clicked", item)}
onVisibleCardsChange={handleVisibleChange}
@@ -61,7 +61,7 @@ export function useInfiniteSlide<T>(
const container = containerRef.current;
if (!container || items.length === 0) return;
// —— 创建画布;禁用内置平移/缩放,改用自定义拖拽以实现无限环绕 ——
// 创建画布;禁用内置平移/缩放,改用自定义拖拽以实现无限环绕
const leafer = new Leafer({
view: container,
fill: background,
@@ -70,7 +70,7 @@ export function useInfiniteSlide<T>(
wheel: { disabled: true }
});
// —— 当前网格状态(由 layout 重建,闭包内拖拽/环绕逻辑共享)——
// 当前网格状态(由 layout 重建,闭包内拖拽/环绕逻辑共享)
let nodes: IUI[] = [];
// 与 nodes 下标对齐的元信息:每个节点对应的原始 item 与其在 items 中的下标
let meta: { item: T; index: number }[] = [];
@@ -82,17 +82,25 @@ export function useInfiniteSlide<T>(
let viewH = 0;
let lastSig = ""; // 上次布局签名,用于去重避免无谓重建
/**
* 解析响应式配置值:传函数则按当前容器尺寸计算,返回正数则用,否则用默认值
* @param v 响应式配置值
* @param de 默认值
* @param w 容器宽
* @param h 容器高
* @returns 解析后的数值(正数)
*/
const resolve = (
v: Responsive<number> | undefined,
dflt: number,
de: number,
w: number,
h: number
): number => {
const r = typeof v === "function" ? v({ width: w, height: h }) : v;
return typeof r === "number" && r > 0 ? r : dflt;
return typeof r === "number" && r > 0 ? r : de;
};
// —— 可视区域扫描(rAF 合帧 + 签名去重,空闲零开销)——
// 可视区域扫描(rAF 合帧 + 签名去重,空闲零开销)
let visRaf = 0; // 待执行的扫描帧句柄(0 表示无)
let lastVisSig = ""; // 上次回调的结果签名,用于去重
@@ -226,7 +234,7 @@ export function useInfiniteSlide<T>(
layout();
layoutRef.current = layout;
// —— 平移 + 取模环绕(连续 wrap,消除阈值处 1px teleport 闪烁)——
// 平移 + 取模环绕(连续 wrap,消除阈值处 1px teleport 闪烁)
// 把 v 规整到 [-size, span - size) 区间:屏幕任意位置都被覆盖
const wrap = (v: number, span: number, size: number) => {
const lo = -size;
@@ -240,7 +248,7 @@ export function useInfiniteSlide<T>(
scheduleVisible();
};
// —— 拖拽状态 ——
// 拖拽状态
let dragging = false;
let lastX = 0;
let lastY = 0;
@@ -248,7 +256,7 @@ export function useInfiniteSlide<T>(
let vy = 0;
let raf = 0;
// 停止惯性滑动(取消 requestAnimationFrame——
// 停止惯性滑动(取消 requestAnimationFrame
const stopInertia = () => {
if (raf) {
cancelAnimationFrame(raf);
@@ -256,7 +264,7 @@ export function useInfiniteSlide<T>(
}
};
// 惯性滑动(每帧按 friction 衰减,低于阈值停止)——
// 惯性滑动(每帧按 friction 衰减,低于阈值停止)
const startInertia = () => {
const MIN = 0.1;
const step = () => {
@@ -272,7 +280,7 @@ export function useInfiniteSlide<T>(
raf = requestAnimationFrame(step);
};
// 拖拽事件处理(PointerEvent——
// 拖拽事件处理(PointerEvent
const onDown = (e: PointerEvent) => {
if (!draggableRef.current) return;
stopInertia();
@@ -281,6 +289,7 @@ export function useInfiniteSlide<T>(
lastY = e.y;
vx = 0;
vy = 0;
container.style.cursor = "grabbing"; // 按下:抓握手势
};
const onMove = (e: PointerEvent) => {
@@ -297,6 +306,7 @@ export function useInfiniteSlide<T>(
const onUp = () => {
if (!dragging) return;
dragging = false;
container.style.cursor = "grab"; // 松开:恢复可抓握手势
if (inertiaRef.current && (Math.abs(vx) > 0.5 || Math.abs(vy) > 0.5)) {
startInertia();
}
@@ -306,11 +316,11 @@ export function useInfiniteSlide<T>(
leafer.on(PointerEvent.MOVE, onMove);
leafer.on(PointerEvent.UP, onUp);
// —— 容器尺寸变化时重新布局(解析值/平铺规模变了才真重建,由签名去重)——
// 容器尺寸变化时重新布局(解析值/平铺规模变了才真重建,由签名去重)
const ro = new ResizeObserver(() => layout());
ro.observe(container);
// —— 清理(StrictMode 双调用安全)——
// 清理(StrictMode 双调用安全)
return () => {
layoutRef.current = undefined;
ro.disconnect();
+3
View File
@@ -1,5 +1,8 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
// Leafer 插件按副作用导入:把 animate()/颜色能力挂到节点上(仅安装不导入不生效)
import "@leafer-in/animate";
import "@leafer-in/color";
import "./index.css";
import App from "./App.tsx";
+16 -1
View File
@@ -2,7 +2,22 @@ import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
// Leafer 插件(@leafer-in/*)通过给 @leafer-ui/draw 的 UI 类打补丁来挂载 animate() 等方法。
// 若 Vite 把 leafer-ui 与各插件分别预打包,会各自内联一份 @leafer-ui/draw
// 导致插件补丁打在 A 份、而 Box/Text 继承自 B 份 → animate() 不生效、报“请安装插件”。
// dedupe + optimizeDeps.include 强制所有 leafer 包共享同一份 @leafer-ui/draw。
const leaferPkgs = [
"leafer-ui",
"@leafer-ui/draw",
"@leafer-ui/core",
"@leafer/core",
"@leafer-in/animate",
"@leafer-in/color"
];
// https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()]
plugins: [react(), tailwindcss()],
resolve: { dedupe: leaferPkgs },
optimizeDeps: { include: leaferPkgs }
});