@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.
@@ -26,17 +26,19 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
26
26
  };
27
27
  const shell = node("div", "pp-panel");
28
28
  const header = node("header", "pp-header");
29
- const back = node("button", "pp-back", "返回");
29
+ const back = node("button", "pp-back", "");
30
+ back.setAttribute("aria-label", "返回");
30
31
  back.type = "button";
31
32
  const heading = node("h1", "pp-title", title);
32
33
  const viewport = node("div", "pp-viewport");
33
34
  const toast = node("div", "pp-toast");
34
35
  toast.setAttribute("role", "status");
35
36
  toast.hidden = true;
36
- header.append(back, heading);
37
+ header.append(back, heading, node("span", "pp-nav-spacer"));
37
38
  shell.append(header, viewport, toast);
38
39
  root.append(shell);
39
40
  let timer,
41
+ secondaryRoute,
40
42
  routedPath,
41
43
  generation = 0,
42
44
  active = null,
@@ -131,40 +133,68 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
131
133
  const view = node("section", "pp-fields");
132
134
  /** @type {Array<() => void>} 挂载后执行的多行高度更新 / Textarea sizing callbacks run after mounting. */
133
135
  const growingInputs = [];
136
+ const editors = new Map();
137
+ const summaries = [];
138
+ const groups = new Map();
139
+ const scrollPositions = new WeakMap();
140
+ let activeEditor;
141
+ let queue = Promise.resolve(),
142
+ pendingWrites = 0;
134
143
  /**
135
- * 写入期间统一切换控件禁用状态。
136
- * Toggle all control disabled states during mutations.
137
- * @param {boolean} disabled 是否禁用 / Whether controls are disabled.
144
+ * 根据 hash 切换多选页,保留上级 DOM 和滚动位置。
145
+ * Switch multi-select views by hash while retaining parent DOM and scroll position.
138
146
  * @returns {void} 无返回值 / No return value.
139
147
  */
140
- const disableControls = disabled => {
141
- view.querySelectorAll("button,input,select,textarea").forEach(input => {
142
- input.disabled = disabled;
143
- });
148
+ const showEditor = () => {
149
+ let key;
150
+ try {
151
+ key = decodeURIComponent(window.location.hash.slice(1));
152
+ } catch {
153
+ key = "";
154
+ }
155
+ const editor = editors.get(key);
156
+ const previous = activeEditor?.node ?? view;
157
+ const next = editor?.node ?? view;
158
+ if (previous !== next) {
159
+ scrollPositions.set(previous, previous.scrollTop);
160
+ previous.remove();
161
+ viewport.append(next);
162
+ next.scrollTop = scrollPositions.get(next) ?? 0;
163
+ 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)" });
164
+ }
165
+ activeEditor = editor;
166
+ heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
167
+ back.disabled = saving || (!editor && window.history.length <= 1);
144
168
  };
169
+ secondaryRoute = showEditor;
145
170
  /**
146
- * 执行页面操作,期间锁定控件,完成后处理延后的导航。
147
- * Run a page action with controls locked, then process deferred navigation.
171
+ * 串行执行页面操作,输入可继续编辑,完成后处理延后导航。
172
+ * Serialize page actions while inputs remain editable, then process deferred navigation.
148
173
  * @param {() => Promise<void>} action 请求或写入 / Request or mutation.
149
174
  * @param {() => void} success 成功后的局部更新 / Local update after success.
175
+ * @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.
150
176
  * @returns {Promise<void>} 操作完成 / Operation completion.
151
177
  */
152
- async function perform(action, success) {
153
- if (saving) return;
178
+ function perform(action, success, failure = () => {}) {
179
+ pendingWrites++;
154
180
  saving = true;
155
181
  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
- }
182
+ return (queue = queue
183
+ .then(action)
184
+ .then(() => {
185
+ if (!destroyed) success();
186
+ })
187
+ .catch(() => {
188
+ /* 请求层已通知错误 / The request layer has already reported the error. */
189
+ if (!destroyed) failure();
190
+ })
191
+ .finally(() => {
192
+ pendingWrites--;
193
+ saving = pendingWrites > 0;
194
+ if (destroyed && !saving) client.leave(active);
195
+ back.disabled = saving || (!activeEditor && window.history.length <= 1);
196
+ if (!saving && !destroyed && pendingRoute) route();
197
+ }));
168
198
  }
169
199
  const metadata = definition.metadata;
170
200
  if (metadata) {
@@ -201,14 +231,27 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
201
231
  view.append(info);
202
232
  }
203
233
  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));
234
+ const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
235
+ const group = match?.[1] ?? "通用";
236
+ if (!groups.has(group)) {
237
+ const section = node("section", "form-group");
238
+ const rows = node("div", "form-group__row");
239
+ section.append(node("h2", "form-group__title", group), rows);
240
+ groups.set(group, rows);
241
+ view.append(section);
242
+ }
243
+ const row = node("div", "form-row pp-field");
244
+ const label = node("div", "form-row__text");
245
+ label.append(node("span", "form-row__title", match?.[2] ?? field.name));
246
+ if (field.description) label.append(node("span", "form-row__subtitle", field.description));
247
+ row.append(label);
207
248
  const value = values[field.key];
208
249
  /** @type {() => unknown} 读取尚未保存的输入 / Read the unsaved input. */
209
250
  let read;
210
251
  /** @type {(value: unknown) => void} 更新当前控件 / Update the current control. */
211
252
  let write;
253
+ let inputContainer = row;
254
+ let eventName = "change";
212
255
  switch (true) {
213
256
  case Boolean(field.options) && field.type !== "array": {
214
257
  const select = node("select", "pp-input");
@@ -226,12 +269,42 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
226
269
  break;
227
270
  }
228
271
  case field.type === "array" && Boolean(field.options): {
272
+ const page = node("section", "pp-choice-page");
273
+ if (field.description) page.append(node("p", "pp-description", field.description));
274
+ const choices = node("div", "form-group__row");
275
+ page.append(choices);
276
+ inputContainer = choices;
277
+ editors.set(field.key, { node: page, title: match?.[2] ?? field.name });
278
+ const summary = node("span", "form-row__value pp-summary");
279
+ const link = node("button", "pp-choice-link");
280
+ link.type = "button";
281
+ link.setAttribute("aria-label", field.name);
282
+ link.append(summary, node("span", "pp-chevron", "›"));
283
+ row.append(link);
284
+ const refresh = () => {
285
+ const value = client.snapshot(active).values[field.key];
286
+ summary.textContent =
287
+ field.options
288
+ .filter(option => Array.isArray(value) && value.includes(option.key))
289
+ .map(option => option.label)
290
+ .join("、") || "未选择";
291
+ };
292
+ summaries.push(refresh);
293
+ refresh();
294
+ link.onclick = () => {
295
+ window.history.pushState({ ...window.history.state, preferencePane: active }, "", `#${encodeURIComponent(field.key)}`);
296
+ showEditor();
297
+ };
298
+ row.addEventListener("click", event => {
299
+ if (!link.contains(event.target)) link.click();
300
+ });
229
301
  const inputs = field.options.map(option => {
230
- const label = node("label", "pp-choice", option.label);
302
+ const label = node("label", "form-row pp-choice", option.label);
231
303
  const input = node("input", "");
232
304
  input.type = "checkbox";
233
- label.prepend(input);
234
- row.append(label);
305
+ input.setAttribute("aria-label", option.label);
306
+ label.append(input);
307
+ choices.append(label);
235
308
  return { input, key: option.key };
236
309
  });
237
310
  read = () => inputs.filter(option => option.input.checked).map(option => option.key);
@@ -243,6 +316,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
243
316
  default: {
244
317
  const multiline = field.control === "textarea" || field.type === "array";
245
318
  const input = node(multiline ? "textarea" : "input", "pp-input");
319
+ if (multiline) row.classList.add("pp-multiline");
246
320
  input.setAttribute("aria-label", field.name);
247
321
  if (field.placeholder) input.placeholder = field.placeholder;
248
322
  if (multiline && field.rows) input.rows = field.rows;
@@ -265,11 +339,14 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
265
339
  }
266
340
  if (field.type === "boolean") {
267
341
  input.type = "checkbox";
342
+ input.classList.add("pp-switch");
343
+ input.setAttribute("role", "switch");
268
344
  write = value => {
269
345
  input.checked = value === true;
270
346
  };
271
347
  read = () => input.checked;
272
348
  } else {
349
+ eventName = "input";
273
350
  if (!multiline) input.type = field.type === "number" ? "number" : "text";
274
351
  write = value => {
275
352
  input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
@@ -291,34 +368,31 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
291
368
  }
292
369
  }
293
370
  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);
371
+ let inputVersion = 0;
372
+ inputContainer.addEventListener(eventName, event => {
373
+ if (event.isComposing) return;
374
+ const version = ++inputVersion,
375
+ module = active;
376
+ let value;
377
+ try {
378
+ value = read();
379
+ } catch (error) {
380
+ notify({ kind: "error", message: error.message });
381
+ return;
382
+ }
383
+ const restore = () => {
384
+ if (version === inputVersion) write(client.snapshot(module).values[field.key]);
385
+ };
386
+ perform(
387
+ () => client.set(module, field.key, value),
388
+ () => {
389
+ for (const refresh of summaries) refresh();
390
+ },
391
+ restore,
392
+ );
393
+ });
394
+ if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
395
+ groups.get(group).append(row);
322
396
  }
323
397
  const maintenance = node("section", "pp-maintenance");
324
398
  maintenance.append(node("h2", "pp-title", "模块数据"));
@@ -331,6 +405,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
331
405
  output.setAttribute("aria-label", "Caches 内容");
332
406
  for (const button of [cacheView, cacheClear, reset]) button.type = "button";
333
407
  cacheView.onclick = () => {
408
+ if (saving) return;
334
409
  let value;
335
410
  return perform(
336
411
  async () => {
@@ -349,6 +424,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
349
424
  );
350
425
  };
351
426
  cacheClear.onclick = () => {
427
+ if (saving) return;
352
428
  if (!window.confirm(`清空 ${active} 的全部 Caches?`)) return;
353
429
  return perform(
354
430
  () => client.clearCaches(active),
@@ -358,6 +434,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
358
434
  );
359
435
  };
360
436
  reset.onclick = () => {
437
+ if (saving) return;
361
438
  if (!window.confirm(`重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) return;
362
439
  return perform(() => client.reset(active), controls);
363
440
  };
@@ -366,6 +443,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
366
443
  view.append(maintenance);
367
444
  viewport.replaceChildren(view);
368
445
  for (const grow of growingInputs) grow();
446
+ showEditor();
369
447
  }
370
448
  /**
371
449
  * 按页面 pathname 切换模块,写入尚未完成时延后导航。
@@ -378,6 +456,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
378
456
  return;
379
457
  }
380
458
  pendingRoute = false;
459
+ secondaryRoute = undefined;
381
460
  if (active) client.leave(active);
382
461
  routedPath = window.location.pathname;
383
462
  const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(routedPath);
@@ -397,7 +476,9 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
397
476
  */
398
477
  const onPopState = () => {
399
478
  if (window.location.pathname !== routedPath) route();
479
+ else secondaryRoute?.();
400
480
  };
481
+ const onHashChange = () => secondaryRoute?.();
401
482
  /**
402
483
  * 从浏览器往返缓存恢复时重新读取当前模块。
403
484
  * Reload the current module when restored from the browser back-forward cache.
@@ -408,10 +489,15 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
408
489
  if (event.persisted) route();
409
490
  };
410
491
  back.onclick = () => {
411
- if (!saving) window.history.back();
492
+ if (saving) return;
493
+ if (window.location.hash && window.history.state?.preferencePane !== active) {
494
+ window.history.replaceState(window.history.state, "", window.location.pathname);
495
+ secondaryRoute?.();
496
+ } else window.history.back();
412
497
  };
413
498
  window.addEventListener("popstate", onPopState);
414
499
  window.addEventListener("pageshow", onPageShow);
500
+ window.addEventListener("hashchange", onHashChange);
415
501
  route();
416
502
  return {
417
503
  /**
@@ -423,8 +509,9 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
423
509
  destroyed = true;
424
510
  window.removeEventListener("popstate", onPopState);
425
511
  window.removeEventListener("pageshow", onPageShow);
512
+ window.removeEventListener("hashchange", onHashChange);
426
513
  generation++;
427
- if (active) client.leave(active);
514
+ if (active && !saving) client.leave(active);
428
515
  clearTimeout(timer);
429
516
  shell.remove();
430
517
  },
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="zh-CN">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
6
+ <meta name="color-scheme" content="light dark">
7
+ <title>Preferences</title>
8
+ <link rel="stylesheet" href="/settings/assets/panel.css?v=__VERSION__">
9
+ <link rel="stylesheet" href="/settings/assets/home.css?v=__VERSION__">
10
+ </head>
11
+ <body>
12
+ <main id="preferences"></main>
13
+ <script type="module" src="/settings/assets/app.mjs?v=__VERSION__"></script>
14
+ </body>
15
+ </html>
package/src/index.d.ts CHANGED
@@ -84,6 +84,29 @@ export interface SettingsHandlerOptions {
84
84
  /** 默认 X-Settings-Client,值必须为 1;不是认证凭据 / Defaults to X-Settings-Client with value 1; not an authentication credential. */
85
85
  requestHeader?: string;
86
86
  }
87
+ /**
88
+ * 原生 Mock 的等价资源映射,支持不具备该语法的代理。
89
+ * Equivalent resource mapping for proxies lacking native Mock syntax.
90
+ */
91
+ export interface PreferencesHandlerOptions extends SettingsHandlerOptions {
92
+ /** 按 pathname 匹配,不匹配下载源自身 / Match pathnames without intercepting the download source itself. */
93
+ resources: Array<{
94
+ /** 锚定的 pathname 正则 / Anchored pathname regular expression. */
95
+ pattern: string;
96
+ /** HTTPS 下载源 / HTTPS resource source. */
97
+ source: string;
98
+ /** 响应媒体类型 / Response media type. */
99
+ contentType: string;
100
+ }>;
101
+ }
102
+ /**
103
+ * 通用安装入口;API 保持无网络读写,静态请求才下载资源。
104
+ * Generic installation entry; APIs remain network-free and only static requests download resources.
105
+ */
106
+ export class PreferencesHandler extends SettingsHandler {
107
+ /** @param options 安装 JSON 配置 / Installation JSON configuration. */
108
+ constructor(options: PreferencesHandlerOptions);
109
+ }
87
110
  /**
88
111
  * 属于单个模块的字段、存储根及可选展示元数据。
89
112
  * Fields, storage root and optional display metadata belonging to one module.
package/src/index.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  export { normalizeBoxJs } from "./lib/boxjs.mjs";
2
2
  export { parseSettingsPath } from "./lib/settings-path.mjs";
3
+ export { PreferencesHandler } from "./PreferencesHandler.mjs";
3
4
  /**
4
5
  * 通用代理处理器与配置解析的公开入口。
5
6
  * Public entry for the proxy handler and configuration parsing.