@nsnanocat/preference-panes 0.7.2 → 0.8.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 +25 -76
- package/dist/api.js +1622 -0
- package/dist/module/app.mjs +169 -52
- package/dist/module/index.html +1 -1
- package/dist/module/navigation.mjs +218 -4
- package/dist/preference-panes.mjs +168 -51
- package/package.json +1 -1
- package/src/Store.mjs +40 -53
- package/src/browser/ActionMenu.mjs +115 -0
- package/src/browser/ModuleFrame.mjs +13 -2
- package/src/browser/ModuleStatus.mjs +86 -0
- package/src/browser/Navigation.d.mts +99 -1
- package/src/browser/Navigation.mjs +2 -0
- package/src/browser/client.d.mts +2 -2
- package/src/browser/client.mjs +21 -21
- package/src/browser/panel.css +2 -15
- package/src/browser/panel.mjs +31 -21
- package/src/build.mjs +3 -13
- package/src/index.d.ts +6 -6
- package/src/lib/page-inputs.mjs +1 -1
- package/src/lib/settings-path.mjs +0 -18
- package/src/proxy/handler.mjs +9 -22
- package/dist/preference-panes.config.js +0 -842
- package/dist/preference-panes.proxy.js +0 -1754
- package/src/proxy/config.mjs +0 -14
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsnanocat/preference-panes",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.1",
|
|
4
4
|
"description": "Shared settings API runtime for JavaScript proxy modules",
|
|
5
5
|
"author": "VirgilClyne <Virgil@nanocat.me>",
|
|
6
6
|
"homepage": "https://NSNanoCat.github.io/preference-panes",
|
package/src/Store.mjs
CHANGED
|
@@ -2,83 +2,70 @@ import { URL } from "@nsnanocat/url";
|
|
|
2
2
|
import { Lodash as _ } from "@nsnanocat/util/polyfill/Lodash.mjs";
|
|
3
3
|
import { Storage } from "@nsnanocat/util/polyfill/Storage";
|
|
4
4
|
import { response } from "./lib/response.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { validatePathParts } from "./lib/settings-path.mjs";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* 无配置绑定的本地存储桥接;form 字段名就是完整 @root.path。
|
|
9
|
+
* Unbound local storage bridge; the form field name is the complete @root.path.
|
|
10
10
|
*/
|
|
11
11
|
export class Store {
|
|
12
|
-
#catalog;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* 复用包内已解析的目录,构造时不访问网络或存储。
|
|
16
|
-
* Reuse the parsed internal catalog without network or persistence access during construction.
|
|
17
|
-
* @param {import("./BoxJS.mjs").BoxJS} catalog BoxJS 路径目录 / BoxJS path catalog.
|
|
18
|
-
*/
|
|
19
|
-
constructor(catalog) {
|
|
20
|
-
this.#catalog = catalog;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
12
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
13
|
+
* POST /api/get、set、delete;不下载配置、不解析控件、不鉴权。
|
|
14
|
+
* POST /api/get, set or delete without config downloads, control parsing or authentication.
|
|
26
15
|
* @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
|
|
27
|
-
* @param {URL} [url]
|
|
28
|
-
* @returns {Promise<import("./index.js").SettingsResponse | undefined>}
|
|
16
|
+
* @param {URL} [url] 已解析地址 / Parsed URL.
|
|
17
|
+
* @returns {Promise<import("./index.js").SettingsResponse | undefined>} 操作结果 / Operation result.
|
|
29
18
|
*/
|
|
30
19
|
async handle(request, url = new URL(request.url)) {
|
|
31
20
|
if (!url.pathname.startsWith("/api/")) return;
|
|
32
21
|
const reply = (status, data) => response(request, status, data);
|
|
33
|
-
|
|
22
|
+
const action = url.pathname.slice(5);
|
|
23
|
+
if (!["get", "set", "delete"].includes(action)) return reply(404, { error: "Unknown action" });
|
|
24
|
+
if (request.method !== "POST") return reply(405, { error: "Use POST with a form body" });
|
|
25
|
+
const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
|
|
26
|
+
if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return reply(415, { error: "Expected application/x-www-form-urlencoded" });
|
|
27
|
+
if (typeof request.body !== "string" || request.body.length > 65536) return reply(400, { error: "Expected a form body up to 65536 characters" });
|
|
28
|
+
let parts, value;
|
|
34
29
|
try {
|
|
35
|
-
|
|
30
|
+
const fields = request.body.split("&");
|
|
31
|
+
if (fields.length !== 1) throw new TypeError("Send exactly one storage key");
|
|
32
|
+
const separator = fields[0].indexOf("=");
|
|
33
|
+
if (separator < 0) throw new TypeError("Expected @root.path=value");
|
|
34
|
+
const key = decodeURIComponent(fields[0].slice(0, separator).replace(/\+/g, " "));
|
|
35
|
+
value = decodeURIComponent(fields[0].slice(separator + 1).replace(/\+/g, " "));
|
|
36
|
+
if (!key.startsWith("@")) throw new TypeError("Storage keys must start with @");
|
|
37
|
+
parts = validatePathParts(key.slice(1).split("."));
|
|
38
|
+
if (parts.length < 2) throw new TypeError("Specify a storage root and child path");
|
|
36
39
|
} catch (error) {
|
|
37
40
|
return reply(400, { error: error.message });
|
|
38
41
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
case "HEAD":
|
|
46
|
-
return reply(200, undefined);
|
|
47
|
-
case "GET":
|
|
48
|
-
case "DELETE":
|
|
49
|
-
break;
|
|
50
|
-
case "POST":
|
|
51
|
-
if (requestHeaders["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") return reply(415, { error: "Expected application/json" });
|
|
52
|
-
if (typeof request.body !== "string") return reply(400, { error: "Expected a JSON string body" });
|
|
53
|
-
if (request.body.length > 65536) return reply(413, { error: "Body exceeds 65536 UTF-16 code units" });
|
|
54
|
-
try {
|
|
55
|
-
value = JSON.parse(request.body);
|
|
56
|
-
} catch {
|
|
57
|
-
return reply(400, { error: "Invalid JSON" });
|
|
58
|
-
}
|
|
59
|
-
break;
|
|
60
|
-
default:
|
|
61
|
-
return { ...reply(405, { error: "Method not allowed" }), headers: { ...reply(405).headers, Allow: "HEAD, GET, POST, DELETE" } };
|
|
42
|
+
if (action === "set") {
|
|
43
|
+
try {
|
|
44
|
+
value = JSON.parse(value);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
47
|
+
}
|
|
62
48
|
}
|
|
49
|
+
const [storageKey, ...path] = parts;
|
|
63
50
|
try {
|
|
64
|
-
const root = Storage.getItem(
|
|
65
|
-
if (!isRecord(root)) throw new TypeError("
|
|
66
|
-
const parent = storageParent(root,
|
|
67
|
-
const key =
|
|
68
|
-
switch (
|
|
69
|
-
case "
|
|
51
|
+
const root = Storage.getItem(storageKey, {});
|
|
52
|
+
if (!isRecord(root)) throw new TypeError("Stored root must be an object");
|
|
53
|
+
const parent = storageParent(root, path, action === "set");
|
|
54
|
+
const key = path.at(-1);
|
|
55
|
+
switch (action) {
|
|
56
|
+
case "get": {
|
|
70
57
|
const result = parent ? _.get(parent, [key]) : undefined;
|
|
71
58
|
return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
|
|
72
59
|
}
|
|
73
|
-
case "
|
|
60
|
+
case "set":
|
|
74
61
|
_.set(parent, [key], value);
|
|
75
62
|
break;
|
|
76
|
-
case "
|
|
63
|
+
case "delete":
|
|
77
64
|
if (parent) _.unset(parent, [key]);
|
|
78
65
|
break;
|
|
79
66
|
}
|
|
80
|
-
if (!Storage.setItem(
|
|
81
|
-
return reply(200,
|
|
67
|
+
if (!Storage.setItem(storageKey, root)) throw new Error("Storage write failed");
|
|
68
|
+
return reply(200, action === "set" ? { saved: true } : { deleted: true });
|
|
82
69
|
} catch (error) {
|
|
83
70
|
return reply(500, { error: error.message });
|
|
84
71
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。
|
|
3
|
+
* Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.
|
|
4
|
+
*/
|
|
5
|
+
export class ActionMenu {
|
|
6
|
+
#button;
|
|
7
|
+
#popup;
|
|
8
|
+
#backdrop;
|
|
9
|
+
#select;
|
|
10
|
+
#document;
|
|
11
|
+
#key = event => {
|
|
12
|
+
if (event.key === "Escape" && !this.#popup.hidden) {
|
|
13
|
+
event.preventDefault();
|
|
14
|
+
this.close();
|
|
15
|
+
this.#button.focus();
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 创建菜单,操作逻辑由调用方提供。
|
|
21
|
+
* Create a menu whose actions are handled by the caller.
|
|
22
|
+
* @param {(id: string) => void} select 菜单选择回调 / Selection callback.
|
|
23
|
+
*/
|
|
24
|
+
constructor(select) {
|
|
25
|
+
this.#document = document;
|
|
26
|
+
this.#select = select;
|
|
27
|
+
this.element = document.createElement("span");
|
|
28
|
+
const root = this.element.attachShadow({ mode: "open" });
|
|
29
|
+
root.innerHTML = `<style>
|
|
30
|
+
:host{display:inline-flex;position:relative;width:44px;height:44px;color:inherit}
|
|
31
|
+
:host([hidden]),[hidden]{display:none!important}
|
|
32
|
+
button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
33
|
+
button:disabled{opacity:.4;cursor:default}
|
|
34
|
+
button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
|
|
35
|
+
#trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}
|
|
36
|
+
svg{display:block;width:24px;height:24px;fill:currentColor}
|
|
37
|
+
#backdrop{position:fixed;inset:0;z-index:1}
|
|
38
|
+
#items{position:absolute;right:0;top:46px;z-index:2;min-width:160px;padding:6px;background:var(--pp-surface,Canvas);color:var(--pp-text,CanvasText);border:1px solid var(--pp-border,#8884);border-radius:12px;box-shadow:0 8px 28px #0003}
|
|
39
|
+
#items button{display:block;text-align:left;white-space:nowrap;width:100%;padding:11px 14px;border-radius:8px;font:14px/1.4 system-ui,sans-serif}
|
|
40
|
+
#items button:hover{background:#8882}
|
|
41
|
+
#items button[data-danger]{color:#e45656}
|
|
42
|
+
</style><button id="trigger" type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false" aria-controls="items"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="4" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="20" cy="12" r="2"/></svg></button><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单" hidden></button><div id="items" role="menu" hidden></div>`;
|
|
43
|
+
this.#button = root.querySelector("#trigger");
|
|
44
|
+
this.#popup = root.querySelector("#items");
|
|
45
|
+
this.#backdrop = root.querySelector("#backdrop");
|
|
46
|
+
this.#button.onclick = () => {
|
|
47
|
+
const open = this.#popup.hidden;
|
|
48
|
+
this.#popup.hidden = this.#backdrop.hidden = !open;
|
|
49
|
+
this.#button.setAttribute("aria-expanded", String(open));
|
|
50
|
+
if (open) this.#popup.firstElementChild.focus();
|
|
51
|
+
};
|
|
52
|
+
this.#backdrop.onclick = () => this.close();
|
|
53
|
+
this.#popup.onkeydown = event => {
|
|
54
|
+
if (event.key === "Tab") {
|
|
55
|
+
this.close();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const items = [...this.#popup.children];
|
|
59
|
+
const index = items.indexOf(root.activeElement);
|
|
60
|
+
const offsets = { ArrowDown: 1, ArrowUp: -1 };
|
|
61
|
+
if (event.key in offsets) {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
items[(index + offsets[event.key] + items.length) % items.length].focus();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
document.addEventListener("keydown", this.#key);
|
|
67
|
+
this.update([]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 同步可用操作和忙碌状态,不重建菜单触发按钮。
|
|
72
|
+
* Update actions and busy state without replacing the trigger button.
|
|
73
|
+
* @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.
|
|
74
|
+
* @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
|
|
75
|
+
* @returns {void} 无返回值 / No return value.
|
|
76
|
+
*/
|
|
77
|
+
update(items, disabled = false) {
|
|
78
|
+
this.close();
|
|
79
|
+
this.#button.disabled = disabled || items.length === 0;
|
|
80
|
+
this.#popup.replaceChildren(
|
|
81
|
+
...items.map(item => {
|
|
82
|
+
const button = this.#document.createElement("button");
|
|
83
|
+
button.type = "button";
|
|
84
|
+
button.setAttribute("role", "menuitem");
|
|
85
|
+
button.textContent = item.label;
|
|
86
|
+
button.toggleAttribute("data-danger", Boolean(item.destructive));
|
|
87
|
+
button.onclick = () => {
|
|
88
|
+
this.close();
|
|
89
|
+
this.#select(item.id);
|
|
90
|
+
};
|
|
91
|
+
return button;
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 关闭菜单。
|
|
98
|
+
* Close the menu.
|
|
99
|
+
* @returns {void} 无返回值 / No return value.
|
|
100
|
+
*/
|
|
101
|
+
close() {
|
|
102
|
+
this.#popup.hidden = this.#backdrop.hidden = true;
|
|
103
|
+
this.#button.setAttribute("aria-expanded", "false");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 移除监听器与节点。
|
|
108
|
+
* Remove listeners and elements.
|
|
109
|
+
* @returns {void} 无返回值 / No return value.
|
|
110
|
+
*/
|
|
111
|
+
destroy() {
|
|
112
|
+
this.#document.removeEventListener("keydown", this.#key);
|
|
113
|
+
this.element.remove();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -11,7 +11,7 @@ export class ModuleFrame extends EventTarget {
|
|
|
11
11
|
#abort = () => this.destroy();
|
|
12
12
|
#state;
|
|
13
13
|
#change = event => {
|
|
14
|
-
this.#state = event.detail;
|
|
14
|
+
this.#state = { ...event.detail, actions: event.detail.actions ?? [] };
|
|
15
15
|
this.dispatchEvent(new Event("change"));
|
|
16
16
|
};
|
|
17
17
|
|
|
@@ -30,7 +30,7 @@ export class ModuleFrame extends EventTarget {
|
|
|
30
30
|
this.element.title = `${inputs.module} 设置`;
|
|
31
31
|
this.element.dataset.preferencePanes = JSON.stringify(inputs);
|
|
32
32
|
this.element.addEventListener("preferencepanes:change", this.#change);
|
|
33
|
-
this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true };
|
|
33
|
+
this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true, actions: [] };
|
|
34
34
|
options.signal?.addEventListener("abort", this.#abort, { once: true });
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -70,6 +70,17 @@ export class ModuleFrame extends EventTarget {
|
|
|
70
70
|
if (!this.#state.busy && this.#state.canGoBack) this.element.contentWindow.history.back();
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* 向模块发送菜单操作,不让宿主访问内部 DOM 或存储客户端。
|
|
75
|
+
* Dispatch a menu action without host access to internal DOM or the storage client.
|
|
76
|
+
* @param {string} id 当前可用操作 / Available action identifier.
|
|
77
|
+
* @returns {void} 无返回值 / No return value.
|
|
78
|
+
*/
|
|
79
|
+
perform(id) {
|
|
80
|
+
if (this.#state.busy || !this.#state.actions.some(action => action.id === id)) throw new Error("Action is not available");
|
|
81
|
+
this.element.dispatchEvent(new CustomEvent("preferencepanes:action", { detail: id }));
|
|
82
|
+
}
|
|
83
|
+
|
|
73
84
|
/**
|
|
74
85
|
* 取消加载与事件订阅;节点保留到 Navigation 的退出动画结束。
|
|
75
86
|
* Cancel loading and subscriptions; Navigation retains the node until its exit animation ends.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模块入口的固定状态行,只通过 HEAD 探测安装状态和业务版本。
|
|
3
|
+
* Fixed module status row, probing installation and business version with HEAD only.
|
|
4
|
+
*/
|
|
5
|
+
export class ModuleStatus extends EventTarget {
|
|
6
|
+
#element;
|
|
7
|
+
#controller;
|
|
8
|
+
#state = { status: "checking", version: null };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 绑定调用方提供的状态行。
|
|
12
|
+
* Bind a caller-owned status row.
|
|
13
|
+
* @param {HTMLElement} element 状态文字容器 / Status text container.
|
|
14
|
+
*/
|
|
15
|
+
constructor(element) {
|
|
16
|
+
super();
|
|
17
|
+
this.#element = element;
|
|
18
|
+
this.#render("checking");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 当前安装状态与业务版本。
|
|
23
|
+
* Current installation state and business version.
|
|
24
|
+
*/
|
|
25
|
+
get state() {
|
|
26
|
+
return { ...this.#state };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 每次进入重新探测,取消旧请求并忽略其迟到结果。
|
|
31
|
+
* Reprobe on entry, cancelling old requests and ignoring late results.
|
|
32
|
+
* @param {string | URL} url 配置 Mock 地址 / Configuration Mock URL.
|
|
33
|
+
* @returns {Promise<void>} 探测完成 / Probe completion.
|
|
34
|
+
*/
|
|
35
|
+
async check(url) {
|
|
36
|
+
this.#controller?.abort();
|
|
37
|
+
const controller = (this.#controller = new AbortController());
|
|
38
|
+
this.#render("checking");
|
|
39
|
+
const timer = setTimeout(() => controller.abort(), 3500);
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal });
|
|
42
|
+
if (controller !== this.#controller) return;
|
|
43
|
+
const version = response.headers.get("X-PreferencePanes-Version")?.trim() || null;
|
|
44
|
+
this.#render(response.status === 200 ? "installed" : "missing", version);
|
|
45
|
+
} catch {
|
|
46
|
+
if (controller === this.#controller) this.#render("missing");
|
|
47
|
+
} finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 更新状态标签,缺少版本时不伪造版本号。
|
|
54
|
+
* Render the label without inventing a missing version.
|
|
55
|
+
* @param {"checking" | "installed" | "missing"} status 状态 / State.
|
|
56
|
+
* @param {string | null} [version] 业务版本 / Business version.
|
|
57
|
+
* @returns {void} 无返回值 / No return value.
|
|
58
|
+
*/
|
|
59
|
+
#render(status, version = null) {
|
|
60
|
+
this.#state = { status, version: status === "installed" ? version : null };
|
|
61
|
+
switch (status) {
|
|
62
|
+
case "checking":
|
|
63
|
+
this.#element.textContent = "检测中";
|
|
64
|
+
break;
|
|
65
|
+
case "installed":
|
|
66
|
+
this.#element.textContent = version ?? "版本未知";
|
|
67
|
+
break;
|
|
68
|
+
case "missing":
|
|
69
|
+
this.#element.textContent = "未安装";
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
this.#element.dataset.state = status;
|
|
73
|
+
this.#element.title = this.#element.textContent;
|
|
74
|
+
this.dispatchEvent(new Event("change"));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 释放尚未完成的探测。
|
|
79
|
+
* Release pending probes.
|
|
80
|
+
* @returns {void} 无返回值 / No return value.
|
|
81
|
+
*/
|
|
82
|
+
destroy() {
|
|
83
|
+
this.#controller?.abort();
|
|
84
|
+
this.#controller = undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -19,7 +19,7 @@ export class ModuleFrame extends EventTarget {
|
|
|
19
19
|
* change 事件对应的导航状态。
|
|
20
20
|
* Navigation state exposed with change events.
|
|
21
21
|
*/
|
|
22
|
-
readonly state: { title: string; module: string; busy: boolean; canGoBack: boolean };
|
|
22
|
+
readonly state: { title: string; module: string; busy: boolean; canGoBack: boolean; actions: MenuAction[] };
|
|
23
23
|
/**
|
|
24
24
|
* 获取原始 HTML。
|
|
25
25
|
* Fetch unmodified HTML.
|
|
@@ -32,6 +32,13 @@ export class ModuleFrame extends EventTarget {
|
|
|
32
32
|
* @returns 无返回值 / No return value.
|
|
33
33
|
*/
|
|
34
34
|
back(): void;
|
|
35
|
+
/**
|
|
36
|
+
* 执行模块提供的菜单操作。
|
|
37
|
+
* Perform a module-provided menu action.
|
|
38
|
+
* @param id 操作标识 / Action identifier.
|
|
39
|
+
* @returns 无返回值 / No return value.
|
|
40
|
+
*/
|
|
41
|
+
perform(id: string): void;
|
|
35
42
|
/**
|
|
36
43
|
* 取消请求与事件订阅。
|
|
37
44
|
* Cancel requests and subscriptions.
|
|
@@ -40,6 +47,97 @@ export class ModuleFrame extends EventTarget {
|
|
|
40
47
|
destroy(): void;
|
|
41
48
|
}
|
|
42
49
|
|
|
50
|
+
/**
|
|
51
|
+
* 菜单操作描述。
|
|
52
|
+
* Menu action descriptor.
|
|
53
|
+
*/
|
|
54
|
+
export interface MenuAction {
|
|
55
|
+
/**
|
|
56
|
+
* 操作标识。
|
|
57
|
+
* Action identifier.
|
|
58
|
+
*/
|
|
59
|
+
id: string;
|
|
60
|
+
/**
|
|
61
|
+
* 显示文字。
|
|
62
|
+
* Display text.
|
|
63
|
+
*/
|
|
64
|
+
label: string;
|
|
65
|
+
/**
|
|
66
|
+
* 危险操作样式。
|
|
67
|
+
* Destructive action style.
|
|
68
|
+
*/
|
|
69
|
+
destructive?: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* 共用三点菜单。
|
|
74
|
+
* Shared overflow menu.
|
|
75
|
+
*/
|
|
76
|
+
export class ActionMenu {
|
|
77
|
+
/**
|
|
78
|
+
* 创建菜单。
|
|
79
|
+
* Create a menu.
|
|
80
|
+
* @param select 选择回调 / Selection callback.
|
|
81
|
+
*/
|
|
82
|
+
constructor(select: (id: string) => void);
|
|
83
|
+
/**
|
|
84
|
+
* 菜单节点。
|
|
85
|
+
* Menu element.
|
|
86
|
+
*/
|
|
87
|
+
readonly element: HTMLElement;
|
|
88
|
+
/**
|
|
89
|
+
* 更新操作列表。
|
|
90
|
+
* Update available actions.
|
|
91
|
+
* @param items 操作列表 / Actions.
|
|
92
|
+
* @param disabled 是否忙碌 / Busy state.
|
|
93
|
+
* @returns 无返回值 / No return value.
|
|
94
|
+
*/
|
|
95
|
+
update(items: MenuAction[], disabled?: boolean): void;
|
|
96
|
+
/**
|
|
97
|
+
* 关闭菜单。
|
|
98
|
+
* Close the menu.
|
|
99
|
+
* @returns 无返回值 / No return value.
|
|
100
|
+
*/
|
|
101
|
+
close(): void;
|
|
102
|
+
/**
|
|
103
|
+
* 释放组件。
|
|
104
|
+
* Release the component.
|
|
105
|
+
* @returns 无返回值 / No return value.
|
|
106
|
+
*/
|
|
107
|
+
destroy(): void;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* HEAD 探测的固定模块状态行。
|
|
112
|
+
* Fixed module status row backed by HEAD probes.
|
|
113
|
+
*/
|
|
114
|
+
export class ModuleStatus extends EventTarget {
|
|
115
|
+
/**
|
|
116
|
+
* 绑定状态行。
|
|
117
|
+
* Bind a status row.
|
|
118
|
+
* @param element 状态行节点 / Status row node.
|
|
119
|
+
*/
|
|
120
|
+
constructor(element: HTMLElement);
|
|
121
|
+
/**
|
|
122
|
+
* 当前状态及模块版本。
|
|
123
|
+
* Current state and module version.
|
|
124
|
+
*/
|
|
125
|
+
readonly state: { status: "checking" | "installed" | "missing"; version: string | null };
|
|
126
|
+
/**
|
|
127
|
+
* 探测配置 Mock。
|
|
128
|
+
* Probe a configuration Mock.
|
|
129
|
+
* @param url 配置地址 / Configuration URL.
|
|
130
|
+
* @returns 探测完成 / Probe completion.
|
|
131
|
+
*/
|
|
132
|
+
check(url: string | URL): Promise<void>;
|
|
133
|
+
/**
|
|
134
|
+
* 取消探测。
|
|
135
|
+
* Cancel probes.
|
|
136
|
+
* @returns 无返回值 / No return value.
|
|
137
|
+
*/
|
|
138
|
+
destroy(): void;
|
|
139
|
+
}
|
|
140
|
+
|
|
43
141
|
/**
|
|
44
142
|
* 同一文档的根页/子页导航,不定义页面布局或模块业务。
|
|
45
143
|
* Home/detail navigation within a document, without layout or module business rules.
|
package/src/browser/client.d.mts
CHANGED
|
@@ -111,8 +111,8 @@ export interface PreferencesClient {
|
|
|
111
111
|
*/
|
|
112
112
|
set(module: string, key: string, value: SettingsScalar | SettingsScalar[]): Promise<void>;
|
|
113
113
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
114
|
+
* POST /api/delete 删除覆盖值,200 后显示默认值,不追加读取。
|
|
115
|
+
* POST /api/delete removes an override and displays its default after 200, without rereading.
|
|
116
116
|
* @param module 已打开的模块 / Open module.
|
|
117
117
|
* @param key 完整点分字段路径 / Complete dotted field path.
|
|
118
118
|
* @returns 操作完成 / Completion of the operation.
|
package/src/browser/client.mjs
CHANGED
|
@@ -24,31 +24,31 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
24
24
|
*/
|
|
25
25
|
const sessions = new Map();
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
* Send a
|
|
29
|
-
* @param {string} path
|
|
30
|
-
* @param {"
|
|
31
|
-
* @param {unknown} body
|
|
27
|
+
* 用 form 发送完整存储键;读取 404 交给调用方处理。
|
|
28
|
+
* Send a complete storage key as form data; callers handle missing reads.
|
|
29
|
+
* @param {string} path 完整 @root.path / Complete @root.path.
|
|
30
|
+
* @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
|
|
31
|
+
* @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
|
|
32
32
|
* @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
|
|
33
33
|
* @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
|
|
34
34
|
* @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
|
|
35
35
|
*/
|
|
36
|
-
async function send(path,
|
|
36
|
+
async function send(path, action, body, signal) {
|
|
37
37
|
const controller = new AbortController();
|
|
38
38
|
const abort = () => controller.abort();
|
|
39
39
|
if (signal?.aborted) abort();
|
|
40
40
|
signal?.addEventListener("abort", abort, { once: true });
|
|
41
41
|
const timer = setTimeout(abort, timeout);
|
|
42
42
|
try {
|
|
43
|
-
const response = await request(
|
|
44
|
-
method,
|
|
43
|
+
const response = await request(`/api/${action}`, {
|
|
44
|
+
method: "POST",
|
|
45
45
|
credentials: "omit",
|
|
46
46
|
cache: "no-store",
|
|
47
47
|
signal: controller.signal,
|
|
48
|
-
headers: { "
|
|
49
|
-
|
|
48
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
49
|
+
body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
|
|
50
50
|
});
|
|
51
|
-
if (response.status !== 200 && !(
|
|
51
|
+
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
52
52
|
return response;
|
|
53
53
|
} finally {
|
|
54
54
|
clearTimeout(timer);
|
|
@@ -72,21 +72,21 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
72
72
|
* Serialize single-key mutations and update a still-active session only after success.
|
|
73
73
|
* @param {string} module 已打开模块 / Open module.
|
|
74
74
|
* @param {string} key 完整点分字段路径 / Complete dotted field path.
|
|
75
|
-
* @param {"
|
|
75
|
+
* @param {"set" | "delete"} action 写入或删除 / Write or delete.
|
|
76
76
|
* @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
|
|
77
77
|
* @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
|
|
78
78
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
79
79
|
* @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
|
|
80
80
|
*/
|
|
81
|
-
async function change(module, key,
|
|
81
|
+
async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
|
|
82
82
|
const state = sessions.get(module);
|
|
83
83
|
if (!state?.definition) throw new Error("Open the module first");
|
|
84
84
|
if (state.saving) throw new Error("A settings write is already in progress");
|
|
85
85
|
const field = state.definition.fields.find(field => field.key === key);
|
|
86
86
|
state.saving = true;
|
|
87
87
|
try {
|
|
88
|
-
if ((operation === "write" || operation === "delete") && (!field || (
|
|
89
|
-
await send(
|
|
88
|
+
if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
|
|
89
|
+
await send(`@${state.definition.storageKey}.${key}`, action, value);
|
|
90
90
|
if (sessions.get(module) === state) {
|
|
91
91
|
switch (operation) {
|
|
92
92
|
case "write":
|
|
@@ -129,7 +129,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
129
129
|
sessions.set(module, state);
|
|
130
130
|
try {
|
|
131
131
|
const definition = normalizeBoxJs(catalog.select(module), module);
|
|
132
|
-
const response = await send(
|
|
132
|
+
const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
|
|
133
133
|
let subtree = response.status === 404 ? {} : await response.json();
|
|
134
134
|
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
135
135
|
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
@@ -159,7 +159,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
159
159
|
async readCaches(module) {
|
|
160
160
|
const state = sessions.get(module);
|
|
161
161
|
if (!state?.definition) throw new Error("Open the module first");
|
|
162
|
-
const response = await send(
|
|
162
|
+
const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
|
|
163
163
|
return response.status === 404 ? undefined : response.json();
|
|
164
164
|
},
|
|
165
165
|
/**
|
|
@@ -168,14 +168,14 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
168
168
|
* @param {string} module 已打开模块 / Open module.
|
|
169
169
|
* @returns {Promise<void>} 清理完成 / Cleanup completion.
|
|
170
170
|
*/
|
|
171
|
-
clearCaches: module => change(module, `${module}.Caches`, "
|
|
171
|
+
clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
|
|
172
172
|
/**
|
|
173
173
|
* 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
|
|
174
174
|
* Delete module persistence and reset the page cache using current BoxJS defaults.
|
|
175
175
|
* @param {string} module 已打开模块 / Open module.
|
|
176
176
|
* @returns {Promise<void>} 重置完成 / Reset completion.
|
|
177
177
|
*/
|
|
178
|
-
reset: module => change(module, module, "
|
|
178
|
+
reset: module => change(module, module, "delete", undefined, "reset"),
|
|
179
179
|
/**
|
|
180
180
|
* 取消读取并清除会话,不撤销已发送的写入。
|
|
181
181
|
* Abort reads and clear the session without undoing dispatched writes.
|
|
@@ -194,7 +194,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
194
194
|
* @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
|
|
195
195
|
* @returns {Promise<void>} 写入完成 / Write completion.
|
|
196
196
|
*/
|
|
197
|
-
set: (module, key, value) => change(module, key, "
|
|
197
|
+
set: (module, key, value) => change(module, key, "set", value),
|
|
198
198
|
/**
|
|
199
199
|
* 删除单键覆盖值并显示默认值。
|
|
200
200
|
* Delete one override and display its default value.
|
|
@@ -202,6 +202,6 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
202
202
|
* @param {string} key 点分字段路径 / Dotted field path.
|
|
203
203
|
* @returns {Promise<void>} 删除完成 / Delete completion.
|
|
204
204
|
*/
|
|
205
|
-
remove: (module, key) => change(module, key, "
|
|
205
|
+
remove: (module, key) => change(module, key, "delete"),
|
|
206
206
|
};
|
|
207
207
|
}
|