@nsnanocat/preference-panes 1.2.1 → 1.2.3

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.
@@ -5,6 +5,8 @@
5
5
  * Prevent default on confirm events and settle host dialogs through detail.resolve/reject.
6
6
  * notice 事件可 preventDefault 后交由宿主显示,模块不再创建网页 Toast。
7
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.
8
10
  */
9
11
  export class ModuleFrame extends EventTarget {
10
12
  /**
@@ -94,6 +96,18 @@ export interface Notice {
94
96
  message: string;
95
97
  }
96
98
 
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
+
97
111
  /**
98
112
  * 菜单操作描述。
99
113
  * Menu action descriptor.
@@ -164,16 +164,60 @@ function scalar(value) {
164
164
  }
165
165
 
166
166
  /**
167
- * 检查值类型、数组唯一性及声明的选项,不进行转换。
168
- * Check value type, array uniqueness and declared choices without coercion.
167
+ * 检查值类型与数组结构,不检查选项是否仍在当前配置中。
168
+ * Check value type and array structure without requiring current option membership.
169
169
  * @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
170
- * @param {unknown} value 待写入的 JSON 值 / JSON value to write.
171
- * @returns {boolean} 是否符合字段约束 / Whether the value satisfies field constraints.
170
+ * @param {unknown} value 待检查值 / Value to inspect.
171
+ * @returns {boolean} 是否可由控件表示 / Whether the control can represent the value.
172
172
  */
173
- export function validValue(field, value) {
173
+ function validValueShape(field, value) {
174
174
  if (field.type === "array") {
175
175
  if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
176
176
  } else if (typeof value !== field.type || !scalar(value)) return false;
177
177
  if (field.control === "url" && (!/^[a-z][a-z\d+.-]*:/i.test(value) || /^(?:javascript|data|vbscript):/i.test(value))) return false;
178
- return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
178
+ return true;
179
+ }
180
+
181
+ /**
182
+ * 返回当前配置未声明的选项值。
183
+ * Return option values absent from the current definition.
184
+ * @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
185
+ * @param {unknown} value 已确认结构有效的值 / Value with a valid shape.
186
+ * @returns {unknown[]} 未定义值 / Undefined values.
187
+ */
188
+ function undefinedOptions(field, value) {
189
+ if (!field.options) return [];
190
+ return (field.type === "array" ? value : [value]).filter(item => !field.options.some(option => option.key === item));
191
+ }
192
+
193
+ /**
194
+ * 将外部存储值解析为可渲染值,并把兼容性问题作为字段级警告返回。
195
+ * Resolve external storage into a renderable value and return compatibility issues as field warnings.
196
+ * @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
197
+ * @param {unknown} stored 外部存储值 / External stored value.
198
+ * @returns {{value?: unknown, warning?: {kind: "invalid-value" | "undefined-options", values: unknown[]}}} 解析结果 / Resolution result.
199
+ */
200
+ export function resolveStoredValue(field, stored) {
201
+ if (field.control === "url") return { value: field.defaultValue };
202
+ const value = normalizeStoredValue(field, stored === undefined ? field.defaultValue : stored);
203
+ if (value === undefined) return {};
204
+ if (!validValueShape(field, value)) {
205
+ return {
206
+ ...(Object.hasOwn(field, "defaultValue") ? { value: field.defaultValue } : {}),
207
+ warning: { kind: "invalid-value", values: [stored] },
208
+ };
209
+ }
210
+ const undefinedValues = undefinedOptions(field, value);
211
+ return { value, ...(undefinedValues.length ? { warning: { kind: "undefined-options", values: undefinedValues } } : {}) };
212
+ }
213
+
214
+ /**
215
+ * 严格检查写入值的类型、数组唯一性及声明选项。
216
+ * Strictly check a write value's type, array uniqueness, and declared options.
217
+ * @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
218
+ * @param {unknown} value 待写入的 JSON 值 / JSON value to write.
219
+ * @returns {boolean} 是否允许写入 / Whether the value may be written.
220
+ */
221
+ export function validValue(field, value) {
222
+ return validValueShape(field, value) && undefinedOptions(field, value).length === 0;
179
223
  }
@@ -29,6 +29,16 @@ export interface PreferencesClientOptions {
29
29
  /** 请求超时毫秒数 / Request timeout in milliseconds. */
30
30
  timeout?: number;
31
31
  }
32
+ /**
33
+ * 外部存储值与当前字段定义不完全兼容时的非阻断诊断。
34
+ * Non-blocking diagnostic for external storage that no longer fully matches a field definition.
35
+ */
36
+ export interface StoredValueWarning {
37
+ /** 警告类别 / Warning kind. */
38
+ kind: "invalid-value" | "undefined-options";
39
+ /** 触发警告的原始值或未定义选项 / Raw value or undefined options that triggered the warning. */
40
+ values: unknown[];
41
+ }
32
42
  /**
33
43
  * 会话的深拷贝快照。
34
44
  * Deep-cloned session snapshot.
@@ -38,6 +48,8 @@ export interface ModuleSnapshot {
38
48
  definition: ModuleDefinition;
39
49
  /** 当前页面值 / Current page values. */
40
50
  values: Record<string, SettingsScalar | SettingsScalar[] | null>;
51
+ /** 按字段路径索引的非阻断存储警告 / Non-blocking storage warnings keyed by field path. */
52
+ warnings: Record<string, StoredValueWarning>;
41
53
  }
42
54
  /**
43
55
  * 管理单模块页面的 API 请求、值快照和会话终止。
@@ -1,4 +1,4 @@
1
- import { normalizeStoredValue, validValue } from "./boxjs.mjs";
1
+ import { resolveStoredValue, validValue } from "./boxjs.mjs";
2
2
 
3
3
  /**
4
4
  * 管理单模块页面的 API 请求、值快照和会话终止。
@@ -12,6 +12,7 @@ export class PreferencesClient {
12
12
  #timeout;
13
13
  #session = new AbortController();
14
14
  #values = {};
15
+ #warnings = {};
15
16
  #saving = false;
16
17
 
17
18
  /**
@@ -38,17 +39,18 @@ export class PreferencesClient {
38
39
  if (typeof subtree === "string") subtree = JSON.parse(subtree);
39
40
  if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
40
41
  const values = {};
42
+ const warnings = {};
41
43
  for (const field of this.#definition.fields) {
42
44
  const stored = field.key
43
45
  .split(".")
44
46
  .slice(this.#definition.settingsPath.length)
45
47
  .reduce((parent, part) => Object(parent)[part], subtree);
46
- const value = normalizeStoredValue(field, stored === undefined ? field.defaultValue : stored);
47
- if (value === undefined) continue;
48
- if (!validValue(field, value)) throw new TypeError(`Invalid stored value: ${field.key}`);
49
- values[field.key] = value;
48
+ const resolved = resolveStoredValue(field, stored);
49
+ if (Object.hasOwn(resolved, "value")) values[field.key] = resolved.value;
50
+ if (resolved.warning) warnings[field.key] = resolved.warning;
50
51
  }
51
52
  this.#values = values;
53
+ this.#warnings = warnings;
52
54
  return this.snapshot();
53
55
  }
54
56
 
@@ -58,7 +60,7 @@ export class PreferencesClient {
58
60
  * @returns {import("./client.mjs").ModuleSnapshot} 会话快照 / Session snapshot.
59
61
  */
60
62
  snapshot() {
61
- return structuredClone({ definition: this.#definition, values: this.#values });
63
+ return structuredClone({ definition: this.#definition, values: this.#values, warnings: this.#warnings });
62
64
  }
63
65
 
64
66
  /**
@@ -182,15 +184,18 @@ export class PreferencesClient {
182
184
  switch (operation) {
183
185
  case "write":
184
186
  this.#values[key] = structuredClone(value);
187
+ delete this.#warnings[key];
185
188
  break;
186
189
  case "delete": {
187
190
  delete this.#values[key];
191
+ delete this.#warnings[key];
188
192
  if (Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
189
193
  break;
190
194
  }
191
195
  case "clearCaches":
192
196
  break;
193
197
  case "reset":
198
+ this.#warnings = {};
194
199
  for (const field of this.#definition.fields) {
195
200
  delete this.#values[field.key];
196
201
  if (Object.hasOwn(field, "defaultValue")) this.#values[field.key] = structuredClone(field.defaultValue);
@@ -88,3 +88,17 @@ export function requestConfirmation(host, message) {
88
88
  resolve(host.confirm(message));
89
89
  });
90
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
+ }
@@ -8,6 +8,7 @@
8
8
  --pp-border: #e3e5e7;
9
9
  --pp-muted: #797f87;
10
10
  --pp-accent: #1677ff;
11
+ --pp-warning: #9a6700;
11
12
  font:
12
13
  15px / 1.5 -apple-system,
13
14
  BlinkMacSystemFont,
@@ -32,6 +33,7 @@
32
33
  --pp-field: #2f3238;
33
34
  --pp-border: #2f3238;
34
35
  --pp-muted: #9499a0;
36
+ --pp-warning: #f0b849;
35
37
  }
36
38
  .pp-panel * {
37
39
  box-sizing: border-box;
@@ -175,6 +177,12 @@
175
177
  color: var(--pp-muted);
176
178
  font-size: 12px;
177
179
  }
180
+ .pp-field-warning {
181
+ margin-top: 4px;
182
+ color: var(--pp-warning);
183
+ font-size: 12px;
184
+ overflow-wrap: anywhere;
185
+ }
178
186
  .pp-group {
179
187
  margin-top: 16px;
180
188
  }
@@ -1,9 +1,25 @@
1
1
  import { ActionMenu } from "./ActionMenu.mjs";
2
2
  import { validValue } from "./boxjs.mjs";
3
3
  import { PreferencesClient } from "./client.mjs";
4
- import { fieldControl, element as node, requestConfirmation, resourceURL, settingRow, statusView } from "./components.mjs";
4
+ import { fieldControl, element as node, requestConfirmation, requestURLNavigation, resourceURL, settingRow, statusView } from "./components.mjs";
5
5
  import { Navigation } from "./Navigation.mjs";
6
6
 
7
+ /**
8
+ * 将字段级存储诊断转换为简短提示。
9
+ * Convert a field-level storage diagnostic into concise display text.
10
+ * @param {import("./client.mjs").StoredValueWarning} warning 存储警告 / Storage warning.
11
+ * @returns {string} 用户可见提示 / User-visible warning.
12
+ */
13
+ function warningMessage(warning) {
14
+ const values = warning.values
15
+ .map(value => {
16
+ if (typeof value === "string") return value;
17
+ return JSON.stringify(value) ?? String(value);
18
+ })
19
+ .join("、");
20
+ return warning.kind === "undefined-options" ? `当前配置未定义值:${values}` : `当前存储值格式不受支持:${values}`;
21
+ }
22
+
7
23
  /**
8
24
  * 管理模块表单、导航、操作队列和短暂通知。
9
25
  * Manage the module form, navigation, operation queue, and transient notifications.
@@ -182,6 +198,7 @@ export class PreferencesPanel {
182
198
  const growingInputs = [];
183
199
  const editors = new Map();
184
200
  const summaries = [];
201
+ const warningRefreshers = [];
185
202
  const groups = new Map();
186
203
  let queue = Promise.resolve(),
187
204
  pendingWrites = 0;
@@ -258,6 +275,15 @@ export class PreferencesPanel {
258
275
  const label = node("div", "pp-label");
259
276
  label.append(node("span", "pp-field-name", match?.[2] ?? field.name));
260
277
  if (field.description) label.append(node("span", "pp-field-description", field.description));
278
+ const warning = node("span", "pp-field-warning");
279
+ const refreshWarning = () => {
280
+ const issue = client.snapshot().warnings[field.key];
281
+ warning.hidden = !issue;
282
+ warning.textContent = issue ? warningMessage(issue) : "";
283
+ };
284
+ warningRefreshers.push(refreshWarning);
285
+ refreshWarning();
286
+ label.append(warning);
261
287
  row.append(label);
262
288
  const value = values[field.key];
263
289
  /**
@@ -282,6 +308,9 @@ export class PreferencesPanel {
282
308
  write = value => {
283
309
  link.href = value;
284
310
  };
311
+ link.addEventListener("click", event => {
312
+ if (requestURLNavigation(window, link.href)) event.preventDefault();
313
+ });
285
314
  row.append(link);
286
315
  row.addEventListener("click", event => {
287
316
  if (!link.contains(event.target)) link.click();
@@ -431,6 +460,7 @@ export class PreferencesPanel {
431
460
  },
432
461
  () => {
433
462
  for (const refresh of summaries) refresh();
463
+ for (const refresh of warningRefreshers) refresh();
434
464
  },
435
465
  restore,
436
466
  );