feat: 添加可视区域回调

This commit is contained in:
2026-06-27 14:13:39 +08:00
parent 7a71d0d789
commit 82236dafe6
6 changed files with 270 additions and 46 deletions
+62
View File
@@ -0,0 +1,62 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
本文件为 Claude Code 在本仓库中工作提供指引。请使用中文回复。
## 项目概述
基于 [Leafer-UI](https://www.leaferjs.com/) 的 React 无限滑动画布组件(`InfiniteCanvas`)。卡片以网格平铺,支持四向无限环绕、拖拽平移、惯性滑动、响应式布局与点击回调。核心是一个可复用的组件库(`src/components/InfiniteCanvas`),`src/App.tsx` 仅是演示用例
技术栈:React 19 · TypeScript · Vite · Leafer-UI · Tailwind CSS v4 · pnpm
Leafer 文档查询:[https://context7.com/leaferjs/ai-docs/llms.txt?tokens=10000](https://context7.com/leaferjs/ai-docs/llms.txt?tokens=10000)
## 工作约定
- 每次改动尽量小,大改动需要先与用户确认
- 不要影响原有逻辑
- 不要过度封装代码
- 代码需要加上中文注释
- 必要时需要更新README.md文件,该文件主要是说明如何使用,不需要说明太多技术细节
## 常用命令
```bash
pnpm install
pnpm dev # 启动 Vite 开发服务器
pnpm build # 先 tsc -b 类型检查,再 vite build 打包
pnpm preview # 预览构建产物
pnpm lint # ESLint 检查全仓库
```
注意:`pnpm build` 会先执行 `tsc -b`,类型错误会中断构建。没有配置测试框架
## 架构
组件分三层,关注点分离清晰:
- **InfiniteCanvas.tsx** — 薄壳组件。只创建容器 `div`(默认填满父级、`cursor: grab``touchAction: none`)并把 `containerRef` 与 props 交给 hook。所有逻辑都不在这里
- **useInfiniteSlide.ts** — 全部滑动引擎逻辑所在。这是改动行为时唯一需要重点理解的文件
- **types.ts** — `InfiniteCanvasProps<T>``Responsive<V>` 类型定义。组件是泛型的,与卡片数据类型 `T` 强绑定
### 滑动引擎的关键设计(useInfiniteSlide
理解这些约束后再改动,否则容易引入画布重建或闪烁问题:
1. **双 effect + ref 模式**:结构性 effect 的依赖只有 `[containerRef, items, renderCard, background]`,这四者变化才会销毁并重建整个 Leafer 画布。其余易变的配置(`onCardClick` / `draggable` / `inertia` / `friction` / 布局配置)都存在 `ref` 中,由一个无依赖的 passive effect 持续同步最新值,避免触发画布重建。**新增 prop 时务必判断它该进依赖数组还是 ref**
2. **`renderCard` 必须引用稳定**:它在结构性 effect 依赖里,每次引用变化都会重建画布。调用方必须用 `useCallback` 包裹
3. **签名去重**`layout()` 会把解析后的布局值拼成 `sig` 字符串,与 `lastSig` 比对,无变化时直接 return,零开销。`ResizeObserver` 和布局配置变化都通过 `layoutRef.current?.()` 触发 `layout()`,靠签名去重避免无谓重建
4. **无限环绕用取模 `wrap`**:把每个节点坐标规整到 `[-size, span - size)` 区间,使屏幕任意位置都被卡片覆盖。平铺份数(repX/repY)保证总跨度 ≥ 视口 + 一个步距。连续 wrap(而非阈值跳变)消除 1px 闪烁
5. **Leafer 内置交互全部禁用**`move` / `zoom` / `wheel``disabled`),改用自定义 `PointerEvent.DOWN/MOVE/UP` 实现拖拽与无限环绕
6. **惯性滑动**:松手时记录最近帧速度 `vx/vy`,用 `requestAnimationFrame` 每帧按 `frictionRef` 衰减,低于阈值(0.1)停止
7. **点击命中用 `PointerEvent.TAP`**:Leafer 自带拖拽阈值,平移后不会误触发点击
8. **清理兼容 StrictMode 双调用**:return 的清理函数会断开 ResizeObserver、停止惯性、解绑事件、销毁画布并清空 `layoutRef`
### 重要不变量
`cardWidth` / `cardHeight` 同时用于网格定位、无限环绕命中判定**和**传给 `renderCard``size``renderCard` 实际绘制的尺寸必须与传入的 `size` 一致,否则环绕命中与视觉会错位。响应式场景下务必使用第三个参数 `size` 来绘制
## 响应式
布局类 props`columns` / `cardWidth` / `cardHeight` / `gapX` / `gapY`)均为 `Responsive<V>` 类型,即「固定值」或 `(size) => V` 函数。函数依容器宽高动态计算,由 hook 内的 `resolve()` 解析(返回非正数或非 number 时回退默认值)
+65 -40
View File
@@ -1,20 +1,18 @@
# InfiniteCanvas 无限滑动画布
基于 [Leafer-UI](https://www.leaferjs.com/) 的 React 无限滑动画布组件。卡片以网格平铺,支持**四向无限环绕**、**拖拽平移**、**惯性滑动**、**响应式布局**与**点击回调**。卡片内容通过 `renderCard` 插槽以 Leafer 节点形式自由绘制
基于 [Leafer-UI](https://www.leaferjs.com/) 的 React 无限滑动画布组件。卡片以网格平铺,支持四向无限环绕、拖拽惯性、响应式布局,并可监听可视区域内的卡片
## 特性
- **四向无限环绕**:上下左右任意方向都能无限滑动,卡片通过取模循环复用,无缝衔接。
- **拖拽 + 惯性**:鼠标/触摸拖拽平移,松手后按摩擦系数衰减惯性滑动。
- **响应式布局**`columns` / `cardWidth` / `cardHeight` / `gapX` / `gapY`可传函数,依容器尺寸动态计算;内部 `ResizeObserver` 监听并按布局签名去重,无变化零开销重建
- **自定义渲染**`renderCard` 返回任意 Leafer 节点(`Box` / `Image` / `Text` / `Rect` …),完全掌控卡片样式
- **点击命中**:基于 Leafer 的 `TAP` 事件(自带拖拽阈值),平移后不会误触发点击
- **泛型支持**`InfiniteCanvas<T>` 与数据类型强绑定,类型安全
- **四向无限环绕**任意方向无限滑动,卡片取模循环复用,无缝衔接。
- **拖拽 + 惯性**拖拽平移,松手`friction` 衰减滑动。
- **响应式布局** — 布局 props 可传函数,依容器尺寸动态计算;内部 `ResizeObserver` 监听并按签名去重,无变化零开销。
- **可视区域回调** — `onVisibleCardsChange` 实时拿到屏幕内的卡片及其可视度(0~1)
- **自定义渲染** — `renderCard` 返回任意 Leafer 节点,完全掌控卡片样式
- **泛型 + 类型安全** — `InfiniteCanvas<T>` 与数据类型强绑定。
## 安装
组件依赖 `leafer-ui``react`
```bash
pnpm add leafer-ui react react-dom
```
@@ -43,8 +41,8 @@ const items: Card[] = Array.from({ length: 50 }, (_, i) => ({
function App() {
// 用 useCallback 保持引用稳定,避免每次渲染重建画布
const renderCard = useCallback(
(item: Card, _index: number, size: { width: number; height: number }) => {
return new Box({
(item: Card, _index: number, size: { width: number; height: number }) =>
new Box({
width: size.width,
height: size.height,
fill: item.color,
@@ -56,13 +54,11 @@ function App() {
text: item.label,
fill: "#ffffff",
fontSize: Math.round(size.width * 0.11),
fontWeight: "bold",
textAlign: "center",
verticalAlign: "middle"
})
]
});
},
}),
[]
);
@@ -76,7 +72,6 @@ function App() {
cardHeight={250}
gapX={32}
gapY={32}
friction={0.97}
background="#171717"
onCardClick={(item) => console.log("clicked", item)}
/>
@@ -87,7 +82,7 @@ function App() {
## 响应式布局
布局相关 props 支持传入 `(size) => value` 函数,依容器宽高动态计算。例如按断点切换列数与卡片尺寸:
布局相关 props 支持传入 `(size) => value` 函数,依容器宽高动态计算。`renderCard` 的第三个参数 `size` 即当前解析后的卡片宽高,用它绘制可保证卡片随响应式尺寸缩放。
```tsx
// 按容器宽度分档:手机窄、平板中、桌面宽
@@ -100,31 +95,52 @@ const byWidth = <V,>(w: number, sm: V, md: V, lg: V): V =>
columns={({ width }) => byWidth(width, 3, 5, 7)}
cardWidth={({ width }) => byWidth(width, 180, 260, 340)}
cardHeight={({ width }) => byWidth(width, 250, 360, 470)}
/>;
```
## 可视区域回调
`onVisibleCardsChange` 在可视卡片集合或可视度变化时触发,返回当前屏幕内的卡片列表(按可视度从高到低排序)。可视度 `visibility` 为 0~1:完全可见为 `1`
```tsx
<InfiniteCanvas
items={items}
renderCard={renderCard}
visibilityThreshold={0.5} // 只关心“露出一半以上”的卡片
onVisibleCardsChange={(visible) => {
for (const v of visible) {
console.log(v.item.label, `${(v.visibility * 100).toFixed(0)}%`);
}
}}
/>
```
> `renderCard` 的第三个参数 `size` 即当前解析后的卡片宽高,绘制时使用它可保证卡片随响应式尺寸缩放,与网格布局一致
注意:因卡片平铺循环复用,同一张卡片可能同时出现在屏幕多处,每处是独立条目(`key` 不同),可用 `key` 作为 React 列表 key
性能上回调用 `requestAnimationFrame` 合帧、并对结果按可视度量化去重,空闲时零开销;容器尺寸变化也会触发重算(边缘卡片可视度会变)。
## API
### `InfiniteCanvas<T>` Props
| 属性 | 类型 | 默认值 | 说明 |
| --------------- | ---------------------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------- |
| `items` | `T[]` | — | **必填**。卡片数据数组。 |
| `renderCard` | `(item: T, index: number, size: { width: number; height: number }) => IUI` | — | **必填**。卡片插槽,返回一个 Leafer 节点。建议用 `useCallback` 保持引用稳定。 |
| `columns` | `Responsive<number>` | `7` | 列数。 |
| `cardWidth` | `Responsive<number>` | `350` | 卡片宽(用于网格定位与无限环绕命中,需与`renderCard` 输出一致)。 |
| `cardHeight` | `Responsive<number>` | `500` | 卡片高。 |
| `gapX` | `Responsive<number>` | `40` | 横向间距。 |
| `gapY` | `Responsive<number>` | `40` | 纵向间距。 |
| `draggable` | `boolean` | `true` | 是否可拖拽平移。 |
| `inertia` | `boolean` | `true` | 是否开启惯性滑动。 |
| `friction` | `number` | `0.92` | 惯性每帧衰减系数(0~1,越大滑得越久)。 |
| `onCardClick` | `(item: T, index: number) => void` | — | 点击卡片回调。 |
| `background` | `string` | — | 画布背景色。 |
| `className` | `string` | — | 容器`className` |
| `style` | `CSSProperties` | 填满父级 100%×100% | 容器内联样式。 |
| 属性 | 类型 | 默认值 | 说明 |
| --- | --- | --- | --- |
| `items` | `T[]` | — | **必填**。卡片数据数组。 |
| `renderCard` | `(item: T, index: number, size: { width: number; height: number }) => IUI` | — | **必填**。卡片插槽,返回一个 Leafer 节点。建议用 `useCallback` 保持引用稳定。 |
| `columns` | `Responsive<number>` | `7` | 列数。 |
| `cardWidth` | `Responsive<number>` | `350` | 卡片宽(需与 `renderCard` 输出一致)。 |
| `cardHeight` | `Responsive<number>` | `500` | 卡片高。 |
| `gapX` | `Responsive<number>` | `40` | 横向间距。 |
| `gapY` | `Responsive<number>` | `40` | 纵向间距。 |
| `draggable` | `boolean` | `true` | 是否可拖拽平移。 |
| `inertia` | `boolean` | `true` | 是否开启惯性滑动。 |
| `friction` | `number` | `0.92` | 惯性每帧衰减系数(0~1,越大滑得越久)。 |
| `onCardClick` | `(item: T, index: number) => void` | — | 点击卡片回调。 |
| `onVisibleCardsChange` | `(visible: VisibleCard<T>[]) => void` | — | 可视卡片变化回调。 |
| `visibilityThreshold` | `number` | `0` | 可视度阈值,低于此比例的卡片不计入回调。 |
| `background` | `string` | — | 画布背景色。 |
| `className` | `string` | — | 容器 `className`。 |
| `style` | `CSSProperties` | 填满父级 100%×100% | 容器内联样式。 |
### 类型
@@ -133,25 +149,34 @@ const byWidth = <V,>(w: number, sm: V, md: V, lg: V): V =>
export type Responsive<V> =
| V
| ((size: { width: number; height: number }) => V);
/** 一张当前出现在可视区域内的卡片 */
export interface VisibleCard<T> {
item: T; // 原始卡片数据
index: number; // 在 items 中的下标
visibility: number; // 可视度 0~1(落在视口内的面积占比)
rect: { x: number; y: number; width: number; height: number }; // 相对容器左上角的位置与完整尺寸
key: number; // 本次出现实例的稳定标识,可用作 React list key
}
```
## 工作原理
`useInfiniteSlide` 钩子封装了完整的滑动引擎:
1. **网格平铺**:将 `columns × baseRows`基础瓦片平铺到比视口更大的范围,空槽循环复用 `items`补全为完整矩形,避免出现空洞。
2. **取模环绕**拖拽/惯性时对每个节点坐标做 `wrap` 取模运算,将其规整到 `[-size, span - size)` 区间,使屏幕任意位置都被卡片覆盖;连续 wrap 消除阈值处的 1px 闪烁
3. **惯性滑动**松手记录最近速度,`requestAnimationFrame` 每帧按 `friction` 衰减,低于阈值时停止。
4. **响应式重建**`ResizeObserver` 监听容器尺寸,解析布局配置;仅当解析值或平铺规模变化时(签名去重)才真正重建网格
5. **生命周期安全**:易变的回调/配置存于 `ref`,避免触发画布重建;清理逻辑兼容 React StrictMode 的双调用
1. **网格平铺** — 将基础瓦片平铺到比视口更大的范围,空槽循环复用 `items`避免空洞。
2. **取模环绕**拖拽/惯性时对每个节点坐标做 `wrap` 取模规整到 `[-size, span)` 区间,使屏幕任意位置都被覆盖
3. **惯性滑动**松手记录速度,每帧按 `friction` 衰减直到停止。
4. **可视扫描** — 平移或 resize 后用 rAF 合帧扫描节点与视口的重叠面积,算出可视度并去重回调
5. **响应式重建**`ResizeObserver` 监听容器,仅当布局签名变化时才真正重建网格
## 本地运行
仓库内含一个使用 50 张色块卡片的示例(`src/App.tsx`),无需任何图片资源即可跑通
仓库内含一个使用 50 张色块卡片的示例(`src/App.tsx`),右上角实时展示可视卡片及其可视度
```bash
pnpm install
pnpm dev # 启动开发服务器
pnpm dev # 开发服务器
pnpm build # 类型检查 + 打包
pnpm preview # 预览构建产物
```
+24 -2
View File
@@ -1,6 +1,7 @@
import { useCallback } from "react";
import { useCallback, useState } from "react";
import { Box, Text } from "leafer-ui";
import { InfiniteCanvas } from "./components/InfiniteCanvas";
import type { VisibleCard } from "./components/InfiniteCanvas";
interface Card {
id: number;
@@ -33,6 +34,13 @@ const byWidth = <V,>(w: number, sm: V, md: V, lg: V): V =>
w < 768 ? sm : w < 1440 ? md : lg;
function App() {
// 当前可视卡片列表
const [visible, setVisible] = useState<VisibleCard<Card>[]>([]);
const handleVisibleChange = useCallback((v: VisibleCard<Card>[]) => {
setVisible(v);
}, []);
// renderCard 用第三参 size 绘制,保证卡片随响应式尺寸缩放
const renderCard = useCallback(
(item: Card, _index: number, size: { width: number; height: number }) => {
@@ -59,7 +67,7 @@ function App() {
);
return (
<div className="w-screen h-screen bg-[#171717]">
<div className="relative w-screen h-screen bg-[#171717]">
<InfiniteCanvas
items={items}
renderCard={renderCard}
@@ -71,7 +79,21 @@ function App() {
friction={0.97}
background="#171717"
onCardClick={(item) => console.log("clicked", item)}
onVisibleCardsChange={handleVisibleChange}
/>
{/* 实时展示可视卡片:可视度 + 左上角坐标 */}
<div className="pointer-events-none absolute right-3 top-3 max-h-[80vh] w-56 overflow-hidden rounded-lg bg-black/60 p-3 text-xs text-white">
<div className="mb-1 font-bold"> ({visible.length})</div>
{visible.map((v) => (
<div key={v.key} className="flex justify-between gap-2 tabular-nums">
<span className="shrink-0">{v.item.label}</span>
<span className="text-white/60">
({v.rect.x.toFixed(0)}, {v.rect.y.toFixed(0)})
</span>
<span>{(v.visibility * 100).toFixed(0)}%</span>
</div>
))}
</div>
</div>
);
}
+1 -1
View File
@@ -1,2 +1,2 @@
export { InfiniteCanvas } from "./InfiniteCanvas";
export type { InfiniteCanvasProps, Responsive } from "./types";
export type { InfiniteCanvasProps, Responsive, VisibleCard } from "./types";
+31
View File
@@ -6,6 +6,26 @@ export type Responsive<V> =
| V
| ((size: { width: number; height: number }) => V);
/** 一张当前出现在可视区域内的卡片 */
export interface VisibleCard<T> {
/** 原始卡片数据(来自 items) */
item: T;
/** item 在 items 中的下标 */
index: number;
/**
* 可视度 0~1:卡片落在视口内的面积占整张卡片面积的比例
* 完全可见为 1
*/
visibility: number;
/** 卡片相对容器左上角的位置与完整尺寸(含被视口裁掉的部分) */
rect: { x: number; y: number; width: number; height: number };
/**
* 本次出现实例的稳定标识。同一张卡片因无限平铺可能同时出现在多处,
* 每处是独立实例、key 不同;可用作 React list 的 key
*/
key: number;
}
export interface InfiniteCanvasProps<T> {
/** 卡片数据数组 */
items: T[];
@@ -41,6 +61,17 @@ export interface InfiniteCanvasProps<T> {
friction?: number;
/** 点击卡片回调 */
onCardClick?: (item: T, index: number) => void;
/**
* 可视区域卡片变化回调:每当可视卡片集合或其可视度发生变化时触发
* 内部用 requestAnimationFrame 合帧、并对结果做去重,空闲时零开销
* 列表按 visibility 从高到低排序
*/
onVisibleCardsChange?: (visible: VisibleCard<T>[]) => void;
/**
* 可视度阈值 0~1:低于该比例的卡片不计入回调结果,默认 0(露一点就算)
* 调高可只关心“基本露全”的卡片,并进一步降低回调频率
*/
visibilityThreshold?: number;
// —— 外观 / 容器 ——
/** 画布背景色(可选) */
@@ -1,7 +1,7 @@
import { useEffect, useRef } from "react";
import { Leafer, PointerEvent } from "leafer-ui";
import type { IUI } from "leafer-ui";
import type { InfiniteCanvasProps, Responsive } from "./types";
import type { InfiniteCanvasProps, Responsive, VisibleCard } from "./types";
/**
* 无限滑动引擎:负责 Leafer 生命周期、网格布局、拖拽平移、惯性滑动、
@@ -26,6 +26,8 @@ export function useInfiniteSlide<T>(
// 易变的回调/配置用 ref 存最新值,避免进入 effect 依赖导致画布重建
// 在 passive effect 中更新(不能在 render 期间写 ref
const onCardClickRef = useRef(props.onCardClick);
const onVisibleCardsChangeRef = useRef(props.onVisibleCardsChange);
const visibilityThresholdRef = useRef(props.visibilityThreshold ?? 0);
const draggableRef = useRef(draggable);
const inertiaRef = useRef(inertia);
const frictionRef = useRef(friction);
@@ -41,6 +43,8 @@ export function useInfiniteSlide<T>(
useEffect(() => {
onCardClickRef.current = props.onCardClick;
onVisibleCardsChangeRef.current = props.onVisibleCardsChange;
visibilityThresholdRef.current = props.visibilityThreshold ?? 0;
draggableRef.current = draggable;
inertiaRef.current = inertia;
frictionRef.current = friction;
@@ -68,10 +72,14 @@ export function useInfiniteSlide<T>(
// —— 当前网格状态(由 layout 重建,闭包内拖拽/环绕逻辑共享)——
let nodes: IUI[] = [];
// 与 nodes 下标对齐的元信息:每个节点对应的原始 item 与其在 items 中的下标
let meta: { item: T; index: number }[] = [];
let totalSpanX = 0; // 横向环绕周期
let totalSpanY = 0; // 纵向环绕周期
let curCardWidth = 0; // 解析后的卡片宽(环绕命中用)
let curCardHeight = 0;
let viewW = 0; // 最近一次布局时的视口宽(可视度计算用)
let viewH = 0;
let lastSig = ""; // 上次布局签名,用于去重避免无谓重建
const resolve = (
@@ -84,6 +92,69 @@ export function useInfiniteSlide<T>(
return typeof r === "number" && r > 0 ? r : dflt;
};
// —— 可视区域扫描(rAF 合帧 + 签名去重,空闲零开销)——
let visRaf = 0; // 待执行的扫描帧句柄(0 表示无)
let lastVisSig = ""; // 上次回调的结果签名,用于去重
// 把一次可视度扫描排进下一帧;同帧多次平移只扫一次
const scheduleVisible = () => {
if (!onVisibleCardsChangeRef.current || visRaf) return;
visRaf = requestAnimationFrame(computeVisible);
};
// 扫描所有节点,算出与视口 [0,viewW]×[0,viewH] 的重叠面积比,产出可视卡片列表
const computeVisible = () => {
visRaf = 0;
const cb = onVisibleCardsChangeRef.current;
if (!cb) return;
const cardArea = curCardWidth * curCardHeight;
if (cardArea <= 0) return;
const threshold = visibilityThresholdRef.current;
const result: VisibleCard<T>[] = [];
for (let i = 0; i < nodes.length; i++) {
const node = nodes[i];
const x = node.x ?? 0;
const y = node.y ?? 0;
// AABB 早退:与视口无交集直接跳过
if (
x >= viewW ||
y >= viewH ||
x + curCardWidth <= 0 ||
y + curCardHeight <= 0
) {
continue;
}
const overlapW = Math.min(x + curCardWidth, viewW) - Math.max(x, 0);
const overlapH = Math.min(y + curCardHeight, viewH) - Math.max(y, 0);
const visibility = (overlapW * overlapH) / cardArea;
if (visibility < threshold || visibility <= 0) continue;
const m = meta[i];
result.push({
item: m.item,
index: m.index,
visibility,
rect: { x, y, width: curCardWidth, height: curCardHeight },
key: i
});
}
// 最可见的排前面
result.sort((a, b) => b.visibility - a.visibility);
// 结果签名去重:可视度量化到 1% 网格,避免亚像素抖动反复触发回调
let sig = "";
for (const r of result) {
sig += `${r.key}:${Math.round(r.visibility * 100)};`;
}
if (sig === lastVisSig) return;
lastVisSig = sig;
cb(result);
};
/**
* 按当前容器尺寸解析配置并(必要时)重建网格:把 columns×baseRows 的基础
* 瓦片平铺到比视口更大,并补全为完整矩形(空槽循环复用 items,避免空洞)
@@ -109,13 +180,21 @@ export function useInfiniteSlide<T>(
const totalCols = columns * repX;
const totalRows = baseRows * repY;
// 解析值 + 平铺规模都没变 → 跳过,避免无谓重建(如 resize 但未越界)
// 视口尺寸即使在网格无需重建时也可能变化(影响边缘卡可视度),故每次都更新
viewW = W;
viewH = H;
// 解析值 + 平铺规模都没变 → 跳过重建,但仍重算可视度(视口可能已变)
const sig = `${columns}|${cardWidth}|${cardHeight}|${gapX}|${gapY}|${totalCols}|${totalRows}`;
if (sig === lastSig) return;
if (sig === lastSig) {
scheduleVisible();
return;
}
lastSig = sig;
for (const node of nodes) node.destroy();
nodes = [];
meta = [];
curCardWidth = cardWidth;
curCardHeight = cardHeight;
totalSpanX = totalCols * stepX;
@@ -137,8 +216,11 @@ export function useInfiniteSlide<T>(
);
leafer.add(node);
nodes.push(node);
meta.push({ item, index });
}
}
scheduleVisible();
};
layout();
@@ -155,6 +237,7 @@ export function useInfiniteSlide<T>(
node.x = wrap((node.x ?? 0) + dx, totalSpanX, curCardWidth);
node.y = wrap((node.y ?? 0) + dy, totalSpanY, curCardHeight);
}
scheduleVisible();
};
// —— 拖拽状态 ——
@@ -232,6 +315,7 @@ export function useInfiniteSlide<T>(
layoutRef.current = undefined;
ro.disconnect();
stopInertia();
if (visRaf) cancelAnimationFrame(visRaf);
leafer.off(PointerEvent.DOWN, onDown);
leafer.off(PointerEvent.MOVE, onMove);
leafer.off(PointerEvent.UP, onUp);