@windcoder/astro-theme-orchid 0.0.0-stage → 0.2.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.
Files changed (83) hide show
  1. package/README.md +118 -2
  2. package/package.json +70 -5
  3. package/src/components/AlbumCard.astro +73 -0
  4. package/src/components/Analytics.astro +22 -0
  5. package/src/components/BaseLayout.astro +188 -0
  6. package/src/components/Calendar.astro +379 -0
  7. package/src/components/CategoryBar.astro +74 -0
  8. package/src/components/Comment.astro +48 -0
  9. package/src/components/EncryptedContent.astro +42 -0
  10. package/src/components/Footer.astro +31 -0
  11. package/src/components/Header.astro +153 -0
  12. package/src/components/License.astro +29 -0
  13. package/src/components/Live2DWidget.astro +262 -0
  14. package/src/components/MusicPlayer.astro +940 -0
  15. package/src/components/OutdatedCard.astro +53 -0
  16. package/src/components/Pagination.astro +94 -0
  17. package/src/components/PhotoCard.astro +22 -0
  18. package/src/components/PostCard.astro +89 -0
  19. package/src/components/PostNav.astro +40 -0
  20. package/src/components/RecommendedPost.astro +166 -0
  21. package/src/components/SakuraEffect.astro +152 -0
  22. package/src/components/Sidebar.astro +334 -0
  23. package/src/components/Toc.astro +27 -0
  24. package/src/components/analytics/GoogleAnalytics.astro +22 -0
  25. package/src/components/analytics/La51Analytics.astro +52 -0
  26. package/src/components/analytics/MicrosoftClarity.astro +18 -0
  27. package/src/components/analytics/UmamiAnalytics.astro +86 -0
  28. package/src/components/comments/Artalk.astro +51 -0
  29. package/src/components/comments/Disqus.astro +43 -0
  30. package/src/components/comments/Giscus.astro +50 -0
  31. package/src/components/comments/Twikoo.astro +65 -0
  32. package/src/components/comments/Waline.astro +36 -0
  33. package/src/i18n/en.ts +164 -0
  34. package/src/i18n/index.ts +18 -0
  35. package/src/i18n/resolve.ts +49 -0
  36. package/src/i18n/zh-CN.ts +184 -0
  37. package/src/icons.ts +71 -0
  38. package/src/index.ts +411 -0
  39. package/src/plugins/plantuml-encoder.ts +95 -0
  40. package/src/plugins/rehype-diagram.ts +218 -0
  41. package/src/plugins/rehype-ec-vt-rerun.ts +80 -0
  42. package/src/plugins/rehype-email.ts +75 -0
  43. package/src/plugins/rehype-github-card.ts +114 -0
  44. package/src/plugins/rehype.ts +284 -0
  45. package/src/plugins/remark-diagram.ts +78 -0
  46. package/src/plugins/remark-directives.ts +100 -0
  47. package/src/plugins/remark-excerpt.ts +29 -0
  48. package/src/plugins/remark-image-grid.ts +97 -0
  49. package/src/plugins/remark-reading-time.ts +38 -0
  50. package/src/plugins/scripts/diagram-panzoom.js +388 -0
  51. package/src/plugins/scripts/plantuml-theme-switch.js +90 -0
  52. package/src/routes/404.astro +14 -0
  53. package/src/routes/_hydrate.ts +34 -0
  54. package/src/routes/about.astro +25 -0
  55. package/src/routes/api/allPostMeta.json.ts +26 -0
  56. package/src/routes/archive.astro +81 -0
  57. package/src/routes/categories/[category].astro +37 -0
  58. package/src/routes/categories/index.astro +29 -0
  59. package/src/routes/friends.astro +83 -0
  60. package/src/routes/gallery/[album].astro +97 -0
  61. package/src/routes/gallery/index.astro +100 -0
  62. package/src/routes/guestbook.astro +37 -0
  63. package/src/routes/index.astro +47 -0
  64. package/src/routes/og/[...slug].ts +281 -0
  65. package/src/routes/page/[page].astro +53 -0
  66. package/src/routes/posts/[...slug].astro +350 -0
  67. package/src/routes/robots.txt.ts +18 -0
  68. package/src/routes/rss.xml.ts +37 -0
  69. package/src/routes/series/[name].astro +66 -0
  70. package/src/routes/series/index.astro +64 -0
  71. package/src/routes/sponsor.astro +84 -0
  72. package/src/routes/tags/[tag].astro +40 -0
  73. package/src/routes/tags/index.astro +29 -0
  74. package/src/schema.ts +1218 -0
  75. package/src/scripts/app.ts +1041 -0
  76. package/src/scripts/share-poster.ts +468 -0
  77. package/src/styles/global.css +4864 -0
  78. package/src/utils/crypto.ts +46 -0
  79. package/src/utils/gallery.ts +76 -0
  80. package/src/utils/lqip.ts +18 -0
  81. package/src/utils/related.ts +101 -0
  82. package/src/utils.ts +233 -0
  83. package/src/vite.ts +105 -0
@@ -0,0 +1,1041 @@
1
+ /* 清言集 · Theme Orchid — 全局交互脚本
2
+ 移植自 design/prototype/app.js(冻结),改造为 View Transitions 兼容版:
3
+ - document/window 级一次性委托监听(VT 换页不失联)
4
+ - astro:page-load 时重初始化页面级状态(nav active / reveal / TOC spy / 灯箱 PhotoSwipe / 调色面板)
5
+ - astro:after-swap 时重应用暗色 class 与持久化 hue(VT swap 会替换 <html> 属性)
6
+ - 搜索浮层(M3b-b6):Pagefind 构建期索引,运行时惰性加载
7
+ - 加密文章(M3b-b7):Web Crypto 解密 + sessionStorage 密码缓存 */
8
+
9
+ import { openSharePoster } from "./share-poster.js";
10
+
11
+ const doc = document;
12
+ const root = doc.documentElement;
13
+
14
+ /* ---------- 深浅模式 ---------- */
15
+ function applyPreferredDark() {
16
+ let mode: string | null = null;
17
+ try {
18
+ mode = localStorage.getItem("qy-mode");
19
+ } catch {
20
+ /* ignore */
21
+ }
22
+ /* 未选择时回落到 colorMode 配置(§4.16,boot 脚本同源逻辑) */
23
+ const fallback = root.dataset.colorMode || "system";
24
+ const dark = mode
25
+ ? mode === "dark"
26
+ : fallback === "dark" ||
27
+ (fallback === "system" &&
28
+ window.matchMedia("(prefers-color-scheme: dark)").matches);
29
+ root.classList.toggle("dark", dark);
30
+ }
31
+ applyPreferredDark();
32
+ doc.addEventListener("astro:after-swap", applyPreferredDark);
33
+ /* 灯箱(§4.69):PhotoSwipeLightbox 实例挂在旧 body 的元素上,VT swap 后立即销毁;
34
+ 新 body 的委托绑定由 astro:page-load 的 initPage → initLightbox 重建 */
35
+ doc.addEventListener("astro:after-swap", () => {
36
+ pswpLightbox?.destroy();
37
+ pswpLightbox = null;
38
+ });
39
+
40
+ /* ---------- 客户端动态文案(构建期经 BaseLayout #qy-ui-labels 注入,读一次缓存) ---------- */
41
+ const UI_FALLBACK: Record<string, string> = {
42
+ close: "关闭",
43
+ prevImage: "上一张",
44
+ nextImage: "下一张",
45
+ copied: "已复制",
46
+ fullscreen: "全屏",
47
+ slideshow: "自动播放",
48
+ thumbs: "缩略图条",
49
+ };
50
+ let uiLabels: Record<string, string> | null = null;
51
+ function ui(key: string): string {
52
+ if (!uiLabels) {
53
+ try {
54
+ const el = doc.getElementById("qy-ui-labels");
55
+ uiLabels = el
56
+ ? (JSON.parse(el.textContent || "{}") as Record<string, string>)
57
+ : {};
58
+ } catch {
59
+ uiLabels = {};
60
+ }
61
+ }
62
+ return uiLabels[key] || UI_FALLBACK[key] || key;
63
+ }
64
+
65
+ /* ---------- hue 调色面板(运行时覆写 --accent-h,s/l 保持配置值) ---------- */
66
+ const HUE_KEY = "qy-hue";
67
+
68
+ /* 构建期 hue(BaseLayout 由 accentColor 计算并写入 data-accent-h) */
69
+ function buildHue(): number {
70
+ const v = parseFloat(root.dataset.accentH || "");
71
+ return Number.isFinite(v) ? v : 0;
72
+ }
73
+
74
+ /* 当前生效 hue:运行时覆写优先,否则构建期值 */
75
+ function currentHue(): number {
76
+ const cur = parseFloat(root.style.getPropertyValue("--accent-h"));
77
+ return Number.isFinite(cur) ? cur : buildHue();
78
+ }
79
+
80
+ function persistHue(h: number | null) {
81
+ try {
82
+ if (h === null) localStorage.removeItem(HUE_KEY);
83
+ else localStorage.setItem(HUE_KEY, String(h));
84
+ } catch {
85
+ /* ignore */
86
+ }
87
+ }
88
+
89
+ function savedHue(): number | null {
90
+ try {
91
+ const v = parseFloat(localStorage.getItem(HUE_KEY) || "");
92
+ if (Number.isFinite(v)) return ((v % 360) + 360) % 360;
93
+ } catch {
94
+ /* ignore */
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /* VT swap 替换 <html> style 属性后,重应用持久化 hue(与 applyPreferredDark 并列) */
100
+ function applySavedHue() {
101
+ const h = savedHue();
102
+ if (h !== null) root.style.setProperty("--accent-h", String(h));
103
+ }
104
+ applySavedHue();
105
+ doc.addEventListener("astro:after-swap", applySavedHue);
106
+
107
+ /* 同步面板 UI:滑杆 / 徽章 / swatch 激活态(差值 <1° 判定,含 0/360 环绕) */
108
+ function syncPaletteUI(h: number) {
109
+ const pop = doc.getElementById("palettePop");
110
+ if (!pop) return;
111
+ const rounded = Math.round(((h % 360) + 360) % 360) % 360;
112
+ const slider = doc.getElementById("hueSlider") as HTMLInputElement | null;
113
+ if (slider) slider.value = String(rounded);
114
+ const badge = doc.getElementById("hueBadge");
115
+ if (badge) badge.textContent = String(rounded);
116
+ pop.querySelectorAll<HTMLElement>(".swatch").forEach((sw) => {
117
+ const dh = Math.abs(parseFloat(sw.dataset.hue || "0") - rounded);
118
+ sw.classList.toggle("is-active", dh < 1 || dh > 359);
119
+ });
120
+ }
121
+
122
+ function applyHue(h: number) {
123
+ const v = Math.round(((h % 360) + 360) % 360);
124
+ root.style.setProperty("--accent-h", String(v));
125
+ syncPaletteUI(v);
126
+ persistHue(v);
127
+ }
128
+
129
+ function closePalette() {
130
+ const pop = doc.getElementById("palettePop");
131
+ const toggle = doc.getElementById("paletteToggle");
132
+ if (pop) pop.hidden = true;
133
+ if (toggle) toggle.setAttribute("aria-expanded", "false");
134
+ }
135
+
136
+ /* 每次导航后同步面板状态并复位收起(面板 DOM 会随 swap 重建) */
137
+ function initPalette() {
138
+ const pop = doc.getElementById("palettePop");
139
+ if (!pop) return;
140
+ closePalette();
141
+ syncPaletteUI(currentHue());
142
+ }
143
+
144
+ /* ---------- 通用灯箱(PhotoSwipe,§4.69-§4.71,MIT 替换 Fancybox) ---------- */
145
+ /* 主题公共 API:data-lightbox(单图)、data-lightbox-group/data-lightbox-item(组浏览)、
146
+ data-lightbox-gallery(文章级组容器,§4.70)。容器内 img 即灯箱项:文章页把封面+整篇
147
+ img(独立段落 figure、行内图、image-grid 网格图)归为一组(§4.71:封面经 wrapper 容器
148
+ 入组、成为组内第 0 张),左右切换/计数/缩略图条共享同一上下文;显式
149
+ [data-lightbox]/[data-lightbox-group] 经 closest 就近匹配优先于文章容器。
150
+ 嵌套容器都会被绑定点击监听而双重触发开图,故内层绝不能再套组属性——rehypeFigure 产物
151
+ (figure 包裸 img)也因此不带 data-lightbox(§4.70)。v5 的 _domElementToItemData 只从
152
+ <a> 读 data-pswp-*,裸 img 结构下经 itemData filter 直读 element 补 src 与
153
+ naturalWidth/Height。preloadFirstSlide 关闭:v5 打开前会预生成首图 Content 并注入 core
154
+ 缓存,数据不全时完整 itemData 无法生效,直接关掉最稳。自定义 UI(§4.70/§4.71):uiRegister
155
+ 时惰性补齐 {gallery} 形 dataSource 的 items(与 core 同一逻辑 querySelectorAll('img'),
156
+ 谁先填充索引都一致),再按图数注册全屏按钮(恒有)与自动播放按钮/缩略图条/缩略图开关
157
+ (>1 图;开关状态模块级跨打开保持)。PhotoSwipe v5 内置键盘/滑动/箭头/计数/关闭,
158
+ wheelToZoom 对齐桌面滚轮缩放;pswp core 经 pswpModule 动态加载,保持代码分割。点击须
159
+ 落在 img 上(children 只匹配后代,容器空白处不触发)。 */
160
+ type PswpLightbox = typeof import("photoswipe/lightbox")["default"];
161
+ let pswpLightbox: PswpLightbox | null = null;
162
+ const LB_SELECTOR =
163
+ "[data-lightbox], [data-lightbox-group], [data-lightbox-gallery]";
164
+ const LB_SLIDESHOW_INTERVAL = 4000;
165
+ /* 缩略图条显隐(§4.71):模块级状态跨灯箱打开/跨页面导航保持用户选择 */
166
+ let lbThumbsVisible = true;
167
+
168
+ async function initLightbox() {
169
+ if (!doc.querySelector(LB_SELECTOR)) return;
170
+ /* photoswipe/lightbox 仅 default 导出(export { PhotoSwipeLightbox as default }),解构命名导出会拿到 undefined */
171
+ const { default: PhotoSwipeLightbox } = await import("photoswipe/lightbox");
172
+ /* destroy 放在 await 之后:initPage 首帧直跑与 astro:page-load 首载也会触发,两次调用
173
+ 在 await 处交错时,先恢复者创建的实例会被后恢复者覆盖模块变量而泄漏双绑定 */
174
+ pswpLightbox?.destroy();
175
+ pswpLightbox = null;
176
+ const lb = new PhotoSwipeLightbox({
177
+ gallery: LB_SELECTOR,
178
+ children: "img",
179
+ preloadFirstSlide: false,
180
+ wheelToZoom: true,
181
+ pswpModule: () => import("photoswipe"),
182
+ l10n: {
183
+ closeTitle: ui("close"),
184
+ arrowPrevTitle: ui("prevImage"),
185
+ arrowNextTitle: ui("nextImage"),
186
+ },
187
+ });
188
+ pswpLightbox = lb;
189
+ /* v5 只从 <a> 读 data-pswp-*,裸 img 结构下 itemData 仅 { element };
190
+ filter 直读 element 本身,不经模块变量间接寻址,任何实例交错状态下都正确 */
191
+ lb.addFilter("itemData", (itemData) => {
192
+ const el = itemData.element;
193
+ const img = el instanceof HTMLImageElement ? el : el?.querySelector("img");
194
+ if (!img) return itemData;
195
+ if (!itemData.src) itemData.src = img.currentSrc || img.src;
196
+ if (img.naturalWidth > 0) {
197
+ itemData.width = img.naturalWidth;
198
+ itemData.height = img.naturalHeight;
199
+ }
200
+ return itemData;
201
+ });
202
+ /* 自定义 UI(§4.70):全屏/自动播放/缩略图条。uiRegister 在 UI 构建期触发一次,
203
+ 此时 pswp 就绪;{gallery} 形 dataSource 的 items 可能尚未被 core 惰性填充,主动补齐 */
204
+ lb.on("uiRegister", () => {
205
+ const pswp = lb.pswp;
206
+ const ds = pswp?.options?.dataSource;
207
+ if (!pswp || !ds) return;
208
+ if (!Array.isArray(ds) && ds.gallery && !ds.items) {
209
+ const gal =
210
+ ds.gallery instanceof Element
211
+ ? ds.gallery
212
+ : Array.isArray(ds.gallery)
213
+ ? ds.gallery[0]
214
+ : null;
215
+ if (gal) {
216
+ ds.items = Array.from(
217
+ gal.querySelectorAll<HTMLImageElement>("img"),
218
+ ).map((el) => ({ element: el }));
219
+ }
220
+ }
221
+ const items = Array.isArray(ds) ? ds : (ds.items ?? []);
222
+ if (!pswp.ui) return;
223
+
224
+ /* 全屏(灯箱根元素进全屏;enter/exit 两态图标由 :fullscreen CSS 切换) */
225
+ pswp.ui.registerElement({
226
+ name: "qy-fullscreen",
227
+ title: ui("fullscreen"),
228
+ ariaLabel: ui("fullscreen"),
229
+ isButton: true,
230
+ appendTo: "bar",
231
+ order: 8,
232
+ html:
233
+ '<svg class="qy-pswp-icn qy-icn-fs-enter" viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5"/><path d="M20 9V4h-5"/><path d="M4 15v5h5"/><path d="M20 15v5h-5"/></svg>' +
234
+ '<svg class="qy-pswp-icn qy-icn-fs-exit" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 4v5H4"/><path d="M15 4v5h5"/><path d="M9 20v-5H4"/><path d="M15 20v-5h5"/></svg>',
235
+ onClick: () => {
236
+ type FsElement = HTMLElement & {
237
+ webkitRequestFullscreen?: () => Promise<void> | void;
238
+ };
239
+ type FsDocument = Document & {
240
+ webkitFullscreenElement?: Element | null;
241
+ webkitExitFullscreen?: () => Promise<void> | void;
242
+ };
243
+ const d = doc as FsDocument;
244
+ const target = pswp.element as FsElement | undefined;
245
+ if (!target) return;
246
+ if (d.fullscreenElement || d.webkitFullscreenElement) {
247
+ (d.exitFullscreen || d.webkitExitFullscreen)?.call(d);
248
+ } else {
249
+ (target.requestFullscreen || target.webkitRequestFullscreen)?.call(
250
+ target,
251
+ );
252
+ }
253
+ },
254
+ });
255
+
256
+ if (items.length < 2) return;
257
+
258
+ /* 自动播放(is-active 态切 play/pause 图标;播到末张自动停;销毁时清定时器) */
259
+ let slideshowTimer = 0;
260
+ const stopSlideshow = (btn: HTMLElement) => {
261
+ if (!slideshowTimer) return;
262
+ clearInterval(slideshowTimer);
263
+ slideshowTimer = 0;
264
+ btn.classList.remove("is-active");
265
+ };
266
+ pswp.ui.registerElement({
267
+ name: "qy-slideshow",
268
+ title: ui("slideshow"),
269
+ ariaLabel: ui("slideshow"),
270
+ isButton: true,
271
+ appendTo: "bar",
272
+ order: 9,
273
+ html:
274
+ '<svg class="qy-pswp-icn qy-icn-fill qy-icn-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5.5v13l10.5-6.5z"/></svg>' +
275
+ '<svg class="qy-pswp-icn qy-icn-fill qy-icn-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 5h3.5v14H7z"/><path d="M13.5 5H17v14h-3.5z"/></svg>',
276
+ onClick: (_e, btn) => {
277
+ if (slideshowTimer) {
278
+ stopSlideshow(btn);
279
+ return;
280
+ }
281
+ btn.classList.add("is-active");
282
+ slideshowTimer = window.setInterval(
283
+ () => pswp.next(),
284
+ LB_SLIDESHOW_INTERVAL,
285
+ );
286
+ },
287
+ });
288
+ pswp.on("change", () => {
289
+ if (slideshowTimer && pswp.currIndex === items.length - 1) {
290
+ const btn = pswp.element?.querySelector<HTMLElement>(
291
+ ".pswp__button--qy-slideshow",
292
+ );
293
+ if (btn) stopSlideshow(btn);
294
+ }
295
+ });
296
+ pswp.on("destroy", () => {
297
+ if (slideshowTimer) clearInterval(slideshowTimer);
298
+ slideshowTimer = 0;
299
+ });
300
+
301
+ /* 缩略图条(root 层横滚条,点击跳转;change 时同步高亮并居中滚动) */
302
+ pswp.ui.registerElement({
303
+ name: "qy-thumbs",
304
+ tagName: "div",
305
+ appendTo: "root",
306
+ onInit: (el) => {
307
+ items.forEach((item, i) => {
308
+ const img =
309
+ item.element instanceof HTMLImageElement ? item.element : null;
310
+ const src =
311
+ (img && (img.currentSrc || img.getAttribute("src"))) ||
312
+ item.src ||
313
+ "";
314
+ const thumb = doc.createElement("button");
315
+ thumb.type = "button";
316
+ thumb.className = "pswp__qy-thumb";
317
+ thumb.dataset.index = String(i);
318
+ const thumbImg = doc.createElement("img");
319
+ thumbImg.loading = "lazy";
320
+ thumbImg.alt = "";
321
+ if (src) thumbImg.src = src;
322
+ thumb.appendChild(thumbImg);
323
+ el.appendChild(thumb);
324
+ });
325
+ el.addEventListener("click", (e) => {
326
+ const btn = (e.target as HTMLElement).closest<HTMLElement>(
327
+ ".pswp__qy-thumb",
328
+ );
329
+ const idx = btn?.dataset.index;
330
+ if (idx !== undefined) pswp.goTo(Number(idx));
331
+ });
332
+ const sync = () => {
333
+ el.querySelector(".pswp__qy-thumb.is-active")?.classList.remove(
334
+ "is-active",
335
+ );
336
+ const active = el.children[pswp.currIndex];
337
+ if (active instanceof HTMLElement) {
338
+ active.classList.add("is-active");
339
+ active.scrollIntoView({
340
+ block: "nearest",
341
+ inline: "center",
342
+ behavior: "smooth",
343
+ });
344
+ }
345
+ };
346
+ pswp.on("change", sync);
347
+ sync();
348
+ },
349
+ });
350
+
351
+ /* 缩略图条开关(§4.71):is-active/aria-pressed 表示「显示中」;模块级状态跨打开保持,
352
+ 收起后重新显示时 dispatch change 触发 sync 把高亮缩略图滚回居中 */
353
+ pswp.ui.registerElement({
354
+ name: "qy-thumbs-toggle",
355
+ title: ui("thumbs"),
356
+ ariaLabel: ui("thumbs"),
357
+ isButton: true,
358
+ appendTo: "bar",
359
+ order: 7,
360
+ html: '<svg class="qy-pswp-icn" viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="8" width="18" height="8" rx="1.5"/><path d="M9 8v8"/><path d="M15 8v8"/></svg>',
361
+ onInit: (btn) => {
362
+ btn.classList.toggle("is-active", lbThumbsVisible);
363
+ btn.setAttribute("aria-pressed", String(lbThumbsVisible));
364
+ if (!lbThumbsVisible) {
365
+ pswp.element
366
+ ?.querySelector(".pswp__qy-thumbs")
367
+ ?.classList.add("is-hidden");
368
+ }
369
+ },
370
+ onClick: (_e, btn) => {
371
+ lbThumbsVisible = !lbThumbsVisible;
372
+ pswp.element
373
+ ?.querySelector(".pswp__qy-thumbs")
374
+ ?.classList.toggle("is-hidden", !lbThumbsVisible);
375
+ btn.classList.toggle("is-active", lbThumbsVisible);
376
+ btn.setAttribute("aria-pressed", String(lbThumbsVisible));
377
+ if (lbThumbsVisible) pswp.dispatch("change");
378
+ },
379
+ });
380
+ });
381
+ lb.init();
382
+ }
383
+
384
+ /* ---------- 页面级初始化(每次导航执行) ---------- */
385
+ function initNavActive() {
386
+ const page = doc.body.getAttribute("data-page");
387
+ if (!page) return;
388
+ doc.querySelectorAll<HTMLElement>(".nav-item.is-active").forEach((n) => {
389
+ n.classList.remove("is-active");
390
+ });
391
+ doc
392
+ .querySelectorAll<HTMLElement>(
393
+ ".nav-link[data-nav], .nav-sub-link[data-nav]",
394
+ )
395
+ .forEach((link) => {
396
+ const on = link.getAttribute("data-nav") === page;
397
+ link.classList.toggle("is-active", on);
398
+ /* 子链接激活时父项同步高亮(§4.16 子菜单) */
399
+ if (on) link.closest(".nav-item")?.classList.add("is-active");
400
+ });
401
+ }
402
+
403
+ function closeMobileNav() {
404
+ const nav = doc.getElementById("siteNav");
405
+ const burger = doc.getElementById("navToggle");
406
+ if (nav) nav.classList.remove("open");
407
+ if (burger) burger.setAttribute("aria-expanded", "false");
408
+ }
409
+
410
+ /* reveal:全部同步入场(§4.40):原 70ms 逐个级联在元素多时此起彼伏,
411
+ 整屏持续变化被感知为频闪——改为一帧后 batch 加类,单次整体淡入。
412
+ reduced-motion 由 CSS 全局禁用过场兜底 */
413
+ function initReveal() {
414
+ const reveals = doc.querySelectorAll(".reveal");
415
+ if (!reveals.length) return;
416
+ requestAnimationFrame(() => {
417
+ reveals.forEach((el) => {
418
+ el.classList.add("is-in");
419
+ });
420
+ });
421
+ }
422
+
423
+ /* TOC 高亮:IntersectionObserver,与原型同款 rootMargin */
424
+ let spy: IntersectionObserver | null = null;
425
+ function initTocSpy() {
426
+ if (spy) {
427
+ spy.disconnect();
428
+ spy = null;
429
+ }
430
+ const tocLinks = doc.querySelectorAll<HTMLElement>('.toc a[href^="#"]');
431
+ const headings = doc.querySelectorAll<HTMLElement>(
432
+ ".prose h2[id], .prose h3[id], .prose h4[id]",
433
+ );
434
+ if (
435
+ !tocLinks.length ||
436
+ !headings.length ||
437
+ !("IntersectionObserver" in window)
438
+ )
439
+ return;
440
+ const map = new Map<string, Element>();
441
+ tocLinks.forEach((a) => {
442
+ const href = a.getAttribute("href");
443
+ if (href) map.set(href.slice(1), a);
444
+ });
445
+ spy = new IntersectionObserver(
446
+ (entries) => {
447
+ for (const en of entries) {
448
+ if (!en.isIntersecting) continue;
449
+ tocLinks.forEach((a) => {
450
+ a.classList.remove("is-active");
451
+ });
452
+ const link = map.get(en.target.id);
453
+ if (link) link.classList.add("is-active");
454
+ }
455
+ },
456
+ { rootMargin: "-30% 0px -60% 0px" },
457
+ );
458
+ headings.forEach((h) => {
459
+ spy?.observe(h);
460
+ });
461
+ }
462
+
463
+ /* back-top 显隐(window 级监听只挂一次,元素每次现查,VT 安全) */
464
+ function updateBackTop() {
465
+ const backTop = doc.getElementById("backTop");
466
+ if (!backTop) return;
467
+ backTop.hidden = (window.scrollY || root.scrollTop) <= 480;
468
+ }
469
+
470
+ /* 阅读进度条(§4.12):仅文章页有元素,scaleX 驱动零重排;内容不足一屏时隐藏 */
471
+ function updateReadProgress() {
472
+ const bar = doc.getElementById("readProgress");
473
+ if (!bar) return;
474
+ const max = doc.documentElement.scrollHeight - window.innerHeight;
475
+ if (max <= 0) {
476
+ bar.hidden = true;
477
+ return;
478
+ }
479
+ bar.hidden = false;
480
+ bar.style.transform = `scaleX(${Math.min((window.scrollY || 0) / max, 1)})`;
481
+ }
482
+ window.addEventListener(
483
+ "scroll",
484
+ () => {
485
+ updateBackTop();
486
+ updateReadProgress();
487
+ },
488
+ { passive: true },
489
+ );
490
+ window.addEventListener("resize", updateReadProgress, { passive: true });
491
+
492
+ /* ---------- 搜索浮层(M3b-b6):Pagefind 构建期索引,运行时惰性加载 ---------- */
493
+ type PagefindResultData = {
494
+ url: string;
495
+ excerpt: string;
496
+ meta?: { title?: string };
497
+ };
498
+ type Pagefind = {
499
+ search: (
500
+ q: string,
501
+ ) => Promise<{ results: Array<{ data: () => Promise<PagefindResultData> }> }>;
502
+ options?: (o: Record<string, unknown>) => Promise<void>;
503
+ };
504
+
505
+ /* undefined=未加载 null=不可用(dev 无索引/加载失败);模块级缓存,VT 换页不重复加载 */
506
+ let pagefind: Pagefind | null | undefined;
507
+ let searchSeq = 0;
508
+
509
+ async function loadPagefind(): Promise<Pagefind | null> {
510
+ if (pagefind !== undefined) return pagefind;
511
+ try {
512
+ const src = "/pagefind/pagefind.js";
513
+ const res = await fetch(src, { method: "HEAD" });
514
+ if (!res.ok) throw new Error("no index");
515
+ // 变量形式 + @vite-ignore:构建产物不存在时(dev/无索引)不做静态解析,运行时才加载
516
+ const pf = (await import(/* @vite-ignore */ src)) as unknown as Pagefind;
517
+ if (pf.options) await pf.options({ excerptLength: 20 });
518
+ pagefind = pf;
519
+ } catch {
520
+ pagefind = null;
521
+ }
522
+ return pagefind;
523
+ }
524
+
525
+ function setSearchHint(text: string) {
526
+ const box = doc.getElementById("searchResults");
527
+ if (!box) return;
528
+ box.replaceChildren();
529
+ const p = doc.createElement("div");
530
+ p.className = "search-hint";
531
+ p.textContent = text;
532
+ box.appendChild(p);
533
+ box.hidden = false;
534
+ }
535
+
536
+ function renderSearchResults(items: PagefindResultData[]) {
537
+ const box = doc.getElementById("searchResults");
538
+ if (!box) return;
539
+ box.replaceChildren();
540
+ for (const item of items) {
541
+ const a = doc.createElement("a");
542
+ a.className = "search-item";
543
+ a.href = item.url;
544
+ const title = doc.createElement("div");
545
+ title.className = "search-item-title";
546
+ title.textContent = item.meta?.title || item.url;
547
+ const excerpt = doc.createElement("div");
548
+ excerpt.className = "search-item-excerpt";
549
+ /* pagefind 产出的受限高亮片段(<mark>/<strong>),非用户输入 */
550
+ excerpt.innerHTML = item.excerpt;
551
+ a.append(title, excerpt);
552
+ box.appendChild(a);
553
+ }
554
+ box.hidden = false;
555
+ }
556
+
557
+ function closeSearch() {
558
+ const pop = doc.getElementById("searchPop");
559
+ if (pop && !pop.hidden) {
560
+ pop.hidden = true;
561
+ doc.getElementById("searchToggle")?.setAttribute("aria-expanded", "false");
562
+ }
563
+ /* §4.56 双输入框:桌面内联 + 移动端浮层内,关闭时一并清空 */
564
+ for (const id of ["searchInput", "searchInputM"]) {
565
+ const input = doc.getElementById(id) as HTMLInputElement | null;
566
+ if (input) input.value = "";
567
+ }
568
+ const box = doc.getElementById("searchResults");
569
+ if (box) {
570
+ box.hidden = true;
571
+ box.replaceChildren();
572
+ }
573
+ }
574
+
575
+ /* 打开搜索(§4.12 快捷键用):桌面聚焦内联框(输入即出结果浮层);
576
+ 内联框不可见(<640px 移动形态)时打开浮层并聚焦浮层内输入框 */
577
+ function openSearch() {
578
+ const inline = doc.getElementById("searchInput") as HTMLInputElement | null;
579
+ if (inline?.offsetParent) {
580
+ inline.focus();
581
+ return;
582
+ }
583
+ const pop = doc.getElementById("searchPop");
584
+ if (!pop?.hidden) return;
585
+ pop.hidden = false;
586
+ doc.getElementById("searchToggle")?.setAttribute("aria-expanded", "true");
587
+ (doc.getElementById("searchInputM") as HTMLInputElement | null)?.focus();
588
+ }
589
+
590
+ /* 输入 → 300ms 防抖查询;searchSeq 丢弃过期响应;§4.56 有词即弹出结果浮层 */
591
+ function runSearch(keyword: string) {
592
+ const seq = ++searchSeq;
593
+ const box = doc.getElementById("searchResults");
594
+ const pop = doc.getElementById("searchPop");
595
+ if (!box || !pop) return;
596
+ if (!keyword) {
597
+ pop.hidden = true;
598
+ box.hidden = true;
599
+ box.replaceChildren();
600
+ return;
601
+ }
602
+ pop.hidden = false;
603
+ setSearchHint(ui("searchLoading"));
604
+ window.setTimeout(async () => {
605
+ if (seq !== searchSeq) return;
606
+ const pf = await loadPagefind();
607
+ if (seq !== searchSeq) return;
608
+ if (!pf) {
609
+ setSearchHint(ui("searchUnavailable"));
610
+ return;
611
+ }
612
+ try {
613
+ const res = await pf.search(keyword);
614
+ const items = await Promise.all(
615
+ res.results.slice(0, 8).map((r) => r.data()),
616
+ );
617
+ if (seq !== searchSeq) return;
618
+ if (items.length) renderSearchResults(items);
619
+ else setSearchHint(ui("searchNoResults"));
620
+ } catch {
621
+ if (seq === searchSeq) setSearchHint(ui("searchNoResults"));
622
+ }
623
+ }, 300);
624
+ }
625
+
626
+ /* ---------- 加密文章(M3b-b7):Web Crypto 解密(与 utils/crypto.ts 构建期参数对齐) ---------- */
627
+ const PW_KEY_PREFIX = "qy-pw:";
628
+ const ENC_SALT_LEN = 16;
629
+ const ENC_IV_LEN = 12;
630
+ const ENC_TAG_LEN = 16;
631
+
632
+ function b64ToBytes(b64: string): Uint8Array {
633
+ const bin = atob(b64);
634
+ const bytes = new Uint8Array(bin.length);
635
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
636
+ return bytes;
637
+ }
638
+
639
+ /** base64 密文 → PBKDF2(100k, SHA-256) 派生密钥 → AES-GCM 解密 → HTML 字符串 */
640
+ async function decryptContent(dataB64: string, pwd: string): Promise<string> {
641
+ const raw = b64ToBytes(dataB64);
642
+ const salt = raw.slice(0, ENC_SALT_LEN);
643
+ const iv = raw.slice(ENC_SALT_LEN, ENC_SALT_LEN + ENC_IV_LEN);
644
+ const tag = raw.slice(
645
+ ENC_SALT_LEN + ENC_IV_LEN,
646
+ ENC_SALT_LEN + ENC_IV_LEN + ENC_TAG_LEN,
647
+ );
648
+ const ct = raw.slice(ENC_SALT_LEN + ENC_IV_LEN + ENC_TAG_LEN);
649
+ // Web Crypto 的 GCM 密文格式为 ciphertext+tag 连续排列,与 node:crypto 的分离输出拼接
650
+ const combined = new Uint8Array(ct.length + ENC_TAG_LEN);
651
+ combined.set(ct);
652
+ combined.set(tag, ct.length);
653
+
654
+ const keyMaterial = await crypto.subtle.importKey(
655
+ "raw",
656
+ new TextEncoder().encode(pwd),
657
+ "PBKDF2",
658
+ false,
659
+ ["deriveKey"],
660
+ );
661
+ const key = await crypto.subtle.deriveKey(
662
+ { name: "PBKDF2", salt, iterations: 100000, hash: "SHA-256" },
663
+ keyMaterial,
664
+ { name: "AES-GCM", length: 256 },
665
+ false,
666
+ ["decrypt"],
667
+ );
668
+ const plain = await crypto.subtle.decrypt(
669
+ { name: "AES-GCM", iv },
670
+ key,
671
+ combined,
672
+ );
673
+ return new TextDecoder().decode(plain);
674
+ }
675
+
676
+ /** 解密成功:注入 HTML → 隐藏锁 UI → 重跑 reveal 动画 + 灯箱属性预置(§4.68) */
677
+ function showDecrypted(
678
+ container: HTMLElement,
679
+ contentEl: HTMLElement,
680
+ html: string,
681
+ ) {
682
+ contentEl.innerHTML = html;
683
+ contentEl.hidden = false;
684
+ container.hidden = true;
685
+ initReveal();
686
+ initLightbox();
687
+ }
688
+
689
+ function initEncrypted() {
690
+ const container = doc.getElementById("encryptedContainer");
691
+ const contentEl = doc.getElementById("decryptedContent");
692
+ if (!container || !contentEl || container.hidden) return;
693
+ const data = container.getAttribute("data-encrypted") || "";
694
+ if (!data) return;
695
+ const slug = container.getAttribute("data-slug") || "";
696
+ const cacheKey = PW_KEY_PREFIX + slug;
697
+ const form = doc.getElementById("encForm") as HTMLFormElement | null;
698
+ const input = doc.getElementById("encInput") as HTMLInputElement | null;
699
+ const errorEl = doc.getElementById("encError");
700
+
701
+ form?.addEventListener("submit", (e) => {
702
+ e.preventDefault();
703
+ const pwd = input?.value.trim() ?? "";
704
+ if (!pwd) return;
705
+ if (errorEl) errorEl.hidden = true;
706
+ decryptContent(data, pwd)
707
+ .then((html) => {
708
+ try {
709
+ sessionStorage.setItem(cacheKey, pwd);
710
+ } catch {
711
+ /* ignore */
712
+ }
713
+ showDecrypted(container, contentEl, html);
714
+ })
715
+ .catch(() => {
716
+ if (errorEl) errorEl.hidden = false;
717
+ });
718
+ });
719
+
720
+ /* 缓存密码自动解密(salt/iv 确定性派生:同 slug+密码同密文,缓存跨刷新有效) */
721
+ try {
722
+ const cached = sessionStorage.getItem(cacheKey);
723
+ if (cached) {
724
+ decryptContent(data, cached)
725
+ .then((html) => showDecrypted(container, contentEl, html))
726
+ .catch(() => {
727
+ try {
728
+ sessionStorage.removeItem(cacheKey);
729
+ } catch {
730
+ /* ignore */
731
+ }
732
+ });
733
+ }
734
+ } catch {
735
+ /* ignore */
736
+ }
737
+ }
738
+
739
+ /* ---------- document 级委托(一次性,VT 换页不失联) ---------- */
740
+ doc.addEventListener("click", (e) => {
741
+ const target = e.target as Element;
742
+ if (!target || typeof target.closest !== "function") return;
743
+
744
+ /* 点击调色面板/搜索浮层外部时先收起(不拦截后续点击行为) */
745
+ if (!target.closest(".palette-wrap")) {
746
+ const pop = doc.getElementById("palettePop");
747
+ if (pop && !pop.hidden) closePalette();
748
+ }
749
+ if (!target.closest(".search-wrap")) {
750
+ const pop = doc.getElementById("searchPop");
751
+ if (pop && !pop.hidden) closeSearch();
752
+ }
753
+
754
+ /* 搜索浮层开关(§4.56 移动端 icon 按钮;桌面内联框输入即搜,无开关) */
755
+ if (target.closest("#searchToggle")) {
756
+ const pop = doc.getElementById("searchPop");
757
+ if (pop) {
758
+ pop.hidden = !pop.hidden;
759
+ doc
760
+ .getElementById("searchToggle")
761
+ ?.setAttribute("aria-expanded", pop.hidden ? "false" : "true");
762
+ if (!pop.hidden) {
763
+ (
764
+ doc.getElementById("searchInputM") as HTMLInputElement | null
765
+ )?.focus();
766
+ }
767
+ }
768
+ return;
769
+ }
770
+
771
+ /* 搜索结果点击:收起浮层后放行默认导航(VT 接管换页) */
772
+ if (target.closest(".search-item")) {
773
+ closeSearch();
774
+ }
775
+
776
+ /* 调色面板开关 */
777
+ const paletteToggle = target.closest("#paletteToggle");
778
+ if (paletteToggle) {
779
+ const pop = doc.getElementById("palettePop");
780
+ if (pop) {
781
+ pop.hidden = !pop.hidden;
782
+ paletteToggle.setAttribute(
783
+ "aria-expanded",
784
+ pop.hidden ? "false" : "true",
785
+ );
786
+ }
787
+ return;
788
+ }
789
+
790
+ /* 预设色板 */
791
+ const swatch = target.closest<HTMLElement>(".palette-pop .swatch");
792
+ if (swatch) {
793
+ const h = parseFloat(swatch.dataset.hue || "");
794
+ if (Number.isFinite(h)) applyHue(h);
795
+ return;
796
+ }
797
+
798
+ /* hue 重置:移除运行时覆写,回落到构建期 accentColor */
799
+ if (target.closest("#hueReset")) {
800
+ root.style.removeProperty("--accent-h");
801
+ persistHue(null);
802
+ syncPaletteUI(buildHue());
803
+ return;
804
+ }
805
+
806
+ /* 模式切换 */
807
+ if (target.closest("#modeToggle")) {
808
+ const dark = root.classList.toggle("dark");
809
+ try {
810
+ localStorage.setItem("qy-mode", dark ? "dark" : "light");
811
+ } catch {
812
+ /* ignore */
813
+ }
814
+ return;
815
+ }
816
+
817
+ /* burger 导航 */
818
+ if (target.closest("#navToggle")) {
819
+ const nav = doc.getElementById("siteNav");
820
+ if (nav) {
821
+ const open = nav.classList.toggle("open");
822
+ target
823
+ .closest("#navToggle")
824
+ ?.setAttribute("aria-expanded", open ? "true" : "false");
825
+ }
826
+ return;
827
+ }
828
+
829
+ /* nav 子菜单 toggle(§4.16):无 href 的父项点击展开/收起 */
830
+ if (target.closest(".nav-sub-toggle")) {
831
+ const item = target.closest(".nav-item");
832
+ if (item) {
833
+ const open = item.classList.toggle("open-sub");
834
+ target
835
+ .closest(".nav-sub-toggle")
836
+ ?.setAttribute("aria-expanded", open ? "true" : "false");
837
+ }
838
+ return;
839
+ }
840
+
841
+ /* 归档按年折叠(§4.30):点击年份头切换该年时间线显隐 */
842
+ if (target.closest(".archive-year")) {
843
+ const btn = target.closest(".archive-year") as HTMLElement;
844
+ const timeline = btn.closest(".timeline");
845
+ if (timeline) {
846
+ // toggle 返回 true = 加了 fold 类 = 已收起
847
+ const collapsed = timeline.classList.toggle("fold");
848
+ btn.setAttribute("aria-expanded", collapsed ? "false" : "true");
849
+ }
850
+ return;
851
+ }
852
+
853
+ /* 列表形态切换(§4.34):toggle grid-mode + localStorage 记忆,CSS 过渡呈现 */
854
+ if (target.closest("#layoutSwitch")) {
855
+ const list = doc.getElementById("postList");
856
+ const btn = target.closest("#layoutSwitch") as HTMLElement | null;
857
+ if (list && btn && list.dataset.allowSwitch === "true") {
858
+ const toGrid = !list.classList.contains("grid-mode");
859
+ list.classList.toggle("grid-mode", toGrid);
860
+ list.style.setProperty("--col-w", `${list.dataset.gridCol || "320"}px`);
861
+ btn.setAttribute("aria-pressed", toGrid ? "true" : "false");
862
+ try {
863
+ localStorage.setItem("qy-postList", toGrid ? "grid" : "list");
864
+ } catch {
865
+ /* ignore */
866
+ }
867
+ }
868
+ return;
869
+ }
870
+
871
+ /* back-top */
872
+ if (target.closest("#backTop")) {
873
+ window.scrollTo({ top: 0, behavior: "smooth" });
874
+ return;
875
+ }
876
+
877
+ /* 分享海报(b9.6):文章页「生成分享海报」按钮 */
878
+ const shareBtn = target.closest("#qy-share-btn");
879
+ if (shareBtn) {
880
+ openSharePoster(shareBtn as HTMLElement);
881
+ return;
882
+ }
883
+ });
884
+
885
+ /* 随机封面加载失败重试(§4.33):img error 不冒泡,捕获阶段 document 级委托
886
+ (bundled 一次性执行,VT 换页后仍有效)。按 data-api-retries 顺序换源,
887
+ 全部失败加 cover-broken 藏 img,露出底层 LQIP 渐变占位。 */
888
+ doc.addEventListener(
889
+ "error",
890
+ (e) => {
891
+ const img = e.target as HTMLImageElement | null;
892
+ if (img?.tagName !== "IMG") return;
893
+ const raw = img.dataset.apiRetries;
894
+ if (!raw) return;
895
+ let list: string[] = [];
896
+ try {
897
+ list = JSON.parse(raw);
898
+ } catch {
899
+ return;
900
+ }
901
+ const idx = Number(img.dataset.retryIdx ?? "1");
902
+ if (idx >= 0 && idx < list.length) {
903
+ img.dataset.retryIdx = String(idx + 1);
904
+ img.src = list[idx];
905
+ } else {
906
+ img.classList.add("cover-broken");
907
+ }
908
+ },
909
+ true,
910
+ );
911
+
912
+ /* input 委托(VT 安全):hue 滑杆 + 搜索输入 */
913
+ doc.addEventListener("input", (e) => {
914
+ const el = e.target as Element | null;
915
+ if (el && el.id === "hueSlider") {
916
+ const h = parseFloat((el as HTMLInputElement).value);
917
+ if (Number.isFinite(h)) applyHue(h);
918
+ }
919
+ if (el && (el.id === "searchInput" || el.id === "searchInputM")) {
920
+ runSearch((el as HTMLInputElement).value.trim());
921
+ }
922
+ });
923
+
924
+ doc.addEventListener("keydown", (e) => {
925
+ /* 搜索浮层:Esc 关闭 */
926
+ if (e.key === "Escape") {
927
+ const pop = doc.getElementById("searchPop");
928
+ if (pop && !pop.hidden) {
929
+ closeSearch();
930
+ return;
931
+ }
932
+ }
933
+ /* 搜索快捷键(§4.12):/ 或 ⌘/Ctrl+K;输入中、灯箱(PhotoSwipe)打开时不触发 */
934
+ if (
935
+ e.key === "/" ||
936
+ ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k")
937
+ ) {
938
+ if (e.altKey || e.shiftKey) return;
939
+ const el = e.target as HTMLElement | null;
940
+ const typing =
941
+ !!el &&
942
+ (el.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el.tagName));
943
+ if (!typing && !pswpLightbox?.pswp) {
944
+ e.preventDefault();
945
+ openSearch();
946
+ }
947
+ return;
948
+ }
949
+ });
950
+
951
+ /* ---------- LQIP(b9.5):图片加载完成后淡出占位渐变 ---------- */
952
+ function initLqip() {
953
+ doc.querySelectorAll<HTMLImageElement>("img[data-lqip]").forEach((img) => {
954
+ const box = img.parentElement;
955
+ if (!box) return;
956
+ const done = () => box.classList.add("qy-img-in");
957
+ // 缓存命中立即淡出;load/error 均淡出(失败也不能永久盖住图位)
958
+ if (img.complete && img.naturalWidth > 0) done();
959
+ else {
960
+ img.addEventListener("load", done, { once: true });
961
+ img.addEventListener("error", done, { once: true });
962
+ }
963
+ });
964
+ }
965
+
966
+ /* ---------- 分类栏溢出渐隐(§4.21):scrollWidth 溢出 → .is-scrollable 显右缘渐隐 ---------- */
967
+ // 模块级保存 resize handler 引用:VT 换页重复 initPage 时先移除旧监听防泄漏
968
+ const catBarResize: { fn?: () => void } = {};
969
+ function initCategoryBar() {
970
+ const bar = doc.querySelector<HTMLElement>(".category-bar");
971
+ if (!bar) return;
972
+ const scroll = bar.querySelector<HTMLElement>(".bar-scroll");
973
+ if (!scroll) return;
974
+
975
+ const measure = () => {
976
+ // +1 容差防亚像素抖动;溢出时 CSS 右缘渐隐提示可横向滑动
977
+ bar.classList.toggle(
978
+ "is-scrollable",
979
+ scroll.scrollWidth > scroll.clientWidth + 1,
980
+ );
981
+ };
982
+ measure();
983
+ // 字体加载会改变量宽,就绪后重测一次(ready 为全局单例 promise,重复 then 无害)
984
+ document.fonts?.ready.then(measure).catch(() => {});
985
+ if (catBarResize.fn) window.removeEventListener("resize", catBarResize.fn);
986
+ catBarResize.fn = measure;
987
+ window.addEventListener("resize", measure, { passive: true });
988
+ }
989
+
990
+ /* ---------- 列表形态恢复(§4.34→§4.55) ---------- */
991
+ /* VT 换页后内联 boot 脚本不重执行,由这里按 localStorage > defaultLayout 恢复 grid-mode。
992
+ defaultLayout 无论 allowSwitch 开关均生效(allowSwitch 仅控制访客切换按钮) */
993
+ function initPostListLayout() {
994
+ const list = doc.getElementById("postList");
995
+ if (!list) return;
996
+ let mode: string;
997
+ try {
998
+ mode =
999
+ localStorage.getItem("qy-postList") ||
1000
+ list.dataset.defaultLayout ||
1001
+ "list";
1002
+ } catch {
1003
+ mode = list.dataset.defaultLayout || "list";
1004
+ }
1005
+ list.classList.toggle("grid-mode", mode === "grid");
1006
+ list.style.setProperty("--col-w", `${list.dataset.gridCol || "320"}px`);
1007
+ const btn = doc.getElementById("layoutSwitch");
1008
+ if (btn) btn.setAttribute("aria-pressed", mode === "grid" ? "true" : "false");
1009
+ }
1010
+
1011
+ /* ---------- 专题导航定位(§4.58):目录 280px 限高滚动,当前篇在可视区外时居中定位 ---------- */
1012
+ /* 只滚 .series-nav-list 容器内部(rect 差值计算),不带动页面滚动;已可见时不动作 */
1013
+ function initSeriesNav() {
1014
+ const list = doc.querySelector<HTMLElement>(".series-nav-list");
1015
+ const cur = list?.querySelector<HTMLElement>(".series-nav-item.cur");
1016
+ if (!list || !cur) return;
1017
+ const lr = list.getBoundingClientRect();
1018
+ const cr = cur.getBoundingClientRect();
1019
+ if (cr.top < lr.top || cr.bottom > lr.bottom) {
1020
+ list.scrollTop += (cr.top + cr.bottom) / 2 - (lr.top + lr.bottom) / 2;
1021
+ }
1022
+ }
1023
+
1024
+ /* ---------- 生命周期 ---------- */
1025
+ function initPage() {
1026
+ initLightbox();
1027
+ initNavActive();
1028
+ closeMobileNav();
1029
+ initReveal();
1030
+ initTocSpy();
1031
+ updateBackTop();
1032
+ updateReadProgress();
1033
+ initPalette();
1034
+ initEncrypted();
1035
+ initLqip();
1036
+ initCategoryBar();
1037
+ initPostListLayout();
1038
+ initSeriesNav();
1039
+ }
1040
+ initPage();
1041
+ doc.addEventListener("astro:page-load", initPage);