@nsnanocat/preference-panes 0.8.1 → 0.9.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.
@@ -1,4 +1,4 @@
1
- var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-brand {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n text-align: center;\n}\n.pp-brand-icon {\n display: none;\n flex: none;\n width: 28px;\n height: 28px;\n}\n.pp-brand-icon:not(:empty) {\n display: block;\n}\n.pp-brand-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n: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";
1
+ var defaults = "@import url(\"https://hilo.bilibili.com/h5_common/theme.min.css\") layer(preference-panes);\n@import url(\"https://hilo.bilibili.com/h5_common/b-style.min.css\") layer(preference-panes);\n@import url(\"https://hilo.bilibili.com/app_settings/assets/messageSettingsLayout-ltzQ1gMi.css\") layer(preference-panes);\n@import url(\"https://hilo.bilibili.com/app_settings/assets/message-settings-BD3N1lqQ.css\") layer(preference-panes);\n@layer preference-panes {\n/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: var(--text1);\n --pp-background: var(--bg2);\n --pp-surface: var(--bg1);\n --pp-border: var(--line_regular);\n --pp-muted: var(--text3);\n --pp-accent: var(--brand_pink);\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:not(.v-toggle) {\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) + 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}\n.pp-search {\n width: 100%;\n margin-bottom: 12px;\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-panel .v-toggle {\n border: 0;\n padding: 0;\n flex: none;\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\n}";
2
2
 
3
3
  /**
4
4
  * 校验原始路径片段,不进行 URL 编码转换。
@@ -109,10 +109,30 @@ function metadata(source) {
109
109
  function element(tag, className, text) {
110
110
  const node = document.createElement(tag);
111
111
  node.className = className;
112
+ // 官方 AppSettings 1.1.2 的作用域标记与原版 CSS 一起固定版本。
113
+ // Pin official AppSettings 1.1.2 scope attributes together with its unmodified CSS.
114
+ if (/\bform-row(?:\b|__)/.test(className)) node.setAttribute("data-v-b69aa1ea", "");
115
+ if (/\bform-group(?:\b|__)/.test(className)) node.setAttribute("data-v-e590be47", "");
112
116
  if (text !== undefined) node.textContent = text;
113
117
  return node;
114
118
  }
115
119
 
120
+ /**
121
+ * 搜索、选择和文本控件共用官方 VField 的 DOM 结构。
122
+ * Share the official VField DOM structure across search, select and text controls.
123
+ * @param {HTMLElement} control 已创建的原生控件 / Existing native control.
124
+ * @param {boolean} [multiline] 是否为多行输入 / Whether the control is multiline.
125
+ * @returns {HTMLDivElement} 字段容器 / Field container.
126
+ */
127
+ function fieldControl(control, multiline = false) {
128
+ const field = element("div", `v-field pp-editor${multiline ? " v-field--textarea" : ""}`);
129
+ const body = element("div", "v-field__body");
130
+ control.classList.add("v-field__control");
131
+ body.append(control);
132
+ field.append(body);
133
+ return field;
134
+ }
135
+
116
136
  /**
117
137
  * 元数据地址只允许 HTTP(S) 和相对地址。
118
138
  * Allow only HTTP(S) and relative metadata addresses.
@@ -157,6 +177,24 @@ function errorView(error, retry) {
157
177
  return view;
158
178
  }
159
179
 
180
+ /**
181
+ * 请求宿主确认;独立网页使用浏览器对话框。
182
+ * Request confirmation from the host, using the browser dialog for standalone pages.
183
+ * @param {Window} host 模块窗口 / Module window.
184
+ * @param {string} message 确认内容 / Confirmation message.
185
+ * @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.
186
+ */
187
+ function requestConfirmation(host, message) {
188
+ return new Promise((resolve, reject) => {
189
+ const frame = host.frameElement;
190
+ if (frame) {
191
+ const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:confirm", { cancelable: true, detail: { message, resolve, reject } });
192
+ if (!frame.dispatchEvent(event)) return;
193
+ }
194
+ resolve(host.confirm(message));
195
+ });
196
+ }
197
+
160
198
  /**
161
199
  * 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。
162
200
  * Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.
@@ -777,7 +815,7 @@ function mountPanel(root, catalog) {
777
815
  { id: "clearCaches", label: "清空缓存", destructive: true },
778
816
  { id: "reset", label: "重置模块", destructive: true },
779
817
  ];
780
- const menu = new ActionMenu(id => handlers.get(id)());
818
+ const menu = new ActionMenu(id => runAction(id));
781
819
  const trailing = element("span", "pp-nav-spacer");
782
820
  trailing.append(menu.element);
783
821
  const brand = element("div", "pp-brand");
@@ -807,7 +845,7 @@ function mountPanel(root, catalog) {
807
845
  );
808
846
  };
809
847
  const onAction = event => {
810
- if (!saving && handlers.has(event.detail)) handlers.get(event.detail)();
848
+ if (!saving && handlers.has(event.detail)) runAction(event.detail);
811
849
  };
812
850
  window.frameElement?.addEventListener("preferencepanes:action", onAction);
813
851
  let timer,
@@ -849,6 +887,19 @@ function mountPanel(root, catalog) {
849
887
  }, 2400);
850
888
  };
851
889
  const client = createPreferencesClient({ catalog, notify });
890
+ /**
891
+ * 两种菜单入口共用异步错误处理,包含宿主确认框错误。
892
+ * Share async error handling between both menus, including host-dialog errors.
893
+ * @param {string} id 操作标识 / Action identifier.
894
+ * @returns {Promise<void>} 操作已处理 / Action handled.
895
+ */
896
+ async function runAction(id) {
897
+ try {
898
+ await handlers.get(id)();
899
+ } catch (error) {
900
+ notify({ kind: "error", message: error.message });
901
+ }
902
+ }
852
903
  /**
853
904
  * 打开模块并忽略已过期的异步结果。
854
905
  * Open a module and ignore stale asynchronous results.
@@ -880,6 +931,14 @@ function mountPanel(root, catalog) {
880
931
  const { definition, values } = client.snapshot(active);
881
932
  heading.textContent = definition.metadata?.name || active;
882
933
  const view = element("section", "pp-fields");
934
+ const search = element("input", "");
935
+ search.type = "search";
936
+ search.placeholder = "搜索设置项";
937
+ search.setAttribute("aria-label", "搜索设置");
938
+ const searchField = fieldControl(search);
939
+ searchField.classList.add("pp-search");
940
+ view.append(searchField);
941
+ const searchRows = [];
883
942
  /**
884
943
  * 挂载后执行的多行高度更新
885
944
  * Textarea sizing callbacks run after mounting.
@@ -954,7 +1013,7 @@ function mountPanel(root, catalog) {
954
1013
  const match = /^\[([^\]]+)\]\s*(.*)$/.exec(field.name);
955
1014
  const group = match?.[1] ?? "通用";
956
1015
  if (!groups.has(group)) {
957
- const section = element("section", "form-group");
1016
+ const section = element("section", "form-group form-group--has-title");
958
1017
  const rows = element("div", "form-group__row");
959
1018
  section.append(element("h2", "form-group__title", group), rows);
960
1019
  groups.set(group, rows);
@@ -982,7 +1041,7 @@ function mountPanel(root, catalog) {
982
1041
  let eventName = "change";
983
1042
  switch (true) {
984
1043
  case Boolean(field.options) && field.type !== "array": {
985
- const select = element("select", "pp-input");
1044
+ const select = element("select", "");
986
1045
  select.setAttribute("aria-label", field.name);
987
1046
  field.options.forEach((option, index) => {
988
1047
  const item = element("option", "", option.label);
@@ -992,7 +1051,7 @@ function mountPanel(root, catalog) {
992
1051
  write = value => {
993
1052
  select.selectedIndex = field.options.findIndex(option => option.key === value);
994
1053
  };
995
- row.append(select);
1054
+ row.append(fieldControl(select));
996
1055
  read = () => field.options[select.selectedIndex]?.key;
997
1056
  break;
998
1057
  }
@@ -1038,9 +1097,27 @@ function mountPanel(root, catalog) {
1038
1097
  };
1039
1098
  break;
1040
1099
  }
1100
+ case field.type === "boolean": {
1101
+ const toggle = element("button", "v-toggle v-toggle--small form-row__toggle");
1102
+ toggle.type = "button";
1103
+ toggle.setAttribute("role", "switch");
1104
+ toggle.setAttribute("aria-label", field.name);
1105
+ toggle.append(element("span", "v-toggle__circle"));
1106
+ write = value => {
1107
+ toggle.setAttribute("aria-checked", String(value === true));
1108
+ toggle.classList.toggle("v-toggle--closed", value !== true);
1109
+ };
1110
+ read = () => toggle.getAttribute("aria-checked") === "true";
1111
+ toggle.onclick = () => {
1112
+ write(!read());
1113
+ toggle.dispatchEvent(new window.Event("change", { bubbles: true }));
1114
+ };
1115
+ row.append(toggle);
1116
+ break;
1117
+ }
1041
1118
  default: {
1042
1119
  const multiline = field.control === "textarea" || field.type === "array";
1043
- const input = element(multiline ? "textarea" : "input", "pp-input");
1120
+ const input = element(multiline ? "textarea" : "input", "");
1044
1121
  if (multiline) row.classList.add("pp-multiline");
1045
1122
  input.setAttribute("aria-label", field.name);
1046
1123
  if (field.placeholder) input.placeholder = field.placeholder;
@@ -1062,33 +1139,23 @@ function mountPanel(root, catalog) {
1062
1139
  input.addEventListener("input", grow);
1063
1140
  growingInputs.push(grow);
1064
1141
  }
1065
- if (field.type === "boolean") {
1066
- input.type = "checkbox";
1067
- input.classList.add("pp-switch");
1068
- input.setAttribute("role", "switch");
1069
- write = value => {
1070
- input.checked = value === true;
1071
- };
1072
- read = () => input.checked;
1073
- } else {
1074
- eventName = "input";
1075
- if (!multiline) input.type = field.type === "number" ? "number" : "text";
1076
- write = value => {
1077
- input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
1078
- grow();
1079
- };
1080
- read = () => {
1081
- switch (field.type) {
1082
- case "array":
1083
- return JSON.parse(input.value);
1084
- case "number":
1085
- return input.value === "" ? Number.NaN : Number(input.value);
1086
- default:
1087
- return input.value;
1088
- }
1089
- };
1090
- }
1091
- row.append(input);
1142
+ eventName = "input";
1143
+ if (!multiline) input.type = field.type === "number" ? "number" : "text";
1144
+ write = value => {
1145
+ input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
1146
+ grow();
1147
+ };
1148
+ read = () => {
1149
+ switch (field.type) {
1150
+ case "array":
1151
+ return JSON.parse(input.value);
1152
+ case "number":
1153
+ return input.value === "" ? Number.NaN : Number(input.value);
1154
+ default:
1155
+ return input.value;
1156
+ }
1157
+ };
1158
+ row.append(fieldControl(input, multiline));
1092
1159
  break;
1093
1160
  }
1094
1161
  }
@@ -1118,7 +1185,18 @@ function mountPanel(root, catalog) {
1118
1185
  });
1119
1186
  if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
1120
1187
  groups.get(group).append(row);
1188
+ searchRows.push({ row, text: [field.name, field.key, field.description, ...(field.options ?? []).map(option => option.label)].join(" ").toLocaleLowerCase() });
1121
1189
  }
1190
+ const empty = element("p", "pp-description", "没有匹配的设置项");
1191
+ empty.hidden = true;
1192
+ empty.setAttribute("role", "status");
1193
+ view.append(empty);
1194
+ search.oninput = () => {
1195
+ const words = search.value.trim().toLocaleLowerCase().split(/\s+/);
1196
+ for (const { row, text } of searchRows) row.hidden = !words.every(word => text.includes(word));
1197
+ for (const rows of groups.values()) rows.parentElement.hidden = [...rows.children].every(row => row.hidden);
1198
+ empty.hidden = searchRows.some(({ row }) => !row.hidden);
1199
+ };
1122
1200
  const cachePage = element("section", "pp-cache-page");
1123
1201
  const output = element("pre", "pp-cache");
1124
1202
  output.textContent = "暂无缓存";
@@ -1143,9 +1221,9 @@ function mountPanel(root, catalog) {
1143
1221
  },
1144
1222
  );
1145
1223
  });
1146
- handlers.set("clearCaches", () => {
1224
+ handlers.set("clearCaches", async () => {
1147
1225
  if (saving) return;
1148
- if (!window.confirm(`清空 ${active} 的全部 Caches?`)) return;
1226
+ if (!(await requestConfirmation(window, `清空 ${active} 的全部 Caches?`)) || destroyed || saving) return;
1149
1227
  return perform(
1150
1228
  () => client.clearCaches(active),
1151
1229
  () => {
@@ -1153,9 +1231,9 @@ function mountPanel(root, catalog) {
1153
1231
  },
1154
1232
  );
1155
1233
  });
1156
- handlers.set("reset", () => {
1234
+ handlers.set("reset", async () => {
1157
1235
  if (saving) return;
1158
- if (!window.confirm(`重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) return;
1236
+ if (!(await requestConfirmation(window, `重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) || destroyed || saving) return;
1159
1237
  return perform(() => client.reset(active), controls);
1160
1238
  });
1161
1239
  navigation?.destroy();
@@ -1195,8 +1273,8 @@ function mountPanel(root, catalog) {
1195
1273
  }
1196
1274
 
1197
1275
  /**
1198
- * 只挂载导入 JSON 对应的模块设置页,默认样式内置,CSS 仅用于该页。
1199
- * Mount only the imported module's settings page with built-in defaults and optional page CSS.
1276
+ * 挂载模块设置页;内置资源只引用官方地址,CSS 输入仅用于该页。
1277
+ * Mount the module page with official resource URLs and optional page-specific CSS.
1200
1278
  * @param {import("../index.js").BoxJSInput} boxjs 单个模块的 BoxJS JSON / BoxJS JSON for one module.
1201
1279
  * @param {string} [css] 可选 CSS 正文 / Optional CSS text.
1202
1280
  * @returns {import("./index.js").MountedPreferences} 模块生命周期句柄 / Module lifecycle handle.
@@ -1221,8 +1299,28 @@ function mount(boxjs, css = "") {
1221
1299
  document.head.append(base, custom);
1222
1300
  const previousTitle = document.title;
1223
1301
  const previousTheme = document.documentElement.dataset.theme;
1224
- const theme = navigator.userAgent.match(/themeId\/(\d+)/)?.[1];
1225
- if (theme) document.documentElement.dataset.theme = theme === "2" ? "dark" : "light";
1302
+ const previousDark = document.documentElement.classList.contains("bili_dark");
1303
+ const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
1304
+ const previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
1305
+ const host = window.frameElement?.ownerDocument.documentElement;
1306
+ /**
1307
+ * 跟随嵌入宿主的通用环境状态,不识别业务 App 或解析其 UA。
1308
+ * Follow generic host appearance without detecting a business app or parsing its user agent.
1309
+ * @returns {void} 已同步主题与键盘避让 / Theme and keyboard clearance synchronized.
1310
+ */
1311
+ const syncAppearance = () => {
1312
+ const theme = host?.dataset.theme ?? previousTheme ?? (systemTheme.matches ? "dark" : "light");
1313
+ document.documentElement.dataset.theme = theme;
1314
+ document.documentElement.classList.toggle("bili_dark", theme === "dark");
1315
+ if (host) document.documentElement.style.setProperty("--pp-keyboard-height", host.style.getPropertyValue("--pp-keyboard-height"));
1316
+ };
1317
+ let observer;
1318
+ syncAppearance();
1319
+ systemTheme.addEventListener("change", syncAppearance);
1320
+ if (host) {
1321
+ observer = new MutationObserver(syncAppearance);
1322
+ observer.observe(host, { attributes: true, attributeFilter: ["data-theme", "style"] });
1323
+ }
1226
1324
  document.title = metadata.name ?? catalog.module.module;
1227
1325
  let panel;
1228
1326
  const view = {
@@ -1232,6 +1330,9 @@ function mount(boxjs, css = "") {
1232
1330
  * @returns {void} 无返回值 / No return value.
1233
1331
  */
1234
1332
  destroy() {
1333
+ observer?.disconnect();
1334
+ systemTheme.removeEventListener("change", syncAppearance);
1335
+ document.documentElement.classList.toggle("bili_dark", previousDark);
1235
1336
  panel?.destroy();
1236
1337
  base.remove();
1237
1338
  custom.remove();
@@ -1240,6 +1341,7 @@ function mount(boxjs, css = "") {
1240
1341
  document.title = previousTitle;
1241
1342
  if (previousTheme === undefined) delete document.documentElement.dataset.theme;
1242
1343
  else document.documentElement.dataset.theme = previousTheme;
1344
+ document.documentElement.style.setProperty("--pp-keyboard-height", previousKeyboard);
1243
1345
  },
1244
1346
  };
1245
1347
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nsnanocat/preference-panes",
3
- "version": "0.8.1",
3
+ "version": "0.9.1",
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",
@@ -14,6 +14,10 @@ export class ModuleFrame extends EventTarget {
14
14
  this.#state = { ...event.detail, actions: event.detail.actions ?? [] };
15
15
  this.dispatchEvent(new Event("change"));
16
16
  };
17
+ #confirmation = event => {
18
+ const request = new CustomEvent("confirm", { cancelable: true, detail: event.detail });
19
+ if (!this.dispatchEvent(request)) event.preventDefault();
20
+ };
17
21
 
18
22
  /**
19
23
  * 建立 iframe 与请求输入;调用方挂载 element 后调用 load。
@@ -30,6 +34,7 @@ export class ModuleFrame extends EventTarget {
30
34
  this.element.title = `${inputs.module} 设置`;
31
35
  this.element.dataset.preferencePanes = JSON.stringify(inputs);
32
36
  this.element.addEventListener("preferencepanes:change", this.#change);
37
+ this.element.addEventListener("preferencepanes:confirm", this.#confirmation);
33
38
  this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true, actions: [] };
34
39
  options.signal?.addEventListener("abort", this.#abort, { once: true });
35
40
  }
@@ -90,5 +95,6 @@ export class ModuleFrame extends EventTarget {
90
95
  this.#controller.abort();
91
96
  this.#options.signal?.removeEventListener("abort", this.#abort);
92
97
  this.element.removeEventListener("preferencepanes:change", this.#change);
98
+ this.element.removeEventListener("preferencepanes:confirm", this.#confirmation);
93
99
  }
94
100
  }
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * 原始 HTML 的模块 iframe 容器;通过元素传递请求上下文。
3
3
  * Iframe container preserving module HTML and carrying context on the element.
4
+ * confirm 事件可 preventDefault 后通过 detail.resolve/reject 完成宿主确认。
5
+ * Prevent default on confirm events and settle host dialogs through detail.resolve/reject.
4
6
  */
5
7
  export class ModuleFrame extends EventTarget {
6
8
  /**
@@ -47,6 +49,32 @@ export class ModuleFrame extends EventTarget {
47
49
  destroy(): void;
48
50
  }
49
51
 
52
+ /**
53
+ * 宿主确认事件的数据。
54
+ * Host confirmation event detail.
55
+ */
56
+ export interface ConfirmationRequest {
57
+ /**
58
+ * 确认内容。
59
+ * Confirmation message.
60
+ */
61
+ message: string;
62
+ /**
63
+ * 返回选择。
64
+ * Return the user's choice.
65
+ * @param confirmed 是否确认 / Whether confirmed.
66
+ * @returns 无返回值 / No return value.
67
+ */
68
+ resolve(confirmed: boolean): void;
69
+ /**
70
+ * 返回失败。
71
+ * Return a failure.
72
+ * @param error 错误 / Error.
73
+ * @returns 无返回值 / No return value.
74
+ */
75
+ reject(error: Error): void;
76
+ }
77
+
50
78
  /**
51
79
  * 菜单操作描述。
52
80
  * Menu action descriptor.
@@ -10,10 +10,30 @@
10
10
  export function element(tag, className, text) {
11
11
  const node = document.createElement(tag);
12
12
  node.className = className;
13
+ // 官方 AppSettings 1.1.2 的作用域标记与原版 CSS 一起固定版本。
14
+ // Pin official AppSettings 1.1.2 scope attributes together with its unmodified CSS.
15
+ if (/\bform-row(?:\b|__)/.test(className)) node.setAttribute("data-v-b69aa1ea", "");
16
+ if (/\bform-group(?:\b|__)/.test(className)) node.setAttribute("data-v-e590be47", "");
13
17
  if (text !== undefined) node.textContent = text;
14
18
  return node;
15
19
  }
16
20
 
21
+ /**
22
+ * 搜索、选择和文本控件共用官方 VField 的 DOM 结构。
23
+ * Share the official VField DOM structure across search, select and text controls.
24
+ * @param {HTMLElement} control 已创建的原生控件 / Existing native control.
25
+ * @param {boolean} [multiline] 是否为多行输入 / Whether the control is multiline.
26
+ * @returns {HTMLDivElement} 字段容器 / Field container.
27
+ */
28
+ export function fieldControl(control, multiline = false) {
29
+ const field = element("div", `v-field pp-editor${multiline ? " v-field--textarea" : ""}`);
30
+ const body = element("div", "v-field__body");
31
+ control.classList.add("v-field__control");
32
+ body.append(control);
33
+ field.append(body);
34
+ return field;
35
+ }
36
+
17
37
  /**
18
38
  * 元数据地址只允许 HTTP(S) 和相对地址。
19
39
  * Allow only HTTP(S) and relative metadata addresses.
@@ -57,3 +77,21 @@ export function errorView(error, retry) {
57
77
  view.append(element("p", "", `加载失败:${error.message}`), button);
58
78
  return view;
59
79
  }
80
+
81
+ /**
82
+ * 请求宿主确认;独立网页使用浏览器对话框。
83
+ * Request confirmation from the host, using the browser dialog for standalone pages.
84
+ * @param {Window} host 模块窗口 / Module window.
85
+ * @param {string} message 确认内容 / Confirmation message.
86
+ * @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.
87
+ */
88
+ export function requestConfirmation(host, message) {
89
+ return new Promise((resolve, reject) => {
90
+ const frame = host.frameElement;
91
+ if (frame) {
92
+ const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:confirm", { cancelable: true, detail: { message, resolve, reject } });
93
+ if (!frame.dispatchEvent(event)) return;
94
+ }
95
+ resolve(host.confirm(message));
96
+ });
97
+ }
@@ -4,8 +4,8 @@ import { element, resourceURL } from "./components.mjs";
4
4
  import { mountPanel } from "./panel.mjs";
5
5
 
6
6
  /**
7
- * 只挂载导入 JSON 对应的模块设置页,默认样式内置,CSS 仅用于该页。
8
- * Mount only the imported module's settings page with built-in defaults and optional page CSS.
7
+ * 挂载模块设置页;内置资源只引用官方地址,CSS 输入仅用于该页。
8
+ * Mount the module page with official resource URLs and optional page-specific CSS.
9
9
  * @param {import("../index.js").BoxJSInput} boxjs 单个模块的 BoxJS JSON / BoxJS JSON for one module.
10
10
  * @param {string} [css] 可选 CSS 正文 / Optional CSS text.
11
11
  * @returns {import("./index.js").MountedPreferences} 模块生命周期句柄 / Module lifecycle handle.
@@ -30,8 +30,28 @@ export function mount(boxjs, css = "") {
30
30
  document.head.append(base, custom);
31
31
  const previousTitle = document.title;
32
32
  const previousTheme = document.documentElement.dataset.theme;
33
- const theme = navigator.userAgent.match(/themeId\/(\d+)/)?.[1];
34
- if (theme) document.documentElement.dataset.theme = theme === "2" ? "dark" : "light";
33
+ const previousDark = document.documentElement.classList.contains("bili_dark");
34
+ const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
35
+ const previousKeyboard = document.documentElement.style.getPropertyValue("--pp-keyboard-height");
36
+ const host = window.frameElement?.ownerDocument.documentElement;
37
+ /**
38
+ * 跟随嵌入宿主的通用环境状态,不识别业务 App 或解析其 UA。
39
+ * Follow generic host appearance without detecting a business app or parsing its user agent.
40
+ * @returns {void} 已同步主题与键盘避让 / Theme and keyboard clearance synchronized.
41
+ */
42
+ const syncAppearance = () => {
43
+ const theme = host?.dataset.theme ?? previousTheme ?? (systemTheme.matches ? "dark" : "light");
44
+ document.documentElement.dataset.theme = theme;
45
+ document.documentElement.classList.toggle("bili_dark", theme === "dark");
46
+ if (host) document.documentElement.style.setProperty("--pp-keyboard-height", host.style.getPropertyValue("--pp-keyboard-height"));
47
+ };
48
+ let observer;
49
+ syncAppearance();
50
+ systemTheme.addEventListener("change", syncAppearance);
51
+ if (host) {
52
+ observer = new MutationObserver(syncAppearance);
53
+ observer.observe(host, { attributes: true, attributeFilter: ["data-theme", "style"] });
54
+ }
35
55
  document.title = metadata.name ?? catalog.module.module;
36
56
  let panel;
37
57
  const view = {
@@ -41,6 +61,9 @@ export function mount(boxjs, css = "") {
41
61
  * @returns {void} 无返回值 / No return value.
42
62
  */
43
63
  destroy() {
64
+ observer?.disconnect();
65
+ systemTheme.removeEventListener("change", syncAppearance);
66
+ document.documentElement.classList.toggle("bili_dark", previousDark);
44
67
  panel?.destroy();
45
68
  base.remove();
46
69
  custom.remove();
@@ -49,6 +72,7 @@ export function mount(boxjs, css = "") {
49
72
  document.title = previousTitle;
50
73
  if (previousTheme === undefined) delete document.documentElement.dataset.theme;
51
74
  else document.documentElement.dataset.theme = previousTheme;
75
+ document.documentElement.style.setProperty("--pp-keyboard-height", previousKeyboard);
52
76
  },
53
77
  };
54
78
  try {
@@ -0,0 +1,6 @@
1
+ {
2
+ "https://hilo.bilibili.com/h5_common/theme.min.css": "theme.min.css",
3
+ "https://hilo.bilibili.com/h5_common/b-style.min.css": "b-style.min.css",
4
+ "https://hilo.bilibili.com/app_settings/assets/messageSettingsLayout-ltzQ1gMi.css": "messageSettingsLayout-ltzQ1gMi.css",
5
+ "https://hilo.bilibili.com/app_settings/assets/message-settings-BD3N1lqQ.css": "message-settings-BD3N1lqQ.css"
6
+ }