@nsnanocat/preference-panes 0.9.16 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -16
- package/dist/api.js +1454 -967
- package/dist/module/index.html +1 -1
- package/dist/module/index.mjs +1519 -0
- package/dist/module/navigation.mjs +7 -6
- package/dist/preference-panes.mjs +861 -849
- package/dist/web.js +1202 -0
- package/package.json +1 -1
- package/src/api.mjs +191 -0
- package/src/browser/ModuleStatus.mjs +7 -6
- package/src/browser/Navigation.d.mts +8 -6
- package/src/{lib → browser}/boxjs.mjs +72 -16
- package/src/browser/client.d.mts +43 -133
- package/src/browser/client.mjs +146 -186
- package/src/browser/index.d.ts +21 -10
- package/src/browser/index.mjs +95 -72
- package/src/browser/module.html +1 -1
- package/src/browser/mount.mjs +119 -0
- package/src/browser/panel.mjs +470 -441
- package/src/build.mjs +4 -5
- package/src/index.d.ts +16 -2
- package/src/web.mjs +52 -0
- package/dist/module/app.mjs +0 -1461
- package/src/BoxJS.mjs +0 -72
- package/src/Store.mjs +0 -114
- package/src/browser/app.mjs +0 -51
- package/src/lib/response.mjs +0 -16
- package/src/proxy/handler.mjs +0 -39
- package/src/proxy/response.mjs +0 -16
|
@@ -11,76 +11,178 @@ function validatePathParts(parts) {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
* BoxJS
|
|
15
|
-
*
|
|
14
|
+
* 将 BoxJS 数组、app 或订阅转换为浏览器字段定义。
|
|
15
|
+
* Normalize a BoxJS array, app or subscription into browser field definitions.
|
|
16
|
+
* @param {unknown} config 原始 BoxJS JSON / Raw BoxJS JSON.
|
|
17
|
+
* @param {string} [module] API 模块路径段;省略时要求输入只有一个模块 / API module path segment; omission requires exactly one module.
|
|
18
|
+
* @returns {import("../index.js").ModuleDefinition} 浏览器字段定义 / Browser field definition.
|
|
16
19
|
*/
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
if (!
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
validatePathParts(parts);
|
|
40
|
-
const module = parts[0];
|
|
41
|
-
let target = this.modules.get(module);
|
|
42
|
-
if (!target) {
|
|
43
|
-
target = { module, storageKey, entries: [], owners: new Set() };
|
|
44
|
-
this.modules.set(module, target);
|
|
45
|
-
}
|
|
46
|
-
if (target.storageKey !== storageKey) throw new TypeError(`A module must use one storage root: ${module}`);
|
|
47
|
-
target.entries.push(entry);
|
|
48
|
-
target.owners.add(app);
|
|
20
|
+
function normalizeBoxJs(config, module) {
|
|
21
|
+
if (!config || typeof config !== "object") throw new TypeError("Expected BoxJS JSON");
|
|
22
|
+
const document = JSON.parse(JSON.stringify(config));
|
|
23
|
+
const apps = Array.isArray(document) ? [{ settings: document }] : (document.apps ?? [document]);
|
|
24
|
+
if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
|
|
25
|
+
const modules = new Map();
|
|
26
|
+
for (const app of apps) {
|
|
27
|
+
if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
|
|
28
|
+
for (const entry of app.settings) {
|
|
29
|
+
if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
|
|
30
|
+
if (!entry.id.startsWith("@")) {
|
|
31
|
+
if (Array.isArray(document)) throw new TypeError("BoxJS settings require @root.path IDs");
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
const [storageKey, ...parts] = entry.id.slice(1).split(".");
|
|
35
|
+
if (!storageKey || storageKey.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
|
|
36
|
+
validatePathParts(parts);
|
|
37
|
+
const name = parts[0];
|
|
38
|
+
let target = modules.get(name);
|
|
39
|
+
if (!target) {
|
|
40
|
+
target = { module: name, storageKey, entries: [], owners: new Set() };
|
|
41
|
+
modules.set(name, target);
|
|
49
42
|
}
|
|
43
|
+
if (target.storageKey !== storageKey) throw new TypeError(`A module must use one storage root: ${name}`);
|
|
44
|
+
target.entries.push(entry);
|
|
45
|
+
target.owners.add(app);
|
|
50
46
|
}
|
|
51
|
-
this.metadata = metadata(Array.isArray(document) ? {} : document);
|
|
52
|
-
for (const target of this.modules.values()) target.metadata = target.owners.size === 1 ? metadata([...target.owners][0]) : {};
|
|
53
47
|
}
|
|
48
|
+
if (module === undefined && modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
|
|
49
|
+
const target = module === undefined ? modules.values().next().value : modules.get(module);
|
|
50
|
+
if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
51
|
+
const metadata = normalizeMetadata(target.owners.size === 1 ? presentation([...target.owners][0]) : {});
|
|
52
|
+
const fields = [];
|
|
53
|
+
for (const entry of target.entries) {
|
|
54
|
+
const parts = entry.id.slice(1).split(".").slice(1);
|
|
55
|
+
const type = { boolean: "boolean", checkboxes: "array", selects: "select", text: "string", textarea: "string", number: "number" }[entry.type];
|
|
56
|
+
if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
|
|
57
|
+
const field = {
|
|
58
|
+
key: parts.join("."),
|
|
59
|
+
type: type === "select" ? typeof entry.val : type,
|
|
54
60
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
name: entry.name,
|
|
62
|
+
description: entry.desc ?? "",
|
|
63
|
+
control: entry.type,
|
|
64
|
+
...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),
|
|
65
|
+
...(entry.rows === undefined ? {} : { rows: entry.rows }),
|
|
66
|
+
...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),
|
|
67
|
+
};
|
|
68
|
+
if (type === "select" && !["string", "number", "boolean"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);
|
|
69
|
+
if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));
|
|
70
|
+
if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
|
|
71
|
+
if (
|
|
72
|
+
typeof field.name !== "string" ||
|
|
73
|
+
(field.placeholder !== undefined && typeof field.placeholder !== "string") ||
|
|
74
|
+
(field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||
|
|
75
|
+
(field.autoGrow !== undefined && typeof field.autoGrow !== "boolean") ||
|
|
76
|
+
fields.some(other => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
|
|
77
|
+
)
|
|
78
|
+
throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
|
|
79
|
+
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}`);
|
|
80
|
+
if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
|
|
81
|
+
fields.push(field);
|
|
63
82
|
}
|
|
83
|
+
if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${target.module}`);
|
|
84
|
+
const common = fields[0].key.split(".").slice(0, -1);
|
|
85
|
+
for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
|
|
86
|
+
return {
|
|
87
|
+
module: target.module,
|
|
88
|
+
storageKey: target.storageKey,
|
|
89
|
+
fields,
|
|
90
|
+
settingsPath: common,
|
|
91
|
+
...(Object.keys(metadata).length ? { metadata } : {}),
|
|
92
|
+
};
|
|
64
93
|
}
|
|
65
94
|
|
|
66
95
|
/**
|
|
67
|
-
*
|
|
68
|
-
* Retain
|
|
69
|
-
* @param {object} source BoxJS app
|
|
70
|
-
* @returns {
|
|
96
|
+
* 保留字段所属 app 的原始展示信息。
|
|
97
|
+
* Retain raw presentation metadata from the app owning the fields.
|
|
98
|
+
* @param {object} source BoxJS app / BoxJS app.
|
|
99
|
+
* @returns {Record<string, unknown>} 原始展示信息 / Raw presentation metadata.
|
|
71
100
|
*/
|
|
72
|
-
function
|
|
101
|
+
function presentation(source) {
|
|
73
102
|
const result = {};
|
|
74
103
|
for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc", "icons", "descs"]) {
|
|
75
104
|
if (source[key] === undefined) continue;
|
|
105
|
+
result[key] = source[key];
|
|
106
|
+
}
|
|
107
|
+
return result;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 校验供浏览器展示的标准 BoxJS 元数据。
|
|
112
|
+
* Validate standard BoxJS metadata used by the browser renderer.
|
|
113
|
+
* @param {Record<string, unknown>} source 原始展示元数据 / Raw presentation metadata.
|
|
114
|
+
* @returns {import("../index.js").BoxJSMetadata} 规范化展示元数据 / Normalized presentation metadata.
|
|
115
|
+
*/
|
|
116
|
+
function normalizeMetadata(source) {
|
|
117
|
+
const result = {};
|
|
118
|
+
for (const [key, value] of Object.entries(source)) {
|
|
76
119
|
const multiple = key === "icons" || key === "descs";
|
|
77
|
-
const values = multiple ?
|
|
120
|
+
const values = multiple ? value : [value];
|
|
78
121
|
if (!Array.isArray(values) || values.some(item => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
|
|
79
|
-
result[key] = multiple ? [...values] :
|
|
122
|
+
result[key] = multiple ? [...values] : value;
|
|
80
123
|
}
|
|
81
124
|
return result;
|
|
82
125
|
}
|
|
83
126
|
|
|
127
|
+
/**
|
|
128
|
+
* 归一化 BoxJS 的字符串存储值,不改变普通文本内容。
|
|
129
|
+
* Normalize BoxJS string persistence without changing free-text values.
|
|
130
|
+
* @param {import("../index.js").SettingsField} field 前端字段约束 / Frontend field constraints.
|
|
131
|
+
* @param {unknown} value 存储值 / Stored value.
|
|
132
|
+
* @returns {unknown} 转换后的控件值;是否允许写入由 validValue 单独校验 / Converted control value; write eligibility is checked separately by validValue.
|
|
133
|
+
*/
|
|
134
|
+
function normalizeStoredValue(field, value) {
|
|
135
|
+
switch (field.type) {
|
|
136
|
+
case "boolean":
|
|
137
|
+
if (value === "true" || value === "false") return value === "true";
|
|
138
|
+
break;
|
|
139
|
+
case "number":
|
|
140
|
+
if (typeof value === "string" && value.trim() !== "") return Number(value);
|
|
141
|
+
break;
|
|
142
|
+
case "array":
|
|
143
|
+
if (typeof value === "string") value = value === "" || value === "[]" ? [] : value.split(",");
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
if (field.options) {
|
|
147
|
+
const match = item => field.options.find(option => String(option.key) === String(item))?.key ?? item;
|
|
148
|
+
return field.type === "array" && Array.isArray(value) ? value.map(match) : match(value);
|
|
149
|
+
}
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 校验支持的标量范围,包括文本长度与数值有限性。
|
|
155
|
+
* Validate supported scalar bounds, including text length and numeric finiteness.
|
|
156
|
+
* @param {unknown} value 待检查值 / Value to inspect.
|
|
157
|
+
* @returns {boolean} 是否为有效标量 / Whether the scalar is valid.
|
|
158
|
+
*/
|
|
159
|
+
function scalar(value) {
|
|
160
|
+
switch (typeof value) {
|
|
161
|
+
case "boolean":
|
|
162
|
+
return true;
|
|
163
|
+
case "string":
|
|
164
|
+
return value.length <= 2048;
|
|
165
|
+
case "number":
|
|
166
|
+
return Number.isFinite(value);
|
|
167
|
+
default:
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* 检查值类型、数组唯一性及声明的选项,不进行转换。
|
|
174
|
+
* Check value type, array uniqueness and declared choices without coercion.
|
|
175
|
+
* @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
|
|
176
|
+
* @param {unknown} value 待写入的 JSON 值 / JSON value to write.
|
|
177
|
+
* @returns {boolean} 是否符合字段约束 / Whether the value satisfies field constraints.
|
|
178
|
+
*/
|
|
179
|
+
function validValue(field, value) {
|
|
180
|
+
if (field.type === "array") {
|
|
181
|
+
if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
|
|
182
|
+
} else if (typeof value !== field.type || !scalar(value)) return false;
|
|
183
|
+
return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
|
|
184
|
+
}
|
|
185
|
+
|
|
84
186
|
/**
|
|
85
187
|
* 创建元素,所有展示文本通过 textContent 写入。
|
|
86
188
|
* Create elements and assign display text through textContent only.
|
|
@@ -321,337 +423,183 @@ class ActionMenu {
|
|
|
321
423
|
}
|
|
322
424
|
|
|
323
425
|
/**
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
* @param {unknown | BoxJS} config BoxJS JSON 或已解析目录 / BoxJS document or parsed catalog.
|
|
327
|
-
* @param {string} module API 第一段模块名 / First API path segment.
|
|
328
|
-
* @returns {import("../index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
|
|
329
|
-
* @throws {TypeError} 配置结构、字段路径、默认值或展示属性无效 / Invalid configuration, field path, default or presentation attribute.
|
|
426
|
+
* 管理单模块页面的 API 请求、值快照和会话终止。
|
|
427
|
+
* Manage API requests, value snapshots, and session termination for one module page.
|
|
330
428
|
*/
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
|
|
342
|
-
const field = {
|
|
343
|
-
key: parts.join("."),
|
|
344
|
-
type: type === "select" ? typeof entry.val : type,
|
|
429
|
+
class PreferencesClient {
|
|
430
|
+
#module;
|
|
431
|
+
#configURL;
|
|
432
|
+
#definition;
|
|
433
|
+
#request;
|
|
434
|
+
#notify;
|
|
435
|
+
#timeout;
|
|
436
|
+
#session = new AbortController();
|
|
437
|
+
#values;
|
|
438
|
+
#saving = false;
|
|
345
439
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
(field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||
|
|
360
|
-
(field.autoGrow !== undefined && typeof field.autoGrow !== "boolean") ||
|
|
361
|
-
fields.some(other => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
|
|
362
|
-
)
|
|
363
|
-
throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
|
|
364
|
-
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}`);
|
|
365
|
-
if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
|
|
366
|
-
fields.push(field);
|
|
440
|
+
/**
|
|
441
|
+
* 创建只调用模块 API、不读取或解析 BoxJS 的页面客户端。
|
|
442
|
+
* Create a page client that only calls the module API and never reads or parses BoxJS.
|
|
443
|
+
* @param {import("./client.mjs").PreferencesClientOptions} options API 模型、请求与通知 / API model, requests, and notifications.
|
|
444
|
+
*/
|
|
445
|
+
constructor({ model, definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
|
|
446
|
+
this.#module = model.module;
|
|
447
|
+
this.#configURL = model.configURL;
|
|
448
|
+
this.#definition = definition;
|
|
449
|
+
this.#request = request;
|
|
450
|
+
this.#notify = notify;
|
|
451
|
+
this.#timeout = timeout;
|
|
452
|
+
this.#values = structuredClone(model.values);
|
|
367
453
|
}
|
|
368
|
-
if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
369
|
-
const common = fields[0].key.split(".").slice(0, -1);
|
|
370
|
-
for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
|
|
371
|
-
return {
|
|
372
|
-
module,
|
|
373
|
-
storageKey,
|
|
374
|
-
fields,
|
|
375
|
-
settingsPath: common,
|
|
376
|
-
...(Object.keys(metadata).length ? { metadata } : {}),
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
454
|
|
|
380
|
-
/**
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
function normalizeStoredValue(field, value) {
|
|
388
|
-
switch (field.type) {
|
|
389
|
-
case "boolean":
|
|
390
|
-
if (value === "true" || value === "false") return value === "true";
|
|
391
|
-
break;
|
|
392
|
-
case "number":
|
|
393
|
-
if (typeof value === "string" && value.trim() !== "") return Number(value);
|
|
394
|
-
break;
|
|
395
|
-
case "array":
|
|
396
|
-
if (typeof value === "string") value = value === "" || value === "[]" ? [] : value.split(",");
|
|
397
|
-
break;
|
|
455
|
+
/**
|
|
456
|
+
* 获取当前字段定义和值的深拷贝,不发起网络请求。
|
|
457
|
+
* Return a deep copy of the current field definition and values without a network request.
|
|
458
|
+
* @returns {import("./client.mjs").ModuleSnapshot} 会话快照 / Session snapshot.
|
|
459
|
+
*/
|
|
460
|
+
snapshot() {
|
|
461
|
+
return structuredClone({ definition: this.#definition, values: this.#values });
|
|
398
462
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* 读取 Settings 子树。
|
|
466
|
+
* Read the Settings subtree.
|
|
467
|
+
* @returns {Promise<unknown>} Settings 内容或 undefined / Settings content or undefined.
|
|
468
|
+
*/
|
|
469
|
+
async readSettings() {
|
|
470
|
+
const response = await this.#send("get", { scope: "settings" });
|
|
471
|
+
return response.status === 404 ? undefined : response.json();
|
|
402
472
|
}
|
|
403
|
-
return value;
|
|
404
|
-
}
|
|
405
473
|
|
|
406
|
-
/**
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
case "boolean":
|
|
415
|
-
return true;
|
|
416
|
-
case "string":
|
|
417
|
-
return value.length <= 2048;
|
|
418
|
-
case "number":
|
|
419
|
-
return Number.isFinite(value);
|
|
420
|
-
default:
|
|
421
|
-
return false;
|
|
474
|
+
/**
|
|
475
|
+
* 读取 Caches 子树。
|
|
476
|
+
* Read the Caches subtree.
|
|
477
|
+
* @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
|
|
478
|
+
*/
|
|
479
|
+
async readCaches() {
|
|
480
|
+
const response = await this.#send("get", { scope: "caches" });
|
|
481
|
+
return response.status === 404 ? undefined : response.json();
|
|
422
482
|
}
|
|
423
|
-
}
|
|
424
483
|
|
|
425
|
-
/**
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
if (field.type === "array") {
|
|
434
|
-
if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
|
|
435
|
-
} else if (typeof value !== field.type || !scalar(value)) return false;
|
|
436
|
-
return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
|
|
437
|
-
}
|
|
484
|
+
/**
|
|
485
|
+
* 删除当前模块的 Caches 子树。
|
|
486
|
+
* Delete the current module Caches subtree.
|
|
487
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
488
|
+
*/
|
|
489
|
+
clearCaches() {
|
|
490
|
+
return this.#change("delete", { scope: "caches" }, "clearCaches");
|
|
491
|
+
}
|
|
438
492
|
|
|
439
|
-
/**
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
493
|
+
/**
|
|
494
|
+
* 删除当前模块数据并恢复页面默认值。
|
|
495
|
+
* Delete current module data and restore page defaults.
|
|
496
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
497
|
+
*/
|
|
498
|
+
reset() {
|
|
499
|
+
return this.#change("delete", { scope: "module" }, "reset");
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* 终止当前页面仍在进行的请求。
|
|
504
|
+
* Abort requests still owned by the current page.
|
|
505
|
+
* @returns {void} 无返回值 / No return value.
|
|
506
|
+
*/
|
|
507
|
+
leave() {
|
|
508
|
+
this.#session.abort();
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* 写入单个字段。
|
|
513
|
+
* Write one field.
|
|
514
|
+
* @param {string} key 字段路径 / Field path.
|
|
515
|
+
* @param {unknown} value 已校验值 / Validated value.
|
|
516
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
517
|
+
*/
|
|
518
|
+
set(key, value) {
|
|
519
|
+
return this.#change("set", { key, value }, "write", key);
|
|
520
|
+
}
|
|
448
521
|
|
|
449
|
-
/**
|
|
450
|
-
* 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。
|
|
451
|
-
* Create a page-session cache; reload on open and mutate cache only after HTTP 200.
|
|
452
|
-
* @param {import("./client.mjs").PreferencesClientOptions} options 包内目录、请求与通知 / Internal catalog, requests and notifications.
|
|
453
|
-
* @returns {import("./client.mjs").PreferencesClient} 通用客户端 / Generic client.
|
|
454
|
-
*/
|
|
455
|
-
function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
|
|
456
522
|
/**
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
* @
|
|
523
|
+
* 删除单个字段覆盖值。
|
|
524
|
+
* Delete one field override.
|
|
525
|
+
* @param {string} key 字段路径 / Field path.
|
|
526
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
460
527
|
*/
|
|
461
|
-
|
|
528
|
+
remove(key) {
|
|
529
|
+
return this.#change("delete", { key }, "delete", key);
|
|
530
|
+
}
|
|
531
|
+
|
|
462
532
|
/**
|
|
463
|
-
*
|
|
464
|
-
* Send a
|
|
465
|
-
* @param {
|
|
466
|
-
* @param {
|
|
467
|
-
* @
|
|
468
|
-
* @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
|
|
469
|
-
* @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
|
|
470
|
-
* @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
|
|
533
|
+
* 向模块 API 发送 JSON 动作。
|
|
534
|
+
* Send a JSON action to the module API.
|
|
535
|
+
* @param {"get" | "set" | "delete"} action 模块动作 / Module action.
|
|
536
|
+
* @param {unknown} payload JSON 请求体 / JSON request body.
|
|
537
|
+
* @returns {Promise<Response>} 原始响应 / Raw response.
|
|
471
538
|
*/
|
|
472
|
-
async
|
|
539
|
+
async #send(action, payload) {
|
|
473
540
|
const controller = new AbortController();
|
|
474
541
|
const abort = () => controller.abort();
|
|
475
|
-
if (signal
|
|
476
|
-
signal
|
|
477
|
-
const timer = setTimeout(abort, timeout);
|
|
542
|
+
if (this.#session.signal.aborted) abort();
|
|
543
|
+
this.#session.signal.addEventListener("abort", abort, { once: true });
|
|
544
|
+
const timer = setTimeout(abort, this.#timeout);
|
|
478
545
|
try {
|
|
479
|
-
const response = await request(`/api/${action}`, {
|
|
546
|
+
const response = await this.#request(`/api/${encodeURIComponent(this.#module)}/${action}`, {
|
|
480
547
|
method: "POST",
|
|
481
548
|
credentials: "omit",
|
|
482
549
|
cache: "no-store",
|
|
483
550
|
signal: controller.signal,
|
|
484
|
-
headers: { "Content-Type": "application/
|
|
485
|
-
body:
|
|
551
|
+
headers: { "Content-Type": "application/json", "X-PreferencePanes-JSON": this.#configURL },
|
|
552
|
+
body: JSON.stringify(payload),
|
|
486
553
|
});
|
|
487
554
|
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
488
555
|
return response;
|
|
489
556
|
} finally {
|
|
490
557
|
clearTimeout(timer);
|
|
491
|
-
signal
|
|
558
|
+
this.#session.signal.removeEventListener("abort", abort);
|
|
492
559
|
}
|
|
493
560
|
}
|
|
561
|
+
|
|
494
562
|
/**
|
|
495
|
-
*
|
|
496
|
-
*
|
|
497
|
-
* @param {
|
|
498
|
-
* @
|
|
499
|
-
* @
|
|
500
|
-
|
|
501
|
-
const snapshot = module => {
|
|
502
|
-
const state = sessions.get(module);
|
|
503
|
-
if (!state?.definition) throw new Error("Open the module first");
|
|
504
|
-
return structuredClone({ definition: state.definition, values: state.values });
|
|
505
|
-
};
|
|
506
|
-
/**
|
|
507
|
-
* 串行修改单键,仅成功后更新仍存活的会话。
|
|
508
|
-
* Serialize single-key mutations and update a still-active session only after success.
|
|
509
|
-
* @param {string} module 已打开模块 / Open module.
|
|
510
|
-
* @param {string} key 完整点分字段路径 / Complete dotted field path.
|
|
511
|
-
* @param {"set" | "delete"} action 写入或删除 / Write or delete.
|
|
512
|
-
* @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
|
|
513
|
-
* @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
|
|
563
|
+
* 执行写入动作;成功后只更新当前页面值。
|
|
564
|
+
* Execute a mutation and update only the current page values after success.
|
|
565
|
+
* @param {"set" | "delete"} action API 动作 / API action.
|
|
566
|
+
* @param {Record<string, unknown>} payload JSON 请求体 / JSON request body.
|
|
567
|
+
* @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
|
|
568
|
+
* @param {string} [key] 字段路径 / Field path.
|
|
514
569
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
515
|
-
* @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
|
|
516
570
|
*/
|
|
517
|
-
async
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (state.saving) throw new Error("A settings write is already in progress");
|
|
521
|
-
const field = state.definition.fields.find(field => field.key === key);
|
|
522
|
-
state.saving = true;
|
|
571
|
+
async #change(action, payload, operation, key) {
|
|
572
|
+
if (this.#saving) throw new Error("A settings write is already in progress");
|
|
573
|
+
this.#saving = true;
|
|
523
574
|
try {
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
for (const candidate of state.definition.fields) {
|
|
535
|
-
if (candidate.key !== key && !candidate.key.startsWith(`${key}.`)) continue;
|
|
536
|
-
delete state.values[candidate.key];
|
|
537
|
-
if (Object.hasOwn(candidate, "defaultValue")) state.values[candidate.key] = structuredClone(candidate.defaultValue);
|
|
538
|
-
}
|
|
539
|
-
break;
|
|
575
|
+
await this.#send(action, payload);
|
|
576
|
+
switch (operation) {
|
|
577
|
+
case "write":
|
|
578
|
+
this.#values[key] = structuredClone(payload.value);
|
|
579
|
+
break;
|
|
580
|
+
case "delete": {
|
|
581
|
+
const field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
582
|
+
delete this.#values[key];
|
|
583
|
+
if (field && Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
|
|
584
|
+
break;
|
|
540
585
|
}
|
|
586
|
+
case "clearCaches":
|
|
587
|
+
break;
|
|
588
|
+
case "reset":
|
|
589
|
+
for (const field of this.#definition.fields) {
|
|
590
|
+
delete this.#values[field.key];
|
|
591
|
+
if (Object.hasOwn(field, "defaultValue")) this.#values[field.key] = structuredClone(field.defaultValue);
|
|
592
|
+
}
|
|
593
|
+
break;
|
|
541
594
|
}
|
|
542
|
-
notify({ kind: "success", operation, module, key });
|
|
595
|
+
this.#notify({ kind: "success", operation, module: this.#module, key });
|
|
543
596
|
} catch (error) {
|
|
544
|
-
notify({ kind: "error", operation, module, key, message: error.message });
|
|
597
|
+
this.#notify({ kind: "error", operation, module: this.#module, key, message: error.message });
|
|
545
598
|
throw error;
|
|
546
599
|
} finally {
|
|
547
|
-
|
|
600
|
+
this.#saving = false;
|
|
548
601
|
}
|
|
549
602
|
}
|
|
550
|
-
return {
|
|
551
|
-
/**
|
|
552
|
-
* 从已导入的 JSON 创建新会话,只读取一次设置值。
|
|
553
|
-
* Create a session from imported JSON and read stored settings once.
|
|
554
|
-
* @param {string} module 模块标识 / Module identifier.
|
|
555
|
-
* @returns {Promise<import("./client.mjs").ModuleSnapshot>} 新快照 / New snapshot.
|
|
556
|
-
* @throws {Error} 读取失败、会话被替换或写入尚未完成 / Read failure, replaced session or unfinished write.
|
|
557
|
-
*/
|
|
558
|
-
async open(module) {
|
|
559
|
-
const binding = catalog.modules.get(module);
|
|
560
|
-
if (!binding) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
561
|
-
const previous = sessions.get(module);
|
|
562
|
-
if (previous?.saving) throw new Error("Cannot refresh while saving");
|
|
563
|
-
previous?.controller.abort();
|
|
564
|
-
const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
|
|
565
|
-
sessions.set(module, state);
|
|
566
|
-
try {
|
|
567
|
-
const definition = normalizeBoxJs(catalog, module);
|
|
568
|
-
const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
|
|
569
|
-
let subtree = response.status === 404 ? {} : await response.json();
|
|
570
|
-
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
571
|
-
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
572
|
-
if (sessions.get(module) !== state) throw new Error("Module session was replaced");
|
|
573
|
-
state.definition = definition;
|
|
574
|
-
for (const field of definition.fields) {
|
|
575
|
-
const stored = field.key
|
|
576
|
-
.split(".")
|
|
577
|
-
.slice(definition.settingsPath.length)
|
|
578
|
-
.reduce((parent, part) => Object(parent)[part], subtree);
|
|
579
|
-
const value = stored === undefined ? field.defaultValue : stored;
|
|
580
|
-
if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);
|
|
581
|
-
}
|
|
582
|
-
return snapshot(module);
|
|
583
|
-
} catch (error) {
|
|
584
|
-
if (sessions.get(module) === state) sessions.delete(module);
|
|
585
|
-
throw error;
|
|
586
|
-
}
|
|
587
|
-
},
|
|
588
|
-
snapshot,
|
|
589
|
-
/**
|
|
590
|
-
* 按需重新读取模块 Settings,不更新页面会话缓存。
|
|
591
|
-
* Reread module Settings on demand without updating the page-session cache.
|
|
592
|
-
* @param {string} module 已打开的模块 / Open module.
|
|
593
|
-
* @returns {Promise<unknown>} 设置值,缺失为 undefined / Settings value, or undefined when absent.
|
|
594
|
-
*/
|
|
595
|
-
async readSettings(module) {
|
|
596
|
-
const state = sessions.get(module);
|
|
597
|
-
if (!state?.definition) throw new Error("Open the module first");
|
|
598
|
-
const response = await send(`@${state.definition.storageKey}.${state.definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
|
|
599
|
-
return response.status === 404 ? undefined : response.json();
|
|
600
|
-
},
|
|
601
|
-
/**
|
|
602
|
-
* 按需读取模块 Caches,不自动读取其它设置。
|
|
603
|
-
* Read module Caches on demand without refreshing other settings.
|
|
604
|
-
* @param {string} module 已打开的模块 / Open module.
|
|
605
|
-
* @returns {Promise<unknown>} 缓存值,缺失为 undefined / Cache value, or undefined when absent.
|
|
606
|
-
*/
|
|
607
|
-
async readCaches(module) {
|
|
608
|
-
const state = sessions.get(module);
|
|
609
|
-
if (!state?.definition) throw new Error("Open the module first");
|
|
610
|
-
const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
|
|
611
|
-
return response.status === 404 ? undefined : response.json();
|
|
612
|
-
},
|
|
613
|
-
/**
|
|
614
|
-
* 删除整个 Caches 节点,成功后不追加 GET。
|
|
615
|
-
* Delete the entire Caches node without a follow-up GET.
|
|
616
|
-
* @param {string} module 已打开模块 / Open module.
|
|
617
|
-
* @returns {Promise<void>} 清理完成 / Cleanup completion.
|
|
618
|
-
*/
|
|
619
|
-
clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
|
|
620
|
-
/**
|
|
621
|
-
* 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
|
|
622
|
-
* Delete module persistence and reset the page cache using current BoxJS defaults.
|
|
623
|
-
* @param {string} module 已打开模块 / Open module.
|
|
624
|
-
* @returns {Promise<void>} 重置完成 / Reset completion.
|
|
625
|
-
*/
|
|
626
|
-
reset: module => change(module, module, "delete", undefined, "reset"),
|
|
627
|
-
/**
|
|
628
|
-
* 取消读取并清除会话,不撤销已发送的写入。
|
|
629
|
-
* Abort reads and clear the session without undoing dispatched writes.
|
|
630
|
-
* @param {string} module 模块标识 / Module identifier.
|
|
631
|
-
* @returns {void} 无返回值 / No return value.
|
|
632
|
-
*/
|
|
633
|
-
leave(module) {
|
|
634
|
-
sessions.get(module)?.controller.abort();
|
|
635
|
-
sessions.delete(module);
|
|
636
|
-
},
|
|
637
|
-
/**
|
|
638
|
-
* 写入单键并更新当前会话。
|
|
639
|
-
* Write one key and update the current session.
|
|
640
|
-
* @param {string} module 已打开模块 / Open module.
|
|
641
|
-
* @param {string} key 点分字段路径 / Dotted field path.
|
|
642
|
-
* @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
|
|
643
|
-
* @returns {Promise<void>} 写入完成 / Write completion.
|
|
644
|
-
*/
|
|
645
|
-
set: (module, key, value) => change(module, key, "set", value),
|
|
646
|
-
/**
|
|
647
|
-
* 删除单键覆盖值并显示默认值。
|
|
648
|
-
* Delete one override and display its default value.
|
|
649
|
-
* @param {string} module 已打开模块 / Open module.
|
|
650
|
-
* @param {string} key 点分字段路径 / Dotted field path.
|
|
651
|
-
* @returns {Promise<void>} 删除完成 / Delete completion.
|
|
652
|
-
*/
|
|
653
|
-
remove: (module, key) => change(module, key, "delete"),
|
|
654
|
-
};
|
|
655
603
|
}
|
|
656
604
|
|
|
657
605
|
/**
|
|
@@ -814,488 +762,515 @@ class Navigation extends EventTarget {
|
|
|
814
762
|
}
|
|
815
763
|
|
|
816
764
|
/**
|
|
817
|
-
*
|
|
818
|
-
*
|
|
819
|
-
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
820
|
-
* @param {import("../BoxJS.mjs").BoxJS} catalog 包内 BoxJS 目录 / Internal BoxJS catalog.
|
|
821
|
-
* @returns {import("./index.js").MountedPreferences} 面板生命周期句柄 / Panel lifecycle handle.
|
|
765
|
+
* 管理模块表单、导航、操作队列和短暂通知。
|
|
766
|
+
* Manage the module form, navigation, operation queue, and transient notifications.
|
|
822
767
|
*/
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
const window = document.defaultView;
|
|
827
|
-
const shell = element("div", "pp-panel");
|
|
828
|
-
shell.dataset.module = catalog.module.module;
|
|
829
|
-
const header = element("header", "pp-header");
|
|
830
|
-
const back = element("button", "pp-back", "‹");
|
|
831
|
-
back.setAttribute("aria-label", "返回");
|
|
832
|
-
back.type = "button";
|
|
833
|
-
const heading = element("h1", "pp-title", title);
|
|
834
|
-
const handlers = new Map();
|
|
835
|
-
const menuItems = [
|
|
836
|
-
{ id: "viewSettings", label: "查看设置" },
|
|
837
|
-
{ id: "viewCaches", label: "查看缓存" },
|
|
838
|
-
{ id: "clearCaches", label: "清空缓存", destructive: true },
|
|
839
|
-
{ id: "reset", label: "重置设置", destructive: true },
|
|
840
|
-
];
|
|
841
|
-
const menu = new ActionMenu(id => runAction(id));
|
|
842
|
-
const trailing = element("span", "pp-nav-spacer");
|
|
843
|
-
trailing.append(menu.element);
|
|
844
|
-
const viewport = element("div", "pp-viewport");
|
|
845
|
-
let toast;
|
|
846
|
-
header.append(back, heading, trailing);
|
|
847
|
-
shell.append(header, viewport);
|
|
848
|
-
root.append(shell);
|
|
849
|
-
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
850
|
-
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
851
|
-
const publishNavigation = () => {
|
|
852
|
-
const actions = handlers.size ? menuItems : [];
|
|
853
|
-
menu.update(actions, saving);
|
|
854
|
-
const frame = window.frameElement;
|
|
855
|
-
if (!frame?.dataset.preferencePanes) return;
|
|
856
|
-
frame.dispatchEvent(
|
|
857
|
-
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
858
|
-
detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled, actions },
|
|
859
|
-
}),
|
|
860
|
-
);
|
|
861
|
-
};
|
|
862
|
-
const onAction = event => {
|
|
863
|
-
if (!saving && handlers.has(event.detail)) runAction(event.detail);
|
|
864
|
-
};
|
|
865
|
-
window.frameElement?.addEventListener("preferencepanes:action", onAction);
|
|
866
|
-
let timer,
|
|
867
|
-
navigation,
|
|
868
|
-
generation = 0,
|
|
869
|
-
active = null,
|
|
870
|
-
saving = false,
|
|
871
|
-
destroyed = false;
|
|
872
|
-
/**
|
|
873
|
-
* 展示短暂通知,不刷新设置数据。
|
|
874
|
-
* Display a transient notification without refreshing settings.
|
|
875
|
-
* @param {{kind: "success" | "error", operation?: "write" | "delete" | "clearCaches" | "reset", message?: string}} event 操作结果 / Operation result.
|
|
876
|
-
* @returns {void} 无返回值 / No return value.
|
|
877
|
-
*/
|
|
878
|
-
const notify = event => {
|
|
879
|
-
if (destroyed) return;
|
|
880
|
-
let message;
|
|
881
|
-
switch (true) {
|
|
882
|
-
case event.kind === "error":
|
|
883
|
-
message = `操作失败:${event.message}`;
|
|
884
|
-
break;
|
|
885
|
-
case event.operation === "delete":
|
|
886
|
-
message = "删除成功";
|
|
887
|
-
break;
|
|
888
|
-
case event.operation === "clearCaches":
|
|
889
|
-
message = "Caches 已清空";
|
|
890
|
-
break;
|
|
891
|
-
case event.operation === "reset":
|
|
892
|
-
message = "设置已重置";
|
|
893
|
-
break;
|
|
894
|
-
default:
|
|
895
|
-
message = "修改成功";
|
|
896
|
-
break;
|
|
897
|
-
}
|
|
898
|
-
// 宿主接管时不创建网页 Toast,也不运行其计时器。
|
|
899
|
-
// A host-owned notice creates no web Toast and starts no local timer.
|
|
900
|
-
const frame = window.frameElement;
|
|
901
|
-
if (frame && !frame.dispatchEvent(new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:notice", { cancelable: true, detail: { kind: event.kind, message } }))) return;
|
|
902
|
-
if (!toast) {
|
|
903
|
-
toast = element("div", "pp-toast");
|
|
904
|
-
toast.setAttribute("role", "status");
|
|
905
|
-
shell.append(toast);
|
|
906
|
-
}
|
|
907
|
-
toast.textContent = message;
|
|
908
|
-
toast.dataset.kind = event.kind;
|
|
909
|
-
toast.hidden = false;
|
|
910
|
-
clearTimeout(timer);
|
|
911
|
-
timer = setTimeout(() => {
|
|
912
|
-
toast.hidden = true;
|
|
913
|
-
}, 2400);
|
|
914
|
-
};
|
|
915
|
-
const client = createPreferencesClient({ catalog, notify });
|
|
916
|
-
/**
|
|
917
|
-
* 两种菜单入口共用异步错误处理,包含宿主确认框错误。
|
|
918
|
-
* Share async error handling between both menus, including host-dialog errors.
|
|
919
|
-
* @param {string} id 操作标识 / Action identifier.
|
|
920
|
-
* @returns {Promise<void>} 操作已处理 / Action handled.
|
|
921
|
-
*/
|
|
922
|
-
async function runAction(id) {
|
|
923
|
-
try {
|
|
924
|
-
await handlers.get(id)();
|
|
925
|
-
} catch (error) {
|
|
926
|
-
notify({ kind: "error", message: error.message });
|
|
927
|
-
}
|
|
928
|
-
}
|
|
768
|
+
class PreferencesPanel {
|
|
769
|
+
#release;
|
|
770
|
+
|
|
929
771
|
/**
|
|
930
|
-
*
|
|
931
|
-
*
|
|
932
|
-
* @param {
|
|
933
|
-
* @
|
|
772
|
+
* 挂载 API 返回的模块模型表单。
|
|
773
|
+
* Mount the module form returned by the API.
|
|
774
|
+
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
775
|
+
* @param {import("../index.js").ModuleModel & {definition: import("../index.js").ModuleDefinition}} model 已规范化模块模型 / Normalized module model.
|
|
934
776
|
*/
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
active = module;
|
|
938
|
-
back.disabled = window.history.length <= 1;
|
|
939
|
-
heading.textContent = module;
|
|
940
|
-
publishNavigation();
|
|
941
|
-
viewport.replaceChildren(statusView("读取设置…"));
|
|
942
|
-
try {
|
|
943
|
-
await client.open(module);
|
|
944
|
-
if (version === generation) controls();
|
|
945
|
-
} catch (error) {
|
|
946
|
-
if (version !== generation) return;
|
|
947
|
-
viewport.replaceChildren(statusView(`加载失败:${error.message}`, () => open(module)));
|
|
948
|
-
publishNavigation();
|
|
949
|
-
}
|
|
777
|
+
constructor(root, model) {
|
|
778
|
+
this.#release = this.#mount(root, model);
|
|
950
779
|
}
|
|
780
|
+
|
|
951
781
|
/**
|
|
952
|
-
*
|
|
953
|
-
* Build
|
|
954
|
-
* @
|
|
782
|
+
* 建立面板 DOM、交互和会话,并返回其释放操作。
|
|
783
|
+
* Build panel DOM, interactions, and session, then return its release operation.
|
|
784
|
+
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
785
|
+
* @param {import("../index.js").ModuleModel & {definition: import("../index.js").ModuleDefinition}} model 已规范化模块模型 / Normalized module model.
|
|
786
|
+
* @returns {() => void} 释放操作 / Release operation.
|
|
955
787
|
*/
|
|
956
|
-
|
|
957
|
-
const { definition
|
|
958
|
-
|
|
959
|
-
const
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
const
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
788
|
+
#mount(root, model) {
|
|
789
|
+
const { definition } = model;
|
|
790
|
+
const title = definition.metadata?.name ?? definition.module;
|
|
791
|
+
const document = root.ownerDocument;
|
|
792
|
+
const window = document.defaultView;
|
|
793
|
+
const shell = element("div", "pp-panel");
|
|
794
|
+
shell.dataset.module = definition.module;
|
|
795
|
+
const header = element("header", "pp-header");
|
|
796
|
+
const back = element("button", "pp-back", "‹");
|
|
797
|
+
back.setAttribute("aria-label", "返回");
|
|
798
|
+
back.type = "button";
|
|
799
|
+
const heading = element("h1", "pp-title", title);
|
|
800
|
+
const handlers = new Map();
|
|
801
|
+
const menuItems = [
|
|
802
|
+
{ id: "viewSettings", label: "查看设置" },
|
|
803
|
+
{ id: "viewCaches", label: "查看缓存" },
|
|
804
|
+
{ id: "clearCaches", label: "清空缓存", destructive: true },
|
|
805
|
+
{ id: "reset", label: "重置设置", destructive: true },
|
|
806
|
+
];
|
|
807
|
+
const menu = new ActionMenu(id => runAction(id));
|
|
808
|
+
const trailing = element("span", "pp-nav-spacer");
|
|
809
|
+
trailing.append(menu.element);
|
|
810
|
+
const viewport = element("div", "pp-viewport");
|
|
811
|
+
let toast;
|
|
812
|
+
header.append(back, heading, trailing);
|
|
813
|
+
shell.append(header, viewport);
|
|
814
|
+
root.append(shell);
|
|
815
|
+
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
816
|
+
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
817
|
+
const publishNavigation = () => {
|
|
818
|
+
const actions = handlers.size ? menuItems : [];
|
|
819
|
+
menu.update(actions, saving);
|
|
820
|
+
const frame = window.frameElement;
|
|
821
|
+
if (!frame?.dataset.preferencePanes) return;
|
|
822
|
+
frame.dispatchEvent(
|
|
823
|
+
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
824
|
+
detail: { title: heading.textContent, module: definition.module, busy: saving, canGoBack: !back.disabled, actions },
|
|
825
|
+
}),
|
|
826
|
+
);
|
|
827
|
+
};
|
|
828
|
+
const onAction = event => {
|
|
829
|
+
if (!saving && handlers.has(event.detail)) runAction(event.detail);
|
|
830
|
+
};
|
|
831
|
+
window.frameElement?.addEventListener("preferencepanes:action", onAction);
|
|
832
|
+
let timer,
|
|
833
|
+
navigation,
|
|
834
|
+
generation = 0,
|
|
835
|
+
active = null,
|
|
836
|
+
saving = false,
|
|
837
|
+
destroyed = false;
|
|
971
838
|
/**
|
|
972
|
-
*
|
|
973
|
-
*
|
|
839
|
+
* 展示短暂通知,不刷新设置数据。
|
|
840
|
+
* Display a transient notification without refreshing settings.
|
|
841
|
+
* @param {{kind: "success" | "error", operation?: "write" | "delete" | "clearCaches" | "reset", message?: string}} event 操作结果 / Operation result.
|
|
974
842
|
* @returns {void} 无返回值 / No return value.
|
|
975
843
|
*/
|
|
976
|
-
const
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
844
|
+
const notify = event => {
|
|
845
|
+
if (destroyed) return;
|
|
846
|
+
let message;
|
|
847
|
+
switch (true) {
|
|
848
|
+
case event.kind === "error":
|
|
849
|
+
message = `操作失败:${event.message}`;
|
|
850
|
+
break;
|
|
851
|
+
case event.operation === "delete":
|
|
852
|
+
message = "删除成功";
|
|
853
|
+
break;
|
|
854
|
+
case event.operation === "clearCaches":
|
|
855
|
+
message = "Caches 已清空";
|
|
856
|
+
break;
|
|
857
|
+
case event.operation === "reset":
|
|
858
|
+
message = "设置已重置";
|
|
859
|
+
break;
|
|
860
|
+
default:
|
|
861
|
+
message = "修改成功";
|
|
862
|
+
break;
|
|
863
|
+
}
|
|
864
|
+
// 宿主接管时不创建网页 Toast,也不运行其计时器。
|
|
865
|
+
// A host-owned notice creates no web Toast and starts no local timer.
|
|
866
|
+
const frame = window.frameElement;
|
|
867
|
+
if (frame && !frame.dispatchEvent(new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:notice", { cancelable: true, detail: { kind: event.kind, message } }))) return;
|
|
868
|
+
if (!toast) {
|
|
869
|
+
toast = element("div", "pp-toast");
|
|
870
|
+
toast.setAttribute("role", "status");
|
|
871
|
+
shell.append(toast);
|
|
872
|
+
}
|
|
873
|
+
toast.textContent = message;
|
|
874
|
+
toast.dataset.kind = event.kind;
|
|
875
|
+
toast.hidden = false;
|
|
876
|
+
clearTimeout(timer);
|
|
877
|
+
timer = setTimeout(() => {
|
|
878
|
+
toast.hidden = true;
|
|
879
|
+
}, 2400);
|
|
981
880
|
};
|
|
881
|
+
const client = new PreferencesClient({ model, definition, notify });
|
|
982
882
|
/**
|
|
983
|
-
*
|
|
984
|
-
*
|
|
985
|
-
* @param {
|
|
986
|
-
* @
|
|
987
|
-
* @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.
|
|
988
|
-
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
883
|
+
* 两种菜单入口共用异步错误处理,包含宿主确认框错误。
|
|
884
|
+
* Share async error handling between both menus, including host-dialog errors.
|
|
885
|
+
* @param {string} id 操作标识 / Action identifier.
|
|
886
|
+
* @returns {Promise<void>} 操作已处理 / Action handled.
|
|
989
887
|
*/
|
|
990
|
-
function
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
return (queue = queue
|
|
996
|
-
.then(action)
|
|
997
|
-
.then(() => {
|
|
998
|
-
if (!destroyed) success();
|
|
999
|
-
})
|
|
1000
|
-
.catch(() => {
|
|
1001
|
-
/* 请求层已通知错误。
|
|
1002
|
-
* The request layer has already reported the error. */
|
|
1003
|
-
if (!destroyed) failure();
|
|
1004
|
-
})
|
|
1005
|
-
.finally(() => {
|
|
1006
|
-
pendingWrites--;
|
|
1007
|
-
saving = pendingWrites > 0;
|
|
1008
|
-
if (destroyed && !saving) client.leave(active);
|
|
1009
|
-
back.disabled = saving || !navigation.canGoBack;
|
|
1010
|
-
publishNavigation();
|
|
1011
|
-
}));
|
|
1012
|
-
}
|
|
1013
|
-
const metadata = definition.metadata;
|
|
1014
|
-
if (metadata) {
|
|
1015
|
-
const info = element("div", "pp-module-info");
|
|
1016
|
-
const details = element("div", "pp-module-details");
|
|
1017
|
-
for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(element("p", "pp-description", description));
|
|
1018
|
-
if (metadata.repo) {
|
|
1019
|
-
const link = element("a", "pp-module-source", "项目主页");
|
|
1020
|
-
link.href = resourceURL(metadata.repo);
|
|
1021
|
-
link.target = "_blank";
|
|
1022
|
-
link.rel = "noopener noreferrer";
|
|
1023
|
-
details.append(link);
|
|
888
|
+
async function runAction(id) {
|
|
889
|
+
try {
|
|
890
|
+
await handlers.get(id)();
|
|
891
|
+
} catch (error) {
|
|
892
|
+
notify({ kind: "error", message: error.message });
|
|
1024
893
|
}
|
|
1025
|
-
info.append(details);
|
|
1026
|
-
view.append(info);
|
|
1027
894
|
}
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
895
|
+
/**
|
|
896
|
+
* 打开模块并忽略已过期的异步结果。
|
|
897
|
+
* Open a module and ignore stale asynchronous results.
|
|
898
|
+
* @param {string} module 模块标识 / Module identifier.
|
|
899
|
+
* @returns {Promise<void>} 视图加载完成,失败显示错误视图 / View load completion; failures display an error view.
|
|
900
|
+
*/
|
|
901
|
+
async function open(module) {
|
|
902
|
+
const version = ++generation;
|
|
903
|
+
active = module;
|
|
904
|
+
back.disabled = window.history.length <= 1;
|
|
905
|
+
heading.textContent = module;
|
|
906
|
+
publishNavigation();
|
|
907
|
+
viewport.replaceChildren(statusView("读取设置…"));
|
|
908
|
+
try {
|
|
909
|
+
if (version === generation) controls();
|
|
910
|
+
} catch (error) {
|
|
911
|
+
if (version !== generation) return;
|
|
912
|
+
viewport.replaceChildren(statusView(`加载失败:${error.message}`, () => open(module)));
|
|
913
|
+
publishNavigation();
|
|
1037
914
|
}
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
915
|
+
}
|
|
916
|
+
/**
|
|
917
|
+
* 从会话快照创建控件与操作按钮,不重新读取网络配置。
|
|
918
|
+
* Build controls and actions from the session snapshot without fetching config again.
|
|
919
|
+
* @returns {void} 无返回值 / No return value.
|
|
920
|
+
*/
|
|
921
|
+
function controls() {
|
|
922
|
+
const { definition, values } = client.snapshot();
|
|
923
|
+
heading.textContent = definition.metadata?.name || active;
|
|
924
|
+
const view = element("section", "pp-fields");
|
|
1045
925
|
/**
|
|
1046
|
-
*
|
|
1047
|
-
*
|
|
1048
|
-
* @type {() =>
|
|
926
|
+
* 挂载后执行的多行高度更新
|
|
927
|
+
* Textarea sizing callbacks run after mounting.
|
|
928
|
+
* @type {Array<() => void>}
|
|
1049
929
|
*/
|
|
1050
|
-
|
|
930
|
+
const growingInputs = [];
|
|
931
|
+
const editors = new Map();
|
|
932
|
+
const summaries = [];
|
|
933
|
+
const groups = new Map();
|
|
934
|
+
let queue = Promise.resolve(),
|
|
935
|
+
pendingWrites = 0;
|
|
1051
936
|
/**
|
|
1052
|
-
*
|
|
1053
|
-
*
|
|
1054
|
-
* @
|
|
937
|
+
* 导航组件处理页面切换,表单只更新当前标题与返回按钮。
|
|
938
|
+
* Let navigation own transitions; the form only updates the title and back button.
|
|
939
|
+
* @returns {void} 无返回值 / No return value.
|
|
1055
940
|
*/
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
field.options
|
|
1092
|
-
.filter(option => Array.isArray(value) && value.includes(option.key))
|
|
1093
|
-
.map(option => option.label)
|
|
1094
|
-
.join("、") || "未选择";
|
|
1095
|
-
};
|
|
1096
|
-
summaries.push(refresh);
|
|
1097
|
-
refresh();
|
|
1098
|
-
link.onclick = () => navigation.open(field.key);
|
|
1099
|
-
row.addEventListener("click", event => {
|
|
1100
|
-
if (!link.contains(event.target)) link.click();
|
|
1101
|
-
});
|
|
1102
|
-
const inputs = field.options.map(option => {
|
|
1103
|
-
const label = settingRow("label");
|
|
1104
|
-
label.classList.add("pp-choice");
|
|
1105
|
-
label.textContent = option.label;
|
|
1106
|
-
const input = element("input", "");
|
|
1107
|
-
input.type = "checkbox";
|
|
1108
|
-
input.setAttribute("aria-label", option.label);
|
|
1109
|
-
label.append(input);
|
|
1110
|
-
choices.append(label);
|
|
1111
|
-
return { input, key: option.key };
|
|
941
|
+
const updateNavigation = () => {
|
|
942
|
+
const editor = editors.get(navigation.current);
|
|
943
|
+
heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
|
|
944
|
+
back.disabled = saving || !navigation.canGoBack;
|
|
945
|
+
publishNavigation();
|
|
946
|
+
};
|
|
947
|
+
/**
|
|
948
|
+
* 串行执行模块操作,保持输入可编辑。
|
|
949
|
+
* Serialize module actions while keeping inputs editable.
|
|
950
|
+
* @param {() => Promise<void>} action 请求或写入 / Request or mutation.
|
|
951
|
+
* @param {() => void} success 成功后的局部更新 / Local update after success.
|
|
952
|
+
* @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.
|
|
953
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
954
|
+
*/
|
|
955
|
+
function perform(action, success, failure = () => {}) {
|
|
956
|
+
pendingWrites++;
|
|
957
|
+
saving = true;
|
|
958
|
+
back.disabled = true;
|
|
959
|
+
publishNavigation();
|
|
960
|
+
queue = queue
|
|
961
|
+
.then(action)
|
|
962
|
+
.then(() => {
|
|
963
|
+
if (!destroyed) success();
|
|
964
|
+
})
|
|
965
|
+
.catch(() => {
|
|
966
|
+
/* 请求层已通知错误。
|
|
967
|
+
* The request layer has already reported the error. */
|
|
968
|
+
if (!destroyed) failure();
|
|
969
|
+
})
|
|
970
|
+
.finally(() => {
|
|
971
|
+
pendingWrites--;
|
|
972
|
+
saving = pendingWrites > 0;
|
|
973
|
+
if (destroyed && !saving) client.leave();
|
|
974
|
+
back.disabled = saving || !navigation.canGoBack;
|
|
975
|
+
publishNavigation();
|
|
1112
976
|
});
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
977
|
+
return queue;
|
|
978
|
+
}
|
|
979
|
+
const metadata = definition.metadata;
|
|
980
|
+
if (metadata) {
|
|
981
|
+
const info = element("div", "pp-module-info");
|
|
982
|
+
const details = element("div", "pp-module-details");
|
|
983
|
+
for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(element("p", "pp-description", description));
|
|
984
|
+
if (metadata.repo) {
|
|
985
|
+
const link = element("a", "pp-module-source", "项目主页");
|
|
986
|
+
link.href = resourceURL(metadata.repo);
|
|
987
|
+
link.target = "_blank";
|
|
988
|
+
link.rel = "noopener noreferrer";
|
|
989
|
+
details.append(link);
|
|
1118
990
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
991
|
+
info.append(details);
|
|
992
|
+
view.append(info);
|
|
993
|
+
}
|
|
994
|
+
for (const field of definition.fields) {
|
|
995
|
+
const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
|
|
996
|
+
const group = match?.[1] ?? "通用";
|
|
997
|
+
if (!groups.has(group)) {
|
|
998
|
+
const section = element("section", "pp-group");
|
|
999
|
+
const rows = element("div", "pp-rows");
|
|
1000
|
+
section.append(element("h2", "pp-group-title", group), rows);
|
|
1001
|
+
groups.set(group, rows);
|
|
1002
|
+
view.append(section);
|
|
1131
1003
|
}
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1004
|
+
const row = settingRow("div");
|
|
1005
|
+
row.classList.add("pp-field");
|
|
1006
|
+
const label = element("div", "pp-label");
|
|
1007
|
+
label.append(element("span", "pp-field-name", match?.[2] ?? field.name));
|
|
1008
|
+
if (field.description) label.append(element("span", "pp-field-description", field.description));
|
|
1009
|
+
row.append(label);
|
|
1010
|
+
const value = values[field.key];
|
|
1011
|
+
/**
|
|
1012
|
+
* 读取尚未保存的输入
|
|
1013
|
+
* Read the unsaved input.
|
|
1014
|
+
* @type {() => unknown}
|
|
1015
|
+
*/
|
|
1016
|
+
let read;
|
|
1017
|
+
/**
|
|
1018
|
+
* 更新当前控件
|
|
1019
|
+
* Update the current control.
|
|
1020
|
+
* @type {(value: unknown) => void}
|
|
1021
|
+
*/
|
|
1022
|
+
let write;
|
|
1023
|
+
let inputContainer = row;
|
|
1024
|
+
let eventName = "change";
|
|
1025
|
+
switch (true) {
|
|
1026
|
+
case Boolean(field.options) && field.type !== "array": {
|
|
1027
|
+
const select = element("select", "");
|
|
1028
|
+
select.setAttribute("aria-label", field.name);
|
|
1029
|
+
field.options.forEach((option, index) => {
|
|
1030
|
+
const item = element("option", "", option.label);
|
|
1031
|
+
item.value = String(index);
|
|
1032
|
+
select.append(item);
|
|
1033
|
+
});
|
|
1034
|
+
write = value => {
|
|
1035
|
+
select.selectedIndex = field.options.findIndex(option => option.key === value);
|
|
1036
|
+
};
|
|
1037
|
+
row.append(fieldControl(select));
|
|
1038
|
+
read = () => field.options[select.selectedIndex]?.key;
|
|
1039
|
+
break;
|
|
1155
1040
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1041
|
+
case field.type === "array" && Boolean(field.options): {
|
|
1042
|
+
const page = element("section", "pp-choice-page");
|
|
1043
|
+
if (field.description) page.append(element("p", "pp-description", field.description));
|
|
1044
|
+
const choices = element("div", "pp-rows");
|
|
1045
|
+
page.append(choices);
|
|
1046
|
+
inputContainer = choices;
|
|
1047
|
+
editors.set(field.key, { node: page, title: match?.[2] ?? field.name });
|
|
1048
|
+
const summary = element("span", "pp-summary");
|
|
1049
|
+
const link = element("button", "pp-choice-link");
|
|
1050
|
+
link.type = "button";
|
|
1051
|
+
link.setAttribute("aria-label", field.name);
|
|
1052
|
+
link.append(summary, element("span", "pp-chevron", "›"));
|
|
1053
|
+
row.append(link);
|
|
1054
|
+
const refresh = () => {
|
|
1055
|
+
const value = client.snapshot().values[field.key];
|
|
1056
|
+
summary.textContent =
|
|
1057
|
+
field.options
|
|
1058
|
+
.filter(option => Array.isArray(value) && value.includes(option.key))
|
|
1059
|
+
.map(option => option.label)
|
|
1060
|
+
.join("、") || "未选择";
|
|
1061
|
+
};
|
|
1062
|
+
summaries.push(refresh);
|
|
1063
|
+
refresh();
|
|
1064
|
+
link.onclick = () => navigation.open(field.key);
|
|
1065
|
+
row.addEventListener("click", event => {
|
|
1066
|
+
if (!link.contains(event.target)) link.click();
|
|
1067
|
+
});
|
|
1068
|
+
const inputs = field.options.map(option => {
|
|
1069
|
+
const label = settingRow("label");
|
|
1070
|
+
label.classList.add("pp-choice");
|
|
1071
|
+
label.textContent = option.label;
|
|
1072
|
+
const input = element("input", "");
|
|
1073
|
+
input.type = "checkbox";
|
|
1074
|
+
input.setAttribute("aria-label", option.label);
|
|
1075
|
+
label.append(input);
|
|
1076
|
+
choices.append(label);
|
|
1077
|
+
return { input, key: option.key };
|
|
1078
|
+
});
|
|
1079
|
+
read = () => inputs.filter(option => option.input.checked).map(option => option.key);
|
|
1080
|
+
write = value => {
|
|
1081
|
+
for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);
|
|
1082
|
+
};
|
|
1083
|
+
break;
|
|
1084
|
+
}
|
|
1085
|
+
case field.type === "boolean": {
|
|
1086
|
+
const toggle = element("input", "pp-switch");
|
|
1087
|
+
toggle.type = "checkbox";
|
|
1088
|
+
toggle.setAttribute("switch", "");
|
|
1089
|
+
toggle.setAttribute("role", "switch");
|
|
1090
|
+
toggle.setAttribute("aria-label", field.name);
|
|
1091
|
+
write = value => {
|
|
1092
|
+
toggle.checked = value === true;
|
|
1093
|
+
};
|
|
1094
|
+
read = () => toggle.checked;
|
|
1095
|
+
row.append(toggle);
|
|
1096
|
+
break;
|
|
1097
|
+
}
|
|
1098
|
+
default: {
|
|
1099
|
+
const multiline = field.control === "textarea" || field.type === "array";
|
|
1100
|
+
const input = element(multiline ? "textarea" : "input", "");
|
|
1101
|
+
if (multiline) row.classList.add("pp-multiline");
|
|
1102
|
+
input.setAttribute("aria-label", field.name);
|
|
1103
|
+
if (field.placeholder) input.placeholder = field.placeholder;
|
|
1104
|
+
if (multiline && field.rows) input.rows = field.rows;
|
|
1105
|
+
/**
|
|
1106
|
+
* 在挂载后根据内容调整高度,同时保留基础行数。
|
|
1107
|
+
* Size mounted textareas to their contents while retaining baseline rows.
|
|
1108
|
+
* @returns {void} 无返回值 / No return value.
|
|
1109
|
+
*/
|
|
1110
|
+
const grow = () => {
|
|
1111
|
+
if (!multiline || !field.autoGrow || !input.isConnected) return;
|
|
1112
|
+
input.style.height = "auto";
|
|
1113
|
+
const baseline = input.getBoundingClientRect().height;
|
|
1114
|
+
const style = window.getComputedStyle(input);
|
|
1115
|
+
const borders = Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
|
|
1116
|
+
input.style.height = `${Math.max(baseline, input.scrollHeight + borders)}px`;
|
|
1117
|
+
};
|
|
1118
|
+
if (multiline && field.autoGrow) {
|
|
1119
|
+
input.addEventListener("input", grow);
|
|
1120
|
+
growingInputs.push(grow);
|
|
1170
1121
|
}
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1122
|
+
eventName = "input";
|
|
1123
|
+
if (!multiline) input.type = field.type === "number" ? "number" : "text";
|
|
1124
|
+
write = value => {
|
|
1125
|
+
input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
|
|
1126
|
+
grow();
|
|
1127
|
+
};
|
|
1128
|
+
read = () => {
|
|
1129
|
+
switch (field.type) {
|
|
1130
|
+
case "array":
|
|
1131
|
+
return JSON.parse(input.value);
|
|
1132
|
+
case "number":
|
|
1133
|
+
return input.value === "" ? Number.NaN : Number(input.value);
|
|
1134
|
+
default:
|
|
1135
|
+
return input.value;
|
|
1136
|
+
}
|
|
1137
|
+
};
|
|
1138
|
+
row.append(fieldControl(input));
|
|
1139
|
+
break;
|
|
1140
|
+
}
|
|
1174
1141
|
}
|
|
1142
|
+
write(value);
|
|
1143
|
+
let inputVersion = 0;
|
|
1144
|
+
inputContainer.addEventListener(eventName, event => {
|
|
1145
|
+
if (event.isComposing) return;
|
|
1146
|
+
const version = ++inputVersion;
|
|
1147
|
+
let value;
|
|
1148
|
+
try {
|
|
1149
|
+
value = read();
|
|
1150
|
+
} catch (error) {
|
|
1151
|
+
notify({ kind: "error", message: error.message });
|
|
1152
|
+
return;
|
|
1153
|
+
}
|
|
1154
|
+
const restore = () => {
|
|
1155
|
+
if (version === inputVersion) write(client.snapshot().values[field.key]);
|
|
1156
|
+
};
|
|
1157
|
+
perform(
|
|
1158
|
+
() => {
|
|
1159
|
+
if (!validValue(field, value)) {
|
|
1160
|
+
const error = new TypeError("Invalid setting value");
|
|
1161
|
+
notify({ kind: "error", operation: "write", key: field.key, message: error.message });
|
|
1162
|
+
throw error;
|
|
1163
|
+
}
|
|
1164
|
+
return client.set(field.key, value);
|
|
1165
|
+
},
|
|
1166
|
+
() => {
|
|
1167
|
+
for (const refresh of summaries) refresh();
|
|
1168
|
+
},
|
|
1169
|
+
restore,
|
|
1170
|
+
);
|
|
1171
|
+
});
|
|
1172
|
+
if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
|
|
1173
|
+
groups.get(group).append(row);
|
|
1175
1174
|
}
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1175
|
+
const settingsPage = element("section", "pp-settings-page");
|
|
1176
|
+
const settingsOutput = element("pre", "pp-cache");
|
|
1177
|
+
settingsOutput.setAttribute("aria-label", "Settings 内容");
|
|
1178
|
+
settingsPage.append(settingsOutput);
|
|
1179
|
+
editors.set("$settings", { node: settingsPage, title: "设置" });
|
|
1180
|
+
handlers.set("viewSettings", () => {
|
|
1181
|
+
if (saving) return;
|
|
1182
1182
|
let value;
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
perform(
|
|
1193
|
-
() => client.set(module, field.key, value),
|
|
1183
|
+
return perform(
|
|
1184
|
+
async () => {
|
|
1185
|
+
try {
|
|
1186
|
+
value = await client.readSettings();
|
|
1187
|
+
} catch (error) {
|
|
1188
|
+
notify({ kind: "error", message: error.message });
|
|
1189
|
+
throw error;
|
|
1190
|
+
}
|
|
1191
|
+
},
|
|
1194
1192
|
() => {
|
|
1195
|
-
|
|
1193
|
+
settingsOutput.textContent = value === undefined ? "暂无设置" : JSON.stringify(value, null, 2);
|
|
1194
|
+
navigation.open("$settings");
|
|
1196
1195
|
},
|
|
1197
|
-
restore,
|
|
1198
1196
|
);
|
|
1199
1197
|
});
|
|
1200
|
-
|
|
1201
|
-
|
|
1198
|
+
const cachePage = element("section", "pp-cache-page");
|
|
1199
|
+
const output = element("pre", "pp-cache");
|
|
1200
|
+
output.textContent = "暂无缓存";
|
|
1201
|
+
output.setAttribute("aria-label", "Caches 内容");
|
|
1202
|
+
cachePage.append(output);
|
|
1203
|
+
editors.set("$caches", { node: cachePage, title: "缓存" });
|
|
1204
|
+
handlers.set("viewCaches", () => {
|
|
1205
|
+
if (saving) return;
|
|
1206
|
+
let value;
|
|
1207
|
+
return perform(
|
|
1208
|
+
async () => {
|
|
1209
|
+
try {
|
|
1210
|
+
value = await client.readCaches();
|
|
1211
|
+
} catch (error) {
|
|
1212
|
+
notify({ kind: "error", message: error.message });
|
|
1213
|
+
throw error;
|
|
1214
|
+
}
|
|
1215
|
+
},
|
|
1216
|
+
() => {
|
|
1217
|
+
output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
|
|
1218
|
+
navigation.open("$caches");
|
|
1219
|
+
},
|
|
1220
|
+
);
|
|
1221
|
+
});
|
|
1222
|
+
handlers.set("clearCaches", async () => {
|
|
1223
|
+
if (saving) return;
|
|
1224
|
+
if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;
|
|
1225
|
+
return perform(
|
|
1226
|
+
() => client.clearCaches(),
|
|
1227
|
+
() => {
|
|
1228
|
+
output.textContent = "暂无缓存";
|
|
1229
|
+
},
|
|
1230
|
+
);
|
|
1231
|
+
});
|
|
1232
|
+
handlers.set("reset", async () => {
|
|
1233
|
+
if (saving) return;
|
|
1234
|
+
if (!(await requestConfirmation(window, `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;
|
|
1235
|
+
return perform(() => client.reset(), controls);
|
|
1236
|
+
});
|
|
1237
|
+
navigation?.destroy();
|
|
1238
|
+
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
1239
|
+
navigation.addEventListener("change", updateNavigation);
|
|
1240
|
+
for (const grow of growingInputs) grow();
|
|
1241
|
+
updateNavigation();
|
|
1202
1242
|
}
|
|
1203
|
-
const settingsPage = element("section", "pp-settings-page");
|
|
1204
|
-
const settingsOutput = element("pre", "pp-cache");
|
|
1205
|
-
settingsOutput.setAttribute("aria-label", "Settings 内容");
|
|
1206
|
-
settingsPage.append(settingsOutput);
|
|
1207
|
-
editors.set("$settings", { node: settingsPage, title: "设置" });
|
|
1208
|
-
handlers.set("viewSettings", () => {
|
|
1209
|
-
if (saving) return;
|
|
1210
|
-
let value;
|
|
1211
|
-
return perform(
|
|
1212
|
-
async () => {
|
|
1213
|
-
try {
|
|
1214
|
-
value = await client.readSettings(active);
|
|
1215
|
-
} catch (error) {
|
|
1216
|
-
notify({ kind: "error", message: error.message });
|
|
1217
|
-
throw error;
|
|
1218
|
-
}
|
|
1219
|
-
},
|
|
1220
|
-
() => {
|
|
1221
|
-
settingsOutput.textContent = value === undefined ? "暂无设置" : JSON.stringify(value, null, 2);
|
|
1222
|
-
navigation.open("$settings");
|
|
1223
|
-
},
|
|
1224
|
-
);
|
|
1225
|
-
});
|
|
1226
|
-
const cachePage = element("section", "pp-cache-page");
|
|
1227
|
-
const output = element("pre", "pp-cache");
|
|
1228
|
-
output.textContent = "暂无缓存";
|
|
1229
|
-
output.setAttribute("aria-label", "Caches 内容");
|
|
1230
|
-
cachePage.append(output);
|
|
1231
|
-
editors.set("$caches", { node: cachePage, title: "缓存" });
|
|
1232
|
-
handlers.set("viewCaches", () => {
|
|
1233
|
-
if (saving) return;
|
|
1234
|
-
let value;
|
|
1235
|
-
return perform(
|
|
1236
|
-
async () => {
|
|
1237
|
-
try {
|
|
1238
|
-
value = await client.readCaches(active);
|
|
1239
|
-
} catch (error) {
|
|
1240
|
-
notify({ kind: "error", message: error.message });
|
|
1241
|
-
throw error;
|
|
1242
|
-
}
|
|
1243
|
-
},
|
|
1244
|
-
() => {
|
|
1245
|
-
output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
|
|
1246
|
-
navigation.open("$caches");
|
|
1247
|
-
},
|
|
1248
|
-
);
|
|
1249
|
-
});
|
|
1250
|
-
handlers.set("clearCaches", async () => {
|
|
1251
|
-
if (saving) return;
|
|
1252
|
-
if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;
|
|
1253
|
-
return perform(
|
|
1254
|
-
() => client.clearCaches(active),
|
|
1255
|
-
() => {
|
|
1256
|
-
output.textContent = "暂无缓存";
|
|
1257
|
-
},
|
|
1258
|
-
);
|
|
1259
|
-
});
|
|
1260
|
-
handlers.set("reset", async () => {
|
|
1261
|
-
if (saving) return;
|
|
1262
|
-
if (!(await requestConfirmation(window, `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;
|
|
1263
|
-
return perform(() => client.reset(active), controls);
|
|
1264
|
-
});
|
|
1265
|
-
navigation?.destroy();
|
|
1266
|
-
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
1267
|
-
navigation.addEventListener("change", updateNavigation);
|
|
1268
|
-
for (const grow of growingInputs) grow();
|
|
1269
|
-
updateNavigation();
|
|
1270
|
-
}
|
|
1271
|
-
/**
|
|
1272
|
-
* 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。
|
|
1273
|
-
* Loaded forms delegate back to navigation; loading views can return to the previous document.
|
|
1274
|
-
* @returns {void} 无返回值 / No return value.
|
|
1275
|
-
*/
|
|
1276
|
-
back.onclick = () => {
|
|
1277
|
-
if (saving) return;
|
|
1278
|
-
if (navigation) navigation.back();
|
|
1279
|
-
else window.history.back();
|
|
1280
|
-
};
|
|
1281
|
-
open(catalog.module.module);
|
|
1282
|
-
return {
|
|
1283
1243
|
/**
|
|
1284
|
-
*
|
|
1285
|
-
*
|
|
1244
|
+
* 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。
|
|
1245
|
+
* Loaded forms delegate back to navigation; loading views can return to the previous document.
|
|
1286
1246
|
* @returns {void} 无返回值 / No return value.
|
|
1287
1247
|
*/
|
|
1288
|
-
|
|
1248
|
+
back.onclick = () => {
|
|
1249
|
+
if (saving) return;
|
|
1250
|
+
if (navigation) navigation.back();
|
|
1251
|
+
else window.history.back();
|
|
1252
|
+
};
|
|
1253
|
+
open(definition.module);
|
|
1254
|
+
return () => {
|
|
1289
1255
|
destroyed = true;
|
|
1290
1256
|
menu.destroy();
|
|
1291
1257
|
window.frameElement?.removeEventListener("preferencepanes:action", onAction);
|
|
1292
1258
|
navigation?.destroy();
|
|
1293
1259
|
generation++;
|
|
1294
|
-
if (active && !saving) client.leave(
|
|
1260
|
+
if (active && !saving) client.leave();
|
|
1295
1261
|
clearTimeout(timer);
|
|
1296
1262
|
shell.remove();
|
|
1297
|
-
}
|
|
1298
|
-
}
|
|
1263
|
+
};
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
/**
|
|
1267
|
+
* 移除监听器、定时器、会话和挂载内容。
|
|
1268
|
+
* Remove listeners, timers, session, and mounted content.
|
|
1269
|
+
* @returns {void} 无返回值 / No return value.
|
|
1270
|
+
*/
|
|
1271
|
+
destroy() {
|
|
1272
|
+
this.#release();
|
|
1273
|
+
}
|
|
1299
1274
|
}
|
|
1300
1275
|
|
|
1301
1276
|
var defaults = "/* 通用默认样式只使用 pp 命名空间;项目可通过 CSS 输入覆盖变量和组件。\n * Generic defaults use only the pp namespace; projects may override variables and components through CSS input. */\n.pp-panel {\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 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 height: 100vh;\n overflow: hidden;\n}\n\n:root[data-theme=\"dark\"] .pp-panel {\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}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n flex: none;\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: relative;\n z-index: 2;\n}\n.pp-title {\n flex: 1;\n text-align: center;\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n flex: 1;\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n@supports (height: 100dvh) {\n .pp-panel {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page,\n.pp-settings-page,\n.pp-cache-page {\n position: absolute;\n inset: 0;\n min-width: 0;\n overflow-x: hidden;\n overflow-y: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + var(--pp-keyboard-height, 0px));\n scroll-padding-bottom: var(--pp-keyboard-height, 0px);\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-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 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-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-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-status {\n position: fixed;\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 color-mix(in srgb, currentColor 25%, transparent);\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-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n";
|
|
@@ -1319,81 +1294,118 @@ function installDefaultStyles(document) {
|
|
|
1319
1294
|
}
|
|
1320
1295
|
|
|
1321
1296
|
/**
|
|
1322
|
-
*
|
|
1323
|
-
*
|
|
1324
|
-
* @param {import("../index.js").BoxJSInput} boxjs 单个模块的 BoxJS JSON / BoxJS JSON for one module.
|
|
1325
|
-
* @param {string} [css] 可选 CSS 正文 / Optional CSS text.
|
|
1326
|
-
* @returns {import("./index.js").MountedPreferences} 模块生命周期句柄 / Module lifecycle handle.
|
|
1297
|
+
* 管理模块设置视图的模型规范化、样式、主题同步和面板生命周期。
|
|
1298
|
+
* Manage model normalization, styles, theme synchronization, and panel lifecycle for a module settings view.
|
|
1327
1299
|
*/
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1300
|
+
class PreferencesView {
|
|
1301
|
+
#existing;
|
|
1302
|
+
#root;
|
|
1303
|
+
#base;
|
|
1304
|
+
#ownsBase;
|
|
1305
|
+
#custom;
|
|
1306
|
+
#previousTitle;
|
|
1307
|
+
#previousTheme;
|
|
1308
|
+
#systemTheme;
|
|
1309
|
+
#previousKeyboard;
|
|
1310
|
+
#host;
|
|
1311
|
+
#observer;
|
|
1312
|
+
#panel;
|
|
1313
|
+
|
|
1314
|
+
/**
|
|
1315
|
+
* 使用模块 API 返回的模型挂载设置页。
|
|
1316
|
+
* Mount a settings page from the model returned by the module API.
|
|
1317
|
+
* @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
|
|
1318
|
+
* @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
|
|
1319
|
+
*/
|
|
1320
|
+
constructor(model, css = "") {
|
|
1321
|
+
if (typeof css !== "string") throw new TypeError("CSS must be a string");
|
|
1322
|
+
const definition = normalizeBoxJs(model.boxjs, model.module);
|
|
1323
|
+
const values = { ...model.values };
|
|
1324
|
+
for (const field of definition.fields) {
|
|
1325
|
+
if (values[field.key] === undefined) continue;
|
|
1326
|
+
values[field.key] = normalizeStoredValue(field, values[field.key]);
|
|
1327
|
+
if (!validValue(field, values[field.key])) throw new TypeError(`Invalid stored value: ${field.key}`);
|
|
1328
|
+
}
|
|
1329
|
+
for (const field of definition.fields) if (values[field.key] === undefined && Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
|
|
1330
|
+
const rendered = { ...model, definition, values };
|
|
1331
|
+
const metadata = definition.metadata ?? {};
|
|
1332
|
+
const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
1333
|
+
if (image) resourceURL(image);
|
|
1334
|
+
if (metadata.repo) resourceURL(metadata.repo);
|
|
1335
|
+
|
|
1336
|
+
this.#existing = document.querySelector("#preferences");
|
|
1337
|
+
this.#root = this.#existing ?? element("main", "");
|
|
1338
|
+
if (!this.#existing) {
|
|
1339
|
+
this.#root.id = "preferences";
|
|
1340
|
+
document.body.append(this.#root);
|
|
1341
|
+
}
|
|
1342
|
+
const styles = installDefaultStyles(document);
|
|
1343
|
+
this.#base = styles.element;
|
|
1344
|
+
this.#ownsBase = styles.owned;
|
|
1345
|
+
this.#custom = element("style", "");
|
|
1346
|
+
this.#custom.textContent = css;
|
|
1347
|
+
document.head.append(this.#custom);
|
|
1348
|
+
this.#previousTitle = document.title;
|
|
1349
|
+
this.#previousTheme = document.documentElement.dataset.theme;
|
|
1350
|
+
this.#systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
1351
|
+
this.#previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
|
|
1352
|
+
this.#host = window.frameElement?.ownerDocument.documentElement;
|
|
1353
|
+
this.#syncAppearance();
|
|
1354
|
+
this.#systemTheme.addEventListener("change", this.#syncAppearance);
|
|
1355
|
+
if (this.#host) {
|
|
1356
|
+
this.#observer = new MutationObserver(this.#syncAppearance);
|
|
1357
|
+
this.#observer.observe(this.#host, { attributes: true, attributeFilter: ["data-theme", "style"] });
|
|
1358
|
+
}
|
|
1359
|
+
document.title = metadata.name ?? definition.module;
|
|
1360
|
+
try {
|
|
1361
|
+
this.#root.replaceChildren();
|
|
1362
|
+
this.#panel = new PreferencesPanel(this.#root, rendered);
|
|
1363
|
+
} catch (error) {
|
|
1364
|
+
this.destroy();
|
|
1365
|
+
throw error;
|
|
1366
|
+
}
|
|
1340
1367
|
}
|
|
1341
|
-
|
|
1342
|
-
const custom = element("style", "");
|
|
1343
|
-
custom.textContent = css;
|
|
1344
|
-
document.head.append(custom);
|
|
1345
|
-
const previousTitle = document.title;
|
|
1346
|
-
const previousTheme = document.documentElement.dataset.theme;
|
|
1347
|
-
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
1348
|
-
const previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
|
|
1349
|
-
const host = window.frameElement?.ownerDocument.documentElement;
|
|
1368
|
+
|
|
1350
1369
|
/**
|
|
1351
1370
|
* 跟随嵌入宿主的通用环境状态,不识别业务 App 或解析其 UA。
|
|
1352
|
-
* Follow generic host appearance without detecting a business
|
|
1371
|
+
* Follow generic host appearance without detecting a business App or parsing its UA.
|
|
1353
1372
|
* @returns {void} 已同步主题与键盘避让 / Theme and keyboard clearance synchronized.
|
|
1354
1373
|
*/
|
|
1355
|
-
|
|
1356
|
-
const theme = host?.dataset.theme ?? previousTheme ?? (systemTheme.matches ? "dark" : "light");
|
|
1374
|
+
#syncAppearance = () => {
|
|
1375
|
+
const theme = this.#host?.dataset.theme ?? this.#previousTheme ?? (this.#systemTheme.matches ? "dark" : "light");
|
|
1357
1376
|
document.documentElement.dataset.theme = theme;
|
|
1358
|
-
if (host) document.documentElement.style.setProperty("--pp-keyboard-height", host.style.getPropertyValue("--pp-keyboard-height"));
|
|
1377
|
+
if (this.#host) document.documentElement.style.setProperty("--pp-keyboard-height", this.#host.style.getPropertyValue("--pp-keyboard-height"));
|
|
1359
1378
|
};
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
panel?.destroy();
|
|
1379
|
-
if (ownsBase) base.remove();
|
|
1380
|
-
custom.remove();
|
|
1381
|
-
if (existing) root.replaceChildren();
|
|
1382
|
-
else root.remove();
|
|
1383
|
-
document.title = previousTitle;
|
|
1384
|
-
if (previousTheme === undefined) delete document.documentElement.dataset.theme;
|
|
1385
|
-
else document.documentElement.dataset.theme = previousTheme;
|
|
1386
|
-
document.documentElement.style.setProperty("--pp-keyboard-height", previousKeyboard);
|
|
1387
|
-
},
|
|
1388
|
-
};
|
|
1389
|
-
try {
|
|
1390
|
-
root.replaceChildren();
|
|
1391
|
-
panel = mountPanel(root, catalog);
|
|
1392
|
-
return view;
|
|
1393
|
-
} catch (error) {
|
|
1394
|
-
view.destroy();
|
|
1395
|
-
throw error;
|
|
1379
|
+
|
|
1380
|
+
/**
|
|
1381
|
+
* 释放模块视图、样式与会话,不操作项目入口页。
|
|
1382
|
+
* Release the module view, styles, and session without operating a project landing page.
|
|
1383
|
+
* @returns {void} 无返回值 / No return value.
|
|
1384
|
+
*/
|
|
1385
|
+
destroy() {
|
|
1386
|
+
this.#observer?.disconnect();
|
|
1387
|
+
this.#systemTheme.removeEventListener("change", this.#syncAppearance);
|
|
1388
|
+
this.#panel?.destroy();
|
|
1389
|
+
if (this.#ownsBase) this.#base.remove();
|
|
1390
|
+
this.#custom.remove();
|
|
1391
|
+
if (this.#existing) this.#root.replaceChildren();
|
|
1392
|
+
else this.#root.remove();
|
|
1393
|
+
document.title = this.#previousTitle;
|
|
1394
|
+
if (this.#previousTheme === undefined) delete document.documentElement.dataset.theme;
|
|
1395
|
+
else document.documentElement.dataset.theme = this.#previousTheme;
|
|
1396
|
+
document.documentElement.style.setProperty("--pp-keyboard-height", this.#previousKeyboard);
|
|
1396
1397
|
}
|
|
1397
1398
|
}
|
|
1398
1399
|
|
|
1399
|
-
|
|
1400
|
+
/**
|
|
1401
|
+
* 使用模块 API 返回的模型挂载设置页;CSS 仅覆盖当前模块。
|
|
1402
|
+
* Mount a settings page from a module API model; CSS only overrides this module.
|
|
1403
|
+
* @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
|
|
1404
|
+
* @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
|
|
1405
|
+
* @returns {PreferencesView} 模块视图 / Module view.
|
|
1406
|
+
*/
|
|
1407
|
+
function mount(model, css = "") {
|
|
1408
|
+
return new PreferencesView(model, css);
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
export { PreferencesView, mount };
|