@nsnanocat/preference-panes 0.2.0 → 0.3.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.
@@ -4,278 +4,429 @@ import { createPreferencesClient } from "./client.mjs";
4
4
  * 挂载从 BoxJS 实时生成的设置面板和短暂通知。
5
5
  * Mount runtime-generated BoxJS controls and transient notifications.
6
6
  * @param {import("./index.js").PreferencesPanelOptions} options 容器与请求;页面路径 /settings/{module} 对应配置 / Container and requests; /settings/{module} selects config.
7
- * @returns {{destroy(): void}} 清理接口 / Cleanup handle.
7
+ * @returns {import("./index.js").PreferencesPanel} 面板生命周期句柄 / Panel lifecycle handle.
8
8
  */
9
9
  export function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
10
- const document = root.ownerDocument;
11
- const window = document.defaultView;
12
- const node = (tag, className, text) => {
13
- const el = document.createElement(tag);
14
- el.className = className;
15
- if (text !== undefined) el.textContent = text;
16
- return el;
17
- };
18
- const shell = node("div", "pp-panel");
19
- const header = node("header", "pp-header");
20
- const back = node("button", "pp-back", "返回");
21
- back.type = "button";
22
- const heading = node("h1", "pp-title", title);
23
- const viewport = node("div", "pp-viewport");
24
- const toast = node("div", "pp-toast");
25
- toast.setAttribute("role", "status");
26
- toast.hidden = true;
27
- header.append(back, heading);
28
- shell.append(header, viewport, toast);
29
- root.append(shell);
30
- let timer,
31
- routedPath,
32
- generation = 0,
33
- active = null,
34
- saving = false,
35
- pendingRoute = false,
36
- destroyed = false;
37
- const notify = (event) => {
38
- if (destroyed) return;
39
- toast.textContent = event.kind === "error" ? `操作失败:${event.message}` : event.operation === "delete" ? "删除成功" : "修改成功";
40
- toast.dataset.kind = event.kind;
41
- toast.hidden = false;
42
- clearTimeout(timer);
43
- timer = setTimeout(() => {
44
- toast.hidden = true;
45
- }, 2400);
46
- };
47
- const client = createPreferencesClient({ ...(fetch ? { fetch } : {}), notify });
48
- function replace(view, direction) {
49
- const old = viewport.firstElementChild;
50
- viewport.replaceChildren(view);
51
- if (old && !document.defaultView.matchMedia("(prefers-reduced-motion: reduce)").matches)
52
- view.animate(
53
- [
54
- { opacity: 0.4, transform: `translateX(${direction * 24}px)` },
55
- { opacity: 1, transform: "translateX(0)" },
56
- ],
57
- { duration: 180, easing: "ease-out" },
58
- );
59
- }
60
- async function open(module) {
61
- const version = ++generation;
62
- active = module;
63
- back.disabled = window.history.length <= 1;
64
- heading.textContent = module;
65
- replace(node("p", "pp-loading", "读取设置…"), 1);
66
- try {
67
- await client.open(module);
68
- if (version === generation) controls();
69
- } catch (error) {
70
- if (version !== generation) return;
71
- const view = node("section", "pp-error");
72
- view.append(node("p", "", `加载失败:${error.message}`));
73
- const retry = node("button", "", "重新读取");
74
- retry.onclick = () => open(module);
75
- view.append(retry);
76
- replace(view, 1);
77
- }
78
- }
79
- function controls() {
80
- const { definition, values } = client.snapshot(active);
81
- heading.textContent = definition.metadata?.name || active;
82
- const view = node("section", "pp-fields");
83
- const growingInputs = [];
84
- const metadata = definition.metadata;
85
- if (metadata) {
86
- const info = node("div", "pp-module-info");
87
- const iconURL = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
88
- const resourceURL = (value) => {
89
- const url = new window.URL(value, window.location.href);
90
- if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Module metadata URLs must use HTTP or HTTPS");
91
- return url.href;
92
- };
93
- if (iconURL) {
94
- const image = node("img", "pp-module-icon");
95
- image.src = resourceURL(iconURL);
96
- image.alt = "";
97
- info.append(image);
98
- }
99
- const details = node("div", "pp-module-details");
100
- if (metadata.author) details.append(node("p", "pp-description", metadata.author));
101
- for (const description of [metadata.desc ?? metadata.description, ...(metadata.descs ?? [])])
102
- if (description) details.append(node("p", "pp-description", description));
103
- if (metadata.repo) {
104
- const link = node("a", "pp-module-source", "项目主页");
105
- link.href = resourceURL(metadata.repo);
106
- link.target = "_blank";
107
- link.rel = "noopener noreferrer";
108
- details.append(link);
109
- }
110
- info.append(details);
111
- view.append(info);
112
- }
113
- for (const field of definition.fields) {
114
- const row = node("fieldset", "pp-field");
115
- row.append(node("legend", "", field.name));
116
- if (field.description) row.append(node("p", "pp-description", field.description));
117
- const value = values[field.key];
118
- let read, write;
119
- if (field.options && field.type !== "array") {
120
- const select = node("select", "pp-input");
121
- select.setAttribute("aria-label", field.name);
122
- field.options.forEach((option, index) => {
123
- const item = node("option", "", option.label);
124
- item.value = String(index);
125
- select.append(item);
126
- });
127
- write = (value) => {
128
- select.selectedIndex = field.options.findIndex((option) => option.key === value);
129
- };
130
- row.append(select);
131
- read = () => field.options[select.selectedIndex]?.key;
132
- } else if (field.type === "array" && field.options) {
133
- const inputs = field.options.map((option) => {
134
- const label = node("label", "pp-choice", option.label);
135
- const input = node("input", "");
136
- input.type = "checkbox";
137
- input.checked = Array.isArray(value) && value.includes(option.key);
138
- label.prepend(input);
139
- row.append(label);
140
- return { input, key: option.key };
141
- });
142
- read = () => inputs.filter((option) => option.input.checked).map((option) => option.key);
143
- write = (value) => {
144
- for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);
145
- };
146
- } else {
147
- const multiline = field.control === "textarea" || field.type === "array";
148
- const input = node(multiline ? "textarea" : "input", "pp-input");
149
- input.setAttribute("aria-label", field.name);
150
- if (field.placeholder) input.placeholder = field.placeholder;
151
- if (multiline && field.rows) input.rows = field.rows;
152
- const grow = () => {
153
- if (!multiline || !field.autoGrow || !input.isConnected) return;
154
- input.style.height = "auto";
155
- const baseline = input.getBoundingClientRect().height;
156
- const style = window.getComputedStyle(input);
157
- const borders = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
158
- input.style.height = `${Math.max(baseline, input.scrollHeight + borders)}px`;
159
- };
160
- if (multiline && field.autoGrow) {
161
- input.addEventListener("input", grow);
162
- growingInputs.push(grow);
163
- }
164
- if (field.type === "boolean") {
165
- input.type = "checkbox";
166
- write = (value) => {
167
- input.checked = value === true;
168
- };
169
- read = () => input.checked;
170
- } else {
171
- if (!multiline) input.type = field.type === "number" ? "number" : "text";
172
- write = (value) => {
173
- input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
174
- grow();
175
- };
176
- read = () =>
177
- field.type === "array"
178
- ? JSON.parse(input.value)
179
- : field.type === "number"
180
- ? input.value === ""
181
- ? Number.NaN
182
- : Number(input.value)
183
- : input.value;
184
- }
185
- row.append(input);
186
- }
187
- write(value);
188
- const actions = node("div", "pp-actions");
189
- for (const [operation, label] of [
190
- ["write", "保存"],
191
- ["delete", "删除覆盖值"],
192
- ]) {
193
- const button = node("button", "", label);
194
- button.type = "button";
195
- button.onclick = async () => {
196
- if (saving) return;
197
- saving = true;
198
- back.disabled = true;
199
- view.querySelectorAll("button,input,select,textarea").forEach((input) => {
200
- input.disabled = true;
201
- });
202
- let success = false;
203
- try {
204
- if (operation === "delete") await client.remove(active, field.key);
205
- else {
206
- let value;
207
- try {
208
- value = read();
209
- } catch (error) {
210
- notify({ kind: "error", message: error.message });
211
- throw error;
212
- }
213
- await client.set(active, field.key, value);
214
- }
215
- success = true;
216
- } catch {
217
- /* 客户端已显示错误通知 / Client already displayed an error notification. */
218
- } finally {
219
- saving = false;
220
- back.disabled = window.history.length <= 1;
221
- view.querySelectorAll("button,input,select,textarea").forEach((input) => {
222
- input.disabled = false;
223
- });
224
- if (success && !destroyed) {
225
- // 只更新当前控件,保留其它尚未保存的输入。
226
- // Update this control without discarding other unsaved inputs.
227
- write(client.snapshot(active).values[field.key]);
228
- }
229
- if (!destroyed && pendingRoute) route();
230
- }
231
- };
232
- actions.append(button);
233
- }
234
- row.append(actions);
235
- view.append(row);
236
- }
237
- viewport.replaceChildren(view);
238
- for (const grow of growingInputs) grow();
239
- }
240
- function route() {
241
- if (saving) {
242
- pendingRoute = true;
243
- return;
244
- }
245
- pendingRoute = false;
246
- if (active) client.leave(active);
247
- routedPath = window.location.pathname;
248
- const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(routedPath);
249
- if (!match) {
250
- generation++;
251
- active = null;
252
- heading.textContent = title;
253
- replace(node("p", "pp-error", "页面地址应为 /settings/模块标识。"), 1);
254
- return;
255
- }
256
- open(match[1]);
257
- }
258
- const onPopState = () => {
259
- if (window.location.pathname !== routedPath) route();
260
- };
261
- const onPageShow = (event) => {
262
- if (event.persisted) route();
263
- };
264
- back.onclick = () => {
265
- if (!saving) window.history.back();
266
- };
267
- window.addEventListener("popstate", onPopState);
268
- window.addEventListener("pageshow", onPageShow);
269
- route();
270
- return {
271
- destroy() {
272
- destroyed = true;
273
- window.removeEventListener("popstate", onPopState);
274
- window.removeEventListener("pageshow", onPageShow);
275
- generation++;
276
- if (active) client.leave(active);
277
- clearTimeout(timer);
278
- shell.remove();
279
- },
280
- };
10
+ const document = root.ownerDocument;
11
+ const window = document.defaultView;
12
+ /**
13
+ * 创建元素,文本统一通过 textContent 写入。
14
+ * Create an element and assign text only through textContent.
15
+ * @template {keyof HTMLElementTagNameMap} T
16
+ * @param {T} tag HTML 标签 / HTML tag.
17
+ * @param {string} className 样式类名 / CSS class name.
18
+ * @param {string} [text] 纯文本内容 / Plain-text content.
19
+ * @returns {HTMLElementTagNameMap[T]} 对应类型的元素 / Element of the corresponding type.
20
+ */
21
+ const node = (tag, className, text) => {
22
+ const el = document.createElement(tag);
23
+ el.className = className;
24
+ if (text !== undefined) el.textContent = text;
25
+ return el;
26
+ };
27
+ const shell = node("div", "pp-panel");
28
+ const header = node("header", "pp-header");
29
+ const back = node("button", "pp-back", "返回");
30
+ back.type = "button";
31
+ const heading = node("h1", "pp-title", title);
32
+ const viewport = node("div", "pp-viewport");
33
+ const toast = node("div", "pp-toast");
34
+ toast.setAttribute("role", "status");
35
+ toast.hidden = true;
36
+ header.append(back, heading);
37
+ shell.append(header, viewport, toast);
38
+ root.append(shell);
39
+ let timer,
40
+ routedPath,
41
+ generation = 0,
42
+ active = null,
43
+ saving = false,
44
+ pendingRoute = false,
45
+ destroyed = false;
46
+ /**
47
+ * 展示短暂通知,不刷新设置数据。
48
+ * Display a transient notification without refreshing settings.
49
+ * @param {{kind: "success" | "error", operation?: "write" | "delete" | "clearCaches" | "reset", message?: string}} event 操作结果 / Operation result.
50
+ * @returns {void} 无返回值 / No return value.
51
+ */
52
+ const notify = event => {
53
+ if (destroyed) return;
54
+ switch (true) {
55
+ case event.kind === "error":
56
+ toast.textContent = `操作失败:${event.message}`;
57
+ break;
58
+ case event.operation === "delete":
59
+ toast.textContent = "删除成功";
60
+ break;
61
+ case event.operation === "clearCaches":
62
+ toast.textContent = "Caches 已清空";
63
+ break;
64
+ case event.operation === "reset":
65
+ toast.textContent = "模块已重置";
66
+ break;
67
+ default:
68
+ toast.textContent = "修改成功";
69
+ break;
70
+ }
71
+ toast.dataset.kind = event.kind;
72
+ toast.hidden = false;
73
+ clearTimeout(timer);
74
+ timer = setTimeout(() => {
75
+ toast.hidden = true;
76
+ }, 2400);
77
+ };
78
+ const client = createPreferencesClient({ fetch, notify });
79
+ /**
80
+ * 切换加载或错误视图,按用户的动态效果偏好播放过渡。
81
+ * Replace a loading or error view, respecting reduced-motion preferences.
82
+ * @param {HTMLElement} view 新视图 / New view.
83
+ * @param {number} direction 过渡方向,正数从右侧进入 / Transition direction; positive enters from the right.
84
+ * @returns {void} 无返回值 / No return value.
85
+ */
86
+ function replace(view, direction) {
87
+ const old = viewport.firstElementChild;
88
+ viewport.replaceChildren(view);
89
+ if (old && !window.matchMedia("(prefers-reduced-motion: reduce)").matches)
90
+ view.animate(
91
+ [
92
+ { opacity: 0.4, transform: `translateX(${direction * 24}px)` },
93
+ { opacity: 1, transform: "translateX(0)" },
94
+ ],
95
+ { duration: 180, easing: "ease-out" },
96
+ );
97
+ }
98
+ /**
99
+ * 打开模块并忽略已过期的异步结果。
100
+ * Open a module and ignore stale asynchronous results.
101
+ * @param {string} module 模块标识 / Module identifier.
102
+ * @returns {Promise<void>} 视图加载完成,失败显示错误视图 / View load completion; failures display an error view.
103
+ */
104
+ async function open(module) {
105
+ const version = ++generation;
106
+ active = module;
107
+ back.disabled = window.history.length <= 1;
108
+ heading.textContent = module;
109
+ replace(node("p", "pp-loading", "读取设置…"), 1);
110
+ try {
111
+ await client.open(module);
112
+ if (version === generation) controls();
113
+ } catch (error) {
114
+ if (version !== generation) return;
115
+ const view = node("section", "pp-error");
116
+ view.append(node("p", "", `加载失败:${error.message}`));
117
+ const retry = node("button", "", "重新读取");
118
+ retry.onclick = () => open(module);
119
+ view.append(retry);
120
+ replace(view, 1);
121
+ }
122
+ }
123
+ /**
124
+ * 从会话快照创建控件与操作按钮,不重新读取网络配置。
125
+ * Build controls and actions from the session snapshot without fetching config again.
126
+ * @returns {void} 无返回值 / No return value.
127
+ */
128
+ function controls() {
129
+ const { definition, values } = client.snapshot(active);
130
+ heading.textContent = definition.metadata?.name || active;
131
+ const view = node("section", "pp-fields");
132
+ /** @type {Array<() => void>} 挂载后执行的多行高度更新 / Textarea sizing callbacks run after mounting. */
133
+ const growingInputs = [];
134
+ /**
135
+ * 写入期间统一切换控件禁用状态。
136
+ * Toggle all control disabled states during mutations.
137
+ * @param {boolean} disabled 是否禁用 / Whether controls are disabled.
138
+ * @returns {void} 无返回值 / No return value.
139
+ */
140
+ const disableControls = disabled => {
141
+ view.querySelectorAll("button,input,select,textarea").forEach(input => {
142
+ input.disabled = disabled;
143
+ });
144
+ };
145
+ /**
146
+ * 执行页面操作,期间锁定控件,完成后处理延后的导航。
147
+ * Run a page action with controls locked, then process deferred navigation.
148
+ * @param {() => Promise<void>} action 请求或写入 / Request or mutation.
149
+ * @param {() => void} success 成功后的局部更新 / Local update after success.
150
+ * @returns {Promise<void>} 操作完成 / Operation completion.
151
+ */
152
+ async function perform(action, success) {
153
+ if (saving) return;
154
+ saving = true;
155
+ back.disabled = true;
156
+ disableControls(true);
157
+ try {
158
+ await action();
159
+ if (!destroyed) success();
160
+ } catch {
161
+ /* 请求层已通知错误 / The request layer has already reported the error. */
162
+ } finally {
163
+ saving = false;
164
+ back.disabled = window.history.length <= 1;
165
+ disableControls(false);
166
+ if (!destroyed && pendingRoute) route();
167
+ }
168
+ }
169
+ const metadata = definition.metadata;
170
+ if (metadata) {
171
+ const info = node("div", "pp-module-info");
172
+ const iconURL = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
173
+ /**
174
+ * 将元数据地址解析为可显示的 HTTP(S) URL。
175
+ * Resolve a metadata address into an HTTP(S) URL suitable for display.
176
+ * @param {string} value 绝对或相对地址 / Absolute or relative address.
177
+ * @returns {string} 完整地址 / Absolute URL.
178
+ * @throws {TypeError} 非 HTTP(S) 协议 / Non-HTTP(S) protocol.
179
+ */
180
+ const resourceURL = value => {
181
+ const url = new window.URL(value, window.location.href);
182
+ if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Module metadata URLs must use HTTP or HTTPS");
183
+ return url.href;
184
+ };
185
+ if (iconURL) {
186
+ const image = node("img", "pp-module-icon");
187
+ image.src = resourceURL(iconURL);
188
+ image.alt = "";
189
+ info.append(image);
190
+ }
191
+ const details = node("div", "pp-module-details");
192
+ for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(node("p", "pp-description", description));
193
+ if (metadata.repo) {
194
+ const link = node("a", "pp-module-source", "项目主页");
195
+ link.href = resourceURL(metadata.repo);
196
+ link.target = "_blank";
197
+ link.rel = "noopener noreferrer";
198
+ details.append(link);
199
+ }
200
+ info.append(details);
201
+ view.append(info);
202
+ }
203
+ for (const field of definition.fields) {
204
+ const row = node("fieldset", "pp-field");
205
+ row.append(node("legend", "", field.name));
206
+ if (field.description) row.append(node("p", "pp-description", field.description));
207
+ const value = values[field.key];
208
+ /** @type {() => unknown} 读取尚未保存的输入 / Read the unsaved input. */
209
+ let read;
210
+ /** @type {(value: unknown) => void} 更新当前控件 / Update the current control. */
211
+ let write;
212
+ switch (true) {
213
+ case Boolean(field.options) && field.type !== "array": {
214
+ const select = node("select", "pp-input");
215
+ select.setAttribute("aria-label", field.name);
216
+ field.options.forEach((option, index) => {
217
+ const item = node("option", "", option.label);
218
+ item.value = String(index);
219
+ select.append(item);
220
+ });
221
+ write = value => {
222
+ select.selectedIndex = field.options.findIndex(option => option.key === value);
223
+ };
224
+ row.append(select);
225
+ read = () => field.options[select.selectedIndex]?.key;
226
+ break;
227
+ }
228
+ case field.type === "array" && Boolean(field.options): {
229
+ const inputs = field.options.map(option => {
230
+ const label = node("label", "pp-choice", option.label);
231
+ const input = node("input", "");
232
+ input.type = "checkbox";
233
+ label.prepend(input);
234
+ row.append(label);
235
+ return { input, key: option.key };
236
+ });
237
+ read = () => inputs.filter(option => option.input.checked).map(option => option.key);
238
+ write = value => {
239
+ for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);
240
+ };
241
+ break;
242
+ }
243
+ default: {
244
+ const multiline = field.control === "textarea" || field.type === "array";
245
+ const input = node(multiline ? "textarea" : "input", "pp-input");
246
+ input.setAttribute("aria-label", field.name);
247
+ if (field.placeholder) input.placeholder = field.placeholder;
248
+ if (multiline && field.rows) input.rows = field.rows;
249
+ /**
250
+ * 在挂载后根据内容调整高度,同时保留基础行数。
251
+ * Size mounted textareas to their contents while retaining baseline rows.
252
+ * @returns {void} 无返回值 / No return value.
253
+ */
254
+ const grow = () => {
255
+ if (!multiline || !field.autoGrow || !input.isConnected) return;
256
+ input.style.height = "auto";
257
+ const baseline = input.getBoundingClientRect().height;
258
+ const style = window.getComputedStyle(input);
259
+ const borders = Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
260
+ input.style.height = `${Math.max(baseline, input.scrollHeight + borders)}px`;
261
+ };
262
+ if (multiline && field.autoGrow) {
263
+ input.addEventListener("input", grow);
264
+ growingInputs.push(grow);
265
+ }
266
+ if (field.type === "boolean") {
267
+ input.type = "checkbox";
268
+ write = value => {
269
+ input.checked = value === true;
270
+ };
271
+ read = () => input.checked;
272
+ } else {
273
+ if (!multiline) input.type = field.type === "number" ? "number" : "text";
274
+ write = value => {
275
+ input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
276
+ grow();
277
+ };
278
+ read = () => {
279
+ switch (field.type) {
280
+ case "array":
281
+ return JSON.parse(input.value);
282
+ case "number":
283
+ return input.value === "" ? Number.NaN : Number(input.value);
284
+ default:
285
+ return input.value;
286
+ }
287
+ };
288
+ }
289
+ row.append(input);
290
+ break;
291
+ }
292
+ }
293
+ write(value);
294
+ const actions = node("div", "pp-actions");
295
+ for (const [operation, label] of [
296
+ ["write", "保存"],
297
+ ["delete", "删除覆盖值"],
298
+ ]) {
299
+ const button = node("button", "", label);
300
+ button.type = "button";
301
+ button.onclick = () =>
302
+ perform(
303
+ async () => {
304
+ if (operation === "delete") await client.remove(active, field.key);
305
+ else {
306
+ let value;
307
+ try {
308
+ value = read();
309
+ } catch (error) {
310
+ notify({ kind: "error", message: error.message });
311
+ throw error;
312
+ }
313
+ await client.set(active, field.key, value);
314
+ }
315
+ },
316
+ () => write(client.snapshot(active).values[field.key]),
317
+ );
318
+ actions.append(button);
319
+ }
320
+ row.append(actions);
321
+ view.append(row);
322
+ }
323
+ const maintenance = node("section", "pp-maintenance");
324
+ maintenance.append(node("h2", "pp-title", "模块数据"));
325
+ const actions = node("div", "pp-actions");
326
+ const cacheView = node("button", "", "查看 Caches");
327
+ const cacheClear = node("button", "", "清空 Caches");
328
+ const reset = node("button", "pp-danger", "重置模块");
329
+ const output = node("pre", "pp-cache");
330
+ output.hidden = true;
331
+ output.setAttribute("aria-label", "Caches 内容");
332
+ for (const button of [cacheView, cacheClear, reset]) button.type = "button";
333
+ cacheView.onclick = () => {
334
+ let value;
335
+ return perform(
336
+ async () => {
337
+ try {
338
+ value = await client.readCaches(active);
339
+ } catch (error) {
340
+ notify({ kind: "error", message: error.message });
341
+ throw error;
342
+ }
343
+ },
344
+ () => {
345
+ output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
346
+ output.hidden = false;
347
+ cacheView.textContent = "刷新 Caches";
348
+ },
349
+ );
350
+ };
351
+ cacheClear.onclick = () => {
352
+ if (!window.confirm(`清空 ${active} 的全部 Caches?`)) return;
353
+ return perform(
354
+ () => client.clearCaches(active),
355
+ () => {
356
+ output.textContent = "暂无缓存";
357
+ },
358
+ );
359
+ };
360
+ reset.onclick = () => {
361
+ if (!window.confirm(`重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) return;
362
+ return perform(() => client.reset(active), controls);
363
+ };
364
+ actions.append(cacheView, cacheClear, reset);
365
+ maintenance.append(actions, output);
366
+ view.append(maintenance);
367
+ viewport.replaceChildren(view);
368
+ for (const grow of growingInputs) grow();
369
+ }
370
+ /**
371
+ * 按页面 pathname 切换模块,写入尚未完成时延后导航。
372
+ * Route by the page pathname, deferring navigation while a mutation is pending.
373
+ * @returns {void} 无返回值 / No return value.
374
+ */
375
+ function route() {
376
+ if (saving) {
377
+ pendingRoute = true;
378
+ return;
379
+ }
380
+ pendingRoute = false;
381
+ if (active) client.leave(active);
382
+ routedPath = window.location.pathname;
383
+ const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(routedPath);
384
+ if (!match) {
385
+ generation++;
386
+ active = null;
387
+ heading.textContent = title;
388
+ replace(node("p", "pp-error", "页面地址应为 /settings/模块标识。"), 1);
389
+ return;
390
+ }
391
+ open(match[1]);
392
+ }
393
+ /**
394
+ * 仅在 pathname 改变时处理历史导航。
395
+ * Handle history navigation only when the pathname changes.
396
+ * @returns {void} 无返回值 / No return value.
397
+ */
398
+ const onPopState = () => {
399
+ if (window.location.pathname !== routedPath) route();
400
+ };
401
+ /**
402
+ * 从浏览器往返缓存恢复时重新读取当前模块。
403
+ * Reload the current module when restored from the browser back-forward cache.
404
+ * @param {PageTransitionEvent} event 页面恢复事件 / Page restoration event.
405
+ * @returns {void} 无返回值 / No return value.
406
+ */
407
+ const onPageShow = event => {
408
+ if (event.persisted) route();
409
+ };
410
+ back.onclick = () => {
411
+ if (!saving) window.history.back();
412
+ };
413
+ window.addEventListener("popstate", onPopState);
414
+ window.addEventListener("pageshow", onPageShow);
415
+ route();
416
+ return {
417
+ /**
418
+ * 移除监听器、定时器、会话和挂载内容。
419
+ * Remove listeners, timers, session and mounted content.
420
+ * @returns {void} 无返回值 / No return value.
421
+ */
422
+ destroy() {
423
+ destroyed = true;
424
+ window.removeEventListener("popstate", onPopState);
425
+ window.removeEventListener("pageshow", onPageShow);
426
+ generation++;
427
+ if (active) client.leave(active);
428
+ clearTimeout(timer);
429
+ shell.remove();
430
+ },
431
+ };
281
432
  }