@nsnanocat/preference-panes 0.6.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 +60 -116
- package/dist/module/app.mjs +1170 -0
- package/dist/module/index.html +14 -0
- package/dist/module/navigation.mjs +160 -0
- package/dist/preference-panes.config.js +839 -813
- package/dist/preference-panes.mjs +1052 -825
- package/dist/preference-panes.proxy.js +1751 -2123
- package/package.json +66 -67
- package/src/BoxJS.mjs +86 -0
- package/src/Store.mjs +127 -0
- package/src/browser/Navigation.d.mts +43 -0
- package/src/browser/Navigation.mjs +158 -0
- package/src/browser/app.mjs +30 -148
- package/src/browser/client.d.mts +150 -0
- package/src/browser/client.mjs +191 -212
- package/src/browser/components.mjs +59 -0
- package/src/browser/index.d.ts +19 -152
- package/src/browser/index.mjs +60 -5
- package/src/browser/module.html +14 -0
- package/src/browser/panel.css +245 -223
- package/src/browser/panel.mjs +414 -514
- package/src/build.mjs +33 -0
- package/src/index.d.ts +227 -133
- package/src/index.mjs +3 -6
- package/src/lib/boxjs.mjs +77 -99
- package/src/lib/page-inputs.mjs +17 -0
- package/src/lib/response.mjs +16 -0
- package/src/lib/settings-path.mjs +10 -23
- package/src/proxy/config.mjs +6 -11
- package/src/proxy/handler.mjs +48 -27
- package/src/proxy/response.mjs +16 -0
- package/dist/preference-panes.request.js +0 -2126
- package/dist/settings/app.mjs +0 -1044
- package/dist/settings/home.css +0 -134
- package/dist/settings/index.html +0 -15
- package/dist/settings/panel.css +0 -325
- package/src/PreferencesHandler.mjs +0 -50
- package/src/SettingsHandler.mjs +0 -144
- package/src/browser/home.css +0 -134
- package/src/browser/site.html +0 -15
- package/src/proxy/request.mjs +0 -5
package/package.json
CHANGED
|
@@ -1,69 +1,68 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
2
|
+
"name": "@nsnanocat/preference-panes",
|
|
3
|
+
"version": "0.7.1",
|
|
4
|
+
"description": "Shared settings API runtime for JavaScript proxy modules",
|
|
5
|
+
"author": "VirgilClyne <Virgil@nanocat.me>",
|
|
6
|
+
"homepage": "https://NSNanoCat.github.io/preference-panes",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"loon",
|
|
9
|
+
"quantumult",
|
|
10
|
+
"surge",
|
|
11
|
+
"shadowrocket",
|
|
12
|
+
"stash",
|
|
13
|
+
"egern",
|
|
14
|
+
"settings"
|
|
15
|
+
],
|
|
16
|
+
"license": "Apache-2.0",
|
|
17
|
+
"bugs": "https://github.com/NSNanoCat/PreferencePanes/issues",
|
|
18
|
+
"type": "module",
|
|
19
|
+
"main": "src/index.mjs",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./src/index.d.ts",
|
|
23
|
+
"import": "./src/index.mjs"
|
|
24
|
+
},
|
|
25
|
+
"./browser": {
|
|
26
|
+
"types": "./src/browser/index.d.ts",
|
|
27
|
+
"import": "./dist/preference-panes.mjs"
|
|
28
|
+
},
|
|
29
|
+
"./navigation": {
|
|
30
|
+
"types": "./src/browser/Navigation.d.mts",
|
|
31
|
+
"import": "./dist/module/navigation.mjs"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"types": "src/index.d.ts",
|
|
35
|
+
"scripts": {
|
|
36
|
+
"test": "node --test test/*.test.mjs",
|
|
37
|
+
"pretest": "npm run build",
|
|
38
|
+
"check": "npm run lint && npm run typecheck && npm test",
|
|
39
|
+
"lint": "biome check .",
|
|
40
|
+
"format": "biome check --write .",
|
|
41
|
+
"typecheck": "tsc --noEmit",
|
|
42
|
+
"build": "rollup -c",
|
|
43
|
+
"prebuild": "node --input-type=module -e \"import { rm } from 'node:fs/promises'; await rm('dist', { recursive: true, force: true });\"",
|
|
44
|
+
"prepack": "npm run build",
|
|
45
|
+
"preview": "npm run build && node examples/preview.mjs",
|
|
46
|
+
"apifox:generate": "node apifox/generate.mjs",
|
|
47
|
+
"apifox:check": "node apifox/generate.mjs --check"
|
|
48
|
+
},
|
|
49
|
+
"repository": {
|
|
50
|
+
"type": "git",
|
|
51
|
+
"url": "git+https://github.com/NSNanoCat/PreferencePanes.git"
|
|
52
|
+
},
|
|
53
|
+
"files": [
|
|
54
|
+
"src",
|
|
55
|
+
"dist"
|
|
56
|
+
],
|
|
57
|
+
"devDependencies": {
|
|
58
|
+
"@biomejs/biome": "2.1.4",
|
|
59
|
+
"@nsnanocat/url": "^1.2.6",
|
|
60
|
+
"@nsnanocat/util": "^2.7.4",
|
|
61
|
+
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
62
|
+
"rollup": "^4.63.1",
|
|
63
|
+
"typescript": "^5.9.3"
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public"
|
|
67
|
+
}
|
|
69
68
|
}
|
package/src/BoxJS.mjs
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { validatePathParts } from "./lib/settings-path.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* BoxJS 的共同目录:模块、存储根和展示元数据都来自同一份 JSON。
|
|
5
|
+
* Shared BoxJS catalog deriving modules, storage roots and metadata from one JSON document.
|
|
6
|
+
*/
|
|
7
|
+
export class BoxJS {
|
|
8
|
+
/**
|
|
9
|
+
* 建立路径索引,不解析控件类型,也不读写持久化存储。
|
|
10
|
+
* Index field paths without interpreting controls or accessing persistence.
|
|
11
|
+
* @param {unknown} input 字段数组、单个 app 或 apps 订阅 / Field array, app or apps subscription.
|
|
12
|
+
*/
|
|
13
|
+
constructor(input) {
|
|
14
|
+
if (!input || typeof input !== "object") throw new TypeError("Expected BoxJS JSON");
|
|
15
|
+
this.document = JSON.parse(JSON.stringify(input));
|
|
16
|
+
const apps = Array.isArray(this.document) ? [{ settings: this.document }] : (this.document.apps ?? [this.document]);
|
|
17
|
+
if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
|
|
18
|
+
this.modules = new Map();
|
|
19
|
+
for (const app of apps) {
|
|
20
|
+
if (!app || !Array.isArray(app.settings)) throw new TypeError("Expected BoxJS settings array");
|
|
21
|
+
for (const entry of app.settings) {
|
|
22
|
+
if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
|
|
23
|
+
if (!entry.id.startsWith("@")) {
|
|
24
|
+
if (Array.isArray(this.document)) throw new TypeError("BoxJS settings require @root.path IDs");
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const [storageKey, ...parts] = entry.id.slice(1).split(".");
|
|
28
|
+
if (!storageKey || storageKey.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
|
|
29
|
+
validatePathParts(parts);
|
|
30
|
+
const module = parts[0];
|
|
31
|
+
let target = this.modules.get(module);
|
|
32
|
+
if (!target) {
|
|
33
|
+
target = { module, storageKey, entries: [], owners: new Set() };
|
|
34
|
+
this.modules.set(module, target);
|
|
35
|
+
}
|
|
36
|
+
if (target.storageKey !== storageKey) throw new TypeError(`A module must use one storage root: ${module}`);
|
|
37
|
+
target.entries.push(entry);
|
|
38
|
+
target.owners.add(app);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
this.metadata = metadata(Array.isArray(this.document) ? {} : this.document);
|
|
42
|
+
for (const target of this.modules.values()) target.metadata = target.owners.size === 1 ? metadata([...target.owners][0]) : {};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 提取一个模块的原生 BoxJS,保留所属 app 的元数据。
|
|
47
|
+
* Select a module's native BoxJS while retaining owning-app metadata.
|
|
48
|
+
* @param {string} module 模块标识 / Module identifier.
|
|
49
|
+
* @returns {unknown} 可直接用作配置 Mock 的 JSON / JSON suitable for a configuration Mock.
|
|
50
|
+
*/
|
|
51
|
+
select(module) {
|
|
52
|
+
const target = this.modules.get(module);
|
|
53
|
+
if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
54
|
+
if (Array.isArray(this.document)) return target.entries;
|
|
55
|
+
const apps = [...target.owners].map(app => ({ ...app, settings: app.settings.filter(entry => target.entries.includes(entry)) }));
|
|
56
|
+
return this.document.apps ? { ...this.document, apps } : apps[0];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 取得本次导入的唯一模块,避免把模块数据变成项目目录。
|
|
61
|
+
* Get the single imported module without turning module data into a project directory.
|
|
62
|
+
* @returns {object} 唯一模块的目录项 / The single module entry.
|
|
63
|
+
*/
|
|
64
|
+
get module() {
|
|
65
|
+
if (this.modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
|
|
66
|
+
return this.modules.values().next().value;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 保留标准 BoxJS 展示信息;script 仅为元数据,不执行。
|
|
72
|
+
* Retain standard BoxJS presentation data; script is metadata only and never executed.
|
|
73
|
+
* @param {object} source BoxJS app 或订阅 / BoxJS app or subscription.
|
|
74
|
+
* @returns {object} 经过类型检查的展示信息 / Type-checked presentation metadata.
|
|
75
|
+
*/
|
|
76
|
+
function metadata(source) {
|
|
77
|
+
const result = {};
|
|
78
|
+
for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc", "icons", "descs"]) {
|
|
79
|
+
if (source[key] === undefined) continue;
|
|
80
|
+
const multiple = key === "icons" || key === "descs";
|
|
81
|
+
const values = multiple ? source[key] : [source[key]];
|
|
82
|
+
if (!Array.isArray(values) || values.some(item => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
|
|
83
|
+
result[key] = multiple ? [...values] : source[key];
|
|
84
|
+
}
|
|
85
|
+
return result;
|
|
86
|
+
}
|
package/src/Store.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { URL } from "@nsnanocat/url";
|
|
2
|
+
import { Lodash as _ } from "@nsnanocat/util/polyfill/Lodash.mjs";
|
|
3
|
+
import { Storage } from "@nsnanocat/util/polyfill/Storage";
|
|
4
|
+
import { response } from "./lib/response.mjs";
|
|
5
|
+
import { parseSettingsPathname } from "./lib/settings-path.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 根据 BoxJS 目录桥接持久化存储,不下载配置或解析控件。
|
|
9
|
+
* Bridge persistence using the BoxJS catalog without downloading configuration or interpreting controls.
|
|
10
|
+
*/
|
|
11
|
+
export class Store {
|
|
12
|
+
#catalog;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 复用包内已解析的目录,构造时不访问网络或存储。
|
|
16
|
+
* Reuse the parsed internal catalog without network or persistence access during construction.
|
|
17
|
+
* @param {import("./BoxJS.mjs").BoxJS} catalog BoxJS 路径目录 / BoxJS path catalog.
|
|
18
|
+
*/
|
|
19
|
+
constructor(catalog) {
|
|
20
|
+
this.#catalog = catalog;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* GET 返回指定值,POST 替换指定值,DELETE 删除指定键或整个模块。
|
|
25
|
+
* GET returns a value, POST replaces it, and DELETE removes a key or the entire module.
|
|
26
|
+
* @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
|
|
27
|
+
* @param {URL} [url] 包内复用的已解析地址 / Parsed URL reused within the package.
|
|
28
|
+
* @returns {Promise<import("./index.js").SettingsResponse | undefined>} 响应或非接管请求 / Response, or undefined for an unhandled request.
|
|
29
|
+
*/
|
|
30
|
+
async handle(request, url = new URL(request.url)) {
|
|
31
|
+
if (!url.pathname.startsWith("/api/")) return;
|
|
32
|
+
const reply = (status, data) => response(request, status, data);
|
|
33
|
+
let parts;
|
|
34
|
+
try {
|
|
35
|
+
parts = parseSettingsPathname(url.pathname);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
return reply(400, { error: error.message });
|
|
38
|
+
}
|
|
39
|
+
const binding = this.#catalog.modules.get(parts[0]);
|
|
40
|
+
if (!binding) return reply(404, { error: "Module is not declared in BoxJS" });
|
|
41
|
+
const requestHeaders = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
|
|
42
|
+
if (requestHeaders["x-settings-client"] !== "1" || (requestHeaders.origin && requestHeaders.origin !== url.origin)) return reply(403, { error: "Forbidden settings client" });
|
|
43
|
+
let value;
|
|
44
|
+
switch (request.method) {
|
|
45
|
+
case "HEAD":
|
|
46
|
+
return reply(200, undefined);
|
|
47
|
+
case "GET":
|
|
48
|
+
case "DELETE":
|
|
49
|
+
break;
|
|
50
|
+
case "POST":
|
|
51
|
+
if (requestHeaders["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") return reply(415, { error: "Expected application/json" });
|
|
52
|
+
if (typeof request.body !== "string") return reply(400, { error: "Expected a JSON string body" });
|
|
53
|
+
if (request.body.length > 65536) return reply(413, { error: "Body exceeds 65536 UTF-16 code units" });
|
|
54
|
+
try {
|
|
55
|
+
value = JSON.parse(request.body);
|
|
56
|
+
} catch {
|
|
57
|
+
return reply(400, { error: "Invalid JSON" });
|
|
58
|
+
}
|
|
59
|
+
break;
|
|
60
|
+
default:
|
|
61
|
+
return { ...reply(405, { error: "Method not allowed" }), headers: { ...reply(405).headers, Allow: "HEAD, GET, POST, DELETE" } };
|
|
62
|
+
}
|
|
63
|
+
try {
|
|
64
|
+
const root = Storage.getItem(binding.storageKey, {});
|
|
65
|
+
if (!isRecord(root)) throw new TypeError("stored root must be an object");
|
|
66
|
+
const parent = storageParent(root, parts, request.method === "POST");
|
|
67
|
+
const key = parts.at(-1);
|
|
68
|
+
switch (request.method) {
|
|
69
|
+
case "GET": {
|
|
70
|
+
const result = parent ? _.get(parent, [key]) : undefined;
|
|
71
|
+
return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
|
|
72
|
+
}
|
|
73
|
+
case "POST":
|
|
74
|
+
_.set(parent, [key], value);
|
|
75
|
+
break;
|
|
76
|
+
case "DELETE":
|
|
77
|
+
if (parent) _.unset(parent, [key]);
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
if (!Storage.setItem(binding.storageKey, root)) throw new Error("Storage write failed");
|
|
81
|
+
return reply(200, request.method === "POST" ? { saved: true } : { deleted: true });
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return reply(500, { error: error.message });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 判断根节点是否为普通对象。
|
|
90
|
+
* Determine whether a root node is a plain object.
|
|
91
|
+
* @param {unknown} value 待检查值 / Value to inspect.
|
|
92
|
+
* @returns {boolean} 是否为普通对象 / Whether this is a plain object.
|
|
93
|
+
*/
|
|
94
|
+
function isRecord(value) {
|
|
95
|
+
return value !== null && typeof value === "object" && Object.getPrototypeOf(value) === Object.prototype;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 遍历父路径,兼容旧存储中 JSON 字符串形式的中间节点。
|
|
100
|
+
* Traverse parents, supporting legacy intermediate nodes serialized as JSON strings.
|
|
101
|
+
* @param {Record<string, unknown>} root 存储根 / Storage root.
|
|
102
|
+
* @param {string[]} parts 完整路径 / Complete path.
|
|
103
|
+
* @param {boolean} create 是否创建缺失节点 / Whether to create missing parents.
|
|
104
|
+
* @returns {object | undefined} 父节点,缺失且不创建时为 undefined / Parent, or undefined when absent and not creating.
|
|
105
|
+
* @throws {TypeError} 无法继续遍历标量节点 / A scalar node cannot be traversed.
|
|
106
|
+
*/
|
|
107
|
+
function storageParent(root, parts, create) {
|
|
108
|
+
let parent = root;
|
|
109
|
+
for (const part of parts.slice(0, -1)) {
|
|
110
|
+
let next = _.get(parent, [part]);
|
|
111
|
+
switch (typeof next) {
|
|
112
|
+
case "undefined":
|
|
113
|
+
if (!create) return;
|
|
114
|
+
next = {};
|
|
115
|
+
break;
|
|
116
|
+
case "string":
|
|
117
|
+
next = JSON.parse(next);
|
|
118
|
+
break;
|
|
119
|
+
default:
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
if (!isRecord(next) && !Array.isArray(next)) throw new TypeError("Stored parent is not an object or array");
|
|
123
|
+
_.set(parent, [part], next);
|
|
124
|
+
parent = next;
|
|
125
|
+
}
|
|
126
|
+
return parent;
|
|
127
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同一文档的根页/子页导航,不定义页面布局或模块业务。
|
|
3
|
+
* Home/detail navigation within a document, without layout or module business rules.
|
|
4
|
+
*/
|
|
5
|
+
export class Navigation extends EventTarget {
|
|
6
|
+
/**
|
|
7
|
+
* 挂载根页并按 URL 创建子页;容器负责页面定位与背景样式。
|
|
8
|
+
* Mount home and resolve details from the URL; the container owns positioning and backgrounds.
|
|
9
|
+
* @param container 页面容器 / View container.
|
|
10
|
+
* @param home 根页节点 / Home node.
|
|
11
|
+
* @param create 子页工厂,signal 在离开时取消 / Detail factory; signal aborts on departure.
|
|
12
|
+
*/
|
|
13
|
+
constructor(container: HTMLElement, home: HTMLElement, create: (key: string, signal: AbortSignal) => HTMLElement | undefined);
|
|
14
|
+
/**
|
|
15
|
+
* 当前键;空字符串表示根页。
|
|
16
|
+
* Current key; empty means home.
|
|
17
|
+
*/
|
|
18
|
+
readonly current: string;
|
|
19
|
+
/**
|
|
20
|
+
* 是否可以后退。
|
|
21
|
+
* Whether back navigation is available.
|
|
22
|
+
*/
|
|
23
|
+
readonly canGoBack: boolean;
|
|
24
|
+
/**
|
|
25
|
+
* 前进到子页。
|
|
26
|
+
* Navigate to a detail.
|
|
27
|
+
* @param key 子页键 / Detail key.
|
|
28
|
+
* @returns 无返回值 / No return value.
|
|
29
|
+
*/
|
|
30
|
+
open(key: string): void;
|
|
31
|
+
/**
|
|
32
|
+
* 沿联合历史后退。
|
|
33
|
+
* Go back through joint history.
|
|
34
|
+
* @returns 无返回值 / No return value.
|
|
35
|
+
*/
|
|
36
|
+
back(): void;
|
|
37
|
+
/**
|
|
38
|
+
* 释放加载、监听器与视图。
|
|
39
|
+
* Release loads, listeners and views.
|
|
40
|
+
* @returns 无返回值 / No return value.
|
|
41
|
+
*/
|
|
42
|
+
destroy(): void;
|
|
43
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
|
|
3
|
+
* Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
|
|
4
|
+
*/
|
|
5
|
+
export class Navigation extends EventTarget {
|
|
6
|
+
#container;
|
|
7
|
+
#home;
|
|
8
|
+
#create;
|
|
9
|
+
#window;
|
|
10
|
+
#key = null;
|
|
11
|
+
#view;
|
|
12
|
+
#retiring;
|
|
13
|
+
#controller;
|
|
14
|
+
#animation;
|
|
15
|
+
#scroll = new WeakMap();
|
|
16
|
+
#onHistory = () => this.#route();
|
|
17
|
+
#onPageShow = event => {
|
|
18
|
+
if (event.persisted) this.#route(true);
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。
|
|
23
|
+
* Retain the home view and create details on demand; signal cancels async work after departure.
|
|
24
|
+
* @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.
|
|
25
|
+
* @param {HTMLElement} home 已创建的主页节点 / Existing home view.
|
|
26
|
+
* @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.
|
|
27
|
+
*/
|
|
28
|
+
constructor(container, home, create) {
|
|
29
|
+
super();
|
|
30
|
+
this.#container = container;
|
|
31
|
+
this.#home = home;
|
|
32
|
+
this.#create = create;
|
|
33
|
+
this.#window = container.ownerDocument.defaultView;
|
|
34
|
+
container.replaceChildren(home);
|
|
35
|
+
this.#window.addEventListener("popstate", this.#onHistory);
|
|
36
|
+
this.#window.addEventListener("hashchange", this.#onHistory);
|
|
37
|
+
this.#window.addEventListener("pageshow", this.#onPageShow);
|
|
38
|
+
this.#route();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 当前子页键;空字符串表示主页。
|
|
43
|
+
* Current detail key; empty means home.
|
|
44
|
+
*/
|
|
45
|
+
get current() {
|
|
46
|
+
return this.#key;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 是否可以返回上一级或先前文档。
|
|
51
|
+
* Whether a parent view or previous document is available.
|
|
52
|
+
*/
|
|
53
|
+
get canGoBack() {
|
|
54
|
+
return Boolean(this.#key) || this.#window.history.length > 1;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 加入子页历史;使用文档自身 URL,避免 srcdoc 按宿主 base URL 跳转。
|
|
59
|
+
* Push a detail using the document URL, avoiding srcdoc navigation against the host base URL.
|
|
60
|
+
* @param {string} key 子页键 / Detail key.
|
|
61
|
+
* @returns {void} 无返回值 / No return value.
|
|
62
|
+
*/
|
|
63
|
+
open(key) {
|
|
64
|
+
if (key === this.#key) return;
|
|
65
|
+
const url = new URL(this.#window.location.href);
|
|
66
|
+
url.hash = encodeURIComponent(key);
|
|
67
|
+
this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
|
|
68
|
+
this.#route();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* 沿浏览器联合历史返回,根页可退回宿主或上个文档。
|
|
73
|
+
* Go back through joint history, including a host or previous document from home.
|
|
74
|
+
* @returns {void} 无返回值 / No return value.
|
|
75
|
+
*/
|
|
76
|
+
back() {
|
|
77
|
+
if (this.canGoBack) this.#window.history.back();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
|
|
82
|
+
* Resolve the URL and coordinate transitions, cancellation and release after animation.
|
|
83
|
+
* @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.
|
|
84
|
+
* @returns {void} 无返回值 / No return value.
|
|
85
|
+
*/
|
|
86
|
+
#route(reload = false) {
|
|
87
|
+
const url = new URL(this.#window.location.href);
|
|
88
|
+
let key;
|
|
89
|
+
try {
|
|
90
|
+
key = decodeURIComponent(url.hash.slice(1));
|
|
91
|
+
} catch (error) {
|
|
92
|
+
if (!(error instanceof URIError)) throw error;
|
|
93
|
+
key = "";
|
|
94
|
+
}
|
|
95
|
+
if (!reload && key === this.#key) return;
|
|
96
|
+
this.#controller?.abort();
|
|
97
|
+
this.#controller = new AbortController();
|
|
98
|
+
const next = key ? this.#create(key, this.#controller.signal) : undefined;
|
|
99
|
+
if (!next) key = "";
|
|
100
|
+
const history = this.#window.history;
|
|
101
|
+
// 直接打开子页时建立一次主页历史;刷新不重复堆叠。
|
|
102
|
+
// Seed home history once for direct details, without stacking entries on reload.
|
|
103
|
+
if (url.hash && history.state?.preferencePanesRoute !== key) {
|
|
104
|
+
url.hash = "";
|
|
105
|
+
history.replaceState({ ...history.state, preferencePanesRoute: "" }, "", url.href);
|
|
106
|
+
if (key) {
|
|
107
|
+
url.hash = encodeURIComponent(key);
|
|
108
|
+
history.pushState({ ...history.state, preferencePanesRoute: key }, "", url.href);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const previous = this.#view;
|
|
112
|
+
const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
|
|
113
|
+
this.#animation?.cancel();
|
|
114
|
+
this.#retiring?.remove();
|
|
115
|
+
this.#retiring = previous;
|
|
116
|
+
if (previous) {
|
|
117
|
+
this.#scroll.set(previous, previous.scrollTop);
|
|
118
|
+
previous.inert = true;
|
|
119
|
+
}
|
|
120
|
+
this.#key = key;
|
|
121
|
+
this.#view = next;
|
|
122
|
+
this.#home.inert = Boolean(next);
|
|
123
|
+
if (next) {
|
|
124
|
+
next.inert = false;
|
|
125
|
+
this.#container.append(next);
|
|
126
|
+
next.scrollTop = this.#scroll.get(next) ?? 0;
|
|
127
|
+
}
|
|
128
|
+
const moving = next ?? previous;
|
|
129
|
+
if (moving) {
|
|
130
|
+
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" });
|
|
131
|
+
this.#animation = animation;
|
|
132
|
+
animation.onfinish = () => {
|
|
133
|
+
if (this.#animation !== animation) return;
|
|
134
|
+
this.#retiring?.remove();
|
|
135
|
+
this.#retiring = undefined;
|
|
136
|
+
animation.cancel();
|
|
137
|
+
this.#animation = undefined;
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
this.dispatchEvent(new Event("change"));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 释放监听器、加载、动画和节点;调用方可重新创建导航。
|
|
145
|
+
* Release listeners, loads, animations and nodes so callers can recreate navigation.
|
|
146
|
+
* @returns {void} 无返回值 / No return value.
|
|
147
|
+
*/
|
|
148
|
+
destroy() {
|
|
149
|
+
this.#window.removeEventListener("popstate", this.#onHistory);
|
|
150
|
+
this.#window.removeEventListener("hashchange", this.#onHistory);
|
|
151
|
+
this.#window.removeEventListener("pageshow", this.#onPageShow);
|
|
152
|
+
this.#controller?.abort();
|
|
153
|
+
this.#animation?.cancel();
|
|
154
|
+
this.#retiring?.remove();
|
|
155
|
+
this.#view?.remove();
|
|
156
|
+
this.#home.remove();
|
|
157
|
+
}
|
|
158
|
+
}
|