@qingwu-ui/scroll-fab 0.9.0-beta.1 → 0.9.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +115 -0
- package/dist/index.cjs +264 -37
- package/dist/index.d.cts +114 -6
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +114 -6
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +264 -37
- package/dist/index.mjs.map +1 -1
- package/dist/style.css +32 -2
- package/package.json +14 -2
package/README.md
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# @qingwu-ui/scroll-fab
|
|
2
|
+
|
|
3
|
+
[青梧UI](https://github.com/apricotdream/qingwu-ui) 的 **悬浮滚动栏** —— 框架无关,纯 DOM + CSS,零强依赖。
|
|
4
|
+
|
|
5
|
+
- **绕圈翻转**:桌面悬停 / 触屏按住推进描边进度,绕满一圈在「滚动到底部 / 返回顶部」间纯切换(不附带滚动),未绕满移开/松手则描边衰减倒转
|
|
6
|
+
- **点击与描边正交**:轻点恒直接执行当前模式动作;触屏绕满后的释放被消费,避免同一手势既翻转又滚动
|
|
7
|
+
- **键盘语义正确**:Enter/Space 执行当前模式动作(原生按钮激活);双模式下方向键 `ArrowUp/ArrowDown` 翻转模式
|
|
8
|
+
- **单模式 / 阈值显隐**:`modes` 允许只跑「返回顶部」,`showThreshold` 滚过阈值才出现、不打扰首屏
|
|
9
|
+
- **进度回调**:`onScroll(pct)` 实时吐出 0..1 滚动进度
|
|
10
|
+
- **Lenis 可插拔**:内置 rAF 缓动,可选接入 [Lenis](https://github.com/darkroomengineering/lenis)(未安装静默回退)
|
|
11
|
+
- **异形形状**:银杏叶等自定义外形,进度描边沿外扩轮廓完全包裹
|
|
12
|
+
- 程序滚动支持 wheel / touch / keydown 打断、懒加载变长追击
|
|
13
|
+
- 移动端一等支持:Pointer Events 单路径、ghost click 抑制、safe-area 定位、44px 命中下限
|
|
14
|
+
- 尊重 `prefers-reduced-motion`;window 与自定义容器双支持
|
|
15
|
+
|
|
16
|
+
## 安装
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npm install @qingwu-ui/scroll-fab
|
|
20
|
+
# 可选:启用 Lenis 平滑滚动
|
|
21
|
+
npm install lenis
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## 使用
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
import { ScrollFab } from "@qingwu-ui/scroll-fab";
|
|
28
|
+
import "@qingwu-ui/scroll-fab/style.css";
|
|
29
|
+
|
|
30
|
+
// 零配置:右下角圆形悬浮栏
|
|
31
|
+
const fab = new ScrollFab();
|
|
32
|
+
|
|
33
|
+
// 销毁
|
|
34
|
+
fab.destroy();
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### 接入 Lenis
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// ① 自建实例(仅驱动按钮程序滚动,不劫持页面滚轮)
|
|
41
|
+
new ScrollFab({
|
|
42
|
+
lenis: true,
|
|
43
|
+
onLenisReady: (lenis) => console.log("Lenis 就绪", lenis),
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// ② 透传 Lenis 配置(target 容器自动映射为 wrapper)
|
|
47
|
+
new ScrollFab({ lenis: { duration: 1.1 } });
|
|
48
|
+
|
|
49
|
+
// ③ 复用宿主已有的 Lenis 实例(组件永不销毁它)
|
|
50
|
+
const lenis = new Lenis();
|
|
51
|
+
new ScrollFab({ lenis });
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
未安装 `lenis` 时自动回退内置 rAF,无任何报错。
|
|
55
|
+
|
|
56
|
+
### 异形形状(银杏叶等)
|
|
57
|
+
|
|
58
|
+
进度要「完全包裹」形状必须用双 path:`fill` 是叶片实体,`outline` 是外扩一圈的闭合轮廓(SVG 描边居中于路径,单 path 会有一半笔画压进形状内部)。
|
|
59
|
+
|
|
60
|
+
```ts
|
|
61
|
+
const LEAF = "M24 46 L23.2 34 C16 33 9 28 7 20 Z /* …完整 path d… */";
|
|
62
|
+
|
|
63
|
+
new ScrollFab({
|
|
64
|
+
shape: {
|
|
65
|
+
fill: LEAF,
|
|
66
|
+
outline: LEAF, // 与 fill 同形
|
|
67
|
+
outlineTransform: "translate(24 24) scale(1.13) translate(-24 -24)",
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
异形模式下圆形外壳自动退场(背景/边框/圆角),阴影改用 `drop-shadow` 跟随叶片真实形状;命中区仍为矩形以保证 44px 触控目标。
|
|
73
|
+
|
|
74
|
+
### 单模式 / 阈值显隐 / 进度回调
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// 只跑「返回顶部」:首屏不打扰,滚过 400px 才出现;实时读滚动进度
|
|
78
|
+
new ScrollFab({
|
|
79
|
+
modes: ["to-top"],
|
|
80
|
+
showThreshold: 400,
|
|
81
|
+
onScroll: (pct) => console.log(`${Math.round(pct * 100)}%`),
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
单模式没有绕环翻转与描边,视觉上即普通按钮;键盘 Enter/Space 执行动作。
|
|
86
|
+
|
|
87
|
+
## API
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
new ScrollFab(options?: ScrollFabOptions): ScrollFab
|
|
91
|
+
|
|
92
|
+
interface ScrollFabOptions {
|
|
93
|
+
target?: HTMLElement | null; // 滚动容器,默认 window
|
|
94
|
+
size?: number; // 直径 px,默认 48(最小 44)
|
|
95
|
+
ringDuration?: number; // 绕圈时长 ms,默认 800
|
|
96
|
+
decayDuration?: number; // 衰减倒转 ms,默认 300
|
|
97
|
+
cancelThreshold?: number; // 触屏位移放弃阈值 px,默认 10
|
|
98
|
+
content?: string | HTMLElement; // 中心内容,默认方向箭头
|
|
99
|
+
position?: ScrollFabPosition; // 悬浮位置,默认右下 24/24
|
|
100
|
+
zIndex?: number; // 默认 9999
|
|
101
|
+
className?: string;
|
|
102
|
+
animate?: boolean; // 默认 true
|
|
103
|
+
lenis?: boolean | ScrollFabLenisConfig | LenisLike;
|
|
104
|
+
shape?: ScrollFabShape;
|
|
105
|
+
onLenisReady?: (lenis: LenisLike) => void;
|
|
106
|
+
ariaLabelToBottom?: string;
|
|
107
|
+
ariaLabelToTop?: string;
|
|
108
|
+
onModeChange?: (mode: "to-bottom" | "to-top") => void;
|
|
109
|
+
modes?: ("to-bottom" | "to-top")[]; // 缺省双模式;初始取首项;单模式无绕环/描边;空配置抛 TypeError
|
|
110
|
+
showThreshold?: number; // 显示所需最小 scrollPos px,默认 0(行为不变),负数按 0
|
|
111
|
+
onScroll?: (pct: number) => void; // 0..1 小数,rAF 节流,隐藏期间照吐,构造/refresh 后补发
|
|
112
|
+
}
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
实例方法:`scrollToBottom()` / `scrollToTop()` / `refresh()` / `destroy()`;只读属性 `currentMode`、`el`。
|
package/dist/index.cjs
CHANGED
|
@@ -74,19 +74,28 @@ S16("<path d=\"m9 18 6-6-6-6\"/>");
|
|
|
74
74
|
//#endregion
|
|
75
75
|
//#region src/scroll-fab.ts
|
|
76
76
|
/** 青梧UI ScrollFab 悬浮滚动栏:默认“滚动到底部”,桌面悬停/触屏按住绕满描边一圈翻转为“返回顶部”,点击(轻点)直接执行当前模式动作 */
|
|
77
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
77
78
|
const RADIUS = 21;
|
|
79
|
+
const FALLBACK_PATH_LEN = 100;
|
|
78
80
|
const DEFAULT_LABEL_BOTTOM = "滚动到底部(悬停或按住绕圈可切换为返回顶部)";
|
|
79
81
|
const DEFAULT_LABEL_TOP = "返回顶部(悬停或按住绕圈可切换为滚动到底部)";
|
|
82
|
+
const STATIC_LABEL_BOTTOM = "滚动到底部";
|
|
83
|
+
const STATIC_LABEL_TOP = "返回顶部";
|
|
80
84
|
function easeInOutCubic(p) {
|
|
81
85
|
return p < .5 ? 4 * p * p * p : 1 - (-2 * p + 2) ** 3 / 2;
|
|
82
86
|
}
|
|
87
|
+
function isLenisInstance(v) {
|
|
88
|
+
return typeof v === "object" && v !== null && typeof v.scrollTo === "function";
|
|
89
|
+
}
|
|
83
90
|
var ScrollFab = class {
|
|
84
91
|
el;
|
|
85
92
|
ring;
|
|
86
93
|
track;
|
|
94
|
+
fillPath = null;
|
|
87
95
|
icon;
|
|
88
96
|
len;
|
|
89
97
|
targetEl;
|
|
98
|
+
shape;
|
|
90
99
|
ringMs;
|
|
91
100
|
decayMs;
|
|
92
101
|
cancelPx;
|
|
@@ -94,7 +103,18 @@ var ScrollFab = class {
|
|
|
94
103
|
labelBottom;
|
|
95
104
|
labelTop;
|
|
96
105
|
onModeChange;
|
|
97
|
-
|
|
106
|
+
onLenisReady;
|
|
107
|
+
onScroll;
|
|
108
|
+
modes;
|
|
109
|
+
single;
|
|
110
|
+
threshold;
|
|
111
|
+
/** 外部注入:宿主实例,组件永不销毁 */
|
|
112
|
+
externalLenis = null;
|
|
113
|
+
/** 自建配置:null = 不走 lenis;true 形态也归一成对象 */
|
|
114
|
+
ownLenisConfig = null;
|
|
115
|
+
ownLenis = null;
|
|
116
|
+
ownLenisPromise = null;
|
|
117
|
+
mode;
|
|
98
118
|
progress = 0;
|
|
99
119
|
hovering = false;
|
|
100
120
|
pressing = false;
|
|
@@ -105,41 +125,79 @@ var ScrollFab = class {
|
|
|
105
125
|
ringRaf = null;
|
|
106
126
|
lastTs = null;
|
|
107
127
|
scrollRaf = null;
|
|
128
|
+
lenisLoopRaf = null;
|
|
129
|
+
lenisScrolling = false;
|
|
130
|
+
lenisDest = null;
|
|
108
131
|
flashTimer = null;
|
|
109
132
|
tapResetTimer = null;
|
|
110
133
|
touchTap = false;
|
|
111
134
|
ro = null;
|
|
135
|
+
actionSeq = 0;
|
|
136
|
+
destroyed = false;
|
|
137
|
+
emitRaf = null;
|
|
138
|
+
lastPct = -1;
|
|
112
139
|
constructor(options = {}) {
|
|
113
140
|
this.targetEl = options.target ?? null;
|
|
141
|
+
this.shape = options.shape ?? null;
|
|
114
142
|
this.ringMs = Math.max(100, Math.min(5e3, options.ringDuration ?? 800));
|
|
115
143
|
this.decayMs = Math.max(100, options.decayDuration ?? 300);
|
|
116
144
|
this.cancelPx = Math.max(4, options.cancelThreshold ?? 10);
|
|
117
145
|
this.animate = options.animate ?? true;
|
|
118
|
-
this.
|
|
119
|
-
this.labelTop = options.ariaLabelToTop ?? DEFAULT_LABEL_TOP;
|
|
146
|
+
this.threshold = Math.max(0, options.showThreshold ?? 0);
|
|
120
147
|
this.onModeChange = options.onModeChange;
|
|
148
|
+
this.onLenisReady = options.onLenisReady;
|
|
149
|
+
this.onScroll = options.onScroll;
|
|
150
|
+
this.modes = this.normalizeModes(options.modes);
|
|
151
|
+
this.single = this.modes.length === 1;
|
|
152
|
+
this.mode = this.modes[0];
|
|
153
|
+
this.labelBottom = options.ariaLabelToBottom ?? (this.single ? STATIC_LABEL_BOTTOM : DEFAULT_LABEL_BOTTOM);
|
|
154
|
+
this.labelTop = options.ariaLabelToTop ?? (this.single ? STATIC_LABEL_TOP : DEFAULT_LABEL_TOP);
|
|
155
|
+
if (isLenisInstance(options.lenis)) this.externalLenis = options.lenis;
|
|
156
|
+
else if (options.lenis != null && options.lenis !== false) {
|
|
157
|
+
const cfg = options.lenis === true ? {} : options.lenis;
|
|
158
|
+
this.ownLenisConfig = {
|
|
159
|
+
smoothWheel: false,
|
|
160
|
+
...cfg
|
|
161
|
+
};
|
|
162
|
+
}
|
|
121
163
|
this.el = document.createElement("button");
|
|
122
164
|
this.el.type = "button";
|
|
123
165
|
this.el.className = `qsf-root${options.className ? ` ${options.className}` : ""}`;
|
|
124
166
|
this.el.dataset.mode = this.mode;
|
|
125
|
-
this.el.
|
|
167
|
+
if (this.shape) this.el.dataset.shape = "on";
|
|
168
|
+
this.el.setAttribute("aria-label", this.mode === "to-bottom" ? this.labelBottom : this.labelTop);
|
|
126
169
|
this.el.style.setProperty("--qsf-size", `${Math.max(44, options.size ?? 48)}px`);
|
|
127
170
|
if (options.zIndex != null) this.el.style.setProperty("--qsf-z", String(options.zIndex));
|
|
128
171
|
if (options.position?.right != null) this.el.style.setProperty("--qsf-right", `${options.position.right}px`);
|
|
129
172
|
if (options.position?.bottom != null) this.el.style.setProperty("--qsf-bottom", `${options.position.bottom}px`);
|
|
130
173
|
if (options.position?.left != null) this.el.style.setProperty("--qsf-left", `${options.position.left}px`);
|
|
131
174
|
if (options.position?.top != null) this.el.style.setProperty("--qsf-top", `${options.position.top}px`);
|
|
132
|
-
const svg = document.createElementNS(
|
|
175
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
133
176
|
svg.setAttribute("class", "qsf-svg");
|
|
134
|
-
svg.setAttribute("viewBox", "0 0 48 48");
|
|
177
|
+
svg.setAttribute("viewBox", this.shape?.viewBox ?? "0 0 48 48");
|
|
135
178
|
svg.setAttribute("aria-hidden", "true");
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
179
|
+
if (this.shape) {
|
|
180
|
+
this.fillPath = document.createElementNS(SVG_NS, "path");
|
|
181
|
+
this.fillPath.setAttribute("class", "qsf-shape-fill");
|
|
182
|
+
this.fillPath.setAttribute("d", this.shape.fill);
|
|
183
|
+
this.track = this.makePath("qsf-track", this.shape.outline);
|
|
184
|
+
this.ring = this.makePath("qsf-bar", this.shape.outline);
|
|
185
|
+
if (this.shape.outlineTransform) {
|
|
186
|
+
this.track.setAttribute("transform", this.shape.outlineTransform);
|
|
187
|
+
this.ring.setAttribute("transform", this.shape.outlineTransform);
|
|
188
|
+
}
|
|
189
|
+
svg.append(this.fillPath, this.track, this.ring);
|
|
190
|
+
} else {
|
|
191
|
+
this.track = this.makeCircle("qsf-track");
|
|
192
|
+
this.ring = this.makeCircle("qsf-bar");
|
|
193
|
+
svg.append(this.track, this.ring);
|
|
194
|
+
}
|
|
195
|
+
let len;
|
|
196
|
+
if (this.shape) len = FALLBACK_PATH_LEN;
|
|
197
|
+
else len = 2 * Math.PI * RADIUS;
|
|
140
198
|
try {
|
|
141
199
|
const measured = this.ring.getTotalLength();
|
|
142
|
-
if (Number.isFinite(measured)) len = measured;
|
|
200
|
+
if (Number.isFinite(measured) && measured > 0) len = measured;
|
|
143
201
|
} catch {}
|
|
144
202
|
this.len = len;
|
|
145
203
|
this.ring.style.strokeDasharray = String(this.len);
|
|
@@ -152,12 +210,14 @@ var ScrollFab = class {
|
|
|
152
210
|
else this.icon.innerHTML = ICON_CHEVRON_DOWN;
|
|
153
211
|
this.el.append(svg, this.icon);
|
|
154
212
|
document.body.append(this.el);
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
213
|
+
if (!this.single) {
|
|
214
|
+
this.el.addEventListener("pointerenter", this.onEnter);
|
|
215
|
+
this.el.addEventListener("pointerleave", this.onLeave);
|
|
216
|
+
this.el.addEventListener("pointerdown", this.onDown);
|
|
217
|
+
this.el.addEventListener("pointermove", this.onMove);
|
|
218
|
+
this.el.addEventListener("pointerup", this.onUp);
|
|
219
|
+
this.el.addEventListener("pointercancel", this.onCancel);
|
|
220
|
+
}
|
|
161
221
|
this.el.addEventListener("keydown", this.onKey);
|
|
162
222
|
this.el.addEventListener("click", this.onClick);
|
|
163
223
|
window.addEventListener("resize", this.onRecheck);
|
|
@@ -167,48 +227,73 @@ var ScrollFab = class {
|
|
|
167
227
|
this.ro.observe(this.targetEl ?? document.documentElement);
|
|
168
228
|
}
|
|
169
229
|
this.syncVisible();
|
|
230
|
+
this.emitScroll();
|
|
170
231
|
}
|
|
171
232
|
/** 当前模式 */
|
|
172
233
|
get currentMode() {
|
|
173
234
|
return this.mode;
|
|
174
235
|
}
|
|
175
|
-
/** 内容懒加载等外部高度变化后可显式触发重估(滚动/resize
|
|
236
|
+
/** 内容懒加载等外部高度变化后可显式触发重估(滚动/resize 时也会自动重估),并强制补发一次进度 */
|
|
176
237
|
refresh() {
|
|
177
|
-
this.
|
|
238
|
+
this.syncVisible();
|
|
239
|
+
this.lastPct = -1;
|
|
240
|
+
this.emitScroll();
|
|
178
241
|
}
|
|
179
242
|
scrollToBottom() {
|
|
180
243
|
this.animateScroll("bottom");
|
|
181
244
|
}
|
|
182
245
|
scrollToTop() {
|
|
183
|
-
this.
|
|
246
|
+
this.animateScroll("top");
|
|
184
247
|
}
|
|
185
248
|
destroy() {
|
|
186
|
-
this.
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
249
|
+
this.destroyed = true;
|
|
250
|
+
if (!this.single) {
|
|
251
|
+
this.el.removeEventListener("pointerenter", this.onEnter);
|
|
252
|
+
this.el.removeEventListener("pointerleave", this.onLeave);
|
|
253
|
+
this.el.removeEventListener("pointerdown", this.onDown);
|
|
254
|
+
this.el.removeEventListener("pointermove", this.onMove);
|
|
255
|
+
this.el.removeEventListener("pointerup", this.onUp);
|
|
256
|
+
this.el.removeEventListener("pointercancel", this.onCancel);
|
|
257
|
+
}
|
|
192
258
|
this.el.removeEventListener("keydown", this.onKey);
|
|
193
259
|
this.el.removeEventListener("click", this.onClick);
|
|
194
260
|
window.removeEventListener("resize", this.onRecheck);
|
|
195
261
|
(this.targetEl ?? window).removeEventListener("scroll", this.onRecheck);
|
|
196
262
|
this.ro?.disconnect();
|
|
197
263
|
this.cancelScroll();
|
|
264
|
+
this.stopLenisLoop();
|
|
265
|
+
this.ownLenis?.destroy?.();
|
|
266
|
+
this.ownLenis = null;
|
|
198
267
|
if (this.ringRaf != null) cancelAnimationFrame(this.ringRaf);
|
|
199
268
|
this.ringRaf = null;
|
|
269
|
+
if (this.emitRaf != null) cancelAnimationFrame(this.emitRaf);
|
|
270
|
+
this.emitRaf = null;
|
|
200
271
|
if (this.flashTimer != null) clearTimeout(this.flashTimer);
|
|
201
272
|
if (this.tapResetTimer != null) clearTimeout(this.tapResetTimer);
|
|
202
273
|
this.el.remove();
|
|
203
274
|
}
|
|
275
|
+
/** 规范化 modes:缺省双模式;去重、过滤非法值;结果为空直接抛错 */
|
|
276
|
+
normalizeModes(input) {
|
|
277
|
+
if (!input) return ["to-bottom", "to-top"];
|
|
278
|
+
const out = [];
|
|
279
|
+
for (const m of input) if ((m === "to-bottom" || m === "to-top") && !out.includes(m)) out.push(m);
|
|
280
|
+
if (out.length === 0) throw new TypeError("ScrollFab: modes 至少需要一个有效模式('to-bottom' | 'to-top')");
|
|
281
|
+
return out;
|
|
282
|
+
}
|
|
204
283
|
makeCircle(cls) {
|
|
205
|
-
const c = document.createElementNS(
|
|
284
|
+
const c = document.createElementNS(SVG_NS, "circle");
|
|
206
285
|
c.setAttribute("class", cls);
|
|
207
286
|
c.setAttribute("cx", "24");
|
|
208
287
|
c.setAttribute("cy", "24");
|
|
209
288
|
c.setAttribute("r", String(RADIUS));
|
|
210
289
|
return c;
|
|
211
290
|
}
|
|
291
|
+
makePath(cls, d) {
|
|
292
|
+
const p = document.createElementNS(SVG_NS, "path");
|
|
293
|
+
p.setAttribute("class", cls);
|
|
294
|
+
p.setAttribute("d", d);
|
|
295
|
+
return p;
|
|
296
|
+
}
|
|
212
297
|
onEnter = (e) => {
|
|
213
298
|
if (e.pointerType !== "mouse") return;
|
|
214
299
|
this.hovering = true;
|
|
@@ -268,9 +353,12 @@ var ScrollFab = class {
|
|
|
268
353
|
this.doAction();
|
|
269
354
|
};
|
|
270
355
|
onKey = (e) => {
|
|
271
|
-
if (e.repeat || e.key !== "
|
|
356
|
+
if (e.repeat || e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
|
|
357
|
+
if (this.single) return;
|
|
358
|
+
const want = e.key === "ArrowUp" ? "to-top" : "to-bottom";
|
|
359
|
+
if (want === this.mode) return;
|
|
272
360
|
e.preventDefault();
|
|
273
|
-
this.setMode(
|
|
361
|
+
this.setMode(want);
|
|
274
362
|
};
|
|
275
363
|
holding() {
|
|
276
364
|
return this.hovering || this.pressing;
|
|
@@ -322,12 +410,110 @@ var ScrollFab = class {
|
|
|
322
410
|
this.animateScroll(this.mode === "to-bottom" ? "bottom" : "top");
|
|
323
411
|
}
|
|
324
412
|
animateScroll(dest) {
|
|
413
|
+
++this.actionSeq;
|
|
325
414
|
this.cancelScroll();
|
|
326
415
|
if (!this.animate || this.reducedMotion()) {
|
|
327
416
|
this.setScrollPos(this.targetPos(dest));
|
|
328
417
|
return;
|
|
329
418
|
}
|
|
330
|
-
this.
|
|
419
|
+
if (this.externalLenis) {
|
|
420
|
+
this.scrollWithLenis(this.externalLenis, dest, false);
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
if (this.ownLenisConfig) {
|
|
424
|
+
const seq = ++this.actionSeq;
|
|
425
|
+
this.resolveOwnLenis().then((lenis) => {
|
|
426
|
+
if (this.destroyed || seq !== this.actionSeq) return;
|
|
427
|
+
if (lenis) this.scrollWithLenis(lenis, dest, true);
|
|
428
|
+
else this.animateRaf(dest);
|
|
429
|
+
});
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
this.animateRaf(dest);
|
|
433
|
+
}
|
|
434
|
+
/** 首次点击时动态 import lenis 并自建实例;未安装(或加载失败)→ null 永久回退 rAF */
|
|
435
|
+
resolveOwnLenis() {
|
|
436
|
+
if (this.ownLenis) return Promise.resolve(this.ownLenis);
|
|
437
|
+
if (this.ownLenisPromise) return this.ownLenisPromise;
|
|
438
|
+
this.ownLenisPromise = import("lenis").then((mod) => {
|
|
439
|
+
const LenisCtor = mod.default ?? mod;
|
|
440
|
+
const cfg = { ...this.ownLenisConfig };
|
|
441
|
+
if (this.targetEl) cfg.wrapper = this.targetEl;
|
|
442
|
+
this.ownLenis = new LenisCtor(cfg);
|
|
443
|
+
this.onLenisReady?.(this.ownLenis);
|
|
444
|
+
return this.ownLenis;
|
|
445
|
+
}).catch(() => {
|
|
446
|
+
this.ownLenisConfig = null;
|
|
447
|
+
this.ownLenisPromise = null;
|
|
448
|
+
return null;
|
|
449
|
+
});
|
|
450
|
+
return this.ownLenisPromise;
|
|
451
|
+
}
|
|
452
|
+
/**
|
|
453
|
+
* 用 Lenis 执行程序滚动。
|
|
454
|
+
* 自建实例(own):组件自跑 raf loop(Lenis 需外部驱动 raf),保留 wheel/touch/keydown 打断与到底追击;
|
|
455
|
+
* 外部实例:宿主自带 raf loop 与手势处理,组件只发 scrollTo,不追击不打断。
|
|
456
|
+
*/
|
|
457
|
+
scrollWithLenis(lenis, dest, own) {
|
|
458
|
+
const immediate = !this.animate || this.reducedMotion();
|
|
459
|
+
const target = this.targetPos(dest);
|
|
460
|
+
this.lenisDest = dest;
|
|
461
|
+
if (own) {
|
|
462
|
+
lenis.start?.();
|
|
463
|
+
lenis.scrollTo(target, {
|
|
464
|
+
immediate,
|
|
465
|
+
onComplete: () => this.endLenisScroll()
|
|
466
|
+
});
|
|
467
|
+
if (!immediate) {
|
|
468
|
+
this.lenisScrolling = true;
|
|
469
|
+
this.attachInterrupt();
|
|
470
|
+
this.startLenisLoop(lenis, dest);
|
|
471
|
+
} else this.endLenisScroll();
|
|
472
|
+
} else {
|
|
473
|
+
lenis.scrollTo(target, { immediate });
|
|
474
|
+
this.lenisDest = null;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/** 自建实例的 raf 驱动 + 懒加载到底追击(对齐内置 rAF 语义) */
|
|
478
|
+
startLenisLoop(lenis, dest) {
|
|
479
|
+
let lastEnd = this.targetPos(dest);
|
|
480
|
+
const loop = (time) => {
|
|
481
|
+
if (!this.lenisScrolling) return;
|
|
482
|
+
if (dest === "bottom") {
|
|
483
|
+
const live = this.targetPos("bottom");
|
|
484
|
+
if (Math.abs(live - lastEnd) > 2) {
|
|
485
|
+
lastEnd = live;
|
|
486
|
+
lenis.scrollTo(live, {
|
|
487
|
+
force: true,
|
|
488
|
+
onComplete: () => this.endLenisScroll()
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
lenis.raf?.(time);
|
|
493
|
+
this.lenisLoopRaf = requestAnimationFrame(loop);
|
|
494
|
+
};
|
|
495
|
+
this.lenisLoopRaf = requestAnimationFrame(loop);
|
|
496
|
+
}
|
|
497
|
+
stopLenisLoop() {
|
|
498
|
+
if (this.lenisLoopRaf != null) {
|
|
499
|
+
cancelAnimationFrame(this.lenisLoopRaf);
|
|
500
|
+
this.lenisLoopRaf = null;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
/** 打断自建 Lenis 程序滚动:stop 冻结动画(下次发起前 start 复位) */
|
|
504
|
+
onLenisInterrupt = () => {
|
|
505
|
+
if (!this.lenisScrolling) return;
|
|
506
|
+
this.ownLenis?.stop?.();
|
|
507
|
+
this.endLenisScroll();
|
|
508
|
+
};
|
|
509
|
+
endLenisScroll() {
|
|
510
|
+
this.lenisScrolling = false;
|
|
511
|
+
this.lenisDest = null;
|
|
512
|
+
this.stopLenisLoop();
|
|
513
|
+
this.detachInterrupt();
|
|
514
|
+
}
|
|
515
|
+
animateRaf(dest) {
|
|
516
|
+
this.attachRafInterrupt();
|
|
331
517
|
let init = false;
|
|
332
518
|
let start = 0;
|
|
333
519
|
let end = 0;
|
|
@@ -354,7 +540,7 @@ var ScrollFab = class {
|
|
|
354
540
|
if (p < 1) this.scrollRaf = requestAnimationFrame(step);
|
|
355
541
|
else {
|
|
356
542
|
this.scrollRaf = null;
|
|
357
|
-
this.
|
|
543
|
+
this.detachRafInterrupt();
|
|
358
544
|
}
|
|
359
545
|
};
|
|
360
546
|
this.scrollRaf = requestAnimationFrame(step);
|
|
@@ -368,15 +554,33 @@ var ScrollFab = class {
|
|
|
368
554
|
interruptSource() {
|
|
369
555
|
return this.targetEl ?? window;
|
|
370
556
|
}
|
|
557
|
+
/** 自建 Lenis 路线的打断监听 */
|
|
371
558
|
attachInterrupt() {
|
|
372
559
|
const src = this.interruptSource();
|
|
373
560
|
for (const ev of [
|
|
374
561
|
"wheel",
|
|
375
562
|
"touchstart",
|
|
376
563
|
"keydown"
|
|
377
|
-
]) src.addEventListener(ev, this.
|
|
564
|
+
]) src.addEventListener(ev, this.onLenisInterrupt, { passive: true });
|
|
378
565
|
}
|
|
379
566
|
detachInterrupt() {
|
|
567
|
+
const src = this.interruptSource();
|
|
568
|
+
for (const ev of [
|
|
569
|
+
"wheel",
|
|
570
|
+
"touchstart",
|
|
571
|
+
"keydown"
|
|
572
|
+
]) src.removeEventListener(ev, this.onLenisInterrupt);
|
|
573
|
+
}
|
|
574
|
+
/** 内置 rAF 路线的打断监听 */
|
|
575
|
+
attachRafInterrupt() {
|
|
576
|
+
const src = this.interruptSource();
|
|
577
|
+
for (const ev of [
|
|
578
|
+
"wheel",
|
|
579
|
+
"touchstart",
|
|
580
|
+
"keydown"
|
|
581
|
+
]) src.addEventListener(ev, this.onInterrupt, { passive: true });
|
|
582
|
+
}
|
|
583
|
+
detachRafInterrupt() {
|
|
380
584
|
const src = this.interruptSource();
|
|
381
585
|
for (const ev of [
|
|
382
586
|
"wheel",
|
|
@@ -389,7 +593,16 @@ var ScrollFab = class {
|
|
|
389
593
|
cancelAnimationFrame(this.scrollRaf);
|
|
390
594
|
this.scrollRaf = null;
|
|
391
595
|
}
|
|
392
|
-
this.
|
|
596
|
+
if (this.lenisScrolling) this.endLenisScroll();
|
|
597
|
+
const src = this.interruptSource();
|
|
598
|
+
for (const ev of [
|
|
599
|
+
"wheel",
|
|
600
|
+
"touchstart",
|
|
601
|
+
"keydown"
|
|
602
|
+
]) {
|
|
603
|
+
src.removeEventListener(ev, this.onInterrupt);
|
|
604
|
+
src.removeEventListener(ev, this.onLenisInterrupt);
|
|
605
|
+
}
|
|
393
606
|
}
|
|
394
607
|
maxScroll() {
|
|
395
608
|
if (this.targetEl) return Math.max(0, this.targetEl.scrollHeight - this.targetEl.clientHeight);
|
|
@@ -406,14 +619,28 @@ var ScrollFab = class {
|
|
|
406
619
|
if (this.targetEl) this.targetEl.scrollTop = v;
|
|
407
620
|
else window.scrollTo(0, v);
|
|
408
621
|
}
|
|
409
|
-
scrollTo(v) {
|
|
410
|
-
this.animateScroll(v === 0 ? "top" : "bottom");
|
|
411
|
-
}
|
|
412
622
|
onRecheck = () => {
|
|
413
623
|
this.syncVisible();
|
|
624
|
+
this.scheduleScroll();
|
|
414
625
|
};
|
|
415
626
|
syncVisible() {
|
|
416
|
-
this.el.hidden = this.maxScroll() <= 1;
|
|
627
|
+
this.el.hidden = this.maxScroll() <= 1 || this.scrollPos() < this.threshold;
|
|
628
|
+
}
|
|
629
|
+
/** rAF 节流:一帧内多次重估只吐一次;按钮隐藏期间照常调度 */
|
|
630
|
+
scheduleScroll() {
|
|
631
|
+
if (this.emitRaf != null || this.destroyed) return;
|
|
632
|
+
this.emitRaf = requestAnimationFrame(() => {
|
|
633
|
+
this.emitRaf = null;
|
|
634
|
+
this.emitScroll();
|
|
635
|
+
});
|
|
636
|
+
}
|
|
637
|
+
emitScroll() {
|
|
638
|
+
if (this.destroyed) return;
|
|
639
|
+
const max = this.maxScroll();
|
|
640
|
+
const pct = max <= 0 ? 0 : Math.min(1, Math.max(0, this.scrollPos() / max));
|
|
641
|
+
if (pct === this.lastPct) return;
|
|
642
|
+
this.lastPct = pct;
|
|
643
|
+
this.onScroll?.(pct);
|
|
417
644
|
}
|
|
418
645
|
};
|
|
419
646
|
//#endregion
|