@nsnanocat/preference-panes 1.0.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 -44
- package/dist/api.js +11 -31
- package/dist/module/index.html +1 -1
- package/dist/module/{app.mjs → index.mjs} +764 -623
- package/dist/module/navigation.mjs +15 -32
- package/dist/preference-panes.mjs +703 -566
- 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 +10 -15
- package/src/browser/client.mjs +154 -48
- package/src/browser/index.d.ts +6 -7
- package/src/browser/index.mjs +59 -79
- package/src/browser/module.html +1 -1
- package/src/browser/mount.mjs +103 -0
- package/src/browser/panel.mjs +464 -444
- package/src/index.d.ts +0 -22
- package/src/index.mjs +3 -3
- package/src/web.mjs +1 -5
- package/src/browser/app.mjs +0 -49
- package/src/build.mjs +0 -20
- package/src/lib/page-inputs.mjs +0 -17
|
@@ -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 = [];
|
|
@@ -423,16 +423,133 @@ class ActionMenu {
|
|
|
423
423
|
}
|
|
424
424
|
|
|
425
425
|
/**
|
|
426
|
-
*
|
|
427
|
-
*
|
|
428
|
-
* @param {import("./client.mjs").PreferencesClientOptions} options API 模型、请求与通知 / API model, requests and notifications.
|
|
429
|
-
* @returns {import("./client.mjs").PreferencesClient} 页面客户端 / Page client.
|
|
426
|
+
* 管理单模块页面的 API 请求、值快照和会话终止。
|
|
427
|
+
* Manage API requests, value snapshots, and session termination for one module page.
|
|
430
428
|
*/
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
429
|
+
class PreferencesClient {
|
|
430
|
+
#module;
|
|
431
|
+
#definition;
|
|
432
|
+
#request;
|
|
433
|
+
#notify;
|
|
434
|
+
#timeout;
|
|
435
|
+
#session = new AbortController();
|
|
436
|
+
#values = {};
|
|
437
|
+
#saving = false;
|
|
438
|
+
|
|
439
|
+
/**
|
|
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.
|
|
443
|
+
*/
|
|
444
|
+
constructor({ definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
|
|
445
|
+
this.#module = definition.module;
|
|
446
|
+
this.#definition = definition;
|
|
447
|
+
this.#request = request;
|
|
448
|
+
this.#notify = notify;
|
|
449
|
+
this.#timeout = timeout;
|
|
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();
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* 获取当前字段定义和值的深拷贝,不发起网络请求。
|
|
479
|
+
* Return a deep copy of the current field definition and values without a network request.
|
|
480
|
+
* @returns {import("./client.mjs").ModuleSnapshot} 会话快照 / Session snapshot.
|
|
481
|
+
*/
|
|
482
|
+
snapshot() {
|
|
483
|
+
return structuredClone({ definition: this.#definition, values: this.#values });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* 读取 Settings 子树。
|
|
488
|
+
* Read the Settings subtree.
|
|
489
|
+
* @returns {Promise<unknown>} Settings 内容或 undefined / Settings content or undefined.
|
|
490
|
+
*/
|
|
491
|
+
async readSettings() {
|
|
492
|
+
const response = await this.#send("get", { scope: "settings" });
|
|
493
|
+
return response.status === 404 ? undefined : response.json();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 读取 Caches 子树。
|
|
498
|
+
* Read the Caches subtree.
|
|
499
|
+
* @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
|
|
500
|
+
*/
|
|
501
|
+
async readCaches() {
|
|
502
|
+
const response = await this.#send("get", { scope: "caches" });
|
|
503
|
+
return response.status === 404 ? undefined : response.json();
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* 删除当前模块的 Caches 子树。
|
|
508
|
+
* Delete the current module Caches subtree.
|
|
509
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
510
|
+
*/
|
|
511
|
+
clearCaches() {
|
|
512
|
+
return this.#change("delete", { scope: "caches" }, "clearCaches");
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* 删除当前模块数据并恢复页面默认值。
|
|
517
|
+
* Delete current module data and restore page defaults.
|
|
518
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
519
|
+
*/
|
|
520
|
+
reset() {
|
|
521
|
+
return this.#change("delete", { scope: "module" }, "reset");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* 终止当前页面仍在进行的请求。
|
|
526
|
+
* Abort requests still owned by the current page.
|
|
527
|
+
* @returns {void} 无返回值 / No return value.
|
|
528
|
+
*/
|
|
529
|
+
leave() {
|
|
530
|
+
this.#session.abort();
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* 写入单个字段。
|
|
535
|
+
* Write one field.
|
|
536
|
+
* @param {string} key 字段路径 / Field path.
|
|
537
|
+
* @param {unknown} value 已校验值 / Validated value.
|
|
538
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
539
|
+
*/
|
|
540
|
+
set(key, value) {
|
|
541
|
+
return this.#change("set", { key, value }, "write", key);
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* 删除单个字段覆盖值。
|
|
546
|
+
* Delete one field override.
|
|
547
|
+
* @param {string} key 字段路径 / Field path.
|
|
548
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
549
|
+
*/
|
|
550
|
+
remove(key) {
|
|
551
|
+
return this.#change("delete", { key }, "delete", key);
|
|
552
|
+
}
|
|
436
553
|
|
|
437
554
|
/**
|
|
438
555
|
* 向模块 API 发送 JSON 动作。
|
|
@@ -441,26 +558,26 @@ function createPreferencesClient({ model, definition, fetch: request = globalThi
|
|
|
441
558
|
* @param {unknown} payload JSON 请求体 / JSON request body.
|
|
442
559
|
* @returns {Promise<Response>} 原始响应 / Raw response.
|
|
443
560
|
*/
|
|
444
|
-
async
|
|
561
|
+
async #send(action, payload) {
|
|
445
562
|
const controller = new AbortController();
|
|
446
563
|
const abort = () => controller.abort();
|
|
447
|
-
if (session.signal.aborted) abort();
|
|
448
|
-
session.signal.addEventListener("abort", abort, { once: true });
|
|
449
|
-
const timer = setTimeout(abort, timeout);
|
|
564
|
+
if (this.#session.signal.aborted) abort();
|
|
565
|
+
this.#session.signal.addEventListener("abort", abort, { once: true });
|
|
566
|
+
const timer = setTimeout(abort, this.#timeout);
|
|
450
567
|
try {
|
|
451
|
-
const response = await request(`/api/${encodeURIComponent(module)}/${action}`, {
|
|
568
|
+
const response = await this.#request(`/api/${encodeURIComponent(this.#module)}/${action}`, {
|
|
452
569
|
method: "POST",
|
|
453
570
|
credentials: "omit",
|
|
454
571
|
cache: "no-store",
|
|
455
572
|
signal: controller.signal,
|
|
456
|
-
headers: { "Content-Type": "application/json"
|
|
573
|
+
headers: { "Content-Type": "application/json" },
|
|
457
574
|
body: JSON.stringify(payload),
|
|
458
575
|
});
|
|
459
576
|
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
460
577
|
return response;
|
|
461
578
|
} finally {
|
|
462
579
|
clearTimeout(timer);
|
|
463
|
-
session.signal.removeEventListener("abort", abort);
|
|
580
|
+
this.#session.signal.removeEventListener("abort", abort);
|
|
464
581
|
}
|
|
465
582
|
}
|
|
466
583
|
|
|
@@ -468,60 +585,47 @@ function createPreferencesClient({ model, definition, fetch: request = globalThi
|
|
|
468
585
|
* 执行写入动作;成功后只更新当前页面值。
|
|
469
586
|
* Execute a mutation and update only the current page values after success.
|
|
470
587
|
* @param {"set" | "delete"} action API 动作 / API action.
|
|
471
|
-
* @param {unknown} payload JSON 请求体 / JSON request body.
|
|
588
|
+
* @param {Record<string, unknown>} payload JSON 请求体 / JSON request body.
|
|
472
589
|
* @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
|
|
473
590
|
* @param {string} [key] 字段路径 / Field path.
|
|
474
591
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
475
592
|
*/
|
|
476
|
-
async
|
|
477
|
-
if (saving) throw new Error("A settings write is already in progress");
|
|
478
|
-
saving = true;
|
|
593
|
+
async #change(action, payload, operation, key) {
|
|
594
|
+
if (this.#saving) throw new Error("A settings write is already in progress");
|
|
595
|
+
this.#saving = true;
|
|
479
596
|
try {
|
|
480
|
-
|
|
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
|
+
}
|
|
601
|
+
await this.#send(action, payload);
|
|
481
602
|
switch (operation) {
|
|
482
603
|
case "write":
|
|
483
|
-
values[key] = structuredClone(payload.value);
|
|
604
|
+
this.#values[key] = structuredClone(payload.value);
|
|
484
605
|
break;
|
|
485
606
|
case "delete": {
|
|
486
|
-
const field = definition.fields.find(candidate => candidate.key === key);
|
|
487
|
-
delete values[key];
|
|
488
|
-
if (field && Object.hasOwn(field, "defaultValue")) values[key] = structuredClone(field.defaultValue);
|
|
607
|
+
const field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
608
|
+
delete this.#values[key];
|
|
609
|
+
if (field && Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
|
|
489
610
|
break;
|
|
490
611
|
}
|
|
491
612
|
case "clearCaches":
|
|
492
613
|
break;
|
|
493
614
|
case "reset":
|
|
494
|
-
for (const field of definition.fields) {
|
|
495
|
-
delete values[field.key];
|
|
496
|
-
if (Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
|
|
615
|
+
for (const field of this.#definition.fields) {
|
|
616
|
+
delete this.#values[field.key];
|
|
617
|
+
if (Object.hasOwn(field, "defaultValue")) this.#values[field.key] = structuredClone(field.defaultValue);
|
|
497
618
|
}
|
|
498
619
|
break;
|
|
499
620
|
}
|
|
500
|
-
notify({ kind: "success", operation, module, key });
|
|
621
|
+
this.#notify({ kind: "success", operation, module: this.#module, key });
|
|
501
622
|
} catch (error) {
|
|
502
|
-
notify({ kind: "error", operation, module, key, message: error.message });
|
|
623
|
+
this.#notify({ kind: "error", operation, module: this.#module, key, message: error.message });
|
|
503
624
|
throw error;
|
|
504
625
|
} finally {
|
|
505
|
-
saving = false;
|
|
626
|
+
this.#saving = false;
|
|
506
627
|
}
|
|
507
628
|
}
|
|
508
|
-
|
|
509
|
-
return {
|
|
510
|
-
snapshot: () => structuredClone({ definition, values }),
|
|
511
|
-
async readSettings() {
|
|
512
|
-
const response = await send("get", { scope: "settings" });
|
|
513
|
-
return response.status === 404 ? undefined : response.json();
|
|
514
|
-
},
|
|
515
|
-
async readCaches() {
|
|
516
|
-
const response = await send("get", { scope: "caches" });
|
|
517
|
-
return response.status === 404 ? undefined : response.json();
|
|
518
|
-
},
|
|
519
|
-
clearCaches: () => change("delete", { scope: "caches" }, "clearCaches"),
|
|
520
|
-
reset: () => change("delete", { scope: "module" }, "reset"),
|
|
521
|
-
leave: () => session.abort(),
|
|
522
|
-
set: (key, value) => change("set", { key, value }, "write", key),
|
|
523
|
-
remove: key => change("delete", { key }, "delete", key),
|
|
524
|
-
};
|
|
525
629
|
}
|
|
526
630
|
|
|
527
631
|
/**
|
|
@@ -684,485 +788,496 @@ class Navigation extends EventTarget {
|
|
|
684
788
|
}
|
|
685
789
|
|
|
686
790
|
/**
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
690
|
-
* @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
|
|
691
|
-
* @returns {import("./index.js").MountedPreferences} 面板生命周期句柄 / Panel lifecycle handle.
|
|
791
|
+
* 管理模块表单、导航、操作队列和短暂通知。
|
|
792
|
+
* Manage the module form, navigation, operation queue, and transient notifications.
|
|
692
793
|
*/
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
const document = root.ownerDocument;
|
|
697
|
-
const window = document.defaultView;
|
|
698
|
-
const shell = element("div", "pp-panel");
|
|
699
|
-
shell.dataset.module = definition.module;
|
|
700
|
-
const header = element("header", "pp-header");
|
|
701
|
-
const back = element("button", "pp-back", "‹");
|
|
702
|
-
back.setAttribute("aria-label", "返回");
|
|
703
|
-
back.type = "button";
|
|
704
|
-
const heading = element("h1", "pp-title", title);
|
|
705
|
-
const handlers = new Map();
|
|
706
|
-
const menuItems = [
|
|
707
|
-
{ id: "viewSettings", label: "查看设置" },
|
|
708
|
-
{ id: "viewCaches", label: "查看缓存" },
|
|
709
|
-
{ id: "clearCaches", label: "清空缓存", destructive: true },
|
|
710
|
-
{ id: "reset", label: "重置设置", destructive: true },
|
|
711
|
-
];
|
|
712
|
-
const menu = new ActionMenu(id => runAction(id));
|
|
713
|
-
const trailing = element("span", "pp-nav-spacer");
|
|
714
|
-
trailing.append(menu.element);
|
|
715
|
-
const viewport = element("div", "pp-viewport");
|
|
716
|
-
let toast;
|
|
717
|
-
header.append(back, heading, trailing);
|
|
718
|
-
shell.append(header, viewport);
|
|
719
|
-
root.append(shell);
|
|
720
|
-
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
721
|
-
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
722
|
-
const publishNavigation = () => {
|
|
723
|
-
const actions = handlers.size ? menuItems : [];
|
|
724
|
-
menu.update(actions, saving);
|
|
725
|
-
const frame = window.frameElement;
|
|
726
|
-
if (!frame?.dataset.preferencePanes) return;
|
|
727
|
-
frame.dispatchEvent(
|
|
728
|
-
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
729
|
-
detail: { title: heading.textContent, module: definition.module, busy: saving, canGoBack: !back.disabled, actions },
|
|
730
|
-
}),
|
|
731
|
-
);
|
|
732
|
-
};
|
|
733
|
-
const onAction = event => {
|
|
734
|
-
if (!saving && handlers.has(event.detail)) runAction(event.detail);
|
|
735
|
-
};
|
|
736
|
-
window.frameElement?.addEventListener("preferencepanes:action", onAction);
|
|
737
|
-
let timer,
|
|
738
|
-
navigation,
|
|
739
|
-
generation = 0,
|
|
740
|
-
active = null,
|
|
741
|
-
saving = false,
|
|
742
|
-
destroyed = false;
|
|
743
|
-
/**
|
|
744
|
-
* 展示短暂通知,不刷新设置数据。
|
|
745
|
-
* Display a transient notification without refreshing settings.
|
|
746
|
-
* @param {{kind: "success" | "error", operation?: "write" | "delete" | "clearCaches" | "reset", message?: string}} event 操作结果 / Operation result.
|
|
747
|
-
* @returns {void} 无返回值 / No return value.
|
|
748
|
-
*/
|
|
749
|
-
const notify = event => {
|
|
750
|
-
if (destroyed) return;
|
|
751
|
-
let message;
|
|
752
|
-
switch (true) {
|
|
753
|
-
case event.kind === "error":
|
|
754
|
-
message = `操作失败:${event.message}`;
|
|
755
|
-
break;
|
|
756
|
-
case event.operation === "delete":
|
|
757
|
-
message = "删除成功";
|
|
758
|
-
break;
|
|
759
|
-
case event.operation === "clearCaches":
|
|
760
|
-
message = "Caches 已清空";
|
|
761
|
-
break;
|
|
762
|
-
case event.operation === "reset":
|
|
763
|
-
message = "设置已重置";
|
|
764
|
-
break;
|
|
765
|
-
default:
|
|
766
|
-
message = "修改成功";
|
|
767
|
-
break;
|
|
768
|
-
}
|
|
769
|
-
// 宿主接管时不创建网页 Toast,也不运行其计时器。
|
|
770
|
-
// A host-owned notice creates no web Toast and starts no local timer.
|
|
771
|
-
const frame = window.frameElement;
|
|
772
|
-
if (frame && !frame.dispatchEvent(new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:notice", { cancelable: true, detail: { kind: event.kind, message } }))) return;
|
|
773
|
-
if (!toast) {
|
|
774
|
-
toast = element("div", "pp-toast");
|
|
775
|
-
toast.setAttribute("role", "status");
|
|
776
|
-
shell.append(toast);
|
|
777
|
-
}
|
|
778
|
-
toast.textContent = message;
|
|
779
|
-
toast.dataset.kind = event.kind;
|
|
780
|
-
toast.hidden = false;
|
|
781
|
-
clearTimeout(timer);
|
|
782
|
-
timer = setTimeout(() => {
|
|
783
|
-
toast.hidden = true;
|
|
784
|
-
}, 2400);
|
|
785
|
-
};
|
|
786
|
-
const client = createPreferencesClient({ model, definition, notify });
|
|
787
|
-
/**
|
|
788
|
-
* 两种菜单入口共用异步错误处理,包含宿主确认框错误。
|
|
789
|
-
* Share async error handling between both menus, including host-dialog errors.
|
|
790
|
-
* @param {string} id 操作标识 / Action identifier.
|
|
791
|
-
* @returns {Promise<void>} 操作已处理 / Action handled.
|
|
792
|
-
*/
|
|
793
|
-
async function runAction(id) {
|
|
794
|
-
try {
|
|
795
|
-
await handlers.get(id)();
|
|
796
|
-
} catch (error) {
|
|
797
|
-
notify({ kind: "error", message: error.message });
|
|
798
|
-
}
|
|
799
|
-
}
|
|
794
|
+
class PreferencesPanel {
|
|
795
|
+
#release;
|
|
796
|
+
|
|
800
797
|
/**
|
|
801
|
-
*
|
|
802
|
-
*
|
|
803
|
-
* @param {
|
|
804
|
-
* @
|
|
798
|
+
* 挂载 BoxJS 定义对应的模块表单。
|
|
799
|
+
* Mount the module form described by a BoxJS definition.
|
|
800
|
+
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
801
|
+
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
805
802
|
*/
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
active = module;
|
|
809
|
-
back.disabled = window.history.length <= 1;
|
|
810
|
-
heading.textContent = module;
|
|
811
|
-
publishNavigation();
|
|
812
|
-
viewport.replaceChildren(statusView("读取设置…"));
|
|
813
|
-
try {
|
|
814
|
-
if (version === generation) controls();
|
|
815
|
-
} catch (error) {
|
|
816
|
-
if (version !== generation) return;
|
|
817
|
-
viewport.replaceChildren(statusView(`加载失败:${error.message}`, () => open(module)));
|
|
818
|
-
publishNavigation();
|
|
819
|
-
}
|
|
803
|
+
constructor(root, definition) {
|
|
804
|
+
this.#release = this.#mount(root, definition);
|
|
820
805
|
}
|
|
806
|
+
|
|
821
807
|
/**
|
|
822
|
-
*
|
|
823
|
-
* Build
|
|
824
|
-
* @
|
|
808
|
+
* 建立面板 DOM、交互和会话,并返回其释放操作。
|
|
809
|
+
* Build panel DOM, interactions, and session, then return its release operation.
|
|
810
|
+
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
811
|
+
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
812
|
+
* @returns {() => void} 释放操作 / Release operation.
|
|
825
813
|
*/
|
|
826
|
-
|
|
827
|
-
const
|
|
828
|
-
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
const
|
|
837
|
-
const
|
|
838
|
-
const
|
|
839
|
-
|
|
840
|
-
|
|
814
|
+
#mount(root, definition) {
|
|
815
|
+
const title = definition.metadata?.name ?? definition.module;
|
|
816
|
+
const document = root.ownerDocument;
|
|
817
|
+
const window = document.defaultView;
|
|
818
|
+
const shell = element("div", "pp-panel");
|
|
819
|
+
shell.dataset.module = definition.module;
|
|
820
|
+
const header = element("header", "pp-header");
|
|
821
|
+
const back = element("button", "pp-back", "‹");
|
|
822
|
+
back.setAttribute("aria-label", "返回");
|
|
823
|
+
back.type = "button";
|
|
824
|
+
const heading = element("h1", "pp-title", title);
|
|
825
|
+
const handlers = new Map();
|
|
826
|
+
const menuItems = [
|
|
827
|
+
{ id: "viewSettings", label: "查看设置" },
|
|
828
|
+
{ id: "viewCaches", label: "查看缓存" },
|
|
829
|
+
{ id: "clearCaches", label: "清空缓存", destructive: true },
|
|
830
|
+
{ id: "reset", label: "重置设置", destructive: true },
|
|
831
|
+
];
|
|
832
|
+
const menu = new ActionMenu(id => runAction(id));
|
|
833
|
+
const trailing = element("span", "pp-nav-spacer");
|
|
834
|
+
trailing.append(menu.element);
|
|
835
|
+
const viewport = element("div", "pp-viewport");
|
|
836
|
+
let toast;
|
|
837
|
+
header.append(back, heading, trailing);
|
|
838
|
+
shell.append(header, viewport);
|
|
839
|
+
root.append(shell);
|
|
840
|
+
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
841
|
+
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
842
|
+
const publishNavigation = () => {
|
|
843
|
+
const actions = handlers.size ? menuItems : [];
|
|
844
|
+
menu.update(actions, saving);
|
|
845
|
+
const frame = window.frameElement;
|
|
846
|
+
if (!frame?.dataset.preferencePanes) return;
|
|
847
|
+
frame.dispatchEvent(
|
|
848
|
+
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
849
|
+
detail: { title: heading.textContent, module: definition.module, busy: saving, canGoBack: !back.disabled, actions },
|
|
850
|
+
}),
|
|
851
|
+
);
|
|
852
|
+
};
|
|
853
|
+
const onAction = event => {
|
|
854
|
+
if (!saving && handlers.has(event.detail)) runAction(event.detail);
|
|
855
|
+
};
|
|
856
|
+
window.frameElement?.addEventListener("preferencepanes:action", onAction);
|
|
857
|
+
let timer,
|
|
858
|
+
navigation,
|
|
859
|
+
generation = 0,
|
|
860
|
+
active = null,
|
|
861
|
+
saving = false,
|
|
862
|
+
destroyed = false;
|
|
841
863
|
/**
|
|
842
|
-
*
|
|
843
|
-
*
|
|
864
|
+
* 展示短暂通知,不刷新设置数据。
|
|
865
|
+
* Display a transient notification without refreshing settings.
|
|
866
|
+
* @param {{kind: "success" | "error", operation?: "write" | "delete" | "clearCaches" | "reset", message?: string}} event 操作结果 / Operation result.
|
|
844
867
|
* @returns {void} 无返回值 / No return value.
|
|
845
868
|
*/
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
869
|
+
const notify = event => {
|
|
870
|
+
if (destroyed) return;
|
|
871
|
+
let message;
|
|
872
|
+
switch (true) {
|
|
873
|
+
case event.kind === "error":
|
|
874
|
+
message = `操作失败:${event.message}`;
|
|
875
|
+
break;
|
|
876
|
+
case event.operation === "delete":
|
|
877
|
+
message = "删除成功";
|
|
878
|
+
break;
|
|
879
|
+
case event.operation === "clearCaches":
|
|
880
|
+
message = "Caches 已清空";
|
|
881
|
+
break;
|
|
882
|
+
case event.operation === "reset":
|
|
883
|
+
message = "设置已重置";
|
|
884
|
+
break;
|
|
885
|
+
default:
|
|
886
|
+
message = "修改成功";
|
|
887
|
+
break;
|
|
888
|
+
}
|
|
889
|
+
// 宿主接管时不创建网页 Toast,也不运行其计时器。
|
|
890
|
+
// A host-owned notice creates no web Toast and starts no local timer.
|
|
891
|
+
const frame = window.frameElement;
|
|
892
|
+
if (frame && !frame.dispatchEvent(new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:notice", { cancelable: true, detail: { kind: event.kind, message } }))) return;
|
|
893
|
+
if (!toast) {
|
|
894
|
+
toast = element("div", "pp-toast");
|
|
895
|
+
toast.setAttribute("role", "status");
|
|
896
|
+
shell.append(toast);
|
|
897
|
+
}
|
|
898
|
+
toast.textContent = message;
|
|
899
|
+
toast.dataset.kind = event.kind;
|
|
900
|
+
toast.hidden = false;
|
|
901
|
+
clearTimeout(timer);
|
|
902
|
+
timer = setTimeout(() => {
|
|
903
|
+
toast.hidden = true;
|
|
904
|
+
}, 2400);
|
|
851
905
|
};
|
|
906
|
+
const client = new PreferencesClient({ definition, notify });
|
|
852
907
|
/**
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
* @param {
|
|
856
|
-
* @
|
|
857
|
-
* @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.
|
|
858
|
-
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
908
|
+
* 两种菜单入口共用异步错误处理,包含宿主确认框错误。
|
|
909
|
+
* Share async error handling between both menus, including host-dialog errors.
|
|
910
|
+
* @param {string} id 操作标识 / Action identifier.
|
|
911
|
+
* @returns {Promise<void>} 操作已处理 / Action handled.
|
|
859
912
|
*/
|
|
860
|
-
function
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
queue = queue
|
|
866
|
-
.then(action)
|
|
867
|
-
.then(() => {
|
|
868
|
-
if (!destroyed) success();
|
|
869
|
-
})
|
|
870
|
-
.catch(() => {
|
|
871
|
-
/* 请求层已通知错误。
|
|
872
|
-
* The request layer has already reported the error. */
|
|
873
|
-
if (!destroyed) failure();
|
|
874
|
-
})
|
|
875
|
-
.finally(() => {
|
|
876
|
-
pendingWrites--;
|
|
877
|
-
saving = pendingWrites > 0;
|
|
878
|
-
if (destroyed && !saving) client.leave();
|
|
879
|
-
back.disabled = saving || !navigation.canGoBack;
|
|
880
|
-
publishNavigation();
|
|
881
|
-
});
|
|
882
|
-
return queue;
|
|
883
|
-
}
|
|
884
|
-
const metadata = definition.metadata;
|
|
885
|
-
if (metadata) {
|
|
886
|
-
const info = element("div", "pp-module-info");
|
|
887
|
-
const details = element("div", "pp-module-details");
|
|
888
|
-
for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(element("p", "pp-description", description));
|
|
889
|
-
if (metadata.repo) {
|
|
890
|
-
const link = element("a", "pp-module-source", "项目主页");
|
|
891
|
-
link.href = resourceURL(metadata.repo);
|
|
892
|
-
link.target = "_blank";
|
|
893
|
-
link.rel = "noopener noreferrer";
|
|
894
|
-
details.append(link);
|
|
913
|
+
async function runAction(id) {
|
|
914
|
+
try {
|
|
915
|
+
await handlers.get(id)();
|
|
916
|
+
} catch (error) {
|
|
917
|
+
notify({ kind: "error", message: error.message });
|
|
895
918
|
}
|
|
896
|
-
info.append(details);
|
|
897
|
-
view.append(info);
|
|
898
919
|
}
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
920
|
+
/**
|
|
921
|
+
* 打开模块并忽略已过期的异步结果。
|
|
922
|
+
* Open a module and ignore stale asynchronous results.
|
|
923
|
+
* @param {string} module 模块标识 / Module identifier.
|
|
924
|
+
* @returns {Promise<void>} 视图加载完成,失败显示错误视图 / View load completion; failures display an error view.
|
|
925
|
+
*/
|
|
926
|
+
async function open(module) {
|
|
927
|
+
const version = ++generation;
|
|
928
|
+
active = module;
|
|
929
|
+
back.disabled = window.history.length <= 1;
|
|
930
|
+
heading.textContent = module;
|
|
931
|
+
publishNavigation();
|
|
932
|
+
viewport.replaceChildren(statusView("读取设置…"));
|
|
933
|
+
try {
|
|
934
|
+
await client.open();
|
|
935
|
+
if (version === generation) controls();
|
|
936
|
+
} catch (error) {
|
|
937
|
+
if (version !== generation) return;
|
|
938
|
+
viewport.replaceChildren(statusView(`加载失败:${error.message}`, () => open(module)));
|
|
939
|
+
publishNavigation();
|
|
908
940
|
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* 从会话快照创建控件与操作按钮,不重新读取网络配置。
|
|
944
|
+
* Build controls and actions from the session snapshot without fetching config again.
|
|
945
|
+
* @returns {void} 无返回值 / No return value.
|
|
946
|
+
*/
|
|
947
|
+
function controls() {
|
|
948
|
+
const { definition, values } = client.snapshot();
|
|
949
|
+
heading.textContent = definition.metadata?.name || active;
|
|
950
|
+
const view = element("section", "pp-fields");
|
|
916
951
|
/**
|
|
917
|
-
*
|
|
918
|
-
*
|
|
919
|
-
* @type {() =>
|
|
952
|
+
* 挂载后执行的多行高度更新
|
|
953
|
+
* Textarea sizing callbacks run after mounting.
|
|
954
|
+
* @type {Array<() => void>}
|
|
920
955
|
*/
|
|
921
|
-
|
|
956
|
+
const growingInputs = [];
|
|
957
|
+
const editors = new Map();
|
|
958
|
+
const summaries = [];
|
|
959
|
+
const groups = new Map();
|
|
960
|
+
let queue = Promise.resolve(),
|
|
961
|
+
pendingWrites = 0;
|
|
922
962
|
/**
|
|
923
|
-
*
|
|
924
|
-
*
|
|
925
|
-
* @
|
|
963
|
+
* 导航组件处理页面切换,表单只更新当前标题与返回按钮。
|
|
964
|
+
* Let navigation own transitions; the form only updates the title and back button.
|
|
965
|
+
* @returns {void} 无返回值 / No return value.
|
|
926
966
|
*/
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
field.options
|
|
963
|
-
.filter(option => Array.isArray(value) && value.includes(option.key))
|
|
964
|
-
.map(option => option.label)
|
|
965
|
-
.join("、") || "未选择";
|
|
966
|
-
};
|
|
967
|
-
summaries.push(refresh);
|
|
968
|
-
refresh();
|
|
969
|
-
link.onclick = () => navigation.open(field.key);
|
|
970
|
-
row.addEventListener("click", event => {
|
|
971
|
-
if (!link.contains(event.target)) link.click();
|
|
972
|
-
});
|
|
973
|
-
const inputs = field.options.map(option => {
|
|
974
|
-
const label = settingRow("label");
|
|
975
|
-
label.classList.add("pp-choice");
|
|
976
|
-
label.textContent = option.label;
|
|
977
|
-
const input = element("input", "");
|
|
978
|
-
input.type = "checkbox";
|
|
979
|
-
input.setAttribute("aria-label", option.label);
|
|
980
|
-
label.append(input);
|
|
981
|
-
choices.append(label);
|
|
982
|
-
return { input, key: option.key };
|
|
967
|
+
const updateNavigation = () => {
|
|
968
|
+
const editor = editors.get(navigation.current);
|
|
969
|
+
heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
|
|
970
|
+
back.disabled = saving || !navigation.canGoBack;
|
|
971
|
+
publishNavigation();
|
|
972
|
+
};
|
|
973
|
+
/**
|
|
974
|
+
* 串行执行模块操作,保持输入可编辑。
|
|
975
|
+
* Serialize module actions while keeping inputs editable.
|
|
976
|
+
* @param {() => Promise<void>} action 请求或写入 / Request or mutation.
|
|
977
|
+
* @param {() => void} success 成功后的局部更新 / Local update after success.
|
|
978
|
+
* @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.
|
|
979
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
980
|
+
*/
|
|
981
|
+
function perform(action, success, failure = () => {}) {
|
|
982
|
+
pendingWrites++;
|
|
983
|
+
saving = true;
|
|
984
|
+
back.disabled = true;
|
|
985
|
+
publishNavigation();
|
|
986
|
+
queue = queue
|
|
987
|
+
.then(action)
|
|
988
|
+
.then(() => {
|
|
989
|
+
if (!destroyed) success();
|
|
990
|
+
})
|
|
991
|
+
.catch(() => {
|
|
992
|
+
/* 请求层已通知错误。
|
|
993
|
+
* The request layer has already reported the error. */
|
|
994
|
+
if (!destroyed) failure();
|
|
995
|
+
})
|
|
996
|
+
.finally(() => {
|
|
997
|
+
pendingWrites--;
|
|
998
|
+
saving = pendingWrites > 0;
|
|
999
|
+
if (destroyed && !saving) client.leave();
|
|
1000
|
+
back.disabled = saving || !navigation.canGoBack;
|
|
1001
|
+
publishNavigation();
|
|
983
1002
|
});
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
1003
|
+
return queue;
|
|
1004
|
+
}
|
|
1005
|
+
const metadata = definition.metadata;
|
|
1006
|
+
if (metadata) {
|
|
1007
|
+
const info = element("div", "pp-module-info");
|
|
1008
|
+
const details = element("div", "pp-module-details");
|
|
1009
|
+
for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(element("p", "pp-description", description));
|
|
1010
|
+
if (metadata.repo) {
|
|
1011
|
+
const link = element("a", "pp-module-source", "项目主页");
|
|
1012
|
+
link.href = resourceURL(metadata.repo);
|
|
1013
|
+
link.target = "_blank";
|
|
1014
|
+
link.rel = "noopener noreferrer";
|
|
1015
|
+
details.append(link);
|
|
989
1016
|
}
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1017
|
+
info.append(details);
|
|
1018
|
+
view.append(info);
|
|
1019
|
+
}
|
|
1020
|
+
for (const field of definition.fields) {
|
|
1021
|
+
const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
|
|
1022
|
+
const group = match?.[1] ?? "通用";
|
|
1023
|
+
if (!groups.has(group)) {
|
|
1024
|
+
const section = element("section", "pp-group");
|
|
1025
|
+
const rows = element("div", "pp-rows");
|
|
1026
|
+
section.append(element("h2", "pp-group-title", group), rows);
|
|
1027
|
+
groups.set(group, rows);
|
|
1028
|
+
view.append(section);
|
|
1002
1029
|
}
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1030
|
+
const row = settingRow("div");
|
|
1031
|
+
row.classList.add("pp-field");
|
|
1032
|
+
const label = element("div", "pp-label");
|
|
1033
|
+
label.append(element("span", "pp-field-name", match?.[2] ?? field.name));
|
|
1034
|
+
if (field.description) label.append(element("span", "pp-field-description", field.description));
|
|
1035
|
+
row.append(label);
|
|
1036
|
+
const value = values[field.key];
|
|
1037
|
+
/**
|
|
1038
|
+
* 读取尚未保存的输入
|
|
1039
|
+
* Read the unsaved input.
|
|
1040
|
+
* @type {() => unknown}
|
|
1041
|
+
*/
|
|
1042
|
+
let read;
|
|
1043
|
+
/**
|
|
1044
|
+
* 更新当前控件
|
|
1045
|
+
* Update the current control.
|
|
1046
|
+
* @type {(value: unknown) => void}
|
|
1047
|
+
*/
|
|
1048
|
+
let write;
|
|
1049
|
+
let inputContainer = row;
|
|
1050
|
+
let eventName = "change";
|
|
1051
|
+
switch (true) {
|
|
1052
|
+
case Boolean(field.options) && field.type !== "array": {
|
|
1053
|
+
const select = element("select", "");
|
|
1054
|
+
select.setAttribute("aria-label", field.name);
|
|
1055
|
+
field.options.forEach((option, index) => {
|
|
1056
|
+
const item = element("option", "", option.label);
|
|
1057
|
+
item.value = String(index);
|
|
1058
|
+
select.append(item);
|
|
1059
|
+
});
|
|
1060
|
+
write = value => {
|
|
1061
|
+
select.selectedIndex = field.options.findIndex(option => option.key === value);
|
|
1062
|
+
};
|
|
1063
|
+
row.append(fieldControl(select));
|
|
1064
|
+
read = () => field.options[select.selectedIndex]?.key;
|
|
1065
|
+
break;
|
|
1026
1066
|
}
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1067
|
+
case field.type === "array" && Boolean(field.options): {
|
|
1068
|
+
const page = element("section", "pp-choice-page");
|
|
1069
|
+
if (field.description) page.append(element("p", "pp-description", field.description));
|
|
1070
|
+
const choices = element("div", "pp-rows");
|
|
1071
|
+
page.append(choices);
|
|
1072
|
+
inputContainer = choices;
|
|
1073
|
+
editors.set(field.key, { node: page, title: match?.[2] ?? field.name });
|
|
1074
|
+
const summary = element("span", "pp-summary");
|
|
1075
|
+
const link = element("button", "pp-choice-link");
|
|
1076
|
+
link.type = "button";
|
|
1077
|
+
link.setAttribute("aria-label", field.name);
|
|
1078
|
+
link.append(summary, element("span", "pp-chevron", "›"));
|
|
1079
|
+
row.append(link);
|
|
1080
|
+
const refresh = () => {
|
|
1081
|
+
const value = client.snapshot().values[field.key];
|
|
1082
|
+
summary.textContent =
|
|
1083
|
+
field.options
|
|
1084
|
+
.filter(option => Array.isArray(value) && value.includes(option.key))
|
|
1085
|
+
.map(option => option.label)
|
|
1086
|
+
.join("、") || "未选择";
|
|
1087
|
+
};
|
|
1088
|
+
summaries.push(refresh);
|
|
1089
|
+
refresh();
|
|
1090
|
+
link.onclick = () => navigation.open(field.key);
|
|
1091
|
+
row.addEventListener("click", event => {
|
|
1092
|
+
if (!link.contains(event.target)) link.click();
|
|
1093
|
+
});
|
|
1094
|
+
const inputs = field.options.map(option => {
|
|
1095
|
+
const label = settingRow("label");
|
|
1096
|
+
label.classList.add("pp-choice");
|
|
1097
|
+
label.textContent = option.label;
|
|
1098
|
+
const input = element("input", "");
|
|
1099
|
+
input.type = "checkbox";
|
|
1100
|
+
input.setAttribute("aria-label", option.label);
|
|
1101
|
+
label.append(input);
|
|
1102
|
+
choices.append(label);
|
|
1103
|
+
return { input, key: option.key };
|
|
1104
|
+
});
|
|
1105
|
+
read = () => inputs.filter(option => option.input.checked).map(option => option.key);
|
|
1106
|
+
write = value => {
|
|
1107
|
+
for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);
|
|
1108
|
+
};
|
|
1109
|
+
break;
|
|
1110
|
+
}
|
|
1111
|
+
case field.type === "boolean": {
|
|
1112
|
+
const toggle = element("input", "pp-switch");
|
|
1113
|
+
toggle.type = "checkbox";
|
|
1114
|
+
toggle.setAttribute("switch", "");
|
|
1115
|
+
toggle.setAttribute("role", "switch");
|
|
1116
|
+
toggle.setAttribute("aria-label", field.name);
|
|
1117
|
+
write = value => {
|
|
1118
|
+
toggle.checked = value === true;
|
|
1119
|
+
};
|
|
1120
|
+
read = () => toggle.checked;
|
|
1121
|
+
row.append(toggle);
|
|
1122
|
+
break;
|
|
1123
|
+
}
|
|
1124
|
+
default: {
|
|
1125
|
+
const multiline = field.control === "textarea" || field.type === "array";
|
|
1126
|
+
const input = element(multiline ? "textarea" : "input", "");
|
|
1127
|
+
if (multiline) row.classList.add("pp-multiline");
|
|
1128
|
+
input.setAttribute("aria-label", field.name);
|
|
1129
|
+
if (field.placeholder) input.placeholder = field.placeholder;
|
|
1130
|
+
if (multiline && field.rows) input.rows = field.rows;
|
|
1131
|
+
/**
|
|
1132
|
+
* 在挂载后根据内容调整高度,同时保留基础行数。
|
|
1133
|
+
* Size mounted textareas to their contents while retaining baseline rows.
|
|
1134
|
+
* @returns {void} 无返回值 / No return value.
|
|
1135
|
+
*/
|
|
1136
|
+
const grow = () => {
|
|
1137
|
+
if (!multiline || !field.autoGrow || !input.isConnected) return;
|
|
1138
|
+
input.style.height = "auto";
|
|
1139
|
+
const baseline = input.getBoundingClientRect().height;
|
|
1140
|
+
const style = window.getComputedStyle(input);
|
|
1141
|
+
const borders = Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
|
|
1142
|
+
input.style.height = `${Math.max(baseline, input.scrollHeight + borders)}px`;
|
|
1143
|
+
};
|
|
1144
|
+
if (multiline && field.autoGrow) {
|
|
1145
|
+
input.addEventListener("input", grow);
|
|
1146
|
+
growingInputs.push(grow);
|
|
1041
1147
|
}
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1148
|
+
eventName = "input";
|
|
1149
|
+
if (!multiline) input.type = field.type === "number" ? "number" : "text";
|
|
1150
|
+
write = value => {
|
|
1151
|
+
input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
|
|
1152
|
+
grow();
|
|
1153
|
+
};
|
|
1154
|
+
read = () => {
|
|
1155
|
+
switch (field.type) {
|
|
1156
|
+
case "array":
|
|
1157
|
+
return JSON.parse(input.value);
|
|
1158
|
+
case "number":
|
|
1159
|
+
return input.value === "" ? Number.NaN : Number(input.value);
|
|
1160
|
+
default:
|
|
1161
|
+
return input.value;
|
|
1162
|
+
}
|
|
1163
|
+
};
|
|
1164
|
+
row.append(fieldControl(input));
|
|
1165
|
+
break;
|
|
1166
|
+
}
|
|
1045
1167
|
}
|
|
1168
|
+
write(value);
|
|
1169
|
+
let inputVersion = 0;
|
|
1170
|
+
inputContainer.addEventListener(eventName, event => {
|
|
1171
|
+
if (event.isComposing) return;
|
|
1172
|
+
const version = ++inputVersion;
|
|
1173
|
+
let value;
|
|
1174
|
+
try {
|
|
1175
|
+
value = read();
|
|
1176
|
+
} catch (error) {
|
|
1177
|
+
notify({ kind: "error", message: error.message });
|
|
1178
|
+
return;
|
|
1179
|
+
}
|
|
1180
|
+
const restore = () => {
|
|
1181
|
+
if (version === inputVersion) write(client.snapshot().values[field.key]);
|
|
1182
|
+
};
|
|
1183
|
+
perform(
|
|
1184
|
+
() => {
|
|
1185
|
+
if (!validValue(field, value)) {
|
|
1186
|
+
const error = new TypeError("Invalid setting value");
|
|
1187
|
+
notify({ kind: "error", operation: "write", key: field.key, message: error.message });
|
|
1188
|
+
throw error;
|
|
1189
|
+
}
|
|
1190
|
+
return client.set(field.key, value);
|
|
1191
|
+
},
|
|
1192
|
+
() => {
|
|
1193
|
+
for (const refresh of summaries) refresh();
|
|
1194
|
+
},
|
|
1195
|
+
restore,
|
|
1196
|
+
);
|
|
1197
|
+
});
|
|
1198
|
+
if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
|
|
1199
|
+
groups.get(group).append(row);
|
|
1046
1200
|
}
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1201
|
+
const settingsPage = element("section", "pp-settings-page");
|
|
1202
|
+
const settingsOutput = element("pre", "pp-cache");
|
|
1203
|
+
settingsOutput.setAttribute("aria-label", "Settings 内容");
|
|
1204
|
+
settingsPage.append(settingsOutput);
|
|
1205
|
+
editors.set("$settings", { node: settingsPage, title: "设置" });
|
|
1206
|
+
handlers.set("viewSettings", () => {
|
|
1207
|
+
if (saving) return;
|
|
1052
1208
|
let value;
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
perform(
|
|
1209
|
+
return perform(
|
|
1210
|
+
async () => {
|
|
1211
|
+
try {
|
|
1212
|
+
value = await client.readSettings();
|
|
1213
|
+
} catch (error) {
|
|
1214
|
+
notify({ kind: "error", message: error.message });
|
|
1215
|
+
throw error;
|
|
1216
|
+
}
|
|
1217
|
+
},
|
|
1063
1218
|
() => {
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1219
|
+
settingsOutput.textContent = value === undefined ? "暂无设置" : JSON.stringify(value, null, 2);
|
|
1220
|
+
navigation.open("$settings");
|
|
1221
|
+
},
|
|
1222
|
+
);
|
|
1223
|
+
});
|
|
1224
|
+
const cachePage = element("section", "pp-cache-page");
|
|
1225
|
+
const output = element("pre", "pp-cache");
|
|
1226
|
+
output.textContent = "暂无缓存";
|
|
1227
|
+
output.setAttribute("aria-label", "Caches 内容");
|
|
1228
|
+
cachePage.append(output);
|
|
1229
|
+
editors.set("$caches", { node: cachePage, title: "缓存" });
|
|
1230
|
+
handlers.set("viewCaches", () => {
|
|
1231
|
+
if (saving) return;
|
|
1232
|
+
let value;
|
|
1233
|
+
return perform(
|
|
1234
|
+
async () => {
|
|
1235
|
+
try {
|
|
1236
|
+
value = await client.readCaches();
|
|
1237
|
+
} catch (error) {
|
|
1238
|
+
notify({ kind: "error", message: error.message });
|
|
1067
1239
|
throw error;
|
|
1068
1240
|
}
|
|
1069
|
-
return client.set(field.key, value);
|
|
1070
1241
|
},
|
|
1071
1242
|
() => {
|
|
1072
|
-
|
|
1243
|
+
output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
|
|
1244
|
+
navigation.open("$caches");
|
|
1073
1245
|
},
|
|
1074
|
-
restore,
|
|
1075
1246
|
);
|
|
1076
1247
|
});
|
|
1077
|
-
|
|
1078
|
-
|
|
1248
|
+
handlers.set("clearCaches", async () => {
|
|
1249
|
+
if (saving) return;
|
|
1250
|
+
if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;
|
|
1251
|
+
return perform(
|
|
1252
|
+
() => client.clearCaches(),
|
|
1253
|
+
() => {
|
|
1254
|
+
output.textContent = "暂无缓存";
|
|
1255
|
+
},
|
|
1256
|
+
);
|
|
1257
|
+
});
|
|
1258
|
+
handlers.set("reset", async () => {
|
|
1259
|
+
if (saving) return;
|
|
1260
|
+
if (!(await requestConfirmation(window, `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;
|
|
1261
|
+
return perform(() => client.reset(), controls);
|
|
1262
|
+
});
|
|
1263
|
+
navigation?.destroy();
|
|
1264
|
+
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
1265
|
+
navigation.addEventListener("change", updateNavigation);
|
|
1266
|
+
for (const grow of growingInputs) grow();
|
|
1267
|
+
updateNavigation();
|
|
1079
1268
|
}
|
|
1080
|
-
const settingsPage = element("section", "pp-settings-page");
|
|
1081
|
-
const settingsOutput = element("pre", "pp-cache");
|
|
1082
|
-
settingsOutput.setAttribute("aria-label", "Settings 内容");
|
|
1083
|
-
settingsPage.append(settingsOutput);
|
|
1084
|
-
editors.set("$settings", { node: settingsPage, title: "设置" });
|
|
1085
|
-
handlers.set("viewSettings", () => {
|
|
1086
|
-
if (saving) return;
|
|
1087
|
-
let value;
|
|
1088
|
-
return perform(
|
|
1089
|
-
async () => {
|
|
1090
|
-
try {
|
|
1091
|
-
value = await client.readSettings();
|
|
1092
|
-
} catch (error) {
|
|
1093
|
-
notify({ kind: "error", message: error.message });
|
|
1094
|
-
throw error;
|
|
1095
|
-
}
|
|
1096
|
-
},
|
|
1097
|
-
() => {
|
|
1098
|
-
settingsOutput.textContent = value === undefined ? "暂无设置" : JSON.stringify(value, null, 2);
|
|
1099
|
-
navigation.open("$settings");
|
|
1100
|
-
},
|
|
1101
|
-
);
|
|
1102
|
-
});
|
|
1103
|
-
const cachePage = element("section", "pp-cache-page");
|
|
1104
|
-
const output = element("pre", "pp-cache");
|
|
1105
|
-
output.textContent = "暂无缓存";
|
|
1106
|
-
output.setAttribute("aria-label", "Caches 内容");
|
|
1107
|
-
cachePage.append(output);
|
|
1108
|
-
editors.set("$caches", { node: cachePage, title: "缓存" });
|
|
1109
|
-
handlers.set("viewCaches", () => {
|
|
1110
|
-
if (saving) return;
|
|
1111
|
-
let value;
|
|
1112
|
-
return perform(
|
|
1113
|
-
async () => {
|
|
1114
|
-
try {
|
|
1115
|
-
value = await client.readCaches();
|
|
1116
|
-
} catch (error) {
|
|
1117
|
-
notify({ kind: "error", message: error.message });
|
|
1118
|
-
throw error;
|
|
1119
|
-
}
|
|
1120
|
-
},
|
|
1121
|
-
() => {
|
|
1122
|
-
output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
|
|
1123
|
-
navigation.open("$caches");
|
|
1124
|
-
},
|
|
1125
|
-
);
|
|
1126
|
-
});
|
|
1127
|
-
handlers.set("clearCaches", async () => {
|
|
1128
|
-
if (saving) return;
|
|
1129
|
-
if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;
|
|
1130
|
-
return perform(
|
|
1131
|
-
() => client.clearCaches(),
|
|
1132
|
-
() => {
|
|
1133
|
-
output.textContent = "暂无缓存";
|
|
1134
|
-
},
|
|
1135
|
-
);
|
|
1136
|
-
});
|
|
1137
|
-
handlers.set("reset", async () => {
|
|
1138
|
-
if (saving) return;
|
|
1139
|
-
if (!(await requestConfirmation(window, `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;
|
|
1140
|
-
return perform(() => client.reset(), controls);
|
|
1141
|
-
});
|
|
1142
|
-
navigation?.destroy();
|
|
1143
|
-
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
1144
|
-
navigation.addEventListener("change", updateNavigation);
|
|
1145
|
-
for (const grow of growingInputs) grow();
|
|
1146
|
-
updateNavigation();
|
|
1147
|
-
}
|
|
1148
|
-
/**
|
|
1149
|
-
* 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。
|
|
1150
|
-
* Loaded forms delegate back to navigation; loading views can return to the previous document.
|
|
1151
|
-
* @returns {void} 无返回值 / No return value.
|
|
1152
|
-
*/
|
|
1153
|
-
back.onclick = () => {
|
|
1154
|
-
if (saving) return;
|
|
1155
|
-
if (navigation) navigation.back();
|
|
1156
|
-
else window.history.back();
|
|
1157
|
-
};
|
|
1158
|
-
open(definition.module);
|
|
1159
|
-
return {
|
|
1160
1269
|
/**
|
|
1161
|
-
*
|
|
1162
|
-
*
|
|
1270
|
+
* 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。
|
|
1271
|
+
* Loaded forms delegate back to navigation; loading views can return to the previous document.
|
|
1163
1272
|
* @returns {void} 无返回值 / No return value.
|
|
1164
1273
|
*/
|
|
1165
|
-
|
|
1274
|
+
back.onclick = () => {
|
|
1275
|
+
if (saving) return;
|
|
1276
|
+
if (navigation) navigation.back();
|
|
1277
|
+
else window.history.back();
|
|
1278
|
+
};
|
|
1279
|
+
open(definition.module);
|
|
1280
|
+
return () => {
|
|
1166
1281
|
destroyed = true;
|
|
1167
1282
|
menu.destroy();
|
|
1168
1283
|
window.frameElement?.removeEventListener("preferencepanes:action", onAction);
|
|
@@ -1171,8 +1286,17 @@ function mountPanel(root, model) {
|
|
|
1171
1286
|
if (active && !saving) client.leave();
|
|
1172
1287
|
clearTimeout(timer);
|
|
1173
1288
|
shell.remove();
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
/**
|
|
1293
|
+
* 移除监听器、定时器、会话和挂载内容。
|
|
1294
|
+
* Remove listeners, timers, session, and mounted content.
|
|
1295
|
+
* @returns {void} 无返回值 / No return value.
|
|
1296
|
+
*/
|
|
1297
|
+
destroy() {
|
|
1298
|
+
this.#release();
|
|
1299
|
+
}
|
|
1176
1300
|
}
|
|
1177
1301
|
|
|
1178
1302
|
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";
|
|
@@ -1196,89 +1320,102 @@ function installDefaultStyles(document) {
|
|
|
1196
1320
|
}
|
|
1197
1321
|
|
|
1198
1322
|
/**
|
|
1199
|
-
*
|
|
1200
|
-
*
|
|
1201
|
-
* @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
|
|
1202
|
-
* @param {string} [css] 可选 CSS 正文 / Optional CSS text.
|
|
1203
|
-
* @returns {import("./index.js").MountedPreferences} 模块生命周期句柄 / Module lifecycle handle.
|
|
1323
|
+
* 管理 BoxJS 规范化、主题同步和面板生命周期。
|
|
1324
|
+
* Manage BoxJS normalization, theme synchronization, and panel lifecycle.
|
|
1204
1325
|
*/
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1326
|
+
class PreferencesView {
|
|
1327
|
+
#existing;
|
|
1328
|
+
#root;
|
|
1329
|
+
#base;
|
|
1330
|
+
#ownsBase;
|
|
1331
|
+
#previousTitle;
|
|
1332
|
+
#previousTheme;
|
|
1333
|
+
#systemTheme;
|
|
1334
|
+
#previousKeyboard;
|
|
1335
|
+
#host;
|
|
1336
|
+
#observer;
|
|
1337
|
+
#panel;
|
|
1338
|
+
|
|
1339
|
+
/**
|
|
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.
|
|
1343
|
+
*/
|
|
1344
|
+
constructor(boxjs) {
|
|
1345
|
+
const definition = normalizeBoxJs(boxjs);
|
|
1346
|
+
const metadata = definition.metadata ?? {};
|
|
1347
|
+
const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
1348
|
+
if (image) resourceURL(image);
|
|
1349
|
+
if (metadata.repo) resourceURL(metadata.repo);
|
|
1350
|
+
|
|
1351
|
+
this.#existing = document.querySelector("#preferences");
|
|
1352
|
+
this.#root = this.#existing ?? element("main", "");
|
|
1353
|
+
if (!this.#existing) {
|
|
1354
|
+
this.#root.id = "preferences";
|
|
1355
|
+
document.body.append(this.#root);
|
|
1356
|
+
}
|
|
1357
|
+
const styles = installDefaultStyles(document);
|
|
1358
|
+
this.#base = styles.element;
|
|
1359
|
+
this.#ownsBase = styles.owned;
|
|
1360
|
+
this.#previousTitle = document.title;
|
|
1361
|
+
this.#previousTheme = document.documentElement.dataset.theme;
|
|
1362
|
+
this.#systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
1363
|
+
this.#previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
|
|
1364
|
+
this.#host = window.frameElement?.ownerDocument.documentElement;
|
|
1365
|
+
this.#syncAppearance();
|
|
1366
|
+
this.#systemTheme.addEventListener("change", this.#syncAppearance);
|
|
1367
|
+
if (this.#host) {
|
|
1368
|
+
this.#observer = new MutationObserver(this.#syncAppearance);
|
|
1369
|
+
this.#observer.observe(this.#host, { attributes: true, attributeFilter: ["data-theme", "style"] });
|
|
1370
|
+
}
|
|
1371
|
+
document.title = metadata.name ?? definition.module;
|
|
1372
|
+
try {
|
|
1373
|
+
this.#root.replaceChildren();
|
|
1374
|
+
this.#panel = new PreferencesPanel(this.#root, definition);
|
|
1375
|
+
} catch (error) {
|
|
1376
|
+
this.destroy();
|
|
1377
|
+
throw error;
|
|
1378
|
+
}
|
|
1225
1379
|
}
|
|
1226
|
-
|
|
1227
|
-
const custom = element("style", "");
|
|
1228
|
-
custom.textContent = css;
|
|
1229
|
-
document.head.append(custom);
|
|
1230
|
-
const previousTitle = document.title;
|
|
1231
|
-
const previousTheme = document.documentElement.dataset.theme;
|
|
1232
|
-
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
1233
|
-
const previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
|
|
1234
|
-
const host = window.frameElement?.ownerDocument.documentElement;
|
|
1380
|
+
|
|
1235
1381
|
/**
|
|
1236
1382
|
* 跟随嵌入宿主的通用环境状态,不识别业务 App 或解析其 UA。
|
|
1237
|
-
* Follow generic host appearance without detecting a business
|
|
1383
|
+
* Follow generic host appearance without detecting a business App or parsing its UA.
|
|
1238
1384
|
* @returns {void} 已同步主题与键盘避让 / Theme and keyboard clearance synchronized.
|
|
1239
1385
|
*/
|
|
1240
|
-
|
|
1241
|
-
const theme = host?.dataset.theme ?? previousTheme ?? (systemTheme.matches ? "dark" : "light");
|
|
1386
|
+
#syncAppearance = () => {
|
|
1387
|
+
const theme = this.#host?.dataset.theme ?? this.#previousTheme ?? (this.#systemTheme.matches ? "dark" : "light");
|
|
1242
1388
|
document.documentElement.dataset.theme = theme;
|
|
1243
|
-
if (host) document.documentElement.style.setProperty("--pp-keyboard-height", host.style.getPropertyValue("--pp-keyboard-height"));
|
|
1389
|
+
if (this.#host) document.documentElement.style.setProperty("--pp-keyboard-height", this.#host.style.getPropertyValue("--pp-keyboard-height"));
|
|
1244
1390
|
};
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
systemTheme.removeEventListener("change", syncAppearance);
|
|
1263
|
-
panel?.destroy();
|
|
1264
|
-
if (ownsBase) base.remove();
|
|
1265
|
-
custom.remove();
|
|
1266
|
-
if (existing) root.replaceChildren();
|
|
1267
|
-
else root.remove();
|
|
1268
|
-
document.title = previousTitle;
|
|
1269
|
-
if (previousTheme === undefined) delete document.documentElement.dataset.theme;
|
|
1270
|
-
else document.documentElement.dataset.theme = previousTheme;
|
|
1271
|
-
document.documentElement.style.setProperty("--pp-keyboard-height", previousKeyboard);
|
|
1272
|
-
},
|
|
1273
|
-
};
|
|
1274
|
-
try {
|
|
1275
|
-
root.replaceChildren();
|
|
1276
|
-
panel = mountPanel(root, rendered);
|
|
1277
|
-
return view;
|
|
1278
|
-
} catch (error) {
|
|
1279
|
-
view.destroy();
|
|
1280
|
-
throw error;
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* 释放模块视图、样式与会话,不操作项目入口页。
|
|
1394
|
+
* Release the module view, styles, and session without operating a project landing page.
|
|
1395
|
+
* @returns {void} 无返回值 / No return value.
|
|
1396
|
+
*/
|
|
1397
|
+
destroy() {
|
|
1398
|
+
this.#observer?.disconnect();
|
|
1399
|
+
this.#systemTheme.removeEventListener("change", this.#syncAppearance);
|
|
1400
|
+
this.#panel?.destroy();
|
|
1401
|
+
if (this.#ownsBase) this.#base.remove();
|
|
1402
|
+
if (this.#existing) this.#root.replaceChildren();
|
|
1403
|
+
else this.#root.remove();
|
|
1404
|
+
document.title = this.#previousTitle;
|
|
1405
|
+
if (this.#previousTheme === undefined) delete document.documentElement.dataset.theme;
|
|
1406
|
+
else document.documentElement.dataset.theme = this.#previousTheme;
|
|
1407
|
+
document.documentElement.style.setProperty("--pp-keyboard-height", this.#previousKeyboard);
|
|
1281
1408
|
}
|
|
1282
1409
|
}
|
|
1283
1410
|
|
|
1411
|
+
/**
|
|
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.
|
|
1416
|
+
*/
|
|
1417
|
+
function mount(boxjs) {
|
|
1418
|
+
return new PreferencesView(boxjs);
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1284
1421
|
export { mount };
|