@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nsnanocat/preference-panes",
3
- "version": "1.2.5",
3
+ "version": "1.3.1",
4
4
  "description": "Generic BoxJS settings frontend and persistence API for JavaScript proxy modules",
5
5
  "author": "VirgilClyne <Virgil@nanocat.me>",
6
6
  "homepage": "https://NSNanoCat.github.io/preference-panes",
@@ -28,7 +28,7 @@
28
28
  },
29
29
  "./navigation": {
30
30
  "types": "./src/browser/Navigation.d.mts",
31
- "import": "./dist/module/navigation.mjs"
31
+ "import": "./dist/navigation.mjs"
32
32
  }
33
33
  },
34
34
  "types": "src/index.d.ts",
@@ -52,7 +52,8 @@
52
52
  },
53
53
  "files": [
54
54
  "src",
55
- "dist"
55
+ "dist",
56
+ "CHANGELOG.md"
56
57
  ],
57
58
  "devDependencies": {
58
59
  "@biomejs/biome": "2.1.4",
@@ -1,6 +1,6 @@
1
1
  /**
2
- * 独立页面的三点按钮和底部操作菜单;嵌入页面由宿主提供对应界面。
3
- * Overflow trigger and bottom action sheet for standalone pages; embedded pages use host-provided chrome.
2
+ * 共用菜单按钮和底部操作菜单;原生宿主可通过 MORE 调用。
3
+ * Shared menu trigger and bottom action sheet; native hosts may invoke it through MORE.
4
4
  */
5
5
  export class ActionMenu {
6
6
  #button;
@@ -28,13 +28,14 @@ export class ActionMenu {
28
28
  this.element = document.createElement("span");
29
29
  const triggerRoot = this.element.attachShadow({ mode: "open" });
30
30
  triggerRoot.innerHTML = `<style>
31
- :host{display:inline-flex;width:44px;height:44px;color:inherit}
31
+ :host{display:inline-flex;flex:none;width:37px;height:37px;color:inherit}
32
32
  :host([hidden]){display:none!important}
33
- button{width:44px;height:44px;padding:10px;font:inherit;cursor:pointer;border:0;color:inherit;background:none}
33
+ button{box-sizing:border-box;width:37px;height:37px;padding:8px;border-radius:4px;font:inherit;cursor:pointer;border:0;color:inherit;background:none;transition:background-color .3s}
34
+ button:hover{background:var(--pp-field,#8882)}
34
35
  button:disabled{opacity:.4;cursor:default}
35
36
  button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
36
- svg{display:block;width:24px;height:24px;fill:currentColor}
37
- </style><button type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="4" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="20" cy="12" r="2"/></svg></button>`;
37
+ svg{display:block;width:21px;height:21px;fill:none}
38
+ </style><button type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false"><svg viewBox="0 0 21 21" aria-hidden="true"><path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.667" d="M3.645 5.225h13.333M3.645 10.225h13.333M3.645 15.225h13.333"/></svg></button>`;
38
39
  this.#button = triggerRoot.querySelector("button");
39
40
  this.#layer = document.createElement("span");
40
41
  const layerRoot = this.#layer.attachShadow({ mode: "open" });
@@ -1,84 +1,3 @@
1
- /**
2
- * 静态 HTML 的模块 iframe 容器;页面资源输入写入 iframe 数据属性。
3
- * Static HTML module iframe whose resource inputs are written to iframe data attributes.
4
- * confirm 事件可 preventDefault 后通过 detail.resolve/reject 完成宿主确认。
5
- * Prevent default on confirm events and settle host dialogs through detail.resolve/reject.
6
- * notice 事件可 preventDefault 后交由宿主显示,模块不再创建网页 Toast。
7
- * Prevent default on notice events to display them in the host without a module web Toast.
8
- * open-url 事件可 preventDefault 后交由宿主打开 URL,模块不再执行链接默认导航。
9
- * Prevent default on open-url events to open the URL in the host instead of navigating the module.
10
- */
11
- export class ModuleFrame extends EventTarget {
12
- /**
13
- * 创建容器。
14
- * Create a container.
15
- * @param url 模块地址 / Module URL.
16
- * @param options 静态页面请求与资源输入 / Static page request and resource inputs.
17
- */
18
- constructor(url: string | URL, options?: { signal?: AbortSignal; headers?: HeadersInit });
19
- /**
20
- * 宿主挂载节点。
21
- * Host-mounted element.
22
- */
23
- readonly element: HTMLIFrameElement;
24
- /**
25
- * change 事件对应的导航状态。
26
- * Navigation state exposed with change events.
27
- */
28
- readonly state: { title: string; module: string; busy: boolean; canGoBack: boolean; actions: MenuAction[] };
29
- /**
30
- * 获取原始 HTML。
31
- * Fetch unmodified HTML.
32
- * @returns 加载完成 / Load completion.
33
- */
34
- load(): Promise<void>;
35
- /**
36
- * 沿模块历史返回。
37
- * Go back through module history.
38
- * @returns 无返回值 / No return value.
39
- */
40
- back(): void;
41
- /**
42
- * 执行模块提供的菜单操作。
43
- * Perform a module-provided menu action.
44
- * @param id 操作标识 / Action identifier.
45
- * @returns 无返回值 / No return value.
46
- */
47
- perform(id: string): void;
48
- /**
49
- * 取消请求与事件订阅。
50
- * Cancel requests and subscriptions.
51
- * @returns 无返回值 / No return value.
52
- */
53
- destroy(): void;
54
- }
55
-
56
- /**
57
- * 宿主确认事件的数据。
58
- * Host confirmation event detail.
59
- */
60
- export interface ConfirmationRequest {
61
- /**
62
- * 确认内容。
63
- * Confirmation message.
64
- */
65
- message: string;
66
- /**
67
- * 返回选择。
68
- * Return the user's choice.
69
- * @param confirmed 是否确认 / Whether confirmed.
70
- * @returns 无返回值 / No return value.
71
- */
72
- resolve(confirmed: boolean): void;
73
- /**
74
- * 返回失败。
75
- * Return a failure.
76
- * @param error 错误 / Error.
77
- * @returns 无返回值 / No return value.
78
- */
79
- reject(error: Error): void;
80
- }
81
-
82
1
  /**
83
2
  * 操作结果通知。
84
3
  * Operation result notice.
@@ -96,18 +15,6 @@ export interface Notice {
96
15
  message: string;
97
16
  }
98
17
 
99
- /**
100
- * 宿主 URL 导航请求。
101
- * Host URL navigation request.
102
- */
103
- export interface OpenURLRequest {
104
- /**
105
- * 完整目标地址。
106
- * Absolute target URL.
107
- */
108
- url: string;
109
- }
110
-
111
18
  /**
112
19
  * 菜单操作描述。
113
20
  * Menu action descriptor.
@@ -131,8 +38,8 @@ export interface MenuAction {
131
38
  }
132
39
 
133
40
  /**
134
- * 共用三点菜单。
135
- * Shared overflow menu.
41
+ * 共用操作菜单。
42
+ * Shared action menu.
136
43
  */
137
44
  export class ActionMenu {
138
45
  /**
@@ -258,6 +165,8 @@ export class Navigation extends EventTarget {
258
165
  * @returns 无返回值 / No return value.
259
166
  */
260
167
  open(key: string): void;
168
+ /** 更新当前挂载视图,不增加历史 / Refresh the mounted view without adding history. */
169
+ refresh(): void;
261
170
  /**
262
171
  * 沿联合历史后退。
263
172
  * Go back through joint history.
@@ -1,10 +1,9 @@
1
1
  export { ActionMenu } from "./ActionMenu.mjs";
2
- export { ModuleFrame } from "./ModuleFrame.mjs";
3
2
  export { ModuleStatus, probeModule } from "./ModuleStatus.mjs";
4
3
 
5
4
  /**
6
- * 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
7
- * Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
5
+ * 同一文档内的首页、模块及字段子页导航。
6
+ * Navigate homepage, module and field views within one document.
8
7
  */
9
8
  export class Navigation extends EventTarget {
10
9
  #container;
@@ -16,7 +15,7 @@ export class Navigation extends EventTarget {
16
15
  #retiring;
17
16
  #controller;
18
17
  #animation;
19
- #scroll = new WeakMap();
18
+ #scroll = new Map();
20
19
  #onHistory = () => this.#route();
21
20
  #onPageShow = event => {
22
21
  if (event.persisted) this.#route(true);
@@ -59,28 +58,37 @@ export class Navigation extends EventTarget {
59
58
  }
60
59
 
61
60
  /**
62
- * 加入子页历史;使用文档自身 URL,避免 srcdoc 按宿主 base URL 跳转。
63
- * Push a detail using the document URL, avoiding srcdoc navigation against the host base URL.
61
+ * 加入模块或字段子页历史,保留路由中的层级分隔符。
62
+ * Push a module or field view while retaining route hierarchy separators.
64
63
  * @param {string} key 子页键 / Detail key.
65
64
  * @returns {void} 无返回值 / No return value.
66
65
  */
67
66
  open(key) {
68
67
  if (key === this.#key) return;
69
68
  const url = new URL(this.#window.location.href);
70
- url.hash = encodeURIComponent(key);
69
+ url.hash = key.split("/").map(encodeURIComponent).join("/");
71
70
  this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
72
71
  this.#route();
73
72
  }
74
73
 
75
74
  /**
76
- * 沿浏览器联合历史返回,根页可退回宿主或上个文档。
77
- * Go back through joint history, including a host or previous document from home.
75
+ * 沿文档历史返回,根页可退回上个文档。
76
+ * Go back through document history, including the previous document from home.
78
77
  * @returns {void} 无返回值 / No return value.
79
78
  */
80
79
  back() {
81
80
  if (this.canGoBack) this.#window.history.back();
82
81
  }
83
82
 
83
+ /**
84
+ * 更新当前页的挂载视图,保留路由和滚动位置。
85
+ * Refresh the mounted view while retaining route and scroll position.
86
+ * @returns {void} 无返回值 / No return value.
87
+ */
88
+ refresh() {
89
+ this.#route(true);
90
+ }
91
+
84
92
  /**
85
93
  * 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
86
94
  * Resolve the URL and coordinate transitions, cancellation and release after animation.
@@ -97,28 +105,42 @@ export class Navigation extends EventTarget {
97
105
  key = "";
98
106
  }
99
107
  if (!reload && key === this.#key) return;
108
+ if (key !== this.#key) this.#scroll.set(this.#key, (this.#view ?? this.#home).scrollTop);
100
109
  this.#controller?.abort();
101
110
  this.#controller = new AbortController();
102
111
  const next = key ? this.#create(key, this.#controller.signal) : undefined;
103
112
  if (!next) key = "";
104
113
  const history = this.#window.history;
105
- // 直接打开子页时建立一次主页历史;刷新不重复堆叠。
106
- // Seed home history once for direct details, without stacking entries on reload.
114
+ // 直接打开字段页时补齐首页和模块历史;刷新不重复堆叠。
115
+ // Seed homepage and module history for direct field links without stacking on reload.
107
116
  if (url.hash && history.state?.preferencePanesRoute !== key) {
108
117
  url.hash = "";
109
118
  history.replaceState({ ...history.state, preferencePanesRoute: "" }, "", url.href);
110
119
  if (key) {
111
- url.hash = encodeURIComponent(key);
120
+ const module = key.split("/")[0];
121
+ if (key.includes("/") && module !== "$link") {
122
+ url.hash = encodeURIComponent(module);
123
+ history.pushState({ ...history.state, preferencePanesRoute: module }, "", url.href);
124
+ }
125
+ url.hash = key.split("/").map(encodeURIComponent).join("/");
112
126
  history.pushState({ ...history.state, preferencePanesRoute: key }, "", url.href);
113
127
  }
114
128
  }
115
129
  const previous = this.#view;
130
+ if (previous === next && this.#key === key) {
131
+ if (previous && this.#scroll.has(key)) {
132
+ previous.scrollTop = this.#scroll.get(key);
133
+ this.#scroll.delete(key);
134
+ }
135
+ this.dispatchEvent(new Event("change"));
136
+ return;
137
+ }
138
+ const scroll = this.#scroll.get(key) ?? (key === this.#key ? (previous?.scrollTop ?? 0) : 0);
116
139
  const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
117
140
  this.#animation?.cancel();
118
141
  this.#retiring?.remove();
119
142
  this.#retiring = previous;
120
143
  if (previous) {
121
- this.#scroll.set(previous, previous.scrollTop);
122
144
  previous.inert = true;
123
145
  }
124
146
  this.#key = key;
@@ -127,7 +149,14 @@ export class Navigation extends EventTarget {
127
149
  if (next) {
128
150
  next.inert = false;
129
151
  this.#container.append(next);
130
- next.scrollTop = this.#scroll.get(next) ?? 0;
152
+ next.scrollTop = scroll;
153
+ // 加载占位页可能无法滚到原位置;配置与控件就绪后的 refresh 再恢复一次。
154
+ // A loading placeholder may clamp scrolling; restore once more when config and controls refresh.
155
+ if (next.scrollTop === scroll) this.#scroll.delete(key);
156
+ }
157
+ if (!next) {
158
+ this.#home.scrollTop = this.#scroll.get("") ?? 0;
159
+ this.#scroll.delete("");
131
160
  }
132
161
  const moving = next ?? previous;
133
162
  if (moving) {
@@ -17,6 +17,7 @@ export function normalizeBoxJs(config, module) {
17
17
  if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
18
18
  for (const entry of app.settings) {
19
19
  if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
20
+ entry.id = entry.id.replace(/\[(0|[1-9]\d*)\]$/, ".$1");
20
21
  if (!entry.id.startsWith("@")) {
21
22
  if (Array.isArray(document)) throw new TypeError("BoxJS settings require @root.path IDs");
22
23
  continue;
@@ -58,7 +59,7 @@ export function normalizeBoxJs(config, module) {
58
59
  };
59
60
  if (type === "select" && !["string", "number", "boolean"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);
60
61
  if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));
61
- if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
62
+ if (Object.prototype.hasOwnProperty.call(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
62
63
  if (
63
64
  typeof field.name !== "string" ||
64
65
  (field.placeholder !== undefined && typeof field.placeholder !== "string") ||
@@ -68,18 +69,42 @@ export function normalizeBoxJs(config, module) {
68
69
  )
69
70
  throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
70
71
  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}`);
71
- if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
72
- if (field.control === "url" && !Object.hasOwn(field, "defaultValue")) throw new TypeError(`URL BoxJS settings require a val: ${entry.id}`);
72
+ if (Object.prototype.hasOwnProperty.call(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
73
+ if (field.control === "url" && !Object.prototype.hasOwnProperty.call(field, "defaultValue")) throw new TypeError(`URL BoxJS settings require a val: ${entry.id}`);
73
74
  fields.push(field);
74
75
  }
75
76
  if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${target.module}`);
76
- const common = fields[0].key.split(".").slice(0, -1);
77
+ for (const field of fields) {
78
+ const indexed = /^(.*)\.(\d+)$/.exec(field.key);
79
+ if (!indexed) continue;
80
+ const siblings = fields.filter(candidate => candidate.key.startsWith(`${indexed[1]}.`) && /^\d+$/.test(candidate.key.slice(indexed[1].length + 1)));
81
+ if (fields.some(candidate => candidate.key.startsWith(`${indexed[1]}.`) && !siblings.includes(candidate))) throw new TypeError(`Indexed settings cannot mix array entries and named children: ${indexed[1]}`);
82
+ if (siblings.some(candidate => !Object.prototype.hasOwnProperty.call(candidate, "defaultValue")) || !siblings.some(candidate => candidate.key === `${indexed[1]}.${siblings.length - 1}`) || siblings.some(candidate => Number(candidate.key.slice(indexed[1].length + 1)) >= siblings.length))
83
+ throw new TypeError(`Indexed settings require contiguous indices and defaults: ${indexed[1]}`);
84
+ }
85
+ const owner = target.owners.size === 1 ? [...target.owners][0] : undefined;
86
+ const cachePath = owner?.cachePath ?? `@${target.storageKey}.${target.module}.Caches`;
87
+ if (typeof cachePath !== "string" || !cachePath.startsWith(`@${target.storageKey}.`)) throw new TypeError("cachePath must use the module storage root");
88
+ const cacheParts = validatePathParts(cachePath.slice(target.storageKey.length + 2).split("."));
89
+ if (cacheParts.length !== 2 || cacheParts[1] !== "Caches") throw new TypeError("cachePath must identify a module Caches subtree");
90
+ if (owner?.resetPaths !== undefined) {
91
+ if (!Array.isArray(owner.resetPaths) || !owner.resetPaths.length) throw new TypeError("resetPaths must be a nonempty array");
92
+ for (const path of owner.resetPaths) {
93
+ if (typeof path !== "string" || !path.startsWith(`@${target.storageKey}.${target.module}.Settings.`)) throw new TypeError("resetPaths must stay in this module's Settings");
94
+ validatePathParts(path.slice(target.storageKey.length + 2).split("."));
95
+ }
96
+ if (fields.some(field => !owner.resetPaths.some(path => `@${target.storageKey}.${field.key}` === path || `@${target.storageKey}.${field.key}`.startsWith(`${path}.`)))) throw new TypeError("resetPaths must cover the page settings");
97
+ }
98
+ const firstIndexed = /^(.*)\.(\d+)$/.exec(fields[0].key);
99
+ const common = (firstIndexed ? firstIndexed[1] : fields[0].key).split(".").slice(0, -1);
77
100
  for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
78
101
  return {
79
102
  module: target.module,
80
103
  storageKey: target.storageKey,
81
104
  fields,
82
105
  settingsPath: common,
106
+ ...(owner?.cachePath === undefined ? {} : { cachePath }),
107
+ ...(owner?.resetPaths === undefined ? {} : { resetPaths: [...owner.resetPaths] }),
83
108
  ...(Object.keys(metadata).length ? { metadata } : {}),
84
109
  };
85
110
  }
@@ -171,9 +196,14 @@ function scalar(value) {
171
196
  * @returns {boolean} 是否可由控件表示 / Whether the control can represent the value.
172
197
  */
173
198
  function validValueShape(field, value) {
174
- if (field.type === "array") {
175
- if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
176
- } else if (typeof value !== field.type || !scalar(value)) return false;
199
+ switch (field.type) {
200
+ case "array":
201
+ if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
202
+ break;
203
+ default:
204
+ if (typeof value !== field.type || !scalar(value)) return false;
205
+ break;
206
+ }
177
207
  if (field.control === "url" && (!/^[a-z][a-z\d+.-]*:/i.test(value) || /^(?:javascript|data|vbscript):/i.test(value))) return false;
178
208
  return true;
179
209
  }
@@ -203,7 +233,7 @@ export function resolveStoredValue(field, stored) {
203
233
  if (value === undefined) return {};
204
234
  if (!validValueShape(field, value)) {
205
235
  return {
206
- ...(Object.hasOwn(field, "defaultValue") ? { value: field.defaultValue } : {}),
236
+ ...(Object.prototype.hasOwnProperty.call(field, "defaultValue") ? { value: field.defaultValue } : {}),
207
237
  warning: { kind: "invalid-value", values: [stored] },
208
238
  };
209
239
  }
@@ -66,11 +66,11 @@ export class PreferencesClient {
66
66
  readSettings(): Promise<unknown>;
67
67
  /** 读取 Caches 子树 / Read the Caches subtree. */
68
68
  readCaches(): Promise<unknown>;
69
- /** 删除 Caches / Delete Caches. */
69
+ /** 删除声明的 Caches 子树 / Delete the declared Caches subtree. */
70
70
  clearCaches(): Promise<void>;
71
- /** 删除整个模块数据并恢复默认值 / Delete module data and restore defaults. */
71
+ /** 删除声明的设置路径,默认整个模块数据,并恢复默认值 / Delete declared setting paths, defaulting to module data, and restore defaults. */
72
72
  reset(): Promise<void>;
73
- /** 取消请求 / Cancel requests. */
73
+ /** 取消读取,已开始的写入继续完成 / Cancel reads while allowing started writes to finish. */
74
74
  leave(): void;
75
75
  /** 写入单个字段 / Write one field. */
76
76
  set(key: string, value: SettingsScalar | SettingsScalar[]): Promise<void>;
@@ -14,6 +14,7 @@ export class PreferencesClient {
14
14
  #values = {};
15
15
  #warnings = {};
16
16
  #saving = false;
17
+ #arrays = new Map();
17
18
 
18
19
  /**
19
20
  * 创建从 BoxJS 定义读取和持久化设置的页面客户端。
@@ -40,13 +41,22 @@ export class PreferencesClient {
40
41
  if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
41
42
  const values = {};
42
43
  const warnings = {};
44
+ this.#arrays.clear();
43
45
  for (const field of this.#definition.fields) {
46
+ const indexed = /^(.*)\.(\d+)$/.exec(field.key);
47
+ if (indexed && !this.#arrays.has(indexed[1])) {
48
+ const storedArray = indexed[1]
49
+ .split(".")
50
+ .slice(this.#definition.settingsPath.length)
51
+ .reduce((parent, part) => Object(parent)[part], subtree);
52
+ this.#arrays.set(indexed[1], Array.isArray(storedArray) ? [...storedArray] : []);
53
+ }
44
54
  const stored = field.key
45
55
  .split(".")
46
56
  .slice(this.#definition.settingsPath.length)
47
57
  .reduce((parent, part) => Object(parent)[part], subtree);
48
58
  const resolved = resolveStoredValue(field, stored);
49
- if (Object.hasOwn(resolved, "value")) values[field.key] = resolved.value;
59
+ if (Object.prototype.hasOwnProperty.call(resolved, "value")) values[field.key] = resolved.value;
50
60
  if (resolved.warning) warnings[field.key] = resolved.warning;
51
61
  }
52
62
  this.#values = values;
@@ -60,7 +70,7 @@ export class PreferencesClient {
60
70
  * @returns {import("./client.mjs").ModuleSnapshot} 会话快照 / Session snapshot.
61
71
  */
62
72
  snapshot() {
63
- return structuredClone({ definition: this.#definition, values: this.#values, warnings: this.#warnings });
73
+ return JSON.parse(JSON.stringify({ definition: this.#definition, values: this.#values, warnings: this.#warnings }));
64
74
  }
65
75
 
66
76
  /**
@@ -79,22 +89,23 @@ export class PreferencesClient {
79
89
  * @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
80
90
  */
81
91
  async readCaches() {
82
- const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#module}.Caches`);
92
+ const response = await this.#send("get", this.#definition.cachePath ?? `@${this.#definition.storageKey}.${this.#module}.Caches`);
83
93
  return response.status === 404 ? undefined : response.json();
84
94
  }
85
95
 
86
96
  /**
87
- * 删除当前模块的 Caches 子树。
88
- * Delete the current module Caches subtree.
97
+ * 删除声明的缓存子树,默认当前模块 Caches。
98
+ * Delete the declared cache subtree, defaulting to the current module Caches.
89
99
  * @returns {Promise<void>} 操作完成 / Operation completion.
90
100
  */
91
101
  clearCaches() {
92
- return this.#change("delete", `${this.#module}.Caches`, undefined, "clearCaches");
102
+ const path = this.#definition.cachePath?.slice(this.#definition.storageKey.length + 2) ?? `${this.#module}.Caches`;
103
+ return this.#change("delete", path, undefined, "clearCaches");
93
104
  }
94
105
 
95
106
  /**
96
- * 删除当前模块数据并恢复页面默认值。
97
- * Delete current module data and restore page defaults.
107
+ * 删除声明的设置子树,默认删除当前模块数据,并恢复页面默认值。
108
+ * Delete declared settings subtrees, defaulting to current module data, and restore page defaults.
98
109
  * @returns {Promise<void>} 操作完成 / Operation completion.
99
110
  */
100
111
  reset() {
@@ -102,8 +113,8 @@ export class PreferencesClient {
102
113
  }
103
114
 
104
115
  /**
105
- * 终止当前页面仍在进行的请求。
106
- * Abort requests still owned by the current page.
116
+ * 终止当前页面的读取;已开始的持久化写入继续完成。
117
+ * Abort reads owned by the current page while allowing started persistence writes to finish.
107
118
  * @returns {void} 无返回值 / No return value.
108
119
  */
109
120
  leave() {
@@ -142,8 +153,10 @@ export class PreferencesClient {
142
153
  async #send(action, path, value) {
143
154
  const controller = new AbortController();
144
155
  const abort = () => controller.abort();
145
- if (this.#session.signal.aborted) abort();
146
- this.#session.signal.addEventListener("abort", abort, { once: true });
156
+ if (action === "get") {
157
+ if (this.#session.signal.aborted) abort();
158
+ this.#session.signal.addEventListener("abort", abort, { once: true });
159
+ }
147
160
  const timer = setTimeout(abort, this.#timeout);
148
161
  try {
149
162
  const response = await this.#request(`/api/${action}`, {
@@ -175,30 +188,49 @@ export class PreferencesClient {
175
188
  if (this.#saving) throw new Error("A settings write is already in progress");
176
189
  this.#saving = true;
177
190
  try {
178
- let field;
179
- if (operation === "write" || operation === "delete") {
180
- field = this.#definition.fields.find(candidate => candidate.key === key);
181
- if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
182
- }
183
- await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
184
191
  switch (operation) {
185
192
  case "write":
186
- this.#values[key] = structuredClone(value);
187
- delete this.#warnings[key];
188
- break;
189
193
  case "delete": {
190
- delete this.#values[key];
191
- delete this.#warnings[key];
192
- if (Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
194
+ const field = this.#definition.fields.find(candidate => candidate.key === key);
195
+ if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
196
+ const indexed = /^(.*)\.(\d+)$/.exec(key);
197
+ if (indexed) {
198
+ const array = [...(this.#arrays.get(indexed[1]) ?? [])];
199
+ for (const sibling of this.#definition.fields) {
200
+ if (sibling.key.startsWith(`${indexed[1]}.`) && /^\d+$/.test(sibling.key.slice(indexed[1].length + 1))) {
201
+ const index = Number(sibling.key.slice(indexed[1].length + 1));
202
+ if (array[index] === undefined) array[index] = this.#values[sibling.key] ?? sibling.defaultValue;
203
+ }
204
+ }
205
+ array[Number(indexed[2])] = operation === "delete" ? field.defaultValue : value;
206
+ await this.#send("set", `@${this.#definition.storageKey}.${indexed[1]}`, array);
207
+ this.#arrays.set(indexed[1], array);
208
+ } else await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
209
+ switch (operation) {
210
+ case "write":
211
+ this.#values[key] = JSON.parse(JSON.stringify(value));
212
+ delete this.#warnings[key];
213
+ break;
214
+ case "delete":
215
+ delete this.#values[key];
216
+ delete this.#warnings[key];
217
+ if (Object.prototype.hasOwnProperty.call(field, "defaultValue")) this.#values[key] = JSON.parse(JSON.stringify(field.defaultValue));
218
+ break;
219
+ }
193
220
  break;
194
221
  }
195
222
  case "clearCaches":
223
+ await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
196
224
  break;
197
225
  case "reset":
226
+ if (this.#definition.resetPaths) {
227
+ for (const path of this.#definition.resetPaths) await this.#send("delete", path);
228
+ } else await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
229
+ this.#arrays.clear();
198
230
  this.#warnings = {};
199
231
  for (const field of this.#definition.fields) {
200
232
  delete this.#values[field.key];
201
- if (Object.hasOwn(field, "defaultValue")) this.#values[field.key] = structuredClone(field.defaultValue);
233
+ if (Object.prototype.hasOwnProperty.call(field, "defaultValue")) this.#values[field.key] = JSON.parse(JSON.stringify(field.defaultValue));
202
234
  }
203
235
  break;
204
236
  }
@@ -36,18 +36,6 @@ export function fieldControl(control) {
36
36
  return control;
37
37
  }
38
38
 
39
- /**
40
- * 元数据地址只允许 HTTP(S) 和相对地址。
41
- * Allow only HTTP(S) and relative metadata addresses.
42
- * @param {string} value 元数据地址 / Metadata address.
43
- * @returns {string} 完整地址 / Absolute address.
44
- */
45
- export function resourceURL(value) {
46
- const url = new URL(value, document.baseURI);
47
- if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Metadata URLs must use HTTP(S)");
48
- return url.href;
49
- }
50
-
51
39
  /**
52
40
  * 创建覆盖可用内容区的通用读取状态,失败时可附加重试动作。
53
41
  * Create a shared status view that fills the available content area and may include retry.
@@ -70,35 +58,3 @@ export function statusView(message, retry) {
70
58
  }
71
59
  return view;
72
60
  }
73
-
74
- /**
75
- * 请求宿主确认;独立网页使用浏览器对话框。
76
- * Request confirmation from the host, using the browser dialog for standalone pages.
77
- * @param {Window} host 模块窗口 / Module window.
78
- * @param {string} message 确认内容 / Confirmation message.
79
- * @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.
80
- */
81
- export function requestConfirmation(host, message) {
82
- return new Promise((resolve, reject) => {
83
- const frame = host.frameElement;
84
- if (frame) {
85
- const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:confirm", { cancelable: true, detail: { message, resolve, reject } });
86
- if (!frame.dispatchEvent(event)) return;
87
- }
88
- resolve(host.confirm(message));
89
- });
90
- }
91
-
92
- /**
93
- * 请求宿主打开 URL;未被接管时保留链接默认导航。
94
- * Request host URL navigation while preserving the link default when unhandled.
95
- * @param {Window} host 模块窗口 / Module window.
96
- * @param {string} url 完整目标地址 / Absolute target URL.
97
- * @returns {boolean} 宿主是否接管导航 / Whether the host accepted navigation ownership.
98
- */
99
- export function requestURLNavigation(host, url) {
100
- const frame = host.frameElement;
101
- if (!frame) return false;
102
- const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:open-url", { cancelable: true, detail: { url } });
103
- return !frame.dispatchEvent(event);
104
- }