@nsnanocat/preference-panes 1.3.0 → 1.3.2

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.
@@ -0,0 +1,324 @@
1
+ import { normalizeBoxJs } from "./boxjs.mjs";
2
+ import { element, statusView } from "./components.mjs";
3
+ import { Navigation } from "./Navigation.mjs";
4
+ import { PreferencesPanel } from "./panel.mjs";
5
+
6
+ /**
7
+ * 浏览器宿主提供网页导航、通知、主题和可视区域避让。
8
+ * The browser host provides web navigation, notices, theme and viewport clearance.
9
+ * @param {import("./home.mjs").BridgeOptions} options 菜单和返回动作 / Menu and back action.
10
+ * @returns {import("./home.mjs").Bridge} 浏览器宿主 / Browser host.
11
+ */
12
+ export function browserHost({ menu, back }) {
13
+ const root = document.documentElement;
14
+ const header = document.createElement("header");
15
+ header.className = "pp-host-header";
16
+ const button = document.createElement("button");
17
+ button.className = "pp-host-back";
18
+ button.type = "button";
19
+ button.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="m15 5-7 7 7 7"/></svg>';
20
+ button.setAttribute("aria-label", "返回");
21
+ button.onclick = back;
22
+ const left = document.createElement("span");
23
+ left.className = "pp-host-left";
24
+ left.append(button);
25
+ const title = document.createElement("span");
26
+ title.className = "pp-host-title";
27
+ const brand = document.createElement("div");
28
+ brand.className = "pp-host-brand";
29
+ const brandLink = document.createElement("a");
30
+ brandLink.className = "pp-host-brand-link";
31
+ const logo = document.createElement("span");
32
+ logo.className = "pp-host-logo";
33
+ const image = document.createElement("img");
34
+ image.alt = "logo";
35
+ logo.append(image);
36
+ brandLink.append(logo, title);
37
+ brand.append(brandLink);
38
+ header.append(left, brand, menu.element);
39
+ document.body.prepend(header);
40
+ const toast = document.createElement("div");
41
+ toast.className = "pp-host-toast";
42
+ toast.setAttribute("role", "status");
43
+ toast.hidden = true;
44
+ document.body.append(toast);
45
+ let timer;
46
+ const theme = window.matchMedia("(prefers-color-scheme: dark)");
47
+ const applyTheme = () => {
48
+ root.dataset.theme = theme.matches ? "dark" : "light";
49
+ };
50
+ const viewport = window.visualViewport;
51
+ const resize = () => {
52
+ root.style.setProperty("--pp-host-height", `${header.getBoundingClientRect().height}px`);
53
+ const clearance = viewport && viewport.scale === 1 ? Math.max(0, window.innerHeight - viewport.height - viewport.offsetTop) : 0;
54
+ root.style.setProperty("--pp-keyboard-height", `${clearance}px`);
55
+ };
56
+ const scroll = () => {
57
+ const pages = document.querySelector("[data-preference-panes-pages]");
58
+ const current = [...pages.children].find(view => !view.inert);
59
+ header.classList.toggle("pp-scrolled", (current?.scrollTop ?? 0) >= 100);
60
+ };
61
+ document.addEventListener("scroll", scroll, true);
62
+ theme.addEventListener("change", applyTheme);
63
+ window.addEventListener("resize", resize);
64
+ window.addEventListener("pageshow", resize);
65
+ viewport?.addEventListener("resize", resize);
66
+ viewport?.addEventListener("scroll", resize);
67
+ applyTheme();
68
+ resize();
69
+ return {
70
+ update(state) {
71
+ title.textContent = state.logoText ?? state.title;
72
+ brandLink.href = state.logoHref ?? "/";
73
+ logo.hidden = !state.logo;
74
+ if (state.logo) image.src = state.logo;
75
+ menu.element.hidden = !state.actions.length;
76
+ button.disabled = state.busy || !state.canGoBack;
77
+ scroll();
78
+ },
79
+ confirm: message => window.confirm(message),
80
+ notice(detail) {
81
+ clearTimeout(timer);
82
+ toast.textContent = detail.message;
83
+ toast.hidden = false;
84
+ timer = setTimeout(() => {
85
+ toast.hidden = true;
86
+ }, 2400);
87
+ },
88
+ openURL(url, target) {
89
+ switch (target) {
90
+ case "_blank":
91
+ window.open(url, "_blank", "noopener,noreferrer");
92
+ break;
93
+ default:
94
+ window.location.assign(url);
95
+ break;
96
+ }
97
+ },
98
+ destroy() {
99
+ clearTimeout(timer);
100
+ document.removeEventListener("scroll", scroll, true);
101
+ theme.removeEventListener("change", applyTheme);
102
+ window.removeEventListener("resize", resize);
103
+ window.removeEventListener("pageshow", resize);
104
+ viewport?.removeEventListener("resize", resize);
105
+ viewport?.removeEventListener("scroll", resize);
106
+ root.style.removeProperty("--pp-host-height");
107
+ root.style.removeProperty("--pp-keyboard-height");
108
+ delete root.dataset.theme;
109
+ header.remove();
110
+ toast.remove();
111
+ },
112
+ };
113
+ }
114
+
115
+ /**
116
+ * 一个文档共享宿主、路由和当前模块会话,字段子页不重新读取配置。
117
+ * Share one host, router and module session within a document without reloading config for field views.
118
+ */
119
+ export class PageController {
120
+ #navigation;
121
+ #host;
122
+ #menu;
123
+ #title;
124
+ #logo;
125
+ #logoText;
126
+ #logoHref;
127
+ #modules;
128
+ #links;
129
+ #session;
130
+ #destroyed = false;
131
+
132
+ /**
133
+ * 接管已初始化的宿主与首页;此接口仅供包内入口组合。
134
+ * Own an initialized host and homepage; this interface is only used by internal entry points.
135
+ * @param {HTMLElement} container 页面容器 / Page container.
136
+ * @param {HTMLElement} home 首页节点 / Homepage node.
137
+ * @param {object} options 宿主、菜单、品牌及入口 / Host, menu, brand and entries.
138
+ */
139
+ constructor(container, home, { host, menu, title, logo, logoText, logoHref, modules, links = [] }) {
140
+ this.#host = host;
141
+ this.#menu = menu;
142
+ this.#title = title;
143
+ this.#logo = logo;
144
+ this.#logoText = logoText || (logo ? "" : title);
145
+ this.#logoHref = logoHref;
146
+ this.#modules = modules;
147
+ this.#links = links;
148
+ this.#navigation = new Navigation(container, home, (key, signal) => this.#view(key, signal));
149
+ this.#navigation.addEventListener("change", () => this.update());
150
+ this.update();
151
+ }
152
+
153
+ /**
154
+ * 创建模块会话或复用其字段页;外部网站才使用 iframe。
155
+ * Create a module session or reuse a field view; only external sites use an iframe.
156
+ * @param {string} key 路由键 / Route key.
157
+ * @param {AbortSignal} signal 当前外链视图的取消信号 / Cancellation signal for the current external view.
158
+ * @returns {HTMLElement | undefined} 视图;未知模块返回 undefined / View; undefined for unknown modules.
159
+ */
160
+ #view(key, signal) {
161
+ const [module, ...parts] = key.split("/");
162
+ const view = parts.join("/");
163
+ if (module !== this.#session?.module) {
164
+ this.#session?.controller.abort();
165
+ this.#session?.panel?.destroy();
166
+ this.#session = undefined;
167
+ }
168
+ if (module === "$link") {
169
+ const link = this.#links[Number(view)];
170
+ if (!link || link.dataset.target !== "internal") return;
171
+ const page = element("section", "pp-link-page");
172
+ const iframe = element("iframe", "");
173
+ iframe.title = link.querySelector(".pp-home-name").textContent;
174
+ iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox");
175
+ iframe.src = link.href;
176
+ page.append(iframe);
177
+ signal.addEventListener("abort", () => iframe.remove(), { once: true });
178
+ return page;
179
+ }
180
+ const entry = this.#modules.get(module);
181
+ if (!entry) return;
182
+ if (!this.#session) {
183
+ const controller = new AbortController();
184
+ const root = element("section", "pp-module-page");
185
+ const session = { module, root, controller };
186
+ this.#session = session;
187
+ const load = async () => {
188
+ if (controller.signal.aborted) return;
189
+ root.replaceChildren(statusView("正在打开设置…"));
190
+ try {
191
+ let boxjs = entry.boxjs;
192
+ if (!boxjs) {
193
+ const response = await fetch(`/api/${encodeURIComponent(module)}`, { cache: "no-store", signal: controller.signal });
194
+ if (!response.ok) throw new Error(`配置加载失败:HTTP ${response.status}`);
195
+ boxjs = await response.json();
196
+ }
197
+ if (controller.signal.aborted) return;
198
+ root.replaceChildren();
199
+ session.panel = new PreferencesPanel(root, normalizeBoxJs(boxjs), this);
200
+ this.update();
201
+ } catch (error) {
202
+ if (controller.signal.aborted) return;
203
+ root.replaceChildren(statusView(`加载失败:${error.message}`, load));
204
+ }
205
+ };
206
+ // 已提供 BoxJS 时也等待路由切换完成,再发布表单状态。
207
+ // Wait for routing to finish before publishing form state, even with supplied BoxJS.
208
+ queueMicrotask(load);
209
+ }
210
+ return this.#session.panel?.view(view) ?? this.#session.root;
211
+ }
212
+
213
+ /**
214
+ * 同步唯一标题栏与菜单;离开模块时终止读取和会话。
215
+ * Synchronize the only title bar and menu, ending reads and sessions on departure.
216
+ * @returns {void} 无返回值 / No return value.
217
+ */
218
+ update() {
219
+ if (this.#destroyed || !this.#navigation) return;
220
+ const [module, ...parts] = this.#navigation.current.split("/");
221
+ if (!module && this.#session) {
222
+ this.#session.controller.abort();
223
+ this.#session.panel?.destroy();
224
+ this.#session = undefined;
225
+ }
226
+ const state = this.#session?.panel?.state(parts.join("/")) ?? {
227
+ title: module === "$link" ? this.#links[Number(parts[0])].querySelector(".pp-home-name").textContent : (this.#modules.get(module)?.name ?? this.#title),
228
+ actions: [],
229
+ busy: false,
230
+ };
231
+ document.title = state.title;
232
+ this.#menu.update(state.actions, state.busy);
233
+ 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 }));
234
+ }
235
+
236
+ /**
237
+ * 控件重建后重新解析当前路由,不增加历史或重新加载模块配置。
238
+ * Resolve the current route after rebuilding controls without adding history or reloading config.
239
+ * @returns {void} 无返回值 / No return value.
240
+ */
241
+ refresh() {
242
+ if (!this.#destroyed) this.#navigation.refresh();
243
+ }
244
+
245
+ /**
246
+ * 打开当前模块的字段或操作子页。
247
+ * Open a field or action subview of the current module.
248
+ * @param {string} view 子页键 / Detail key.
249
+ * @returns {void} 无返回值 / No return value.
250
+ */
251
+ open(view) {
252
+ this.#navigation.open(`${this.#session.module}/${view}`);
253
+ }
254
+
255
+ /**
256
+ * 执行当前表单操作。
257
+ * Perform an action on the current form.
258
+ * @param {string} id 操作标识 / Action identifier.
259
+ * @returns {void} 无返回值 / No return value.
260
+ */
261
+ perform(id) {
262
+ this.#session?.panel?.perform(id);
263
+ }
264
+
265
+ /**
266
+ * 请求宿主确认;退出后忽略晚到的选择。
267
+ * Request host confirmation and ignore a late choice after departure.
268
+ * @param {string} message 确认文字 / Confirmation message.
269
+ * @returns {Promise<boolean>} 用户选择 / User choice.
270
+ */
271
+ async confirm(message) {
272
+ const session = this.#session;
273
+ const confirmed = await this.#host.confirm(message);
274
+ return !this.#destroyed && session === this.#session && confirmed;
275
+ }
276
+
277
+ /**
278
+ * 由唯一宿主显示通知。
279
+ * Display a notice through the only host.
280
+ * @param {import("./Navigation.mjs").Notice} detail 已格式化通知 / Formatted notice.
281
+ * @returns {void} 无返回值 / No return value.
282
+ */
283
+ notice(detail) {
284
+ if (!this.#destroyed) this.#host.notice(detail);
285
+ }
286
+
287
+ /**
288
+ * 由宿主执行标准外链导航。
289
+ * Delegate standard external navigation to the host.
290
+ * @param {string} url 完整地址 / Absolute URL.
291
+ * @param {"_self" | "_blank"} [target] 打开方式 / Opening mode.
292
+ * @returns {void} 无返回值 / No return value.
293
+ */
294
+ openURL(url, target) {
295
+ try {
296
+ Promise.resolve(this.#host.openURL(url, target)).catch(error => this.notice({ kind: "error", message: error.message }));
297
+ } catch (error) {
298
+ this.notice({ kind: "error", message: error.message });
299
+ }
300
+ }
301
+
302
+ /**
303
+ * 共用首页与表单导航。
304
+ * Navigation shared by the homepage and forms.
305
+ */
306
+ get navigation() {
307
+ return this.#navigation;
308
+ }
309
+
310
+ /**
311
+ * 结束页面能力和会话;表单写入队列自行收尾。
312
+ * End page capabilities and sessions while forms finish their write queues.
313
+ * @returns {void} 无返回值 / No return value.
314
+ */
315
+ destroy() {
316
+ if (this.#destroyed) return;
317
+ this.#destroyed = true;
318
+ this.#session?.controller.abort();
319
+ this.#session?.panel?.destroy();
320
+ this.#navigation.destroy();
321
+ this.#host.destroy();
322
+ this.#menu.destroy();
323
+ }
324
+ }