@qingwu-ui/scroll-fab 0.9.0-beta.1 → 0.9.0-beta.2
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 +96 -0
- package/dist/index.cjs +193 -16
- package/dist/index.d.cts +90 -5
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +90 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +193 -16
- 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,96 @@
|
|
|
1
|
+
# @qingwu-ui/scroll-fab
|
|
2
|
+
|
|
3
|
+
[青梧UI](https://github.com/apricotdream/qingwu-ui) 的 **悬浮滚动栏** —— 框架无关,纯 DOM + CSS,零强依赖。
|
|
4
|
+
|
|
5
|
+
- **绕圈翻转**:桌面悬停 / 触屏按住推进描边进度,绕满一圈在「滚动到底部 / 返回顶部」间纯切换(不附带滚动),未绕满移开/松手则描边衰减倒转
|
|
6
|
+
- **点击与描边正交**:轻点恒直接执行当前模式动作;触屏绕满后的释放被消费,避免同一手势既翻转又滚动
|
|
7
|
+
- **Lenis 可插拔**:内置 rAF 缓动,可选接入 [Lenis](https://github.com/darkroomengineering/lenis)(未安装静默回退)
|
|
8
|
+
- **异形形状**:银杏叶等自定义外形,进度描边沿外扩轮廓完全包裹
|
|
9
|
+
- 程序滚动支持 wheel / touch / keydown 打断、懒加载变长追击
|
|
10
|
+
- 移动端一等支持:Pointer Events 单路径、ghost click 抑制、safe-area 定位、44px 命中下限
|
|
11
|
+
- 尊重 `prefers-reduced-motion`;window 与自定义容器双支持
|
|
12
|
+
|
|
13
|
+
## 安装
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @qingwu-ui/scroll-fab
|
|
17
|
+
# 可选:启用 Lenis 平滑滚动
|
|
18
|
+
npm install lenis
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 使用
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { ScrollFab } from "@qingwu-ui/scroll-fab";
|
|
25
|
+
import "@qingwu-ui/scroll-fab/style.css";
|
|
26
|
+
|
|
27
|
+
// 零配置:右下角圆形悬浮栏
|
|
28
|
+
const fab = new ScrollFab();
|
|
29
|
+
|
|
30
|
+
// 销毁
|
|
31
|
+
fab.destroy();
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### 接入 Lenis
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// ① 自建实例(仅驱动按钮程序滚动,不劫持页面滚轮)
|
|
38
|
+
new ScrollFab({
|
|
39
|
+
lenis: true,
|
|
40
|
+
onLenisReady: (lenis) => console.log("Lenis 就绪", lenis),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// ② 透传 Lenis 配置(target 容器自动映射为 wrapper)
|
|
44
|
+
new ScrollFab({ lenis: { duration: 1.1 } });
|
|
45
|
+
|
|
46
|
+
// ③ 复用宿主已有的 Lenis 实例(组件永不销毁它)
|
|
47
|
+
const lenis = new Lenis();
|
|
48
|
+
new ScrollFab({ lenis });
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
未安装 `lenis` 时自动回退内置 rAF,无任何报错。
|
|
52
|
+
|
|
53
|
+
### 异形形状(银杏叶等)
|
|
54
|
+
|
|
55
|
+
进度要「完全包裹」形状必须用双 path:`fill` 是叶片实体,`outline` 是外扩一圈的闭合轮廓(SVG 描边居中于路径,单 path 会有一半笔画压进形状内部)。
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const LEAF = "M24 46 L23.2 34 C16 33 9 28 7 20 Z /* …完整 path d… */";
|
|
59
|
+
|
|
60
|
+
new ScrollFab({
|
|
61
|
+
shape: {
|
|
62
|
+
fill: LEAF,
|
|
63
|
+
outline: LEAF, // 与 fill 同形
|
|
64
|
+
outlineTransform: "translate(24 24) scale(1.13) translate(-24 -24)",
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
异形模式下圆形外壳自动退场(背景/边框/圆角),阴影改用 `drop-shadow` 跟随叶片真实形状;命中区仍为矩形以保证 44px 触控目标。
|
|
70
|
+
|
|
71
|
+
## API
|
|
72
|
+
|
|
73
|
+
```ts
|
|
74
|
+
new ScrollFab(options?: ScrollFabOptions): ScrollFab
|
|
75
|
+
|
|
76
|
+
interface ScrollFabOptions {
|
|
77
|
+
target?: HTMLElement | null; // 滚动容器,默认 window
|
|
78
|
+
size?: number; // 直径 px,默认 48(最小 44)
|
|
79
|
+
ringDuration?: number; // 绕圈时长 ms,默认 800
|
|
80
|
+
decayDuration?: number; // 衰减倒转 ms,默认 300
|
|
81
|
+
cancelThreshold?: number; // 触屏位移放弃阈值 px,默认 10
|
|
82
|
+
content?: string | HTMLElement; // 中心内容,默认方向箭头
|
|
83
|
+
position?: ScrollFabPosition; // 悬浮位置,默认右下 24/24
|
|
84
|
+
zIndex?: number; // 默认 9999
|
|
85
|
+
className?: string;
|
|
86
|
+
animate?: boolean; // 默认 true
|
|
87
|
+
lenis?: boolean | ScrollFabLenisConfig | LenisLike;
|
|
88
|
+
shape?: ScrollFabShape;
|
|
89
|
+
onLenisReady?: (lenis: LenisLike) => void;
|
|
90
|
+
ariaLabelToBottom?: string;
|
|
91
|
+
ariaLabelToTop?: string;
|
|
92
|
+
onModeChange?: (mode: "to-bottom" | "to-top") => void;
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
实例方法:`scrollToBottom()` / `scrollToTop()` / `refresh()` / `destroy()`;只读属性 `currentMode`、`el`。
|
package/dist/index.cjs
CHANGED
|
@@ -74,19 +74,26 @@ 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 = "返回顶部(悬停或按住绕圈可切换为滚动到底部)";
|
|
80
82
|
function easeInOutCubic(p) {
|
|
81
83
|
return p < .5 ? 4 * p * p * p : 1 - (-2 * p + 2) ** 3 / 2;
|
|
82
84
|
}
|
|
85
|
+
function isLenisInstance(v) {
|
|
86
|
+
return typeof v === "object" && v !== null && typeof v.scrollTo === "function";
|
|
87
|
+
}
|
|
83
88
|
var ScrollFab = class {
|
|
84
89
|
el;
|
|
85
90
|
ring;
|
|
86
91
|
track;
|
|
92
|
+
fillPath = null;
|
|
87
93
|
icon;
|
|
88
94
|
len;
|
|
89
95
|
targetEl;
|
|
96
|
+
shape;
|
|
90
97
|
ringMs;
|
|
91
98
|
decayMs;
|
|
92
99
|
cancelPx;
|
|
@@ -94,6 +101,13 @@ var ScrollFab = class {
|
|
|
94
101
|
labelBottom;
|
|
95
102
|
labelTop;
|
|
96
103
|
onModeChange;
|
|
104
|
+
onLenisReady;
|
|
105
|
+
/** 外部注入:宿主实例,组件永不销毁 */
|
|
106
|
+
externalLenis = null;
|
|
107
|
+
/** 自建配置:null = 不走 lenis;true 形态也归一成对象 */
|
|
108
|
+
ownLenisConfig = null;
|
|
109
|
+
ownLenis = null;
|
|
110
|
+
ownLenisPromise = null;
|
|
97
111
|
mode = "to-bottom";
|
|
98
112
|
progress = 0;
|
|
99
113
|
hovering = false;
|
|
@@ -105,12 +119,18 @@ var ScrollFab = class {
|
|
|
105
119
|
ringRaf = null;
|
|
106
120
|
lastTs = null;
|
|
107
121
|
scrollRaf = null;
|
|
122
|
+
lenisLoopRaf = null;
|
|
123
|
+
lenisScrolling = false;
|
|
124
|
+
lenisDest = null;
|
|
108
125
|
flashTimer = null;
|
|
109
126
|
tapResetTimer = null;
|
|
110
127
|
touchTap = false;
|
|
111
128
|
ro = null;
|
|
129
|
+
actionSeq = 0;
|
|
130
|
+
destroyed = false;
|
|
112
131
|
constructor(options = {}) {
|
|
113
132
|
this.targetEl = options.target ?? null;
|
|
133
|
+
this.shape = options.shape ?? null;
|
|
114
134
|
this.ringMs = Math.max(100, Math.min(5e3, options.ringDuration ?? 800));
|
|
115
135
|
this.decayMs = Math.max(100, options.decayDuration ?? 300);
|
|
116
136
|
this.cancelPx = Math.max(4, options.cancelThreshold ?? 10);
|
|
@@ -118,10 +138,20 @@ var ScrollFab = class {
|
|
|
118
138
|
this.labelBottom = options.ariaLabelToBottom ?? DEFAULT_LABEL_BOTTOM;
|
|
119
139
|
this.labelTop = options.ariaLabelToTop ?? DEFAULT_LABEL_TOP;
|
|
120
140
|
this.onModeChange = options.onModeChange;
|
|
141
|
+
this.onLenisReady = options.onLenisReady;
|
|
142
|
+
if (isLenisInstance(options.lenis)) this.externalLenis = options.lenis;
|
|
143
|
+
else if (options.lenis != null && options.lenis !== false) {
|
|
144
|
+
const cfg = options.lenis === true ? {} : options.lenis;
|
|
145
|
+
this.ownLenisConfig = {
|
|
146
|
+
smoothWheel: false,
|
|
147
|
+
...cfg
|
|
148
|
+
};
|
|
149
|
+
}
|
|
121
150
|
this.el = document.createElement("button");
|
|
122
151
|
this.el.type = "button";
|
|
123
152
|
this.el.className = `qsf-root${options.className ? ` ${options.className}` : ""}`;
|
|
124
153
|
this.el.dataset.mode = this.mode;
|
|
154
|
+
if (this.shape) this.el.dataset.shape = "on";
|
|
125
155
|
this.el.setAttribute("aria-label", this.labelBottom);
|
|
126
156
|
this.el.style.setProperty("--qsf-size", `${Math.max(44, options.size ?? 48)}px`);
|
|
127
157
|
if (options.zIndex != null) this.el.style.setProperty("--qsf-z", String(options.zIndex));
|
|
@@ -129,17 +159,32 @@ var ScrollFab = class {
|
|
|
129
159
|
if (options.position?.bottom != null) this.el.style.setProperty("--qsf-bottom", `${options.position.bottom}px`);
|
|
130
160
|
if (options.position?.left != null) this.el.style.setProperty("--qsf-left", `${options.position.left}px`);
|
|
131
161
|
if (options.position?.top != null) this.el.style.setProperty("--qsf-top", `${options.position.top}px`);
|
|
132
|
-
const svg = document.createElementNS(
|
|
162
|
+
const svg = document.createElementNS(SVG_NS, "svg");
|
|
133
163
|
svg.setAttribute("class", "qsf-svg");
|
|
134
|
-
svg.setAttribute("viewBox", "0 0 48 48");
|
|
164
|
+
svg.setAttribute("viewBox", this.shape?.viewBox ?? "0 0 48 48");
|
|
135
165
|
svg.setAttribute("aria-hidden", "true");
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
166
|
+
if (this.shape) {
|
|
167
|
+
this.fillPath = document.createElementNS(SVG_NS, "path");
|
|
168
|
+
this.fillPath.setAttribute("class", "qsf-shape-fill");
|
|
169
|
+
this.fillPath.setAttribute("d", this.shape.fill);
|
|
170
|
+
this.track = this.makePath("qsf-track", this.shape.outline);
|
|
171
|
+
this.ring = this.makePath("qsf-bar", this.shape.outline);
|
|
172
|
+
if (this.shape.outlineTransform) {
|
|
173
|
+
this.track.setAttribute("transform", this.shape.outlineTransform);
|
|
174
|
+
this.ring.setAttribute("transform", this.shape.outlineTransform);
|
|
175
|
+
}
|
|
176
|
+
svg.append(this.fillPath, this.track, this.ring);
|
|
177
|
+
} else {
|
|
178
|
+
this.track = this.makeCircle("qsf-track");
|
|
179
|
+
this.ring = this.makeCircle("qsf-bar");
|
|
180
|
+
svg.append(this.track, this.ring);
|
|
181
|
+
}
|
|
182
|
+
let len;
|
|
183
|
+
if (this.shape) len = FALLBACK_PATH_LEN;
|
|
184
|
+
else len = 2 * Math.PI * RADIUS;
|
|
140
185
|
try {
|
|
141
186
|
const measured = this.ring.getTotalLength();
|
|
142
|
-
if (Number.isFinite(measured)) len = measured;
|
|
187
|
+
if (Number.isFinite(measured) && measured > 0) len = measured;
|
|
143
188
|
} catch {}
|
|
144
189
|
this.len = len;
|
|
145
190
|
this.ring.style.strokeDasharray = String(this.len);
|
|
@@ -180,9 +225,10 @@ var ScrollFab = class {
|
|
|
180
225
|
this.animateScroll("bottom");
|
|
181
226
|
}
|
|
182
227
|
scrollToTop() {
|
|
183
|
-
this.
|
|
228
|
+
this.animateScroll("top");
|
|
184
229
|
}
|
|
185
230
|
destroy() {
|
|
231
|
+
this.destroyed = true;
|
|
186
232
|
this.el.removeEventListener("pointerenter", this.onEnter);
|
|
187
233
|
this.el.removeEventListener("pointerleave", this.onLeave);
|
|
188
234
|
this.el.removeEventListener("pointerdown", this.onDown);
|
|
@@ -195,6 +241,9 @@ var ScrollFab = class {
|
|
|
195
241
|
(this.targetEl ?? window).removeEventListener("scroll", this.onRecheck);
|
|
196
242
|
this.ro?.disconnect();
|
|
197
243
|
this.cancelScroll();
|
|
244
|
+
this.stopLenisLoop();
|
|
245
|
+
this.ownLenis?.destroy?.();
|
|
246
|
+
this.ownLenis = null;
|
|
198
247
|
if (this.ringRaf != null) cancelAnimationFrame(this.ringRaf);
|
|
199
248
|
this.ringRaf = null;
|
|
200
249
|
if (this.flashTimer != null) clearTimeout(this.flashTimer);
|
|
@@ -202,13 +251,19 @@ var ScrollFab = class {
|
|
|
202
251
|
this.el.remove();
|
|
203
252
|
}
|
|
204
253
|
makeCircle(cls) {
|
|
205
|
-
const c = document.createElementNS(
|
|
254
|
+
const c = document.createElementNS(SVG_NS, "circle");
|
|
206
255
|
c.setAttribute("class", cls);
|
|
207
256
|
c.setAttribute("cx", "24");
|
|
208
257
|
c.setAttribute("cy", "24");
|
|
209
258
|
c.setAttribute("r", String(RADIUS));
|
|
210
259
|
return c;
|
|
211
260
|
}
|
|
261
|
+
makePath(cls, d) {
|
|
262
|
+
const p = document.createElementNS(SVG_NS, "path");
|
|
263
|
+
p.setAttribute("class", cls);
|
|
264
|
+
p.setAttribute("d", d);
|
|
265
|
+
return p;
|
|
266
|
+
}
|
|
212
267
|
onEnter = (e) => {
|
|
213
268
|
if (e.pointerType !== "mouse") return;
|
|
214
269
|
this.hovering = true;
|
|
@@ -322,12 +377,110 @@ var ScrollFab = class {
|
|
|
322
377
|
this.animateScroll(this.mode === "to-bottom" ? "bottom" : "top");
|
|
323
378
|
}
|
|
324
379
|
animateScroll(dest) {
|
|
380
|
+
++this.actionSeq;
|
|
325
381
|
this.cancelScroll();
|
|
326
382
|
if (!this.animate || this.reducedMotion()) {
|
|
327
383
|
this.setScrollPos(this.targetPos(dest));
|
|
328
384
|
return;
|
|
329
385
|
}
|
|
330
|
-
this.
|
|
386
|
+
if (this.externalLenis) {
|
|
387
|
+
this.scrollWithLenis(this.externalLenis, dest, false);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
if (this.ownLenisConfig) {
|
|
391
|
+
const seq = ++this.actionSeq;
|
|
392
|
+
this.resolveOwnLenis().then((lenis) => {
|
|
393
|
+
if (this.destroyed || seq !== this.actionSeq) return;
|
|
394
|
+
if (lenis) this.scrollWithLenis(lenis, dest, true);
|
|
395
|
+
else this.animateRaf(dest);
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
this.animateRaf(dest);
|
|
400
|
+
}
|
|
401
|
+
/** 首次点击时动态 import lenis 并自建实例;未安装(或加载失败)→ null 永久回退 rAF */
|
|
402
|
+
resolveOwnLenis() {
|
|
403
|
+
if (this.ownLenis) return Promise.resolve(this.ownLenis);
|
|
404
|
+
if (this.ownLenisPromise) return this.ownLenisPromise;
|
|
405
|
+
this.ownLenisPromise = import("lenis").then((mod) => {
|
|
406
|
+
const LenisCtor = mod.default ?? mod;
|
|
407
|
+
const cfg = { ...this.ownLenisConfig };
|
|
408
|
+
if (this.targetEl) cfg.wrapper = this.targetEl;
|
|
409
|
+
this.ownLenis = new LenisCtor(cfg);
|
|
410
|
+
this.onLenisReady?.(this.ownLenis);
|
|
411
|
+
return this.ownLenis;
|
|
412
|
+
}).catch(() => {
|
|
413
|
+
this.ownLenisConfig = null;
|
|
414
|
+
this.ownLenisPromise = null;
|
|
415
|
+
return null;
|
|
416
|
+
});
|
|
417
|
+
return this.ownLenisPromise;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* 用 Lenis 执行程序滚动。
|
|
421
|
+
* 自建实例(own):组件自跑 raf loop(Lenis 需外部驱动 raf),保留 wheel/touch/keydown 打断与到底追击;
|
|
422
|
+
* 外部实例:宿主自带 raf loop 与手势处理,组件只发 scrollTo,不追击不打断。
|
|
423
|
+
*/
|
|
424
|
+
scrollWithLenis(lenis, dest, own) {
|
|
425
|
+
const immediate = !this.animate || this.reducedMotion();
|
|
426
|
+
const target = this.targetPos(dest);
|
|
427
|
+
this.lenisDest = dest;
|
|
428
|
+
if (own) {
|
|
429
|
+
lenis.start?.();
|
|
430
|
+
lenis.scrollTo(target, {
|
|
431
|
+
immediate,
|
|
432
|
+
onComplete: () => this.endLenisScroll()
|
|
433
|
+
});
|
|
434
|
+
if (!immediate) {
|
|
435
|
+
this.lenisScrolling = true;
|
|
436
|
+
this.attachInterrupt();
|
|
437
|
+
this.startLenisLoop(lenis, dest);
|
|
438
|
+
} else this.endLenisScroll();
|
|
439
|
+
} else {
|
|
440
|
+
lenis.scrollTo(target, { immediate });
|
|
441
|
+
this.lenisDest = null;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
/** 自建实例的 raf 驱动 + 懒加载到底追击(对齐内置 rAF 语义) */
|
|
445
|
+
startLenisLoop(lenis, dest) {
|
|
446
|
+
let lastEnd = this.targetPos(dest);
|
|
447
|
+
const loop = (time) => {
|
|
448
|
+
if (!this.lenisScrolling) return;
|
|
449
|
+
if (dest === "bottom") {
|
|
450
|
+
const live = this.targetPos("bottom");
|
|
451
|
+
if (Math.abs(live - lastEnd) > 2) {
|
|
452
|
+
lastEnd = live;
|
|
453
|
+
lenis.scrollTo(live, {
|
|
454
|
+
force: true,
|
|
455
|
+
onComplete: () => this.endLenisScroll()
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
lenis.raf?.(time);
|
|
460
|
+
this.lenisLoopRaf = requestAnimationFrame(loop);
|
|
461
|
+
};
|
|
462
|
+
this.lenisLoopRaf = requestAnimationFrame(loop);
|
|
463
|
+
}
|
|
464
|
+
stopLenisLoop() {
|
|
465
|
+
if (this.lenisLoopRaf != null) {
|
|
466
|
+
cancelAnimationFrame(this.lenisLoopRaf);
|
|
467
|
+
this.lenisLoopRaf = null;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/** 打断自建 Lenis 程序滚动:stop 冻结动画(下次发起前 start 复位) */
|
|
471
|
+
onLenisInterrupt = () => {
|
|
472
|
+
if (!this.lenisScrolling) return;
|
|
473
|
+
this.ownLenis?.stop?.();
|
|
474
|
+
this.endLenisScroll();
|
|
475
|
+
};
|
|
476
|
+
endLenisScroll() {
|
|
477
|
+
this.lenisScrolling = false;
|
|
478
|
+
this.lenisDest = null;
|
|
479
|
+
this.stopLenisLoop();
|
|
480
|
+
this.detachInterrupt();
|
|
481
|
+
}
|
|
482
|
+
animateRaf(dest) {
|
|
483
|
+
this.attachRafInterrupt();
|
|
331
484
|
let init = false;
|
|
332
485
|
let start = 0;
|
|
333
486
|
let end = 0;
|
|
@@ -354,7 +507,7 @@ var ScrollFab = class {
|
|
|
354
507
|
if (p < 1) this.scrollRaf = requestAnimationFrame(step);
|
|
355
508
|
else {
|
|
356
509
|
this.scrollRaf = null;
|
|
357
|
-
this.
|
|
510
|
+
this.detachRafInterrupt();
|
|
358
511
|
}
|
|
359
512
|
};
|
|
360
513
|
this.scrollRaf = requestAnimationFrame(step);
|
|
@@ -368,15 +521,33 @@ var ScrollFab = class {
|
|
|
368
521
|
interruptSource() {
|
|
369
522
|
return this.targetEl ?? window;
|
|
370
523
|
}
|
|
524
|
+
/** 自建 Lenis 路线的打断监听 */
|
|
371
525
|
attachInterrupt() {
|
|
372
526
|
const src = this.interruptSource();
|
|
373
527
|
for (const ev of [
|
|
374
528
|
"wheel",
|
|
375
529
|
"touchstart",
|
|
376
530
|
"keydown"
|
|
377
|
-
]) src.addEventListener(ev, this.
|
|
531
|
+
]) src.addEventListener(ev, this.onLenisInterrupt, { passive: true });
|
|
378
532
|
}
|
|
379
533
|
detachInterrupt() {
|
|
534
|
+
const src = this.interruptSource();
|
|
535
|
+
for (const ev of [
|
|
536
|
+
"wheel",
|
|
537
|
+
"touchstart",
|
|
538
|
+
"keydown"
|
|
539
|
+
]) src.removeEventListener(ev, this.onLenisInterrupt);
|
|
540
|
+
}
|
|
541
|
+
/** 内置 rAF 路线的打断监听 */
|
|
542
|
+
attachRafInterrupt() {
|
|
543
|
+
const src = this.interruptSource();
|
|
544
|
+
for (const ev of [
|
|
545
|
+
"wheel",
|
|
546
|
+
"touchstart",
|
|
547
|
+
"keydown"
|
|
548
|
+
]) src.addEventListener(ev, this.onInterrupt, { passive: true });
|
|
549
|
+
}
|
|
550
|
+
detachRafInterrupt() {
|
|
380
551
|
const src = this.interruptSource();
|
|
381
552
|
for (const ev of [
|
|
382
553
|
"wheel",
|
|
@@ -389,7 +560,16 @@ var ScrollFab = class {
|
|
|
389
560
|
cancelAnimationFrame(this.scrollRaf);
|
|
390
561
|
this.scrollRaf = null;
|
|
391
562
|
}
|
|
392
|
-
this.
|
|
563
|
+
if (this.lenisScrolling) this.endLenisScroll();
|
|
564
|
+
const src = this.interruptSource();
|
|
565
|
+
for (const ev of [
|
|
566
|
+
"wheel",
|
|
567
|
+
"touchstart",
|
|
568
|
+
"keydown"
|
|
569
|
+
]) {
|
|
570
|
+
src.removeEventListener(ev, this.onInterrupt);
|
|
571
|
+
src.removeEventListener(ev, this.onLenisInterrupt);
|
|
572
|
+
}
|
|
393
573
|
}
|
|
394
574
|
maxScroll() {
|
|
395
575
|
if (this.targetEl) return Math.max(0, this.targetEl.scrollHeight - this.targetEl.clientHeight);
|
|
@@ -406,9 +586,6 @@ var ScrollFab = class {
|
|
|
406
586
|
if (this.targetEl) this.targetEl.scrollTop = v;
|
|
407
587
|
else window.scrollTo(0, v);
|
|
408
588
|
}
|
|
409
|
-
scrollTo(v) {
|
|
410
|
-
this.animateScroll(v === 0 ? "top" : "bottom");
|
|
411
|
-
}
|
|
412
589
|
onRecheck = () => {
|
|
413
590
|
this.syncVisible();
|
|
414
591
|
};
|
package/dist/index.d.cts
CHANGED
|
@@ -7,6 +7,46 @@ interface ScrollFabPosition {
|
|
|
7
7
|
bottom?: number;
|
|
8
8
|
left?: number;
|
|
9
9
|
}
|
|
10
|
+
/** Lenis 实例的最小结构性类型(组件不硬依赖 lenis 包,按结构识别) */
|
|
11
|
+
interface LenisLike {
|
|
12
|
+
scrollTo: (target: number | string | HTMLElement, options?: Record<string, unknown>) => void;
|
|
13
|
+
raf?: (time: number) => void;
|
|
14
|
+
destroy?: () => void;
|
|
15
|
+
start?: () => void;
|
|
16
|
+
stop?: () => void;
|
|
17
|
+
on?: (event: string, callback: (...args: unknown[]) => void) => void;
|
|
18
|
+
off?: (event: string, callback: (...args: unknown[]) => void) => void;
|
|
19
|
+
isScrolling?: boolean;
|
|
20
|
+
}
|
|
21
|
+
/** 内部自建 Lenis 时的构造配置(lenis 官方选项子集,允许透传其他字段) */
|
|
22
|
+
interface ScrollFabLenisConfig {
|
|
23
|
+
wrapper?: HTMLElement | Window;
|
|
24
|
+
content?: HTMLElement;
|
|
25
|
+
duration?: number;
|
|
26
|
+
easing?: (t: number) => number;
|
|
27
|
+
/** 默认强制 false:自建实例不劫持用户滚轮,只驱动按钮触发的程序滚动;显式置 true 则接管全局 */
|
|
28
|
+
smoothWheel?: boolean;
|
|
29
|
+
smoothTouch?: boolean;
|
|
30
|
+
wheelMultiplier?: number;
|
|
31
|
+
touchMultiplier?: number;
|
|
32
|
+
[key: string]: unknown;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* 异形按钮形状(如银杏叶)。
|
|
36
|
+
* 双 path 才能保证进度描边「完全包裹」:fill 是叶片实体,outline 是外扩一圈的闭合轮廓,
|
|
37
|
+
* SVG 描边居中于路径,单条 path 必有一半笔画压进形状内部。
|
|
38
|
+
* outline 的起点约定在 12 点钟方向(描边顺时针推进)。
|
|
39
|
+
*/
|
|
40
|
+
interface ScrollFabShape {
|
|
41
|
+
/** SVG viewBox,默认 "0 0 48 48";自定义 path 建议按 48×48 绘制 */
|
|
42
|
+
viewBox?: string;
|
|
43
|
+
/** 形状实体 path d(被填充) */
|
|
44
|
+
fill: string;
|
|
45
|
+
/** 外扩进度轮廓 path d(track + 进度描边沿它走);可与 fill 同形再配 outlineTransform 外扩 */
|
|
46
|
+
outline: string;
|
|
47
|
+
/** 作用于 outline path 的 SVG transform,如 "translate(24 24) scale(1.1) translate(-24 -24)" */
|
|
48
|
+
outlineTransform?: string;
|
|
49
|
+
}
|
|
10
50
|
interface ScrollFabOptions {
|
|
11
51
|
/** 滚动容器,null(默认)为 window */
|
|
12
52
|
target?: HTMLElement | null;
|
|
@@ -28,6 +68,18 @@ interface ScrollFabOptions {
|
|
|
28
68
|
className?: string;
|
|
29
69
|
/** 动画开关,关闭或 prefers-reduced-motion 时程序滚动瞬时到位,默认 true */
|
|
30
70
|
animate?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* Lenis 平滑滚动(lenis 为 optional peerDependency,未安装静默回退内置 rAF):
|
|
73
|
+
* - 传 Lenis 实例:复用宿主实例,组件不负责销毁;
|
|
74
|
+
* - true:点击时动态 import lenis 并自建实例(仅驱动按钮程序滚动,不劫持滚轮);
|
|
75
|
+
* - 传配置对象:自建实例并透传配置(target 容器自动映射为 wrapper);
|
|
76
|
+
* - false / 缺省:内置 rAF 缓动。
|
|
77
|
+
*/
|
|
78
|
+
lenis?: boolean | ScrollFabLenisConfig | LenisLike;
|
|
79
|
+
/** 异形形状(银杏叶等);传入后圆形外壳消失,进度沿外扩轮廓包裹 */
|
|
80
|
+
shape?: ScrollFabShape;
|
|
81
|
+
/** 自建 Lenis 实例就绪回调(外部实例注入时不触发),与 onEditorReady 模式对齐 */
|
|
82
|
+
onLenisReady?: (lenis: LenisLike) => void;
|
|
31
83
|
/** 模式①(滚动到底部)无障碍标签 */
|
|
32
84
|
ariaLabelToBottom?: string;
|
|
33
85
|
/** 模式②(返回顶部)无障碍标签 */
|
|
@@ -39,11 +91,13 @@ interface ScrollFabOptions {
|
|
|
39
91
|
//#region src/scroll-fab.d.ts
|
|
40
92
|
declare class ScrollFab {
|
|
41
93
|
readonly el: HTMLButtonElement;
|
|
42
|
-
private
|
|
43
|
-
private
|
|
94
|
+
private ring;
|
|
95
|
+
private track;
|
|
96
|
+
private fillPath;
|
|
44
97
|
private readonly icon;
|
|
45
|
-
private
|
|
98
|
+
private len;
|
|
46
99
|
private readonly targetEl;
|
|
100
|
+
private readonly shape;
|
|
47
101
|
private readonly ringMs;
|
|
48
102
|
private readonly decayMs;
|
|
49
103
|
private readonly cancelPx;
|
|
@@ -51,6 +105,13 @@ declare class ScrollFab {
|
|
|
51
105
|
private readonly labelBottom;
|
|
52
106
|
private readonly labelTop;
|
|
53
107
|
private readonly onModeChange?;
|
|
108
|
+
private readonly onLenisReady?;
|
|
109
|
+
/** 外部注入:宿主实例,组件永不销毁 */
|
|
110
|
+
private externalLenis;
|
|
111
|
+
/** 自建配置:null = 不走 lenis;true 形态也归一成对象 */
|
|
112
|
+
private ownLenisConfig;
|
|
113
|
+
private ownLenis;
|
|
114
|
+
private ownLenisPromise;
|
|
54
115
|
private mode;
|
|
55
116
|
private progress;
|
|
56
117
|
private hovering;
|
|
@@ -62,10 +123,15 @@ declare class ScrollFab {
|
|
|
62
123
|
private ringRaf;
|
|
63
124
|
private lastTs;
|
|
64
125
|
private scrollRaf;
|
|
126
|
+
private lenisLoopRaf;
|
|
127
|
+
private lenisScrolling;
|
|
128
|
+
private lenisDest;
|
|
65
129
|
private flashTimer;
|
|
66
130
|
private tapResetTimer;
|
|
67
131
|
private touchTap;
|
|
68
132
|
private ro;
|
|
133
|
+
private actionSeq;
|
|
134
|
+
private destroyed;
|
|
69
135
|
constructor(options?: ScrollFabOptions);
|
|
70
136
|
/** 当前模式 */
|
|
71
137
|
get currentMode(): ScrollFabMode;
|
|
@@ -75,6 +141,7 @@ declare class ScrollFab {
|
|
|
75
141
|
scrollToTop(): void;
|
|
76
142
|
destroy(): void;
|
|
77
143
|
private makeCircle;
|
|
144
|
+
private makePath;
|
|
78
145
|
private onEnter;
|
|
79
146
|
private onLeave;
|
|
80
147
|
private onDown;
|
|
@@ -92,20 +159,38 @@ declare class ScrollFab {
|
|
|
92
159
|
private setMode;
|
|
93
160
|
private doAction;
|
|
94
161
|
private animateScroll;
|
|
162
|
+
/** 首次点击时动态 import lenis 并自建实例;未安装(或加载失败)→ null 永久回退 rAF */
|
|
163
|
+
private resolveOwnLenis;
|
|
164
|
+
/**
|
|
165
|
+
* 用 Lenis 执行程序滚动。
|
|
166
|
+
* 自建实例(own):组件自跑 raf loop(Lenis 需外部驱动 raf),保留 wheel/touch/keydown 打断与到底追击;
|
|
167
|
+
* 外部实例:宿主自带 raf loop 与手势处理,组件只发 scrollTo,不追击不打断。
|
|
168
|
+
*/
|
|
169
|
+
private scrollWithLenis;
|
|
170
|
+
/** 自建实例的 raf 驱动 + 懒加载到底追击(对齐内置 rAF 语义) */
|
|
171
|
+
private startLenisLoop;
|
|
172
|
+
private stopLenisLoop;
|
|
173
|
+
/** 打断自建 Lenis 程序滚动:stop 冻结动画(下次发起前 start 复位) */
|
|
174
|
+
private onLenisInterrupt;
|
|
175
|
+
private endLenisScroll;
|
|
176
|
+
private animateRaf;
|
|
95
177
|
private reducedMotion;
|
|
96
178
|
private onInterrupt;
|
|
97
179
|
private interruptSource;
|
|
180
|
+
/** 自建 Lenis 路线的打断监听 */
|
|
98
181
|
private attachInterrupt;
|
|
99
182
|
private detachInterrupt;
|
|
183
|
+
/** 内置 rAF 路线的打断监听 */
|
|
184
|
+
private attachRafInterrupt;
|
|
185
|
+
private detachRafInterrupt;
|
|
100
186
|
private cancelScroll;
|
|
101
187
|
private maxScroll;
|
|
102
188
|
private targetPos;
|
|
103
189
|
private scrollPos;
|
|
104
190
|
private setScrollPos;
|
|
105
|
-
private scrollTo;
|
|
106
191
|
private onRecheck;
|
|
107
192
|
private syncVisible;
|
|
108
193
|
}
|
|
109
194
|
//#endregion
|
|
110
|
-
export { ScrollFab, type ScrollFabMode, type ScrollFabOptions, type ScrollFabPosition };
|
|
195
|
+
export { type LenisLike, ScrollFab, type ScrollFabLenisConfig, type ScrollFabMode, type ScrollFabOptions, type ScrollFabPosition, type ScrollFabShape };
|
|
111
196
|
//# sourceMappingURL=index.d.cts.map
|
package/dist/index.d.cts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.cts","names":[],"sources":["../src/types.ts","../src/scroll-fab.ts"],"mappings":";KAAY;;UAGK;EACf;EACA;EACA;EACA;;UAGe;;EAEf,SAAS;;EAET;;EAEA;;EAEA;;EAEA;;EAEA,mBAAmB;;EAEnB,WAAW;;EAEX;;EAEA;;EAEA;;
|
|
1
|
+
{"version":3,"file":"index.d.cts","names":[],"sources":["../src/types.ts","../src/scroll-fab.ts"],"mappings":";KAAY;;UAGK;EACf;EACA;EACA;EACA;;;UAIe;EACf,WACE,0BAA0B,aAC1B,UAAU;EAEZ,OAAO;EACP;EACA;EACA;EACA,MAAM,eAAe,cAAc;EACnC,OAAO,eAAe,cAAc;EACpC;;;UAIe;EACf,UAAU,cAAc;EACxB,UAAU;EACV;EACA,UAAU;;EAEV;EACA;EACA;EACA;GACC;;;;;;;;UASc;;EAEf;;EAEA;;EAEA;;EAEA;;UAGe;;EAEf,SAAS;;EAET;;EAEA;;EAEA;;EAEA;;EAEA,mBAAmB;;EAEnB,WAAW;;EAEX;;EAEA;;EAEA;;;;;;;;EAQA,kBAAkB,uBAAuB;;EAEzC,QAAQ;;EAER,gBAAgB,OAAO;;EAEvB;;EAEA;;EAEA,gBAAgB,MAAM;;;;cCjEX;WACF,IAAI;UAEL;UACA;UACA;mBACS;UACT;mBAES;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;mBACA;;UAGT;;UAEA;UACA;UACA;UAEA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;UACA;EAER,YAAY,UAAS;;MA2GjB,eAAe;;EAKnB;EAIA;EAIA;EAIA;UAyBQ;UASA;UAaA;UAMA;UAMA;UAeA;UAUA;UAeA;UAOA;UAUA;UAQA;UAIA;UAMA;;UAoBA;UAYA;UAOA;UAUA;UAIA;;UAyBA;;;;;;UA6BA;;UA6BA;UAqBA;;UAQA;UAMA;UASA;UAmCA;UAMA;UAIA;;UAKA;UAMA;;UAOA;UAMA;UAMA;UAeA;UAKA;UAIA;UAKA;UAKA;UAIA"}
|