@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.
- package/README.md +7 -4
- package/dist/module/index.html +2 -1
- package/dist/module/index.mjs +50 -39
- package/dist/module/navigation.mjs +7 -7
- package/dist/preference-panes.mjs +50 -39
- package/dist/web.js +42 -2
- package/package.json +1 -1
- package/src/browser/ActionMenu.mjs +2 -2
- package/src/browser/ModuleFrame.mjs +5 -5
- package/src/browser/Navigation.d.mts +4 -4
- package/src/browser/module.html +1 -0
- package/src/browser/panel.css +0 -3
- package/src/browser/panel.mjs +45 -35
- package/src/browser/styles.mjs +2 -1
- package/src/web.mjs +41 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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]
|
|
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
|
|
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
|
|
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.
|
package/src/browser/module.html
CHANGED
package/src/browser/panel.css
CHANGED
package/src/browser/panel.mjs
CHANGED
|
@@ -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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
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
|
|
501
|
-
|
|
510
|
+
menu?.destroy();
|
|
511
|
+
frame?.removeEventListener("preferencepanes:action", onAction);
|
|
502
512
|
navigation?.destroy();
|
|
503
513
|
generation++;
|
|
504
514
|
if (active && !saving) client.leave();
|
package/src/browser/styles.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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("&", "&").replaceAll('"', """);
|
|
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" });
|