@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 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
- mode = "to-bottom";
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.labelBottom = options.ariaLabelToBottom ?? DEFAULT_LABEL_BOTTOM;
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.setAttribute("aria-label", this.labelBottom);
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("http://www.w3.org/2000/svg", "svg");
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
- this.track = this.makeCircle("qsf-track");
137
- this.ring = this.makeCircle("qsf-bar");
138
- svg.append(this.track, this.ring);
139
- let len = 2 * Math.PI * RADIUS;
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
- this.el.addEventListener("pointerenter", this.onEnter);
156
- this.el.addEventListener("pointerleave", this.onLeave);
157
- this.el.addEventListener("pointerdown", this.onDown);
158
- this.el.addEventListener("pointermove", this.onMove);
159
- this.el.addEventListener("pointerup", this.onUp);
160
- this.el.addEventListener("pointercancel", this.onCancel);
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.onRecheck();
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.scrollTo(0);
246
+ this.animateScroll("top");
184
247
  }
185
248
  destroy() {
186
- this.el.removeEventListener("pointerenter", this.onEnter);
187
- this.el.removeEventListener("pointerleave", this.onLeave);
188
- this.el.removeEventListener("pointerdown", this.onDown);
189
- this.el.removeEventListener("pointermove", this.onMove);
190
- this.el.removeEventListener("pointerup", this.onUp);
191
- this.el.removeEventListener("pointercancel", this.onCancel);
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("http://www.w3.org/2000/svg", "circle");
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 !== "Enter" && e.key !== " ") return;
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(this.mode === "to-bottom" ? "to-top" : "to-bottom");
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.attachInterrupt();
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.detachInterrupt();
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.onInterrupt, { passive: true });
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.detachInterrupt();
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