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