@nsnanocat/preference-panes 0.7.0 → 0.7.2
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 +59 -1
- package/dist/module/app.mjs +248 -80
- package/dist/module/index.html +1 -1
- package/dist/module/navigation.mjs +260 -0
- package/dist/preference-panes.mjs +204 -71
- package/dist/preference-panes.proxy.js +28 -3
- package/package.json +5 -1
- package/src/browser/ModuleFrame.mjs +83 -0
- package/src/browser/Navigation.d.mts +85 -0
- package/src/browser/Navigation.mjs +160 -0
- package/src/browser/app.mjs +27 -9
- package/src/browser/components.mjs +1 -1
- package/src/browser/panel.css +33 -2
- package/src/browser/panel.mjs +44 -69
- package/src/build.mjs +3 -1
- package/src/lib/page-inputs.mjs +17 -0
- package/src/proxy/handler.mjs +10 -2
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
export { ModuleFrame } from "./ModuleFrame.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
|
|
5
|
+
* Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
|
|
6
|
+
*/
|
|
7
|
+
export class Navigation extends EventTarget {
|
|
8
|
+
#container;
|
|
9
|
+
#home;
|
|
10
|
+
#create;
|
|
11
|
+
#window;
|
|
12
|
+
#key = null;
|
|
13
|
+
#view;
|
|
14
|
+
#retiring;
|
|
15
|
+
#controller;
|
|
16
|
+
#animation;
|
|
17
|
+
#scroll = new WeakMap();
|
|
18
|
+
#onHistory = () => this.#route();
|
|
19
|
+
#onPageShow = event => {
|
|
20
|
+
if (event.persisted) this.#route(true);
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。
|
|
25
|
+
* Retain the home view and create details on demand; signal cancels async work after departure.
|
|
26
|
+
* @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.
|
|
27
|
+
* @param {HTMLElement} home 已创建的主页节点 / Existing home view.
|
|
28
|
+
* @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.
|
|
29
|
+
*/
|
|
30
|
+
constructor(container, home, create) {
|
|
31
|
+
super();
|
|
32
|
+
this.#container = container;
|
|
33
|
+
this.#home = home;
|
|
34
|
+
this.#create = create;
|
|
35
|
+
this.#window = container.ownerDocument.defaultView;
|
|
36
|
+
container.replaceChildren(home);
|
|
37
|
+
this.#window.addEventListener("popstate", this.#onHistory);
|
|
38
|
+
this.#window.addEventListener("hashchange", this.#onHistory);
|
|
39
|
+
this.#window.addEventListener("pageshow", this.#onPageShow);
|
|
40
|
+
this.#route();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 当前子页键;空字符串表示主页。
|
|
45
|
+
* Current detail key; empty means home.
|
|
46
|
+
*/
|
|
47
|
+
get current() {
|
|
48
|
+
return this.#key;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 是否可以返回上一级或先前文档。
|
|
53
|
+
* Whether a parent view or previous document is available.
|
|
54
|
+
*/
|
|
55
|
+
get canGoBack() {
|
|
56
|
+
return Boolean(this.#key) || this.#window.history.length > 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 加入子页历史;使用文档自身 URL,避免 srcdoc 按宿主 base URL 跳转。
|
|
61
|
+
* Push a detail using the document URL, avoiding srcdoc navigation against the host base URL.
|
|
62
|
+
* @param {string} key 子页键 / Detail key.
|
|
63
|
+
* @returns {void} 无返回值 / No return value.
|
|
64
|
+
*/
|
|
65
|
+
open(key) {
|
|
66
|
+
if (key === this.#key) return;
|
|
67
|
+
const url = new URL(this.#window.location.href);
|
|
68
|
+
url.hash = encodeURIComponent(key);
|
|
69
|
+
this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
|
|
70
|
+
this.#route();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* 沿浏览器联合历史返回,根页可退回宿主或上个文档。
|
|
75
|
+
* Go back through joint history, including a host or previous document from home.
|
|
76
|
+
* @returns {void} 无返回值 / No return value.
|
|
77
|
+
*/
|
|
78
|
+
back() {
|
|
79
|
+
if (this.canGoBack) this.#window.history.back();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
|
|
84
|
+
* Resolve the URL and coordinate transitions, cancellation and release after animation.
|
|
85
|
+
* @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.
|
|
86
|
+
* @returns {void} 无返回值 / No return value.
|
|
87
|
+
*/
|
|
88
|
+
#route(reload = false) {
|
|
89
|
+
const url = new URL(this.#window.location.href);
|
|
90
|
+
let key;
|
|
91
|
+
try {
|
|
92
|
+
key = decodeURIComponent(url.hash.slice(1));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
if (!(error instanceof URIError)) throw error;
|
|
95
|
+
key = "";
|
|
96
|
+
}
|
|
97
|
+
if (!reload && key === this.#key) return;
|
|
98
|
+
this.#controller?.abort();
|
|
99
|
+
this.#controller = new AbortController();
|
|
100
|
+
const next = key ? this.#create(key, this.#controller.signal) : undefined;
|
|
101
|
+
if (!next) key = "";
|
|
102
|
+
const history = this.#window.history;
|
|
103
|
+
// 直接打开子页时建立一次主页历史;刷新不重复堆叠。
|
|
104
|
+
// Seed home history once for direct details, without stacking entries on reload.
|
|
105
|
+
if (url.hash && history.state?.preferencePanesRoute !== key) {
|
|
106
|
+
url.hash = "";
|
|
107
|
+
history.replaceState({ ...history.state, preferencePanesRoute: "" }, "", url.href);
|
|
108
|
+
if (key) {
|
|
109
|
+
url.hash = encodeURIComponent(key);
|
|
110
|
+
history.pushState({ ...history.state, preferencePanesRoute: key }, "", url.href);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const previous = this.#view;
|
|
114
|
+
const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
|
|
115
|
+
this.#animation?.cancel();
|
|
116
|
+
this.#retiring?.remove();
|
|
117
|
+
this.#retiring = previous;
|
|
118
|
+
if (previous) {
|
|
119
|
+
this.#scroll.set(previous, previous.scrollTop);
|
|
120
|
+
previous.inert = true;
|
|
121
|
+
}
|
|
122
|
+
this.#key = key;
|
|
123
|
+
this.#view = next;
|
|
124
|
+
this.#home.inert = Boolean(next);
|
|
125
|
+
if (next) {
|
|
126
|
+
next.inert = false;
|
|
127
|
+
this.#container.append(next);
|
|
128
|
+
next.scrollTop = this.#scroll.get(next) ?? 0;
|
|
129
|
+
}
|
|
130
|
+
const moving = next ?? previous;
|
|
131
|
+
if (moving) {
|
|
132
|
+
const animation = moving.animate([{ transform: next ? "translateX(100%)" : position }, { transform: next ? "translateX(0)" : "translateX(100%)" }], { duration: this.#window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 280, easing: "cubic-bezier(.22,.61,.36,1)", fill: "forwards" });
|
|
133
|
+
this.#animation = animation;
|
|
134
|
+
animation.onfinish = () => {
|
|
135
|
+
if (this.#animation !== animation) return;
|
|
136
|
+
this.#retiring?.remove();
|
|
137
|
+
this.#retiring = undefined;
|
|
138
|
+
animation.cancel();
|
|
139
|
+
this.#animation = undefined;
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
this.dispatchEvent(new Event("change"));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 释放监听器、加载、动画和节点;调用方可重新创建导航。
|
|
147
|
+
* Release listeners, loads, animations and nodes so callers can recreate navigation.
|
|
148
|
+
* @returns {void} 无返回值 / No return value.
|
|
149
|
+
*/
|
|
150
|
+
destroy() {
|
|
151
|
+
this.#window.removeEventListener("popstate", this.#onHistory);
|
|
152
|
+
this.#window.removeEventListener("hashchange", this.#onHistory);
|
|
153
|
+
this.#window.removeEventListener("pageshow", this.#onPageShow);
|
|
154
|
+
this.#controller?.abort();
|
|
155
|
+
this.#animation?.cancel();
|
|
156
|
+
this.#retiring?.remove();
|
|
157
|
+
this.#view?.remove();
|
|
158
|
+
this.#home.remove();
|
|
159
|
+
}
|
|
160
|
+
}
|
package/src/browser/app.mjs
CHANGED
|
@@ -1,25 +1,43 @@
|
|
|
1
1
|
import { BoxJS } from "../BoxJS.mjs";
|
|
2
|
+
import { pageInputs } from "../lib/page-inputs.mjs";
|
|
2
3
|
import { errorView } from "./components.mjs";
|
|
3
4
|
import { mount } from "./index.mjs";
|
|
4
5
|
|
|
5
6
|
let view;
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
-
* Import JSON
|
|
8
|
+
* 从 URL 或代理传递的 Header 导入 JSON/CSS,支持独立文档与 srcdoc。
|
|
9
|
+
* Import JSON/CSS from the URL or proxy-carried headers in standalone and srcdoc documents.
|
|
9
10
|
* @returns {Promise<void>} 启动完成 / Startup completion.
|
|
10
11
|
*/
|
|
11
12
|
async function start() {
|
|
12
13
|
try {
|
|
13
14
|
view?.destroy();
|
|
14
15
|
view = undefined;
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
const context = document.querySelector('meta[name="preference-panes-inputs"]');
|
|
17
|
+
const embedded = window.frameElement?.dataset.preferencePanes;
|
|
18
|
+
let inputs;
|
|
19
|
+
switch (true) {
|
|
20
|
+
case embedded !== undefined:
|
|
21
|
+
inputs = JSON.parse(embedded);
|
|
22
|
+
document.documentElement.dataset.preferencePanesEmbedded = "";
|
|
23
|
+
break;
|
|
24
|
+
case context !== null:
|
|
25
|
+
inputs = JSON.parse(decodeURIComponent(context.content));
|
|
26
|
+
break;
|
|
27
|
+
default:
|
|
28
|
+
inputs = pageInputs(new URL(location.href));
|
|
29
|
+
}
|
|
30
|
+
const resources = [inputs.json, inputs.css].map(source => {
|
|
31
|
+
if (!source) return null;
|
|
32
|
+
const url = new URL(source, inputs.url);
|
|
33
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Resources must use HTTP(S) URLs");
|
|
34
|
+
return url.href;
|
|
35
|
+
});
|
|
36
|
+
const [data, style] = await Promise.all(resources.map(url => (url ? fetch(url, { cache: "no-store", credentials: "omit" }) : null)));
|
|
37
|
+
if (data.status !== 200 || (style && style.status !== 200)) throw new Error(`HTTP ${data.status !== 200 ? data.status : style.status}`);
|
|
20
38
|
const boxjs = await data.json();
|
|
21
|
-
if (new BoxJS(boxjs).module.module !== module) throw new Error("Imported JSON does not match the module URL");
|
|
22
|
-
view = mount(boxjs, await style.text());
|
|
39
|
+
if (new BoxJS(boxjs).module.module !== inputs.module) throw new Error("Imported JSON does not match the module URL");
|
|
40
|
+
view = mount(boxjs, style ? await style.text() : "");
|
|
23
41
|
} catch (error) {
|
|
24
42
|
document.querySelector("#preferences").replaceChildren(errorView(error, start));
|
|
25
43
|
}
|
|
@@ -21,7 +21,7 @@ export function element(tag, className, text) {
|
|
|
21
21
|
* @returns {string} 完整地址 / Absolute address.
|
|
22
22
|
*/
|
|
23
23
|
export function resourceURL(value) {
|
|
24
|
-
const url = new URL(value,
|
|
24
|
+
const url = new URL(value, document.baseURI);
|
|
25
25
|
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Metadata URLs must use HTTP(S)");
|
|
26
26
|
return url.href;
|
|
27
27
|
}
|
package/src/browser/panel.css
CHANGED
|
@@ -28,7 +28,9 @@
|
|
|
28
28
|
align-items: center;
|
|
29
29
|
background: var(--pp-surface);
|
|
30
30
|
border-bottom: 1px solid var(--pp-border);
|
|
31
|
-
position:
|
|
31
|
+
position: sticky;
|
|
32
|
+
top: 0;
|
|
33
|
+
z-index: 1;
|
|
32
34
|
}
|
|
33
35
|
.pp-title {
|
|
34
36
|
font-size: 17px;
|
|
@@ -37,10 +39,30 @@
|
|
|
37
39
|
min-width: 0;
|
|
38
40
|
overflow-wrap: anywhere;
|
|
39
41
|
}
|
|
40
|
-
.pp-
|
|
42
|
+
.pp-brand {
|
|
41
43
|
flex: 1;
|
|
44
|
+
min-width: 0;
|
|
45
|
+
display: flex;
|
|
46
|
+
align-items: center;
|
|
47
|
+
justify-content: center;
|
|
48
|
+
gap: 8px;
|
|
42
49
|
text-align: center;
|
|
43
50
|
}
|
|
51
|
+
.pp-brand-icon {
|
|
52
|
+
display: none;
|
|
53
|
+
flex: none;
|
|
54
|
+
width: 28px;
|
|
55
|
+
height: 28px;
|
|
56
|
+
}
|
|
57
|
+
.pp-brand-icon:not(:empty) {
|
|
58
|
+
display: block;
|
|
59
|
+
}
|
|
60
|
+
.pp-brand-icon img {
|
|
61
|
+
display: block;
|
|
62
|
+
width: 100%;
|
|
63
|
+
height: 100%;
|
|
64
|
+
object-fit: contain;
|
|
65
|
+
}
|
|
44
66
|
.pp-nav-spacer {
|
|
45
67
|
width: 44px;
|
|
46
68
|
flex: none;
|
|
@@ -69,10 +91,19 @@
|
|
|
69
91
|
height: calc(100vh - 52px - env(safe-area-inset-top));
|
|
70
92
|
overflow: hidden;
|
|
71
93
|
}
|
|
94
|
+
:root[data-preference-panes-embedded] .pp-header {
|
|
95
|
+
display: none;
|
|
96
|
+
}
|
|
97
|
+
:root[data-preference-panes-embedded] .pp-viewport {
|
|
98
|
+
height: 100vh;
|
|
99
|
+
}
|
|
72
100
|
@supports (height: 100dvh) {
|
|
73
101
|
.pp-viewport {
|
|
74
102
|
height: calc(100dvh - 52px - env(safe-area-inset-top));
|
|
75
103
|
}
|
|
104
|
+
:root[data-preference-panes-embedded] .pp-viewport {
|
|
105
|
+
height: 100dvh;
|
|
106
|
+
}
|
|
76
107
|
}
|
|
77
108
|
.pp-fields,
|
|
78
109
|
.pp-choice-page {
|
package/src/browser/panel.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createPreferencesClient } from "./client.mjs";
|
|
2
2
|
import { errorView, icon, element as node, resourceURL } from "./components.mjs";
|
|
3
|
+
import { Navigation } from "./Navigation.mjs";
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* 挂载已导入 BoxJS 对应的模块表单和短暂通知。
|
|
@@ -13,20 +14,38 @@ export function mountPanel(root, catalog) {
|
|
|
13
14
|
const document = root.ownerDocument;
|
|
14
15
|
const window = document.defaultView;
|
|
15
16
|
const shell = node("div", "pp-panel");
|
|
17
|
+
shell.dataset.module = catalog.module.module;
|
|
16
18
|
const header = node("header", "pp-header");
|
|
17
19
|
const back = node("button", "pp-back", "‹");
|
|
18
20
|
back.setAttribute("aria-label", "返回");
|
|
19
21
|
back.type = "button";
|
|
20
22
|
const heading = node("h1", "pp-title", title);
|
|
23
|
+
const brand = node("div", "pp-brand");
|
|
24
|
+
const logo = node("span", "pp-brand-icon");
|
|
25
|
+
logo.setAttribute("aria-hidden", "true");
|
|
26
|
+
const image = icon(catalog.module.metadata, "");
|
|
27
|
+
if (image) logo.append(image);
|
|
28
|
+
brand.append(logo, heading);
|
|
21
29
|
const viewport = node("div", "pp-viewport");
|
|
22
30
|
const toast = node("div", "pp-toast");
|
|
23
31
|
toast.setAttribute("role", "status");
|
|
24
32
|
toast.hidden = true;
|
|
25
|
-
header.append(back,
|
|
33
|
+
header.append(back, brand, node("span", "pp-nav-spacer"));
|
|
26
34
|
shell.append(header, viewport, toast);
|
|
27
35
|
root.append(shell);
|
|
36
|
+
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
37
|
+
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
38
|
+
const publishNavigation = () => {
|
|
39
|
+
const frame = window.frameElement;
|
|
40
|
+
if (!frame?.dataset.preferencePanes) return;
|
|
41
|
+
frame.dispatchEvent(
|
|
42
|
+
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
43
|
+
detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled },
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
};
|
|
28
47
|
let timer,
|
|
29
|
-
|
|
48
|
+
navigation,
|
|
30
49
|
generation = 0,
|
|
31
50
|
active = null,
|
|
32
51
|
saving = false,
|
|
@@ -64,25 +83,6 @@ export function mountPanel(root, catalog) {
|
|
|
64
83
|
}, 2400);
|
|
65
84
|
};
|
|
66
85
|
const client = createPreferencesClient({ catalog, notify });
|
|
67
|
-
/**
|
|
68
|
-
* 切换加载或错误视图,按用户的动态效果偏好播放过渡。
|
|
69
|
-
* Replace a loading or error view, respecting reduced-motion preferences.
|
|
70
|
-
* @param {HTMLElement} view 新视图 / New view.
|
|
71
|
-
* @param {number} direction 过渡方向,正数从右侧进入 / Transition direction; positive enters from the right.
|
|
72
|
-
* @returns {void} 无返回值 / No return value.
|
|
73
|
-
*/
|
|
74
|
-
function replace(view, direction) {
|
|
75
|
-
const old = viewport.firstElementChild;
|
|
76
|
-
viewport.replaceChildren(view);
|
|
77
|
-
if (old && !window.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
|
78
|
-
view.animate(
|
|
79
|
-
[
|
|
80
|
-
{ opacity: 0.4, transform: `translateX(${direction * 24}px)` },
|
|
81
|
-
{ opacity: 1, transform: "translateX(0)" },
|
|
82
|
-
],
|
|
83
|
-
{ duration: 180, easing: "ease-out" },
|
|
84
|
-
);
|
|
85
|
-
}
|
|
86
86
|
/**
|
|
87
87
|
* 打开模块并忽略已过期的异步结果。
|
|
88
88
|
* Open a module and ignore stale asynchronous results.
|
|
@@ -94,16 +94,15 @@ export function mountPanel(root, catalog) {
|
|
|
94
94
|
active = module;
|
|
95
95
|
back.disabled = window.history.length <= 1;
|
|
96
96
|
heading.textContent = module;
|
|
97
|
-
|
|
97
|
+
publishNavigation();
|
|
98
|
+
viewport.replaceChildren(node("p", "pp-loading", "读取设置…"));
|
|
98
99
|
try {
|
|
99
100
|
await client.open(module);
|
|
100
101
|
if (version === generation) controls();
|
|
101
102
|
} catch (error) {
|
|
102
103
|
if (version !== generation) return;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
1,
|
|
106
|
-
);
|
|
104
|
+
viewport.replaceChildren(errorView(error, () => open(module)));
|
|
105
|
+
publishNavigation();
|
|
107
106
|
}
|
|
108
107
|
}
|
|
109
108
|
/**
|
|
@@ -124,37 +123,19 @@ export function mountPanel(root, catalog) {
|
|
|
124
123
|
const editors = new Map();
|
|
125
124
|
const summaries = [];
|
|
126
125
|
const groups = new Map();
|
|
127
|
-
const scrollPositions = new WeakMap();
|
|
128
|
-
let activeEditor;
|
|
129
126
|
let queue = Promise.resolve(),
|
|
130
127
|
pendingWrites = 0;
|
|
131
128
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
129
|
+
* 导航组件处理页面切换,表单只更新当前标题与返回按钮。
|
|
130
|
+
* Let navigation own transitions; the form only updates the title and back button.
|
|
134
131
|
* @returns {void} 无返回值 / No return value.
|
|
135
132
|
*/
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
try {
|
|
139
|
-
key = decodeURIComponent(window.location.hash.slice(1));
|
|
140
|
-
} catch {
|
|
141
|
-
key = "";
|
|
142
|
-
}
|
|
143
|
-
const editor = editors.get(key);
|
|
144
|
-
const previous = activeEditor?.node ?? view;
|
|
145
|
-
const next = editor?.node ?? view;
|
|
146
|
-
if (previous !== next) {
|
|
147
|
-
scrollPositions.set(previous, previous.scrollTop);
|
|
148
|
-
previous.remove();
|
|
149
|
-
viewport.append(next);
|
|
150
|
-
next.scrollTop = scrollPositions.get(next) ?? 0;
|
|
151
|
-
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)" });
|
|
152
|
-
}
|
|
153
|
-
activeEditor = editor;
|
|
133
|
+
const updateNavigation = () => {
|
|
134
|
+
const editor = editors.get(navigation.current);
|
|
154
135
|
heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
|
|
155
|
-
back.disabled = saving ||
|
|
136
|
+
back.disabled = saving || !navigation.canGoBack;
|
|
137
|
+
publishNavigation();
|
|
156
138
|
};
|
|
157
|
-
secondaryRoute = showEditor;
|
|
158
139
|
/**
|
|
159
140
|
* 串行执行模块操作,保持输入可编辑。
|
|
160
141
|
* Serialize module actions while keeping inputs editable.
|
|
@@ -167,6 +148,7 @@ export function mountPanel(root, catalog) {
|
|
|
167
148
|
pendingWrites++;
|
|
168
149
|
saving = true;
|
|
169
150
|
back.disabled = true;
|
|
151
|
+
publishNavigation();
|
|
170
152
|
return (queue = queue
|
|
171
153
|
.then(action)
|
|
172
154
|
.then(() => {
|
|
@@ -181,7 +163,8 @@ export function mountPanel(root, catalog) {
|
|
|
181
163
|
pendingWrites--;
|
|
182
164
|
saving = pendingWrites > 0;
|
|
183
165
|
if (destroyed && !saving) client.leave(active);
|
|
184
|
-
back.disabled = saving ||
|
|
166
|
+
back.disabled = saving || !navigation.canGoBack;
|
|
167
|
+
publishNavigation();
|
|
185
168
|
}));
|
|
186
169
|
}
|
|
187
170
|
const metadata = definition.metadata;
|
|
@@ -270,10 +253,7 @@ export function mountPanel(root, catalog) {
|
|
|
270
253
|
};
|
|
271
254
|
summaries.push(refresh);
|
|
272
255
|
refresh();
|
|
273
|
-
link.onclick = () =>
|
|
274
|
-
window.history.pushState({ ...window.history.state, preferencePane: active }, "", `#${encodeURIComponent(field.key)}`);
|
|
275
|
-
showEditor();
|
|
276
|
-
};
|
|
256
|
+
link.onclick = () => navigation.open(field.key);
|
|
277
257
|
row.addEventListener("click", event => {
|
|
278
258
|
if (!link.contains(event.target)) link.click();
|
|
279
259
|
});
|
|
@@ -420,26 +400,22 @@ export function mountPanel(root, catalog) {
|
|
|
420
400
|
actions.append(cacheView, cacheClear, reset);
|
|
421
401
|
maintenance.append(actions, output);
|
|
422
402
|
view.append(maintenance);
|
|
423
|
-
|
|
403
|
+
navigation?.destroy();
|
|
404
|
+
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
405
|
+
navigation.addEventListener("change", updateNavigation);
|
|
424
406
|
for (const grow of growingInputs) grow();
|
|
425
|
-
|
|
407
|
+
updateNavigation();
|
|
426
408
|
}
|
|
427
409
|
/**
|
|
428
|
-
*
|
|
429
|
-
*
|
|
410
|
+
* 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。
|
|
411
|
+
* Loaded forms delegate back to navigation; loading views can return to the previous document.
|
|
430
412
|
* @returns {void} 无返回值 / No return value.
|
|
431
413
|
*/
|
|
432
|
-
const onPopState = () => secondaryRoute?.();
|
|
433
|
-
const onHashChange = () => secondaryRoute?.();
|
|
434
414
|
back.onclick = () => {
|
|
435
415
|
if (saving) return;
|
|
436
|
-
if (
|
|
437
|
-
|
|
438
|
-
secondaryRoute?.();
|
|
439
|
-
} else window.history.back();
|
|
416
|
+
if (navigation) navigation.back();
|
|
417
|
+
else window.history.back();
|
|
440
418
|
};
|
|
441
|
-
window.addEventListener("popstate", onPopState);
|
|
442
|
-
window.addEventListener("hashchange", onHashChange);
|
|
443
419
|
open(catalog.module.module);
|
|
444
420
|
return {
|
|
445
421
|
/**
|
|
@@ -449,8 +425,7 @@ export function mountPanel(root, catalog) {
|
|
|
449
425
|
*/
|
|
450
426
|
destroy() {
|
|
451
427
|
destroyed = true;
|
|
452
|
-
|
|
453
|
-
window.removeEventListener("hashchange", onHashChange);
|
|
428
|
+
navigation?.destroy();
|
|
454
429
|
generation++;
|
|
455
430
|
if (active && !saving) client.leave(active);
|
|
456
431
|
clearTimeout(timer);
|
package/src/build.mjs
CHANGED
|
@@ -12,9 +12,10 @@ export async function build(boxjs, css = "") {
|
|
|
12
12
|
if (typeof css !== "string") throw new TypeError("CSS must be a string");
|
|
13
13
|
const catalog = new BoxJS(boxjs);
|
|
14
14
|
const module = catalog.module.module;
|
|
15
|
-
const [html, app, proxy, mock] = await Promise.all([
|
|
15
|
+
const [html, app, navigation, proxy, mock] = await Promise.all([
|
|
16
16
|
readFile(new URL("../dist/module/index.html", import.meta.url), "utf8"),
|
|
17
17
|
readFile(new URL("../dist/module/app.mjs", import.meta.url), "utf8"),
|
|
18
|
+
readFile(new URL("../dist/module/navigation.mjs", import.meta.url), "utf8"),
|
|
18
19
|
readFile(new URL("../dist/preference-panes.proxy.js", import.meta.url), "utf8"),
|
|
19
20
|
readFile(new URL("../dist/preference-panes.config.js", import.meta.url), "utf8"),
|
|
20
21
|
]);
|
|
@@ -23,6 +24,7 @@ export async function build(boxjs, css = "") {
|
|
|
23
24
|
[`settings/${module}/index.html`]: html,
|
|
24
25
|
[`settings/assets/${module}.html`]: html,
|
|
25
26
|
"settings/assets/app.mjs": app,
|
|
27
|
+
"settings/assets/navigation.mjs": navigation,
|
|
26
28
|
[`settings/assets/${module}.boxjs.json`]: config,
|
|
27
29
|
[`settings/assets/${module}.css`]: css,
|
|
28
30
|
[`settings/assets/${module}.request.js`]: `${proxy}\nPreferencePanes.run(${config},${JSON.stringify(css)});\n`,
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
|
|
3
|
+
* Resolve module resource locations: headers override query parameters and module conventions.
|
|
4
|
+
* @param {URL} url 已解析的页面请求地址 / Parsed page request URL.
|
|
5
|
+
* @param {Record<string, string | undefined>} [headers] 请求头,名称不区分大小写 / Case-insensitive request headers.
|
|
6
|
+
* @returns {{url: string, module: string, json: string, css: string}} 页面上下文与两个资源输入 / Page context and two resource inputs.
|
|
7
|
+
*/
|
|
8
|
+
export function pageInputs(url, headers = {}) {
|
|
9
|
+
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname);
|
|
10
|
+
if (!match) throw new TypeError("Open a concrete module URL");
|
|
11
|
+
const module = match[1];
|
|
12
|
+
const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
13
|
+
const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
|
|
14
|
+
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? `/settings/assets/${module}.css`;
|
|
15
|
+
if (!json.trim()) throw new TypeError("JSON resource URL is required");
|
|
16
|
+
return { url: url.href, module, json, css };
|
|
17
|
+
}
|
package/src/proxy/handler.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { URL } from "@nsnanocat/url";
|
|
2
2
|
import assets from "#assets";
|
|
3
3
|
import { BoxJS } from "../BoxJS.mjs";
|
|
4
|
+
import { pageInputs } from "../lib/page-inputs.mjs";
|
|
4
5
|
import { response } from "../lib/response.mjs";
|
|
5
6
|
import { Store } from "../Store.mjs";
|
|
6
7
|
import { complete } from "./response.mjs";
|
|
@@ -26,12 +27,19 @@ export async function run(boxjs, css = "") {
|
|
|
26
27
|
break;
|
|
27
28
|
case url.pathname.startsWith("/configs/"):
|
|
28
29
|
break;
|
|
30
|
+
case url.pathname === `/settings/${module}` || url.pathname === `/settings/${module}/`: {
|
|
31
|
+
// Header 由代理传入文档,浏览器再下载 JSON/CSS;代理不获取外部资源。
|
|
32
|
+
// Carry headers into the document; only the browser downloads external JSON/CSS.
|
|
33
|
+
const inputs = encodeURIComponent(JSON.stringify(pageInputs(url, request.headers)));
|
|
34
|
+
const html = assets.page.body.replace("</head>", `<meta name="preference-panes-inputs" content="${inputs}"></head>`);
|
|
35
|
+
result = response(request, 200, html, "text/html");
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
29
38
|
case url.pathname === `/settings/assets/${module}.css`:
|
|
30
39
|
result = response(request, 200, css, "text/css");
|
|
31
40
|
break;
|
|
32
41
|
default: {
|
|
33
|
-
const
|
|
34
|
-
const asset = assets[path];
|
|
42
|
+
const asset = assets[url.pathname];
|
|
35
43
|
if (asset) result = response(request, 200, asset.body, asset.type);
|
|
36
44
|
}
|
|
37
45
|
}
|