@nsnanocat/preference-panes 0.7.0 → 0.7.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.
@@ -0,0 +1,160 @@
1
+ /**
2
+ * 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
3
+ * Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
4
+ */
5
+ class Navigation extends EventTarget {
6
+ #container;
7
+ #home;
8
+ #create;
9
+ #window;
10
+ #key = null;
11
+ #view;
12
+ #retiring;
13
+ #controller;
14
+ #animation;
15
+ #scroll = new WeakMap();
16
+ #onHistory = () => this.#route();
17
+ #onPageShow = event => {
18
+ if (event.persisted) this.#route(true);
19
+ };
20
+
21
+ /**
22
+ * 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。
23
+ * Retain the home view and create details on demand; signal cancels async work after departure.
24
+ * @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.
25
+ * @param {HTMLElement} home 已创建的主页节点 / Existing home view.
26
+ * @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.
27
+ */
28
+ constructor(container, home, create) {
29
+ super();
30
+ this.#container = container;
31
+ this.#home = home;
32
+ this.#create = create;
33
+ this.#window = container.ownerDocument.defaultView;
34
+ container.replaceChildren(home);
35
+ this.#window.addEventListener("popstate", this.#onHistory);
36
+ this.#window.addEventListener("hashchange", this.#onHistory);
37
+ this.#window.addEventListener("pageshow", this.#onPageShow);
38
+ this.#route();
39
+ }
40
+
41
+ /**
42
+ * 当前子页键;空字符串表示主页。
43
+ * Current detail key; empty means home.
44
+ */
45
+ get current() {
46
+ return this.#key;
47
+ }
48
+
49
+ /**
50
+ * 是否可以返回上一级或先前文档。
51
+ * Whether a parent view or previous document is available.
52
+ */
53
+ get canGoBack() {
54
+ return Boolean(this.#key) || this.#window.history.length > 1;
55
+ }
56
+
57
+ /**
58
+ * 加入子页历史;使用文档自身 URL,避免 srcdoc 按宿主 base URL 跳转。
59
+ * Push a detail using the document URL, avoiding srcdoc navigation against the host base URL.
60
+ * @param {string} key 子页键 / Detail key.
61
+ * @returns {void} 无返回值 / No return value.
62
+ */
63
+ open(key) {
64
+ if (key === this.#key) return;
65
+ const url = new URL(this.#window.location.href);
66
+ url.hash = encodeURIComponent(key);
67
+ this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
68
+ this.#route();
69
+ }
70
+
71
+ /**
72
+ * 沿浏览器联合历史返回,根页可退回宿主或上个文档。
73
+ * Go back through joint history, including a host or previous document from home.
74
+ * @returns {void} 无返回值 / No return value.
75
+ */
76
+ back() {
77
+ if (this.canGoBack) this.#window.history.back();
78
+ }
79
+
80
+ /**
81
+ * 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
82
+ * Resolve the URL and coordinate transitions, cancellation and release after animation.
83
+ * @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.
84
+ * @returns {void} 无返回值 / No return value.
85
+ */
86
+ #route(reload = false) {
87
+ const url = new URL(this.#window.location.href);
88
+ let key;
89
+ try {
90
+ key = decodeURIComponent(url.hash.slice(1));
91
+ } catch (error) {
92
+ if (!(error instanceof URIError)) throw error;
93
+ key = "";
94
+ }
95
+ if (!reload && key === this.#key) return;
96
+ this.#controller?.abort();
97
+ this.#controller = new AbortController();
98
+ const next = key ? this.#create(key, this.#controller.signal) : undefined;
99
+ if (!next) key = "";
100
+ const history = this.#window.history;
101
+ // 直接打开子页时建立一次主页历史;刷新不重复堆叠。
102
+ // Seed home history once for direct details, without stacking entries on reload.
103
+ if (url.hash && history.state?.preferencePanesRoute !== key) {
104
+ url.hash = "";
105
+ history.replaceState({ ...history.state, preferencePanesRoute: "" }, "", url.href);
106
+ if (key) {
107
+ url.hash = encodeURIComponent(key);
108
+ history.pushState({ ...history.state, preferencePanesRoute: key }, "", url.href);
109
+ }
110
+ }
111
+ const previous = this.#view;
112
+ const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
113
+ this.#animation?.cancel();
114
+ this.#retiring?.remove();
115
+ this.#retiring = previous;
116
+ if (previous) {
117
+ this.#scroll.set(previous, previous.scrollTop);
118
+ previous.inert = true;
119
+ }
120
+ this.#key = key;
121
+ this.#view = next;
122
+ this.#home.inert = Boolean(next);
123
+ if (next) {
124
+ next.inert = false;
125
+ this.#container.append(next);
126
+ next.scrollTop = this.#scroll.get(next) ?? 0;
127
+ }
128
+ const moving = next ?? previous;
129
+ if (moving) {
130
+ const animation = moving.animate([{ transform: next ? "translateX(100%)" : position }, { transform: next ? "translateX(0)" : "translateX(100%)" }], { duration: this.#window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 280, easing: "cubic-bezier(.22,.61,.36,1)", fill: "forwards" });
131
+ this.#animation = animation;
132
+ animation.onfinish = () => {
133
+ if (this.#animation !== animation) return;
134
+ this.#retiring?.remove();
135
+ this.#retiring = undefined;
136
+ animation.cancel();
137
+ this.#animation = undefined;
138
+ };
139
+ }
140
+ this.dispatchEvent(new Event("change"));
141
+ }
142
+
143
+ /**
144
+ * 释放监听器、加载、动画和节点;调用方可重新创建导航。
145
+ * Release listeners, loads, animations and nodes so callers can recreate navigation.
146
+ * @returns {void} 无返回值 / No return value.
147
+ */
148
+ destroy() {
149
+ this.#window.removeEventListener("popstate", this.#onHistory);
150
+ this.#window.removeEventListener("hashchange", this.#onHistory);
151
+ this.#window.removeEventListener("pageshow", this.#onPageShow);
152
+ this.#controller?.abort();
153
+ this.#animation?.cancel();
154
+ this.#retiring?.remove();
155
+ this.#view?.remove();
156
+ this.#home.remove();
157
+ }
158
+ }
159
+
160
+ export { Navigation };
@@ -1,4 +1,4 @@
1
- var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: relative;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-header .pp-title {\n flex: 1;\n text-align: center;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n}\n.pp-fields,\n.pp-choice-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
1
+ var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-brand {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n text-align: center;\n}\n.pp-brand-icon {\n display: none;\n flex: none;\n width: 28px;\n height: 28px;\n}\n.pp-brand-icon:not(:empty) {\n display: block;\n}\n.pp-brand-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n}\n.pp-fields,\n.pp-choice-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
2
2
 
3
3
  /**
4
4
  * 解析已经取得的 pathname,避免重复构造 URL。
@@ -128,7 +128,7 @@ function element(tag, className, text) {
128
128
  * @returns {string} 完整地址 / Absolute address.
129
129
  */
130
130
  function resourceURL(value) {
131
- const url = new URL(value, location.href);
131
+ const url = new URL(value, document.baseURI);
132
132
  if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Metadata URLs must use HTTP(S)");
133
133
  return url.href;
134
134
  }
@@ -486,6 +486,165 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
486
486
  };
487
487
  }
488
488
 
489
+ /**
490
+ * 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
491
+ * Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
492
+ */
493
+ class Navigation extends EventTarget {
494
+ #container;
495
+ #home;
496
+ #create;
497
+ #window;
498
+ #key = null;
499
+ #view;
500
+ #retiring;
501
+ #controller;
502
+ #animation;
503
+ #scroll = new WeakMap();
504
+ #onHistory = () => this.#route();
505
+ #onPageShow = event => {
506
+ if (event.persisted) this.#route(true);
507
+ };
508
+
509
+ /**
510
+ * 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。
511
+ * Retain the home view and create details on demand; signal cancels async work after departure.
512
+ * @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.
513
+ * @param {HTMLElement} home 已创建的主页节点 / Existing home view.
514
+ * @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.
515
+ */
516
+ constructor(container, home, create) {
517
+ super();
518
+ this.#container = container;
519
+ this.#home = home;
520
+ this.#create = create;
521
+ this.#window = container.ownerDocument.defaultView;
522
+ container.replaceChildren(home);
523
+ this.#window.addEventListener("popstate", this.#onHistory);
524
+ this.#window.addEventListener("hashchange", this.#onHistory);
525
+ this.#window.addEventListener("pageshow", this.#onPageShow);
526
+ this.#route();
527
+ }
528
+
529
+ /**
530
+ * 当前子页键;空字符串表示主页。
531
+ * Current detail key; empty means home.
532
+ */
533
+ get current() {
534
+ return this.#key;
535
+ }
536
+
537
+ /**
538
+ * 是否可以返回上一级或先前文档。
539
+ * Whether a parent view or previous document is available.
540
+ */
541
+ get canGoBack() {
542
+ return Boolean(this.#key) || this.#window.history.length > 1;
543
+ }
544
+
545
+ /**
546
+ * 加入子页历史;使用文档自身 URL,避免 srcdoc 按宿主 base URL 跳转。
547
+ * Push a detail using the document URL, avoiding srcdoc navigation against the host base URL.
548
+ * @param {string} key 子页键 / Detail key.
549
+ * @returns {void} 无返回值 / No return value.
550
+ */
551
+ open(key) {
552
+ if (key === this.#key) return;
553
+ const url = new URL(this.#window.location.href);
554
+ url.hash = encodeURIComponent(key);
555
+ this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
556
+ this.#route();
557
+ }
558
+
559
+ /**
560
+ * 沿浏览器联合历史返回,根页可退回宿主或上个文档。
561
+ * Go back through joint history, including a host or previous document from home.
562
+ * @returns {void} 无返回值 / No return value.
563
+ */
564
+ back() {
565
+ if (this.canGoBack) this.#window.history.back();
566
+ }
567
+
568
+ /**
569
+ * 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
570
+ * Resolve the URL and coordinate transitions, cancellation and release after animation.
571
+ * @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.
572
+ * @returns {void} 无返回值 / No return value.
573
+ */
574
+ #route(reload = false) {
575
+ const url = new URL(this.#window.location.href);
576
+ let key;
577
+ try {
578
+ key = decodeURIComponent(url.hash.slice(1));
579
+ } catch (error) {
580
+ if (!(error instanceof URIError)) throw error;
581
+ key = "";
582
+ }
583
+ if (!reload && key === this.#key) return;
584
+ this.#controller?.abort();
585
+ this.#controller = new AbortController();
586
+ const next = key ? this.#create(key, this.#controller.signal) : undefined;
587
+ if (!next) key = "";
588
+ const history = this.#window.history;
589
+ // 直接打开子页时建立一次主页历史;刷新不重复堆叠。
590
+ // Seed home history once for direct details, without stacking entries on reload.
591
+ if (url.hash && history.state?.preferencePanesRoute !== key) {
592
+ url.hash = "";
593
+ history.replaceState({ ...history.state, preferencePanesRoute: "" }, "", url.href);
594
+ if (key) {
595
+ url.hash = encodeURIComponent(key);
596
+ history.pushState({ ...history.state, preferencePanesRoute: key }, "", url.href);
597
+ }
598
+ }
599
+ const previous = this.#view;
600
+ const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
601
+ this.#animation?.cancel();
602
+ this.#retiring?.remove();
603
+ this.#retiring = previous;
604
+ if (previous) {
605
+ this.#scroll.set(previous, previous.scrollTop);
606
+ previous.inert = true;
607
+ }
608
+ this.#key = key;
609
+ this.#view = next;
610
+ this.#home.inert = Boolean(next);
611
+ if (next) {
612
+ next.inert = false;
613
+ this.#container.append(next);
614
+ next.scrollTop = this.#scroll.get(next) ?? 0;
615
+ }
616
+ const moving = next ?? previous;
617
+ if (moving) {
618
+ const animation = moving.animate([{ transform: next ? "translateX(100%)" : position }, { transform: next ? "translateX(0)" : "translateX(100%)" }], { duration: this.#window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 280, easing: "cubic-bezier(.22,.61,.36,1)", fill: "forwards" });
619
+ this.#animation = animation;
620
+ animation.onfinish = () => {
621
+ if (this.#animation !== animation) return;
622
+ this.#retiring?.remove();
623
+ this.#retiring = undefined;
624
+ animation.cancel();
625
+ this.#animation = undefined;
626
+ };
627
+ }
628
+ this.dispatchEvent(new Event("change"));
629
+ }
630
+
631
+ /**
632
+ * 释放监听器、加载、动画和节点;调用方可重新创建导航。
633
+ * Release listeners, loads, animations and nodes so callers can recreate navigation.
634
+ * @returns {void} 无返回值 / No return value.
635
+ */
636
+ destroy() {
637
+ this.#window.removeEventListener("popstate", this.#onHistory);
638
+ this.#window.removeEventListener("hashchange", this.#onHistory);
639
+ this.#window.removeEventListener("pageshow", this.#onPageShow);
640
+ this.#controller?.abort();
641
+ this.#animation?.cancel();
642
+ this.#retiring?.remove();
643
+ this.#view?.remove();
644
+ this.#home.remove();
645
+ }
646
+ }
647
+
489
648
  /**
490
649
  * 挂载已导入 BoxJS 对应的模块表单和短暂通知。
491
650
  * Mount the imported BoxJS module form and transient notifications.
@@ -498,20 +657,27 @@ function mountPanel(root, catalog) {
498
657
  const document = root.ownerDocument;
499
658
  const window = document.defaultView;
500
659
  const shell = element("div", "pp-panel");
660
+ shell.dataset.module = catalog.module.module;
501
661
  const header = element("header", "pp-header");
502
662
  const back = element("button", "pp-back", "‹");
503
663
  back.setAttribute("aria-label", "返回");
504
664
  back.type = "button";
505
665
  const heading = element("h1", "pp-title", title);
666
+ const brand = element("div", "pp-brand");
667
+ const logo = element("span", "pp-brand-icon");
668
+ logo.setAttribute("aria-hidden", "true");
669
+ const image = icon(catalog.module.metadata, "");
670
+ if (image) logo.append(image);
671
+ brand.append(logo, heading);
506
672
  const viewport = element("div", "pp-viewport");
507
673
  const toast = element("div", "pp-toast");
508
674
  toast.setAttribute("role", "status");
509
675
  toast.hidden = true;
510
- header.append(back, heading, element("span", "pp-nav-spacer"));
676
+ header.append(back, brand, element("span", "pp-nav-spacer"));
511
677
  shell.append(header, viewport, toast);
512
678
  root.append(shell);
513
679
  let timer,
514
- secondaryRoute,
680
+ navigation,
515
681
  generation = 0,
516
682
  active = null,
517
683
  saving = false,
@@ -549,25 +715,6 @@ function mountPanel(root, catalog) {
549
715
  }, 2400);
550
716
  };
551
717
  const client = createPreferencesClient({ catalog, notify });
552
- /**
553
- * 切换加载或错误视图,按用户的动态效果偏好播放过渡。
554
- * Replace a loading or error view, respecting reduced-motion preferences.
555
- * @param {HTMLElement} view 新视图 / New view.
556
- * @param {number} direction 过渡方向,正数从右侧进入 / Transition direction; positive enters from the right.
557
- * @returns {void} 无返回值 / No return value.
558
- */
559
- function replace(view, direction) {
560
- const old = viewport.firstElementChild;
561
- viewport.replaceChildren(view);
562
- if (old && !window.matchMedia("(prefers-reduced-motion: reduce)").matches)
563
- view.animate(
564
- [
565
- { opacity: 0.4, transform: `translateX(${direction * 24}px)` },
566
- { opacity: 1, transform: "translateX(0)" },
567
- ],
568
- { duration: 180, easing: "ease-out" },
569
- );
570
- }
571
718
  /**
572
719
  * 打开模块并忽略已过期的异步结果。
573
720
  * Open a module and ignore stale asynchronous results.
@@ -579,16 +726,13 @@ function mountPanel(root, catalog) {
579
726
  active = module;
580
727
  back.disabled = window.history.length <= 1;
581
728
  heading.textContent = module;
582
- replace(element("p", "pp-loading", "读取设置…"), 1);
729
+ viewport.replaceChildren(element("p", "pp-loading", "读取设置…"));
583
730
  try {
584
731
  await client.open(module);
585
732
  if (version === generation) controls();
586
733
  } catch (error) {
587
734
  if (version !== generation) return;
588
- replace(
589
- errorView(error, () => open(module)),
590
- 1,
591
- );
735
+ viewport.replaceChildren(errorView(error, () => open(module)));
592
736
  }
593
737
  }
594
738
  /**
@@ -609,37 +753,18 @@ function mountPanel(root, catalog) {
609
753
  const editors = new Map();
610
754
  const summaries = [];
611
755
  const groups = new Map();
612
- const scrollPositions = new WeakMap();
613
- let activeEditor;
614
756
  let queue = Promise.resolve(),
615
757
  pendingWrites = 0;
616
758
  /**
617
- * 根据 hash 切换多选页,保留上级 DOM 和滚动位置。
618
- * Switch multi-select views by hash while retaining parent DOM and scroll position.
759
+ * 导航组件处理页面切换,表单只更新当前标题与返回按钮。
760
+ * Let navigation own transitions; the form only updates the title and back button.
619
761
  * @returns {void} 无返回值 / No return value.
620
762
  */
621
- const showEditor = () => {
622
- let key;
623
- try {
624
- key = decodeURIComponent(window.location.hash.slice(1));
625
- } catch {
626
- key = "";
627
- }
628
- const editor = editors.get(key);
629
- const previous = activeEditor?.node ?? view;
630
- const next = editor?.node ?? view;
631
- if (previous !== next) {
632
- scrollPositions.set(previous, previous.scrollTop);
633
- previous.remove();
634
- viewport.append(next);
635
- next.scrollTop = scrollPositions.get(next) ?? 0;
636
- if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) next.animate([{ transform: `translateX(${editor ? 100 : -100}%)` }, { transform: "translateX(0)" }], { duration: 260, easing: "cubic-bezier(.22,.61,.36,1)" });
637
- }
638
- activeEditor = editor;
763
+ const updateNavigation = () => {
764
+ const editor = editors.get(navigation.current);
639
765
  heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
640
- back.disabled = saving || (!editor && window.history.length <= 1);
766
+ back.disabled = saving || !navigation.canGoBack;
641
767
  };
642
- secondaryRoute = showEditor;
643
768
  /**
644
769
  * 串行执行模块操作,保持输入可编辑。
645
770
  * Serialize module actions while keeping inputs editable.
@@ -666,7 +791,7 @@ function mountPanel(root, catalog) {
666
791
  pendingWrites--;
667
792
  saving = pendingWrites > 0;
668
793
  if (destroyed && !saving) client.leave(active);
669
- back.disabled = saving || (!activeEditor && window.history.length <= 1);
794
+ back.disabled = saving || !navigation.canGoBack;
670
795
  }));
671
796
  }
672
797
  const metadata = definition.metadata;
@@ -755,10 +880,7 @@ function mountPanel(root, catalog) {
755
880
  };
756
881
  summaries.push(refresh);
757
882
  refresh();
758
- link.onclick = () => {
759
- window.history.pushState({ ...window.history.state, preferencePane: active }, "", `#${encodeURIComponent(field.key)}`);
760
- showEditor();
761
- };
883
+ link.onclick = () => navigation.open(field.key);
762
884
  row.addEventListener("click", event => {
763
885
  if (!link.contains(event.target)) link.click();
764
886
  });
@@ -905,26 +1027,22 @@ function mountPanel(root, catalog) {
905
1027
  actions.append(cacheView, cacheClear, reset);
906
1028
  maintenance.append(actions, output);
907
1029
  view.append(maintenance);
908
- viewport.replaceChildren(view);
1030
+ navigation?.destroy();
1031
+ navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
1032
+ navigation.addEventListener("change", updateNavigation);
909
1033
  for (const grow of growingInputs) grow();
910
- showEditor();
1034
+ updateNavigation();
911
1035
  }
912
1036
  /**
913
- * 历史导航只切换当前模块的二级页,不接管项目主页或跨模块路由。
914
- * History navigation switches only this module's subpages, never project or cross-module routes.
1037
+ * 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。
1038
+ * Loaded forms delegate back to navigation; loading views can return to the previous document.
915
1039
  * @returns {void} 无返回值 / No return value.
916
1040
  */
917
- const onPopState = () => secondaryRoute?.();
918
- const onHashChange = () => secondaryRoute?.();
919
1041
  back.onclick = () => {
920
1042
  if (saving) return;
921
- if (window.location.hash && window.history.state?.preferencePane !== active) {
922
- window.history.replaceState(window.history.state, "", window.location.pathname);
923
- secondaryRoute?.();
924
- } else window.history.back();
1043
+ if (navigation) navigation.back();
1044
+ else window.history.back();
925
1045
  };
926
- window.addEventListener("popstate", onPopState);
927
- window.addEventListener("hashchange", onHashChange);
928
1046
  open(catalog.module.module);
929
1047
  return {
930
1048
  /**
@@ -934,8 +1052,7 @@ function mountPanel(root, catalog) {
934
1052
  */
935
1053
  destroy() {
936
1054
  destroyed = true;
937
- window.removeEventListener("popstate", onPopState);
938
- window.removeEventListener("hashchange", onHashChange);
1055
+ navigation?.destroy();
939
1056
  generation++;
940
1057
  if (active && !saving) client.leave(active);
941
1058
  clearTimeout(timer);