@nsnanocat/preference-panes 0.7.1 → 0.8.0
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 +31 -68
- package/dist/api.js +1622 -0
- package/dist/module/app.mjs +52 -32
- package/dist/module/index.html +1 -1
- package/dist/module/navigation.mjs +101 -1
- package/dist/preference-panes.mjs +38 -30
- package/package.json +1 -1
- package/src/Store.mjs +40 -53
- package/src/browser/ModuleFrame.mjs +83 -0
- package/src/browser/Navigation.d.mts +42 -0
- package/src/browser/Navigation.mjs +2 -0
- package/src/browser/app.mjs +13 -1
- package/src/browser/client.d.mts +2 -2
- package/src/browser/client.mjs +21 -21
- package/src/browser/panel.css +9 -0
- package/src/browser/panel.mjs +16 -0
- 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
package/dist/module/app.mjs
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 解析已经取得的 pathname,避免重复构造 URL。
|
|
3
|
-
* Parse an existing pathname without constructing another URL.
|
|
4
|
-
* @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
|
|
5
|
-
* @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
|
|
6
|
-
* @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
1
|
/**
|
|
10
2
|
* 校验原始路径片段,不进行 URL 编码转换。
|
|
11
3
|
* Validate raw path segments without URL encoding conversion.
|
|
@@ -116,7 +108,7 @@ function pageInputs(url, headers = {}) {
|
|
|
116
108
|
const module = match[1];
|
|
117
109
|
const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
118
110
|
const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
|
|
119
|
-
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ??
|
|
111
|
+
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
|
|
120
112
|
if (!json.trim()) throw new TypeError("JSON resource URL is required");
|
|
121
113
|
return { url: url.href, module, json, css };
|
|
122
114
|
}
|
|
@@ -181,7 +173,7 @@ function errorView(error, retry) {
|
|
|
181
173
|
return view;
|
|
182
174
|
}
|
|
183
175
|
|
|
184
|
-
var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-brand {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n text-align: center;\n}\n.pp-brand-icon {\n display: none;\n flex: none;\n width: 28px;\n height: 28px;\n}\n.pp-brand-icon:not(:empty) {\n display: block;\n}\n.pp-brand-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n}\n.pp-fields,\n.pp-choice-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
|
|
176
|
+
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-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
|
|
185
177
|
|
|
186
178
|
/**
|
|
187
179
|
* 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
|
|
@@ -322,31 +314,31 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
322
314
|
*/
|
|
323
315
|
const sessions = new Map();
|
|
324
316
|
/**
|
|
325
|
-
*
|
|
326
|
-
* Send a
|
|
327
|
-
* @param {string} path
|
|
328
|
-
* @param {"
|
|
329
|
-
* @param {unknown} body
|
|
317
|
+
* 用 form 发送完整存储键;读取 404 交给调用方处理。
|
|
318
|
+
* Send a complete storage key as form data; callers handle missing reads.
|
|
319
|
+
* @param {string} path 完整 @root.path / Complete @root.path.
|
|
320
|
+
* @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
|
|
321
|
+
* @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
|
|
330
322
|
* @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
|
|
331
323
|
* @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
|
|
332
324
|
* @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
|
|
333
325
|
*/
|
|
334
|
-
async function send(path,
|
|
326
|
+
async function send(path, action, body, signal) {
|
|
335
327
|
const controller = new AbortController();
|
|
336
328
|
const abort = () => controller.abort();
|
|
337
329
|
if (signal?.aborted) abort();
|
|
338
330
|
signal?.addEventListener("abort", abort, { once: true });
|
|
339
331
|
const timer = setTimeout(abort, timeout);
|
|
340
332
|
try {
|
|
341
|
-
const response = await request(
|
|
342
|
-
method,
|
|
333
|
+
const response = await request(`/api/${action}`, {
|
|
334
|
+
method: "POST",
|
|
343
335
|
credentials: "omit",
|
|
344
336
|
cache: "no-store",
|
|
345
337
|
signal: controller.signal,
|
|
346
|
-
headers: { "
|
|
347
|
-
|
|
338
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
339
|
+
body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
|
|
348
340
|
});
|
|
349
|
-
if (response.status !== 200 && !(
|
|
341
|
+
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
350
342
|
return response;
|
|
351
343
|
} finally {
|
|
352
344
|
clearTimeout(timer);
|
|
@@ -370,21 +362,21 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
370
362
|
* Serialize single-key mutations and update a still-active session only after success.
|
|
371
363
|
* @param {string} module 已打开模块 / Open module.
|
|
372
364
|
* @param {string} key 完整点分字段路径 / Complete dotted field path.
|
|
373
|
-
* @param {"
|
|
365
|
+
* @param {"set" | "delete"} action 写入或删除 / Write or delete.
|
|
374
366
|
* @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
|
|
375
367
|
* @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
|
|
376
368
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
377
369
|
* @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
|
|
378
370
|
*/
|
|
379
|
-
async function change(module, key,
|
|
371
|
+
async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
|
|
380
372
|
const state = sessions.get(module);
|
|
381
373
|
if (!state?.definition) throw new Error("Open the module first");
|
|
382
374
|
if (state.saving) throw new Error("A settings write is already in progress");
|
|
383
375
|
const field = state.definition.fields.find(field => field.key === key);
|
|
384
376
|
state.saving = true;
|
|
385
377
|
try {
|
|
386
|
-
if ((operation === "write" || operation === "delete") && (!field || (
|
|
387
|
-
await send(
|
|
378
|
+
if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
|
|
379
|
+
await send(`@${state.definition.storageKey}.${key}`, action, value);
|
|
388
380
|
if (sessions.get(module) === state) {
|
|
389
381
|
switch (operation) {
|
|
390
382
|
case "write":
|
|
@@ -427,7 +419,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
427
419
|
sessions.set(module, state);
|
|
428
420
|
try {
|
|
429
421
|
const definition = normalizeBoxJs(catalog.select(module), module);
|
|
430
|
-
const response = await send(
|
|
422
|
+
const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
|
|
431
423
|
let subtree = response.status === 404 ? {} : await response.json();
|
|
432
424
|
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
433
425
|
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
@@ -457,7 +449,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
457
449
|
async readCaches(module) {
|
|
458
450
|
const state = sessions.get(module);
|
|
459
451
|
if (!state?.definition) throw new Error("Open the module first");
|
|
460
|
-
const response = await send(
|
|
452
|
+
const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
|
|
461
453
|
return response.status === 404 ? undefined : response.json();
|
|
462
454
|
},
|
|
463
455
|
/**
|
|
@@ -466,14 +458,14 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
466
458
|
* @param {string} module 已打开模块 / Open module.
|
|
467
459
|
* @returns {Promise<void>} 清理完成 / Cleanup completion.
|
|
468
460
|
*/
|
|
469
|
-
clearCaches: module => change(module, `${module}.Caches`, "
|
|
461
|
+
clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
|
|
470
462
|
/**
|
|
471
463
|
* 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
|
|
472
464
|
* Delete module persistence and reset the page cache using current BoxJS defaults.
|
|
473
465
|
* @param {string} module 已打开模块 / Open module.
|
|
474
466
|
* @returns {Promise<void>} 重置完成 / Reset completion.
|
|
475
467
|
*/
|
|
476
|
-
reset: module => change(module, module, "
|
|
468
|
+
reset: module => change(module, module, "delete", undefined, "reset"),
|
|
477
469
|
/**
|
|
478
470
|
* 取消读取并清除会话,不撤销已发送的写入。
|
|
479
471
|
* Abort reads and clear the session without undoing dispatched writes.
|
|
@@ -492,7 +484,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
492
484
|
* @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
|
|
493
485
|
* @returns {Promise<void>} 写入完成 / Write completion.
|
|
494
486
|
*/
|
|
495
|
-
set: (module, key, value) => change(module, key, "
|
|
487
|
+
set: (module, key, value) => change(module, key, "set", value),
|
|
496
488
|
/**
|
|
497
489
|
* 删除单键覆盖值并显示默认值。
|
|
498
490
|
* Delete one override and display its default value.
|
|
@@ -500,7 +492,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
500
492
|
* @param {string} key 点分字段路径 / Dotted field path.
|
|
501
493
|
* @returns {Promise<void>} 删除完成 / Delete completion.
|
|
502
494
|
*/
|
|
503
|
-
remove: (module, key) => change(module, key, "
|
|
495
|
+
remove: (module, key) => change(module, key, "delete"),
|
|
504
496
|
};
|
|
505
497
|
}
|
|
506
498
|
|
|
@@ -694,6 +686,17 @@ function mountPanel(root, catalog) {
|
|
|
694
686
|
header.append(back, brand, element("span", "pp-nav-spacer"));
|
|
695
687
|
shell.append(header, viewport, toast);
|
|
696
688
|
root.append(shell);
|
|
689
|
+
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
690
|
+
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
691
|
+
const publishNavigation = () => {
|
|
692
|
+
const frame = window.frameElement;
|
|
693
|
+
if (!frame?.dataset.preferencePanes) return;
|
|
694
|
+
frame.dispatchEvent(
|
|
695
|
+
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
696
|
+
detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled },
|
|
697
|
+
}),
|
|
698
|
+
);
|
|
699
|
+
};
|
|
697
700
|
let timer,
|
|
698
701
|
navigation,
|
|
699
702
|
generation = 0,
|
|
@@ -744,6 +747,7 @@ function mountPanel(root, catalog) {
|
|
|
744
747
|
active = module;
|
|
745
748
|
back.disabled = window.history.length <= 1;
|
|
746
749
|
heading.textContent = module;
|
|
750
|
+
publishNavigation();
|
|
747
751
|
viewport.replaceChildren(element("p", "pp-loading", "读取设置…"));
|
|
748
752
|
try {
|
|
749
753
|
await client.open(module);
|
|
@@ -751,6 +755,7 @@ function mountPanel(root, catalog) {
|
|
|
751
755
|
} catch (error) {
|
|
752
756
|
if (version !== generation) return;
|
|
753
757
|
viewport.replaceChildren(errorView(error, () => open(module)));
|
|
758
|
+
publishNavigation();
|
|
754
759
|
}
|
|
755
760
|
}
|
|
756
761
|
/**
|
|
@@ -782,6 +787,7 @@ function mountPanel(root, catalog) {
|
|
|
782
787
|
const editor = editors.get(navigation.current);
|
|
783
788
|
heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
|
|
784
789
|
back.disabled = saving || !navigation.canGoBack;
|
|
790
|
+
publishNavigation();
|
|
785
791
|
};
|
|
786
792
|
/**
|
|
787
793
|
* 串行执行模块操作,保持输入可编辑。
|
|
@@ -795,6 +801,7 @@ function mountPanel(root, catalog) {
|
|
|
795
801
|
pendingWrites++;
|
|
796
802
|
saving = true;
|
|
797
803
|
back.disabled = true;
|
|
804
|
+
publishNavigation();
|
|
798
805
|
return (queue = queue
|
|
799
806
|
.then(action)
|
|
800
807
|
.then(() => {
|
|
@@ -810,6 +817,7 @@ function mountPanel(root, catalog) {
|
|
|
810
817
|
saving = pendingWrites > 0;
|
|
811
818
|
if (destroyed && !saving) client.leave(active);
|
|
812
819
|
back.disabled = saving || !navigation.canGoBack;
|
|
820
|
+
publishNavigation();
|
|
813
821
|
}));
|
|
814
822
|
}
|
|
815
823
|
const metadata = definition.metadata;
|
|
@@ -1148,7 +1156,19 @@ async function start() {
|
|
|
1148
1156
|
view?.destroy();
|
|
1149
1157
|
view = undefined;
|
|
1150
1158
|
const context = document.querySelector('meta[name="preference-panes-inputs"]');
|
|
1151
|
-
const
|
|
1159
|
+
const embedded = window.frameElement?.dataset.preferencePanes;
|
|
1160
|
+
let inputs;
|
|
1161
|
+
switch (true) {
|
|
1162
|
+
case embedded !== undefined:
|
|
1163
|
+
inputs = JSON.parse(embedded);
|
|
1164
|
+
document.documentElement.dataset.preferencePanesEmbedded = "";
|
|
1165
|
+
break;
|
|
1166
|
+
case context !== null:
|
|
1167
|
+
inputs = JSON.parse(decodeURIComponent(context.content));
|
|
1168
|
+
break;
|
|
1169
|
+
default:
|
|
1170
|
+
inputs = pageInputs(new URL(location.href));
|
|
1171
|
+
}
|
|
1152
1172
|
const resources = [inputs.json, inputs.css].map(source => {
|
|
1153
1173
|
if (!source) return null;
|
|
1154
1174
|
const url = new URL(source, inputs.url);
|
package/dist/module/index.html
CHANGED
|
@@ -1,3 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
|
|
3
|
+
* Resolve module resource locations: headers override query parameters and module conventions.
|
|
4
|
+
* @param {URL} url 已解析的页面请求地址 / Parsed page request URL.
|
|
5
|
+
* @param {Record<string, string | undefined>} [headers] 请求头,名称不区分大小写 / Case-insensitive request headers.
|
|
6
|
+
* @returns {{url: string, module: string, json: string, css: string}} 页面上下文与两个资源输入 / Page context and two resource inputs.
|
|
7
|
+
*/
|
|
8
|
+
function pageInputs(url, headers = {}) {
|
|
9
|
+
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(url.pathname);
|
|
10
|
+
if (!match) throw new TypeError("Open a concrete module URL");
|
|
11
|
+
const module = match[1];
|
|
12
|
+
const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
13
|
+
const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
|
|
14
|
+
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
|
|
15
|
+
if (!json.trim()) throw new TypeError("JSON resource URL is required");
|
|
16
|
+
return { url: url.href, module, json, css };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 模块文档容器:原始 HTML 不改写,请求上下文随 iframe 元素传递。
|
|
21
|
+
* Module document container: preserve HTML verbatim and carry request context on the iframe element.
|
|
22
|
+
*/
|
|
23
|
+
class ModuleFrame extends EventTarget {
|
|
24
|
+
#url;
|
|
25
|
+
#options;
|
|
26
|
+
#controller = new AbortController();
|
|
27
|
+
#abort = () => this.destroy();
|
|
28
|
+
#state;
|
|
29
|
+
#change = event => {
|
|
30
|
+
this.#state = event.detail;
|
|
31
|
+
this.dispatchEvent(new Event("change"));
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 建立 iframe 与请求输入;调用方挂载 element 后调用 load。
|
|
36
|
+
* Create the iframe and request inputs; callers mount element and then call load.
|
|
37
|
+
* @param {string | URL} url 模块请求地址 / Module request URL.
|
|
38
|
+
* @param {RequestInit} [options] 原生请求头和取消信号 / Native headers and cancellation signal.
|
|
39
|
+
*/
|
|
40
|
+
constructor(url, options = {}) {
|
|
41
|
+
super();
|
|
42
|
+
this.#url = new URL(url, document.baseURI);
|
|
43
|
+
this.#options = { ...options, headers: new Headers(options.headers) };
|
|
44
|
+
const inputs = pageInputs(this.#url, Object.fromEntries(this.#options.headers));
|
|
45
|
+
this.element = document.createElement("iframe");
|
|
46
|
+
this.element.title = `${inputs.module} 设置`;
|
|
47
|
+
this.element.dataset.preferencePanes = JSON.stringify(inputs);
|
|
48
|
+
this.element.addEventListener("preferencepanes:change", this.#change);
|
|
49
|
+
this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true };
|
|
50
|
+
options.signal?.addEventListener("abort", this.#abort, { once: true });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 当前模块导航状态。
|
|
55
|
+
* Current module navigation state.
|
|
56
|
+
*/
|
|
57
|
+
get state() {
|
|
58
|
+
return { ...this.#state };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 获取原始 HTML;晚到响应在退出后不得重新挂载。
|
|
63
|
+
* Fetch unmodified HTML; a late response must not remount after departure.
|
|
64
|
+
* @returns {Promise<void>} HTML 已交给 iframe;表单状态通过 change 事件提供 / HTML assigned; form state is reported through change.
|
|
65
|
+
*/
|
|
66
|
+
async load() {
|
|
67
|
+
if (this.#options.signal?.aborted) this.destroy();
|
|
68
|
+
const timer = setTimeout(() => this.#controller.abort(), 10000);
|
|
69
|
+
try {
|
|
70
|
+
const response = await fetch(this.#url, { cache: "no-store", credentials: "omit", ...this.#options, signal: this.#controller.signal });
|
|
71
|
+
if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
|
|
72
|
+
const html = await response.text();
|
|
73
|
+
this.#controller.signal.throwIfAborted();
|
|
74
|
+
this.element.srcdoc = html;
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 使用 iframe 的联合历史返回;写入期间不导航。
|
|
82
|
+
* Navigate joint iframe history back, except while a write is pending.
|
|
83
|
+
* @returns {void} 无返回值 / No return value.
|
|
84
|
+
*/
|
|
85
|
+
back() {
|
|
86
|
+
if (!this.#state.busy && this.#state.canGoBack) this.element.contentWindow.history.back();
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 取消加载与事件订阅;节点保留到 Navigation 的退出动画结束。
|
|
91
|
+
* Cancel loading and subscriptions; Navigation retains the node until its exit animation ends.
|
|
92
|
+
* @returns {void} 无返回值 / No return value.
|
|
93
|
+
*/
|
|
94
|
+
destroy() {
|
|
95
|
+
this.#controller.abort();
|
|
96
|
+
this.#options.signal?.removeEventListener("abort", this.#abort);
|
|
97
|
+
this.element.removeEventListener("preferencepanes:change", this.#change);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
1
101
|
/**
|
|
2
102
|
* 同一文档内的主页/子页导航;iframe 各自的实例通过浏览器联合历史协作。
|
|
3
103
|
* Navigate home/detail views within a document; iframe instances cooperate through joint browser history.
|
|
@@ -157,4 +257,4 @@ class Navigation extends EventTarget {
|
|
|
157
257
|
}
|
|
158
258
|
}
|
|
159
259
|
|
|
160
|
-
export { Navigation };
|
|
260
|
+
export { ModuleFrame, 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@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n}\n.pp-fields,\n.pp-choice-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
|
|
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 position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
|
|
10
2
|
|
|
11
3
|
/**
|
|
12
4
|
* 校验原始路径片段,不进行 URL 编码转换。
|
|
@@ -304,31 +296,31 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
304
296
|
*/
|
|
305
297
|
const sessions = new Map();
|
|
306
298
|
/**
|
|
307
|
-
*
|
|
308
|
-
* Send a
|
|
309
|
-
* @param {string} path
|
|
310
|
-
* @param {"
|
|
311
|
-
* @param {unknown} body
|
|
299
|
+
* 用 form 发送完整存储键;读取 404 交给调用方处理。
|
|
300
|
+
* Send a complete storage key as form data; callers handle missing reads.
|
|
301
|
+
* @param {string} path 完整 @root.path / Complete @root.path.
|
|
302
|
+
* @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
|
|
303
|
+
* @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
|
|
312
304
|
* @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
|
|
313
305
|
* @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
|
|
314
306
|
* @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
|
|
315
307
|
*/
|
|
316
|
-
async function send(path,
|
|
308
|
+
async function send(path, action, body, signal) {
|
|
317
309
|
const controller = new AbortController();
|
|
318
310
|
const abort = () => controller.abort();
|
|
319
311
|
if (signal?.aborted) abort();
|
|
320
312
|
signal?.addEventListener("abort", abort, { once: true });
|
|
321
313
|
const timer = setTimeout(abort, timeout);
|
|
322
314
|
try {
|
|
323
|
-
const response = await request(
|
|
324
|
-
method,
|
|
315
|
+
const response = await request(`/api/${action}`, {
|
|
316
|
+
method: "POST",
|
|
325
317
|
credentials: "omit",
|
|
326
318
|
cache: "no-store",
|
|
327
319
|
signal: controller.signal,
|
|
328
|
-
headers: { "
|
|
329
|
-
|
|
320
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
321
|
+
body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
|
|
330
322
|
});
|
|
331
|
-
if (response.status !== 200 && !(
|
|
323
|
+
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
332
324
|
return response;
|
|
333
325
|
} finally {
|
|
334
326
|
clearTimeout(timer);
|
|
@@ -352,21 +344,21 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
352
344
|
* Serialize single-key mutations and update a still-active session only after success.
|
|
353
345
|
* @param {string} module 已打开模块 / Open module.
|
|
354
346
|
* @param {string} key 完整点分字段路径 / Complete dotted field path.
|
|
355
|
-
* @param {"
|
|
347
|
+
* @param {"set" | "delete"} action 写入或删除 / Write or delete.
|
|
356
348
|
* @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
|
|
357
349
|
* @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
|
|
358
350
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
359
351
|
* @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
|
|
360
352
|
*/
|
|
361
|
-
async function change(module, key,
|
|
353
|
+
async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
|
|
362
354
|
const state = sessions.get(module);
|
|
363
355
|
if (!state?.definition) throw new Error("Open the module first");
|
|
364
356
|
if (state.saving) throw new Error("A settings write is already in progress");
|
|
365
357
|
const field = state.definition.fields.find(field => field.key === key);
|
|
366
358
|
state.saving = true;
|
|
367
359
|
try {
|
|
368
|
-
if ((operation === "write" || operation === "delete") && (!field || (
|
|
369
|
-
await send(
|
|
360
|
+
if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
|
|
361
|
+
await send(`@${state.definition.storageKey}.${key}`, action, value);
|
|
370
362
|
if (sessions.get(module) === state) {
|
|
371
363
|
switch (operation) {
|
|
372
364
|
case "write":
|
|
@@ -409,7 +401,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
409
401
|
sessions.set(module, state);
|
|
410
402
|
try {
|
|
411
403
|
const definition = normalizeBoxJs(catalog.select(module), module);
|
|
412
|
-
const response = await send(
|
|
404
|
+
const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
|
|
413
405
|
let subtree = response.status === 404 ? {} : await response.json();
|
|
414
406
|
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
415
407
|
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
@@ -439,7 +431,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
439
431
|
async readCaches(module) {
|
|
440
432
|
const state = sessions.get(module);
|
|
441
433
|
if (!state?.definition) throw new Error("Open the module first");
|
|
442
|
-
const response = await send(
|
|
434
|
+
const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
|
|
443
435
|
return response.status === 404 ? undefined : response.json();
|
|
444
436
|
},
|
|
445
437
|
/**
|
|
@@ -448,14 +440,14 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
448
440
|
* @param {string} module 已打开模块 / Open module.
|
|
449
441
|
* @returns {Promise<void>} 清理完成 / Cleanup completion.
|
|
450
442
|
*/
|
|
451
|
-
clearCaches: module => change(module, `${module}.Caches`, "
|
|
443
|
+
clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
|
|
452
444
|
/**
|
|
453
445
|
* 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
|
|
454
446
|
* Delete module persistence and reset the page cache using current BoxJS defaults.
|
|
455
447
|
* @param {string} module 已打开模块 / Open module.
|
|
456
448
|
* @returns {Promise<void>} 重置完成 / Reset completion.
|
|
457
449
|
*/
|
|
458
|
-
reset: module => change(module, module, "
|
|
450
|
+
reset: module => change(module, module, "delete", undefined, "reset"),
|
|
459
451
|
/**
|
|
460
452
|
* 取消读取并清除会话,不撤销已发送的写入。
|
|
461
453
|
* Abort reads and clear the session without undoing dispatched writes.
|
|
@@ -474,7 +466,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
474
466
|
* @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
|
|
475
467
|
* @returns {Promise<void>} 写入完成 / Write completion.
|
|
476
468
|
*/
|
|
477
|
-
set: (module, key, value) => change(module, key, "
|
|
469
|
+
set: (module, key, value) => change(module, key, "set", value),
|
|
478
470
|
/**
|
|
479
471
|
* 删除单键覆盖值并显示默认值。
|
|
480
472
|
* Delete one override and display its default value.
|
|
@@ -482,7 +474,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
482
474
|
* @param {string} key 点分字段路径 / Dotted field path.
|
|
483
475
|
* @returns {Promise<void>} 删除完成 / Delete completion.
|
|
484
476
|
*/
|
|
485
|
-
remove: (module, key) => change(module, key, "
|
|
477
|
+
remove: (module, key) => change(module, key, "delete"),
|
|
486
478
|
};
|
|
487
479
|
}
|
|
488
480
|
|
|
@@ -676,6 +668,17 @@ function mountPanel(root, catalog) {
|
|
|
676
668
|
header.append(back, brand, element("span", "pp-nav-spacer"));
|
|
677
669
|
shell.append(header, viewport, toast);
|
|
678
670
|
root.append(shell);
|
|
671
|
+
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
672
|
+
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
673
|
+
const publishNavigation = () => {
|
|
674
|
+
const frame = window.frameElement;
|
|
675
|
+
if (!frame?.dataset.preferencePanes) return;
|
|
676
|
+
frame.dispatchEvent(
|
|
677
|
+
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
678
|
+
detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled },
|
|
679
|
+
}),
|
|
680
|
+
);
|
|
681
|
+
};
|
|
679
682
|
let timer,
|
|
680
683
|
navigation,
|
|
681
684
|
generation = 0,
|
|
@@ -726,6 +729,7 @@ function mountPanel(root, catalog) {
|
|
|
726
729
|
active = module;
|
|
727
730
|
back.disabled = window.history.length <= 1;
|
|
728
731
|
heading.textContent = module;
|
|
732
|
+
publishNavigation();
|
|
729
733
|
viewport.replaceChildren(element("p", "pp-loading", "读取设置…"));
|
|
730
734
|
try {
|
|
731
735
|
await client.open(module);
|
|
@@ -733,6 +737,7 @@ function mountPanel(root, catalog) {
|
|
|
733
737
|
} catch (error) {
|
|
734
738
|
if (version !== generation) return;
|
|
735
739
|
viewport.replaceChildren(errorView(error, () => open(module)));
|
|
740
|
+
publishNavigation();
|
|
736
741
|
}
|
|
737
742
|
}
|
|
738
743
|
/**
|
|
@@ -764,6 +769,7 @@ function mountPanel(root, catalog) {
|
|
|
764
769
|
const editor = editors.get(navigation.current);
|
|
765
770
|
heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
|
|
766
771
|
back.disabled = saving || !navigation.canGoBack;
|
|
772
|
+
publishNavigation();
|
|
767
773
|
};
|
|
768
774
|
/**
|
|
769
775
|
* 串行执行模块操作,保持输入可编辑。
|
|
@@ -777,6 +783,7 @@ function mountPanel(root, catalog) {
|
|
|
777
783
|
pendingWrites++;
|
|
778
784
|
saving = true;
|
|
779
785
|
back.disabled = true;
|
|
786
|
+
publishNavigation();
|
|
780
787
|
return (queue = queue
|
|
781
788
|
.then(action)
|
|
782
789
|
.then(() => {
|
|
@@ -792,6 +799,7 @@ function mountPanel(root, catalog) {
|
|
|
792
799
|
saving = pendingWrites > 0;
|
|
793
800
|
if (destroyed && !saving) client.leave(active);
|
|
794
801
|
back.disabled = saving || !navigation.canGoBack;
|
|
802
|
+
publishNavigation();
|
|
795
803
|
}));
|
|
796
804
|
}
|
|
797
805
|
const metadata = definition.metadata;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsnanocat/preference-panes",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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",
|