@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
|
@@ -1,3 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 共用菜单按钮和底部操作菜单;原生宿主可通过 MORE 调用。
|
|
3
|
+
* Shared menu trigger and bottom action sheet; native hosts may invoke it through MORE.
|
|
4
|
+
*/
|
|
5
|
+
class ActionMenu {
|
|
6
|
+
#button;
|
|
7
|
+
#layer;
|
|
8
|
+
#items;
|
|
9
|
+
#select;
|
|
10
|
+
#document;
|
|
11
|
+
#disabled = true;
|
|
12
|
+
#key = event => {
|
|
13
|
+
if (event.key === "Escape" && !this.#layer.hidden) {
|
|
14
|
+
event.preventDefault();
|
|
15
|
+
this.close();
|
|
16
|
+
this.#button.focus();
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 创建菜单,操作逻辑由调用方提供。
|
|
22
|
+
* Create a menu whose actions are handled by the caller.
|
|
23
|
+
* @param {(id: string) => void} select 菜单选择回调 / Selection callback.
|
|
24
|
+
*/
|
|
25
|
+
constructor(select) {
|
|
26
|
+
this.#document = document;
|
|
27
|
+
this.#select = select;
|
|
28
|
+
this.element = document.createElement("span");
|
|
29
|
+
const triggerRoot = this.element.attachShadow({ mode: "open" });
|
|
30
|
+
triggerRoot.innerHTML = `<style>
|
|
31
|
+
:host{display:inline-flex;flex:none;width:37px;height:37px;color:inherit}
|
|
32
|
+
:host([hidden]){display:none!important}
|
|
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)}
|
|
35
|
+
button:disabled{opacity:.4;cursor:default}
|
|
36
|
+
button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
|
|
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>`;
|
|
39
|
+
this.#button = triggerRoot.querySelector("button");
|
|
40
|
+
this.#layer = document.createElement("span");
|
|
41
|
+
const layerRoot = this.#layer.attachShadow({ mode: "open" });
|
|
42
|
+
layerRoot.innerHTML = `<style>
|
|
43
|
+
:host{position:fixed;inset:0;z-index:2147483647;color:var(--pp-text,CanvasText);font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
44
|
+
:host([hidden]){display:none!important}
|
|
45
|
+
*,*::before,*::after{box-sizing:border-box}
|
|
46
|
+
button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
47
|
+
button:focus-visible{outline:2px solid var(--pp-accent,Highlight);outline-offset:-3px}
|
|
48
|
+
#backdrop{position:absolute;inset:0;width:100%;height:100%;padding:0;background:#0008;animation:pp-fade-in .18s ease-out}
|
|
49
|
+
#sheet{position:absolute;z-index:1;left:0;right:0;bottom:0;width:100%;max-width:540px;max-height:calc(100% - 24px);margin:auto;padding:8px 8px calc(8px + env(safe-area-inset-bottom));animation:pp-sheet-in .22s cubic-bezier(.2,.8,.2,1)}
|
|
50
|
+
#items,#cancel{overflow:hidden;background:var(--pp-surface,Canvas);border:1px solid var(--pp-border,#8884);border-radius:14px;box-shadow:0 8px 28px #0004}
|
|
51
|
+
#items{max-height:calc(100vh - 116px - env(safe-area-inset-bottom));overflow-y:auto;-webkit-overflow-scrolling:touch}
|
|
52
|
+
#items button,#cancel{display:block;width:100%;min-height:54px;padding:14px 18px;text-align:center}
|
|
53
|
+
#items button+button{border-top:1px solid var(--pp-border,#8884)}
|
|
54
|
+
#items button[data-danger]{color:var(--pp-danger,#e45656)}
|
|
55
|
+
#cancel{margin-top:8px;color:var(--pp-accent,Highlight);font-weight:600}
|
|
56
|
+
@keyframes pp-fade-in{from{opacity:0}}
|
|
57
|
+
@keyframes pp-sheet-in{from{transform:translateY(100%)}}
|
|
58
|
+
@media (prefers-reduced-motion:reduce){#backdrop,#sheet{animation:none}}
|
|
59
|
+
</style><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单"></button><section id="sheet" role="dialog" aria-modal="true" aria-label="更多操作"><div id="items" role="menu"></div><button id="cancel" type="button">取消</button></section>`;
|
|
60
|
+
this.#items = layerRoot.querySelector("#items");
|
|
61
|
+
this.#button.onclick = () => (this.#layer.hidden ? this.open() : this.close());
|
|
62
|
+
layerRoot.querySelector("#backdrop").onclick = () => {
|
|
63
|
+
this.close();
|
|
64
|
+
this.#button.focus();
|
|
65
|
+
};
|
|
66
|
+
layerRoot.querySelector("#cancel").onclick = () => {
|
|
67
|
+
this.close();
|
|
68
|
+
this.#button.focus();
|
|
69
|
+
};
|
|
70
|
+
this.#items.onkeydown = event => {
|
|
71
|
+
const items = [...this.#items.children];
|
|
72
|
+
const index = items.indexOf(layerRoot.activeElement);
|
|
73
|
+
const offsets = { ArrowDown: 1, ArrowUp: -1 };
|
|
74
|
+
if (event.key in offsets) {
|
|
75
|
+
event.preventDefault();
|
|
76
|
+
items[(index + offsets[event.key] + items.length) % items.length].focus();
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
document.body.append(this.#layer);
|
|
80
|
+
document.addEventListener("keydown", this.#key);
|
|
81
|
+
this.update([]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 同步可用操作和忙碌状态,不重建菜单触发按钮。
|
|
86
|
+
* Update actions and busy state without replacing the trigger button.
|
|
87
|
+
* @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.
|
|
88
|
+
* @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
|
|
89
|
+
* @returns {void} 无返回值 / No return value.
|
|
90
|
+
*/
|
|
91
|
+
update(items, disabled = false) {
|
|
92
|
+
this.close();
|
|
93
|
+
this.#disabled = disabled || items.length === 0;
|
|
94
|
+
this.#button.disabled = this.#disabled;
|
|
95
|
+
this.#items.replaceChildren(
|
|
96
|
+
...items.map(item => {
|
|
97
|
+
const button = this.#document.createElement("button");
|
|
98
|
+
button.type = "button";
|
|
99
|
+
button.setAttribute("role", "menuitem");
|
|
100
|
+
button.textContent = item.label;
|
|
101
|
+
button.toggleAttribute("data-danger", Boolean(item.destructive));
|
|
102
|
+
button.onclick = () => {
|
|
103
|
+
this.close();
|
|
104
|
+
this.#select(item.id);
|
|
105
|
+
};
|
|
106
|
+
return button;
|
|
107
|
+
}),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 打开当前操作菜单。
|
|
113
|
+
* Open the current action sheet.
|
|
114
|
+
* @returns {void} 无返回值 / No return value.
|
|
115
|
+
*/
|
|
116
|
+
open() {
|
|
117
|
+
if (this.#disabled) return;
|
|
118
|
+
const style = getComputedStyle(this.element);
|
|
119
|
+
for (const property of ["--pp-text", "--pp-surface", "--pp-border", "--pp-accent", "--pp-danger"]) {
|
|
120
|
+
const value = style.getPropertyValue(property);
|
|
121
|
+
if (value) this.#layer.style.setProperty(property, value);
|
|
122
|
+
}
|
|
123
|
+
this.#layer.hidden = false;
|
|
124
|
+
this.#button.setAttribute("aria-expanded", "true");
|
|
125
|
+
this.#items.firstElementChild.focus();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 关闭菜单。
|
|
130
|
+
* Close the menu.
|
|
131
|
+
* @returns {void} 无返回值 / No return value.
|
|
132
|
+
*/
|
|
133
|
+
close() {
|
|
134
|
+
this.#layer.hidden = true;
|
|
135
|
+
this.#button.setAttribute("aria-expanded", "false");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 移除监听器与节点。
|
|
140
|
+
* Remove listeners and elements.
|
|
141
|
+
* @returns {void} 无返回值 / No return value.
|
|
142
|
+
*/
|
|
143
|
+
destroy() {
|
|
144
|
+
this.#document.removeEventListener("keydown", this.#key);
|
|
145
|
+
this.#layer.remove();
|
|
146
|
+
this.element.remove();
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
1
150
|
/**
|
|
2
151
|
* 校验原始路径片段,不进行 URL 编码转换。
|
|
3
152
|
* Validate raw path segments without URL encoding conversion.
|
|
@@ -27,6 +176,7 @@ function normalizeBoxJs(config, module) {
|
|
|
27
176
|
if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
|
|
28
177
|
for (const entry of app.settings) {
|
|
29
178
|
if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
|
|
179
|
+
entry.id = entry.id.replace(/\[(0|[1-9]\d*)\]$/, ".$1");
|
|
30
180
|
if (!entry.id.startsWith("@")) {
|
|
31
181
|
if (Array.isArray(document)) throw new TypeError("BoxJS settings require @root.path IDs");
|
|
32
182
|
continue;
|
|
@@ -68,7 +218,7 @@ function normalizeBoxJs(config, module) {
|
|
|
68
218
|
};
|
|
69
219
|
if (type === "select" && !["string", "number", "boolean"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);
|
|
70
220
|
if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));
|
|
71
|
-
if (Object.
|
|
221
|
+
if (Object.prototype.hasOwnProperty.call(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
|
|
72
222
|
if (
|
|
73
223
|
typeof field.name !== "string" ||
|
|
74
224
|
(field.placeholder !== undefined && typeof field.placeholder !== "string") ||
|
|
@@ -78,18 +228,42 @@ function normalizeBoxJs(config, module) {
|
|
|
78
228
|
)
|
|
79
229
|
throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
|
|
80
230
|
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}`);
|
|
81
|
-
if (Object.
|
|
82
|
-
if (field.control === "url" && !Object.
|
|
231
|
+
if (Object.prototype.hasOwnProperty.call(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
|
|
232
|
+
if (field.control === "url" && !Object.prototype.hasOwnProperty.call(field, "defaultValue")) throw new TypeError(`URL BoxJS settings require a val: ${entry.id}`);
|
|
83
233
|
fields.push(field);
|
|
84
234
|
}
|
|
85
235
|
if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${target.module}`);
|
|
86
|
-
const
|
|
236
|
+
for (const field of fields) {
|
|
237
|
+
const indexed = /^(.*)\.(\d+)$/.exec(field.key);
|
|
238
|
+
if (!indexed) continue;
|
|
239
|
+
const siblings = fields.filter(candidate => candidate.key.startsWith(`${indexed[1]}.`) && /^\d+$/.test(candidate.key.slice(indexed[1].length + 1)));
|
|
240
|
+
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]}`);
|
|
241
|
+
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))
|
|
242
|
+
throw new TypeError(`Indexed settings require contiguous indices and defaults: ${indexed[1]}`);
|
|
243
|
+
}
|
|
244
|
+
const owner = target.owners.size === 1 ? [...target.owners][0] : undefined;
|
|
245
|
+
const cachePath = owner?.cachePath ?? `@${target.storageKey}.${target.module}.Caches`;
|
|
246
|
+
if (typeof cachePath !== "string" || !cachePath.startsWith(`@${target.storageKey}.`)) throw new TypeError("cachePath must use the module storage root");
|
|
247
|
+
const cacheParts = validatePathParts(cachePath.slice(target.storageKey.length + 2).split("."));
|
|
248
|
+
if (cacheParts.length !== 2 || cacheParts[1] !== "Caches") throw new TypeError("cachePath must identify a module Caches subtree");
|
|
249
|
+
if (owner?.resetPaths !== undefined) {
|
|
250
|
+
if (!Array.isArray(owner.resetPaths) || !owner.resetPaths.length) throw new TypeError("resetPaths must be a nonempty array");
|
|
251
|
+
for (const path of owner.resetPaths) {
|
|
252
|
+
if (typeof path !== "string" || !path.startsWith(`@${target.storageKey}.${target.module}.Settings.`)) throw new TypeError("resetPaths must stay in this module's Settings");
|
|
253
|
+
validatePathParts(path.slice(target.storageKey.length + 2).split("."));
|
|
254
|
+
}
|
|
255
|
+
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");
|
|
256
|
+
}
|
|
257
|
+
const firstIndexed = /^(.*)\.(\d+)$/.exec(fields[0].key);
|
|
258
|
+
const common = (firstIndexed ? firstIndexed[1] : fields[0].key).split(".").slice(0, -1);
|
|
87
259
|
for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
|
|
88
260
|
return {
|
|
89
261
|
module: target.module,
|
|
90
262
|
storageKey: target.storageKey,
|
|
91
263
|
fields,
|
|
92
264
|
settingsPath: common,
|
|
265
|
+
...(owner?.cachePath === undefined ? {} : { cachePath }),
|
|
266
|
+
...(owner?.resetPaths === undefined ? {} : { resetPaths: [...owner.resetPaths] }),
|
|
93
267
|
...(Object.keys(metadata).length ? { metadata } : {}),
|
|
94
268
|
};
|
|
95
269
|
}
|
|
@@ -179,9 +353,14 @@ function scalar(value) {
|
|
|
179
353
|
* @returns {boolean} 是否可由控件表示 / Whether the control can represent the value.
|
|
180
354
|
*/
|
|
181
355
|
function validValueShape(field, value) {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
356
|
+
switch (field.type) {
|
|
357
|
+
case "array":
|
|
358
|
+
if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
|
|
359
|
+
break;
|
|
360
|
+
default:
|
|
361
|
+
if (typeof value !== field.type || !scalar(value)) return false;
|
|
362
|
+
break;
|
|
363
|
+
}
|
|
185
364
|
if (field.control === "url" && (!/^[a-z][a-z\d+.-]*:/i.test(value) || /^(?:javascript|data|vbscript):/i.test(value))) return false;
|
|
186
365
|
return true;
|
|
187
366
|
}
|
|
@@ -211,7 +390,7 @@ function resolveStoredValue(field, stored) {
|
|
|
211
390
|
if (value === undefined) return {};
|
|
212
391
|
if (!validValueShape(field, value)) {
|
|
213
392
|
return {
|
|
214
|
-
...(Object.
|
|
393
|
+
...(Object.prototype.hasOwnProperty.call(field, "defaultValue") ? { value: field.defaultValue } : {}),
|
|
215
394
|
warning: { kind: "invalid-value", values: [stored] },
|
|
216
395
|
};
|
|
217
396
|
}
|
|
@@ -268,18 +447,6 @@ function fieldControl(control) {
|
|
|
268
447
|
return control;
|
|
269
448
|
}
|
|
270
449
|
|
|
271
|
-
/**
|
|
272
|
-
* 元数据地址只允许 HTTP(S) 和相对地址。
|
|
273
|
-
* Allow only HTTP(S) and relative metadata addresses.
|
|
274
|
-
* @param {string} value 元数据地址 / Metadata address.
|
|
275
|
-
* @returns {string} 完整地址 / Absolute address.
|
|
276
|
-
*/
|
|
277
|
-
function resourceURL(value) {
|
|
278
|
-
const url = new URL(value, document.baseURI);
|
|
279
|
-
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Metadata URLs must use HTTP(S)");
|
|
280
|
-
return url.href;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
450
|
/**
|
|
284
451
|
* 创建覆盖可用内容区的通用读取状态,失败时可附加重试动作。
|
|
285
452
|
* Create a shared status view that fills the available content area and may include retry.
|
|
@@ -304,182 +471,191 @@ function statusView(message, retry) {
|
|
|
304
471
|
}
|
|
305
472
|
|
|
306
473
|
/**
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
* @param {Window} host 模块窗口 / Module window.
|
|
310
|
-
* @param {string} message 确认内容 / Confirmation message.
|
|
311
|
-
* @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.
|
|
474
|
+
* 同一文档内的首页、模块及字段子页导航。
|
|
475
|
+
* Navigate homepage, module and field views within one document.
|
|
312
476
|
*/
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
477
|
+
class Navigation extends EventTarget {
|
|
478
|
+
#container;
|
|
479
|
+
#home;
|
|
480
|
+
#create;
|
|
481
|
+
#window;
|
|
482
|
+
#key = null;
|
|
483
|
+
#view;
|
|
484
|
+
#retiring;
|
|
485
|
+
#controller;
|
|
486
|
+
#animation;
|
|
487
|
+
#scroll = new Map();
|
|
488
|
+
#onHistory = () => this.#route();
|
|
489
|
+
#onPageShow = event => {
|
|
490
|
+
if (event.persisted) this.#route(true);
|
|
491
|
+
};
|
|
323
492
|
|
|
324
|
-
/**
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
493
|
+
/**
|
|
494
|
+
* 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。
|
|
495
|
+
* Retain the home view and create details on demand; signal cancels async work after departure.
|
|
496
|
+
* @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.
|
|
497
|
+
* @param {HTMLElement} home 已创建的主页节点 / Existing home view.
|
|
498
|
+
* @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.
|
|
499
|
+
*/
|
|
500
|
+
constructor(container, home, create) {
|
|
501
|
+
super();
|
|
502
|
+
this.#container = container;
|
|
503
|
+
this.#home = home;
|
|
504
|
+
this.#create = create;
|
|
505
|
+
this.#window = container.ownerDocument.defaultView;
|
|
506
|
+
container.replaceChildren(home);
|
|
507
|
+
this.#window.addEventListener("popstate", this.#onHistory);
|
|
508
|
+
this.#window.addEventListener("hashchange", this.#onHistory);
|
|
509
|
+
this.#window.addEventListener("pageshow", this.#onPageShow);
|
|
510
|
+
this.#route();
|
|
511
|
+
}
|
|
337
512
|
|
|
338
|
-
/**
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
#items;
|
|
346
|
-
#select;
|
|
347
|
-
#document;
|
|
348
|
-
#disabled = true;
|
|
349
|
-
#key = event => {
|
|
350
|
-
if (event.key === "Escape" && !this.#layer.hidden) {
|
|
351
|
-
event.preventDefault();
|
|
352
|
-
this.close();
|
|
353
|
-
this.#button.focus();
|
|
354
|
-
}
|
|
355
|
-
};
|
|
513
|
+
/**
|
|
514
|
+
* 当前子页键;空字符串表示主页。
|
|
515
|
+
* Current detail key; empty means home.
|
|
516
|
+
*/
|
|
517
|
+
get current() {
|
|
518
|
+
return this.#key;
|
|
519
|
+
}
|
|
356
520
|
|
|
357
521
|
/**
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
* @param {(id: string) => void} select 菜单选择回调 / Selection callback.
|
|
522
|
+
* 是否可以返回上一级或先前文档。
|
|
523
|
+
* Whether a parent view or previous document is available.
|
|
361
524
|
*/
|
|
362
|
-
|
|
363
|
-
this.#
|
|
364
|
-
this.#select = select;
|
|
365
|
-
this.element = document.createElement("span");
|
|
366
|
-
const triggerRoot = this.element.attachShadow({ mode: "open" });
|
|
367
|
-
triggerRoot.innerHTML = `<style>
|
|
368
|
-
:host{display:inline-flex;width:44px;height:44px;color:inherit}
|
|
369
|
-
:host([hidden]){display:none!important}
|
|
370
|
-
button{width:44px;height:44px;padding:10px;font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
371
|
-
button:disabled{opacity:.4;cursor:default}
|
|
372
|
-
button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
|
|
373
|
-
svg{display:block;width:24px;height:24px;fill:currentColor}
|
|
374
|
-
</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>`;
|
|
375
|
-
this.#button = triggerRoot.querySelector("button");
|
|
376
|
-
this.#layer = document.createElement("span");
|
|
377
|
-
const layerRoot = this.#layer.attachShadow({ mode: "open" });
|
|
378
|
-
layerRoot.innerHTML = `<style>
|
|
379
|
-
:host{position:fixed;inset:0;z-index:2147483647;color:var(--pp-text,CanvasText);font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
380
|
-
:host([hidden]){display:none!important}
|
|
381
|
-
*,*::before,*::after{box-sizing:border-box}
|
|
382
|
-
button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
383
|
-
button:focus-visible{outline:2px solid var(--pp-accent,Highlight);outline-offset:-3px}
|
|
384
|
-
#backdrop{position:absolute;inset:0;width:100%;height:100%;padding:0;background:#0008;animation:pp-fade-in .18s ease-out}
|
|
385
|
-
#sheet{position:absolute;z-index:1;left:0;right:0;bottom:0;width:100%;max-width:540px;max-height:calc(100% - 24px);margin:auto;padding:8px 8px calc(8px + env(safe-area-inset-bottom));animation:pp-sheet-in .22s cubic-bezier(.2,.8,.2,1)}
|
|
386
|
-
#items,#cancel{overflow:hidden;background:var(--pp-surface,Canvas);border:1px solid var(--pp-border,#8884);border-radius:14px;box-shadow:0 8px 28px #0004}
|
|
387
|
-
#items{max-height:calc(100vh - 116px - env(safe-area-inset-bottom));overflow-y:auto;-webkit-overflow-scrolling:touch}
|
|
388
|
-
#items button,#cancel{display:block;width:100%;min-height:54px;padding:14px 18px;text-align:center}
|
|
389
|
-
#items button+button{border-top:1px solid var(--pp-border,#8884)}
|
|
390
|
-
#items button[data-danger]{color:var(--pp-danger,#e45656)}
|
|
391
|
-
#cancel{margin-top:8px;color:var(--pp-accent,Highlight);font-weight:600}
|
|
392
|
-
@keyframes pp-fade-in{from{opacity:0}}
|
|
393
|
-
@keyframes pp-sheet-in{from{transform:translateY(100%)}}
|
|
394
|
-
@media (prefers-reduced-motion:reduce){#backdrop,#sheet{animation:none}}
|
|
395
|
-
</style><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单"></button><section id="sheet" role="dialog" aria-modal="true" aria-label="更多操作"><div id="items" role="menu"></div><button id="cancel" type="button">取消</button></section>`;
|
|
396
|
-
this.#items = layerRoot.querySelector("#items");
|
|
397
|
-
this.#button.onclick = () => (this.#layer.hidden ? this.open() : this.close());
|
|
398
|
-
layerRoot.querySelector("#backdrop").onclick = () => {
|
|
399
|
-
this.close();
|
|
400
|
-
this.#button.focus();
|
|
401
|
-
};
|
|
402
|
-
layerRoot.querySelector("#cancel").onclick = () => {
|
|
403
|
-
this.close();
|
|
404
|
-
this.#button.focus();
|
|
405
|
-
};
|
|
406
|
-
this.#items.onkeydown = event => {
|
|
407
|
-
const items = [...this.#items.children];
|
|
408
|
-
const index = items.indexOf(layerRoot.activeElement);
|
|
409
|
-
const offsets = { ArrowDown: 1, ArrowUp: -1 };
|
|
410
|
-
if (event.key in offsets) {
|
|
411
|
-
event.preventDefault();
|
|
412
|
-
items[(index + offsets[event.key] + items.length) % items.length].focus();
|
|
413
|
-
}
|
|
414
|
-
};
|
|
415
|
-
document.body.append(this.#layer);
|
|
416
|
-
document.addEventListener("keydown", this.#key);
|
|
417
|
-
this.update([]);
|
|
525
|
+
get canGoBack() {
|
|
526
|
+
return Boolean(this.#key) || this.#window.history.length > 1;
|
|
418
527
|
}
|
|
419
528
|
|
|
420
529
|
/**
|
|
421
|
-
*
|
|
422
|
-
*
|
|
423
|
-
* @param {
|
|
424
|
-
* @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
|
|
530
|
+
* 加入模块或字段子页历史,保留路由中的层级分隔符。
|
|
531
|
+
* Push a module or field view while retaining route hierarchy separators.
|
|
532
|
+
* @param {string} key 子页键 / Detail key.
|
|
425
533
|
* @returns {void} 无返回值 / No return value.
|
|
426
534
|
*/
|
|
427
|
-
|
|
428
|
-
this
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
this.#
|
|
432
|
-
|
|
433
|
-
const button = this.#document.createElement("button");
|
|
434
|
-
button.type = "button";
|
|
435
|
-
button.setAttribute("role", "menuitem");
|
|
436
|
-
button.textContent = item.label;
|
|
437
|
-
button.toggleAttribute("data-danger", Boolean(item.destructive));
|
|
438
|
-
button.onclick = () => {
|
|
439
|
-
this.close();
|
|
440
|
-
this.#select(item.id);
|
|
441
|
-
};
|
|
442
|
-
return button;
|
|
443
|
-
}),
|
|
444
|
-
);
|
|
535
|
+
open(key) {
|
|
536
|
+
if (key === this.#key) return;
|
|
537
|
+
const url = new URL(this.#window.location.href);
|
|
538
|
+
url.hash = key.split("/").map(encodeURIComponent).join("/");
|
|
539
|
+
this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
|
|
540
|
+
this.#route();
|
|
445
541
|
}
|
|
446
542
|
|
|
447
543
|
/**
|
|
448
|
-
*
|
|
449
|
-
*
|
|
544
|
+
* 沿文档历史返回,根页可退回上个文档。
|
|
545
|
+
* Go back through document history, including the previous document from home.
|
|
450
546
|
* @returns {void} 无返回值 / No return value.
|
|
451
547
|
*/
|
|
452
|
-
|
|
453
|
-
if (this.#
|
|
454
|
-
const style = getComputedStyle(this.element);
|
|
455
|
-
for (const property of ["--pp-text", "--pp-surface", "--pp-border", "--pp-accent", "--pp-danger"]) {
|
|
456
|
-
const value = style.getPropertyValue(property);
|
|
457
|
-
if (value) this.#layer.style.setProperty(property, value);
|
|
458
|
-
}
|
|
459
|
-
this.#layer.hidden = false;
|
|
460
|
-
this.#button.setAttribute("aria-expanded", "true");
|
|
461
|
-
this.#items.firstElementChild.focus();
|
|
548
|
+
back() {
|
|
549
|
+
if (this.canGoBack) this.#window.history.back();
|
|
462
550
|
}
|
|
463
551
|
|
|
464
552
|
/**
|
|
465
|
-
*
|
|
466
|
-
*
|
|
553
|
+
* 更新当前页的挂载视图,保留路由和滚动位置。
|
|
554
|
+
* Refresh the mounted view while retaining route and scroll position.
|
|
467
555
|
* @returns {void} 无返回值 / No return value.
|
|
468
556
|
*/
|
|
469
|
-
|
|
470
|
-
this.#
|
|
471
|
-
this.#button.setAttribute("aria-expanded", "false");
|
|
557
|
+
refresh() {
|
|
558
|
+
this.#route(true);
|
|
472
559
|
}
|
|
473
560
|
|
|
474
561
|
/**
|
|
475
|
-
*
|
|
476
|
-
*
|
|
562
|
+
* 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
|
|
563
|
+
* Resolve the URL and coordinate transitions, cancellation and release after animation.
|
|
564
|
+
* @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.
|
|
565
|
+
* @returns {void} 无返回值 / No return value.
|
|
566
|
+
*/
|
|
567
|
+
#route(reload = false) {
|
|
568
|
+
const url = new URL(this.#window.location.href);
|
|
569
|
+
let key;
|
|
570
|
+
try {
|
|
571
|
+
key = decodeURIComponent(url.hash.slice(1));
|
|
572
|
+
} catch (error) {
|
|
573
|
+
if (!(error instanceof URIError)) throw error;
|
|
574
|
+
key = "";
|
|
575
|
+
}
|
|
576
|
+
if (!reload && key === this.#key) return;
|
|
577
|
+
if (key !== this.#key) this.#scroll.set(this.#key, (this.#view ?? this.#home).scrollTop);
|
|
578
|
+
this.#controller?.abort();
|
|
579
|
+
this.#controller = new AbortController();
|
|
580
|
+
const next = key ? this.#create(key, this.#controller.signal) : undefined;
|
|
581
|
+
if (!next) key = "";
|
|
582
|
+
const history = this.#window.history;
|
|
583
|
+
// 直接打开字段页时补齐首页和模块历史;刷新不重复堆叠。
|
|
584
|
+
// Seed homepage and module history for direct field links without stacking on reload.
|
|
585
|
+
if (url.hash && history.state?.preferencePanesRoute !== key) {
|
|
586
|
+
url.hash = "";
|
|
587
|
+
history.replaceState({ ...history.state, preferencePanesRoute: "" }, "", url.href);
|
|
588
|
+
if (key) {
|
|
589
|
+
const module = key.split("/")[0];
|
|
590
|
+
if (key.includes("/") && module !== "$link") {
|
|
591
|
+
url.hash = encodeURIComponent(module);
|
|
592
|
+
history.pushState({ ...history.state, preferencePanesRoute: module }, "", url.href);
|
|
593
|
+
}
|
|
594
|
+
url.hash = key.split("/").map(encodeURIComponent).join("/");
|
|
595
|
+
history.pushState({ ...history.state, preferencePanesRoute: key }, "", url.href);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
const previous = this.#view;
|
|
599
|
+
if (previous === next && this.#key === key) {
|
|
600
|
+
if (previous && this.#scroll.has(key)) {
|
|
601
|
+
previous.scrollTop = this.#scroll.get(key);
|
|
602
|
+
this.#scroll.delete(key);
|
|
603
|
+
}
|
|
604
|
+
this.dispatchEvent(new Event("change"));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
const scroll = this.#scroll.get(key) ?? (key === this.#key ? (previous?.scrollTop ?? 0) : 0);
|
|
608
|
+
const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
|
|
609
|
+
this.#animation?.cancel();
|
|
610
|
+
this.#retiring?.remove();
|
|
611
|
+
this.#retiring = previous;
|
|
612
|
+
if (previous) {
|
|
613
|
+
previous.inert = true;
|
|
614
|
+
}
|
|
615
|
+
this.#key = key;
|
|
616
|
+
this.#view = next;
|
|
617
|
+
this.#home.inert = Boolean(next);
|
|
618
|
+
if (next) {
|
|
619
|
+
next.inert = false;
|
|
620
|
+
this.#container.append(next);
|
|
621
|
+
next.scrollTop = scroll;
|
|
622
|
+
// 加载占位页可能无法滚到原位置;配置与控件就绪后的 refresh 再恢复一次。
|
|
623
|
+
// A loading placeholder may clamp scrolling; restore once more when config and controls refresh.
|
|
624
|
+
if (next.scrollTop === scroll) this.#scroll.delete(key);
|
|
625
|
+
}
|
|
626
|
+
if (!next) {
|
|
627
|
+
this.#home.scrollTop = this.#scroll.get("") ?? 0;
|
|
628
|
+
this.#scroll.delete("");
|
|
629
|
+
}
|
|
630
|
+
const moving = next ?? previous;
|
|
631
|
+
if (moving) {
|
|
632
|
+
const animation = moving.animate([{ transform: next ? "translateX(100%)" : position }, { transform: next ? "translateX(0)" : "translateX(100%)" }], { duration: this.#window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 280, easing: "cubic-bezier(.22,.61,.36,1)", fill: "forwards" });
|
|
633
|
+
this.#animation = animation;
|
|
634
|
+
animation.onfinish = () => {
|
|
635
|
+
if (this.#animation !== animation) return;
|
|
636
|
+
this.#retiring?.remove();
|
|
637
|
+
this.#retiring = undefined;
|
|
638
|
+
animation.cancel();
|
|
639
|
+
this.#animation = undefined;
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
this.dispatchEvent(new Event("change"));
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* 释放监听器、加载、动画和节点;调用方可重新创建导航。
|
|
647
|
+
* Release listeners, loads, animations and nodes so callers can recreate navigation.
|
|
477
648
|
* @returns {void} 无返回值 / No return value.
|
|
478
649
|
*/
|
|
479
650
|
destroy() {
|
|
480
|
-
this.#
|
|
481
|
-
this.#
|
|
482
|
-
this.
|
|
651
|
+
this.#window.removeEventListener("popstate", this.#onHistory);
|
|
652
|
+
this.#window.removeEventListener("hashchange", this.#onHistory);
|
|
653
|
+
this.#window.removeEventListener("pageshow", this.#onPageShow);
|
|
654
|
+
this.#controller?.abort();
|
|
655
|
+
this.#animation?.cancel();
|
|
656
|
+
this.#retiring?.remove();
|
|
657
|
+
this.#view?.remove();
|
|
658
|
+
this.#home.remove();
|
|
483
659
|
}
|
|
484
660
|
}
|
|
485
661
|
|
|
@@ -497,6 +673,7 @@ class PreferencesClient {
|
|
|
497
673
|
#values = {};
|
|
498
674
|
#warnings = {};
|
|
499
675
|
#saving = false;
|
|
676
|
+
#arrays = new Map();
|
|
500
677
|
|
|
501
678
|
/**
|
|
502
679
|
* 创建从 BoxJS 定义读取和持久化设置的页面客户端。
|
|
@@ -523,13 +700,22 @@ class PreferencesClient {
|
|
|
523
700
|
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
524
701
|
const values = {};
|
|
525
702
|
const warnings = {};
|
|
703
|
+
this.#arrays.clear();
|
|
526
704
|
for (const field of this.#definition.fields) {
|
|
705
|
+
const indexed = /^(.*)\.(\d+)$/.exec(field.key);
|
|
706
|
+
if (indexed && !this.#arrays.has(indexed[1])) {
|
|
707
|
+
const storedArray = indexed[1]
|
|
708
|
+
.split(".")
|
|
709
|
+
.slice(this.#definition.settingsPath.length)
|
|
710
|
+
.reduce((parent, part) => Object(parent)[part], subtree);
|
|
711
|
+
this.#arrays.set(indexed[1], Array.isArray(storedArray) ? [...storedArray] : []);
|
|
712
|
+
}
|
|
527
713
|
const stored = field.key
|
|
528
714
|
.split(".")
|
|
529
715
|
.slice(this.#definition.settingsPath.length)
|
|
530
716
|
.reduce((parent, part) => Object(parent)[part], subtree);
|
|
531
717
|
const resolved = resolveStoredValue(field, stored);
|
|
532
|
-
if (Object.
|
|
718
|
+
if (Object.prototype.hasOwnProperty.call(resolved, "value")) values[field.key] = resolved.value;
|
|
533
719
|
if (resolved.warning) warnings[field.key] = resolved.warning;
|
|
534
720
|
}
|
|
535
721
|
this.#values = values;
|
|
@@ -543,7 +729,7 @@ class PreferencesClient {
|
|
|
543
729
|
* @returns {import("./client.mjs").ModuleSnapshot} 会话快照 / Session snapshot.
|
|
544
730
|
*/
|
|
545
731
|
snapshot() {
|
|
546
|
-
return
|
|
732
|
+
return JSON.parse(JSON.stringify({ definition: this.#definition, values: this.#values, warnings: this.#warnings }));
|
|
547
733
|
}
|
|
548
734
|
|
|
549
735
|
/**
|
|
@@ -562,22 +748,23 @@ class PreferencesClient {
|
|
|
562
748
|
* @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
|
|
563
749
|
*/
|
|
564
750
|
async readCaches() {
|
|
565
|
-
const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#module}.Caches`);
|
|
751
|
+
const response = await this.#send("get", this.#definition.cachePath ?? `@${this.#definition.storageKey}.${this.#module}.Caches`);
|
|
566
752
|
return response.status === 404 ? undefined : response.json();
|
|
567
753
|
}
|
|
568
754
|
|
|
569
755
|
/**
|
|
570
|
-
*
|
|
571
|
-
* Delete the current module Caches
|
|
756
|
+
* 删除声明的缓存子树,默认当前模块 Caches。
|
|
757
|
+
* Delete the declared cache subtree, defaulting to the current module Caches.
|
|
572
758
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
573
759
|
*/
|
|
574
760
|
clearCaches() {
|
|
575
|
-
|
|
761
|
+
const path = this.#definition.cachePath?.slice(this.#definition.storageKey.length + 2) ?? `${this.#module}.Caches`;
|
|
762
|
+
return this.#change("delete", path, undefined, "clearCaches");
|
|
576
763
|
}
|
|
577
764
|
|
|
578
765
|
/**
|
|
579
|
-
*
|
|
580
|
-
* Delete current module data and restore page defaults.
|
|
766
|
+
* 删除声明的设置子树,默认删除当前模块数据,并恢复页面默认值。
|
|
767
|
+
* Delete declared settings subtrees, defaulting to current module data, and restore page defaults.
|
|
581
768
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
582
769
|
*/
|
|
583
770
|
reset() {
|
|
@@ -585,8 +772,8 @@ class PreferencesClient {
|
|
|
585
772
|
}
|
|
586
773
|
|
|
587
774
|
/**
|
|
588
|
-
*
|
|
589
|
-
* Abort
|
|
775
|
+
* 终止当前页面的读取;已开始的持久化写入继续完成。
|
|
776
|
+
* Abort reads owned by the current page while allowing started persistence writes to finish.
|
|
590
777
|
* @returns {void} 无返回值 / No return value.
|
|
591
778
|
*/
|
|
592
779
|
leave() {
|
|
@@ -625,8 +812,10 @@ class PreferencesClient {
|
|
|
625
812
|
async #send(action, path, value) {
|
|
626
813
|
const controller = new AbortController();
|
|
627
814
|
const abort = () => controller.abort();
|
|
628
|
-
if (
|
|
629
|
-
|
|
815
|
+
if (action === "get") {
|
|
816
|
+
if (this.#session.signal.aborted) abort();
|
|
817
|
+
this.#session.signal.addEventListener("abort", abort, { once: true });
|
|
818
|
+
}
|
|
630
819
|
const timer = setTimeout(abort, this.#timeout);
|
|
631
820
|
try {
|
|
632
821
|
const response = await this.#request(`/api/${action}`, {
|
|
@@ -646,211 +835,71 @@ class PreferencesClient {
|
|
|
646
835
|
}
|
|
647
836
|
|
|
648
837
|
/**
|
|
649
|
-
* 执行写入动作;成功后只更新当前页面值。
|
|
650
|
-
* Execute a mutation and update only the current page values after success.
|
|
651
|
-
* @param {"set" | "delete"} action API 动作 / API action.
|
|
652
|
-
* @param {string} key 不含存储根的路径 / Path without the storage root.
|
|
653
|
-
* @param {unknown} value set 写入值 / Value written by set.
|
|
654
|
-
* @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
|
|
655
|
-
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
656
|
-
*/
|
|
657
|
-
async #change(action, key, value, operation) {
|
|
658
|
-
if (this.#saving) throw new Error("A settings write is already in progress");
|
|
659
|
-
this.#saving = true;
|
|
660
|
-
try {
|
|
661
|
-
let field;
|
|
662
|
-
if (operation === "write" || operation === "delete") {
|
|
663
|
-
field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
664
|
-
if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
|
|
665
|
-
}
|
|
666
|
-
await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
|
|
667
|
-
switch (operation) {
|
|
668
|
-
case "write":
|
|
669
|
-
this.#values[key] = structuredClone(value);
|
|
670
|
-
delete this.#warnings[key];
|
|
671
|
-
break;
|
|
672
|
-
case "delete": {
|
|
673
|
-
delete this.#values[key];
|
|
674
|
-
delete this.#warnings[key];
|
|
675
|
-
if (Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
|
|
676
|
-
break;
|
|
677
|
-
}
|
|
678
|
-
case "clearCaches":
|
|
679
|
-
break;
|
|
680
|
-
case "reset":
|
|
681
|
-
this.#warnings = {};
|
|
682
|
-
for (const field of this.#definition.fields) {
|
|
683
|
-
delete this.#values[field.key];
|
|
684
|
-
if (Object.hasOwn(field, "defaultValue")) this.#values[field.key] = structuredClone(field.defaultValue);
|
|
685
|
-
}
|
|
686
|
-
break;
|
|
687
|
-
}
|
|
688
|
-
this.#notify({ kind: "success", operation, module: this.#module, key });
|
|
689
|
-
} catch (error) {
|
|
690
|
-
this.#notify({ kind: "error", operation, module: this.#module, key, message: error.message });
|
|
691
|
-
throw error;
|
|
692
|
-
} finally {
|
|
693
|
-
this.#saving = false;
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
/**
|
|
699
|
-
* 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
|
|
700
|
-
* Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
|
|
701
|
-
*/
|
|
702
|
-
class Navigation extends EventTarget {
|
|
703
|
-
#container;
|
|
704
|
-
#home;
|
|
705
|
-
#create;
|
|
706
|
-
#window;
|
|
707
|
-
#key = null;
|
|
708
|
-
#view;
|
|
709
|
-
#retiring;
|
|
710
|
-
#controller;
|
|
711
|
-
#animation;
|
|
712
|
-
#scroll = new WeakMap();
|
|
713
|
-
#onHistory = () => this.#route();
|
|
714
|
-
#onPageShow = event => {
|
|
715
|
-
if (event.persisted) this.#route(true);
|
|
716
|
-
};
|
|
717
|
-
|
|
718
|
-
/**
|
|
719
|
-
* 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。
|
|
720
|
-
* Retain the home view and create details on demand; signal cancels async work after departure.
|
|
721
|
-
* @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.
|
|
722
|
-
* @param {HTMLElement} home 已创建的主页节点 / Existing home view.
|
|
723
|
-
* @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.
|
|
724
|
-
*/
|
|
725
|
-
constructor(container, home, create) {
|
|
726
|
-
super();
|
|
727
|
-
this.#container = container;
|
|
728
|
-
this.#home = home;
|
|
729
|
-
this.#create = create;
|
|
730
|
-
this.#window = container.ownerDocument.defaultView;
|
|
731
|
-
container.replaceChildren(home);
|
|
732
|
-
this.#window.addEventListener("popstate", this.#onHistory);
|
|
733
|
-
this.#window.addEventListener("hashchange", this.#onHistory);
|
|
734
|
-
this.#window.addEventListener("pageshow", this.#onPageShow);
|
|
735
|
-
this.#route();
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
/**
|
|
739
|
-
* 当前子页键;空字符串表示主页。
|
|
740
|
-
* Current detail key; empty means home.
|
|
741
|
-
*/
|
|
742
|
-
get current() {
|
|
743
|
-
return this.#key;
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
/**
|
|
747
|
-
* 是否可以返回上一级或先前文档。
|
|
748
|
-
* Whether a parent view or previous document is available.
|
|
749
|
-
*/
|
|
750
|
-
get canGoBack() {
|
|
751
|
-
return Boolean(this.#key) || this.#window.history.length > 1;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
/**
|
|
755
|
-
* 加入子页历史;使用文档自身 URL,避免 srcdoc 按宿主 base URL 跳转。
|
|
756
|
-
* Push a detail using the document URL, avoiding srcdoc navigation against the host base URL.
|
|
757
|
-
* @param {string} key 子页键 / Detail key.
|
|
758
|
-
* @returns {void} 无返回值 / No return value.
|
|
759
|
-
*/
|
|
760
|
-
open(key) {
|
|
761
|
-
if (key === this.#key) return;
|
|
762
|
-
const url = new URL(this.#window.location.href);
|
|
763
|
-
url.hash = encodeURIComponent(key);
|
|
764
|
-
this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
|
|
765
|
-
this.#route();
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
/**
|
|
769
|
-
* 沿浏览器联合历史返回,根页可退回宿主或上个文档。
|
|
770
|
-
* Go back through joint history, including a host or previous document from home.
|
|
771
|
-
* @returns {void} 无返回值 / No return value.
|
|
772
|
-
*/
|
|
773
|
-
back() {
|
|
774
|
-
if (this.canGoBack) this.#window.history.back();
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
/**
|
|
778
|
-
* 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
|
|
779
|
-
* Resolve the URL and coordinate transitions, cancellation and release after animation.
|
|
780
|
-
* @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.
|
|
781
|
-
* @returns {void} 无返回值 / No return value.
|
|
838
|
+
* 执行写入动作;成功后只更新当前页面值。
|
|
839
|
+
* Execute a mutation and update only the current page values after success.
|
|
840
|
+
* @param {"set" | "delete"} action API 动作 / API action.
|
|
841
|
+
* @param {string} key 不含存储根的路径 / Path without the storage root.
|
|
842
|
+
* @param {unknown} value set 写入值 / Value written by set.
|
|
843
|
+
* @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
|
|
844
|
+
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
782
845
|
*/
|
|
783
|
-
#
|
|
784
|
-
|
|
785
|
-
|
|
846
|
+
async #change(action, key, value, operation) {
|
|
847
|
+
if (this.#saving) throw new Error("A settings write is already in progress");
|
|
848
|
+
this.#saving = true;
|
|
786
849
|
try {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
850
|
+
switch (operation) {
|
|
851
|
+
case "write":
|
|
852
|
+
case "delete": {
|
|
853
|
+
const field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
854
|
+
if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
|
|
855
|
+
const indexed = /^(.*)\.(\d+)$/.exec(key);
|
|
856
|
+
if (indexed) {
|
|
857
|
+
const array = [...(this.#arrays.get(indexed[1]) ?? [])];
|
|
858
|
+
for (const sibling of this.#definition.fields) {
|
|
859
|
+
if (sibling.key.startsWith(`${indexed[1]}.`) && /^\d+$/.test(sibling.key.slice(indexed[1].length + 1))) {
|
|
860
|
+
const index = Number(sibling.key.slice(indexed[1].length + 1));
|
|
861
|
+
if (array[index] === undefined) array[index] = this.#values[sibling.key] ?? sibling.defaultValue;
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
array[Number(indexed[2])] = operation === "delete" ? field.defaultValue : value;
|
|
865
|
+
await this.#send("set", `@${this.#definition.storageKey}.${indexed[1]}`, array);
|
|
866
|
+
this.#arrays.set(indexed[1], array);
|
|
867
|
+
} else await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
|
|
868
|
+
switch (operation) {
|
|
869
|
+
case "write":
|
|
870
|
+
this.#values[key] = JSON.parse(JSON.stringify(value));
|
|
871
|
+
delete this.#warnings[key];
|
|
872
|
+
break;
|
|
873
|
+
case "delete":
|
|
874
|
+
delete this.#values[key];
|
|
875
|
+
delete this.#warnings[key];
|
|
876
|
+
if (Object.prototype.hasOwnProperty.call(field, "defaultValue")) this.#values[key] = JSON.parse(JSON.stringify(field.defaultValue));
|
|
877
|
+
break;
|
|
878
|
+
}
|
|
879
|
+
break;
|
|
880
|
+
}
|
|
881
|
+
case "clearCaches":
|
|
882
|
+
await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
|
|
883
|
+
break;
|
|
884
|
+
case "reset":
|
|
885
|
+
if (this.#definition.resetPaths) {
|
|
886
|
+
for (const path of this.#definition.resetPaths) await this.#send("delete", path);
|
|
887
|
+
} else await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
|
|
888
|
+
this.#arrays.clear();
|
|
889
|
+
this.#warnings = {};
|
|
890
|
+
for (const field of this.#definition.fields) {
|
|
891
|
+
delete this.#values[field.key];
|
|
892
|
+
if (Object.prototype.hasOwnProperty.call(field, "defaultValue")) this.#values[field.key] = JSON.parse(JSON.stringify(field.defaultValue));
|
|
893
|
+
}
|
|
894
|
+
break;
|
|
806
895
|
}
|
|
896
|
+
this.#notify({ kind: "success", operation, module: this.#module, key });
|
|
897
|
+
} catch (error) {
|
|
898
|
+
this.#notify({ kind: "error", operation, module: this.#module, key, message: error.message });
|
|
899
|
+
throw error;
|
|
900
|
+
} finally {
|
|
901
|
+
this.#saving = false;
|
|
807
902
|
}
|
|
808
|
-
const previous = this.#view;
|
|
809
|
-
const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
|
|
810
|
-
this.#animation?.cancel();
|
|
811
|
-
this.#retiring?.remove();
|
|
812
|
-
this.#retiring = previous;
|
|
813
|
-
if (previous) {
|
|
814
|
-
this.#scroll.set(previous, previous.scrollTop);
|
|
815
|
-
previous.inert = true;
|
|
816
|
-
}
|
|
817
|
-
this.#key = key;
|
|
818
|
-
this.#view = next;
|
|
819
|
-
this.#home.inert = Boolean(next);
|
|
820
|
-
if (next) {
|
|
821
|
-
next.inert = false;
|
|
822
|
-
this.#container.append(next);
|
|
823
|
-
next.scrollTop = this.#scroll.get(next) ?? 0;
|
|
824
|
-
}
|
|
825
|
-
const moving = next ?? previous;
|
|
826
|
-
if (moving) {
|
|
827
|
-
const animation = moving.animate([{ transform: next ? "translateX(100%)" : position }, { transform: next ? "translateX(0)" : "translateX(100%)" }], { duration: this.#window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : 280, easing: "cubic-bezier(.22,.61,.36,1)", fill: "forwards" });
|
|
828
|
-
this.#animation = animation;
|
|
829
|
-
animation.onfinish = () => {
|
|
830
|
-
if (this.#animation !== animation) return;
|
|
831
|
-
this.#retiring?.remove();
|
|
832
|
-
this.#retiring = undefined;
|
|
833
|
-
animation.cancel();
|
|
834
|
-
this.#animation = undefined;
|
|
835
|
-
};
|
|
836
|
-
}
|
|
837
|
-
this.dispatchEvent(new Event("change"));
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
/**
|
|
841
|
-
* 释放监听器、加载、动画和节点;调用方可重新创建导航。
|
|
842
|
-
* Release listeners, loads, animations and nodes so callers can recreate navigation.
|
|
843
|
-
* @returns {void} 无返回值 / No return value.
|
|
844
|
-
*/
|
|
845
|
-
destroy() {
|
|
846
|
-
this.#window.removeEventListener("popstate", this.#onHistory);
|
|
847
|
-
this.#window.removeEventListener("hashchange", this.#onHistory);
|
|
848
|
-
this.#window.removeEventListener("pageshow", this.#onPageShow);
|
|
849
|
-
this.#controller?.abort();
|
|
850
|
-
this.#animation?.cancel();
|
|
851
|
-
this.#retiring?.remove();
|
|
852
|
-
this.#view?.remove();
|
|
853
|
-
this.#home.remove();
|
|
854
903
|
}
|
|
855
904
|
}
|
|
856
905
|
|
|
@@ -871,20 +920,24 @@ function warningMessage(warning) {
|
|
|
871
920
|
}
|
|
872
921
|
|
|
873
922
|
/**
|
|
874
|
-
*
|
|
875
|
-
* Manage the module form,
|
|
923
|
+
* 管理模块表单、操作队列和设置会话。
|
|
924
|
+
* Manage the module form, operation queue and settings session.
|
|
876
925
|
*/
|
|
877
926
|
class PreferencesPanel {
|
|
878
927
|
#release;
|
|
928
|
+
#view;
|
|
929
|
+
#state;
|
|
930
|
+
#perform;
|
|
879
931
|
|
|
880
932
|
/**
|
|
881
933
|
* 挂载 BoxJS 定义对应的模块表单。
|
|
882
934
|
* Mount the module form described by a BoxJS definition.
|
|
883
935
|
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
884
936
|
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
937
|
+
* @param {import("./page.mjs").PageController} page 内部页面控制器 / Internal page controller.
|
|
885
938
|
*/
|
|
886
|
-
constructor(root, definition) {
|
|
887
|
-
this.#release = this.#mount(root, definition);
|
|
939
|
+
constructor(root, definition, page) {
|
|
940
|
+
this.#release = this.#mount(root, definition, page);
|
|
888
941
|
}
|
|
889
942
|
|
|
890
943
|
/**
|
|
@@ -892,13 +945,13 @@ class PreferencesPanel {
|
|
|
892
945
|
* Build panel DOM, interactions, and session, then return its release operation.
|
|
893
946
|
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
894
947
|
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
948
|
+
* @param {import("./page.mjs").PageController} page 内部页面控制器 / Internal page controller.
|
|
895
949
|
* @returns {() => void} 释放操作 / Release operation.
|
|
896
950
|
*/
|
|
897
|
-
#mount(root, definition) {
|
|
951
|
+
#mount(root, definition, page) {
|
|
898
952
|
const title = definition.metadata?.name ?? definition.module;
|
|
899
953
|
const document = root.ownerDocument;
|
|
900
954
|
const window = document.defaultView;
|
|
901
|
-
const frame = window.frameElement?.dataset.preferencePanes ? window.frameElement : undefined;
|
|
902
955
|
const shell = element("div", "pp-panel");
|
|
903
956
|
shell.dataset.module = definition.module;
|
|
904
957
|
const handlers = new Map();
|
|
@@ -909,50 +962,25 @@ class PreferencesPanel {
|
|
|
909
962
|
{ id: "reset", label: "重置设置", destructive: true },
|
|
910
963
|
];
|
|
911
964
|
const viewport = element("div", "pp-viewport");
|
|
912
|
-
|
|
913
|
-
if (frame) shell.append(viewport);
|
|
914
|
-
else {
|
|
915
|
-
const header = element("header", "pp-header");
|
|
916
|
-
back = element("button", "pp-back", "‹");
|
|
917
|
-
back.setAttribute("aria-label", "返回");
|
|
918
|
-
back.type = "button";
|
|
919
|
-
heading = element("h1", "pp-title", title);
|
|
920
|
-
menu = new ActionMenu(id => runAction(id));
|
|
921
|
-
const trailing = element("span", "pp-nav-spacer");
|
|
922
|
-
trailing.append(menu.element);
|
|
923
|
-
header.append(back, heading, trailing);
|
|
924
|
-
shell.append(header, viewport);
|
|
925
|
-
}
|
|
926
|
-
let toast;
|
|
965
|
+
shell.append(viewport);
|
|
927
966
|
root.append(shell);
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
frame.dispatchEvent(
|
|
934
|
-
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
935
|
-
detail: { title: currentTitle, module: definition.module, busy: saving, canGoBack, actions },
|
|
936
|
-
}),
|
|
937
|
-
);
|
|
938
|
-
else {
|
|
939
|
-
heading.textContent = currentTitle;
|
|
940
|
-
back.disabled = !canGoBack;
|
|
941
|
-
menu.update(actions, saving);
|
|
942
|
-
}
|
|
967
|
+
const editors = new Map();
|
|
968
|
+
this.#view = key => {
|
|
969
|
+
const editor = editors.get(key);
|
|
970
|
+
editor?.load?.();
|
|
971
|
+
return editor?.node ?? root;
|
|
943
972
|
};
|
|
944
|
-
|
|
945
|
-
|
|
973
|
+
this.#state = key => ({ title: editors.get(key)?.title ?? title, actions: handlers.size ? menuItems : [], busy: saving });
|
|
974
|
+
this.#perform = id => {
|
|
975
|
+
if (!saving && handlers.has(id)) runAction(id);
|
|
946
976
|
};
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
977
|
+
const publishNavigation = () => {
|
|
978
|
+
if (!destroyed) page.update();
|
|
979
|
+
};
|
|
980
|
+
let generation = 0,
|
|
951
981
|
active = null,
|
|
952
982
|
saving = false,
|
|
953
|
-
destroyed = false
|
|
954
|
-
currentTitle = title,
|
|
955
|
-
canGoBack = window.history.length > 1;
|
|
983
|
+
destroyed = false;
|
|
956
984
|
/**
|
|
957
985
|
* 展示短暂通知,不刷新设置数据。
|
|
958
986
|
* Display a transient notification without refreshing settings.
|
|
@@ -962,43 +990,29 @@ class PreferencesPanel {
|
|
|
962
990
|
const notify = event => {
|
|
963
991
|
if (destroyed) return;
|
|
964
992
|
let message;
|
|
965
|
-
switch (
|
|
966
|
-
case
|
|
993
|
+
switch (event.kind === "error" ? "error" : event.operation) {
|
|
994
|
+
case "error":
|
|
967
995
|
message = `操作失败:${event.message}`;
|
|
968
996
|
break;
|
|
969
|
-
case
|
|
997
|
+
case "delete":
|
|
970
998
|
message = "删除成功";
|
|
971
999
|
break;
|
|
972
|
-
case
|
|
1000
|
+
case "clearCaches":
|
|
973
1001
|
message = "Caches 已清空";
|
|
974
1002
|
break;
|
|
975
|
-
case
|
|
1003
|
+
case "reset":
|
|
976
1004
|
message = "设置已重置";
|
|
977
1005
|
break;
|
|
978
1006
|
default:
|
|
979
1007
|
message = "修改成功";
|
|
980
1008
|
break;
|
|
981
1009
|
}
|
|
982
|
-
|
|
983
|
-
// A host-owned notice creates no web Toast and starts no local timer.
|
|
984
|
-
if (frame && !frame.dispatchEvent(new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:notice", { cancelable: true, detail: { kind: event.kind, message } }))) return;
|
|
985
|
-
if (!toast) {
|
|
986
|
-
toast = element("div", "pp-toast");
|
|
987
|
-
toast.setAttribute("role", "status");
|
|
988
|
-
shell.append(toast);
|
|
989
|
-
}
|
|
990
|
-
toast.textContent = message;
|
|
991
|
-
toast.dataset.kind = event.kind;
|
|
992
|
-
toast.hidden = false;
|
|
993
|
-
clearTimeout(timer);
|
|
994
|
-
timer = setTimeout(() => {
|
|
995
|
-
toast.hidden = true;
|
|
996
|
-
}, 2400);
|
|
1010
|
+
page.notice({ kind: event.kind, message });
|
|
997
1011
|
};
|
|
998
1012
|
const client = new PreferencesClient({ definition, notify });
|
|
999
1013
|
/**
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1014
|
+
* 菜单操作统一处理异步错误,包含宿主确认框错误。
|
|
1015
|
+
* Handle asynchronous action errors, including host-dialog errors.
|
|
1002
1016
|
* @param {string} id 操作标识 / Action identifier.
|
|
1003
1017
|
* @returns {Promise<void>} 操作已处理 / Action handled.
|
|
1004
1018
|
*/
|
|
@@ -1018,8 +1032,6 @@ class PreferencesPanel {
|
|
|
1018
1032
|
async function open(module) {
|
|
1019
1033
|
const version = ++generation;
|
|
1020
1034
|
active = module;
|
|
1021
|
-
currentTitle = module;
|
|
1022
|
-
canGoBack = window.history.length > 1;
|
|
1023
1035
|
publishNavigation();
|
|
1024
1036
|
viewport.replaceChildren(statusView("读取设置…"));
|
|
1025
1037
|
try {
|
|
@@ -1038,7 +1050,6 @@ class PreferencesPanel {
|
|
|
1038
1050
|
*/
|
|
1039
1051
|
function controls() {
|
|
1040
1052
|
const { definition, values } = client.snapshot();
|
|
1041
|
-
currentTitle = definition.metadata?.name || active;
|
|
1042
1053
|
const view = element("section", "pp-fields");
|
|
1043
1054
|
/**
|
|
1044
1055
|
* 挂载后执行的多行高度更新
|
|
@@ -1046,23 +1057,12 @@ class PreferencesPanel {
|
|
|
1046
1057
|
* @type {Array<() => void>}
|
|
1047
1058
|
*/
|
|
1048
1059
|
const growingInputs = [];
|
|
1049
|
-
|
|
1060
|
+
editors.clear();
|
|
1050
1061
|
const summaries = [];
|
|
1051
1062
|
const warningRefreshers = [];
|
|
1052
1063
|
const groups = new Map();
|
|
1053
1064
|
let queue = Promise.resolve(),
|
|
1054
1065
|
pendingWrites = 0;
|
|
1055
|
-
/**
|
|
1056
|
-
* 导航组件处理页面切换,表单只更新当前标题与返回按钮。
|
|
1057
|
-
* Let navigation own transitions; the form only updates the title and back button.
|
|
1058
|
-
* @returns {void} 无返回值 / No return value.
|
|
1059
|
-
*/
|
|
1060
|
-
const updateNavigation = () => {
|
|
1061
|
-
const editor = editors.get(navigation.current);
|
|
1062
|
-
currentTitle = editor?.title ?? definition.metadata?.name ?? active;
|
|
1063
|
-
canGoBack = !saving && navigation.canGoBack;
|
|
1064
|
-
publishNavigation();
|
|
1065
|
-
};
|
|
1066
1066
|
/**
|
|
1067
1067
|
* 串行执行模块操作,保持输入可编辑。
|
|
1068
1068
|
* Serialize module actions while keeping inputs editable.
|
|
@@ -1074,7 +1074,6 @@ class PreferencesPanel {
|
|
|
1074
1074
|
function perform(action, success, failure = () => {}) {
|
|
1075
1075
|
pendingWrites++;
|
|
1076
1076
|
saving = true;
|
|
1077
|
-
canGoBack = false;
|
|
1078
1077
|
publishNavigation();
|
|
1079
1078
|
queue = queue
|
|
1080
1079
|
.then(action)
|
|
@@ -1089,27 +1088,10 @@ class PreferencesPanel {
|
|
|
1089
1088
|
.finally(() => {
|
|
1090
1089
|
pendingWrites--;
|
|
1091
1090
|
saving = pendingWrites > 0;
|
|
1092
|
-
if (destroyed && !saving) client.leave();
|
|
1093
|
-
canGoBack = !saving && navigation.canGoBack;
|
|
1094
1091
|
publishNavigation();
|
|
1095
1092
|
});
|
|
1096
1093
|
return queue;
|
|
1097
1094
|
}
|
|
1098
|
-
const metadata = definition.metadata;
|
|
1099
|
-
if (metadata) {
|
|
1100
|
-
const info = element("div", "pp-module-info");
|
|
1101
|
-
const details = element("div", "pp-module-details");
|
|
1102
|
-
for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(element("p", "pp-description", description));
|
|
1103
|
-
if (metadata.repo) {
|
|
1104
|
-
const link = element("a", "pp-module-source", "项目主页");
|
|
1105
|
-
link.href = resourceURL(metadata.repo);
|
|
1106
|
-
link.target = "_blank";
|
|
1107
|
-
link.rel = "noopener noreferrer";
|
|
1108
|
-
details.append(link);
|
|
1109
|
-
}
|
|
1110
|
-
info.append(details);
|
|
1111
|
-
view.append(info);
|
|
1112
|
-
}
|
|
1113
1095
|
for (const field of definition.fields) {
|
|
1114
1096
|
const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
|
|
1115
1097
|
const group = match?.[1] ?? "通用";
|
|
@@ -1150,8 +1132,8 @@ class PreferencesPanel {
|
|
|
1150
1132
|
let write;
|
|
1151
1133
|
let inputContainer = row;
|
|
1152
1134
|
let eventName = "change";
|
|
1153
|
-
switch (
|
|
1154
|
-
case
|
|
1135
|
+
switch (field.control === "url" ? "url" : field.options ? (field.type === "array" ? "checkboxes" : "selects") : field.type) {
|
|
1136
|
+
case "url": {
|
|
1155
1137
|
const link = element("a", "pp-choice-link", "打开");
|
|
1156
1138
|
link.setAttribute("aria-label", field.name);
|
|
1157
1139
|
link.rel = "noopener noreferrer";
|
|
@@ -1159,7 +1141,8 @@ class PreferencesPanel {
|
|
|
1159
1141
|
link.href = value;
|
|
1160
1142
|
};
|
|
1161
1143
|
link.addEventListener("click", event => {
|
|
1162
|
-
|
|
1144
|
+
event.preventDefault();
|
|
1145
|
+
page.openURL(link.href);
|
|
1163
1146
|
});
|
|
1164
1147
|
row.append(link);
|
|
1165
1148
|
row.addEventListener("click", event => {
|
|
@@ -1167,7 +1150,7 @@ class PreferencesPanel {
|
|
|
1167
1150
|
});
|
|
1168
1151
|
break;
|
|
1169
1152
|
}
|
|
1170
|
-
case
|
|
1153
|
+
case "selects": {
|
|
1171
1154
|
const select = element("select", "");
|
|
1172
1155
|
select.setAttribute("aria-label", field.name);
|
|
1173
1156
|
field.options.forEach((option, index) => {
|
|
@@ -1182,13 +1165,13 @@ class PreferencesPanel {
|
|
|
1182
1165
|
read = () => field.options[select.selectedIndex]?.key;
|
|
1183
1166
|
break;
|
|
1184
1167
|
}
|
|
1185
|
-
case
|
|
1186
|
-
const
|
|
1187
|
-
if (field.description)
|
|
1168
|
+
case "checkboxes": {
|
|
1169
|
+
const editor = element("section", "pp-panel pp-detail pp-choice-page");
|
|
1170
|
+
if (field.description) editor.append(element("p", "pp-description", field.description));
|
|
1188
1171
|
const choices = element("div", "pp-rows");
|
|
1189
|
-
|
|
1172
|
+
editor.append(choices);
|
|
1190
1173
|
inputContainer = choices;
|
|
1191
|
-
editors.set(field.key, { node:
|
|
1174
|
+
editors.set(field.key, { node: editor, title: match?.[2] ?? field.name });
|
|
1192
1175
|
const summary = element("span", "pp-summary");
|
|
1193
1176
|
const link = element("button", "pp-choice-link");
|
|
1194
1177
|
link.type = "button";
|
|
@@ -1205,7 +1188,7 @@ class PreferencesPanel {
|
|
|
1205
1188
|
};
|
|
1206
1189
|
summaries.push(refresh);
|
|
1207
1190
|
refresh();
|
|
1208
|
-
link.onclick = () =>
|
|
1191
|
+
link.onclick = () => page.open(field.key);
|
|
1209
1192
|
row.addEventListener("click", event => {
|
|
1210
1193
|
if (!link.contains(event.target)) link.click();
|
|
1211
1194
|
});
|
|
@@ -1226,7 +1209,7 @@ class PreferencesPanel {
|
|
|
1226
1209
|
};
|
|
1227
1210
|
break;
|
|
1228
1211
|
}
|
|
1229
|
-
case
|
|
1212
|
+
case "boolean": {
|
|
1230
1213
|
const toggle = element("input", "pp-switch");
|
|
1231
1214
|
toggle.type = "checkbox";
|
|
1232
1215
|
toggle.setAttribute("switch", "");
|
|
@@ -1264,7 +1247,12 @@ class PreferencesPanel {
|
|
|
1264
1247
|
growingInputs.push(grow);
|
|
1265
1248
|
}
|
|
1266
1249
|
eventName = "input";
|
|
1267
|
-
if (!multiline)
|
|
1250
|
+
if (!multiline) {
|
|
1251
|
+
input.type = field.type === "number" ? "number" : "text";
|
|
1252
|
+
input.autocapitalize = "off";
|
|
1253
|
+
input.setAttribute("autocorrect", "off");
|
|
1254
|
+
input.spellcheck = false;
|
|
1255
|
+
}
|
|
1268
1256
|
write = value => {
|
|
1269
1257
|
input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
|
|
1270
1258
|
grow();
|
|
@@ -1317,12 +1305,12 @@ class PreferencesPanel {
|
|
|
1317
1305
|
if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
|
|
1318
1306
|
groups.get(group).append(row);
|
|
1319
1307
|
}
|
|
1320
|
-
const settingsPage = element("section", "pp-settings-page");
|
|
1308
|
+
const settingsPage = element("section", "pp-panel pp-detail pp-settings-page");
|
|
1321
1309
|
const settingsOutput = element("pre", "pp-cache");
|
|
1322
1310
|
settingsOutput.setAttribute("aria-label", "Settings 内容");
|
|
1323
1311
|
settingsPage.append(settingsOutput);
|
|
1324
|
-
|
|
1325
|
-
|
|
1312
|
+
const loadSettings = () => {
|
|
1313
|
+
editors.get("$settings").load = undefined;
|
|
1326
1314
|
if (saving) return;
|
|
1327
1315
|
let value;
|
|
1328
1316
|
return perform(
|
|
@@ -1336,17 +1324,24 @@ class PreferencesPanel {
|
|
|
1336
1324
|
},
|
|
1337
1325
|
() => {
|
|
1338
1326
|
settingsOutput.textContent = value === undefined ? "暂无设置" : JSON.stringify(value, null, 2);
|
|
1339
|
-
|
|
1327
|
+
},
|
|
1328
|
+
() => {
|
|
1329
|
+
settingsOutput.textContent = "读取设置失败";
|
|
1340
1330
|
},
|
|
1341
1331
|
);
|
|
1332
|
+
};
|
|
1333
|
+
editors.set("$settings", { node: settingsPage, title: "设置", load: loadSettings });
|
|
1334
|
+
handlers.set("viewSettings", () => {
|
|
1335
|
+
loadSettings();
|
|
1336
|
+
page.open("$settings");
|
|
1342
1337
|
});
|
|
1343
|
-
const cachePage = element("section", "pp-cache-page");
|
|
1338
|
+
const cachePage = element("section", "pp-panel pp-detail pp-cache-page");
|
|
1344
1339
|
const output = element("pre", "pp-cache");
|
|
1345
|
-
output.textContent = "
|
|
1340
|
+
output.textContent = "正在读取缓存…";
|
|
1346
1341
|
output.setAttribute("aria-label", "Caches 内容");
|
|
1347
1342
|
cachePage.append(output);
|
|
1348
|
-
|
|
1349
|
-
|
|
1343
|
+
const loadCaches = () => {
|
|
1344
|
+
editors.get("$caches").load = undefined;
|
|
1350
1345
|
if (saving) return;
|
|
1351
1346
|
let value;
|
|
1352
1347
|
return perform(
|
|
@@ -1360,13 +1355,22 @@ class PreferencesPanel {
|
|
|
1360
1355
|
},
|
|
1361
1356
|
() => {
|
|
1362
1357
|
output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
|
|
1363
|
-
|
|
1358
|
+
},
|
|
1359
|
+
() => {
|
|
1360
|
+
output.textContent = "读取缓存失败";
|
|
1364
1361
|
},
|
|
1365
1362
|
);
|
|
1363
|
+
};
|
|
1364
|
+
editors.set("$caches", { node: cachePage, title: "缓存", load: loadCaches });
|
|
1365
|
+
handlers.set("viewCaches", () => {
|
|
1366
|
+
loadCaches();
|
|
1367
|
+
page.open("$caches");
|
|
1366
1368
|
});
|
|
1367
1369
|
handlers.set("clearCaches", async () => {
|
|
1368
1370
|
if (saving) return;
|
|
1369
|
-
|
|
1371
|
+
const shared = definition.cachePath && definition.cachePath !== `@${definition.storageKey}.${active}.Caches`;
|
|
1372
|
+
const message = shared ? `清空共享缓存 ${definition.cachePath}?使用该缓存的其它模块也会受影响。` : `清空 ${active} 的全部 Caches?`;
|
|
1373
|
+
if (!(await page.confirm(message)) || destroyed || saving) return;
|
|
1370
1374
|
return perform(
|
|
1371
1375
|
() => client.clearCaches(),
|
|
1372
1376
|
() => {
|
|
@@ -1376,42 +1380,58 @@ class PreferencesPanel {
|
|
|
1376
1380
|
});
|
|
1377
1381
|
handlers.set("reset", async () => {
|
|
1378
1382
|
if (saving) return;
|
|
1379
|
-
|
|
1383
|
+
const message = definition.resetPaths ? `重置 ${definition.metadata?.name ?? active} 的设置?只删除本页面的设置,保留共享存储中的其它设置和缓存。` : `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`;
|
|
1384
|
+
if (!(await page.confirm(message)) || destroyed || saving) return;
|
|
1380
1385
|
return perform(() => client.reset(), controls);
|
|
1381
1386
|
});
|
|
1382
|
-
|
|
1383
|
-
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
1384
|
-
navigation.addEventListener("change", updateNavigation);
|
|
1387
|
+
viewport.replaceChildren(view);
|
|
1385
1388
|
for (const grow of growingInputs) grow();
|
|
1386
|
-
|
|
1389
|
+
page.refresh();
|
|
1387
1390
|
}
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
* @returns {void} 无返回值 / No return value.
|
|
1392
|
-
*/
|
|
1393
|
-
if (back)
|
|
1394
|
-
back.onclick = () => {
|
|
1395
|
-
if (saving) return;
|
|
1396
|
-
if (navigation) navigation.back();
|
|
1397
|
-
else window.history.back();
|
|
1398
|
-
};
|
|
1399
|
-
open(definition.module);
|
|
1391
|
+
queueMicrotask(() => {
|
|
1392
|
+
if (!destroyed) open(definition.module);
|
|
1393
|
+
});
|
|
1400
1394
|
return () => {
|
|
1401
1395
|
destroyed = true;
|
|
1402
|
-
menu?.destroy();
|
|
1403
|
-
frame?.removeEventListener("preferencepanes:action", onAction);
|
|
1404
|
-
navigation?.destroy();
|
|
1405
1396
|
generation++;
|
|
1406
|
-
|
|
1407
|
-
clearTimeout(timer);
|
|
1397
|
+
client.leave();
|
|
1408
1398
|
shell.remove();
|
|
1409
1399
|
};
|
|
1410
1400
|
}
|
|
1411
1401
|
|
|
1412
1402
|
/**
|
|
1413
|
-
*
|
|
1414
|
-
*
|
|
1403
|
+
* 返回当前会话中的字段或操作页,不再读取配置。
|
|
1404
|
+
* Return a field or action view from the current session without fetching configuration.
|
|
1405
|
+
* @param {string} key 子页键 / Detail key.
|
|
1406
|
+
* @returns {HTMLElement} 当前视图 / Current view.
|
|
1407
|
+
*/
|
|
1408
|
+
view(key) {
|
|
1409
|
+
return this.#view(key);
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
/**
|
|
1413
|
+
* 提供当前页标题及菜单状态。
|
|
1414
|
+
* Provide the current title and action state.
|
|
1415
|
+
* @param {string} key 子页键 / Detail key.
|
|
1416
|
+
* @returns {object} 页面状态 / Page state.
|
|
1417
|
+
*/
|
|
1418
|
+
state(key) {
|
|
1419
|
+
return this.#state(key);
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
/**
|
|
1423
|
+
* 执行共用菜单中的操作。
|
|
1424
|
+
* Perform an action from the shared menu.
|
|
1425
|
+
* @param {string} id 操作标识 / Action identifier.
|
|
1426
|
+
* @returns {void} 无返回值 / No return value.
|
|
1427
|
+
*/
|
|
1428
|
+
perform(id) {
|
|
1429
|
+
this.#perform(id);
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
/**
|
|
1433
|
+
* 释放读取与视图;已开始的写入仍沿原队列完成。
|
|
1434
|
+
* Release reads and views while allowing started writes to finish through the existing queue.
|
|
1415
1435
|
* @returns {void} 无返回值 / No return value.
|
|
1416
1436
|
*/
|
|
1417
1437
|
destroy() {
|
|
@@ -1419,124 +1439,403 @@ class PreferencesPanel {
|
|
|
1419
1439
|
}
|
|
1420
1440
|
}
|
|
1421
1441
|
|
|
1422
|
-
var defaults = "/* 通用默认样式只使用 pp 命名空间;项目可通过 CSS 输入覆盖变量和组件。\n * Generic defaults use only the pp namespace; projects may override variables and components through CSS input. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-field: #f1f2f3;\n --pp-border: #e3e5e7;\n --pp-muted: #797f87;\n --pp-accent: #1677ff;\n --pp-warning: #9a6700;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n max-width: 100vw;\n min-width: 0;\n height: 100vh;\n overflow: hidden;\n}\n\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #f1f2f3;\n --pp-background: #0d0e0f;\n --pp-surface: #18191c;\n --pp-field: #2f3238;\n --pp-border: #2f3238;\n --pp-muted: #9499a0;\n --pp-warning: #f0b849;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n flex: none;\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: relative;\n z-index: 2;\n}\n.pp-title {\n flex: 1;\n text-align: center;\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n flex: 1;\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n}\n@supports (height: 100dvh) {\n .pp-panel {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page,\n.pp-settings-page,\n.pp-cache-page {\n position: absolute;\n inset: 0;\n min-width: 0;\n overflow-x: hidden;\n overflow-y: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + var(--pp-keyboard-height, 0px));\n scroll-padding-bottom: var(--pp-keyboard-height, 0px);\n background: var(--pp-background);\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-panel a.pp-choice-link {\n color: var(--pp-accent);\n text-decoration: none;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-editor {\n flex: none;\n width: 45%;\n min-width: 0;\n min-height: 36px;\n padding: 8px 10px;\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-field);\n border: 0;\n border-radius: 6px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-editor {\n width: 100%;\n margin-top: 10px;\n}\n.pp-panel [hidden] {\n display: none !important;\n}\n.pp-label {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n margin-right: 16px;\n}\n.pp-field-name {\n color: var(--pp-text);\n font-size: 15px;\n}\n.pp-field-description {\n margin-top: 2px;\n color: var(--pp-muted);\n font-size: 12px;\n}\n.pp-field-warning {\n margin-top: 4px;\n color: var(--pp-warning);\n font-size: 12px;\n overflow-wrap: anywhere;\n}\n.pp-group {\n margin-top: 16px;\n}\n.pp-group-title {\n margin: 0 0 8px;\n color: var(--pp-muted);\n font-size: 15px;\n font-weight: 400;\n}\n.pp-row {\n min-width: 0;\n min-height: 48px;\n padding: 16px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n}\n.pp-rows > :last-child {\n border-bottom: 0 !important;\n}\n.pp-switch {\n flex: none;\n accent-color: var(--pp-accent);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-status {\n position: fixed;\n inset: 0;\n display: grid;\n place-content: center;\n justify-items: center;\n gap: 12px;\n min-width: 0;\n min-height: 0;\n margin: 0;\n padding: 24px;\n color: var(--pp-muted, GrayText);\n text-align: center;\n background: var(--pp-background, Canvas);\n}\n.pp-viewport > .pp-status {\n position: absolute;\n}\n.pp-status-spinner {\n box-sizing: border-box;\n width: 28px;\n height: 28px;\n border: 3px solid color-mix(in srgb, currentColor 25%, transparent);\n border-top-color: var(--pp-accent, AccentColor);\n border-radius: 50%;\n animation: pp-status-spin 0.8s linear infinite;\n}\n.pp-status-message {\n max-width: 100%;\n margin: 0;\n overflow-wrap: anywhere;\n}\n.pp-status-action {\n min-width: 96px;\n min-height: 44px;\n padding: 8px 16px;\n border: 0;\n border-radius: 6px;\n color: var(--pp-text, ButtonText);\n font: inherit;\n cursor: pointer;\n background: var(--pp-surface, ButtonFace);\n}\n@keyframes pp-status-spin {\n to {\n transform: rotate(1turn);\n }\n}\n.pp-cache {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n";
|
|
1423
|
-
|
|
1424
|
-
const selector = "style[data-preference-panes-defaults]";
|
|
1425
|
-
const stylesheetSelector = "link[data-preference-panes-stylesheet]";
|
|
1426
|
-
|
|
1427
1442
|
/**
|
|
1428
|
-
*
|
|
1429
|
-
*
|
|
1430
|
-
* @param {
|
|
1431
|
-
* @returns {
|
|
1443
|
+
* 浏览器宿主提供网页导航、通知、主题和可视区域避让。
|
|
1444
|
+
* The browser host provides web navigation, notices, theme and viewport clearance.
|
|
1445
|
+
* @param {import("./home.mjs").BridgeOptions} options 菜单和返回动作 / Menu and back action.
|
|
1446
|
+
* @returns {import("./home.mjs").Bridge} 浏览器宿主 / Browser host.
|
|
1432
1447
|
*/
|
|
1433
|
-
function
|
|
1434
|
-
const
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1448
|
+
function browserHost({ menu, back }) {
|
|
1449
|
+
const root = document.documentElement;
|
|
1450
|
+
const header = document.createElement("header");
|
|
1451
|
+
header.className = "pp-host-header";
|
|
1452
|
+
const button = document.createElement("button");
|
|
1453
|
+
button.className = "pp-host-back";
|
|
1454
|
+
button.type = "button";
|
|
1455
|
+
button.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 5-7 7 7 7"/></svg>';
|
|
1456
|
+
button.setAttribute("aria-label", "返回");
|
|
1457
|
+
button.onclick = back;
|
|
1458
|
+
const left = document.createElement("span");
|
|
1459
|
+
left.className = "pp-host-left";
|
|
1460
|
+
left.append(button);
|
|
1461
|
+
const title = document.createElement("span");
|
|
1462
|
+
title.className = "pp-host-title";
|
|
1463
|
+
const brand = document.createElement("div");
|
|
1464
|
+
brand.className = "pp-host-brand";
|
|
1465
|
+
const brandLink = document.createElement("a");
|
|
1466
|
+
brandLink.className = "pp-host-brand-link";
|
|
1467
|
+
const logo = document.createElement("span");
|
|
1468
|
+
logo.className = "pp-host-logo";
|
|
1469
|
+
const image = document.createElement("img");
|
|
1470
|
+
image.alt = "logo";
|
|
1471
|
+
logo.append(image);
|
|
1472
|
+
brandLink.append(logo, title);
|
|
1473
|
+
brand.append(brandLink);
|
|
1474
|
+
header.append(left, brand, menu.element);
|
|
1475
|
+
document.body.prepend(header);
|
|
1476
|
+
const toast = document.createElement("div");
|
|
1477
|
+
toast.className = "pp-host-toast";
|
|
1478
|
+
toast.setAttribute("role", "status");
|
|
1479
|
+
toast.hidden = true;
|
|
1480
|
+
document.body.append(toast);
|
|
1481
|
+
let timer;
|
|
1482
|
+
const theme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
1483
|
+
const applyTheme = () => {
|
|
1484
|
+
root.dataset.theme = theme.matches ? "dark" : "light";
|
|
1485
|
+
};
|
|
1486
|
+
const viewport = window.visualViewport;
|
|
1487
|
+
const resize = () => {
|
|
1488
|
+
root.style.setProperty("--pp-host-height", `${header.getBoundingClientRect().height}px`);
|
|
1489
|
+
const clearance = viewport && viewport.scale === 1 ? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop) : 0;
|
|
1490
|
+
root.style.setProperty("--pp-keyboard-height", `${clearance}px`);
|
|
1491
|
+
};
|
|
1492
|
+
const scroll = () => {
|
|
1493
|
+
const pages = document.querySelector("[data-preference-panes-pages]");
|
|
1494
|
+
const current = [...pages.children].find(view => !view.inert);
|
|
1495
|
+
header.classList.toggle("pp-scrolled", (current?.scrollTop ?? 0) >= 100);
|
|
1496
|
+
};
|
|
1497
|
+
document.addEventListener("scroll", scroll, true);
|
|
1498
|
+
theme.addEventListener("change", applyTheme);
|
|
1499
|
+
window.addEventListener("resize", resize);
|
|
1500
|
+
window.addEventListener("pageshow", resize);
|
|
1501
|
+
viewport?.addEventListener("resize", resize);
|
|
1502
|
+
viewport?.addEventListener("scroll", resize);
|
|
1503
|
+
applyTheme();
|
|
1504
|
+
resize();
|
|
1505
|
+
return {
|
|
1506
|
+
update(state) {
|
|
1507
|
+
title.textContent = state.logoText ?? state.title;
|
|
1508
|
+
brandLink.href = state.logoHref ?? "/";
|
|
1509
|
+
logo.hidden = !state.logo;
|
|
1510
|
+
if (state.logo) image.src = state.logo;
|
|
1511
|
+
left.hidden = !state.canGoBack;
|
|
1512
|
+
menu.element.hidden = !state.actions.length;
|
|
1513
|
+
button.disabled = state.busy;
|
|
1514
|
+
scroll();
|
|
1515
|
+
},
|
|
1516
|
+
confirm: message => window.confirm(message),
|
|
1517
|
+
notice(detail) {
|
|
1518
|
+
clearTimeout(timer);
|
|
1519
|
+
toast.textContent = detail.message;
|
|
1520
|
+
toast.hidden = false;
|
|
1521
|
+
timer = setTimeout(() => {
|
|
1522
|
+
toast.hidden = true;
|
|
1523
|
+
}, 2400);
|
|
1524
|
+
},
|
|
1525
|
+
openURL(url, target) {
|
|
1526
|
+
switch (target) {
|
|
1527
|
+
case "_blank":
|
|
1528
|
+
window.open(url, "_blank", "noopener,noreferrer");
|
|
1529
|
+
break;
|
|
1530
|
+
default:
|
|
1531
|
+
window.location.assign(url);
|
|
1532
|
+
break;
|
|
1533
|
+
}
|
|
1534
|
+
},
|
|
1535
|
+
destroy() {
|
|
1536
|
+
clearTimeout(timer);
|
|
1537
|
+
document.removeEventListener("scroll", scroll, true);
|
|
1538
|
+
theme.removeEventListener("change", applyTheme);
|
|
1539
|
+
window.removeEventListener("resize", resize);
|
|
1540
|
+
window.removeEventListener("pageshow", resize);
|
|
1541
|
+
viewport?.removeEventListener("resize", resize);
|
|
1542
|
+
viewport?.removeEventListener("scroll", resize);
|
|
1543
|
+
root.style.removeProperty("--pp-host-height");
|
|
1544
|
+
root.style.removeProperty("--pp-keyboard-height");
|
|
1545
|
+
delete root.dataset.theme;
|
|
1546
|
+
header.remove();
|
|
1547
|
+
toast.remove();
|
|
1548
|
+
},
|
|
1549
|
+
};
|
|
1441
1550
|
}
|
|
1442
1551
|
|
|
1443
1552
|
/**
|
|
1444
|
-
*
|
|
1445
|
-
*
|
|
1553
|
+
* 一个文档共享宿主、路由和当前模块会话,字段子页不重新读取配置。
|
|
1554
|
+
* Share one host, router and module session within a document without reloading config for field views.
|
|
1446
1555
|
*/
|
|
1447
|
-
class
|
|
1448
|
-
#
|
|
1449
|
-
#root;
|
|
1450
|
-
#base;
|
|
1451
|
-
#ownsBase;
|
|
1452
|
-
#previousTitle;
|
|
1453
|
-
#previousTheme;
|
|
1454
|
-
#systemTheme;
|
|
1455
|
-
#previousKeyboard;
|
|
1556
|
+
class PageController {
|
|
1557
|
+
#navigation;
|
|
1456
1558
|
#host;
|
|
1457
|
-
#
|
|
1458
|
-
#
|
|
1559
|
+
#menu;
|
|
1560
|
+
#title;
|
|
1561
|
+
#logo;
|
|
1562
|
+
#logoText;
|
|
1563
|
+
#logoHref;
|
|
1564
|
+
#modules;
|
|
1565
|
+
#links;
|
|
1566
|
+
#session;
|
|
1567
|
+
#destroyed = false;
|
|
1568
|
+
|
|
1569
|
+
/**
|
|
1570
|
+
* 接管已初始化的宿主与首页;此接口仅供包内入口组合。
|
|
1571
|
+
* Own an initialized host and homepage; this interface is only used by internal entry points.
|
|
1572
|
+
* @param {HTMLElement} container 页面容器 / Page container.
|
|
1573
|
+
* @param {HTMLElement} home 首页节点 / Homepage node.
|
|
1574
|
+
* @param {object} options 宿主、菜单、品牌及入口 / Host, menu, brand and entries.
|
|
1575
|
+
*/
|
|
1576
|
+
constructor(container, home, { host, menu, title, logo, logoText, logoHref, modules, links = [] }) {
|
|
1577
|
+
this.#host = host;
|
|
1578
|
+
this.#menu = menu;
|
|
1579
|
+
this.#title = title;
|
|
1580
|
+
this.#logo = logo;
|
|
1581
|
+
this.#logoText = logoText || (logo ? "" : title);
|
|
1582
|
+
this.#logoHref = logoHref;
|
|
1583
|
+
this.#modules = modules;
|
|
1584
|
+
this.#links = links;
|
|
1585
|
+
this.#navigation = new Navigation(container, home, (key, signal) => this.#view(key, signal));
|
|
1586
|
+
this.#navigation.addEventListener("change", () => this.update());
|
|
1587
|
+
this.update();
|
|
1588
|
+
}
|
|
1459
1589
|
|
|
1460
1590
|
/**
|
|
1461
|
-
*
|
|
1462
|
-
*
|
|
1463
|
-
* @param {
|
|
1591
|
+
* 创建模块会话或复用其字段页;外部网站才使用 iframe。
|
|
1592
|
+
* Create a module session or reuse a field view; only external sites use an iframe.
|
|
1593
|
+
* @param {string} key 路由键 / Route key.
|
|
1594
|
+
* @param {AbortSignal} signal 当前外链视图的取消信号 / Cancellation signal for the current external view.
|
|
1595
|
+
* @returns {HTMLElement | undefined} 视图;未知模块返回 undefined / View; undefined for unknown modules.
|
|
1464
1596
|
*/
|
|
1465
|
-
|
|
1466
|
-
const
|
|
1467
|
-
const
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1597
|
+
#view(key, signal) {
|
|
1598
|
+
const [module, ...parts] = key.split("/");
|
|
1599
|
+
const view = parts.join("/");
|
|
1600
|
+
if (module !== this.#session?.module) {
|
|
1601
|
+
this.#session?.controller.abort();
|
|
1602
|
+
this.#session?.panel?.destroy();
|
|
1603
|
+
this.#session = undefined;
|
|
1604
|
+
}
|
|
1605
|
+
if (module === "$link") {
|
|
1606
|
+
const link = this.#links[Number(view)];
|
|
1607
|
+
if (!link || link.dataset.target !== "internal") return;
|
|
1608
|
+
const page = element("section", "pp-link-page");
|
|
1609
|
+
const iframe = element("iframe", "");
|
|
1610
|
+
iframe.title = link.querySelector(".pp-home-name").textContent;
|
|
1611
|
+
iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox");
|
|
1612
|
+
iframe.src = link.href;
|
|
1613
|
+
page.append(iframe);
|
|
1614
|
+
signal.addEventListener("abort", () => iframe.remove(), { once: true });
|
|
1615
|
+
return page;
|
|
1616
|
+
}
|
|
1617
|
+
const entry = this.#modules.get(module);
|
|
1618
|
+
if (!entry) return;
|
|
1619
|
+
if (!this.#session) {
|
|
1620
|
+
const controller = new AbortController();
|
|
1621
|
+
const root = element("section", "pp-module-page");
|
|
1622
|
+
const session = { module, root, controller };
|
|
1623
|
+
this.#session = session;
|
|
1624
|
+
const load = async () => {
|
|
1625
|
+
if (controller.signal.aborted) return;
|
|
1626
|
+
root.replaceChildren(statusView("正在打开设置…"));
|
|
1627
|
+
try {
|
|
1628
|
+
let boxjs = entry.boxjs;
|
|
1629
|
+
if (!boxjs) {
|
|
1630
|
+
const response = await fetch(`/api/${encodeURIComponent(module)}`, { cache: "no-store", signal: controller.signal });
|
|
1631
|
+
if (!response.ok) throw new Error(`配置加载失败:HTTP ${response.status}`);
|
|
1632
|
+
boxjs = await response.json();
|
|
1633
|
+
}
|
|
1634
|
+
if (controller.signal.aborted) return;
|
|
1635
|
+
root.replaceChildren();
|
|
1636
|
+
session.panel = new PreferencesPanel(root, normalizeBoxJs(boxjs), this);
|
|
1637
|
+
this.update();
|
|
1638
|
+
} catch (error) {
|
|
1639
|
+
if (controller.signal.aborted) return;
|
|
1640
|
+
root.replaceChildren(statusView(`加载失败:${error.message}`, load));
|
|
1641
|
+
}
|
|
1642
|
+
};
|
|
1643
|
+
// 已提供 BoxJS 时也等待路由切换完成,再发布表单状态。
|
|
1644
|
+
// Wait for routing to finish before publishing form state, even with supplied BoxJS.
|
|
1645
|
+
queueMicrotask(load);
|
|
1477
1646
|
}
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
this.#
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
this.#
|
|
1647
|
+
return this.#session.panel?.view(view) ?? this.#session.root;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/**
|
|
1651
|
+
* 同步唯一标题栏与菜单;离开模块时终止读取和会话。
|
|
1652
|
+
* Synchronize the only title bar and menu, ending reads and sessions on departure.
|
|
1653
|
+
* @returns {void} 无返回值 / No return value.
|
|
1654
|
+
*/
|
|
1655
|
+
update() {
|
|
1656
|
+
if (this.#destroyed || !this.#navigation) return;
|
|
1657
|
+
const [module, ...parts] = this.#navigation.current.split("/");
|
|
1658
|
+
if (!module && this.#session) {
|
|
1659
|
+
this.#session.controller.abort();
|
|
1660
|
+
this.#session.panel?.destroy();
|
|
1661
|
+
this.#session = undefined;
|
|
1491
1662
|
}
|
|
1492
|
-
|
|
1663
|
+
const state = this.#session?.panel?.state(parts.join("/")) ?? {
|
|
1664
|
+
title: module === "$link" ? this.#links[Number(parts[0])].querySelector(".pp-home-name").textContent : (this.#modules.get(module)?.name ?? this.#title),
|
|
1665
|
+
actions: [],
|
|
1666
|
+
busy: false,
|
|
1667
|
+
};
|
|
1668
|
+
document.title = state.title;
|
|
1669
|
+
this.#menu.update(state.actions, state.busy);
|
|
1670
|
+
Promise.resolve(this.#host.update({ ...state, logo: module ? undefined : this.#logo, logoText: module ? undefined : this.#logoText, logoHref: this.#logoHref, canGoBack: this.#navigation.canGoBack })).catch(error => this.notice({ kind: "error", message: error.message }));
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
/**
|
|
1674
|
+
* 控件重建后重新解析当前路由,不增加历史或重新加载模块配置。
|
|
1675
|
+
* Resolve the current route after rebuilding controls without adding history or reloading config.
|
|
1676
|
+
* @returns {void} 无返回值 / No return value.
|
|
1677
|
+
*/
|
|
1678
|
+
refresh() {
|
|
1679
|
+
if (!this.#destroyed) this.#navigation.refresh();
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* 打开当前模块的字段或操作子页。
|
|
1684
|
+
* Open a field or action subview of the current module.
|
|
1685
|
+
* @param {string} view 子页键 / Detail key.
|
|
1686
|
+
* @returns {void} 无返回值 / No return value.
|
|
1687
|
+
*/
|
|
1688
|
+
open(view) {
|
|
1689
|
+
this.#navigation.open(`${this.#session.module}/${view}`);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
/**
|
|
1693
|
+
* 执行当前表单操作。
|
|
1694
|
+
* Perform an action on the current form.
|
|
1695
|
+
* @param {string} id 操作标识 / Action identifier.
|
|
1696
|
+
* @returns {void} 无返回值 / No return value.
|
|
1697
|
+
*/
|
|
1698
|
+
perform(id) {
|
|
1699
|
+
this.#session?.panel?.perform(id);
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* 请求宿主确认;退出后忽略晚到的选择。
|
|
1704
|
+
* Request host confirmation and ignore a late choice after departure.
|
|
1705
|
+
* @param {string} message 确认文字 / Confirmation message.
|
|
1706
|
+
* @returns {Promise<boolean>} 用户选择 / User choice.
|
|
1707
|
+
*/
|
|
1708
|
+
async confirm(message) {
|
|
1709
|
+
const session = this.#session;
|
|
1710
|
+
const confirmed = await this.#host.confirm(message);
|
|
1711
|
+
return !this.#destroyed && session === this.#session && confirmed;
|
|
1712
|
+
}
|
|
1713
|
+
|
|
1714
|
+
/**
|
|
1715
|
+
* 由唯一宿主显示通知。
|
|
1716
|
+
* Display a notice through the only host.
|
|
1717
|
+
* @param {import("./Navigation.mjs").Notice} detail 已格式化通知 / Formatted notice.
|
|
1718
|
+
* @returns {void} 无返回值 / No return value.
|
|
1719
|
+
*/
|
|
1720
|
+
notice(detail) {
|
|
1721
|
+
if (!this.#destroyed) this.#host.notice(detail);
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/**
|
|
1725
|
+
* 由宿主执行标准外链导航。
|
|
1726
|
+
* Delegate standard external navigation to the host.
|
|
1727
|
+
* @param {string} url 完整地址 / Absolute URL.
|
|
1728
|
+
* @param {"_self" | "_blank"} [target] 打开方式 / Opening mode.
|
|
1729
|
+
* @returns {void} 无返回值 / No return value.
|
|
1730
|
+
*/
|
|
1731
|
+
openURL(url, target) {
|
|
1493
1732
|
try {
|
|
1494
|
-
this.#
|
|
1495
|
-
this.#panel = new PreferencesPanel(this.#root, definition);
|
|
1733
|
+
Promise.resolve(this.#host.openURL(url, target)).catch(error => this.notice({ kind: "error", message: error.message }));
|
|
1496
1734
|
} catch (error) {
|
|
1497
|
-
this.
|
|
1498
|
-
throw error;
|
|
1735
|
+
this.notice({ kind: "error", message: error.message });
|
|
1499
1736
|
}
|
|
1500
1737
|
}
|
|
1501
1738
|
|
|
1502
1739
|
/**
|
|
1503
|
-
*
|
|
1504
|
-
*
|
|
1505
|
-
* @returns {void} 已同步主题与键盘避让 / Theme and keyboard clearance synchronized.
|
|
1740
|
+
* 共用首页与表单导航。
|
|
1741
|
+
* Navigation shared by the homepage and forms.
|
|
1506
1742
|
*/
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
if (this.#host) document.documentElement.style.setProperty("--pp-keyboard-height", this.#host.style.getPropertyValue("--pp-keyboard-height"));
|
|
1511
|
-
};
|
|
1743
|
+
get navigation() {
|
|
1744
|
+
return this.#navigation;
|
|
1745
|
+
}
|
|
1512
1746
|
|
|
1513
1747
|
/**
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1748
|
+
* 结束页面能力和会话;表单写入队列自行收尾。
|
|
1749
|
+
* End page capabilities and sessions while forms finish their write queues.
|
|
1516
1750
|
* @returns {void} 无返回值 / No return value.
|
|
1517
1751
|
*/
|
|
1518
1752
|
destroy() {
|
|
1519
|
-
this.#
|
|
1520
|
-
this.#
|
|
1521
|
-
this.#
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
if (this.#previousTheme === undefined) delete document.documentElement.dataset.theme;
|
|
1527
|
-
else document.documentElement.dataset.theme = this.#previousTheme;
|
|
1528
|
-
document.documentElement.style.setProperty("--pp-keyboard-height", this.#previousKeyboard);
|
|
1753
|
+
if (this.#destroyed) return;
|
|
1754
|
+
this.#destroyed = true;
|
|
1755
|
+
this.#session?.controller.abort();
|
|
1756
|
+
this.#session?.panel?.destroy();
|
|
1757
|
+
this.#navigation.destroy();
|
|
1758
|
+
this.#host.destroy();
|
|
1759
|
+
this.#menu.destroy();
|
|
1529
1760
|
}
|
|
1530
1761
|
}
|
|
1531
1762
|
|
|
1763
|
+
var defaults = "/* 通用默认样式只使用 pp 命名空间;项目可通过 CSS 输入覆盖变量和组件。\n * Generic defaults use only the pp namespace; projects may override variables and components through CSS input. */\n:root {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-field: #f1f2f3;\n --pp-border: #e3e5e7;\n --pp-muted: #797f87;\n --pp-accent: #1677ff;\n --pp-warning: #9a6700;\n --pp-host-height: 0px;\n --pp-keyboard-height: 0px;\n}\n.pp-panel {\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n max-width: 100vw;\n min-width: 0;\n}\n\n:root[data-theme=\"dark\"] {\n --pp-text: #f1f2f3;\n --pp-background: #0d0e0f;\n --pp-surface: #18191c;\n --pp-field: #2f3238;\n --pp-border: #2f3238;\n --pp-muted: #9499a0;\n --pp-warning: #f0b849;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n min-width: 0;\n}\n.pp-fields,\n.pp-choice-page,\n.pp-settings-page,\n.pp-cache-page {\n box-sizing: border-box;\n min-width: 0;\n padding: calc(12px + var(--pp-host-height)) max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + var(--pp-keyboard-height));\n scroll-padding-bottom: var(--pp-keyboard-height);\n background: var(--pp-background);\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-panel a.pp-choice-link {\n color: var(--pp-accent);\n text-decoration: none;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-editor {\n flex: none;\n width: 45%;\n min-width: 0;\n min-height: 36px;\n padding: 8px 10px;\n font: inherit;\n font-size: 16px;\n color: var(--pp-text);\n background: var(--pp-field);\n border: 0;\n border-radius: 6px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-editor {\n width: 100%;\n margin-top: 10px;\n}\n.pp-panel [hidden] {\n display: none !important;\n}\n.pp-label {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n margin-right: 16px;\n}\n.pp-field-name {\n color: var(--pp-text);\n font-size: 15px;\n}\n.pp-field-description {\n margin-top: 2px;\n color: var(--pp-muted);\n font-size: 12px;\n}\n.pp-field-warning {\n margin-top: 4px;\n color: var(--pp-warning);\n font-size: 12px;\n overflow-wrap: anywhere;\n}\n.pp-group {\n margin-top: 16px;\n}\n.pp-group-title {\n margin: 0 0 8px;\n color: var(--pp-muted);\n font-size: 15px;\n font-weight: 400;\n}\n.pp-row {\n min-width: 0;\n min-height: 48px;\n padding: 16px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n}\n.pp-rows > :last-child {\n border-bottom: 0 !important;\n}\n.pp-switch {\n flex: none;\n accent-color: var(--pp-accent);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-status {\n position: absolute;\n inset: 0;\n display: grid;\n place-content: center;\n justify-items: center;\n gap: 12px;\n min-width: 0;\n min-height: 0;\n margin: 0;\n padding: 24px;\n color: var(--pp-muted, GrayText);\n text-align: center;\n background: var(--pp-background, Canvas);\n}\n.pp-viewport > .pp-status {\n position: absolute;\n}\n.pp-status-spinner {\n box-sizing: border-box;\n width: 28px;\n height: 28px;\n border: 3px solid var(--pp-border, #8884);\n border-top-color: var(--pp-accent, AccentColor);\n border-radius: 50%;\n animation: pp-status-spin 0.8s linear infinite;\n}\n.pp-status-message {\n max-width: 100%;\n margin: 0;\n overflow-wrap: anywhere;\n}\n.pp-status-action {\n min-width: 96px;\n min-height: 44px;\n padding: 8px 16px;\n border: 0;\n border-radius: 6px;\n color: var(--pp-text, ButtonText);\n font: inherit;\n cursor: pointer;\n background: var(--pp-surface, ButtonFace);\n}\n@keyframes pp-status-spin {\n to {\n transform: rotate(1turn);\n }\n}\n.pp-cache {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n\n/* 首页沿用表单的分组与说明样式,入口按屏幕宽度自动换行。\n * The landing page reuses form groups and descriptions, with entries wrapping by viewport width. */\n:root.pp-page {\n color-scheme: light dark;\n}\n.pp-page body {\n margin: 0;\n overflow: hidden;\n font:\n 16px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n background: var(--pp-background, Canvas);\n color: var(--pp-text, CanvasText);\n}\n[data-preference-panes-pages] {\n position: relative;\n height: 100vh;\n background: inherit;\n overflow: hidden;\n}\n@supports (height: 100dvh) {\n [data-preference-panes-pages] {\n height: 100dvh;\n }\n}\n.pp-home,\n.pp-module-page,\n.pp-detail,\n.pp-link-page {\n position: absolute;\n inset: 0;\n overflow-x: hidden;\n overflow-y: auto;\n overscroll-behavior: contain;\n background: var(--pp-background);\n}\n.pp-module-page,\n.pp-detail,\n.pp-link-page {\n z-index: 1;\n box-shadow: -8px 0 24px #00000014;\n}\n.pp-link-page {\n top: var(--pp-host-height);\n}\n.pp-link-page iframe {\n display: block;\n border: 0;\n width: 100%;\n height: 100%;\n background: inherit;\n}\n.pp-host-header {\n box-sizing: border-box;\n position: fixed;\n inset: 0 0 auto;\n z-index: 2;\n display: flex;\n align-items: center;\n height: calc(64px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) max(20px, env(safe-area-inset-right)) 0 max(20px, env(safe-area-inset-left));\n border-bottom: 1px solid transparent;\n background: transparent;\n -webkit-backdrop-filter: blur(25px);\n backdrop-filter: blur(25px);\n color: var(--pp-text);\n transition:\n background-color 200ms ease-in-out,\n border-color 200ms ease-in-out;\n}\n.pp-host-header.pp-scrolled {\n background: rgba(255, 255, 255, 0.6);\n border-color: var(--pp-border);\n}\n:root[data-theme=\"dark\"] .pp-host-header.pp-scrolled {\n background: rgba(13, 14, 15, 0.6);\n}\n@media (min-width: 1280px) {\n .pp-host-header {\n padding-right: max(20px, calc((100vw - 1588px) / 2));\n padding-left: max(20px, calc((100vw - 1588px) / 2));\n }\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-host-header {\n transition: none;\n }\n}\n.pp-host-brand {\n display: flex;\n align-items: center;\n flex: 1;\n height: 36px;\n min-width: 0;\n margin-right: 8px;\n}\n.pp-host-brand-link {\n display: flex;\n align-items: center;\n height: 100%;\n min-width: 0;\n max-width: 100%;\n color: inherit;\n text-decoration: none;\n transition: opacity 0.3s;\n}\n.pp-host-brand-link:hover {\n opacity: 0.6;\n}\n.pp-host-logo {\n flex: none;\n min-width: 2rem;\n margin-right: 0.25rem;\n}\n.pp-host-logo img {\n display: block;\n height: 1.6rem;\n}\n.pp-host-title {\n min-width: 0;\n text-align: left;\n font-size: 16px;\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.pp-host-left {\n display: flex;\n align-items: center;\n justify-content: center;\n flex: none;\n}\n.pp-host-left::after {\n content: \"\";\n display: inline-block;\n width: 1px;\n height: 24px;\n margin: 0 16px;\n background-color: #0000001f;\n}\n:root[data-theme=\"dark\"] .pp-host-left::after {\n background-color: #5454547a;\n}\n.pp-host-left[hidden] {\n display: none;\n}\n.pp-host-back {\n border: 0;\n border-radius: 18px;\n background: none;\n color: inherit;\n width: 36px;\n height: 36px;\n padding: 8px;\n cursor: pointer;\n transition: background-color 0.2s ease-in-out;\n}\n.pp-host-back svg {\n display: block;\n width: 20px;\n height: 20px;\n fill: none;\n stroke: currentColor;\n stroke-width: 2;\n}\n.pp-host-back:hover {\n background: #f1f1f1;\n}\n:root[data-theme=\"dark\"] .pp-host-back:hover {\n background: #343a46;\n}\n.pp-host-toast {\n position: fixed;\n z-index: 3;\n bottom: calc(24px + env(safe-area-inset-bottom) + var(--pp-keyboard-height));\n left: 50%;\n transform: translateX(-50%);\n max-width: calc(100% - 48px);\n padding: 10px 18px;\n border-radius: 12px;\n background: #222e;\n color: #fff;\n font-size: 14px;\n text-align: center;\n}\n.pp-host-toast[hidden] {\n display: none;\n}\n.pp-host-error {\n margin: 24px;\n padding: 16px;\n border: 1px solid var(--pp-danger, #e45656);\n border-radius: 12px;\n font-size: 16px;\n overflow-wrap: anywhere;\n}\n\n.pp-home {\n max-width: 1000px;\n margin: auto;\n padding: var(--pp-host-height) max(16px, env(safe-area-inset-right)) calc(24px + env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));\n}\n.pp-home-entries {\n display: flex;\n flex-wrap: wrap;\n justify-content: space-around;\n row-gap: 16px;\n padding: 16px 0;\n background: var(--pp-surface);\n}\n.pp-home-item {\n box-sizing: border-box;\n display: flex;\n flex-direction: column;\n align-items: center;\n width: 25%;\n min-width: 0;\n padding: 0 4px;\n border: 0;\n background: none;\n color: var(--pp-text);\n font:\n 12px / 1.5 -apple-system,\n sans-serif;\n cursor: pointer;\n text-decoration: none;\n}\n@media (min-width: 500px) {\n .pp-home-item {\n width: calc(100% / 6);\n }\n}\n@media (min-width: 700px) {\n .pp-home-item {\n width: 12.5%;\n }\n}\n.pp-home-icon {\n box-sizing: border-box;\n width: 56px;\n height: 56px;\n padding: 8px;\n border-radius: 50%;\n background: var(--pp-field);\n font-size: 32px;\n line-height: 40px;\n text-align: center;\n}\n.pp-home-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-home-name {\n margin-top: 8px;\n}\n.pp-home-content {\n min-width: 0;\n width: 100%;\n}\n.pp-home-content > span {\n display: block;\n}\n.pp-home-name,\n.pp-home-item [data-module-status] {\n width: 100%;\n overflow: hidden;\n text-align: center;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.pp-home-item [data-module-status] {\n min-height: 20px;\n}\n.pp-home-item:disabled {\n opacity: 0.45;\n cursor: default;\n}\n.pp-home-entries[data-layout=\"list\"] {\n display: block;\n padding: 0;\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-item {\n flex-direction: row;\n gap: 12px;\n width: 100%;\n padding: 12px 16px;\n font-size: 15px;\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-item + .pp-home-item {\n border-top: 1px solid var(--pp-border);\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-icon {\n flex: none;\n width: 40px;\n height: 40px;\n padding: 4px;\n font-size: 24px;\n line-height: 32px;\n}\n.pp-home-entries[data-layout=\"list\"] .pp-home-name {\n margin-top: 0;\n text-align: left;\n}\n.pp-home-arrow {\n color: var(--pp-muted);\n font-size: 24px;\n}\n.pp-home-footer {\n margin-top: 16px;\n text-align: center;\n white-space: normal;\n}\n.pp-home-footer p {\n margin: 8px 0 0;\n}\n";
|
|
1764
|
+
|
|
1765
|
+
const selector = "style[data-preference-panes-defaults]";
|
|
1766
|
+
const stylesheetSelector = "link[data-preference-panes-stylesheet]";
|
|
1767
|
+
|
|
1768
|
+
/**
|
|
1769
|
+
* 在文档中安装一次默认样式,并标记当前调用方是否拥有该节点。
|
|
1770
|
+
* Install default styles once and report whether the current caller owns the node.
|
|
1771
|
+
* @param {Document} document 目标文档 / Target document.
|
|
1772
|
+
* @returns {{element: HTMLStyleElement, owned: boolean}} 样式节点及所有权 / Style node and ownership.
|
|
1773
|
+
*/
|
|
1774
|
+
function installDefaultStyles(document) {
|
|
1775
|
+
const existing = document.head.querySelector(selector);
|
|
1776
|
+
if (existing) return { element: existing, owned: false };
|
|
1777
|
+
const element = document.createElement("style");
|
|
1778
|
+
element.dataset.preferencePanesDefaults = "";
|
|
1779
|
+
element.textContent = defaults;
|
|
1780
|
+
document.head.insertBefore(element, document.head.querySelector(stylesheetSelector));
|
|
1781
|
+
return { element, owned: true };
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1532
1784
|
/**
|
|
1533
|
-
* 使用原始 BoxJS JSON
|
|
1534
|
-
* Mount a settings page from raw BoxJS JSON.
|
|
1785
|
+
* 使用原始 BoxJS JSON 挂载设置页,共用内部页面控制器。
|
|
1786
|
+
* Mount a settings page from raw BoxJS JSON through the shared internal page controller.
|
|
1535
1787
|
* @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
|
|
1536
1788
|
* @returns {import("./index.js").MountedPreferences} 模块视图 / Module view.
|
|
1537
1789
|
*/
|
|
1538
1790
|
function mount(boxjs) {
|
|
1539
|
-
|
|
1791
|
+
const definition = normalizeBoxJs(boxjs);
|
|
1792
|
+
const title = definition.metadata?.name ?? definition.module;
|
|
1793
|
+
const existing = document.querySelector("#preferences");
|
|
1794
|
+
const root = existing ?? element("main", "");
|
|
1795
|
+
const previousTitle = document.title;
|
|
1796
|
+
const previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
|
|
1797
|
+
const previousHeight = document.documentElement.style.getPropertyValue("--pp-host-height");
|
|
1798
|
+
const previousTheme = document.documentElement.dataset.theme;
|
|
1799
|
+
const previousClass = document.documentElement.classList.contains("pp-page");
|
|
1800
|
+
document.documentElement.classList.add("pp-page");
|
|
1801
|
+
root.dataset.preferencePanesPages = "";
|
|
1802
|
+
if (!existing) {
|
|
1803
|
+
root.id = "preferences";
|
|
1804
|
+
document.body.append(root);
|
|
1805
|
+
}
|
|
1806
|
+
const defaults = installDefaultStyles(document);
|
|
1807
|
+
const menu = new ActionMenu(id => page.perform(id));
|
|
1808
|
+
const host = browserHost({ menu, back: () => page.navigation.back() });
|
|
1809
|
+
const page = new PageController(root, element("main", "pp-home"), { host, menu, title, modules: new Map([[definition.module, { name: title, boxjs }]]) });
|
|
1810
|
+
if (!page.navigation.current) page.navigation.open(definition.module);
|
|
1811
|
+
let destroyed = false;
|
|
1812
|
+
const pagehide = event => {
|
|
1813
|
+
if (!event.persisted) destroy();
|
|
1814
|
+
};
|
|
1815
|
+
window.addEventListener("pagehide", pagehide);
|
|
1816
|
+
/**
|
|
1817
|
+
* 释放模块、样式与监听器,并恢复调用方的文档状态。
|
|
1818
|
+
* Release the module, styles and listeners and restore the caller's document state.
|
|
1819
|
+
* @returns {void} 无返回值 / No return value.
|
|
1820
|
+
*/
|
|
1821
|
+
function destroy() {
|
|
1822
|
+
if (destroyed) return;
|
|
1823
|
+
destroyed = true;
|
|
1824
|
+
page.destroy();
|
|
1825
|
+
window.removeEventListener("pagehide", pagehide);
|
|
1826
|
+
if (defaults.owned) defaults.element.remove();
|
|
1827
|
+
if (existing) {
|
|
1828
|
+
root.replaceChildren();
|
|
1829
|
+
delete root.dataset.preferencePanesPages;
|
|
1830
|
+
} else root.remove();
|
|
1831
|
+
if (!previousClass) document.documentElement.classList.remove("pp-page");
|
|
1832
|
+
document.title = previousTitle;
|
|
1833
|
+
document.documentElement.style.setProperty("--pp-keyboard-height", previousKeyboard);
|
|
1834
|
+
document.documentElement.style.setProperty("--pp-host-height", previousHeight);
|
|
1835
|
+
if (previousTheme === undefined) delete document.documentElement.dataset.theme;
|
|
1836
|
+
else document.documentElement.dataset.theme = previousTheme;
|
|
1837
|
+
}
|
|
1838
|
+
return { destroy };
|
|
1540
1839
|
}
|
|
1541
1840
|
|
|
1542
1841
|
export { mount };
|