@nsnanocat/preference-panes 0.8.0 → 0.9.0
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/README.md +12 -0
- package/dist/api.js +1 -1
- package/dist/module/app.mjs +284 -57
- package/dist/module/index.html +1 -1
- package/dist/module/navigation.mjs +223 -3
- package/dist/preference-panes.mjs +284 -57
- package/package.json +2 -1
- package/src/browser/ActionMenu.mjs +115 -0
- package/src/browser/ModuleFrame.mjs +19 -2
- package/src/browser/ModuleStatus.mjs +86 -0
- package/src/browser/Navigation.d.mts +127 -1
- package/src/browser/Navigation.mjs +2 -0
- package/src/browser/components.mjs +38 -0
- package/src/browser/index.mjs +26 -2
- package/src/browser/panel.css +23 -143
- package/src/browser/panel.mjs +105 -55
- package/src/browser/vendor/b-style.min.css +7 -0
- package/src/browser/vendor/message-settings-BD3N1lqQ.css +1 -0
- package/src/browser/vendor/messageSettingsLayout-ltzQ1gMi.css +1 -0
- package/src/browser/vendor/provenance.json +31 -0
- package/src/browser/vendor/theme.min.css +1198 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsnanocat/preference-panes",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "Shared settings API runtime for JavaScript proxy modules",
|
|
5
5
|
"author": "VirgilClyne <Virgil@nanocat.me>",
|
|
6
6
|
"homepage": "https://NSNanoCat.github.io/preference-panes",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"type": "module",
|
|
19
19
|
"main": "src/index.mjs",
|
|
20
20
|
"exports": {
|
|
21
|
+
"./styles/*": "./src/browser/vendor/*",
|
|
21
22
|
".": {
|
|
22
23
|
"types": "./src/index.d.ts",
|
|
23
24
|
"import": "./src/index.mjs"
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。
|
|
3
|
+
* Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.
|
|
4
|
+
*/
|
|
5
|
+
export class ActionMenu {
|
|
6
|
+
#button;
|
|
7
|
+
#popup;
|
|
8
|
+
#backdrop;
|
|
9
|
+
#select;
|
|
10
|
+
#document;
|
|
11
|
+
#key = event => {
|
|
12
|
+
if (event.key === "Escape" && !this.#popup.hidden) {
|
|
13
|
+
event.preventDefault();
|
|
14
|
+
this.close();
|
|
15
|
+
this.#button.focus();
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 创建菜单,操作逻辑由调用方提供。
|
|
21
|
+
* Create a menu whose actions are handled by the caller.
|
|
22
|
+
* @param {(id: string) => void} select 菜单选择回调 / Selection callback.
|
|
23
|
+
*/
|
|
24
|
+
constructor(select) {
|
|
25
|
+
this.#document = document;
|
|
26
|
+
this.#select = select;
|
|
27
|
+
this.element = document.createElement("span");
|
|
28
|
+
const root = this.element.attachShadow({ mode: "open" });
|
|
29
|
+
root.innerHTML = `<style>
|
|
30
|
+
:host{display:inline-flex;position:relative;width:44px;height:44px;color:inherit}
|
|
31
|
+
:host([hidden]),[hidden]{display:none!important}
|
|
32
|
+
button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
33
|
+
button:disabled{opacity:.4;cursor:default}
|
|
34
|
+
button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
|
|
35
|
+
#trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}
|
|
36
|
+
svg{display:block;width:24px;height:24px;fill:currentColor}
|
|
37
|
+
#backdrop{position:fixed;inset:0;z-index:1}
|
|
38
|
+
#items{position:absolute;right:0;top:46px;z-index:2;min-width:160px;padding:6px;background:var(--pp-surface,Canvas);color:var(--pp-text,CanvasText);border:1px solid var(--pp-border,#8884);border-radius:12px;box-shadow:0 8px 28px #0003}
|
|
39
|
+
#items button{display:block;text-align:left;white-space:nowrap;width:100%;padding:11px 14px;border-radius:8px;font:14px/1.4 system-ui,sans-serif}
|
|
40
|
+
#items button:hover{background:#8882}
|
|
41
|
+
#items button[data-danger]{color:#e45656}
|
|
42
|
+
</style><button id="trigger" type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false" aria-controls="items"><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><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单" hidden></button><div id="items" role="menu" hidden></div>`;
|
|
43
|
+
this.#button = root.querySelector("#trigger");
|
|
44
|
+
this.#popup = root.querySelector("#items");
|
|
45
|
+
this.#backdrop = root.querySelector("#backdrop");
|
|
46
|
+
this.#button.onclick = () => {
|
|
47
|
+
const open = this.#popup.hidden;
|
|
48
|
+
this.#popup.hidden = this.#backdrop.hidden = !open;
|
|
49
|
+
this.#button.setAttribute("aria-expanded", String(open));
|
|
50
|
+
if (open) this.#popup.firstElementChild.focus();
|
|
51
|
+
};
|
|
52
|
+
this.#backdrop.onclick = () => this.close();
|
|
53
|
+
this.#popup.onkeydown = event => {
|
|
54
|
+
if (event.key === "Tab") {
|
|
55
|
+
this.close();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const items = [...this.#popup.children];
|
|
59
|
+
const index = items.indexOf(root.activeElement);
|
|
60
|
+
const offsets = { ArrowDown: 1, ArrowUp: -1 };
|
|
61
|
+
if (event.key in offsets) {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
items[(index + offsets[event.key] + items.length) % items.length].focus();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
document.addEventListener("keydown", this.#key);
|
|
67
|
+
this.update([]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 同步可用操作和忙碌状态,不重建菜单触发按钮。
|
|
72
|
+
* Update actions and busy state without replacing the trigger button.
|
|
73
|
+
* @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.
|
|
74
|
+
* @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
|
|
75
|
+
* @returns {void} 无返回值 / No return value.
|
|
76
|
+
*/
|
|
77
|
+
update(items, disabled = false) {
|
|
78
|
+
this.close();
|
|
79
|
+
this.#button.disabled = disabled || items.length === 0;
|
|
80
|
+
this.#popup.replaceChildren(
|
|
81
|
+
...items.map(item => {
|
|
82
|
+
const button = this.#document.createElement("button");
|
|
83
|
+
button.type = "button";
|
|
84
|
+
button.setAttribute("role", "menuitem");
|
|
85
|
+
button.textContent = item.label;
|
|
86
|
+
button.toggleAttribute("data-danger", Boolean(item.destructive));
|
|
87
|
+
button.onclick = () => {
|
|
88
|
+
this.close();
|
|
89
|
+
this.#select(item.id);
|
|
90
|
+
};
|
|
91
|
+
return button;
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 关闭菜单。
|
|
98
|
+
* Close the menu.
|
|
99
|
+
* @returns {void} 无返回值 / No return value.
|
|
100
|
+
*/
|
|
101
|
+
close() {
|
|
102
|
+
this.#popup.hidden = this.#backdrop.hidden = true;
|
|
103
|
+
this.#button.setAttribute("aria-expanded", "false");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 移除监听器与节点。
|
|
108
|
+
* Remove listeners and elements.
|
|
109
|
+
* @returns {void} 无返回值 / No return value.
|
|
110
|
+
*/
|
|
111
|
+
destroy() {
|
|
112
|
+
this.#document.removeEventListener("keydown", this.#key);
|
|
113
|
+
this.element.remove();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -11,9 +11,13 @@ export class ModuleFrame extends EventTarget {
|
|
|
11
11
|
#abort = () => this.destroy();
|
|
12
12
|
#state;
|
|
13
13
|
#change = event => {
|
|
14
|
-
this.#state = event.detail;
|
|
14
|
+
this.#state = { ...event.detail, actions: event.detail.actions ?? [] };
|
|
15
15
|
this.dispatchEvent(new Event("change"));
|
|
16
16
|
};
|
|
17
|
+
#confirmation = event => {
|
|
18
|
+
const request = new CustomEvent("confirm", { cancelable: true, detail: event.detail });
|
|
19
|
+
if (!this.dispatchEvent(request)) event.preventDefault();
|
|
20
|
+
};
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
23
|
* 建立 iframe 与请求输入;调用方挂载 element 后调用 load。
|
|
@@ -30,7 +34,8 @@ export class ModuleFrame extends EventTarget {
|
|
|
30
34
|
this.element.title = `${inputs.module} 设置`;
|
|
31
35
|
this.element.dataset.preferencePanes = JSON.stringify(inputs);
|
|
32
36
|
this.element.addEventListener("preferencepanes:change", this.#change);
|
|
33
|
-
this
|
|
37
|
+
this.element.addEventListener("preferencepanes:confirm", this.#confirmation);
|
|
38
|
+
this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true, actions: [] };
|
|
34
39
|
options.signal?.addEventListener("abort", this.#abort, { once: true });
|
|
35
40
|
}
|
|
36
41
|
|
|
@@ -70,6 +75,17 @@ export class ModuleFrame extends EventTarget {
|
|
|
70
75
|
if (!this.#state.busy && this.#state.canGoBack) this.element.contentWindow.history.back();
|
|
71
76
|
}
|
|
72
77
|
|
|
78
|
+
/**
|
|
79
|
+
* 向模块发送菜单操作,不让宿主访问内部 DOM 或存储客户端。
|
|
80
|
+
* Dispatch a menu action without host access to internal DOM or the storage client.
|
|
81
|
+
* @param {string} id 当前可用操作 / Available action identifier.
|
|
82
|
+
* @returns {void} 无返回值 / No return value.
|
|
83
|
+
*/
|
|
84
|
+
perform(id) {
|
|
85
|
+
if (this.#state.busy || !this.#state.actions.some(action => action.id === id)) throw new Error("Action is not available");
|
|
86
|
+
this.element.dispatchEvent(new CustomEvent("preferencepanes:action", { detail: id }));
|
|
87
|
+
}
|
|
88
|
+
|
|
73
89
|
/**
|
|
74
90
|
* 取消加载与事件订阅;节点保留到 Navigation 的退出动画结束。
|
|
75
91
|
* Cancel loading and subscriptions; Navigation retains the node until its exit animation ends.
|
|
@@ -79,5 +95,6 @@ export class ModuleFrame extends EventTarget {
|
|
|
79
95
|
this.#controller.abort();
|
|
80
96
|
this.#options.signal?.removeEventListener("abort", this.#abort);
|
|
81
97
|
this.element.removeEventListener("preferencepanes:change", this.#change);
|
|
98
|
+
this.element.removeEventListener("preferencepanes:confirm", this.#confirmation);
|
|
82
99
|
}
|
|
83
100
|
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模块入口的固定状态行,只通过 HEAD 探测安装状态和业务版本。
|
|
3
|
+
* Fixed module status row, probing installation and business version with HEAD only.
|
|
4
|
+
*/
|
|
5
|
+
export class ModuleStatus extends EventTarget {
|
|
6
|
+
#element;
|
|
7
|
+
#controller;
|
|
8
|
+
#state = { status: "checking", version: null };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 绑定调用方提供的状态行。
|
|
12
|
+
* Bind a caller-owned status row.
|
|
13
|
+
* @param {HTMLElement} element 状态文字容器 / Status text container.
|
|
14
|
+
*/
|
|
15
|
+
constructor(element) {
|
|
16
|
+
super();
|
|
17
|
+
this.#element = element;
|
|
18
|
+
this.#render("checking");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 当前安装状态与业务版本。
|
|
23
|
+
* Current installation state and business version.
|
|
24
|
+
*/
|
|
25
|
+
get state() {
|
|
26
|
+
return { ...this.#state };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 每次进入重新探测,取消旧请求并忽略其迟到结果。
|
|
31
|
+
* Reprobe on entry, cancelling old requests and ignoring late results.
|
|
32
|
+
* @param {string | URL} url 配置 Mock 地址 / Configuration Mock URL.
|
|
33
|
+
* @returns {Promise<void>} 探测完成 / Probe completion.
|
|
34
|
+
*/
|
|
35
|
+
async check(url) {
|
|
36
|
+
this.#controller?.abort();
|
|
37
|
+
const controller = (this.#controller = new AbortController());
|
|
38
|
+
this.#render("checking");
|
|
39
|
+
const timer = setTimeout(() => controller.abort(), 3500);
|
|
40
|
+
try {
|
|
41
|
+
const response = await fetch(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal });
|
|
42
|
+
if (controller !== this.#controller) return;
|
|
43
|
+
const version = response.headers.get("X-PreferencePanes-Version")?.trim() || null;
|
|
44
|
+
this.#render(response.status === 200 ? "installed" : "missing", version);
|
|
45
|
+
} catch {
|
|
46
|
+
if (controller === this.#controller) this.#render("missing");
|
|
47
|
+
} finally {
|
|
48
|
+
clearTimeout(timer);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 更新状态标签,缺少版本时不伪造版本号。
|
|
54
|
+
* Render the label without inventing a missing version.
|
|
55
|
+
* @param {"checking" | "installed" | "missing"} status 状态 / State.
|
|
56
|
+
* @param {string | null} [version] 业务版本 / Business version.
|
|
57
|
+
* @returns {void} 无返回值 / No return value.
|
|
58
|
+
*/
|
|
59
|
+
#render(status, version = null) {
|
|
60
|
+
this.#state = { status, version: status === "installed" ? version : null };
|
|
61
|
+
switch (status) {
|
|
62
|
+
case "checking":
|
|
63
|
+
this.#element.textContent = "检测中";
|
|
64
|
+
break;
|
|
65
|
+
case "installed":
|
|
66
|
+
this.#element.textContent = version ?? "版本未知";
|
|
67
|
+
break;
|
|
68
|
+
case "missing":
|
|
69
|
+
this.#element.textContent = "未安装";
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
this.#element.dataset.state = status;
|
|
73
|
+
this.#element.title = this.#element.textContent;
|
|
74
|
+
this.dispatchEvent(new Event("change"));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 释放尚未完成的探测。
|
|
79
|
+
* Release pending probes.
|
|
80
|
+
* @returns {void} 无返回值 / No return value.
|
|
81
|
+
*/
|
|
82
|
+
destroy() {
|
|
83
|
+
this.#controller?.abort();
|
|
84
|
+
this.#controller = undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 原始 HTML 的模块 iframe 容器;通过元素传递请求上下文。
|
|
3
3
|
* Iframe container preserving module HTML and carrying context on the element.
|
|
4
|
+
* confirm 事件可 preventDefault 后通过 detail.resolve/reject 完成宿主确认。
|
|
5
|
+
* Prevent default on confirm events and settle host dialogs through detail.resolve/reject.
|
|
4
6
|
*/
|
|
5
7
|
export class ModuleFrame extends EventTarget {
|
|
6
8
|
/**
|
|
@@ -19,7 +21,7 @@ export class ModuleFrame extends EventTarget {
|
|
|
19
21
|
* change 事件对应的导航状态。
|
|
20
22
|
* Navigation state exposed with change events.
|
|
21
23
|
*/
|
|
22
|
-
readonly state: { title: string; module: string; busy: boolean; canGoBack: boolean };
|
|
24
|
+
readonly state: { title: string; module: string; busy: boolean; canGoBack: boolean; actions: MenuAction[] };
|
|
23
25
|
/**
|
|
24
26
|
* 获取原始 HTML。
|
|
25
27
|
* Fetch unmodified HTML.
|
|
@@ -32,6 +34,13 @@ export class ModuleFrame extends EventTarget {
|
|
|
32
34
|
* @returns 无返回值 / No return value.
|
|
33
35
|
*/
|
|
34
36
|
back(): void;
|
|
37
|
+
/**
|
|
38
|
+
* 执行模块提供的菜单操作。
|
|
39
|
+
* Perform a module-provided menu action.
|
|
40
|
+
* @param id 操作标识 / Action identifier.
|
|
41
|
+
* @returns 无返回值 / No return value.
|
|
42
|
+
*/
|
|
43
|
+
perform(id: string): void;
|
|
35
44
|
/**
|
|
36
45
|
* 取消请求与事件订阅。
|
|
37
46
|
* Cancel requests and subscriptions.
|
|
@@ -40,6 +49,123 @@ export class ModuleFrame extends EventTarget {
|
|
|
40
49
|
destroy(): void;
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
/**
|
|
53
|
+
* 宿主确认事件的数据。
|
|
54
|
+
* Host confirmation event detail.
|
|
55
|
+
*/
|
|
56
|
+
export interface ConfirmationRequest {
|
|
57
|
+
/**
|
|
58
|
+
* 确认内容。
|
|
59
|
+
* Confirmation message.
|
|
60
|
+
*/
|
|
61
|
+
message: string;
|
|
62
|
+
/**
|
|
63
|
+
* 返回选择。
|
|
64
|
+
* Return the user's choice.
|
|
65
|
+
* @param confirmed 是否确认 / Whether confirmed.
|
|
66
|
+
* @returns 无返回值 / No return value.
|
|
67
|
+
*/
|
|
68
|
+
resolve(confirmed: boolean): void;
|
|
69
|
+
/**
|
|
70
|
+
* 返回失败。
|
|
71
|
+
* Return a failure.
|
|
72
|
+
* @param error 错误 / Error.
|
|
73
|
+
* @returns 无返回值 / No return value.
|
|
74
|
+
*/
|
|
75
|
+
reject(error: Error): void;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 菜单操作描述。
|
|
80
|
+
* Menu action descriptor.
|
|
81
|
+
*/
|
|
82
|
+
export interface MenuAction {
|
|
83
|
+
/**
|
|
84
|
+
* 操作标识。
|
|
85
|
+
* Action identifier.
|
|
86
|
+
*/
|
|
87
|
+
id: string;
|
|
88
|
+
/**
|
|
89
|
+
* 显示文字。
|
|
90
|
+
* Display text.
|
|
91
|
+
*/
|
|
92
|
+
label: string;
|
|
93
|
+
/**
|
|
94
|
+
* 危险操作样式。
|
|
95
|
+
* Destructive action style.
|
|
96
|
+
*/
|
|
97
|
+
destructive?: boolean;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 共用三点菜单。
|
|
102
|
+
* Shared overflow menu.
|
|
103
|
+
*/
|
|
104
|
+
export class ActionMenu {
|
|
105
|
+
/**
|
|
106
|
+
* 创建菜单。
|
|
107
|
+
* Create a menu.
|
|
108
|
+
* @param select 选择回调 / Selection callback.
|
|
109
|
+
*/
|
|
110
|
+
constructor(select: (id: string) => void);
|
|
111
|
+
/**
|
|
112
|
+
* 菜单节点。
|
|
113
|
+
* Menu element.
|
|
114
|
+
*/
|
|
115
|
+
readonly element: HTMLElement;
|
|
116
|
+
/**
|
|
117
|
+
* 更新操作列表。
|
|
118
|
+
* Update available actions.
|
|
119
|
+
* @param items 操作列表 / Actions.
|
|
120
|
+
* @param disabled 是否忙碌 / Busy state.
|
|
121
|
+
* @returns 无返回值 / No return value.
|
|
122
|
+
*/
|
|
123
|
+
update(items: MenuAction[], disabled?: boolean): void;
|
|
124
|
+
/**
|
|
125
|
+
* 关闭菜单。
|
|
126
|
+
* Close the menu.
|
|
127
|
+
* @returns 无返回值 / No return value.
|
|
128
|
+
*/
|
|
129
|
+
close(): void;
|
|
130
|
+
/**
|
|
131
|
+
* 释放组件。
|
|
132
|
+
* Release the component.
|
|
133
|
+
* @returns 无返回值 / No return value.
|
|
134
|
+
*/
|
|
135
|
+
destroy(): void;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* HEAD 探测的固定模块状态行。
|
|
140
|
+
* Fixed module status row backed by HEAD probes.
|
|
141
|
+
*/
|
|
142
|
+
export class ModuleStatus extends EventTarget {
|
|
143
|
+
/**
|
|
144
|
+
* 绑定状态行。
|
|
145
|
+
* Bind a status row.
|
|
146
|
+
* @param element 状态行节点 / Status row node.
|
|
147
|
+
*/
|
|
148
|
+
constructor(element: HTMLElement);
|
|
149
|
+
/**
|
|
150
|
+
* 当前状态及模块版本。
|
|
151
|
+
* Current state and module version.
|
|
152
|
+
*/
|
|
153
|
+
readonly state: { status: "checking" | "installed" | "missing"; version: string | null };
|
|
154
|
+
/**
|
|
155
|
+
* 探测配置 Mock。
|
|
156
|
+
* Probe a configuration Mock.
|
|
157
|
+
* @param url 配置地址 / Configuration URL.
|
|
158
|
+
* @returns 探测完成 / Probe completion.
|
|
159
|
+
*/
|
|
160
|
+
check(url: string | URL): Promise<void>;
|
|
161
|
+
/**
|
|
162
|
+
* 取消探测。
|
|
163
|
+
* Cancel probes.
|
|
164
|
+
* @returns 无返回值 / No return value.
|
|
165
|
+
*/
|
|
166
|
+
destroy(): void;
|
|
167
|
+
}
|
|
168
|
+
|
|
43
169
|
/**
|
|
44
170
|
* 同一文档的根页/子页导航,不定义页面布局或模块业务。
|
|
45
171
|
* Home/detail navigation within a document, without layout or module business rules.
|
|
@@ -10,10 +10,30 @@
|
|
|
10
10
|
export function element(tag, className, text) {
|
|
11
11
|
const node = document.createElement(tag);
|
|
12
12
|
node.className = className;
|
|
13
|
+
// 官方 AppSettings 1.1.2 的作用域标记与原版 CSS 一起固定版本。
|
|
14
|
+
// Pin official AppSettings 1.1.2 scope attributes together with its unmodified CSS.
|
|
15
|
+
if (/\bform-row(?:\b|__)/.test(className)) node.setAttribute("data-v-b69aa1ea", "");
|
|
16
|
+
if (/\bform-group(?:\b|__)/.test(className)) node.setAttribute("data-v-e590be47", "");
|
|
13
17
|
if (text !== undefined) node.textContent = text;
|
|
14
18
|
return node;
|
|
15
19
|
}
|
|
16
20
|
|
|
21
|
+
/**
|
|
22
|
+
* 搜索、选择和文本控件共用官方 VField 的 DOM 结构。
|
|
23
|
+
* Share the official VField DOM structure across search, select and text controls.
|
|
24
|
+
* @param {HTMLElement} control 已创建的原生控件 / Existing native control.
|
|
25
|
+
* @param {boolean} [multiline] 是否为多行输入 / Whether the control is multiline.
|
|
26
|
+
* @returns {HTMLDivElement} 字段容器 / Field container.
|
|
27
|
+
*/
|
|
28
|
+
export function fieldControl(control, multiline = false) {
|
|
29
|
+
const field = element("div", `v-field pp-editor${multiline ? " v-field--textarea" : ""}`);
|
|
30
|
+
const body = element("div", "v-field__body");
|
|
31
|
+
control.classList.add("v-field__control");
|
|
32
|
+
body.append(control);
|
|
33
|
+
field.append(body);
|
|
34
|
+
return field;
|
|
35
|
+
}
|
|
36
|
+
|
|
17
37
|
/**
|
|
18
38
|
* 元数据地址只允许 HTTP(S) 和相对地址。
|
|
19
39
|
* Allow only HTTP(S) and relative metadata addresses.
|
|
@@ -57,3 +77,21 @@ export function errorView(error, retry) {
|
|
|
57
77
|
view.append(element("p", "", `加载失败:${error.message}`), button);
|
|
58
78
|
return view;
|
|
59
79
|
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 请求宿主确认;独立网页使用浏览器对话框。
|
|
83
|
+
* Request confirmation from the host, using the browser dialog for standalone pages.
|
|
84
|
+
* @param {Window} host 模块窗口 / Module window.
|
|
85
|
+
* @param {string} message 确认内容 / Confirmation message.
|
|
86
|
+
* @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.
|
|
87
|
+
*/
|
|
88
|
+
export function requestConfirmation(host, message) {
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const frame = host.frameElement;
|
|
91
|
+
if (frame) {
|
|
92
|
+
const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:confirm", { cancelable: true, detail: { message, resolve, reject } });
|
|
93
|
+
if (!frame.dispatchEvent(event)) return;
|
|
94
|
+
}
|
|
95
|
+
resolve(host.confirm(message));
|
|
96
|
+
});
|
|
97
|
+
}
|
package/src/browser/index.mjs
CHANGED
|
@@ -30,8 +30,28 @@ export function mount(boxjs, css = "") {
|
|
|
30
30
|
document.head.append(base, custom);
|
|
31
31
|
const previousTitle = document.title;
|
|
32
32
|
const previousTheme = document.documentElement.dataset.theme;
|
|
33
|
-
const
|
|
34
|
-
|
|
33
|
+
const previousDark = document.documentElement.classList.contains("bili_dark");
|
|
34
|
+
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
35
|
+
const previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
|
|
36
|
+
const host = window.frameElement?.ownerDocument.documentElement;
|
|
37
|
+
/**
|
|
38
|
+
* 跟随嵌入宿主的通用环境状态,不识别业务 App 或解析其 UA。
|
|
39
|
+
* Follow generic host appearance without detecting a business app or parsing its user agent.
|
|
40
|
+
* @returns {void} 已同步主题与键盘避让 / Theme and keyboard clearance synchronized.
|
|
41
|
+
*/
|
|
42
|
+
const syncAppearance = () => {
|
|
43
|
+
const theme = host?.dataset.theme ?? previousTheme ?? (systemTheme.matches ? "dark" : "light");
|
|
44
|
+
document.documentElement.dataset.theme = theme;
|
|
45
|
+
document.documentElement.classList.toggle("bili_dark", theme === "dark");
|
|
46
|
+
if (host) document.documentElement.style.setProperty("--pp-keyboard-height", host.style.getPropertyValue("--pp-keyboard-height"));
|
|
47
|
+
};
|
|
48
|
+
let observer;
|
|
49
|
+
syncAppearance();
|
|
50
|
+
systemTheme.addEventListener("change", syncAppearance);
|
|
51
|
+
if (host) {
|
|
52
|
+
observer = new MutationObserver(syncAppearance);
|
|
53
|
+
observer.observe(host, { attributes: true, attributeFilter: ["data-theme", "style"] });
|
|
54
|
+
}
|
|
35
55
|
document.title = metadata.name ?? catalog.module.module;
|
|
36
56
|
let panel;
|
|
37
57
|
const view = {
|
|
@@ -41,6 +61,9 @@ export function mount(boxjs, css = "") {
|
|
|
41
61
|
* @returns {void} 无返回值 / No return value.
|
|
42
62
|
*/
|
|
43
63
|
destroy() {
|
|
64
|
+
observer?.disconnect();
|
|
65
|
+
systemTheme.removeEventListener("change", syncAppearance);
|
|
66
|
+
document.documentElement.classList.toggle("bili_dark", previousDark);
|
|
44
67
|
panel?.destroy();
|
|
45
68
|
base.remove();
|
|
46
69
|
custom.remove();
|
|
@@ -49,6 +72,7 @@ export function mount(boxjs, css = "") {
|
|
|
49
72
|
document.title = previousTitle;
|
|
50
73
|
if (previousTheme === undefined) delete document.documentElement.dataset.theme;
|
|
51
74
|
else document.documentElement.dataset.theme = previousTheme;
|
|
75
|
+
document.documentElement.style.setProperty("--pp-keyboard-height", previousKeyboard);
|
|
52
76
|
},
|
|
53
77
|
};
|
|
54
78
|
try {
|