@nsnanocat/preference-panes 0.7.2 → 0.8.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 +25 -76
- package/dist/api.js +1622 -0
- package/dist/module/app.mjs +169 -52
- package/dist/module/index.html +1 -1
- package/dist/module/navigation.mjs +218 -4
- package/dist/preference-panes.mjs +168 -51
- package/package.json +1 -1
- package/src/Store.mjs +40 -53
- package/src/browser/ActionMenu.mjs +115 -0
- package/src/browser/ModuleFrame.mjs +13 -2
- package/src/browser/ModuleStatus.mjs +86 -0
- package/src/browser/Navigation.d.mts +99 -1
- package/src/browser/Navigation.mjs +2 -0
- package/src/browser/client.d.mts +2 -2
- package/src/browser/client.mjs +21 -21
- package/src/browser/panel.css +2 -15
- package/src/browser/panel.mjs +31 -21
- package/src/build.mjs +3 -13
- package/src/index.d.ts +6 -6
- package/src/lib/page-inputs.mjs +1 -1
- package/src/lib/settings-path.mjs +0 -18
- package/src/proxy/handler.mjs +9 -22
- package/dist/preference-panes.config.js +0 -842
- package/dist/preference-panes.proxy.js +0 -1754
- package/src/proxy/config.mjs +0 -14
|
@@ -1,3 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。
|
|
3
|
+
* Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.
|
|
4
|
+
*/
|
|
5
|
+
class ActionMenu {
|
|
6
|
+
#button;
|
|
7
|
+
#popup;
|
|
8
|
+
#backdrop;
|
|
9
|
+
#select;
|
|
10
|
+
#document;
|
|
11
|
+
#key = event => {
|
|
12
|
+
if (event.key === "Escape" && !this.#popup.hidden) {
|
|
13
|
+
event.preventDefault();
|
|
14
|
+
this.close();
|
|
15
|
+
this.#button.focus();
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 创建菜单,操作逻辑由调用方提供。
|
|
21
|
+
* Create a menu whose actions are handled by the caller.
|
|
22
|
+
* @param {(id: string) => void} select 菜单选择回调 / Selection callback.
|
|
23
|
+
*/
|
|
24
|
+
constructor(select) {
|
|
25
|
+
this.#document = document;
|
|
26
|
+
this.#select = select;
|
|
27
|
+
this.element = document.createElement("span");
|
|
28
|
+
const root = this.element.attachShadow({ mode: "open" });
|
|
29
|
+
root.innerHTML = `<style>
|
|
30
|
+
:host{display:inline-flex;position:relative;width:44px;height:44px;color:inherit}
|
|
31
|
+
:host([hidden]),[hidden]{display:none!important}
|
|
32
|
+
button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
33
|
+
button:disabled{opacity:.4;cursor:default}
|
|
34
|
+
button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
|
|
35
|
+
#trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}
|
|
36
|
+
svg{display:block;width:24px;height:24px;fill:currentColor}
|
|
37
|
+
#backdrop{position:fixed;inset:0;z-index:1}
|
|
38
|
+
#items{position:absolute;right:0;top:46px;z-index:2;min-width:160px;padding:6px;background:var(--pp-surface,Canvas);color:var(--pp-text,CanvasText);border:1px solid var(--pp-border,#8884);border-radius:12px;box-shadow:0 8px 28px #0003}
|
|
39
|
+
#items button{display:block;text-align:left;white-space:nowrap;width:100%;padding:11px 14px;border-radius:8px;font:14px/1.4 system-ui,sans-serif}
|
|
40
|
+
#items button:hover{background:#8882}
|
|
41
|
+
#items button[data-danger]{color:#e45656}
|
|
42
|
+
</style><button id="trigger" type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false" aria-controls="items"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="4" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="20" cy="12" r="2"/></svg></button><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单" hidden></button><div id="items" role="menu" hidden></div>`;
|
|
43
|
+
this.#button = root.querySelector("#trigger");
|
|
44
|
+
this.#popup = root.querySelector("#items");
|
|
45
|
+
this.#backdrop = root.querySelector("#backdrop");
|
|
46
|
+
this.#button.onclick = () => {
|
|
47
|
+
const open = this.#popup.hidden;
|
|
48
|
+
this.#popup.hidden = this.#backdrop.hidden = !open;
|
|
49
|
+
this.#button.setAttribute("aria-expanded", String(open));
|
|
50
|
+
if (open) this.#popup.firstElementChild.focus();
|
|
51
|
+
};
|
|
52
|
+
this.#backdrop.onclick = () => this.close();
|
|
53
|
+
this.#popup.onkeydown = event => {
|
|
54
|
+
if (event.key === "Tab") {
|
|
55
|
+
this.close();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const items = [...this.#popup.children];
|
|
59
|
+
const index = items.indexOf(root.activeElement);
|
|
60
|
+
const offsets = { ArrowDown: 1, ArrowUp: -1 };
|
|
61
|
+
if (event.key in offsets) {
|
|
62
|
+
event.preventDefault();
|
|
63
|
+
items[(index + offsets[event.key] + items.length) % items.length].focus();
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
document.addEventListener("keydown", this.#key);
|
|
67
|
+
this.update([]);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 同步可用操作和忙碌状态,不重建菜单触发按钮。
|
|
72
|
+
* Update actions and busy state without replacing the trigger button.
|
|
73
|
+
* @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.
|
|
74
|
+
* @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
|
|
75
|
+
* @returns {void} 无返回值 / No return value.
|
|
76
|
+
*/
|
|
77
|
+
update(items, disabled = false) {
|
|
78
|
+
this.close();
|
|
79
|
+
this.#button.disabled = disabled || items.length === 0;
|
|
80
|
+
this.#popup.replaceChildren(
|
|
81
|
+
...items.map(item => {
|
|
82
|
+
const button = this.#document.createElement("button");
|
|
83
|
+
button.type = "button";
|
|
84
|
+
button.setAttribute("role", "menuitem");
|
|
85
|
+
button.textContent = item.label;
|
|
86
|
+
button.toggleAttribute("data-danger", Boolean(item.destructive));
|
|
87
|
+
button.onclick = () => {
|
|
88
|
+
this.close();
|
|
89
|
+
this.#select(item.id);
|
|
90
|
+
};
|
|
91
|
+
return button;
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 关闭菜单。
|
|
98
|
+
* Close the menu.
|
|
99
|
+
* @returns {void} 无返回值 / No return value.
|
|
100
|
+
*/
|
|
101
|
+
close() {
|
|
102
|
+
this.#popup.hidden = this.#backdrop.hidden = true;
|
|
103
|
+
this.#button.setAttribute("aria-expanded", "false");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* 移除监听器与节点。
|
|
108
|
+
* Remove listeners and elements.
|
|
109
|
+
* @returns {void} 无返回值 / No return value.
|
|
110
|
+
*/
|
|
111
|
+
destroy() {
|
|
112
|
+
this.#document.removeEventListener("keydown", this.#key);
|
|
113
|
+
this.element.remove();
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
1
117
|
/**
|
|
2
118
|
* 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
|
|
3
119
|
* Resolve module resource locations: headers override query parameters and module conventions.
|
|
@@ -11,7 +127,7 @@ function pageInputs(url, headers = {}) {
|
|
|
11
127
|
const module = match[1];
|
|
12
128
|
const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
13
129
|
const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
|
|
14
|
-
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ??
|
|
130
|
+
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
|
|
15
131
|
if (!json.trim()) throw new TypeError("JSON resource URL is required");
|
|
16
132
|
return { url: url.href, module, json, css };
|
|
17
133
|
}
|
|
@@ -27,7 +143,7 @@ class ModuleFrame extends EventTarget {
|
|
|
27
143
|
#abort = () => this.destroy();
|
|
28
144
|
#state;
|
|
29
145
|
#change = event => {
|
|
30
|
-
this.#state = event.detail;
|
|
146
|
+
this.#state = { ...event.detail, actions: event.detail.actions ?? [] };
|
|
31
147
|
this.dispatchEvent(new Event("change"));
|
|
32
148
|
};
|
|
33
149
|
|
|
@@ -46,7 +162,7 @@ class ModuleFrame extends EventTarget {
|
|
|
46
162
|
this.element.title = `${inputs.module} 设置`;
|
|
47
163
|
this.element.dataset.preferencePanes = JSON.stringify(inputs);
|
|
48
164
|
this.element.addEventListener("preferencepanes:change", this.#change);
|
|
49
|
-
this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true };
|
|
165
|
+
this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true, actions: [] };
|
|
50
166
|
options.signal?.addEventListener("abort", this.#abort, { once: true });
|
|
51
167
|
}
|
|
52
168
|
|
|
@@ -86,6 +202,17 @@ class ModuleFrame extends EventTarget {
|
|
|
86
202
|
if (!this.#state.busy && this.#state.canGoBack) this.element.contentWindow.history.back();
|
|
87
203
|
}
|
|
88
204
|
|
|
205
|
+
/**
|
|
206
|
+
* 向模块发送菜单操作,不让宿主访问内部 DOM 或存储客户端。
|
|
207
|
+
* Dispatch a menu action without host access to internal DOM or the storage client.
|
|
208
|
+
* @param {string} id 当前可用操作 / Available action identifier.
|
|
209
|
+
* @returns {void} 无返回值 / No return value.
|
|
210
|
+
*/
|
|
211
|
+
perform(id) {
|
|
212
|
+
if (this.#state.busy || !this.#state.actions.some(action => action.id === id)) throw new Error("Action is not available");
|
|
213
|
+
this.element.dispatchEvent(new CustomEvent("preferencepanes:action", { detail: id }));
|
|
214
|
+
}
|
|
215
|
+
|
|
89
216
|
/**
|
|
90
217
|
* 取消加载与事件订阅;节点保留到 Navigation 的退出动画结束。
|
|
91
218
|
* Cancel loading and subscriptions; Navigation retains the node until its exit animation ends.
|
|
@@ -98,6 +225,93 @@ class ModuleFrame extends EventTarget {
|
|
|
98
225
|
}
|
|
99
226
|
}
|
|
100
227
|
|
|
228
|
+
/**
|
|
229
|
+
* 模块入口的固定状态行,只通过 HEAD 探测安装状态和业务版本。
|
|
230
|
+
* Fixed module status row, probing installation and business version with HEAD only.
|
|
231
|
+
*/
|
|
232
|
+
class ModuleStatus extends EventTarget {
|
|
233
|
+
#element;
|
|
234
|
+
#controller;
|
|
235
|
+
#state = { status: "checking", version: null };
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 绑定调用方提供的状态行。
|
|
239
|
+
* Bind a caller-owned status row.
|
|
240
|
+
* @param {HTMLElement} element 状态文字容器 / Status text container.
|
|
241
|
+
*/
|
|
242
|
+
constructor(element) {
|
|
243
|
+
super();
|
|
244
|
+
this.#element = element;
|
|
245
|
+
this.#render("checking");
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 当前安装状态与业务版本。
|
|
250
|
+
* Current installation state and business version.
|
|
251
|
+
*/
|
|
252
|
+
get state() {
|
|
253
|
+
return { ...this.#state };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* 每次进入重新探测,取消旧请求并忽略其迟到结果。
|
|
258
|
+
* Reprobe on entry, cancelling old requests and ignoring late results.
|
|
259
|
+
* @param {string | URL} url 配置 Mock 地址 / Configuration Mock URL.
|
|
260
|
+
* @returns {Promise<void>} 探测完成 / Probe completion.
|
|
261
|
+
*/
|
|
262
|
+
async check(url) {
|
|
263
|
+
this.#controller?.abort();
|
|
264
|
+
const controller = (this.#controller = new AbortController());
|
|
265
|
+
this.#render("checking");
|
|
266
|
+
const timer = setTimeout(() => controller.abort(), 3500);
|
|
267
|
+
try {
|
|
268
|
+
const response = await fetch(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal });
|
|
269
|
+
if (controller !== this.#controller) return;
|
|
270
|
+
const version = response.headers.get("X-PreferencePanes-Version")?.trim() || null;
|
|
271
|
+
this.#render(response.status === 200 ? "installed" : "missing", version);
|
|
272
|
+
} catch {
|
|
273
|
+
if (controller === this.#controller) this.#render("missing");
|
|
274
|
+
} finally {
|
|
275
|
+
clearTimeout(timer);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* 更新状态标签,缺少版本时不伪造版本号。
|
|
281
|
+
* Render the label without inventing a missing version.
|
|
282
|
+
* @param {"checking" | "installed" | "missing"} status 状态 / State.
|
|
283
|
+
* @param {string | null} [version] 业务版本 / Business version.
|
|
284
|
+
* @returns {void} 无返回值 / No return value.
|
|
285
|
+
*/
|
|
286
|
+
#render(status, version = null) {
|
|
287
|
+
this.#state = { status, version: status === "installed" ? version : null };
|
|
288
|
+
switch (status) {
|
|
289
|
+
case "checking":
|
|
290
|
+
this.#element.textContent = "检测中";
|
|
291
|
+
break;
|
|
292
|
+
case "installed":
|
|
293
|
+
this.#element.textContent = version ?? "版本未知";
|
|
294
|
+
break;
|
|
295
|
+
case "missing":
|
|
296
|
+
this.#element.textContent = "未安装";
|
|
297
|
+
break;
|
|
298
|
+
}
|
|
299
|
+
this.#element.dataset.state = status;
|
|
300
|
+
this.#element.title = this.#element.textContent;
|
|
301
|
+
this.dispatchEvent(new Event("change"));
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* 释放尚未完成的探测。
|
|
306
|
+
* Release pending probes.
|
|
307
|
+
* @returns {void} 无返回值 / No return value.
|
|
308
|
+
*/
|
|
309
|
+
destroy() {
|
|
310
|
+
this.#controller?.abort();
|
|
311
|
+
this.#controller = undefined;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
101
315
|
/**
|
|
102
316
|
* 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
|
|
103
317
|
* Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
|
|
@@ -257,4 +471,4 @@ class Navigation extends EventTarget {
|
|
|
257
471
|
}
|
|
258
472
|
}
|
|
259
473
|
|
|
260
|
-
export { ModuleFrame, Navigation };
|
|
474
|
+
export { ActionMenu, ModuleFrame, ModuleStatus, Navigation };
|
|
@@ -1,12 +1,4 @@
|
|
|
1
|
-
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:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\n height: 100dvh;\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-
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* 解析已经取得的 pathname,避免重复构造 URL。
|
|
5
|
-
* Parse an existing pathname without constructing another URL.
|
|
6
|
-
* @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
|
|
7
|
-
* @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
|
|
8
|
-
* @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
|
|
9
|
-
*/
|
|
1
|
+
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:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\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));\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-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@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";
|
|
10
2
|
|
|
11
3
|
/**
|
|
12
4
|
* 校验原始路径片段,不进行 URL 编码转换。
|
|
@@ -165,6 +157,122 @@ function errorView(error, retry) {
|
|
|
165
157
|
return view;
|
|
166
158
|
}
|
|
167
159
|
|
|
160
|
+
/**
|
|
161
|
+
* 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。
|
|
162
|
+
* Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.
|
|
163
|
+
*/
|
|
164
|
+
class ActionMenu {
|
|
165
|
+
#button;
|
|
166
|
+
#popup;
|
|
167
|
+
#backdrop;
|
|
168
|
+
#select;
|
|
169
|
+
#document;
|
|
170
|
+
#key = event => {
|
|
171
|
+
if (event.key === "Escape" && !this.#popup.hidden) {
|
|
172
|
+
event.preventDefault();
|
|
173
|
+
this.close();
|
|
174
|
+
this.#button.focus();
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 创建菜单,操作逻辑由调用方提供。
|
|
180
|
+
* Create a menu whose actions are handled by the caller.
|
|
181
|
+
* @param {(id: string) => void} select 菜单选择回调 / Selection callback.
|
|
182
|
+
*/
|
|
183
|
+
constructor(select) {
|
|
184
|
+
this.#document = document;
|
|
185
|
+
this.#select = select;
|
|
186
|
+
this.element = document.createElement("span");
|
|
187
|
+
const root = this.element.attachShadow({ mode: "open" });
|
|
188
|
+
root.innerHTML = `<style>
|
|
189
|
+
:host{display:inline-flex;position:relative;width:44px;height:44px;color:inherit}
|
|
190
|
+
:host([hidden]),[hidden]{display:none!important}
|
|
191
|
+
button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
192
|
+
button:disabled{opacity:.4;cursor:default}
|
|
193
|
+
button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
|
|
194
|
+
#trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}
|
|
195
|
+
svg{display:block;width:24px;height:24px;fill:currentColor}
|
|
196
|
+
#backdrop{position:fixed;inset:0;z-index:1}
|
|
197
|
+
#items{position:absolute;right:0;top:46px;z-index:2;min-width:160px;padding:6px;background:var(--pp-surface,Canvas);color:var(--pp-text,CanvasText);border:1px solid var(--pp-border,#8884);border-radius:12px;box-shadow:0 8px 28px #0003}
|
|
198
|
+
#items button{display:block;text-align:left;white-space:nowrap;width:100%;padding:11px 14px;border-radius:8px;font:14px/1.4 system-ui,sans-serif}
|
|
199
|
+
#items button:hover{background:#8882}
|
|
200
|
+
#items button[data-danger]{color:#e45656}
|
|
201
|
+
</style><button id="trigger" type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false" aria-controls="items"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="4" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="20" cy="12" r="2"/></svg></button><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单" hidden></button><div id="items" role="menu" hidden></div>`;
|
|
202
|
+
this.#button = root.querySelector("#trigger");
|
|
203
|
+
this.#popup = root.querySelector("#items");
|
|
204
|
+
this.#backdrop = root.querySelector("#backdrop");
|
|
205
|
+
this.#button.onclick = () => {
|
|
206
|
+
const open = this.#popup.hidden;
|
|
207
|
+
this.#popup.hidden = this.#backdrop.hidden = !open;
|
|
208
|
+
this.#button.setAttribute("aria-expanded", String(open));
|
|
209
|
+
if (open) this.#popup.firstElementChild.focus();
|
|
210
|
+
};
|
|
211
|
+
this.#backdrop.onclick = () => this.close();
|
|
212
|
+
this.#popup.onkeydown = event => {
|
|
213
|
+
if (event.key === "Tab") {
|
|
214
|
+
this.close();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
const items = [...this.#popup.children];
|
|
218
|
+
const index = items.indexOf(root.activeElement);
|
|
219
|
+
const offsets = { ArrowDown: 1, ArrowUp: -1 };
|
|
220
|
+
if (event.key in offsets) {
|
|
221
|
+
event.preventDefault();
|
|
222
|
+
items[(index + offsets[event.key] + items.length) % items.length].focus();
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
document.addEventListener("keydown", this.#key);
|
|
226
|
+
this.update([]);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* 同步可用操作和忙碌状态,不重建菜单触发按钮。
|
|
231
|
+
* Update actions and busy state without replacing the trigger button.
|
|
232
|
+
* @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.
|
|
233
|
+
* @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
|
|
234
|
+
* @returns {void} 无返回值 / No return value.
|
|
235
|
+
*/
|
|
236
|
+
update(items, disabled = false) {
|
|
237
|
+
this.close();
|
|
238
|
+
this.#button.disabled = disabled || items.length === 0;
|
|
239
|
+
this.#popup.replaceChildren(
|
|
240
|
+
...items.map(item => {
|
|
241
|
+
const button = this.#document.createElement("button");
|
|
242
|
+
button.type = "button";
|
|
243
|
+
button.setAttribute("role", "menuitem");
|
|
244
|
+
button.textContent = item.label;
|
|
245
|
+
button.toggleAttribute("data-danger", Boolean(item.destructive));
|
|
246
|
+
button.onclick = () => {
|
|
247
|
+
this.close();
|
|
248
|
+
this.#select(item.id);
|
|
249
|
+
};
|
|
250
|
+
return button;
|
|
251
|
+
}),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* 关闭菜单。
|
|
257
|
+
* Close the menu.
|
|
258
|
+
* @returns {void} 无返回值 / No return value.
|
|
259
|
+
*/
|
|
260
|
+
close() {
|
|
261
|
+
this.#popup.hidden = this.#backdrop.hidden = true;
|
|
262
|
+
this.#button.setAttribute("aria-expanded", "false");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 移除监听器与节点。
|
|
267
|
+
* Remove listeners and elements.
|
|
268
|
+
* @returns {void} 无返回值 / No return value.
|
|
269
|
+
*/
|
|
270
|
+
destroy() {
|
|
271
|
+
this.#document.removeEventListener("keydown", this.#key);
|
|
272
|
+
this.element.remove();
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
168
276
|
/**
|
|
169
277
|
* 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
|
|
170
278
|
* Normalize a BoxJS array, app or subscription using the source JSON as the field authority.
|
|
@@ -304,31 +412,31 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
304
412
|
*/
|
|
305
413
|
const sessions = new Map();
|
|
306
414
|
/**
|
|
307
|
-
*
|
|
308
|
-
* Send a
|
|
309
|
-
* @param {string} path
|
|
310
|
-
* @param {"
|
|
311
|
-
* @param {unknown} body
|
|
415
|
+
* 用 form 发送完整存储键;读取 404 交给调用方处理。
|
|
416
|
+
* Send a complete storage key as form data; callers handle missing reads.
|
|
417
|
+
* @param {string} path 完整 @root.path / Complete @root.path.
|
|
418
|
+
* @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
|
|
419
|
+
* @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
|
|
312
420
|
* @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
|
|
313
421
|
* @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
|
|
314
422
|
* @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
|
|
315
423
|
*/
|
|
316
|
-
async function send(path,
|
|
424
|
+
async function send(path, action, body, signal) {
|
|
317
425
|
const controller = new AbortController();
|
|
318
426
|
const abort = () => controller.abort();
|
|
319
427
|
if (signal?.aborted) abort();
|
|
320
428
|
signal?.addEventListener("abort", abort, { once: true });
|
|
321
429
|
const timer = setTimeout(abort, timeout);
|
|
322
430
|
try {
|
|
323
|
-
const response = await request(
|
|
324
|
-
method,
|
|
431
|
+
const response = await request(`/api/${action}`, {
|
|
432
|
+
method: "POST",
|
|
325
433
|
credentials: "omit",
|
|
326
434
|
cache: "no-store",
|
|
327
435
|
signal: controller.signal,
|
|
328
|
-
headers: { "
|
|
329
|
-
|
|
436
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
437
|
+
body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
|
|
330
438
|
});
|
|
331
|
-
if (response.status !== 200 && !(
|
|
439
|
+
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
332
440
|
return response;
|
|
333
441
|
} finally {
|
|
334
442
|
clearTimeout(timer);
|
|
@@ -352,21 +460,21 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
352
460
|
* Serialize single-key mutations and update a still-active session only after success.
|
|
353
461
|
* @param {string} module 已打开模块 / Open module.
|
|
354
462
|
* @param {string} key 完整点分字段路径 / Complete dotted field path.
|
|
355
|
-
* @param {"
|
|
463
|
+
* @param {"set" | "delete"} action 写入或删除 / Write or delete.
|
|
356
464
|
* @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
|
|
357
465
|
* @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
|
|
358
466
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
359
467
|
* @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
|
|
360
468
|
*/
|
|
361
|
-
async function change(module, key,
|
|
469
|
+
async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
|
|
362
470
|
const state = sessions.get(module);
|
|
363
471
|
if (!state?.definition) throw new Error("Open the module first");
|
|
364
472
|
if (state.saving) throw new Error("A settings write is already in progress");
|
|
365
473
|
const field = state.definition.fields.find(field => field.key === key);
|
|
366
474
|
state.saving = true;
|
|
367
475
|
try {
|
|
368
|
-
if ((operation === "write" || operation === "delete") && (!field || (
|
|
369
|
-
await send(
|
|
476
|
+
if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
|
|
477
|
+
await send(`@${state.definition.storageKey}.${key}`, action, value);
|
|
370
478
|
if (sessions.get(module) === state) {
|
|
371
479
|
switch (operation) {
|
|
372
480
|
case "write":
|
|
@@ -409,7 +517,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
409
517
|
sessions.set(module, state);
|
|
410
518
|
try {
|
|
411
519
|
const definition = normalizeBoxJs(catalog.select(module), module);
|
|
412
|
-
const response = await send(
|
|
520
|
+
const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
|
|
413
521
|
let subtree = response.status === 404 ? {} : await response.json();
|
|
414
522
|
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
415
523
|
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
@@ -439,7 +547,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
439
547
|
async readCaches(module) {
|
|
440
548
|
const state = sessions.get(module);
|
|
441
549
|
if (!state?.definition) throw new Error("Open the module first");
|
|
442
|
-
const response = await send(
|
|
550
|
+
const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
|
|
443
551
|
return response.status === 404 ? undefined : response.json();
|
|
444
552
|
},
|
|
445
553
|
/**
|
|
@@ -448,14 +556,14 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
448
556
|
* @param {string} module 已打开模块 / Open module.
|
|
449
557
|
* @returns {Promise<void>} 清理完成 / Cleanup completion.
|
|
450
558
|
*/
|
|
451
|
-
clearCaches: module => change(module, `${module}.Caches`, "
|
|
559
|
+
clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
|
|
452
560
|
/**
|
|
453
561
|
* 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
|
|
454
562
|
* Delete module persistence and reset the page cache using current BoxJS defaults.
|
|
455
563
|
* @param {string} module 已打开模块 / Open module.
|
|
456
564
|
* @returns {Promise<void>} 重置完成 / Reset completion.
|
|
457
565
|
*/
|
|
458
|
-
reset: module => change(module, module, "
|
|
566
|
+
reset: module => change(module, module, "delete", undefined, "reset"),
|
|
459
567
|
/**
|
|
460
568
|
* 取消读取并清除会话,不撤销已发送的写入。
|
|
461
569
|
* Abort reads and clear the session without undoing dispatched writes.
|
|
@@ -474,7 +582,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
474
582
|
* @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
|
|
475
583
|
* @returns {Promise<void>} 写入完成 / Write completion.
|
|
476
584
|
*/
|
|
477
|
-
set: (module, key, value) => change(module, key, "
|
|
585
|
+
set: (module, key, value) => change(module, key, "set", value),
|
|
478
586
|
/**
|
|
479
587
|
* 删除单键覆盖值并显示默认值。
|
|
480
588
|
* Delete one override and display its default value.
|
|
@@ -482,7 +590,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
482
590
|
* @param {string} key 点分字段路径 / Dotted field path.
|
|
483
591
|
* @returns {Promise<void>} 删除完成 / Delete completion.
|
|
484
592
|
*/
|
|
485
|
-
remove: (module, key) => change(module, key, "
|
|
593
|
+
remove: (module, key) => change(module, key, "delete"),
|
|
486
594
|
};
|
|
487
595
|
}
|
|
488
596
|
|
|
@@ -663,6 +771,15 @@ function mountPanel(root, catalog) {
|
|
|
663
771
|
back.setAttribute("aria-label", "返回");
|
|
664
772
|
back.type = "button";
|
|
665
773
|
const heading = element("h1", "pp-title", title);
|
|
774
|
+
const handlers = new Map();
|
|
775
|
+
const menuItems = [
|
|
776
|
+
{ id: "viewCaches", label: "查看缓存" },
|
|
777
|
+
{ id: "clearCaches", label: "清空缓存", destructive: true },
|
|
778
|
+
{ id: "reset", label: "重置模块", destructive: true },
|
|
779
|
+
];
|
|
780
|
+
const menu = new ActionMenu(id => handlers.get(id)());
|
|
781
|
+
const trailing = element("span", "pp-nav-spacer");
|
|
782
|
+
trailing.append(menu.element);
|
|
666
783
|
const brand = element("div", "pp-brand");
|
|
667
784
|
const logo = element("span", "pp-brand-icon");
|
|
668
785
|
logo.setAttribute("aria-hidden", "true");
|
|
@@ -673,20 +790,26 @@ function mountPanel(root, catalog) {
|
|
|
673
790
|
const toast = element("div", "pp-toast");
|
|
674
791
|
toast.setAttribute("role", "status");
|
|
675
792
|
toast.hidden = true;
|
|
676
|
-
header.append(back, brand,
|
|
793
|
+
header.append(back, brand, trailing);
|
|
677
794
|
shell.append(header, viewport, toast);
|
|
678
795
|
root.append(shell);
|
|
679
796
|
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
680
797
|
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
681
798
|
const publishNavigation = () => {
|
|
799
|
+
const actions = handlers.size ? menuItems : [];
|
|
800
|
+
menu.update(actions, saving);
|
|
682
801
|
const frame = window.frameElement;
|
|
683
802
|
if (!frame?.dataset.preferencePanes) return;
|
|
684
803
|
frame.dispatchEvent(
|
|
685
804
|
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
686
|
-
detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled },
|
|
805
|
+
detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled, actions },
|
|
687
806
|
}),
|
|
688
807
|
);
|
|
689
808
|
};
|
|
809
|
+
const onAction = event => {
|
|
810
|
+
if (!saving && handlers.has(event.detail)) handlers.get(event.detail)();
|
|
811
|
+
};
|
|
812
|
+
window.frameElement?.addEventListener("preferencepanes:action", onAction);
|
|
690
813
|
let timer,
|
|
691
814
|
navigation,
|
|
692
815
|
generation = 0,
|
|
@@ -996,17 +1119,13 @@ function mountPanel(root, catalog) {
|
|
|
996
1119
|
if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
|
|
997
1120
|
groups.get(group).append(row);
|
|
998
1121
|
}
|
|
999
|
-
const
|
|
1000
|
-
maintenance.append(element("h2", "pp-title", "模块数据"));
|
|
1001
|
-
const actions = element("div", "pp-actions");
|
|
1002
|
-
const cacheView = element("button", "", "查看 Caches");
|
|
1003
|
-
const cacheClear = element("button", "", "清空 Caches");
|
|
1004
|
-
const reset = element("button", "pp-danger", "重置模块");
|
|
1122
|
+
const cachePage = element("section", "pp-cache-page");
|
|
1005
1123
|
const output = element("pre", "pp-cache");
|
|
1006
|
-
output.
|
|
1124
|
+
output.textContent = "暂无缓存";
|
|
1007
1125
|
output.setAttribute("aria-label", "Caches 内容");
|
|
1008
|
-
|
|
1009
|
-
|
|
1126
|
+
cachePage.append(output);
|
|
1127
|
+
editors.set("$caches", { node: cachePage, title: "缓存" });
|
|
1128
|
+
handlers.set("viewCaches", () => {
|
|
1010
1129
|
if (saving) return;
|
|
1011
1130
|
let value;
|
|
1012
1131
|
return perform(
|
|
@@ -1020,12 +1139,11 @@ function mountPanel(root, catalog) {
|
|
|
1020
1139
|
},
|
|
1021
1140
|
() => {
|
|
1022
1141
|
output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
|
|
1023
|
-
|
|
1024
|
-
cacheView.textContent = "刷新 Caches";
|
|
1142
|
+
navigation.open("$caches");
|
|
1025
1143
|
},
|
|
1026
1144
|
);
|
|
1027
|
-
};
|
|
1028
|
-
|
|
1145
|
+
});
|
|
1146
|
+
handlers.set("clearCaches", () => {
|
|
1029
1147
|
if (saving) return;
|
|
1030
1148
|
if (!window.confirm(`清空 ${active} 的全部 Caches?`)) return;
|
|
1031
1149
|
return perform(
|
|
@@ -1034,15 +1152,12 @@ function mountPanel(root, catalog) {
|
|
|
1034
1152
|
output.textContent = "暂无缓存";
|
|
1035
1153
|
},
|
|
1036
1154
|
);
|
|
1037
|
-
};
|
|
1038
|
-
reset
|
|
1155
|
+
});
|
|
1156
|
+
handlers.set("reset", () => {
|
|
1039
1157
|
if (saving) return;
|
|
1040
1158
|
if (!window.confirm(`重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) return;
|
|
1041
1159
|
return perform(() => client.reset(active), controls);
|
|
1042
|
-
};
|
|
1043
|
-
actions.append(cacheView, cacheClear, reset);
|
|
1044
|
-
maintenance.append(actions, output);
|
|
1045
|
-
view.append(maintenance);
|
|
1160
|
+
});
|
|
1046
1161
|
navigation?.destroy();
|
|
1047
1162
|
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
1048
1163
|
navigation.addEventListener("change", updateNavigation);
|
|
@@ -1068,6 +1183,8 @@ function mountPanel(root, catalog) {
|
|
|
1068
1183
|
*/
|
|
1069
1184
|
destroy() {
|
|
1070
1185
|
destroyed = true;
|
|
1186
|
+
menu.destroy();
|
|
1187
|
+
window.frameElement?.removeEventListener("preferencepanes:action", onAction);
|
|
1071
1188
|
navigation?.destroy();
|
|
1072
1189
|
generation++;
|
|
1073
1190
|
if (active && !saving) client.leave(active);
|