@nsnanocat/preference-panes 0.9.16 → 1.1.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 +24 -16
- package/dist/api.js +1454 -967
- package/dist/module/index.html +1 -1
- package/dist/module/index.mjs +1519 -0
- package/dist/module/navigation.mjs +7 -6
- package/dist/preference-panes.mjs +861 -849
- package/dist/web.js +1202 -0
- package/package.json +1 -1
- package/src/api.mjs +191 -0
- package/src/browser/ModuleStatus.mjs +7 -6
- package/src/browser/Navigation.d.mts +8 -6
- package/src/{lib → browser}/boxjs.mjs +72 -16
- package/src/browser/client.d.mts +43 -133
- package/src/browser/client.mjs +146 -186
- package/src/browser/index.d.ts +21 -10
- package/src/browser/index.mjs +95 -72
- package/src/browser/module.html +1 -1
- package/src/browser/mount.mjs +119 -0
- package/src/browser/panel.mjs +470 -441
- package/src/build.mjs +4 -5
- package/src/index.d.ts +16 -2
- package/src/web.mjs +52 -0
- package/dist/module/app.mjs +0 -1461
- package/src/BoxJS.mjs +0 -72
- package/src/Store.mjs +0 -114
- package/src/browser/app.mjs +0 -51
- package/src/lib/response.mjs +0 -16
- package/src/proxy/handler.mjs +0 -39
- package/src/proxy/response.mjs +0 -16
package/src/browser/panel.mjs
CHANGED
|
@@ -1,489 +1,518 @@
|
|
|
1
1
|
import { ActionMenu } from "./ActionMenu.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { validValue } from "./boxjs.mjs";
|
|
3
|
+
import { PreferencesClient } from "./client.mjs";
|
|
3
4
|
import { fieldControl, element as node, requestConfirmation, resourceURL, settingRow, statusView } from "./components.mjs";
|
|
4
5
|
import { Navigation } from "./Navigation.mjs";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
10
|
-
* @param {import("../BoxJS.mjs").BoxJS} catalog 包内 BoxJS 目录 / Internal BoxJS catalog.
|
|
11
|
-
* @returns {import("./index.js").MountedPreferences} 面板生命周期句柄 / Panel lifecycle handle.
|
|
8
|
+
* 管理模块表单、导航、操作队列和短暂通知。
|
|
9
|
+
* Manage the module form, navigation, operation queue, and transient notifications.
|
|
12
10
|
*/
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const window = document.defaultView;
|
|
17
|
-
const shell = node("div", "pp-panel");
|
|
18
|
-
shell.dataset.module = catalog.module.module;
|
|
19
|
-
const header = node("header", "pp-header");
|
|
20
|
-
const back = node("button", "pp-back", "‹");
|
|
21
|
-
back.setAttribute("aria-label", "返回");
|
|
22
|
-
back.type = "button";
|
|
23
|
-
const heading = node("h1", "pp-title", title);
|
|
24
|
-
const handlers = new Map();
|
|
25
|
-
const menuItems = [
|
|
26
|
-
{ id: "viewSettings", label: "查看设置" },
|
|
27
|
-
{ id: "viewCaches", label: "查看缓存" },
|
|
28
|
-
{ id: "clearCaches", label: "清空缓存", destructive: true },
|
|
29
|
-
{ id: "reset", label: "重置设置", destructive: true },
|
|
30
|
-
];
|
|
31
|
-
const menu = new ActionMenu(id => runAction(id));
|
|
32
|
-
const trailing = node("span", "pp-nav-spacer");
|
|
33
|
-
trailing.append(menu.element);
|
|
34
|
-
const viewport = node("div", "pp-viewport");
|
|
35
|
-
let toast;
|
|
36
|
-
header.append(back, heading, trailing);
|
|
37
|
-
shell.append(header, viewport);
|
|
38
|
-
root.append(shell);
|
|
39
|
-
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
40
|
-
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
41
|
-
const publishNavigation = () => {
|
|
42
|
-
const actions = handlers.size ? menuItems : [];
|
|
43
|
-
menu.update(actions, saving);
|
|
44
|
-
const frame = window.frameElement;
|
|
45
|
-
if (!frame?.dataset.preferencePanes) return;
|
|
46
|
-
frame.dispatchEvent(
|
|
47
|
-
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
48
|
-
detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled, actions },
|
|
49
|
-
}),
|
|
50
|
-
);
|
|
51
|
-
};
|
|
52
|
-
const onAction = event => {
|
|
53
|
-
if (!saving && handlers.has(event.detail)) runAction(event.detail);
|
|
54
|
-
};
|
|
55
|
-
window.frameElement?.addEventListener("preferencepanes:action", onAction);
|
|
56
|
-
let timer,
|
|
57
|
-
navigation,
|
|
58
|
-
generation = 0,
|
|
59
|
-
active = null,
|
|
60
|
-
saving = false,
|
|
61
|
-
destroyed = false;
|
|
62
|
-
/**
|
|
63
|
-
* 展示短暂通知,不刷新设置数据。
|
|
64
|
-
* Display a transient notification without refreshing settings.
|
|
65
|
-
* @param {{kind: "success" | "error", operation?: "write" | "delete" | "clearCaches" | "reset", message?: string}} event 操作结果 / Operation result.
|
|
66
|
-
* @returns {void} 无返回值 / No return value.
|
|
67
|
-
*/
|
|
68
|
-
const notify = event => {
|
|
69
|
-
if (destroyed) return;
|
|
70
|
-
let message;
|
|
71
|
-
switch (true) {
|
|
72
|
-
case event.kind === "error":
|
|
73
|
-
message = `操作失败:${event.message}`;
|
|
74
|
-
break;
|
|
75
|
-
case event.operation === "delete":
|
|
76
|
-
message = "删除成功";
|
|
77
|
-
break;
|
|
78
|
-
case event.operation === "clearCaches":
|
|
79
|
-
message = "Caches 已清空";
|
|
80
|
-
break;
|
|
81
|
-
case event.operation === "reset":
|
|
82
|
-
message = "设置已重置";
|
|
83
|
-
break;
|
|
84
|
-
default:
|
|
85
|
-
message = "修改成功";
|
|
86
|
-
break;
|
|
87
|
-
}
|
|
88
|
-
// 宿主接管时不创建网页 Toast,也不运行其计时器。
|
|
89
|
-
// A host-owned notice creates no web Toast and starts no local timer.
|
|
90
|
-
const frame = window.frameElement;
|
|
91
|
-
if (frame && !frame.dispatchEvent(new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:notice", { cancelable: true, detail: { kind: event.kind, message } }))) return;
|
|
92
|
-
if (!toast) {
|
|
93
|
-
toast = node("div", "pp-toast");
|
|
94
|
-
toast.setAttribute("role", "status");
|
|
95
|
-
shell.append(toast);
|
|
96
|
-
}
|
|
97
|
-
toast.textContent = message;
|
|
98
|
-
toast.dataset.kind = event.kind;
|
|
99
|
-
toast.hidden = false;
|
|
100
|
-
clearTimeout(timer);
|
|
101
|
-
timer = setTimeout(() => {
|
|
102
|
-
toast.hidden = true;
|
|
103
|
-
}, 2400);
|
|
104
|
-
};
|
|
105
|
-
const client = createPreferencesClient({ catalog, notify });
|
|
106
|
-
/**
|
|
107
|
-
* 两种菜单入口共用异步错误处理,包含宿主确认框错误。
|
|
108
|
-
* Share async error handling between both menus, including host-dialog errors.
|
|
109
|
-
* @param {string} id 操作标识 / Action identifier.
|
|
110
|
-
* @returns {Promise<void>} 操作已处理 / Action handled.
|
|
111
|
-
*/
|
|
112
|
-
async function runAction(id) {
|
|
113
|
-
try {
|
|
114
|
-
await handlers.get(id)();
|
|
115
|
-
} catch (error) {
|
|
116
|
-
notify({ kind: "error", message: error.message });
|
|
117
|
-
}
|
|
118
|
-
}
|
|
11
|
+
export class PreferencesPanel {
|
|
12
|
+
#release;
|
|
13
|
+
|
|
119
14
|
/**
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* @param {
|
|
123
|
-
* @
|
|
15
|
+
* 挂载 API 返回的模块模型表单。
|
|
16
|
+
* Mount the module form returned by the API.
|
|
17
|
+
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
18
|
+
* @param {import("../index.js").ModuleModel & {definition: import("../index.js").ModuleDefinition}} model 已规范化模块模型 / Normalized module model.
|
|
124
19
|
*/
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
active = module;
|
|
128
|
-
back.disabled = window.history.length <= 1;
|
|
129
|
-
heading.textContent = module;
|
|
130
|
-
publishNavigation();
|
|
131
|
-
viewport.replaceChildren(statusView("读取设置…"));
|
|
132
|
-
try {
|
|
133
|
-
await client.open(module);
|
|
134
|
-
if (version === generation) controls();
|
|
135
|
-
} catch (error) {
|
|
136
|
-
if (version !== generation) return;
|
|
137
|
-
viewport.replaceChildren(statusView(`加载失败:${error.message}`, () => open(module)));
|
|
138
|
-
publishNavigation();
|
|
139
|
-
}
|
|
20
|
+
constructor(root, model) {
|
|
21
|
+
this.#release = this.#mount(root, model);
|
|
140
22
|
}
|
|
23
|
+
|
|
141
24
|
/**
|
|
142
|
-
*
|
|
143
|
-
* Build
|
|
144
|
-
* @
|
|
25
|
+
* 建立面板 DOM、交互和会话,并返回其释放操作。
|
|
26
|
+
* Build panel DOM, interactions, and session, then return its release operation.
|
|
27
|
+
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
28
|
+
* @param {import("../index.js").ModuleModel & {definition: import("../index.js").ModuleDefinition}} model 已规范化模块模型 / Normalized module model.
|
|
29
|
+
* @returns {() => void} 释放操作 / Release operation.
|
|
145
30
|
*/
|
|
146
|
-
|
|
147
|
-
const { definition
|
|
148
|
-
|
|
149
|
-
const
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
const
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
31
|
+
#mount(root, model) {
|
|
32
|
+
const { definition } = model;
|
|
33
|
+
const title = definition.metadata?.name ?? definition.module;
|
|
34
|
+
const document = root.ownerDocument;
|
|
35
|
+
const window = document.defaultView;
|
|
36
|
+
const shell = node("div", "pp-panel");
|
|
37
|
+
shell.dataset.module = definition.module;
|
|
38
|
+
const header = node("header", "pp-header");
|
|
39
|
+
const back = node("button", "pp-back", "‹");
|
|
40
|
+
back.setAttribute("aria-label", "返回");
|
|
41
|
+
back.type = "button";
|
|
42
|
+
const heading = node("h1", "pp-title", title);
|
|
43
|
+
const handlers = new Map();
|
|
44
|
+
const menuItems = [
|
|
45
|
+
{ id: "viewSettings", label: "查看设置" },
|
|
46
|
+
{ id: "viewCaches", label: "查看缓存" },
|
|
47
|
+
{ id: "clearCaches", label: "清空缓存", destructive: true },
|
|
48
|
+
{ id: "reset", label: "重置设置", destructive: true },
|
|
49
|
+
];
|
|
50
|
+
const menu = new ActionMenu(id => runAction(id));
|
|
51
|
+
const trailing = node("span", "pp-nav-spacer");
|
|
52
|
+
trailing.append(menu.element);
|
|
53
|
+
const viewport = node("div", "pp-viewport");
|
|
54
|
+
let toast;
|
|
55
|
+
header.append(back, heading, trailing);
|
|
56
|
+
shell.append(header, viewport);
|
|
57
|
+
root.append(shell);
|
|
58
|
+
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
59
|
+
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
60
|
+
const publishNavigation = () => {
|
|
61
|
+
const actions = handlers.size ? menuItems : [];
|
|
62
|
+
menu.update(actions, saving);
|
|
63
|
+
const frame = window.frameElement;
|
|
64
|
+
if (!frame?.dataset.preferencePanes) return;
|
|
65
|
+
frame.dispatchEvent(
|
|
66
|
+
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
67
|
+
detail: { title: heading.textContent, module: definition.module, busy: saving, canGoBack: !back.disabled, actions },
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
};
|
|
71
|
+
const onAction = event => {
|
|
72
|
+
if (!saving && handlers.has(event.detail)) runAction(event.detail);
|
|
73
|
+
};
|
|
74
|
+
window.frameElement?.addEventListener("preferencepanes:action", onAction);
|
|
75
|
+
let timer,
|
|
76
|
+
navigation,
|
|
77
|
+
generation = 0,
|
|
78
|
+
active = null,
|
|
79
|
+
saving = false,
|
|
80
|
+
destroyed = false;
|
|
161
81
|
/**
|
|
162
|
-
*
|
|
163
|
-
*
|
|
82
|
+
* 展示短暂通知,不刷新设置数据。
|
|
83
|
+
* Display a transient notification without refreshing settings.
|
|
84
|
+
* @param {{kind: "success" | "error", operation?: "write" | "delete" | "clearCaches" | "reset", message?: string}} event 操作结果 / Operation result.
|
|
164
85
|
* @returns {void} 无返回值 / No return value.
|
|
165
86
|
*/
|
|
166
|
-
const
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
87
|
+
const notify = event => {
|
|
88
|
+
if (destroyed) return;
|
|
89
|
+
let message;
|
|
90
|
+
switch (true) {
|
|
91
|
+
case event.kind === "error":
|
|
92
|
+
message = `操作失败:${event.message}`;
|
|
93
|
+
break;
|
|
94
|
+
case event.operation === "delete":
|
|
95
|
+
message = "删除成功";
|
|
96
|
+
break;
|
|
97
|
+
case event.operation === "clearCaches":
|
|
98
|
+
message = "Caches 已清空";
|
|
99
|
+
break;
|
|
100
|
+
case event.operation === "reset":
|
|
101
|
+
message = "设置已重置";
|
|
102
|
+
break;
|
|
103
|
+
default:
|
|
104
|
+
message = "修改成功";
|
|
105
|
+
break;
|
|
106
|
+
}
|
|
107
|
+
// 宿主接管时不创建网页 Toast,也不运行其计时器。
|
|
108
|
+
// A host-owned notice creates no web Toast and starts no local timer.
|
|
109
|
+
const frame = window.frameElement;
|
|
110
|
+
if (frame && !frame.dispatchEvent(new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:notice", { cancelable: true, detail: { kind: event.kind, message } }))) return;
|
|
111
|
+
if (!toast) {
|
|
112
|
+
toast = node("div", "pp-toast");
|
|
113
|
+
toast.setAttribute("role", "status");
|
|
114
|
+
shell.append(toast);
|
|
115
|
+
}
|
|
116
|
+
toast.textContent = message;
|
|
117
|
+
toast.dataset.kind = event.kind;
|
|
118
|
+
toast.hidden = false;
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
timer = setTimeout(() => {
|
|
121
|
+
toast.hidden = true;
|
|
122
|
+
}, 2400);
|
|
171
123
|
};
|
|
124
|
+
const client = new PreferencesClient({ model, definition, notify });
|
|
172
125
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
* @param {
|
|
176
|
-
* @
|
|
177
|
-
* @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.
|
|
178
|
-
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
126
|
+
* 两种菜单入口共用异步错误处理,包含宿主确认框错误。
|
|
127
|
+
* Share async error handling between both menus, including host-dialog errors.
|
|
128
|
+
* @param {string} id 操作标识 / Action identifier.
|
|
129
|
+
* @returns {Promise<void>} 操作已处理 / Action handled.
|
|
179
130
|
*/
|
|
180
|
-
function
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return (queue = queue
|
|
186
|
-
.then(action)
|
|
187
|
-
.then(() => {
|
|
188
|
-
if (!destroyed) success();
|
|
189
|
-
})
|
|
190
|
-
.catch(() => {
|
|
191
|
-
/* 请求层已通知错误。
|
|
192
|
-
* The request layer has already reported the error. */
|
|
193
|
-
if (!destroyed) failure();
|
|
194
|
-
})
|
|
195
|
-
.finally(() => {
|
|
196
|
-
pendingWrites--;
|
|
197
|
-
saving = pendingWrites > 0;
|
|
198
|
-
if (destroyed && !saving) client.leave(active);
|
|
199
|
-
back.disabled = saving || !navigation.canGoBack;
|
|
200
|
-
publishNavigation();
|
|
201
|
-
}));
|
|
202
|
-
}
|
|
203
|
-
const metadata = definition.metadata;
|
|
204
|
-
if (metadata) {
|
|
205
|
-
const info = node("div", "pp-module-info");
|
|
206
|
-
const details = node("div", "pp-module-details");
|
|
207
|
-
for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(node("p", "pp-description", description));
|
|
208
|
-
if (metadata.repo) {
|
|
209
|
-
const link = node("a", "pp-module-source", "项目主页");
|
|
210
|
-
link.href = resourceURL(metadata.repo);
|
|
211
|
-
link.target = "_blank";
|
|
212
|
-
link.rel = "noopener noreferrer";
|
|
213
|
-
details.append(link);
|
|
131
|
+
async function runAction(id) {
|
|
132
|
+
try {
|
|
133
|
+
await handlers.get(id)();
|
|
134
|
+
} catch (error) {
|
|
135
|
+
notify({ kind: "error", message: error.message });
|
|
214
136
|
}
|
|
215
|
-
info.append(details);
|
|
216
|
-
view.append(info);
|
|
217
137
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
138
|
+
/**
|
|
139
|
+
* 打开模块并忽略已过期的异步结果。
|
|
140
|
+
* Open a module and ignore stale asynchronous results.
|
|
141
|
+
* @param {string} module 模块标识 / Module identifier.
|
|
142
|
+
* @returns {Promise<void>} 视图加载完成,失败显示错误视图 / View load completion; failures display an error view.
|
|
143
|
+
*/
|
|
144
|
+
async function open(module) {
|
|
145
|
+
const version = ++generation;
|
|
146
|
+
active = module;
|
|
147
|
+
back.disabled = window.history.length <= 1;
|
|
148
|
+
heading.textContent = module;
|
|
149
|
+
publishNavigation();
|
|
150
|
+
viewport.replaceChildren(statusView("读取设置…"));
|
|
151
|
+
try {
|
|
152
|
+
if (version === generation) controls();
|
|
153
|
+
} catch (error) {
|
|
154
|
+
if (version !== generation) return;
|
|
155
|
+
viewport.replaceChildren(statusView(`加载失败:${error.message}`, () => open(module)));
|
|
156
|
+
publishNavigation();
|
|
227
157
|
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* 从会话快照创建控件与操作按钮,不重新读取网络配置。
|
|
161
|
+
* Build controls and actions from the session snapshot without fetching config again.
|
|
162
|
+
* @returns {void} 无返回值 / No return value.
|
|
163
|
+
*/
|
|
164
|
+
function controls() {
|
|
165
|
+
const { definition, values } = client.snapshot();
|
|
166
|
+
heading.textContent = definition.metadata?.name || active;
|
|
167
|
+
const view = node("section", "pp-fields");
|
|
235
168
|
/**
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
* @type {() =>
|
|
169
|
+
* 挂载后执行的多行高度更新
|
|
170
|
+
* Textarea sizing callbacks run after mounting.
|
|
171
|
+
* @type {Array<() => void>}
|
|
239
172
|
*/
|
|
240
|
-
|
|
173
|
+
const growingInputs = [];
|
|
174
|
+
const editors = new Map();
|
|
175
|
+
const summaries = [];
|
|
176
|
+
const groups = new Map();
|
|
177
|
+
let queue = Promise.resolve(),
|
|
178
|
+
pendingWrites = 0;
|
|
241
179
|
/**
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
* @
|
|
180
|
+
* 导航组件处理页面切换,表单只更新当前标题与返回按钮。
|
|
181
|
+
* Let navigation own transitions; the form only updates the title and back button.
|
|
182
|
+
* @returns {void} 无返回值 / No return value.
|
|
245
183
|
*/
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
field.options
|
|
282
|
-
.filter(option => Array.isArray(value) && value.includes(option.key))
|
|
283
|
-
.map(option => option.label)
|
|
284
|
-
.join("、") || "未选择";
|
|
285
|
-
};
|
|
286
|
-
summaries.push(refresh);
|
|
287
|
-
refresh();
|
|
288
|
-
link.onclick = () => navigation.open(field.key);
|
|
289
|
-
row.addEventListener("click", event => {
|
|
290
|
-
if (!link.contains(event.target)) link.click();
|
|
291
|
-
});
|
|
292
|
-
const inputs = field.options.map(option => {
|
|
293
|
-
const label = settingRow("label");
|
|
294
|
-
label.classList.add("pp-choice");
|
|
295
|
-
label.textContent = option.label;
|
|
296
|
-
const input = node("input", "");
|
|
297
|
-
input.type = "checkbox";
|
|
298
|
-
input.setAttribute("aria-label", option.label);
|
|
299
|
-
label.append(input);
|
|
300
|
-
choices.append(label);
|
|
301
|
-
return { input, key: option.key };
|
|
184
|
+
const updateNavigation = () => {
|
|
185
|
+
const editor = editors.get(navigation.current);
|
|
186
|
+
heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
|
|
187
|
+
back.disabled = saving || !navigation.canGoBack;
|
|
188
|
+
publishNavigation();
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* 串行执行模块操作,保持输入可编辑。
|
|
192
|
+
* Serialize module actions while keeping inputs editable.
|
|
193
|
+
* @param {() => Promise<void>} action 请求或写入 / Request or mutation.
|
|
194
|
+
* @param {() => void} success 成功后的局部更新 / Local update after success.
|
|
195
|
+
* @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.
|
|
196
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
197
|
+
*/
|
|
198
|
+
function perform(action, success, failure = () => {}) {
|
|
199
|
+
pendingWrites++;
|
|
200
|
+
saving = true;
|
|
201
|
+
back.disabled = true;
|
|
202
|
+
publishNavigation();
|
|
203
|
+
queue = queue
|
|
204
|
+
.then(action)
|
|
205
|
+
.then(() => {
|
|
206
|
+
if (!destroyed) success();
|
|
207
|
+
})
|
|
208
|
+
.catch(() => {
|
|
209
|
+
/* 请求层已通知错误。
|
|
210
|
+
* The request layer has already reported the error. */
|
|
211
|
+
if (!destroyed) failure();
|
|
212
|
+
})
|
|
213
|
+
.finally(() => {
|
|
214
|
+
pendingWrites--;
|
|
215
|
+
saving = pendingWrites > 0;
|
|
216
|
+
if (destroyed && !saving) client.leave();
|
|
217
|
+
back.disabled = saving || !navigation.canGoBack;
|
|
218
|
+
publishNavigation();
|
|
302
219
|
});
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
220
|
+
return queue;
|
|
221
|
+
}
|
|
222
|
+
const metadata = definition.metadata;
|
|
223
|
+
if (metadata) {
|
|
224
|
+
const info = node("div", "pp-module-info");
|
|
225
|
+
const details = node("div", "pp-module-details");
|
|
226
|
+
for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(node("p", "pp-description", description));
|
|
227
|
+
if (metadata.repo) {
|
|
228
|
+
const link = node("a", "pp-module-source", "项目主页");
|
|
229
|
+
link.href = resourceURL(metadata.repo);
|
|
230
|
+
link.target = "_blank";
|
|
231
|
+
link.rel = "noopener noreferrer";
|
|
232
|
+
details.append(link);
|
|
308
233
|
}
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
234
|
+
info.append(details);
|
|
235
|
+
view.append(info);
|
|
236
|
+
}
|
|
237
|
+
for (const field of definition.fields) {
|
|
238
|
+
const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
|
|
239
|
+
const group = match?.[1] ?? "通用";
|
|
240
|
+
if (!groups.has(group)) {
|
|
241
|
+
const section = node("section", "pp-group");
|
|
242
|
+
const rows = node("div", "pp-rows");
|
|
243
|
+
section.append(node("h2", "pp-group-title", group), rows);
|
|
244
|
+
groups.set(group, rows);
|
|
245
|
+
view.append(section);
|
|
321
246
|
}
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
247
|
+
const row = settingRow("div");
|
|
248
|
+
row.classList.add("pp-field");
|
|
249
|
+
const label = node("div", "pp-label");
|
|
250
|
+
label.append(node("span", "pp-field-name", match?.[2] ?? field.name));
|
|
251
|
+
if (field.description) label.append(node("span", "pp-field-description", field.description));
|
|
252
|
+
row.append(label);
|
|
253
|
+
const value = values[field.key];
|
|
254
|
+
/**
|
|
255
|
+
* 读取尚未保存的输入
|
|
256
|
+
* Read the unsaved input.
|
|
257
|
+
* @type {() => unknown}
|
|
258
|
+
*/
|
|
259
|
+
let read;
|
|
260
|
+
/**
|
|
261
|
+
* 更新当前控件
|
|
262
|
+
* Update the current control.
|
|
263
|
+
* @type {(value: unknown) => void}
|
|
264
|
+
*/
|
|
265
|
+
let write;
|
|
266
|
+
let inputContainer = row;
|
|
267
|
+
let eventName = "change";
|
|
268
|
+
switch (true) {
|
|
269
|
+
case Boolean(field.options) && field.type !== "array": {
|
|
270
|
+
const select = node("select", "");
|
|
271
|
+
select.setAttribute("aria-label", field.name);
|
|
272
|
+
field.options.forEach((option, index) => {
|
|
273
|
+
const item = node("option", "", option.label);
|
|
274
|
+
item.value = String(index);
|
|
275
|
+
select.append(item);
|
|
276
|
+
});
|
|
277
|
+
write = value => {
|
|
278
|
+
select.selectedIndex = field.options.findIndex(option => option.key === value);
|
|
279
|
+
};
|
|
280
|
+
row.append(fieldControl(select));
|
|
281
|
+
read = () => field.options[select.selectedIndex]?.key;
|
|
282
|
+
break;
|
|
345
283
|
}
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
284
|
+
case field.type === "array" && Boolean(field.options): {
|
|
285
|
+
const page = node("section", "pp-choice-page");
|
|
286
|
+
if (field.description) page.append(node("p", "pp-description", field.description));
|
|
287
|
+
const choices = node("div", "pp-rows");
|
|
288
|
+
page.append(choices);
|
|
289
|
+
inputContainer = choices;
|
|
290
|
+
editors.set(field.key, { node: page, title: match?.[2] ?? field.name });
|
|
291
|
+
const summary = node("span", "pp-summary");
|
|
292
|
+
const link = node("button", "pp-choice-link");
|
|
293
|
+
link.type = "button";
|
|
294
|
+
link.setAttribute("aria-label", field.name);
|
|
295
|
+
link.append(summary, node("span", "pp-chevron", "›"));
|
|
296
|
+
row.append(link);
|
|
297
|
+
const refresh = () => {
|
|
298
|
+
const value = client.snapshot().values[field.key];
|
|
299
|
+
summary.textContent =
|
|
300
|
+
field.options
|
|
301
|
+
.filter(option => Array.isArray(value) && value.includes(option.key))
|
|
302
|
+
.map(option => option.label)
|
|
303
|
+
.join("、") || "未选择";
|
|
304
|
+
};
|
|
305
|
+
summaries.push(refresh);
|
|
306
|
+
refresh();
|
|
307
|
+
link.onclick = () => navigation.open(field.key);
|
|
308
|
+
row.addEventListener("click", event => {
|
|
309
|
+
if (!link.contains(event.target)) link.click();
|
|
310
|
+
});
|
|
311
|
+
const inputs = field.options.map(option => {
|
|
312
|
+
const label = settingRow("label");
|
|
313
|
+
label.classList.add("pp-choice");
|
|
314
|
+
label.textContent = option.label;
|
|
315
|
+
const input = node("input", "");
|
|
316
|
+
input.type = "checkbox";
|
|
317
|
+
input.setAttribute("aria-label", option.label);
|
|
318
|
+
label.append(input);
|
|
319
|
+
choices.append(label);
|
|
320
|
+
return { input, key: option.key };
|
|
321
|
+
});
|
|
322
|
+
read = () => inputs.filter(option => option.input.checked).map(option => option.key);
|
|
323
|
+
write = value => {
|
|
324
|
+
for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);
|
|
325
|
+
};
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
case field.type === "boolean": {
|
|
329
|
+
const toggle = node("input", "pp-switch");
|
|
330
|
+
toggle.type = "checkbox";
|
|
331
|
+
toggle.setAttribute("switch", "");
|
|
332
|
+
toggle.setAttribute("role", "switch");
|
|
333
|
+
toggle.setAttribute("aria-label", field.name);
|
|
334
|
+
write = value => {
|
|
335
|
+
toggle.checked = value === true;
|
|
336
|
+
};
|
|
337
|
+
read = () => toggle.checked;
|
|
338
|
+
row.append(toggle);
|
|
339
|
+
break;
|
|
340
|
+
}
|
|
341
|
+
default: {
|
|
342
|
+
const multiline = field.control === "textarea" || field.type === "array";
|
|
343
|
+
const input = node(multiline ? "textarea" : "input", "");
|
|
344
|
+
if (multiline) row.classList.add("pp-multiline");
|
|
345
|
+
input.setAttribute("aria-label", field.name);
|
|
346
|
+
if (field.placeholder) input.placeholder = field.placeholder;
|
|
347
|
+
if (multiline && field.rows) input.rows = field.rows;
|
|
348
|
+
/**
|
|
349
|
+
* 在挂载后根据内容调整高度,同时保留基础行数。
|
|
350
|
+
* Size mounted textareas to their contents while retaining baseline rows.
|
|
351
|
+
* @returns {void} 无返回值 / No return value.
|
|
352
|
+
*/
|
|
353
|
+
const grow = () => {
|
|
354
|
+
if (!multiline || !field.autoGrow || !input.isConnected) return;
|
|
355
|
+
input.style.height = "auto";
|
|
356
|
+
const baseline = input.getBoundingClientRect().height;
|
|
357
|
+
const style = window.getComputedStyle(input);
|
|
358
|
+
const borders = Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
|
|
359
|
+
input.style.height = `${Math.max(baseline, input.scrollHeight + borders)}px`;
|
|
360
|
+
};
|
|
361
|
+
if (multiline && field.autoGrow) {
|
|
362
|
+
input.addEventListener("input", grow);
|
|
363
|
+
growingInputs.push(grow);
|
|
360
364
|
}
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
365
|
+
eventName = "input";
|
|
366
|
+
if (!multiline) input.type = field.type === "number" ? "number" : "text";
|
|
367
|
+
write = value => {
|
|
368
|
+
input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
|
|
369
|
+
grow();
|
|
370
|
+
};
|
|
371
|
+
read = () => {
|
|
372
|
+
switch (field.type) {
|
|
373
|
+
case "array":
|
|
374
|
+
return JSON.parse(input.value);
|
|
375
|
+
case "number":
|
|
376
|
+
return input.value === "" ? Number.NaN : Number(input.value);
|
|
377
|
+
default:
|
|
378
|
+
return input.value;
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
row.append(fieldControl(input));
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
364
384
|
}
|
|
385
|
+
write(value);
|
|
386
|
+
let inputVersion = 0;
|
|
387
|
+
inputContainer.addEventListener(eventName, event => {
|
|
388
|
+
if (event.isComposing) return;
|
|
389
|
+
const version = ++inputVersion,
|
|
390
|
+
module = active;
|
|
391
|
+
let value;
|
|
392
|
+
try {
|
|
393
|
+
value = read();
|
|
394
|
+
} catch (error) {
|
|
395
|
+
notify({ kind: "error", message: error.message });
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
const restore = () => {
|
|
399
|
+
if (version === inputVersion) write(client.snapshot().values[field.key]);
|
|
400
|
+
};
|
|
401
|
+
perform(
|
|
402
|
+
() => {
|
|
403
|
+
if (!validValue(field, value)) {
|
|
404
|
+
const error = new TypeError("Invalid setting value");
|
|
405
|
+
notify({ kind: "error", operation: "write", module, key: field.key, message: error.message });
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
return client.set(field.key, value);
|
|
409
|
+
},
|
|
410
|
+
() => {
|
|
411
|
+
for (const refresh of summaries) refresh();
|
|
412
|
+
},
|
|
413
|
+
restore,
|
|
414
|
+
);
|
|
415
|
+
});
|
|
416
|
+
if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
|
|
417
|
+
groups.get(group).append(row);
|
|
365
418
|
}
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
419
|
+
const settingsPage = node("section", "pp-settings-page");
|
|
420
|
+
const settingsOutput = node("pre", "pp-cache");
|
|
421
|
+
settingsOutput.setAttribute("aria-label", "Settings 内容");
|
|
422
|
+
settingsPage.append(settingsOutput);
|
|
423
|
+
editors.set("$settings", { node: settingsPage, title: "设置" });
|
|
424
|
+
handlers.set("viewSettings", () => {
|
|
425
|
+
if (saving) return;
|
|
372
426
|
let value;
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
perform(
|
|
383
|
-
() => client.set(module, field.key, value),
|
|
427
|
+
return perform(
|
|
428
|
+
async () => {
|
|
429
|
+
try {
|
|
430
|
+
value = await client.readSettings();
|
|
431
|
+
} catch (error) {
|
|
432
|
+
notify({ kind: "error", message: error.message });
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
435
|
+
},
|
|
384
436
|
() => {
|
|
385
|
-
|
|
437
|
+
settingsOutput.textContent = value === undefined ? "暂无设置" : JSON.stringify(value, null, 2);
|
|
438
|
+
navigation.open("$settings");
|
|
386
439
|
},
|
|
387
|
-
restore,
|
|
388
440
|
);
|
|
389
441
|
});
|
|
390
|
-
|
|
391
|
-
|
|
442
|
+
const cachePage = node("section", "pp-cache-page");
|
|
443
|
+
const output = node("pre", "pp-cache");
|
|
444
|
+
output.textContent = "暂无缓存";
|
|
445
|
+
output.setAttribute("aria-label", "Caches 内容");
|
|
446
|
+
cachePage.append(output);
|
|
447
|
+
editors.set("$caches", { node: cachePage, title: "缓存" });
|
|
448
|
+
handlers.set("viewCaches", () => {
|
|
449
|
+
if (saving) return;
|
|
450
|
+
let value;
|
|
451
|
+
return perform(
|
|
452
|
+
async () => {
|
|
453
|
+
try {
|
|
454
|
+
value = await client.readCaches();
|
|
455
|
+
} catch (error) {
|
|
456
|
+
notify({ kind: "error", message: error.message });
|
|
457
|
+
throw error;
|
|
458
|
+
}
|
|
459
|
+
},
|
|
460
|
+
() => {
|
|
461
|
+
output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
|
|
462
|
+
navigation.open("$caches");
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
});
|
|
466
|
+
handlers.set("clearCaches", async () => {
|
|
467
|
+
if (saving) return;
|
|
468
|
+
if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;
|
|
469
|
+
return perform(
|
|
470
|
+
() => client.clearCaches(),
|
|
471
|
+
() => {
|
|
472
|
+
output.textContent = "暂无缓存";
|
|
473
|
+
},
|
|
474
|
+
);
|
|
475
|
+
});
|
|
476
|
+
handlers.set("reset", async () => {
|
|
477
|
+
if (saving) return;
|
|
478
|
+
if (!(await requestConfirmation(window, `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;
|
|
479
|
+
return perform(() => client.reset(), controls);
|
|
480
|
+
});
|
|
481
|
+
navigation?.destroy();
|
|
482
|
+
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
483
|
+
navigation.addEventListener("change", updateNavigation);
|
|
484
|
+
for (const grow of growingInputs) grow();
|
|
485
|
+
updateNavigation();
|
|
392
486
|
}
|
|
393
|
-
const settingsPage = node("section", "pp-settings-page");
|
|
394
|
-
const settingsOutput = node("pre", "pp-cache");
|
|
395
|
-
settingsOutput.setAttribute("aria-label", "Settings 内容");
|
|
396
|
-
settingsPage.append(settingsOutput);
|
|
397
|
-
editors.set("$settings", { node: settingsPage, title: "设置" });
|
|
398
|
-
handlers.set("viewSettings", () => {
|
|
399
|
-
if (saving) return;
|
|
400
|
-
let value;
|
|
401
|
-
return perform(
|
|
402
|
-
async () => {
|
|
403
|
-
try {
|
|
404
|
-
value = await client.readSettings(active);
|
|
405
|
-
} catch (error) {
|
|
406
|
-
notify({ kind: "error", message: error.message });
|
|
407
|
-
throw error;
|
|
408
|
-
}
|
|
409
|
-
},
|
|
410
|
-
() => {
|
|
411
|
-
settingsOutput.textContent = value === undefined ? "暂无设置" : JSON.stringify(value, null, 2);
|
|
412
|
-
navigation.open("$settings");
|
|
413
|
-
},
|
|
414
|
-
);
|
|
415
|
-
});
|
|
416
|
-
const cachePage = node("section", "pp-cache-page");
|
|
417
|
-
const output = node("pre", "pp-cache");
|
|
418
|
-
output.textContent = "暂无缓存";
|
|
419
|
-
output.setAttribute("aria-label", "Caches 内容");
|
|
420
|
-
cachePage.append(output);
|
|
421
|
-
editors.set("$caches", { node: cachePage, title: "缓存" });
|
|
422
|
-
handlers.set("viewCaches", () => {
|
|
423
|
-
if (saving) return;
|
|
424
|
-
let value;
|
|
425
|
-
return perform(
|
|
426
|
-
async () => {
|
|
427
|
-
try {
|
|
428
|
-
value = await client.readCaches(active);
|
|
429
|
-
} catch (error) {
|
|
430
|
-
notify({ kind: "error", message: error.message });
|
|
431
|
-
throw error;
|
|
432
|
-
}
|
|
433
|
-
},
|
|
434
|
-
() => {
|
|
435
|
-
output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
|
|
436
|
-
navigation.open("$caches");
|
|
437
|
-
},
|
|
438
|
-
);
|
|
439
|
-
});
|
|
440
|
-
handlers.set("clearCaches", async () => {
|
|
441
|
-
if (saving) return;
|
|
442
|
-
if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;
|
|
443
|
-
return perform(
|
|
444
|
-
() => client.clearCaches(active),
|
|
445
|
-
() => {
|
|
446
|
-
output.textContent = "暂无缓存";
|
|
447
|
-
},
|
|
448
|
-
);
|
|
449
|
-
});
|
|
450
|
-
handlers.set("reset", async () => {
|
|
451
|
-
if (saving) return;
|
|
452
|
-
if (!(await requestConfirmation(window, `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;
|
|
453
|
-
return perform(() => client.reset(active), controls);
|
|
454
|
-
});
|
|
455
|
-
navigation?.destroy();
|
|
456
|
-
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
457
|
-
navigation.addEventListener("change", updateNavigation);
|
|
458
|
-
for (const grow of growingInputs) grow();
|
|
459
|
-
updateNavigation();
|
|
460
|
-
}
|
|
461
|
-
/**
|
|
462
|
-
* 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。
|
|
463
|
-
* Loaded forms delegate back to navigation; loading views can return to the previous document.
|
|
464
|
-
* @returns {void} 无返回值 / No return value.
|
|
465
|
-
*/
|
|
466
|
-
back.onclick = () => {
|
|
467
|
-
if (saving) return;
|
|
468
|
-
if (navigation) navigation.back();
|
|
469
|
-
else window.history.back();
|
|
470
|
-
};
|
|
471
|
-
open(catalog.module.module);
|
|
472
|
-
return {
|
|
473
487
|
/**
|
|
474
|
-
*
|
|
475
|
-
*
|
|
488
|
+
* 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。
|
|
489
|
+
* Loaded forms delegate back to navigation; loading views can return to the previous document.
|
|
476
490
|
* @returns {void} 无返回值 / No return value.
|
|
477
491
|
*/
|
|
478
|
-
|
|
492
|
+
back.onclick = () => {
|
|
493
|
+
if (saving) return;
|
|
494
|
+
if (navigation) navigation.back();
|
|
495
|
+
else window.history.back();
|
|
496
|
+
};
|
|
497
|
+
open(definition.module);
|
|
498
|
+
return () => {
|
|
479
499
|
destroyed = true;
|
|
480
500
|
menu.destroy();
|
|
481
501
|
window.frameElement?.removeEventListener("preferencepanes:action", onAction);
|
|
482
502
|
navigation?.destroy();
|
|
483
503
|
generation++;
|
|
484
|
-
if (active && !saving) client.leave(
|
|
504
|
+
if (active && !saving) client.leave();
|
|
485
505
|
clearTimeout(timer);
|
|
486
506
|
shell.remove();
|
|
487
|
-
}
|
|
488
|
-
}
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* 移除监听器、定时器、会话和挂载内容。
|
|
512
|
+
* Remove listeners, timers, session, and mounted content.
|
|
513
|
+
* @returns {void} 无返回值 / No return value.
|
|
514
|
+
*/
|
|
515
|
+
destroy() {
|
|
516
|
+
this.#release();
|
|
517
|
+
}
|
|
489
518
|
}
|