@nsnanocat/preference-panes 0.9.9 → 0.9.11
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 +2 -4
- package/dist/api.js +1 -1
- package/dist/module/app.mjs +82 -107
- package/dist/module/index.html +1 -1
- package/dist/module/navigation.mjs +68 -36
- package/dist/preference-panes.mjs +79 -104
- package/package.json +1 -1
- package/src/BoxJS.mjs +4 -18
- package/src/browser/ActionMenu.mjs +68 -36
- package/src/browser/Navigation.d.mts +6 -0
- package/src/browser/app.mjs +3 -3
- package/src/browser/client.mjs +1 -1
- package/src/browser/components.mjs +0 -16
- package/src/browser/index.mjs +1 -1
- package/src/browser/panel.css +0 -35
- package/src/browser/panel.mjs +2 -30
- package/src/build.mjs +1 -3
- package/src/lib/boxjs.mjs +3 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
var defaults = "/* 通用默认样式只使用 pp 命名空间;项目可通过 CSS 输入覆盖变量和组件。\n * Generic defaults use only the pp namespace; projects may override variables and components through CSS input. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-field: #f1f2f3;\n --pp-border: #e3e5e7;\n --pp-muted: #797f87;\n --pp-accent: #1677ff;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n max-width: 100vw;\n min-width: 0;\n height: 100vh;\n overflow: hidden;\n}\n\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #f1f2f3;\n --pp-background: #0d0e0f;\n --pp-surface: #18191c;\n --pp-field: #2f3238;\n --pp-border: #2f3238;\n --pp-muted: #9499a0;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n flex: none;\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: relative;\n z-index: 2;\n}\n.pp-title {\n flex: 1;\n text-align: center;\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n
|
|
1
|
+
var defaults = "/* 通用默认样式只使用 pp 命名空间;项目可通过 CSS 输入覆盖变量和组件。\n * Generic defaults use only the pp namespace; projects may override variables and components through CSS input. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-field: #f1f2f3;\n --pp-border: #e3e5e7;\n --pp-muted: #797f87;\n --pp-accent: #1677ff;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n max-width: 100vw;\n min-width: 0;\n height: 100vh;\n overflow: hidden;\n}\n\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #f1f2f3;\n --pp-background: #0d0e0f;\n --pp-surface: #18191c;\n --pp-field: #2f3238;\n --pp-border: #2f3238;\n --pp-muted: #9499a0;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n flex: none;\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: relative;\n z-index: 2;\n}\n.pp-title {\n flex: 1;\n text-align: center;\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.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 flex: 1;\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n@supports (height: 100dvh) {\n .pp-panel {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page,\n.pp-cache-page {\n position: absolute;\n inset: 0;\n min-width: 0;\n overflow-x: hidden;\n overflow-y: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + var(--pp-keyboard-height, 0px));\n scroll-padding-bottom: var(--pp-keyboard-height, 0px);\n background: var(--pp-background);\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-editor {\n flex: none;\n width: 45%;\n min-width: 0;\n min-height: 36px;\n padding: 8px 10px;\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-field);\n border: 0;\n border-radius: 6px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-editor {\n width: 100%;\n margin-top: 10px;\n}\n.pp-panel [hidden] {\n display: none !important;\n}\n.pp-label {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n margin-right: 16px;\n}\n.pp-field-name {\n color: var(--pp-text);\n font-size: 15px;\n}\n.pp-field-description {\n margin-top: 2px;\n color: var(--pp-muted);\n font-size: 12px;\n}\n.pp-group {\n margin-top: 16px;\n}\n.pp-group-title {\n margin: 0 0 8px;\n color: var(--pp-muted);\n font-size: 15px;\n font-weight: 400;\n}\n.pp-row {\n min-width: 0;\n min-height: 48px;\n padding: 16px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n}\n.pp-rows > :last-child {\n border-bottom: 0 !important;\n}\n.pp-switch {\n flex: none;\n accent-color: var(--pp-accent);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-cache {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* 校验原始路径片段,不进行 URL 编码转换。
|
|
@@ -24,8 +24,8 @@ class BoxJS {
|
|
|
24
24
|
*/
|
|
25
25
|
constructor(input) {
|
|
26
26
|
if (!input || typeof input !== "object") throw new TypeError("Expected BoxJS JSON");
|
|
27
|
-
|
|
28
|
-
const apps = Array.isArray(
|
|
27
|
+
const document = JSON.parse(JSON.stringify(input));
|
|
28
|
+
const apps = Array.isArray(document) ? [{ settings: document }] : (document.apps ?? [document]);
|
|
29
29
|
if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
|
|
30
30
|
this.modules = new Map();
|
|
31
31
|
for (const app of apps) {
|
|
@@ -33,7 +33,7 @@ class BoxJS {
|
|
|
33
33
|
for (const entry of app.settings) {
|
|
34
34
|
if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
|
|
35
35
|
if (!entry.id.startsWith("@")) {
|
|
36
|
-
if (Array.isArray(
|
|
36
|
+
if (Array.isArray(document)) throw new TypeError("BoxJS settings require @root.path IDs");
|
|
37
37
|
continue;
|
|
38
38
|
}
|
|
39
39
|
const [storageKey, ...parts] = entry.id.slice(1).split(".");
|
|
@@ -50,24 +50,10 @@ class BoxJS {
|
|
|
50
50
|
target.owners.add(app);
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
|
-
this.metadata = metadata(Array.isArray(
|
|
53
|
+
this.metadata = metadata(Array.isArray(document) ? {} : document);
|
|
54
54
|
for (const target of this.modules.values()) target.metadata = target.owners.size === 1 ? metadata([...target.owners][0]) : {};
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
* 提取一个模块的原生 BoxJS,保留所属 app 的元数据。
|
|
59
|
-
* Select a module's native BoxJS while retaining owning-app metadata.
|
|
60
|
-
* @param {string} module 模块标识 / Module identifier.
|
|
61
|
-
* @returns {unknown} 可直接用作配置 Mock 的 JSON / JSON suitable for a configuration Mock.
|
|
62
|
-
*/
|
|
63
|
-
select(module) {
|
|
64
|
-
const target = this.modules.get(module);
|
|
65
|
-
if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
66
|
-
if (Array.isArray(this.document)) return target.entries;
|
|
67
|
-
const apps = [...target.owners].map(app => ({ ...app, settings: app.settings.filter(entry => target.entries.includes(entry)) }));
|
|
68
|
-
return this.document.apps ? { ...this.document, apps } : apps[0];
|
|
69
|
-
}
|
|
70
|
-
|
|
71
57
|
/**
|
|
72
58
|
* 取得本次导入的唯一模块,避免把模块数据变成项目目录。
|
|
73
59
|
* Get the single imported module without turning module data into a project directory.
|
|
@@ -147,22 +133,6 @@ function resourceURL(value) {
|
|
|
147
133
|
return url.href;
|
|
148
134
|
}
|
|
149
135
|
|
|
150
|
-
/**
|
|
151
|
-
* 展示标准 BoxJS 图标;icons 保持透明/彩色语义,不解释为亮暗版本。
|
|
152
|
-
* Display standard BoxJS icons, preserving transparent/color rather than light/dark semantics.
|
|
153
|
-
* @param {import("../index.js").BoxJSMetadata} metadata 展示信息 / Presentation metadata.
|
|
154
|
-
* @param {string} className 样式 / CSS class.
|
|
155
|
-
* @returns {HTMLImageElement | null} 图标或无图标 / Icon or no icon.
|
|
156
|
-
*/
|
|
157
|
-
function icon(metadata, className) {
|
|
158
|
-
const source = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
159
|
-
if (!source) return null;
|
|
160
|
-
const image = element("img", className);
|
|
161
|
-
image.src = resourceURL(source);
|
|
162
|
-
image.alt = "";
|
|
163
|
-
return image;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
136
|
/**
|
|
167
137
|
* 共享加载失败视图,不创建配置表单或数据读取。
|
|
168
138
|
* Share a load-error view without creating controls or reading settings.
|
|
@@ -198,17 +168,18 @@ function requestConfirmation(host, message) {
|
|
|
198
168
|
}
|
|
199
169
|
|
|
200
170
|
/**
|
|
201
|
-
*
|
|
202
|
-
* Shared
|
|
171
|
+
* 共用三点按钮和底部操作菜单;弹层挂载到文档根部,不受标题栏显示状态影响。
|
|
172
|
+
* Shared overflow trigger and bottom action sheet; the layer is mounted at document level and remains independent of header visibility.
|
|
203
173
|
*/
|
|
204
174
|
class ActionMenu {
|
|
205
175
|
#button;
|
|
206
|
-
#
|
|
207
|
-
#
|
|
176
|
+
#layer;
|
|
177
|
+
#items;
|
|
208
178
|
#select;
|
|
209
179
|
#document;
|
|
180
|
+
#disabled = true;
|
|
210
181
|
#key = event => {
|
|
211
|
-
if (event.key === "Escape" && !this.#
|
|
182
|
+
if (event.key === "Escape" && !this.#layer.hidden) {
|
|
212
183
|
event.preventDefault();
|
|
213
184
|
this.close();
|
|
214
185
|
this.#button.focus();
|
|
@@ -224,44 +195,56 @@ class ActionMenu {
|
|
|
224
195
|
this.#document = document;
|
|
225
196
|
this.#select = select;
|
|
226
197
|
this.element = document.createElement("span");
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
:host{display:inline-flex;
|
|
230
|
-
:host([hidden])
|
|
231
|
-
button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
198
|
+
const triggerRoot = this.element.attachShadow({ mode: "open" });
|
|
199
|
+
triggerRoot.innerHTML = `<style>
|
|
200
|
+
:host{display:inline-flex;width:44px;height:44px;color:inherit}
|
|
201
|
+
:host([hidden]){display:none!important}
|
|
202
|
+
button{width:44px;height:44px;padding:10px;font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
232
203
|
button:disabled{opacity:.4;cursor:default}
|
|
233
204
|
button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
|
|
234
|
-
#trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}
|
|
235
205
|
svg{display:block;width:24px;height:24px;fill:currentColor}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
206
|
+
</style><button type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false"><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>`;
|
|
207
|
+
this.#button = triggerRoot.querySelector("button");
|
|
208
|
+
this.#layer = document.createElement("span");
|
|
209
|
+
const layerRoot = this.#layer.attachShadow({ mode: "open" });
|
|
210
|
+
layerRoot.innerHTML = `<style>
|
|
211
|
+
:host{position:fixed;inset:0;z-index:2147483647;color:var(--pp-text,CanvasText);font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
212
|
+
:host([hidden]){display:none!important}
|
|
213
|
+
*,*::before,*::after{box-sizing:border-box}
|
|
214
|
+
button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
215
|
+
button:focus-visible{outline:2px solid var(--pp-accent,Highlight);outline-offset:-3px}
|
|
216
|
+
#backdrop{position:absolute;inset:0;width:100%;height:100%;padding:0;background:#0008;animation:pp-fade-in .18s ease-out}
|
|
217
|
+
#sheet{position:absolute;z-index:1;left:0;right:0;bottom:0;width:100%;max-width:540px;max-height:calc(100% - 24px);margin:auto;padding:8px 8px calc(8px + env(safe-area-inset-bottom));animation:pp-sheet-in .22s cubic-bezier(.2,.8,.2,1)}
|
|
218
|
+
#items,#cancel{overflow:hidden;background:var(--pp-surface,Canvas);border:1px solid var(--pp-border,#8884);border-radius:14px;box-shadow:0 8px 28px #0004}
|
|
219
|
+
#items{max-height:calc(100vh - 116px - env(safe-area-inset-bottom));overflow-y:auto;-webkit-overflow-scrolling:touch}
|
|
220
|
+
#items button,#cancel{display:block;width:100%;min-height:54px;padding:14px 18px;text-align:center}
|
|
221
|
+
#items button+button{border-top:1px solid var(--pp-border,#8884)}
|
|
222
|
+
#items button[data-danger]{color:var(--pp-danger,#e45656)}
|
|
223
|
+
#cancel{margin-top:8px;color:var(--pp-accent,Highlight);font-weight:600}
|
|
224
|
+
@keyframes pp-fade-in{from{opacity:0}}
|
|
225
|
+
@keyframes pp-sheet-in{from{transform:translateY(100%)}}
|
|
226
|
+
@media (prefers-reduced-motion:reduce){#backdrop,#sheet{animation:none}}
|
|
227
|
+
</style><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单"></button><section id="sheet" role="dialog" aria-modal="true" aria-label="更多操作"><div id="items" role="menu"></div><button id="cancel" type="button">取消</button></section>`;
|
|
228
|
+
this.#items = layerRoot.querySelector("#items");
|
|
229
|
+
this.#button.onclick = () => (this.#layer.hidden ? this.open() : this.close());
|
|
230
|
+
layerRoot.querySelector("#backdrop").onclick = () => {
|
|
231
|
+
this.close();
|
|
232
|
+
this.#button.focus();
|
|
250
233
|
};
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const
|
|
258
|
-
const index = items.indexOf(root.activeElement);
|
|
234
|
+
layerRoot.querySelector("#cancel").onclick = () => {
|
|
235
|
+
this.close();
|
|
236
|
+
this.#button.focus();
|
|
237
|
+
};
|
|
238
|
+
this.#items.onkeydown = event => {
|
|
239
|
+
const items = [...this.#items.children];
|
|
240
|
+
const index = items.indexOf(layerRoot.activeElement);
|
|
259
241
|
const offsets = { ArrowDown: 1, ArrowUp: -1 };
|
|
260
242
|
if (event.key in offsets) {
|
|
261
243
|
event.preventDefault();
|
|
262
244
|
items[(index + offsets[event.key] + items.length) % items.length].focus();
|
|
263
245
|
}
|
|
264
246
|
};
|
|
247
|
+
document.body.append(this.#layer);
|
|
265
248
|
document.addEventListener("keydown", this.#key);
|
|
266
249
|
this.update([]);
|
|
267
250
|
}
|
|
@@ -275,8 +258,9 @@ class ActionMenu {
|
|
|
275
258
|
*/
|
|
276
259
|
update(items, disabled = false) {
|
|
277
260
|
this.close();
|
|
278
|
-
this.#
|
|
279
|
-
this.#
|
|
261
|
+
this.#disabled = disabled || items.length === 0;
|
|
262
|
+
this.#button.disabled = this.#disabled;
|
|
263
|
+
this.#items.replaceChildren(
|
|
280
264
|
...items.map(item => {
|
|
281
265
|
const button = this.#document.createElement("button");
|
|
282
266
|
button.type = "button";
|
|
@@ -292,13 +276,30 @@ class ActionMenu {
|
|
|
292
276
|
);
|
|
293
277
|
}
|
|
294
278
|
|
|
279
|
+
/**
|
|
280
|
+
* 打开当前操作菜单。
|
|
281
|
+
* Open the current action sheet.
|
|
282
|
+
* @returns {void} 无返回值 / No return value.
|
|
283
|
+
*/
|
|
284
|
+
open() {
|
|
285
|
+
if (this.#disabled) return;
|
|
286
|
+
const style = getComputedStyle(this.element);
|
|
287
|
+
for (const property of ["--pp-text", "--pp-surface", "--pp-border", "--pp-accent", "--pp-danger"]) {
|
|
288
|
+
const value = style.getPropertyValue(property);
|
|
289
|
+
if (value) this.#layer.style.setProperty(property, value);
|
|
290
|
+
}
|
|
291
|
+
this.#layer.hidden = false;
|
|
292
|
+
this.#button.setAttribute("aria-expanded", "true");
|
|
293
|
+
this.#items.firstElementChild.focus();
|
|
294
|
+
}
|
|
295
|
+
|
|
295
296
|
/**
|
|
296
297
|
* 关闭菜单。
|
|
297
298
|
* Close the menu.
|
|
298
299
|
* @returns {void} 无返回值 / No return value.
|
|
299
300
|
*/
|
|
300
301
|
close() {
|
|
301
|
-
this.#
|
|
302
|
+
this.#layer.hidden = true;
|
|
302
303
|
this.#button.setAttribute("aria-expanded", "false");
|
|
303
304
|
}
|
|
304
305
|
|
|
@@ -309,6 +310,7 @@ class ActionMenu {
|
|
|
309
310
|
*/
|
|
310
311
|
destroy() {
|
|
311
312
|
this.#document.removeEventListener("keydown", this.#key);
|
|
313
|
+
this.#layer.remove();
|
|
312
314
|
this.element.remove();
|
|
313
315
|
}
|
|
314
316
|
}
|
|
@@ -316,14 +318,15 @@ class ActionMenu {
|
|
|
316
318
|
/**
|
|
317
319
|
* 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
|
|
318
320
|
* Normalize a BoxJS array, app or subscription using the source JSON as the field authority.
|
|
319
|
-
* @param {unknown} config BoxJS JSON / BoxJS document.
|
|
321
|
+
* @param {unknown | BoxJS} config BoxJS JSON 或已解析目录 / BoxJS document or parsed catalog.
|
|
320
322
|
* @param {string} module API 第一段模块名 / First API path segment.
|
|
321
323
|
* @returns {import("../index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
|
|
322
324
|
* @throws {TypeError} 配置结构、字段路径、默认值或展示属性无效 / Invalid configuration, field path, default or presentation attribute.
|
|
323
325
|
*/
|
|
324
326
|
function normalizeBoxJs(config, module) {
|
|
325
327
|
validatePathParts([module]);
|
|
326
|
-
const
|
|
328
|
+
const catalog = config instanceof BoxJS ? config : new BoxJS(config);
|
|
329
|
+
const target = catalog.modules.get(module);
|
|
327
330
|
if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
328
331
|
const { entries, storageKey, metadata } = target;
|
|
329
332
|
const fields = [];
|
|
@@ -556,7 +559,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
556
559
|
const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
|
|
557
560
|
sessions.set(module, state);
|
|
558
561
|
try {
|
|
559
|
-
const definition = normalizeBoxJs(catalog
|
|
562
|
+
const definition = normalizeBoxJs(catalog, module);
|
|
560
563
|
const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
|
|
561
564
|
let subtree = response.status === 404 ? {} : await response.json();
|
|
562
565
|
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
@@ -820,17 +823,10 @@ function mountPanel(root, catalog) {
|
|
|
820
823
|
const menu = new ActionMenu(id => runAction(id));
|
|
821
824
|
const trailing = element("span", "pp-nav-spacer");
|
|
822
825
|
trailing.append(menu.element);
|
|
823
|
-
const logo = element("span", "pp-module-logo");
|
|
824
|
-
logo.setAttribute("aria-hidden", "true");
|
|
825
|
-
const image = icon(catalog.module.metadata, "");
|
|
826
|
-
if (image) logo.append(image);
|
|
827
|
-
const toolbar = element("div", "pp-toolbar");
|
|
828
|
-
toolbar.setAttribute("role", "search");
|
|
829
|
-
toolbar.hidden = true;
|
|
830
826
|
const viewport = element("div", "pp-viewport");
|
|
831
827
|
let toast;
|
|
832
828
|
header.append(back, heading, trailing);
|
|
833
|
-
shell.append(header,
|
|
829
|
+
shell.append(header, viewport);
|
|
834
830
|
root.append(shell);
|
|
835
831
|
// 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
|
|
836
832
|
// Embedded mode publishes navigation state without host reads or mutations of the module DOM.
|
|
@@ -921,7 +917,6 @@ function mountPanel(root, catalog) {
|
|
|
921
917
|
async function open(module) {
|
|
922
918
|
const version = ++generation;
|
|
923
919
|
active = module;
|
|
924
|
-
toolbar.hidden = true;
|
|
925
920
|
back.disabled = window.history.length <= 1;
|
|
926
921
|
heading.textContent = module;
|
|
927
922
|
publishNavigation();
|
|
@@ -944,14 +939,6 @@ function mountPanel(root, catalog) {
|
|
|
944
939
|
const { definition, values } = client.snapshot(active);
|
|
945
940
|
heading.textContent = definition.metadata?.name || active;
|
|
946
941
|
const view = element("section", "pp-fields");
|
|
947
|
-
const search = element("input", "");
|
|
948
|
-
search.type = "search";
|
|
949
|
-
search.placeholder = "搜索设置项";
|
|
950
|
-
search.setAttribute("aria-label", "搜索设置");
|
|
951
|
-
const searchField = fieldControl(search);
|
|
952
|
-
searchField.classList.add("pp-search");
|
|
953
|
-
toolbar.replaceChildren(logo, searchField);
|
|
954
|
-
const searchRows = [];
|
|
955
942
|
/**
|
|
956
943
|
* 挂载后执行的多行高度更新
|
|
957
944
|
* Textarea sizing callbacks run after mounting.
|
|
@@ -970,7 +957,6 @@ function mountPanel(root, catalog) {
|
|
|
970
957
|
*/
|
|
971
958
|
const updateNavigation = () => {
|
|
972
959
|
const editor = editors.get(navigation.current);
|
|
973
|
-
toolbar.hidden = Boolean(navigation.current);
|
|
974
960
|
heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
|
|
975
961
|
back.disabled = saving || !navigation.canGoBack;
|
|
976
962
|
publishNavigation();
|
|
@@ -1195,18 +1181,7 @@ function mountPanel(root, catalog) {
|
|
|
1195
1181
|
});
|
|
1196
1182
|
if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
|
|
1197
1183
|
groups.get(group).append(row);
|
|
1198
|
-
searchRows.push({ row, text: [field.name, field.key, field.description, ...(field.options ?? []).map(option => option.label)].join(" ").toLocaleLowerCase() });
|
|
1199
1184
|
}
|
|
1200
|
-
const empty = element("p", "pp-description", "没有匹配的设置项");
|
|
1201
|
-
empty.hidden = true;
|
|
1202
|
-
empty.setAttribute("role", "status");
|
|
1203
|
-
view.append(empty);
|
|
1204
|
-
search.oninput = () => {
|
|
1205
|
-
const words = search.value.trim().toLocaleLowerCase().split(/\s+/);
|
|
1206
|
-
for (const { row, text } of searchRows) row.hidden = !words.every(word => text.includes(word));
|
|
1207
|
-
for (const rows of groups.values()) rows.parentElement.hidden = [...rows.children].every(row => row.hidden);
|
|
1208
|
-
empty.hidden = searchRows.some(({ row }) => !row.hidden);
|
|
1209
|
-
};
|
|
1210
1185
|
const cachePage = element("section", "pp-cache-page");
|
|
1211
1186
|
const output = element("pre", "pp-cache");
|
|
1212
1187
|
output.textContent = "暂无缓存";
|
|
@@ -1291,7 +1266,7 @@ function mountPanel(root, catalog) {
|
|
|
1291
1266
|
*/
|
|
1292
1267
|
function mount(boxjs, css = "") {
|
|
1293
1268
|
if (typeof css !== "string") throw new TypeError("CSS must be a string");
|
|
1294
|
-
const catalog = new BoxJS(boxjs);
|
|
1269
|
+
const catalog = boxjs instanceof BoxJS ? boxjs : new BoxJS(boxjs);
|
|
1295
1270
|
const metadata = catalog.module.metadata;
|
|
1296
1271
|
const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
1297
1272
|
if (image) resourceURL(image);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsnanocat/preference-panes",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.11",
|
|
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",
|
package/src/BoxJS.mjs
CHANGED
|
@@ -12,8 +12,8 @@ export class BoxJS {
|
|
|
12
12
|
*/
|
|
13
13
|
constructor(input) {
|
|
14
14
|
if (!input || typeof input !== "object") throw new TypeError("Expected BoxJS JSON");
|
|
15
|
-
|
|
16
|
-
const apps = Array.isArray(
|
|
15
|
+
const document = JSON.parse(JSON.stringify(input));
|
|
16
|
+
const apps = Array.isArray(document) ? [{ settings: document }] : (document.apps ?? [document]);
|
|
17
17
|
if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
|
|
18
18
|
this.modules = new Map();
|
|
19
19
|
for (const app of apps) {
|
|
@@ -21,7 +21,7 @@ export class BoxJS {
|
|
|
21
21
|
for (const entry of app.settings) {
|
|
22
22
|
if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
|
|
23
23
|
if (!entry.id.startsWith("@")) {
|
|
24
|
-
if (Array.isArray(
|
|
24
|
+
if (Array.isArray(document)) throw new TypeError("BoxJS settings require @root.path IDs");
|
|
25
25
|
continue;
|
|
26
26
|
}
|
|
27
27
|
const [storageKey, ...parts] = entry.id.slice(1).split(".");
|
|
@@ -38,24 +38,10 @@ export class BoxJS {
|
|
|
38
38
|
target.owners.add(app);
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
-
this.metadata = metadata(Array.isArray(
|
|
41
|
+
this.metadata = metadata(Array.isArray(document) ? {} : document);
|
|
42
42
|
for (const target of this.modules.values()) target.metadata = target.owners.size === 1 ? metadata([...target.owners][0]) : {};
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
* 提取一个模块的原生 BoxJS,保留所属 app 的元数据。
|
|
47
|
-
* Select a module's native BoxJS while retaining owning-app metadata.
|
|
48
|
-
* @param {string} module 模块标识 / Module identifier.
|
|
49
|
-
* @returns {unknown} 可直接用作配置 Mock 的 JSON / JSON suitable for a configuration Mock.
|
|
50
|
-
*/
|
|
51
|
-
select(module) {
|
|
52
|
-
const target = this.modules.get(module);
|
|
53
|
-
if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
54
|
-
if (Array.isArray(this.document)) return target.entries;
|
|
55
|
-
const apps = [...target.owners].map(app => ({ ...app, settings: app.settings.filter(entry => target.entries.includes(entry)) }));
|
|
56
|
-
return this.document.apps ? { ...this.document, apps } : apps[0];
|
|
57
|
-
}
|
|
58
|
-
|
|
59
45
|
/**
|
|
60
46
|
* 取得本次导入的唯一模块,避免把模块数据变成项目目录。
|
|
61
47
|
* Get the single imported module without turning module data into a project directory.
|
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
* Shared
|
|
2
|
+
* 共用三点按钮和底部操作菜单;弹层挂载到文档根部,不受标题栏显示状态影响。
|
|
3
|
+
* Shared overflow trigger and bottom action sheet; the layer is mounted at document level and remains independent of header visibility.
|
|
4
4
|
*/
|
|
5
5
|
export class ActionMenu {
|
|
6
6
|
#button;
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
#layer;
|
|
8
|
+
#items;
|
|
9
9
|
#select;
|
|
10
10
|
#document;
|
|
11
|
+
#disabled = true;
|
|
11
12
|
#key = event => {
|
|
12
|
-
if (event.key === "Escape" && !this.#
|
|
13
|
+
if (event.key === "Escape" && !this.#layer.hidden) {
|
|
13
14
|
event.preventDefault();
|
|
14
15
|
this.close();
|
|
15
16
|
this.#button.focus();
|
|
@@ -25,44 +26,56 @@ export class ActionMenu {
|
|
|
25
26
|
this.#document = document;
|
|
26
27
|
this.#select = select;
|
|
27
28
|
this.element = document.createElement("span");
|
|
28
|
-
const
|
|
29
|
-
|
|
30
|
-
:host{display:inline-flex;
|
|
31
|
-
:host([hidden])
|
|
32
|
-
button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
29
|
+
const triggerRoot = this.element.attachShadow({ mode: "open" });
|
|
30
|
+
triggerRoot.innerHTML = `<style>
|
|
31
|
+
:host{display:inline-flex;width:44px;height:44px;color:inherit}
|
|
32
|
+
:host([hidden]){display:none!important}
|
|
33
|
+
button{width:44px;height:44px;padding:10px;font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
33
34
|
button:disabled{opacity:.4;cursor:default}
|
|
34
35
|
button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
|
|
35
|
-
#trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}
|
|
36
36
|
svg{display:block;width:24px;height:24px;fill:currentColor}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
37
|
+
</style><button type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false"><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>`;
|
|
38
|
+
this.#button = triggerRoot.querySelector("button");
|
|
39
|
+
this.#layer = document.createElement("span");
|
|
40
|
+
const layerRoot = this.#layer.attachShadow({ mode: "open" });
|
|
41
|
+
layerRoot.innerHTML = `<style>
|
|
42
|
+
:host{position:fixed;inset:0;z-index:2147483647;color:var(--pp-text,CanvasText);font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
|
|
43
|
+
:host([hidden]){display:none!important}
|
|
44
|
+
*,*::before,*::after{box-sizing:border-box}
|
|
45
|
+
button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
|
|
46
|
+
button:focus-visible{outline:2px solid var(--pp-accent,Highlight);outline-offset:-3px}
|
|
47
|
+
#backdrop{position:absolute;inset:0;width:100%;height:100%;padding:0;background:#0008;animation:pp-fade-in .18s ease-out}
|
|
48
|
+
#sheet{position:absolute;z-index:1;left:0;right:0;bottom:0;width:100%;max-width:540px;max-height:calc(100% - 24px);margin:auto;padding:8px 8px calc(8px + env(safe-area-inset-bottom));animation:pp-sheet-in .22s cubic-bezier(.2,.8,.2,1)}
|
|
49
|
+
#items,#cancel{overflow:hidden;background:var(--pp-surface,Canvas);border:1px solid var(--pp-border,#8884);border-radius:14px;box-shadow:0 8px 28px #0004}
|
|
50
|
+
#items{max-height:calc(100vh - 116px - env(safe-area-inset-bottom));overflow-y:auto;-webkit-overflow-scrolling:touch}
|
|
51
|
+
#items button,#cancel{display:block;width:100%;min-height:54px;padding:14px 18px;text-align:center}
|
|
52
|
+
#items button+button{border-top:1px solid var(--pp-border,#8884)}
|
|
53
|
+
#items button[data-danger]{color:var(--pp-danger,#e45656)}
|
|
54
|
+
#cancel{margin-top:8px;color:var(--pp-accent,Highlight);font-weight:600}
|
|
55
|
+
@keyframes pp-fade-in{from{opacity:0}}
|
|
56
|
+
@keyframes pp-sheet-in{from{transform:translateY(100%)}}
|
|
57
|
+
@media (prefers-reduced-motion:reduce){#backdrop,#sheet{animation:none}}
|
|
58
|
+
</style><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单"></button><section id="sheet" role="dialog" aria-modal="true" aria-label="更多操作"><div id="items" role="menu"></div><button id="cancel" type="button">取消</button></section>`;
|
|
59
|
+
this.#items = layerRoot.querySelector("#items");
|
|
60
|
+
this.#button.onclick = () => (this.#layer.hidden ? this.open() : this.close());
|
|
61
|
+
layerRoot.querySelector("#backdrop").onclick = () => {
|
|
62
|
+
this.close();
|
|
63
|
+
this.#button.focus();
|
|
51
64
|
};
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
const index = items.indexOf(root.activeElement);
|
|
65
|
+
layerRoot.querySelector("#cancel").onclick = () => {
|
|
66
|
+
this.close();
|
|
67
|
+
this.#button.focus();
|
|
68
|
+
};
|
|
69
|
+
this.#items.onkeydown = event => {
|
|
70
|
+
const items = [...this.#items.children];
|
|
71
|
+
const index = items.indexOf(layerRoot.activeElement);
|
|
60
72
|
const offsets = { ArrowDown: 1, ArrowUp: -1 };
|
|
61
73
|
if (event.key in offsets) {
|
|
62
74
|
event.preventDefault();
|
|
63
75
|
items[(index + offsets[event.key] + items.length) % items.length].focus();
|
|
64
76
|
}
|
|
65
77
|
};
|
|
78
|
+
document.body.append(this.#layer);
|
|
66
79
|
document.addEventListener("keydown", this.#key);
|
|
67
80
|
this.update([]);
|
|
68
81
|
}
|
|
@@ -76,8 +89,9 @@ export class ActionMenu {
|
|
|
76
89
|
*/
|
|
77
90
|
update(items, disabled = false) {
|
|
78
91
|
this.close();
|
|
79
|
-
this.#
|
|
80
|
-
this.#
|
|
92
|
+
this.#disabled = disabled || items.length === 0;
|
|
93
|
+
this.#button.disabled = this.#disabled;
|
|
94
|
+
this.#items.replaceChildren(
|
|
81
95
|
...items.map(item => {
|
|
82
96
|
const button = this.#document.createElement("button");
|
|
83
97
|
button.type = "button";
|
|
@@ -93,13 +107,30 @@ export class ActionMenu {
|
|
|
93
107
|
);
|
|
94
108
|
}
|
|
95
109
|
|
|
110
|
+
/**
|
|
111
|
+
* 打开当前操作菜单。
|
|
112
|
+
* Open the current action sheet.
|
|
113
|
+
* @returns {void} 无返回值 / No return value.
|
|
114
|
+
*/
|
|
115
|
+
open() {
|
|
116
|
+
if (this.#disabled) return;
|
|
117
|
+
const style = getComputedStyle(this.element);
|
|
118
|
+
for (const property of ["--pp-text", "--pp-surface", "--pp-border", "--pp-accent", "--pp-danger"]) {
|
|
119
|
+
const value = style.getPropertyValue(property);
|
|
120
|
+
if (value) this.#layer.style.setProperty(property, value);
|
|
121
|
+
}
|
|
122
|
+
this.#layer.hidden = false;
|
|
123
|
+
this.#button.setAttribute("aria-expanded", "true");
|
|
124
|
+
this.#items.firstElementChild.focus();
|
|
125
|
+
}
|
|
126
|
+
|
|
96
127
|
/**
|
|
97
128
|
* 关闭菜单。
|
|
98
129
|
* Close the menu.
|
|
99
130
|
* @returns {void} 无返回值 / No return value.
|
|
100
131
|
*/
|
|
101
132
|
close() {
|
|
102
|
-
this.#
|
|
133
|
+
this.#layer.hidden = true;
|
|
103
134
|
this.#button.setAttribute("aria-expanded", "false");
|
|
104
135
|
}
|
|
105
136
|
|
|
@@ -110,6 +141,7 @@ export class ActionMenu {
|
|
|
110
141
|
*/
|
|
111
142
|
destroy() {
|
|
112
143
|
this.#document.removeEventListener("keydown", this.#key);
|
|
144
|
+
this.#layer.remove();
|
|
113
145
|
this.element.remove();
|
|
114
146
|
}
|
|
115
147
|
}
|
|
@@ -140,6 +140,12 @@ export class ActionMenu {
|
|
|
140
140
|
* @returns 无返回值 / No return value.
|
|
141
141
|
*/
|
|
142
142
|
update(items: MenuAction[], disabled?: boolean): void;
|
|
143
|
+
/**
|
|
144
|
+
* 打开当前操作菜单。
|
|
145
|
+
* Open the current action sheet.
|
|
146
|
+
* @returns 无返回值 / No return value.
|
|
147
|
+
*/
|
|
148
|
+
open(): void;
|
|
143
149
|
/**
|
|
144
150
|
* 关闭菜单。
|
|
145
151
|
* Close the menu.
|
package/src/browser/app.mjs
CHANGED
|
@@ -35,9 +35,9 @@ async function start() {
|
|
|
35
35
|
});
|
|
36
36
|
const [data, style] = await Promise.all(resources.map(url => (url ? fetch(url, { cache: "no-store", credentials: "omit" }) : null)));
|
|
37
37
|
if (data.status !== 200 || (style && style.status !== 200)) throw new Error(`HTTP ${data.status !== 200 ? data.status : style.status}`);
|
|
38
|
-
const
|
|
39
|
-
if (
|
|
40
|
-
view = mount(
|
|
38
|
+
const catalog = new BoxJS(await data.json());
|
|
39
|
+
if (catalog.module.module !== inputs.module) throw new Error("Imported JSON does not match the module URL");
|
|
40
|
+
view = mount(catalog, style ? await style.text() : "");
|
|
41
41
|
} catch (error) {
|
|
42
42
|
document.querySelector("#preferences").replaceChildren(errorView(error, start));
|
|
43
43
|
}
|
package/src/browser/client.mjs
CHANGED
|
@@ -128,7 +128,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
128
128
|
const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
|
|
129
129
|
sessions.set(module, state);
|
|
130
130
|
try {
|
|
131
|
-
const definition = normalizeBoxJs(catalog
|
|
131
|
+
const definition = normalizeBoxJs(catalog, module);
|
|
132
132
|
const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
|
|
133
133
|
let subtree = response.status === 404 ? {} : await response.json();
|
|
134
134
|
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
@@ -48,22 +48,6 @@ export function resourceURL(value) {
|
|
|
48
48
|
return url.href;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/**
|
|
52
|
-
* 展示标准 BoxJS 图标;icons 保持透明/彩色语义,不解释为亮暗版本。
|
|
53
|
-
* Display standard BoxJS icons, preserving transparent/color rather than light/dark semantics.
|
|
54
|
-
* @param {import("../index.js").BoxJSMetadata} metadata 展示信息 / Presentation metadata.
|
|
55
|
-
* @param {string} className 样式 / CSS class.
|
|
56
|
-
* @returns {HTMLImageElement | null} 图标或无图标 / Icon or no icon.
|
|
57
|
-
*/
|
|
58
|
-
export function icon(metadata, className) {
|
|
59
|
-
const source = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
|
|
60
|
-
if (!source) return null;
|
|
61
|
-
const image = element("img", className);
|
|
62
|
-
image.src = resourceURL(source);
|
|
63
|
-
image.alt = "";
|
|
64
|
-
return image;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
51
|
/**
|
|
68
52
|
* 共享加载失败视图,不创建配置表单或数据读取。
|
|
69
53
|
* Share a load-error view without creating controls or reading settings.
|