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 +21 -0
- package/README.md +154 -0
- package/package.json +59 -0
- package/src/index.js +5 -0
- package/src/rocket-renderer.js +415 -0
- package/src/rocket-to-top.js +341 -0
- package/src/scroll-controller.js +148 -0
- package/types/index.d.ts +56 -0
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,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
|
+
}
|
package/types/index.d.ts
ADDED
|
@@ -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 };
|