@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 +41 -2
- package/dist/preference-panes.mjs +148 -61
- package/dist/settings/app.mjs +1043 -0
- package/dist/settings/home.css +134 -0
- package/dist/settings/index.html +15 -0
- package/dist/settings/panel.css +325 -0
- package/package.json +3 -2
- package/src/PreferencesHandler.mjs +50 -0
- package/src/browser/app.mjs +153 -0
- package/src/browser/home.css +134 -0
- package/src/browser/index.d.ts +2 -0
- package/src/browser/panel.css +260 -75
- package/src/browser/panel.mjs +148 -61
- package/src/browser/site.html +15 -0
- package/src/index.d.ts +23 -0
- package/src/index.mjs +1 -0
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
|
-
|
|
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.mjs
|
|
132
|
+
构建生成 dist/preference-panes.mjs、dist/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
|
-
*
|
|
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
|
},
|