rocket-back-to-top 1.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,154 @@
1
+ # rocket-back-to-top
2
+
3
+ 零依赖 Web Component:用一艘 Canvas 绘制的火箭「返回页面顶部」。
4
+
5
+ - 越接近页面底部,燃料越满,火焰与粒子越强
6
+ - 点击后原地喷射,页面平滑滚动跟随回顶
7
+ - 回顶过程中用户滚动可立即打断并交还控制权
8
+ - 可在原生 HTML / Vue / React / Angular 中直接使用
9
+
10
+ 自定义元素标签:`<rocket-to-top>`
11
+
12
+ ## 安装
13
+
14
+ ```bash
15
+ npm install rocket-back-to-top
16
+ # 或
17
+ yarn add rocket-back-to-top
18
+ # 或
19
+ pnpm add rocket-back-to-top
20
+ ```
21
+
22
+ ## 使用
23
+
24
+ ```js
25
+ import 'rocket-back-to-top';
26
+ ```
27
+
28
+ ```html
29
+ <rocket-to-top threshold="300" position="right" size="64"></rocket-to-top>
30
+ ```
31
+
32
+ CDN:
33
+
34
+ ```html
35
+ <script type="module">
36
+ import 'https://cdn.jsdelivr.net/npm/rocket-back-to-top/src/index.js';
37
+ </script>
38
+ <rocket-to-top></rocket-to-top>
39
+ ```
40
+
41
+ ## 属性
42
+
43
+ | 属性 | 类型 | 默认 | 说明 |
44
+ |------|------|------|------|
45
+ | `threshold` | number | `300` | 滚过多少 px 后显示 |
46
+ | `position` | `"right"` \| `"left"` | `"right"` | 贴在视口左/右下角 |
47
+ | `size` | number | `64` | 逻辑宽度(px),高度约为 `size * 1.85` |
48
+
49
+ ## 事件与方法
50
+
51
+ | 事件 | 说明 |
52
+ |------|------|
53
+ | `rocket-launch` | 开始喷射并回顶 |
54
+ | `rocket-arrive` | 已到达顶部 |
55
+ | `rocket-pause` | 回顶过程中被用户滚动打断 |
56
+
57
+ ```js
58
+ const el = document.querySelector('rocket-to-top');
59
+
60
+ el.addEventListener('rocket-launch', (e) => {
61
+ console.log('fuel', e.detail.fuel);
62
+ });
63
+ el.addEventListener('rocket-arrive', () => {
64
+ console.log('arrived');
65
+ });
66
+ el.addEventListener('rocket-pause', (e) => {
67
+ console.log('paused', e.detail.reason, e.detail.scrollY);
68
+ });
69
+
70
+ el.launch(); // 开始回顶
71
+ el.pause(); // 手动打断回顶
72
+ ```
73
+
74
+ ## 跨框架
75
+
76
+ ### Vue 3
77
+
78
+ ```vue
79
+ <script setup>
80
+ import 'rocket-back-to-top';
81
+ </script>
82
+
83
+ <template>
84
+ <rocket-to-top
85
+ threshold="300"
86
+ position="right"
87
+ size="64"
88
+ @rocket-launch="onLaunch"
89
+ @rocket-arrive="onArrive"
90
+ @rocket-pause="onPause"
91
+ />
92
+ </template>
93
+ ```
94
+
95
+ Vite 可配置:
96
+
97
+ ```js
98
+ vue: {
99
+ template: {
100
+ compilerOptions: {
101
+ isCustomElement: (tag) => tag === 'rocket-to-top',
102
+ },
103
+ },
104
+ }
105
+ ```
106
+
107
+ ### React
108
+
109
+ ```jsx
110
+ import { useEffect, useRef } from 'react';
111
+ import 'rocket-back-to-top';
112
+
113
+ export function BackToTop() {
114
+ const ref = useRef(null);
115
+
116
+ useEffect(() => {
117
+ const el = ref.current;
118
+ if (!el) return;
119
+ const onLaunch = () => console.log('launch');
120
+ const onArrive = () => console.log('arrive');
121
+ const onPause = () => console.log('pause');
122
+ el.addEventListener('rocket-launch', onLaunch);
123
+ el.addEventListener('rocket-arrive', onArrive);
124
+ el.addEventListener('rocket-pause', onPause);
125
+ return () => {
126
+ el.removeEventListener('rocket-launch', onLaunch);
127
+ el.removeEventListener('rocket-arrive', onArrive);
128
+ el.removeEventListener('rocket-pause', onPause);
129
+ };
130
+ }, []);
131
+
132
+ return <rocket-to-top ref={ref} threshold="300" position="right" size="64" />;
133
+ }
134
+ ```
135
+
136
+ ### Angular
137
+
138
+ ```ts
139
+ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
140
+ import 'rocket-back-to-top';
141
+
142
+ @NgModule({
143
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
144
+ })
145
+ export class AppModule {}
146
+ ```
147
+
148
+ ```html
149
+ <rocket-to-top threshold="300" position="right" size="64"></rocket-to-top>
150
+ ```
151
+
152
+ ## License
153
+
154
+ MIT
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "rocket-back-to-top",
3
+ "version": "1.0.0",
4
+ "description": "Zero-dependency Web Component: a rocket back-to-top button with fuel, flame and particle effects",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "module": "./src/index.js",
8
+ "types": "./types/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./types/index.d.ts",
12
+ "import": "./src/index.js",
13
+ "default": "./src/index.js"
14
+ },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "files": [
18
+ "src",
19
+ "types",
20
+ "LICENSE",
21
+ "README.md"
22
+ ],
23
+ "sideEffects": [
24
+ "./src/index.js",
25
+ "./src/rocket-to-top.js"
26
+ ],
27
+ "scripts": {
28
+ "demo": "npx --yes serve . -p 4173",
29
+ "pack:check": "npm pack --dry-run"
30
+ },
31
+ "keywords": [
32
+ "web-component",
33
+ "custom-element",
34
+ "back-to-top",
35
+ "scroll-to-top",
36
+ "rocket",
37
+ "canvas",
38
+ "vanilla-js",
39
+ "vue",
40
+ "react",
41
+ "angular"
42
+ ],
43
+ "author": "detectiveBoy",
44
+ "license": "MIT",
45
+ "repository": {
46
+ "type": "git",
47
+ "url": "git+https://gitee.com/detective-boy/rocket-back-to-top.git"
48
+ },
49
+ "bugs": {
50
+ "url": "https://gitee.com/detective-boy/rocket-back-to-top/issues"
51
+ },
52
+ "homepage": "https://gitee.com/detective-boy/rocket-back-to-top#readme",
53
+ "engines": {
54
+ "node": ">=16"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ }
59
+ }
package/src/index.js ADDED
@@ -0,0 +1,5 @@
1
+ /**
2
+ * rocket-back-to-top
3
+ * Zero-dependency Web Component: <rocket-to-top>
4
+ */
5
+ export { RocketToTop, RocketToTop as default } from './rocket-to-top.js';
@@ -0,0 +1,415 @@
1
+ /**
2
+ * Full-canvas rocket, fuel gauge, flame, and particle renderer.
3
+ */
4
+
5
+ const PARTICLE_COLORS = [
6
+ '#fff7c2',
7
+ '#ffe066',
8
+ '#ff9f1a',
9
+ '#ff6b00',
10
+ '#ff3d00',
11
+ '#ff1744',
12
+ ];
13
+
14
+ function rand(min, max) {
15
+ return min + Math.random() * (max - min);
16
+ }
17
+
18
+ function pick(arr) {
19
+ return arr[(Math.random() * arr.length) | 0];
20
+ }
21
+
22
+ export class RocketRenderer {
23
+ /**
24
+ * @param {HTMLCanvasElement} canvas
25
+ */
26
+ constructor(canvas) {
27
+ this.canvas = canvas;
28
+ this.ctx = canvas.getContext('2d');
29
+ this.particles = [];
30
+ this.time = 0;
31
+ this.fuel = 0;
32
+ this.thrust = 0;
33
+ this.visible = false;
34
+ this._logicalSize = 64;
35
+ this._dpr = 1;
36
+ }
37
+
38
+ setSize(logicalSize) {
39
+ this._logicalSize = logicalSize;
40
+ this.resize();
41
+ }
42
+
43
+ resize() {
44
+ const dpr = Math.min(window.devicePixelRatio || 1, 2);
45
+ this._dpr = dpr;
46
+ // Extra vertical room for flame + particles below rocket
47
+ const w = this._logicalSize;
48
+ const h = this._logicalSize * 1.85;
49
+ this.canvas.width = Math.round(w * dpr);
50
+ this.canvas.height = Math.round(h * dpr);
51
+ this.canvas.style.width = `${w}px`;
52
+ this.canvas.style.height = `${h}px`;
53
+ this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
54
+ }
55
+
56
+ /**
57
+ * @param {{ fuel: number, thrust: number, visible: boolean, dt: number }} state
58
+ */
59
+ render(state) {
60
+ this.fuel = state.fuel;
61
+ this.thrust = state.thrust;
62
+ this.visible = state.visible;
63
+ this.time += state.dt;
64
+
65
+ const ctx = this.ctx;
66
+ const w = this._logicalSize;
67
+ const h = this._logicalSize * 1.85;
68
+ ctx.clearRect(0, 0, w, h);
69
+
70
+ if (!this.visible && this.thrust <= 0.01) {
71
+ this.particles.length = 0;
72
+ return;
73
+ }
74
+
75
+ const rocketCenterX = w / 2;
76
+ // Layout: nose → body → nozzle; flame/particles emit from nozzle
77
+ const topY = w * 0.06;
78
+ const noseH = w * 0.22;
79
+ const bodyH = w * 0.52;
80
+ const nozzleH = w * 0.06;
81
+ const nozzleY = topY + noseH + bodyH + nozzleH;
82
+ const intensity = Math.max(this.fuel * 0.55, this.thrust);
83
+
84
+ this._spawnParticles(rocketCenterX, nozzleY, intensity, state.dt);
85
+ this._updateParticles(state.dt);
86
+ this._drawParticles(ctx);
87
+ this._drawFlame(ctx, rocketCenterX, nozzleY, intensity);
88
+ this._drawRocket(ctx, rocketCenterX, topY, w);
89
+ }
90
+
91
+ _spawnParticles(x, nozzleY, intensity, dt) {
92
+ if (intensity < 0.04) return;
93
+
94
+ const rate = 18 + intensity * 90;
95
+ const count = Math.floor(rate * dt + Math.random());
96
+
97
+ for (let i = 0; i < count; i++) {
98
+ const speed = rand(40, 80) + intensity * rand(80, 160);
99
+ const angle = Math.PI / 2 + rand(-0.45, 0.45);
100
+ this.particles.push({
101
+ x: x + rand(-4, 4),
102
+ y: nozzleY + rand(0, 4),
103
+ vx: Math.cos(angle) * speed * rand(0.3, 0.7) + rand(-20, 20),
104
+ vy: Math.sin(angle) * speed,
105
+ life: rand(0.25, 0.55 + intensity * 0.35),
106
+ maxLife: 0,
107
+ size: rand(1.2, 2.8) + intensity * rand(0.5, 2.2),
108
+ color: pick(PARTICLE_COLORS),
109
+ spin: rand(-6, 6),
110
+ });
111
+ this.particles[this.particles.length - 1].maxLife =
112
+ this.particles[this.particles.length - 1].life;
113
+ }
114
+
115
+ // Cap particle count for performance
116
+ if (this.particles.length > 220) {
117
+ this.particles.splice(0, this.particles.length - 220);
118
+ }
119
+ }
120
+
121
+ _updateParticles(dt) {
122
+ for (let i = this.particles.length - 1; i >= 0; i--) {
123
+ const p = this.particles[i];
124
+ p.life -= dt;
125
+ if (p.life <= 0) {
126
+ this.particles.splice(i, 1);
127
+ continue;
128
+ }
129
+ p.x += p.vx * dt;
130
+ p.y += p.vy * dt;
131
+ p.vx *= 0.98;
132
+ p.vy += 30 * dt;
133
+ p.vx += Math.sin(this.time * 12 + p.spin) * 8 * dt;
134
+ }
135
+ }
136
+
137
+ _drawParticles(ctx) {
138
+ for (const p of this.particles) {
139
+ const alpha = clamp01(p.life / p.maxLife);
140
+ ctx.globalAlpha = alpha * 0.9;
141
+ ctx.fillStyle = p.color;
142
+ ctx.beginPath();
143
+ ctx.arc(p.x, p.y, p.size * (0.5 + alpha * 0.5), 0, Math.PI * 2);
144
+ ctx.fill();
145
+ }
146
+ ctx.globalAlpha = 1;
147
+ }
148
+
149
+ _drawFlame(ctx, x, nozzleY, intensity) {
150
+ if (intensity < 0.03) return;
151
+
152
+ const flicker = 0.85 + Math.sin(this.time * 28) * 0.08 + Math.sin(this.time * 47) * 0.07;
153
+ const power = intensity * flicker;
154
+ const flameH = (14 + power * 42) * (0.9 + this.thrust * 0.35);
155
+ const flameW = 6 + power * 14;
156
+
157
+ ctx.save();
158
+ ctx.translate(x, nozzleY);
159
+ ctx.globalCompositeOperation = 'lighter';
160
+
161
+ // Outer glow
162
+ const glow = ctx.createRadialGradient(0, flameH * 0.35, 1, 0, flameH * 0.2, flameW * 1.8);
163
+ glow.addColorStop(0, `rgba(255, 120, 20, ${0.35 * power})`);
164
+ glow.addColorStop(1, 'rgba(255, 40, 0, 0)');
165
+ ctx.fillStyle = glow;
166
+ ctx.beginPath();
167
+ ctx.ellipse(0, flameH * 0.35, flameW * 1.6, flameH * 0.7, 0, 0, Math.PI * 2);
168
+ ctx.fill();
169
+
170
+ // Outer cone
171
+ this._flameCone(ctx, flameW * 1.15, flameH, [
172
+ [0, `rgba(255, 60, 0, ${0.55 * power})`],
173
+ [0.45, `rgba(255, 100, 10, ${0.4 * power})`],
174
+ [1, 'rgba(255, 40, 0, 0)'],
175
+ ]);
176
+
177
+ // Mid cone
178
+ this._flameCone(ctx, flameW * 0.72, flameH * 0.82, [
179
+ [0, `rgba(255, 180, 40, ${0.75 * power})`],
180
+ [0.5, `rgba(255, 120, 20, ${0.45 * power})`],
181
+ [1, 'rgba(255, 80, 0, 0)'],
182
+ ]);
183
+
184
+ // Core
185
+ this._flameCone(ctx, flameW * 0.38, flameH * 0.55, [
186
+ [0, `rgba(255, 255, 240, ${0.95 * power})`],
187
+ [0.35, `rgba(255, 240, 160, ${0.8 * power})`],
188
+ [1, 'rgba(255, 200, 60, 0)'],
189
+ ]);
190
+
191
+ ctx.restore();
192
+ }
193
+
194
+ _flameCone(ctx, halfW, height, stops) {
195
+ const sway = Math.sin(this.time * 22) * halfW * 0.12;
196
+ const path = new Path2D();
197
+ path.moveTo(-halfW * 0.55, 0);
198
+ path.quadraticCurveTo(-halfW + sway * 0.3, height * 0.4, sway * 0.5, height);
199
+ path.quadraticCurveTo(halfW + sway * 0.3, height * 0.4, halfW * 0.55, 0);
200
+ path.closePath();
201
+
202
+ const grad = ctx.createLinearGradient(0, 0, 0, height);
203
+ for (const [t, color] of stops) grad.addColorStop(t, color);
204
+ ctx.fillStyle = grad;
205
+ ctx.fill(path);
206
+ }
207
+
208
+ _drawRocket(ctx, cx, topY, size) {
209
+ const bodyW = size * 0.28;
210
+ const bodyH = size * 0.52;
211
+ const noseH = size * 0.22;
212
+ const finH = size * 0.18;
213
+ const bodyTop = topY + noseH;
214
+ const bodyBottom = bodyTop + bodyH;
215
+
216
+ ctx.save();
217
+
218
+ // Soft shadow under rocket
219
+ ctx.fillStyle = 'rgba(0,0,0,0.18)';
220
+ ctx.beginPath();
221
+ ctx.ellipse(cx, bodyBottom + 6, bodyW * 0.7, 4, 0, 0, Math.PI * 2);
222
+ ctx.fill();
223
+
224
+ // Fins
225
+ this._drawFin(ctx, cx - bodyW * 0.55, bodyBottom - finH * 0.2, -1, finH, bodyW);
226
+ this._drawFin(ctx, cx + bodyW * 0.55, bodyBottom - finH * 0.2, 1, finH, bodyW);
227
+
228
+ // Body cylinder with metallic gradient
229
+ const bodyGrad = ctx.createLinearGradient(cx - bodyW / 2, 0, cx + bodyW / 2, 0);
230
+ bodyGrad.addColorStop(0, '#7a8699');
231
+ bodyGrad.addColorStop(0.18, '#e8edf5');
232
+ bodyGrad.addColorStop(0.4, '#f7f9fc');
233
+ bodyGrad.addColorStop(0.62, '#c5cedb');
234
+ bodyGrad.addColorStop(0.85, '#8b96a8');
235
+ bodyGrad.addColorStop(1, '#5c6678');
236
+
237
+ roundRect(ctx, cx - bodyW / 2, bodyTop, bodyW, bodyH, 3);
238
+ ctx.fillStyle = bodyGrad;
239
+ ctx.fill();
240
+
241
+ // Body outline
242
+ ctx.strokeStyle = 'rgba(40,50,70,0.35)';
243
+ ctx.lineWidth = 1;
244
+ ctx.stroke();
245
+
246
+ // Horizontal panel lines
247
+ ctx.strokeStyle = 'rgba(60,70,90,0.25)';
248
+ ctx.lineWidth = 0.8;
249
+ for (let i = 1; i <= 3; i++) {
250
+ const y = bodyTop + (bodyH * i) / 4;
251
+ ctx.beginPath();
252
+ ctx.moveTo(cx - bodyW / 2 + 2, y);
253
+ ctx.lineTo(cx + bodyW / 2 - 2, y);
254
+ ctx.stroke();
255
+ }
256
+
257
+ // Red accent stripe
258
+ const stripeY = bodyTop + bodyH * 0.38;
259
+ const stripeGrad = ctx.createLinearGradient(cx - bodyW / 2, 0, cx + bodyW / 2, 0);
260
+ stripeGrad.addColorStop(0, '#a31212');
261
+ stripeGrad.addColorStop(0.5, '#e53935');
262
+ stripeGrad.addColorStop(1, '#8e0e0e');
263
+ ctx.fillStyle = stripeGrad;
264
+ ctx.fillRect(cx - bodyW / 2, stripeY, bodyW, bodyH * 0.1);
265
+
266
+ // Fuel gauge on left side of body
267
+ this._drawFuelGauge(ctx, cx - bodyW / 2 - size * 0.06, bodyTop + bodyH * 0.15, size * 0.05, bodyH * 0.55);
268
+
269
+ // Window
270
+ const winR = bodyW * 0.22;
271
+ const winY = bodyTop + bodyH * 0.22;
272
+ const winGrad = ctx.createRadialGradient(cx - winR * 0.3, winY - winR * 0.3, 1, cx, winY, winR);
273
+ winGrad.addColorStop(0, '#b3e5fc');
274
+ winGrad.addColorStop(0.55, '#0288d1');
275
+ winGrad.addColorStop(1, '#01579b');
276
+ ctx.beginPath();
277
+ ctx.arc(cx, winY, winR, 0, Math.PI * 2);
278
+ ctx.fillStyle = winGrad;
279
+ ctx.fill();
280
+ ctx.strokeStyle = '#37474f';
281
+ ctx.lineWidth = 1.4;
282
+ ctx.stroke();
283
+ // Window highlight
284
+ ctx.beginPath();
285
+ ctx.arc(cx - winR * 0.35, winY - winR * 0.35, winR * 0.28, 0, Math.PI * 2);
286
+ ctx.fillStyle = 'rgba(255,255,255,0.55)';
287
+ ctx.fill();
288
+
289
+ // Nose cone
290
+ const noseGrad = ctx.createLinearGradient(cx - bodyW / 2, topY, cx + bodyW / 2, bodyTop);
291
+ noseGrad.addColorStop(0, '#c62828');
292
+ noseGrad.addColorStop(0.45, '#ef5350');
293
+ noseGrad.addColorStop(1, '#8e0000');
294
+ ctx.beginPath();
295
+ ctx.moveTo(cx, topY);
296
+ ctx.quadraticCurveTo(cx + bodyW * 0.55, bodyTop - noseH * 0.15, cx + bodyW / 2, bodyTop);
297
+ ctx.lineTo(cx - bodyW / 2, bodyTop);
298
+ ctx.quadraticCurveTo(cx - bodyW * 0.55, bodyTop - noseH * 0.15, cx, topY);
299
+ ctx.closePath();
300
+ ctx.fillStyle = noseGrad;
301
+ ctx.fill();
302
+ ctx.strokeStyle = 'rgba(80,0,0,0.35)';
303
+ ctx.lineWidth = 1;
304
+ ctx.stroke();
305
+
306
+ // Nose tip highlight
307
+ ctx.beginPath();
308
+ ctx.moveTo(cx - 1, topY + 4);
309
+ ctx.lineTo(cx - bodyW * 0.12, bodyTop - 4);
310
+ ctx.strokeStyle = 'rgba(255,255,255,0.35)';
311
+ ctx.lineWidth = 1.5;
312
+ ctx.stroke();
313
+
314
+ // Nozzle
315
+ const nzW = bodyW * 0.55;
316
+ const nzH = size * 0.06;
317
+ const nzGrad = ctx.createLinearGradient(cx - nzW / 2, 0, cx + nzW / 2, 0);
318
+ nzGrad.addColorStop(0, '#455a64');
319
+ nzGrad.addColorStop(0.5, '#90a4ae');
320
+ nzGrad.addColorStop(1, '#37474f');
321
+ ctx.beginPath();
322
+ ctx.moveTo(cx - nzW * 0.35, bodyBottom);
323
+ ctx.lineTo(cx + nzW * 0.35, bodyBottom);
324
+ ctx.lineTo(cx + nzW / 2, bodyBottom + nzH);
325
+ ctx.lineTo(cx - nzW / 2, bodyBottom + nzH);
326
+ ctx.closePath();
327
+ ctx.fillStyle = nzGrad;
328
+ ctx.fill();
329
+
330
+ ctx.restore();
331
+ }
332
+
333
+ _drawFin(ctx, x, y, dir, finH, bodyW) {
334
+ const finW = bodyW * 0.55;
335
+ const grad = ctx.createLinearGradient(x, y, x + dir * finW, y + finH);
336
+ grad.addColorStop(0, '#d32f2f');
337
+ grad.addColorStop(1, '#6d0f0f');
338
+ ctx.beginPath();
339
+ ctx.moveTo(x, y);
340
+ ctx.lineTo(x + dir * finW, y + finH * 0.85);
341
+ ctx.lineTo(x + dir * finW * 0.15, y + finH);
342
+ ctx.lineTo(x, y + finH * 0.55);
343
+ ctx.closePath();
344
+ ctx.fillStyle = grad;
345
+ ctx.fill();
346
+ ctx.strokeStyle = 'rgba(60,0,0,0.4)';
347
+ ctx.lineWidth = 0.8;
348
+ ctx.stroke();
349
+ }
350
+
351
+ _drawFuelGauge(ctx, x, y, w, h) {
352
+ // Frame
353
+ ctx.fillStyle = 'rgba(30,35,45,0.85)';
354
+ roundRect(ctx, x - 1, y - 1, w + 2, h + 2, 2);
355
+ ctx.fill();
356
+
357
+ ctx.fillStyle = '#1a1f2a';
358
+ roundRect(ctx, x, y, w, h, 1.5);
359
+ ctx.fill();
360
+
361
+ const fillH = h * clamp01(this.fuel);
362
+ if (fillH > 0.5) {
363
+ const fy = y + h - fillH;
364
+ const fuelGrad = ctx.createLinearGradient(x, fy, x, y + h);
365
+ const hot = this.fuel > 0.7;
366
+ fuelGrad.addColorStop(0, hot ? '#fff59d' : '#ffb74d');
367
+ fuelGrad.addColorStop(0.4, hot ? '#ffca28' : '#ff9800');
368
+ fuelGrad.addColorStop(1, hot ? '#ff6f00' : '#e65100');
369
+ ctx.fillStyle = fuelGrad;
370
+ roundRect(ctx, x, fy, w, fillH, 1.5);
371
+ ctx.fill();
372
+
373
+ // Shine
374
+ ctx.fillStyle = 'rgba(255,255,255,0.25)';
375
+ ctx.fillRect(x + 1, fy + 1, w * 0.35, Math.max(0, fillH - 2));
376
+ }
377
+
378
+ // Tick marks
379
+ ctx.strokeStyle = 'rgba(255,255,255,0.25)';
380
+ ctx.lineWidth = 0.6;
381
+ for (let i = 1; i < 4; i++) {
382
+ const ty = y + (h * i) / 4;
383
+ ctx.beginPath();
384
+ ctx.moveTo(x, ty);
385
+ ctx.lineTo(x + w * 0.4, ty);
386
+ ctx.stroke();
387
+ }
388
+
389
+ // Glow when full
390
+ if (this.fuel > 0.92) {
391
+ ctx.shadowColor = '#ffab00';
392
+ ctx.shadowBlur = 6;
393
+ ctx.strokeStyle = 'rgba(255,200,50,0.6)';
394
+ ctx.lineWidth = 1;
395
+ roundRect(ctx, x - 1, y - 1, w + 2, h + 2, 2);
396
+ ctx.stroke();
397
+ ctx.shadowBlur = 0;
398
+ }
399
+ }
400
+ }
401
+
402
+ function clamp01(v) {
403
+ return Math.min(1, Math.max(0, v));
404
+ }
405
+
406
+ function roundRect(ctx, x, y, w, h, r) {
407
+ const rr = Math.min(r, w / 2, h / 2);
408
+ ctx.beginPath();
409
+ ctx.moveTo(x + rr, y);
410
+ ctx.arcTo(x + w, y, x + w, y + h, rr);
411
+ ctx.arcTo(x + w, y + h, x, y + h, rr);
412
+ ctx.arcTo(x, y + h, x, y, rr);
413
+ ctx.arcTo(x, y, x + w, y, rr);
414
+ ctx.closePath();
415
+ }
@@ -0,0 +1,341 @@
1
+ import { RocketRenderer } from './rocket-renderer.js';
2
+ import {
3
+ getScrollProgress,
4
+ animateScrollToTop,
5
+ clamp,
6
+ } from './scroll-controller.js';
7
+
8
+ const STYLES = `
9
+ :host {
10
+ position: fixed;
11
+ z-index: 9999;
12
+ bottom: 28px;
13
+ display: block;
14
+ width: var(--rtt-size, 64px);
15
+ height: calc(var(--rtt-size, 64px) * 1.85);
16
+ pointer-events: none;
17
+ opacity: 0;
18
+ transform: translateY(12px) scale(0.92);
19
+ transition: opacity 0.28s ease, transform 0.28s ease;
20
+ }
21
+ :host([position="left"]) {
22
+ left: 24px;
23
+ right: auto;
24
+ }
25
+ :host(:not([position="left"])) {
26
+ right: 24px;
27
+ left: auto;
28
+ }
29
+ :host([data-visible]) {
30
+ opacity: 1;
31
+ transform: translateY(0) scale(1);
32
+ pointer-events: auto;
33
+ }
34
+ :host([data-launching]) {
35
+ pointer-events: none;
36
+ }
37
+ button {
38
+ all: unset;
39
+ box-sizing: border-box;
40
+ display: block;
41
+ width: 100%;
42
+ height: 100%;
43
+ cursor: pointer;
44
+ border-radius: 12px;
45
+ }
46
+ button:focus-visible {
47
+ outline: 2px solid #ff9800;
48
+ outline-offset: 4px;
49
+ }
50
+ canvas {
51
+ display: block;
52
+ width: 100%;
53
+ height: 100%;
54
+ pointer-events: none;
55
+ }
56
+ `;
57
+
58
+ /**
59
+ * <rocket-to-top threshold="300" position="right" size="64"></rocket-to-top>
60
+ *
61
+ * Events:
62
+ * - rocket-launch
63
+ * - rocket-arrive
64
+ * - rocket-pause (用户滚动打断回顶动画)
65
+ */
66
+ export class RocketToTop extends HTMLElement {
67
+ static get observedAttributes() {
68
+ return ['threshold', 'position', 'size'];
69
+ }
70
+
71
+ constructor() {
72
+ super();
73
+ this._root = this.attachShadow({ mode: 'open' });
74
+ this._fuel = 0;
75
+ this._thrust = 0;
76
+ this._visible = false;
77
+ this._launching = false;
78
+ this._rafId = 0;
79
+ this._lastTs = 0;
80
+ this._cancelScroll = null;
81
+ this._onScroll = this._onScroll.bind(this);
82
+ this._onResize = this._onResize.bind(this);
83
+ this._loop = this._loop.bind(this);
84
+ this._onClick = this._onClick.bind(this);
85
+ this._onKeyDown = this._onKeyDown.bind(this);
86
+ }
87
+
88
+ connectedCallback() {
89
+ this._renderDom();
90
+ this._applySize();
91
+ this._renderer = new RocketRenderer(this._canvas);
92
+ this._renderer.setSize(this.size);
93
+ this._onScroll();
94
+ window.addEventListener('scroll', this._onScroll, { passive: true });
95
+ window.addEventListener('resize', this._onResize, { passive: true });
96
+ this._btn.addEventListener('click', this._onClick);
97
+ this._btn.addEventListener('keydown', this._onKeyDown);
98
+ this._startLoop();
99
+ }
100
+
101
+ disconnectedCallback() {
102
+ window.removeEventListener('scroll', this._onScroll);
103
+ window.removeEventListener('resize', this._onResize);
104
+ this._btn?.removeEventListener('click', this._onClick);
105
+ this._btn?.removeEventListener('keydown', this._onKeyDown);
106
+ this._stopLoop();
107
+ this._cancelScroll?.();
108
+ this._cancelScroll = null;
109
+ this._launching = false;
110
+ this.removeAttribute('data-launching');
111
+ }
112
+
113
+ attributeChangedCallback(name) {
114
+ if (!this._canvas) return;
115
+ if (name === 'size') {
116
+ this._applySize();
117
+ this._renderer?.setSize(this.size);
118
+ }
119
+ if (name === 'threshold' || name === 'position') {
120
+ this._onScroll();
121
+ }
122
+ }
123
+
124
+ get threshold() {
125
+ const v = Number(this.getAttribute('threshold'));
126
+ return Number.isFinite(v) && v >= 0 ? v : 300;
127
+ }
128
+
129
+ set threshold(v) {
130
+ this.setAttribute('threshold', String(v));
131
+ }
132
+
133
+ get position() {
134
+ return this.getAttribute('position') === 'left' ? 'left' : 'right';
135
+ }
136
+
137
+ set position(v) {
138
+ this.setAttribute('position', v === 'left' ? 'left' : 'right');
139
+ }
140
+
141
+ get size() {
142
+ const v = Number(this.getAttribute('size'));
143
+ return Number.isFinite(v) && v > 16 ? v : 64;
144
+ }
145
+
146
+ set size(v) {
147
+ this.setAttribute('size', String(v));
148
+ }
149
+
150
+ _renderDom() {
151
+ if (this._canvas) return;
152
+ const style = document.createElement('style');
153
+ style.textContent = STYLES;
154
+ this._btn = document.createElement('button');
155
+ this._btn.type = 'button';
156
+ this._btn.setAttribute('aria-label', '返回顶部');
157
+ this._btn.setAttribute('title', '火箭返回顶部');
158
+ this._canvas = document.createElement('canvas');
159
+ this._btn.appendChild(this._canvas);
160
+ this._root.append(style, this._btn);
161
+ }
162
+
163
+ _applySize() {
164
+ this.style.setProperty('--rtt-size', `${this.size}px`);
165
+ }
166
+
167
+ _onScroll() {
168
+ if (this._launching) return;
169
+ const { scrollY, progress } = getScrollProgress();
170
+ this._fuel = progress;
171
+ const shouldShow = scrollY > this.threshold;
172
+ this._setVisible(shouldShow);
173
+ }
174
+
175
+ _onResize() {
176
+ this._renderer?.resize();
177
+ this._onScroll();
178
+ }
179
+
180
+ _setVisible(show) {
181
+ this._visible = show;
182
+ if (show) {
183
+ this.setAttribute('data-visible', '');
184
+ } else {
185
+ this.removeAttribute('data-visible');
186
+ }
187
+ if (show || this._thrust > 0.01) {
188
+ this._startLoop();
189
+ }
190
+ }
191
+
192
+ _onClick(e) {
193
+ e.preventDefault();
194
+ this.launch();
195
+ }
196
+
197
+ _onKeyDown(e) {
198
+ if (e.key === 'Enter' || e.key === ' ') {
199
+ e.preventDefault();
200
+ this.launch();
201
+ }
202
+ }
203
+
204
+ /**
205
+ * 打断回顶:停止滚动动画,火焰熄灭,把滚动控制权还给用户。
206
+ * @param {string} [reason]
207
+ */
208
+ pause(reason = 'manual') {
209
+ if (!this._launching) return;
210
+ this._cancelScroll?.();
211
+ this._cancelScroll = null;
212
+ this._finishLaunchInterrupted(reason);
213
+ }
214
+
215
+ _finishLaunchInterrupted(reason) {
216
+ this._launching = false;
217
+ this.removeAttribute('data-launching');
218
+ this._thrust = 0;
219
+ const { scrollY, progress } = getScrollProgress();
220
+ this._fuel = progress;
221
+ this._setVisible(scrollY > this.threshold);
222
+ this._startLoop();
223
+ this.dispatchEvent(
224
+ new CustomEvent('rocket-pause', {
225
+ bubbles: true,
226
+ composed: true,
227
+ detail: { reason, scrollY, fuel: progress },
228
+ })
229
+ );
230
+ }
231
+
232
+ /** Public API: start launch / scroll to top */
233
+ launch() {
234
+ if (this._launching) return;
235
+ const { scrollY } = getScrollProgress();
236
+ if (scrollY <= 0) return;
237
+
238
+ this._launching = true;
239
+ this.setAttribute('data-launching', '');
240
+ this._setVisible(true);
241
+ this._thrust = 1;
242
+ this._startLoop();
243
+
244
+ this.dispatchEvent(
245
+ new CustomEvent('rocket-launch', {
246
+ bubbles: true,
247
+ composed: true,
248
+ detail: { fuel: this._fuel, scrollY },
249
+ })
250
+ );
251
+
252
+ const durationMs = clamp(900 + scrollY * 0.35, 1000, 2200);
253
+
254
+ this._cancelScroll = animateScrollToTop({
255
+ durationMs,
256
+ onUpdate: (t, y) => {
257
+ if (!this._launching) return;
258
+ // Strong thrust at start, taper as we approach top; keep flame lively
259
+ this._thrust = clamp(1 - t * 0.55, 0.35, 1);
260
+ const { progress } = getScrollProgress();
261
+ this._fuel = progress;
262
+ if (y <= this.threshold) {
263
+ this._setVisible(true);
264
+ }
265
+ },
266
+ onInterrupt: (reason) => {
267
+ this._cancelScroll = null;
268
+ this._finishLaunchInterrupted(reason);
269
+ },
270
+ onComplete: () => {
271
+ this._cancelScroll = null;
272
+ this._thrust = 0;
273
+ this._fuel = 0;
274
+ this._launching = false;
275
+ this.removeAttribute('data-launching');
276
+ this._setVisible(false);
277
+ this.dispatchEvent(
278
+ new CustomEvent('rocket-arrive', {
279
+ bubbles: true,
280
+ composed: true,
281
+ })
282
+ );
283
+ },
284
+ });
285
+ }
286
+
287
+ _startLoop() {
288
+ if (this._rafId) return;
289
+ this._lastTs = performance.now();
290
+ this._rafId = requestAnimationFrame(this._loop);
291
+ }
292
+
293
+ _stopLoop() {
294
+ if (this._rafId) {
295
+ cancelAnimationFrame(this._rafId);
296
+ this._rafId = 0;
297
+ }
298
+ }
299
+
300
+ _loop(ts) {
301
+ const dt = clamp((ts - this._lastTs) / 1000, 0, 0.05);
302
+ this._lastTs = ts;
303
+
304
+ // Idle flame subtle pulse from fuel
305
+ let thrust = this._thrust;
306
+ if (!this._launching && this._visible) {
307
+ thrust = Math.max(thrust, this._fuel * 0.25);
308
+ }
309
+
310
+ this._renderer.render({
311
+ fuel: this._fuel,
312
+ thrust,
313
+ visible: this._visible || this._launching,
314
+ dt,
315
+ });
316
+
317
+ const needContinue =
318
+ this._visible ||
319
+ this._launching ||
320
+ this._thrust > 0.01 ||
321
+ (this._renderer.particles?.length ?? 0) > 0;
322
+
323
+ if (needContinue) {
324
+ this._rafId = requestAnimationFrame(this._loop);
325
+ } else {
326
+ this._rafId = 0;
327
+ this._renderer.render({
328
+ fuel: 0,
329
+ thrust: 0,
330
+ visible: false,
331
+ dt: 0,
332
+ });
333
+ }
334
+ }
335
+ }
336
+
337
+ if (!customElements.get('rocket-to-top')) {
338
+ customElements.define('rocket-to-top', RocketToTop);
339
+ }
340
+
341
+ export default RocketToTop;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Scroll progress tracking and smooth scroll-to-top animation.
3
+ */
4
+
5
+ export function clamp(value, min, max) {
6
+ return Math.min(max, Math.max(min, value));
7
+ }
8
+
9
+ export function getScrollProgress() {
10
+ const doc = document.documentElement;
11
+ const scrollY = window.scrollY || doc.scrollTop || 0;
12
+ const maxScroll = Math.max(1, doc.scrollHeight - window.innerHeight);
13
+ return {
14
+ scrollY,
15
+ maxScroll,
16
+ progress: clamp(scrollY / maxScroll, 0, 1),
17
+ };
18
+ }
19
+
20
+ /**
21
+ * Ease-out cubic for natural deceleration.
22
+ */
23
+ export function easeOutCubic(t) {
24
+ return 1 - Math.pow(1 - t, 3);
25
+ }
26
+
27
+ const NAV_KEYS = new Set([
28
+ 'ArrowUp',
29
+ 'ArrowDown',
30
+ 'PageUp',
31
+ 'PageDown',
32
+ 'Home',
33
+ 'End',
34
+ ' ',
35
+ 'Spacebar',
36
+ ]);
37
+
38
+ /**
39
+ * Animate window scroll from current position to top.
40
+ * User wheel / touch / nav keys / scrollbar drag will interrupt and call onInterrupt.
41
+ *
42
+ * @param {object} options
43
+ * @param {number} options.durationMs
44
+ * @param {(t: number, scrollY: number) => void} options.onUpdate - t in [0,1]
45
+ * @param {() => void} options.onComplete
46
+ * @param {(reason: string) => void} options.onInterrupt
47
+ * @returns {() => void} cancel function
48
+ */
49
+ export function animateScrollToTop({
50
+ durationMs = 1400,
51
+ onUpdate,
52
+ onComplete,
53
+ onInterrupt,
54
+ } = {}) {
55
+ const startY = window.scrollY || document.documentElement.scrollTop || 0;
56
+ if (startY <= 0) {
57
+ onUpdate?.(1, 0);
58
+ onComplete?.();
59
+ return () => {};
60
+ }
61
+
62
+ const startTime = performance.now();
63
+ let rafId = 0;
64
+ let cancelled = false;
65
+ let finished = false;
66
+ let expectedY = startY;
67
+ let programmatic = false;
68
+ let clearProgrammaticId = 0;
69
+
70
+ const cleanup = () => {
71
+ window.removeEventListener('scroll', onScrollCheck, true);
72
+ window.removeEventListener('wheel', onUserInput, true);
73
+ window.removeEventListener('touchmove', onUserInput, true);
74
+ window.removeEventListener('keydown', onKeyInterrupt, true);
75
+ if (clearProgrammaticId) cancelAnimationFrame(clearProgrammaticId);
76
+ };
77
+
78
+ const stop = (reason, interrupted) => {
79
+ if (cancelled || finished) return;
80
+ cancelled = true;
81
+ if (rafId) cancelAnimationFrame(rafId);
82
+ rafId = 0;
83
+ cleanup();
84
+ if (interrupted) {
85
+ onInterrupt?.(reason);
86
+ }
87
+ };
88
+
89
+ const onUserInput = () => {
90
+ stop('pointer', true);
91
+ };
92
+
93
+ const onKeyInterrupt = (e) => {
94
+ if (NAV_KEYS.has(e.key)) stop('keydown', true);
95
+ };
96
+
97
+ const onScrollCheck = () => {
98
+ if (cancelled || finished || programmatic) return;
99
+ const actual = window.scrollY || document.documentElement.scrollTop || 0;
100
+ // Scrollbar drag or other external scroll fighting the animation
101
+ if (Math.abs(actual - expectedY) > 6) {
102
+ stop('scroll', true);
103
+ }
104
+ };
105
+
106
+ const tick = (now) => {
107
+ if (cancelled) return;
108
+
109
+ const elapsed = now - startTime;
110
+ const t = clamp(elapsed / durationMs, 0, 1);
111
+ const eased = easeOutCubic(t);
112
+ const nextY = startY * (1 - eased);
113
+
114
+ programmatic = true;
115
+ expectedY = nextY;
116
+ window.scrollTo(0, nextY);
117
+ if (clearProgrammaticId) cancelAnimationFrame(clearProgrammaticId);
118
+ clearProgrammaticId = requestAnimationFrame(() => {
119
+ programmatic = false;
120
+ clearProgrammaticId = 0;
121
+ });
122
+
123
+ onUpdate?.(t, nextY);
124
+
125
+ if (t < 1) {
126
+ rafId = requestAnimationFrame(tick);
127
+ } else {
128
+ finished = true;
129
+ programmatic = true;
130
+ expectedY = 0;
131
+ window.scrollTo(0, 0);
132
+ cleanup();
133
+ onUpdate?.(1, 0);
134
+ onComplete?.();
135
+ }
136
+ };
137
+
138
+ window.addEventListener('scroll', onScrollCheck, { passive: true, capture: true });
139
+ window.addEventListener('wheel', onUserInput, { passive: true, capture: true });
140
+ window.addEventListener('touchmove', onUserInput, { passive: true, capture: true });
141
+ window.addEventListener('keydown', onKeyInterrupt, { capture: true });
142
+
143
+ rafId = requestAnimationFrame(tick);
144
+
145
+ return () => {
146
+ stop('cancel', false);
147
+ };
148
+ }
@@ -0,0 +1,56 @@
1
+ export interface RocketLaunchDetail {
2
+ fuel: number;
3
+ scrollY: number;
4
+ }
5
+
6
+ export interface RocketPauseDetail {
7
+ reason: string;
8
+ scrollY: number;
9
+ fuel: number;
10
+ }
11
+
12
+ export declare class RocketToTop extends HTMLElement {
13
+ static readonly observedAttributes: string[];
14
+
15
+ threshold: number;
16
+ position: 'left' | 'right';
17
+ size: number;
18
+
19
+ /** Start launch / smooth scroll to top */
20
+ launch(): void;
21
+
22
+ /** Interrupt launch and restore user scroll control */
23
+ pause(reason?: string): void;
24
+ }
25
+
26
+ declare global {
27
+ interface HTMLElementTagNameMap {
28
+ 'rocket-to-top': RocketToTop;
29
+ }
30
+
31
+ interface HTMLElementEventMap {
32
+ 'rocket-launch': CustomEvent<RocketLaunchDetail>;
33
+ 'rocket-arrive': CustomEvent<void>;
34
+ 'rocket-pause': CustomEvent<RocketPauseDetail>;
35
+ }
36
+ }
37
+
38
+ /** React / JSX consumers (optional, no react dependency required) */
39
+ declare namespace JSX {
40
+ interface IntrinsicElements {
41
+ 'rocket-to-top': {
42
+ threshold?: string | number;
43
+ position?: 'left' | 'right';
44
+ size?: string | number;
45
+ children?: unknown;
46
+ class?: string;
47
+ className?: string;
48
+ style?: unknown;
49
+ ref?: unknown;
50
+ key?: unknown;
51
+ [attr: string]: unknown;
52
+ };
53
+ }
54
+ }
55
+
56
+ export { RocketToTop as default };