@nsnanocat/preference-panes 0.7.0 → 0.7.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 +41 -1
- package/dist/module/app.mjs +220 -80
- package/dist/module/index.html +1 -1
- package/dist/module/navigation.mjs +160 -0
- package/dist/preference-panes.mjs +188 -71
- package/dist/preference-panes.proxy.js +28 -3
- package/package.json +5 -1
- package/src/browser/Navigation.d.mts +43 -0
- package/src/browser/Navigation.mjs +158 -0
- package/src/browser/app.mjs +15 -9
- package/src/browser/components.mjs +1 -1
- package/src/browser/panel.css +24 -2
- package/src/browser/panel.mjs +28 -69
- package/src/build.mjs +3 -1
- package/src/lib/page-inputs.mjs +17 -0
- package/src/proxy/handler.mjs +10 -2
package/src/browser/app.mjs
CHANGED
|
@@ -1,25 +1,31 @@
|
|
|
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
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
const context = document.querySelector('meta[name="preference-panes-inputs"]');
|
|
17
|
+
const inputs = context ? JSON.parse(decodeURIComponent(context.content)) : pageInputs(new URL(location.href));
|
|
18
|
+
const resources = [inputs.json, inputs.css].map(source => {
|
|
19
|
+
if (!source) return null;
|
|
20
|
+
const url = new URL(source, inputs.url);
|
|
21
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Resources must use HTTP(S) URLs");
|
|
22
|
+
return url.href;
|
|
23
|
+
});
|
|
24
|
+
const [data, style] = await Promise.all(resources.map(url => (url ? fetch(url, { cache: "no-store", credentials: "omit" }) : null)));
|
|
25
|
+
if (data.status !== 200 || (style && style.status !== 200)) throw new Error(`HTTP ${data.status !== 200 ? data.status : style.status}`);
|
|
20
26
|
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());
|
|
27
|
+
if (new BoxJS(boxjs).module.module !== inputs.module) throw new Error("Imported JSON does not match the module URL");
|
|
28
|
+
view = mount(boxjs, style ? await style.text() : "");
|
|
23
29
|
} catch (error) {
|
|
24
30
|
document.querySelector("#preferences").replaceChildren(errorView(error, start));
|
|
25
31
|
}
|
|
@@ -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;
|
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,27 @@ 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);
|
|
28
36
|
let timer,
|
|
29
|
-
|
|
37
|
+
navigation,
|
|
30
38
|
generation = 0,
|
|
31
39
|
active = null,
|
|
32
40
|
saving = false,
|
|
@@ -64,25 +72,6 @@ export function mountPanel(root, catalog) {
|
|
|
64
72
|
}, 2400);
|
|
65
73
|
};
|
|
66
74
|
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
75
|
/**
|
|
87
76
|
* 打开模块并忽略已过期的异步结果。
|
|
88
77
|
* Open a module and ignore stale asynchronous results.
|
|
@@ -94,16 +83,13 @@ export function mountPanel(root, catalog) {
|
|
|
94
83
|
active = module;
|
|
95
84
|
back.disabled = window.history.length <= 1;
|
|
96
85
|
heading.textContent = module;
|
|
97
|
-
|
|
86
|
+
viewport.replaceChildren(node("p", "pp-loading", "读取设置…"));
|
|
98
87
|
try {
|
|
99
88
|
await client.open(module);
|
|
100
89
|
if (version === generation) controls();
|
|
101
90
|
} catch (error) {
|
|
102
91
|
if (version !== generation) return;
|
|
103
|
-
|
|
104
|
-
errorView(error, () => open(module)),
|
|
105
|
-
1,
|
|
106
|
-
);
|
|
92
|
+
viewport.replaceChildren(errorView(error, () => open(module)));
|
|
107
93
|
}
|
|
108
94
|
}
|
|
109
95
|
/**
|
|
@@ -124,37 +110,18 @@ export function mountPanel(root, catalog) {
|
|
|
124
110
|
const editors = new Map();
|
|
125
111
|
const summaries = [];
|
|
126
112
|
const groups = new Map();
|
|
127
|
-
const scrollPositions = new WeakMap();
|
|
128
|
-
let activeEditor;
|
|
129
113
|
let queue = Promise.resolve(),
|
|
130
114
|
pendingWrites = 0;
|
|
131
115
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
116
|
+
* 导航组件处理页面切换,表单只更新当前标题与返回按钮。
|
|
117
|
+
* Let navigation own transitions; the form only updates the title and back button.
|
|
134
118
|
* @returns {void} 无返回值 / No return value.
|
|
135
119
|
*/
|
|
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;
|
|
120
|
+
const updateNavigation = () => {
|
|
121
|
+
const editor = editors.get(navigation.current);
|
|
154
122
|
heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
|
|
155
|
-
back.disabled = saving ||
|
|
123
|
+
back.disabled = saving || !navigation.canGoBack;
|
|
156
124
|
};
|
|
157
|
-
secondaryRoute = showEditor;
|
|
158
125
|
/**
|
|
159
126
|
* 串行执行模块操作,保持输入可编辑。
|
|
160
127
|
* Serialize module actions while keeping inputs editable.
|
|
@@ -181,7 +148,7 @@ export function mountPanel(root, catalog) {
|
|
|
181
148
|
pendingWrites--;
|
|
182
149
|
saving = pendingWrites > 0;
|
|
183
150
|
if (destroyed && !saving) client.leave(active);
|
|
184
|
-
back.disabled = saving ||
|
|
151
|
+
back.disabled = saving || !navigation.canGoBack;
|
|
185
152
|
}));
|
|
186
153
|
}
|
|
187
154
|
const metadata = definition.metadata;
|
|
@@ -270,10 +237,7 @@ export function mountPanel(root, catalog) {
|
|
|
270
237
|
};
|
|
271
238
|
summaries.push(refresh);
|
|
272
239
|
refresh();
|
|
273
|
-
link.onclick = () =>
|
|
274
|
-
window.history.pushState({ ...window.history.state, preferencePane: active }, "", `#${encodeURIComponent(field.key)}`);
|
|
275
|
-
showEditor();
|
|
276
|
-
};
|
|
240
|
+
link.onclick = () => navigation.open(field.key);
|
|
277
241
|
row.addEventListener("click", event => {
|
|
278
242
|
if (!link.contains(event.target)) link.click();
|
|
279
243
|
});
|
|
@@ -420,26 +384,22 @@ export function mountPanel(root, catalog) {
|
|
|
420
384
|
actions.append(cacheView, cacheClear, reset);
|
|
421
385
|
maintenance.append(actions, output);
|
|
422
386
|
view.append(maintenance);
|
|
423
|
-
|
|
387
|
+
navigation?.destroy();
|
|
388
|
+
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
389
|
+
navigation.addEventListener("change", updateNavigation);
|
|
424
390
|
for (const grow of growingInputs) grow();
|
|
425
|
-
|
|
391
|
+
updateNavigation();
|
|
426
392
|
}
|
|
427
393
|
/**
|
|
428
|
-
*
|
|
429
|
-
*
|
|
394
|
+
* 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。
|
|
395
|
+
* Loaded forms delegate back to navigation; loading views can return to the previous document.
|
|
430
396
|
* @returns {void} 无返回值 / No return value.
|
|
431
397
|
*/
|
|
432
|
-
const onPopState = () => secondaryRoute?.();
|
|
433
|
-
const onHashChange = () => secondaryRoute?.();
|
|
434
398
|
back.onclick = () => {
|
|
435
399
|
if (saving) return;
|
|
436
|
-
if (
|
|
437
|
-
|
|
438
|
-
secondaryRoute?.();
|
|
439
|
-
} else window.history.back();
|
|
400
|
+
if (navigation) navigation.back();
|
|
401
|
+
else window.history.back();
|
|
440
402
|
};
|
|
441
|
-
window.addEventListener("popstate", onPopState);
|
|
442
|
-
window.addEventListener("hashchange", onHashChange);
|
|
443
403
|
open(catalog.module.module);
|
|
444
404
|
return {
|
|
445
405
|
/**
|
|
@@ -449,8 +409,7 @@ export function mountPanel(root, catalog) {
|
|
|
449
409
|
*/
|
|
450
410
|
destroy() {
|
|
451
411
|
destroyed = true;
|
|
452
|
-
|
|
453
|
-
window.removeEventListener("hashchange", onHashChange);
|
|
412
|
+
navigation?.destroy();
|
|
454
413
|
generation++;
|
|
455
414
|
if (active && !saving) client.leave(active);
|
|
456
415
|
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
|
}
|