@nsnanocat/preference-panes 0.3.0 → 0.3.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 CHANGED
@@ -62,6 +62,8 @@ const panel = mountPreferencePanes({ element: document.querySelector("#preferenc
62
62
 
63
63
  每次进入主菜单仅并发 HEAD 各配置 Mock。打开、再次进入或刷新模块页,各 GET 一次 BoxJS 与设置子树;404 的设置子树按无覆盖值处理。保存/删除根据 HTTP 200 更新页面缓存并显示通知,不追加 GET。
64
64
 
65
+ 设置页使用分组行布局:单选为下拉框,开关为即时切换,多选显示摘要并进入可前进/后退的二级选项页。文本输入、下拉选择及勾选变化均立即串行 POST,无逐项保存或删除按钮;失败恢复当前项已保存值,较新的输入不会被较早请求覆盖。多选页返回时保留主列表滚动位置,不重新 GET 配置。单键 DELETE 能力保留在 API/客户端方法中,不作为逐项页面按钮展示。
66
+
65
67
  模块页底部提供查看/刷新 Caches、清空 Caches 和重置模块。查看缓存按需 GET;清空和重置经确认后 DELETE,成功只更新本页状态。重置后控件显示当前 BoxJS 默认值,再次进入页面才重新读取。模块选择、设置值校验和默认值处理都在前端完成。
66
68
 
67
69
  业务主菜单由调用项目维护,Biliverse 的入口和四个模块按钮归 Enhanced。未提供对应配置 Mock 的插件入口保持禁用。
@@ -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
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nsnanocat/preference-panes",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Shared settings API runtime for JavaScript proxy modules",
5
5
  "author": "VirgilClyne <Virgil@nanocat.me>",
6
6
  "homepage": "https://NSNanoCat.github.io/preference-panes",
@@ -147,6 +147,8 @@ export function createPreferencesClient(options?: PreferencesClientOptions): Pre
147
147
  /**
148
148
  * 按 /settings/{module} 挂载动态面板,监听导航和页面恢复事件。
149
149
  * Mount a dynamic panel at /settings/{module} and observe navigation and page restoration.
150
+ * 控件变化即时串行写入;多选用二级页,返回不重新读取设置。
151
+ * Control changes save immediately in sequence; multi-select uses a secondary page without refetching on return.
150
152
  * @param options 挂载选项 / Mount options.
151
153
  * @returns 面板生命周期句柄 / Panel lifecycle handle.
152
154
  */
@@ -1,106 +1,274 @@
1
+ /* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。
2
+ * Grouped rows follow the Bilibili settings layout, scoped to the panel. */
1
3
  .pp-panel {
4
+ --pp-text: #18191c;
5
+ --pp-background: #f6f7f8;
6
+ --pp-surface: #fff;
7
+ --pp-border: #e3e5e7;
8
+ --pp-muted: #9499a0;
9
+ --pp-accent: #fb7299;
2
10
  font:
3
- 15px / 1.5 system-ui,
11
+ 15px / 1.5 -apple-system,
12
+ BlinkMacSystemFont,
13
+ "Segoe UI",
4
14
  sans-serif;
5
- color: var(--pp-text, #18191c);
6
- background: var(--pp-background, #f6f7f8);
7
- max-width: 760px;
8
- margin: auto;
15
+ color: var(--pp-text);
16
+ background: var(--pp-background);
9
17
  position: relative;
18
+ min-height: 100vh;
10
19
  }
11
20
  .pp-panel * {
12
21
  box-sizing: border-box;
22
+ letter-spacing: 0;
13
23
  }
14
24
  .pp-header {
25
+ height: calc(52px + env(safe-area-inset-top));
26
+ padding: env(safe-area-inset-top) 12px 0;
15
27
  display: flex;
16
28
  align-items: center;
17
- gap: 12px;
18
- padding: 12px 16px;
19
- background: var(--pp-surface, #fff);
29
+ background: var(--pp-surface);
30
+ border-bottom: 1px solid var(--pp-border);
31
+ position: relative;
20
32
  }
21
33
  .pp-title {
22
- font-size: 18px;
34
+ font-size: 17px;
35
+ font-weight: 500;
23
36
  margin: 0;
24
- overflow-wrap: anywhere;
25
37
  min-width: 0;
38
+ overflow-wrap: anywhere;
26
39
  }
27
- .pp-module-info {
28
- display: flex;
29
- gap: 12px;
30
- margin-bottom: 16px;
40
+ .pp-header .pp-title {
41
+ flex: 1;
42
+ text-align: center;
31
43
  }
32
- .pp-module-icon {
33
- width: 48px;
34
- height: 48px;
44
+ .pp-nav-spacer {
45
+ width: 44px;
35
46
  flex: none;
36
- object-fit: contain;
37
- }
38
- .pp-module-details {
39
- min-width: 0;
40
- overflow-wrap: anywhere;
41
47
  }
42
- .pp-module-source {
48
+ .pp-panel button {
49
+ font: inherit;
50
+ cursor: pointer;
51
+ border: 0;
52
+ background: none;
43
53
  color: inherit;
44
- text-decoration: underline;
54
+ }
55
+ .pp-panel .pp-back {
56
+ width: 44px;
57
+ height: 44px;
58
+ flex: none;
59
+ font-size: 34px;
60
+ line-height: 32px;
61
+ padding: 0;
62
+ }
63
+ .pp-panel button:disabled {
64
+ opacity: 0.5;
65
+ cursor: wait;
45
66
  }
46
67
  .pp-viewport {
47
- max-height: 80vh;
68
+ position: relative;
69
+ height: calc(100vh - 52px - env(safe-area-inset-top));
70
+ overflow: hidden;
71
+ }
72
+ @supports (height: 100dvh) {
73
+ .pp-viewport {
74
+ height: calc(100dvh - 52px - env(safe-area-inset-top));
75
+ }
76
+ }
77
+ .pp-fields,
78
+ .pp-choice-page {
79
+ position: absolute;
80
+ inset: 0;
48
81
  overflow: auto;
49
- padding: 16px;
82
+ padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));
83
+ background: var(--pp-background);
50
84
  }
51
- .pp-panel button {
52
- font: inherit;
53
- cursor: pointer;
54
- border: 1px solid var(--pp-border, #d8dce0);
85
+ .pp-panel .form-group {
86
+ margin: 0 0 12px;
87
+ }
88
+ .pp-panel .form-group__title {
89
+ font-size: 12px;
90
+ line-height: 17px;
91
+ font-weight: 400;
92
+ color: var(--pp-muted);
93
+ padding-left: 12px;
94
+ margin: 12px 0 6px;
95
+ }
96
+ .pp-panel .form-group__row {
55
97
  border-radius: 8px;
56
- padding: 8px 12px;
57
- color: inherit;
58
- background: var(--pp-surface, #fff);
98
+ overflow: hidden;
99
+ background: var(--pp-surface);
59
100
  }
60
- .pp-panel button:disabled {
61
- opacity: 0.45;
62
- cursor: default;
63
- }
64
- .pp-field {
65
- margin: 0 0 16px;
66
- padding: 16px;
67
- border: 1px solid var(--pp-border, #d8dce0);
68
- border-radius: 10px;
69
- background: var(--pp-surface, #fff);
101
+ .pp-panel .form-row {
102
+ position: relative;
103
+ display: flex;
104
+ align-items: center;
105
+ width: 100%;
106
+ min-height: 46px;
107
+ padding: 12px;
108
+ border: 0;
109
+ border-bottom: 1px solid var(--pp-border);
110
+ background: var(--pp-surface);
111
+ gap: 12px;
112
+ }
113
+ .pp-panel .form-row:last-child {
114
+ border-bottom: 0;
115
+ }
116
+ .pp-panel .form-row__text {
117
+ flex: 1;
70
118
  min-width: 0;
119
+ margin: 0;
120
+ display: flex;
121
+ flex-direction: column;
71
122
  }
72
- .pp-description {
123
+ .pp-panel .form-row__title {
124
+ font-size: 15px;
125
+ line-height: 22px;
126
+ color: var(--pp-text);
127
+ text-align: left;
128
+ }
129
+ .pp-panel .form-row__subtitle {
130
+ font-size: 12px;
131
+ line-height: 18px;
132
+ color: var(--pp-muted);
133
+ overflow-wrap: anywhere;
134
+ margin-top: 2px;
135
+ }
136
+ .pp-choice-link {
137
+ display: flex;
138
+ align-items: center;
139
+ justify-content: flex-end;
140
+ gap: 8px;
141
+ max-width: 45%;
142
+ min-width: 44px;
143
+ min-height: 44px;
144
+ padding: 0;
145
+ text-align: right;
146
+ flex: 1;
147
+ }
148
+ .pp-summary {
149
+ color: var(--pp-muted);
73
150
  font-size: 13px;
74
- opacity: 0.7;
75
- margin: 0 0 8px;
76
- white-space: pre-wrap;
151
+ line-height: 18px;
152
+ display: -webkit-box;
153
+ -webkit-line-clamp: 2;
154
+ -webkit-box-orient: vertical;
155
+ overflow: hidden;
77
156
  overflow-wrap: anywhere;
78
157
  }
79
- .pp-input:not([type="checkbox"]) {
80
- width: 100%;
158
+ .pp-chevron {
159
+ color: var(--pp-muted);
160
+ font-size: 22px;
161
+ flex: none;
162
+ }
163
+ .pp-input {
81
164
  font: inherit;
82
- padding: 8px;
83
- border: 1px solid var(--pp-border, #d8dce0);
165
+ color: var(--pp-text);
166
+ background: var(--pp-surface);
167
+ border: 1px solid var(--pp-border);
84
168
  border-radius: 6px;
85
- background: var(--pp-surface, #fff);
86
- color: inherit;
169
+ padding: 8px;
170
+ min-width: 0;
171
+ max-width: 45%;
172
+ width: 45%;
87
173
  }
88
- .pp-choice {
174
+ select.pp-input {
175
+ text-overflow: ellipsis;
176
+ font-size: 13px;
177
+ }
178
+ .pp-panel .pp-multiline {
89
179
  display: block;
90
- margin: 6px 0;
180
+ }
181
+ .pp-multiline .pp-input {
182
+ max-width: 100%;
183
+ width: 100%;
184
+ margin-top: 10px;
185
+ }
186
+ .pp-switch {
187
+ appearance: none;
188
+ -webkit-appearance: none;
189
+ position: relative;
190
+ flex: none;
191
+ width: 32px;
192
+ height: 20px;
193
+ max-width: none;
194
+ border: 0;
195
+ border-radius: 15px;
196
+ padding: 0;
197
+ background: #c9ccd0;
198
+ cursor: pointer;
199
+ transition: background 0.2s;
200
+ }
201
+ .pp-switch::before {
202
+ content: "";
203
+ position: absolute;
204
+ top: 3px;
205
+ left: 3px;
206
+ width: 14px;
207
+ height: 14px;
208
+ border-radius: 50%;
209
+ background: white;
210
+ transition: transform 0.2s;
211
+ }
212
+ .pp-switch:checked {
213
+ background: var(--pp-accent);
214
+ }
215
+ .pp-switch:checked::before {
216
+ transform: translateX(12px);
217
+ }
218
+ .pp-choice {
219
+ justify-content: space-between;
220
+ cursor: pointer;
91
221
  }
92
222
  .pp-choice input {
93
- margin-right: 8px;
223
+ width: 20px;
224
+ height: 20px;
225
+ flex: none;
226
+ accent-color: var(--pp-accent);
227
+ margin: 0;
228
+ }
229
+ .pp-description {
230
+ font-size: 12px;
231
+ line-height: 1.6;
232
+ color: var(--pp-muted);
233
+ white-space: pre-wrap;
234
+ overflow-wrap: anywhere;
235
+ }
236
+ .pp-module-info {
237
+ display: flex;
238
+ gap: 12px;
239
+ margin: 12px 0;
240
+ }
241
+ .pp-module-icon {
242
+ width: 48px;
243
+ height: 48px;
244
+ object-fit: contain;
245
+ flex: none;
246
+ }
247
+ .pp-module-details {
248
+ min-width: 0;
249
+ overflow-wrap: anywhere;
250
+ }
251
+ .pp-module-source {
252
+ color: inherit;
253
+ text-decoration: underline;
254
+ }
255
+ .pp-maintenance {
256
+ margin-top: 24px;
94
257
  }
95
258
  .pp-actions {
96
259
  display: flex;
97
260
  flex-wrap: wrap;
98
261
  gap: 8px;
99
- margin-top: 12px;
100
262
  }
101
- .pp-maintenance {
102
- border-top: 1px solid var(--pp-border, #d8dce0);
103
- padding-top: 16px;
263
+ .pp-actions button,
264
+ .pp-error button {
265
+ min-height: 44px;
266
+ padding: 8px 12px;
267
+ border-radius: 6px;
268
+ background: var(--pp-surface);
269
+ }
270
+ .pp-panel .pp-danger {
271
+ color: #e45656;
104
272
  }
105
273
  .pp-cache {
106
274
  max-height: 320px;
@@ -108,33 +276,50 @@
108
276
  white-space: pre-wrap;
109
277
  overflow-wrap: anywhere;
110
278
  }
111
- .pp-panel .pp-danger {
112
- color: #d34242;
113
- }
114
279
  .pp-toast {
280
+ pointer-events: none;
115
281
  position: fixed;
116
- bottom: 32px;
282
+ bottom: calc(30px + env(safe-area-inset-bottom));
117
283
  left: 50%;
118
284
  transform: translateX(-50%);
119
- padding: 10px 18px;
120
- border-radius: 10px;
121
- background: #252525;
122
- color: #fff;
123
285
  max-width: 90vw;
124
- z-index: 1000;
286
+ padding: 10px 16px;
287
+ border-radius: 8px;
288
+ background: #333e;
289
+ color: white;
290
+ font-size: 13px;
291
+ z-index: 20;
125
292
  }
126
293
  .pp-toast[data-kind="error"] {
127
294
  background: #8d2424;
128
295
  }
129
296
  .pp-panel :focus-visible {
130
- outline: 2px solid #008ac5;
131
- outline-offset: 2px;
297
+ outline: 2px solid var(--pp-accent);
298
+ outline-offset: -2px;
132
299
  }
133
300
  @media (prefers-color-scheme: dark) {
134
301
  .pp-panel {
135
- --pp-text: #eee;
136
- --pp-background: #18191c;
137
- --pp-surface: #25262a;
138
- --pp-border: #47494e;
302
+ --pp-text: #e3e5e7;
303
+ --pp-background: #17181a;
304
+ --pp-surface: #232427;
305
+ --pp-border: #343538;
306
+ }
307
+ }
308
+ :root[data-theme="dark"] .pp-panel {
309
+ --pp-text: #e3e5e7;
310
+ --pp-background: #17181a;
311
+ --pp-surface: #232427;
312
+ --pp-border: #343538;
313
+ }
314
+ :root[data-theme="light"] .pp-panel {
315
+ --pp-text: #18191c;
316
+ --pp-background: #f6f7f8;
317
+ --pp-surface: #fff;
318
+ --pp-border: #e3e5e7;
319
+ }
320
+ @media (prefers-reduced-motion: reduce) {
321
+ .pp-panel .pp-switch,
322
+ .pp-panel .pp-switch::before {
323
+ transition: none;
139
324
  }
140
325
  }
@@ -26,17 +26,19 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
26
26
  };
27
27
  const shell = node("div", "pp-panel");
28
28
  const header = node("header", "pp-header");
29
- const back = node("button", "pp-back", "返回");
29
+ const back = node("button", "pp-back", "");
30
+ back.setAttribute("aria-label", "返回");
30
31
  back.type = "button";
31
32
  const heading = node("h1", "pp-title", title);
32
33
  const viewport = node("div", "pp-viewport");
33
34
  const toast = node("div", "pp-toast");
34
35
  toast.setAttribute("role", "status");
35
36
  toast.hidden = true;
36
- header.append(back, heading);
37
+ header.append(back, heading, node("span", "pp-nav-spacer"));
37
38
  shell.append(header, viewport, toast);
38
39
  root.append(shell);
39
40
  let timer,
41
+ secondaryRoute,
40
42
  routedPath,
41
43
  generation = 0,
42
44
  active = null,
@@ -131,40 +133,68 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
131
133
  const view = node("section", "pp-fields");
132
134
  /** @type {Array<() => void>} 挂载后执行的多行高度更新 / Textarea sizing callbacks run after mounting. */
133
135
  const growingInputs = [];
136
+ const editors = new Map();
137
+ const summaries = [];
138
+ const groups = new Map();
139
+ const scrollPositions = new WeakMap();
140
+ let activeEditor;
141
+ let queue = Promise.resolve(),
142
+ pendingWrites = 0;
134
143
  /**
135
- * 写入期间统一切换控件禁用状态。
136
- * Toggle all control disabled states during mutations.
137
- * @param {boolean} disabled 是否禁用 / Whether controls are disabled.
144
+ * 根据 hash 切换多选页,保留上级 DOM 和滚动位置。
145
+ * Switch multi-select views by hash while retaining parent DOM and scroll position.
138
146
  * @returns {void} 无返回值 / No return value.
139
147
  */
140
- const disableControls = disabled => {
141
- view.querySelectorAll("button,input,select,textarea").forEach(input => {
142
- input.disabled = disabled;
143
- });
148
+ const showEditor = () => {
149
+ let key;
150
+ try {
151
+ key = decodeURIComponent(window.location.hash.slice(1));
152
+ } catch {
153
+ key = "";
154
+ }
155
+ const editor = editors.get(key);
156
+ const previous = activeEditor?.node ?? view;
157
+ const next = editor?.node ?? view;
158
+ if (previous !== next) {
159
+ scrollPositions.set(previous, previous.scrollTop);
160
+ previous.remove();
161
+ viewport.append(next);
162
+ next.scrollTop = scrollPositions.get(next) ?? 0;
163
+ 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)" });
164
+ }
165
+ activeEditor = editor;
166
+ heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
167
+ back.disabled = saving || (!editor && window.history.length <= 1);
144
168
  };
169
+ secondaryRoute = showEditor;
145
170
  /**
146
- * 执行页面操作,期间锁定控件,完成后处理延后的导航。
147
- * Run a page action with controls locked, then process deferred navigation.
171
+ * 串行执行页面操作,输入可继续编辑,完成后处理延后导航。
172
+ * Serialize page actions while inputs remain editable, then process deferred navigation.
148
173
  * @param {() => Promise<void>} action 请求或写入 / Request or mutation.
149
174
  * @param {() => void} success 成功后的局部更新 / Local update after success.
175
+ * @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.
150
176
  * @returns {Promise<void>} 操作完成 / Operation completion.
151
177
  */
152
- async function perform(action, success) {
153
- if (saving) return;
178
+ function perform(action, success, failure = () => {}) {
179
+ pendingWrites++;
154
180
  saving = true;
155
181
  back.disabled = true;
156
- disableControls(true);
157
- try {
158
- await action();
159
- if (!destroyed) success();
160
- } catch {
161
- /* 请求层已通知错误 / The request layer has already reported the error. */
162
- } finally {
163
- saving = false;
164
- back.disabled = window.history.length <= 1;
165
- disableControls(false);
166
- if (!destroyed && pendingRoute) route();
167
- }
182
+ return (queue = queue
183
+ .then(action)
184
+ .then(() => {
185
+ if (!destroyed) success();
186
+ })
187
+ .catch(() => {
188
+ /* 请求层已通知错误 / The request layer has already reported the error. */
189
+ if (!destroyed) failure();
190
+ })
191
+ .finally(() => {
192
+ pendingWrites--;
193
+ saving = pendingWrites > 0;
194
+ if (destroyed && !saving) client.leave(active);
195
+ back.disabled = saving || (!activeEditor && window.history.length <= 1);
196
+ if (!saving && !destroyed && pendingRoute) route();
197
+ }));
168
198
  }
169
199
  const metadata = definition.metadata;
170
200
  if (metadata) {
@@ -201,14 +231,27 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
201
231
  view.append(info);
202
232
  }
203
233
  for (const field of definition.fields) {
204
- const row = node("fieldset", "pp-field");
205
- row.append(node("legend", "", field.name));
206
- if (field.description) row.append(node("p", "pp-description", field.description));
234
+ const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
235
+ const group = match?.[1] ?? "通用";
236
+ if (!groups.has(group)) {
237
+ const section = node("section", "form-group");
238
+ const rows = node("div", "form-group__row");
239
+ section.append(node("h2", "form-group__title", group), rows);
240
+ groups.set(group, rows);
241
+ view.append(section);
242
+ }
243
+ const row = node("div", "form-row pp-field");
244
+ const label = node("div", "form-row__text");
245
+ label.append(node("span", "form-row__title", match?.[2] ?? field.name));
246
+ if (field.description) label.append(node("span", "form-row__subtitle", field.description));
247
+ row.append(label);
207
248
  const value = values[field.key];
208
249
  /** @type {() => unknown} 读取尚未保存的输入 / Read the unsaved input. */
209
250
  let read;
210
251
  /** @type {(value: unknown) => void} 更新当前控件 / Update the current control. */
211
252
  let write;
253
+ let inputContainer = row;
254
+ let eventName = "change";
212
255
  switch (true) {
213
256
  case Boolean(field.options) && field.type !== "array": {
214
257
  const select = node("select", "pp-input");
@@ -226,12 +269,42 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
226
269
  break;
227
270
  }
228
271
  case field.type === "array" && Boolean(field.options): {
272
+ const page = node("section", "pp-choice-page");
273
+ if (field.description) page.append(node("p", "pp-description", field.description));
274
+ const choices = node("div", "form-group__row");
275
+ page.append(choices);
276
+ inputContainer = choices;
277
+ editors.set(field.key, { node: page, title: match?.[2] ?? field.name });
278
+ const summary = node("span", "form-row__value pp-summary");
279
+ const link = node("button", "pp-choice-link");
280
+ link.type = "button";
281
+ link.setAttribute("aria-label", field.name);
282
+ link.append(summary, node("span", "pp-chevron", "›"));
283
+ row.append(link);
284
+ const refresh = () => {
285
+ const value = client.snapshot(active).values[field.key];
286
+ summary.textContent =
287
+ field.options
288
+ .filter(option => Array.isArray(value) && value.includes(option.key))
289
+ .map(option => option.label)
290
+ .join("、") || "未选择";
291
+ };
292
+ summaries.push(refresh);
293
+ refresh();
294
+ link.onclick = () => {
295
+ window.history.pushState({ ...window.history.state, preferencePane: active }, "", `#${encodeURIComponent(field.key)}`);
296
+ showEditor();
297
+ };
298
+ row.addEventListener("click", event => {
299
+ if (!link.contains(event.target)) link.click();
300
+ });
229
301
  const inputs = field.options.map(option => {
230
- const label = node("label", "pp-choice", option.label);
302
+ const label = node("label", "form-row pp-choice", option.label);
231
303
  const input = node("input", "");
232
304
  input.type = "checkbox";
233
- label.prepend(input);
234
- row.append(label);
305
+ input.setAttribute("aria-label", option.label);
306
+ label.append(input);
307
+ choices.append(label);
235
308
  return { input, key: option.key };
236
309
  });
237
310
  read = () => inputs.filter(option => option.input.checked).map(option => option.key);
@@ -243,6 +316,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
243
316
  default: {
244
317
  const multiline = field.control === "textarea" || field.type === "array";
245
318
  const input = node(multiline ? "textarea" : "input", "pp-input");
319
+ if (multiline) row.classList.add("pp-multiline");
246
320
  input.setAttribute("aria-label", field.name);
247
321
  if (field.placeholder) input.placeholder = field.placeholder;
248
322
  if (multiline && field.rows) input.rows = field.rows;
@@ -265,11 +339,14 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
265
339
  }
266
340
  if (field.type === "boolean") {
267
341
  input.type = "checkbox";
342
+ input.classList.add("pp-switch");
343
+ input.setAttribute("role", "switch");
268
344
  write = value => {
269
345
  input.checked = value === true;
270
346
  };
271
347
  read = () => input.checked;
272
348
  } else {
349
+ eventName = "input";
273
350
  if (!multiline) input.type = field.type === "number" ? "number" : "text";
274
351
  write = value => {
275
352
  input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
@@ -291,34 +368,31 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
291
368
  }
292
369
  }
293
370
  write(value);
294
- const actions = node("div", "pp-actions");
295
- for (const [operation, label] of [
296
- ["write", "保存"],
297
- ["delete", "删除覆盖值"],
298
- ]) {
299
- const button = node("button", "", label);
300
- button.type = "button";
301
- button.onclick = () =>
302
- perform(
303
- async () => {
304
- if (operation === "delete") await client.remove(active, field.key);
305
- else {
306
- let value;
307
- try {
308
- value = read();
309
- } catch (error) {
310
- notify({ kind: "error", message: error.message });
311
- throw error;
312
- }
313
- await client.set(active, field.key, value);
314
- }
315
- },
316
- () => write(client.snapshot(active).values[field.key]),
317
- );
318
- actions.append(button);
319
- }
320
- row.append(actions);
321
- view.append(row);
371
+ let inputVersion = 0;
372
+ inputContainer.addEventListener(eventName, event => {
373
+ if (event.isComposing) return;
374
+ const version = ++inputVersion,
375
+ module = active;
376
+ let value;
377
+ try {
378
+ value = read();
379
+ } catch (error) {
380
+ notify({ kind: "error", message: error.message });
381
+ return;
382
+ }
383
+ const restore = () => {
384
+ if (version === inputVersion) write(client.snapshot(module).values[field.key]);
385
+ };
386
+ perform(
387
+ () => client.set(module, field.key, value),
388
+ () => {
389
+ for (const refresh of summaries) refresh();
390
+ },
391
+ restore,
392
+ );
393
+ });
394
+ if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
395
+ groups.get(group).append(row);
322
396
  }
323
397
  const maintenance = node("section", "pp-maintenance");
324
398
  maintenance.append(node("h2", "pp-title", "模块数据"));
@@ -331,6 +405,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
331
405
  output.setAttribute("aria-label", "Caches 内容");
332
406
  for (const button of [cacheView, cacheClear, reset]) button.type = "button";
333
407
  cacheView.onclick = () => {
408
+ if (saving) return;
334
409
  let value;
335
410
  return perform(
336
411
  async () => {
@@ -349,6 +424,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
349
424
  );
350
425
  };
351
426
  cacheClear.onclick = () => {
427
+ if (saving) return;
352
428
  if (!window.confirm(`清空 ${active} 的全部 Caches?`)) return;
353
429
  return perform(
354
430
  () => client.clearCaches(active),
@@ -358,6 +434,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
358
434
  );
359
435
  };
360
436
  reset.onclick = () => {
437
+ if (saving) return;
361
438
  if (!window.confirm(`重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) return;
362
439
  return perform(() => client.reset(active), controls);
363
440
  };
@@ -366,6 +443,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
366
443
  view.append(maintenance);
367
444
  viewport.replaceChildren(view);
368
445
  for (const grow of growingInputs) grow();
446
+ showEditor();
369
447
  }
370
448
  /**
371
449
  * 按页面 pathname 切换模块,写入尚未完成时延后导航。
@@ -378,6 +456,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
378
456
  return;
379
457
  }
380
458
  pendingRoute = false;
459
+ secondaryRoute = undefined;
381
460
  if (active) client.leave(active);
382
461
  routedPath = window.location.pathname;
383
462
  const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(routedPath);
@@ -397,7 +476,9 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
397
476
  */
398
477
  const onPopState = () => {
399
478
  if (window.location.pathname !== routedPath) route();
479
+ else secondaryRoute?.();
400
480
  };
481
+ const onHashChange = () => secondaryRoute?.();
401
482
  /**
402
483
  * 从浏览器往返缓存恢复时重新读取当前模块。
403
484
  * Reload the current module when restored from the browser back-forward cache.
@@ -408,10 +489,15 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
408
489
  if (event.persisted) route();
409
490
  };
410
491
  back.onclick = () => {
411
- if (!saving) window.history.back();
492
+ if (saving) return;
493
+ if (window.location.hash && window.history.state?.preferencePane !== active) {
494
+ window.history.replaceState(window.history.state, "", window.location.pathname);
495
+ secondaryRoute?.();
496
+ } else window.history.back();
412
497
  };
413
498
  window.addEventListener("popstate", onPopState);
414
499
  window.addEventListener("pageshow", onPageShow);
500
+ window.addEventListener("hashchange", onHashChange);
415
501
  route();
416
502
  return {
417
503
  /**
@@ -423,8 +509,9 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
423
509
  destroyed = true;
424
510
  window.removeEventListener("popstate", onPopState);
425
511
  window.removeEventListener("pageshow", onPageShow);
512
+ window.removeEventListener("hashchange", onHashChange);
426
513
  generation++;
427
- if (active) client.leave(active);
514
+ if (active && !saving) client.leave(active);
428
515
  clearTimeout(timer);
429
516
  shell.remove();
430
517
  },