@nsnanocat/preference-panes 0.3.0 → 0.4.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 +41 -2
- package/dist/preference-panes.mjs +148 -61
- package/dist/settings/app.mjs +1043 -0
- package/dist/settings/home.css +134 -0
- package/dist/settings/index.html +15 -0
- package/dist/settings/panel.css +325 -0
- package/package.json +3 -2
- package/src/PreferencesHandler.mjs +50 -0
- package/src/browser/app.mjs +153 -0
- package/src/browser/home.css +134 -0
- package/src/browser/index.d.ts +2 -0
- package/src/browser/panel.css +260 -75
- package/src/browser/panel.mjs +148 -61
- package/src/browser/site.html +15 -0
- package/src/index.d.ts +23 -0
- package/src/index.mjs +1 -0
|
@@ -0,0 +1,1043 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 校验原始路径片段,不进行 URL 编码转换。
|
|
3
|
+
* Validate raw path segments without URL encoding conversion.
|
|
4
|
+
* @param {string[]} parts 原始路径片段 / Raw path segments.
|
|
5
|
+
* @returns {string[]} 同一数组,不复制或修改 / The same array without copying or mutation.
|
|
6
|
+
* @throws {TypeError} 空片段、非法字符或原型属性名 / Empty segments, invalid characters or prototype property names.
|
|
7
|
+
*/
|
|
8
|
+
function validatePathParts(parts) {
|
|
9
|
+
if (!parts.every(part => typeof part === "string" && /^[a-zA-Z0-9_-]+$/.test(part) && !["__proto__", "prototype", "constructor"].includes(part))) throw new TypeError("Invalid key path");
|
|
10
|
+
return parts;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
|
|
15
|
+
* Normalize a BoxJS array, app or subscription using the source JSON as the field authority.
|
|
16
|
+
* @param {unknown} config BoxJS JSON / BoxJS document.
|
|
17
|
+
* @param {string} module API 第一段模块名 / First API path segment.
|
|
18
|
+
* @returns {import("../index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
|
|
19
|
+
* @throws {TypeError} 配置结构、字段路径、默认值或展示属性无效 / Invalid configuration, field path, default or presentation attribute.
|
|
20
|
+
*/
|
|
21
|
+
function normalizeBoxJs(config, module) {
|
|
22
|
+
validatePathParts([module]);
|
|
23
|
+
const apps = Array.isArray(config) ? [] : (config?.apps ?? [config]);
|
|
24
|
+
if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
|
|
25
|
+
for (const candidate of apps) {
|
|
26
|
+
if (!candidate || typeof candidate !== "object") throw new TypeError("Expected BoxJS app object");
|
|
27
|
+
if (candidate.settings !== undefined && !Array.isArray(candidate.settings)) throw new TypeError("Expected BoxJS settings array");
|
|
28
|
+
}
|
|
29
|
+
const owners = apps.filter(candidate => candidate.settings?.some(entry => typeof entry.id === "string" && entry.id.startsWith("@") && entry.id.slice(1).split(".")[1] === module));
|
|
30
|
+
const entries = Array.isArray(config) ? config : owners.flatMap(candidate => candidate.settings);
|
|
31
|
+
const app = owners.length === 1 ? owners[0] : undefined;
|
|
32
|
+
let storageKey;
|
|
33
|
+
const fields = [];
|
|
34
|
+
for (const entry of entries) {
|
|
35
|
+
if (typeof entry.id !== "string" || !entry.id.startsWith("@")) throw new TypeError("BoxJS settings require @root.path IDs");
|
|
36
|
+
const [root, ...parts] = entry.id.slice(1).split(".");
|
|
37
|
+
if (parts[0] !== module) continue;
|
|
38
|
+
if (parts.length < 2) throw new TypeError("A BoxJS setting must be below the module root");
|
|
39
|
+
validatePathParts(parts);
|
|
40
|
+
if (!root || (storageKey && root !== storageKey)) throw new TypeError("A module must use one storage root");
|
|
41
|
+
storageKey = root;
|
|
42
|
+
const type = { boolean: "boolean", checkboxes: "array", selects: "select", text: "string", textarea: "string", number: "number" }[entry.type];
|
|
43
|
+
if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
|
|
44
|
+
const field = {
|
|
45
|
+
key: parts.join("."),
|
|
46
|
+
type: type === "select" ? typeof entry.val : type,
|
|
47
|
+
|
|
48
|
+
name: entry.name,
|
|
49
|
+
description: entry.desc ?? "",
|
|
50
|
+
control: entry.type,
|
|
51
|
+
...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),
|
|
52
|
+
...(entry.rows === undefined ? {} : { rows: entry.rows }),
|
|
53
|
+
...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),
|
|
54
|
+
};
|
|
55
|
+
if (type === "select" && !["string", "number", "boolean"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);
|
|
56
|
+
if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));
|
|
57
|
+
if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
|
|
58
|
+
if (
|
|
59
|
+
typeof field.name !== "string" ||
|
|
60
|
+
(field.placeholder !== undefined && typeof field.placeholder !== "string") ||
|
|
61
|
+
(field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||
|
|
62
|
+
(field.autoGrow !== undefined && typeof field.autoGrow !== "boolean") ||
|
|
63
|
+
fields.some(other => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
|
|
64
|
+
)
|
|
65
|
+
throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
|
|
66
|
+
if (field.options && (new Set(field.options.map(item => item.key)).size !== field.options.length || field.options.some(item => !scalar(item.key) || typeof item.label !== "string"))) throw new TypeError(`Invalid options: ${entry.id}`);
|
|
67
|
+
if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
|
|
68
|
+
fields.push(field);
|
|
69
|
+
}
|
|
70
|
+
if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
71
|
+
const common = fields[0].key.split(".").slice(0, -1);
|
|
72
|
+
for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
|
|
73
|
+
const metadata = {};
|
|
74
|
+
if (app) {
|
|
75
|
+
for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc", "icons", "descs"]) {
|
|
76
|
+
if (app[key] === undefined) continue;
|
|
77
|
+
const multiple = key === "icons" || key === "descs";
|
|
78
|
+
const values = multiple ? app[key] : [app[key]];
|
|
79
|
+
if (!Array.isArray(values) || values.some(item => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
|
|
80
|
+
metadata[key] = multiple ? [...values] : app[key];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
module,
|
|
85
|
+
storageKey,
|
|
86
|
+
fields,
|
|
87
|
+
settingsPath: common,
|
|
88
|
+
...(Object.keys(metadata).length ? { metadata } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 归一化 BoxJS 的字符串存储值,不改变普通文本内容。
|
|
94
|
+
* Normalize BoxJS string persistence without changing free-text values.
|
|
95
|
+
* @param {import("../index.js").SettingsField} field 前端字段约束 / Frontend field constraints.
|
|
96
|
+
* @param {unknown} value 存储值 / Stored value.
|
|
97
|
+
* @returns {unknown} 转换后的控件值;是否允许写入由 validValue 单独校验 / Converted control value; write eligibility is checked separately by validValue.
|
|
98
|
+
*/
|
|
99
|
+
function normalizeStoredValue(field, value) {
|
|
100
|
+
switch (field.type) {
|
|
101
|
+
case "boolean":
|
|
102
|
+
if (value === "true" || value === "false") return value === "true";
|
|
103
|
+
break;
|
|
104
|
+
case "number":
|
|
105
|
+
if (typeof value === "string" && value.trim() !== "") return Number(value);
|
|
106
|
+
break;
|
|
107
|
+
case "array":
|
|
108
|
+
if (typeof value === "string") value = value === "" || value === "[]" ? [] : value.split(",");
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
if (field.options) {
|
|
112
|
+
const match = item => field.options.find(option => String(option.key) === String(item))?.key ?? item;
|
|
113
|
+
return field.type === "array" && Array.isArray(value) ? value.map(match) : match(value);
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 校验支持的标量范围,包括文本长度与数值有限性。
|
|
120
|
+
* Validate supported scalar bounds, including text length and numeric finiteness.
|
|
121
|
+
* @param {unknown} value 待检查值 / Value to inspect.
|
|
122
|
+
* @returns {boolean} 是否为有效标量 / Whether the scalar is valid.
|
|
123
|
+
*/
|
|
124
|
+
function scalar(value) {
|
|
125
|
+
switch (typeof value) {
|
|
126
|
+
case "boolean":
|
|
127
|
+
return true;
|
|
128
|
+
case "string":
|
|
129
|
+
return value.length <= 2048;
|
|
130
|
+
case "number":
|
|
131
|
+
return Number.isFinite(value);
|
|
132
|
+
default:
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 检查值类型、数组唯一性及声明的选项,不进行转换。
|
|
139
|
+
* Check value type, array uniqueness and declared choices without coercion.
|
|
140
|
+
* @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
|
|
141
|
+
* @param {unknown} value 待写入的 JSON 值 / JSON value to write.
|
|
142
|
+
* @returns {boolean} 是否符合字段约束 / Whether the value satisfies field constraints.
|
|
143
|
+
*/
|
|
144
|
+
function validValue(field, value) {
|
|
145
|
+
if (field.type === "array") {
|
|
146
|
+
if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
|
|
147
|
+
} else if (typeof value !== field.type || !scalar(value)) return false;
|
|
148
|
+
return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 单个模块的临时会话;离开页面后丢弃。
|
|
153
|
+
* Transient module session discarded when leaving the page.
|
|
154
|
+
* @typedef {object} ModuleSession
|
|
155
|
+
* @property {AbortController} controller 读取请求的取消控制器 / Abort controller for reads.
|
|
156
|
+
* @property {import("../index.js").ModuleDefinition | null} definition 加载完成的配置,加载中为 null / Loaded configuration, or null while loading.
|
|
157
|
+
* @property {import("./index.js").ModuleSnapshot["values"]} values 当前显示值 / Current display values.
|
|
158
|
+
* @property {boolean} saving 是否正在写入 / Whether a mutation is in progress.
|
|
159
|
+
*/
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。
|
|
163
|
+
* Create a page-session cache; reload on open and mutate cache only after HTTP 200.
|
|
164
|
+
* @param {import("./index.js").PreferencesClientOptions} options 请求与通知 / Requests and notifications.
|
|
165
|
+
* @returns {import("./index.js").PreferencesClient} 通用客户端 / Generic client.
|
|
166
|
+
*/
|
|
167
|
+
function createPreferencesClient({ fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 } = {}) {
|
|
168
|
+
/** @type {Map<string, ModuleSession>} 模块会话表 / Module session map. */
|
|
169
|
+
const sessions = new Map();
|
|
170
|
+
/**
|
|
171
|
+
* 发送同源请求,处理超时与取消;数据 GET 的 404 交给调用方处理。
|
|
172
|
+
* Send a same-origin request with timeout and cancellation; callers handle missing-data GET responses.
|
|
173
|
+
* @param {string} path 相对请求路径 / Relative request path.
|
|
174
|
+
* @param {"HEAD" | "GET" | "POST" | "DELETE"} method HTTP 方法 / HTTP method.
|
|
175
|
+
* @param {unknown} body POST 值,其它方法忽略 / POST value, ignored by other methods.
|
|
176
|
+
* @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
|
|
177
|
+
* @param {boolean} [resource=false] 是否为无标记头的配置资源 / Whether this is a config resource without the marker header.
|
|
178
|
+
* @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
|
|
179
|
+
* @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
|
|
180
|
+
*/
|
|
181
|
+
async function send(path, method, body, signal, resource = false) {
|
|
182
|
+
const controller = new AbortController();
|
|
183
|
+
const abort = () => controller.abort();
|
|
184
|
+
if (signal?.aborted) abort();
|
|
185
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
186
|
+
const timer = setTimeout(abort, timeout);
|
|
187
|
+
try {
|
|
188
|
+
const response = await request(path, {
|
|
189
|
+
method,
|
|
190
|
+
credentials: "omit",
|
|
191
|
+
cache: "no-store",
|
|
192
|
+
signal: controller.signal,
|
|
193
|
+
headers: resource ? {} : { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
|
|
194
|
+
...(method === "POST" ? { body: JSON.stringify(body) } : {}),
|
|
195
|
+
});
|
|
196
|
+
if (response.status !== 200 && !(!resource && method === "GET" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
197
|
+
return response;
|
|
198
|
+
} finally {
|
|
199
|
+
clearTimeout(timer);
|
|
200
|
+
signal?.removeEventListener("abort", abort);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* 由合法模块名生成配置 Mock 路径。
|
|
205
|
+
* Build the config Mock path from a valid module name.
|
|
206
|
+
* @param {string} module 模块标识 / Module identifier.
|
|
207
|
+
* @returns {string} 配置路径 / Config path.
|
|
208
|
+
*/
|
|
209
|
+
const configPath = module => {
|
|
210
|
+
validatePathParts([module]);
|
|
211
|
+
return `/configs/${encodeURIComponent(module)}`;
|
|
212
|
+
};
|
|
213
|
+
/**
|
|
214
|
+
* 获取独立快照,避免调用方修改内部缓存。
|
|
215
|
+
* Return an independent snapshot so callers cannot mutate the cache.
|
|
216
|
+
* @param {string} module 已打开模块 / Open module.
|
|
217
|
+
* @returns {import("./index.js").ModuleSnapshot} 会话快照 / Session snapshot.
|
|
218
|
+
* @throws {Error} 模块未完成加载 / Module has not finished loading.
|
|
219
|
+
*/
|
|
220
|
+
const snapshot = module => {
|
|
221
|
+
const state = sessions.get(module);
|
|
222
|
+
if (!state?.definition) throw new Error("Open the module first");
|
|
223
|
+
return structuredClone({ definition: state.definition, values: state.values });
|
|
224
|
+
};
|
|
225
|
+
/**
|
|
226
|
+
* 串行修改单键,仅成功后更新仍存活的会话。
|
|
227
|
+
* Serialize single-key mutations and update a still-active session only after success.
|
|
228
|
+
* @param {string} module 已打开模块 / Open module.
|
|
229
|
+
* @param {string} key 完整点分字段路径 / Complete dotted field path.
|
|
230
|
+
* @param {"POST" | "DELETE"} method 写入或删除 / Write or delete.
|
|
231
|
+
* @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
|
|
232
|
+
* @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
|
|
233
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
234
|
+
* @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
|
|
235
|
+
*/
|
|
236
|
+
async function change(module, key, method, value, operation = method === "POST" ? "write" : "delete") {
|
|
237
|
+
const state = sessions.get(module);
|
|
238
|
+
if (!state?.definition) throw new Error("Open the module first");
|
|
239
|
+
if (state.saving) throw new Error("A settings write is already in progress");
|
|
240
|
+
const field = state.definition.fields.find(field => field.key === key);
|
|
241
|
+
state.saving = true;
|
|
242
|
+
try {
|
|
243
|
+
if ((operation === "write" || operation === "delete") && (!field || (method === "POST" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
|
|
244
|
+
await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
|
|
245
|
+
if (sessions.get(module) === state) {
|
|
246
|
+
switch (operation) {
|
|
247
|
+
case "write":
|
|
248
|
+
state.values[key] = structuredClone(value);
|
|
249
|
+
break;
|
|
250
|
+
case "delete":
|
|
251
|
+
case "clearCaches":
|
|
252
|
+
case "reset":
|
|
253
|
+
for (const candidate of state.definition.fields) {
|
|
254
|
+
if (candidate.key !== key && !candidate.key.startsWith(`${key}.`)) continue;
|
|
255
|
+
delete state.values[candidate.key];
|
|
256
|
+
if (Object.hasOwn(candidate, "defaultValue")) state.values[candidate.key] = structuredClone(candidate.defaultValue);
|
|
257
|
+
}
|
|
258
|
+
break;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
notify({ kind: "success", operation, module, key });
|
|
262
|
+
} catch (error) {
|
|
263
|
+
notify({ kind: "error", operation, module, key, message: error.message });
|
|
264
|
+
throw error;
|
|
265
|
+
} finally {
|
|
266
|
+
state.saving = false;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
/**
|
|
271
|
+
* 探测配置 Mock,不读写存储。
|
|
272
|
+
* Probe the config Mock without accessing storage.
|
|
273
|
+
* @param {string} module 模块标识 / Module identifier.
|
|
274
|
+
* @returns {Promise<boolean>} 是否返回 HTTP 200 / Whether HTTP 200 was returned.
|
|
275
|
+
*/
|
|
276
|
+
async probe(module) {
|
|
277
|
+
try {
|
|
278
|
+
await send(configPath(module), "HEAD", undefined, undefined, true);
|
|
279
|
+
return true;
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
/**
|
|
285
|
+
* 替换旧会话,读取一次配置与一次设置子树。
|
|
286
|
+
* Replace the previous session and read config and settings subtree once each.
|
|
287
|
+
* @param {string} module 模块标识 / Module identifier.
|
|
288
|
+
* @returns {Promise<import("./index.js").ModuleSnapshot>} 新快照 / New snapshot.
|
|
289
|
+
* @throws {Error} 读取失败、会话被替换或写入尚未完成 / Read failure, replaced session or unfinished write.
|
|
290
|
+
*/
|
|
291
|
+
async open(module) {
|
|
292
|
+
const previous = sessions.get(module);
|
|
293
|
+
if (previous?.saving) throw new Error("Cannot refresh while saving");
|
|
294
|
+
previous?.controller.abort();
|
|
295
|
+
const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
|
|
296
|
+
sessions.set(module, state);
|
|
297
|
+
try {
|
|
298
|
+
const definition = normalizeBoxJs(await (await send(configPath(module), "GET", undefined, state.controller.signal, true)).json(), module);
|
|
299
|
+
if (definition.settingsPath.length < 2) throw new TypeError("BoxJS fields must share a settings subtree below the module root");
|
|
300
|
+
const response = await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal);
|
|
301
|
+
let subtree = response.status === 404 ? {} : await response.json();
|
|
302
|
+
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
303
|
+
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
304
|
+
if (sessions.get(module) !== state) throw new Error("Module session was replaced");
|
|
305
|
+
state.definition = definition;
|
|
306
|
+
for (const field of definition.fields) {
|
|
307
|
+
const stored = field.key
|
|
308
|
+
.split(".")
|
|
309
|
+
.slice(definition.settingsPath.length)
|
|
310
|
+
.reduce((parent, part) => Object(parent)[part], subtree);
|
|
311
|
+
const value = stored === undefined ? field.defaultValue : stored;
|
|
312
|
+
if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);
|
|
313
|
+
}
|
|
314
|
+
return snapshot(module);
|
|
315
|
+
} catch (error) {
|
|
316
|
+
if (sessions.get(module) === state) sessions.delete(module);
|
|
317
|
+
throw error;
|
|
318
|
+
}
|
|
319
|
+
},
|
|
320
|
+
snapshot,
|
|
321
|
+
/**
|
|
322
|
+
* 按需读取模块 Caches,不自动读取其它设置。
|
|
323
|
+
* Read module Caches on demand without refreshing other settings.
|
|
324
|
+
* @param {string} module 已打开的模块 / Open module.
|
|
325
|
+
* @returns {Promise<unknown>} 缓存值,缺失为 undefined / Cache value, or undefined when absent.
|
|
326
|
+
*/
|
|
327
|
+
async readCaches(module) {
|
|
328
|
+
const state = sessions.get(module);
|
|
329
|
+
if (!state?.definition) throw new Error("Open the module first");
|
|
330
|
+
const response = await send(`/api/${encodeURIComponent(module)}/Caches`, "GET", undefined, state.controller.signal);
|
|
331
|
+
return response.status === 404 ? undefined : response.json();
|
|
332
|
+
},
|
|
333
|
+
/**
|
|
334
|
+
* 删除整个 Caches 节点,成功后不追加 GET。
|
|
335
|
+
* Delete the entire Caches node without a follow-up GET.
|
|
336
|
+
* @param {string} module 已打开模块 / Open module.
|
|
337
|
+
* @returns {Promise<void>} 清理完成 / Cleanup completion.
|
|
338
|
+
*/
|
|
339
|
+
clearCaches: module => change(module, `${module}.Caches`, "DELETE", undefined, "clearCaches"),
|
|
340
|
+
/**
|
|
341
|
+
* 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
|
|
342
|
+
* Delete module persistence and reset the page cache using current BoxJS defaults.
|
|
343
|
+
* @param {string} module 已打开模块 / Open module.
|
|
344
|
+
* @returns {Promise<void>} 重置完成 / Reset completion.
|
|
345
|
+
*/
|
|
346
|
+
reset: module => change(module, module, "DELETE", undefined, "reset"),
|
|
347
|
+
/**
|
|
348
|
+
* 取消读取并清除会话,不撤销已发送的写入。
|
|
349
|
+
* Abort reads and clear the session without undoing dispatched writes.
|
|
350
|
+
* @param {string} module 模块标识 / Module identifier.
|
|
351
|
+
* @returns {void} 无返回值 / No return value.
|
|
352
|
+
*/
|
|
353
|
+
leave(module) {
|
|
354
|
+
sessions.get(module)?.controller.abort();
|
|
355
|
+
sessions.delete(module);
|
|
356
|
+
},
|
|
357
|
+
/**
|
|
358
|
+
* 写入单键并更新当前会话。
|
|
359
|
+
* Write one key and update the current session.
|
|
360
|
+
* @param {string} module 已打开模块 / Open module.
|
|
361
|
+
* @param {string} key 点分字段路径 / Dotted field path.
|
|
362
|
+
* @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
|
|
363
|
+
* @returns {Promise<void>} 写入完成 / Write completion.
|
|
364
|
+
*/
|
|
365
|
+
set: (module, key, value) => change(module, key, "POST", value),
|
|
366
|
+
/**
|
|
367
|
+
* 删除单键覆盖值并显示默认值。
|
|
368
|
+
* Delete one override and display its default value.
|
|
369
|
+
* @param {string} module 已打开模块 / Open module.
|
|
370
|
+
* @param {string} key 点分字段路径 / Dotted field path.
|
|
371
|
+
* @returns {Promise<void>} 删除完成 / Delete completion.
|
|
372
|
+
*/
|
|
373
|
+
remove: (module, key) => change(module, key, "DELETE"),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* 挂载从 BoxJS 实时生成的设置面板和短暂通知。
|
|
379
|
+
* Mount runtime-generated BoxJS controls and transient notifications.
|
|
380
|
+
* @param {import("./index.js").PreferencesPanelOptions} options 容器与请求;页面路径 /settings/{module} 对应配置 / Container and requests; /settings/{module} selects config.
|
|
381
|
+
* @returns {import("./index.js").PreferencesPanel} 面板生命周期句柄 / Panel lifecycle handle.
|
|
382
|
+
*/
|
|
383
|
+
function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
|
|
384
|
+
const document = root.ownerDocument;
|
|
385
|
+
const window = document.defaultView;
|
|
386
|
+
/**
|
|
387
|
+
* 创建元素,文本统一通过 textContent 写入。
|
|
388
|
+
* Create an element and assign text only through textContent.
|
|
389
|
+
* @template {keyof HTMLElementTagNameMap} T
|
|
390
|
+
* @param {T} tag HTML 标签 / HTML tag.
|
|
391
|
+
* @param {string} className 样式类名 / CSS class name.
|
|
392
|
+
* @param {string} [text] 纯文本内容 / Plain-text content.
|
|
393
|
+
* @returns {HTMLElementTagNameMap[T]} 对应类型的元素 / Element of the corresponding type.
|
|
394
|
+
*/
|
|
395
|
+
const node = (tag, className, text) => {
|
|
396
|
+
const el = document.createElement(tag);
|
|
397
|
+
el.className = className;
|
|
398
|
+
if (text !== undefined) el.textContent = text;
|
|
399
|
+
return el;
|
|
400
|
+
};
|
|
401
|
+
const shell = node("div", "pp-panel");
|
|
402
|
+
const header = node("header", "pp-header");
|
|
403
|
+
const back = node("button", "pp-back", "‹");
|
|
404
|
+
back.setAttribute("aria-label", "返回");
|
|
405
|
+
back.type = "button";
|
|
406
|
+
const heading = node("h1", "pp-title", title);
|
|
407
|
+
const viewport = node("div", "pp-viewport");
|
|
408
|
+
const toast = node("div", "pp-toast");
|
|
409
|
+
toast.setAttribute("role", "status");
|
|
410
|
+
toast.hidden = true;
|
|
411
|
+
header.append(back, heading, node("span", "pp-nav-spacer"));
|
|
412
|
+
shell.append(header, viewport, toast);
|
|
413
|
+
root.append(shell);
|
|
414
|
+
let timer,
|
|
415
|
+
secondaryRoute,
|
|
416
|
+
routedPath,
|
|
417
|
+
generation = 0,
|
|
418
|
+
active = null,
|
|
419
|
+
saving = false,
|
|
420
|
+
pendingRoute = false,
|
|
421
|
+
destroyed = false;
|
|
422
|
+
/**
|
|
423
|
+
* 展示短暂通知,不刷新设置数据。
|
|
424
|
+
* Display a transient notification without refreshing settings.
|
|
425
|
+
* @param {{kind: "success" | "error", operation?: "write" | "delete" | "clearCaches" | "reset", message?: string}} event 操作结果 / Operation result.
|
|
426
|
+
* @returns {void} 无返回值 / No return value.
|
|
427
|
+
*/
|
|
428
|
+
const notify = event => {
|
|
429
|
+
if (destroyed) return;
|
|
430
|
+
switch (true) {
|
|
431
|
+
case event.kind === "error":
|
|
432
|
+
toast.textContent = `操作失败:${event.message}`;
|
|
433
|
+
break;
|
|
434
|
+
case event.operation === "delete":
|
|
435
|
+
toast.textContent = "删除成功";
|
|
436
|
+
break;
|
|
437
|
+
case event.operation === "clearCaches":
|
|
438
|
+
toast.textContent = "Caches 已清空";
|
|
439
|
+
break;
|
|
440
|
+
case event.operation === "reset":
|
|
441
|
+
toast.textContent = "模块已重置";
|
|
442
|
+
break;
|
|
443
|
+
default:
|
|
444
|
+
toast.textContent = "修改成功";
|
|
445
|
+
break;
|
|
446
|
+
}
|
|
447
|
+
toast.dataset.kind = event.kind;
|
|
448
|
+
toast.hidden = false;
|
|
449
|
+
clearTimeout(timer);
|
|
450
|
+
timer = setTimeout(() => {
|
|
451
|
+
toast.hidden = true;
|
|
452
|
+
}, 2400);
|
|
453
|
+
};
|
|
454
|
+
const client = createPreferencesClient({ fetch, notify });
|
|
455
|
+
/**
|
|
456
|
+
* 切换加载或错误视图,按用户的动态效果偏好播放过渡。
|
|
457
|
+
* Replace a loading or error view, respecting reduced-motion preferences.
|
|
458
|
+
* @param {HTMLElement} view 新视图 / New view.
|
|
459
|
+
* @param {number} direction 过渡方向,正数从右侧进入 / Transition direction; positive enters from the right.
|
|
460
|
+
* @returns {void} 无返回值 / No return value.
|
|
461
|
+
*/
|
|
462
|
+
function replace(view, direction) {
|
|
463
|
+
const old = viewport.firstElementChild;
|
|
464
|
+
viewport.replaceChildren(view);
|
|
465
|
+
if (old && !window.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
|
466
|
+
view.animate(
|
|
467
|
+
[
|
|
468
|
+
{ opacity: 0.4, transform: `translateX(${direction * 24}px)` },
|
|
469
|
+
{ opacity: 1, transform: "translateX(0)" },
|
|
470
|
+
],
|
|
471
|
+
{ duration: 180, easing: "ease-out" },
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* 打开模块并忽略已过期的异步结果。
|
|
476
|
+
* Open a module and ignore stale asynchronous results.
|
|
477
|
+
* @param {string} module 模块标识 / Module identifier.
|
|
478
|
+
* @returns {Promise<void>} 视图加载完成,失败显示错误视图 / View load completion; failures display an error view.
|
|
479
|
+
*/
|
|
480
|
+
async function open(module) {
|
|
481
|
+
const version = ++generation;
|
|
482
|
+
active = module;
|
|
483
|
+
back.disabled = window.history.length <= 1;
|
|
484
|
+
heading.textContent = module;
|
|
485
|
+
replace(node("p", "pp-loading", "读取设置…"), 1);
|
|
486
|
+
try {
|
|
487
|
+
await client.open(module);
|
|
488
|
+
if (version === generation) controls();
|
|
489
|
+
} catch (error) {
|
|
490
|
+
if (version !== generation) return;
|
|
491
|
+
const view = node("section", "pp-error");
|
|
492
|
+
view.append(node("p", "", `加载失败:${error.message}`));
|
|
493
|
+
const retry = node("button", "", "重新读取");
|
|
494
|
+
retry.onclick = () => open(module);
|
|
495
|
+
view.append(retry);
|
|
496
|
+
replace(view, 1);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* 从会话快照创建控件与操作按钮,不重新读取网络配置。
|
|
501
|
+
* Build controls and actions from the session snapshot without fetching config again.
|
|
502
|
+
* @returns {void} 无返回值 / No return value.
|
|
503
|
+
*/
|
|
504
|
+
function controls() {
|
|
505
|
+
const { definition, values } = client.snapshot(active);
|
|
506
|
+
heading.textContent = definition.metadata?.name || active;
|
|
507
|
+
const view = node("section", "pp-fields");
|
|
508
|
+
/** @type {Array<() => void>} 挂载后执行的多行高度更新 / Textarea sizing callbacks run after mounting. */
|
|
509
|
+
const growingInputs = [];
|
|
510
|
+
const editors = new Map();
|
|
511
|
+
const summaries = [];
|
|
512
|
+
const groups = new Map();
|
|
513
|
+
const scrollPositions = new WeakMap();
|
|
514
|
+
let activeEditor;
|
|
515
|
+
let queue = Promise.resolve(),
|
|
516
|
+
pendingWrites = 0;
|
|
517
|
+
/**
|
|
518
|
+
* 根据 hash 切换多选页,保留上级 DOM 和滚动位置。
|
|
519
|
+
* Switch multi-select views by hash while retaining parent DOM and scroll position.
|
|
520
|
+
* @returns {void} 无返回值 / No return value.
|
|
521
|
+
*/
|
|
522
|
+
const showEditor = () => {
|
|
523
|
+
let key;
|
|
524
|
+
try {
|
|
525
|
+
key = decodeURIComponent(window.location.hash.slice(1));
|
|
526
|
+
} catch {
|
|
527
|
+
key = "";
|
|
528
|
+
}
|
|
529
|
+
const editor = editors.get(key);
|
|
530
|
+
const previous = activeEditor?.node ?? view;
|
|
531
|
+
const next = editor?.node ?? view;
|
|
532
|
+
if (previous !== next) {
|
|
533
|
+
scrollPositions.set(previous, previous.scrollTop);
|
|
534
|
+
previous.remove();
|
|
535
|
+
viewport.append(next);
|
|
536
|
+
next.scrollTop = scrollPositions.get(next) ?? 0;
|
|
537
|
+
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) next.animate([{ transform: `translateX(${editor ? 100 : -100}%)` }, { transform: "translateX(0)" }], { duration: 260, easing: "cubic-bezier(.22,.61,.36,1)" });
|
|
538
|
+
}
|
|
539
|
+
activeEditor = editor;
|
|
540
|
+
heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
|
|
541
|
+
back.disabled = saving || (!editor && window.history.length <= 1);
|
|
542
|
+
};
|
|
543
|
+
secondaryRoute = showEditor;
|
|
544
|
+
/**
|
|
545
|
+
* 串行执行页面操作,输入可继续编辑,完成后处理延后导航。
|
|
546
|
+
* Serialize page actions while inputs remain editable, then process deferred navigation.
|
|
547
|
+
* @param {() => Promise<void>} action 请求或写入 / Request or mutation.
|
|
548
|
+
* @param {() => void} success 成功后的局部更新 / Local update after success.
|
|
549
|
+
* @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.
|
|
550
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
551
|
+
*/
|
|
552
|
+
function perform(action, success, failure = () => {}) {
|
|
553
|
+
pendingWrites++;
|
|
554
|
+
saving = true;
|
|
555
|
+
back.disabled = true;
|
|
556
|
+
return (queue = queue
|
|
557
|
+
.then(action)
|
|
558
|
+
.then(() => {
|
|
559
|
+
if (!destroyed) success();
|
|
560
|
+
})
|
|
561
|
+
.catch(() => {
|
|
562
|
+
/* 请求层已通知错误 / The request layer has already reported the error. */
|
|
563
|
+
if (!destroyed) failure();
|
|
564
|
+
})
|
|
565
|
+
.finally(() => {
|
|
566
|
+
pendingWrites--;
|
|
567
|
+
saving = pendingWrites > 0;
|
|
568
|
+
if (destroyed && !saving) client.leave(active);
|
|
569
|
+
back.disabled = saving || (!activeEditor && window.history.length <= 1);
|
|
570
|
+
if (!saving && !destroyed && pendingRoute) route();
|
|
571
|
+
}));
|
|
572
|
+
}
|
|
573
|
+
const metadata = definition.metadata;
|
|
574
|
+
if (metadata) {
|
|
575
|
+
const info = node("div", "pp-module-info");
|
|
576
|
+
const iconURL = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
577
|
+
/**
|
|
578
|
+
* 将元数据地址解析为可显示的 HTTP(S) URL。
|
|
579
|
+
* Resolve a metadata address into an HTTP(S) URL suitable for display.
|
|
580
|
+
* @param {string} value 绝对或相对地址 / Absolute or relative address.
|
|
581
|
+
* @returns {string} 完整地址 / Absolute URL.
|
|
582
|
+
* @throws {TypeError} 非 HTTP(S) 协议 / Non-HTTP(S) protocol.
|
|
583
|
+
*/
|
|
584
|
+
const resourceURL = value => {
|
|
585
|
+
const url = new window.URL(value, window.location.href);
|
|
586
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Module metadata URLs must use HTTP or HTTPS");
|
|
587
|
+
return url.href;
|
|
588
|
+
};
|
|
589
|
+
if (iconURL) {
|
|
590
|
+
const image = node("img", "pp-module-icon");
|
|
591
|
+
image.src = resourceURL(iconURL);
|
|
592
|
+
image.alt = "";
|
|
593
|
+
info.append(image);
|
|
594
|
+
}
|
|
595
|
+
const details = node("div", "pp-module-details");
|
|
596
|
+
for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(node("p", "pp-description", description));
|
|
597
|
+
if (metadata.repo) {
|
|
598
|
+
const link = node("a", "pp-module-source", "项目主页");
|
|
599
|
+
link.href = resourceURL(metadata.repo);
|
|
600
|
+
link.target = "_blank";
|
|
601
|
+
link.rel = "noopener noreferrer";
|
|
602
|
+
details.append(link);
|
|
603
|
+
}
|
|
604
|
+
info.append(details);
|
|
605
|
+
view.append(info);
|
|
606
|
+
}
|
|
607
|
+
for (const field of definition.fields) {
|
|
608
|
+
const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
|
|
609
|
+
const group = match?.[1] ?? "通用";
|
|
610
|
+
if (!groups.has(group)) {
|
|
611
|
+
const section = node("section", "form-group");
|
|
612
|
+
const rows = node("div", "form-group__row");
|
|
613
|
+
section.append(node("h2", "form-group__title", group), rows);
|
|
614
|
+
groups.set(group, rows);
|
|
615
|
+
view.append(section);
|
|
616
|
+
}
|
|
617
|
+
const row = node("div", "form-row pp-field");
|
|
618
|
+
const label = node("div", "form-row__text");
|
|
619
|
+
label.append(node("span", "form-row__title", match?.[2] ?? field.name));
|
|
620
|
+
if (field.description) label.append(node("span", "form-row__subtitle", field.description));
|
|
621
|
+
row.append(label);
|
|
622
|
+
const value = values[field.key];
|
|
623
|
+
/** @type {() => unknown} 读取尚未保存的输入 / Read the unsaved input. */
|
|
624
|
+
let read;
|
|
625
|
+
/** @type {(value: unknown) => void} 更新当前控件 / Update the current control. */
|
|
626
|
+
let write;
|
|
627
|
+
let inputContainer = row;
|
|
628
|
+
let eventName = "change";
|
|
629
|
+
switch (true) {
|
|
630
|
+
case Boolean(field.options) && field.type !== "array": {
|
|
631
|
+
const select = node("select", "pp-input");
|
|
632
|
+
select.setAttribute("aria-label", field.name);
|
|
633
|
+
field.options.forEach((option, index) => {
|
|
634
|
+
const item = node("option", "", option.label);
|
|
635
|
+
item.value = String(index);
|
|
636
|
+
select.append(item);
|
|
637
|
+
});
|
|
638
|
+
write = value => {
|
|
639
|
+
select.selectedIndex = field.options.findIndex(option => option.key === value);
|
|
640
|
+
};
|
|
641
|
+
row.append(select);
|
|
642
|
+
read = () => field.options[select.selectedIndex]?.key;
|
|
643
|
+
break;
|
|
644
|
+
}
|
|
645
|
+
case field.type === "array" && Boolean(field.options): {
|
|
646
|
+
const page = node("section", "pp-choice-page");
|
|
647
|
+
if (field.description) page.append(node("p", "pp-description", field.description));
|
|
648
|
+
const choices = node("div", "form-group__row");
|
|
649
|
+
page.append(choices);
|
|
650
|
+
inputContainer = choices;
|
|
651
|
+
editors.set(field.key, { node: page, title: match?.[2] ?? field.name });
|
|
652
|
+
const summary = node("span", "form-row__value pp-summary");
|
|
653
|
+
const link = node("button", "pp-choice-link");
|
|
654
|
+
link.type = "button";
|
|
655
|
+
link.setAttribute("aria-label", field.name);
|
|
656
|
+
link.append(summary, node("span", "pp-chevron", "›"));
|
|
657
|
+
row.append(link);
|
|
658
|
+
const refresh = () => {
|
|
659
|
+
const value = client.snapshot(active).values[field.key];
|
|
660
|
+
summary.textContent =
|
|
661
|
+
field.options
|
|
662
|
+
.filter(option => Array.isArray(value) && value.includes(option.key))
|
|
663
|
+
.map(option => option.label)
|
|
664
|
+
.join("、") || "未选择";
|
|
665
|
+
};
|
|
666
|
+
summaries.push(refresh);
|
|
667
|
+
refresh();
|
|
668
|
+
link.onclick = () => {
|
|
669
|
+
window.history.pushState({ ...window.history.state, preferencePane: active }, "", `#${encodeURIComponent(field.key)}`);
|
|
670
|
+
showEditor();
|
|
671
|
+
};
|
|
672
|
+
row.addEventListener("click", event => {
|
|
673
|
+
if (!link.contains(event.target)) link.click();
|
|
674
|
+
});
|
|
675
|
+
const inputs = field.options.map(option => {
|
|
676
|
+
const label = node("label", "form-row pp-choice", option.label);
|
|
677
|
+
const input = node("input", "");
|
|
678
|
+
input.type = "checkbox";
|
|
679
|
+
input.setAttribute("aria-label", option.label);
|
|
680
|
+
label.append(input);
|
|
681
|
+
choices.append(label);
|
|
682
|
+
return { input, key: option.key };
|
|
683
|
+
});
|
|
684
|
+
read = () => inputs.filter(option => option.input.checked).map(option => option.key);
|
|
685
|
+
write = value => {
|
|
686
|
+
for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);
|
|
687
|
+
};
|
|
688
|
+
break;
|
|
689
|
+
}
|
|
690
|
+
default: {
|
|
691
|
+
const multiline = field.control === "textarea" || field.type === "array";
|
|
692
|
+
const input = node(multiline ? "textarea" : "input", "pp-input");
|
|
693
|
+
if (multiline) row.classList.add("pp-multiline");
|
|
694
|
+
input.setAttribute("aria-label", field.name);
|
|
695
|
+
if (field.placeholder) input.placeholder = field.placeholder;
|
|
696
|
+
if (multiline && field.rows) input.rows = field.rows;
|
|
697
|
+
/**
|
|
698
|
+
* 在挂载后根据内容调整高度,同时保留基础行数。
|
|
699
|
+
* Size mounted textareas to their contents while retaining baseline rows.
|
|
700
|
+
* @returns {void} 无返回值 / No return value.
|
|
701
|
+
*/
|
|
702
|
+
const grow = () => {
|
|
703
|
+
if (!multiline || !field.autoGrow || !input.isConnected) return;
|
|
704
|
+
input.style.height = "auto";
|
|
705
|
+
const baseline = input.getBoundingClientRect().height;
|
|
706
|
+
const style = window.getComputedStyle(input);
|
|
707
|
+
const borders = Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
|
|
708
|
+
input.style.height = `${Math.max(baseline, input.scrollHeight + borders)}px`;
|
|
709
|
+
};
|
|
710
|
+
if (multiline && field.autoGrow) {
|
|
711
|
+
input.addEventListener("input", grow);
|
|
712
|
+
growingInputs.push(grow);
|
|
713
|
+
}
|
|
714
|
+
if (field.type === "boolean") {
|
|
715
|
+
input.type = "checkbox";
|
|
716
|
+
input.classList.add("pp-switch");
|
|
717
|
+
input.setAttribute("role", "switch");
|
|
718
|
+
write = value => {
|
|
719
|
+
input.checked = value === true;
|
|
720
|
+
};
|
|
721
|
+
read = () => input.checked;
|
|
722
|
+
} else {
|
|
723
|
+
eventName = "input";
|
|
724
|
+
if (!multiline) input.type = field.type === "number" ? "number" : "text";
|
|
725
|
+
write = value => {
|
|
726
|
+
input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
|
|
727
|
+
grow();
|
|
728
|
+
};
|
|
729
|
+
read = () => {
|
|
730
|
+
switch (field.type) {
|
|
731
|
+
case "array":
|
|
732
|
+
return JSON.parse(input.value);
|
|
733
|
+
case "number":
|
|
734
|
+
return input.value === "" ? Number.NaN : Number(input.value);
|
|
735
|
+
default:
|
|
736
|
+
return input.value;
|
|
737
|
+
}
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
row.append(input);
|
|
741
|
+
break;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
write(value);
|
|
745
|
+
let inputVersion = 0;
|
|
746
|
+
inputContainer.addEventListener(eventName, event => {
|
|
747
|
+
if (event.isComposing) return;
|
|
748
|
+
const version = ++inputVersion,
|
|
749
|
+
module = active;
|
|
750
|
+
let value;
|
|
751
|
+
try {
|
|
752
|
+
value = read();
|
|
753
|
+
} catch (error) {
|
|
754
|
+
notify({ kind: "error", message: error.message });
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
const restore = () => {
|
|
758
|
+
if (version === inputVersion) write(client.snapshot(module).values[field.key]);
|
|
759
|
+
};
|
|
760
|
+
perform(
|
|
761
|
+
() => client.set(module, field.key, value),
|
|
762
|
+
() => {
|
|
763
|
+
for (const refresh of summaries) refresh();
|
|
764
|
+
},
|
|
765
|
+
restore,
|
|
766
|
+
);
|
|
767
|
+
});
|
|
768
|
+
if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
|
|
769
|
+
groups.get(group).append(row);
|
|
770
|
+
}
|
|
771
|
+
const maintenance = node("section", "pp-maintenance");
|
|
772
|
+
maintenance.append(node("h2", "pp-title", "模块数据"));
|
|
773
|
+
const actions = node("div", "pp-actions");
|
|
774
|
+
const cacheView = node("button", "", "查看 Caches");
|
|
775
|
+
const cacheClear = node("button", "", "清空 Caches");
|
|
776
|
+
const reset = node("button", "pp-danger", "重置模块");
|
|
777
|
+
const output = node("pre", "pp-cache");
|
|
778
|
+
output.hidden = true;
|
|
779
|
+
output.setAttribute("aria-label", "Caches 内容");
|
|
780
|
+
for (const button of [cacheView, cacheClear, reset]) button.type = "button";
|
|
781
|
+
cacheView.onclick = () => {
|
|
782
|
+
if (saving) return;
|
|
783
|
+
let value;
|
|
784
|
+
return perform(
|
|
785
|
+
async () => {
|
|
786
|
+
try {
|
|
787
|
+
value = await client.readCaches(active);
|
|
788
|
+
} catch (error) {
|
|
789
|
+
notify({ kind: "error", message: error.message });
|
|
790
|
+
throw error;
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
() => {
|
|
794
|
+
output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
|
|
795
|
+
output.hidden = false;
|
|
796
|
+
cacheView.textContent = "刷新 Caches";
|
|
797
|
+
},
|
|
798
|
+
);
|
|
799
|
+
};
|
|
800
|
+
cacheClear.onclick = () => {
|
|
801
|
+
if (saving) return;
|
|
802
|
+
if (!window.confirm(`清空 ${active} 的全部 Caches?`)) return;
|
|
803
|
+
return perform(
|
|
804
|
+
() => client.clearCaches(active),
|
|
805
|
+
() => {
|
|
806
|
+
output.textContent = "暂无缓存";
|
|
807
|
+
},
|
|
808
|
+
);
|
|
809
|
+
};
|
|
810
|
+
reset.onclick = () => {
|
|
811
|
+
if (saving) return;
|
|
812
|
+
if (!window.confirm(`重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) return;
|
|
813
|
+
return perform(() => client.reset(active), controls);
|
|
814
|
+
};
|
|
815
|
+
actions.append(cacheView, cacheClear, reset);
|
|
816
|
+
maintenance.append(actions, output);
|
|
817
|
+
view.append(maintenance);
|
|
818
|
+
viewport.replaceChildren(view);
|
|
819
|
+
for (const grow of growingInputs) grow();
|
|
820
|
+
showEditor();
|
|
821
|
+
}
|
|
822
|
+
/**
|
|
823
|
+
* 按页面 pathname 切换模块,写入尚未完成时延后导航。
|
|
824
|
+
* Route by the page pathname, deferring navigation while a mutation is pending.
|
|
825
|
+
* @returns {void} 无返回值 / No return value.
|
|
826
|
+
*/
|
|
827
|
+
function route() {
|
|
828
|
+
if (saving) {
|
|
829
|
+
pendingRoute = true;
|
|
830
|
+
return;
|
|
831
|
+
}
|
|
832
|
+
pendingRoute = false;
|
|
833
|
+
secondaryRoute = undefined;
|
|
834
|
+
if (active) client.leave(active);
|
|
835
|
+
routedPath = window.location.pathname;
|
|
836
|
+
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(routedPath);
|
|
837
|
+
if (!match) {
|
|
838
|
+
generation++;
|
|
839
|
+
active = null;
|
|
840
|
+
heading.textContent = title;
|
|
841
|
+
replace(node("p", "pp-error", "页面地址应为 /settings/模块标识。"), 1);
|
|
842
|
+
return;
|
|
843
|
+
}
|
|
844
|
+
open(match[1]);
|
|
845
|
+
}
|
|
846
|
+
/**
|
|
847
|
+
* 仅在 pathname 改变时处理历史导航。
|
|
848
|
+
* Handle history navigation only when the pathname changes.
|
|
849
|
+
* @returns {void} 无返回值 / No return value.
|
|
850
|
+
*/
|
|
851
|
+
const onPopState = () => {
|
|
852
|
+
if (window.location.pathname !== routedPath) route();
|
|
853
|
+
else secondaryRoute?.();
|
|
854
|
+
};
|
|
855
|
+
const onHashChange = () => secondaryRoute?.();
|
|
856
|
+
/**
|
|
857
|
+
* 从浏览器往返缓存恢复时重新读取当前模块。
|
|
858
|
+
* Reload the current module when restored from the browser back-forward cache.
|
|
859
|
+
* @param {PageTransitionEvent} event 页面恢复事件 / Page restoration event.
|
|
860
|
+
* @returns {void} 无返回值 / No return value.
|
|
861
|
+
*/
|
|
862
|
+
const onPageShow = event => {
|
|
863
|
+
if (event.persisted) route();
|
|
864
|
+
};
|
|
865
|
+
back.onclick = () => {
|
|
866
|
+
if (saving) return;
|
|
867
|
+
if (window.location.hash && window.history.state?.preferencePane !== active) {
|
|
868
|
+
window.history.replaceState(window.history.state, "", window.location.pathname);
|
|
869
|
+
secondaryRoute?.();
|
|
870
|
+
} else window.history.back();
|
|
871
|
+
};
|
|
872
|
+
window.addEventListener("popstate", onPopState);
|
|
873
|
+
window.addEventListener("pageshow", onPageShow);
|
|
874
|
+
window.addEventListener("hashchange", onHashChange);
|
|
875
|
+
route();
|
|
876
|
+
return {
|
|
877
|
+
/**
|
|
878
|
+
* 移除监听器、定时器、会话和挂载内容。
|
|
879
|
+
* Remove listeners, timers, session and mounted content.
|
|
880
|
+
* @returns {void} 无返回值 / No return value.
|
|
881
|
+
*/
|
|
882
|
+
destroy() {
|
|
883
|
+
destroyed = true;
|
|
884
|
+
window.removeEventListener("popstate", onPopState);
|
|
885
|
+
window.removeEventListener("pageshow", onPageShow);
|
|
886
|
+
window.removeEventListener("hashchange", onHashChange);
|
|
887
|
+
generation++;
|
|
888
|
+
if (active && !saving) client.leave(active);
|
|
889
|
+
clearTimeout(timer);
|
|
890
|
+
shell.remove();
|
|
891
|
+
},
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* 自包含页面入口,业务名称、图标和入口全部从站点 BoxJS JSON 读取。
|
|
897
|
+
* Self-contained page entry reading all branding, icons and navigation from site BoxJS JSON.
|
|
898
|
+
* @module @nsnanocat/preference-panes/site
|
|
899
|
+
*/
|
|
900
|
+
const root = document.querySelector("#preferences");
|
|
901
|
+
const client = createPreferencesClient({ timeout: 3500 });
|
|
902
|
+
const theme = navigator.userAgent.match(/themeId\/(\d+)/)?.[1];
|
|
903
|
+
if (theme) document.documentElement.dataset.theme = theme === "2" ? "dark" : "light";
|
|
904
|
+
let panel,
|
|
905
|
+
menu,
|
|
906
|
+
revision = 0,
|
|
907
|
+
routedPath;
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* 创建只含纯文本的元素。
|
|
911
|
+
* Create an element containing plain text only.
|
|
912
|
+
* @param {string} tag 标签 / Tag.
|
|
913
|
+
* @param {string} className 样式 / CSS class.
|
|
914
|
+
* @param {string} [text] 文本 / Text.
|
|
915
|
+
* @returns {HTMLElement} 元素 / Element.
|
|
916
|
+
*/
|
|
917
|
+
function node(tag, className, text) {
|
|
918
|
+
const element = document.createElement(tag);
|
|
919
|
+
element.className = className;
|
|
920
|
+
if (text !== undefined) element.textContent = text;
|
|
921
|
+
return element;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* 按站点 JSON 显示图标;iconDark 是明确的暗色扩展,不改变 BoxJS icons 语义。
|
|
926
|
+
* Display configured icons; iconDark is an explicit dark variant without changing BoxJS icons semantics.
|
|
927
|
+
* @param {{icon?:string,iconDark?:string,icons?:string[]}} metadata 图标数据 / Icon data.
|
|
928
|
+
* @param {string} className 样式 / CSS class.
|
|
929
|
+
* @returns {HTMLPictureElement} 图片元素 / Picture element.
|
|
930
|
+
*/
|
|
931
|
+
function icon(metadata, className) {
|
|
932
|
+
const picture = node("picture", className),
|
|
933
|
+
image = node("img", "");
|
|
934
|
+
if (metadata.iconDark) {
|
|
935
|
+
const source = node("source", "");
|
|
936
|
+
source.media = theme ? (theme === "2" ? "all" : "not all") : "(prefers-color-scheme: dark)";
|
|
937
|
+
source.srcset = resourceURL(metadata.iconDark);
|
|
938
|
+
picture.append(source);
|
|
939
|
+
}
|
|
940
|
+
const selected = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
941
|
+
if (selected) image.src = resourceURL(selected);
|
|
942
|
+
image.alt = "";
|
|
943
|
+
picture.append(image);
|
|
944
|
+
return picture;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* 校验配置中的资源 URL。
|
|
949
|
+
* Validate resource URLs in site configuration.
|
|
950
|
+
* @param {string} value 地址 / Address.
|
|
951
|
+
* @returns {string} 完整 URL / Absolute URL.
|
|
952
|
+
*/
|
|
953
|
+
function resourceURL(value) {
|
|
954
|
+
const url = new URL(value, location.href);
|
|
955
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Resources must use HTTP(S)");
|
|
956
|
+
return url.href;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* 挂载通用设置或站点菜单,每次进入菜单都重新 HEAD 探测。
|
|
961
|
+
* Mount settings or the site menu, repeating HEAD probes on every menu entry.
|
|
962
|
+
* @returns {Promise<void>} 挂载完成 / Mount completion.
|
|
963
|
+
*/
|
|
964
|
+
async function render() {
|
|
965
|
+
routedPath = location.pathname;
|
|
966
|
+
const version = ++revision;
|
|
967
|
+
panel?.destroy();
|
|
968
|
+
panel = undefined;
|
|
969
|
+
root.replaceChildren();
|
|
970
|
+
if (routedPath !== "/settings/") {
|
|
971
|
+
document.title = routedPath.split("/")[2] || "Preferences";
|
|
972
|
+
panel = mountPreferencePanes({ element: root });
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
try {
|
|
976
|
+
if (!menu) {
|
|
977
|
+
const response = await fetch("/settings/assets/site.boxjs.json", { cache: "no-store", credentials: "omit" });
|
|
978
|
+
if (response.status !== 200) throw new Error(`Site config HTTP ${response.status}`);
|
|
979
|
+
const data = await response.json();
|
|
980
|
+
if (typeof data.name !== "string" || !Array.isArray(data.apps)) throw new TypeError("Invalid site BoxJS JSON");
|
|
981
|
+
for (const app of data.apps) validatePathParts([app.module]);
|
|
982
|
+
for (const href of data.stylesheets ?? []) {
|
|
983
|
+
const link = document.createElement("link");
|
|
984
|
+
link.rel = "stylesheet";
|
|
985
|
+
link.href = resourceURL(href);
|
|
986
|
+
document.head.append(link);
|
|
987
|
+
}
|
|
988
|
+
menu = data;
|
|
989
|
+
}
|
|
990
|
+
if (version !== revision) return;
|
|
991
|
+
document.title = menu.name;
|
|
992
|
+
const home = node("section", "pp-home");
|
|
993
|
+
home.append(icon(menu, "brand-logo"), node("h1", "", menu.name));
|
|
994
|
+
const section = node("section", "self-panel is-zh");
|
|
995
|
+
section.append(node("h2", "header", menu.sectionTitle ?? "模块"));
|
|
996
|
+
const container = node("div", "container"),
|
|
997
|
+
scrollView = node("div", "scroll-view"),
|
|
998
|
+
rows = node("div", "scroll");
|
|
999
|
+
scrollView.append(rows);
|
|
1000
|
+
container.append(scrollView);
|
|
1001
|
+
section.append(container);
|
|
1002
|
+
home.append(section);
|
|
1003
|
+
if (menu.desc) home.append(node("p", "settings-note", menu.desc));
|
|
1004
|
+
root.append(home);
|
|
1005
|
+
for (const app of menu.apps) {
|
|
1006
|
+
const button = node("button", "self-item is-zh"),
|
|
1007
|
+
status = node("span", "module-status", "检测中");
|
|
1008
|
+
button.type = "button";
|
|
1009
|
+
button.disabled = true;
|
|
1010
|
+
button.dataset.module = app.module;
|
|
1011
|
+
button.append(icon(app, "logo"), node("span", "name", app.name ?? app.module), status);
|
|
1012
|
+
rows.append(button);
|
|
1013
|
+
button.onclick = () => {
|
|
1014
|
+
history.pushState(null, "", `/settings/${app.module}`);
|
|
1015
|
+
render();
|
|
1016
|
+
if (!matchMedia("(prefers-reduced-motion: reduce)").matches) root.animate([{ transform: "translateX(100%)" }, { transform: "translateX(0)" }], { duration: 260, easing: "ease-out" });
|
|
1017
|
+
};
|
|
1018
|
+
client.probe(app.module).then(available => {
|
|
1019
|
+
if (revision !== version) return;
|
|
1020
|
+
button.disabled = !available;
|
|
1021
|
+
status.textContent = available ? "" : "未响应";
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
} catch (error) {
|
|
1025
|
+
if (version === revision) {
|
|
1026
|
+
const retry = node("button", "", "重新读取");
|
|
1027
|
+
retry.type = "button";
|
|
1028
|
+
retry.onclick = render;
|
|
1029
|
+
root.replaceChildren(node("p", "pp-site-error", `加载失败:${error.message}`), retry);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
window.addEventListener("popstate", () => {
|
|
1035
|
+
if (routedPath !== location.pathname) {
|
|
1036
|
+
render();
|
|
1037
|
+
if (location.pathname === "/settings/" && !matchMedia("(prefers-reduced-motion: reduce)").matches) root.animate([{ transform: "translateX(-100%)" }, { transform: "translateX(0)" }], { duration: 260, easing: "ease-out" });
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
window.addEventListener("pageshow", event => {
|
|
1041
|
+
if (event.persisted && location.pathname === "/settings/") render();
|
|
1042
|
+
});
|
|
1043
|
+
render();
|