@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.
@@ -1,4 +1,5 @@
1
- import { ActionMenu, ModuleFrame, ModuleStatus, Navigation } from "./Navigation.mjs";
1
+ import { ActionMenu, ModuleStatus } from "./Navigation.mjs";
2
+ import { browserHost, PageController } from "./page.mjs";
2
3
  import { installDefaultStyles } from "./styles.mjs";
3
4
 
4
5
  /**
@@ -10,6 +11,11 @@ import { installDefaultStyles } from "./styles.mjs";
10
11
  function validateHome(config) {
11
12
  if (!config || typeof config.title !== "string" || !Array.isArray(config.sections)) throw new Error("首页 JSON 缺少 title 或 sections");
12
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
+ }
13
19
  if (config.footer !== undefined && (!Array.isArray(config.footer) || config.footer.some(text => typeof text !== "string"))) throw new Error("首页 footer 必须是文字数组");
14
20
  const modules = new Set();
15
21
  for (const section of config.sections) {
@@ -21,11 +27,11 @@ function validateHome(config) {
21
27
  if (item.target !== undefined && (item.module !== undefined || !["internal", "_self", "_blank"].includes(item.target))) throw new Error(`入口 ${item.name} 的 target 只允许链接使用 internal、_self 或 _blank`);
22
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`);
23
29
  if (item.module !== undefined) {
24
- if (!/^[A-Za-z0-9_-]+$/.test(item.module) || (item.pageModule !== undefined && (typeof item.pageModule !== "string" || !/^[A-Za-z0-9_-]+$/.test(item.pageModule))) || modules.has(item.module)) throw new Error(`入口 ${item.name} 的模块名称无效或重复`);
30
+ if (!/^[A-Za-z0-9_-]+$/.test(item.module) || modules.has(item.module)) throw new Error(`入口 ${item.name} 的模块名称无效或重复`);
25
31
  modules.add(item.module);
26
32
  } else {
27
33
  const protocol = new URL(item.href, "https://preference-panes.invalid/").protocol;
28
- if (item.pageModule !== undefined || ["javascript:", "data:", "vbscript:"].includes(protocol) || (item.target === "internal" && !["http:", "https:"].includes(protocol))) throw new Error(`入口 ${item.name} 的链接无效`);
34
+ if (["javascript:", "data:", "vbscript:"].includes(protocol) || (item.target === "internal" && !["http:", "https:"].includes(protocol))) throw new Error(`入口 ${item.name} 的链接无效`);
29
35
  }
30
36
  }
31
37
  }
@@ -42,13 +48,6 @@ function validateHome(config) {
42
48
  function renderHome(config, url, home) {
43
49
  const document = home.ownerDocument;
44
50
  home.replaceChildren();
45
- if (config.logo) {
46
- const logo = document.createElement("img");
47
- logo.className = "pp-home-logo";
48
- logo.src = new URL(config.logo, url).href;
49
- logo.alt = config.title;
50
- home.append(logo);
51
- }
52
51
  for (const section of config.sections) {
53
52
  const group = document.createElement("section");
54
53
  group.className = "pp-group";
@@ -65,7 +64,6 @@ function renderHome(config, url, home) {
65
64
  entry.type = "button";
66
65
  entry.disabled = true;
67
66
  entry.dataset.module = item.module;
68
- if (item.pageModule) entry.dataset.preferencePanesModule = item.pageModule;
69
67
  } else {
70
68
  entry.href = new URL(item.href, url).href;
71
69
  if (item.target !== undefined) {
@@ -132,90 +130,6 @@ function renderHome(config, url, home) {
132
130
  home.append(footer);
133
131
  }
134
132
 
135
- /**
136
- * 浏览器宿主提供网页导航、通知、主题和可视区域避让。
137
- * The browser host provides web navigation, notices, theme and viewport clearance.
138
- * @param {import("./home.mjs").BridgeOptions} options 菜单和返回动作 / Menu and back action.
139
- * @returns {import("./home.mjs").Bridge} 浏览器宿主 / Browser host.
140
- */
141
- function browserHost({ menu, back }) {
142
- const root = document.documentElement;
143
- const header = document.createElement("header");
144
- header.className = "pp-host-header";
145
- const button = document.createElement("button");
146
- button.className = "pp-host-back";
147
- button.type = "button";
148
- button.textContent = "‹";
149
- button.setAttribute("aria-label", "返回");
150
- button.onclick = back;
151
- const title = document.createElement("span");
152
- title.className = "pp-host-title";
153
- header.append(button, title, menu.element);
154
- document.body.prepend(header);
155
- const toast = document.createElement("div");
156
- toast.className = "pp-host-toast";
157
- toast.setAttribute("role", "status");
158
- toast.hidden = true;
159
- document.body.append(toast);
160
- let timer;
161
- const theme = window.matchMedia("(prefers-color-scheme: dark)");
162
- const applyTheme = () => {
163
- root.dataset.theme = theme.matches ? "dark" : "light";
164
- };
165
- const viewport = window.visualViewport;
166
- const resize = () => {
167
- root.style.setProperty("--pp-host-height", `${header.getBoundingClientRect().height}px`);
168
- const clearance = viewport && viewport.scale === 1 ? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop) : 0;
169
- root.style.setProperty("--pp-keyboard-height", `${clearance}px`);
170
- };
171
- theme.addEventListener("change", applyTheme);
172
- window.addEventListener("resize", resize);
173
- window.addEventListener("pageshow", resize);
174
- viewport?.addEventListener("resize", resize);
175
- viewport?.addEventListener("scroll", resize);
176
- applyTheme();
177
- resize();
178
- return {
179
- update(state) {
180
- title.textContent = state.title;
181
- button.hidden = !state.canGoBack;
182
- menu.element.hidden = !state.actions.length;
183
- },
184
- confirm: message => window.confirm(message),
185
- notice(detail) {
186
- clearTimeout(timer);
187
- toast.textContent = detail.message;
188
- toast.hidden = false;
189
- timer = setTimeout(() => {
190
- toast.hidden = true;
191
- }, 2400);
192
- },
193
- openURL(url, target) {
194
- switch (target) {
195
- case "_blank":
196
- window.open(url, "_blank", "noopener,noreferrer");
197
- break;
198
- default:
199
- window.location.assign(url);
200
- break;
201
- }
202
- },
203
- destroy() {
204
- clearTimeout(timer);
205
- theme.removeEventListener("change", applyTheme);
206
- window.removeEventListener("resize", resize);
207
- window.removeEventListener("pageshow", resize);
208
- viewport?.removeEventListener("resize", resize);
209
- viewport?.removeEventListener("scroll", resize);
210
- root.style.removeProperty("--pp-host-height");
211
- root.style.removeProperty("--pp-keyboard-height");
212
- delete root.dataset.theme;
213
- header.remove();
214
- toast.remove();
215
- },
216
- };
217
- }
218
-
219
133
  /**
220
134
  * 读取固定相对路径的首页 JSON 与可选 CSS、Bridge,初始化后共用模块探测与导航。
221
135
  * Load homepage JSON and optional CSS and Bridge at fixed relative paths, then share module probes and navigation.
@@ -225,8 +139,7 @@ export async function startHome() {
225
139
  let host;
226
140
  let menu;
227
141
  let navigation;
228
- let frame;
229
- let linkTitle;
142
+ let page;
230
143
  let statuses = [];
231
144
  let destroyed = false;
232
145
  let defaults;
@@ -237,9 +150,11 @@ export async function startHome() {
237
150
  destroyed = true;
238
151
  controller.abort();
239
152
  for (const { status } of statuses) status.destroy();
240
- navigation?.destroy();
241
- host?.destroy();
242
- menu?.destroy();
153
+ if (page) page.destroy();
154
+ else {
155
+ host?.destroy();
156
+ menu?.destroy();
157
+ }
243
158
  stylesheet?.remove();
244
159
  if (defaults?.owned) defaults.element.remove();
245
160
  window.removeEventListener("pagehide", pagehide);
@@ -301,8 +216,8 @@ export async function startHome() {
301
216
  }
302
217
  if (destroyed) return destroy;
303
218
  document.title = config.title;
304
- menu = new ActionMenu(id => frame.perform(id));
305
- host = await factory({ menu, back: () => (navigation.current && frame ? frame.back() : navigation.back()) });
219
+ menu = new ActionMenu(id => page.perform(id));
220
+ host = await factory({ menu, back: () => navigation.back() });
306
221
  if (destroyed) {
307
222
  host.destroy();
308
223
  menu.destroy();
@@ -310,82 +225,20 @@ export async function startHome() {
310
225
  }
311
226
  const container = document.querySelector("[data-preference-panes-pages]");
312
227
  const home = document.querySelector("[data-preference-panes-home]");
313
- const template = document.querySelector("template[data-preference-panes-module]");
314
228
  renderHome(config, jsonURL, home);
315
229
  const buttons = [...home.querySelectorAll("[data-module]")];
316
230
  const links = [...home.querySelectorAll("a[href]")];
317
- const stylesheetURL = stylesheet?.href;
318
- const update = () => {
319
- if (destroyed) return;
320
- const state = navigation.current && frame ? frame.state : { title: navigation.current ? linkTitle : document.title, actions: [], busy: false, canGoBack: navigation.canGoBack };
321
- menu.update(state.actions, state.busy);
322
- Promise.resolve(host.update(state)).catch(error => {
323
- if (!destroyed) host.notice({ kind: "error", message: error.message });
324
- });
325
- };
326
- navigation = new Navigation(container, home, (module, signal) => {
327
- frame = undefined;
328
- const link = links.find((link, index) => link.dataset.target === "internal" && module === `link:${index}`);
329
- if (link) {
330
- const page = document.createElement("section");
331
- page.dataset.preferencePanesLinkPage = "";
332
- const iframe = document.createElement("iframe");
333
- linkTitle = link.querySelector(".pp-home-name").textContent;
334
- iframe.title = linkTitle;
335
- iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox");
336
- iframe.src = link.href;
337
- page.append(iframe);
338
- signal.addEventListener("abort", () => iframe.remove(), { once: true });
339
- return page;
340
- }
341
- const button = buttons.find(button => button.dataset.module === module);
342
- if (!button) return;
343
- const page = template.content.firstElementChild.cloneNode(true);
344
- page.dataset.preferencePanesModulePage = "";
345
- const message = page.querySelector("[data-module-message]");
346
- const moduleName = encodeURIComponent(button.dataset.preferencePanesModule ?? module);
347
- const current = new ModuleFrame(`/settings/${moduleName}`, {
348
- signal,
349
- headers: { "X-PreferencePanes-JSON": `/api/${encodeURIComponent(module)}`, ...(stylesheetURL ? { "X-PreferencePanes-CSS": stylesheetURL } : {}) },
350
- });
351
- frame = current;
352
- current.addEventListener("confirm", event => {
353
- event.preventDefault();
354
- Promise.resolve()
355
- .then(() => host.confirm(event.detail.message))
356
- .then(
357
- value => {
358
- if (!signal.aborted) event.detail.resolve(value);
359
- },
360
- error => {
361
- if (!signal.aborted) event.detail.reject(error);
362
- },
363
- );
364
- });
365
- current.addEventListener("notice", event => {
366
- event.preventDefault();
367
- host.notice(event.detail);
368
- });
369
- current.addEventListener("open-url", event => {
370
- event.preventDefault();
371
- Promise.resolve()
372
- .then(() => host.openURL(event.detail.url))
373
- .catch(error => {
374
- if (!signal.aborted) host.notice({ kind: "error", message: error.message });
375
- });
376
- });
377
- current.addEventListener("change", () => {
378
- if (!signal.aborted) update();
379
- });
380
- current.element.onload = () => {
381
- if (!signal.aborted) message.hidden = true;
382
- };
383
- page.append(current.element);
384
- current.load().catch(error => {
385
- if (!signal.aborted) message.textContent = error.message;
386
- });
387
- return page;
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,
388
240
  });
241
+ navigation = page.navigation;
389
242
  for (const [index, link] of links.entries()) {
390
243
  link.addEventListener(
391
244
  "click",
@@ -394,16 +247,10 @@ export async function startHome() {
394
247
  event.preventDefault();
395
248
  switch (link.dataset.target) {
396
249
  case "internal":
397
- navigation.open(`link:${index}`);
250
+ navigation.open(`$link/${index}`);
398
251
  break;
399
252
  default:
400
- try {
401
- Promise.resolve(host.openURL(link.href, link.dataset.target)).catch(error => {
402
- if (!destroyed) host.notice({ kind: "error", message: error.message });
403
- });
404
- } catch (error) {
405
- if (!destroyed) host.notice({ kind: "error", message: error.message });
406
- }
253
+ page.openURL(link.href, link.dataset.target);
407
254
  break;
408
255
  }
409
256
  },
@@ -419,7 +266,6 @@ export async function startHome() {
419
266
  return { button, status };
420
267
  });
421
268
  const probe = () => {
422
- update();
423
269
  if (navigation.current) return;
424
270
  for (const { button, status } of statuses) status.check(`/api/${encodeURIComponent(button.dataset.module)}`);
425
271
  };
@@ -1,103 +1,62 @@
1
+ import { ActionMenu } from "./ActionMenu.mjs";
1
2
  import { normalizeBoxJs } from "./boxjs.mjs";
2
- import { element, resourceURL } from "./components.mjs";
3
- import { PreferencesPanel } from "./panel.mjs";
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
- * 管理 BoxJS 规范化、主题同步和面板生命周期。
8
- * Manage BoxJS normalization, theme synchronization, and panel lifecycle.
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
- class PreferencesView {
11
- #existing;
12
- #root;
13
- #base;
14
- #ownsBase;
15
- #previousTitle;
16
- #previousTheme;
17
- #systemTheme;
18
- #previousKeyboard;
19
- #host;
20
- #observer;
21
- #panel;
22
-
23
- /**
24
- * 使用原始 BoxJS JSON 挂载设置页。
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
- * 跟随嵌入宿主的通用环境状态,不识别业务 App 或解析其 UA。
67
- * Follow generic host appearance without detecting a business App or parsing its UA.
68
- * @returns {void} 已同步主题与键盘避让 / Theme and keyboard clearance synchronized.
69
- */
70
- #syncAppearance = () => {
71
- const theme = this.#host?.dataset.theme ?? this.#previousTheme ?? (this.#systemTheme.matches ? "dark" : "light");
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 view, styles, and session without operating a project landing page.
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
- this.#observer?.disconnect();
83
- this.#systemTheme.removeEventListener("change", this.#syncAppearance);
84
- this.#panel?.destroy();
85
- if (this.#ownsBase) this.#base.remove();
86
- if (this.#existing) this.#root.replaceChildren();
87
- else this.#root.remove();
88
- document.title = this.#previousTitle;
89
- if (this.#previousTheme === undefined) delete document.documentElement.dataset.theme;
90
- else document.documentElement.dataset.theme = this.#previousTheme;
91
- document.documentElement.style.setProperty("--pp-keyboard-height", this.#previousKeyboard);
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
  }