@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.
@@ -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-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.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, element("span", "pp-nav-spacer"));
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 maintenance = element("section", "pp-maintenance");
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.hidden = true;
1142
+ output.textContent = "暂无缓存";
1017
1143
  output.setAttribute("aria-label", "Caches 内容");
1018
- for (const button of [cacheView, cacheClear, reset]) button.type = "button";
1019
- cacheView.onclick = () => {
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
- output.hidden = false;
1034
- cacheView.textContent = "刷新 Caches";
1160
+ navigation.open("$caches");
1035
1161
  },
1036
1162
  );
1037
- };
1038
- cacheClear.onclick = () => {
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.onclick = () => {
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);
@@ -9,6 +9,6 @@
9
9
  </head>
10
10
  <body>
11
11
  <main id="preferences"></main>
12
- <script type="module" src="/settings/assets/app.mjs?v=0.8.0"></script>
12
+ <script type="module" src="/settings/assets/app.mjs?v=0.8.1"></script>
13
13
  </body>
14
14
  </html>
@@ -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 };