@nsnanocat/preference-panes 0.7.0 → 0.7.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/README.md +41 -1
- package/dist/module/app.mjs +220 -80
- package/dist/module/index.html +1 -1
- package/dist/module/navigation.mjs +160 -0
- package/dist/preference-panes.mjs +188 -71
- package/dist/preference-panes.proxy.js +28 -3
- package/package.json +5 -1
- package/src/browser/Navigation.d.mts +43 -0
- package/src/browser/Navigation.mjs +158 -0
- package/src/browser/app.mjs +15 -9
- package/src/browser/components.mjs +1 -1
- package/src/browser/panel.css +24 -2
- package/src/browser/panel.mjs +28 -69
- package/src/build.mjs +3 -1
- package/src/lib/page-inputs.mjs +17 -0
- package/src/proxy/handler.mjs +10 -2
package/README.md
CHANGED
|
@@ -32,8 +32,48 @@ const files = await build(boxjs, css);
|
|
|
32
32
|
|
|
33
33
|
直接访问 `/settings/{module}` 时,启动器先导入 `/configs/{module}` JSON 与该模块 CSS,再调用 mount。JSON 缺失或与 URL 不符时不生成表单。项目主页可自行 HEAD `/configs/{module}` 判断入口可用性;PreferencePanes 不接管主页探测逻辑。
|
|
34
34
|
|
|
35
|
+
也支持为模块页传入 JSON/CSS **资源 URL**:
|
|
36
|
+
|
|
37
|
+
```http
|
|
38
|
+
GET /settings/Module?json=%2Fconfigs%2FModule&css=%2Ftheme.css
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
```http
|
|
42
|
+
GET /settings/Module
|
|
43
|
+
X-PreferencePanes-JSON: /configs/Module
|
|
44
|
+
X-PreferencePanes-CSS: /theme.css
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
每个 Header 分别优先于对应查询参数,未提供时使用上述模块约定;CSS 传空字符串时仅使用内置默认样式。资源必须为 HTTP(S) 或相对 URL,跨域资源需要允许浏览器 CORS。模块身份仍需与 BoxJS 一致,不改变存储根绑定。
|
|
48
|
+
|
|
49
|
+
Header 方式需要独立代理脚本处理页面请求,将输入写入 HTML;静态 HTML Mock 无法读取请求头。普通网页用 fetch 携带 Header,并把返回的 HTML 赋给同源 iframe.srcdoc;不要用 innerHTML。iframe 隔离模块 CSS,模块二级页使用自身 fragment 历史;主页负责建立进入模块的历史记录,返回时销毁 iframe。重新打开或刷新由主页重新 fetch,不用 localStorage/sessionStorage 缓存输入或设置。
|
|
50
|
+
|
|
35
51
|
## 模块页行为
|
|
36
52
|
|
|
53
|
+
### 共用导航组件
|
|
54
|
+
|
|
55
|
+
模块二级页与外部定制主页复用 `Navigation`,通过独立导出使用,不引入表单、BoxJS 或品牌目录:
|
|
56
|
+
|
|
57
|
+
```js
|
|
58
|
+
import { Navigation } from "@nsnanocat/preference-panes/navigation";
|
|
59
|
+
|
|
60
|
+
const navigation = new Navigation(container, home, (key, signal) => {
|
|
61
|
+
// 返回由项目创建的子页节点;异步加载监听 signal,在退出时取消。
|
|
62
|
+
// Return the project's detail node; async loads observe signal for cancellation on departure.
|
|
63
|
+
return detailViews.get(key);
|
|
64
|
+
});
|
|
65
|
+
navigation.open("detail");
|
|
66
|
+
navigation.addEventListener("change", () => { back.disabled = !navigation.canGoBack; });
|
|
67
|
+
back.onclick = () => navigation.back();
|
|
68
|
+
// 卸载整个组件时释放监听器和页面。
|
|
69
|
+
// Release listeners and views when unmounting the entire component.
|
|
70
|
+
navigation.destroy();
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
工厂返回 HTMLElement,未知键返回 undefined;布局、背景与内容由调用方 CSS/DOM 定义。容器内只放导航管理的节点,组件接管其子节点。根页一直保留,子页 280ms 滑入/滑出,返回动画结束后移除。模块内部返回缓存节点保留控件值与滚动;外部主页每次创建新 iframe,重新读取设置。快速切换会取消旧加载与动画,减少动态效果时立即切换。历史记录、书签初始根页和 srcdoc fragment 只在组件中处理;实例之间依靠浏览器联合历史,不跨 iframe 操作 DOM。
|
|
74
|
+
|
|
75
|
+
`build` 额外输出公共 `settings/assets/navigation.mjs`,静态主页可直接导入该地址;它不生成或接管主页内容。
|
|
76
|
+
|
|
37
77
|
渲染器使用已导入的 JSON 创建控件,只 GET 一次设置子树;不会再次请求配置或探测其它模块。打开/刷新模块文档重新导入,再读取设置。单选为下拉框,多选为二级选项页;二级返回不刷新设置值。
|
|
38
78
|
|
|
39
79
|
修改立即串行 POST,200 后更新当前内存并通知;失败恢复已保存值。保留 Caches 查看/清空和模块重置,不展示逐字段保存或删除按钮。API 按 BoxJS 根和模块用 util 读写任意 JSON 路径,不重复校验控件或枚举。
|
|
@@ -48,6 +88,6 @@ npm run preview
|
|
|
48
88
|
|
|
49
89
|
## 维护
|
|
50
90
|
|
|
51
|
-
遵循 AGENTS.md 与通用 Biome 配置。运行 `npm run check` 和 `npm run apifox:check` 验证代码、类型、行为与文档。0.7.
|
|
91
|
+
遵循 AGENTS.md 与通用 Biome 配置。运行 `npm run check` 和 `npm run apifox:check` 验证代码、类型、行为与文档。0.7.1 采用单模块双输入接口,并将页面与主页导航收敛到共用组件;运行时无额外 npm 依赖。
|
|
52
92
|
|
|
53
93
|
[接口规范](apifox/Specification.md) · [Apifox JSON](apifox/preference-panes.apifox.json)
|
package/dist/module/app.mjs
CHANGED
|
@@ -103,6 +103,24 @@ function metadata(source) {
|
|
|
103
103
|
return result;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
/**
|
|
107
|
+
* 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
|
|
108
|
+
* Resolve module resource locations: headers override query parameters and module conventions.
|
|
109
|
+
* @param {URL} url 已解析的页面请求地址 / Parsed page request URL.
|
|
110
|
+
* @param {Record<string, string | undefined>} [headers] 请求头,名称不区分大小写 / Case-insensitive request headers.
|
|
111
|
+
* @returns {{url: string, module: string, json: string, css: string}} 页面上下文与两个资源输入 / Page context and two resource inputs.
|
|
112
|
+
*/
|
|
113
|
+
function pageInputs(url, headers = {}) {
|
|
114
|
+
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname);
|
|
115
|
+
if (!match) throw new TypeError("Open a concrete module URL");
|
|
116
|
+
const module = match[1];
|
|
117
|
+
const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
118
|
+
const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
|
|
119
|
+
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? `/settings/assets/${module}.css`;
|
|
120
|
+
if (!json.trim()) throw new TypeError("JSON resource URL is required");
|
|
121
|
+
return { url: url.href, module, json, css };
|
|
122
|
+
}
|
|
123
|
+
|
|
106
124
|
/**
|
|
107
125
|
* 创建元素,所有展示文本通过 textContent 写入。
|
|
108
126
|
* Create elements and assign display text through textContent only.
|
|
@@ -126,7 +144,7 @@ function element(tag, className, text) {
|
|
|
126
144
|
* @returns {string} 完整地址 / Absolute address.
|
|
127
145
|
*/
|
|
128
146
|
function resourceURL(value) {
|
|
129
|
-
const url = new URL(value,
|
|
147
|
+
const url = new URL(value, document.baseURI);
|
|
130
148
|
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Metadata URLs must use HTTP(S)");
|
|
131
149
|
return url.href;
|
|
132
150
|
}
|
|
@@ -163,7 +181,7 @@ function errorView(error, retry) {
|
|
|
163
181
|
return view;
|
|
164
182
|
}
|
|
165
183
|
|
|
166
|
-
var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\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 min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\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:
|
|
184
|
+
var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\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 min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\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: sticky;\n top: 0;\n z-index: 1;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-brand {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n text-align: center;\n}\n.pp-brand-icon {\n display: none;\n flex: none;\n width: 28px;\n height: 28px;\n}\n.pp-brand-icon:not(:empty) {\n display: block;\n}\n.pp-brand-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\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 height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n}\n.pp-fields,\n.pp-choice-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\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-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-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\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-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\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-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\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@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
|
|
167
185
|
|
|
168
186
|
/**
|
|
169
187
|
* 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
|
|
@@ -486,6 +504,165 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
486
504
|
};
|
|
487
505
|
}
|
|
488
506
|
|
|
507
|
+
/**
|
|
508
|
+
* 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
|
|
509
|
+
* Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
|
|
510
|
+
*/
|
|
511
|
+
class Navigation extends EventTarget {
|
|
512
|
+
#container;
|
|
513
|
+
#home;
|
|
514
|
+
#create;
|
|
515
|
+
#window;
|
|
516
|
+
#key = null;
|
|
517
|
+
#view;
|
|
518
|
+
#retiring;
|
|
519
|
+
#controller;
|
|
520
|
+
#animation;
|
|
521
|
+
#scroll = new WeakMap();
|
|
522
|
+
#onHistory = () => this.#route();
|
|
523
|
+
#onPageShow = event => {
|
|
524
|
+
if (event.persisted) this.#route(true);
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。
|
|
529
|
+
* Retain the home view and create details on demand; signal cancels async work after departure.
|
|
530
|
+
* @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.
|
|
531
|
+
* @param {HTMLElement} home 已创建的主页节点 / Existing home view.
|
|
532
|
+
* @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.
|
|
533
|
+
*/
|
|
534
|
+
constructor(container, home, create) {
|
|
535
|
+
super();
|
|
536
|
+
this.#container = container;
|
|
537
|
+
this.#home = home;
|
|
538
|
+
this.#create = create;
|
|
539
|
+
this.#window = container.ownerDocument.defaultView;
|
|
540
|
+
container.replaceChildren(home);
|
|
541
|
+
this.#window.addEventListener("popstate", this.#onHistory);
|
|
542
|
+
this.#window.addEventListener("hashchange", this.#onHistory);
|
|
543
|
+
this.#window.addEventListener("pageshow", this.#onPageShow);
|
|
544
|
+
this.#route();
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* 当前子页键;空字符串表示主页。
|
|
549
|
+
* Current detail key; empty means home.
|
|
550
|
+
*/
|
|
551
|
+
get current() {
|
|
552
|
+
return this.#key;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* 是否可以返回上一级或先前文档。
|
|
557
|
+
* Whether a parent view or previous document is available.
|
|
558
|
+
*/
|
|
559
|
+
get canGoBack() {
|
|
560
|
+
return Boolean(this.#key) || this.#window.history.length > 1;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* 加入子页历史;使用文档自身 URL,避免 srcdoc 按宿主 base URL 跳转。
|
|
565
|
+
* Push a detail using the document URL, avoiding srcdoc navigation against the host base URL.
|
|
566
|
+
* @param {string} key 子页键 / Detail key.
|
|
567
|
+
* @returns {void} 无返回值 / No return value.
|
|
568
|
+
*/
|
|
569
|
+
open(key) {
|
|
570
|
+
if (key === this.#key) return;
|
|
571
|
+
const url = new URL(this.#window.location.href);
|
|
572
|
+
url.hash = encodeURIComponent(key);
|
|
573
|
+
this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
|
|
574
|
+
this.#route();
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* 沿浏览器联合历史返回,根页可退回宿主或上个文档。
|
|
579
|
+
* Go back through joint history, including a host or previous document from home.
|
|
580
|
+
* @returns {void} 无返回值 / No return value.
|
|
581
|
+
*/
|
|
582
|
+
back() {
|
|
583
|
+
if (this.canGoBack) this.#window.history.back();
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
|
|
588
|
+
* Resolve the URL and coordinate transitions, cancellation and release after animation.
|
|
589
|
+
* @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.
|
|
590
|
+
* @returns {void} 无返回值 / No return value.
|
|
591
|
+
*/
|
|
592
|
+
#route(reload = false) {
|
|
593
|
+
const url = new URL(this.#window.location.href);
|
|
594
|
+
let key;
|
|
595
|
+
try {
|
|
596
|
+
key = decodeURIComponent(url.hash.slice(1));
|
|
597
|
+
} catch (error) {
|
|
598
|
+
if (!(error instanceof URIError)) throw error;
|
|
599
|
+
key = "";
|
|
600
|
+
}
|
|
601
|
+
if (!reload && key === this.#key) return;
|
|
602
|
+
this.#controller?.abort();
|
|
603
|
+
this.#controller = new AbortController();
|
|
604
|
+
const next = key ? this.#create(key, this.#controller.signal) : undefined;
|
|
605
|
+
if (!next) key = "";
|
|
606
|
+
const history = this.#window.history;
|
|
607
|
+
// 直接打开子页时建立一次主页历史;刷新不重复堆叠。
|
|
608
|
+
// Seed home history once for direct details, without stacking entries on reload.
|
|
609
|
+
if (url.hash && history.state?.preferencePanesRoute !== key) {
|
|
610
|
+
url.hash = "";
|
|
611
|
+
history.replaceState({ ...history.state, preferencePanesRoute: "" }, "", url.href);
|
|
612
|
+
if (key) {
|
|
613
|
+
url.hash = encodeURIComponent(key);
|
|
614
|
+
history.pushState({ ...history.state, preferencePanesRoute: key }, "", url.href);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const previous = this.#view;
|
|
618
|
+
const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
|
|
619
|
+
this.#animation?.cancel();
|
|
620
|
+
this.#retiring?.remove();
|
|
621
|
+
this.#retiring = previous;
|
|
622
|
+
if (previous) {
|
|
623
|
+
this.#scroll.set(previous, previous.scrollTop);
|
|
624
|
+
previous.inert = true;
|
|
625
|
+
}
|
|
626
|
+
this.#key = key;
|
|
627
|
+
this.#view = next;
|
|
628
|
+
this.#home.inert = Boolean(next);
|
|
629
|
+
if (next) {
|
|
630
|
+
next.inert = false;
|
|
631
|
+
this.#container.append(next);
|
|
632
|
+
next.scrollTop = this.#scroll.get(next) ?? 0;
|
|
633
|
+
}
|
|
634
|
+
const moving = next ?? previous;
|
|
635
|
+
if (moving) {
|
|
636
|
+
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" });
|
|
637
|
+
this.#animation = animation;
|
|
638
|
+
animation.onfinish = () => {
|
|
639
|
+
if (this.#animation !== animation) return;
|
|
640
|
+
this.#retiring?.remove();
|
|
641
|
+
this.#retiring = undefined;
|
|
642
|
+
animation.cancel();
|
|
643
|
+
this.#animation = undefined;
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
this.dispatchEvent(new Event("change"));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* 释放监听器、加载、动画和节点;调用方可重新创建导航。
|
|
651
|
+
* Release listeners, loads, animations and nodes so callers can recreate navigation.
|
|
652
|
+
* @returns {void} 无返回值 / No return value.
|
|
653
|
+
*/
|
|
654
|
+
destroy() {
|
|
655
|
+
this.#window.removeEventListener("popstate", this.#onHistory);
|
|
656
|
+
this.#window.removeEventListener("hashchange", this.#onHistory);
|
|
657
|
+
this.#window.removeEventListener("pageshow", this.#onPageShow);
|
|
658
|
+
this.#controller?.abort();
|
|
659
|
+
this.#animation?.cancel();
|
|
660
|
+
this.#retiring?.remove();
|
|
661
|
+
this.#view?.remove();
|
|
662
|
+
this.#home.remove();
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
489
666
|
/**
|
|
490
667
|
* 挂载已导入 BoxJS 对应的模块表单和短暂通知。
|
|
491
668
|
* Mount the imported BoxJS module form and transient notifications.
|
|
@@ -498,20 +675,27 @@ function mountPanel(root, catalog) {
|
|
|
498
675
|
const document = root.ownerDocument;
|
|
499
676
|
const window = document.defaultView;
|
|
500
677
|
const shell = element("div", "pp-panel");
|
|
678
|
+
shell.dataset.module = catalog.module.module;
|
|
501
679
|
const header = element("header", "pp-header");
|
|
502
680
|
const back = element("button", "pp-back", "‹");
|
|
503
681
|
back.setAttribute("aria-label", "返回");
|
|
504
682
|
back.type = "button";
|
|
505
683
|
const heading = element("h1", "pp-title", title);
|
|
684
|
+
const brand = element("div", "pp-brand");
|
|
685
|
+
const logo = element("span", "pp-brand-icon");
|
|
686
|
+
logo.setAttribute("aria-hidden", "true");
|
|
687
|
+
const image = icon(catalog.module.metadata, "");
|
|
688
|
+
if (image) logo.append(image);
|
|
689
|
+
brand.append(logo, heading);
|
|
506
690
|
const viewport = element("div", "pp-viewport");
|
|
507
691
|
const toast = element("div", "pp-toast");
|
|
508
692
|
toast.setAttribute("role", "status");
|
|
509
693
|
toast.hidden = true;
|
|
510
|
-
header.append(back,
|
|
694
|
+
header.append(back, brand, element("span", "pp-nav-spacer"));
|
|
511
695
|
shell.append(header, viewport, toast);
|
|
512
696
|
root.append(shell);
|
|
513
697
|
let timer,
|
|
514
|
-
|
|
698
|
+
navigation,
|
|
515
699
|
generation = 0,
|
|
516
700
|
active = null,
|
|
517
701
|
saving = false,
|
|
@@ -549,25 +733,6 @@ function mountPanel(root, catalog) {
|
|
|
549
733
|
}, 2400);
|
|
550
734
|
};
|
|
551
735
|
const client = createPreferencesClient({ catalog, notify });
|
|
552
|
-
/**
|
|
553
|
-
* 切换加载或错误视图,按用户的动态效果偏好播放过渡。
|
|
554
|
-
* Replace a loading or error view, respecting reduced-motion preferences.
|
|
555
|
-
* @param {HTMLElement} view 新视图 / New view.
|
|
556
|
-
* @param {number} direction 过渡方向,正数从右侧进入 / Transition direction; positive enters from the right.
|
|
557
|
-
* @returns {void} 无返回值 / No return value.
|
|
558
|
-
*/
|
|
559
|
-
function replace(view, direction) {
|
|
560
|
-
const old = viewport.firstElementChild;
|
|
561
|
-
viewport.replaceChildren(view);
|
|
562
|
-
if (old && !window.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
|
563
|
-
view.animate(
|
|
564
|
-
[
|
|
565
|
-
{ opacity: 0.4, transform: `translateX(${direction * 24}px)` },
|
|
566
|
-
{ opacity: 1, transform: "translateX(0)" },
|
|
567
|
-
],
|
|
568
|
-
{ duration: 180, easing: "ease-out" },
|
|
569
|
-
);
|
|
570
|
-
}
|
|
571
736
|
/**
|
|
572
737
|
* 打开模块并忽略已过期的异步结果。
|
|
573
738
|
* Open a module and ignore stale asynchronous results.
|
|
@@ -579,16 +744,13 @@ function mountPanel(root, catalog) {
|
|
|
579
744
|
active = module;
|
|
580
745
|
back.disabled = window.history.length <= 1;
|
|
581
746
|
heading.textContent = module;
|
|
582
|
-
|
|
747
|
+
viewport.replaceChildren(element("p", "pp-loading", "读取设置…"));
|
|
583
748
|
try {
|
|
584
749
|
await client.open(module);
|
|
585
750
|
if (version === generation) controls();
|
|
586
751
|
} catch (error) {
|
|
587
752
|
if (version !== generation) return;
|
|
588
|
-
|
|
589
|
-
errorView(error, () => open(module)),
|
|
590
|
-
1,
|
|
591
|
-
);
|
|
753
|
+
viewport.replaceChildren(errorView(error, () => open(module)));
|
|
592
754
|
}
|
|
593
755
|
}
|
|
594
756
|
/**
|
|
@@ -609,37 +771,18 @@ function mountPanel(root, catalog) {
|
|
|
609
771
|
const editors = new Map();
|
|
610
772
|
const summaries = [];
|
|
611
773
|
const groups = new Map();
|
|
612
|
-
const scrollPositions = new WeakMap();
|
|
613
|
-
let activeEditor;
|
|
614
774
|
let queue = Promise.resolve(),
|
|
615
775
|
pendingWrites = 0;
|
|
616
776
|
/**
|
|
617
|
-
*
|
|
618
|
-
*
|
|
777
|
+
* 导航组件处理页面切换,表单只更新当前标题与返回按钮。
|
|
778
|
+
* Let navigation own transitions; the form only updates the title and back button.
|
|
619
779
|
* @returns {void} 无返回值 / No return value.
|
|
620
780
|
*/
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
try {
|
|
624
|
-
key = decodeURIComponent(window.location.hash.slice(1));
|
|
625
|
-
} catch {
|
|
626
|
-
key = "";
|
|
627
|
-
}
|
|
628
|
-
const editor = editors.get(key);
|
|
629
|
-
const previous = activeEditor?.node ?? view;
|
|
630
|
-
const next = editor?.node ?? view;
|
|
631
|
-
if (previous !== next) {
|
|
632
|
-
scrollPositions.set(previous, previous.scrollTop);
|
|
633
|
-
previous.remove();
|
|
634
|
-
viewport.append(next);
|
|
635
|
-
next.scrollTop = scrollPositions.get(next) ?? 0;
|
|
636
|
-
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) next.animate([{ transform: `translateX(${editor ? 100 : -100}%)` }, { transform: "translateX(0)" }], { duration: 260, easing: "cubic-bezier(.22,.61,.36,1)" });
|
|
637
|
-
}
|
|
638
|
-
activeEditor = editor;
|
|
781
|
+
const updateNavigation = () => {
|
|
782
|
+
const editor = editors.get(navigation.current);
|
|
639
783
|
heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
|
|
640
|
-
back.disabled = saving ||
|
|
784
|
+
back.disabled = saving || !navigation.canGoBack;
|
|
641
785
|
};
|
|
642
|
-
secondaryRoute = showEditor;
|
|
643
786
|
/**
|
|
644
787
|
* 串行执行模块操作,保持输入可编辑。
|
|
645
788
|
* Serialize module actions while keeping inputs editable.
|
|
@@ -666,7 +809,7 @@ function mountPanel(root, catalog) {
|
|
|
666
809
|
pendingWrites--;
|
|
667
810
|
saving = pendingWrites > 0;
|
|
668
811
|
if (destroyed && !saving) client.leave(active);
|
|
669
|
-
back.disabled = saving ||
|
|
812
|
+
back.disabled = saving || !navigation.canGoBack;
|
|
670
813
|
}));
|
|
671
814
|
}
|
|
672
815
|
const metadata = definition.metadata;
|
|
@@ -755,10 +898,7 @@ function mountPanel(root, catalog) {
|
|
|
755
898
|
};
|
|
756
899
|
summaries.push(refresh);
|
|
757
900
|
refresh();
|
|
758
|
-
link.onclick = () =>
|
|
759
|
-
window.history.pushState({ ...window.history.state, preferencePane: active }, "", `#${encodeURIComponent(field.key)}`);
|
|
760
|
-
showEditor();
|
|
761
|
-
};
|
|
901
|
+
link.onclick = () => navigation.open(field.key);
|
|
762
902
|
row.addEventListener("click", event => {
|
|
763
903
|
if (!link.contains(event.target)) link.click();
|
|
764
904
|
});
|
|
@@ -905,26 +1045,22 @@ function mountPanel(root, catalog) {
|
|
|
905
1045
|
actions.append(cacheView, cacheClear, reset);
|
|
906
1046
|
maintenance.append(actions, output);
|
|
907
1047
|
view.append(maintenance);
|
|
908
|
-
|
|
1048
|
+
navigation?.destroy();
|
|
1049
|
+
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
1050
|
+
navigation.addEventListener("change", updateNavigation);
|
|
909
1051
|
for (const grow of growingInputs) grow();
|
|
910
|
-
|
|
1052
|
+
updateNavigation();
|
|
911
1053
|
}
|
|
912
1054
|
/**
|
|
913
|
-
*
|
|
914
|
-
*
|
|
1055
|
+
* 已加载的表单交由导航组件返回;加载阶段可以返回先前文档。
|
|
1056
|
+
* Loaded forms delegate back to navigation; loading views can return to the previous document.
|
|
915
1057
|
* @returns {void} 无返回值 / No return value.
|
|
916
1058
|
*/
|
|
917
|
-
const onPopState = () => secondaryRoute?.();
|
|
918
|
-
const onHashChange = () => secondaryRoute?.();
|
|
919
1059
|
back.onclick = () => {
|
|
920
1060
|
if (saving) return;
|
|
921
|
-
if (
|
|
922
|
-
|
|
923
|
-
secondaryRoute?.();
|
|
924
|
-
} else window.history.back();
|
|
1061
|
+
if (navigation) navigation.back();
|
|
1062
|
+
else window.history.back();
|
|
925
1063
|
};
|
|
926
|
-
window.addEventListener("popstate", onPopState);
|
|
927
|
-
window.addEventListener("hashchange", onHashChange);
|
|
928
1064
|
open(catalog.module.module);
|
|
929
1065
|
return {
|
|
930
1066
|
/**
|
|
@@ -934,8 +1070,7 @@ function mountPanel(root, catalog) {
|
|
|
934
1070
|
*/
|
|
935
1071
|
destroy() {
|
|
936
1072
|
destroyed = true;
|
|
937
|
-
|
|
938
|
-
window.removeEventListener("hashchange", onHashChange);
|
|
1073
|
+
navigation?.destroy();
|
|
939
1074
|
generation++;
|
|
940
1075
|
if (active && !saving) client.leave(active);
|
|
941
1076
|
clearTimeout(timer);
|
|
@@ -1004,22 +1139,27 @@ function mount(boxjs, css = "") {
|
|
|
1004
1139
|
|
|
1005
1140
|
let view;
|
|
1006
1141
|
/**
|
|
1007
|
-
*
|
|
1008
|
-
* Import JSON
|
|
1142
|
+
* 从 URL 或代理传递的 Header 导入 JSON/CSS,支持独立文档与 srcdoc。
|
|
1143
|
+
* Import JSON/CSS from the URL or proxy-carried headers in standalone and srcdoc documents.
|
|
1009
1144
|
* @returns {Promise<void>} 启动完成 / Startup completion.
|
|
1010
1145
|
*/
|
|
1011
1146
|
async function start() {
|
|
1012
1147
|
try {
|
|
1013
1148
|
view?.destroy();
|
|
1014
1149
|
view = undefined;
|
|
1015
|
-
const
|
|
1016
|
-
|
|
1017
|
-
const
|
|
1018
|
-
|
|
1019
|
-
|
|
1150
|
+
const context = document.querySelector('meta[name="preference-panes-inputs"]');
|
|
1151
|
+
const inputs = context ? JSON.parse(decodeURIComponent(context.content)) : pageInputs(new URL(location.href));
|
|
1152
|
+
const resources = [inputs.json, inputs.css].map(source => {
|
|
1153
|
+
if (!source) return null;
|
|
1154
|
+
const url = new URL(source, inputs.url);
|
|
1155
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Resources must use HTTP(S) URLs");
|
|
1156
|
+
return url.href;
|
|
1157
|
+
});
|
|
1158
|
+
const [data, style] = await Promise.all(resources.map(url => (url ? fetch(url, { cache: "no-store", credentials: "omit" }) : null)));
|
|
1159
|
+
if (data.status !== 200 || (style && style.status !== 200)) throw new Error(`HTTP ${data.status !== 200 ? data.status : style.status}`);
|
|
1020
1160
|
const boxjs = await data.json();
|
|
1021
|
-
if (new BoxJS(boxjs).module.module !== module) throw new Error("Imported JSON does not match the module URL");
|
|
1022
|
-
view = mount(boxjs, await style.text());
|
|
1161
|
+
if (new BoxJS(boxjs).module.module !== inputs.module) throw new Error("Imported JSON does not match the module URL");
|
|
1162
|
+
view = mount(boxjs, style ? await style.text() : "");
|
|
1023
1163
|
} catch (error) {
|
|
1024
1164
|
document.querySelector("#preferences").replaceChildren(errorView(error, start));
|
|
1025
1165
|
}
|