@nsnanocat/preference-panes 0.7.0 → 0.7.2
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 +59 -1
- package/dist/module/app.mjs +248 -80
- package/dist/module/index.html +1 -1
- package/dist/module/navigation.mjs +260 -0
- package/dist/preference-panes.mjs +204 -71
- package/dist/preference-panes.proxy.js +28 -3
- package/package.json +5 -1
- package/src/browser/ModuleFrame.mjs +83 -0
- package/src/browser/Navigation.d.mts +85 -0
- package/src/browser/Navigation.mjs +160 -0
- package/src/browser/app.mjs +27 -9
- package/src/browser/components.mjs +1 -1
- package/src/browser/panel.css +33 -2
- package/src/browser/panel.mjs +44 -69
- package/src/build.mjs +3 -1
- package/src/lib/page-inputs.mjs +17 -0
- package/src/proxy/handler.mjs +10 -2
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
|
|
3
|
+
* Resolve module resource locations: headers override query parameters and module conventions.
|
|
4
|
+
* @param {URL} url 已解析的页面请求地址 / Parsed page request URL.
|
|
5
|
+
* @param {Record<string, string | undefined>} [headers] 请求头,名称不区分大小写 / Case-insensitive request headers.
|
|
6
|
+
* @returns {{url: string, module: string, json: string, css: string}} 页面上下文与两个资源输入 / Page context and two resource inputs.
|
|
7
|
+
*/
|
|
8
|
+
function pageInputs(url, headers = {}) {
|
|
9
|
+
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname);
|
|
10
|
+
if (!match) throw new TypeError("Open a concrete module URL");
|
|
11
|
+
const module = match[1];
|
|
12
|
+
const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
13
|
+
const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
|
|
14
|
+
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? `/settings/assets/${module}.css`;
|
|
15
|
+
if (!json.trim()) throw new TypeError("JSON resource URL is required");
|
|
16
|
+
return { url: url.href, module, json, css };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 模块文档容器:原始 HTML 不改写,请求上下文随 iframe 元素传递。
|
|
21
|
+
* Module document container: preserve HTML verbatim and carry request context on the iframe element.
|
|
22
|
+
*/
|
|
23
|
+
class ModuleFrame extends EventTarget {
|
|
24
|
+
#url;
|
|
25
|
+
#options;
|
|
26
|
+
#controller = new AbortController();
|
|
27
|
+
#abort = () => this.destroy();
|
|
28
|
+
#state;
|
|
29
|
+
#change = event => {
|
|
30
|
+
this.#state = event.detail;
|
|
31
|
+
this.dispatchEvent(new Event("change"));
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 建立 iframe 与请求输入;调用方挂载 element 后调用 load。
|
|
36
|
+
* Create the iframe and request inputs; callers mount element and then call load.
|
|
37
|
+
* @param {string | URL} url 模块请求地址 / Module request URL.
|
|
38
|
+
* @param {RequestInit} [options] 原生请求头和取消信号 / Native headers and cancellation signal.
|
|
39
|
+
*/
|
|
40
|
+
constructor(url, options = {}) {
|
|
41
|
+
super();
|
|
42
|
+
this.#url = new URL(url, document.baseURI);
|
|
43
|
+
this.#options = { ...options, headers: new Headers(options.headers) };
|
|
44
|
+
const inputs = pageInputs(this.#url, Object.fromEntries(this.#options.headers));
|
|
45
|
+
this.element = document.createElement("iframe");
|
|
46
|
+
this.element.title = `${inputs.module} 设置`;
|
|
47
|
+
this.element.dataset.preferencePanes = JSON.stringify(inputs);
|
|
48
|
+
this.element.addEventListener("preferencepanes:change", this.#change);
|
|
49
|
+
this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true };
|
|
50
|
+
options.signal?.addEventListener("abort", this.#abort, { once: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 当前模块导航状态。
|
|
55
|
+
* Current module navigation state.
|
|
56
|
+
*/
|
|
57
|
+
get state() {
|
|
58
|
+
return { ...this.#state };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 获取原始 HTML;晚到响应在退出后不得重新挂载。
|
|
63
|
+
* Fetch unmodified HTML; a late response must not remount after departure.
|
|
64
|
+
* @returns {Promise<void>} HTML 已交给 iframe;表单状态通过 change 事件提供 / HTML assigned; form state is reported through change.
|
|
65
|
+
*/
|
|
66
|
+
async load() {
|
|
67
|
+
if (this.#options.signal?.aborted) this.destroy();
|
|
68
|
+
const timer = setTimeout(() => this.#controller.abort(), 10000);
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", ...this.#options, signal: this.#controller.signal });
|
|
71
|
+
if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
|
|
72
|
+
const html = await response.text();
|
|
73
|
+
this.#controller.signal.throwIfAborted();
|
|
74
|
+
this.element.srcdoc = html;
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 使用 iframe 的联合历史返回;写入期间不导航。
|
|
82
|
+
* Navigate joint iframe history back, except while a write is pending.
|
|
83
|
+
* @returns {void} 无返回值 / No return value.
|
|
84
|
+
*/
|
|
85
|
+
back() {
|
|
86
|
+
if (!this.#state.busy && this.#state.canGoBack) this.element.contentWindow.history.back();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 取消加载与事件订阅;节点保留到 Navigation 的退出动画结束。
|
|
91
|
+
* Cancel loading and subscriptions; Navigation retains the node until its exit animation ends.
|
|
92
|
+
* @returns {void} 无返回值 / No return value.
|
|
93
|
+
*/
|
|
94
|
+
destroy() {
|
|
95
|
+
this.#controller.abort();
|
|
96
|
+
this.#options.signal?.removeEventListener("abort", this.#abort);
|
|
97
|
+
this.element.removeEventListener("preferencepanes:change", this.#change);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
|
|
103
|
+
* Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
|
|
104
|
+
*/
|
|
105
|
+
class Navigation extends EventTarget {
|
|
106
|
+
#container;
|
|
107
|
+
#home;
|
|
108
|
+
#create;
|
|
109
|
+
#window;
|
|
110
|
+
#key = null;
|
|
111
|
+
#view;
|
|
112
|
+
#retiring;
|
|
113
|
+
#controller;
|
|
114
|
+
#animation;
|
|
115
|
+
#scroll = new WeakMap();
|
|
116
|
+
#onHistory = () => this.#route();
|
|
117
|
+
#onPageShow = event => {
|
|
118
|
+
if (event.persisted) this.#route(true);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。
|
|
123
|
+
* Retain the home view and create details on demand; signal cancels async work after departure.
|
|
124
|
+
* @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.
|
|
125
|
+
* @param {HTMLElement} home 已创建的主页节点 / Existing home view.
|
|
126
|
+
* @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.
|
|
127
|
+
*/
|
|
128
|
+
constructor(container, home, create) {
|
|
129
|
+
super();
|
|
130
|
+
this.#container = container;
|
|
131
|
+
this.#home = home;
|
|
132
|
+
this.#create = create;
|
|
133
|
+
this.#window = container.ownerDocument.defaultView;
|
|
134
|
+
container.replaceChildren(home);
|
|
135
|
+
this.#window.addEventListener("popstate", this.#onHistory);
|
|
136
|
+
this.#window.addEventListener("hashchange", this.#onHistory);
|
|
137
|
+
this.#window.addEventListener("pageshow", this.#onPageShow);
|
|
138
|
+
this.#route();
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 当前子页键;空字符串表示主页。
|
|
143
|
+
* Current detail key; empty means home.
|
|
144
|
+
*/
|
|
145
|
+
get current() {
|
|
146
|
+
return this.#key;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 是否可以返回上一级或先前文档。
|
|
151
|
+
* Whether a parent view or previous document is available.
|
|
152
|
+
*/
|
|
153
|
+
get canGoBack() {
|
|
154
|
+
return Boolean(this.#key) || this.#window.history.length > 1;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* 加入子页历史;使用文档自身 URL,避免 srcdoc 按宿主 base URL 跳转。
|
|
159
|
+
* Push a detail using the document URL, avoiding srcdoc navigation against the host base URL.
|
|
160
|
+
* @param {string} key 子页键 / Detail key.
|
|
161
|
+
* @returns {void} 无返回值 / No return value.
|
|
162
|
+
*/
|
|
163
|
+
open(key) {
|
|
164
|
+
if (key === this.#key) return;
|
|
165
|
+
const url = new URL(this.#window.location.href);
|
|
166
|
+
url.hash = encodeURIComponent(key);
|
|
167
|
+
this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
|
|
168
|
+
this.#route();
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 沿浏览器联合历史返回,根页可退回宿主或上个文档。
|
|
173
|
+
* Go back through joint history, including a host or previous document from home.
|
|
174
|
+
* @returns {void} 无返回值 / No return value.
|
|
175
|
+
*/
|
|
176
|
+
back() {
|
|
177
|
+
if (this.canGoBack) this.#window.history.back();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
|
|
182
|
+
* Resolve the URL and coordinate transitions, cancellation and release after animation.
|
|
183
|
+
* @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.
|
|
184
|
+
* @returns {void} 无返回值 / No return value.
|
|
185
|
+
*/
|
|
186
|
+
#route(reload = false) {
|
|
187
|
+
const url = new URL(this.#window.location.href);
|
|
188
|
+
let key;
|
|
189
|
+
try {
|
|
190
|
+
key = decodeURIComponent(url.hash.slice(1));
|
|
191
|
+
} catch (error) {
|
|
192
|
+
if (!(error instanceof URIError)) throw error;
|
|
193
|
+
key = "";
|
|
194
|
+
}
|
|
195
|
+
if (!reload && key === this.#key) return;
|
|
196
|
+
this.#controller?.abort();
|
|
197
|
+
this.#controller = new AbortController();
|
|
198
|
+
const next = key ? this.#create(key, this.#controller.signal) : undefined;
|
|
199
|
+
if (!next) key = "";
|
|
200
|
+
const history = this.#window.history;
|
|
201
|
+
// 直接打开子页时建立一次主页历史;刷新不重复堆叠。
|
|
202
|
+
// Seed home history once for direct details, without stacking entries on reload.
|
|
203
|
+
if (url.hash && history.state?.preferencePanesRoute !== key) {
|
|
204
|
+
url.hash = "";
|
|
205
|
+
history.replaceState({ ...history.state, preferencePanesRoute: "" }, "", url.href);
|
|
206
|
+
if (key) {
|
|
207
|
+
url.hash = encodeURIComponent(key);
|
|
208
|
+
history.pushState({ ...history.state, preferencePanesRoute: key }, "", url.href);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
const previous = this.#view;
|
|
212
|
+
const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
|
|
213
|
+
this.#animation?.cancel();
|
|
214
|
+
this.#retiring?.remove();
|
|
215
|
+
this.#retiring = previous;
|
|
216
|
+
if (previous) {
|
|
217
|
+
this.#scroll.set(previous, previous.scrollTop);
|
|
218
|
+
previous.inert = true;
|
|
219
|
+
}
|
|
220
|
+
this.#key = key;
|
|
221
|
+
this.#view = next;
|
|
222
|
+
this.#home.inert = Boolean(next);
|
|
223
|
+
if (next) {
|
|
224
|
+
next.inert = false;
|
|
225
|
+
this.#container.append(next);
|
|
226
|
+
next.scrollTop = this.#scroll.get(next) ?? 0;
|
|
227
|
+
}
|
|
228
|
+
const moving = next ?? previous;
|
|
229
|
+
if (moving) {
|
|
230
|
+
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" });
|
|
231
|
+
this.#animation = animation;
|
|
232
|
+
animation.onfinish = () => {
|
|
233
|
+
if (this.#animation !== animation) return;
|
|
234
|
+
this.#retiring?.remove();
|
|
235
|
+
this.#retiring = undefined;
|
|
236
|
+
animation.cancel();
|
|
237
|
+
this.#animation = undefined;
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
this.dispatchEvent(new Event("change"));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 释放监听器、加载、动画和节点;调用方可重新创建导航。
|
|
245
|
+
* Release listeners, loads, animations and nodes so callers can recreate navigation.
|
|
246
|
+
* @returns {void} 无返回值 / No return value.
|
|
247
|
+
*/
|
|
248
|
+
destroy() {
|
|
249
|
+
this.#window.removeEventListener("popstate", this.#onHistory);
|
|
250
|
+
this.#window.removeEventListener("hashchange", this.#onHistory);
|
|
251
|
+
this.#window.removeEventListener("pageshow", this.#onPageShow);
|
|
252
|
+
this.#controller?.abort();
|
|
253
|
+
this.#animation?.cancel();
|
|
254
|
+
this.#retiring?.remove();
|
|
255
|
+
this.#view?.remove();
|
|
256
|
+
this.#home.remove();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export { ModuleFrame, 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:
|
|
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:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\n height: 100dvh;\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,
|
|
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,38 @@ 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,
|
|
676
|
+
header.append(back, brand, element("span", "pp-nav-spacer"));
|
|
511
677
|
shell.append(header, viewport, toast);
|
|
512
678
|
root.append(shell);
|
|
679
|
+
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
680
|
+
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
681
|
+
const publishNavigation = () => {
|
|
682
|
+
const frame = window.frameElement;
|
|
683
|
+
if (!frame?.dataset.preferencePanes) return;
|
|
684
|
+
frame.dispatchEvent(
|
|
685
|
+
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
686
|
+
detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled },
|
|
687
|
+
}),
|
|
688
|
+
);
|
|
689
|
+
};
|
|
513
690
|
let timer,
|
|
514
|
-
|
|
691
|
+
navigation,
|
|
515
692
|
generation = 0,
|
|
516
693
|
active = null,
|
|
517
694
|
saving = false,
|
|
@@ -549,25 +726,6 @@ function mountPanel(root, catalog) {
|
|
|
549
726
|
}, 2400);
|
|
550
727
|
};
|
|
551
728
|
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
729
|
/**
|
|
572
730
|
* 打开模块并忽略已过期的异步结果。
|
|
573
731
|
* Open a module and ignore stale asynchronous results.
|
|
@@ -579,16 +737,15 @@ function mountPanel(root, catalog) {
|
|
|
579
737
|
active = module;
|
|
580
738
|
back.disabled = window.history.length <= 1;
|
|
581
739
|
heading.textContent = module;
|
|
582
|
-
|
|
740
|
+
publishNavigation();
|
|
741
|
+
viewport.replaceChildren(element("p", "pp-loading", "读取设置…"));
|
|
583
742
|
try {
|
|
584
743
|
await client.open(module);
|
|
585
744
|
if (version === generation) controls();
|
|
586
745
|
} catch (error) {
|
|
587
746
|
if (version !== generation) return;
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
1,
|
|
591
|
-
);
|
|
747
|
+
viewport.replaceChildren(errorView(error, () => open(module)));
|
|
748
|
+
publishNavigation();
|
|
592
749
|
}
|
|
593
750
|
}
|
|
594
751
|
/**
|
|
@@ -609,37 +766,19 @@ function mountPanel(root, catalog) {
|
|
|
609
766
|
const editors = new Map();
|
|
610
767
|
const summaries = [];
|
|
611
768
|
const groups = new Map();
|
|
612
|
-
const scrollPositions = new WeakMap();
|
|
613
|
-
let activeEditor;
|
|
614
769
|
let queue = Promise.resolve(),
|
|
615
770
|
pendingWrites = 0;
|
|
616
771
|
/**
|
|
617
|
-
*
|
|
618
|
-
*
|
|
772
|
+
* 导航组件处理页面切换,表单只更新当前标题与返回按钮。
|
|
773
|
+
* Let navigation own transitions; the form only updates the title and back button.
|
|
619
774
|
* @returns {void} 无返回值 / No return value.
|
|
620
775
|
*/
|
|
621
|
-
const
|
|
622
|
-
|
|
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;
|
|
776
|
+
const updateNavigation = () => {
|
|
777
|
+
const editor = editors.get(navigation.current);
|
|
639
778
|
heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
|
|
640
|
-
back.disabled = saving ||
|
|
779
|
+
back.disabled = saving || !navigation.canGoBack;
|
|
780
|
+
publishNavigation();
|
|
641
781
|
};
|
|
642
|
-
secondaryRoute = showEditor;
|
|
643
782
|
/**
|
|
644
783
|
* 串行执行模块操作,保持输入可编辑。
|
|
645
784
|
* Serialize module actions while keeping inputs editable.
|
|
@@ -652,6 +791,7 @@ function mountPanel(root, catalog) {
|
|
|
652
791
|
pendingWrites++;
|
|
653
792
|
saving = true;
|
|
654
793
|
back.disabled = true;
|
|
794
|
+
publishNavigation();
|
|
655
795
|
return (queue = queue
|
|
656
796
|
.then(action)
|
|
657
797
|
.then(() => {
|
|
@@ -666,7 +806,8 @@ function mountPanel(root, catalog) {
|
|
|
666
806
|
pendingWrites--;
|
|
667
807
|
saving = pendingWrites > 0;
|
|
668
808
|
if (destroyed && !saving) client.leave(active);
|
|
669
|
-
back.disabled = saving ||
|
|
809
|
+
back.disabled = saving || !navigation.canGoBack;
|
|
810
|
+
publishNavigation();
|
|
670
811
|
}));
|
|
671
812
|
}
|
|
672
813
|
const metadata = definition.metadata;
|
|
@@ -755,10 +896,7 @@ function mountPanel(root, catalog) {
|
|
|
755
896
|
};
|
|
756
897
|
summaries.push(refresh);
|
|
757
898
|
refresh();
|
|
758
|
-
link.onclick = () =>
|
|
759
|
-
window.history.pushState({ ...window.history.state, preferencePane: active }, "", `#${encodeURIComponent(field.key)}`);
|
|
760
|
-
showEditor();
|
|
761
|
-
};
|
|
899
|
+
link.onclick = () => navigation.open(field.key);
|
|
762
900
|
row.addEventListener("click", event => {
|
|
763
901
|
if (!link.contains(event.target)) link.click();
|
|
764
902
|
});
|
|
@@ -905,26 +1043,22 @@ function mountPanel(root, catalog) {
|
|
|
905
1043
|
actions.append(cacheView, cacheClear, reset);
|
|
906
1044
|
maintenance.append(actions, output);
|
|
907
1045
|
view.append(maintenance);
|
|
908
|
-
|
|
1046
|
+
navigation?.destroy();
|
|
1047
|
+
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
1048
|
+
navigation.addEventListener("change", updateNavigation);
|
|
909
1049
|
for (const grow of growingInputs) grow();
|
|
910
|
-
|
|
1050
|
+
updateNavigation();
|
|
911
1051
|
}
|
|
912
1052
|
/**
|
|
913
|
-
*
|
|
914
|
-
*
|
|
1053
|
+
* 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。
|
|
1054
|
+
* Loaded forms delegate back to navigation; loading views can return to the previous document.
|
|
915
1055
|
* @returns {void} 无返回值 / No return value.
|
|
916
1056
|
*/
|
|
917
|
-
const onPopState = () => secondaryRoute?.();
|
|
918
|
-
const onHashChange = () => secondaryRoute?.();
|
|
919
1057
|
back.onclick = () => {
|
|
920
1058
|
if (saving) return;
|
|
921
|
-
if (
|
|
922
|
-
|
|
923
|
-
secondaryRoute?.();
|
|
924
|
-
} else window.history.back();
|
|
1059
|
+
if (navigation) navigation.back();
|
|
1060
|
+
else window.history.back();
|
|
925
1061
|
};
|
|
926
|
-
window.addEventListener("popstate", onPopState);
|
|
927
|
-
window.addEventListener("hashchange", onHashChange);
|
|
928
1062
|
open(catalog.module.module);
|
|
929
1063
|
return {
|
|
930
1064
|
/**
|
|
@@ -934,8 +1068,7 @@ function mountPanel(root, catalog) {
|
|
|
934
1068
|
*/
|
|
935
1069
|
destroy() {
|
|
936
1070
|
destroyed = true;
|
|
937
|
-
|
|
938
|
-
window.removeEventListener("hashchange", onHashChange);
|
|
1071
|
+
navigation?.destroy();
|
|
939
1072
|
generation++;
|
|
940
1073
|
if (active && !saving) client.leave(active);
|
|
941
1074
|
clearTimeout(timer);
|