@nsnanocat/preference-panes 1.2.5 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,6 @@
1
- import { ActionMenu } from "./ActionMenu.mjs";
2
1
  import { validValue } from "./boxjs.mjs";
3
2
  import { PreferencesClient } from "./client.mjs";
4
- import { fieldControl, element as node, requestConfirmation, requestURLNavigation, resourceURL, settingRow, statusView } from "./components.mjs";
5
- import { Navigation } from "./Navigation.mjs";
3
+ import { fieldControl, element as node, settingRow, statusView } from "./components.mjs";
6
4
 
7
5
  /**
8
6
  * 将字段级存储诊断转换为简短提示。
@@ -21,20 +19,24 @@ function warningMessage(warning) {
21
19
  }
22
20
 
23
21
  /**
24
- * 管理模块表单、导航、操作队列和短暂通知。
25
- * Manage the module form, navigation, operation queue, and transient notifications.
22
+ * 管理模块表单、操作队列和设置会话。
23
+ * Manage the module form, operation queue and settings session.
26
24
  */
27
25
  export class PreferencesPanel {
28
26
  #release;
27
+ #view;
28
+ #state;
29
+ #perform;
29
30
 
30
31
  /**
31
32
  * 挂载 BoxJS 定义对应的模块表单。
32
33
  * Mount the module form described by a BoxJS definition.
33
34
  * @param {HTMLElement} root 包内挂载元素 / Internal mount element.
34
35
  * @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
36
+ * @param {import("./page.mjs").PageController} page 内部页面控制器 / Internal page controller.
35
37
  */
36
- constructor(root, definition) {
37
- this.#release = this.#mount(root, definition);
38
+ constructor(root, definition, page) {
39
+ this.#release = this.#mount(root, definition, page);
38
40
  }
39
41
 
40
42
  /**
@@ -42,13 +44,13 @@ export class PreferencesPanel {
42
44
  * Build panel DOM, interactions, and session, then return its release operation.
43
45
  * @param {HTMLElement} root 包内挂载元素 / Internal mount element.
44
46
  * @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
47
+ * @param {import("./page.mjs").PageController} page 内部页面控制器 / Internal page controller.
45
48
  * @returns {() => void} 释放操作 / Release operation.
46
49
  */
47
- #mount(root, definition) {
50
+ #mount(root, definition, page) {
48
51
  const title = definition.metadata?.name ?? definition.module;
49
52
  const document = root.ownerDocument;
50
53
  const window = document.defaultView;
51
- const frame = window.frameElement?.dataset.preferencePanes ? window.frameElement : undefined;
52
54
  const shell = node("div", "pp-panel");
53
55
  shell.dataset.module = definition.module;
54
56
  const handlers = new Map();
@@ -59,50 +61,25 @@ export class PreferencesPanel {
59
61
  { id: "reset", label: "重置设置", destructive: true },
60
62
  ];
61
63
  const viewport = node("div", "pp-viewport");
62
- let back, heading, menu;
63
- if (frame) shell.append(viewport);
64
- else {
65
- const header = node("header", "pp-header");
66
- back = node("button", "pp-back", "‹");
67
- back.setAttribute("aria-label", "返回");
68
- back.type = "button";
69
- heading = node("h1", "pp-title", title);
70
- menu = new ActionMenu(id => runAction(id));
71
- const trailing = node("span", "pp-nav-spacer");
72
- trailing.append(menu.element);
73
- header.append(back, heading, trailing);
74
- shell.append(header, viewport);
75
- }
76
- let toast;
64
+ shell.append(viewport);
77
65
  root.append(shell);
78
- // 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
79
- // Embedded mode publishes navigation state without host reads or mutations of the module DOM.
80
- const publishNavigation = () => {
81
- const actions = handlers.size ? menuItems : [];
82
- if (frame)
83
- frame.dispatchEvent(
84
- new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
85
- detail: { title: currentTitle, module: definition.module, busy: saving, canGoBack, actions },
86
- }),
87
- );
88
- else {
89
- heading.textContent = currentTitle;
90
- back.disabled = !canGoBack;
91
- menu.update(actions, saving);
92
- }
66
+ const editors = new Map();
67
+ this.#view = key => {
68
+ const editor = editors.get(key);
69
+ editor?.load?.();
70
+ return editor?.node ?? root;
71
+ };
72
+ this.#state = key => ({ title: editors.get(key)?.title ?? title, actions: handlers.size ? menuItems : [], busy: saving });
73
+ this.#perform = id => {
74
+ if (!saving && handlers.has(id)) runAction(id);
93
75
  };
94
- const onAction = event => {
95
- if (!saving && handlers.has(event.detail)) runAction(event.detail);
76
+ const publishNavigation = () => {
77
+ if (!destroyed) page.update();
96
78
  };
97
- frame?.addEventListener("preferencepanes:action", onAction);
98
- let timer,
99
- navigation,
100
- generation = 0,
79
+ let generation = 0,
101
80
  active = null,
102
81
  saving = false,
103
- destroyed = false,
104
- currentTitle = title,
105
- canGoBack = window.history.length > 1;
82
+ destroyed = false;
106
83
  /**
107
84
  * 展示短暂通知,不刷新设置数据。
108
85
  * Display a transient notification without refreshing settings.
@@ -112,43 +89,29 @@ export class PreferencesPanel {
112
89
  const notify = event => {
113
90
  if (destroyed) return;
114
91
  let message;
115
- switch (true) {
116
- case event.kind === "error":
92
+ switch (event.kind === "error" ? "error" : event.operation) {
93
+ case "error":
117
94
  message = `操作失败:${event.message}`;
118
95
  break;
119
- case event.operation === "delete":
96
+ case "delete":
120
97
  message = "删除成功";
121
98
  break;
122
- case event.operation === "clearCaches":
99
+ case "clearCaches":
123
100
  message = "Caches 已清空";
124
101
  break;
125
- case event.operation === "reset":
102
+ case "reset":
126
103
  message = "设置已重置";
127
104
  break;
128
105
  default:
129
106
  message = "修改成功";
130
107
  break;
131
108
  }
132
- // 宿主接管时不创建网页 Toast,也不运行其计时器。
133
- // A host-owned notice creates no web Toast and starts no local timer.
134
- if (frame && !frame.dispatchEvent(new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:notice", { cancelable: true, detail: { kind: event.kind, message } }))) return;
135
- if (!toast) {
136
- toast = node("div", "pp-toast");
137
- toast.setAttribute("role", "status");
138
- shell.append(toast);
139
- }
140
- toast.textContent = message;
141
- toast.dataset.kind = event.kind;
142
- toast.hidden = false;
143
- clearTimeout(timer);
144
- timer = setTimeout(() => {
145
- toast.hidden = true;
146
- }, 2400);
109
+ page.notice({ kind: event.kind, message });
147
110
  };
148
111
  const client = new PreferencesClient({ definition, notify });
149
112
  /**
150
- * 两种菜单入口共用异步错误处理,包含宿主确认框错误。
151
- * Share async error handling between both menus, including host-dialog errors.
113
+ * 菜单操作统一处理异步错误,包含宿主确认框错误。
114
+ * Handle asynchronous action errors, including host-dialog errors.
152
115
  * @param {string} id 操作标识 / Action identifier.
153
116
  * @returns {Promise<void>} 操作已处理 / Action handled.
154
117
  */
@@ -168,8 +131,6 @@ export class PreferencesPanel {
168
131
  async function open(module) {
169
132
  const version = ++generation;
170
133
  active = module;
171
- currentTitle = module;
172
- canGoBack = window.history.length > 1;
173
134
  publishNavigation();
174
135
  viewport.replaceChildren(statusView("读取设置…"));
175
136
  try {
@@ -188,7 +149,6 @@ export class PreferencesPanel {
188
149
  */
189
150
  function controls() {
190
151
  const { definition, values } = client.snapshot();
191
- currentTitle = definition.metadata?.name || active;
192
152
  const view = node("section", "pp-fields");
193
153
  /**
194
154
  * 挂载后执行的多行高度更新
@@ -196,23 +156,12 @@ export class PreferencesPanel {
196
156
  * @type {Array<() => void>}
197
157
  */
198
158
  const growingInputs = [];
199
- const editors = new Map();
159
+ editors.clear();
200
160
  const summaries = [];
201
161
  const warningRefreshers = [];
202
162
  const groups = new Map();
203
163
  let queue = Promise.resolve(),
204
164
  pendingWrites = 0;
205
- /**
206
- * 导航组件处理页面切换,表单只更新当前标题与返回按钮。
207
- * Let navigation own transitions; the form only updates the title and back button.
208
- * @returns {void} 无返回值 / No return value.
209
- */
210
- const updateNavigation = () => {
211
- const editor = editors.get(navigation.current);
212
- currentTitle = editor?.title ?? definition.metadata?.name ?? active;
213
- canGoBack = !saving && navigation.canGoBack;
214
- publishNavigation();
215
- };
216
165
  /**
217
166
  * 串行执行模块操作,保持输入可编辑。
218
167
  * Serialize module actions while keeping inputs editable.
@@ -224,7 +173,6 @@ export class PreferencesPanel {
224
173
  function perform(action, success, failure = () => {}) {
225
174
  pendingWrites++;
226
175
  saving = true;
227
- canGoBack = false;
228
176
  publishNavigation();
229
177
  queue = queue
230
178
  .then(action)
@@ -239,27 +187,10 @@ export class PreferencesPanel {
239
187
  .finally(() => {
240
188
  pendingWrites--;
241
189
  saving = pendingWrites > 0;
242
- if (destroyed && !saving) client.leave();
243
- canGoBack = !saving && navigation.canGoBack;
244
190
  publishNavigation();
245
191
  });
246
192
  return queue;
247
193
  }
248
- const metadata = definition.metadata;
249
- if (metadata) {
250
- const info = node("div", "pp-module-info");
251
- const details = node("div", "pp-module-details");
252
- for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(node("p", "pp-description", description));
253
- if (metadata.repo) {
254
- const link = node("a", "pp-module-source", "项目主页");
255
- link.href = resourceURL(metadata.repo);
256
- link.target = "_blank";
257
- link.rel = "noopener noreferrer";
258
- details.append(link);
259
- }
260
- info.append(details);
261
- view.append(info);
262
- }
263
194
  for (const field of definition.fields) {
264
195
  const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
265
196
  const group = match?.[1] ?? "通用";
@@ -300,8 +231,8 @@ export class PreferencesPanel {
300
231
  let write;
301
232
  let inputContainer = row;
302
233
  let eventName = "change";
303
- switch (true) {
304
- case field.control === "url": {
234
+ switch (field.control === "url" ? "url" : field.options ? (field.type === "array" ? "checkboxes" : "selects") : field.type) {
235
+ case "url": {
305
236
  const link = node("a", "pp-choice-link", "打开");
306
237
  link.setAttribute("aria-label", field.name);
307
238
  link.rel = "noopener noreferrer";
@@ -309,7 +240,8 @@ export class PreferencesPanel {
309
240
  link.href = value;
310
241
  };
311
242
  link.addEventListener("click", event => {
312
- if (requestURLNavigation(window, link.href)) event.preventDefault();
243
+ event.preventDefault();
244
+ page.openURL(link.href);
313
245
  });
314
246
  row.append(link);
315
247
  row.addEventListener("click", event => {
@@ -317,7 +249,7 @@ export class PreferencesPanel {
317
249
  });
318
250
  break;
319
251
  }
320
- case Boolean(field.options) && field.type !== "array": {
252
+ case "selects": {
321
253
  const select = node("select", "");
322
254
  select.setAttribute("aria-label", field.name);
323
255
  field.options.forEach((option, index) => {
@@ -332,13 +264,13 @@ export class PreferencesPanel {
332
264
  read = () => field.options[select.selectedIndex]?.key;
333
265
  break;
334
266
  }
335
- case field.type === "array" && Boolean(field.options): {
336
- const page = node("section", "pp-choice-page");
337
- if (field.description) page.append(node("p", "pp-description", field.description));
267
+ case "checkboxes": {
268
+ const editor = node("section", "pp-panel pp-detail pp-choice-page");
269
+ if (field.description) editor.append(node("p", "pp-description", field.description));
338
270
  const choices = node("div", "pp-rows");
339
- page.append(choices);
271
+ editor.append(choices);
340
272
  inputContainer = choices;
341
- editors.set(field.key, { node: page, title: match?.[2] ?? field.name });
273
+ editors.set(field.key, { node: editor, title: match?.[2] ?? field.name });
342
274
  const summary = node("span", "pp-summary");
343
275
  const link = node("button", "pp-choice-link");
344
276
  link.type = "button";
@@ -355,7 +287,7 @@ export class PreferencesPanel {
355
287
  };
356
288
  summaries.push(refresh);
357
289
  refresh();
358
- link.onclick = () => navigation.open(field.key);
290
+ link.onclick = () => page.open(field.key);
359
291
  row.addEventListener("click", event => {
360
292
  if (!link.contains(event.target)) link.click();
361
293
  });
@@ -376,7 +308,7 @@ export class PreferencesPanel {
376
308
  };
377
309
  break;
378
310
  }
379
- case field.type === "boolean": {
311
+ case "boolean": {
380
312
  const toggle = node("input", "pp-switch");
381
313
  toggle.type = "checkbox";
382
314
  toggle.setAttribute("switch", "");
@@ -414,7 +346,12 @@ export class PreferencesPanel {
414
346
  growingInputs.push(grow);
415
347
  }
416
348
  eventName = "input";
417
- if (!multiline) input.type = field.type === "number" ? "number" : "text";
349
+ if (!multiline) {
350
+ input.type = field.type === "number" ? "number" : "text";
351
+ input.autocapitalize = "off";
352
+ input.setAttribute("autocorrect", "off");
353
+ input.spellcheck = false;
354
+ }
418
355
  write = value => {
419
356
  input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
420
357
  grow();
@@ -468,12 +405,12 @@ export class PreferencesPanel {
468
405
  if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
469
406
  groups.get(group).append(row);
470
407
  }
471
- const settingsPage = node("section", "pp-settings-page");
408
+ const settingsPage = node("section", "pp-panel pp-detail pp-settings-page");
472
409
  const settingsOutput = node("pre", "pp-cache");
473
410
  settingsOutput.setAttribute("aria-label", "Settings 内容");
474
411
  settingsPage.append(settingsOutput);
475
- editors.set("$settings", { node: settingsPage, title: "设置" });
476
- handlers.set("viewSettings", () => {
412
+ const loadSettings = () => {
413
+ editors.get("$settings").load = undefined;
477
414
  if (saving) return;
478
415
  let value;
479
416
  return perform(
@@ -487,17 +424,24 @@ export class PreferencesPanel {
487
424
  },
488
425
  () => {
489
426
  settingsOutput.textContent = value === undefined ? "暂无设置" : JSON.stringify(value, null, 2);
490
- navigation.open("$settings");
427
+ },
428
+ () => {
429
+ settingsOutput.textContent = "读取设置失败";
491
430
  },
492
431
  );
432
+ };
433
+ editors.set("$settings", { node: settingsPage, title: "设置", load: loadSettings });
434
+ handlers.set("viewSettings", () => {
435
+ loadSettings();
436
+ page.open("$settings");
493
437
  });
494
- const cachePage = node("section", "pp-cache-page");
438
+ const cachePage = node("section", "pp-panel pp-detail pp-cache-page");
495
439
  const output = node("pre", "pp-cache");
496
- output.textContent = "暂无缓存";
440
+ output.textContent = "正在读取缓存…";
497
441
  output.setAttribute("aria-label", "Caches 内容");
498
442
  cachePage.append(output);
499
- editors.set("$caches", { node: cachePage, title: "缓存" });
500
- handlers.set("viewCaches", () => {
443
+ const loadCaches = () => {
444
+ editors.get("$caches").load = undefined;
501
445
  if (saving) return;
502
446
  let value;
503
447
  return perform(
@@ -511,13 +455,22 @@ export class PreferencesPanel {
511
455
  },
512
456
  () => {
513
457
  output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
514
- navigation.open("$caches");
458
+ },
459
+ () => {
460
+ output.textContent = "读取缓存失败";
515
461
  },
516
462
  );
463
+ };
464
+ editors.set("$caches", { node: cachePage, title: "缓存", load: loadCaches });
465
+ handlers.set("viewCaches", () => {
466
+ loadCaches();
467
+ page.open("$caches");
517
468
  });
518
469
  handlers.set("clearCaches", async () => {
519
470
  if (saving) return;
520
- if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;
471
+ const shared = definition.cachePath && definition.cachePath !== `@${definition.storageKey}.${active}.Caches`;
472
+ const message = shared ? `清空共享缓存 ${definition.cachePath}?使用该缓存的其它模块也会受影响。` : `清空 ${active} 的全部 Caches?`;
473
+ if (!(await page.confirm(message)) || destroyed || saving) return;
521
474
  return perform(
522
475
  () => client.clearCaches(),
523
476
  () => {
@@ -527,42 +480,58 @@ export class PreferencesPanel {
527
480
  });
528
481
  handlers.set("reset", async () => {
529
482
  if (saving) return;
530
- if (!(await requestConfirmation(window, `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;
483
+ const message = definition.resetPaths ? `重置 ${definition.metadata?.name ?? active} 的设置?只删除本页面的设置,保留共享存储中的其它设置和缓存。` : `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`;
484
+ if (!(await page.confirm(message)) || destroyed || saving) return;
531
485
  return perform(() => client.reset(), controls);
532
486
  });
533
- navigation?.destroy();
534
- navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
535
- navigation.addEventListener("change", updateNavigation);
487
+ viewport.replaceChildren(view);
536
488
  for (const grow of growingInputs) grow();
537
- updateNavigation();
489
+ page.refresh();
538
490
  }
539
- /**
540
- * 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。
541
- * Loaded forms delegate back to navigation; loading views can return to the previous document.
542
- * @returns {void} 无返回值 / No return value.
543
- */
544
- if (back)
545
- back.onclick = () => {
546
- if (saving) return;
547
- if (navigation) navigation.back();
548
- else window.history.back();
549
- };
550
- open(definition.module);
491
+ queueMicrotask(() => {
492
+ if (!destroyed) open(definition.module);
493
+ });
551
494
  return () => {
552
495
  destroyed = true;
553
- menu?.destroy();
554
- frame?.removeEventListener("preferencepanes:action", onAction);
555
- navigation?.destroy();
556
496
  generation++;
557
- if (active && !saving) client.leave();
558
- clearTimeout(timer);
497
+ client.leave();
559
498
  shell.remove();
560
499
  };
561
500
  }
562
501
 
563
502
  /**
564
- * 移除监听器、定时器、会话和挂载内容。
565
- * Remove listeners, timers, session, and mounted content.
503
+ * 返回当前会话中的字段或操作页,不再读取配置。
504
+ * Return a field or action view from the current session without fetching configuration.
505
+ * @param {string} key 子页键 / Detail key.
506
+ * @returns {HTMLElement} 当前视图 / Current view.
507
+ */
508
+ view(key) {
509
+ return this.#view(key);
510
+ }
511
+
512
+ /**
513
+ * 提供当前页标题及菜单状态。
514
+ * Provide the current title and action state.
515
+ * @param {string} key 子页键 / Detail key.
516
+ * @returns {object} 页面状态 / Page state.
517
+ */
518
+ state(key) {
519
+ return this.#state(key);
520
+ }
521
+
522
+ /**
523
+ * 执行共用菜单中的操作。
524
+ * Perform an action from the shared menu.
525
+ * @param {string} id 操作标识 / Action identifier.
526
+ * @returns {void} 无返回值 / No return value.
527
+ */
528
+ perform(id) {
529
+ this.#perform(id);
530
+ }
531
+
532
+ /**
533
+ * 释放读取与视图;已开始的写入仍沿原队列完成。
534
+ * Release reads and views while allowing started writes to finish through the existing queue.
566
535
  * @returns {void} 无返回值 / No return value.
567
536
  */
568
537
  destroy() {