@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 +2 -0
- package/dist/preference-panes.mjs +148 -61
- package/package.json +1 -1
- package/src/browser/index.d.ts +2 -0
- package/src/browser/panel.css +260 -75
- package/src/browser/panel.mjs +148 -61
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
|
-
*
|
|
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
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
527
|
-
|
|
552
|
+
function perform(action, success, failure = () => {}) {
|
|
553
|
+
pendingWrites++;
|
|
528
554
|
saving = true;
|
|
529
555
|
back.disabled = true;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
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
|
|
579
|
-
|
|
580
|
-
if (
|
|
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.
|
|
608
|
-
|
|
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
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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 (
|
|
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.
|
|
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",
|
package/src/browser/index.d.ts
CHANGED
|
@@ -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
|
*/
|
package/src/browser/panel.css
CHANGED
|
@@ -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
|
|
11
|
+
15px / 1.5 -apple-system,
|
|
12
|
+
BlinkMacSystemFont,
|
|
13
|
+
"Segoe UI",
|
|
4
14
|
sans-serif;
|
|
5
|
-
color: var(--pp-text
|
|
6
|
-
background: var(--pp-background
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
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-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
margin-bottom: 16px;
|
|
40
|
+
.pp-header .pp-title {
|
|
41
|
+
flex: 1;
|
|
42
|
+
text-align: center;
|
|
31
43
|
}
|
|
32
|
-
.pp-
|
|
33
|
-
width:
|
|
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-
|
|
48
|
+
.pp-panel button {
|
|
49
|
+
font: inherit;
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
border: 0;
|
|
52
|
+
background: none;
|
|
43
53
|
color: inherit;
|
|
44
|
-
|
|
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
|
-
|
|
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
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
background: var(--pp-surface, #fff);
|
|
98
|
+
overflow: hidden;
|
|
99
|
+
background: var(--pp-surface);
|
|
59
100
|
}
|
|
60
|
-
.pp-panel
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
padding:
|
|
67
|
-
border:
|
|
68
|
-
border-
|
|
69
|
-
background: var(--pp-surface
|
|
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-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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-
|
|
80
|
-
|
|
158
|
+
.pp-chevron {
|
|
159
|
+
color: var(--pp-muted);
|
|
160
|
+
font-size: 22px;
|
|
161
|
+
flex: none;
|
|
162
|
+
}
|
|
163
|
+
.pp-input {
|
|
81
164
|
font: inherit;
|
|
82
|
-
|
|
83
|
-
|
|
165
|
+
color: var(--pp-text);
|
|
166
|
+
background: var(--pp-surface);
|
|
167
|
+
border: 1px solid var(--pp-border);
|
|
84
168
|
border-radius: 6px;
|
|
85
|
-
|
|
86
|
-
|
|
169
|
+
padding: 8px;
|
|
170
|
+
min-width: 0;
|
|
171
|
+
max-width: 45%;
|
|
172
|
+
width: 45%;
|
|
87
173
|
}
|
|
88
|
-
.pp-
|
|
174
|
+
select.pp-input {
|
|
175
|
+
text-overflow: ellipsis;
|
|
176
|
+
font-size: 13px;
|
|
177
|
+
}
|
|
178
|
+
.pp-panel .pp-multiline {
|
|
89
179
|
display: block;
|
|
90
|
-
|
|
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
|
-
|
|
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-
|
|
102
|
-
|
|
103
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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: #
|
|
136
|
-
--pp-background: #
|
|
137
|
-
--pp-surface: #
|
|
138
|
-
--pp-border: #
|
|
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
|
}
|
package/src/browser/panel.mjs
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
153
|
-
|
|
178
|
+
function perform(action, success, failure = () => {}) {
|
|
179
|
+
pendingWrites++;
|
|
154
180
|
saving = true;
|
|
155
181
|
back.disabled = true;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
|
205
|
-
|
|
206
|
-
if (
|
|
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.
|
|
234
|
-
|
|
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
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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 (
|
|
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
|
},
|