@nsnanocat/preference-panes 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +41 -2
- package/dist/preference-panes.mjs +148 -61
- package/dist/settings/app.mjs +1043 -0
- package/dist/settings/home.css +134 -0
- package/dist/settings/index.html +15 -0
- package/dist/settings/panel.css +325 -0
- package/package.json +3 -2
- package/src/PreferencesHandler.mjs +50 -0
- package/src/browser/app.mjs +153 -0
- package/src/browser/home.css +134 -0
- package/src/browser/index.d.ts +2 -0
- package/src/browser/panel.css +260 -75
- package/src/browser/panel.mjs +148 -61
- package/src/browser/site.html +15 -0
- package/src/index.d.ts +23 -0
- package/src/index.mjs +1 -0
package/src/browser/panel.mjs
CHANGED
|
@@ -26,17 +26,19 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
26
26
|
};
|
|
27
27
|
const shell = node("div", "pp-panel");
|
|
28
28
|
const header = node("header", "pp-header");
|
|
29
|
-
const back = node("button", "pp-back", "
|
|
29
|
+
const back = node("button", "pp-back", "‹");
|
|
30
|
+
back.setAttribute("aria-label", "返回");
|
|
30
31
|
back.type = "button";
|
|
31
32
|
const heading = node("h1", "pp-title", title);
|
|
32
33
|
const viewport = node("div", "pp-viewport");
|
|
33
34
|
const toast = node("div", "pp-toast");
|
|
34
35
|
toast.setAttribute("role", "status");
|
|
35
36
|
toast.hidden = true;
|
|
36
|
-
header.append(back, heading);
|
|
37
|
+
header.append(back, heading, node("span", "pp-nav-spacer"));
|
|
37
38
|
shell.append(header, viewport, toast);
|
|
38
39
|
root.append(shell);
|
|
39
40
|
let timer,
|
|
41
|
+
secondaryRoute,
|
|
40
42
|
routedPath,
|
|
41
43
|
generation = 0,
|
|
42
44
|
active = null,
|
|
@@ -131,40 +133,68 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
131
133
|
const view = node("section", "pp-fields");
|
|
132
134
|
/** @type {Array<() => void>} 挂载后执行的多行高度更新 / Textarea sizing callbacks run after mounting. */
|
|
133
135
|
const growingInputs = [];
|
|
136
|
+
const editors = new Map();
|
|
137
|
+
const summaries = [];
|
|
138
|
+
const groups = new Map();
|
|
139
|
+
const scrollPositions = new WeakMap();
|
|
140
|
+
let activeEditor;
|
|
141
|
+
let queue = Promise.resolve(),
|
|
142
|
+
pendingWrites = 0;
|
|
134
143
|
/**
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* @param {boolean} disabled 是否禁用 / Whether controls are disabled.
|
|
144
|
+
* 根据 hash 切换多选页,保留上级 DOM 和滚动位置。
|
|
145
|
+
* Switch multi-select views by hash while retaining parent DOM and scroll position.
|
|
138
146
|
* @returns {void} 无返回值 / No return value.
|
|
139
147
|
*/
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
148
|
+
const showEditor = () => {
|
|
149
|
+
let key;
|
|
150
|
+
try {
|
|
151
|
+
key = decodeURIComponent(window.location.hash.slice(1));
|
|
152
|
+
} catch {
|
|
153
|
+
key = "";
|
|
154
|
+
}
|
|
155
|
+
const editor = editors.get(key);
|
|
156
|
+
const previous = activeEditor?.node ?? view;
|
|
157
|
+
const next = editor?.node ?? view;
|
|
158
|
+
if (previous !== next) {
|
|
159
|
+
scrollPositions.set(previous, previous.scrollTop);
|
|
160
|
+
previous.remove();
|
|
161
|
+
viewport.append(next);
|
|
162
|
+
next.scrollTop = scrollPositions.get(next) ?? 0;
|
|
163
|
+
if (!window.matchMedia("(prefers-reduced-motion: reduce)").matches) next.animate([{ transform: `translateX(${editor ? 100 : -100}%)` }, { transform: "translateX(0)" }], { duration: 260, easing: "cubic-bezier(.22,.61,.36,1)" });
|
|
164
|
+
}
|
|
165
|
+
activeEditor = editor;
|
|
166
|
+
heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
|
|
167
|
+
back.disabled = saving || (!editor && window.history.length <= 1);
|
|
144
168
|
};
|
|
169
|
+
secondaryRoute = showEditor;
|
|
145
170
|
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
171
|
+
* 串行执行页面操作,输入可继续编辑,完成后处理延后导航。
|
|
172
|
+
* Serialize page actions while inputs remain editable, then process deferred navigation.
|
|
148
173
|
* @param {() => Promise<void>} action 请求或写入 / Request or mutation.
|
|
149
174
|
* @param {() => void} success 成功后的局部更新 / Local update after success.
|
|
175
|
+
* @param {() => void} [failure] 失败后恢复当前输入 / Restore the current input on failure.
|
|
150
176
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
151
177
|
*/
|
|
152
|
-
|
|
153
|
-
|
|
178
|
+
function perform(action, success, failure = () => {}) {
|
|
179
|
+
pendingWrites++;
|
|
154
180
|
saving = true;
|
|
155
181
|
back.disabled = true;
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
182
|
+
return (queue = queue
|
|
183
|
+
.then(action)
|
|
184
|
+
.then(() => {
|
|
185
|
+
if (!destroyed) success();
|
|
186
|
+
})
|
|
187
|
+
.catch(() => {
|
|
188
|
+
/* 请求层已通知错误 / The request layer has already reported the error. */
|
|
189
|
+
if (!destroyed) failure();
|
|
190
|
+
})
|
|
191
|
+
.finally(() => {
|
|
192
|
+
pendingWrites--;
|
|
193
|
+
saving = pendingWrites > 0;
|
|
194
|
+
if (destroyed && !saving) client.leave(active);
|
|
195
|
+
back.disabled = saving || (!activeEditor && window.history.length <= 1);
|
|
196
|
+
if (!saving && !destroyed && pendingRoute) route();
|
|
197
|
+
}));
|
|
168
198
|
}
|
|
169
199
|
const metadata = definition.metadata;
|
|
170
200
|
if (metadata) {
|
|
@@ -201,14 +231,27 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
201
231
|
view.append(info);
|
|
202
232
|
}
|
|
203
233
|
for (const field of definition.fields) {
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
if (
|
|
234
|
+
const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
|
|
235
|
+
const group = match?.[1] ?? "通用";
|
|
236
|
+
if (!groups.has(group)) {
|
|
237
|
+
const section = node("section", "form-group");
|
|
238
|
+
const rows = node("div", "form-group__row");
|
|
239
|
+
section.append(node("h2", "form-group__title", group), rows);
|
|
240
|
+
groups.set(group, rows);
|
|
241
|
+
view.append(section);
|
|
242
|
+
}
|
|
243
|
+
const row = node("div", "form-row pp-field");
|
|
244
|
+
const label = node("div", "form-row__text");
|
|
245
|
+
label.append(node("span", "form-row__title", match?.[2] ?? field.name));
|
|
246
|
+
if (field.description) label.append(node("span", "form-row__subtitle", field.description));
|
|
247
|
+
row.append(label);
|
|
207
248
|
const value = values[field.key];
|
|
208
249
|
/** @type {() => unknown} 读取尚未保存的输入 / Read the unsaved input. */
|
|
209
250
|
let read;
|
|
210
251
|
/** @type {(value: unknown) => void} 更新当前控件 / Update the current control. */
|
|
211
252
|
let write;
|
|
253
|
+
let inputContainer = row;
|
|
254
|
+
let eventName = "change";
|
|
212
255
|
switch (true) {
|
|
213
256
|
case Boolean(field.options) && field.type !== "array": {
|
|
214
257
|
const select = node("select", "pp-input");
|
|
@@ -226,12 +269,42 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
226
269
|
break;
|
|
227
270
|
}
|
|
228
271
|
case field.type === "array" && Boolean(field.options): {
|
|
272
|
+
const page = node("section", "pp-choice-page");
|
|
273
|
+
if (field.description) page.append(node("p", "pp-description", field.description));
|
|
274
|
+
const choices = node("div", "form-group__row");
|
|
275
|
+
page.append(choices);
|
|
276
|
+
inputContainer = choices;
|
|
277
|
+
editors.set(field.key, { node: page, title: match?.[2] ?? field.name });
|
|
278
|
+
const summary = node("span", "form-row__value pp-summary");
|
|
279
|
+
const link = node("button", "pp-choice-link");
|
|
280
|
+
link.type = "button";
|
|
281
|
+
link.setAttribute("aria-label", field.name);
|
|
282
|
+
link.append(summary, node("span", "pp-chevron", "›"));
|
|
283
|
+
row.append(link);
|
|
284
|
+
const refresh = () => {
|
|
285
|
+
const value = client.snapshot(active).values[field.key];
|
|
286
|
+
summary.textContent =
|
|
287
|
+
field.options
|
|
288
|
+
.filter(option => Array.isArray(value) && value.includes(option.key))
|
|
289
|
+
.map(option => option.label)
|
|
290
|
+
.join("、") || "未选择";
|
|
291
|
+
};
|
|
292
|
+
summaries.push(refresh);
|
|
293
|
+
refresh();
|
|
294
|
+
link.onclick = () => {
|
|
295
|
+
window.history.pushState({ ...window.history.state, preferencePane: active }, "", `#${encodeURIComponent(field.key)}`);
|
|
296
|
+
showEditor();
|
|
297
|
+
};
|
|
298
|
+
row.addEventListener("click", event => {
|
|
299
|
+
if (!link.contains(event.target)) link.click();
|
|
300
|
+
});
|
|
229
301
|
const inputs = field.options.map(option => {
|
|
230
|
-
const label = node("label", "pp-choice", option.label);
|
|
302
|
+
const label = node("label", "form-row pp-choice", option.label);
|
|
231
303
|
const input = node("input", "");
|
|
232
304
|
input.type = "checkbox";
|
|
233
|
-
label.
|
|
234
|
-
|
|
305
|
+
input.setAttribute("aria-label", option.label);
|
|
306
|
+
label.append(input);
|
|
307
|
+
choices.append(label);
|
|
235
308
|
return { input, key: option.key };
|
|
236
309
|
});
|
|
237
310
|
read = () => inputs.filter(option => option.input.checked).map(option => option.key);
|
|
@@ -243,6 +316,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
243
316
|
default: {
|
|
244
317
|
const multiline = field.control === "textarea" || field.type === "array";
|
|
245
318
|
const input = node(multiline ? "textarea" : "input", "pp-input");
|
|
319
|
+
if (multiline) row.classList.add("pp-multiline");
|
|
246
320
|
input.setAttribute("aria-label", field.name);
|
|
247
321
|
if (field.placeholder) input.placeholder = field.placeholder;
|
|
248
322
|
if (multiline && field.rows) input.rows = field.rows;
|
|
@@ -265,11 +339,14 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
265
339
|
}
|
|
266
340
|
if (field.type === "boolean") {
|
|
267
341
|
input.type = "checkbox";
|
|
342
|
+
input.classList.add("pp-switch");
|
|
343
|
+
input.setAttribute("role", "switch");
|
|
268
344
|
write = value => {
|
|
269
345
|
input.checked = value === true;
|
|
270
346
|
};
|
|
271
347
|
read = () => input.checked;
|
|
272
348
|
} else {
|
|
349
|
+
eventName = "input";
|
|
273
350
|
if (!multiline) input.type = field.type === "number" ? "number" : "text";
|
|
274
351
|
write = value => {
|
|
275
352
|
input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
|
|
@@ -291,34 +368,31 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
291
368
|
}
|
|
292
369
|
}
|
|
293
370
|
write(value);
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
320
|
-
row.append(actions);
|
|
321
|
-
view.append(row);
|
|
371
|
+
let inputVersion = 0;
|
|
372
|
+
inputContainer.addEventListener(eventName, event => {
|
|
373
|
+
if (event.isComposing) return;
|
|
374
|
+
const version = ++inputVersion,
|
|
375
|
+
module = active;
|
|
376
|
+
let value;
|
|
377
|
+
try {
|
|
378
|
+
value = read();
|
|
379
|
+
} catch (error) {
|
|
380
|
+
notify({ kind: "error", message: error.message });
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const restore = () => {
|
|
384
|
+
if (version === inputVersion) write(client.snapshot(module).values[field.key]);
|
|
385
|
+
};
|
|
386
|
+
perform(
|
|
387
|
+
() => client.set(module, field.key, value),
|
|
388
|
+
() => {
|
|
389
|
+
for (const refresh of summaries) refresh();
|
|
390
|
+
},
|
|
391
|
+
restore,
|
|
392
|
+
);
|
|
393
|
+
});
|
|
394
|
+
if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
|
|
395
|
+
groups.get(group).append(row);
|
|
322
396
|
}
|
|
323
397
|
const maintenance = node("section", "pp-maintenance");
|
|
324
398
|
maintenance.append(node("h2", "pp-title", "模块数据"));
|
|
@@ -331,6 +405,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
331
405
|
output.setAttribute("aria-label", "Caches 内容");
|
|
332
406
|
for (const button of [cacheView, cacheClear, reset]) button.type = "button";
|
|
333
407
|
cacheView.onclick = () => {
|
|
408
|
+
if (saving) return;
|
|
334
409
|
let value;
|
|
335
410
|
return perform(
|
|
336
411
|
async () => {
|
|
@@ -349,6 +424,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
349
424
|
);
|
|
350
425
|
};
|
|
351
426
|
cacheClear.onclick = () => {
|
|
427
|
+
if (saving) return;
|
|
352
428
|
if (!window.confirm(`清空 ${active} 的全部 Caches?`)) return;
|
|
353
429
|
return perform(
|
|
354
430
|
() => client.clearCaches(active),
|
|
@@ -358,6 +434,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
358
434
|
);
|
|
359
435
|
};
|
|
360
436
|
reset.onclick = () => {
|
|
437
|
+
if (saving) return;
|
|
361
438
|
if (!window.confirm(`重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) return;
|
|
362
439
|
return perform(() => client.reset(active), controls);
|
|
363
440
|
};
|
|
@@ -366,6 +443,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
366
443
|
view.append(maintenance);
|
|
367
444
|
viewport.replaceChildren(view);
|
|
368
445
|
for (const grow of growingInputs) grow();
|
|
446
|
+
showEditor();
|
|
369
447
|
}
|
|
370
448
|
/**
|
|
371
449
|
* 按页面 pathname 切换模块,写入尚未完成时延后导航。
|
|
@@ -378,6 +456,7 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
378
456
|
return;
|
|
379
457
|
}
|
|
380
458
|
pendingRoute = false;
|
|
459
|
+
secondaryRoute = undefined;
|
|
381
460
|
if (active) client.leave(active);
|
|
382
461
|
routedPath = window.location.pathname;
|
|
383
462
|
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(routedPath);
|
|
@@ -397,7 +476,9 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
397
476
|
*/
|
|
398
477
|
const onPopState = () => {
|
|
399
478
|
if (window.location.pathname !== routedPath) route();
|
|
479
|
+
else secondaryRoute?.();
|
|
400
480
|
};
|
|
481
|
+
const onHashChange = () => secondaryRoute?.();
|
|
401
482
|
/**
|
|
402
483
|
* 从浏览器往返缓存恢复时重新读取当前模块。
|
|
403
484
|
* Reload the current module when restored from the browser back-forward cache.
|
|
@@ -408,10 +489,15 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
408
489
|
if (event.persisted) route();
|
|
409
490
|
};
|
|
410
491
|
back.onclick = () => {
|
|
411
|
-
if (
|
|
492
|
+
if (saving) return;
|
|
493
|
+
if (window.location.hash && window.history.state?.preferencePane !== active) {
|
|
494
|
+
window.history.replaceState(window.history.state, "", window.location.pathname);
|
|
495
|
+
secondaryRoute?.();
|
|
496
|
+
} else window.history.back();
|
|
412
497
|
};
|
|
413
498
|
window.addEventListener("popstate", onPopState);
|
|
414
499
|
window.addEventListener("pageshow", onPageShow);
|
|
500
|
+
window.addEventListener("hashchange", onHashChange);
|
|
415
501
|
route();
|
|
416
502
|
return {
|
|
417
503
|
/**
|
|
@@ -423,8 +509,9 @@ export function mountPreferencePanes({ element: root, fetch, title = "Preference
|
|
|
423
509
|
destroyed = true;
|
|
424
510
|
window.removeEventListener("popstate", onPopState);
|
|
425
511
|
window.removeEventListener("pageshow", onPageShow);
|
|
512
|
+
window.removeEventListener("hashchange", onHashChange);
|
|
426
513
|
generation++;
|
|
427
|
-
if (active) client.leave(active);
|
|
514
|
+
if (active && !saving) client.leave(active);
|
|
428
515
|
clearTimeout(timer);
|
|
429
516
|
shell.remove();
|
|
430
517
|
},
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-CN">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
|
|
6
|
+
<meta name="color-scheme" content="light dark">
|
|
7
|
+
<title>Preferences</title>
|
|
8
|
+
<link rel="stylesheet" href="/settings/assets/panel.css?v=__VERSION__">
|
|
9
|
+
<link rel="stylesheet" href="/settings/assets/home.css?v=__VERSION__">
|
|
10
|
+
</head>
|
|
11
|
+
<body>
|
|
12
|
+
<main id="preferences"></main>
|
|
13
|
+
<script type="module" src="/settings/assets/app.mjs?v=__VERSION__"></script>
|
|
14
|
+
</body>
|
|
15
|
+
</html>
|
package/src/index.d.ts
CHANGED
|
@@ -84,6 +84,29 @@ export interface SettingsHandlerOptions {
|
|
|
84
84
|
/** 默认 X-Settings-Client,值必须为 1;不是认证凭据 / Defaults to X-Settings-Client with value 1; not an authentication credential. */
|
|
85
85
|
requestHeader?: string;
|
|
86
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* 原生 Mock 的等价资源映射,支持不具备该语法的代理。
|
|
89
|
+
* Equivalent resource mapping for proxies lacking native Mock syntax.
|
|
90
|
+
*/
|
|
91
|
+
export interface PreferencesHandlerOptions extends SettingsHandlerOptions {
|
|
92
|
+
/** 按 pathname 匹配,不匹配下载源自身 / Match pathnames without intercepting the download source itself. */
|
|
93
|
+
resources: Array<{
|
|
94
|
+
/** 锚定的 pathname 正则 / Anchored pathname regular expression. */
|
|
95
|
+
pattern: string;
|
|
96
|
+
/** HTTPS 下载源 / HTTPS resource source. */
|
|
97
|
+
source: string;
|
|
98
|
+
/** 响应媒体类型 / Response media type. */
|
|
99
|
+
contentType: string;
|
|
100
|
+
}>;
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* 通用安装入口;API 保持无网络读写,静态请求才下载资源。
|
|
104
|
+
* Generic installation entry; APIs remain network-free and only static requests download resources.
|
|
105
|
+
*/
|
|
106
|
+
export class PreferencesHandler extends SettingsHandler {
|
|
107
|
+
/** @param options 安装 JSON 配置 / Installation JSON configuration. */
|
|
108
|
+
constructor(options: PreferencesHandlerOptions);
|
|
109
|
+
}
|
|
87
110
|
/**
|
|
88
111
|
* 属于单个模块的字段、存储根及可选展示元数据。
|
|
89
112
|
* Fields, storage root and optional display metadata belonging to one module.
|
package/src/index.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { normalizeBoxJs } from "./lib/boxjs.mjs";
|
|
2
2
|
export { parseSettingsPath } from "./lib/settings-path.mjs";
|
|
3
|
+
export { PreferencesHandler } from "./PreferencesHandler.mjs";
|
|
3
4
|
/**
|
|
4
5
|
* 通用代理处理器与配置解析的公开入口。
|
|
5
6
|
* Public entry for the proxy handler and configuration parsing.
|