@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/dist/index.mjs CHANGED
@@ -73,19 +73,28 @@ S16("<path d=\"m9 18 6-6-6-6\"/>");
73
73
  //#endregion
74
74
  //#region src/scroll-fab.ts
75
75
  /** 青梧UI ScrollFab 悬浮滚动栏:默认“滚动到底部”,桌面悬停/触屏按住绕满描边一圈翻转为“返回顶部”,点击(轻点)直接执行当前模式动作 */
76
+ const SVG_NS = "http://www.w3.org/2000/svg";
76
77
  const RADIUS = 21;
78
+ const FALLBACK_PATH_LEN = 100;
77
79
  const DEFAULT_LABEL_BOTTOM = "滚动到底部(悬停或按住绕圈可切换为返回顶部)";
78
80
  const DEFAULT_LABEL_TOP = "返回顶部(悬停或按住绕圈可切换为滚动到底部)";
81
+ const STATIC_LABEL_BOTTOM = "滚动到底部";
82
+ const STATIC_LABEL_TOP = "返回顶部";
79
83
  function easeInOutCubic(p) {
80
84
  return p < .5 ? 4 * p * p * p : 1 - (-2 * p + 2) ** 3 / 2;
81
85
  }
86
+ function isLenisInstance(v) {
87
+ return typeof v === "object" && v !== null && typeof v.scrollTo === "function";
88
+ }
82
89
  var ScrollFab = class {
83
90
  el;
84
91
  ring;
85
92
  track;
93
+ fillPath = null;
86
94
  icon;
87
95
  len;
88
96
  targetEl;
97
+ shape;
89
98
  ringMs;
90
99
  decayMs;
91
100
  cancelPx;
@@ -93,7 +102,18 @@ var ScrollFab = class {
93
102
  labelBottom;
94
103
  labelTop;
95
104
  onModeChange;
96
- mode = "to-bottom";
105
+ onLenisReady;
106
+ onScroll;
107
+ modes;
108
+ single;
109
+ threshold;
110
+ /** 外部注入:宿主实例,组件永不销毁 */
111
+ externalLenis = null;
112
+ /** 自建配置:null = 不走 lenis;true 形态也归一成对象 */
113
+ ownLenisConfig = null;
114
+ ownLenis = null;
115
+ ownLenisPromise = null;
116
+ mode;
97
117
  progress = 0;
98
118
  hovering = false;
99
119
  pressing = false;
@@ -104,41 +124,79 @@ var ScrollFab = class {
104
124
  ringRaf = null;
105
125
  lastTs = null;
106
126
  scrollRaf = null;
127
+ lenisLoopRaf = null;
128
+ lenisScrolling = false;
129
+ lenisDest = null;
107
130
  flashTimer = null;
108
131
  tapResetTimer = null;
109
132
  touchTap = false;
110
133
  ro = null;
134
+ actionSeq = 0;
135
+ destroyed = false;
136
+ emitRaf = null;
137
+ lastPct = -1;
111
138
  constructor(options = {}) {
112
139
  this.targetEl = options.target ?? null;
140
+ this.shape = options.shape ?? null;
113
141
  this.ringMs = Math.max(100, Math.min(5e3, options.ringDuration ?? 800));
114
142
  this.decayMs = Math.max(100, options.decayDuration ?? 300);
115
143
  this.cancelPx = Math.max(4, options.cancelThreshold ?? 10);
116
144
  this.animate = options.animate ?? true;
117
- this.labelBottom = options.ariaLabelToBottom ?? DEFAULT_LABEL_BOTTOM;
118
- this.labelTop = options.ariaLabelToTop ?? DEFAULT_LABEL_TOP;
145
+ this.threshold = Math.max(0, options.showThreshold ?? 0);
119
146
  this.onModeChange = options.onModeChange;
147
+ this.onLenisReady = options.onLenisReady;
148
+ this.onScroll = options.onScroll;
149
+ this.modes = this.normalizeModes(options.modes);
150
+ this.single = this.modes.length === 1;
151
+ this.mode = this.modes[0];
152
+ this.labelBottom = options.ariaLabelToBottom ?? (this.single ? STATIC_LABEL_BOTTOM : DEFAULT_LABEL_BOTTOM);
153
+ this.labelTop = options.ariaLabelToTop ?? (this.single ? STATIC_LABEL_TOP : DEFAULT_LABEL_TOP);
154
+ if (isLenisInstance(options.lenis)) this.externalLenis = options.lenis;
155
+ else if (options.lenis != null && options.lenis !== false) {
156
+ const cfg = options.lenis === true ? {} : options.lenis;
157
+ this.ownLenisConfig = {
158
+ smoothWheel: false,
159
+ ...cfg
160
+ };
161
+ }
120
162
  this.el = document.createElement("button");
121
163
  this.el.type = "button";
122
164
  this.el.className = `qsf-root${options.className ? ` ${options.className}` : ""}`;
123
165
  this.el.dataset.mode = this.mode;
124
- this.el.setAttribute("aria-label", this.labelBottom);
166
+ if (this.shape) this.el.dataset.shape = "on";
167
+ this.el.setAttribute("aria-label", this.mode === "to-bottom" ? this.labelBottom : this.labelTop);
125
168
  this.el.style.setProperty("--qsf-size", `${Math.max(44, options.size ?? 48)}px`);
126
169
  if (options.zIndex != null) this.el.style.setProperty("--qsf-z", String(options.zIndex));
127
170
  if (options.position?.right != null) this.el.style.setProperty("--qsf-right", `${options.position.right}px`);
128
171
  if (options.position?.bottom != null) this.el.style.setProperty("--qsf-bottom", `${options.position.bottom}px`);
129
172
  if (options.position?.left != null) this.el.style.setProperty("--qsf-left", `${options.position.left}px`);
130
173
  if (options.position?.top != null) this.el.style.setProperty("--qsf-top", `${options.position.top}px`);
131
- const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
174
+ const svg = document.createElementNS(SVG_NS, "svg");
132
175
  svg.setAttribute("class", "qsf-svg");
133
- svg.setAttribute("viewBox", "0 0 48 48");
176
+ svg.setAttribute("viewBox", this.shape?.viewBox ?? "0 0 48 48");
134
177
  svg.setAttribute("aria-hidden", "true");
135
- this.track = this.makeCircle("qsf-track");
136
- this.ring = this.makeCircle("qsf-bar");
137
- svg.append(this.track, this.ring);
138
- let len = 2 * Math.PI * RADIUS;
178
+ if (this.shape) {
179
+ this.fillPath = document.createElementNS(SVG_NS, "path");
180
+ this.fillPath.setAttribute("class", "qsf-shape-fill");
181
+ this.fillPath.setAttribute("d", this.shape.fill);
182
+ this.track = this.makePath("qsf-track", this.shape.outline);
183
+ this.ring = this.makePath("qsf-bar", this.shape.outline);
184
+ if (this.shape.outlineTransform) {
185
+ this.track.setAttribute("transform", this.shape.outlineTransform);
186
+ this.ring.setAttribute("transform", this.shape.outlineTransform);
187
+ }
188
+ svg.append(this.fillPath, this.track, this.ring);
189
+ } else {
190
+ this.track = this.makeCircle("qsf-track");
191
+ this.ring = this.makeCircle("qsf-bar");
192
+ svg.append(this.track, this.ring);
193
+ }
194
+ let len;
195
+ if (this.shape) len = FALLBACK_PATH_LEN;
196
+ else len = 2 * Math.PI * RADIUS;
139
197
  try {
140
198
  const measured = this.ring.getTotalLength();
141
- if (Number.isFinite(measured)) len = measured;
199
+ if (Number.isFinite(measured) && measured > 0) len = measured;
142
200
  } catch {}
143
201
  this.len = len;
144
202
  this.ring.style.strokeDasharray = String(this.len);
@@ -151,12 +209,14 @@ var ScrollFab = class {
151
209
  else this.icon.innerHTML = ICON_CHEVRON_DOWN;
152
210
  this.el.append(svg, this.icon);
153
211
  document.body.append(this.el);
154
- this.el.addEventListener("pointerenter", this.onEnter);
155
- this.el.addEventListener("pointerleave", this.onLeave);
156
- this.el.addEventListener("pointerdown", this.onDown);
157
- this.el.addEventListener("pointermove", this.onMove);
158
- this.el.addEventListener("pointerup", this.onUp);
159
- this.el.addEventListener("pointercancel", this.onCancel);
212
+ if (!this.single) {
213
+ this.el.addEventListener("pointerenter", this.onEnter);
214
+ this.el.addEventListener("pointerleave", this.onLeave);
215
+ this.el.addEventListener("pointerdown", this.onDown);
216
+ this.el.addEventListener("pointermove", this.onMove);
217
+ this.el.addEventListener("pointerup", this.onUp);
218
+ this.el.addEventListener("pointercancel", this.onCancel);
219
+ }
160
220
  this.el.addEventListener("keydown", this.onKey);
161
221
  this.el.addEventListener("click", this.onClick);
162
222
  window.addEventListener("resize", this.onRecheck);
@@ -166,48 +226,73 @@ var ScrollFab = class {
166
226
  this.ro.observe(this.targetEl ?? document.documentElement);
167
227
  }
168
228
  this.syncVisible();
229
+ this.emitScroll();
169
230
  }
170
231
  /** 当前模式 */
171
232
  get currentMode() {
172
233
  return this.mode;
173
234
  }
174
- /** 内容懒加载等外部高度变化后可显式触发重估(滚动/resize 时也会自动重估) */
235
+ /** 内容懒加载等外部高度变化后可显式触发重估(滚动/resize 时也会自动重估),并强制补发一次进度 */
175
236
  refresh() {
176
- this.onRecheck();
237
+ this.syncVisible();
238
+ this.lastPct = -1;
239
+ this.emitScroll();
177
240
  }
178
241
  scrollToBottom() {
179
242
  this.animateScroll("bottom");
180
243
  }
181
244
  scrollToTop() {
182
- this.scrollTo(0);
245
+ this.animateScroll("top");
183
246
  }
184
247
  destroy() {
185
- this.el.removeEventListener("pointerenter", this.onEnter);
186
- this.el.removeEventListener("pointerleave", this.onLeave);
187
- this.el.removeEventListener("pointerdown", this.onDown);
188
- this.el.removeEventListener("pointermove", this.onMove);
189
- this.el.removeEventListener("pointerup", this.onUp);
190
- this.el.removeEventListener("pointercancel", this.onCancel);
248
+ this.destroyed = true;
249
+ if (!this.single) {
250
+ this.el.removeEventListener("pointerenter", this.onEnter);
251
+ this.el.removeEventListener("pointerleave", this.onLeave);
252
+ this.el.removeEventListener("pointerdown", this.onDown);
253
+ this.el.removeEventListener("pointermove", this.onMove);
254
+ this.el.removeEventListener("pointerup", this.onUp);
255
+ this.el.removeEventListener("pointercancel", this.onCancel);
256
+ }
191
257
  this.el.removeEventListener("keydown", this.onKey);
192
258
  this.el.removeEventListener("click", this.onClick);
193
259
  window.removeEventListener("resize", this.onRecheck);
194
260
  (this.targetEl ?? window).removeEventListener("scroll", this.onRecheck);
195
261
  this.ro?.disconnect();
196
262
  this.cancelScroll();
263
+ this.stopLenisLoop();
264
+ this.ownLenis?.destroy?.();
265
+ this.ownLenis = null;
197
266
  if (this.ringRaf != null) cancelAnimationFrame(this.ringRaf);
198
267
  this.ringRaf = null;
268
+ if (this.emitRaf != null) cancelAnimationFrame(this.emitRaf);
269
+ this.emitRaf = null;
199
270
  if (this.flashTimer != null) clearTimeout(this.flashTimer);
200
271
  if (this.tapResetTimer != null) clearTimeout(this.tapResetTimer);
201
272
  this.el.remove();
202
273
  }
274
+ /** 规范化 modes:缺省双模式;去重、过滤非法值;结果为空直接抛错 */
275
+ normalizeModes(input) {
276
+ if (!input) return ["to-bottom", "to-top"];
277
+ const out = [];
278
+ for (const m of input) if ((m === "to-bottom" || m === "to-top") && !out.includes(m)) out.push(m);
279
+ if (out.length === 0) throw new TypeError("ScrollFab: modes 至少需要一个有效模式('to-bottom' | 'to-top')");
280
+ return out;
281
+ }
203
282
  makeCircle(cls) {
204
- const c = document.createElementNS("http://www.w3.org/2000/svg", "circle");
283
+ const c = document.createElementNS(SVG_NS, "circle");
205
284
  c.setAttribute("class", cls);
206
285
  c.setAttribute("cx", "24");
207
286
  c.setAttribute("cy", "24");
208
287
  c.setAttribute("r", String(RADIUS));
209
288
  return c;
210
289
  }
290
+ makePath(cls, d) {
291
+ const p = document.createElementNS(SVG_NS, "path");
292
+ p.setAttribute("class", cls);
293
+ p.setAttribute("d", d);
294
+ return p;
295
+ }
211
296
  onEnter = (e) => {
212
297
  if (e.pointerType !== "mouse") return;
213
298
  this.hovering = true;
@@ -267,9 +352,12 @@ var ScrollFab = class {
267
352
  this.doAction();
268
353
  };
269
354
  onKey = (e) => {
270
- if (e.repeat || e.key !== "Enter" && e.key !== " ") return;
355
+ if (e.repeat || e.key !== "ArrowUp" && e.key !== "ArrowDown") return;
356
+ if (this.single) return;
357
+ const want = e.key === "ArrowUp" ? "to-top" : "to-bottom";
358
+ if (want === this.mode) return;
271
359
  e.preventDefault();
272
- this.setMode(this.mode === "to-bottom" ? "to-top" : "to-bottom");
360
+ this.setMode(want);
273
361
  };
274
362
  holding() {
275
363
  return this.hovering || this.pressing;
@@ -321,12 +409,110 @@ var ScrollFab = class {
321
409
  this.animateScroll(this.mode === "to-bottom" ? "bottom" : "top");
322
410
  }
323
411
  animateScroll(dest) {
412
+ ++this.actionSeq;
324
413
  this.cancelScroll();
325
414
  if (!this.animate || this.reducedMotion()) {
326
415
  this.setScrollPos(this.targetPos(dest));
327
416
  return;
328
417
  }
329
- this.attachInterrupt();
418
+ if (this.externalLenis) {
419
+ this.scrollWithLenis(this.externalLenis, dest, false);
420
+ return;
421
+ }
422
+ if (this.ownLenisConfig) {
423
+ const seq = ++this.actionSeq;
424
+ this.resolveOwnLenis().then((lenis) => {
425
+ if (this.destroyed || seq !== this.actionSeq) return;
426
+ if (lenis) this.scrollWithLenis(lenis, dest, true);
427
+ else this.animateRaf(dest);
428
+ });
429
+ return;
430
+ }
431
+ this.animateRaf(dest);
432
+ }
433
+ /** 首次点击时动态 import lenis 并自建实例;未安装(或加载失败)→ null 永久回退 rAF */
434
+ resolveOwnLenis() {
435
+ if (this.ownLenis) return Promise.resolve(this.ownLenis);
436
+ if (this.ownLenisPromise) return this.ownLenisPromise;
437
+ this.ownLenisPromise = import("lenis").then((mod) => {
438
+ const LenisCtor = mod.default ?? mod;
439
+ const cfg = { ...this.ownLenisConfig };
440
+ if (this.targetEl) cfg.wrapper = this.targetEl;
441
+ this.ownLenis = new LenisCtor(cfg);
442
+ this.onLenisReady?.(this.ownLenis);
443
+ return this.ownLenis;
444
+ }).catch(() => {
445
+ this.ownLenisConfig = null;
446
+ this.ownLenisPromise = null;
447
+ return null;
448
+ });
449
+ return this.ownLenisPromise;
450
+ }
451
+ /**
452
+ * 用 Lenis 执行程序滚动。
453
+ * 自建实例(own):组件自跑 raf loop(Lenis 需外部驱动 raf),保留 wheel/touch/keydown 打断与到底追击;
454
+ * 外部实例:宿主自带 raf loop 与手势处理,组件只发 scrollTo,不追击不打断。
455
+ */
456
+ scrollWithLenis(lenis, dest, own) {
457
+ const immediate = !this.animate || this.reducedMotion();
458
+ const target = this.targetPos(dest);
459
+ this.lenisDest = dest;
460
+ if (own) {
461
+ lenis.start?.();
462
+ lenis.scrollTo(target, {
463
+ immediate,
464
+ onComplete: () => this.endLenisScroll()
465
+ });
466
+ if (!immediate) {
467
+ this.lenisScrolling = true;
468
+ this.attachInterrupt();
469
+ this.startLenisLoop(lenis, dest);
470
+ } else this.endLenisScroll();
471
+ } else {
472
+ lenis.scrollTo(target, { immediate });
473
+ this.lenisDest = null;
474
+ }
475
+ }
476
+ /** 自建实例的 raf 驱动 + 懒加载到底追击(对齐内置 rAF 语义) */
477
+ startLenisLoop(lenis, dest) {
478
+ let lastEnd = this.targetPos(dest);
479
+ const loop = (time) => {
480
+ if (!this.lenisScrolling) return;
481
+ if (dest === "bottom") {
482
+ const live = this.targetPos("bottom");
483
+ if (Math.abs(live - lastEnd) > 2) {
484
+ lastEnd = live;
485
+ lenis.scrollTo(live, {
486
+ force: true,
487
+ onComplete: () => this.endLenisScroll()
488
+ });
489
+ }
490
+ }
491
+ lenis.raf?.(time);
492
+ this.lenisLoopRaf = requestAnimationFrame(loop);
493
+ };
494
+ this.lenisLoopRaf = requestAnimationFrame(loop);
495
+ }
496
+ stopLenisLoop() {
497
+ if (this.lenisLoopRaf != null) {
498
+ cancelAnimationFrame(this.lenisLoopRaf);
499
+ this.lenisLoopRaf = null;
500
+ }
501
+ }
502
+ /** 打断自建 Lenis 程序滚动:stop 冻结动画(下次发起前 start 复位) */
503
+ onLenisInterrupt = () => {
504
+ if (!this.lenisScrolling) return;
505
+ this.ownLenis?.stop?.();
506
+ this.endLenisScroll();
507
+ };
508
+ endLenisScroll() {
509
+ this.lenisScrolling = false;
510
+ this.lenisDest = null;
511
+ this.stopLenisLoop();
512
+ this.detachInterrupt();
513
+ }
514
+ animateRaf(dest) {
515
+ this.attachRafInterrupt();
330
516
  let init = false;
331
517
  let start = 0;
332
518
  let end = 0;
@@ -353,7 +539,7 @@ var ScrollFab = class {
353
539
  if (p < 1) this.scrollRaf = requestAnimationFrame(step);
354
540
  else {
355
541
  this.scrollRaf = null;
356
- this.detachInterrupt();
542
+ this.detachRafInterrupt();
357
543
  }
358
544
  };
359
545
  this.scrollRaf = requestAnimationFrame(step);
@@ -367,15 +553,33 @@ var ScrollFab = class {
367
553
  interruptSource() {
368
554
  return this.targetEl ?? window;
369
555
  }
556
+ /** 自建 Lenis 路线的打断监听 */
370
557
  attachInterrupt() {
371
558
  const src = this.interruptSource();
372
559
  for (const ev of [
373
560
  "wheel",
374
561
  "touchstart",
375
562
  "keydown"
376
- ]) src.addEventListener(ev, this.onInterrupt, { passive: true });
563
+ ]) src.addEventListener(ev, this.onLenisInterrupt, { passive: true });
377
564
  }
378
565
  detachInterrupt() {
566
+ const src = this.interruptSource();
567
+ for (const ev of [
568
+ "wheel",
569
+ "touchstart",
570
+ "keydown"
571
+ ]) src.removeEventListener(ev, this.onLenisInterrupt);
572
+ }
573
+ /** 内置 rAF 路线的打断监听 */
574
+ attachRafInterrupt() {
575
+ const src = this.interruptSource();
576
+ for (const ev of [
577
+ "wheel",
578
+ "touchstart",
579
+ "keydown"
580
+ ]) src.addEventListener(ev, this.onInterrupt, { passive: true });
581
+ }
582
+ detachRafInterrupt() {
379
583
  const src = this.interruptSource();
380
584
  for (const ev of [
381
585
  "wheel",
@@ -388,7 +592,16 @@ var ScrollFab = class {
388
592
  cancelAnimationFrame(this.scrollRaf);
389
593
  this.scrollRaf = null;
390
594
  }
391
- this.detachInterrupt();
595
+ if (this.lenisScrolling) this.endLenisScroll();
596
+ const src = this.interruptSource();
597
+ for (const ev of [
598
+ "wheel",
599
+ "touchstart",
600
+ "keydown"
601
+ ]) {
602
+ src.removeEventListener(ev, this.onInterrupt);
603
+ src.removeEventListener(ev, this.onLenisInterrupt);
604
+ }
392
605
  }
393
606
  maxScroll() {
394
607
  if (this.targetEl) return Math.max(0, this.targetEl.scrollHeight - this.targetEl.clientHeight);
@@ -405,14 +618,28 @@ var ScrollFab = class {
405
618
  if (this.targetEl) this.targetEl.scrollTop = v;
406
619
  else window.scrollTo(0, v);
407
620
  }
408
- scrollTo(v) {
409
- this.animateScroll(v === 0 ? "top" : "bottom");
410
- }
411
621
  onRecheck = () => {
412
622
  this.syncVisible();
623
+ this.scheduleScroll();
413
624
  };
414
625
  syncVisible() {
415
- this.el.hidden = this.maxScroll() <= 1;
626
+ this.el.hidden = this.maxScroll() <= 1 || this.scrollPos() < this.threshold;
627
+ }
628
+ /** rAF 节流:一帧内多次重估只吐一次;按钮隐藏期间照常调度 */
629
+ scheduleScroll() {
630
+ if (this.emitRaf != null || this.destroyed) return;
631
+ this.emitRaf = requestAnimationFrame(() => {
632
+ this.emitRaf = null;
633
+ this.emitScroll();
634
+ });
635
+ }
636
+ emitScroll() {
637
+ if (this.destroyed) return;
638
+ const max = this.maxScroll();
639
+ const pct = max <= 0 ? 0 : Math.min(1, Math.max(0, this.scrollPos() / max));
640
+ if (pct === this.lastPct) return;
641
+ this.lastPct = pct;
642
+ this.onScroll?.(pct);
416
643
  }
417
644
  };
418
645
  //#endregion