@nsnanocat/preference-panes 1.1.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -46
- package/dist/api.js +11 -31
- package/dist/module/index.html +1 -1
- package/dist/module/index.mjs +76 -109
- package/dist/module/navigation.mjs +15 -32
- package/dist/preference-panes.mjs +62 -52
- package/dist/web.js +2 -23
- package/package.json +2 -2
- package/src/api.mjs +11 -31
- 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 +38 -10
- package/src/browser/index.d.ts +5 -21
- package/src/browser/index.mjs +13 -48
- package/src/browser/mount.mjs +16 -32
- package/src/browser/panel.mjs +9 -9
- package/src/index.d.ts +0 -22
- 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
|
@@ -147,26 +147,8 @@ class ActionMenu {
|
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
/**
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
* @param {URL} url 已解析的页面请求地址 / Parsed page request URL.
|
|
153
|
-
* @param {Record<string, string | undefined>} [headers] 请求头,名称不区分大小写 / Case-insensitive request headers.
|
|
154
|
-
* @returns {{url: string, module: string, json: string, css: string}} 页面上下文与两个资源输入 / Page context and two resource inputs.
|
|
155
|
-
*/
|
|
156
|
-
function pageInputs(url, headers = {}) {
|
|
157
|
-
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname);
|
|
158
|
-
if (!match) throw new TypeError("Open a concrete module URL");
|
|
159
|
-
const module = match[1];
|
|
160
|
-
const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
161
|
-
const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
|
|
162
|
-
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
|
|
163
|
-
if (!json.trim()) throw new TypeError("JSON resource URL is required");
|
|
164
|
-
return { url: url.href, module, json, css };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
/**
|
|
168
|
-
* 模块文档容器:原始 HTML 不改写,请求上下文随 iframe 元素传递。
|
|
169
|
-
* Module document container: preserve HTML verbatim and carry request context on the iframe element.
|
|
150
|
+
* 模块文档容器:原始 HTML 不改写,只向 iframe 标记模块身份。
|
|
151
|
+
* Module document container: preserve HTML verbatim and mark only the module identity on the iframe.
|
|
170
152
|
*/
|
|
171
153
|
class ModuleFrame extends EventTarget {
|
|
172
154
|
#url;
|
|
@@ -188,23 +170,25 @@ class ModuleFrame extends EventTarget {
|
|
|
188
170
|
};
|
|
189
171
|
|
|
190
172
|
/**
|
|
191
|
-
* 建立 iframe
|
|
192
|
-
* Create the iframe
|
|
173
|
+
* 建立 iframe;调用方挂载 element 后调用 load。
|
|
174
|
+
* Create the iframe; callers mount element and then call load.
|
|
193
175
|
* @param {string | URL} url 模块请求地址 / Module request URL.
|
|
194
|
-
* @param {
|
|
176
|
+
* @param {{signal?: AbortSignal}} [options] 外部取消信号 / External cancellation signal.
|
|
195
177
|
*/
|
|
196
178
|
constructor(url, options = {}) {
|
|
197
179
|
super();
|
|
198
180
|
this.#url = new URL(url, document.baseURI);
|
|
199
|
-
|
|
200
|
-
|
|
181
|
+
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(this.#url.pathname);
|
|
182
|
+
if (!match) throw new TypeError("Open a concrete module URL");
|
|
183
|
+
this.#options = options;
|
|
201
184
|
this.element = document.createElement("iframe");
|
|
202
|
-
this.element.title = `${
|
|
203
|
-
this.element.dataset.preferencePanes =
|
|
185
|
+
this.element.title = `${match[1]} 设置`;
|
|
186
|
+
this.element.dataset.preferencePanes = "true";
|
|
187
|
+
this.element.dataset.preferencePanesModule = match[1];
|
|
204
188
|
this.element.addEventListener("preferencepanes:change", this.#change);
|
|
205
189
|
this.element.addEventListener("preferencepanes:confirm", this.#confirmation);
|
|
206
190
|
this.element.addEventListener("preferencepanes:notice", this.#notice);
|
|
207
|
-
this.#state = { title:
|
|
191
|
+
this.#state = { title: match[1], module: match[1], busy: false, canGoBack: true, actions: [] };
|
|
208
192
|
options.signal?.addEventListener("abort", this.#abort, { once: true });
|
|
209
193
|
}
|
|
210
194
|
|
|
@@ -225,7 +209,7 @@ class ModuleFrame extends EventTarget {
|
|
|
225
209
|
if (this.#options.signal?.aborted) this.destroy();
|
|
226
210
|
const timer = setTimeout(() => this.#controller.abort(), 10000);
|
|
227
211
|
try {
|
|
228
|
-
const response = await fetch(this.#url, { cache: "no-store", credentials: "omit",
|
|
212
|
+
const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", signal: this.#controller.signal });
|
|
229
213
|
if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
|
|
230
214
|
const html = await response.text();
|
|
231
215
|
this.#controller.signal.throwIfAborted();
|
|
@@ -275,7 +259,6 @@ class ModuleFrame extends EventTarget {
|
|
|
275
259
|
* @typedef {object} ModuleProbeOptions
|
|
276
260
|
* @property {typeof globalThis.fetch} [fetch] 可注入的 fetch / Injectable fetch.
|
|
277
261
|
* @property {AbortSignal} [signal] 外部取消信号 / External cancellation signal.
|
|
278
|
-
* @property {string} [json] BoxJS JSON 来源,将随探测请求头传递 / BoxJS JSON source sent in the probe header.
|
|
279
262
|
* @property {number} [timeout] 超时毫秒数,默认 3500 / Timeout in milliseconds, defaults to 3500.
|
|
280
263
|
*/
|
|
281
264
|
|
|
@@ -286,14 +269,14 @@ class ModuleFrame extends EventTarget {
|
|
|
286
269
|
* @param {ModuleProbeOptions} [options] 请求选项 / Request options.
|
|
287
270
|
* @returns {Promise<Response>} 原始 HTTP 响应,可直接读取 status 和响应头 / Native HTTP response; read status and headers directly.
|
|
288
271
|
*/
|
|
289
|
-
async function probeModule(url, { fetch: request = globalThis.fetch,
|
|
272
|
+
async function probeModule(url, { fetch: request = globalThis.fetch, signal, timeout = 3500 } = {}) {
|
|
290
273
|
const controller = new AbortController();
|
|
291
274
|
const abort = () => controller.abort();
|
|
292
275
|
if (signal?.aborted) abort();
|
|
293
276
|
signal?.addEventListener("abort", abort, { once: true });
|
|
294
277
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
295
278
|
try {
|
|
296
|
-
return await request(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal
|
|
279
|
+
return await request(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal });
|
|
297
280
|
} finally {
|
|
298
281
|
clearTimeout(timer);
|
|
299
282
|
signal?.removeEventListener("abort", abort);
|
|
@@ -45,8 +45,8 @@ function normalizeBoxJs(config, module) {
|
|
|
45
45
|
target.owners.add(app);
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
|
-
if (
|
|
49
|
-
const target =
|
|
48
|
+
if (modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
|
|
49
|
+
const target = modules.values().next().value ;
|
|
50
50
|
if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
51
51
|
const metadata = normalizeMetadata(target.owners.size === 1 ? presentation([...target.owners][0]) : {});
|
|
52
52
|
const fields = [];
|
|
@@ -428,28 +428,50 @@ class ActionMenu {
|
|
|
428
428
|
*/
|
|
429
429
|
class PreferencesClient {
|
|
430
430
|
#module;
|
|
431
|
-
#configURL;
|
|
432
431
|
#definition;
|
|
433
432
|
#request;
|
|
434
433
|
#notify;
|
|
435
434
|
#timeout;
|
|
436
435
|
#session = new AbortController();
|
|
437
|
-
#values;
|
|
436
|
+
#values = {};
|
|
438
437
|
#saving = false;
|
|
439
438
|
|
|
440
439
|
/**
|
|
441
|
-
*
|
|
442
|
-
* Create a page client that
|
|
443
|
-
* @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.
|
|
444
443
|
*/
|
|
445
|
-
constructor({
|
|
446
|
-
this.#module =
|
|
447
|
-
this.#configURL = model.configURL;
|
|
444
|
+
constructor({ definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
|
|
445
|
+
this.#module = definition.module;
|
|
448
446
|
this.#definition = definition;
|
|
449
447
|
this.#request = request;
|
|
450
448
|
this.#notify = notify;
|
|
451
449
|
this.#timeout = timeout;
|
|
452
|
-
|
|
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();
|
|
453
475
|
}
|
|
454
476
|
|
|
455
477
|
/**
|
|
@@ -548,7 +570,7 @@ class PreferencesClient {
|
|
|
548
570
|
credentials: "omit",
|
|
549
571
|
cache: "no-store",
|
|
550
572
|
signal: controller.signal,
|
|
551
|
-
headers: { "Content-Type": "application/json"
|
|
573
|
+
headers: { "Content-Type": "application/json" },
|
|
552
574
|
body: JSON.stringify(payload),
|
|
553
575
|
});
|
|
554
576
|
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
@@ -572,6 +594,10 @@ class PreferencesClient {
|
|
|
572
594
|
if (this.#saving) throw new Error("A settings write is already in progress");
|
|
573
595
|
this.#saving = true;
|
|
574
596
|
try {
|
|
597
|
+
if (operation === "write") {
|
|
598
|
+
const field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
599
|
+
if (!field || !validValue(field, payload.value)) throw new TypeError("Invalid setting value");
|
|
600
|
+
}
|
|
575
601
|
await this.#send(action, payload);
|
|
576
602
|
switch (operation) {
|
|
577
603
|
case "write":
|
|
@@ -769,24 +795,23 @@ class PreferencesPanel {
|
|
|
769
795
|
#release;
|
|
770
796
|
|
|
771
797
|
/**
|
|
772
|
-
* 挂载
|
|
773
|
-
* Mount the module form
|
|
798
|
+
* 挂载 BoxJS 定义对应的模块表单。
|
|
799
|
+
* Mount the module form described by a BoxJS definition.
|
|
774
800
|
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
775
|
-
* @param {import("../index.js").
|
|
801
|
+
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
776
802
|
*/
|
|
777
|
-
constructor(root,
|
|
778
|
-
this.#release = this.#mount(root,
|
|
803
|
+
constructor(root, definition) {
|
|
804
|
+
this.#release = this.#mount(root, definition);
|
|
779
805
|
}
|
|
780
806
|
|
|
781
807
|
/**
|
|
782
808
|
* 建立面板 DOM、交互和会话,并返回其释放操作。
|
|
783
809
|
* Build panel DOM, interactions, and session, then return its release operation.
|
|
784
810
|
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
785
|
-
* @param {import("../index.js").
|
|
811
|
+
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
786
812
|
* @returns {() => void} 释放操作 / Release operation.
|
|
787
813
|
*/
|
|
788
|
-
#mount(root,
|
|
789
|
-
const { definition } = model;
|
|
814
|
+
#mount(root, definition) {
|
|
790
815
|
const title = definition.metadata?.name ?? definition.module;
|
|
791
816
|
const document = root.ownerDocument;
|
|
792
817
|
const window = document.defaultView;
|
|
@@ -878,7 +903,7 @@ class PreferencesPanel {
|
|
|
878
903
|
toast.hidden = true;
|
|
879
904
|
}, 2400);
|
|
880
905
|
};
|
|
881
|
-
const client = new PreferencesClient({
|
|
906
|
+
const client = new PreferencesClient({ definition, notify });
|
|
882
907
|
/**
|
|
883
908
|
* 两种菜单入口共用异步错误处理,包含宿主确认框错误。
|
|
884
909
|
* Share async error handling between both menus, including host-dialog errors.
|
|
@@ -906,6 +931,7 @@ class PreferencesPanel {
|
|
|
906
931
|
publishNavigation();
|
|
907
932
|
viewport.replaceChildren(statusView("读取设置…"));
|
|
908
933
|
try {
|
|
934
|
+
await client.open();
|
|
909
935
|
if (version === generation) controls();
|
|
910
936
|
} catch (error) {
|
|
911
937
|
if (version !== generation) return;
|
|
@@ -1294,15 +1320,14 @@ function installDefaultStyles(document) {
|
|
|
1294
1320
|
}
|
|
1295
1321
|
|
|
1296
1322
|
/**
|
|
1297
|
-
*
|
|
1298
|
-
* Manage
|
|
1323
|
+
* 管理 BoxJS 规范化、主题同步和面板生命周期。
|
|
1324
|
+
* Manage BoxJS normalization, theme synchronization, and panel lifecycle.
|
|
1299
1325
|
*/
|
|
1300
1326
|
class PreferencesView {
|
|
1301
1327
|
#existing;
|
|
1302
1328
|
#root;
|
|
1303
1329
|
#base;
|
|
1304
1330
|
#ownsBase;
|
|
1305
|
-
#custom;
|
|
1306
1331
|
#previousTitle;
|
|
1307
1332
|
#previousTheme;
|
|
1308
1333
|
#systemTheme;
|
|
@@ -1312,22 +1337,12 @@ class PreferencesView {
|
|
|
1312
1337
|
#panel;
|
|
1313
1338
|
|
|
1314
1339
|
/**
|
|
1315
|
-
*
|
|
1316
|
-
* Mount a settings page from
|
|
1317
|
-
* @param {import("../index.js").
|
|
1318
|
-
* @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
|
|
1340
|
+
* 使用原始 BoxJS JSON 挂载设置页。
|
|
1341
|
+
* Mount a settings page from raw BoxJS JSON.
|
|
1342
|
+
* @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
|
|
1319
1343
|
*/
|
|
1320
|
-
constructor(
|
|
1321
|
-
|
|
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 };
|
|
1344
|
+
constructor(boxjs) {
|
|
1345
|
+
const definition = normalizeBoxJs(boxjs);
|
|
1331
1346
|
const metadata = definition.metadata ?? {};
|
|
1332
1347
|
const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
1333
1348
|
if (image) resourceURL(image);
|
|
@@ -1342,9 +1357,6 @@ class PreferencesView {
|
|
|
1342
1357
|
const styles = installDefaultStyles(document);
|
|
1343
1358
|
this.#base = styles.element;
|
|
1344
1359
|
this.#ownsBase = styles.owned;
|
|
1345
|
-
this.#custom = element("style", "");
|
|
1346
|
-
this.#custom.textContent = css;
|
|
1347
|
-
document.head.append(this.#custom);
|
|
1348
1360
|
this.#previousTitle = document.title;
|
|
1349
1361
|
this.#previousTheme = document.documentElement.dataset.theme;
|
|
1350
1362
|
this.#systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
@@ -1359,7 +1371,7 @@ class PreferencesView {
|
|
|
1359
1371
|
document.title = metadata.name ?? definition.module;
|
|
1360
1372
|
try {
|
|
1361
1373
|
this.#root.replaceChildren();
|
|
1362
|
-
this.#panel = new PreferencesPanel(this.#root,
|
|
1374
|
+
this.#panel = new PreferencesPanel(this.#root, definition);
|
|
1363
1375
|
} catch (error) {
|
|
1364
1376
|
this.destroy();
|
|
1365
1377
|
throw error;
|
|
@@ -1387,7 +1399,6 @@ class PreferencesView {
|
|
|
1387
1399
|
this.#systemTheme.removeEventListener("change", this.#syncAppearance);
|
|
1388
1400
|
this.#panel?.destroy();
|
|
1389
1401
|
if (this.#ownsBase) this.#base.remove();
|
|
1390
|
-
this.#custom.remove();
|
|
1391
1402
|
if (this.#existing) this.#root.replaceChildren();
|
|
1392
1403
|
else this.#root.remove();
|
|
1393
1404
|
document.title = this.#previousTitle;
|
|
@@ -1398,14 +1409,13 @@ class PreferencesView {
|
|
|
1398
1409
|
}
|
|
1399
1410
|
|
|
1400
1411
|
/**
|
|
1401
|
-
*
|
|
1402
|
-
* Mount a settings page from
|
|
1403
|
-
* @param {import("../index.js").
|
|
1404
|
-
* @
|
|
1405
|
-
* @returns {PreferencesView} 模块视图 / Module view.
|
|
1412
|
+
* 使用原始 BoxJS JSON 挂载设置页。
|
|
1413
|
+
* Mount a settings page from raw BoxJS JSON.
|
|
1414
|
+
* @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
|
|
1415
|
+
* @returns {import("./index.js").MountedPreferences} 模块视图 / Module view.
|
|
1406
1416
|
*/
|
|
1407
|
-
function mount(
|
|
1408
|
-
return new PreferencesView(
|
|
1417
|
+
function mount(boxjs) {
|
|
1418
|
+
return new PreferencesView(boxjs);
|
|
1409
1419
|
}
|
|
1410
1420
|
|
|
1411
|
-
export {
|
|
1421
|
+
export { mount };
|