@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/CHANGELOG.md +31 -0
- package/README.md +66 -25
- package/dist/home.html +2208 -0
- package/dist/{module/navigation.mjs → navigation.mjs} +51 -143
- package/dist/preference-panes.mjs +913 -614
- package/package.json +4 -3
- package/src/browser/ActionMenu.mjs +7 -6
- package/src/browser/Navigation.d.mts +4 -95
- package/src/browser/Navigation.mjs +43 -14
- package/src/browser/boxjs.mjs +38 -8
- package/src/browser/client.d.mts +3 -3
- package/src/browser/client.mjs +57 -25
- package/src/browser/components.mjs +0 -44
- package/src/browser/home.d.mts +66 -0
- package/src/browser/home.html +15 -0
- package/src/browser/home.mjs +287 -0
- package/src/browser/index.d.ts +2 -0
- package/src/browser/mount.mjs +50 -91
- package/src/browser/page.mjs +325 -0
- package/src/browser/panel.mjs +118 -149
- package/src/browser/styles.css +565 -0
- package/src/index.d.ts +8 -0
- package/dist/module/index.html +0 -16
- package/dist/module/index.mjs +0 -1625
- package/src/browser/ModuleFrame.mjs +0 -122
- package/src/browser/index.mjs +0 -89
- package/src/browser/module.html +0 -16
- package/src/browser/panel.css +0 -315
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsnanocat/preference-panes",
|
|
3
|
-
"version": "1.
|
|
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/
|
|
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
|
-
*
|
|
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:
|
|
31
|
+
:host{display:inline-flex;flex:none;width:37px;height:37px;color:inherit}
|
|
32
32
|
:host([hidden]){display:none!important}
|
|
33
|
-
button{width:
|
|
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:
|
|
37
|
-
</style><button type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false"><svg viewBox="0 0
|
|
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
|
|
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
|
-
*
|
|
7
|
-
* Navigate
|
|
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
|
|
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
|
-
*
|
|
63
|
-
* Push a
|
|
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(
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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) {
|
package/src/browser/boxjs.mjs
CHANGED
|
@@ -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.
|
|
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.
|
|
72
|
-
if (field.control === "url" && !Object.
|
|
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
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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.
|
|
236
|
+
...(Object.prototype.hasOwnProperty.call(field, "defaultValue") ? { value: field.defaultValue } : {}),
|
|
207
237
|
warning: { kind: "invalid-value", values: [stored] },
|
|
208
238
|
};
|
|
209
239
|
}
|
package/src/browser/client.d.mts
CHANGED
|
@@ -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
|
-
/**
|
|
69
|
+
/** 删除声明的 Caches 子树 / Delete the declared Caches subtree. */
|
|
70
70
|
clearCaches(): Promise<void>;
|
|
71
|
-
/**
|
|
71
|
+
/** 删除声明的设置路径,默认整个模块数据,并恢复默认值 / Delete declared setting paths, defaulting to module data, and restore defaults. */
|
|
72
72
|
reset(): Promise<void>;
|
|
73
|
-
/**
|
|
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>;
|
package/src/browser/client.mjs
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
*
|
|
88
|
-
* Delete the current module Caches
|
|
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
|
-
|
|
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
|
|
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 (
|
|
146
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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.
|
|
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
|
-
}
|