@nsnanocat/preference-panes 1.2.5 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +66 -25
- package/dist/home.html +2208 -0
- package/dist/{module/navigation.mjs → navigation.mjs} +51 -143
- package/dist/preference-panes.mjs +913 -614
- package/package.json +4 -3
- package/src/browser/ActionMenu.mjs +7 -6
- package/src/browser/Navigation.d.mts +4 -95
- package/src/browser/Navigation.mjs +43 -14
- package/src/browser/boxjs.mjs +38 -8
- package/src/browser/client.d.mts +3 -3
- package/src/browser/client.mjs +57 -25
- package/src/browser/components.mjs +0 -44
- package/src/browser/home.d.mts +66 -0
- package/src/browser/home.html +15 -0
- package/src/browser/home.mjs +287 -0
- package/src/browser/index.d.ts +2 -0
- package/src/browser/mount.mjs +50 -91
- package/src/browser/page.mjs +325 -0
- package/src/browser/panel.mjs +118 -149
- package/src/browser/styles.css +565 -0
- package/src/index.d.ts +8 -0
- package/dist/module/index.html +0 -16
- package/dist/module/index.mjs +0 -1625
- package/src/browser/ModuleFrame.mjs +0 -122
- package/src/browser/index.mjs +0 -89
- package/src/browser/module.html +0 -16
- package/src/browser/panel.css +0 -315
package/dist/home.html
ADDED
|
@@ -0,0 +1,2208 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN" class="pp-page">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
6
|
+
<title>设置</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div data-preference-panes-pages>
|
|
10
|
+
<main class="pp-home" data-preference-panes-home></main>
|
|
11
|
+
</div>
|
|
12
|
+
<script type="module">/**
|
|
13
|
+
* 共用菜单按钮和底部操作菜单;原生宿主可通过 MORE 调用。
|
|
14
|
+
* Shared menu trigger and bottom action sheet; native hosts may invoke it through MORE.
|
|
15
|
+
*/
|
|
16
|
+
class ActionMenu {
|
|
17
|
+
#button;
|
|
18
|
+
#layer;
|
|
19
|
+
#items;
|
|
20
|
+
#select;
|
|
21
|
+
#document;
|
|
22
|
+
#disabled = true;
|
|
23
|
+
#key = event => {
|
|
24
|
+
if (event.key === "Escape" && !this.#layer.hidden) {
|
|
25
|
+
event.preventDefault();
|
|
26
|
+
this.close();
|
|
27
|
+
this.#button.focus();
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 创建菜单,操作逻辑由调用方提供。
|
|
33
|
+
* Create a menu whose actions are handled by the caller.
|
|
34
|
+
* @param {(id: string) => void} select 菜单选择回调 / Selection callback.
|
|
35
|
+
*/
|
|
36
|
+
constructor(select) {
|
|
37
|
+
this.#document = document;
|
|
38
|
+
this.#select = select;
|
|
39
|
+
this.element = document.createElement("span");
|
|
40
|
+
const triggerRoot = this.element.attachShadow({ mode: "open" });
|
|
41
|
+
triggerRoot.innerHTML = `<style>
|
|
42
|
+
:host{display:inline-flex;flex:none;width:37px;height:37px;color:inherit}
|
|
43
|
+
:host([hidden]){display:none!important}
|
|
44
|
+
button{box-sizing:border-box;width:37px;height:37px;padding:8px;border-radius:4px;font:inherit;cursor:pointer;border:0;color:inherit;background:none;transition:background-color .3s}
|
|
45
|
+
button:hover{background:var(--pp-field,#8882)}
|
|
46
|
+
button:disabled{opacity:.4;cursor:default}
|
|
47
|
+
button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
|
|
48
|
+
svg{display:block;width:21px;height:21px;fill:none}
|
|
49
|
+
</style><button type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false"><svg viewBox="0 0 21 21" aria-hidden="true"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.667" d="M3.645 5.225h13.333M3.645 10.225h13.333M3.645 15.225h13.333"/></svg></button>`;
|
|
50
|
+
this.#button = triggerRoot.querySelector("button");
|
|
51
|
+
this.#layer = document.createElement("span");
|
|
52
|
+
const layerRoot = this.#layer.attachShadow({ mode: "open" });
|
|
53
|
+
layerRoot.innerHTML = `<style>
|
|
54
|
+
:host{position:fixed;inset:0;z-index:2147483647;color:var(--pp-text,CanvasText);font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
55
|
+
:host([hidden]){display:none!important}
|
|
56
|
+
*,*::before,*::after{box-sizing:border-box}
|
|
57
|
+
button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
58
|
+
button:focus-visible{outline:2px solid var(--pp-accent,Highlight);outline-offset:-3px}
|
|
59
|
+
#backdrop{position:absolute;inset:0;width:100%;height:100%;padding:0;background:#0008;animation:pp-fade-in .18s ease-out}
|
|
60
|
+
#sheet{position:absolute;z-index:1;left:0;right:0;bottom:0;width:100%;max-width:540px;max-height:calc(100% - 24px);margin:auto;padding:8px 8px calc(8px + env(safe-area-inset-bottom));animation:pp-sheet-in .22s cubic-bezier(.2,.8,.2,1)}
|
|
61
|
+
#items,#cancel{overflow:hidden;background:var(--pp-surface,Canvas);border:1px solid var(--pp-border,#8884);border-radius:14px;box-shadow:0 8px 28px #0004}
|
|
62
|
+
#items{max-height:calc(100vh - 116px - env(safe-area-inset-bottom));overflow-y:auto;-webkit-overflow-scrolling:touch}
|
|
63
|
+
#items button,#cancel{display:block;width:100%;min-height:54px;padding:14px 18px;text-align:center}
|
|
64
|
+
#items button+button{border-top:1px solid var(--pp-border,#8884)}
|
|
65
|
+
#items button[data-danger]{color:var(--pp-danger,#e45656)}
|
|
66
|
+
#cancel{margin-top:8px;color:var(--pp-accent,Highlight);font-weight:600}
|
|
67
|
+
@keyframes pp-fade-in{from{opacity:0}}
|
|
68
|
+
@keyframes pp-sheet-in{from{transform:translateY(100%)}}
|
|
69
|
+
@media (prefers-reduced-motion:reduce){#backdrop,#sheet{animation:none}}
|
|
70
|
+
</style><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单"></button><section id="sheet" role="dialog" aria-modal="true" aria-label="更多操作"><div id="items" role="menu"></div><button id="cancel" type="button">取消</button></section>`;
|
|
71
|
+
this.#items = layerRoot.querySelector("#items");
|
|
72
|
+
this.#button.onclick = () => (this.#layer.hidden ? this.open() : this.close());
|
|
73
|
+
layerRoot.querySelector("#backdrop").onclick = () => {
|
|
74
|
+
this.close();
|
|
75
|
+
this.#button.focus();
|
|
76
|
+
};
|
|
77
|
+
layerRoot.querySelector("#cancel").onclick = () => {
|
|
78
|
+
this.close();
|
|
79
|
+
this.#button.focus();
|
|
80
|
+
};
|
|
81
|
+
this.#items.onkeydown = event => {
|
|
82
|
+
const items = [...this.#items.children];
|
|
83
|
+
const index = items.indexOf(layerRoot.activeElement);
|
|
84
|
+
const offsets = { ArrowDown: 1, ArrowUp: -1 };
|
|
85
|
+
if (event.key in offsets) {
|
|
86
|
+
event.preventDefault();
|
|
87
|
+
items[(index + offsets[event.key] + items.length) % items.length].focus();
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
document.body.append(this.#layer);
|
|
91
|
+
document.addEventListener("keydown", this.#key);
|
|
92
|
+
this.update([]);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 同步可用操作和忙碌状态,不重建菜单触发按钮。
|
|
97
|
+
* Update actions and busy state without replacing the trigger button.
|
|
98
|
+
* @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.
|
|
99
|
+
* @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
|
|
100
|
+
* @returns {void} 无返回值 / No return value.
|
|
101
|
+
*/
|
|
102
|
+
update(items, disabled = false) {
|
|
103
|
+
this.close();
|
|
104
|
+
this.#disabled = disabled || items.length === 0;
|
|
105
|
+
this.#button.disabled = this.#disabled;
|
|
106
|
+
this.#items.replaceChildren(
|
|
107
|
+
...items.map(item => {
|
|
108
|
+
const button = this.#document.createElement("button");
|
|
109
|
+
button.type = "button";
|
|
110
|
+
button.setAttribute("role", "menuitem");
|
|
111
|
+
button.textContent = item.label;
|
|
112
|
+
button.toggleAttribute("data-danger", Boolean(item.destructive));
|
|
113
|
+
button.onclick = () => {
|
|
114
|
+
this.close();
|
|
115
|
+
this.#select(item.id);
|
|
116
|
+
};
|
|
117
|
+
return button;
|
|
118
|
+
}),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* 打开当前操作菜单。
|
|
124
|
+
* Open the current action sheet.
|
|
125
|
+
* @returns {void} 无返回值 / No return value.
|
|
126
|
+
*/
|
|
127
|
+
open() {
|
|
128
|
+
if (this.#disabled) return;
|
|
129
|
+
const style = getComputedStyle(this.element);
|
|
130
|
+
for (const property of ["--pp-text", "--pp-surface", "--pp-border", "--pp-accent", "--pp-danger"]) {
|
|
131
|
+
const value = style.getPropertyValue(property);
|
|
132
|
+
if (value) this.#layer.style.setProperty(property, value);
|
|
133
|
+
}
|
|
134
|
+
this.#layer.hidden = false;
|
|
135
|
+
this.#button.setAttribute("aria-expanded", "true");
|
|
136
|
+
this.#items.firstElementChild.focus();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* 关闭菜单。
|
|
141
|
+
* Close the menu.
|
|
142
|
+
* @returns {void} 无返回值 / No return value.
|
|
143
|
+
*/
|
|
144
|
+
close() {
|
|
145
|
+
this.#layer.hidden = true;
|
|
146
|
+
this.#button.setAttribute("aria-expanded", "false");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* 移除监听器与节点。
|
|
151
|
+
* Remove listeners and elements.
|
|
152
|
+
* @returns {void} 无返回值 / No return value.
|
|
153
|
+
*/
|
|
154
|
+
destroy() {
|
|
155
|
+
this.#document.removeEventListener("keydown", this.#key);
|
|
156
|
+
this.#layer.remove();
|
|
157
|
+
this.element.remove();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 模块探测请求选项。
|
|
163
|
+
* Options for a module probe request.
|
|
164
|
+
* @typedef {object} ModuleProbeOptions
|
|
165
|
+
* @property {typeof globalThis.fetch} [fetch] 可注入的 fetch / Injectable fetch.
|
|
166
|
+
* @property {AbortSignal} [signal] 外部取消信号 / External cancellation signal.
|
|
167
|
+
* @property {number} [timeout] 超时毫秒数,默认 3500 / Timeout in milliseconds, defaults to 3500.
|
|
168
|
+
*/
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* 通过模块 API 的 HEAD 响应检测安装状态和业务版本。
|
|
172
|
+
* Probe installation and business version from the module API HEAD response.
|
|
173
|
+
* @param {string | URL} url 模块 API 地址 / Module API URL.
|
|
174
|
+
* @param {ModuleProbeOptions} [options] 请求选项 / Request options.
|
|
175
|
+
* @returns {Promise<Response>} 原始 HTTP 响应,可直接读取 status 和响应头 / Native HTTP response; read status and headers directly.
|
|
176
|
+
*/
|
|
177
|
+
async function probeModule(url, { fetch: request = globalThis.fetch, signal, timeout = 3500 } = {}) {
|
|
178
|
+
const controller = new AbortController();
|
|
179
|
+
const abort = () => controller.abort();
|
|
180
|
+
if (signal?.aborted) abort();
|
|
181
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
182
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
183
|
+
try {
|
|
184
|
+
return await request(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal });
|
|
185
|
+
} finally {
|
|
186
|
+
clearTimeout(timer);
|
|
187
|
+
signal?.removeEventListener("abort", abort);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 模块入口的固定状态行,只通过 HEAD 探测安装状态和业务版本。
|
|
193
|
+
* Fixed module status row, probing installation and business version with HEAD only.
|
|
194
|
+
*/
|
|
195
|
+
class ModuleStatus extends EventTarget {
|
|
196
|
+
#element;
|
|
197
|
+
#controller;
|
|
198
|
+
#state = { status: "checking", version: null };
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* 绑定调用方提供的状态行。
|
|
202
|
+
* Bind a caller-owned status row.
|
|
203
|
+
* @param {HTMLElement} element 状态文字容器 / Status text container.
|
|
204
|
+
*/
|
|
205
|
+
constructor(element) {
|
|
206
|
+
super();
|
|
207
|
+
this.#element = element;
|
|
208
|
+
this.#render("checking");
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* 当前安装状态与业务版本。
|
|
213
|
+
* Current installation state and business version.
|
|
214
|
+
*/
|
|
215
|
+
get state() {
|
|
216
|
+
return { ...this.#state };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 每次进入重新探测,取消旧请求并忽略其迟到结果。
|
|
221
|
+
* Reprobe on entry, cancelling old requests and ignoring late results.
|
|
222
|
+
* @param {string | URL} url 模块 API 地址 / Module API URL.
|
|
223
|
+
* @param {ModuleProbeOptions} [options] 请求选项 / Request options.
|
|
224
|
+
* @returns {Promise<Response | undefined>} 原始响应;被取消时无返回值 / Native response; undefined when cancelled.
|
|
225
|
+
*/
|
|
226
|
+
async check(url, options = {}) {
|
|
227
|
+
this.#controller?.abort();
|
|
228
|
+
const controller = new AbortController();
|
|
229
|
+
this.#controller = controller;
|
|
230
|
+
const externalSignal = options.signal;
|
|
231
|
+
const abort = () => controller.abort();
|
|
232
|
+
if (externalSignal?.aborted) abort();
|
|
233
|
+
externalSignal?.addEventListener("abort", abort, { once: true });
|
|
234
|
+
this.#render("checking");
|
|
235
|
+
try {
|
|
236
|
+
const response = await probeModule(url, { ...options, signal: controller.signal });
|
|
237
|
+
if (controller !== this.#controller) return response;
|
|
238
|
+
const version = response.status === 200 ? response.headers.get("X-PreferencePanes-Version")?.trim() || null : null;
|
|
239
|
+
this.#render(version ? "installed" : "missing", version);
|
|
240
|
+
return response;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
if (controller !== this.#controller) return;
|
|
243
|
+
if (externalSignal?.aborted) throw error;
|
|
244
|
+
this.#render("missing");
|
|
245
|
+
} finally {
|
|
246
|
+
externalSignal?.removeEventListener("abort", abort);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* 更新状态标签,缺少版本时不伪造版本号。
|
|
252
|
+
* Render the label without inventing a missing version.
|
|
253
|
+
* @param {"checking" | "installed" | "missing"} status 状态 / State.
|
|
254
|
+
* @param {string | null} [version] 业务版本 / Business version.
|
|
255
|
+
* @returns {void} 无返回值 / No return value.
|
|
256
|
+
*/
|
|
257
|
+
#render(status, version = null) {
|
|
258
|
+
this.#state = { status, version: status === "installed" ? version : null };
|
|
259
|
+
switch (status) {
|
|
260
|
+
case "checking":
|
|
261
|
+
this.#element.textContent = "检测中";
|
|
262
|
+
break;
|
|
263
|
+
case "installed":
|
|
264
|
+
this.#element.textContent = version ?? "版本未知";
|
|
265
|
+
break;
|
|
266
|
+
case "missing":
|
|
267
|
+
this.#element.textContent = "未安装";
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
this.#element.dataset.state = status;
|
|
271
|
+
this.#element.title = this.#element.textContent;
|
|
272
|
+
this.dispatchEvent(new Event("change"));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* 释放尚未完成的探测。
|
|
277
|
+
* Release pending probes.
|
|
278
|
+
* @returns {void} 无返回值 / No return value.
|
|
279
|
+
*/
|
|
280
|
+
destroy() {
|
|
281
|
+
this.#controller?.abort();
|
|
282
|
+
this.#controller = undefined;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* 同一文档内的首页、模块及字段子页导航。
|
|
288
|
+
* Navigate homepage, module and field views within one document.
|
|
289
|
+
*/
|
|
290
|
+
class Navigation extends EventTarget {
|
|
291
|
+
#container;
|
|
292
|
+
#home;
|
|
293
|
+
#create;
|
|
294
|
+
#window;
|
|
295
|
+
#key = null;
|
|
296
|
+
#view;
|
|
297
|
+
#retiring;
|
|
298
|
+
#controller;
|
|
299
|
+
#animation;
|
|
300
|
+
#scroll = new Map();
|
|
301
|
+
#onHistory = () => this.#route();
|
|
302
|
+
#onPageShow = event => {
|
|
303
|
+
if (event.persisted) this.#route(true);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。
|
|
308
|
+
* Retain the home view and create details on demand; signal cancels async work after departure.
|
|
309
|
+
* @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.
|
|
310
|
+
* @param {HTMLElement} home 已创建的主页节点 / Existing home view.
|
|
311
|
+
* @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.
|
|
312
|
+
*/
|
|
313
|
+
constructor(container, home, create) {
|
|
314
|
+
super();
|
|
315
|
+
this.#container = container;
|
|
316
|
+
this.#home = home;
|
|
317
|
+
this.#create = create;
|
|
318
|
+
this.#window = container.ownerDocument.defaultView;
|
|
319
|
+
container.replaceChildren(home);
|
|
320
|
+
this.#window.addEventListener("popstate", this.#onHistory);
|
|
321
|
+
this.#window.addEventListener("hashchange", this.#onHistory);
|
|
322
|
+
this.#window.addEventListener("pageshow", this.#onPageShow);
|
|
323
|
+
this.#route();
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* 当前子页键;空字符串表示主页。
|
|
328
|
+
* Current detail key; empty means home.
|
|
329
|
+
*/
|
|
330
|
+
get current() {
|
|
331
|
+
return this.#key;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* 是否可以返回上一级或先前文档。
|
|
336
|
+
* Whether a parent view or previous document is available.
|
|
337
|
+
*/
|
|
338
|
+
get canGoBack() {
|
|
339
|
+
return Boolean(this.#key) || this.#window.history.length > 1;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* 加入模块或字段子页历史,保留路由中的层级分隔符。
|
|
344
|
+
* Push a module or field view while retaining route hierarchy separators.
|
|
345
|
+
* @param {string} key 子页键 / Detail key.
|
|
346
|
+
* @returns {void} 无返回值 / No return value.
|
|
347
|
+
*/
|
|
348
|
+
open(key) {
|
|
349
|
+
if (key === this.#key) return;
|
|
350
|
+
const url = new URL(this.#window.location.href);
|
|
351
|
+
url.hash = key.split("/").map(encodeURIComponent).join("/");
|
|
352
|
+
this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
|
|
353
|
+
this.#route();
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* 沿文档历史返回,根页可退回上个文档。
|
|
358
|
+
* Go back through document history, including the previous document from home.
|
|
359
|
+
* @returns {void} 无返回值 / No return value.
|
|
360
|
+
*/
|
|
361
|
+
back() {
|
|
362
|
+
if (this.canGoBack) this.#window.history.back();
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* 更新当前页的挂载视图,保留路由和滚动位置。
|
|
367
|
+
* Refresh the mounted view while retaining route and scroll position.
|
|
368
|
+
* @returns {void} 无返回值 / No return value.
|
|
369
|
+
*/
|
|
370
|
+
refresh() {
|
|
371
|
+
this.#route(true);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
|
|
376
|
+
* Resolve the URL and coordinate transitions, cancellation and release after animation.
|
|
377
|
+
* @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.
|
|
378
|
+
* @returns {void} 无返回值 / No return value.
|
|
379
|
+
*/
|
|
380
|
+
#route(reload = false) {
|
|
381
|
+
const url = new URL(this.#window.location.href);
|
|
382
|
+
let key;
|
|
383
|
+
try {
|
|
384
|
+
key = decodeURIComponent(url.hash.slice(1));
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (!(error instanceof URIError)) throw error;
|
|
387
|
+
key = "";
|
|
388
|
+
}
|
|
389
|
+
if (!reload && key === this.#key) return;
|
|
390
|
+
if (key !== this.#key) this.#scroll.set(this.#key, (this.#view ?? this.#home).scrollTop);
|
|
391
|
+
this.#controller?.abort();
|
|
392
|
+
this.#controller = new AbortController();
|
|
393
|
+
const next = key ? this.#create(key, this.#controller.signal) : undefined;
|
|
394
|
+
if (!next) key = "";
|
|
395
|
+
const history = this.#window.history;
|
|
396
|
+
// 直接打开字段页时补齐首页和模块历史;刷新不重复堆叠。
|
|
397
|
+
// Seed homepage and module history for direct field links without stacking on reload.
|
|
398
|
+
if (url.hash && history.state?.preferencePanesRoute !== key) {
|
|
399
|
+
url.hash = "";
|
|
400
|
+
history.replaceState({ ...history.state, preferencePanesRoute: "" }, "", url.href);
|
|
401
|
+
if (key) {
|
|
402
|
+
const module = key.split("/")[0];
|
|
403
|
+
if (key.includes("/") && module !== "$link") {
|
|
404
|
+
url.hash = encodeURIComponent(module);
|
|
405
|
+
history.pushState({ ...history.state, preferencePanesRoute: module }, "", url.href);
|
|
406
|
+
}
|
|
407
|
+
url.hash = key.split("/").map(encodeURIComponent).join("/");
|
|
408
|
+
history.pushState({ ...history.state, preferencePanesRoute: key }, "", url.href);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
const previous = this.#view;
|
|
412
|
+
if (previous === next && this.#key === key) {
|
|
413
|
+
if (previous && this.#scroll.has(key)) {
|
|
414
|
+
previous.scrollTop = this.#scroll.get(key);
|
|
415
|
+
this.#scroll.delete(key);
|
|
416
|
+
}
|
|
417
|
+
this.dispatchEvent(new Event("change"));
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
const scroll = this.#scroll.get(key) ?? (key === this.#key ? (previous?.scrollTop ?? 0) : 0);
|
|
421
|
+
const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
|
|
422
|
+
this.#animation?.cancel();
|
|
423
|
+
this.#retiring?.remove();
|
|
424
|
+
this.#retiring = previous;
|
|
425
|
+
if (previous) {
|
|
426
|
+
previous.inert = true;
|
|
427
|
+
}
|
|
428
|
+
this.#key = key;
|
|
429
|
+
this.#view = next;
|
|
430
|
+
this.#home.inert = Boolean(next);
|
|
431
|
+
if (next) {
|
|
432
|
+
next.inert = false;
|
|
433
|
+
this.#container.append(next);
|
|
434
|
+
next.scrollTop = scroll;
|
|
435
|
+
// 加载占位页可能无法滚到原位置;配置与控件就绪后的 refresh 再恢复一次。
|
|
436
|
+
// A loading placeholder may clamp scrolling; restore once more when config and controls refresh.
|
|
437
|
+
if (next.scrollTop === scroll) this.#scroll.delete(key);
|
|
438
|
+
}
|
|
439
|
+
if (!next) {
|
|
440
|
+
this.#home.scrollTop = this.#scroll.get("") ?? 0;
|
|
441
|
+
this.#scroll.delete("");
|
|
442
|
+
}
|
|
443
|
+
const moving = next ?? previous;
|
|
444
|
+
if (moving) {
|
|
445
|
+
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" });
|
|
446
|
+
this.#animation = animation;
|
|
447
|
+
animation.onfinish = () => {
|
|
448
|
+
if (this.#animation !== animation) return;
|
|
449
|
+
this.#retiring?.remove();
|
|
450
|
+
this.#retiring = undefined;
|
|
451
|
+
animation.cancel();
|
|
452
|
+
this.#animation = undefined;
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
this.dispatchEvent(new Event("change"));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* 释放监听器、加载、动画和节点;调用方可重新创建导航。
|
|
460
|
+
* Release listeners, loads, animations and nodes so callers can recreate navigation.
|
|
461
|
+
* @returns {void} 无返回值 / No return value.
|
|
462
|
+
*/
|
|
463
|
+
destroy() {
|
|
464
|
+
this.#window.removeEventListener("popstate", this.#onHistory);
|
|
465
|
+
this.#window.removeEventListener("hashchange", this.#onHistory);
|
|
466
|
+
this.#window.removeEventListener("pageshow", this.#onPageShow);
|
|
467
|
+
this.#controller?.abort();
|
|
468
|
+
this.#animation?.cancel();
|
|
469
|
+
this.#retiring?.remove();
|
|
470
|
+
this.#view?.remove();
|
|
471
|
+
this.#home.remove();
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* 校验原始路径片段,不进行 URL 编码转换。
|
|
477
|
+
* Validate raw path segments without URL encoding conversion.
|
|
478
|
+
* @param {string[]} parts 原始路径片段 / Raw path segments.
|
|
479
|
+
* @returns {string[]} 同一数组,不复制或修改 / The same array without copying or mutation.
|
|
480
|
+
* @throws {TypeError} 空片段、非法字符或原型属性名 / Empty segments, invalid characters or prototype property names.
|
|
481
|
+
*/
|
|
482
|
+
function validatePathParts(parts) {
|
|
483
|
+
if (!parts.every(part => typeof part === "string" && /^[a-zA-Z0-9_-]+$/.test(part) && !["__proto__", "prototype", "constructor"].includes(part))) throw new TypeError("Invalid key path");
|
|
484
|
+
return parts;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
/**
|
|
488
|
+
* 将 BoxJS 数组、app 或订阅转换为浏览器字段定义。
|
|
489
|
+
* Normalize a BoxJS array, app or subscription into browser field definitions.
|
|
490
|
+
* @param {unknown} config 原始 BoxJS JSON / Raw BoxJS JSON.
|
|
491
|
+
* @param {string} [module] API 模块路径段;省略时要求输入只有一个模块 / API module path segment; omission requires exactly one module.
|
|
492
|
+
* @returns {import("../index.js").ModuleDefinition} 浏览器字段定义 / Browser field definition.
|
|
493
|
+
*/
|
|
494
|
+
function normalizeBoxJs(config, module) {
|
|
495
|
+
if (!config || typeof config !== "object") throw new TypeError("Expected BoxJS JSON");
|
|
496
|
+
const document = JSON.parse(JSON.stringify(config));
|
|
497
|
+
const apps = Array.isArray(document) ? [{ settings: document }] : (document.apps ?? [document]);
|
|
498
|
+
if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
|
|
499
|
+
const modules = new Map();
|
|
500
|
+
for (const app of apps) {
|
|
501
|
+
if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
|
|
502
|
+
for (const entry of app.settings) {
|
|
503
|
+
if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
|
|
504
|
+
entry.id = entry.id.replace(/\[(0|[1-9]\d*)\]$/, ".$1");
|
|
505
|
+
if (!entry.id.startsWith("@")) {
|
|
506
|
+
if (Array.isArray(document)) throw new TypeError("BoxJS settings require @root.path IDs");
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
const [storageKey, ...parts] = entry.id.slice(1).split(".");
|
|
510
|
+
if (!storageKey || storageKey.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
|
|
511
|
+
validatePathParts(parts);
|
|
512
|
+
const name = parts[0];
|
|
513
|
+
if (["get", "set", "delete"].includes(name)) throw new TypeError(`Reserved API module name: ${name}`);
|
|
514
|
+
let target = modules.get(name);
|
|
515
|
+
if (!target) {
|
|
516
|
+
target = { module: name, storageKey, entries: [], owners: new Set() };
|
|
517
|
+
modules.set(name, target);
|
|
518
|
+
}
|
|
519
|
+
if (target.storageKey !== storageKey) throw new TypeError(`A module must use one storage root: ${name}`);
|
|
520
|
+
target.entries.push(entry);
|
|
521
|
+
target.owners.add(app);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
|
|
525
|
+
const target = module === undefined ? modules.values().next().value : modules.get(module);
|
|
526
|
+
if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
527
|
+
const metadata = normalizeMetadata(target.owners.size === 1 ? presentation([...target.owners][0]) : {});
|
|
528
|
+
const fields = [];
|
|
529
|
+
for (const entry of target.entries) {
|
|
530
|
+
const parts = entry.id.slice(1).split(".").slice(1);
|
|
531
|
+
const type = { boolean: "boolean", checkboxes: "array", selects: "select", text: "string", textarea: "string", url: "string", number: "number" }[entry.type];
|
|
532
|
+
if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
|
|
533
|
+
const field = {
|
|
534
|
+
key: parts.join("."),
|
|
535
|
+
type: type === "select" ? typeof entry.val : type,
|
|
536
|
+
|
|
537
|
+
name: entry.name,
|
|
538
|
+
description: entry.desc ?? "",
|
|
539
|
+
control: entry.type,
|
|
540
|
+
...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),
|
|
541
|
+
...(entry.rows === undefined ? {} : { rows: entry.rows }),
|
|
542
|
+
...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),
|
|
543
|
+
};
|
|
544
|
+
if (type === "select" && !["string", "number", "boolean"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);
|
|
545
|
+
if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));
|
|
546
|
+
if (Object.prototype.hasOwnProperty.call(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
|
|
547
|
+
if (
|
|
548
|
+
typeof field.name !== "string" ||
|
|
549
|
+
(field.placeholder !== undefined && typeof field.placeholder !== "string") ||
|
|
550
|
+
(field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||
|
|
551
|
+
(field.autoGrow !== undefined && typeof field.autoGrow !== "boolean") ||
|
|
552
|
+
fields.some(other => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
|
|
553
|
+
)
|
|
554
|
+
throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
|
|
555
|
+
if (field.options && (new Set(field.options.map(item => item.key)).size !== field.options.length || field.options.some(item => !scalar(item.key) || typeof item.label !== "string"))) throw new TypeError(`Invalid options: ${entry.id}`);
|
|
556
|
+
if (Object.prototype.hasOwnProperty.call(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
|
|
557
|
+
if (field.control === "url" && !Object.prototype.hasOwnProperty.call(field, "defaultValue")) throw new TypeError(`URL BoxJS settings require a val: ${entry.id}`);
|
|
558
|
+
fields.push(field);
|
|
559
|
+
}
|
|
560
|
+
if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${target.module}`);
|
|
561
|
+
for (const field of fields) {
|
|
562
|
+
const indexed = /^(.*)\.(\d+)$/.exec(field.key);
|
|
563
|
+
if (!indexed) continue;
|
|
564
|
+
const siblings = fields.filter(candidate => candidate.key.startsWith(`${indexed[1]}.`) && /^\d+$/.test(candidate.key.slice(indexed[1].length + 1)));
|
|
565
|
+
if (fields.some(candidate => candidate.key.startsWith(`${indexed[1]}.`) && !siblings.includes(candidate))) throw new TypeError(`Indexed settings cannot mix array entries and named children: ${indexed[1]}`);
|
|
566
|
+
if (siblings.some(candidate => !Object.prototype.hasOwnProperty.call(candidate, "defaultValue")) || !siblings.some(candidate => candidate.key === `${indexed[1]}.${siblings.length - 1}`) || siblings.some(candidate => Number(candidate.key.slice(indexed[1].length + 1)) >= siblings.length))
|
|
567
|
+
throw new TypeError(`Indexed settings require contiguous indices and defaults: ${indexed[1]}`);
|
|
568
|
+
}
|
|
569
|
+
const owner = target.owners.size === 1 ? [...target.owners][0] : undefined;
|
|
570
|
+
const cachePath = owner?.cachePath ?? `@${target.storageKey}.${target.module}.Caches`;
|
|
571
|
+
if (typeof cachePath !== "string" || !cachePath.startsWith(`@${target.storageKey}.`)) throw new TypeError("cachePath must use the module storage root");
|
|
572
|
+
const cacheParts = validatePathParts(cachePath.slice(target.storageKey.length + 2).split("."));
|
|
573
|
+
if (cacheParts.length !== 2 || cacheParts[1] !== "Caches") throw new TypeError("cachePath must identify a module Caches subtree");
|
|
574
|
+
if (owner?.resetPaths !== undefined) {
|
|
575
|
+
if (!Array.isArray(owner.resetPaths) || !owner.resetPaths.length) throw new TypeError("resetPaths must be a nonempty array");
|
|
576
|
+
for (const path of owner.resetPaths) {
|
|
577
|
+
if (typeof path !== "string" || !path.startsWith(`@${target.storageKey}.${target.module}.Settings.`)) throw new TypeError("resetPaths must stay in this module's Settings");
|
|
578
|
+
validatePathParts(path.slice(target.storageKey.length + 2).split("."));
|
|
579
|
+
}
|
|
580
|
+
if (fields.some(field => !owner.resetPaths.some(path => `@${target.storageKey}.${field.key}` === path || `@${target.storageKey}.${field.key}`.startsWith(`${path}.`)))) throw new TypeError("resetPaths must cover the page settings");
|
|
581
|
+
}
|
|
582
|
+
const firstIndexed = /^(.*)\.(\d+)$/.exec(fields[0].key);
|
|
583
|
+
const common = (firstIndexed ? firstIndexed[1] : fields[0].key).split(".").slice(0, -1);
|
|
584
|
+
for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
|
|
585
|
+
return {
|
|
586
|
+
module: target.module,
|
|
587
|
+
storageKey: target.storageKey,
|
|
588
|
+
fields,
|
|
589
|
+
settingsPath: common,
|
|
590
|
+
...(owner?.cachePath === undefined ? {} : { cachePath }),
|
|
591
|
+
...(owner?.resetPaths === undefined ? {} : { resetPaths: [...owner.resetPaths] }),
|
|
592
|
+
...(Object.keys(metadata).length ? { metadata } : {}),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* 保留字段所属 app 的原始展示信息。
|
|
598
|
+
* Retain raw presentation metadata from the app owning the fields.
|
|
599
|
+
* @param {object} source BoxJS app / BoxJS app.
|
|
600
|
+
* @returns {Record<string, unknown>} 原始展示信息 / Raw presentation metadata.
|
|
601
|
+
*/
|
|
602
|
+
function presentation(source) {
|
|
603
|
+
const result = {};
|
|
604
|
+
for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc", "icons", "descs"]) {
|
|
605
|
+
if (source[key] === undefined) continue;
|
|
606
|
+
result[key] = source[key];
|
|
607
|
+
}
|
|
608
|
+
return result;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* 校验供浏览器展示的标准 BoxJS 元数据。
|
|
613
|
+
* Validate standard BoxJS metadata used by the browser renderer.
|
|
614
|
+
* @param {Record<string, unknown>} source 原始展示元数据 / Raw presentation metadata.
|
|
615
|
+
* @returns {import("../index.js").BoxJSMetadata} 规范化展示元数据 / Normalized presentation metadata.
|
|
616
|
+
*/
|
|
617
|
+
function normalizeMetadata(source) {
|
|
618
|
+
const result = {};
|
|
619
|
+
for (const [key, value] of Object.entries(source)) {
|
|
620
|
+
const multiple = key === "icons" || key === "descs";
|
|
621
|
+
const values = multiple ? value : [value];
|
|
622
|
+
if (!Array.isArray(values) || values.some(item => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
|
|
623
|
+
result[key] = multiple ? [...values] : value;
|
|
624
|
+
}
|
|
625
|
+
return result;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* 归一化 BoxJS 的字符串存储值,不改变普通文本内容。
|
|
630
|
+
* Normalize BoxJS string persistence without changing free-text values.
|
|
631
|
+
* @param {import("../index.js").SettingsField} field 前端字段约束 / Frontend field constraints.
|
|
632
|
+
* @param {unknown} value 存储值 / Stored value.
|
|
633
|
+
* @returns {unknown} 转换后的控件值;是否允许写入由 validValue 单独校验 / Converted control value; write eligibility is checked separately by validValue.
|
|
634
|
+
*/
|
|
635
|
+
function normalizeStoredValue(field, value) {
|
|
636
|
+
switch (field.type) {
|
|
637
|
+
case "boolean":
|
|
638
|
+
if (value === "true" || value === "false") return value === "true";
|
|
639
|
+
break;
|
|
640
|
+
case "number":
|
|
641
|
+
if (typeof value === "string" && value.trim() !== "") return Number(value);
|
|
642
|
+
break;
|
|
643
|
+
case "array":
|
|
644
|
+
if (typeof value === "string") value = value === "" || value === "[]" ? [] : value.split(",");
|
|
645
|
+
break;
|
|
646
|
+
}
|
|
647
|
+
if (field.options) {
|
|
648
|
+
const match = item => field.options.find(option => String(option.key) === String(item))?.key ?? item;
|
|
649
|
+
return field.type === "array" && Array.isArray(value) ? value.map(match) : match(value);
|
|
650
|
+
}
|
|
651
|
+
return value;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* 校验支持的标量范围,包括文本长度与数值有限性。
|
|
656
|
+
* Validate supported scalar bounds, including text length and numeric finiteness.
|
|
657
|
+
* @param {unknown} value 待检查值 / Value to inspect.
|
|
658
|
+
* @returns {boolean} 是否为有效标量 / Whether the scalar is valid.
|
|
659
|
+
*/
|
|
660
|
+
function scalar(value) {
|
|
661
|
+
switch (typeof value) {
|
|
662
|
+
case "boolean":
|
|
663
|
+
return true;
|
|
664
|
+
case "string":
|
|
665
|
+
return value.length <= 2048;
|
|
666
|
+
case "number":
|
|
667
|
+
return Number.isFinite(value);
|
|
668
|
+
default:
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* 检查值类型与数组结构,不检查选项是否仍在当前配置中。
|
|
675
|
+
* Check value type and array structure without requiring current option membership.
|
|
676
|
+
* @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
|
|
677
|
+
* @param {unknown} value 待检查值 / Value to inspect.
|
|
678
|
+
* @returns {boolean} 是否可由控件表示 / Whether the control can represent the value.
|
|
679
|
+
*/
|
|
680
|
+
function validValueShape(field, value) {
|
|
681
|
+
switch (field.type) {
|
|
682
|
+
case "array":
|
|
683
|
+
if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
|
|
684
|
+
break;
|
|
685
|
+
default:
|
|
686
|
+
if (typeof value !== field.type || !scalar(value)) return false;
|
|
687
|
+
break;
|
|
688
|
+
}
|
|
689
|
+
if (field.control === "url" && (!/^[a-z][a-z\d+.-]*:/i.test(value) || /^(?:javascript|data|vbscript):/i.test(value))) return false;
|
|
690
|
+
return true;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* 返回当前配置未声明的选项值。
|
|
695
|
+
* Return option values absent from the current definition.
|
|
696
|
+
* @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
|
|
697
|
+
* @param {unknown} value 已确认结构有效的值 / Value with a valid shape.
|
|
698
|
+
* @returns {unknown[]} 未定义值 / Undefined values.
|
|
699
|
+
*/
|
|
700
|
+
function undefinedOptions(field, value) {
|
|
701
|
+
if (!field.options) return [];
|
|
702
|
+
return (field.type === "array" ? value : [value]).filter(item => !field.options.some(option => option.key === item));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
/**
|
|
706
|
+
* 将外部存储值解析为可渲染值,并把兼容性问题作为字段级警告返回。
|
|
707
|
+
* Resolve external storage into a renderable value and return compatibility issues as field warnings.
|
|
708
|
+
* @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
|
|
709
|
+
* @param {unknown} stored 外部存储值 / External stored value.
|
|
710
|
+
* @returns {{value?: unknown, warning?: {kind: "invalid-value" | "undefined-options", values: unknown[]}}} 解析结果 / Resolution result.
|
|
711
|
+
*/
|
|
712
|
+
function resolveStoredValue(field, stored) {
|
|
713
|
+
if (field.control === "url") return { value: field.defaultValue };
|
|
714
|
+
const value = normalizeStoredValue(field, stored === undefined ? field.defaultValue : stored);
|
|
715
|
+
if (value === undefined) return {};
|
|
716
|
+
if (!validValueShape(field, value)) {
|
|
717
|
+
return {
|
|
718
|
+
...(Object.prototype.hasOwnProperty.call(field, "defaultValue") ? { value: field.defaultValue } : {}),
|
|
719
|
+
warning: { kind: "invalid-value", values: [stored] },
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
const undefinedValues = undefinedOptions(field, value);
|
|
723
|
+
return { value, ...(undefinedValues.length ? { warning: { kind: "undefined-options", values: undefinedValues } } : {}) };
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* 严格检查写入值的类型、数组唯一性及声明选项。
|
|
728
|
+
* Strictly check a write value's type, array uniqueness, and declared options.
|
|
729
|
+
* @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
|
|
730
|
+
* @param {unknown} value 待写入的 JSON 值 / JSON value to write.
|
|
731
|
+
* @returns {boolean} 是否允许写入 / Whether the value may be written.
|
|
732
|
+
*/
|
|
733
|
+
function validValue(field, value) {
|
|
734
|
+
return validValueShape(field, value) && undefinedOptions(field, value).length === 0;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* 创建元素,所有展示文本通过 textContent 写入。
|
|
739
|
+
* Create elements and assign display text through textContent only.
|
|
740
|
+
* @template {keyof HTMLElementTagNameMap} T
|
|
741
|
+
* @param {T} tag 元素标签 / Element tag.
|
|
742
|
+
* @param {string} className 样式类名 / CSS class.
|
|
743
|
+
* @param {string} [text] 纯文本 / Plain text.
|
|
744
|
+
* @returns {HTMLElementTagNameMap[T]} 创建的元素 / Created element.
|
|
745
|
+
*/
|
|
746
|
+
function element(tag, className, text) {
|
|
747
|
+
const node = document.createElement(tag);
|
|
748
|
+
node.className = className;
|
|
749
|
+
if (text !== undefined) node.textContent = text;
|
|
750
|
+
return node;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* 创建通用设置行;外部 CSS 可通过 pp 类名覆盖视觉样式。
|
|
755
|
+
* Create a generic settings row whose appearance can be overridden through pp classes.
|
|
756
|
+
* @template {"div" | "label"} T
|
|
757
|
+
* @param {T} tag 行元素 / Row element.
|
|
758
|
+
* @returns {HTMLElementTagNameMap[T]} 设置行 / Settings row.
|
|
759
|
+
*/
|
|
760
|
+
function settingRow(tag) {
|
|
761
|
+
return element(tag, "pp-row");
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* 为标准 HTML 输入控件添加通用面板类名。
|
|
766
|
+
* Add the generic panel class to a standard HTML input control.
|
|
767
|
+
* @param {HTMLElement} control 已创建的原生控件 / Existing native control.
|
|
768
|
+
* @returns {HTMLElement} 输入控件 / Input control.
|
|
769
|
+
*/
|
|
770
|
+
function fieldControl(control) {
|
|
771
|
+
control.classList.add("pp-editor");
|
|
772
|
+
return control;
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/**
|
|
776
|
+
* 创建覆盖可用内容区的通用读取状态,失败时可附加重试动作。
|
|
777
|
+
* Create a shared status view that fills the available content area and may include retry.
|
|
778
|
+
* @param {string} message 状态文本 / Status message.
|
|
779
|
+
* @param {(() => unknown) | undefined} [retry] 重试动作 / Retry action.
|
|
780
|
+
* @returns {HTMLElement} 居中状态视图 / Centered status view.
|
|
781
|
+
*/
|
|
782
|
+
function statusView(message, retry) {
|
|
783
|
+
const view = element("section", "pp-status");
|
|
784
|
+
view.setAttribute("role", "status");
|
|
785
|
+
view.setAttribute("aria-live", "polite");
|
|
786
|
+
const spinner = element("span", "pp-status-spinner");
|
|
787
|
+
spinner.setAttribute("aria-hidden", "true");
|
|
788
|
+
view.append(spinner, element("p", "pp-status-message", message));
|
|
789
|
+
if (retry) {
|
|
790
|
+
const button = element("button", "pp-status-action", "重新读取");
|
|
791
|
+
button.type = "button";
|
|
792
|
+
button.onclick = retry;
|
|
793
|
+
view.append(button);
|
|
794
|
+
}
|
|
795
|
+
return view;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* 管理单模块页面的 API 请求、值快照和会话终止。
|
|
800
|
+
* Manage API requests, value snapshots, and session termination for one module page.
|
|
801
|
+
*/
|
|
802
|
+
class PreferencesClient {
|
|
803
|
+
#module;
|
|
804
|
+
#definition;
|
|
805
|
+
#request;
|
|
806
|
+
#notify;
|
|
807
|
+
#timeout;
|
|
808
|
+
#session = new AbortController();
|
|
809
|
+
#values = {};
|
|
810
|
+
#warnings = {};
|
|
811
|
+
#saving = false;
|
|
812
|
+
#arrays = new Map();
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* 创建从 BoxJS 定义读取和持久化设置的页面客户端。
|
|
816
|
+
* Create a page client that reads and persists settings from a BoxJS definition.
|
|
817
|
+
* @param {import("./client.mjs").PreferencesClientOptions} options 字段定义、请求与通知 / Field definition, requests, and notifications.
|
|
818
|
+
*/
|
|
819
|
+
constructor({ definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
|
|
820
|
+
this.#module = definition.module;
|
|
821
|
+
this.#definition = definition;
|
|
822
|
+
this.#request = request;
|
|
823
|
+
this.#notify = notify;
|
|
824
|
+
this.#timeout = timeout;
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
/**
|
|
828
|
+
* 读取一次 Settings 子树并建立页面值快照。
|
|
829
|
+
* Read the Settings subtree once and establish the page value snapshot.
|
|
830
|
+
* @returns {Promise<import("./client.mjs").ModuleSnapshot>} 页面快照 / Page snapshot.
|
|
831
|
+
*/
|
|
832
|
+
async open() {
|
|
833
|
+
let subtree = await this.readSettings();
|
|
834
|
+
if (subtree === undefined) subtree = {};
|
|
835
|
+
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
836
|
+
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
837
|
+
const values = {};
|
|
838
|
+
const warnings = {};
|
|
839
|
+
this.#arrays.clear();
|
|
840
|
+
for (const field of this.#definition.fields) {
|
|
841
|
+
const indexed = /^(.*)\.(\d+)$/.exec(field.key);
|
|
842
|
+
if (indexed && !this.#arrays.has(indexed[1])) {
|
|
843
|
+
const storedArray = indexed[1]
|
|
844
|
+
.split(".")
|
|
845
|
+
.slice(this.#definition.settingsPath.length)
|
|
846
|
+
.reduce((parent, part) => Object(parent)[part], subtree);
|
|
847
|
+
this.#arrays.set(indexed[1], Array.isArray(storedArray) ? [...storedArray] : []);
|
|
848
|
+
}
|
|
849
|
+
const stored = field.key
|
|
850
|
+
.split(".")
|
|
851
|
+
.slice(this.#definition.settingsPath.length)
|
|
852
|
+
.reduce((parent, part) => Object(parent)[part], subtree);
|
|
853
|
+
const resolved = resolveStoredValue(field, stored);
|
|
854
|
+
if (Object.prototype.hasOwnProperty.call(resolved, "value")) values[field.key] = resolved.value;
|
|
855
|
+
if (resolved.warning) warnings[field.key] = resolved.warning;
|
|
856
|
+
}
|
|
857
|
+
this.#values = values;
|
|
858
|
+
this.#warnings = warnings;
|
|
859
|
+
return this.snapshot();
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* 获取当前字段定义和值的深拷贝,不发起网络请求。
|
|
864
|
+
* Return a deep copy of the current field definition and values without a network request.
|
|
865
|
+
* @returns {import("./client.mjs").ModuleSnapshot} 会话快照 / Session snapshot.
|
|
866
|
+
*/
|
|
867
|
+
snapshot() {
|
|
868
|
+
return JSON.parse(JSON.stringify({ definition: this.#definition, values: this.#values, warnings: this.#warnings }));
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* 读取 Settings 子树。
|
|
873
|
+
* Read the Settings subtree.
|
|
874
|
+
* @returns {Promise<unknown>} Settings 内容或 undefined / Settings content or undefined.
|
|
875
|
+
*/
|
|
876
|
+
async readSettings() {
|
|
877
|
+
const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#definition.settingsPath.join(".")}`);
|
|
878
|
+
return response.status === 404 ? undefined : response.json();
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/**
|
|
882
|
+
* 读取 Caches 子树。
|
|
883
|
+
* Read the Caches subtree.
|
|
884
|
+
* @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
|
|
885
|
+
*/
|
|
886
|
+
async readCaches() {
|
|
887
|
+
const response = await this.#send("get", this.#definition.cachePath ?? `@${this.#definition.storageKey}.${this.#module}.Caches`);
|
|
888
|
+
return response.status === 404 ? undefined : response.json();
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* 删除声明的缓存子树,默认当前模块 Caches。
|
|
893
|
+
* Delete the declared cache subtree, defaulting to the current module Caches.
|
|
894
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
895
|
+
*/
|
|
896
|
+
clearCaches() {
|
|
897
|
+
const path = this.#definition.cachePath?.slice(this.#definition.storageKey.length + 2) ?? `${this.#module}.Caches`;
|
|
898
|
+
return this.#change("delete", path, undefined, "clearCaches");
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* 删除声明的设置子树,默认删除当前模块数据,并恢复页面默认值。
|
|
903
|
+
* Delete declared settings subtrees, defaulting to current module data, and restore page defaults.
|
|
904
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
905
|
+
*/
|
|
906
|
+
reset() {
|
|
907
|
+
return this.#change("delete", this.#module, undefined, "reset");
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/**
|
|
911
|
+
* 终止当前页面的读取;已开始的持久化写入继续完成。
|
|
912
|
+
* Abort reads owned by the current page while allowing started persistence writes to finish.
|
|
913
|
+
* @returns {void} 无返回值 / No return value.
|
|
914
|
+
*/
|
|
915
|
+
leave() {
|
|
916
|
+
this.#session.abort();
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* 写入单个字段。
|
|
921
|
+
* Write one field.
|
|
922
|
+
* @param {string} key 字段路径 / Field path.
|
|
923
|
+
* @param {unknown} value 已校验值 / Validated value.
|
|
924
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
925
|
+
*/
|
|
926
|
+
set(key, value) {
|
|
927
|
+
return this.#change("set", key, value, "write");
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
/**
|
|
931
|
+
* 删除单个字段覆盖值。
|
|
932
|
+
* Delete one field override.
|
|
933
|
+
* @param {string} key 字段路径 / Field path.
|
|
934
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
935
|
+
*/
|
|
936
|
+
remove(key) {
|
|
937
|
+
return this.#change("delete", key, undefined, "delete");
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* 向固定存储 API 发送完整路径的 form 动作。
|
|
942
|
+
* Send a complete-path form action to the fixed storage API.
|
|
943
|
+
* @param {"get" | "set" | "delete"} action 存储动作 / Storage action.
|
|
944
|
+
* @param {string} path 完整 @root.path / Complete @root.path.
|
|
945
|
+
* @param {unknown} [value] set 写入值 / Value written by set.
|
|
946
|
+
* @returns {Promise<Response>} 原始响应 / Raw response.
|
|
947
|
+
*/
|
|
948
|
+
async #send(action, path, value) {
|
|
949
|
+
const controller = new AbortController();
|
|
950
|
+
const abort = () => controller.abort();
|
|
951
|
+
if (action === "get") {
|
|
952
|
+
if (this.#session.signal.aborted) abort();
|
|
953
|
+
this.#session.signal.addEventListener("abort", abort, { once: true });
|
|
954
|
+
}
|
|
955
|
+
const timer = setTimeout(abort, this.#timeout);
|
|
956
|
+
try {
|
|
957
|
+
const response = await this.#request(`/api/${action}`, {
|
|
958
|
+
method: "POST",
|
|
959
|
+
credentials: "omit",
|
|
960
|
+
cache: "no-store",
|
|
961
|
+
signal: controller.signal,
|
|
962
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
963
|
+
body: new URLSearchParams([[path, action === "set" ? JSON.stringify(value) : ""]]).toString(),
|
|
964
|
+
});
|
|
965
|
+
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
966
|
+
return response;
|
|
967
|
+
} finally {
|
|
968
|
+
clearTimeout(timer);
|
|
969
|
+
this.#session.signal.removeEventListener("abort", abort);
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/**
|
|
974
|
+
* 执行写入动作;成功后只更新当前页面值。
|
|
975
|
+
* Execute a mutation and update only the current page values after success.
|
|
976
|
+
* @param {"set" | "delete"} action API 动作 / API action.
|
|
977
|
+
* @param {string} key 不含存储根的路径 / Path without the storage root.
|
|
978
|
+
* @param {unknown} value set 写入值 / Value written by set.
|
|
979
|
+
* @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
|
|
980
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
981
|
+
*/
|
|
982
|
+
async #change(action, key, value, operation) {
|
|
983
|
+
if (this.#saving) throw new Error("A settings write is already in progress");
|
|
984
|
+
this.#saving = true;
|
|
985
|
+
try {
|
|
986
|
+
switch (operation) {
|
|
987
|
+
case "write":
|
|
988
|
+
case "delete": {
|
|
989
|
+
const field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
990
|
+
if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
|
|
991
|
+
const indexed = /^(.*)\.(\d+)$/.exec(key);
|
|
992
|
+
if (indexed) {
|
|
993
|
+
const array = [...(this.#arrays.get(indexed[1]) ?? [])];
|
|
994
|
+
for (const sibling of this.#definition.fields) {
|
|
995
|
+
if (sibling.key.startsWith(`${indexed[1]}.`) && /^\d+$/.test(sibling.key.slice(indexed[1].length + 1))) {
|
|
996
|
+
const index = Number(sibling.key.slice(indexed[1].length + 1));
|
|
997
|
+
if (array[index] === undefined) array[index] = this.#values[sibling.key] ?? sibling.defaultValue;
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
array[Number(indexed[2])] = operation === "delete" ? field.defaultValue : value;
|
|
1001
|
+
await this.#send("set", `@${this.#definition.storageKey}.${indexed[1]}`, array);
|
|
1002
|
+
this.#arrays.set(indexed[1], array);
|
|
1003
|
+
} else await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
|
|
1004
|
+
switch (operation) {
|
|
1005
|
+
case "write":
|
|
1006
|
+
this.#values[key] = JSON.parse(JSON.stringify(value));
|
|
1007
|
+
delete this.#warnings[key];
|
|
1008
|
+
break;
|
|
1009
|
+
case "delete":
|
|
1010
|
+
delete this.#values[key];
|
|
1011
|
+
delete this.#warnings[key];
|
|
1012
|
+
if (Object.prototype.hasOwnProperty.call(field, "defaultValue")) this.#values[key] = JSON.parse(JSON.stringify(field.defaultValue));
|
|
1013
|
+
break;
|
|
1014
|
+
}
|
|
1015
|
+
break;
|
|
1016
|
+
}
|
|
1017
|
+
case "clearCaches":
|
|
1018
|
+
await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
|
|
1019
|
+
break;
|
|
1020
|
+
case "reset":
|
|
1021
|
+
if (this.#definition.resetPaths) {
|
|
1022
|
+
for (const path of this.#definition.resetPaths) await this.#send("delete", path);
|
|
1023
|
+
} else await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
|
|
1024
|
+
this.#arrays.clear();
|
|
1025
|
+
this.#warnings = {};
|
|
1026
|
+
for (const field of this.#definition.fields) {
|
|
1027
|
+
delete this.#values[field.key];
|
|
1028
|
+
if (Object.prototype.hasOwnProperty.call(field, "defaultValue")) this.#values[field.key] = JSON.parse(JSON.stringify(field.defaultValue));
|
|
1029
|
+
}
|
|
1030
|
+
break;
|
|
1031
|
+
}
|
|
1032
|
+
this.#notify({ kind: "success", operation, module: this.#module, key });
|
|
1033
|
+
} catch (error) {
|
|
1034
|
+
this.#notify({ kind: "error", operation, module: this.#module, key, message: error.message });
|
|
1035
|
+
throw error;
|
|
1036
|
+
} finally {
|
|
1037
|
+
this.#saving = false;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* 将字段级存储诊断转换为简短提示。
|
|
1044
|
+
* Convert a field-level storage diagnostic into concise display text.
|
|
1045
|
+
* @param {import("./client.mjs").StoredValueWarning} warning 存储警告 / Storage warning.
|
|
1046
|
+
* @returns {string} 用户可见提示 / User-visible warning.
|
|
1047
|
+
*/
|
|
1048
|
+
function warningMessage(warning) {
|
|
1049
|
+
const values = warning.values
|
|
1050
|
+
.map(value => {
|
|
1051
|
+
if (typeof value === "string") return value;
|
|
1052
|
+
return JSON.stringify(value) ?? String(value);
|
|
1053
|
+
})
|
|
1054
|
+
.join("、");
|
|
1055
|
+
return warning.kind === "undefined-options" ? `当前配置未定义值:${values}` : `当前存储值格式不受支持:${values}`;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* 管理模块表单、操作队列和设置会话。
|
|
1060
|
+
* Manage the module form, operation queue and settings session.
|
|
1061
|
+
*/
|
|
1062
|
+
class PreferencesPanel {
|
|
1063
|
+
#release;
|
|
1064
|
+
#view;
|
|
1065
|
+
#state;
|
|
1066
|
+
#perform;
|
|
1067
|
+
|
|
1068
|
+
/**
|
|
1069
|
+
* 挂载 BoxJS 定义对应的模块表单。
|
|
1070
|
+
* Mount the module form described by a BoxJS definition.
|
|
1071
|
+
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
1072
|
+
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
1073
|
+
* @param {import("./page.mjs").PageController} page 内部页面控制器 / Internal page controller.
|
|
1074
|
+
*/
|
|
1075
|
+
constructor(root, definition, page) {
|
|
1076
|
+
this.#release = this.#mount(root, definition, page);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* 建立面板 DOM、交互和会话,并返回其释放操作。
|
|
1081
|
+
* Build panel DOM, interactions, and session, then return its release operation.
|
|
1082
|
+
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
1083
|
+
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
1084
|
+
* @param {import("./page.mjs").PageController} page 内部页面控制器 / Internal page controller.
|
|
1085
|
+
* @returns {() => void} 释放操作 / Release operation.
|
|
1086
|
+
*/
|
|
1087
|
+
#mount(root, definition, page) {
|
|
1088
|
+
const title = definition.metadata?.name ?? definition.module;
|
|
1089
|
+
const document = root.ownerDocument;
|
|
1090
|
+
const window = document.defaultView;
|
|
1091
|
+
const shell = element("div", "pp-panel");
|
|
1092
|
+
shell.dataset.module = definition.module;
|
|
1093
|
+
const handlers = new Map();
|
|
1094
|
+
const menuItems = [
|
|
1095
|
+
{ id: "viewSettings", label: "查看设置" },
|
|
1096
|
+
{ id: "viewCaches", label: "查看缓存" },
|
|
1097
|
+
{ id: "clearCaches", label: "清空缓存", destructive: true },
|
|
1098
|
+
{ id: "reset", label: "重置设置", destructive: true },
|
|
1099
|
+
];
|
|
1100
|
+
const viewport = element("div", "pp-viewport");
|
|
1101
|
+
shell.append(viewport);
|
|
1102
|
+
root.append(shell);
|
|
1103
|
+
const editors = new Map();
|
|
1104
|
+
this.#view = key => {
|
|
1105
|
+
const editor = editors.get(key);
|
|
1106
|
+
editor?.load?.();
|
|
1107
|
+
return editor?.node ?? root;
|
|
1108
|
+
};
|
|
1109
|
+
this.#state = key => ({ title: editors.get(key)?.title ?? title, actions: handlers.size ? menuItems : [], busy: saving });
|
|
1110
|
+
this.#perform = id => {
|
|
1111
|
+
if (!saving && handlers.has(id)) runAction(id);
|
|
1112
|
+
};
|
|
1113
|
+
const publishNavigation = () => {
|
|
1114
|
+
if (!destroyed) page.update();
|
|
1115
|
+
};
|
|
1116
|
+
let generation = 0,
|
|
1117
|
+
active = null,
|
|
1118
|
+
saving = false,
|
|
1119
|
+
destroyed = false;
|
|
1120
|
+
/**
|
|
1121
|
+
* 展示短暂通知,不刷新设置数据。
|
|
1122
|
+
* Display a transient notification without refreshing settings.
|
|
1123
|
+
* @param {{kind: "success" | "error", operation?: "write" | "delete" | "clearCaches" | "reset", message?: string}} event 操作结果 / Operation result.
|
|
1124
|
+
* @returns {void} 无返回值 / No return value.
|
|
1125
|
+
*/
|
|
1126
|
+
const notify = event => {
|
|
1127
|
+
if (destroyed) return;
|
|
1128
|
+
let message;
|
|
1129
|
+
switch (event.kind === "error" ? "error" : event.operation) {
|
|
1130
|
+
case "error":
|
|
1131
|
+
message = `操作失败:${event.message}`;
|
|
1132
|
+
break;
|
|
1133
|
+
case "delete":
|
|
1134
|
+
message = "删除成功";
|
|
1135
|
+
break;
|
|
1136
|
+
case "clearCaches":
|
|
1137
|
+
message = "Caches 已清空";
|
|
1138
|
+
break;
|
|
1139
|
+
case "reset":
|
|
1140
|
+
message = "设置已重置";
|
|
1141
|
+
break;
|
|
1142
|
+
default:
|
|
1143
|
+
message = "修改成功";
|
|
1144
|
+
break;
|
|
1145
|
+
}
|
|
1146
|
+
page.notice({ kind: event.kind, message });
|
|
1147
|
+
};
|
|
1148
|
+
const client = new PreferencesClient({ definition, notify });
|
|
1149
|
+
/**
|
|
1150
|
+
* 菜单操作统一处理异步错误,包含宿主确认框错误。
|
|
1151
|
+
* Handle asynchronous action errors, including host-dialog errors.
|
|
1152
|
+
* @param {string} id 操作标识 / Action identifier.
|
|
1153
|
+
* @returns {Promise<void>} 操作已处理 / Action handled.
|
|
1154
|
+
*/
|
|
1155
|
+
async function runAction(id) {
|
|
1156
|
+
try {
|
|
1157
|
+
await handlers.get(id)();
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
notify({ kind: "error", message: error.message });
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* 打开模块并忽略已过期的异步结果。
|
|
1164
|
+
* Open a module and ignore stale asynchronous results.
|
|
1165
|
+
* @param {string} module 模块标识 / Module identifier.
|
|
1166
|
+
* @returns {Promise<void>} 视图加载完成,失败显示错误视图 / View load completion; failures display an error view.
|
|
1167
|
+
*/
|
|
1168
|
+
async function open(module) {
|
|
1169
|
+
const version = ++generation;
|
|
1170
|
+
active = module;
|
|
1171
|
+
publishNavigation();
|
|
1172
|
+
viewport.replaceChildren(statusView("读取设置…"));
|
|
1173
|
+
try {
|
|
1174
|
+
await client.open();
|
|
1175
|
+
if (version === generation) controls();
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
if (version !== generation) return;
|
|
1178
|
+
viewport.replaceChildren(statusView(`加载失败:${error.message}`, () => open(module)));
|
|
1179
|
+
publishNavigation();
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* 从会话快照创建控件与操作按钮,不重新读取网络配置。
|
|
1184
|
+
* Build controls and actions from the session snapshot without fetching config again.
|
|
1185
|
+
* @returns {void} 无返回值 / No return value.
|
|
1186
|
+
*/
|
|
1187
|
+
function controls() {
|
|
1188
|
+
const { definition, values } = client.snapshot();
|
|
1189
|
+
const view = element("section", "pp-fields");
|
|
1190
|
+
/**
|
|
1191
|
+
* 挂载后执行的多行高度更新
|
|
1192
|
+
* Textarea sizing callbacks run after mounting.
|
|
1193
|
+
* @type {Array<() => void>}
|
|
1194
|
+
*/
|
|
1195
|
+
const growingInputs = [];
|
|
1196
|
+
editors.clear();
|
|
1197
|
+
const summaries = [];
|
|
1198
|
+
const warningRefreshers = [];
|
|
1199
|
+
const groups = new Map();
|
|
1200
|
+
let queue = Promise.resolve(),
|
|
1201
|
+
pendingWrites = 0;
|
|
1202
|
+
/**
|
|
1203
|
+
* 串行执行模块操作,保持输入可编辑。
|
|
1204
|
+
* Serialize module actions while keeping inputs editable.
|
|
1205
|
+
* @param {() => Promise<void>} action 请求或写入 / Request or mutation.
|
|
1206
|
+
* @param {() => void} success 成功后的局部更新 / Local update after success.
|
|
1207
|
+
* @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.
|
|
1208
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
1209
|
+
*/
|
|
1210
|
+
function perform(action, success, failure = () => {}) {
|
|
1211
|
+
pendingWrites++;
|
|
1212
|
+
saving = true;
|
|
1213
|
+
publishNavigation();
|
|
1214
|
+
queue = queue
|
|
1215
|
+
.then(action)
|
|
1216
|
+
.then(() => {
|
|
1217
|
+
if (!destroyed) success();
|
|
1218
|
+
})
|
|
1219
|
+
.catch(() => {
|
|
1220
|
+
/* 请求层已通知错误。
|
|
1221
|
+
* The request layer has already reported the error. */
|
|
1222
|
+
if (!destroyed) failure();
|
|
1223
|
+
})
|
|
1224
|
+
.finally(() => {
|
|
1225
|
+
pendingWrites--;
|
|
1226
|
+
saving = pendingWrites > 0;
|
|
1227
|
+
publishNavigation();
|
|
1228
|
+
});
|
|
1229
|
+
return queue;
|
|
1230
|
+
}
|
|
1231
|
+
for (const field of definition.fields) {
|
|
1232
|
+
const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
|
|
1233
|
+
const group = match?.[1] ?? "通用";
|
|
1234
|
+
if (!groups.has(group)) {
|
|
1235
|
+
const section = element("section", "pp-group");
|
|
1236
|
+
const rows = element("div", "pp-rows");
|
|
1237
|
+
section.append(element("h2", "pp-group-title", group), rows);
|
|
1238
|
+
groups.set(group, rows);
|
|
1239
|
+
view.append(section);
|
|
1240
|
+
}
|
|
1241
|
+
const row = settingRow("div");
|
|
1242
|
+
row.classList.add("pp-field");
|
|
1243
|
+
const label = element("div", "pp-label");
|
|
1244
|
+
label.append(element("span", "pp-field-name", match?.[2] ?? field.name));
|
|
1245
|
+
if (field.description) label.append(element("span", "pp-field-description", field.description));
|
|
1246
|
+
const warning = element("span", "pp-field-warning");
|
|
1247
|
+
const refreshWarning = () => {
|
|
1248
|
+
const issue = client.snapshot().warnings[field.key];
|
|
1249
|
+
warning.hidden = !issue;
|
|
1250
|
+
warning.textContent = issue ? warningMessage(issue) : "";
|
|
1251
|
+
};
|
|
1252
|
+
warningRefreshers.push(refreshWarning);
|
|
1253
|
+
refreshWarning();
|
|
1254
|
+
label.append(warning);
|
|
1255
|
+
row.append(label);
|
|
1256
|
+
const value = values[field.key];
|
|
1257
|
+
/**
|
|
1258
|
+
* 读取尚未保存的输入
|
|
1259
|
+
* Read the unsaved input.
|
|
1260
|
+
* @type {() => unknown}
|
|
1261
|
+
*/
|
|
1262
|
+
let read;
|
|
1263
|
+
/**
|
|
1264
|
+
* 更新当前控件
|
|
1265
|
+
* Update the current control.
|
|
1266
|
+
* @type {(value: unknown) => void}
|
|
1267
|
+
*/
|
|
1268
|
+
let write;
|
|
1269
|
+
let inputContainer = row;
|
|
1270
|
+
let eventName = "change";
|
|
1271
|
+
switch (field.control === "url" ? "url" : field.options ? (field.type === "array" ? "checkboxes" : "selects") : field.type) {
|
|
1272
|
+
case "url": {
|
|
1273
|
+
const link = element("a", "pp-choice-link", "打开");
|
|
1274
|
+
link.setAttribute("aria-label", field.name);
|
|
1275
|
+
link.rel = "noopener noreferrer";
|
|
1276
|
+
write = value => {
|
|
1277
|
+
link.href = value;
|
|
1278
|
+
};
|
|
1279
|
+
link.addEventListener("click", event => {
|
|
1280
|
+
event.preventDefault();
|
|
1281
|
+
page.openURL(link.href);
|
|
1282
|
+
});
|
|
1283
|
+
row.append(link);
|
|
1284
|
+
row.addEventListener("click", event => {
|
|
1285
|
+
if (!link.contains(event.target)) link.click();
|
|
1286
|
+
});
|
|
1287
|
+
break;
|
|
1288
|
+
}
|
|
1289
|
+
case "selects": {
|
|
1290
|
+
const select = element("select", "");
|
|
1291
|
+
select.setAttribute("aria-label", field.name);
|
|
1292
|
+
field.options.forEach((option, index) => {
|
|
1293
|
+
const item = element("option", "", option.label);
|
|
1294
|
+
item.value = String(index);
|
|
1295
|
+
select.append(item);
|
|
1296
|
+
});
|
|
1297
|
+
write = value => {
|
|
1298
|
+
select.selectedIndex = field.options.findIndex(option => option.key === value);
|
|
1299
|
+
};
|
|
1300
|
+
row.append(fieldControl(select));
|
|
1301
|
+
read = () => field.options[select.selectedIndex]?.key;
|
|
1302
|
+
break;
|
|
1303
|
+
}
|
|
1304
|
+
case "checkboxes": {
|
|
1305
|
+
const editor = element("section", "pp-panel pp-detail pp-choice-page");
|
|
1306
|
+
if (field.description) editor.append(element("p", "pp-description", field.description));
|
|
1307
|
+
const choices = element("div", "pp-rows");
|
|
1308
|
+
editor.append(choices);
|
|
1309
|
+
inputContainer = choices;
|
|
1310
|
+
editors.set(field.key, { node: editor, title: match?.[2] ?? field.name });
|
|
1311
|
+
const summary = element("span", "pp-summary");
|
|
1312
|
+
const link = element("button", "pp-choice-link");
|
|
1313
|
+
link.type = "button";
|
|
1314
|
+
link.setAttribute("aria-label", field.name);
|
|
1315
|
+
link.append(summary, element("span", "pp-chevron", "›"));
|
|
1316
|
+
row.append(link);
|
|
1317
|
+
const refresh = () => {
|
|
1318
|
+
const value = client.snapshot().values[field.key];
|
|
1319
|
+
summary.textContent =
|
|
1320
|
+
field.options
|
|
1321
|
+
.filter(option => Array.isArray(value) && value.includes(option.key))
|
|
1322
|
+
.map(option => option.label)
|
|
1323
|
+
.join("、") || "未选择";
|
|
1324
|
+
};
|
|
1325
|
+
summaries.push(refresh);
|
|
1326
|
+
refresh();
|
|
1327
|
+
link.onclick = () => page.open(field.key);
|
|
1328
|
+
row.addEventListener("click", event => {
|
|
1329
|
+
if (!link.contains(event.target)) link.click();
|
|
1330
|
+
});
|
|
1331
|
+
const inputs = field.options.map(option => {
|
|
1332
|
+
const label = settingRow("label");
|
|
1333
|
+
label.classList.add("pp-choice");
|
|
1334
|
+
label.textContent = option.label;
|
|
1335
|
+
const input = element("input", "");
|
|
1336
|
+
input.type = "checkbox";
|
|
1337
|
+
input.setAttribute("aria-label", option.label);
|
|
1338
|
+
label.append(input);
|
|
1339
|
+
choices.append(label);
|
|
1340
|
+
return { input, key: option.key };
|
|
1341
|
+
});
|
|
1342
|
+
read = () => inputs.filter(option => option.input.checked).map(option => option.key);
|
|
1343
|
+
write = value => {
|
|
1344
|
+
for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);
|
|
1345
|
+
};
|
|
1346
|
+
break;
|
|
1347
|
+
}
|
|
1348
|
+
case "boolean": {
|
|
1349
|
+
const toggle = element("input", "pp-switch");
|
|
1350
|
+
toggle.type = "checkbox";
|
|
1351
|
+
toggle.setAttribute("switch", "");
|
|
1352
|
+
toggle.setAttribute("role", "switch");
|
|
1353
|
+
toggle.setAttribute("aria-label", field.name);
|
|
1354
|
+
write = value => {
|
|
1355
|
+
toggle.checked = value === true;
|
|
1356
|
+
};
|
|
1357
|
+
read = () => toggle.checked;
|
|
1358
|
+
row.append(toggle);
|
|
1359
|
+
break;
|
|
1360
|
+
}
|
|
1361
|
+
default: {
|
|
1362
|
+
const multiline = field.control === "textarea" || field.type === "array";
|
|
1363
|
+
const input = element(multiline ? "textarea" : "input", "");
|
|
1364
|
+
if (multiline) row.classList.add("pp-multiline");
|
|
1365
|
+
input.setAttribute("aria-label", field.name);
|
|
1366
|
+
if (field.placeholder) input.placeholder = field.placeholder;
|
|
1367
|
+
if (multiline && field.rows) input.rows = field.rows;
|
|
1368
|
+
/**
|
|
1369
|
+
* 在挂载后根据内容调整高度,同时保留基础行数。
|
|
1370
|
+
* Size mounted textareas to their contents while retaining baseline rows.
|
|
1371
|
+
* @returns {void} 无返回值 / No return value.
|
|
1372
|
+
*/
|
|
1373
|
+
const grow = () => {
|
|
1374
|
+
if (!multiline || !field.autoGrow || !input.isConnected) return;
|
|
1375
|
+
input.style.height = "auto";
|
|
1376
|
+
const baseline = input.getBoundingClientRect().height;
|
|
1377
|
+
const style = window.getComputedStyle(input);
|
|
1378
|
+
const borders = Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
|
|
1379
|
+
input.style.height = `${Math.max(baseline, input.scrollHeight + borders)}px`;
|
|
1380
|
+
};
|
|
1381
|
+
if (multiline && field.autoGrow) {
|
|
1382
|
+
input.addEventListener("input", grow);
|
|
1383
|
+
growingInputs.push(grow);
|
|
1384
|
+
}
|
|
1385
|
+
eventName = "input";
|
|
1386
|
+
if (!multiline) {
|
|
1387
|
+
input.type = field.type === "number" ? "number" : "text";
|
|
1388
|
+
input.autocapitalize = "off";
|
|
1389
|
+
input.setAttribute("autocorrect", "off");
|
|
1390
|
+
input.spellcheck = false;
|
|
1391
|
+
}
|
|
1392
|
+
write = value => {
|
|
1393
|
+
input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
|
|
1394
|
+
grow();
|
|
1395
|
+
};
|
|
1396
|
+
read = () => {
|
|
1397
|
+
switch (field.type) {
|
|
1398
|
+
case "array":
|
|
1399
|
+
return JSON.parse(input.value);
|
|
1400
|
+
case "number":
|
|
1401
|
+
return input.value === "" ? Number.NaN : Number(input.value);
|
|
1402
|
+
default:
|
|
1403
|
+
return input.value;
|
|
1404
|
+
}
|
|
1405
|
+
};
|
|
1406
|
+
row.append(fieldControl(input));
|
|
1407
|
+
break;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
write(value);
|
|
1411
|
+
let inputVersion = 0;
|
|
1412
|
+
inputContainer.addEventListener(eventName, event => {
|
|
1413
|
+
if (event.isComposing) return;
|
|
1414
|
+
const version = ++inputVersion;
|
|
1415
|
+
let value;
|
|
1416
|
+
try {
|
|
1417
|
+
value = read();
|
|
1418
|
+
} catch (error) {
|
|
1419
|
+
notify({ kind: "error", message: error.message });
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
const restore = () => {
|
|
1423
|
+
if (version === inputVersion) write(client.snapshot().values[field.key]);
|
|
1424
|
+
};
|
|
1425
|
+
perform(
|
|
1426
|
+
() => {
|
|
1427
|
+
if (!validValue(field, value)) {
|
|
1428
|
+
const error = new TypeError("Invalid setting value");
|
|
1429
|
+
notify({ kind: "error", operation: "write", key: field.key, message: error.message });
|
|
1430
|
+
throw error;
|
|
1431
|
+
}
|
|
1432
|
+
return client.set(field.key, value);
|
|
1433
|
+
},
|
|
1434
|
+
() => {
|
|
1435
|
+
for (const refresh of summaries) refresh();
|
|
1436
|
+
for (const refresh of warningRefreshers) refresh();
|
|
1437
|
+
},
|
|
1438
|
+
restore,
|
|
1439
|
+
);
|
|
1440
|
+
});
|
|
1441
|
+
if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
|
|
1442
|
+
groups.get(group).append(row);
|
|
1443
|
+
}
|
|
1444
|
+
const settingsPage = element("section", "pp-panel pp-detail pp-settings-page");
|
|
1445
|
+
const settingsOutput = element("pre", "pp-cache");
|
|
1446
|
+
settingsOutput.setAttribute("aria-label", "Settings 内容");
|
|
1447
|
+
settingsPage.append(settingsOutput);
|
|
1448
|
+
const loadSettings = () => {
|
|
1449
|
+
editors.get("$settings").load = undefined;
|
|
1450
|
+
if (saving) return;
|
|
1451
|
+
let value;
|
|
1452
|
+
return perform(
|
|
1453
|
+
async () => {
|
|
1454
|
+
try {
|
|
1455
|
+
value = await client.readSettings();
|
|
1456
|
+
} catch (error) {
|
|
1457
|
+
notify({ kind: "error", message: error.message });
|
|
1458
|
+
throw error;
|
|
1459
|
+
}
|
|
1460
|
+
},
|
|
1461
|
+
() => {
|
|
1462
|
+
settingsOutput.textContent = value === undefined ? "暂无设置" : JSON.stringify(value, null, 2);
|
|
1463
|
+
},
|
|
1464
|
+
() => {
|
|
1465
|
+
settingsOutput.textContent = "读取设置失败";
|
|
1466
|
+
},
|
|
1467
|
+
);
|
|
1468
|
+
};
|
|
1469
|
+
editors.set("$settings", { node: settingsPage, title: "设置", load: loadSettings });
|
|
1470
|
+
handlers.set("viewSettings", () => {
|
|
1471
|
+
loadSettings();
|
|
1472
|
+
page.open("$settings");
|
|
1473
|
+
});
|
|
1474
|
+
const cachePage = element("section", "pp-panel pp-detail pp-cache-page");
|
|
1475
|
+
const output = element("pre", "pp-cache");
|
|
1476
|
+
output.textContent = "正在读取缓存…";
|
|
1477
|
+
output.setAttribute("aria-label", "Caches 内容");
|
|
1478
|
+
cachePage.append(output);
|
|
1479
|
+
const loadCaches = () => {
|
|
1480
|
+
editors.get("$caches").load = undefined;
|
|
1481
|
+
if (saving) return;
|
|
1482
|
+
let value;
|
|
1483
|
+
return perform(
|
|
1484
|
+
async () => {
|
|
1485
|
+
try {
|
|
1486
|
+
value = await client.readCaches();
|
|
1487
|
+
} catch (error) {
|
|
1488
|
+
notify({ kind: "error", message: error.message });
|
|
1489
|
+
throw error;
|
|
1490
|
+
}
|
|
1491
|
+
},
|
|
1492
|
+
() => {
|
|
1493
|
+
output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
|
|
1494
|
+
},
|
|
1495
|
+
() => {
|
|
1496
|
+
output.textContent = "读取缓存失败";
|
|
1497
|
+
},
|
|
1498
|
+
);
|
|
1499
|
+
};
|
|
1500
|
+
editors.set("$caches", { node: cachePage, title: "缓存", load: loadCaches });
|
|
1501
|
+
handlers.set("viewCaches", () => {
|
|
1502
|
+
loadCaches();
|
|
1503
|
+
page.open("$caches");
|
|
1504
|
+
});
|
|
1505
|
+
handlers.set("clearCaches", async () => {
|
|
1506
|
+
if (saving) return;
|
|
1507
|
+
const shared = definition.cachePath && definition.cachePath !== `@${definition.storageKey}.${active}.Caches`;
|
|
1508
|
+
const message = shared ? `清空共享缓存 ${definition.cachePath}?使用该缓存的其它模块也会受影响。` : `清空 ${active} 的全部 Caches?`;
|
|
1509
|
+
if (!(await page.confirm(message)) || destroyed || saving) return;
|
|
1510
|
+
return perform(
|
|
1511
|
+
() => client.clearCaches(),
|
|
1512
|
+
() => {
|
|
1513
|
+
output.textContent = "暂无缓存";
|
|
1514
|
+
},
|
|
1515
|
+
);
|
|
1516
|
+
});
|
|
1517
|
+
handlers.set("reset", async () => {
|
|
1518
|
+
if (saving) return;
|
|
1519
|
+
const message = definition.resetPaths ? `重置 ${definition.metadata?.name ?? active} 的设置?只删除本页面的设置,保留共享存储中的其它设置和缓存。` : `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`;
|
|
1520
|
+
if (!(await page.confirm(message)) || destroyed || saving) return;
|
|
1521
|
+
return perform(() => client.reset(), controls);
|
|
1522
|
+
});
|
|
1523
|
+
viewport.replaceChildren(view);
|
|
1524
|
+
for (const grow of growingInputs) grow();
|
|
1525
|
+
page.refresh();
|
|
1526
|
+
}
|
|
1527
|
+
queueMicrotask(() => {
|
|
1528
|
+
if (!destroyed) open(definition.module);
|
|
1529
|
+
});
|
|
1530
|
+
return () => {
|
|
1531
|
+
destroyed = true;
|
|
1532
|
+
generation++;
|
|
1533
|
+
client.leave();
|
|
1534
|
+
shell.remove();
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
/**
|
|
1539
|
+
* 返回当前会话中的字段或操作页,不再读取配置。
|
|
1540
|
+
* Return a field or action view from the current session without fetching configuration.
|
|
1541
|
+
* @param {string} key 子页键 / Detail key.
|
|
1542
|
+
* @returns {HTMLElement} 当前视图 / Current view.
|
|
1543
|
+
*/
|
|
1544
|
+
view(key) {
|
|
1545
|
+
return this.#view(key);
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/**
|
|
1549
|
+
* 提供当前页标题及菜单状态。
|
|
1550
|
+
* Provide the current title and action state.
|
|
1551
|
+
* @param {string} key 子页键 / Detail key.
|
|
1552
|
+
* @returns {object} 页面状态 / Page state.
|
|
1553
|
+
*/
|
|
1554
|
+
state(key) {
|
|
1555
|
+
return this.#state(key);
|
|
1556
|
+
}
|
|
1557
|
+
|
|
1558
|
+
/**
|
|
1559
|
+
* 执行共用菜单中的操作。
|
|
1560
|
+
* Perform an action from the shared menu.
|
|
1561
|
+
* @param {string} id 操作标识 / Action identifier.
|
|
1562
|
+
* @returns {void} 无返回值 / No return value.
|
|
1563
|
+
*/
|
|
1564
|
+
perform(id) {
|
|
1565
|
+
this.#perform(id);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* 释放读取与视图;已开始的写入仍沿原队列完成。
|
|
1570
|
+
* Release reads and views while allowing started writes to finish through the existing queue.
|
|
1571
|
+
* @returns {void} 无返回值 / No return value.
|
|
1572
|
+
*/
|
|
1573
|
+
destroy() {
|
|
1574
|
+
this.#release();
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1578
|
+
/**
|
|
1579
|
+
* 浏览器宿主提供网页导航、通知、主题和可视区域避让。
|
|
1580
|
+
* The browser host provides web navigation, notices, theme and viewport clearance.
|
|
1581
|
+
* @param {import("./home.mjs").BridgeOptions} options 菜单和返回动作 / Menu and back action.
|
|
1582
|
+
* @returns {import("./home.mjs").Bridge} 浏览器宿主 / Browser host.
|
|
1583
|
+
*/
|
|
1584
|
+
function browserHost({ menu, back }) {
|
|
1585
|
+
const root = document.documentElement;
|
|
1586
|
+
const header = document.createElement("header");
|
|
1587
|
+
header.className = "pp-host-header";
|
|
1588
|
+
const button = document.createElement("button");
|
|
1589
|
+
button.className = "pp-host-back";
|
|
1590
|
+
button.type = "button";
|
|
1591
|
+
button.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 5-7 7 7 7"/></svg>';
|
|
1592
|
+
button.setAttribute("aria-label", "返回");
|
|
1593
|
+
button.onclick = back;
|
|
1594
|
+
const left = document.createElement("span");
|
|
1595
|
+
left.className = "pp-host-left";
|
|
1596
|
+
left.append(button);
|
|
1597
|
+
const title = document.createElement("span");
|
|
1598
|
+
title.className = "pp-host-title";
|
|
1599
|
+
const brand = document.createElement("div");
|
|
1600
|
+
brand.className = "pp-host-brand";
|
|
1601
|
+
const brandLink = document.createElement("a");
|
|
1602
|
+
brandLink.className = "pp-host-brand-link";
|
|
1603
|
+
const logo = document.createElement("span");
|
|
1604
|
+
logo.className = "pp-host-logo";
|
|
1605
|
+
const image = document.createElement("img");
|
|
1606
|
+
image.alt = "logo";
|
|
1607
|
+
logo.append(image);
|
|
1608
|
+
brandLink.append(logo, title);
|
|
1609
|
+
brand.append(brandLink);
|
|
1610
|
+
header.append(left, brand, menu.element);
|
|
1611
|
+
document.body.prepend(header);
|
|
1612
|
+
const toast = document.createElement("div");
|
|
1613
|
+
toast.className = "pp-host-toast";
|
|
1614
|
+
toast.setAttribute("role", "status");
|
|
1615
|
+
toast.hidden = true;
|
|
1616
|
+
document.body.append(toast);
|
|
1617
|
+
let timer;
|
|
1618
|
+
const theme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
1619
|
+
const applyTheme = () => {
|
|
1620
|
+
root.dataset.theme = theme.matches ? "dark" : "light";
|
|
1621
|
+
};
|
|
1622
|
+
const viewport = window.visualViewport;
|
|
1623
|
+
const resize = () => {
|
|
1624
|
+
root.style.setProperty("--pp-host-height", `${header.getBoundingClientRect().height}px`);
|
|
1625
|
+
const clearance = viewport && viewport.scale === 1 ? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop) : 0;
|
|
1626
|
+
root.style.setProperty("--pp-keyboard-height", `${clearance}px`);
|
|
1627
|
+
};
|
|
1628
|
+
const scroll = () => {
|
|
1629
|
+
const pages = document.querySelector("[data-preference-panes-pages]");
|
|
1630
|
+
const current = [...pages.children].find(view => !view.inert);
|
|
1631
|
+
header.classList.toggle("pp-scrolled", (current?.scrollTop ?? 0) >= 100);
|
|
1632
|
+
};
|
|
1633
|
+
document.addEventListener("scroll", scroll, true);
|
|
1634
|
+
theme.addEventListener("change", applyTheme);
|
|
1635
|
+
window.addEventListener("resize", resize);
|
|
1636
|
+
window.addEventListener("pageshow", resize);
|
|
1637
|
+
viewport?.addEventListener("resize", resize);
|
|
1638
|
+
viewport?.addEventListener("scroll", resize);
|
|
1639
|
+
applyTheme();
|
|
1640
|
+
resize();
|
|
1641
|
+
return {
|
|
1642
|
+
update(state) {
|
|
1643
|
+
title.textContent = state.logoText ?? state.title;
|
|
1644
|
+
brandLink.href = state.logoHref ?? "/";
|
|
1645
|
+
logo.hidden = !state.logo;
|
|
1646
|
+
if (state.logo) image.src = state.logo;
|
|
1647
|
+
left.hidden = !state.canGoBack;
|
|
1648
|
+
menu.element.hidden = !state.actions.length;
|
|
1649
|
+
button.disabled = state.busy;
|
|
1650
|
+
scroll();
|
|
1651
|
+
},
|
|
1652
|
+
confirm: message => window.confirm(message),
|
|
1653
|
+
notice(detail) {
|
|
1654
|
+
clearTimeout(timer);
|
|
1655
|
+
toast.textContent = detail.message;
|
|
1656
|
+
toast.hidden = false;
|
|
1657
|
+
timer = setTimeout(() => {
|
|
1658
|
+
toast.hidden = true;
|
|
1659
|
+
}, 2400);
|
|
1660
|
+
},
|
|
1661
|
+
openURL(url, target) {
|
|
1662
|
+
switch (target) {
|
|
1663
|
+
case "_blank":
|
|
1664
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
1665
|
+
break;
|
|
1666
|
+
default:
|
|
1667
|
+
window.location.assign(url);
|
|
1668
|
+
break;
|
|
1669
|
+
}
|
|
1670
|
+
},
|
|
1671
|
+
destroy() {
|
|
1672
|
+
clearTimeout(timer);
|
|
1673
|
+
document.removeEventListener("scroll", scroll, true);
|
|
1674
|
+
theme.removeEventListener("change", applyTheme);
|
|
1675
|
+
window.removeEventListener("resize", resize);
|
|
1676
|
+
window.removeEventListener("pageshow", resize);
|
|
1677
|
+
viewport?.removeEventListener("resize", resize);
|
|
1678
|
+
viewport?.removeEventListener("scroll", resize);
|
|
1679
|
+
root.style.removeProperty("--pp-host-height");
|
|
1680
|
+
root.style.removeProperty("--pp-keyboard-height");
|
|
1681
|
+
delete root.dataset.theme;
|
|
1682
|
+
header.remove();
|
|
1683
|
+
toast.remove();
|
|
1684
|
+
},
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
/**
|
|
1689
|
+
* 一个文档共享宿主、路由和当前模块会话,字段子页不重新读取配置。
|
|
1690
|
+
* Share one host, router and module session within a document without reloading config for field views.
|
|
1691
|
+
*/
|
|
1692
|
+
class PageController {
|
|
1693
|
+
#navigation;
|
|
1694
|
+
#host;
|
|
1695
|
+
#menu;
|
|
1696
|
+
#title;
|
|
1697
|
+
#logo;
|
|
1698
|
+
#logoText;
|
|
1699
|
+
#logoHref;
|
|
1700
|
+
#modules;
|
|
1701
|
+
#links;
|
|
1702
|
+
#session;
|
|
1703
|
+
#destroyed = false;
|
|
1704
|
+
|
|
1705
|
+
/**
|
|
1706
|
+
* 接管已初始化的宿主与首页;此接口仅供包内入口组合。
|
|
1707
|
+
* Own an initialized host and homepage; this interface is only used by internal entry points.
|
|
1708
|
+
* @param {HTMLElement} container 页面容器 / Page container.
|
|
1709
|
+
* @param {HTMLElement} home 首页节点 / Homepage node.
|
|
1710
|
+
* @param {object} options 宿主、菜单、品牌及入口 / Host, menu, brand and entries.
|
|
1711
|
+
*/
|
|
1712
|
+
constructor(container, home, { host, menu, title, logo, logoText, logoHref, modules, links = [] }) {
|
|
1713
|
+
this.#host = host;
|
|
1714
|
+
this.#menu = menu;
|
|
1715
|
+
this.#title = title;
|
|
1716
|
+
this.#logo = logo;
|
|
1717
|
+
this.#logoText = logoText || (logo ? "" : title);
|
|
1718
|
+
this.#logoHref = logoHref;
|
|
1719
|
+
this.#modules = modules;
|
|
1720
|
+
this.#links = links;
|
|
1721
|
+
this.#navigation = new Navigation(container, home, (key, signal) => this.#view(key, signal));
|
|
1722
|
+
this.#navigation.addEventListener("change", () => this.update());
|
|
1723
|
+
this.update();
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/**
|
|
1727
|
+
* 创建模块会话或复用其字段页;外部网站才使用 iframe。
|
|
1728
|
+
* Create a module session or reuse a field view; only external sites use an iframe.
|
|
1729
|
+
* @param {string} key 路由键 / Route key.
|
|
1730
|
+
* @param {AbortSignal} signal 当前外链视图的取消信号 / Cancellation signal for the current external view.
|
|
1731
|
+
* @returns {HTMLElement | undefined} 视图;未知模块返回 undefined / View; undefined for unknown modules.
|
|
1732
|
+
*/
|
|
1733
|
+
#view(key, signal) {
|
|
1734
|
+
const [module, ...parts] = key.split("/");
|
|
1735
|
+
const view = parts.join("/");
|
|
1736
|
+
if (module !== this.#session?.module) {
|
|
1737
|
+
this.#session?.controller.abort();
|
|
1738
|
+
this.#session?.panel?.destroy();
|
|
1739
|
+
this.#session = undefined;
|
|
1740
|
+
}
|
|
1741
|
+
if (module === "$link") {
|
|
1742
|
+
const link = this.#links[Number(view)];
|
|
1743
|
+
if (!link || link.dataset.target !== "internal") return;
|
|
1744
|
+
const page = element("section", "pp-link-page");
|
|
1745
|
+
const iframe = element("iframe", "");
|
|
1746
|
+
iframe.title = link.querySelector(".pp-home-name").textContent;
|
|
1747
|
+
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox");
|
|
1748
|
+
iframe.src = link.href;
|
|
1749
|
+
page.append(iframe);
|
|
1750
|
+
signal.addEventListener("abort", () => iframe.remove(), { once: true });
|
|
1751
|
+
return page;
|
|
1752
|
+
}
|
|
1753
|
+
const entry = this.#modules.get(module);
|
|
1754
|
+
if (!entry) return;
|
|
1755
|
+
if (!this.#session) {
|
|
1756
|
+
const controller = new AbortController();
|
|
1757
|
+
const root = element("section", "pp-module-page");
|
|
1758
|
+
const session = { module, root, controller };
|
|
1759
|
+
this.#session = session;
|
|
1760
|
+
const load = async () => {
|
|
1761
|
+
if (controller.signal.aborted) return;
|
|
1762
|
+
root.replaceChildren(statusView("正在打开设置…"));
|
|
1763
|
+
try {
|
|
1764
|
+
let boxjs = entry.boxjs;
|
|
1765
|
+
if (!boxjs) {
|
|
1766
|
+
const response = await fetch(`/api/${encodeURIComponent(module)}`, { cache: "no-store", signal: controller.signal });
|
|
1767
|
+
if (!response.ok) throw new Error(`配置加载失败:HTTP ${response.status}`);
|
|
1768
|
+
boxjs = await response.json();
|
|
1769
|
+
}
|
|
1770
|
+
if (controller.signal.aborted) return;
|
|
1771
|
+
root.replaceChildren();
|
|
1772
|
+
session.panel = new PreferencesPanel(root, normalizeBoxJs(boxjs), this);
|
|
1773
|
+
this.update();
|
|
1774
|
+
} catch (error) {
|
|
1775
|
+
if (controller.signal.aborted) return;
|
|
1776
|
+
root.replaceChildren(statusView(`加载失败:${error.message}`, load));
|
|
1777
|
+
}
|
|
1778
|
+
};
|
|
1779
|
+
// 已提供 BoxJS 时也等待路由切换完成,再发布表单状态。
|
|
1780
|
+
// Wait for routing to finish before publishing form state, even with supplied BoxJS.
|
|
1781
|
+
queueMicrotask(load);
|
|
1782
|
+
}
|
|
1783
|
+
return this.#session.panel?.view(view) ?? this.#session.root;
|
|
1784
|
+
}
|
|
1785
|
+
|
|
1786
|
+
/**
|
|
1787
|
+
* 同步唯一标题栏与菜单;离开模块时终止读取和会话。
|
|
1788
|
+
* Synchronize the only title bar and menu, ending reads and sessions on departure.
|
|
1789
|
+
* @returns {void} 无返回值 / No return value.
|
|
1790
|
+
*/
|
|
1791
|
+
update() {
|
|
1792
|
+
if (this.#destroyed || !this.#navigation) return;
|
|
1793
|
+
const [module, ...parts] = this.#navigation.current.split("/");
|
|
1794
|
+
if (!module && this.#session) {
|
|
1795
|
+
this.#session.controller.abort();
|
|
1796
|
+
this.#session.panel?.destroy();
|
|
1797
|
+
this.#session = undefined;
|
|
1798
|
+
}
|
|
1799
|
+
const state = this.#session?.panel?.state(parts.join("/")) ?? {
|
|
1800
|
+
title: module === "$link" ? this.#links[Number(parts[0])].querySelector(".pp-home-name").textContent : (this.#modules.get(module)?.name ?? this.#title),
|
|
1801
|
+
actions: [],
|
|
1802
|
+
busy: false,
|
|
1803
|
+
};
|
|
1804
|
+
document.title = state.title;
|
|
1805
|
+
this.#menu.update(state.actions, state.busy);
|
|
1806
|
+
Promise.resolve(this.#host.update({ ...state, logo: module ? undefined : this.#logo, logoText: module ? undefined : this.#logoText, logoHref: this.#logoHref, canGoBack: this.#navigation.canGoBack })).catch(error => this.notice({ kind: "error", message: error.message }));
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
/**
|
|
1810
|
+
* 控件重建后重新解析当前路由,不增加历史或重新加载模块配置。
|
|
1811
|
+
* Resolve the current route after rebuilding controls without adding history or reloading config.
|
|
1812
|
+
* @returns {void} 无返回值 / No return value.
|
|
1813
|
+
*/
|
|
1814
|
+
refresh() {
|
|
1815
|
+
if (!this.#destroyed) this.#navigation.refresh();
|
|
1816
|
+
}
|
|
1817
|
+
|
|
1818
|
+
/**
|
|
1819
|
+
* 打开当前模块的字段或操作子页。
|
|
1820
|
+
* Open a field or action subview of the current module.
|
|
1821
|
+
* @param {string} view 子页键 / Detail key.
|
|
1822
|
+
* @returns {void} 无返回值 / No return value.
|
|
1823
|
+
*/
|
|
1824
|
+
open(view) {
|
|
1825
|
+
this.#navigation.open(`${this.#session.module}/${view}`);
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
/**
|
|
1829
|
+
* 执行当前表单操作。
|
|
1830
|
+
* Perform an action on the current form.
|
|
1831
|
+
* @param {string} id 操作标识 / Action identifier.
|
|
1832
|
+
* @returns {void} 无返回值 / No return value.
|
|
1833
|
+
*/
|
|
1834
|
+
perform(id) {
|
|
1835
|
+
this.#session?.panel?.perform(id);
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
/**
|
|
1839
|
+
* 请求宿主确认;退出后忽略晚到的选择。
|
|
1840
|
+
* Request host confirmation and ignore a late choice after departure.
|
|
1841
|
+
* @param {string} message 确认文字 / Confirmation message.
|
|
1842
|
+
* @returns {Promise<boolean>} 用户选择 / User choice.
|
|
1843
|
+
*/
|
|
1844
|
+
async confirm(message) {
|
|
1845
|
+
const session = this.#session;
|
|
1846
|
+
const confirmed = await this.#host.confirm(message);
|
|
1847
|
+
return !this.#destroyed && session === this.#session && confirmed;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
/**
|
|
1851
|
+
* 由唯一宿主显示通知。
|
|
1852
|
+
* Display a notice through the only host.
|
|
1853
|
+
* @param {import("./Navigation.mjs").Notice} detail 已格式化通知 / Formatted notice.
|
|
1854
|
+
* @returns {void} 无返回值 / No return value.
|
|
1855
|
+
*/
|
|
1856
|
+
notice(detail) {
|
|
1857
|
+
if (!this.#destroyed) this.#host.notice(detail);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
/**
|
|
1861
|
+
* 由宿主执行标准外链导航。
|
|
1862
|
+
* Delegate standard external navigation to the host.
|
|
1863
|
+
* @param {string} url 完整地址 / Absolute URL.
|
|
1864
|
+
* @param {"_self" | "_blank"} [target] 打开方式 / Opening mode.
|
|
1865
|
+
* @returns {void} 无返回值 / No return value.
|
|
1866
|
+
*/
|
|
1867
|
+
openURL(url, target) {
|
|
1868
|
+
try {
|
|
1869
|
+
Promise.resolve(this.#host.openURL(url, target)).catch(error => this.notice({ kind: "error", message: error.message }));
|
|
1870
|
+
} catch (error) {
|
|
1871
|
+
this.notice({ kind: "error", message: error.message });
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
/**
|
|
1876
|
+
* 共用首页与表单导航。
|
|
1877
|
+
* Navigation shared by the homepage and forms.
|
|
1878
|
+
*/
|
|
1879
|
+
get navigation() {
|
|
1880
|
+
return this.#navigation;
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
/**
|
|
1884
|
+
* 结束页面能力和会话;表单写入队列自行收尾。
|
|
1885
|
+
* End page capabilities and sessions while forms finish their write queues.
|
|
1886
|
+
* @returns {void} 无返回值 / No return value.
|
|
1887
|
+
*/
|
|
1888
|
+
destroy() {
|
|
1889
|
+
if (this.#destroyed) return;
|
|
1890
|
+
this.#destroyed = true;
|
|
1891
|
+
this.#session?.controller.abort();
|
|
1892
|
+
this.#session?.panel?.destroy();
|
|
1893
|
+
this.#navigation.destroy();
|
|
1894
|
+
this.#host.destroy();
|
|
1895
|
+
this.#menu.destroy();
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
var defaults = "/* 通用默认样式只使用 pp 命名空间;项目可通过 CSS 输入覆盖变量和组件。\n * Generic defaults use only the pp namespace; projects may override variables and components through CSS input. */\n:root {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-field: #f1f2f3;\n --pp-border: #e3e5e7;\n --pp-muted: #797f87;\n --pp-accent: #1677ff;\n --pp-warning: #9a6700;\n --pp-host-height: 0px;\n --pp-keyboard-height: 0px;\n}\n.pp-panel {\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 display: flex;\n flex-direction: column;\n width: 100%;\n max-width: 100vw;\n min-width: 0;\n}\n\n:root[data-theme=\"dark\"] {\n --pp-text: #f1f2f3;\n --pp-background: #0d0e0f;\n --pp-surface: #18191c;\n --pp-field: #2f3238;\n --pp-border: #2f3238;\n --pp-muted: #9499a0;\n --pp-warning: #f0b849;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n min-width: 0;\n}\n.pp-fields,\n.pp-choice-page,\n.pp-settings-page,\n.pp-cache-page {\n box-sizing: border-box;\n min-width: 0;\n padding: calc(12px + var(--pp-host-height)) max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + var(--pp-keyboard-height));\n scroll-padding-bottom: var(--pp-keyboard-height);\n background: var(--pp-background);\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-panel a.pp-choice-link {\n color: var(--pp-accent);\n text-decoration: none;\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-editor {\n flex: none;\n width: 45%;\n min-width: 0;\n min-height: 36px;\n padding: 8px 10px;\n font: inherit;\n font-size: 16px;\n color: var(--pp-text);\n background: var(--pp-field);\n border: 0;\n border-radius: 6px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-editor {\n width: 100%;\n margin-top: 10px;\n}\n.pp-panel [hidden] {\n display: none !important;\n}\n.pp-label {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n margin-right: 16px;\n}\n.pp-field-name {\n color: var(--pp-text);\n font-size: 15px;\n}\n.pp-field-description {\n margin-top: 2px;\n color: var(--pp-muted);\n font-size: 12px;\n}\n.pp-field-warning {\n margin-top: 4px;\n color: var(--pp-warning);\n font-size: 12px;\n overflow-wrap: anywhere;\n}\n.pp-group {\n margin-top: 16px;\n}\n.pp-group-title {\n margin: 0 0 8px;\n color: var(--pp-muted);\n font-size: 15px;\n font-weight: 400;\n}\n.pp-row {\n min-width: 0;\n min-height: 48px;\n padding: 16px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n}\n.pp-rows > :last-child {\n border-bottom: 0 !important;\n}\n.pp-switch {\n flex: none;\n accent-color: var(--pp-accent);\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-status {\n position: absolute;\n inset: 0;\n display: grid;\n place-content: center;\n justify-items: center;\n gap: 12px;\n min-width: 0;\n min-height: 0;\n margin: 0;\n padding: 24px;\n color: var(--pp-muted, GrayText);\n text-align: center;\n background: var(--pp-background, Canvas);\n}\n.pp-viewport > .pp-status {\n position: absolute;\n}\n.pp-status-spinner {\n box-sizing: border-box;\n width: 28px;\n height: 28px;\n border: 3px solid var(--pp-border, #8884);\n border-top-color: var(--pp-accent, AccentColor);\n border-radius: 50%;\n animation: pp-status-spin 0.8s linear infinite;\n}\n.pp-status-message {\n max-width: 100%;\n margin: 0;\n overflow-wrap: anywhere;\n}\n.pp-status-action {\n min-width: 96px;\n min-height: 44px;\n padding: 8px 16px;\n border: 0;\n border-radius: 6px;\n color: var(--pp-text, ButtonText);\n font: inherit;\n cursor: pointer;\n background: var(--pp-surface, ButtonFace);\n}\n@keyframes pp-status-spin {\n to {\n transform: rotate(1turn);\n }\n}\n.pp-cache {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n\n/* 首页沿用表单的分组与说明样式,入口按屏幕宽度自动换行。\n * The landing page reuses form groups and descriptions, with entries wrapping by viewport width. */\n:root.pp-page {\n color-scheme: light dark;\n}\n.pp-page body {\n margin: 0;\n overflow: hidden;\n font:\n 16px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n background: var(--pp-background, Canvas);\n color: var(--pp-text, CanvasText);\n}\n[data-preference-panes-pages] {\n position: relative;\n height: 100vh;\n background: inherit;\n overflow: hidden;\n}\n@supports (height: 100dvh) {\n [data-preference-panes-pages] {\n height: 100dvh;\n }\n}\n.pp-home,\n.pp-module-page,\n.pp-detail,\n.pp-link-page {\n position: absolute;\n inset: 0;\n overflow-x: hidden;\n overflow-y: auto;\n overscroll-behavior: contain;\n background: var(--pp-background);\n}\n.pp-module-page,\n.pp-detail,\n.pp-link-page {\n z-index: 1;\n box-shadow: -8px 0 24px #00000014;\n}\n.pp-link-page {\n top: var(--pp-host-height);\n}\n.pp-link-page iframe {\n display: block;\n border: 0;\n width: 100%;\n height: 100%;\n background: inherit;\n}\n.pp-host-header {\n box-sizing: border-box;\n position: fixed;\n inset: 0 0 auto;\n z-index: 2;\n display: flex;\n align-items: center;\n height: calc(64px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) max(20px, env(safe-area-inset-right)) 0 max(20px, env(safe-area-inset-left));\n border-bottom: 1px solid transparent;\n background: transparent;\n -webkit-backdrop-filter: blur(25px);\n backdrop-filter: blur(25px);\n color: var(--pp-text);\n transition:\n background-color 200ms ease-in-out,\n border-color 200ms ease-in-out;\n}\n.pp-host-header.pp-scrolled {\n background: rgba(255, 255, 255, 0.6);\n border-color: var(--pp-border);\n}\n:root[data-theme=\"dark\"] .pp-host-header.pp-scrolled {\n background: rgba(13, 14, 15, 0.6);\n}\n@media (min-width: 1280px) {\n .pp-host-header {\n padding-right: max(20px, calc((100vw - 1588px) / 2));\n padding-left: max(20px, calc((100vw - 1588px) / 2));\n }\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-host-header {\n transition: none;\n }\n}\n.pp-host-brand {\n display: flex;\n align-items: center;\n flex: 1;\n height: 36px;\n min-width: 0;\n margin-right: 8px;\n}\n.pp-host-brand-link {\n display: flex;\n align-items: center;\n height: 100%;\n min-width: 0;\n max-width: 100%;\n color: inherit;\n text-decoration: none;\n transition: opacity 0.3s;\n}\n.pp-host-brand-link:hover {\n opacity: 0.6;\n}\n.pp-host-logo {\n flex: none;\n min-width: 2rem;\n margin-right: 0.25rem;\n}\n.pp-host-logo img {\n display: block;\n height: 1.6rem;\n}\n.pp-host-title {\n min-width: 0;\n text-align: left;\n font-size: 16px;\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.pp-host-left {\n display: flex;\n align-items: center;\n justify-content: center;\n flex: none;\n}\n.pp-host-left::after {\n content: \"\";\n display: inline-block;\n width: 1px;\n height: 24px;\n margin: 0 16px;\n background-color: #0000001f;\n}\n:root[data-theme=\"dark\"] .pp-host-left::after {\n background-color: #5454547a;\n}\n.pp-host-left[hidden] {\n display: none;\n}\n.pp-host-back {\n border: 0;\n border-radius: 18px;\n background: none;\n color: inherit;\n width: 36px;\n height: 36px;\n padding: 8px;\n cursor: pointer;\n transition: background-color 0.2s ease-in-out;\n}\n.pp-host-back svg {\n display: block;\n width: 20px;\n height: 20px;\n fill: none;\n stroke: currentColor;\n stroke-width: 2;\n}\n.pp-host-back:hover {\n background: #f1f1f1;\n}\n:root[data-theme=\"dark\"] .pp-host-back:hover {\n background: #343a46;\n}\n.pp-host-toast {\n position: fixed;\n z-index: 3;\n bottom: calc(24px + env(safe-area-inset-bottom) + var(--pp-keyboard-height));\n left: 50%;\n transform: translateX(-50%);\n max-width: calc(100% - 48px);\n padding: 10px 18px;\n border-radius: 12px;\n background: #222e;\n color: #fff;\n font-size: 14px;\n text-align: center;\n}\n.pp-host-toast[hidden] {\n display: none;\n}\n.pp-host-error {\n margin: 24px;\n padding: 16px;\n border: 1px solid var(--pp-danger, #e45656);\n border-radius: 12px;\n font-size: 16px;\n overflow-wrap: anywhere;\n}\n\n.pp-home {\n max-width: 1000px;\n margin: auto;\n padding: var(--pp-host-height) max(16px, env(safe-area-inset-right)) calc(24px + env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));\n}\n.pp-home-entries {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-around;\n row-gap: 16px;\n padding: 16px 0;\n background: var(--pp-surface);\n}\n.pp-home-item {\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n align-items: center;\n width: 25%;\n min-width: 0;\n padding: 0 4px;\n border: 0;\n background: none;\n color: var(--pp-text);\n font:\n 12px / 1.5 -apple-system,\n sans-serif;\n cursor: pointer;\n text-decoration: none;\n}\n@media (min-width: 500px) {\n .pp-home-item {\n width: calc(100% / 6);\n }\n}\n@media (min-width: 700px) {\n .pp-home-item {\n width: 12.5%;\n }\n}\n.pp-home-icon {\n box-sizing: border-box;\n width: 56px;\n height: 56px;\n padding: 8px;\n border-radius: 50%;\n background: var(--pp-field);\n font-size: 32px;\n line-height: 40px;\n text-align: center;\n}\n.pp-home-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-home-name {\n margin-top: 8px;\n}\n.pp-home-content {\n min-width: 0;\n width: 100%;\n}\n.pp-home-content > span {\n display: block;\n}\n.pp-home-name,\n.pp-home-item [data-module-status] {\n width: 100%;\n overflow: hidden;\n text-align: center;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.pp-home-item [data-module-status] {\n min-height: 20px;\n}\n.pp-home-item:disabled {\n opacity: 0.45;\n cursor: default;\n}\n.pp-home-entries[data-layout=\"list\"] {\n display: block;\n padding: 0;\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-item {\n flex-direction: row;\n gap: 12px;\n width: 100%;\n padding: 12px 16px;\n font-size: 15px;\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-item + .pp-home-item {\n border-top: 1px solid var(--pp-border);\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-icon {\n flex: none;\n width: 40px;\n height: 40px;\n padding: 4px;\n font-size: 24px;\n line-height: 32px;\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-name {\n margin-top: 0;\n text-align: left;\n}\n.pp-home-arrow {\n color: var(--pp-muted);\n font-size: 24px;\n}\n.pp-home-footer {\n margin-top: 16px;\n text-align: center;\n white-space: normal;\n}\n.pp-home-footer p {\n margin: 8px 0 0;\n}\n";
|
|
1900
|
+
|
|
1901
|
+
const selector = "style[data-preference-panes-defaults]";
|
|
1902
|
+
const stylesheetSelector = "link[data-preference-panes-stylesheet]";
|
|
1903
|
+
|
|
1904
|
+
/**
|
|
1905
|
+
* 在文档中安装一次默认样式,并标记当前调用方是否拥有该节点。
|
|
1906
|
+
* Install default styles once and report whether the current caller owns the node.
|
|
1907
|
+
* @param {Document} document 目标文档 / Target document.
|
|
1908
|
+
* @returns {{element: HTMLStyleElement, owned: boolean}} 样式节点及所有权 / Style node and ownership.
|
|
1909
|
+
*/
|
|
1910
|
+
function installDefaultStyles(document) {
|
|
1911
|
+
const existing = document.head.querySelector(selector);
|
|
1912
|
+
if (existing) return { element: existing, owned: false };
|
|
1913
|
+
const element = document.createElement("style");
|
|
1914
|
+
element.dataset.preferencePanesDefaults = "";
|
|
1915
|
+
element.textContent = defaults;
|
|
1916
|
+
document.head.insertBefore(element, document.head.querySelector(stylesheetSelector));
|
|
1917
|
+
return { element, owned: true };
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
/**
|
|
1921
|
+
* 校验外部 JSON 中的章节、入口和导航目标,避免生成不可访问的设置入口。
|
|
1922
|
+
* Validate sections, entries and navigation targets in external JSON before rendering inaccessible entries.
|
|
1923
|
+
* @param {unknown} config 外部首页配置 / External homepage configuration.
|
|
1924
|
+
* @returns {void} 非法输入抛出错误 / Invalid input throws.
|
|
1925
|
+
*/
|
|
1926
|
+
function validateHome(config) {
|
|
1927
|
+
if (!config || typeof config.title !== "string" || !Array.isArray(config.sections)) throw new Error("首页 JSON 缺少 title 或 sections");
|
|
1928
|
+
if (config.logo !== undefined && typeof config.logo !== "string") throw new Error("首页 logo 必须是图片地址");
|
|
1929
|
+
if (config.logoText !== undefined && typeof config.logoText !== "string") throw new Error("首页 logoText 必须是文字");
|
|
1930
|
+
if (config.logoHref !== undefined) {
|
|
1931
|
+
if (typeof config.logoHref !== "string") throw new Error("首页 logoHref 必须是链接地址");
|
|
1932
|
+
if (["javascript:", "data:", "vbscript:"].includes(new URL(config.logoHref, "https://preference-panes.invalid/").protocol)) throw new Error("首页 logoHref 的链接无效");
|
|
1933
|
+
}
|
|
1934
|
+
if (config.footer !== undefined && (!Array.isArray(config.footer) || config.footer.some(text => typeof text !== "string"))) throw new Error("首页 footer 必须是文字数组");
|
|
1935
|
+
const modules = new Set();
|
|
1936
|
+
for (const section of config.sections) {
|
|
1937
|
+
if (!section || typeof section.title !== "string" || !Array.isArray(section.items) || (section.layout !== undefined && !["grid", "list"].includes(section.layout))) throw new Error("首页章节需要 title、items 和有效的 layout");
|
|
1938
|
+
for (const item of section.items) {
|
|
1939
|
+
if (!item || typeof item.name !== "string" || !item.icon || (typeof item.icon.src === "string") === (typeof item.icon.text === "string") || (item.icon.src !== undefined && typeof item.icon.src !== "string") || (item.icon.text !== undefined && typeof item.icon.text !== "string"))
|
|
1940
|
+
throw new Error("首页入口需要 name 和唯一的图片或文字 icon");
|
|
1941
|
+
if (item.description !== undefined && typeof item.description !== "string") throw new Error(`入口 ${item.name} 的 description 必须是文字`);
|
|
1942
|
+
if (item.target !== undefined && (item.module !== undefined || !["internal", "_self", "_blank"].includes(item.target))) throw new Error(`入口 ${item.name} 的 target 只允许链接使用 internal、_self 或 _blank`);
|
|
1943
|
+
if ((typeof item.module === "string") === (typeof item.href === "string") || (item.module !== undefined && typeof item.module !== "string") || (item.href !== undefined && typeof item.href !== "string")) throw new Error(`入口 ${item.name} 必须选择 module 或 href`);
|
|
1944
|
+
if (item.module !== undefined) {
|
|
1945
|
+
if (!/^[A-Za-z0-9_-]+$/.test(item.module) || modules.has(item.module)) throw new Error(`入口 ${item.name} 的模块名称无效或重复`);
|
|
1946
|
+
modules.add(item.module);
|
|
1947
|
+
} else {
|
|
1948
|
+
const protocol = new URL(item.href, "https://preference-panes.invalid/").protocol;
|
|
1949
|
+
if (["javascript:", "data:", "vbscript:"].includes(protocol) || (item.target === "internal" && !["http:", "https:"].includes(protocol))) throw new Error(`入口 ${item.name} 的链接无效`);
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* 共用网格和列表章节;资源地址相对于 JSON,所有项目文字通过 textContent 绘制。
|
|
1957
|
+
* Share grid and list sections, resolve resources against JSON and render project text through textContent.
|
|
1958
|
+
* @param {import("./home.mjs").HomeConfig} config 已校验的首页配置 / Validated homepage configuration.
|
|
1959
|
+
* @param {URL} url JSON 地址 / JSON URL.
|
|
1960
|
+
* @param {HTMLElement} home 首页容器 / Homepage container.
|
|
1961
|
+
* @returns {void} 无返回值 / No return value.
|
|
1962
|
+
*/
|
|
1963
|
+
function renderHome(config, url, home) {
|
|
1964
|
+
const document = home.ownerDocument;
|
|
1965
|
+
home.replaceChildren();
|
|
1966
|
+
for (const section of config.sections) {
|
|
1967
|
+
const group = document.createElement("section");
|
|
1968
|
+
group.className = "pp-group";
|
|
1969
|
+
const title = document.createElement("h2");
|
|
1970
|
+
title.className = "pp-group-title";
|
|
1971
|
+
title.textContent = section.title;
|
|
1972
|
+
const entries = document.createElement("div");
|
|
1973
|
+
entries.className = "pp-home-entries";
|
|
1974
|
+
entries.dataset.layout = section.layout ?? "grid";
|
|
1975
|
+
for (const item of section.items) {
|
|
1976
|
+
const entry = document.createElement(item.module ? "button" : "a");
|
|
1977
|
+
entry.className = "pp-home-item";
|
|
1978
|
+
if (item.module) {
|
|
1979
|
+
entry.type = "button";
|
|
1980
|
+
entry.disabled = true;
|
|
1981
|
+
entry.dataset.module = item.module;
|
|
1982
|
+
} else {
|
|
1983
|
+
entry.href = new URL(item.href, url).href;
|
|
1984
|
+
if (item.target !== undefined) {
|
|
1985
|
+
entry.dataset.target = item.target;
|
|
1986
|
+
switch (item.target) {
|
|
1987
|
+
case "_blank":
|
|
1988
|
+
entry.target = item.target;
|
|
1989
|
+
entry.rel = "noopener noreferrer";
|
|
1990
|
+
break;
|
|
1991
|
+
case "_self":
|
|
1992
|
+
entry.target = item.target;
|
|
1993
|
+
break;
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
const icon = document.createElement("span");
|
|
1998
|
+
icon.className = "pp-home-icon";
|
|
1999
|
+
icon.setAttribute("aria-hidden", "true");
|
|
2000
|
+
if (item.icon.src !== undefined) {
|
|
2001
|
+
const image = document.createElement("img");
|
|
2002
|
+
image.src = new URL(item.icon.src, url).href;
|
|
2003
|
+
image.alt = "";
|
|
2004
|
+
icon.append(image);
|
|
2005
|
+
} else icon.textContent = item.icon.text;
|
|
2006
|
+
const content = document.createElement("span");
|
|
2007
|
+
content.className = "pp-home-content";
|
|
2008
|
+
const name = document.createElement("span");
|
|
2009
|
+
name.className = "pp-home-name";
|
|
2010
|
+
name.textContent = item.name;
|
|
2011
|
+
content.append(name);
|
|
2012
|
+
if (item.description !== undefined) {
|
|
2013
|
+
const description = document.createElement("span");
|
|
2014
|
+
description.className = "pp-description";
|
|
2015
|
+
description.textContent = item.description;
|
|
2016
|
+
content.append(description);
|
|
2017
|
+
}
|
|
2018
|
+
if (item.module) {
|
|
2019
|
+
const status = document.createElement("span");
|
|
2020
|
+
status.className = "pp-description";
|
|
2021
|
+
status.dataset.moduleStatus = "";
|
|
2022
|
+
status.textContent = "检测中";
|
|
2023
|
+
content.append(status);
|
|
2024
|
+
}
|
|
2025
|
+
entry.append(icon, content);
|
|
2026
|
+
if (entries.dataset.layout === "list") {
|
|
2027
|
+
const arrow = document.createElement("span");
|
|
2028
|
+
arrow.className = "pp-home-arrow";
|
|
2029
|
+
arrow.textContent = "›";
|
|
2030
|
+
arrow.setAttribute("aria-hidden", "true");
|
|
2031
|
+
entry.append(arrow);
|
|
2032
|
+
}
|
|
2033
|
+
entries.append(entry);
|
|
2034
|
+
}
|
|
2035
|
+
group.append(title, entries);
|
|
2036
|
+
home.append(group);
|
|
2037
|
+
}
|
|
2038
|
+
const footer = document.createElement("footer");
|
|
2039
|
+
footer.className = "pp-home-footer pp-description";
|
|
2040
|
+
for (const text of config.footer ?? []) {
|
|
2041
|
+
const paragraph = document.createElement("p");
|
|
2042
|
+
paragraph.textContent = text;
|
|
2043
|
+
footer.append(paragraph);
|
|
2044
|
+
}
|
|
2045
|
+
home.append(footer);
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
/**
|
|
2049
|
+
* 读取固定相对路径的首页 JSON 与可选 CSS、Bridge,初始化后共用模块探测与导航。
|
|
2050
|
+
* Load homepage JSON and optional CSS and Bridge at fixed relative paths, then share module probes and navigation.
|
|
2051
|
+
* @returns {Promise<() => void>} 退出清理动作;加载失败会显示错误并拒绝 / Cleanup action; load failures display an error and reject.
|
|
2052
|
+
*/
|
|
2053
|
+
async function startHome() {
|
|
2054
|
+
let host;
|
|
2055
|
+
let menu;
|
|
2056
|
+
let navigation;
|
|
2057
|
+
let page;
|
|
2058
|
+
let statuses = [];
|
|
2059
|
+
let destroyed = false;
|
|
2060
|
+
let defaults;
|
|
2061
|
+
let stylesheet;
|
|
2062
|
+
const controller = new AbortController();
|
|
2063
|
+
const destroy = () => {
|
|
2064
|
+
if (destroyed) return;
|
|
2065
|
+
destroyed = true;
|
|
2066
|
+
controller.abort();
|
|
2067
|
+
for (const { status } of statuses) status.destroy();
|
|
2068
|
+
if (page) page.destroy();
|
|
2069
|
+
else {
|
|
2070
|
+
host?.destroy();
|
|
2071
|
+
menu?.destroy();
|
|
2072
|
+
}
|
|
2073
|
+
stylesheet?.remove();
|
|
2074
|
+
if (defaults?.owned) defaults.element.remove();
|
|
2075
|
+
window.removeEventListener("pagehide", pagehide);
|
|
2076
|
+
};
|
|
2077
|
+
const pagehide = event => {
|
|
2078
|
+
if (!event.persisted) destroy();
|
|
2079
|
+
};
|
|
2080
|
+
window.addEventListener("pagehide", pagehide);
|
|
2081
|
+
try {
|
|
2082
|
+
defaults = installDefaultStyles(document);
|
|
2083
|
+
const jsonURL = new URL("./home.json", document.baseURI);
|
|
2084
|
+
const response = await fetch(jsonURL, { signal: controller.signal });
|
|
2085
|
+
if (!response.ok) throw new Error(`首页 JSON 加载失败:HTTP ${response.status}`);
|
|
2086
|
+
const config = await response.json();
|
|
2087
|
+
validateHome(config);
|
|
2088
|
+
if (destroyed) return destroy;
|
|
2089
|
+
const cssURL = new URL("./theme.css", document.baseURI);
|
|
2090
|
+
const cssResponse = await fetch(cssURL, { method: "HEAD", cache: "no-store", signal: controller.signal });
|
|
2091
|
+
if (destroyed) return destroy;
|
|
2092
|
+
switch (cssResponse.status) {
|
|
2093
|
+
case 200:
|
|
2094
|
+
stylesheet = document.createElement("link");
|
|
2095
|
+
stylesheet.rel = "stylesheet";
|
|
2096
|
+
stylesheet.dataset.preferencePanesStylesheet = "";
|
|
2097
|
+
stylesheet.href = cssURL.href;
|
|
2098
|
+
await new Promise((resolve, reject) => {
|
|
2099
|
+
const abort = () => reject(new DOMException("页面已退出", "AbortError"));
|
|
2100
|
+
controller.signal.addEventListener("abort", abort, { once: true });
|
|
2101
|
+
stylesheet.onload = () => {
|
|
2102
|
+
controller.signal.removeEventListener("abort", abort);
|
|
2103
|
+
resolve();
|
|
2104
|
+
};
|
|
2105
|
+
stylesheet.onerror = () => {
|
|
2106
|
+
controller.signal.removeEventListener("abort", abort);
|
|
2107
|
+
reject(new Error("项目 CSS 加载失败"));
|
|
2108
|
+
};
|
|
2109
|
+
document.head.append(stylesheet);
|
|
2110
|
+
});
|
|
2111
|
+
break;
|
|
2112
|
+
case 404:
|
|
2113
|
+
break;
|
|
2114
|
+
default:
|
|
2115
|
+
throw new Error(`项目 CSS 探测失败:HTTP ${cssResponse.status}`);
|
|
2116
|
+
}
|
|
2117
|
+
if (destroyed) return destroy;
|
|
2118
|
+
const bridgeURL = new URL("./bridge.mjs", document.baseURI);
|
|
2119
|
+
const bridgeResponse = await fetch(bridgeURL, { method: "HEAD", cache: "no-store", signal: controller.signal });
|
|
2120
|
+
if (destroyed) return destroy;
|
|
2121
|
+
let factory;
|
|
2122
|
+
switch (bridgeResponse.status) {
|
|
2123
|
+
case 200:
|
|
2124
|
+
factory = (await import(bridgeURL.href)).default;
|
|
2125
|
+
break;
|
|
2126
|
+
case 404:
|
|
2127
|
+
factory = browserHost;
|
|
2128
|
+
break;
|
|
2129
|
+
default:
|
|
2130
|
+
throw new Error(`Bridge 探测失败:HTTP ${bridgeResponse.status}`);
|
|
2131
|
+
}
|
|
2132
|
+
if (destroyed) return destroy;
|
|
2133
|
+
document.title = config.title;
|
|
2134
|
+
menu = new ActionMenu(id => page.perform(id));
|
|
2135
|
+
host = await factory({ menu, back: () => navigation.back() });
|
|
2136
|
+
if (destroyed) {
|
|
2137
|
+
host.destroy();
|
|
2138
|
+
menu.destroy();
|
|
2139
|
+
return destroy;
|
|
2140
|
+
}
|
|
2141
|
+
const container = document.querySelector("[data-preference-panes-pages]");
|
|
2142
|
+
const home = document.querySelector("[data-preference-panes-home]");
|
|
2143
|
+
renderHome(config, jsonURL, home);
|
|
2144
|
+
const buttons = [...home.querySelectorAll("[data-module]")];
|
|
2145
|
+
const links = [...home.querySelectorAll("a[href]")];
|
|
2146
|
+
page = new PageController(container, home, {
|
|
2147
|
+
host,
|
|
2148
|
+
menu,
|
|
2149
|
+
title: config.title,
|
|
2150
|
+
logo: config.logo ? new URL(config.logo, jsonURL).href : undefined,
|
|
2151
|
+
logoText: config.logoText,
|
|
2152
|
+
logoHref: new URL(config.logoHref || "/", jsonURL).href,
|
|
2153
|
+
modules: new Map(config.sections.flatMap(section => section.items.filter(item => item.module).map(item => [item.module, item]))),
|
|
2154
|
+
links,
|
|
2155
|
+
});
|
|
2156
|
+
navigation = page.navigation;
|
|
2157
|
+
for (const [index, link] of links.entries()) {
|
|
2158
|
+
link.addEventListener(
|
|
2159
|
+
"click",
|
|
2160
|
+
event => {
|
|
2161
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
2162
|
+
event.preventDefault();
|
|
2163
|
+
switch (link.dataset.target) {
|
|
2164
|
+
case "internal":
|
|
2165
|
+
navigation.open(`$link/${index}`);
|
|
2166
|
+
break;
|
|
2167
|
+
default:
|
|
2168
|
+
page.openURL(link.href, link.dataset.target);
|
|
2169
|
+
break;
|
|
2170
|
+
}
|
|
2171
|
+
},
|
|
2172
|
+
{ signal: controller.signal },
|
|
2173
|
+
);
|
|
2174
|
+
}
|
|
2175
|
+
statuses = buttons.map(button => {
|
|
2176
|
+
const status = new ModuleStatus(button.querySelector("[data-module-status]"));
|
|
2177
|
+
status.addEventListener("change", () => {
|
|
2178
|
+
button.disabled = status.state.status !== "installed";
|
|
2179
|
+
});
|
|
2180
|
+
button.addEventListener("click", () => navigation.open(button.dataset.module), { signal: controller.signal });
|
|
2181
|
+
return { button, status };
|
|
2182
|
+
});
|
|
2183
|
+
const probe = () => {
|
|
2184
|
+
if (navigation.current) return;
|
|
2185
|
+
for (const { button, status } of statuses) status.check(`/api/${encodeURIComponent(button.dataset.module)}`);
|
|
2186
|
+
};
|
|
2187
|
+
navigation.addEventListener("change", probe);
|
|
2188
|
+
probe();
|
|
2189
|
+
return destroy;
|
|
2190
|
+
} catch (error) {
|
|
2191
|
+
const display = !destroyed;
|
|
2192
|
+
destroy();
|
|
2193
|
+
if (display) {
|
|
2194
|
+
const message = document.createElement("p");
|
|
2195
|
+
message.className = "pp-host-error";
|
|
2196
|
+
message.setAttribute("role", "alert");
|
|
2197
|
+
message.textContent = `无法打开设置:${error.message}`;
|
|
2198
|
+
document.body.prepend(message);
|
|
2199
|
+
}
|
|
2200
|
+
throw error;
|
|
2201
|
+
}
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
export { startHome };
|
|
2205
|
+
|
|
2206
|
+
await startHome();</script>
|
|
2207
|
+
</body>
|
|
2208
|
+
</html>
|