@nsnanocat/preference-panes 1.3.0 → 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 +13 -0
- package/README.md +19 -35
- package/dist/home.html +1638 -462
- package/dist/{module/navigation.mjs → navigation.mjs} +51 -143
- package/dist/preference-panes.mjs +856 -610
- package/package.json +2 -2
- 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/client.d.mts +1 -1
- package/src/browser/client.mjs +6 -4
- package/src/browser/components.mjs +0 -44
- package/src/browser/home.d.mts +13 -1
- package/src/browser/home.html +0 -1
- package/src/browser/home.mjs +29 -183
- package/src/browser/mount.mjs +50 -91
- package/src/browser/page.mjs +325 -0
- package/src/browser/panel.mjs +99 -123
- package/src/browser/styles.css +133 -125
- package/dist/module/index.html +0 -15
- package/dist/module/index.mjs +0 -1678
- package/src/browser/ModuleFrame.mjs +0 -122
- package/src/browser/index.mjs +0 -89
- package/src/browser/module.html +0 -15
|
@@ -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.
|
|
@@ -298,18 +447,6 @@ function fieldControl(control) {
|
|
|
298
447
|
return control;
|
|
299
448
|
}
|
|
300
449
|
|
|
301
|
-
/**
|
|
302
|
-
* 元数据地址只允许 HTTP(S) 和相对地址。
|
|
303
|
-
* Allow only HTTP(S) and relative metadata addresses.
|
|
304
|
-
* @param {string} value 元数据地址 / Metadata address.
|
|
305
|
-
* @returns {string} 完整地址 / Absolute address.
|
|
306
|
-
*/
|
|
307
|
-
function resourceURL(value) {
|
|
308
|
-
const url = new URL(value, document.baseURI);
|
|
309
|
-
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Metadata URLs must use HTTP(S)");
|
|
310
|
-
return url.href;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
450
|
/**
|
|
314
451
|
* 创建覆盖可用内容区的通用读取状态,失败时可附加重试动作。
|
|
315
452
|
* Create a shared status view that fills the available content area and may include retry.
|
|
@@ -334,182 +471,191 @@ function statusView(message, retry) {
|
|
|
334
471
|
}
|
|
335
472
|
|
|
336
473
|
/**
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
* @param {Window} host 模块窗口 / Module window.
|
|
340
|
-
* @param {string} message 确认内容 / Confirmation message.
|
|
341
|
-
* @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.
|
|
342
|
-
*/
|
|
343
|
-
function requestConfirmation(host, message) {
|
|
344
|
-
return new Promise((resolve, reject) => {
|
|
345
|
-
const frame = host.frameElement;
|
|
346
|
-
if (frame) {
|
|
347
|
-
const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:confirm", { cancelable: true, detail: { message, resolve, reject } });
|
|
348
|
-
if (!frame.dispatchEvent(event)) return;
|
|
349
|
-
}
|
|
350
|
-
resolve(host.confirm(message));
|
|
351
|
-
});
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
/**
|
|
355
|
-
* 请求宿主打开 URL;未被接管时保留链接默认导航。
|
|
356
|
-
* Request host URL navigation while preserving the link default when unhandled.
|
|
357
|
-
* @param {Window} host 模块窗口 / Module window.
|
|
358
|
-
* @param {string} url 完整目标地址 / Absolute target URL.
|
|
359
|
-
* @returns {boolean} 宿主是否接管导航 / Whether the host accepted navigation ownership.
|
|
360
|
-
*/
|
|
361
|
-
function requestURLNavigation(host, url) {
|
|
362
|
-
const frame = host.frameElement;
|
|
363
|
-
if (!frame) return false;
|
|
364
|
-
const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:open-url", { cancelable: true, detail: { url } });
|
|
365
|
-
return !frame.dispatchEvent(event);
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
/**
|
|
369
|
-
* 独立页面的三点按钮和底部操作菜单;嵌入页面由宿主提供对应界面。
|
|
370
|
-
* Overflow trigger and bottom action sheet for standalone pages; embedded pages use host-provided chrome.
|
|
474
|
+
* 同一文档内的首页、模块及字段子页导航。
|
|
475
|
+
* Navigate homepage, module and field views within one document.
|
|
371
476
|
*/
|
|
372
|
-
class
|
|
373
|
-
#
|
|
374
|
-
#
|
|
375
|
-
#
|
|
376
|
-
#
|
|
377
|
-
#
|
|
378
|
-
#
|
|
379
|
-
#
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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);
|
|
385
491
|
};
|
|
386
492
|
|
|
387
493
|
/**
|
|
388
|
-
*
|
|
389
|
-
*
|
|
390
|
-
* @param {
|
|
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.
|
|
391
499
|
*/
|
|
392
|
-
constructor(
|
|
393
|
-
|
|
394
|
-
this.#
|
|
395
|
-
this
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
svg{display:block;width:24px;height:24px;fill:currentColor}
|
|
404
|
-
</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>`;
|
|
405
|
-
this.#button = triggerRoot.querySelector("button");
|
|
406
|
-
this.#layer = document.createElement("span");
|
|
407
|
-
const layerRoot = this.#layer.attachShadow({ mode: "open" });
|
|
408
|
-
layerRoot.innerHTML = `<style>
|
|
409
|
-
:host{position:fixed;inset:0;z-index:2147483647;color:var(--pp-text,CanvasText);font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
410
|
-
:host([hidden]){display:none!important}
|
|
411
|
-
*,*::before,*::after{box-sizing:border-box}
|
|
412
|
-
button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
413
|
-
button:focus-visible{outline:2px solid var(--pp-accent,Highlight);outline-offset:-3px}
|
|
414
|
-
#backdrop{position:absolute;inset:0;width:100%;height:100%;padding:0;background:#0008;animation:pp-fade-in .18s ease-out}
|
|
415
|
-
#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)}
|
|
416
|
-
#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}
|
|
417
|
-
#items{max-height:calc(100vh - 116px - env(safe-area-inset-bottom));overflow-y:auto;-webkit-overflow-scrolling:touch}
|
|
418
|
-
#items button,#cancel{display:block;width:100%;min-height:54px;padding:14px 18px;text-align:center}
|
|
419
|
-
#items button+button{border-top:1px solid var(--pp-border,#8884)}
|
|
420
|
-
#items button[data-danger]{color:var(--pp-danger,#e45656)}
|
|
421
|
-
#cancel{margin-top:8px;color:var(--pp-accent,Highlight);font-weight:600}
|
|
422
|
-
@keyframes pp-fade-in{from{opacity:0}}
|
|
423
|
-
@keyframes pp-sheet-in{from{transform:translateY(100%)}}
|
|
424
|
-
@media (prefers-reduced-motion:reduce){#backdrop,#sheet{animation:none}}
|
|
425
|
-
</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>`;
|
|
426
|
-
this.#items = layerRoot.querySelector("#items");
|
|
427
|
-
this.#button.onclick = () => (this.#layer.hidden ? this.open() : this.close());
|
|
428
|
-
layerRoot.querySelector("#backdrop").onclick = () => {
|
|
429
|
-
this.close();
|
|
430
|
-
this.#button.focus();
|
|
431
|
-
};
|
|
432
|
-
layerRoot.querySelector("#cancel").onclick = () => {
|
|
433
|
-
this.close();
|
|
434
|
-
this.#button.focus();
|
|
435
|
-
};
|
|
436
|
-
this.#items.onkeydown = event => {
|
|
437
|
-
const items = [...this.#items.children];
|
|
438
|
-
const index = items.indexOf(layerRoot.activeElement);
|
|
439
|
-
const offsets = { ArrowDown: 1, ArrowUp: -1 };
|
|
440
|
-
if (event.key in offsets) {
|
|
441
|
-
event.preventDefault();
|
|
442
|
-
items[(index + offsets[event.key] + items.length) % items.length].focus();
|
|
443
|
-
}
|
|
444
|
-
};
|
|
445
|
-
document.body.append(this.#layer);
|
|
446
|
-
document.addEventListener("keydown", this.#key);
|
|
447
|
-
this.update([]);
|
|
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();
|
|
448
511
|
}
|
|
449
512
|
|
|
450
513
|
/**
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
|
|
454
|
-
|
|
514
|
+
* 当前子页键;空字符串表示主页。
|
|
515
|
+
* Current detail key; empty means home.
|
|
516
|
+
*/
|
|
517
|
+
get current() {
|
|
518
|
+
return this.#key;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* 是否可以返回上一级或先前文档。
|
|
523
|
+
* Whether a parent view or previous document is available.
|
|
524
|
+
*/
|
|
525
|
+
get canGoBack() {
|
|
526
|
+
return Boolean(this.#key) || this.#window.history.length > 1;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* 加入模块或字段子页历史,保留路由中的层级分隔符。
|
|
531
|
+
* Push a module or field view while retaining route hierarchy separators.
|
|
532
|
+
* @param {string} key 子页键 / Detail key.
|
|
455
533
|
* @returns {void} 无返回值 / No return value.
|
|
456
534
|
*/
|
|
457
|
-
|
|
458
|
-
this
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
this.#
|
|
462
|
-
|
|
463
|
-
const button = this.#document.createElement("button");
|
|
464
|
-
button.type = "button";
|
|
465
|
-
button.setAttribute("role", "menuitem");
|
|
466
|
-
button.textContent = item.label;
|
|
467
|
-
button.toggleAttribute("data-danger", Boolean(item.destructive));
|
|
468
|
-
button.onclick = () => {
|
|
469
|
-
this.close();
|
|
470
|
-
this.#select(item.id);
|
|
471
|
-
};
|
|
472
|
-
return button;
|
|
473
|
-
}),
|
|
474
|
-
);
|
|
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();
|
|
475
541
|
}
|
|
476
542
|
|
|
477
543
|
/**
|
|
478
|
-
*
|
|
479
|
-
*
|
|
544
|
+
* 沿文档历史返回,根页可退回上个文档。
|
|
545
|
+
* Go back through document history, including the previous document from home.
|
|
480
546
|
* @returns {void} 无返回值 / No return value.
|
|
481
547
|
*/
|
|
482
|
-
|
|
483
|
-
if (this.#
|
|
484
|
-
const style = getComputedStyle(this.element);
|
|
485
|
-
for (const property of ["--pp-text", "--pp-surface", "--pp-border", "--pp-accent", "--pp-danger"]) {
|
|
486
|
-
const value = style.getPropertyValue(property);
|
|
487
|
-
if (value) this.#layer.style.setProperty(property, value);
|
|
488
|
-
}
|
|
489
|
-
this.#layer.hidden = false;
|
|
490
|
-
this.#button.setAttribute("aria-expanded", "true");
|
|
491
|
-
this.#items.firstElementChild.focus();
|
|
548
|
+
back() {
|
|
549
|
+
if (this.canGoBack) this.#window.history.back();
|
|
492
550
|
}
|
|
493
551
|
|
|
494
552
|
/**
|
|
495
|
-
*
|
|
496
|
-
*
|
|
553
|
+
* 更新当前页的挂载视图,保留路由和滚动位置。
|
|
554
|
+
* Refresh the mounted view while retaining route and scroll position.
|
|
497
555
|
* @returns {void} 无返回值 / No return value.
|
|
498
556
|
*/
|
|
499
|
-
|
|
500
|
-
this.#
|
|
501
|
-
this.#button.setAttribute("aria-expanded", "false");
|
|
557
|
+
refresh() {
|
|
558
|
+
this.#route(true);
|
|
502
559
|
}
|
|
503
560
|
|
|
504
561
|
/**
|
|
505
|
-
*
|
|
506
|
-
*
|
|
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.
|
|
507
648
|
* @returns {void} 无返回值 / No return value.
|
|
508
649
|
*/
|
|
509
650
|
destroy() {
|
|
510
|
-
this.#
|
|
511
|
-
this.#
|
|
512
|
-
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();
|
|
513
659
|
}
|
|
514
660
|
}
|
|
515
661
|
|
|
@@ -626,8 +772,8 @@ class PreferencesClient {
|
|
|
626
772
|
}
|
|
627
773
|
|
|
628
774
|
/**
|
|
629
|
-
*
|
|
630
|
-
* Abort
|
|
775
|
+
* 终止当前页面的读取;已开始的持久化写入继续完成。
|
|
776
|
+
* Abort reads owned by the current page while allowing started persistence writes to finish.
|
|
631
777
|
* @returns {void} 无返回值 / No return value.
|
|
632
778
|
*/
|
|
633
779
|
leave() {
|
|
@@ -665,252 +811,95 @@ class PreferencesClient {
|
|
|
665
811
|
*/
|
|
666
812
|
async #send(action, path, value) {
|
|
667
813
|
const controller = new AbortController();
|
|
668
|
-
const abort = () => controller.abort();
|
|
669
|
-
if (
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
try {
|
|
673
|
-
const response = await this.#request(`/api/${action}`, {
|
|
674
|
-
method: "POST",
|
|
675
|
-
credentials: "omit",
|
|
676
|
-
cache: "no-store",
|
|
677
|
-
signal: controller.signal,
|
|
678
|
-
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
679
|
-
body: new URLSearchParams([[path, action === "set" ? JSON.stringify(value) : ""]]).toString(),
|
|
680
|
-
});
|
|
681
|
-
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
682
|
-
return response;
|
|
683
|
-
} finally {
|
|
684
|
-
clearTimeout(timer);
|
|
685
|
-
this.#session.signal.removeEventListener("abort", abort);
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
|
|
689
|
-
/**
|
|
690
|
-
* 执行写入动作;成功后只更新当前页面值。
|
|
691
|
-
* Execute a mutation and update only the current page values after success.
|
|
692
|
-
* @param {"set" | "delete"} action API 动作 / API action.
|
|
693
|
-
* @param {string} key 不含存储根的路径 / Path without the storage root.
|
|
694
|
-
* @param {unknown} value set 写入值 / Value written by set.
|
|
695
|
-
* @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
|
|
696
|
-
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
697
|
-
*/
|
|
698
|
-
async #change(action, key, value, operation) {
|
|
699
|
-
if (this.#saving) throw new Error("A settings write is already in progress");
|
|
700
|
-
this.#saving = true;
|
|
701
|
-
try {
|
|
702
|
-
switch (operation) {
|
|
703
|
-
case "write":
|
|
704
|
-
case "delete": {
|
|
705
|
-
const field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
706
|
-
if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
|
|
707
|
-
const indexed = /^(.*)\.(\d+)$/.exec(key);
|
|
708
|
-
if (indexed) {
|
|
709
|
-
const array = [...(this.#arrays.get(indexed[1]) ?? [])];
|
|
710
|
-
for (const sibling of this.#definition.fields) {
|
|
711
|
-
if (sibling.key.startsWith(`${indexed[1]}.`) && /^\d+$/.test(sibling.key.slice(indexed[1].length + 1))) {
|
|
712
|
-
const index = Number(sibling.key.slice(indexed[1].length + 1));
|
|
713
|
-
if (array[index] === undefined) array[index] = this.#values[sibling.key] ?? sibling.defaultValue;
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
array[Number(indexed[2])] = operation === "delete" ? field.defaultValue : value;
|
|
717
|
-
await this.#send("set", `@${this.#definition.storageKey}.${indexed[1]}`, array);
|
|
718
|
-
this.#arrays.set(indexed[1], array);
|
|
719
|
-
} else await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
|
|
720
|
-
switch (operation) {
|
|
721
|
-
case "write":
|
|
722
|
-
this.#values[key] = JSON.parse(JSON.stringify(value));
|
|
723
|
-
delete this.#warnings[key];
|
|
724
|
-
break;
|
|
725
|
-
case "delete":
|
|
726
|
-
delete this.#values[key];
|
|
727
|
-
delete this.#warnings[key];
|
|
728
|
-
if (Object.prototype.hasOwnProperty.call(field, "defaultValue")) this.#values[key] = JSON.parse(JSON.stringify(field.defaultValue));
|
|
729
|
-
break;
|
|
730
|
-
}
|
|
731
|
-
break;
|
|
732
|
-
}
|
|
733
|
-
case "clearCaches":
|
|
734
|
-
await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
|
|
735
|
-
break;
|
|
736
|
-
case "reset":
|
|
737
|
-
if (this.#definition.resetPaths) {
|
|
738
|
-
for (const path of this.#definition.resetPaths) await this.#send("delete", path);
|
|
739
|
-
} else await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
|
|
740
|
-
this.#arrays.clear();
|
|
741
|
-
this.#warnings = {};
|
|
742
|
-
for (const field of this.#definition.fields) {
|
|
743
|
-
delete this.#values[field.key];
|
|
744
|
-
if (Object.prototype.hasOwnProperty.call(field, "defaultValue")) this.#values[field.key] = JSON.parse(JSON.stringify(field.defaultValue));
|
|
745
|
-
}
|
|
746
|
-
break;
|
|
747
|
-
}
|
|
748
|
-
this.#notify({ kind: "success", operation, module: this.#module, key });
|
|
749
|
-
} catch (error) {
|
|
750
|
-
this.#notify({ kind: "error", operation, module: this.#module, key, message: error.message });
|
|
751
|
-
throw error;
|
|
752
|
-
} finally {
|
|
753
|
-
this.#saving = false;
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
|
|
758
|
-
/**
|
|
759
|
-
* 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
|
|
760
|
-
* Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
|
|
761
|
-
*/
|
|
762
|
-
class Navigation extends EventTarget {
|
|
763
|
-
#container;
|
|
764
|
-
#home;
|
|
765
|
-
#create;
|
|
766
|
-
#window;
|
|
767
|
-
#key = null;
|
|
768
|
-
#view;
|
|
769
|
-
#retiring;
|
|
770
|
-
#controller;
|
|
771
|
-
#animation;
|
|
772
|
-
#scroll = new WeakMap();
|
|
773
|
-
#onHistory = () => this.#route();
|
|
774
|
-
#onPageShow = event => {
|
|
775
|
-
if (event.persisted) this.#route(true);
|
|
776
|
-
};
|
|
777
|
-
|
|
778
|
-
/**
|
|
779
|
-
* 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。
|
|
780
|
-
* Retain the home view and create details on demand; signal cancels async work after departure.
|
|
781
|
-
* @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.
|
|
782
|
-
* @param {HTMLElement} home 已创建的主页节点 / Existing home view.
|
|
783
|
-
* @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.
|
|
784
|
-
*/
|
|
785
|
-
constructor(container, home, create) {
|
|
786
|
-
super();
|
|
787
|
-
this.#container = container;
|
|
788
|
-
this.#home = home;
|
|
789
|
-
this.#create = create;
|
|
790
|
-
this.#window = container.ownerDocument.defaultView;
|
|
791
|
-
container.replaceChildren(home);
|
|
792
|
-
this.#window.addEventListener("popstate", this.#onHistory);
|
|
793
|
-
this.#window.addEventListener("hashchange", this.#onHistory);
|
|
794
|
-
this.#window.addEventListener("pageshow", this.#onPageShow);
|
|
795
|
-
this.#route();
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
/**
|
|
799
|
-
* 当前子页键;空字符串表示主页。
|
|
800
|
-
* Current detail key; empty means home.
|
|
801
|
-
*/
|
|
802
|
-
get current() {
|
|
803
|
-
return this.#key;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
/**
|
|
807
|
-
* 是否可以返回上一级或先前文档。
|
|
808
|
-
* Whether a parent view or previous document is available.
|
|
809
|
-
*/
|
|
810
|
-
get canGoBack() {
|
|
811
|
-
return Boolean(this.#key) || this.#window.history.length > 1;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
/**
|
|
815
|
-
* 加入子页历史;使用文档自身 URL,避免 srcdoc 按宿主 base URL 跳转。
|
|
816
|
-
* Push a detail using the document URL, avoiding srcdoc navigation against the host base URL.
|
|
817
|
-
* @param {string} key 子页键 / Detail key.
|
|
818
|
-
* @returns {void} 无返回值 / No return value.
|
|
819
|
-
*/
|
|
820
|
-
open(key) {
|
|
821
|
-
if (key === this.#key) return;
|
|
822
|
-
const url = new URL(this.#window.location.href);
|
|
823
|
-
url.hash = encodeURIComponent(key);
|
|
824
|
-
this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
|
|
825
|
-
this.#route();
|
|
826
|
-
}
|
|
827
|
-
|
|
828
|
-
/**
|
|
829
|
-
* 沿浏览器联合历史返回,根页可退回宿主或上个文档。
|
|
830
|
-
* Go back through joint history, including a host or previous document from home.
|
|
831
|
-
* @returns {void} 无返回值 / No return value.
|
|
832
|
-
*/
|
|
833
|
-
back() {
|
|
834
|
-
if (this.canGoBack) this.#window.history.back();
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
/**
|
|
838
|
-
* 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
|
|
839
|
-
* Resolve the URL and coordinate transitions, cancellation and release after animation.
|
|
840
|
-
* @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.
|
|
841
|
-
* @returns {void} 无返回值 / No return value.
|
|
842
|
-
*/
|
|
843
|
-
#route(reload = false) {
|
|
844
|
-
const url = new URL(this.#window.location.href);
|
|
845
|
-
let key;
|
|
846
|
-
try {
|
|
847
|
-
key = decodeURIComponent(url.hash.slice(1));
|
|
848
|
-
} catch (error) {
|
|
849
|
-
if (!(error instanceof URIError)) throw error;
|
|
850
|
-
key = "";
|
|
851
|
-
}
|
|
852
|
-
if (!reload && key === this.#key) return;
|
|
853
|
-
this.#controller?.abort();
|
|
854
|
-
this.#controller = new AbortController();
|
|
855
|
-
const next = key ? this.#create(key, this.#controller.signal) : undefined;
|
|
856
|
-
if (!next) key = "";
|
|
857
|
-
const history = this.#window.history;
|
|
858
|
-
// 直接打开子页时建立一次主页历史;刷新不重复堆叠。
|
|
859
|
-
// Seed home history once for direct details, without stacking entries on reload.
|
|
860
|
-
if (url.hash && history.state?.preferencePanesRoute !== key) {
|
|
861
|
-
url.hash = "";
|
|
862
|
-
history.replaceState({ ...history.state, preferencePanesRoute: "" }, "", url.href);
|
|
863
|
-
if (key) {
|
|
864
|
-
url.hash = encodeURIComponent(key);
|
|
865
|
-
history.pushState({ ...history.state, preferencePanesRoute: key }, "", url.href);
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
const previous = this.#view;
|
|
869
|
-
const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
|
|
870
|
-
this.#animation?.cancel();
|
|
871
|
-
this.#retiring?.remove();
|
|
872
|
-
this.#retiring = previous;
|
|
873
|
-
if (previous) {
|
|
874
|
-
this.#scroll.set(previous, previous.scrollTop);
|
|
875
|
-
previous.inert = true;
|
|
876
|
-
}
|
|
877
|
-
this.#key = key;
|
|
878
|
-
this.#view = next;
|
|
879
|
-
this.#home.inert = Boolean(next);
|
|
880
|
-
if (next) {
|
|
881
|
-
next.inert = false;
|
|
882
|
-
this.#container.append(next);
|
|
883
|
-
next.scrollTop = this.#scroll.get(next) ?? 0;
|
|
814
|
+
const abort = () => controller.abort();
|
|
815
|
+
if (action === "get") {
|
|
816
|
+
if (this.#session.signal.aborted) abort();
|
|
817
|
+
this.#session.signal.addEventListener("abort", abort, { once: true });
|
|
884
818
|
}
|
|
885
|
-
const
|
|
886
|
-
|
|
887
|
-
const
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
};
|
|
819
|
+
const timer = setTimeout(abort, this.#timeout);
|
|
820
|
+
try {
|
|
821
|
+
const response = await this.#request(`/api/${action}`, {
|
|
822
|
+
method: "POST",
|
|
823
|
+
credentials: "omit",
|
|
824
|
+
cache: "no-store",
|
|
825
|
+
signal: controller.signal,
|
|
826
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
827
|
+
body: new URLSearchParams([[path, action === "set" ? JSON.stringify(value) : ""]]).toString(),
|
|
828
|
+
});
|
|
829
|
+
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
830
|
+
return response;
|
|
831
|
+
} finally {
|
|
832
|
+
clearTimeout(timer);
|
|
833
|
+
this.#session.signal.removeEventListener("abort", abort);
|
|
896
834
|
}
|
|
897
|
-
this.dispatchEvent(new Event("change"));
|
|
898
835
|
}
|
|
899
836
|
|
|
900
837
|
/**
|
|
901
|
-
*
|
|
902
|
-
*
|
|
903
|
-
* @
|
|
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.
|
|
904
845
|
*/
|
|
905
|
-
|
|
906
|
-
this.#
|
|
907
|
-
this.#
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
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;
|
|
849
|
+
try {
|
|
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;
|
|
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;
|
|
902
|
+
}
|
|
914
903
|
}
|
|
915
904
|
}
|
|
916
905
|
|
|
@@ -931,20 +920,24 @@ function warningMessage(warning) {
|
|
|
931
920
|
}
|
|
932
921
|
|
|
933
922
|
/**
|
|
934
|
-
*
|
|
935
|
-
* Manage the module form,
|
|
923
|
+
* 管理模块表单、操作队列和设置会话。
|
|
924
|
+
* Manage the module form, operation queue and settings session.
|
|
936
925
|
*/
|
|
937
926
|
class PreferencesPanel {
|
|
938
927
|
#release;
|
|
928
|
+
#view;
|
|
929
|
+
#state;
|
|
930
|
+
#perform;
|
|
939
931
|
|
|
940
932
|
/**
|
|
941
933
|
* 挂载 BoxJS 定义对应的模块表单。
|
|
942
934
|
* Mount the module form described by a BoxJS definition.
|
|
943
935
|
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
944
936
|
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
937
|
+
* @param {import("./page.mjs").PageController} page 内部页面控制器 / Internal page controller.
|
|
945
938
|
*/
|
|
946
|
-
constructor(root, definition) {
|
|
947
|
-
this.#release = this.#mount(root, definition);
|
|
939
|
+
constructor(root, definition, page) {
|
|
940
|
+
this.#release = this.#mount(root, definition, page);
|
|
948
941
|
}
|
|
949
942
|
|
|
950
943
|
/**
|
|
@@ -952,13 +945,13 @@ class PreferencesPanel {
|
|
|
952
945
|
* Build panel DOM, interactions, and session, then return its release operation.
|
|
953
946
|
* @param {HTMLElement} root 包内挂载元素 / Internal mount element.
|
|
954
947
|
* @param {import("../index.js").ModuleDefinition} definition 已规范化字段定义 / Normalized field definition.
|
|
948
|
+
* @param {import("./page.mjs").PageController} page 内部页面控制器 / Internal page controller.
|
|
955
949
|
* @returns {() => void} 释放操作 / Release operation.
|
|
956
950
|
*/
|
|
957
|
-
#mount(root, definition) {
|
|
951
|
+
#mount(root, definition, page) {
|
|
958
952
|
const title = definition.metadata?.name ?? definition.module;
|
|
959
953
|
const document = root.ownerDocument;
|
|
960
954
|
const window = document.defaultView;
|
|
961
|
-
const frame = window.frameElement?.dataset.preferencePanes ? window.frameElement : undefined;
|
|
962
955
|
const shell = element("div", "pp-panel");
|
|
963
956
|
shell.dataset.module = definition.module;
|
|
964
957
|
const handlers = new Map();
|
|
@@ -969,50 +962,25 @@ class PreferencesPanel {
|
|
|
969
962
|
{ id: "reset", label: "重置设置", destructive: true },
|
|
970
963
|
];
|
|
971
964
|
const viewport = element("div", "pp-viewport");
|
|
972
|
-
|
|
973
|
-
if (frame) shell.append(viewport);
|
|
974
|
-
else {
|
|
975
|
-
const header = element("header", "pp-header");
|
|
976
|
-
back = element("button", "pp-back", "‹");
|
|
977
|
-
back.setAttribute("aria-label", "返回");
|
|
978
|
-
back.type = "button";
|
|
979
|
-
heading = element("h1", "pp-title", title);
|
|
980
|
-
menu = new ActionMenu(id => runAction(id));
|
|
981
|
-
const trailing = element("span", "pp-nav-spacer");
|
|
982
|
-
trailing.append(menu.element);
|
|
983
|
-
header.append(back, heading, trailing);
|
|
984
|
-
shell.append(header, viewport);
|
|
985
|
-
}
|
|
986
|
-
let toast;
|
|
965
|
+
shell.append(viewport);
|
|
987
966
|
root.append(shell);
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
);
|
|
998
|
-
else {
|
|
999
|
-
heading.textContent = currentTitle;
|
|
1000
|
-
back.disabled = !canGoBack;
|
|
1001
|
-
menu.update(actions, saving);
|
|
1002
|
-
}
|
|
967
|
+
const editors = new Map();
|
|
968
|
+
this.#view = key => {
|
|
969
|
+
const editor = editors.get(key);
|
|
970
|
+
editor?.load?.();
|
|
971
|
+
return editor?.node ?? root;
|
|
972
|
+
};
|
|
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);
|
|
1003
976
|
};
|
|
1004
|
-
const
|
|
1005
|
-
if (!
|
|
977
|
+
const publishNavigation = () => {
|
|
978
|
+
if (!destroyed) page.update();
|
|
1006
979
|
};
|
|
1007
|
-
|
|
1008
|
-
let timer,
|
|
1009
|
-
navigation,
|
|
1010
|
-
generation = 0,
|
|
980
|
+
let generation = 0,
|
|
1011
981
|
active = null,
|
|
1012
982
|
saving = false,
|
|
1013
|
-
destroyed = false
|
|
1014
|
-
currentTitle = title,
|
|
1015
|
-
canGoBack = window.history.length > 1;
|
|
983
|
+
destroyed = false;
|
|
1016
984
|
/**
|
|
1017
985
|
* 展示短暂通知,不刷新设置数据。
|
|
1018
986
|
* Display a transient notification without refreshing settings.
|
|
@@ -1039,26 +1007,12 @@ class PreferencesPanel {
|
|
|
1039
1007
|
message = "修改成功";
|
|
1040
1008
|
break;
|
|
1041
1009
|
}
|
|
1042
|
-
|
|
1043
|
-
// A host-owned notice creates no web Toast and starts no local timer.
|
|
1044
|
-
if (frame && !frame.dispatchEvent(new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:notice", { cancelable: true, detail: { kind: event.kind, message } }))) return;
|
|
1045
|
-
if (!toast) {
|
|
1046
|
-
toast = element("div", "pp-toast");
|
|
1047
|
-
toast.setAttribute("role", "status");
|
|
1048
|
-
shell.append(toast);
|
|
1049
|
-
}
|
|
1050
|
-
toast.textContent = message;
|
|
1051
|
-
toast.dataset.kind = event.kind;
|
|
1052
|
-
toast.hidden = false;
|
|
1053
|
-
clearTimeout(timer);
|
|
1054
|
-
timer = setTimeout(() => {
|
|
1055
|
-
toast.hidden = true;
|
|
1056
|
-
}, 2400);
|
|
1010
|
+
page.notice({ kind: event.kind, message });
|
|
1057
1011
|
};
|
|
1058
1012
|
const client = new PreferencesClient({ definition, notify });
|
|
1059
1013
|
/**
|
|
1060
|
-
*
|
|
1061
|
-
*
|
|
1014
|
+
* 菜单操作统一处理异步错误,包含宿主确认框错误。
|
|
1015
|
+
* Handle asynchronous action errors, including host-dialog errors.
|
|
1062
1016
|
* @param {string} id 操作标识 / Action identifier.
|
|
1063
1017
|
* @returns {Promise<void>} 操作已处理 / Action handled.
|
|
1064
1018
|
*/
|
|
@@ -1078,8 +1032,6 @@ class PreferencesPanel {
|
|
|
1078
1032
|
async function open(module) {
|
|
1079
1033
|
const version = ++generation;
|
|
1080
1034
|
active = module;
|
|
1081
|
-
currentTitle = module;
|
|
1082
|
-
canGoBack = window.history.length > 1;
|
|
1083
1035
|
publishNavigation();
|
|
1084
1036
|
viewport.replaceChildren(statusView("读取设置…"));
|
|
1085
1037
|
try {
|
|
@@ -1098,7 +1050,6 @@ class PreferencesPanel {
|
|
|
1098
1050
|
*/
|
|
1099
1051
|
function controls() {
|
|
1100
1052
|
const { definition, values } = client.snapshot();
|
|
1101
|
-
currentTitle = definition.metadata?.name || active;
|
|
1102
1053
|
const view = element("section", "pp-fields");
|
|
1103
1054
|
/**
|
|
1104
1055
|
* 挂载后执行的多行高度更新
|
|
@@ -1106,23 +1057,12 @@ class PreferencesPanel {
|
|
|
1106
1057
|
* @type {Array<() => void>}
|
|
1107
1058
|
*/
|
|
1108
1059
|
const growingInputs = [];
|
|
1109
|
-
|
|
1060
|
+
editors.clear();
|
|
1110
1061
|
const summaries = [];
|
|
1111
1062
|
const warningRefreshers = [];
|
|
1112
1063
|
const groups = new Map();
|
|
1113
1064
|
let queue = Promise.resolve(),
|
|
1114
1065
|
pendingWrites = 0;
|
|
1115
|
-
/**
|
|
1116
|
-
* 导航组件处理页面切换,表单只更新当前标题与返回按钮。
|
|
1117
|
-
* Let navigation own transitions; the form only updates the title and back button.
|
|
1118
|
-
* @returns {void} 无返回值 / No return value.
|
|
1119
|
-
*/
|
|
1120
|
-
const updateNavigation = () => {
|
|
1121
|
-
const editor = editors.get(navigation.current);
|
|
1122
|
-
currentTitle = editor?.title ?? definition.metadata?.name ?? active;
|
|
1123
|
-
canGoBack = !saving && navigation.canGoBack;
|
|
1124
|
-
publishNavigation();
|
|
1125
|
-
};
|
|
1126
1066
|
/**
|
|
1127
1067
|
* 串行执行模块操作,保持输入可编辑。
|
|
1128
1068
|
* Serialize module actions while keeping inputs editable.
|
|
@@ -1134,7 +1074,6 @@ class PreferencesPanel {
|
|
|
1134
1074
|
function perform(action, success, failure = () => {}) {
|
|
1135
1075
|
pendingWrites++;
|
|
1136
1076
|
saving = true;
|
|
1137
|
-
canGoBack = false;
|
|
1138
1077
|
publishNavigation();
|
|
1139
1078
|
queue = queue
|
|
1140
1079
|
.then(action)
|
|
@@ -1149,8 +1088,6 @@ class PreferencesPanel {
|
|
|
1149
1088
|
.finally(() => {
|
|
1150
1089
|
pendingWrites--;
|
|
1151
1090
|
saving = pendingWrites > 0;
|
|
1152
|
-
if (destroyed && !saving) client.leave();
|
|
1153
|
-
canGoBack = !saving && navigation.canGoBack;
|
|
1154
1091
|
publishNavigation();
|
|
1155
1092
|
});
|
|
1156
1093
|
return queue;
|
|
@@ -1204,7 +1141,8 @@ class PreferencesPanel {
|
|
|
1204
1141
|
link.href = value;
|
|
1205
1142
|
};
|
|
1206
1143
|
link.addEventListener("click", event => {
|
|
1207
|
-
|
|
1144
|
+
event.preventDefault();
|
|
1145
|
+
page.openURL(link.href);
|
|
1208
1146
|
});
|
|
1209
1147
|
row.append(link);
|
|
1210
1148
|
row.addEventListener("click", event => {
|
|
@@ -1228,12 +1166,12 @@ class PreferencesPanel {
|
|
|
1228
1166
|
break;
|
|
1229
1167
|
}
|
|
1230
1168
|
case "checkboxes": {
|
|
1231
|
-
const
|
|
1232
|
-
if (field.description)
|
|
1169
|
+
const editor = element("section", "pp-panel pp-detail pp-choice-page");
|
|
1170
|
+
if (field.description) editor.append(element("p", "pp-description", field.description));
|
|
1233
1171
|
const choices = element("div", "pp-rows");
|
|
1234
|
-
|
|
1172
|
+
editor.append(choices);
|
|
1235
1173
|
inputContainer = choices;
|
|
1236
|
-
editors.set(field.key, { node:
|
|
1174
|
+
editors.set(field.key, { node: editor, title: match?.[2] ?? field.name });
|
|
1237
1175
|
const summary = element("span", "pp-summary");
|
|
1238
1176
|
const link = element("button", "pp-choice-link");
|
|
1239
1177
|
link.type = "button";
|
|
@@ -1250,7 +1188,7 @@ class PreferencesPanel {
|
|
|
1250
1188
|
};
|
|
1251
1189
|
summaries.push(refresh);
|
|
1252
1190
|
refresh();
|
|
1253
|
-
link.onclick = () =>
|
|
1191
|
+
link.onclick = () => page.open(field.key);
|
|
1254
1192
|
row.addEventListener("click", event => {
|
|
1255
1193
|
if (!link.contains(event.target)) link.click();
|
|
1256
1194
|
});
|
|
@@ -1367,12 +1305,12 @@ class PreferencesPanel {
|
|
|
1367
1305
|
if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
|
|
1368
1306
|
groups.get(group).append(row);
|
|
1369
1307
|
}
|
|
1370
|
-
const settingsPage = element("section", "pp-settings-page");
|
|
1308
|
+
const settingsPage = element("section", "pp-panel pp-detail pp-settings-page");
|
|
1371
1309
|
const settingsOutput = element("pre", "pp-cache");
|
|
1372
1310
|
settingsOutput.setAttribute("aria-label", "Settings 内容");
|
|
1373
1311
|
settingsPage.append(settingsOutput);
|
|
1374
|
-
|
|
1375
|
-
|
|
1312
|
+
const loadSettings = () => {
|
|
1313
|
+
editors.get("$settings").load = undefined;
|
|
1376
1314
|
if (saving) return;
|
|
1377
1315
|
let value;
|
|
1378
1316
|
return perform(
|
|
@@ -1386,17 +1324,24 @@ class PreferencesPanel {
|
|
|
1386
1324
|
},
|
|
1387
1325
|
() => {
|
|
1388
1326
|
settingsOutput.textContent = value === undefined ? "暂无设置" : JSON.stringify(value, null, 2);
|
|
1389
|
-
|
|
1327
|
+
},
|
|
1328
|
+
() => {
|
|
1329
|
+
settingsOutput.textContent = "读取设置失败";
|
|
1390
1330
|
},
|
|
1391
1331
|
);
|
|
1332
|
+
};
|
|
1333
|
+
editors.set("$settings", { node: settingsPage, title: "设置", load: loadSettings });
|
|
1334
|
+
handlers.set("viewSettings", () => {
|
|
1335
|
+
loadSettings();
|
|
1336
|
+
page.open("$settings");
|
|
1392
1337
|
});
|
|
1393
|
-
const cachePage = element("section", "pp-cache-page");
|
|
1338
|
+
const cachePage = element("section", "pp-panel pp-detail pp-cache-page");
|
|
1394
1339
|
const output = element("pre", "pp-cache");
|
|
1395
|
-
output.textContent = "
|
|
1340
|
+
output.textContent = "正在读取缓存…";
|
|
1396
1341
|
output.setAttribute("aria-label", "Caches 内容");
|
|
1397
1342
|
cachePage.append(output);
|
|
1398
|
-
|
|
1399
|
-
|
|
1343
|
+
const loadCaches = () => {
|
|
1344
|
+
editors.get("$caches").load = undefined;
|
|
1400
1345
|
if (saving) return;
|
|
1401
1346
|
let value;
|
|
1402
1347
|
return perform(
|
|
@@ -1410,15 +1355,22 @@ class PreferencesPanel {
|
|
|
1410
1355
|
},
|
|
1411
1356
|
() => {
|
|
1412
1357
|
output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
|
|
1413
|
-
|
|
1358
|
+
},
|
|
1359
|
+
() => {
|
|
1360
|
+
output.textContent = "读取缓存失败";
|
|
1414
1361
|
},
|
|
1415
1362
|
);
|
|
1363
|
+
};
|
|
1364
|
+
editors.set("$caches", { node: cachePage, title: "缓存", load: loadCaches });
|
|
1365
|
+
handlers.set("viewCaches", () => {
|
|
1366
|
+
loadCaches();
|
|
1367
|
+
page.open("$caches");
|
|
1416
1368
|
});
|
|
1417
1369
|
handlers.set("clearCaches", async () => {
|
|
1418
1370
|
if (saving) return;
|
|
1419
1371
|
const shared = definition.cachePath && definition.cachePath !== `@${definition.storageKey}.${active}.Caches`;
|
|
1420
1372
|
const message = shared ? `清空共享缓存 ${definition.cachePath}?使用该缓存的其它模块也会受影响。` : `清空 ${active} 的全部 Caches?`;
|
|
1421
|
-
if (!(await
|
|
1373
|
+
if (!(await page.confirm(message)) || destroyed || saving) return;
|
|
1422
1374
|
return perform(
|
|
1423
1375
|
() => client.clearCaches(),
|
|
1424
1376
|
() => {
|
|
@@ -1429,42 +1381,57 @@ class PreferencesPanel {
|
|
|
1429
1381
|
handlers.set("reset", async () => {
|
|
1430
1382
|
if (saving) return;
|
|
1431
1383
|
const message = definition.resetPaths ? `重置 ${definition.metadata?.name ?? active} 的设置?只删除本页面的设置,保留共享存储中的其它设置和缓存。` : `重置 ${active} 的设置?这将删除该模块的 Settings、Caches 和其它持久化数据。`;
|
|
1432
|
-
if (!(await
|
|
1384
|
+
if (!(await page.confirm(message)) || destroyed || saving) return;
|
|
1433
1385
|
return perform(() => client.reset(), controls);
|
|
1434
1386
|
});
|
|
1435
|
-
|
|
1436
|
-
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
1437
|
-
navigation.addEventListener("change", updateNavigation);
|
|
1387
|
+
viewport.replaceChildren(view);
|
|
1438
1388
|
for (const grow of growingInputs) grow();
|
|
1439
|
-
|
|
1389
|
+
page.refresh();
|
|
1440
1390
|
}
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
* @returns {void} 无返回值 / No return value.
|
|
1445
|
-
*/
|
|
1446
|
-
if (back)
|
|
1447
|
-
back.onclick = () => {
|
|
1448
|
-
if (saving) return;
|
|
1449
|
-
if (navigation) navigation.back();
|
|
1450
|
-
else window.history.back();
|
|
1451
|
-
};
|
|
1452
|
-
open(definition.module);
|
|
1391
|
+
queueMicrotask(() => {
|
|
1392
|
+
if (!destroyed) open(definition.module);
|
|
1393
|
+
});
|
|
1453
1394
|
return () => {
|
|
1454
1395
|
destroyed = true;
|
|
1455
|
-
menu?.destroy();
|
|
1456
|
-
frame?.removeEventListener("preferencepanes:action", onAction);
|
|
1457
|
-
navigation?.destroy();
|
|
1458
1396
|
generation++;
|
|
1459
|
-
|
|
1460
|
-
clearTimeout(timer);
|
|
1397
|
+
client.leave();
|
|
1461
1398
|
shell.remove();
|
|
1462
1399
|
};
|
|
1463
1400
|
}
|
|
1464
1401
|
|
|
1465
1402
|
/**
|
|
1466
|
-
*
|
|
1467
|
-
*
|
|
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.
|
|
1468
1435
|
* @returns {void} 无返回值 / No return value.
|
|
1469
1436
|
*/
|
|
1470
1437
|
destroy() {
|
|
@@ -1472,124 +1439,403 @@ class PreferencesPanel {
|
|
|
1472
1439
|
}
|
|
1473
1440
|
}
|
|
1474
1441
|
|
|
1475
|
-
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 height: 100vh;\n overflow: hidden;\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-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 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-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 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-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\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 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 min-height: calc(100vh - var(--pp-host-height));\n background: inherit;\n}\n[data-preference-panes-module-page],\n[data-preference-panes-link-page] {\n position: fixed;\n inset: var(--pp-host-height) 0 0;\n background: inherit;\n z-index: 1;\n box-shadow: -8px 0 24px #00000014;\n}\n[data-preference-panes-module-page] iframe,\n[data-preference-panes-link-page] iframe {\n display: block;\n border: 0;\n width: 100%;\n height: 100%;\n background: inherit;\n}\n[data-module-message] {\n position: absolute;\n inset: 0;\n margin: 0;\n padding: 20px;\n background: inherit;\n}\n[data-module-message][hidden] {\n display: none;\n}\n.pp-host-header {\n position: sticky;\n top: 0;\n z-index: 2;\n display: grid;\n grid-template-columns: 44px minmax(0, 1fr) 44px;\n align-items: center;\n min-height: 44px;\n padding: env(safe-area-inset-top) max(12px, env(safe-area-inset-right)) 0 max(12px, env(safe-area-inset-left));\n border-bottom: 1px solid var(--pp-border, #8884);\n background: var(--pp-surface, Canvas);\n color: var(--pp-accent, Highlight);\n font-size: 17px;\n}\n.pp-host-title {\n text-align: center;\n color: var(--pp-text, CanvasText);\n font-weight: 600;\n overflow: hidden;\n text-overflow: ellipsis;\n white-space: nowrap;\n}\n.pp-host-back {\n border: 0;\n background: none;\n color: inherit;\n width: 44px;\n height: 44px;\n padding: 8px;\n font:\n 32px / 1 -apple-system,\n sans-serif;\n cursor: pointer;\n}\n.pp-host-back[hidden] {\n visibility: hidden;\n display: block;\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: 24px 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-logo {\n display: block;\n width: 64px;\n height: 64px;\n margin: 0 auto 24px;\n object-fit: contain;\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}\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";
|
|
1476
|
-
|
|
1477
|
-
const selector = "style[data-preference-panes-defaults]";
|
|
1478
|
-
const stylesheetSelector = "link[data-preference-panes-stylesheet]";
|
|
1479
|
-
|
|
1480
1442
|
/**
|
|
1481
|
-
*
|
|
1482
|
-
*
|
|
1483
|
-
* @param {
|
|
1484
|
-
* @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.
|
|
1485
1447
|
*/
|
|
1486
|
-
function
|
|
1487
|
-
const
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
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
|
+
};
|
|
1494
1550
|
}
|
|
1495
1551
|
|
|
1496
1552
|
/**
|
|
1497
|
-
*
|
|
1498
|
-
*
|
|
1553
|
+
* 一个文档共享宿主、路由和当前模块会话,字段子页不重新读取配置。
|
|
1554
|
+
* Share one host, router and module session within a document without reloading config for field views.
|
|
1499
1555
|
*/
|
|
1500
|
-
class
|
|
1501
|
-
#
|
|
1502
|
-
#root;
|
|
1503
|
-
#base;
|
|
1504
|
-
#ownsBase;
|
|
1505
|
-
#previousTitle;
|
|
1506
|
-
#previousTheme;
|
|
1507
|
-
#systemTheme;
|
|
1508
|
-
#previousKeyboard;
|
|
1556
|
+
class PageController {
|
|
1557
|
+
#navigation;
|
|
1509
1558
|
#host;
|
|
1510
|
-
#
|
|
1511
|
-
#
|
|
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
|
+
}
|
|
1512
1589
|
|
|
1513
1590
|
/**
|
|
1514
|
-
*
|
|
1515
|
-
*
|
|
1516
|
-
* @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.
|
|
1517
1596
|
*/
|
|
1518
|
-
|
|
1519
|
-
const
|
|
1520
|
-
const
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
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);
|
|
1530
1646
|
}
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
this.#
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
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;
|
|
1544
1662
|
}
|
|
1545
|
-
|
|
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) {
|
|
1546
1732
|
try {
|
|
1547
|
-
this.#
|
|
1548
|
-
this.#panel = new PreferencesPanel(this.#root, definition);
|
|
1733
|
+
Promise.resolve(this.#host.openURL(url, target)).catch(error => this.notice({ kind: "error", message: error.message }));
|
|
1549
1734
|
} catch (error) {
|
|
1550
|
-
this.
|
|
1551
|
-
throw error;
|
|
1735
|
+
this.notice({ kind: "error", message: error.message });
|
|
1552
1736
|
}
|
|
1553
1737
|
}
|
|
1554
1738
|
|
|
1555
1739
|
/**
|
|
1556
|
-
*
|
|
1557
|
-
*
|
|
1558
|
-
* @returns {void} 已同步主题与键盘避让 / Theme and keyboard clearance synchronized.
|
|
1740
|
+
* 共用首页与表单导航。
|
|
1741
|
+
* Navigation shared by the homepage and forms.
|
|
1559
1742
|
*/
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
if (this.#host) document.documentElement.style.setProperty("--pp-keyboard-height", this.#host.style.getPropertyValue("--pp-keyboard-height"));
|
|
1564
|
-
};
|
|
1743
|
+
get navigation() {
|
|
1744
|
+
return this.#navigation;
|
|
1745
|
+
}
|
|
1565
1746
|
|
|
1566
1747
|
/**
|
|
1567
|
-
*
|
|
1568
|
-
*
|
|
1748
|
+
* 结束页面能力和会话;表单写入队列自行收尾。
|
|
1749
|
+
* End page capabilities and sessions while forms finish their write queues.
|
|
1569
1750
|
* @returns {void} 无返回值 / No return value.
|
|
1570
1751
|
*/
|
|
1571
1752
|
destroy() {
|
|
1572
|
-
this.#
|
|
1573
|
-
this.#
|
|
1574
|
-
this.#
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
if (this.#previousTheme === undefined) delete document.documentElement.dataset.theme;
|
|
1580
|
-
else document.documentElement.dataset.theme = this.#previousTheme;
|
|
1581
|
-
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();
|
|
1582
1760
|
}
|
|
1583
1761
|
}
|
|
1584
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
|
+
|
|
1585
1784
|
/**
|
|
1586
|
-
* 使用原始 BoxJS JSON
|
|
1587
|
-
* 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.
|
|
1588
1787
|
* @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
|
|
1589
1788
|
* @returns {import("./index.js").MountedPreferences} 模块视图 / Module view.
|
|
1590
1789
|
*/
|
|
1591
1790
|
function mount(boxjs) {
|
|
1592
|
-
|
|
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 };
|
|
1593
1839
|
}
|
|
1594
1840
|
|
|
1595
1841
|
export { mount };
|