@nsnanocat/preference-panes 0.9.4 → 0.9.6
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 +4 -4
- package/dist/api.js +1 -1
- package/dist/module/app.mjs +8 -3
- package/dist/module/host.mjs +589 -0
- package/dist/module/index.html +1 -1
- package/dist/preference-panes.mjs +8 -3
- package/package.json +1 -1
- package/src/browser/BilibiliHost.mjs +142 -0
- package/src/browser/host.mjs +82 -0
- package/src/browser/panel.css +18 -13
- package/src/browser/panel.mjs +7 -2
|
@@ -0,0 +1,589 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bilibili common WebView 的官方 JSBridge 适配器。
|
|
3
|
+
* Official JSBridge adapter for Bilibili common WebViews.
|
|
4
|
+
*/
|
|
5
|
+
class BilibiliHost {
|
|
6
|
+
#bridge;
|
|
7
|
+
#select;
|
|
8
|
+
#state = { title: "", actions: [], busy: false };
|
|
9
|
+
#revision = 0;
|
|
10
|
+
#queue = Promise.resolve();
|
|
11
|
+
#destroyed = false;
|
|
12
|
+
#document;
|
|
13
|
+
#navigation = result => {
|
|
14
|
+
if (result.code !== 0 || result.data?.id !== "preference-panes.more" || this.#state.busy || this.#state.actions.length === 0) return;
|
|
15
|
+
const revision = this.#revision;
|
|
16
|
+
const actions = this.#state.actions;
|
|
17
|
+
this.#bridge
|
|
18
|
+
.useNative("liveUI.selectPanel", {
|
|
19
|
+
title: "更多操作",
|
|
20
|
+
options: actions.map(action => ({ text: action.label, value: action.id })),
|
|
21
|
+
})
|
|
22
|
+
.then(result => {
|
|
23
|
+
if (this.#destroyed || revision !== this.#revision || this.#state.busy) return;
|
|
24
|
+
const action = actions.find(item => item.id === result.data.text);
|
|
25
|
+
if (action) this.#select(action.id);
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
#theme = result => {
|
|
29
|
+
if (result.code === 0 && result.data?.theme) this.#applyTheme(result.data.theme);
|
|
30
|
+
};
|
|
31
|
+
#keyboard = result => {
|
|
32
|
+
if (result.code === 0 && typeof result.data?.status === "boolean") this.#document.documentElement.style.setProperty("--pp-keyboard-height", `${result.data.status ? result.data.height : 0}px`);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 连接页面已加载的官方 SDK。
|
|
37
|
+
* Connect to the official SDK already loaded by the document.
|
|
38
|
+
* @param {(id: string) => void} select 模块菜单回调 / Module action callback.
|
|
39
|
+
* @param {Window} [host] Bilibili WebView 窗口 / Bilibili WebView window.
|
|
40
|
+
*/
|
|
41
|
+
constructor(select, host = window) {
|
|
42
|
+
this.#bridge = host.biliBridge;
|
|
43
|
+
this.#select = select;
|
|
44
|
+
this.#document = host.document;
|
|
45
|
+
this.#applyTheme(host.navigator.userAgent.includes("themeId/2") ? 2 : 1);
|
|
46
|
+
this.ready = this.#connect();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 初始化主题、键盘和原生导航通道。
|
|
51
|
+
* Initialize theme, keyboard and native navigation channels.
|
|
52
|
+
* @returns {Promise<void>} 初始化完成 / Initialization completion.
|
|
53
|
+
*/
|
|
54
|
+
async #connect() {
|
|
55
|
+
await this.#bridge.initPromise;
|
|
56
|
+
if (!this.#bridge.isWbTypeCommon) throw new Error("PreferencePanes requires a Bilibili common WebView");
|
|
57
|
+
this.#bridge.addChannel("ui.observeThemeChange", this.#theme, { immediately: true });
|
|
58
|
+
this.#bridge.addChannel("ui.observeKeyboardStatus", this.#keyboard);
|
|
59
|
+
this.#bridge.addChannel("ui.observeNavigationClick", this.#navigation);
|
|
60
|
+
await this.#bridge.useNative("ui.setNavigationHide", { hide: false });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 同步官方导航标题和更多按钮。
|
|
65
|
+
* Synchronize the official navigation title and overflow button.
|
|
66
|
+
* @param {{title: string, actions: Array<{id: string, label: string}>, busy: boolean}} state 页面状态 / Page state.
|
|
67
|
+
* @returns {Promise<void>} 更新完成 / Update completion.
|
|
68
|
+
*/
|
|
69
|
+
update(state) {
|
|
70
|
+
this.#state = state;
|
|
71
|
+
const revision = ++this.#revision;
|
|
72
|
+
const render = async () => {
|
|
73
|
+
await this.ready;
|
|
74
|
+
if (this.#destroyed || revision !== this.#revision) return;
|
|
75
|
+
await this.#bridge.useNative("ui.setTitle", { title: state.title });
|
|
76
|
+
if (this.#destroyed || revision !== this.#revision) return;
|
|
77
|
+
await this.#bridge.useNative("ui.setNavigationButton", {
|
|
78
|
+
buttons: !state.busy && state.actions.length ? [{ id: "preference-panes.more", type: 3, visible: true }] : [],
|
|
79
|
+
});
|
|
80
|
+
};
|
|
81
|
+
this.#queue = this.#queue.then(render, render);
|
|
82
|
+
return this.#queue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 显示官方确认面板。
|
|
87
|
+
* Show the official confirmation panel.
|
|
88
|
+
* @param {string} message 确认内容 / Confirmation message.
|
|
89
|
+
* @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.
|
|
90
|
+
*/
|
|
91
|
+
async confirm(message) {
|
|
92
|
+
await this.ready;
|
|
93
|
+
return new Promise((resolve, reject) =>
|
|
94
|
+
this.#bridge.callNative({
|
|
95
|
+
method: "ability.alert",
|
|
96
|
+
data: { type: "confirm", title: this.#state.title, message, confirmButton: "确定", cancelButton: "取消" },
|
|
97
|
+
onConfirm: () => resolve(true),
|
|
98
|
+
onCancel: () => resolve(false),
|
|
99
|
+
onNeutral: () => resolve(false),
|
|
100
|
+
callback: result => {
|
|
101
|
+
if (result instanceof Error || result === "error") reject(new Error("Native confirmation failed"));
|
|
102
|
+
},
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 显示官方短提示。
|
|
109
|
+
* Show an official short notice.
|
|
110
|
+
* @param {string} message 提示内容 / Notice message.
|
|
111
|
+
* @returns {Promise<void>} 提示已提交 / Notice dispatched.
|
|
112
|
+
*/
|
|
113
|
+
async notice(message) {
|
|
114
|
+
await this.ready;
|
|
115
|
+
await this.#bridge.useNative("liveUI.toast", { type: "short", msg: message });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 释放官方事件通道。
|
|
120
|
+
* Release official event channels.
|
|
121
|
+
* @returns {void} 无返回值 / No return value.
|
|
122
|
+
*/
|
|
123
|
+
destroy() {
|
|
124
|
+
this.#destroyed = true;
|
|
125
|
+
this.#revision++;
|
|
126
|
+
this.#bridge.removeChannel("ui.observeThemeChange", this.#theme);
|
|
127
|
+
this.#bridge.removeChannel("ui.observeKeyboardStatus", this.#keyboard);
|
|
128
|
+
this.#bridge.removeChannel("ui.observeNavigationClick", this.#navigation);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* 将官方主题值映射到页面主题标记。
|
|
133
|
+
* Map the official theme value to document theme markers.
|
|
134
|
+
* @param {number | string} value 官方主题值 / Official theme value.
|
|
135
|
+
* @returns {void} 无返回值 / No return value.
|
|
136
|
+
*/
|
|
137
|
+
#applyTheme(value) {
|
|
138
|
+
const dark = value === 2 || value === "dark";
|
|
139
|
+
this.#document.documentElement.dataset.theme = dark ? "dark" : "light";
|
|
140
|
+
this.#document.documentElement.classList.toggle("bili_dark", dark);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
|
|
146
|
+
* Resolve module resource locations: headers override query parameters and module conventions.
|
|
147
|
+
* @param {URL} url 已解析的页面请求地址 / Parsed page request URL.
|
|
148
|
+
* @param {Record<string, string | undefined>} [headers] 请求头,名称不区分大小写 / Case-insensitive request headers.
|
|
149
|
+
* @returns {{url: string, module: string, json: string, css: string}} 页面上下文与两个资源输入 / Page context and two resource inputs.
|
|
150
|
+
*/
|
|
151
|
+
function pageInputs(url, headers = {}) {
|
|
152
|
+
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname);
|
|
153
|
+
if (!match) throw new TypeError("Open a concrete module URL");
|
|
154
|
+
const module = match[1];
|
|
155
|
+
const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
156
|
+
const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
|
|
157
|
+
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
|
|
158
|
+
if (!json.trim()) throw new TypeError("JSON resource URL is required");
|
|
159
|
+
return { url: url.href, module, json, css };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* 模块文档容器:原始 HTML 不改写,请求上下文随 iframe 元素传递。
|
|
164
|
+
* Module document container: preserve HTML verbatim and carry request context on the iframe element.
|
|
165
|
+
*/
|
|
166
|
+
class ModuleFrame extends EventTarget {
|
|
167
|
+
#url;
|
|
168
|
+
#options;
|
|
169
|
+
#controller = new AbortController();
|
|
170
|
+
#abort = () => this.destroy();
|
|
171
|
+
#state;
|
|
172
|
+
#change = event => {
|
|
173
|
+
this.#state = { ...event.detail, actions: event.detail.actions ?? [] };
|
|
174
|
+
this.dispatchEvent(new Event("change"));
|
|
175
|
+
};
|
|
176
|
+
#confirmation = event => {
|
|
177
|
+
const request = new CustomEvent("confirm", { cancelable: true, detail: event.detail });
|
|
178
|
+
if (!this.dispatchEvent(request)) event.preventDefault();
|
|
179
|
+
};
|
|
180
|
+
#notice = event => {
|
|
181
|
+
const notice = new CustomEvent("notice", { cancelable: true, detail: event.detail });
|
|
182
|
+
if (!this.dispatchEvent(notice)) event.preventDefault();
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* 建立 iframe 与请求输入;调用方挂载 element 后调用 load。
|
|
187
|
+
* Create the iframe and request inputs; callers mount element and then call load.
|
|
188
|
+
* @param {string | URL} url 模块请求地址 / Module request URL.
|
|
189
|
+
* @param {RequestInit} [options] 原生请求头和取消信号 / Native headers and cancellation signal.
|
|
190
|
+
*/
|
|
191
|
+
constructor(url, options = {}) {
|
|
192
|
+
super();
|
|
193
|
+
this.#url = new URL(url, document.baseURI);
|
|
194
|
+
this.#options = { ...options, headers: new Headers(options.headers) };
|
|
195
|
+
const inputs = pageInputs(this.#url, Object.fromEntries(this.#options.headers));
|
|
196
|
+
this.element = document.createElement("iframe");
|
|
197
|
+
this.element.title = `${inputs.module} 设置`;
|
|
198
|
+
this.element.dataset.preferencePanes = JSON.stringify(inputs);
|
|
199
|
+
this.element.addEventListener("preferencepanes:change", this.#change);
|
|
200
|
+
this.element.addEventListener("preferencepanes:confirm", this.#confirmation);
|
|
201
|
+
this.element.addEventListener("preferencepanes:notice", this.#notice);
|
|
202
|
+
this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true, actions: [] };
|
|
203
|
+
options.signal?.addEventListener("abort", this.#abort, { once: true });
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 当前模块导航状态。
|
|
208
|
+
* Current module navigation state.
|
|
209
|
+
*/
|
|
210
|
+
get state() {
|
|
211
|
+
return { ...this.#state };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 获取原始 HTML;晚到响应在退出后不得重新挂载。
|
|
216
|
+
* Fetch unmodified HTML; a late response must not remount after departure.
|
|
217
|
+
* @returns {Promise<void>} HTML 已交给 iframe;表单状态通过 change 事件提供 / HTML assigned; form state is reported through change.
|
|
218
|
+
*/
|
|
219
|
+
async load() {
|
|
220
|
+
if (this.#options.signal?.aborted) this.destroy();
|
|
221
|
+
const timer = setTimeout(() => this.#controller.abort(), 10000);
|
|
222
|
+
try {
|
|
223
|
+
const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", ...this.#options, signal: this.#controller.signal });
|
|
224
|
+
if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
|
|
225
|
+
const html = await response.text();
|
|
226
|
+
this.#controller.signal.throwIfAborted();
|
|
227
|
+
this.element.srcdoc = html;
|
|
228
|
+
} finally {
|
|
229
|
+
clearTimeout(timer);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 使用 iframe 的联合历史返回;写入期间不导航。
|
|
235
|
+
* Navigate joint iframe history back, except while a write is pending.
|
|
236
|
+
* @returns {void} 无返回值 / No return value.
|
|
237
|
+
*/
|
|
238
|
+
back() {
|
|
239
|
+
if (!this.#state.busy && this.#state.canGoBack) this.element.contentWindow.history.back();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 向模块发送菜单操作,不让宿主访问内部 DOM 或存储客户端。
|
|
244
|
+
* Dispatch a menu action without host access to internal DOM or the storage client.
|
|
245
|
+
* @param {string} id 当前可用操作 / Available action identifier.
|
|
246
|
+
* @returns {void} 无返回值 / No return value.
|
|
247
|
+
*/
|
|
248
|
+
perform(id) {
|
|
249
|
+
if (this.#state.busy || !this.#state.actions.some(action => action.id === id)) throw new Error("Action is not available");
|
|
250
|
+
this.element.dispatchEvent(new CustomEvent("preferencepanes:action", { detail: id }));
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 取消加载与事件订阅;节点保留到 Navigation 的退出动画结束。
|
|
255
|
+
* Cancel loading and subscriptions; Navigation retains the node until its exit animation ends.
|
|
256
|
+
* @returns {void} 无返回值 / No return value.
|
|
257
|
+
*/
|
|
258
|
+
destroy() {
|
|
259
|
+
this.#controller.abort();
|
|
260
|
+
this.#options.signal?.removeEventListener("abort", this.#abort);
|
|
261
|
+
this.element.removeEventListener("preferencepanes:change", this.#change);
|
|
262
|
+
this.element.removeEventListener("preferencepanes:confirm", this.#confirmation);
|
|
263
|
+
this.element.removeEventListener("preferencepanes:notice", this.#notice);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 模块入口的固定状态行,只通过 HEAD 探测安装状态和业务版本。
|
|
269
|
+
* Fixed module status row, probing installation and business version with HEAD only.
|
|
270
|
+
*/
|
|
271
|
+
class ModuleStatus extends EventTarget {
|
|
272
|
+
#element;
|
|
273
|
+
#controller;
|
|
274
|
+
#state = { status: "checking", version: null };
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* 绑定调用方提供的状态行。
|
|
278
|
+
* Bind a caller-owned status row.
|
|
279
|
+
* @param {HTMLElement} element 状态文字容器 / Status text container.
|
|
280
|
+
*/
|
|
281
|
+
constructor(element) {
|
|
282
|
+
super();
|
|
283
|
+
this.#element = element;
|
|
284
|
+
this.#render("checking");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* 当前安装状态与业务版本。
|
|
289
|
+
* Current installation state and business version.
|
|
290
|
+
*/
|
|
291
|
+
get state() {
|
|
292
|
+
return { ...this.#state };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 每次进入重新探测,取消旧请求并忽略其迟到结果。
|
|
297
|
+
* Reprobe on entry, cancelling old requests and ignoring late results.
|
|
298
|
+
* @param {string | URL} url 配置 Mock 地址 / Configuration Mock URL.
|
|
299
|
+
* @returns {Promise<void>} 探测完成 / Probe completion.
|
|
300
|
+
*/
|
|
301
|
+
async check(url) {
|
|
302
|
+
this.#controller?.abort();
|
|
303
|
+
const controller = (this.#controller = new AbortController());
|
|
304
|
+
this.#render("checking");
|
|
305
|
+
const timer = setTimeout(() => controller.abort(), 3500);
|
|
306
|
+
try {
|
|
307
|
+
const response = await fetch(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal });
|
|
308
|
+
if (controller !== this.#controller) return;
|
|
309
|
+
const version = response.headers.get("X-PreferencePanes-Version")?.trim() || null;
|
|
310
|
+
this.#render(response.status === 200 ? "installed" : "missing", version);
|
|
311
|
+
} catch {
|
|
312
|
+
if (controller === this.#controller) this.#render("missing");
|
|
313
|
+
} finally {
|
|
314
|
+
clearTimeout(timer);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* 更新状态标签,缺少版本时不伪造版本号。
|
|
320
|
+
* Render the label without inventing a missing version.
|
|
321
|
+
* @param {"checking" | "installed" | "missing"} status 状态 / State.
|
|
322
|
+
* @param {string | null} [version] 业务版本 / Business version.
|
|
323
|
+
* @returns {void} 无返回值 / No return value.
|
|
324
|
+
*/
|
|
325
|
+
#render(status, version = null) {
|
|
326
|
+
this.#state = { status, version: status === "installed" ? version : null };
|
|
327
|
+
switch (status) {
|
|
328
|
+
case "checking":
|
|
329
|
+
this.#element.textContent = "检测中";
|
|
330
|
+
break;
|
|
331
|
+
case "installed":
|
|
332
|
+
this.#element.textContent = version ?? "版本未知";
|
|
333
|
+
break;
|
|
334
|
+
case "missing":
|
|
335
|
+
this.#element.textContent = "未安装";
|
|
336
|
+
break;
|
|
337
|
+
}
|
|
338
|
+
this.#element.dataset.state = status;
|
|
339
|
+
this.#element.title = this.#element.textContent;
|
|
340
|
+
this.dispatchEvent(new Event("change"));
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* 释放尚未完成的探测。
|
|
345
|
+
* Release pending probes.
|
|
346
|
+
* @returns {void} 无返回值 / No return value.
|
|
347
|
+
*/
|
|
348
|
+
destroy() {
|
|
349
|
+
this.#controller?.abort();
|
|
350
|
+
this.#controller = undefined;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
|
|
356
|
+
* Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
|
|
357
|
+
*/
|
|
358
|
+
class Navigation extends EventTarget {
|
|
359
|
+
#container;
|
|
360
|
+
#home;
|
|
361
|
+
#create;
|
|
362
|
+
#window;
|
|
363
|
+
#key = null;
|
|
364
|
+
#view;
|
|
365
|
+
#retiring;
|
|
366
|
+
#controller;
|
|
367
|
+
#animation;
|
|
368
|
+
#scroll = new WeakMap();
|
|
369
|
+
#onHistory = () => this.#route();
|
|
370
|
+
#onPageShow = event => {
|
|
371
|
+
if (event.persisted) this.#route(true);
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* 根视图始终保留;工厂按需提供子页,可用 signal 取消离开后的异步加载。
|
|
376
|
+
* Retain the home view and create details on demand; signal cancels async work after departure.
|
|
377
|
+
* @param {HTMLElement} container 由调用方布局的页面容器 / Caller-styled view container.
|
|
378
|
+
* @param {HTMLElement} home 已创建的主页节点 / Existing home view.
|
|
379
|
+
* @param {(key: string, signal: AbortSignal) => HTMLElement | undefined} create 子页工厂;未知路径返回 undefined / Detail factory; undefined for unknown routes.
|
|
380
|
+
*/
|
|
381
|
+
constructor(container, home, create) {
|
|
382
|
+
super();
|
|
383
|
+
this.#container = container;
|
|
384
|
+
this.#home = home;
|
|
385
|
+
this.#create = create;
|
|
386
|
+
this.#window = container.ownerDocument.defaultView;
|
|
387
|
+
container.replaceChildren(home);
|
|
388
|
+
this.#window.addEventListener("popstate", this.#onHistory);
|
|
389
|
+
this.#window.addEventListener("hashchange", this.#onHistory);
|
|
390
|
+
this.#window.addEventListener("pageshow", this.#onPageShow);
|
|
391
|
+
this.#route();
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* 当前子页键;空字符串表示主页。
|
|
396
|
+
* Current detail key; empty means home.
|
|
397
|
+
*/
|
|
398
|
+
get current() {
|
|
399
|
+
return this.#key;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* 是否可以返回上一级或先前文档。
|
|
404
|
+
* Whether a parent view or previous document is available.
|
|
405
|
+
*/
|
|
406
|
+
get canGoBack() {
|
|
407
|
+
return Boolean(this.#key) || this.#window.history.length > 1;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* 加入子页历史;使用文档自身 URL,避免 srcdoc 按宿主 base URL 跳转。
|
|
412
|
+
* Push a detail using the document URL, avoiding srcdoc navigation against the host base URL.
|
|
413
|
+
* @param {string} key 子页键 / Detail key.
|
|
414
|
+
* @returns {void} 无返回值 / No return value.
|
|
415
|
+
*/
|
|
416
|
+
open(key) {
|
|
417
|
+
if (key === this.#key) return;
|
|
418
|
+
const url = new URL(this.#window.location.href);
|
|
419
|
+
url.hash = encodeURIComponent(key);
|
|
420
|
+
this.#window.history.pushState({ ...this.#window.history.state, preferencePanesRoute: key }, "", url.href);
|
|
421
|
+
this.#route();
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* 沿浏览器联合历史返回,根页可退回宿主或上个文档。
|
|
426
|
+
* Go back through joint history, including a host or previous document from home.
|
|
427
|
+
* @returns {void} 无返回值 / No return value.
|
|
428
|
+
*/
|
|
429
|
+
back() {
|
|
430
|
+
if (this.canGoBack) this.#window.history.back();
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* 解析 URL 并统一处理页面切换、加载取消与动画结束后的释放。
|
|
435
|
+
* Resolve the URL and coordinate transitions, cancellation and release after animation.
|
|
436
|
+
* @param {boolean} [reload] 从页面缓存恢复时重新创建子页 / Recreate a detail after bfcache restoration.
|
|
437
|
+
* @returns {void} 无返回值 / No return value.
|
|
438
|
+
*/
|
|
439
|
+
#route(reload = false) {
|
|
440
|
+
const url = new URL(this.#window.location.href);
|
|
441
|
+
let key;
|
|
442
|
+
try {
|
|
443
|
+
key = decodeURIComponent(url.hash.slice(1));
|
|
444
|
+
} catch (error) {
|
|
445
|
+
if (!(error instanceof URIError)) throw error;
|
|
446
|
+
key = "";
|
|
447
|
+
}
|
|
448
|
+
if (!reload && key === this.#key) return;
|
|
449
|
+
this.#controller?.abort();
|
|
450
|
+
this.#controller = new AbortController();
|
|
451
|
+
const next = key ? this.#create(key, this.#controller.signal) : undefined;
|
|
452
|
+
if (!next) key = "";
|
|
453
|
+
const history = this.#window.history;
|
|
454
|
+
// 直接打开子页时建立一次主页历史;刷新不重复堆叠。
|
|
455
|
+
// Seed home history once for direct details, without stacking entries on reload.
|
|
456
|
+
if (url.hash && history.state?.preferencePanesRoute !== key) {
|
|
457
|
+
url.hash = "";
|
|
458
|
+
history.replaceState({ ...history.state, preferencePanesRoute: "" }, "", url.href);
|
|
459
|
+
if (key) {
|
|
460
|
+
url.hash = encodeURIComponent(key);
|
|
461
|
+
history.pushState({ ...history.state, preferencePanesRoute: key }, "", url.href);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
const previous = this.#view;
|
|
465
|
+
const position = previous ? this.#window.getComputedStyle(previous).transform : "none";
|
|
466
|
+
this.#animation?.cancel();
|
|
467
|
+
this.#retiring?.remove();
|
|
468
|
+
this.#retiring = previous;
|
|
469
|
+
if (previous) {
|
|
470
|
+
this.#scroll.set(previous, previous.scrollTop);
|
|
471
|
+
previous.inert = true;
|
|
472
|
+
}
|
|
473
|
+
this.#key = key;
|
|
474
|
+
this.#view = next;
|
|
475
|
+
this.#home.inert = Boolean(next);
|
|
476
|
+
if (next) {
|
|
477
|
+
next.inert = false;
|
|
478
|
+
this.#container.append(next);
|
|
479
|
+
next.scrollTop = this.#scroll.get(next) ?? 0;
|
|
480
|
+
}
|
|
481
|
+
const moving = next ?? previous;
|
|
482
|
+
if (moving) {
|
|
483
|
+
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" });
|
|
484
|
+
this.#animation = animation;
|
|
485
|
+
animation.onfinish = () => {
|
|
486
|
+
if (this.#animation !== animation) return;
|
|
487
|
+
this.#retiring?.remove();
|
|
488
|
+
this.#retiring = undefined;
|
|
489
|
+
animation.cancel();
|
|
490
|
+
this.#animation = undefined;
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
this.dispatchEvent(new Event("change"));
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* 释放监听器、加载、动画和节点;调用方可重新创建导航。
|
|
498
|
+
* Release listeners, loads, animations and nodes so callers can recreate navigation.
|
|
499
|
+
* @returns {void} 无返回值 / No return value.
|
|
500
|
+
*/
|
|
501
|
+
destroy() {
|
|
502
|
+
this.#window.removeEventListener("popstate", this.#onHistory);
|
|
503
|
+
this.#window.removeEventListener("hashchange", this.#onHistory);
|
|
504
|
+
this.#window.removeEventListener("pageshow", this.#onPageShow);
|
|
505
|
+
this.#controller?.abort();
|
|
506
|
+
this.#animation?.cancel();
|
|
507
|
+
this.#retiring?.remove();
|
|
508
|
+
this.#view?.remove();
|
|
509
|
+
this.#home.remove();
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const container = document.querySelector("[data-preference-panes-pages]");
|
|
514
|
+
const home = document.querySelector("[data-preference-panes-home]");
|
|
515
|
+
const template = document.querySelector("template[data-preference-panes-module]");
|
|
516
|
+
const buttons = [...home.querySelectorAll("[data-module][data-page][data-json]")];
|
|
517
|
+
let frame;
|
|
518
|
+
|
|
519
|
+
const native = new BilibiliHost(id => frame.perform(id));
|
|
520
|
+
const navigation = new Navigation(container, home, (module, signal) => {
|
|
521
|
+
const button = buttons.find(button => button.dataset.module === module);
|
|
522
|
+
if (!button) return;
|
|
523
|
+
const host = template.content.firstElementChild.cloneNode(true);
|
|
524
|
+
const message = host.querySelector("[data-module-message]");
|
|
525
|
+
frame = new ModuleFrame(button.dataset.page, {
|
|
526
|
+
signal,
|
|
527
|
+
headers: {
|
|
528
|
+
"X-PreferencePanes-JSON": button.dataset.json,
|
|
529
|
+
...(button.dataset.css ? { "X-PreferencePanes-CSS": button.dataset.css } : {}),
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
frame.addEventListener("confirm", event => {
|
|
533
|
+
event.preventDefault();
|
|
534
|
+
native.confirm(event.detail.message).then(event.detail.resolve, event.detail.reject);
|
|
535
|
+
});
|
|
536
|
+
frame.addEventListener("notice", event => {
|
|
537
|
+
event.preventDefault();
|
|
538
|
+
native.notice(event.detail.message);
|
|
539
|
+
});
|
|
540
|
+
frame.addEventListener("change", updateNavigation);
|
|
541
|
+
frame.element.onload = () => {
|
|
542
|
+
message.hidden = true;
|
|
543
|
+
};
|
|
544
|
+
host.append(frame.element);
|
|
545
|
+
frame.load().catch(error => {
|
|
546
|
+
if (!signal.aborted) message.textContent = error.message;
|
|
547
|
+
});
|
|
548
|
+
return host;
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
const statuses = buttons.map(button => {
|
|
552
|
+
const status = new ModuleStatus(button.querySelector("[data-module-status]"));
|
|
553
|
+
status.addEventListener("change", () => {
|
|
554
|
+
button.disabled = status.state.status !== "installed";
|
|
555
|
+
});
|
|
556
|
+
button.addEventListener("click", () => navigation.open(button.dataset.module));
|
|
557
|
+
return { button, status };
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* 同步当前页面的官方导航状态。
|
|
562
|
+
* Synchronize the official navigation state for the current page.
|
|
563
|
+
* @returns {void} 无返回值 / No return value.
|
|
564
|
+
*/
|
|
565
|
+
function updateNavigation() {
|
|
566
|
+
const state = navigation.current ? frame.state : { title: document.title, actions: [], busy: false };
|
|
567
|
+
native.update(state);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* 每次返回主页并发探测全部配置 Mock。
|
|
572
|
+
* Probe all configuration Mocks concurrently whenever the home page is entered.
|
|
573
|
+
* @returns {void} 无返回值 / No return value.
|
|
574
|
+
*/
|
|
575
|
+
function probe() {
|
|
576
|
+
updateNavigation();
|
|
577
|
+
if (navigation.current) return;
|
|
578
|
+
for (const { button, status } of statuses) status.check(button.dataset.json);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
navigation.addEventListener("change", probe);
|
|
582
|
+
window.addEventListener("pagehide", event => {
|
|
583
|
+
if (!event.persisted) {
|
|
584
|
+
for (const { status } of statuses) status.destroy();
|
|
585
|
+
navigation.destroy();
|
|
586
|
+
native.destroy();
|
|
587
|
+
}
|
|
588
|
+
});
|
|
589
|
+
probe();
|
package/dist/module/index.html
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
var styleURLs = ["https://s1.hdslb.com/bfs/seed/jinkela/short/b-style/theme.min.css","https://s1.hdslb.com/bfs/seed/jinkela/short/b-style/b-style.min.css"];
|
|
2
2
|
|
|
3
|
-
var defaults = "/* 官方 b-style 负责行布局与配色;本文件只定义面板容器和控件约束。\n * Official b-style owns row layout and colors; this file defines panel containers and control constraints. */\n.pp-panel {\n --pp-text: var(--text1);\n --pp-background: var(--bg2);\n --pp-surface: var(--bg1);\n --pp-border: var(--line_regular);\n --pp-muted: var(--text3);\n --pp-accent: var(--brand_pink);\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
|
|
3
|
+
var defaults = "/* 官方 b-style 负责行布局与配色;本文件只定义面板容器和控件约束。\n * Official b-style owns row layout and colors; this file defines panel containers and control constraints. */\n.pp-panel {\n --pp-text: var(--text1);\n --pp-background: var(--bg2);\n --pp-surface: var(--bg1);\n --pp-border: var(--line_regular);\n --pp-muted: var(--text3);\n --pp-accent: var(--brand_pink);\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 display: flex;\n flex-direction: column;\n height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n flex: none;\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: relative;\n z-index: 2;\n}\n.pp-title {\n flex: 1;\n text-align: center;\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n/* 模块图标在正文内;嵌入宿主可使用纯文字原生标题栏。\n * Keep module icons in content so embedded hosts can use text-only native titles. */\n.pp-module-logo {\n display: none;\n width: 64px;\n height: 64px;\n margin: 8px auto 20px;\n}\n.pp-module-logo:not(:empty) {\n display: block;\n}\n.pp-module-logo 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/* 固定工具区与滚动视口共用弹性布局,不按工具栏高度拼接页面偏移。\n * Fixed tools and the scrolling viewport share a flex layout without toolbar-height offsets. */\n.pp-toolbar {\n flex: none;\n padding: 12px max(16px, calc((100% - 688px) / 2));\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n}\n.pp-viewport {\n position: relative;\n flex: 1;\n min-height: 0;\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n@supports (height: 100dvh) {\n .pp-panel {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page,\n.pp-cache-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) + var(--pp-keyboard-height, 0px));\n scroll-padding-bottom: var(--pp-keyboard-height, 0px);\n background: var(--pp-background);\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-editor {\n flex: none;\n width: 45%;\n min-width: 0;\n min-height: 36px;\n font: inherit;\n border: 0;\n}\n.pp-search {\n width: 100%;\n margin: 0;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-editor {\n width: 100%;\n margin-top: 10px;\n}\n.pp-panel [hidden] {\n display: none !important;\n}\n.pp-label {\n flex: 1;\n min-width: 0;\n}\n.pp-row {\n min-height: 48px;\n}\n.pp-rows > :last-child {\n border-bottom: 0 !important;\n}\n.pp-switch {\n flex: none;\n accent-color: var(--pp-accent);\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-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-cache {\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";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* 校验原始路径片段,不进行 URL 编码转换。
|
|
@@ -826,10 +826,13 @@ function mountPanel(root, catalog) {
|
|
|
826
826
|
logo.setAttribute("aria-hidden", "true");
|
|
827
827
|
const image = icon(catalog.module.metadata, "");
|
|
828
828
|
if (image) logo.append(image);
|
|
829
|
+
const toolbar = element("div", "pp-toolbar");
|
|
830
|
+
toolbar.setAttribute("role", "search");
|
|
831
|
+
toolbar.hidden = true;
|
|
829
832
|
const viewport = element("div", "pp-viewport");
|
|
830
833
|
let toast;
|
|
831
834
|
header.append(back, heading, trailing);
|
|
832
|
-
shell.append(header, viewport);
|
|
835
|
+
shell.append(header, toolbar, viewport);
|
|
833
836
|
root.append(shell);
|
|
834
837
|
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
835
838
|
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
@@ -920,6 +923,7 @@ function mountPanel(root, catalog) {
|
|
|
920
923
|
async function open(module) {
|
|
921
924
|
const version = ++generation;
|
|
922
925
|
active = module;
|
|
926
|
+
toolbar.hidden = true;
|
|
923
927
|
back.disabled = window.history.length <= 1;
|
|
924
928
|
heading.textContent = module;
|
|
925
929
|
publishNavigation();
|
|
@@ -949,7 +953,7 @@ function mountPanel(root, catalog) {
|
|
|
949
953
|
search.setAttribute("aria-label", "搜索设置");
|
|
950
954
|
const searchField = fieldControl(search);
|
|
951
955
|
searchField.classList.add("pp-search");
|
|
952
|
-
|
|
956
|
+
toolbar.replaceChildren(searchField);
|
|
953
957
|
const searchRows = [];
|
|
954
958
|
/**
|
|
955
959
|
* 挂载后执行的多行高度更新
|
|
@@ -969,6 +973,7 @@ function mountPanel(root, catalog) {
|
|
|
969
973
|
*/
|
|
970
974
|
const updateNavigation = () => {
|
|
971
975
|
const editor = editors.get(navigation.current);
|
|
976
|
+
toolbar.hidden = Boolean(navigation.current);
|
|
972
977
|
heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
|
|
973
978
|
back.disabled = saving || !navigation.canGoBack;
|
|
974
979
|
publishNavigation();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsnanocat/preference-panes",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.6",
|
|
4
4
|
"description": "Shared settings API runtime for JavaScript proxy modules",
|
|
5
5
|
"author": "VirgilClyne <Virgil@nanocat.me>",
|
|
6
6
|
"homepage": "https://NSNanoCat.github.io/preference-panes",
|