@nsnanocat/preference-panes 1.2.5 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +31 -0
- package/README.md +66 -25
- package/dist/home.html +2208 -0
- package/dist/{module/navigation.mjs → navigation.mjs} +51 -143
- package/dist/preference-panes.mjs +913 -614
- package/package.json +4 -3
- package/src/browser/ActionMenu.mjs +7 -6
- package/src/browser/Navigation.d.mts +4 -95
- package/src/browser/Navigation.mjs +43 -14
- package/src/browser/boxjs.mjs +38 -8
- package/src/browser/client.d.mts +3 -3
- package/src/browser/client.mjs +57 -25
- package/src/browser/components.mjs +0 -44
- package/src/browser/home.d.mts +66 -0
- package/src/browser/home.html +15 -0
- package/src/browser/home.mjs +287 -0
- package/src/browser/index.d.ts +2 -0
- package/src/browser/mount.mjs +50 -91
- package/src/browser/page.mjs +325 -0
- package/src/browser/panel.mjs +118 -149
- package/src/browser/styles.css +565 -0
- package/src/index.d.ts +8 -0
- package/dist/module/index.html +0 -16
- package/dist/module/index.mjs +0 -1625
- package/src/browser/ModuleFrame.mjs +0 -122
- package/src/browser/index.mjs +0 -89
- package/src/browser/module.html +0 -16
- package/src/browser/panel.css +0 -315
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ActionMenu, MenuAction, Notice } from "./Navigation.mjs";
|
|
2
|
+
|
|
3
|
+
/** 宿主导航状态 / Bridge navigation state. */
|
|
4
|
+
export interface HomeState {
|
|
5
|
+
/** 当前标题 / Current title. */
|
|
6
|
+
title: string;
|
|
7
|
+
/** 首页导航图片 / Homepage navigation image. */
|
|
8
|
+
logo?: string;
|
|
9
|
+
/** 首页导航文字 / Homepage navigation text. */
|
|
10
|
+
logoText?: string;
|
|
11
|
+
/** 网页导航品牌链接 / Web navigation brand link. */
|
|
12
|
+
logoHref?: string;
|
|
13
|
+
/** 可用菜单操作 / Available menu actions. */
|
|
14
|
+
actions: MenuAction[];
|
|
15
|
+
/** 操作是否正在执行 / Whether an operation is in progress. */
|
|
16
|
+
busy: boolean;
|
|
17
|
+
/** 联合历史是否可返回 / Whether joint history can go back. */
|
|
18
|
+
canGoBack: boolean;
|
|
19
|
+
}
|
|
20
|
+
/** 宿主初始化输入 / Bridge initialization inputs. */
|
|
21
|
+
export interface BridgeOptions {
|
|
22
|
+
/** 共用操作菜单 / Shared action menu. */
|
|
23
|
+
menu: ActionMenu;
|
|
24
|
+
/** 沿当前页面历史返回 / Go back through the current page history. */
|
|
25
|
+
back(): void;
|
|
26
|
+
}
|
|
27
|
+
/** 可选 Bridge 模块提供的宿主能力 / Host capabilities supplied by the optional Bridge module. */
|
|
28
|
+
export interface Bridge {
|
|
29
|
+
/** 同步当前导航状态 / Synchronize current navigation state. */
|
|
30
|
+
update(state: HomeState): void | Promise<void>;
|
|
31
|
+
/** 返回确认或取消选择 / Return the confirmation or cancellation choice. */
|
|
32
|
+
confirm(message: string): boolean | Promise<boolean>;
|
|
33
|
+
/** 显示操作结果 / Display an operation result. */
|
|
34
|
+
notice(detail: Notice): void;
|
|
35
|
+
/** 打开完整地址;_blank 保留当前页面,_self 替换当前页面 / Open an absolute URL; _blank retains the current page and _self replaces it. */
|
|
36
|
+
openURL(url: string, target?: "_self" | "_blank"): void | Promise<void>;
|
|
37
|
+
/** 释放界面与监听器 / Release UI and listeners. */
|
|
38
|
+
destroy(): void;
|
|
39
|
+
}
|
|
40
|
+
/** 宿主工厂;默认导出 / Bridge factory; the override's default export. */
|
|
41
|
+
export type BridgeFactory = (options: BridgeOptions) => Bridge | Promise<Bridge>;
|
|
42
|
+
/**
|
|
43
|
+
* 初始化固定路径的 JSON 首页及可选 Bridge。
|
|
44
|
+
* Initialize the JSON homepage and optional Bridge at fixed paths.
|
|
45
|
+
* @returns 退出清理动作;失败显示错误并拒绝 / Cleanup action; failures display an error and reject.
|
|
46
|
+
*/
|
|
47
|
+
export function startHome(): Promise<() => void>;
|
|
48
|
+
/** 首页入口图标 / Homepage entry icon. */
|
|
49
|
+
export type HomeIcon = { src: string; text?: never } | { text: string; src?: never };
|
|
50
|
+
/** 外链打开方式:带导航的内部框架、当前页面或新页面 / Link opening mode: an internal frame with navigation, the current page or a new page. */
|
|
51
|
+
export type HomeLinkTarget = "internal" | "_self" | "_blank";
|
|
52
|
+
/** 首页模块或链接入口 / Homepage module or link entry. */
|
|
53
|
+
export type HomeItem = { name: string; icon: HomeIcon; description?: string } & ({ module: string; href?: never; target?: never } | { href: string; target?: HomeLinkTarget; module?: never });
|
|
54
|
+
/** 运行时导入的首页配置 / Runtime-imported homepage configuration. */
|
|
55
|
+
export interface HomeConfig {
|
|
56
|
+
/** 文档和原生导航标题;未提供品牌时也是网页导航文字 / Document and native navigation title; also the web navigation text when no brand is supplied. */
|
|
57
|
+
title: string;
|
|
58
|
+
/** 网页导航图片,相对于 JSON 地址 / Web navigation image, relative to the JSON URL. */
|
|
59
|
+
logo?: string;
|
|
60
|
+
/** 网页导航文字,沿用 Rspress 命名 / Web navigation text, following Rspress naming. */
|
|
61
|
+
logoText?: string;
|
|
62
|
+
/** 网页导航品牌链接,相对于 JSON 地址;默认网站首页 / Web navigation brand link, relative to the JSON URL; defaults to the site homepage. */
|
|
63
|
+
logoHref?: string;
|
|
64
|
+
sections: { title: string; layout?: "grid" | "list"; items: HomeItem[] }[];
|
|
65
|
+
footer?: string[];
|
|
66
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN" class="pp-page">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
6
|
+
<title>设置</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<div data-preference-panes-pages>
|
|
10
|
+
<main class="pp-home" data-preference-panes-home></main>
|
|
11
|
+
</div>
|
|
12
|
+
<script type="module">__PREFERENCE_PANES_HOME_SCRIPT__
|
|
13
|
+
await startHome();</script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { ActionMenu, ModuleStatus } from "./Navigation.mjs";
|
|
2
|
+
import { browserHost, PageController } from "./page.mjs";
|
|
3
|
+
import { installDefaultStyles } from "./styles.mjs";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 校验外部 JSON 中的章节、入口和导航目标,避免生成不可访问的设置入口。
|
|
7
|
+
* Validate sections, entries and navigation targets in external JSON before rendering inaccessible entries.
|
|
8
|
+
* @param {unknown} config 外部首页配置 / External homepage configuration.
|
|
9
|
+
* @returns {void} 非法输入抛出错误 / Invalid input throws.
|
|
10
|
+
*/
|
|
11
|
+
function validateHome(config) {
|
|
12
|
+
if (!config || typeof config.title !== "string" || !Array.isArray(config.sections)) throw new Error("首页 JSON 缺少 title 或 sections");
|
|
13
|
+
if (config.logo !== undefined && typeof config.logo !== "string") throw new Error("首页 logo 必须是图片地址");
|
|
14
|
+
if (config.logoText !== undefined && typeof config.logoText !== "string") throw new Error("首页 logoText 必须是文字");
|
|
15
|
+
if (config.logoHref !== undefined) {
|
|
16
|
+
if (typeof config.logoHref !== "string") throw new Error("首页 logoHref 必须是链接地址");
|
|
17
|
+
if (["javascript:", "data:", "vbscript:"].includes(new URL(config.logoHref, "https://preference-panes.invalid/").protocol)) throw new Error("首页 logoHref 的链接无效");
|
|
18
|
+
}
|
|
19
|
+
if (config.footer !== undefined && (!Array.isArray(config.footer) || config.footer.some(text => typeof text !== "string"))) throw new Error("首页 footer 必须是文字数组");
|
|
20
|
+
const modules = new Set();
|
|
21
|
+
for (const section of config.sections) {
|
|
22
|
+
if (!section || typeof section.title !== "string" || !Array.isArray(section.items) || (section.layout !== undefined && !["grid", "list"].includes(section.layout))) throw new Error("首页章节需要 title、items 和有效的 layout");
|
|
23
|
+
for (const item of section.items) {
|
|
24
|
+
if (!item || typeof item.name !== "string" || !item.icon || (typeof item.icon.src === "string") === (typeof item.icon.text === "string") || (item.icon.src !== undefined && typeof item.icon.src !== "string") || (item.icon.text !== undefined && typeof item.icon.text !== "string"))
|
|
25
|
+
throw new Error("首页入口需要 name 和唯一的图片或文字 icon");
|
|
26
|
+
if (item.description !== undefined && typeof item.description !== "string") throw new Error(`入口 ${item.name} 的 description 必须是文字`);
|
|
27
|
+
if (item.target !== undefined && (item.module !== undefined || !["internal", "_self", "_blank"].includes(item.target))) throw new Error(`入口 ${item.name} 的 target 只允许链接使用 internal、_self 或 _blank`);
|
|
28
|
+
if ((typeof item.module === "string") === (typeof item.href === "string") || (item.module !== undefined && typeof item.module !== "string") || (item.href !== undefined && typeof item.href !== "string")) throw new Error(`入口 ${item.name} 必须选择 module 或 href`);
|
|
29
|
+
if (item.module !== undefined) {
|
|
30
|
+
if (!/^[A-Za-z0-9_-]+$/.test(item.module) || modules.has(item.module)) throw new Error(`入口 ${item.name} 的模块名称无效或重复`);
|
|
31
|
+
modules.add(item.module);
|
|
32
|
+
} else {
|
|
33
|
+
const protocol = new URL(item.href, "https://preference-panes.invalid/").protocol;
|
|
34
|
+
if (["javascript:", "data:", "vbscript:"].includes(protocol) || (item.target === "internal" && !["http:", "https:"].includes(protocol))) throw new Error(`入口 ${item.name} 的链接无效`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 共用网格和列表章节;资源地址相对于 JSON,所有项目文字通过 textContent 绘制。
|
|
42
|
+
* Share grid and list sections, resolve resources against JSON and render project text through textContent.
|
|
43
|
+
* @param {import("./home.mjs").HomeConfig} config 已校验的首页配置 / Validated homepage configuration.
|
|
44
|
+
* @param {URL} url JSON 地址 / JSON URL.
|
|
45
|
+
* @param {HTMLElement} home 首页容器 / Homepage container.
|
|
46
|
+
* @returns {void} 无返回值 / No return value.
|
|
47
|
+
*/
|
|
48
|
+
function renderHome(config, url, home) {
|
|
49
|
+
const document = home.ownerDocument;
|
|
50
|
+
home.replaceChildren();
|
|
51
|
+
for (const section of config.sections) {
|
|
52
|
+
const group = document.createElement("section");
|
|
53
|
+
group.className = "pp-group";
|
|
54
|
+
const title = document.createElement("h2");
|
|
55
|
+
title.className = "pp-group-title";
|
|
56
|
+
title.textContent = section.title;
|
|
57
|
+
const entries = document.createElement("div");
|
|
58
|
+
entries.className = "pp-home-entries";
|
|
59
|
+
entries.dataset.layout = section.layout ?? "grid";
|
|
60
|
+
for (const item of section.items) {
|
|
61
|
+
const entry = document.createElement(item.module ? "button" : "a");
|
|
62
|
+
entry.className = "pp-home-item";
|
|
63
|
+
if (item.module) {
|
|
64
|
+
entry.type = "button";
|
|
65
|
+
entry.disabled = true;
|
|
66
|
+
entry.dataset.module = item.module;
|
|
67
|
+
} else {
|
|
68
|
+
entry.href = new URL(item.href, url).href;
|
|
69
|
+
if (item.target !== undefined) {
|
|
70
|
+
entry.dataset.target = item.target;
|
|
71
|
+
switch (item.target) {
|
|
72
|
+
case "_blank":
|
|
73
|
+
entry.target = item.target;
|
|
74
|
+
entry.rel = "noopener noreferrer";
|
|
75
|
+
break;
|
|
76
|
+
case "_self":
|
|
77
|
+
entry.target = item.target;
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const icon = document.createElement("span");
|
|
83
|
+
icon.className = "pp-home-icon";
|
|
84
|
+
icon.setAttribute("aria-hidden", "true");
|
|
85
|
+
if (item.icon.src !== undefined) {
|
|
86
|
+
const image = document.createElement("img");
|
|
87
|
+
image.src = new URL(item.icon.src, url).href;
|
|
88
|
+
image.alt = "";
|
|
89
|
+
icon.append(image);
|
|
90
|
+
} else icon.textContent = item.icon.text;
|
|
91
|
+
const content = document.createElement("span");
|
|
92
|
+
content.className = "pp-home-content";
|
|
93
|
+
const name = document.createElement("span");
|
|
94
|
+
name.className = "pp-home-name";
|
|
95
|
+
name.textContent = item.name;
|
|
96
|
+
content.append(name);
|
|
97
|
+
if (item.description !== undefined) {
|
|
98
|
+
const description = document.createElement("span");
|
|
99
|
+
description.className = "pp-description";
|
|
100
|
+
description.textContent = item.description;
|
|
101
|
+
content.append(description);
|
|
102
|
+
}
|
|
103
|
+
if (item.module) {
|
|
104
|
+
const status = document.createElement("span");
|
|
105
|
+
status.className = "pp-description";
|
|
106
|
+
status.dataset.moduleStatus = "";
|
|
107
|
+
status.textContent = "检测中";
|
|
108
|
+
content.append(status);
|
|
109
|
+
}
|
|
110
|
+
entry.append(icon, content);
|
|
111
|
+
if (entries.dataset.layout === "list") {
|
|
112
|
+
const arrow = document.createElement("span");
|
|
113
|
+
arrow.className = "pp-home-arrow";
|
|
114
|
+
arrow.textContent = "›";
|
|
115
|
+
arrow.setAttribute("aria-hidden", "true");
|
|
116
|
+
entry.append(arrow);
|
|
117
|
+
}
|
|
118
|
+
entries.append(entry);
|
|
119
|
+
}
|
|
120
|
+
group.append(title, entries);
|
|
121
|
+
home.append(group);
|
|
122
|
+
}
|
|
123
|
+
const footer = document.createElement("footer");
|
|
124
|
+
footer.className = "pp-home-footer pp-description";
|
|
125
|
+
for (const text of config.footer ?? []) {
|
|
126
|
+
const paragraph = document.createElement("p");
|
|
127
|
+
paragraph.textContent = text;
|
|
128
|
+
footer.append(paragraph);
|
|
129
|
+
}
|
|
130
|
+
home.append(footer);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 读取固定相对路径的首页 JSON 与可选 CSS、Bridge,初始化后共用模块探测与导航。
|
|
135
|
+
* Load homepage JSON and optional CSS and Bridge at fixed relative paths, then share module probes and navigation.
|
|
136
|
+
* @returns {Promise<() => void>} 退出清理动作;加载失败会显示错误并拒绝 / Cleanup action; load failures display an error and reject.
|
|
137
|
+
*/
|
|
138
|
+
export async function startHome() {
|
|
139
|
+
let host;
|
|
140
|
+
let menu;
|
|
141
|
+
let navigation;
|
|
142
|
+
let page;
|
|
143
|
+
let statuses = [];
|
|
144
|
+
let destroyed = false;
|
|
145
|
+
let defaults;
|
|
146
|
+
let stylesheet;
|
|
147
|
+
const controller = new AbortController();
|
|
148
|
+
const destroy = () => {
|
|
149
|
+
if (destroyed) return;
|
|
150
|
+
destroyed = true;
|
|
151
|
+
controller.abort();
|
|
152
|
+
for (const { status } of statuses) status.destroy();
|
|
153
|
+
if (page) page.destroy();
|
|
154
|
+
else {
|
|
155
|
+
host?.destroy();
|
|
156
|
+
menu?.destroy();
|
|
157
|
+
}
|
|
158
|
+
stylesheet?.remove();
|
|
159
|
+
if (defaults?.owned) defaults.element.remove();
|
|
160
|
+
window.removeEventListener("pagehide", pagehide);
|
|
161
|
+
};
|
|
162
|
+
const pagehide = event => {
|
|
163
|
+
if (!event.persisted) destroy();
|
|
164
|
+
};
|
|
165
|
+
window.addEventListener("pagehide", pagehide);
|
|
166
|
+
try {
|
|
167
|
+
defaults = installDefaultStyles(document);
|
|
168
|
+
const jsonURL = new URL("./home.json", document.baseURI);
|
|
169
|
+
const response = await fetch(jsonURL, { signal: controller.signal });
|
|
170
|
+
if (!response.ok) throw new Error(`首页 JSON 加载失败:HTTP ${response.status}`);
|
|
171
|
+
const config = await response.json();
|
|
172
|
+
validateHome(config);
|
|
173
|
+
if (destroyed) return destroy;
|
|
174
|
+
const cssURL = new URL("./theme.css", document.baseURI);
|
|
175
|
+
const cssResponse = await fetch(cssURL, { method: "HEAD", cache: "no-store", signal: controller.signal });
|
|
176
|
+
if (destroyed) return destroy;
|
|
177
|
+
switch (cssResponse.status) {
|
|
178
|
+
case 200:
|
|
179
|
+
stylesheet = document.createElement("link");
|
|
180
|
+
stylesheet.rel = "stylesheet";
|
|
181
|
+
stylesheet.dataset.preferencePanesStylesheet = "";
|
|
182
|
+
stylesheet.href = cssURL.href;
|
|
183
|
+
await new Promise((resolve, reject) => {
|
|
184
|
+
const abort = () => reject(new DOMException("页面已退出", "AbortError"));
|
|
185
|
+
controller.signal.addEventListener("abort", abort, { once: true });
|
|
186
|
+
stylesheet.onload = () => {
|
|
187
|
+
controller.signal.removeEventListener("abort", abort);
|
|
188
|
+
resolve();
|
|
189
|
+
};
|
|
190
|
+
stylesheet.onerror = () => {
|
|
191
|
+
controller.signal.removeEventListener("abort", abort);
|
|
192
|
+
reject(new Error("项目 CSS 加载失败"));
|
|
193
|
+
};
|
|
194
|
+
document.head.append(stylesheet);
|
|
195
|
+
});
|
|
196
|
+
break;
|
|
197
|
+
case 404:
|
|
198
|
+
break;
|
|
199
|
+
default:
|
|
200
|
+
throw new Error(`项目 CSS 探测失败:HTTP ${cssResponse.status}`);
|
|
201
|
+
}
|
|
202
|
+
if (destroyed) return destroy;
|
|
203
|
+
const bridgeURL = new URL("./bridge.mjs", document.baseURI);
|
|
204
|
+
const bridgeResponse = await fetch(bridgeURL, { method: "HEAD", cache: "no-store", signal: controller.signal });
|
|
205
|
+
if (destroyed) return destroy;
|
|
206
|
+
let factory;
|
|
207
|
+
switch (bridgeResponse.status) {
|
|
208
|
+
case 200:
|
|
209
|
+
factory = (await import(bridgeURL.href)).default;
|
|
210
|
+
break;
|
|
211
|
+
case 404:
|
|
212
|
+
factory = browserHost;
|
|
213
|
+
break;
|
|
214
|
+
default:
|
|
215
|
+
throw new Error(`Bridge 探测失败:HTTP ${bridgeResponse.status}`);
|
|
216
|
+
}
|
|
217
|
+
if (destroyed) return destroy;
|
|
218
|
+
document.title = config.title;
|
|
219
|
+
menu = new ActionMenu(id => page.perform(id));
|
|
220
|
+
host = await factory({ menu, back: () => navigation.back() });
|
|
221
|
+
if (destroyed) {
|
|
222
|
+
host.destroy();
|
|
223
|
+
menu.destroy();
|
|
224
|
+
return destroy;
|
|
225
|
+
}
|
|
226
|
+
const container = document.querySelector("[data-preference-panes-pages]");
|
|
227
|
+
const home = document.querySelector("[data-preference-panes-home]");
|
|
228
|
+
renderHome(config, jsonURL, home);
|
|
229
|
+
const buttons = [...home.querySelectorAll("[data-module]")];
|
|
230
|
+
const links = [...home.querySelectorAll("a[href]")];
|
|
231
|
+
page = new PageController(container, home, {
|
|
232
|
+
host,
|
|
233
|
+
menu,
|
|
234
|
+
title: config.title,
|
|
235
|
+
logo: config.logo ? new URL(config.logo, jsonURL).href : undefined,
|
|
236
|
+
logoText: config.logoText,
|
|
237
|
+
logoHref: new URL(config.logoHref || "/", jsonURL).href,
|
|
238
|
+
modules: new Map(config.sections.flatMap(section => section.items.filter(item => item.module).map(item => [item.module, item]))),
|
|
239
|
+
links,
|
|
240
|
+
});
|
|
241
|
+
navigation = page.navigation;
|
|
242
|
+
for (const [index, link] of links.entries()) {
|
|
243
|
+
link.addEventListener(
|
|
244
|
+
"click",
|
|
245
|
+
event => {
|
|
246
|
+
if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
247
|
+
event.preventDefault();
|
|
248
|
+
switch (link.dataset.target) {
|
|
249
|
+
case "internal":
|
|
250
|
+
navigation.open(`$link/${index}`);
|
|
251
|
+
break;
|
|
252
|
+
default:
|
|
253
|
+
page.openURL(link.href, link.dataset.target);
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
{ signal: controller.signal },
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
statuses = buttons.map(button => {
|
|
261
|
+
const status = new ModuleStatus(button.querySelector("[data-module-status]"));
|
|
262
|
+
status.addEventListener("change", () => {
|
|
263
|
+
button.disabled = status.state.status !== "installed";
|
|
264
|
+
});
|
|
265
|
+
button.addEventListener("click", () => navigation.open(button.dataset.module), { signal: controller.signal });
|
|
266
|
+
return { button, status };
|
|
267
|
+
});
|
|
268
|
+
const probe = () => {
|
|
269
|
+
if (navigation.current) return;
|
|
270
|
+
for (const { button, status } of statuses) status.check(`/api/${encodeURIComponent(button.dataset.module)}`);
|
|
271
|
+
};
|
|
272
|
+
navigation.addEventListener("change", probe);
|
|
273
|
+
probe();
|
|
274
|
+
return destroy;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const display = !destroyed;
|
|
277
|
+
destroy();
|
|
278
|
+
if (display) {
|
|
279
|
+
const message = document.createElement("p");
|
|
280
|
+
message.className = "pp-host-error";
|
|
281
|
+
message.setAttribute("role", "alert");
|
|
282
|
+
message.textContent = `无法打开设置:${error.message}`;
|
|
283
|
+
document.body.prepend(message);
|
|
284
|
+
}
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
}
|
package/src/browser/index.d.ts
CHANGED
|
@@ -15,3 +15,5 @@ export interface MountedPreferences {
|
|
|
15
15
|
* @returns 模块视图 / Module view.
|
|
16
16
|
*/
|
|
17
17
|
export function mount(boxjs: BoxJSInput): MountedPreferences;
|
|
18
|
+
|
|
19
|
+
export type { HomeConfig, HomeIcon, HomeItem, HomeLinkTarget, HomeState, Bridge, BridgeOptions, BridgeFactory } from "./home.mjs";
|
package/src/browser/mount.mjs
CHANGED
|
@@ -1,103 +1,62 @@
|
|
|
1
|
+
import { ActionMenu } from "./ActionMenu.mjs";
|
|
1
2
|
import { normalizeBoxJs } from "./boxjs.mjs";
|
|
2
|
-
import { element
|
|
3
|
-
import {
|
|
3
|
+
import { element } from "./components.mjs";
|
|
4
|
+
import { browserHost, PageController } from "./page.mjs";
|
|
4
5
|
import { installDefaultStyles } from "./styles.mjs";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
|
-
*
|
|
8
|
-
*
|
|
8
|
+
* 使用原始 BoxJS JSON 挂载设置页,共用内部页面控制器。
|
|
9
|
+
* Mount a settings page from raw BoxJS JSON through the shared internal page controller.
|
|
10
|
+
* @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
|
|
11
|
+
* @returns {import("./index.js").MountedPreferences} 模块视图 / Module view.
|
|
9
12
|
*/
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
#
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
* Mount a settings page from raw BoxJS JSON.
|
|
26
|
-
* @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
|
|
27
|
-
*/
|
|
28
|
-
constructor(boxjs) {
|
|
29
|
-
const definition = normalizeBoxJs(boxjs);
|
|
30
|
-
const metadata = definition.metadata ?? {};
|
|
31
|
-
const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
32
|
-
if (image) resourceURL(image);
|
|
33
|
-
if (metadata.repo) resourceURL(metadata.repo);
|
|
34
|
-
|
|
35
|
-
this.#existing = document.querySelector("#preferences");
|
|
36
|
-
this.#root = this.#existing ?? element("main", "");
|
|
37
|
-
if (!this.#existing) {
|
|
38
|
-
this.#root.id = "preferences";
|
|
39
|
-
document.body.append(this.#root);
|
|
40
|
-
}
|
|
41
|
-
const styles = installDefaultStyles(document);
|
|
42
|
-
this.#base = styles.element;
|
|
43
|
-
this.#ownsBase = styles.owned;
|
|
44
|
-
this.#previousTitle = document.title;
|
|
45
|
-
this.#previousTheme = document.documentElement.dataset.theme;
|
|
46
|
-
this.#systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
|
|
47
|
-
this.#previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
|
|
48
|
-
this.#host = window.frameElement?.ownerDocument.documentElement;
|
|
49
|
-
this.#syncAppearance();
|
|
50
|
-
this.#systemTheme.addEventListener("change", this.#syncAppearance);
|
|
51
|
-
if (this.#host) {
|
|
52
|
-
this.#observer = new MutationObserver(this.#syncAppearance);
|
|
53
|
-
this.#observer.observe(this.#host, { attributes: true, attributeFilter: ["data-theme", "style"] });
|
|
54
|
-
}
|
|
55
|
-
document.title = metadata.name ?? definition.module;
|
|
56
|
-
try {
|
|
57
|
-
this.#root.replaceChildren();
|
|
58
|
-
this.#panel = new PreferencesPanel(this.#root, definition);
|
|
59
|
-
} catch (error) {
|
|
60
|
-
this.destroy();
|
|
61
|
-
throw error;
|
|
62
|
-
}
|
|
13
|
+
export function mount(boxjs) {
|
|
14
|
+
const definition = normalizeBoxJs(boxjs);
|
|
15
|
+
const title = definition.metadata?.name ?? definition.module;
|
|
16
|
+
const existing = document.querySelector("#preferences");
|
|
17
|
+
const root = existing ?? element("main", "");
|
|
18
|
+
const previousTitle = document.title;
|
|
19
|
+
const previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
|
|
20
|
+
const previousHeight = document.documentElement.style.getPropertyValue("--pp-host-height");
|
|
21
|
+
const previousTheme = document.documentElement.dataset.theme;
|
|
22
|
+
const previousClass = document.documentElement.classList.contains("pp-page");
|
|
23
|
+
document.documentElement.classList.add("pp-page");
|
|
24
|
+
root.dataset.preferencePanesPages = "";
|
|
25
|
+
if (!existing) {
|
|
26
|
+
root.id = "preferences";
|
|
27
|
+
document.body.append(root);
|
|
63
28
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
document.documentElement.dataset.theme = theme;
|
|
73
|
-
if (this.#host) document.documentElement.style.setProperty("--pp-keyboard-height", this.#host.style.getPropertyValue("--pp-keyboard-height"));
|
|
29
|
+
const defaults = installDefaultStyles(document);
|
|
30
|
+
const menu = new ActionMenu(id => page.perform(id));
|
|
31
|
+
const host = browserHost({ menu, back: () => page.navigation.back() });
|
|
32
|
+
const page = new PageController(root, element("main", "pp-home"), { host, menu, title, modules: new Map([[definition.module, { name: title, boxjs }]]) });
|
|
33
|
+
if (!page.navigation.current) page.navigation.open(definition.module);
|
|
34
|
+
let destroyed = false;
|
|
35
|
+
const pagehide = event => {
|
|
36
|
+
if (!event.persisted) destroy();
|
|
74
37
|
};
|
|
75
|
-
|
|
38
|
+
window.addEventListener("pagehide", pagehide);
|
|
76
39
|
/**
|
|
77
|
-
*
|
|
78
|
-
* Release the module
|
|
40
|
+
* 释放模块、样式与监听器,并恢复调用方的文档状态。
|
|
41
|
+
* Release the module, styles and listeners and restore the caller's document state.
|
|
79
42
|
* @returns {void} 无返回值 / No return value.
|
|
80
43
|
*/
|
|
81
|
-
destroy() {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
else
|
|
91
|
-
document.documentElement.
|
|
44
|
+
function destroy() {
|
|
45
|
+
if (destroyed) return;
|
|
46
|
+
destroyed = true;
|
|
47
|
+
page.destroy();
|
|
48
|
+
window.removeEventListener("pagehide", pagehide);
|
|
49
|
+
if (defaults.owned) defaults.element.remove();
|
|
50
|
+
if (existing) {
|
|
51
|
+
root.replaceChildren();
|
|
52
|
+
delete root.dataset.preferencePanesPages;
|
|
53
|
+
} else root.remove();
|
|
54
|
+
if (!previousClass) document.documentElement.classList.remove("pp-page");
|
|
55
|
+
document.title = previousTitle;
|
|
56
|
+
document.documentElement.style.setProperty("--pp-keyboard-height", previousKeyboard);
|
|
57
|
+
document.documentElement.style.setProperty("--pp-host-height", previousHeight);
|
|
58
|
+
if (previousTheme === undefined) delete document.documentElement.dataset.theme;
|
|
59
|
+
else document.documentElement.dataset.theme = previousTheme;
|
|
92
60
|
}
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* 使用原始 BoxJS JSON 挂载设置页。
|
|
97
|
-
* Mount a settings page from raw BoxJS JSON.
|
|
98
|
-
* @param {import("../index.js").BoxJSInput} boxjs 单模块 BoxJS JSON / Single-module BoxJS JSON.
|
|
99
|
-
* @returns {import("./index.js").MountedPreferences} 模块视图 / Module view.
|
|
100
|
-
*/
|
|
101
|
-
export function mount(boxjs) {
|
|
102
|
-
return new PreferencesView(boxjs);
|
|
61
|
+
return { destroy };
|
|
103
62
|
}
|