@windcoder/astro-theme-orchid 0.2.1 → 0.4.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/README.md +5 -3
- package/package.json +4 -2
- package/scripts/check-friends.mjs +227 -0
- package/scripts/generate-lqips.mjs +234 -20
- package/scripts/migrate-posts.mjs +375 -0
- package/src/components/AlbumCard.astro +4 -1
- package/src/components/Analytics.astro +3 -0
- package/src/components/BaseLayout.astro +222 -39
- 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 +4 -1
- package/src/components/SakuraEffect.astro +126 -28
- package/src/components/Sidebar.astro +28 -7
- package/src/components/analytics/WebVitals.astro +72 -0
- package/src/i18n/en.ts +42 -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 +43 -0
- package/src/i18n/zh-TW.ts +208 -0
- package/src/index.ts +23 -0
- package/src/plugins/rehype.ts +76 -4
- package/src/routes/bangumi.astro +110 -0
- package/src/routes/friends.astro +10 -2
- package/src/routes/manifest.webmanifest.ts +33 -0
- package/src/routes/page/[page].astro +3 -0
- package/src/routes/search.astro +24 -0
- package/src/routes/sponsor.astro +17 -4
- package/src/routes/sw.js.ts +127 -0
- package/src/schema.ts +446 -45
- package/src/scripts/app.ts +805 -37
- package/src/styles/global.css +939 -18
- package/src/utils/gallery.ts +3 -1
- package/src/utils/image.ts +79 -0
- package/src/utils/lqip.ts +21 -12
- package/src/utils/thumb.ts +26 -0
- package/src/vite.ts +95 -8
package/src/scripts/app.ts
CHANGED
|
@@ -104,16 +104,15 @@ function applySavedHue() {
|
|
|
104
104
|
applySavedHue();
|
|
105
105
|
doc.addEventListener("astro:after-swap", applySavedHue);
|
|
106
106
|
|
|
107
|
-
/* 同步面板 UI:滑杆 / 徽章 / swatch 激活态(差值 <1° 判定,含 0/360
|
|
107
|
+
/* 同步面板 UI:滑杆 / 徽章 / swatch 激活态(差值 <1° 判定,含 0/360 环绕)。
|
|
108
|
+
swatch 可能在旧 palette-pop 或显示设置面板(§4.103 外观 Tab)内,全局查询 */
|
|
108
109
|
function syncPaletteUI(h: number) {
|
|
109
|
-
const pop = doc.getElementById("palettePop");
|
|
110
|
-
if (!pop) return;
|
|
111
110
|
const rounded = Math.round(((h % 360) + 360) % 360) % 360;
|
|
112
111
|
const slider = doc.getElementById("hueSlider") as HTMLInputElement | null;
|
|
113
112
|
if (slider) slider.value = String(rounded);
|
|
114
113
|
const badge = doc.getElementById("hueBadge");
|
|
115
114
|
if (badge) badge.textContent = String(rounded);
|
|
116
|
-
|
|
115
|
+
doc.querySelectorAll<HTMLElement>(".swatch[data-hue]").forEach((sw) => {
|
|
117
116
|
const dh = Math.abs(parseFloat(sw.dataset.hue || "0") - rounded);
|
|
118
117
|
sw.classList.toggle("is-active", dh < 1 || dh > 359);
|
|
119
118
|
});
|
|
@@ -133,11 +132,11 @@ function closePalette() {
|
|
|
133
132
|
if (toggle) toggle.setAttribute("aria-expanded", "false");
|
|
134
133
|
}
|
|
135
134
|
|
|
136
|
-
/* 每次导航后同步面板状态并复位收起(面板 DOM 会随 swap
|
|
135
|
+
/* 每次导航后同步面板状态并复位收起(面板 DOM 会随 swap 重建);
|
|
136
|
+
显示设置面板形态(§4.103)无 palettePop,仍需同步 hue 控件 */
|
|
137
137
|
function initPalette() {
|
|
138
138
|
const pop = doc.getElementById("palettePop");
|
|
139
|
-
if (
|
|
140
|
-
closePalette();
|
|
139
|
+
if (pop) closePalette();
|
|
141
140
|
syncPaletteUI(currentHue());
|
|
142
141
|
}
|
|
143
142
|
|
|
@@ -193,7 +192,15 @@ async function initLightbox() {
|
|
|
193
192
|
const img = el instanceof HTMLImageElement ? el : el?.querySelector("img");
|
|
194
193
|
if (!img) return itemData;
|
|
195
194
|
if (!itemData.src) itemData.src = img.currentSrc || img.src;
|
|
196
|
-
|
|
195
|
+
/* §4.106:列表 img 可能是缩略图(data-full-* 携带原图 URL 与尺寸)——
|
|
196
|
+
优先打开原图;无数据时沿用 img 自身 src 与 natural 尺寸 */
|
|
197
|
+
if (img.dataset.fullSrc) {
|
|
198
|
+
itemData.src = img.dataset.fullSrc;
|
|
199
|
+
if (Number(img.dataset.fullWidth) > 0 && Number(img.dataset.fullHeight) > 0) {
|
|
200
|
+
itemData.width = Number(img.dataset.fullWidth);
|
|
201
|
+
itemData.height = Number(img.dataset.fullHeight);
|
|
202
|
+
}
|
|
203
|
+
} else if (img.naturalWidth > 0) {
|
|
197
204
|
itemData.width = img.naturalWidth;
|
|
198
205
|
itemData.height = img.naturalHeight;
|
|
199
206
|
}
|
|
@@ -484,12 +491,15 @@ window.addEventListener(
|
|
|
484
491
|
() => {
|
|
485
492
|
updateBackTop();
|
|
486
493
|
updateReadProgress();
|
|
494
|
+
updateWallpaperScroll();
|
|
487
495
|
},
|
|
488
496
|
{ passive: true },
|
|
489
497
|
);
|
|
490
498
|
window.addEventListener("resize", updateReadProgress, { passive: true });
|
|
491
499
|
|
|
492
|
-
/* ----------
|
|
500
|
+
/* ---------- 搜索(M3b-b6 → §4.104):Pagefind 构建期索引,标准动态 import 接入。
|
|
501
|
+
导航浮层:输入即搜,最多 8 条,超出时尾部「查看全部」链接进独立搜索页;
|
|
502
|
+
独立搜索页(/search/):URL ?q= 关键词 + ?page= 页码,客户端分页渲染 ---------- */
|
|
493
503
|
type PagefindResultData = {
|
|
494
504
|
url: string;
|
|
495
505
|
excerpt: string;
|
|
@@ -504,15 +514,14 @@ type Pagefind = {
|
|
|
504
514
|
|
|
505
515
|
/* undefined=未加载 null=不可用(dev 无索引/加载失败);模块级缓存,VT 换页不重复加载 */
|
|
506
516
|
let pagefind: Pagefind | null | undefined;
|
|
507
|
-
let searchSeq = 0;
|
|
508
517
|
|
|
509
518
|
async function loadPagefind(): Promise<Pagefind | null> {
|
|
510
519
|
if (pagefind !== undefined) return pagefind;
|
|
511
520
|
try {
|
|
512
521
|
const src = "/pagefind/pagefind.js";
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
//
|
|
522
|
+
// 标准 Pagefind 接入(官方推荐):运行时直接动态 import 构建期索引入口。
|
|
523
|
+
// 变量形式 + @vite-ignore:索引是 build 后才存在的产物,构建期不做静态解析,
|
|
524
|
+
// dev 无索引时 import 失败走 catch 降级提示
|
|
516
525
|
const pf = (await import(/* @vite-ignore */ src)) as unknown as Pagefind;
|
|
517
526
|
if (pf.options) await pf.options({ excerptLength: 20 });
|
|
518
527
|
pagefind = pf;
|
|
@@ -522,6 +531,25 @@ async function loadPagefind(): Promise<Pagefind | null> {
|
|
|
522
531
|
return pagefind;
|
|
523
532
|
}
|
|
524
533
|
|
|
534
|
+
/* 浮层最多展示 8 条(§4.56 视口内条数),超出引导进独立搜索页(§4.104) */
|
|
535
|
+
const SEARCH_POP_MAX = 8;
|
|
536
|
+
|
|
537
|
+
/* 结果项(浮层与搜索页共用):标题 + pagefind 受限高亮摘要 */
|
|
538
|
+
function searchItemEl(item: PagefindResultData): HTMLAnchorElement {
|
|
539
|
+
const a = doc.createElement("a");
|
|
540
|
+
a.className = "search-item";
|
|
541
|
+
a.href = item.url;
|
|
542
|
+
const title = doc.createElement("div");
|
|
543
|
+
title.className = "search-item-title";
|
|
544
|
+
title.textContent = item.meta?.title || item.url;
|
|
545
|
+
const excerpt = doc.createElement("div");
|
|
546
|
+
excerpt.className = "search-item-excerpt";
|
|
547
|
+
/* pagefind 产出的受限高亮片段(<mark>/<strong>),非用户输入 */
|
|
548
|
+
excerpt.innerHTML = item.excerpt;
|
|
549
|
+
a.append(title, excerpt);
|
|
550
|
+
return a;
|
|
551
|
+
}
|
|
552
|
+
|
|
525
553
|
function setSearchHint(text: string) {
|
|
526
554
|
const box = doc.getElementById("searchResults");
|
|
527
555
|
if (!box) return;
|
|
@@ -533,23 +561,24 @@ function setSearchHint(text: string) {
|
|
|
533
561
|
box.hidden = false;
|
|
534
562
|
}
|
|
535
563
|
|
|
536
|
-
|
|
564
|
+
/* 请求序号:只接受最后一次输入的响应,防乱序覆盖 */
|
|
565
|
+
let searchSeq = 0;
|
|
566
|
+
|
|
567
|
+
function renderSearchResults(
|
|
568
|
+
items: PagefindResultData[],
|
|
569
|
+
more?: { keyword: string; total: number },
|
|
570
|
+
) {
|
|
537
571
|
const box = doc.getElementById("searchResults");
|
|
538
572
|
if (!box) return;
|
|
539
573
|
box.replaceChildren();
|
|
540
|
-
for (const item of items)
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
excerpt.className = "search-item-excerpt";
|
|
549
|
-
/* pagefind 产出的受限高亮片段(<mark>/<strong>),非用户输入 */
|
|
550
|
-
excerpt.innerHTML = item.excerpt;
|
|
551
|
-
a.append(title, excerpt);
|
|
552
|
-
box.appendChild(a);
|
|
574
|
+
for (const item of items) box.appendChild(searchItemEl(item));
|
|
575
|
+
if (more) {
|
|
576
|
+
/* §4.104 超出浮层上限:尾部「查看全部 N 条结果」链接进独立搜索页 */
|
|
577
|
+
const link = doc.createElement("a");
|
|
578
|
+
link.className = "search-more";
|
|
579
|
+
link.href = "/search/?q=" + encodeURIComponent(more.keyword);
|
|
580
|
+
link.textContent = ui("searchMore").replace("{n}", String(more.total));
|
|
581
|
+
box.appendChild(link);
|
|
553
582
|
}
|
|
554
583
|
box.hidden = false;
|
|
555
584
|
}
|
|
@@ -611,18 +640,168 @@ function runSearch(keyword: string) {
|
|
|
611
640
|
}
|
|
612
641
|
try {
|
|
613
642
|
const res = await pf.search(keyword);
|
|
643
|
+
/* res.results.length 即命中总数(惰性 API);浮层只取前 SEARCH_POP_MAX 条加载详情 */
|
|
644
|
+
const total = res.results.length;
|
|
614
645
|
const items = await Promise.all(
|
|
615
|
-
res.results.slice(0,
|
|
646
|
+
res.results.slice(0, SEARCH_POP_MAX).map((r) => r.data()),
|
|
616
647
|
);
|
|
617
648
|
if (seq !== searchSeq) return;
|
|
618
|
-
if (
|
|
619
|
-
|
|
649
|
+
if (total > SEARCH_POP_MAX) {
|
|
650
|
+
renderSearchResults(items, { keyword, total });
|
|
651
|
+
} else if (items.length) {
|
|
652
|
+
renderSearchResults(items);
|
|
653
|
+
} else {
|
|
654
|
+
setSearchHint(ui("searchNoResults"));
|
|
655
|
+
}
|
|
620
656
|
} catch {
|
|
621
657
|
if (seq === searchSeq) setSearchHint(ui("searchNoResults"));
|
|
622
658
|
}
|
|
623
659
|
}, 300);
|
|
624
660
|
}
|
|
625
661
|
|
|
662
|
+
/* ---------- 独立搜索页(§4.104,/search/):URL ?q= 关键词 + ?page= 页码。
|
|
663
|
+
静态外壳 + 客户端查询分页;输入即时刷新(replaceState 不经换页),
|
|
664
|
+
翻页走真实链接经 VT 换页后由 astro:page-load 重新渲染 ---------- */
|
|
665
|
+
const SEARCH_PAGE_SIZE = 10;
|
|
666
|
+
let searchPageSeq = 0;
|
|
667
|
+
let searchPageTimer = 0;
|
|
668
|
+
|
|
669
|
+
/* 页码窗口:恒含首末页与当前页±2,间隙以省略号占位(与 Pagination.astro 同规则) */
|
|
670
|
+
function pagerNums(page: number, total: number): Array<number | "ellipsis"> {
|
|
671
|
+
const set = new Set<number>([1, total]);
|
|
672
|
+
for (let p = page - 2; p <= page + 2; p++) {
|
|
673
|
+
if (p >= 1 && p <= total) set.add(p);
|
|
674
|
+
}
|
|
675
|
+
const nums = [...set].sort((a, b) => a - b);
|
|
676
|
+
const out: Array<number | "ellipsis"> = [];
|
|
677
|
+
let prev = 0;
|
|
678
|
+
for (const n of nums) {
|
|
679
|
+
if (prev && n - prev > 1) out.push("ellipsis");
|
|
680
|
+
out.push(n);
|
|
681
|
+
prev = n;
|
|
682
|
+
}
|
|
683
|
+
return out;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
function searchPageHref(q: string, page: number): string {
|
|
687
|
+
return (
|
|
688
|
+
"/search/?q=" +
|
|
689
|
+
encodeURIComponent(q) +
|
|
690
|
+
(page > 1 ? "&page=" + page : "")
|
|
691
|
+
);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function renderSearchPager(pager: HTMLElement, q: string, cur: number, pages: number) {
|
|
695
|
+
if (pages <= 1) {
|
|
696
|
+
pager.hidden = true;
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
pager.hidden = false;
|
|
700
|
+
const nums = doc.createElement("div");
|
|
701
|
+
nums.className = "pg-nums";
|
|
702
|
+
const mk = (href: string, label: string, cls: string) => {
|
|
703
|
+
const a = doc.createElement("a");
|
|
704
|
+
a.className = cls;
|
|
705
|
+
a.href = href;
|
|
706
|
+
a.textContent = label;
|
|
707
|
+
return a;
|
|
708
|
+
};
|
|
709
|
+
const span = (label: string) => {
|
|
710
|
+
const s = doc.createElement("span");
|
|
711
|
+
s.className = "page-item disabled";
|
|
712
|
+
s.textContent = label;
|
|
713
|
+
return s;
|
|
714
|
+
};
|
|
715
|
+
if (cur > 1) nums.appendChild(mk(searchPageHref(q, cur - 1), "‹", "page-item"));
|
|
716
|
+
else nums.appendChild(span("‹"));
|
|
717
|
+
for (const n of pagerNums(cur, pages)) {
|
|
718
|
+
if (n === "ellipsis") {
|
|
719
|
+
const gap = doc.createElement("span");
|
|
720
|
+
gap.className = "pg-gap";
|
|
721
|
+
gap.textContent = "…";
|
|
722
|
+
nums.appendChild(gap);
|
|
723
|
+
} else if (n === cur) {
|
|
724
|
+
const s = doc.createElement("span");
|
|
725
|
+
s.className = "page-item current";
|
|
726
|
+
s.setAttribute("aria-current", "page");
|
|
727
|
+
s.textContent = String(n);
|
|
728
|
+
nums.appendChild(s);
|
|
729
|
+
} else {
|
|
730
|
+
nums.appendChild(mk(searchPageHref(q, n), String(n), "page-item"));
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (cur < pages) nums.appendChild(mk(searchPageHref(q, cur + 1), "›", "page-item"));
|
|
734
|
+
else nums.appendChild(span("›"));
|
|
735
|
+
|
|
736
|
+
const adj = doc.createElement("div");
|
|
737
|
+
adj.className = "pg-adj";
|
|
738
|
+
if (cur > 1) adj.appendChild(mk(searchPageHref(q, cur - 1), ui("prevPage"), "pg-prev"));
|
|
739
|
+
else adj.appendChild(span(ui("prevPage")));
|
|
740
|
+
const mid = doc.createElement("span");
|
|
741
|
+
mid.textContent = ui("pageOf")
|
|
742
|
+
.replace("{current}", String(cur))
|
|
743
|
+
.replace("{total}", String(pages));
|
|
744
|
+
adj.appendChild(mid);
|
|
745
|
+
if (cur < pages) adj.appendChild(mk(searchPageHref(q, cur + 1), ui("nextPage"), "pg-next"));
|
|
746
|
+
else adj.appendChild(span(ui("nextPage")));
|
|
747
|
+
|
|
748
|
+
pager.replaceChildren(nums, adj);
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function renderSearchPage() {
|
|
752
|
+
const seq = ++searchPageSeq;
|
|
753
|
+
const input = doc.getElementById("searchPageInput") as HTMLInputElement | null;
|
|
754
|
+
const meta = doc.getElementById("searchPageMeta");
|
|
755
|
+
const box = doc.getElementById("searchPageResults");
|
|
756
|
+
const pager = doc.getElementById("searchPagePager");
|
|
757
|
+
if (!input || !meta || !box || !pager) return;
|
|
758
|
+
|
|
759
|
+
const params = new URLSearchParams(location.search);
|
|
760
|
+
const q = (params.get("q") || "").trim();
|
|
761
|
+
const page = Math.max(1, parseInt(params.get("page") || "1", 10) || 1);
|
|
762
|
+
input.value = q;
|
|
763
|
+
pager.hidden = true;
|
|
764
|
+
|
|
765
|
+
if (!q) {
|
|
766
|
+
box.replaceChildren();
|
|
767
|
+
meta.textContent = ui("searchTypeSomething");
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
meta.textContent = ui("searchLoading");
|
|
771
|
+
|
|
772
|
+
const pf = await loadPagefind();
|
|
773
|
+
if (seq !== searchPageSeq) return;
|
|
774
|
+
if (!pf) {
|
|
775
|
+
meta.textContent = ui("searchUnavailable");
|
|
776
|
+
box.replaceChildren();
|
|
777
|
+
return;
|
|
778
|
+
}
|
|
779
|
+
try {
|
|
780
|
+
const res = await pf.search(q);
|
|
781
|
+
const total = res.results.length;
|
|
782
|
+
if (seq !== searchPageSeq) return;
|
|
783
|
+
if (!total) {
|
|
784
|
+
box.replaceChildren();
|
|
785
|
+
meta.textContent = ui("searchNoResults");
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const pages = Math.ceil(total / SEARCH_PAGE_SIZE);
|
|
789
|
+
const cur = Math.min(page, pages);
|
|
790
|
+
const items = await Promise.all(
|
|
791
|
+
res.results
|
|
792
|
+
.slice((cur - 1) * SEARCH_PAGE_SIZE, cur * SEARCH_PAGE_SIZE)
|
|
793
|
+
.map((r) => r.data()),
|
|
794
|
+
);
|
|
795
|
+
if (seq !== searchPageSeq) return;
|
|
796
|
+
box.replaceChildren();
|
|
797
|
+
for (const item of items) box.appendChild(searchItemEl(item));
|
|
798
|
+
meta.textContent = ui("searchResultsTotal").replace("{n}", String(total));
|
|
799
|
+
renderSearchPager(pager, q, cur, pages);
|
|
800
|
+
} catch {
|
|
801
|
+
if (seq === searchPageSeq) meta.textContent = ui("searchNoResults");
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
|
|
626
805
|
/* ---------- 加密文章(M3b-b7):Web Crypto 解密(与 utils/crypto.ts 构建期参数对齐) ---------- */
|
|
627
806
|
const PW_KEY_PREFIX = "qy-pw:";
|
|
628
807
|
const ENC_SALT_LEN = 16;
|
|
@@ -741,7 +920,7 @@ doc.addEventListener("click", (e) => {
|
|
|
741
920
|
const target = e.target as Element;
|
|
742
921
|
if (!target || typeof target.closest !== "function") return;
|
|
743
922
|
|
|
744
|
-
/*
|
|
923
|
+
/* 点击调色面板/搜索浮层/显示设置面板外部时先收起(不拦截后续点击行为) */
|
|
745
924
|
if (!target.closest(".palette-wrap")) {
|
|
746
925
|
const pop = doc.getElementById("palettePop");
|
|
747
926
|
if (pop && !pop.hidden) closePalette();
|
|
@@ -750,6 +929,13 @@ doc.addEventListener("click", (e) => {
|
|
|
750
929
|
const pop = doc.getElementById("searchPop");
|
|
751
930
|
if (pop && !pop.hidden) closeSearch();
|
|
752
931
|
}
|
|
932
|
+
if (
|
|
933
|
+
!target.closest(".ds-panel") &&
|
|
934
|
+
!target.closest("#displaySettingsToggle")
|
|
935
|
+
) {
|
|
936
|
+
const panel = doc.getElementById("displaySettingsPanel");
|
|
937
|
+
if (panel && !panel.hidden) closeDsPanel();
|
|
938
|
+
}
|
|
753
939
|
|
|
754
940
|
/* 搜索浮层开关(§4.56 移动端 icon 按钮;桌面内联框输入即搜,无开关) */
|
|
755
941
|
if (target.closest("#searchToggle")) {
|
|
@@ -768,8 +954,8 @@ doc.addEventListener("click", (e) => {
|
|
|
768
954
|
return;
|
|
769
955
|
}
|
|
770
956
|
|
|
771
|
-
/*
|
|
772
|
-
if (target.closest(".search-item")) {
|
|
957
|
+
/* 搜索结果/查看全部点击:浮层收起后放行默认导航(VT 接管换页;search-more 进独立搜索页) */
|
|
958
|
+
if (target.closest(".search-item, .search-more")) {
|
|
773
959
|
closeSearch();
|
|
774
960
|
}
|
|
775
961
|
|
|
@@ -787,8 +973,8 @@ doc.addEventListener("click", (e) => {
|
|
|
787
973
|
return;
|
|
788
974
|
}
|
|
789
975
|
|
|
790
|
-
/*
|
|
791
|
-
const swatch = target.closest<HTMLElement>(".
|
|
976
|
+
/* 预设色板(旧 palette-pop 与显示设置面板外观 Tab 共用同组 swatch) */
|
|
977
|
+
const swatch = target.closest<HTMLElement>(".swatch[data-hue]");
|
|
792
978
|
if (swatch) {
|
|
793
979
|
const h = parseFloat(swatch.dataset.hue || "");
|
|
794
980
|
if (Number.isFinite(h)) applyHue(h);
|
|
@@ -814,6 +1000,36 @@ doc.addEventListener("click", (e) => {
|
|
|
814
1000
|
return;
|
|
815
1001
|
}
|
|
816
1002
|
|
|
1003
|
+
/* 壁纸模式循环切换(§4.98):banner→fullscreen→overlay→none,localStorage 持久化 */
|
|
1004
|
+
if (target.closest("#wallpaperModeToggle")) {
|
|
1005
|
+
const cur = WP_MODES.indexOf(root.dataset.wallpaperMode || "banner");
|
|
1006
|
+
setWallpaperMode(WP_MODES[(cur + 1) % WP_MODES.length]!);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/* 显示设置面板(§4.103):齿轮开合 / Tab 切换 / 模式与布局按钮 / 开关 / 组重置 */
|
|
1011
|
+
if (handleDsPanelClick(target)) return;
|
|
1012
|
+
|
|
1013
|
+
/* 壁纸视频播放/暂停(§4.98) */
|
|
1014
|
+
if (target.closest("#wpPlayerToggle")) {
|
|
1015
|
+
const video = doc.querySelector<HTMLVideoElement>(".wp-video");
|
|
1016
|
+
if (video) {
|
|
1017
|
+
if (video.paused) video.play().catch(() => {});
|
|
1018
|
+
else video.pause();
|
|
1019
|
+
}
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/* 壁纸视频上一段/下一段 */
|
|
1024
|
+
if (target.closest("#wpPlayerPrev")) {
|
|
1025
|
+
switchWallpaperVideo(-1);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
if (target.closest("#wpPlayerNext")) {
|
|
1029
|
+
switchWallpaperVideo(1);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
817
1033
|
/* burger 导航 */
|
|
818
1034
|
if (target.closest("#navToggle")) {
|
|
819
1035
|
const nav = doc.getElementById("siteNav");
|
|
@@ -909,7 +1125,7 @@ doc.addEventListener(
|
|
|
909
1125
|
true,
|
|
910
1126
|
);
|
|
911
1127
|
|
|
912
|
-
/* input 委托(VT 安全):hue 滑杆 + 搜索输入 */
|
|
1128
|
+
/* input 委托(VT 安全):hue 滑杆 + 搜索输入 + 显示设置面板透明滑块(§4.103) */
|
|
913
1129
|
doc.addEventListener("input", (e) => {
|
|
914
1130
|
const el = e.target as Element | null;
|
|
915
1131
|
if (el && el.id === "hueSlider") {
|
|
@@ -919,16 +1135,37 @@ doc.addEventListener("input", (e) => {
|
|
|
919
1135
|
if (el && (el.id === "searchInput" || el.id === "searchInputM")) {
|
|
920
1136
|
runSearch((el as HTMLInputElement).value.trim());
|
|
921
1137
|
}
|
|
1138
|
+
/* 独立搜索页输入(§4.104):300ms 防抖 + replaceState 同步 URL,客户端即时刷新不经换页 */
|
|
1139
|
+
if (el && el.id === "searchPageInput") {
|
|
1140
|
+
const q = (el as HTMLInputElement).value.trim();
|
|
1141
|
+
window.clearTimeout(searchPageTimer);
|
|
1142
|
+
searchPageTimer = window.setTimeout(() => {
|
|
1143
|
+
const url = new URL(location.href);
|
|
1144
|
+
if (q) url.searchParams.set("q", q);
|
|
1145
|
+
else url.searchParams.delete("q");
|
|
1146
|
+
url.searchParams.delete("page");
|
|
1147
|
+
history.replaceState(null, "", url);
|
|
1148
|
+
renderSearchPage();
|
|
1149
|
+
}, 300);
|
|
1150
|
+
}
|
|
1151
|
+
if (el instanceof HTMLInputElement && el.dataset.dsSlider) {
|
|
1152
|
+
handleDsSliderInput(el);
|
|
1153
|
+
}
|
|
922
1154
|
});
|
|
923
1155
|
|
|
924
1156
|
doc.addEventListener("keydown", (e) => {
|
|
925
|
-
/*
|
|
1157
|
+
/* 搜索浮层/显示设置面板:Esc 关闭 */
|
|
926
1158
|
if (e.key === "Escape") {
|
|
927
1159
|
const pop = doc.getElementById("searchPop");
|
|
928
1160
|
if (pop && !pop.hidden) {
|
|
929
1161
|
closeSearch();
|
|
930
1162
|
return;
|
|
931
1163
|
}
|
|
1164
|
+
const panel = doc.getElementById("displaySettingsPanel");
|
|
1165
|
+
if (panel && !panel.hidden) {
|
|
1166
|
+
closeDsPanel();
|
|
1167
|
+
return;
|
|
1168
|
+
}
|
|
932
1169
|
}
|
|
933
1170
|
/* 搜索快捷键(§4.12):/ 或 ⌘/Ctrl+K;输入中、灯箱(PhotoSwipe)打开时不触发 */
|
|
934
1171
|
if (
|
|
@@ -1021,6 +1258,534 @@ function initSeriesNav() {
|
|
|
1021
1258
|
}
|
|
1022
1259
|
}
|
|
1023
1260
|
|
|
1261
|
+
/* ---------- 壁纸(§4.98):模式切换 / 图片轮播 / 打字机 / 视频播放器 ---------- */
|
|
1262
|
+
const WP_KEY = "qy-wallpaper";
|
|
1263
|
+
const WP_MODES = ["banner", "fullscreen", "overlay", "none"];
|
|
1264
|
+
// 模块级定时器/游标:VT 换页重复 initWallpaper 时先清后建防泄漏
|
|
1265
|
+
let wpCarouselTimer: number | undefined;
|
|
1266
|
+
let wpTyperTimer: number | undefined;
|
|
1267
|
+
let wpVideoIdx = 0;
|
|
1268
|
+
|
|
1269
|
+
function savedWallpaperMode(): string | null {
|
|
1270
|
+
try {
|
|
1271
|
+
const m = localStorage.getItem(WP_KEY);
|
|
1272
|
+
return m && WP_MODES.includes(m) ? m : null;
|
|
1273
|
+
} catch {
|
|
1274
|
+
return null;
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
/* 导航栏形态联动:banner/fullscreen 走配置的透明档,overlay/none 回实底 */
|
|
1279
|
+
function syncWallpaperNav(mode: string) {
|
|
1280
|
+
root.dataset.wallpaperNav =
|
|
1281
|
+
mode === "banner" || mode === "fullscreen"
|
|
1282
|
+
? root.dataset.wpNavConfig || "solid"
|
|
1283
|
+
: "solid";
|
|
1284
|
+
}
|
|
1285
|
+
|
|
1286
|
+
/* 壁纸模式统一切换入口(§4.98 循环按钮与 §4.103 面板模式宫格共用):
|
|
1287
|
+
banner/fullscreen 解除 homeOnly 隐藏,overlay/none 停视频并回实底导航;
|
|
1288
|
+
data-wp-stage-hidden 同步供 hero 布局取消首屏下推 */
|
|
1289
|
+
function setWallpaperMode(next: string) {
|
|
1290
|
+
root.dataset.wallpaperMode = next;
|
|
1291
|
+
syncWallpaperNav(next);
|
|
1292
|
+
const stage = doc.querySelector<HTMLElement>(".wp-stage");
|
|
1293
|
+
if (stage) {
|
|
1294
|
+
if (next === "banner" || next === "fullscreen") {
|
|
1295
|
+
delete stage.dataset.wallpaperHidden;
|
|
1296
|
+
delete root.dataset.wpStageHidden;
|
|
1297
|
+
} else {
|
|
1298
|
+
stage.setAttribute("data-wallpaper-hidden", "");
|
|
1299
|
+
root.dataset.wpStageHidden = "true";
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
if (next === "overlay" || next === "none") pauseWallpaperVideo();
|
|
1303
|
+
try {
|
|
1304
|
+
localStorage.setItem(WP_KEY, next);
|
|
1305
|
+
} catch {
|
|
1306
|
+
/* ignore */
|
|
1307
|
+
}
|
|
1308
|
+
syncDsPanelState();
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
function pauseWallpaperVideo() {
|
|
1312
|
+
const video = doc.querySelector<HTMLVideoElement>(".wp-video");
|
|
1313
|
+
if (video && !video.paused) video.pause();
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
/* 恢复访客保存的壁纸模式(after-swap 与 initWallpaper 双入口,幂等):
|
|
1317
|
+
boot 脚本负责首帧,这里兜底 VT 换页;显式偏好解除 homeOnly 的非首页隐藏;
|
|
1318
|
+
§4.103:面板开启时追加恢复布局/开关/透明三件套(html style 随 swap 重建需重应用)。
|
|
1319
|
+
注意:即使保存模式与 SSR 模式相同,也必须同步 data-wallpaper-hidden,否则
|
|
1320
|
+
非首页刷新时 SSR 渲染的隐藏标记不会被清除(banner/fullscreen 应始终可见) */
|
|
1321
|
+
function applySavedWallpaper() {
|
|
1322
|
+
const m =
|
|
1323
|
+
root.dataset.wpSwitchable === "true"
|
|
1324
|
+
? savedWallpaperMode() || root.dataset.wallpaperMode
|
|
1325
|
+
: root.dataset.wallpaperMode;
|
|
1326
|
+
if (m && m !== root.dataset.wallpaperMode) {
|
|
1327
|
+
root.dataset.wallpaperMode = m;
|
|
1328
|
+
syncWallpaperNav(m);
|
|
1329
|
+
}
|
|
1330
|
+
const stage = doc.querySelector<HTMLElement>(".wp-stage");
|
|
1331
|
+
if (stage) {
|
|
1332
|
+
if (m === "banner" || m === "fullscreen") {
|
|
1333
|
+
delete stage.dataset.wallpaperHidden;
|
|
1334
|
+
delete root.dataset.wpStageHidden;
|
|
1335
|
+
} else {
|
|
1336
|
+
stage.setAttribute("data-wallpaper-hidden", "");
|
|
1337
|
+
root.dataset.wpStageHidden = "true";
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
if (root.dataset.dsEnable === "true") applyDsPrefs();
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/* 图片轮播:desktop/mobile 两组独立游标,--wp-interval 供 kenburns/zoom 动画时长对齐;
|
|
1344
|
+
§4.103:访客经面板关闭轮播(html[data-wp-carousel="off"])时不建定时器 */
|
|
1345
|
+
function initWallpaperCarousel() {
|
|
1346
|
+
const stage = doc.querySelector<HTMLElement>(".wp-stage");
|
|
1347
|
+
if (!stage || stage.dataset.carousel !== "true") return;
|
|
1348
|
+
if (root.dataset.wpCarousel === "off") return;
|
|
1349
|
+
const groups = Array.from(stage.querySelectorAll<HTMLElement>(".wp-images"));
|
|
1350
|
+
if (groups.length === 0) return;
|
|
1351
|
+
const interval =
|
|
1352
|
+
Math.max(1000, parseInt(stage.dataset.carouselInterval || "5000", 10)) ||
|
|
1353
|
+
5000;
|
|
1354
|
+
stage.style.setProperty("--wp-interval", `${(interval / 1000).toFixed(2)}s`);
|
|
1355
|
+
const cursors = groups.map(() => 0);
|
|
1356
|
+
wpCarouselTimer = window.setInterval(() => {
|
|
1357
|
+
groups.forEach((group, gi) => {
|
|
1358
|
+
const imgs = group.querySelectorAll(".wp-img");
|
|
1359
|
+
if (imgs.length < 2) return;
|
|
1360
|
+
imgs[cursors[gi]].classList.remove("active");
|
|
1361
|
+
cursors[gi] = (cursors[gi] + 1) % imgs.length;
|
|
1362
|
+
imgs[cursors[gi]].classList.add("active");
|
|
1363
|
+
});
|
|
1364
|
+
}, interval);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
/* 逐字素切分(中文/emoji 安全),环境不支持时退回码元切分 */
|
|
1368
|
+
function splitGraphemes(text: string): string[] {
|
|
1369
|
+
try {
|
|
1370
|
+
const seg = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
1371
|
+
return Array.from(seg.segment(text), (s) => s.segment);
|
|
1372
|
+
} catch {
|
|
1373
|
+
return Array.from(text);
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/* 首页打字机:多条副标题循环(打完→停顿→删完→下一条),单条打完驻留 */
|
|
1378
|
+
function initWallpaperTyper() {
|
|
1379
|
+
const el = doc.getElementById("wpTyper");
|
|
1380
|
+
if (!el) return;
|
|
1381
|
+
let subs: string[] = [];
|
|
1382
|
+
try {
|
|
1383
|
+
subs = JSON.parse(el.dataset.subtitles || "[]");
|
|
1384
|
+
} catch {
|
|
1385
|
+
/* ignore */
|
|
1386
|
+
}
|
|
1387
|
+
subs = subs.filter(Boolean);
|
|
1388
|
+
if (subs.length === 0) return;
|
|
1389
|
+
const speed = parseInt(el.dataset.speed || "100", 10) || 100;
|
|
1390
|
+
const delSpeed = parseInt(el.dataset.deleteSpeed || "50", 10) || 50;
|
|
1391
|
+
const pause = parseInt(el.dataset.pauseTime || "2000", 10) || 2000;
|
|
1392
|
+
const chars = subs.map(splitGraphemes);
|
|
1393
|
+
let si = 0;
|
|
1394
|
+
let ci = 0;
|
|
1395
|
+
let deleting = false;
|
|
1396
|
+
const tick = () => {
|
|
1397
|
+
const cur = chars[si];
|
|
1398
|
+
if (!deleting) {
|
|
1399
|
+
ci += 1;
|
|
1400
|
+
el.textContent = cur.slice(0, ci).join("");
|
|
1401
|
+
if (ci >= cur.length) {
|
|
1402
|
+
if (subs.length === 1) return;
|
|
1403
|
+
deleting = true;
|
|
1404
|
+
wpTyperTimer = window.setTimeout(tick, pause);
|
|
1405
|
+
} else {
|
|
1406
|
+
wpTyperTimer = window.setTimeout(tick, speed);
|
|
1407
|
+
}
|
|
1408
|
+
} else {
|
|
1409
|
+
ci -= 1;
|
|
1410
|
+
el.textContent = cur.slice(0, ci).join("");
|
|
1411
|
+
if (ci <= 0) {
|
|
1412
|
+
deleting = false;
|
|
1413
|
+
si = (si + 1) % subs.length;
|
|
1414
|
+
wpTyperTimer = window.setTimeout(tick, 300);
|
|
1415
|
+
} else {
|
|
1416
|
+
wpTyperTimer = window.setTimeout(tick, delSpeed);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
wpTyperTimer = window.setTimeout(tick, speed);
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
/* 视频壁纸:初始源依 order/random 选取,自动播放(muted+playsinline 免手势),
|
|
1424
|
+
play/pause 事件同步按钮图标(is-playing)与 stage 播放态(data-video-playing) */
|
|
1425
|
+
function initWallpaperVideo() {
|
|
1426
|
+
const video = doc.querySelector<HTMLVideoElement>(".wp-video");
|
|
1427
|
+
if (!video) return;
|
|
1428
|
+
let urls: string[] = [];
|
|
1429
|
+
try {
|
|
1430
|
+
urls = JSON.parse(video.dataset.playerUrls || "[]");
|
|
1431
|
+
} catch {
|
|
1432
|
+
/* ignore */
|
|
1433
|
+
}
|
|
1434
|
+
urls = urls.filter(Boolean);
|
|
1435
|
+
if (urls.length === 0) return;
|
|
1436
|
+
wpVideoIdx =
|
|
1437
|
+
video.dataset.playerMode === "random"
|
|
1438
|
+
? Math.floor(Math.random() * urls.length)
|
|
1439
|
+
: 0;
|
|
1440
|
+
video.src = urls[wpVideoIdx];
|
|
1441
|
+
video.loop = true;
|
|
1442
|
+
const sync = () => {
|
|
1443
|
+
const btn = doc.getElementById("wpPlayerToggle");
|
|
1444
|
+
if (btn) {
|
|
1445
|
+
btn.classList.toggle("is-playing", !video.paused);
|
|
1446
|
+
const label = video.paused ? ui("wpPlay") : ui("wpPause");
|
|
1447
|
+
btn.setAttribute("aria-label", label);
|
|
1448
|
+
btn.title = label;
|
|
1449
|
+
}
|
|
1450
|
+
doc
|
|
1451
|
+
.querySelector(".wp-stage")
|
|
1452
|
+
?.toggleAttribute("data-video-playing", !video.paused);
|
|
1453
|
+
};
|
|
1454
|
+
video.addEventListener("play", sync);
|
|
1455
|
+
video.addEventListener("pause", sync);
|
|
1456
|
+
sync();
|
|
1457
|
+
video.play().catch(() => sync()); // 自动播放被策略拦截时保持暂停态
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
/* 上一段/下一段视频(初始 idx 由 order/random 决定,此后线性切换) */
|
|
1461
|
+
function switchWallpaperVideo(dir: 1 | -1) {
|
|
1462
|
+
const video = doc.querySelector<HTMLVideoElement>(".wp-video");
|
|
1463
|
+
if (!video) return;
|
|
1464
|
+
let urls: string[] = [];
|
|
1465
|
+
try {
|
|
1466
|
+
urls = JSON.parse(video.dataset.playerUrls || "[]");
|
|
1467
|
+
} catch {
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
if (urls.length === 0) return;
|
|
1471
|
+
wpVideoIdx = (wpVideoIdx + dir + urls.length) % urls.length;
|
|
1472
|
+
video.src = urls[wpVideoIdx];
|
|
1473
|
+
video.play().catch(() => {});
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
/* 导航栏滚动实底(html.wp-scrolled):顶部走半透明档,滚过阈值回实底 */
|
|
1477
|
+
function updateWallpaperScroll() {
|
|
1478
|
+
root.classList.toggle("wp-scrolled", (window.scrollY || 0) > 40);
|
|
1479
|
+
}
|
|
1480
|
+
|
|
1481
|
+
function initWallpaper() {
|
|
1482
|
+
if (wpCarouselTimer !== undefined) {
|
|
1483
|
+
window.clearInterval(wpCarouselTimer);
|
|
1484
|
+
wpCarouselTimer = undefined;
|
|
1485
|
+
}
|
|
1486
|
+
if (wpTyperTimer !== undefined) {
|
|
1487
|
+
window.clearTimeout(wpTyperTimer);
|
|
1488
|
+
wpTyperTimer = undefined;
|
|
1489
|
+
}
|
|
1490
|
+
applySavedWallpaper();
|
|
1491
|
+
updateWallpaperScroll();
|
|
1492
|
+
if (!doc.querySelector(".wp-stage")) return;
|
|
1493
|
+
initWallpaperCarousel();
|
|
1494
|
+
initWallpaperTyper();
|
|
1495
|
+
initWallpaperVideo();
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/* VT swap 替换 <html> 属性后立即恢复偏好模式(不等 page-load,防默认形态闪帧) */
|
|
1499
|
+
doc.addEventListener("astro:after-swap", applySavedWallpaper);
|
|
1500
|
+
|
|
1501
|
+
/* ---------- 显示设置面板(§4.103,对齐 Firefly) ----------
|
|
1502
|
+
齿轮入口三 Tab 浮层(外观/壁纸/特效);localStorage 键表见 design.md §4.103。
|
|
1503
|
+
应用机制:html data 属性(显隐类)+ html style CSS 变量(透明三件套),
|
|
1504
|
+
boot 内联脚本(BaseLayout)首帧应用,applyDsPrefs 为 after-swap/初始化双入口 */
|
|
1505
|
+
const DS_KEY = {
|
|
1506
|
+
fsLayout: "qy-wp-fs-layout",
|
|
1507
|
+
homeText: "qy-wp-home-text",
|
|
1508
|
+
carousel: "qy-wp-carousel",
|
|
1509
|
+
waves: "qy-wp-waves",
|
|
1510
|
+
gradient: "qy-wp-gradient",
|
|
1511
|
+
opacity: "qy-wp-opacity",
|
|
1512
|
+
blur: "qy-wp-blur",
|
|
1513
|
+
cardOpacity: "qy-wp-card-opacity",
|
|
1514
|
+
sakura: "qy-sakura",
|
|
1515
|
+
} as const;
|
|
1516
|
+
/* 开关键 ↔ html data 属性 ↔ localStorage 键 映射(on/off 语义) */
|
|
1517
|
+
const DS_SWITCH_MAP = {
|
|
1518
|
+
"home-text": { ls: DS_KEY.homeText, attr: "wpHomeText" },
|
|
1519
|
+
carousel: { ls: DS_KEY.carousel, attr: "wpCarousel" },
|
|
1520
|
+
waves: { ls: DS_KEY.waves, attr: "wpWaves" },
|
|
1521
|
+
gradient: { ls: DS_KEY.gradient, attr: "wpGradient" },
|
|
1522
|
+
sakura: { ls: DS_KEY.sakura, attr: "wpSakura" },
|
|
1523
|
+
} as const;
|
|
1524
|
+
type DsSwitchId = keyof typeof DS_SWITCH_MAP;
|
|
1525
|
+
|
|
1526
|
+
function lsGet(key: string): string | null {
|
|
1527
|
+
try {
|
|
1528
|
+
return localStorage.getItem(key);
|
|
1529
|
+
} catch {
|
|
1530
|
+
return null;
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
function lsSet(key: string, val: string | null) {
|
|
1534
|
+
try {
|
|
1535
|
+
if (val === null) localStorage.removeItem(key);
|
|
1536
|
+
else localStorage.setItem(key, val);
|
|
1537
|
+
} catch {
|
|
1538
|
+
/* ignore */
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/* 透明三件套应用到 html style(boot 同款逻辑;null 表示清除覆写回落配置默认) */
|
|
1543
|
+
function applyOverlayOpacity(v: number | null) {
|
|
1544
|
+
if (v === null) root.style.removeProperty("--wp-overlay-opacity");
|
|
1545
|
+
else root.style.setProperty("--wp-overlay-opacity", String(v));
|
|
1546
|
+
}
|
|
1547
|
+
function applyOverlayBlur(v: number | null) {
|
|
1548
|
+
if (v === null) {
|
|
1549
|
+
root.style.removeProperty("--wp-overlay-blur");
|
|
1550
|
+
root.style.removeProperty("--wp-overlay-scale");
|
|
1551
|
+
} else {
|
|
1552
|
+
root.style.setProperty("--wp-overlay-blur", `${v}px`);
|
|
1553
|
+
root.style.setProperty("--wp-overlay-scale", (1 + v / 100).toFixed(3));
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
function applyCardOpacity(v: number | null) {
|
|
1557
|
+
if (v === null) root.style.removeProperty("--wp-card-opacity");
|
|
1558
|
+
else root.style.setProperty("--wp-card-opacity", `${Math.round(v * 100)}%`);
|
|
1559
|
+
}
|
|
1560
|
+
|
|
1561
|
+
/* 开关应用:html data 属性 + 副作用(轮播定时器清/建、樱花停/启) */
|
|
1562
|
+
function applyDsSwitch(id: DsSwitchId, on: boolean) {
|
|
1563
|
+
const { ls, attr } = DS_SWITCH_MAP[id];
|
|
1564
|
+
root.dataset[attr] = on ? "on" : "off";
|
|
1565
|
+
lsSet(ls, on ? "1" : "0");
|
|
1566
|
+
if (id === "carousel") {
|
|
1567
|
+
if (wpCarouselTimer !== undefined) {
|
|
1568
|
+
window.clearInterval(wpCarouselTimer);
|
|
1569
|
+
wpCarouselTimer = undefined;
|
|
1570
|
+
}
|
|
1571
|
+
if (on) initWallpaperCarousel();
|
|
1572
|
+
}
|
|
1573
|
+
if (id === "sakura") {
|
|
1574
|
+
doc.dispatchEvent(
|
|
1575
|
+
new CustomEvent("orchid:sakura-toggle", { detail: on ? "on" : "off" }),
|
|
1576
|
+
);
|
|
1577
|
+
}
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
/* 恢复面板全部访客偏好(boot 负责首帧,这里兜底 VT swap 与初始化) */
|
|
1581
|
+
function applyDsPrefs() {
|
|
1582
|
+
const l = lsGet(DS_KEY.fsLayout);
|
|
1583
|
+
if (l === "classic" || l === "hero") root.dataset.wpFsLayout = l;
|
|
1584
|
+
for (const id of Object.keys(DS_SWITCH_MAP) as DsSwitchId[]) {
|
|
1585
|
+
const v = lsGet(DS_SWITCH_MAP[id].ls);
|
|
1586
|
+
if (v === "0" || v === "1")
|
|
1587
|
+
root.dataset[DS_SWITCH_MAP[id].attr] = v === "1" ? "on" : "off";
|
|
1588
|
+
}
|
|
1589
|
+
const op = parseFloat(lsGet(DS_KEY.opacity) || "");
|
|
1590
|
+
if (Number.isFinite(op)) applyOverlayOpacity(op);
|
|
1591
|
+
const bl = parseFloat(lsGet(DS_KEY.blur) || "");
|
|
1592
|
+
if (Number.isFinite(bl)) applyOverlayBlur(bl);
|
|
1593
|
+
const co = parseFloat(lsGet(DS_KEY.cardOpacity) || "");
|
|
1594
|
+
if (Number.isFinite(co)) applyCardOpacity(co);
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
/* 面板根节点配置默认值(DisplaySettings.astro 输出的 data-def-*) */
|
|
1598
|
+
function dsPanel(): HTMLElement | null {
|
|
1599
|
+
return doc.getElementById("displaySettingsPanel");
|
|
1600
|
+
}
|
|
1601
|
+
function dsDef(key: string, fallback: number): number {
|
|
1602
|
+
const v = parseFloat(dsPanel()?.dataset[key] || "");
|
|
1603
|
+
return Number.isFinite(v) ? v : fallback;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/* 同步面板控件态:模式/布局按钮激活、组显隐(data-modes)、开关、滑块与徽章。
|
|
1607
|
+
模式或布局切换、面板打开、组重置、VT 换页后调用 */
|
|
1608
|
+
function syncDsPanelState() {
|
|
1609
|
+
const panel = dsPanel();
|
|
1610
|
+
if (!panel) return;
|
|
1611
|
+
const mode = root.dataset.wallpaperMode || "none";
|
|
1612
|
+
const layout = root.dataset.wpFsLayout || "classic";
|
|
1613
|
+
/* 当前生效的模式 token:fullscreen+hero 组合单独成一个虚拟模式 */
|
|
1614
|
+
const tokens = [mode];
|
|
1615
|
+
if (mode === "fullscreen" && layout === "hero")
|
|
1616
|
+
tokens.push("fullscreen-hero");
|
|
1617
|
+
panel.querySelectorAll<HTMLElement>("[data-modes]").forEach((el) => {
|
|
1618
|
+
const modes = (el.dataset.modes || "").split(/\s+/).filter(Boolean);
|
|
1619
|
+
const visible = tokens.some((tok) => modes.includes(tok));
|
|
1620
|
+
if (el.classList.contains("ds-group"))
|
|
1621
|
+
el.classList.toggle("is-hidden", !visible);
|
|
1622
|
+
else el.classList.toggle("ds-row-hidden", !visible);
|
|
1623
|
+
});
|
|
1624
|
+
panel
|
|
1625
|
+
.querySelectorAll<HTMLElement>(".ds-mode-btn[data-mode]")
|
|
1626
|
+
.forEach((b) => {
|
|
1627
|
+
b.classList.toggle("is-active", b.dataset.mode === mode);
|
|
1628
|
+
});
|
|
1629
|
+
panel
|
|
1630
|
+
.querySelectorAll<HTMLElement>(".ds-mode-btn[data-fs-layout]")
|
|
1631
|
+
.forEach((b) => {
|
|
1632
|
+
b.classList.toggle("is-active", b.dataset.fsLayout === layout);
|
|
1633
|
+
});
|
|
1634
|
+
/* 开关态:html data 属性为当前生效值(boot/applyDsPrefs 已写入,默认 on) */
|
|
1635
|
+
for (const id of Object.keys(DS_SWITCH_MAP) as DsSwitchId[]) {
|
|
1636
|
+
const sw = panel.querySelector<HTMLElement>(`[data-ds-switch="${id}"]`);
|
|
1637
|
+
if (sw)
|
|
1638
|
+
sw.setAttribute(
|
|
1639
|
+
"aria-checked",
|
|
1640
|
+
root.dataset[DS_SWITCH_MAP[id].attr] === "off" ? "false" : "true",
|
|
1641
|
+
);
|
|
1642
|
+
}
|
|
1643
|
+
/* 滑块与徽章:localStorage 覆写优先,否则面板 data-def-*(配置默认) */
|
|
1644
|
+
const opSaved = parseFloat(lsGet(DS_KEY.opacity) || "");
|
|
1645
|
+
const op = Number.isFinite(opSaved) ? opSaved : dsDef("defOpacity", 0.8);
|
|
1646
|
+
const blSaved = parseFloat(lsGet(DS_KEY.blur) || "");
|
|
1647
|
+
const bl = Number.isFinite(blSaved) ? blSaved : dsDef("defBlur", 0);
|
|
1648
|
+
const coSaved = parseFloat(lsGet(DS_KEY.cardOpacity) || "");
|
|
1649
|
+
const co = Number.isFinite(coSaved) ? coSaved : dsDef("defCardOpacity", 0.5);
|
|
1650
|
+
const setSlider = (id: string, val: number, text: string) => {
|
|
1651
|
+
const s = panel.querySelector<HTMLInputElement>(`[data-ds-slider="${id}"]`);
|
|
1652
|
+
if (s) s.value = String(val);
|
|
1653
|
+
const badge = panel.querySelector<HTMLElement>(`[data-ds-badge="${id}"]`);
|
|
1654
|
+
if (badge) badge.textContent = text;
|
|
1655
|
+
};
|
|
1656
|
+
setSlider("opacity", Math.round(op * 100), `${Math.round(op * 100)}%`);
|
|
1657
|
+
setSlider("blur", bl, `${bl}px`);
|
|
1658
|
+
setSlider("card-opacity", Math.round(co * 100), `${Math.round(co * 100)}%`);
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
function closeDsPanel() {
|
|
1662
|
+
const panel = dsPanel();
|
|
1663
|
+
if (panel) panel.hidden = true;
|
|
1664
|
+
doc
|
|
1665
|
+
.getElementById("displaySettingsToggle")
|
|
1666
|
+
?.setAttribute("aria-expanded", "false");
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/* 面板 click 委托(document 级):命中面板内控件返回 true 拦截后续处理 */
|
|
1670
|
+
function handleDsPanelClick(target: Element): boolean {
|
|
1671
|
+
/* 齿轮开合 */
|
|
1672
|
+
if (target.closest("#displaySettingsToggle")) {
|
|
1673
|
+
const panel = dsPanel();
|
|
1674
|
+
if (panel) {
|
|
1675
|
+
const open = panel.hidden;
|
|
1676
|
+
panel.hidden = !open;
|
|
1677
|
+
target
|
|
1678
|
+
.closest("#displaySettingsToggle")
|
|
1679
|
+
?.setAttribute("aria-expanded", open ? "true" : "false");
|
|
1680
|
+
if (open) syncDsPanelState();
|
|
1681
|
+
}
|
|
1682
|
+
return true;
|
|
1683
|
+
}
|
|
1684
|
+
/* Tab 切换 */
|
|
1685
|
+
const tab = target.closest<HTMLElement>(".ds-tab[data-tab]");
|
|
1686
|
+
if (tab) {
|
|
1687
|
+
const panel = dsPanel();
|
|
1688
|
+
if (panel) {
|
|
1689
|
+
panel.querySelectorAll(".ds-tab").forEach((b) => {
|
|
1690
|
+
b.classList.toggle("is-active", b === tab);
|
|
1691
|
+
b.setAttribute("aria-selected", b === tab ? "true" : "false");
|
|
1692
|
+
});
|
|
1693
|
+
panel.querySelectorAll<HTMLElement>(".ds-page").forEach((p) => {
|
|
1694
|
+
p.classList.toggle("is-active", p.dataset.page === tab.dataset.tab);
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
return true;
|
|
1698
|
+
}
|
|
1699
|
+
/* 壁纸模式四选一(复用统一切换入口) */
|
|
1700
|
+
const modeBtn = target.closest<HTMLElement>("[data-mode]");
|
|
1701
|
+
if (modeBtn && dsPanel()?.contains(modeBtn)) {
|
|
1702
|
+
const m = modeBtn.dataset.mode || "";
|
|
1703
|
+
if (WP_MODES.includes(m)) setWallpaperMode(m);
|
|
1704
|
+
return true;
|
|
1705
|
+
}
|
|
1706
|
+
/* 全屏布局切换 */
|
|
1707
|
+
const layoutBtn = target.closest<HTMLElement>("[data-fs-layout]");
|
|
1708
|
+
if (layoutBtn) {
|
|
1709
|
+
const l = layoutBtn.dataset.fsLayout || "";
|
|
1710
|
+
if (l === "classic" || l === "hero") {
|
|
1711
|
+
root.dataset.wpFsLayout = l;
|
|
1712
|
+
lsSet(DS_KEY.fsLayout, l);
|
|
1713
|
+
syncDsPanelState();
|
|
1714
|
+
}
|
|
1715
|
+
return true;
|
|
1716
|
+
}
|
|
1717
|
+
/* 开关行 */
|
|
1718
|
+
const sw = target.closest<HTMLElement>("[data-ds-switch]");
|
|
1719
|
+
if (sw) {
|
|
1720
|
+
const id = sw.dataset.dsSwitch as DsSwitchId;
|
|
1721
|
+
if (id in DS_SWITCH_MAP) {
|
|
1722
|
+
const on = sw.getAttribute("aria-checked") !== "true";
|
|
1723
|
+
applyDsSwitch(id, on);
|
|
1724
|
+
sw.setAttribute("aria-checked", on ? "true" : "false");
|
|
1725
|
+
}
|
|
1726
|
+
return true;
|
|
1727
|
+
}
|
|
1728
|
+
/* 组重置(↺):清本组 localStorage,回落配置默认并重应用 */
|
|
1729
|
+
const resetBtn = target.closest<HTMLElement>("[data-reset-group]");
|
|
1730
|
+
if (resetBtn) {
|
|
1731
|
+
if (resetBtn.dataset.resetGroup === "wallpaper") {
|
|
1732
|
+
lsSet(DS_KEY.homeText, null);
|
|
1733
|
+
lsSet(DS_KEY.carousel, null);
|
|
1734
|
+
lsSet(DS_KEY.waves, null);
|
|
1735
|
+
lsSet(DS_KEY.gradient, null);
|
|
1736
|
+
delete root.dataset.wpHomeText;
|
|
1737
|
+
delete root.dataset.wpCarousel;
|
|
1738
|
+
delete root.dataset.wpWaves;
|
|
1739
|
+
delete root.dataset.wpGradient;
|
|
1740
|
+
/* 轮播回到配置默认(开):先清后建 */
|
|
1741
|
+
if (wpCarouselTimer !== undefined) {
|
|
1742
|
+
window.clearInterval(wpCarouselTimer);
|
|
1743
|
+
wpCarouselTimer = undefined;
|
|
1744
|
+
}
|
|
1745
|
+
initWallpaperCarousel();
|
|
1746
|
+
}
|
|
1747
|
+
if (resetBtn.dataset.resetGroup === "overlay") {
|
|
1748
|
+
lsSet(DS_KEY.opacity, null);
|
|
1749
|
+
lsSet(DS_KEY.blur, null);
|
|
1750
|
+
lsSet(DS_KEY.cardOpacity, null);
|
|
1751
|
+
applyOverlayOpacity(null);
|
|
1752
|
+
applyOverlayBlur(null);
|
|
1753
|
+
applyCardOpacity(null);
|
|
1754
|
+
}
|
|
1755
|
+
syncDsPanelState();
|
|
1756
|
+
return true;
|
|
1757
|
+
}
|
|
1758
|
+
return false;
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
/* 滑块 input(input 委托调用):实时应用 + 持久化 + 徽章 */
|
|
1762
|
+
function handleDsSliderInput(el: HTMLInputElement) {
|
|
1763
|
+
const id = el.dataset.dsSlider;
|
|
1764
|
+
const v = parseFloat(el.value);
|
|
1765
|
+
if (!Number.isFinite(v)) return;
|
|
1766
|
+
if (id === "opacity") {
|
|
1767
|
+
applyOverlayOpacity(v / 100);
|
|
1768
|
+
lsSet(DS_KEY.opacity, String(v / 100));
|
|
1769
|
+
} else if (id === "blur") {
|
|
1770
|
+
applyOverlayBlur(v);
|
|
1771
|
+
lsSet(DS_KEY.blur, String(v));
|
|
1772
|
+
} else if (id === "card-opacity") {
|
|
1773
|
+
applyCardOpacity(v / 100);
|
|
1774
|
+
lsSet(DS_KEY.cardOpacity, String(v / 100));
|
|
1775
|
+
} else return;
|
|
1776
|
+
const panel = dsPanel();
|
|
1777
|
+
const badge = panel?.querySelector<HTMLElement>(`[data-ds-badge="${id}"]`);
|
|
1778
|
+
if (badge) badge.textContent = id === "blur" ? `${v}px` : `${Math.round(v)}%`;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
/* 每次导航后重绑面板(DOM 随 swap 重建):复位收起 + 控件态同步 */
|
|
1782
|
+
function initDisplaySettings() {
|
|
1783
|
+
const panel = dsPanel();
|
|
1784
|
+
if (!panel) return;
|
|
1785
|
+
closeDsPanel();
|
|
1786
|
+
syncDsPanelState();
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1024
1789
|
/* ---------- 生命周期 ---------- */
|
|
1025
1790
|
function initPage() {
|
|
1026
1791
|
initLightbox();
|
|
@@ -1036,6 +1801,9 @@ function initPage() {
|
|
|
1036
1801
|
initCategoryBar();
|
|
1037
1802
|
initPostListLayout();
|
|
1038
1803
|
initSeriesNav();
|
|
1804
|
+
initWallpaper();
|
|
1805
|
+
initDisplaySettings();
|
|
1806
|
+
renderSearchPage();
|
|
1039
1807
|
}
|
|
1040
1808
|
initPage();
|
|
1041
1809
|
doc.addEventListener("astro:page-load", initPage);
|