@nsnanocat/preference-panes 1.1.3 → 1.2.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.
@@ -1,6 +1,6 @@
1
1
  /**
2
- * 模块文档容器:原始 HTML 不改写,只向 iframe 标记模块身份。
3
- * Module document container: preserve HTML verbatim and mark only the module identity on the iframe.
2
+ * 模块文档容器:发送 GET 请求并将原始 HTML 交给 iframe,只在元素上标记模块身份。
3
+ * Module document container: send a GET request, preserve HTML verbatim and mark only the module identity on the iframe.
4
4
  */
5
5
  export class ModuleFrame extends EventTarget {
6
6
  #url;
@@ -25,14 +25,14 @@ export class ModuleFrame extends EventTarget {
25
25
  * 建立 iframe;调用方挂载 element 后调用 load。
26
26
  * Create the iframe; callers mount element and then call load.
27
27
  * @param {string | URL} url 模块请求地址 / Module request URL.
28
- * @param {{signal?: AbortSignal}} [options] 外部取消信号 / External cancellation signal.
28
+ * @param {{signal?: AbortSignal, headers?: HeadersInit}} [options] 请求选项 / Request options.
29
29
  */
30
30
  constructor(url, options = {}) {
31
31
  super();
32
32
  this.#url = new URL(url, document.baseURI);
33
33
  const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(this.#url.pathname);
34
34
  if (!match) throw new TypeError("Open a concrete module URL");
35
- this.#options = options;
35
+ this.#options = { signal: options.signal, headers: options.headers };
36
36
  this.element = document.createElement("iframe");
37
37
  this.element.title = `${match[1]} 设置`;
38
38
  this.element.dataset.preferencePanes = "true";
@@ -61,7 +61,7 @@ export class ModuleFrame extends EventTarget {
61
61
  if (this.#options.signal?.aborted) this.destroy();
62
62
  const timer = setTimeout(() => this.#controller.abort(), 10000);
63
63
  try {
64
- const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", signal: this.#controller.signal });
64
+ const response = await fetch(this.#url, { method: "GET", cache: "no-store", credentials: "omit", headers: this.#options.headers, signal: this.#controller.signal });
65
65
  if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
66
66
  const html = await response.text();
67
67
  this.#controller.signal.throwIfAborted();
@@ -1,6 +1,6 @@
1
1
  /**
2
- * 原始 HTML 的模块 iframe 容器;通过元素传递请求上下文。
3
- * Iframe container preserving module HTML and carrying context on the element.
2
+ * 原始 HTML 的模块 iframe 容器;请求选项不写入元素上下文。
3
+ * Iframe container preserving module HTML without writing request options to element context.
4
4
  * confirm 事件可 preventDefault 后通过 detail.resolve/reject 完成宿主确认。
5
5
  * Prevent default on confirm events and settle host dialogs through detail.resolve/reject.
6
6
  * notice 事件可 preventDefault 后交由宿主显示,模块不再创建网页 Toast。
@@ -11,9 +11,9 @@ export class ModuleFrame extends EventTarget {
11
11
  * 创建容器。
12
12
  * Create a container.
13
13
  * @param url 模块地址 / Module URL.
14
- * @param options 外部取消选项 / External cancellation options.
14
+ * @param options GET 请求选项 / GET request options.
15
15
  */
16
- constructor(url: string | URL, options?: { signal?: AbortSignal });
16
+ constructor(url: string | URL, options?: { signal?: AbortSignal; headers?: HeadersInit });
17
17
  /**
18
18
  * 宿主挂载节点。
19
19
  * Host-mounted element.
@@ -6,6 +6,7 @@
6
6
  <meta name="color-scheme" content="light dark">
7
7
  <title>Module Preferences</title>
8
8
  <style>body { margin: 0; }</style>
9
+ <!--__PREFERENCE_PANES_STYLESHEET__-->
9
10
  </head>
10
11
  <body>
11
12
  <main id="preferences"></main>
@@ -87,9 +87,6 @@
87
87
  min-height: 0;
88
88
  overflow: hidden;
89
89
  }
90
- :root[data-preference-panes-embedded] .pp-header {
91
- display: none;
92
- }
93
90
  @supports (height: 100dvh) {
94
91
  .pp-panel {
95
92
  height: 100dvh;
@@ -32,13 +32,9 @@ export class PreferencesPanel {
32
32
  const title = definition.metadata?.name ?? definition.module;
33
33
  const document = root.ownerDocument;
34
34
  const window = document.defaultView;
35
+ const frame = window.frameElement?.dataset.preferencePanes ? window.frameElement : undefined;
35
36
  const shell = node("div", "pp-panel");
36
37
  shell.dataset.module = definition.module;
37
- const header = node("header", "pp-header");
38
- const back = node("button", "pp-back", "‹");
39
- back.setAttribute("aria-label", "返回");
40
- back.type = "button";
41
- const heading = node("h1", "pp-title", title);
42
38
  const handlers = new Map();
43
39
  const menuItems = [
44
40
  { id: "viewSettings", label: "查看设置" },
@@ -46,37 +42,51 @@ export class PreferencesPanel {
46
42
  { id: "clearCaches", label: "清空缓存", destructive: true },
47
43
  { id: "reset", label: "重置设置", destructive: true },
48
44
  ];
49
- const menu = new ActionMenu(id => runAction(id));
50
- const trailing = node("span", "pp-nav-spacer");
51
- trailing.append(menu.element);
52
45
  const viewport = node("div", "pp-viewport");
46
+ let back, heading, menu;
47
+ if (frame) shell.append(viewport);
48
+ else {
49
+ const header = node("header", "pp-header");
50
+ back = node("button", "pp-back", "‹");
51
+ back.setAttribute("aria-label", "返回");
52
+ back.type = "button";
53
+ heading = node("h1", "pp-title", title);
54
+ menu = new ActionMenu(id => runAction(id));
55
+ const trailing = node("span", "pp-nav-spacer");
56
+ trailing.append(menu.element);
57
+ header.append(back, heading, trailing);
58
+ shell.append(header, viewport);
59
+ }
53
60
  let toast;
54
- header.append(back, heading, trailing);
55
- shell.append(header, viewport);
56
61
  root.append(shell);
57
62
  // 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
58
63
  // Embedded mode publishes navigation state without host reads or mutations of the module DOM.
59
64
  const publishNavigation = () => {
60
65
  const actions = handlers.size ? menuItems : [];
61
- menu.update(actions, saving);
62
- const frame = window.frameElement;
63
- if (!frame?.dataset.preferencePanes) return;
64
- frame.dispatchEvent(
65
- new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
66
- detail: { title: heading.textContent, module: definition.module, busy: saving, canGoBack: !back.disabled, actions },
67
- }),
68
- );
66
+ if (frame)
67
+ frame.dispatchEvent(
68
+ new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
69
+ detail: { title: currentTitle, module: definition.module, busy: saving, canGoBack, actions },
70
+ }),
71
+ );
72
+ else {
73
+ heading.textContent = currentTitle;
74
+ back.disabled = !canGoBack;
75
+ menu.update(actions, saving);
76
+ }
69
77
  };
70
78
  const onAction = event => {
71
79
  if (!saving && handlers.has(event.detail)) runAction(event.detail);
72
80
  };
73
- window.frameElement?.addEventListener("preferencepanes:action", onAction);
81
+ frame?.addEventListener("preferencepanes:action", onAction);
74
82
  let timer,
75
83
  navigation,
76
84
  generation = 0,
77
85
  active = null,
78
86
  saving = false,
79
- destroyed = false;
87
+ destroyed = false,
88
+ currentTitle = title,
89
+ canGoBack = window.history.length > 1;
80
90
  /**
81
91
  * 展示短暂通知,不刷新设置数据。
82
92
  * Display a transient notification without refreshing settings.
@@ -105,7 +115,6 @@ export class PreferencesPanel {
105
115
  }
106
116
  // 宿主接管时不创建网页 Toast,也不运行其计时器。
107
117
  // A host-owned notice creates no web Toast and starts no local timer.
108
- const frame = window.frameElement;
109
118
  if (frame && !frame.dispatchEvent(new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:notice", { cancelable: true, detail: { kind: event.kind, message } }))) return;
110
119
  if (!toast) {
111
120
  toast = node("div", "pp-toast");
@@ -143,8 +152,8 @@ export class PreferencesPanel {
143
152
  async function open(module) {
144
153
  const version = ++generation;
145
154
  active = module;
146
- back.disabled = window.history.length <= 1;
147
- heading.textContent = module;
155
+ currentTitle = module;
156
+ canGoBack = window.history.length > 1;
148
157
  publishNavigation();
149
158
  viewport.replaceChildren(statusView("读取设置…"));
150
159
  try {
@@ -163,7 +172,7 @@ export class PreferencesPanel {
163
172
  */
164
173
  function controls() {
165
174
  const { definition, values } = client.snapshot();
166
- heading.textContent = definition.metadata?.name || active;
175
+ currentTitle = definition.metadata?.name || active;
167
176
  const view = node("section", "pp-fields");
168
177
  /**
169
178
  * 挂载后执行的多行高度更新
@@ -183,8 +192,8 @@ export class PreferencesPanel {
183
192
  */
184
193
  const updateNavigation = () => {
185
194
  const editor = editors.get(navigation.current);
186
- heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
187
- back.disabled = saving || !navigation.canGoBack;
195
+ currentTitle = editor?.title ?? definition.metadata?.name ?? active;
196
+ canGoBack = !saving && navigation.canGoBack;
188
197
  publishNavigation();
189
198
  };
190
199
  /**
@@ -198,7 +207,7 @@ export class PreferencesPanel {
198
207
  function perform(action, success, failure = () => {}) {
199
208
  pendingWrites++;
200
209
  saving = true;
201
- back.disabled = true;
210
+ canGoBack = false;
202
211
  publishNavigation();
203
212
  queue = queue
204
213
  .then(action)
@@ -214,7 +223,7 @@ export class PreferencesPanel {
214
223
  pendingWrites--;
215
224
  saving = pendingWrites > 0;
216
225
  if (destroyed && !saving) client.leave();
217
- back.disabled = saving || !navigation.canGoBack;
226
+ canGoBack = !saving && navigation.canGoBack;
218
227
  publishNavigation();
219
228
  });
220
229
  return queue;
@@ -489,16 +498,17 @@ export class PreferencesPanel {
489
498
  * Loaded forms delegate back to navigation; loading views can return to the previous document.
490
499
  * @returns {void} 无返回值 / No return value.
491
500
  */
492
- back.onclick = () => {
493
- if (saving) return;
494
- if (navigation) navigation.back();
495
- else window.history.back();
496
- };
501
+ if (back)
502
+ back.onclick = () => {
503
+ if (saving) return;
504
+ if (navigation) navigation.back();
505
+ else window.history.back();
506
+ };
497
507
  open(definition.module);
498
508
  return () => {
499
509
  destroyed = true;
500
- menu.destroy();
501
- window.frameElement?.removeEventListener("preferencepanes:action", onAction);
510
+ menu?.destroy();
511
+ frame?.removeEventListener("preferencepanes:action", onAction);
502
512
  navigation?.destroy();
503
513
  generation++;
504
514
  if (active && !saving) client.leave();
@@ -1,6 +1,7 @@
1
1
  import defaults from "#styles";
2
2
 
3
3
  const selector = "style[data-preference-panes-defaults]";
4
+ const stylesheetSelector = "link[data-preference-panes-stylesheet]";
4
5
 
5
6
  /**
6
7
  * 在文档中安装一次默认样式,并标记当前调用方是否拥有该节点。
@@ -14,6 +15,6 @@ export function installDefaultStyles(document) {
14
15
  const element = document.createElement("style");
15
16
  element.dataset.preferencePanesDefaults = "";
16
17
  element.textContent = defaults;
17
- document.head.append(element);
18
+ document.head.insertBefore(element, document.head.querySelector(stylesheetSelector));
18
19
  return { element, owned: true };
19
20
  }
package/src/web.mjs CHANGED
@@ -3,6 +3,36 @@ import { $app } from "@nsnanocat/util/lib/app.mjs";
3
3
  import { done } from "@nsnanocat/util/lib/done.mjs";
4
4
  import assets from "#assets";
5
5
 
6
+ /**
7
+ * 按标准 URL 语义解析样式地址,并限制为 HTTP(S)。
8
+ * Resolve a stylesheet reference with standard URL semantics and require HTTP(S).
9
+ * @param {string} source 样式地址 / Stylesheet reference.
10
+ * @param {URL} base 模块页面地址 / Module page URL.
11
+ * @returns {URL} 绝对样式地址 / Absolute stylesheet URL.
12
+ */
13
+ function stylesheetURL(source, base) {
14
+ const scheme = /^([a-zA-Z][a-zA-Z\d+.-]*:)/.exec(source)?.[1].toLowerCase();
15
+ if (scheme && !["http:", "https:"].includes(scheme)) throw new TypeError("CSS resource must use HTTP(S)");
16
+ let resource;
17
+ if (scheme) resource = new URL(source);
18
+ else if (source.startsWith("//")) resource = new URL(`${base.protocol}${source}`);
19
+ else {
20
+ const [, path, query, hash] = /^([^?#]*)(\?[^#]*)?(#.*)?$/.exec(source);
21
+ const pathname = path.startsWith("/") ? path : `${base.pathname.slice(0, base.pathname.lastIndexOf("/") + 1)}${path}`;
22
+ const segments = [];
23
+ for (const segment of pathname.split("/")) {
24
+ if (segment === ".") continue;
25
+ if (segment === "..") {
26
+ if (segments.length > 1) segments.pop();
27
+ } else segments.push(segment);
28
+ }
29
+ const normalized = segments.join("/") || "/";
30
+ resource = new URL(`${base.origin}${normalized}${query ?? (path ? "" : base.search)}${hash ?? ""}`);
31
+ }
32
+ if (!["http:", "https:"].includes(resource.protocol.toLowerCase())) throw new TypeError("CSS resource must use HTTP(S)");
33
+ return resource;
34
+ }
35
+
6
36
  /**
7
37
  * 返回模块页面及其公共浏览器资源,不处理 API、网络或持久化。
8
38
  * Serve module pages and common browser assets without handling APIs, network access or persistence.
@@ -15,7 +45,17 @@ function run() {
15
45
  const url = new URL(request.url);
16
46
  if (/^\/settings\/[a-zA-Z0-9_-]+\/?$/.test(url.pathname)) {
17
47
  if (!["GET", "HEAD"].includes(request.method)) result = response(request, 405, { error: "Method not allowed" });
18
- else result = response(request, 200, assets.page.body, "text/html");
48
+ else {
49
+ const header = Object.entries(request.headers ?? {}).find(([name]) => name.toLowerCase() === "x-preferencepanes-css");
50
+ const source = (header ? header[1] : url.searchParams.get("css"))?.trim();
51
+ let stylesheet = "";
52
+ if (source) {
53
+ const resource = stylesheetURL(source, url);
54
+ const href = resource.href.replaceAll("&", "&amp;").replaceAll('"', "&quot;");
55
+ stylesheet = `<link data-preference-panes-stylesheet rel="stylesheet" href="${href}">`;
56
+ }
57
+ result = response(request, 200, assets.page.body.replace("<!--__PREFERENCE_PANES_STYLESHEET__-->", stylesheet), "text/html");
58
+ }
19
59
  } else {
20
60
  const asset = assets[url.pathname];
21
61
  if (asset) result = ["GET", "HEAD"].includes(request.method) ? response(request, 200, asset.body, asset.type) : response(request, 405, { error: "Method not allowed" });