@nsnanocat/preference-panes 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -47
- package/dist/api.js +114 -124
- package/dist/module/index.html +1 -1
- package/dist/module/index.mjs +188 -218
- package/dist/module/navigation.mjs +15 -32
- package/dist/preference-panes.mjs +83 -72
- package/dist/web.js +2 -23
- package/package.json +2 -2
- package/src/api.mjs +117 -124
- package/src/browser/ModuleFrame.mjs +13 -13
- package/src/browser/ModuleStatus.mjs +2 -3
- package/src/browser/Navigation.d.mts +2 -4
- package/src/browser/client.d.mts +5 -5
- package/src/browser/client.mjs +59 -30
- package/src/browser/index.d.ts +5 -21
- package/src/browser/index.mjs +16 -48
- package/src/browser/mount.mjs +16 -32
- package/src/browser/panel.mjs +9 -9
- package/src/index.d.ts +2 -24
- package/src/index.mjs +3 -3
- package/src/web.mjs +1 -5
- package/src/build.mjs +0 -20
- package/src/lib/page-inputs.mjs +0 -17
package/dist/module/index.mjs
CHANGED
|
@@ -1,112 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
|
|
3
|
-
* Resolve module resource locations: headers override query parameters and module conventions.
|
|
4
|
-
* @param {URL} url 已解析的页面请求地址 / Parsed page request URL.
|
|
5
|
-
* @param {Record<string, string | undefined>} [headers] 请求头,名称不区分大小写 / Case-insensitive request headers.
|
|
6
|
-
* @returns {{url: string, module: string, json: string, css: string}} 页面上下文与两个资源输入 / Page context and two resource inputs.
|
|
7
|
-
*/
|
|
8
|
-
function pageInputs(url, headers = {}) {
|
|
9
|
-
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname);
|
|
10
|
-
if (!match) throw new TypeError("Open a concrete module URL");
|
|
11
|
-
const module = match[1];
|
|
12
|
-
const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
13
|
-
const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
|
|
14
|
-
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
|
|
15
|
-
if (!json.trim()) throw new TypeError("JSON resource URL is required");
|
|
16
|
-
return { url: url.href, module, json, css };
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* 创建元素,所有展示文本通过 textContent 写入。
|
|
21
|
-
* Create elements and assign display text through textContent only.
|
|
22
|
-
* @template {keyof HTMLElementTagNameMap} T
|
|
23
|
-
* @param {T} tag 元素标签 / Element tag.
|
|
24
|
-
* @param {string} className 样式类名 / CSS class.
|
|
25
|
-
* @param {string} [text] 纯文本 / Plain text.
|
|
26
|
-
* @returns {HTMLElementTagNameMap[T]} 创建的元素 / Created element.
|
|
27
|
-
*/
|
|
28
|
-
function element(tag, className, text) {
|
|
29
|
-
const node = document.createElement(tag);
|
|
30
|
-
node.className = className;
|
|
31
|
-
if (text !== undefined) node.textContent = text;
|
|
32
|
-
return node;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* 创建通用设置行;外部 CSS 可通过 pp 类名覆盖视觉样式。
|
|
37
|
-
* Create a generic settings row whose appearance can be overridden through pp classes.
|
|
38
|
-
* @template {"div" | "label"} T
|
|
39
|
-
* @param {T} tag 行元素 / Row element.
|
|
40
|
-
* @returns {HTMLElementTagNameMap[T]} 设置行 / Settings row.
|
|
41
|
-
*/
|
|
42
|
-
function settingRow(tag) {
|
|
43
|
-
return element(tag, "pp-row");
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* 为标准 HTML 输入控件添加通用面板类名。
|
|
48
|
-
* Add the generic panel class to a standard HTML input control.
|
|
49
|
-
* @param {HTMLElement} control 已创建的原生控件 / Existing native control.
|
|
50
|
-
* @returns {HTMLElement} 输入控件 / Input control.
|
|
51
|
-
*/
|
|
52
|
-
function fieldControl(control) {
|
|
53
|
-
control.classList.add("pp-editor");
|
|
54
|
-
return control;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* 元数据地址只允许 HTTP(S) 和相对地址。
|
|
59
|
-
* Allow only HTTP(S) and relative metadata addresses.
|
|
60
|
-
* @param {string} value 元数据地址 / Metadata address.
|
|
61
|
-
* @returns {string} 完整地址 / Absolute address.
|
|
62
|
-
*/
|
|
63
|
-
function resourceURL(value) {
|
|
64
|
-
const url = new URL(value, document.baseURI);
|
|
65
|
-
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Metadata URLs must use HTTP(S)");
|
|
66
|
-
return url.href;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* 创建覆盖可用内容区的通用读取状态,失败时可附加重试动作。
|
|
71
|
-
* Create a shared status view that fills the available content area and may include retry.
|
|
72
|
-
* @param {string} message 状态文本 / Status message.
|
|
73
|
-
* @param {(() => unknown) | undefined} [retry] 重试动作 / Retry action.
|
|
74
|
-
* @returns {HTMLElement} 居中状态视图 / Centered status view.
|
|
75
|
-
*/
|
|
76
|
-
function statusView(message, retry) {
|
|
77
|
-
const view = element("section", "pp-status");
|
|
78
|
-
view.setAttribute("role", "status");
|
|
79
|
-
view.setAttribute("aria-live", "polite");
|
|
80
|
-
const spinner = element("span", "pp-status-spinner");
|
|
81
|
-
spinner.setAttribute("aria-hidden", "true");
|
|
82
|
-
view.append(spinner, element("p", "pp-status-message", message));
|
|
83
|
-
if (retry) {
|
|
84
|
-
const button = element("button", "pp-status-action", "重新读取");
|
|
85
|
-
button.type = "button";
|
|
86
|
-
button.onclick = retry;
|
|
87
|
-
view.append(button);
|
|
88
|
-
}
|
|
89
|
-
return view;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* 请求宿主确认;独立网页使用浏览器对话框。
|
|
94
|
-
* Request confirmation from the host, using the browser dialog for standalone pages.
|
|
95
|
-
* @param {Window} host 模块窗口 / Module window.
|
|
96
|
-
* @param {string} message 确认内容 / Confirmation message.
|
|
97
|
-
* @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.
|
|
98
|
-
*/
|
|
99
|
-
function requestConfirmation(host, message) {
|
|
100
|
-
return new Promise((resolve, reject) => {
|
|
101
|
-
const frame = host.frameElement;
|
|
102
|
-
if (frame) {
|
|
103
|
-
const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:confirm", { cancelable: true, detail: { message, resolve, reject } });
|
|
104
|
-
if (!frame.dispatchEvent(event)) return;
|
|
105
|
-
}
|
|
106
|
-
resolve(host.confirm(message));
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
|
|
110
1
|
/**
|
|
111
2
|
* 校验原始路径片段,不进行 URL 编码转换。
|
|
112
3
|
* Validate raw path segments without URL encoding conversion.
|
|
@@ -292,6 +183,97 @@ function validValue(field, value) {
|
|
|
292
183
|
return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
|
|
293
184
|
}
|
|
294
185
|
|
|
186
|
+
/**
|
|
187
|
+
* 创建元素,所有展示文本通过 textContent 写入。
|
|
188
|
+
* Create elements and assign display text through textContent only.
|
|
189
|
+
* @template {keyof HTMLElementTagNameMap} T
|
|
190
|
+
* @param {T} tag 元素标签 / Element tag.
|
|
191
|
+
* @param {string} className 样式类名 / CSS class.
|
|
192
|
+
* @param {string} [text] 纯文本 / Plain text.
|
|
193
|
+
* @returns {HTMLElementTagNameMap[T]} 创建的元素 / Created element.
|
|
194
|
+
*/
|
|
195
|
+
function element(tag, className, text) {
|
|
196
|
+
const node = document.createElement(tag);
|
|
197
|
+
node.className = className;
|
|
198
|
+
if (text !== undefined) node.textContent = text;
|
|
199
|
+
return node;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 创建通用设置行;外部 CSS 可通过 pp 类名覆盖视觉样式。
|
|
204
|
+
* Create a generic settings row whose appearance can be overridden through pp classes.
|
|
205
|
+
* @template {"div" | "label"} T
|
|
206
|
+
* @param {T} tag 行元素 / Row element.
|
|
207
|
+
* @returns {HTMLElementTagNameMap[T]} 设置行 / Settings row.
|
|
208
|
+
*/
|
|
209
|
+
function settingRow(tag) {
|
|
210
|
+
return element(tag, "pp-row");
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 为标准 HTML 输入控件添加通用面板类名。
|
|
215
|
+
* Add the generic panel class to a standard HTML input control.
|
|
216
|
+
* @param {HTMLElement} control 已创建的原生控件 / Existing native control.
|
|
217
|
+
* @returns {HTMLElement} 输入控件 / Input control.
|
|
218
|
+
*/
|
|
219
|
+
function fieldControl(control) {
|
|
220
|
+
control.classList.add("pp-editor");
|
|
221
|
+
return control;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* 元数据地址只允许 HTTP(S) 和相对地址。
|
|
226
|
+
* Allow only HTTP(S) and relative metadata addresses.
|
|
227
|
+
* @param {string} value 元数据地址 / Metadata address.
|
|
228
|
+
* @returns {string} 完整地址 / Absolute address.
|
|
229
|
+
*/
|
|
230
|
+
function resourceURL(value) {
|
|
231
|
+
const url = new URL(value, document.baseURI);
|
|
232
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Metadata URLs must use HTTP(S)");
|
|
233
|
+
return url.href;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 创建覆盖可用内容区的通用读取状态,失败时可附加重试动作。
|
|
238
|
+
* Create a shared status view that fills the available content area and may include retry.
|
|
239
|
+
* @param {string} message 状态文本 / Status message.
|
|
240
|
+
* @param {(() => unknown) | undefined} [retry] 重试动作 / Retry action.
|
|
241
|
+
* @returns {HTMLElement} 居中状态视图 / Centered status view.
|
|
242
|
+
*/
|
|
243
|
+
function statusView(message, retry) {
|
|
244
|
+
const view = element("section", "pp-status");
|
|
245
|
+
view.setAttribute("role", "status");
|
|
246
|
+
view.setAttribute("aria-live", "polite");
|
|
247
|
+
const spinner = element("span", "pp-status-spinner");
|
|
248
|
+
spinner.setAttribute("aria-hidden", "true");
|
|
249
|
+
view.append(spinner, element("p", "pp-status-message", message));
|
|
250
|
+
if (retry) {
|
|
251
|
+
const button = element("button", "pp-status-action", "重新读取");
|
|
252
|
+
button.type = "button";
|
|
253
|
+
button.onclick = retry;
|
|
254
|
+
view.append(button);
|
|
255
|
+
}
|
|
256
|
+
return view;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 请求宿主确认;独立网页使用浏览器对话框。
|
|
261
|
+
* Request confirmation from the host, using the browser dialog for standalone pages.
|
|
262
|
+
* @param {Window} host 模块窗口 / Module window.
|
|
263
|
+
* @param {string} message 确认内容 / Confirmation message.
|
|
264
|
+
* @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.
|
|
265
|
+
*/
|
|
266
|
+
function requestConfirmation(host, message) {
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
const frame = host.frameElement;
|
|
269
|
+
if (frame) {
|
|
270
|
+
const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:confirm", { cancelable: true, detail: { message, resolve, reject } });
|
|
271
|
+
if (!frame.dispatchEvent(event)) return;
|
|
272
|
+
}
|
|
273
|
+
resolve(host.confirm(message));
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
295
277
|
/**
|
|
296
278
|
* 共用三点按钮和底部操作菜单;弹层挂载到文档根部,不受标题栏显示状态影响。
|
|
297
279
|
* Shared overflow trigger and bottom action sheet; the layer is mounted at document level and remains independent of header visibility.
|
|
@@ -446,28 +428,50 @@ class ActionMenu {
|
|
|
446
428
|
*/
|
|
447
429
|
class PreferencesClient {
|
|
448
430
|
#module;
|
|
449
|
-
#configURL;
|
|
450
431
|
#definition;
|
|
451
432
|
#request;
|
|
452
433
|
#notify;
|
|
453
434
|
#timeout;
|
|
454
435
|
#session = new AbortController();
|
|
455
|
-
#values;
|
|
436
|
+
#values = {};
|
|
456
437
|
#saving = false;
|
|
457
438
|
|
|
458
439
|
/**
|
|
459
|
-
*
|
|
460
|
-
* Create a page client that
|
|
461
|
-
* @param {import("./client.mjs").PreferencesClientOptions} options
|
|
440
|
+
* 创建从 BoxJS 定义读取和持久化设置的页面客户端。
|
|
441
|
+
* Create a page client that reads and persists settings from a BoxJS definition.
|
|
442
|
+
* @param {import("./client.mjs").PreferencesClientOptions} options 字段定义、请求与通知 / Field definition, requests, and notifications.
|
|
462
443
|
*/
|
|
463
|
-
constructor({
|
|
464
|
-
this.#module =
|
|
465
|
-
this.#configURL = model.configURL;
|
|
444
|
+
constructor({ definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
|
|
445
|
+
this.#module = definition.module;
|
|
466
446
|
this.#definition = definition;
|
|
467
447
|
this.#request = request;
|
|
468
448
|
this.#notify = notify;
|
|
469
449
|
this.#timeout = timeout;
|
|
470
|
-
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* 读取一次 Settings 子树并建立页面值快照。
|
|
454
|
+
* Read the Settings subtree once and establish the page value snapshot.
|
|
455
|
+
* @returns {Promise<import("./client.mjs").ModuleSnapshot>} 页面快照 / Page snapshot.
|
|
456
|
+
*/
|
|
457
|
+
async open() {
|
|
458
|
+
let subtree = await this.readSettings();
|
|
459
|
+
if (subtree === undefined) subtree = {};
|
|
460
|
+
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
461
|
+
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
462
|
+
const values = {};
|
|
463
|
+
for (const field of this.#definition.fields) {
|
|
464
|
+
const stored = field.key
|
|
465
|
+
.split(".")
|
|
466
|
+
.slice(this.#definition.settingsPath.length)
|
|
467
|
+
.reduce((parent, part) => Object(parent)[part], subtree);
|
|
468
|
+
const value = normalizeStoredValue(field, stored === undefined ? field.defaultValue : stored);
|
|
469
|
+
if (value === undefined) continue;
|
|
470
|
+
if (!validValue(field, value)) throw new TypeError(`Invalid stored value: ${field.key}`);
|
|
471
|
+
values[field.key] = value;
|
|
472
|
+
}
|
|
473
|
+
this.#values = values;
|
|
474
|
+
return this.snapshot();
|
|
471
475
|
}
|
|
472
476
|
|
|
473
477
|
/**
|
|
@@ -485,7 +489,7 @@ class PreferencesClient {
|
|
|
485
489
|
* @returns {Promise<unknown>} Settings 内容或 undefined / Settings content or undefined.
|
|
486
490
|
*/
|
|
487
491
|
async readSettings() {
|
|
488
|
-
const response = await this.#send("get", {
|
|
492
|
+
const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#definition.settingsPath.join(".")}`);
|
|
489
493
|
return response.status === 404 ? undefined : response.json();
|
|
490
494
|
}
|
|
491
495
|
|
|
@@ -495,7 +499,7 @@ class PreferencesClient {
|
|
|
495
499
|
* @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
|
|
496
500
|
*/
|
|
497
501
|
async readCaches() {
|
|
498
|
-
const response = await this.#send("get", {
|
|
502
|
+
const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#module}.Caches`);
|
|
499
503
|
return response.status === 404 ? undefined : response.json();
|
|
500
504
|
}
|
|
501
505
|
|
|
@@ -505,7 +509,7 @@ class PreferencesClient {
|
|
|
505
509
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
506
510
|
*/
|
|
507
511
|
clearCaches() {
|
|
508
|
-
return this.#change("delete", {
|
|
512
|
+
return this.#change("delete", `${this.#module}.Caches`, undefined, "clearCaches");
|
|
509
513
|
}
|
|
510
514
|
|
|
511
515
|
/**
|
|
@@ -514,7 +518,7 @@ class PreferencesClient {
|
|
|
514
518
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
515
519
|
*/
|
|
516
520
|
reset() {
|
|
517
|
-
return this.#change("delete",
|
|
521
|
+
return this.#change("delete", this.#module, undefined, "reset");
|
|
518
522
|
}
|
|
519
523
|
|
|
520
524
|
/**
|
|
@@ -534,7 +538,7 @@ class PreferencesClient {
|
|
|
534
538
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
535
539
|
*/
|
|
536
540
|
set(key, value) {
|
|
537
|
-
return this.#change("set",
|
|
541
|
+
return this.#change("set", key, value, "write");
|
|
538
542
|
}
|
|
539
543
|
|
|
540
544
|
/**
|
|
@@ -544,30 +548,31 @@ class PreferencesClient {
|
|
|
544
548
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
545
549
|
*/
|
|
546
550
|
remove(key) {
|
|
547
|
-
return this.#change("delete",
|
|
551
|
+
return this.#change("delete", key, undefined, "delete");
|
|
548
552
|
}
|
|
549
553
|
|
|
550
554
|
/**
|
|
551
|
-
*
|
|
552
|
-
* Send a
|
|
553
|
-
* @param {"get" | "set" | "delete"} action
|
|
554
|
-
* @param {
|
|
555
|
+
* 向固定存储 API 发送完整路径的 form 动作。
|
|
556
|
+
* Send a complete-path form action to the fixed storage API.
|
|
557
|
+
* @param {"get" | "set" | "delete"} action 存储动作 / Storage action.
|
|
558
|
+
* @param {string} path 完整 @root.path / Complete @root.path.
|
|
559
|
+
* @param {unknown} [value] set 写入值 / Value written by set.
|
|
555
560
|
* @returns {Promise<Response>} 原始响应 / Raw response.
|
|
556
561
|
*/
|
|
557
|
-
async #send(action,
|
|
562
|
+
async #send(action, path, value) {
|
|
558
563
|
const controller = new AbortController();
|
|
559
564
|
const abort = () => controller.abort();
|
|
560
565
|
if (this.#session.signal.aborted) abort();
|
|
561
566
|
this.#session.signal.addEventListener("abort", abort, { once: true });
|
|
562
567
|
const timer = setTimeout(abort, this.#timeout);
|
|
563
568
|
try {
|
|
564
|
-
const response = await this.#request(`/api/${
|
|
569
|
+
const response = await this.#request(`/api/${action}`, {
|
|
565
570
|
method: "POST",
|
|
566
571
|
credentials: "omit",
|
|
567
572
|
cache: "no-store",
|
|
568
573
|
signal: controller.signal,
|
|
569
|
-
headers: { "Content-Type": "application/
|
|
570
|
-
body: JSON.stringify(
|
|
574
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
575
|
+
body: new URLSearchParams([[path, action === "set" ? JSON.stringify(value) : ""]]).toString(),
|
|
571
576
|
});
|
|
572
577
|
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
573
578
|
return response;
|
|
@@ -581,24 +586,28 @@ class PreferencesClient {
|
|
|
581
586
|
* 执行写入动作;成功后只更新当前页面值。
|
|
582
587
|
* Execute a mutation and update only the current page values after success.
|
|
583
588
|
* @param {"set" | "delete"} action API 动作 / API action.
|
|
584
|
-
* @param {
|
|
589
|
+
* @param {string} key 不含存储根的路径 / Path without the storage root.
|
|
590
|
+
* @param {unknown} value set 写入值 / Value written by set.
|
|
585
591
|
* @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
|
|
586
|
-
* @param {string} [key] 字段路径 / Field path.
|
|
587
592
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
588
593
|
*/
|
|
589
|
-
async #change(action,
|
|
594
|
+
async #change(action, key, value, operation) {
|
|
590
595
|
if (this.#saving) throw new Error("A settings write is already in progress");
|
|
591
596
|
this.#saving = true;
|
|
592
597
|
try {
|
|
593
|
-
|
|
598
|
+
let field;
|
|
599
|
+
if (operation === "write" || operation === "delete") {
|
|
600
|
+
field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
601
|
+
if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
|
|
602
|
+
}
|
|
603
|
+
await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
|
|
594
604
|
switch (operation) {
|
|
595
605
|
case "write":
|
|
596
|
-
this.#values[key] = structuredClone(
|
|
606
|
+
this.#values[key] = structuredClone(value);
|
|
597
607
|
break;
|
|
598
608
|
case "delete": {
|
|
599
|
-
const field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
600
609
|
delete this.#values[key];
|
|
601
|
-
if (
|
|
610
|
+
if (Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
|
|
602
611
|
break;
|
|
603
612
|
}
|
|
604
613
|
case "clearCaches":
|
|
@@ -787,24 +796,23 @@ class PreferencesPanel {
|
|
|
787
796
|
#release;
|
|
788
797
|
|
|
789
798
|
/**
|
|
790
|
-
* 挂载
|
|
791
|
-
* Mount the module form
|
|
799
|
+
* 挂载 BoxJS 定义对应的模块表单。
|
|
800
|
+
* Mount the module form described by a BoxJS definition.
|
|
792
801
|
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
793
|
-
* @param {import("../index.js").
|
|
802
|
+
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
794
803
|
*/
|
|
795
|
-
constructor(root,
|
|
796
|
-
this.#release = this.#mount(root,
|
|
804
|
+
constructor(root, definition) {
|
|
805
|
+
this.#release = this.#mount(root, definition);
|
|
797
806
|
}
|
|
798
807
|
|
|
799
808
|
/**
|
|
800
809
|
* 建立面板 DOM、交互和会话,并返回其释放操作。
|
|
801
810
|
* Build panel DOM, interactions, and session, then return its release operation.
|
|
802
811
|
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
803
|
-
* @param {import("../index.js").
|
|
812
|
+
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
804
813
|
* @returns {() => void} 释放操作 / Release operation.
|
|
805
814
|
*/
|
|
806
|
-
#mount(root,
|
|
807
|
-
const { definition } = model;
|
|
815
|
+
#mount(root, definition) {
|
|
808
816
|
const title = definition.metadata?.name ?? definition.module;
|
|
809
817
|
const document = root.ownerDocument;
|
|
810
818
|
const window = document.defaultView;
|
|
@@ -896,7 +904,7 @@ class PreferencesPanel {
|
|
|
896
904
|
toast.hidden = true;
|
|
897
905
|
}, 2400);
|
|
898
906
|
};
|
|
899
|
-
const client = new PreferencesClient({
|
|
907
|
+
const client = new PreferencesClient({ definition, notify });
|
|
900
908
|
/**
|
|
901
909
|
* 两种菜单入口共用异步错误处理,包含宿主确认框错误。
|
|
902
910
|
* Share async error handling between both menus, including host-dialog errors.
|
|
@@ -924,6 +932,7 @@ class PreferencesPanel {
|
|
|
924
932
|
publishNavigation();
|
|
925
933
|
viewport.replaceChildren(statusView("读取设置…"));
|
|
926
934
|
try {
|
|
935
|
+
await client.open();
|
|
927
936
|
if (version === generation) controls();
|
|
928
937
|
} catch (error) {
|
|
929
938
|
if (version !== generation) return;
|
|
@@ -1312,15 +1321,14 @@ function installDefaultStyles(document) {
|
|
|
1312
1321
|
}
|
|
1313
1322
|
|
|
1314
1323
|
/**
|
|
1315
|
-
*
|
|
1316
|
-
* Manage
|
|
1324
|
+
* 管理 BoxJS 规范化、主题同步和面板生命周期。
|
|
1325
|
+
* Manage BoxJS normalization, theme synchronization, and panel lifecycle.
|
|
1317
1326
|
*/
|
|
1318
1327
|
class PreferencesView {
|
|
1319
1328
|
#existing;
|
|
1320
1329
|
#root;
|
|
1321
1330
|
#base;
|
|
1322
1331
|
#ownsBase;
|
|
1323
|
-
#custom;
|
|
1324
1332
|
#previousTitle;
|
|
1325
1333
|
#previousTheme;
|
|
1326
1334
|
#systemTheme;
|
|
@@ -1330,22 +1338,12 @@ class PreferencesView {
|
|
|
1330
1338
|
#panel;
|
|
1331
1339
|
|
|
1332
1340
|
/**
|
|
1333
|
-
*
|
|
1334
|
-
* Mount a settings page from
|
|
1335
|
-
* @param {import("../index.js").
|
|
1336
|
-
* @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
|
|
1341
|
+
* 使用原始 BoxJS JSON 挂载设置页。
|
|
1342
|
+
* Mount a settings page from raw BoxJS JSON.
|
|
1343
|
+
* @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
|
|
1337
1344
|
*/
|
|
1338
|
-
constructor(
|
|
1339
|
-
|
|
1340
|
-
const definition = normalizeBoxJs(model.boxjs, model.module);
|
|
1341
|
-
const values = { ...model.values };
|
|
1342
|
-
for (const field of definition.fields) {
|
|
1343
|
-
if (values[field.key] === undefined) continue;
|
|
1344
|
-
values[field.key] = normalizeStoredValue(field, values[field.key]);
|
|
1345
|
-
if (!validValue(field, values[field.key])) throw new TypeError(`Invalid stored value: ${field.key}`);
|
|
1346
|
-
}
|
|
1347
|
-
for (const field of definition.fields) if (values[field.key] === undefined && Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
|
|
1348
|
-
const rendered = { ...model, definition, values };
|
|
1345
|
+
constructor(boxjs) {
|
|
1346
|
+
const definition = normalizeBoxJs(boxjs);
|
|
1349
1347
|
const metadata = definition.metadata ?? {};
|
|
1350
1348
|
const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
1351
1349
|
if (image) resourceURL(image);
|
|
@@ -1360,9 +1358,6 @@ class PreferencesView {
|
|
|
1360
1358
|
const styles = installDefaultStyles(document);
|
|
1361
1359
|
this.#base = styles.element;
|
|
1362
1360
|
this.#ownsBase = styles.owned;
|
|
1363
|
-
this.#custom = element("style", "");
|
|
1364
|
-
this.#custom.textContent = css;
|
|
1365
|
-
document.head.append(this.#custom);
|
|
1366
1361
|
this.#previousTitle = document.title;
|
|
1367
1362
|
this.#previousTheme = document.documentElement.dataset.theme;
|
|
1368
1363
|
this.#systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
@@ -1377,7 +1372,7 @@ class PreferencesView {
|
|
|
1377
1372
|
document.title = metadata.name ?? definition.module;
|
|
1378
1373
|
try {
|
|
1379
1374
|
this.#root.replaceChildren();
|
|
1380
|
-
this.#panel = new PreferencesPanel(this.#root,
|
|
1375
|
+
this.#panel = new PreferencesPanel(this.#root, definition);
|
|
1381
1376
|
} catch (error) {
|
|
1382
1377
|
this.destroy();
|
|
1383
1378
|
throw error;
|
|
@@ -1405,7 +1400,6 @@ class PreferencesView {
|
|
|
1405
1400
|
this.#systemTheme.removeEventListener("change", this.#syncAppearance);
|
|
1406
1401
|
this.#panel?.destroy();
|
|
1407
1402
|
if (this.#ownsBase) this.#base.remove();
|
|
1408
|
-
this.#custom.remove();
|
|
1409
1403
|
if (this.#existing) this.#root.replaceChildren();
|
|
1410
1404
|
else this.#root.remove();
|
|
1411
1405
|
document.title = this.#previousTitle;
|
|
@@ -1416,11 +1410,20 @@ class PreferencesView {
|
|
|
1416
1410
|
}
|
|
1417
1411
|
|
|
1418
1412
|
/**
|
|
1419
|
-
*
|
|
1420
|
-
*
|
|
1413
|
+
* 使用原始 BoxJS JSON 挂载设置页。
|
|
1414
|
+
* Mount a settings page from raw BoxJS JSON.
|
|
1415
|
+
* @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
|
|
1416
|
+
* @returns {import("./index.js").MountedPreferences} 模块视图 / Module view.
|
|
1417
|
+
*/
|
|
1418
|
+
function mount(boxjs) {
|
|
1419
|
+
return new PreferencesView(boxjs);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* 管理模块文档的配置请求、重载和错误状态。
|
|
1424
|
+
* Manage configuration requests, reloads, and error states for a module document.
|
|
1421
1425
|
*/
|
|
1422
1426
|
class ModulePage {
|
|
1423
|
-
#document;
|
|
1424
1427
|
#window;
|
|
1425
1428
|
#root;
|
|
1426
1429
|
#view;
|
|
@@ -1431,7 +1434,6 @@ class ModulePage {
|
|
|
1431
1434
|
* @param {Document} document 模块文档 / Module document.
|
|
1432
1435
|
*/
|
|
1433
1436
|
constructor(document) {
|
|
1434
|
-
this.#document = document;
|
|
1435
1437
|
this.#window = document.defaultView;
|
|
1436
1438
|
this.#root = document.querySelector("#preferences");
|
|
1437
1439
|
installDefaultStyles(document);
|
|
@@ -1439,8 +1441,8 @@ class ModulePage {
|
|
|
1439
1441
|
}
|
|
1440
1442
|
|
|
1441
1443
|
/**
|
|
1442
|
-
*
|
|
1443
|
-
*
|
|
1444
|
+
* 通过模块 API 读取 BoxJS JSON 并挂载通用前端。
|
|
1445
|
+
* Read BoxJS JSON through the module API and mount the generic frontend.
|
|
1444
1446
|
* @returns {Promise<void>} 启动完成 / Startup completion.
|
|
1445
1447
|
*/
|
|
1446
1448
|
async start() {
|
|
@@ -1448,12 +1450,15 @@ class ModulePage {
|
|
|
1448
1450
|
this.#view?.destroy();
|
|
1449
1451
|
this.#view = undefined;
|
|
1450
1452
|
this.#root.replaceChildren(statusView("读取设置…"));
|
|
1451
|
-
const
|
|
1452
|
-
const
|
|
1453
|
-
const
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1453
|
+
const embedded = this.#window.frameElement?.dataset.preferencePanesModule;
|
|
1454
|
+
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(this.#window.location.pathname);
|
|
1455
|
+
const module = embedded ?? match?.[1];
|
|
1456
|
+
if (!module) throw new TypeError("Open a concrete module URL");
|
|
1457
|
+
const response = await fetch(`/api/${encodeURIComponent(module)}`, { cache: "no-store", credentials: "omit", headers: { Accept: "application/json" } });
|
|
1458
|
+
if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
|
|
1459
|
+
const boxjs = await response.json();
|
|
1460
|
+
normalizeBoxJs(boxjs, module);
|
|
1461
|
+
this.#view = mount(boxjs);
|
|
1457
1462
|
} catch (error) {
|
|
1458
1463
|
this.#root.replaceChildren(statusView(`加载失败:${error.message}`, () => this.start()));
|
|
1459
1464
|
}
|
|
@@ -1470,39 +1475,6 @@ class ModulePage {
|
|
|
1470
1475
|
this.#view = undefined;
|
|
1471
1476
|
}
|
|
1472
1477
|
|
|
1473
|
-
/**
|
|
1474
|
-
* 读取嵌入参数、文档元数据或当前 URL 输入。
|
|
1475
|
-
* Read embedded parameters, document metadata, or current URL inputs.
|
|
1476
|
-
* @returns {ReturnType<typeof pageInputs>} 页面输入 / Page inputs.
|
|
1477
|
-
*/
|
|
1478
|
-
#readInputs() {
|
|
1479
|
-
const context = this.#document.querySelector('meta[name="preference-panes-inputs"]');
|
|
1480
|
-
const embedded = this.#window.frameElement?.dataset.preferencePanes;
|
|
1481
|
-
switch (true) {
|
|
1482
|
-
case embedded !== undefined:
|
|
1483
|
-
this.#document.documentElement.dataset.preferencePanesEmbedded = "";
|
|
1484
|
-
return JSON.parse(embedded);
|
|
1485
|
-
case context !== null:
|
|
1486
|
-
return JSON.parse(decodeURIComponent(context.content));
|
|
1487
|
-
default:
|
|
1488
|
-
return pageInputs(new URL(this.#window.location.href));
|
|
1489
|
-
}
|
|
1490
|
-
}
|
|
1491
|
-
|
|
1492
|
-
/**
|
|
1493
|
-
* 将可选页面资源限制为 HTTP(S) 地址。
|
|
1494
|
-
* Restrict an optional page resource to an HTTP(S) URL.
|
|
1495
|
-
* @param {string | undefined} source 资源地址 / Resource location.
|
|
1496
|
-
* @param {string} baseURL 页面基准地址 / Page base URL.
|
|
1497
|
-
* @returns {string | null} 绝对资源地址 / Absolute resource URL.
|
|
1498
|
-
*/
|
|
1499
|
-
#resourceURL(source, baseURL) {
|
|
1500
|
-
if (!source) return null;
|
|
1501
|
-
const url = new URL(source, baseURL);
|
|
1502
|
-
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Resources must use HTTP(S) URLs");
|
|
1503
|
-
return url.href;
|
|
1504
|
-
}
|
|
1505
|
-
|
|
1506
1478
|
/**
|
|
1507
1479
|
* 从前进后退缓存恢复时重新加载模块。
|
|
1508
1480
|
* Reload the module when restored from the back-forward cache.
|
|
@@ -1515,5 +1487,3 @@ class ModulePage {
|
|
|
1515
1487
|
}
|
|
1516
1488
|
|
|
1517
1489
|
new ModulePage(document).start();
|
|
1518
|
-
|
|
1519
|
-
export { ModulePage };
|