@nsnanocat/preference-panes 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -7,6 +7,7 @@
7
7
  | 目录 | 内容 |
8
8
  | --- | --- |
9
9
  | src/SettingsHandler.mjs | 模块存储桥接 class |
10
+ | src/PreferencesHandler.mjs | 存储桥接与声明式静态资源响应 |
10
11
  | src/browser/ | WebView 控件、内存会话和样式 |
11
12
  | src/lib/ | 前端 BoxJS 与通用路径解析 |
12
13
  | src/proxy/ | 代理宿主打包入口 |
@@ -62,9 +63,47 @@ const panel = mountPreferencePanes({ element: document.querySelector("#preferenc
62
63
 
63
64
  每次进入主菜单仅并发 HEAD 各配置 Mock。打开、再次进入或刷新模块页,各 GET 一次 BoxJS 与设置子树;404 的设置子树按无覆盖值处理。保存/删除根据 HTTP 200 更新页面缓存并显示通知,不追加 GET。
64
65
 
66
+ 设置页使用分组行布局:单选为下拉框,开关为即时切换,多选显示摘要并进入可前进/后退的二级选项页。文本输入、下拉选择及勾选变化均立即串行 POST,无逐项保存或删除按钮;失败恢复当前项已保存值,较新的输入不会被较早请求覆盖。多选页返回时保留主列表滚动位置,不重新 GET 配置。单键 DELETE 能力保留在 API/客户端方法中,不作为逐项页面按钮展示。
67
+
65
68
  模块页底部提供查看/刷新 Caches、清空 Caches 和重置模块。查看缓存按需 GET;清空和重置经确认后 DELETE,成功只更新本页状态。重置后控件显示当前 BoxJS 默认值,再次进入页面才重新读取。模块选择、设置值校验和默认值处理都在前端完成。
66
69
 
67
- 业务主菜单由调用项目维护,Biliverse 的入口和四个模块按钮归 Enhanced。未提供对应配置 Mock 的插件入口保持禁用。
70
+ 主菜单、导航、控件和样式均由本包实现。调用项目只提供品牌与模块 JSON,不需要维护 HTML、页面 JS、CSS 或专用 Rollup 配置。未提供对应配置 Mock 的插件入口保持禁用。
71
+
72
+ ## 零前端代码接入
73
+
74
+ 部署本包的 `dist/settings/` 到 `/settings/assets/`,将其中 index.html 同时用于 `/settings/` 与 `/settings/{module}`。根菜单读取同目录的 `site.boxjs.json`:
75
+
76
+ ~~~json
77
+ {
78
+ "name": "Example",
79
+ "icon": "/assets/logo.png",
80
+ "sectionTitle": "模块",
81
+ "apps": [{ "module": "Module", "name": "Example Module", "icon": "/assets/module.png" }]
82
+ }
83
+ ~~~
84
+
85
+ `apps[].module` 明确对应 `/settings/{module}`、`/configs/{module}` 和 `/api/{module}/`,不从名称推断。这个菜单 JSON 只声明入口;实际字段仍从配置 Mock 返回的 BoxJS 生成。菜单可选 `desc`、`iconDark` 和 `stylesheets`;`iconDark` 是显式暗色图标扩展,不能把 BoxJS 的透明/彩色 icons 当成亮暗版本。stylesheets 仅加载接入方指定的 HTTP(S) 样式,不加载业务 JS。
86
+
87
+ 根页面每次进入重新 HEAD 探测,菜单 JSON 在当前文档只读取一次;模块页直接打开时无需先读菜单。图片与额外样式由托管站点提供。
88
+
89
+ 代理优先使用原生 Mock 提供配置和页面。需要请求脚本响应静态资源的平台,直接使用本包的 PreferencesHandler,安装参数也可放在 JSON 中:
90
+
91
+ ~~~js
92
+ import { PreferencesHandler } from "@nsnanocat/preference-panes";
93
+
94
+ const handler = new PreferencesHandler({
95
+ origin: "https://example.org",
96
+ storageKey: "Root",
97
+ module: "Module",
98
+ resources: [
99
+ { pattern: "^/configs/Module$", source: "https://example.org/settings/assets/Module.boxjs.json", contentType: "application/json" },
100
+ { pattern: "^/settings/(?:[a-zA-Z0-9_-]+/?)?$", source: "https://example.org/settings/assets/index.html", contentType: "text/html" }
101
+ ]
102
+ });
103
+ const response = await handler.handle($request);
104
+ ~~~
105
+
106
+ 资源 pattern 匹配 pathname,下载源必须避开拦截路径。只有命中静态资源的 GET/HEAD 才下载文件;API 由 SettingsHandler 直接处理,读写不会下载 BoxJS。接入方保留宿主请求入口和原生 Mock 规则,将响应交给现有 done 适配;无需自行实现路由、fetch、控件或持久化操作。
68
107
 
69
108
  ## BoxJS 兼容
70
109
 
@@ -90,6 +129,6 @@ npm run apifox:check
90
129
  npm pack --dry-run
91
130
  ~~~
92
131
 
93
- 构建生成 dist/preference-panes.mjsdist/preference-panes.request.js;公开 import 路径由 exports 保持稳定。0.3.0 的安装参数替换 0.2.0 的 configURL,HTTP 读写从声明字段变为模块内的任意数据,是一次契约升级。
132
+ 构建生成 dist/preference-panes.mjsdist/preference-panes.request.js,以及可直接部署的 dist/settings/{index.html,app.mjs,panel.css,home.css}。静态文件也通过 `@nsnanocat/preference-panes/dist/settings/*` 导出,托管仓库直接从依赖包复制,不读取业务插件的前端构建目录。0.4.0 增加完整页面与资源处理器,保留 0.3.1 的存储契约和页面交互。
94
133
 
95
134
  [完整接口说明](apifox/guide.md) · [Apifox JSON](apifox/preference-panes.apifox.json) · [同步方式](apifox/README.md) · [发布工作流](.github/RELEASING.md)
@@ -400,17 +400,19 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
400
400
  };
401
401
  const shell = node("div", "pp-panel");
402
402
  const header = node("header", "pp-header");
403
- const back = node("button", "pp-back", "返回");
403
+ const back = node("button", "pp-back", "");
404
+ back.setAttribute("aria-label", "返回");
404
405
  back.type = "button";
405
406
  const heading = node("h1", "pp-title", title);
406
407
  const viewport = node("div", "pp-viewport");
407
408
  const toast = node("div", "pp-toast");
408
409
  toast.setAttribute("role", "status");
409
410
  toast.hidden = true;
410
- header.append(back, heading);
411
+ header.append(back, heading, node("span", "pp-nav-spacer"));
411
412
  shell.append(header, viewport, toast);
412
413
  root.append(shell);
413
414
  let timer,
415
+ secondaryRoute,
414
416
  routedPath,
415
417
  generation = 0,
416
418
  active = null,
@@ -505,40 +507,68 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
505
507
  const view = node("section", "pp-fields");
506
508
  /** @type {Array<() => void>} 挂载后执行的多行高度更新 / Textarea sizing callbacks run after mounting. */
507
509
  const growingInputs = [];
510
+ const editors = new Map();
511
+ const summaries = [];
512
+ const groups = new Map();
513
+ const scrollPositions = new WeakMap();
514
+ let activeEditor;
515
+ let queue = Promise.resolve(),
516
+ pendingWrites = 0;
508
517
  /**
509
- * 写入期间统一切换控件禁用状态。
510
- * Toggle all control disabled states during mutations.
511
- * @param {boolean} disabled 是否禁用 / Whether controls are disabled.
518
+ * 根据 hash 切换多选页,保留上级 DOM 和滚动位置。
519
+ * Switch multi-select views by hash while retaining parent DOM and scroll position.
512
520
  * @returns {void} 无返回值 / No return value.
513
521
  */
514
- const disableControls = disabled => {
515
- view.querySelectorAll("button,input,select,textarea").forEach(input => {
516
- input.disabled = disabled;
517
- });
522
+ const showEditor = () => {
523
+ let key;
524
+ try {
525
+ key = decodeURIComponent(window.location.hash.slice(1));
526
+ } catch {
527
+ key = "";
528
+ }
529
+ const editor = editors.get(key);
530
+ const previous = activeEditor?.node ?? view;
531
+ const next = editor?.node ?? view;
532
+ if (previous !== next) {
533
+ scrollPositions.set(previous, previous.scrollTop);
534
+ previous.remove();
535
+ viewport.append(next);
536
+ next.scrollTop = scrollPositions.get(next) ?? 0;
537
+ if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) next.animate([{ transform: `translateX(${editor ? 100 : -100}%)` }, { transform: "translateX(0)" }], { duration: 260, easing: "cubic-bezier(.22,.61,.36,1)" });
538
+ }
539
+ activeEditor = editor;
540
+ heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
541
+ back.disabled = saving || (!editor && window.history.length <= 1);
518
542
  };
543
+ secondaryRoute = showEditor;
519
544
  /**
520
- * 执行页面操作,期间锁定控件,完成后处理延后的导航。
521
- * Run a page action with controls locked, then process deferred navigation.
545
+ * 串行执行页面操作,输入可继续编辑,完成后处理延后导航。
546
+ * Serialize page actions while inputs remain editable, then process deferred navigation.
522
547
  * @param {() => Promise<void>} action 请求或写入 / Request or mutation.
523
548
  * @param {() => void} success 成功后的局部更新 / Local update after success.
549
+ * @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.
524
550
  * @returns {Promise<void>} 操作完成 / Operation completion.
525
551
  */
526
- async function perform(action, success) {
527
- if (saving) return;
552
+ function perform(action, success, failure = () => {}) {
553
+ pendingWrites++;
528
554
  saving = true;
529
555
  back.disabled = true;
530
- disableControls(true);
531
- try {
532
- await action();
533
- if (!destroyed) success();
534
- } catch {
535
- /* 请求层已通知错误 / The request layer has already reported the error. */
536
- } finally {
537
- saving = false;
538
- back.disabled = window.history.length <= 1;
539
- disableControls(false);
540
- if (!destroyed && pendingRoute) route();
541
- }
556
+ return (queue = queue
557
+ .then(action)
558
+ .then(() => {
559
+ if (!destroyed) success();
560
+ })
561
+ .catch(() => {
562
+ /* 请求层已通知错误 / The request layer has already reported the error. */
563
+ if (!destroyed) failure();
564
+ })
565
+ .finally(() => {
566
+ pendingWrites--;
567
+ saving = pendingWrites > 0;
568
+ if (destroyed && !saving) client.leave(active);
569
+ back.disabled = saving || (!activeEditor && window.history.length <= 1);
570
+ if (!saving && !destroyed && pendingRoute) route();
571
+ }));
542
572
  }
543
573
  const metadata = definition.metadata;
544
574
  if (metadata) {
@@ -575,14 +605,27 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
575
605
  view.append(info);
576
606
  }
577
607
  for (const field of definition.fields) {
578
- const row = node("fieldset", "pp-field");
579
- row.append(node("legend", "", field.name));
580
- if (field.description) row.append(node("p", "pp-description", field.description));
608
+ const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
609
+ const group = match?.[1] ?? "通用";
610
+ if (!groups.has(group)) {
611
+ const section = node("section", "form-group");
612
+ const rows = node("div", "form-group__row");
613
+ section.append(node("h2", "form-group__title", group), rows);
614
+ groups.set(group, rows);
615
+ view.append(section);
616
+ }
617
+ const row = node("div", "form-row pp-field");
618
+ const label = node("div", "form-row__text");
619
+ label.append(node("span", "form-row__title", match?.[2] ?? field.name));
620
+ if (field.description) label.append(node("span", "form-row__subtitle", field.description));
621
+ row.append(label);
581
622
  const value = values[field.key];
582
623
  /** @type {() => unknown} 读取尚未保存的输入 / Read the unsaved input. */
583
624
  let read;
584
625
  /** @type {(value: unknown) => void} 更新当前控件 / Update the current control. */
585
626
  let write;
627
+ let inputContainer = row;
628
+ let eventName = "change";
586
629
  switch (true) {
587
630
  case Boolean(field.options) && field.type !== "array": {
588
631
  const select = node("select", "pp-input");
@@ -600,12 +643,42 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
600
643
  break;
601
644
  }
602
645
  case field.type === "array" && Boolean(field.options): {
646
+ const page = node("section", "pp-choice-page");
647
+ if (field.description) page.append(node("p", "pp-description", field.description));
648
+ const choices = node("div", "form-group__row");
649
+ page.append(choices);
650
+ inputContainer = choices;
651
+ editors.set(field.key, { node: page, title: match?.[2] ?? field.name });
652
+ const summary = node("span", "form-row__value pp-summary");
653
+ const link = node("button", "pp-choice-link");
654
+ link.type = "button";
655
+ link.setAttribute("aria-label", field.name);
656
+ link.append(summary, node("span", "pp-chevron", "›"));
657
+ row.append(link);
658
+ const refresh = () => {
659
+ const value = client.snapshot(active).values[field.key];
660
+ summary.textContent =
661
+ field.options
662
+ .filter(option => Array.isArray(value) && value.includes(option.key))
663
+ .map(option => option.label)
664
+ .join("、") || "未选择";
665
+ };
666
+ summaries.push(refresh);
667
+ refresh();
668
+ link.onclick = () => {
669
+ window.history.pushState({ ...window.history.state, preferencePane: active }, "", `#${encodeURIComponent(field.key)}`);
670
+ showEditor();
671
+ };
672
+ row.addEventListener("click", event => {
673
+ if (!link.contains(event.target)) link.click();
674
+ });
603
675
  const inputs = field.options.map(option => {
604
- const label = node("label", "pp-choice", option.label);
676
+ const label = node("label", "form-row pp-choice", option.label);
605
677
  const input = node("input", "");
606
678
  input.type = "checkbox";
607
- label.prepend(input);
608
- row.append(label);
679
+ input.setAttribute("aria-label", option.label);
680
+ label.append(input);
681
+ choices.append(label);
609
682
  return { input, key: option.key };
610
683
  });
611
684
  read = () => inputs.filter(option => option.input.checked).map(option => option.key);
@@ -617,6 +690,7 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
617
690
  default: {
618
691
  const multiline = field.control === "textarea" || field.type === "array";
619
692
  const input = node(multiline ? "textarea" : "input", "pp-input");
693
+ if (multiline) row.classList.add("pp-multiline");
620
694
  input.setAttribute("aria-label", field.name);
621
695
  if (field.placeholder) input.placeholder = field.placeholder;
622
696
  if (multiline && field.rows) input.rows = field.rows;
@@ -639,11 +713,14 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
639
713
  }
640
714
  if (field.type === "boolean") {
641
715
  input.type = "checkbox";
716
+ input.classList.add("pp-switch");
717
+ input.setAttribute("role", "switch");
642
718
  write = value => {
643
719
  input.checked = value === true;
644
720
  };
645
721
  read = () => input.checked;
646
722
  } else {
723
+ eventName = "input";
647
724
  if (!multiline) input.type = field.type === "number" ? "number" : "text";
648
725
  write = value => {
649
726
  input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
@@ -665,34 +742,31 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
665
742
  }
666
743
  }
667
744
  write(value);
668
- const actions = node("div", "pp-actions");
669
- for (const [operation, label] of [
670
- ["write", "保存"],
671
- ["delete", "删除覆盖值"],
672
- ]) {
673
- const button = node("button", "", label);
674
- button.type = "button";
675
- button.onclick = () =>
676
- perform(
677
- async () => {
678
- if (operation === "delete") await client.remove(active, field.key);
679
- else {
680
- let value;
681
- try {
682
- value = read();
683
- } catch (error) {
684
- notify({ kind: "error", message: error.message });
685
- throw error;
686
- }
687
- await client.set(active, field.key, value);
688
- }
689
- },
690
- () => write(client.snapshot(active).values[field.key]),
691
- );
692
- actions.append(button);
693
- }
694
- row.append(actions);
695
- view.append(row);
745
+ let inputVersion = 0;
746
+ inputContainer.addEventListener(eventName, event => {
747
+ if (event.isComposing) return;
748
+ const version = ++inputVersion,
749
+ module = active;
750
+ let value;
751
+ try {
752
+ value = read();
753
+ } catch (error) {
754
+ notify({ kind: "error", message: error.message });
755
+ return;
756
+ }
757
+ const restore = () => {
758
+ if (version === inputVersion) write(client.snapshot(module).values[field.key]);
759
+ };
760
+ perform(
761
+ () => client.set(module, field.key, value),
762
+ () => {
763
+ for (const refresh of summaries) refresh();
764
+ },
765
+ restore,
766
+ );
767
+ });
768
+ if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
769
+ groups.get(group).append(row);
696
770
  }
697
771
  const maintenance = node("section", "pp-maintenance");
698
772
  maintenance.append(node("h2", "pp-title", "模块数据"));
@@ -705,6 +779,7 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
705
779
  output.setAttribute("aria-label", "Caches 内容");
706
780
  for (const button of [cacheView, cacheClear, reset]) button.type = "button";
707
781
  cacheView.onclick = () => {
782
+ if (saving) return;
708
783
  let value;
709
784
  return perform(
710
785
  async () => {
@@ -723,6 +798,7 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
723
798
  );
724
799
  };
725
800
  cacheClear.onclick = () => {
801
+ if (saving) return;
726
802
  if (!window.confirm(`清空 ${active} 的全部 Caches?`)) return;
727
803
  return perform(
728
804
  () => client.clearCaches(active),
@@ -732,6 +808,7 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
732
808
  );
733
809
  };
734
810
  reset.onclick = () => {
811
+ if (saving) return;
735
812
  if (!window.confirm(`重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) return;
736
813
  return perform(() => client.reset(active), controls);
737
814
  };
@@ -740,6 +817,7 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
740
817
  view.append(maintenance);
741
818
  viewport.replaceChildren(view);
742
819
  for (const grow of growingInputs) grow();
820
+ showEditor();
743
821
  }
744
822
  /**
745
823
  * 按页面 pathname 切换模块,写入尚未完成时延后导航。
@@ -752,6 +830,7 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
752
830
  return;
753
831
  }
754
832
  pendingRoute = false;
833
+ secondaryRoute = undefined;
755
834
  if (active) client.leave(active);
756
835
  routedPath = window.location.pathname;
757
836
  const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(routedPath);
@@ -771,7 +850,9 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
771
850
  */
772
851
  const onPopState = () => {
773
852
  if (window.location.pathname !== routedPath) route();
853
+ else secondaryRoute?.();
774
854
  };
855
+ const onHashChange = () => secondaryRoute?.();
775
856
  /**
776
857
  * 从浏览器往返缓存恢复时重新读取当前模块。
777
858
  * Reload the current module when restored from the browser back-forward cache.
@@ -782,10 +863,15 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
782
863
  if (event.persisted) route();
783
864
  };
784
865
  back.onclick = () => {
785
- if (!saving) window.history.back();
866
+ if (saving) return;
867
+ if (window.location.hash && window.history.state?.preferencePane !== active) {
868
+ window.history.replaceState(window.history.state, "", window.location.pathname);
869
+ secondaryRoute?.();
870
+ } else window.history.back();
786
871
  };
787
872
  window.addEventListener("popstate", onPopState);
788
873
  window.addEventListener("pageshow", onPageShow);
874
+ window.addEventListener("hashchange", onHashChange);
789
875
  route();
790
876
  return {
791
877
  /**
@@ -797,8 +883,9 @@ function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
797
883
  destroyed = true;
798
884
  window.removeEventListener("popstate", onPopState);
799
885
  window.removeEventListener("pageshow", onPageShow);
886
+ window.removeEventListener("hashchange", onHashChange);
800
887
  generation++;
801
- if (active) client.leave(active);
888
+ if (active && !saving) client.leave(active);
802
889
  clearTimeout(timer);
803
890
  shell.remove();
804
891
  },