@nsnanocat/preference-panes 1.0.0 → 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 +7 -5
- package/dist/module/index.html +1 -1
- package/dist/module/{app.mjs → index.mjs} +773 -599
- package/dist/preference-panes.mjs +692 -565
- package/dist/web.js +1 -1
- package/package.json +1 -1
- package/src/browser/client.d.mts +5 -10
- package/src/browser/client.mjs +126 -48
- package/src/browser/index.d.ts +17 -2
- package/src/browser/index.mjs +94 -79
- package/src/browser/module.html +1 -1
- package/src/browser/mount.mjs +119 -0
- package/src/browser/panel.mjs +464 -444
- package/src/build.mjs +2 -2
- package/src/browser/app.mjs +0 -49
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsnanocat/preference-panes",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
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/browser/client.d.mts
CHANGED
|
@@ -42,10 +42,12 @@ export interface ModuleSnapshot {
|
|
|
42
42
|
values: Record<string, SettingsScalar | SettingsScalar[] | null>;
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
45
|
+
* 管理单模块页面的 API 请求、值快照和会话终止。
|
|
46
|
+
* Manage API requests, value snapshots, and session termination for one module page.
|
|
47
47
|
*/
|
|
48
|
-
export
|
|
48
|
+
export class PreferencesClient {
|
|
49
|
+
/** 创建页面客户端 / Create the page client. */
|
|
50
|
+
constructor(options: PreferencesClientOptions);
|
|
49
51
|
/** 获取页面快照,不发请求 / Get a page snapshot without a request. */
|
|
50
52
|
snapshot(): ModuleSnapshot;
|
|
51
53
|
/** 读取 Settings 子树 / Read the Settings subtree. */
|
|
@@ -63,10 +65,3 @@ export interface PreferencesClient {
|
|
|
63
65
|
/** 删除单个字段覆盖值 / Delete one field override. */
|
|
64
66
|
remove(key: string): Promise<void>;
|
|
65
67
|
}
|
|
66
|
-
/**
|
|
67
|
-
* 创建只调用模块 API 的页面客户端。
|
|
68
|
-
* Create a page client that only calls the module API.
|
|
69
|
-
* @param options API 模型与运行环境 / API model and runtime.
|
|
70
|
-
* @returns 页面客户端 / Page client.
|
|
71
|
-
*/
|
|
72
|
-
export function createPreferencesClient(options: PreferencesClientOptions): PreferencesClient;
|
package/src/browser/client.mjs
CHANGED
|
@@ -1,14 +1,109 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* @param {import("./client.mjs").PreferencesClientOptions} options API 模型、请求与通知 / API model, requests and notifications.
|
|
5
|
-
* @returns {import("./client.mjs").PreferencesClient} 页面客户端 / Page client.
|
|
2
|
+
* 管理单模块页面的 API 请求、值快照和会话终止。
|
|
3
|
+
* Manage API requests, value snapshots, and session termination for one module page.
|
|
6
4
|
*/
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
5
|
+
export class PreferencesClient {
|
|
6
|
+
#module;
|
|
7
|
+
#configURL;
|
|
8
|
+
#definition;
|
|
9
|
+
#request;
|
|
10
|
+
#notify;
|
|
11
|
+
#timeout;
|
|
12
|
+
#session = new AbortController();
|
|
13
|
+
#values;
|
|
14
|
+
#saving = false;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 创建只调用模块 API、不读取或解析 BoxJS 的页面客户端。
|
|
18
|
+
* Create a page client that only calls the module API and never reads or parses BoxJS.
|
|
19
|
+
* @param {import("./client.mjs").PreferencesClientOptions} options API 模型、请求与通知 / API model, requests, and notifications.
|
|
20
|
+
*/
|
|
21
|
+
constructor({ model, definition, fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 }) {
|
|
22
|
+
this.#module = model.module;
|
|
23
|
+
this.#configURL = model.configURL;
|
|
24
|
+
this.#definition = definition;
|
|
25
|
+
this.#request = request;
|
|
26
|
+
this.#notify = notify;
|
|
27
|
+
this.#timeout = timeout;
|
|
28
|
+
this.#values = structuredClone(model.values);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 获取当前字段定义和值的深拷贝,不发起网络请求。
|
|
33
|
+
* Return a deep copy of the current field definition and values without a network request.
|
|
34
|
+
* @returns {import("./client.mjs").ModuleSnapshot} 会话快照 / Session snapshot.
|
|
35
|
+
*/
|
|
36
|
+
snapshot() {
|
|
37
|
+
return structuredClone({ definition: this.#definition, values: this.#values });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 读取 Settings 子树。
|
|
42
|
+
* Read the Settings subtree.
|
|
43
|
+
* @returns {Promise<unknown>} Settings 内容或 undefined / Settings content or undefined.
|
|
44
|
+
*/
|
|
45
|
+
async readSettings() {
|
|
46
|
+
const response = await this.#send("get", { scope: "settings" });
|
|
47
|
+
return response.status === 404 ? undefined : response.json();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 读取 Caches 子树。
|
|
52
|
+
* Read the Caches subtree.
|
|
53
|
+
* @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
|
|
54
|
+
*/
|
|
55
|
+
async readCaches() {
|
|
56
|
+
const response = await this.#send("get", { scope: "caches" });
|
|
57
|
+
return response.status === 404 ? undefined : response.json();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 删除当前模块的 Caches 子树。
|
|
62
|
+
* Delete the current module Caches subtree.
|
|
63
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
64
|
+
*/
|
|
65
|
+
clearCaches() {
|
|
66
|
+
return this.#change("delete", { scope: "caches" }, "clearCaches");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 删除当前模块数据并恢复页面默认值。
|
|
71
|
+
* Delete current module data and restore page defaults.
|
|
72
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
73
|
+
*/
|
|
74
|
+
reset() {
|
|
75
|
+
return this.#change("delete", { scope: "module" }, "reset");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 终止当前页面仍在进行的请求。
|
|
80
|
+
* Abort requests still owned by the current page.
|
|
81
|
+
* @returns {void} 无返回值 / No return value.
|
|
82
|
+
*/
|
|
83
|
+
leave() {
|
|
84
|
+
this.#session.abort();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 写入单个字段。
|
|
89
|
+
* Write one field.
|
|
90
|
+
* @param {string} key 字段路径 / Field path.
|
|
91
|
+
* @param {unknown} value 已校验值 / Validated value.
|
|
92
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
93
|
+
*/
|
|
94
|
+
set(key, value) {
|
|
95
|
+
return this.#change("set", { key, value }, "write", key);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 删除单个字段覆盖值。
|
|
100
|
+
* Delete one field override.
|
|
101
|
+
* @param {string} key 字段路径 / Field path.
|
|
102
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
103
|
+
*/
|
|
104
|
+
remove(key) {
|
|
105
|
+
return this.#change("delete", { key }, "delete", key);
|
|
106
|
+
}
|
|
12
107
|
|
|
13
108
|
/**
|
|
14
109
|
* 向模块 API 发送 JSON 动作。
|
|
@@ -17,26 +112,26 @@ export function createPreferencesClient({ model, definition, fetch: request = gl
|
|
|
17
112
|
* @param {unknown} payload JSON 请求体 / JSON request body.
|
|
18
113
|
* @returns {Promise<Response>} 原始响应 / Raw response.
|
|
19
114
|
*/
|
|
20
|
-
async
|
|
115
|
+
async #send(action, payload) {
|
|
21
116
|
const controller = new AbortController();
|
|
22
117
|
const abort = () => controller.abort();
|
|
23
|
-
if (session.signal.aborted) abort();
|
|
24
|
-
session.signal.addEventListener("abort", abort, { once: true });
|
|
25
|
-
const timer = setTimeout(abort, timeout);
|
|
118
|
+
if (this.#session.signal.aborted) abort();
|
|
119
|
+
this.#session.signal.addEventListener("abort", abort, { once: true });
|
|
120
|
+
const timer = setTimeout(abort, this.#timeout);
|
|
26
121
|
try {
|
|
27
|
-
const response = await request(`/api/${encodeURIComponent(module)}/${action}`, {
|
|
122
|
+
const response = await this.#request(`/api/${encodeURIComponent(this.#module)}/${action}`, {
|
|
28
123
|
method: "POST",
|
|
29
124
|
credentials: "omit",
|
|
30
125
|
cache: "no-store",
|
|
31
126
|
signal: controller.signal,
|
|
32
|
-
headers: { "Content-Type": "application/json", "X-PreferencePanes-JSON": configURL },
|
|
127
|
+
headers: { "Content-Type": "application/json", "X-PreferencePanes-JSON": this.#configURL },
|
|
33
128
|
body: JSON.stringify(payload),
|
|
34
129
|
});
|
|
35
130
|
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
36
131
|
return response;
|
|
37
132
|
} finally {
|
|
38
133
|
clearTimeout(timer);
|
|
39
|
-
session.signal.removeEventListener("abort", abort);
|
|
134
|
+
this.#session.signal.removeEventListener("abort", abort);
|
|
40
135
|
}
|
|
41
136
|
}
|
|
42
137
|
|
|
@@ -44,58 +139,41 @@ export function createPreferencesClient({ model, definition, fetch: request = gl
|
|
|
44
139
|
* 执行写入动作;成功后只更新当前页面值。
|
|
45
140
|
* Execute a mutation and update only the current page values after success.
|
|
46
141
|
* @param {"set" | "delete"} action API 动作 / API action.
|
|
47
|
-
* @param {unknown} payload JSON 请求体 / JSON request body.
|
|
142
|
+
* @param {Record<string, unknown>} payload JSON 请求体 / JSON request body.
|
|
48
143
|
* @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
|
|
49
144
|
* @param {string} [key] 字段路径 / Field path.
|
|
50
145
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
51
146
|
*/
|
|
52
|
-
async
|
|
53
|
-
if (saving) throw new Error("A settings write is already in progress");
|
|
54
|
-
saving = true;
|
|
147
|
+
async #change(action, payload, operation, key) {
|
|
148
|
+
if (this.#saving) throw new Error("A settings write is already in progress");
|
|
149
|
+
this.#saving = true;
|
|
55
150
|
try {
|
|
56
|
-
await send(action, payload);
|
|
151
|
+
await this.#send(action, payload);
|
|
57
152
|
switch (operation) {
|
|
58
153
|
case "write":
|
|
59
|
-
values[key] = structuredClone(payload.value);
|
|
154
|
+
this.#values[key] = structuredClone(payload.value);
|
|
60
155
|
break;
|
|
61
156
|
case "delete": {
|
|
62
|
-
const field = definition.fields.find(candidate => candidate.key === key);
|
|
63
|
-
delete values[key];
|
|
64
|
-
if (field && Object.hasOwn(field, "defaultValue")) values[key] = structuredClone(field.defaultValue);
|
|
157
|
+
const field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
158
|
+
delete this.#values[key];
|
|
159
|
+
if (field && Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
|
|
65
160
|
break;
|
|
66
161
|
}
|
|
67
162
|
case "clearCaches":
|
|
68
163
|
break;
|
|
69
164
|
case "reset":
|
|
70
|
-
for (const field of definition.fields) {
|
|
71
|
-
delete values[field.key];
|
|
72
|
-
if (Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
|
|
165
|
+
for (const field of this.#definition.fields) {
|
|
166
|
+
delete this.#values[field.key];
|
|
167
|
+
if (Object.hasOwn(field, "defaultValue")) this.#values[field.key] = structuredClone(field.defaultValue);
|
|
73
168
|
}
|
|
74
169
|
break;
|
|
75
170
|
}
|
|
76
|
-
notify({ kind: "success", operation, module, key });
|
|
171
|
+
this.#notify({ kind: "success", operation, module: this.#module, key });
|
|
77
172
|
} catch (error) {
|
|
78
|
-
notify({ kind: "error", operation, module, key, message: error.message });
|
|
173
|
+
this.#notify({ kind: "error", operation, module: this.#module, key, message: error.message });
|
|
79
174
|
throw error;
|
|
80
175
|
} finally {
|
|
81
|
-
saving = false;
|
|
176
|
+
this.#saving = false;
|
|
82
177
|
}
|
|
83
178
|
}
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
snapshot: () => structuredClone({ definition, values }),
|
|
87
|
-
async readSettings() {
|
|
88
|
-
const response = await send("get", { scope: "settings" });
|
|
89
|
-
return response.status === 404 ? undefined : response.json();
|
|
90
|
-
},
|
|
91
|
-
async readCaches() {
|
|
92
|
-
const response = await send("get", { scope: "caches" });
|
|
93
|
-
return response.status === 404 ? undefined : response.json();
|
|
94
|
-
},
|
|
95
|
-
clearCaches: () => change("delete", { scope: "caches" }, "clearCaches"),
|
|
96
|
-
reset: () => change("delete", { scope: "module" }, "reset"),
|
|
97
|
-
leave: () => session.abort(),
|
|
98
|
-
set: (key, value) => change("set", { key, value }, "write", key),
|
|
99
|
-
remove: key => change("delete", { key }, "delete", key),
|
|
100
|
-
};
|
|
101
179
|
}
|
package/src/browser/index.d.ts
CHANGED
|
@@ -8,11 +8,26 @@ export interface MountedPreferences {
|
|
|
8
8
|
/** 移除页面、样式和监听器 / Remove page, styles and listeners. */
|
|
9
9
|
destroy(): void;
|
|
10
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* 管理模块设置视图的模型、样式、主题和面板生命周期。
|
|
13
|
+
* Manage model, styles, theme, and panel lifecycle for a module settings view.
|
|
14
|
+
*/
|
|
15
|
+
export class PreferencesView implements MountedPreferences {
|
|
16
|
+
/**
|
|
17
|
+
* 使用模块 API 返回的模型挂载设置页。
|
|
18
|
+
* Mount a settings page from the model returned by the module API.
|
|
19
|
+
* @param model 模块 API 模型 / Module API model.
|
|
20
|
+
* @param css 可选 CSS 正文 / Optional CSS text.
|
|
21
|
+
*/
|
|
22
|
+
constructor(model: ModuleModel, css?: string);
|
|
23
|
+
/** 移除页面、样式和监听器 / Remove page, styles and listeners. */
|
|
24
|
+
destroy(): void;
|
|
25
|
+
}
|
|
11
26
|
/**
|
|
12
27
|
* 使用模块 API 返回的模型挂载设置页;CSS 仅覆盖当前模块。
|
|
13
28
|
* Mount a settings page from a module API model; CSS only overrides this module.
|
|
14
29
|
* @param model 模块 API 模型 / Module API model.
|
|
15
30
|
* @param css 可选 CSS 正文 / Optional CSS text.
|
|
16
|
-
* @returns
|
|
31
|
+
* @returns 模块视图 / Module view.
|
|
17
32
|
*/
|
|
18
|
-
export function mount(model: ModuleModel, css?: string):
|
|
33
|
+
export function mount(model: ModuleModel, css?: string): PreferencesView;
|
package/src/browser/index.mjs
CHANGED
|
@@ -1,90 +1,105 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { pageInputs } from "../lib/page-inputs.mjs";
|
|
2
|
+
import { statusView } from "./components.mjs";
|
|
3
|
+
import { PreferencesView } from "./mount.mjs";
|
|
4
4
|
import { installDefaultStyles } from "./styles.mjs";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
|
|
10
|
-
* @param {string} [css] 可选 CSS 正文 / Optional CSS text.
|
|
11
|
-
* @returns {import("./index.js").MountedPreferences} 模块生命周期句柄 / Module lifecycle handle.
|
|
7
|
+
* 管理模块文档的页面输入、初始请求、重载和错误状态。
|
|
8
|
+
* Manage page inputs, initial requests, reloads, and error states for a module document.
|
|
12
9
|
*/
|
|
13
|
-
export
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
10
|
+
export class ModulePage {
|
|
11
|
+
#document;
|
|
12
|
+
#window;
|
|
13
|
+
#root;
|
|
14
|
+
#view;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 创建模块页面控制器并安装基础样式。
|
|
18
|
+
* Create the module page controller and install base styles.
|
|
19
|
+
* @param {Document} document 模块文档 / Module document.
|
|
20
|
+
*/
|
|
21
|
+
constructor(document) {
|
|
22
|
+
this.#document = document;
|
|
23
|
+
this.#window = document.defaultView;
|
|
24
|
+
this.#root = document.querySelector("#preferences");
|
|
25
|
+
installDefaultStyles(document);
|
|
26
|
+
this.#window.addEventListener("pageshow", this.#show);
|
|
21
27
|
}
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 从 URL 或代理传递的 Header 导入 JSON/CSS,支持独立文档与 srcdoc。
|
|
31
|
+
* Import JSON/CSS from the URL or proxy-carried headers in standalone and srcdoc documents.
|
|
32
|
+
* @returns {Promise<void>} 启动完成 / Startup completion.
|
|
33
|
+
*/
|
|
34
|
+
async start() {
|
|
35
|
+
try {
|
|
36
|
+
this.#view?.destroy();
|
|
37
|
+
this.#view = undefined;
|
|
38
|
+
this.#root.replaceChildren(statusView("读取设置…"));
|
|
39
|
+
const inputs = this.#readInputs();
|
|
40
|
+
const apiURL = new URL(`/api/${encodeURIComponent(inputs.module)}`, inputs.url).href;
|
|
41
|
+
const styleURL = this.#resourceURL(inputs.css, inputs.url);
|
|
42
|
+
const [style, modelResponse] = await Promise.all([styleURL ? fetch(styleURL, { cache: "no-store", credentials: "omit" }) : null, fetch(apiURL, { cache: "no-store", credentials: "omit", headers: { Accept: "application/json", "X-PreferencePanes-JSON": inputs.json } })]);
|
|
43
|
+
if ((style && style.status !== 200) || modelResponse.status !== 200) throw new Error(`HTTP ${modelResponse.status !== 200 ? modelResponse.status : style.status}`);
|
|
44
|
+
this.#view = new PreferencesView(await modelResponse.json(), style ? await style.text() : "");
|
|
45
|
+
} catch (error) {
|
|
46
|
+
this.#root.replaceChildren(statusView(`加载失败:${error.message}`, () => this.start()));
|
|
47
|
+
}
|
|
33
48
|
}
|
|
34
|
-
|
|
35
|
-
const custom = element("style", "");
|
|
36
|
-
custom.textContent = css;
|
|
37
|
-
document.head.append(custom);
|
|
38
|
-
const previousTitle = document.title;
|
|
39
|
-
const previousTheme = document.documentElement.dataset.theme;
|
|
40
|
-
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
41
|
-
const previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
|
|
42
|
-
const host = window.frameElement?.ownerDocument.documentElement;
|
|
49
|
+
|
|
43
50
|
/**
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* @returns {void}
|
|
51
|
+
* 释放页面视图和页面级监听器。
|
|
52
|
+
* Release the page view and page-level listener.
|
|
53
|
+
* @returns {void} 无返回值 / No return value.
|
|
47
54
|
*/
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
};
|
|
53
|
-
let observer;
|
|
54
|
-
syncAppearance();
|
|
55
|
-
systemTheme.addEventListener("change", syncAppearance);
|
|
56
|
-
if (host) {
|
|
57
|
-
observer = new MutationObserver(syncAppearance);
|
|
58
|
-
observer.observe(host, { attributes: true, attributeFilter: ["data-theme", "style"] });
|
|
55
|
+
destroy() {
|
|
56
|
+
this.#window.removeEventListener("pageshow", this.#show);
|
|
57
|
+
this.#view?.destroy();
|
|
58
|
+
this.#view = undefined;
|
|
59
59
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 读取嵌入参数、文档元数据或当前 URL 输入。
|
|
63
|
+
* Read embedded parameters, document metadata, or current URL inputs.
|
|
64
|
+
* @returns {ReturnType<typeof pageInputs>} 页面输入 / Page inputs.
|
|
65
|
+
*/
|
|
66
|
+
#readInputs() {
|
|
67
|
+
const context = this.#document.querySelector('meta[name="preference-panes-inputs"]');
|
|
68
|
+
const embedded = this.#window.frameElement?.dataset.preferencePanes;
|
|
69
|
+
switch (true) {
|
|
70
|
+
case embedded !== undefined:
|
|
71
|
+
this.#document.documentElement.dataset.preferencePanesEmbedded = "";
|
|
72
|
+
return JSON.parse(embedded);
|
|
73
|
+
case context !== null:
|
|
74
|
+
return JSON.parse(decodeURIComponent(context.content));
|
|
75
|
+
default:
|
|
76
|
+
return pageInputs(new URL(this.#window.location.href));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 将可选页面资源限制为 HTTP(S) 地址。
|
|
82
|
+
* Restrict an optional page resource to an HTTP(S) URL.
|
|
83
|
+
* @param {string | undefined} source 资源地址 / Resource location.
|
|
84
|
+
* @param {string} baseURL 页面基准地址 / Page base URL.
|
|
85
|
+
* @returns {string | null} 绝对资源地址 / Absolute resource URL.
|
|
86
|
+
*/
|
|
87
|
+
#resourceURL(source, baseURL) {
|
|
88
|
+
if (!source) return null;
|
|
89
|
+
const url = new URL(source, baseURL);
|
|
90
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Resources must use HTTP(S) URLs");
|
|
91
|
+
return url.href;
|
|
89
92
|
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 从前进后退缓存恢复时重新加载模块。
|
|
96
|
+
* Reload the module when restored from the back-forward cache.
|
|
97
|
+
* @param {PageTransitionEvent} event 页面显示事件 / Page show event.
|
|
98
|
+
* @returns {void} 无返回值 / No return value.
|
|
99
|
+
*/
|
|
100
|
+
#show = event => {
|
|
101
|
+
if (event.persisted) this.start();
|
|
102
|
+
};
|
|
90
103
|
}
|
|
104
|
+
|
|
105
|
+
new ModulePage(document).start();
|
package/src/browser/module.html
CHANGED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { normalizeBoxJs, normalizeStoredValue, validValue } from "./boxjs.mjs";
|
|
2
|
+
import { element, resourceURL } from "./components.mjs";
|
|
3
|
+
import { PreferencesPanel } from "./panel.mjs";
|
|
4
|
+
import { installDefaultStyles } from "./styles.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 管理模块设置视图的模型规范化、样式、主题同步和面板生命周期。
|
|
8
|
+
* Manage model normalization, styles, theme synchronization, and panel lifecycle for a module settings view.
|
|
9
|
+
*/
|
|
10
|
+
export class PreferencesView {
|
|
11
|
+
#existing;
|
|
12
|
+
#root;
|
|
13
|
+
#base;
|
|
14
|
+
#ownsBase;
|
|
15
|
+
#custom;
|
|
16
|
+
#previousTitle;
|
|
17
|
+
#previousTheme;
|
|
18
|
+
#systemTheme;
|
|
19
|
+
#previousKeyboard;
|
|
20
|
+
#host;
|
|
21
|
+
#observer;
|
|
22
|
+
#panel;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 使用模块 API 返回的模型挂载设置页。
|
|
26
|
+
* Mount a settings page from the model returned by the module API.
|
|
27
|
+
* @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
|
|
28
|
+
* @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
|
|
29
|
+
*/
|
|
30
|
+
constructor(model, css = "") {
|
|
31
|
+
if (typeof css !== "string") throw new TypeError("CSS must be a string");
|
|
32
|
+
const definition = normalizeBoxJs(model.boxjs, model.module);
|
|
33
|
+
const values = { ...model.values };
|
|
34
|
+
for (const field of definition.fields) {
|
|
35
|
+
if (values[field.key] === undefined) continue;
|
|
36
|
+
values[field.key] = normalizeStoredValue(field, values[field.key]);
|
|
37
|
+
if (!validValue(field, values[field.key])) throw new TypeError(`Invalid stored value: ${field.key}`);
|
|
38
|
+
}
|
|
39
|
+
for (const field of definition.fields) if (values[field.key] === undefined && Object.hasOwn(field, "defaultValue")) values[field.key] = structuredClone(field.defaultValue);
|
|
40
|
+
const rendered = { ...model, definition, values };
|
|
41
|
+
const metadata = definition.metadata ?? {};
|
|
42
|
+
const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
43
|
+
if (image) resourceURL(image);
|
|
44
|
+
if (metadata.repo) resourceURL(metadata.repo);
|
|
45
|
+
|
|
46
|
+
this.#existing = document.querySelector("#preferences");
|
|
47
|
+
this.#root = this.#existing ?? element("main", "");
|
|
48
|
+
if (!this.#existing) {
|
|
49
|
+
this.#root.id = "preferences";
|
|
50
|
+
document.body.append(this.#root);
|
|
51
|
+
}
|
|
52
|
+
const styles = installDefaultStyles(document);
|
|
53
|
+
this.#base = styles.element;
|
|
54
|
+
this.#ownsBase = styles.owned;
|
|
55
|
+
this.#custom = element("style", "");
|
|
56
|
+
this.#custom.textContent = css;
|
|
57
|
+
document.head.append(this.#custom);
|
|
58
|
+
this.#previousTitle = document.title;
|
|
59
|
+
this.#previousTheme = document.documentElement.dataset.theme;
|
|
60
|
+
this.#systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
61
|
+
this.#previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
|
|
62
|
+
this.#host = window.frameElement?.ownerDocument.documentElement;
|
|
63
|
+
this.#syncAppearance();
|
|
64
|
+
this.#systemTheme.addEventListener("change", this.#syncAppearance);
|
|
65
|
+
if (this.#host) {
|
|
66
|
+
this.#observer = new MutationObserver(this.#syncAppearance);
|
|
67
|
+
this.#observer.observe(this.#host, { attributes: true, attributeFilter: ["data-theme", "style"] });
|
|
68
|
+
}
|
|
69
|
+
document.title = metadata.name ?? definition.module;
|
|
70
|
+
try {
|
|
71
|
+
this.#root.replaceChildren();
|
|
72
|
+
this.#panel = new PreferencesPanel(this.#root, rendered);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
this.destroy();
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 跟随嵌入宿主的通用环境状态,不识别业务 App 或解析其 UA。
|
|
81
|
+
* Follow generic host appearance without detecting a business App or parsing its UA.
|
|
82
|
+
* @returns {void} 已同步主题与键盘避让 / Theme and keyboard clearance synchronized.
|
|
83
|
+
*/
|
|
84
|
+
#syncAppearance = () => {
|
|
85
|
+
const theme = this.#host?.dataset.theme ?? this.#previousTheme ?? (this.#systemTheme.matches ? "dark" : "light");
|
|
86
|
+
document.documentElement.dataset.theme = theme;
|
|
87
|
+
if (this.#host) document.documentElement.style.setProperty("--pp-keyboard-height", this.#host.style.getPropertyValue("--pp-keyboard-height"));
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 释放模块视图、样式与会话,不操作项目入口页。
|
|
92
|
+
* Release the module view, styles, and session without operating a project landing page.
|
|
93
|
+
* @returns {void} 无返回值 / No return value.
|
|
94
|
+
*/
|
|
95
|
+
destroy() {
|
|
96
|
+
this.#observer?.disconnect();
|
|
97
|
+
this.#systemTheme.removeEventListener("change", this.#syncAppearance);
|
|
98
|
+
this.#panel?.destroy();
|
|
99
|
+
if (this.#ownsBase) this.#base.remove();
|
|
100
|
+
this.#custom.remove();
|
|
101
|
+
if (this.#existing) this.#root.replaceChildren();
|
|
102
|
+
else this.#root.remove();
|
|
103
|
+
document.title = this.#previousTitle;
|
|
104
|
+
if (this.#previousTheme === undefined) delete document.documentElement.dataset.theme;
|
|
105
|
+
else document.documentElement.dataset.theme = this.#previousTheme;
|
|
106
|
+
document.documentElement.style.setProperty("--pp-keyboard-height", this.#previousKeyboard);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 使用模块 API 返回的模型挂载设置页;CSS 仅覆盖当前模块。
|
|
112
|
+
* Mount a settings page from a module API model; CSS only overrides this module.
|
|
113
|
+
* @param {import("../index.js").ModuleModel} model API 返回的模块模型 / Module model returned by the API.
|
|
114
|
+
* @param {string} [css] 可选 CSS 正文 / Optional module-scoped CSS text.
|
|
115
|
+
* @returns {PreferencesView} 模块视图 / Module view.
|
|
116
|
+
*/
|
|
117
|
+
export function mount(model, css = "") {
|
|
118
|
+
return new PreferencesView(model, css);
|
|
119
|
+
}
|