@nsnanocat/preference-panes 0.8.0 → 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 +4 -0
- package/dist/api.js +1 -1
- package/dist/module/app.mjs +147 -22
- package/dist/module/index.html +1 -1
- package/dist/module/navigation.mjs +217 -3
- package/dist/preference-panes.mjs +147 -22
- package/package.json +1 -1
- 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/panel.css +2 -15
- package/src/browser/panel.mjs +31 -21
package/dist/module/app.mjs
CHANGED
|
@@ -173,7 +173,123 @@ function errorView(error, retry) {
|
|
|
173
173
|
return view;
|
|
174
174
|
}
|
|
175
175
|
|
|
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-
|
|
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.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";
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。
|
|
180
|
+
* Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.
|
|
181
|
+
*/
|
|
182
|
+
class ActionMenu {
|
|
183
|
+
#button;
|
|
184
|
+
#popup;
|
|
185
|
+
#backdrop;
|
|
186
|
+
#select;
|
|
187
|
+
#document;
|
|
188
|
+
#key = event => {
|
|
189
|
+
if (event.key === "Escape" && !this.#popup.hidden) {
|
|
190
|
+
event.preventDefault();
|
|
191
|
+
this.close();
|
|
192
|
+
this.#button.focus();
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 创建菜单,操作逻辑由调用方提供。
|
|
198
|
+
* Create a menu whose actions are handled by the caller.
|
|
199
|
+
* @param {(id: string) => void} select 菜单选择回调 / Selection callback.
|
|
200
|
+
*/
|
|
201
|
+
constructor(select) {
|
|
202
|
+
this.#document = document;
|
|
203
|
+
this.#select = select;
|
|
204
|
+
this.element = document.createElement("span");
|
|
205
|
+
const root = this.element.attachShadow({ mode: "open" });
|
|
206
|
+
root.innerHTML = `<style>
|
|
207
|
+
:host{display:inline-flex;position:relative;width:44px;height:44px;color:inherit}
|
|
208
|
+
:host([hidden]),[hidden]{display:none!important}
|
|
209
|
+
button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
210
|
+
button:disabled{opacity:.4;cursor:default}
|
|
211
|
+
button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
|
|
212
|
+
#trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}
|
|
213
|
+
svg{display:block;width:24px;height:24px;fill:currentColor}
|
|
214
|
+
#backdrop{position:fixed;inset:0;z-index:1}
|
|
215
|
+
#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}
|
|
216
|
+
#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}
|
|
217
|
+
#items button:hover{background:#8882}
|
|
218
|
+
#items button[data-danger]{color:#e45656}
|
|
219
|
+
</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>`;
|
|
220
|
+
this.#button = root.querySelector("#trigger");
|
|
221
|
+
this.#popup = root.querySelector("#items");
|
|
222
|
+
this.#backdrop = root.querySelector("#backdrop");
|
|
223
|
+
this.#button.onclick = () => {
|
|
224
|
+
const open = this.#popup.hidden;
|
|
225
|
+
this.#popup.hidden = this.#backdrop.hidden = !open;
|
|
226
|
+
this.#button.setAttribute("aria-expanded", String(open));
|
|
227
|
+
if (open) this.#popup.firstElementChild.focus();
|
|
228
|
+
};
|
|
229
|
+
this.#backdrop.onclick = () => this.close();
|
|
230
|
+
this.#popup.onkeydown = event => {
|
|
231
|
+
if (event.key === "Tab") {
|
|
232
|
+
this.close();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const items = [...this.#popup.children];
|
|
236
|
+
const index = items.indexOf(root.activeElement);
|
|
237
|
+
const offsets = { ArrowDown: 1, ArrowUp: -1 };
|
|
238
|
+
if (event.key in offsets) {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
items[(index + offsets[event.key] + items.length) % items.length].focus();
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
document.addEventListener("keydown", this.#key);
|
|
244
|
+
this.update([]);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* 同步可用操作和忙碌状态,不重建菜单触发按钮。
|
|
249
|
+
* Update actions and busy state without replacing the trigger button.
|
|
250
|
+
* @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.
|
|
251
|
+
* @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
|
|
252
|
+
* @returns {void} 无返回值 / No return value.
|
|
253
|
+
*/
|
|
254
|
+
update(items, disabled = false) {
|
|
255
|
+
this.close();
|
|
256
|
+
this.#button.disabled = disabled || items.length === 0;
|
|
257
|
+
this.#popup.replaceChildren(
|
|
258
|
+
...items.map(item => {
|
|
259
|
+
const button = this.#document.createElement("button");
|
|
260
|
+
button.type = "button";
|
|
261
|
+
button.setAttribute("role", "menuitem");
|
|
262
|
+
button.textContent = item.label;
|
|
263
|
+
button.toggleAttribute("data-danger", Boolean(item.destructive));
|
|
264
|
+
button.onclick = () => {
|
|
265
|
+
this.close();
|
|
266
|
+
this.#select(item.id);
|
|
267
|
+
};
|
|
268
|
+
return button;
|
|
269
|
+
}),
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* 关闭菜单。
|
|
275
|
+
* Close the menu.
|
|
276
|
+
* @returns {void} 无返回值 / No return value.
|
|
277
|
+
*/
|
|
278
|
+
close() {
|
|
279
|
+
this.#popup.hidden = this.#backdrop.hidden = true;
|
|
280
|
+
this.#button.setAttribute("aria-expanded", "false");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* 移除监听器与节点。
|
|
285
|
+
* Remove listeners and elements.
|
|
286
|
+
* @returns {void} 无返回值 / No return value.
|
|
287
|
+
*/
|
|
288
|
+
destroy() {
|
|
289
|
+
this.#document.removeEventListener("keydown", this.#key);
|
|
290
|
+
this.element.remove();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
177
293
|
|
|
178
294
|
/**
|
|
179
295
|
* 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
|
|
@@ -673,6 +789,15 @@ function mountPanel(root, catalog) {
|
|
|
673
789
|
back.setAttribute("aria-label", "返回");
|
|
674
790
|
back.type = "button";
|
|
675
791
|
const heading = element("h1", "pp-title", title);
|
|
792
|
+
const handlers = new Map();
|
|
793
|
+
const menuItems = [
|
|
794
|
+
{ id: "viewCaches", label: "查看缓存" },
|
|
795
|
+
{ id: "clearCaches", label: "清空缓存", destructive: true },
|
|
796
|
+
{ id: "reset", label: "重置模块", destructive: true },
|
|
797
|
+
];
|
|
798
|
+
const menu = new ActionMenu(id => handlers.get(id)());
|
|
799
|
+
const trailing = element("span", "pp-nav-spacer");
|
|
800
|
+
trailing.append(menu.element);
|
|
676
801
|
const brand = element("div", "pp-brand");
|
|
677
802
|
const logo = element("span", "pp-brand-icon");
|
|
678
803
|
logo.setAttribute("aria-hidden", "true");
|
|
@@ -683,20 +808,26 @@ function mountPanel(root, catalog) {
|
|
|
683
808
|
const toast = element("div", "pp-toast");
|
|
684
809
|
toast.setAttribute("role", "status");
|
|
685
810
|
toast.hidden = true;
|
|
686
|
-
header.append(back, brand,
|
|
811
|
+
header.append(back, brand, trailing);
|
|
687
812
|
shell.append(header, viewport, toast);
|
|
688
813
|
root.append(shell);
|
|
689
814
|
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
690
815
|
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
691
816
|
const publishNavigation = () => {
|
|
817
|
+
const actions = handlers.size ? menuItems : [];
|
|
818
|
+
menu.update(actions, saving);
|
|
692
819
|
const frame = window.frameElement;
|
|
693
820
|
if (!frame?.dataset.preferencePanes) return;
|
|
694
821
|
frame.dispatchEvent(
|
|
695
822
|
new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:change", {
|
|
696
|
-
detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled },
|
|
823
|
+
detail: { title: heading.textContent, module: catalog.module.module, busy: saving, canGoBack: !back.disabled, actions },
|
|
697
824
|
}),
|
|
698
825
|
);
|
|
699
826
|
};
|
|
827
|
+
const onAction = event => {
|
|
828
|
+
if (!saving && handlers.has(event.detail)) handlers.get(event.detail)();
|
|
829
|
+
};
|
|
830
|
+
window.frameElement?.addEventListener("preferencepanes:action", onAction);
|
|
700
831
|
let timer,
|
|
701
832
|
navigation,
|
|
702
833
|
generation = 0,
|
|
@@ -1006,17 +1137,13 @@ function mountPanel(root, catalog) {
|
|
|
1006
1137
|
if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
|
|
1007
1138
|
groups.get(group).append(row);
|
|
1008
1139
|
}
|
|
1009
|
-
const
|
|
1010
|
-
maintenance.append(element("h2", "pp-title", "模块数据"));
|
|
1011
|
-
const actions = element("div", "pp-actions");
|
|
1012
|
-
const cacheView = element("button", "", "查看 Caches");
|
|
1013
|
-
const cacheClear = element("button", "", "清空 Caches");
|
|
1014
|
-
const reset = element("button", "pp-danger", "重置模块");
|
|
1140
|
+
const cachePage = element("section", "pp-cache-page");
|
|
1015
1141
|
const output = element("pre", "pp-cache");
|
|
1016
|
-
output.
|
|
1142
|
+
output.textContent = "暂无缓存";
|
|
1017
1143
|
output.setAttribute("aria-label", "Caches 内容");
|
|
1018
|
-
|
|
1019
|
-
|
|
1144
|
+
cachePage.append(output);
|
|
1145
|
+
editors.set("$caches", { node: cachePage, title: "缓存" });
|
|
1146
|
+
handlers.set("viewCaches", () => {
|
|
1020
1147
|
if (saving) return;
|
|
1021
1148
|
let value;
|
|
1022
1149
|
return perform(
|
|
@@ -1030,12 +1157,11 @@ function mountPanel(root, catalog) {
|
|
|
1030
1157
|
},
|
|
1031
1158
|
() => {
|
|
1032
1159
|
output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
|
|
1033
|
-
|
|
1034
|
-
cacheView.textContent = "刷新 Caches";
|
|
1160
|
+
navigation.open("$caches");
|
|
1035
1161
|
},
|
|
1036
1162
|
);
|
|
1037
|
-
};
|
|
1038
|
-
|
|
1163
|
+
});
|
|
1164
|
+
handlers.set("clearCaches", () => {
|
|
1039
1165
|
if (saving) return;
|
|
1040
1166
|
if (!window.confirm(`清空 ${active} 的全部 Caches?`)) return;
|
|
1041
1167
|
return perform(
|
|
@@ -1044,15 +1170,12 @@ function mountPanel(root, catalog) {
|
|
|
1044
1170
|
output.textContent = "暂无缓存";
|
|
1045
1171
|
},
|
|
1046
1172
|
);
|
|
1047
|
-
};
|
|
1048
|
-
reset
|
|
1173
|
+
});
|
|
1174
|
+
handlers.set("reset", () => {
|
|
1049
1175
|
if (saving) return;
|
|
1050
1176
|
if (!window.confirm(`重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) return;
|
|
1051
1177
|
return perform(() => client.reset(active), controls);
|
|
1052
|
-
};
|
|
1053
|
-
actions.append(cacheView, cacheClear, reset);
|
|
1054
|
-
maintenance.append(actions, output);
|
|
1055
|
-
view.append(maintenance);
|
|
1178
|
+
});
|
|
1056
1179
|
navigation?.destroy();
|
|
1057
1180
|
navigation = new Navigation(viewport, view, key => editors.get(key)?.node);
|
|
1058
1181
|
navigation.addEventListener("change", updateNavigation);
|
|
@@ -1078,6 +1201,8 @@ function mountPanel(root, catalog) {
|
|
|
1078
1201
|
*/
|
|
1079
1202
|
destroy() {
|
|
1080
1203
|
destroyed = true;
|
|
1204
|
+
menu.destroy();
|
|
1205
|
+
window.frameElement?.removeEventListener("preferencepanes:action", onAction);
|
|
1081
1206
|
navigation?.destroy();
|
|
1082
1207
|
generation++;
|
|
1083
1208
|
if (active && !saving) client.leave(active);
|
package/dist/module/index.html
CHANGED
|
@@ -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.
|
|
@@ -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 };
|