@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.
@@ -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
- * 在具体模块地址导入 JSON CSS,再交给模块渲染器。
8
- * Import JSON and CSS at a concrete module URL and pass them to the module renderer.
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 match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(location.pathname);
16
- if (!match) throw new Error("Open a concrete module URL");
17
- const module = match[1];
18
- const [data, style] = await Promise.all([fetch(`/configs/${module}`, { cache: "no-store" }), fetch(`/settings/assets/${module}.css`, { cache: "no-store" })]);
19
- if (data.status !== 200 || style.status !== 200) throw new Error(`HTTP ${data.status !== 200 ? data.status : style.status}`);
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, location.href);
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
  }
@@ -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: relative;
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-header .pp-title {
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;
@@ -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, heading, node("span", "pp-nav-spacer"));
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
- secondaryRoute,
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
- replace(node("p", "pp-loading", "读取设置…"), 1);
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
- replace(
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
- * 根据 hash 切换多选页,保留上级 DOM 和滚动位置。
133
- * Switch multi-select views by hash while retaining parent DOM and scroll position.
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 showEditor = () => {
137
- let key;
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 || (!editor && window.history.length <= 1);
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 || (!activeEditor && window.history.length <= 1);
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
- viewport.replaceChildren(view);
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
- showEditor();
391
+ updateNavigation();
426
392
  }
427
393
  /**
428
- * 历史导航只切换当前模块的二级页,不接管项目主页或跨模块路由。
429
- * History navigation switches only this module's subpages, never project or cross-module routes.
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 (window.location.hash && window.history.state?.preferencePane !== active) {
437
- window.history.replaceState(window.history.state, "", window.location.pathname);
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
- window.removeEventListener("popstate", onPopState);
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
+ }
@@ -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 path = url.pathname === `/settings/${module}` || url.pathname === `/settings/${module}/` ? "page" : url.pathname;
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
  }