@windcoder/astro-theme-orchid 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/scripts/generate-lqips.mjs +239 -20
- package/src/components/AlbumCard.astro +4 -1
- package/src/components/BaseLayout.astro +209 -64
- package/src/components/DisplaySettings.astro +383 -0
- package/src/components/Header.astro +56 -2
- package/src/components/MusicPlayer.astro +45 -5
- package/src/components/MusicWidget.astro +265 -0
- package/src/components/PhotoCard.astro +13 -1
- package/src/components/PostCard.astro +9 -2
- package/src/components/SakuraEffect.astro +126 -28
- package/src/components/Sidebar.astro +28 -7
- package/src/i18n/en.ts +36 -0
- package/src/i18n/ja.ts +213 -0
- package/src/i18n/ko.ts +212 -0
- package/src/i18n/resolve.ts +10 -1
- package/src/i18n/ru.ts +213 -0
- package/src/i18n/zh-CN.ts +37 -0
- package/src/i18n/zh-TW.ts +208 -0
- package/src/index.ts +24 -0
- package/src/plugins/rehype.ts +66 -2
- package/src/routes/friends.astro +10 -2
- package/src/routes/page/[page].astro +3 -0
- package/src/routes/posts/[...slug].astro +7 -2
- package/src/routes/search.astro +24 -0
- package/src/routes/sponsor.astro +17 -4
- package/src/schema.ts +364 -71
- package/src/scripts/app.ts +817 -37
- package/src/styles/global.css +865 -36
- package/src/utils/gallery.ts +3 -1
- package/src/utils/image.ts +90 -0
- package/src/utils/lqip.ts +21 -12
- package/src/utils/thumb.ts +31 -0
- package/src/vite.ts +95 -8
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* 侧栏音乐 widget(§4.100):与 MusicPlayer(悬浮播放器)共用 config.music 歌单,
|
|
4
|
+
* 经 CustomEvent 联动——本组件点击发 orchid:music-cmd,播放器广播 orchid:music-state。
|
|
5
|
+
* 播放内核仍在 MusicPlayer(transition:persist 跨页常驻),本组件纯遥控 + 状态展示。
|
|
6
|
+
* 首载时序:播放器于 DOMContentLoaded 初始化,本组件在 astro:page-load 初始化
|
|
7
|
+
* (晚于播放器),初始化后发 sync 补拉当前状态。
|
|
8
|
+
*/
|
|
9
|
+
import config from "virtual:theme/config";
|
|
10
|
+
import { t } from "../i18n/index.js";
|
|
11
|
+
import { resolveSiteImage } from "../utils/image.js";
|
|
12
|
+
|
|
13
|
+
interface Props {
|
|
14
|
+
/** 移动端(<768px)隐藏(widgets 数组项 hideOnMobile) */
|
|
15
|
+
hideMobile?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const { hideMobile = false } = Astro.props;
|
|
19
|
+
|
|
20
|
+
// 封面三格式解析(§4.97):构建期解析整份歌单,data-playlist 与 SSR 首曲共用
|
|
21
|
+
const playlist = await Promise.all(
|
|
22
|
+
config.music.playlist.map(
|
|
23
|
+
async (tr: { name: string; artist: string; cover?: string }) => ({
|
|
24
|
+
name: tr.name,
|
|
25
|
+
artist: tr.artist,
|
|
26
|
+
cover: tr.cover ? await resolveSiteImage(tr.cover) : "",
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const labels = {
|
|
32
|
+
play: t("musicPlay"),
|
|
33
|
+
pause: t("musicPause"),
|
|
34
|
+
prev: t("musicPrev"),
|
|
35
|
+
next: t("musicNext"),
|
|
36
|
+
};
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
<section
|
|
40
|
+
class:list={["side-card", "card", "reveal", "music-widget", { "side-hide-m": hideMobile }]}
|
|
41
|
+
id="orchid-music-widget"
|
|
42
|
+
data-playlist={JSON.stringify(playlist)}
|
|
43
|
+
data-labels={JSON.stringify(labels)}
|
|
44
|
+
>
|
|
45
|
+
<h3>{t("musicWidget")}</h3>
|
|
46
|
+
<div class="mw-body">
|
|
47
|
+
<div class="mw-cover">
|
|
48
|
+
{playlist[0]?.cover ? (
|
|
49
|
+
<img class="mw-cover-img" src={playlist[0].cover} alt="" loading="lazy" />
|
|
50
|
+
) : (
|
|
51
|
+
<svg class="mw-cover-svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M9 18V5l12-2v13"/><circle cx="6" cy="18" r="3"/><circle cx="18" cy="16" r="3"/></svg>
|
|
52
|
+
)}
|
|
53
|
+
</div>
|
|
54
|
+
<div class="mw-meta">
|
|
55
|
+
<span class="mw-name">{playlist[0]?.name ?? ""}</span>
|
|
56
|
+
<span class="mw-artist">{playlist[0]?.artist ?? ""}</span>
|
|
57
|
+
</div>
|
|
58
|
+
<div class="mw-controls">
|
|
59
|
+
<button class="mw-btn mw-prev" type="button" aria-label={labels.prev} title={labels.prev}>
|
|
60
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 8 12l10 6V6Z" fill="currentColor" stroke="none"/><path d="M6 5v14"/></svg>
|
|
61
|
+
</button>
|
|
62
|
+
<button class="mw-btn mw-play" type="button" aria-label={labels.play} title={labels.play}>
|
|
63
|
+
<svg class="ic-play" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13l11-6.5-11-6.5Z"/></svg>
|
|
64
|
+
<svg class="ic-pause" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="6.5" y="5" width="3.5" height="14" rx="1"/><rect x="14" y="5" width="3.5" height="14" rx="1"/></svg>
|
|
65
|
+
</button>
|
|
66
|
+
<button class="mw-btn mw-next" type="button" aria-label={labels.next} title={labels.next}>
|
|
67
|
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 6 10 6L6 18V6Z" fill="currentColor" stroke="none"/><path d="M18 5v14"/></svg>
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
|
|
73
|
+
<style>
|
|
74
|
+
.mw-body {
|
|
75
|
+
display: flex;
|
|
76
|
+
align-items: center;
|
|
77
|
+
gap: 12px;
|
|
78
|
+
}
|
|
79
|
+
.mw-cover {
|
|
80
|
+
width: 52px;
|
|
81
|
+
height: 52px;
|
|
82
|
+
flex: none;
|
|
83
|
+
border-radius: 50%;
|
|
84
|
+
overflow: hidden;
|
|
85
|
+
display: grid;
|
|
86
|
+
place-items: center;
|
|
87
|
+
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
88
|
+
color: var(--accent);
|
|
89
|
+
}
|
|
90
|
+
.mw-cover img {
|
|
91
|
+
width: 100%;
|
|
92
|
+
height: 100%;
|
|
93
|
+
object-fit: cover;
|
|
94
|
+
}
|
|
95
|
+
.mw-cover svg {
|
|
96
|
+
width: 22px;
|
|
97
|
+
height: 22px;
|
|
98
|
+
}
|
|
99
|
+
.music-widget.playing .mw-cover img {
|
|
100
|
+
animation: mw-spin 12s linear infinite;
|
|
101
|
+
}
|
|
102
|
+
@keyframes mw-spin {
|
|
103
|
+
to {
|
|
104
|
+
transform: rotate(360deg);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
.mw-meta {
|
|
108
|
+
flex: 1;
|
|
109
|
+
min-width: 0;
|
|
110
|
+
display: flex;
|
|
111
|
+
flex-direction: column;
|
|
112
|
+
gap: 2px;
|
|
113
|
+
}
|
|
114
|
+
.mw-name {
|
|
115
|
+
font-size: 14px;
|
|
116
|
+
font-weight: 600;
|
|
117
|
+
white-space: nowrap;
|
|
118
|
+
overflow: hidden;
|
|
119
|
+
text-overflow: ellipsis;
|
|
120
|
+
}
|
|
121
|
+
.mw-artist {
|
|
122
|
+
font-size: 12px;
|
|
123
|
+
color: var(--muted);
|
|
124
|
+
white-space: nowrap;
|
|
125
|
+
overflow: hidden;
|
|
126
|
+
text-overflow: ellipsis;
|
|
127
|
+
}
|
|
128
|
+
.mw-controls {
|
|
129
|
+
display: flex;
|
|
130
|
+
align-items: center;
|
|
131
|
+
gap: 2px;
|
|
132
|
+
flex: none;
|
|
133
|
+
}
|
|
134
|
+
.mw-btn {
|
|
135
|
+
display: grid;
|
|
136
|
+
place-items: center;
|
|
137
|
+
width: 28px;
|
|
138
|
+
height: 28px;
|
|
139
|
+
padding: 0;
|
|
140
|
+
border: none;
|
|
141
|
+
border-radius: 50%;
|
|
142
|
+
background: transparent;
|
|
143
|
+
color: inherit;
|
|
144
|
+
cursor: pointer;
|
|
145
|
+
}
|
|
146
|
+
.mw-btn:hover {
|
|
147
|
+
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
148
|
+
color: var(--accent);
|
|
149
|
+
}
|
|
150
|
+
.mw-btn svg {
|
|
151
|
+
width: 16px;
|
|
152
|
+
height: 16px;
|
|
153
|
+
}
|
|
154
|
+
.mw-play svg {
|
|
155
|
+
width: 18px;
|
|
156
|
+
height: 18px;
|
|
157
|
+
}
|
|
158
|
+
/* 播放/暂停图标切换:playing class 由客户端 applyState 维护 */
|
|
159
|
+
.mw-play .ic-pause {
|
|
160
|
+
display: none;
|
|
161
|
+
}
|
|
162
|
+
.music-widget.playing .mw-play .ic-play {
|
|
163
|
+
display: none;
|
|
164
|
+
}
|
|
165
|
+
.music-widget.playing .mw-play .ic-pause {
|
|
166
|
+
display: block;
|
|
167
|
+
}
|
|
168
|
+
</style>
|
|
169
|
+
|
|
170
|
+
<script>
|
|
171
|
+
// 模块级单例:状态缓存跨 VT 换页保留;document 级 state 监听只绑一次
|
|
172
|
+
// (widget DOM 随页面重建,每次 page-load 重查 DOM 重绑按钮),避免监听器累积
|
|
173
|
+
type MusicState = { idx: number; playing: boolean };
|
|
174
|
+
let state: MusicState = { idx: 0, playing: false };
|
|
175
|
+
let docBound = false;
|
|
176
|
+
|
|
177
|
+
function sendCmd(detail: Record<string, unknown>) {
|
|
178
|
+
document.dispatchEvent(new CustomEvent("orchid:music-cmd", { detail }));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 把当前状态应用到本页 widget DOM(换页后由 init 重新调用)
|
|
182
|
+
function applyState() {
|
|
183
|
+
const root = document.getElementById("orchid-music-widget");
|
|
184
|
+
if (!root) return;
|
|
185
|
+
let playlist: { name: string; artist: string; cover: string }[] = [];
|
|
186
|
+
try {
|
|
187
|
+
playlist = JSON.parse(root.dataset.playlist || "[]");
|
|
188
|
+
} catch {
|
|
189
|
+
/* 歌单数据异常时保留 SSR 首曲内容 */
|
|
190
|
+
}
|
|
191
|
+
const tr = playlist[state.idx];
|
|
192
|
+
if (tr) {
|
|
193
|
+
const nameEl = root.querySelector(".mw-name");
|
|
194
|
+
const artistEl = root.querySelector(".mw-artist");
|
|
195
|
+
if (nameEl) nameEl.textContent = tr.name;
|
|
196
|
+
if (artistEl) artistEl.textContent = tr.artist;
|
|
197
|
+
let coverImg = root.querySelector<HTMLImageElement>(".mw-cover-img");
|
|
198
|
+
const coverSvg = root.querySelector(".mw-cover-svg");
|
|
199
|
+
// SSR 只渲染了首曲封面:当前曲有封面而 DOM 里是图标时动态补 img
|
|
200
|
+
if (tr.cover && !coverImg && coverSvg) {
|
|
201
|
+
const img = document.createElement("img");
|
|
202
|
+
img.className = "mw-cover-img";
|
|
203
|
+
img.alt = "";
|
|
204
|
+
coverSvg.replaceWith(img);
|
|
205
|
+
coverImg = img;
|
|
206
|
+
}
|
|
207
|
+
if (coverImg instanceof HTMLImageElement) {
|
|
208
|
+
if (tr.cover) coverImg.src = tr.cover;
|
|
209
|
+
coverImg.toggleAttribute("hidden", !tr.cover);
|
|
210
|
+
}
|
|
211
|
+
coverSvg?.toggleAttribute("hidden", !!tr.cover);
|
|
212
|
+
}
|
|
213
|
+
root.classList.toggle("playing", state.playing);
|
|
214
|
+
let labels: Record<string, string> = {};
|
|
215
|
+
try {
|
|
216
|
+
labels = JSON.parse(root.dataset.labels || "{}");
|
|
217
|
+
} catch {
|
|
218
|
+
/* labels 异常时沿用 SSR aria */
|
|
219
|
+
}
|
|
220
|
+
const playBtn = root.querySelector<HTMLElement>(".mw-play");
|
|
221
|
+
if (playBtn) {
|
|
222
|
+
const label = state.playing ? labels.pause : labels.play;
|
|
223
|
+
if (label) {
|
|
224
|
+
playBtn.setAttribute("aria-label", label);
|
|
225
|
+
playBtn.title = label;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function initMusicWidget() {
|
|
231
|
+
const root = document.getElementById("orchid-music-widget");
|
|
232
|
+
if (!root) return;
|
|
233
|
+
|
|
234
|
+
// document 级监听仅绑一次;按钮随 DOM 重建每次绑定
|
|
235
|
+
if (!docBound) {
|
|
236
|
+
docBound = true;
|
|
237
|
+
document.addEventListener("orchid:music-state", (e) => {
|
|
238
|
+
state = (e as CustomEvent<MusicState>).detail;
|
|
239
|
+
applyState();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
root
|
|
244
|
+
.querySelector(".mw-play")
|
|
245
|
+
?.addEventListener("click", () => sendCmd({ type: "toggle" }));
|
|
246
|
+
root
|
|
247
|
+
.querySelector(".mw-next")
|
|
248
|
+
?.addEventListener("click", () => sendCmd({ type: "next" }));
|
|
249
|
+
root
|
|
250
|
+
.querySelector(".mw-prev")
|
|
251
|
+
?.addEventListener("click", () => sendCmd({ type: "prev" }));
|
|
252
|
+
// 点封面/曲名区 = 播放当前 widget 正显示的曲目
|
|
253
|
+
root.querySelector(".mw-body")?.addEventListener("click", (e) => {
|
|
254
|
+
if ((e.target as HTMLElement).closest(".mw-btn")) return;
|
|
255
|
+
sendCmd({ type: "playIndex", index: state.idx });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// 先应用缓存状态再向播放器补拉(播放器 DOM persist 常驻,先于本组件就绪)
|
|
259
|
+
applyState();
|
|
260
|
+
sendCmd({ type: "sync" });
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ClientRouter 下 astro:page-load 首载与每次导航都会触发
|
|
264
|
+
document.addEventListener("astro:page-load", initMusicWidget);
|
|
265
|
+
</script>
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* showDecrypted → initLightbox 重预置生效)。
|
|
7
7
|
*/
|
|
8
8
|
import { getLqipStyle } from "../utils/lqip.js";
|
|
9
|
+
import { getThumbInfo } from "../utils/thumb.js";
|
|
9
10
|
|
|
10
11
|
interface Props {
|
|
11
12
|
src: string;
|
|
@@ -13,10 +14,21 @@ interface Props {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
const { src, alt = "" } = Astro.props;
|
|
17
|
+
/* §4.106:列表瀑布流用 480 宽缩略图,data-full-* 供灯箱打开原图(原图宽高) */
|
|
18
|
+
const thumb = getThumbInfo(src);
|
|
16
19
|
---
|
|
17
20
|
|
|
18
21
|
<figure class="gallery-photo" data-lightbox-item>
|
|
19
22
|
{/* LQIP 占位渐变(b9.5):src 支持外链,getLqipStyle 查不到时降级纯色 */}
|
|
20
23
|
<div class="qy-lqip" style={getLqipStyle(src)} aria-hidden="true"></div>
|
|
21
|
-
<img
|
|
24
|
+
<img
|
|
25
|
+
src={thumb.src}
|
|
26
|
+
alt={alt}
|
|
27
|
+
loading="lazy"
|
|
28
|
+
decoding="async"
|
|
29
|
+
data-lqip
|
|
30
|
+
data-full-src={thumb.full}
|
|
31
|
+
data-full-width={thumb.width}
|
|
32
|
+
data-full-height={thumb.height}
|
|
33
|
+
/>
|
|
22
34
|
</figure>
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
import config from "virtual:theme/config";
|
|
3
3
|
import { t } from "../i18n/index.js";
|
|
4
|
+
import { resolveSiteImage } from "../utils/image.js";
|
|
4
5
|
import { getLqipStyle } from "../utils/lqip.js";
|
|
6
|
+
import { getThumbInfo } from "../utils/thumb.js";
|
|
5
7
|
import { formatDate, type PostLike, resolveCover } from "../utils.js";
|
|
6
8
|
|
|
7
9
|
interface Props {
|
|
@@ -24,7 +26,12 @@ const { cover: rawCover, retries } = resolveCover(
|
|
|
24
26
|
config.postList.randomCover.apis,
|
|
25
27
|
post.id,
|
|
26
28
|
);
|
|
27
|
-
|
|
29
|
+
/* §4.110:封面三格式统一语义(对齐 §4.108 其余消费点)——相对路径(站点 src/
|
|
30
|
+
下)构建期解析为产物 URL,public / 外链原样直通 */
|
|
31
|
+
const resolvedCover = rawCover ? await resolveSiteImage(rawCover) : rawCover;
|
|
32
|
+
const cover = config.postList.showCover ? resolvedCover : null;
|
|
33
|
+
/* §4.106:列表封面用缩略图(外链/未命中自动回退原图;点击进文章页看正文原图) */
|
|
34
|
+
const thumb = cover ? getThumbInfo(cover) : null;
|
|
28
35
|
const classes = [
|
|
29
36
|
"post-card",
|
|
30
37
|
featured ? "featured" : "",
|
|
@@ -47,7 +54,7 @@ const excerpt = d.excerpt || d.description;
|
|
|
47
54
|
{/* LQIP 占位渐变(b9.5):绝对定位盖在 img 上,加载完成后由 app.ts 淡出 */}
|
|
48
55
|
<div class="qy-lqip" style={getLqipStyle(cover)} aria-hidden="true"></div>
|
|
49
56
|
<img
|
|
50
|
-
src={cover}
|
|
57
|
+
src={thumb ? thumb.src : cover}
|
|
51
58
|
alt={d.title}
|
|
52
59
|
loading="lazy"
|
|
53
60
|
data-lqip
|
|
@@ -1,19 +1,39 @@
|
|
|
1
1
|
---
|
|
2
2
|
/**
|
|
3
|
-
* 樱花飘落特效(§4.30):全站 canvas
|
|
3
|
+
* 樱花飘落特效(§4.30 / §4.101 八维参数对齐 Firefly):全站 canvas 粒子层,
|
|
4
|
+
* 程序化贝塞尔花瓣(零贴图资产)。
|
|
4
5
|
* - prefers-reduced-motion 自动停用
|
|
5
6
|
* - 页签隐藏暂停、可见时恢复
|
|
7
|
+
* - limitTimes 控制飘落轮数(-1 无限);配额耗尽后不再重生,花瓣落尽即停帧
|
|
6
8
|
* - VT swap 后 body 重建,经 astro:page-load 重新初始化(模块级单例清理旧动画)
|
|
7
9
|
*/
|
|
8
10
|
interface Props {
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
+
/** 完整 sakura 配置(schema 已补全默认值),经 data-cfg JSON 传给客户端 */
|
|
12
|
+
cfg?: {
|
|
13
|
+
enabled?: boolean;
|
|
14
|
+
density: number;
|
|
15
|
+
limitTimes: number;
|
|
16
|
+
zIndex: number;
|
|
17
|
+
size: { min: number; max: number };
|
|
18
|
+
opacity: { min: number; max: number };
|
|
19
|
+
speed: {
|
|
20
|
+
horizontal: { min: number; max: number };
|
|
21
|
+
vertical: { min: number; max: number };
|
|
22
|
+
rotation: number;
|
|
23
|
+
fadeSpeed: number;
|
|
24
|
+
};
|
|
25
|
+
};
|
|
11
26
|
}
|
|
12
27
|
|
|
13
|
-
const
|
|
28
|
+
const cfg = Astro.props.cfg;
|
|
14
29
|
---
|
|
15
30
|
|
|
16
|
-
<canvas
|
|
31
|
+
<canvas
|
|
32
|
+
id="sakura-canvas"
|
|
33
|
+
data-cfg={JSON.stringify(cfg)}
|
|
34
|
+
style={`z-index:${cfg?.zIndex ?? 100}`}
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
></canvas>
|
|
17
37
|
|
|
18
38
|
<style>
|
|
19
39
|
#sakura-canvas {
|
|
@@ -22,7 +42,6 @@ const { density = 24 } = Astro.props;
|
|
|
22
42
|
width: 100%;
|
|
23
43
|
height: 100%;
|
|
24
44
|
pointer-events: none;
|
|
25
|
-
z-index: 90;
|
|
26
45
|
}
|
|
27
46
|
</style>
|
|
28
47
|
|
|
@@ -39,11 +58,41 @@ const { density = 24 } = Astro.props;
|
|
|
39
58
|
) as HTMLCanvasElement | null;
|
|
40
59
|
if (!canvas) return;
|
|
41
60
|
if (matchMedia("(prefers-reduced-motion: reduce)").matches) return;
|
|
61
|
+
/* §4.103:显示设置面板关闭樱花(html[data-wp-sakura="off"])时不启动 */
|
|
62
|
+
if (document.documentElement.dataset.wpSakura === "off") return;
|
|
42
63
|
|
|
43
64
|
const ctx = canvas.getContext("2d");
|
|
44
65
|
if (!ctx) return;
|
|
45
66
|
|
|
46
|
-
|
|
67
|
+
// 八维参数(§4.101):缺省回退与 schema 默认一致(防 data-cfg 解析失败)
|
|
68
|
+
let cfg: {
|
|
69
|
+
density?: number;
|
|
70
|
+
limitTimes?: number;
|
|
71
|
+
size?: { min: number; max: number };
|
|
72
|
+
opacity?: { min: number; max: number };
|
|
73
|
+
speed?: {
|
|
74
|
+
horizontal: { min: number; max: number };
|
|
75
|
+
vertical: { min: number; max: number };
|
|
76
|
+
rotation: number;
|
|
77
|
+
fadeSpeed: number;
|
|
78
|
+
};
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
cfg = JSON.parse(canvas.dataset.cfg || "{}");
|
|
82
|
+
} catch {
|
|
83
|
+
cfg = {};
|
|
84
|
+
}
|
|
85
|
+
const count = Math.max(1, Number(cfg.density) || 24);
|
|
86
|
+
const sizeR = cfg.size ?? { min: 0.5, max: 1.1 };
|
|
87
|
+
const opaR = cfg.opacity ?? { min: 0.3, max: 0.9 };
|
|
88
|
+
const spd = cfg.speed ?? {
|
|
89
|
+
horizontal: { min: -1.7, max: -1.2 },
|
|
90
|
+
vertical: { min: 1.5, max: 2.2 },
|
|
91
|
+
rotation: 0.03,
|
|
92
|
+
fadeSpeed: 0.03,
|
|
93
|
+
};
|
|
94
|
+
const rand = (min: number, max: number) => min + Math.random() * (max - min);
|
|
95
|
+
|
|
47
96
|
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
|
48
97
|
|
|
49
98
|
let w = 0;
|
|
@@ -60,19 +109,24 @@ const { density = 24 } = Astro.props;
|
|
|
60
109
|
|
|
61
110
|
// 3 档粉调色板(对齐参考站观感)
|
|
62
111
|
const colors = ["#ffd7e2", "#ffc0d0", "#fdaec5"];
|
|
112
|
+
// 花瓣基准尺寸(px):size 配置为其倍数区间
|
|
113
|
+
const BASE_SIZE = 14;
|
|
63
114
|
|
|
64
115
|
interface Petal {
|
|
65
116
|
x: number;
|
|
66
117
|
y: number;
|
|
67
118
|
size: number;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
119
|
+
/** 水平漂移速度(负值向左,§4.101 speed.horizontal) */
|
|
120
|
+
vx: number;
|
|
121
|
+
/** 下落速度(§4.101 speed.vertical) */
|
|
122
|
+
vy: number;
|
|
72
123
|
rot: number;
|
|
73
|
-
rotSpeed: number;
|
|
74
124
|
color: string;
|
|
75
|
-
|
|
125
|
+
alpha: number;
|
|
126
|
+
/** 生命帧数:超过后进入临终淡出(fadeSpeed),alpha 耗尽即重生 */
|
|
127
|
+
life: number;
|
|
128
|
+
age: number;
|
|
129
|
+
dead: boolean;
|
|
76
130
|
}
|
|
77
131
|
|
|
78
132
|
const spawn = (randomY = false): Petal => {
|
|
@@ -80,18 +134,25 @@ const { density = 24 } = Astro.props;
|
|
|
80
134
|
return {
|
|
81
135
|
x: Math.random() * w,
|
|
82
136
|
y: randomY ? Math.random() * h : -20,
|
|
83
|
-
size:
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
swayFreq: 0.005 + Math.random() * 0.01,
|
|
87
|
-
phase: Math.random() * Math.PI * 2,
|
|
137
|
+
size: BASE_SIZE * rand(sizeR.min, sizeR.max),
|
|
138
|
+
vx: rand(spd.horizontal.min, spd.horizontal.max),
|
|
139
|
+
vy: rand(spd.vertical.min, spd.vertical.max),
|
|
88
140
|
rot: Math.random() * Math.PI * 2,
|
|
89
|
-
rotSpeed: (Math.random() - 0.5) * 0.03,
|
|
90
141
|
color: colors[i] ?? colors[0]!,
|
|
91
|
-
|
|
142
|
+
alpha: rand(opaR.min, opaR.max),
|
|
143
|
+
life: 480 + Math.random() * 720,
|
|
144
|
+
age: 0,
|
|
145
|
+
dead: false,
|
|
92
146
|
};
|
|
93
147
|
};
|
|
94
148
|
|
|
149
|
+
// limitTimes 飘落轮数(§4.101):-1 无限;>=1 时总重生配额 = 轮数 × 花瓣数,
|
|
150
|
+
// 耗尽后不再重生,花瓣落尽即停止动画
|
|
151
|
+
let quota =
|
|
152
|
+
cfg.limitTimes === undefined || cfg.limitTimes < 0
|
|
153
|
+
? Number.POSITIVE_INFINITY
|
|
154
|
+
: cfg.limitTimes * count;
|
|
155
|
+
|
|
95
156
|
const petals: Petal[] = Array.from({ length: count }, () => spawn(true));
|
|
96
157
|
|
|
97
158
|
// 程序化花瓣:两段贝塞尔拼出泪滴形
|
|
@@ -100,7 +161,7 @@ const { density = 24 } = Astro.props;
|
|
|
100
161
|
ctx.save();
|
|
101
162
|
ctx.translate(p.x, p.y);
|
|
102
163
|
ctx.rotate(p.rot);
|
|
103
|
-
ctx.globalAlpha = p.
|
|
164
|
+
ctx.globalAlpha = p.alpha;
|
|
104
165
|
ctx.fillStyle = p.color;
|
|
105
166
|
ctx.beginPath();
|
|
106
167
|
ctx.moveTo(0, -s);
|
|
@@ -110,19 +171,40 @@ const { density = 24 } = Astro.props;
|
|
|
110
171
|
ctx.restore();
|
|
111
172
|
};
|
|
112
173
|
|
|
174
|
+
// 重生:消耗配额;配额耗尽标记 dead(不再绘制)
|
|
175
|
+
const respawn = (p: Petal) => {
|
|
176
|
+
if (quota > 0) {
|
|
177
|
+
quota--;
|
|
178
|
+
Object.assign(p, spawn());
|
|
179
|
+
} else {
|
|
180
|
+
p.dead = true;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
|
|
113
184
|
let paused = false;
|
|
114
|
-
let frame = 0;
|
|
115
185
|
const tick = () => {
|
|
116
186
|
if (paused) return;
|
|
117
|
-
frame++;
|
|
118
187
|
ctx.clearRect(0, 0, w, h);
|
|
188
|
+
let alive = 0;
|
|
119
189
|
for (const p of petals) {
|
|
120
|
-
p.
|
|
121
|
-
|
|
122
|
-
p.
|
|
123
|
-
|
|
124
|
-
|
|
190
|
+
if (p.dead) continue;
|
|
191
|
+
alive++;
|
|
192
|
+
p.age++;
|
|
193
|
+
// 临终淡出(fadeSpeed):寿命耗尽后 alpha 递减至 0 重生
|
|
194
|
+
if (p.age > p.life) {
|
|
195
|
+
p.alpha -= spd.fadeSpeed;
|
|
196
|
+
if (p.alpha <= 0) {
|
|
197
|
+
respawn(p);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
p.y += p.vy;
|
|
202
|
+
p.x += p.vx;
|
|
203
|
+
p.rot += spd.rotation;
|
|
204
|
+
if (p.y > h + 30 || p.x < -40 || p.x > w + 40) respawn(p);
|
|
205
|
+
else drawPetal(p);
|
|
125
206
|
}
|
|
207
|
+
if (alive === 0) return; // 配额耗尽且花瓣落尽:停止循环
|
|
126
208
|
raf = requestAnimationFrame(tick);
|
|
127
209
|
};
|
|
128
210
|
|
|
@@ -140,6 +222,7 @@ const { density = 24 } = Astro.props;
|
|
|
140
222
|
raf = requestAnimationFrame(tick);
|
|
141
223
|
|
|
142
224
|
cleanup = () => {
|
|
225
|
+
paused = true;
|
|
143
226
|
cancelAnimationFrame(raf);
|
|
144
227
|
window.removeEventListener("resize", resize);
|
|
145
228
|
document.removeEventListener("visibilitychange", onVisibility);
|
|
@@ -149,4 +232,19 @@ const { density = 24 } = Astro.props;
|
|
|
149
232
|
|
|
150
233
|
// ClientRouter 下 astro:page-load 首载与每次导航都会触发
|
|
151
234
|
document.addEventListener("astro:page-load", startSakura);
|
|
235
|
+
|
|
236
|
+
/* §4.103:显示设置面板樱花开关(app.ts applyDsSwitch 派发);
|
|
237
|
+
off 立即停动画并清画布,on 重启(startSakura 内部先清理旧实例) */
|
|
238
|
+
document.addEventListener("orchid:sakura-toggle", (e) => {
|
|
239
|
+
const on = (e as CustomEvent).detail !== "off";
|
|
240
|
+
if (on) {
|
|
241
|
+
startSakura();
|
|
242
|
+
} else {
|
|
243
|
+
cleanup?.();
|
|
244
|
+
const canvas = document.getElementById(
|
|
245
|
+
"sakura-canvas",
|
|
246
|
+
) as HTMLCanvasElement | null;
|
|
247
|
+
canvas?.getContext("2d")?.clearRect(0, 0, canvas.width, canvas.height);
|
|
248
|
+
}
|
|
249
|
+
});
|
|
152
250
|
</script>
|