dsh-remote-web 0.7.9 → 0.7.10-beta.3

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.
@@ -110,6 +110,29 @@
110
110
  * "设置面板铺满全屏"赢(否则设置面板会被 100vw-24px 钉住,又变成带边的浮层)。
111
111
  *
112
112
  * 开关:环境变量 DSH_MOBILE_ADAPTER=0 整体关闭(默认开启)。
113
+ *
114
+ * ===== 首屏性能 & 移动端体验(2026-10-2x)=================================================
115
+ * 背景:手机镜像页首屏 p50 3.9s / p90 21.6s、20.2% 打开 ≥12s(生产遥测 n=8903,事件 mirror_load)。
116
+ * 其中 TTFB、官方 13MB 级 bundle、中继带宽**不在适配层手里**;适配层只对下面四件事负责,
117
+ * 每件都必须满足"异常静默放弃、老浏览器缺 API 就跳过、绝不影响页面加载与现有交互":
118
+ * 1) **首启字号提示**(不然没人知道档位在哪 —— 用户原声「字体太小,因此不用」):
119
+ * 小气泡挂在 FAB **内部**(不新增 body 级浮层),延迟 1.2s 出现、12s 自动消失,
120
+ * 点它直接打开字号菜单;localStorage 记「已提示过」→ 同一浏览器只提示一次,
121
+ * 用户自己调过字号就不提示。只提示,不重做档位能力。
122
+ * 2) **图片按需加载**:给聊天里的 <img> 打 loading=lazy + decoding=async,别让一屏几十张图
123
+ * 在首屏就和 /assets /plugins 的 JS/CSS 抢同一条中继带宽。三层,都是"只打属性、不改结构":
124
+ * · 插入**前**钩子(唯一真能省请求的一层;headless Chrome 实测:插入后补属性无效):
125
+ * 只给 Node.prototype.appendChild/insertBefore 包一层,仅 ≤820px、仅对 IMG、
126
+ * ?dsh_ma_img_lazy=0 可整体关掉、pagehide 还原;
127
+ * · 插入后 childList 观察器(第二层,覆盖钩子装之前/其它引擎);
128
+ * · 看门狗低频补扫(≤1 次/800ms,补扫几何判定的那批;打字/粘贴期间暂停),
129
+ * 外加一条安全阀:打了 lazy 却渲染不出来(隐藏容器等 onload 的写法)→ 改回 eager。
130
+ * 3) **打字/粘贴期间暂停自己的后台扫描**(图片扫描、设置面板识别):主线程留给输入法。
131
+ * 输入框另补 enterkeyhint="enter"/spellcheck="false"(只打属性),字号档位也放大输入框
132
+ * (CSS max(16px, …) —— 永远不低于 16px,防 iOS 聚焦缩放)。
133
+ * 4) **可测量**:__dshMaDiag.perf 里给出首屏里程碑(FCP/TTFB/DCL/load/first-input)、
134
+ * 适配层自身 boot 耗时、HOSTISH 全量序列化次数、懒加载省下的图片数、打字暂停次数。
135
+ * ⚠️ **不新增遥测事件名**(服务端只认白名单,新名字会被丢弃)→ 这些数字只本地可读,不上报。
113
136
  */
114
137
 
115
138
  const ADAPTER_ID = "dsh-mobile-adapter";
@@ -253,9 +276,21 @@ const STYLE = `
253
276
  textarea,
254
277
  input:not([type]), input[type="text"], input[type="search"], input[type="email"],
255
278
  input[type="url"], input[type="tel"], input[type="number"], input[type="password"],
256
- [contenteditable="true"] { font-size: 16px !important; }
279
+ [contenteditable="true"], [data-composer-input], [role="textbox"] { font-size: 16px !important; }
257
280
  select, button, a, [role="button"], [role="menuitem"], [role="option"], [role="tab"] { touch-action: manipulation; }
258
281
 
282
+ /* --- 输入框跟着字号档位放大(2026-10-2x)---
283
+ 用户原声「手机上敲字痛苦」:字号档位(中/大/超大)只放大了正文,输入框被上面那条 16px
284
+ !important 钉死 —— 用户越调大字号,正文越大、输入框越小,打字越难受。
285
+ 这里让输入框也跟着 scale 走,但**永不低于 16px**:16px 是 iOS 聚焦自动放大的阈值,
286
+ 低于它一聚焦整页就跳一下(那是另一种「敲字痛苦」)。
287
+ 写法顺序就是降级链:先写 16px 兜底(老浏览器/不支持 max() 的),再写 max() 增强。 */
288
+ textarea, [contenteditable="true"], [data-composer-input], [role="textbox"] {
289
+ font-size: max(16px, calc(16px * var(--dsh-ma-fs-scale))) !important;
290
+ /* 长按选中/粘贴是手机上的刚需:只保证能选文本,不拦任何默认动作 */
291
+ -webkit-user-select: text; user-select: text;
292
+ }
293
+
259
294
  /* --- hero 工作区/模式座位行:可换行,防右缘被裁(实测 cubgiG_seat right=409>390) --- */
260
295
  div.wSkVaW_heroWorkspaceRow { flex-wrap: wrap; row-gap: 8px; column-gap: 8px; justify-content: center; min-width: 0; }
261
296
  div.wSkVaW_heroWorkspaceRow > * { max-width: calc(100vw - 32px); }
@@ -449,6 +484,56 @@ button.dsh-ma-fab .dsh-ma-fab-rtt[hidden] { display: none !important; }
449
484
  button.dsh-ma-fab .dsh-ma-fab-rtt[data-rtt="ok"] { color: var(--dsw-alias-label-success, #1a7f37); }
450
485
  button.dsh-ma-fab .dsh-ma-fab-rtt[data-rtt="mid"] { color: var(--dsw-alias-label-warning, #9a6700); }
451
486
  button.dsh-ma-fab .dsh-ma-fab-rtt[data-rtt="bad"] { color: var(--dsw-alias-label-danger, #cf222e); }
487
+ /* ===== 首启「字号太小?」提示气泡(2026-10-2x)=====
488
+ 用户原声:「字体太小,因此不用」—— 字号档位其实**早就有**(悬浮菜单里的滑块),但没人知道。
489
+ 这个气泡只做「告知」,**挂在 FAB 内部**(绝对定位子元素),绝不新增 body 级浮层
490
+ (护栏见 test/mobile-adapter-guards.test.mjs「不得新增 body 级固定层」):
491
+ · 位置跟按钮所在半屏走(按钮在上半屏 → 气泡在下方,反之在上方),见 SCRIPT 里 showFsTip;
492
+ · 点气泡 = 直接打开字号菜单;点 ✕ = 关掉;12 秒自动消失;
493
+ · 一次性(localStorage 记一次),且只对「从没调过字号」的人提示。 */
494
+ button.dsh-ma-fab .dsh-ma-fs-tip {
495
+ position: absolute;
496
+ display: block;
497
+ box-sizing: border-box;
498
+ width: max-content;
499
+ max-width: 200px;
500
+ max-width: min(58vw, 200px);
501
+ padding: 6px 8px 6px 10px;
502
+ border-radius: 10px;
503
+ font: 400 13px/1.35 -apple-system, system-ui, sans-serif;
504
+ text-align: left;
505
+ white-space: normal;
506
+ background: var(--dsw-alias-bg-elevated, #fff);
507
+ color: var(--dsw-alias-label-primary, #111);
508
+ border: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.3));
509
+ box-shadow: 0 4px 16px rgba(0,0,0,.22);
510
+ cursor: pointer;
511
+ animation: dsh-ma-fs-tip-in .16s ease-out;
512
+ }
513
+ button.dsh-ma-fab .dsh-ma-fs-tip-txt { display: inline; }
514
+ button.dsh-ma-fab .dsh-ma-fs-tip-x {
515
+ display: inline-block;
516
+ margin-left: 6px; padding: 0 2px;
517
+ font-size: 12px; line-height: 1;
518
+ color: var(--dsw-alias-label-tertiary, #6b7280);
519
+ }
520
+ button.dsh-ma-fab .dsh-ma-fs-tip-right { right: -4px; }
521
+ button.dsh-ma-fab .dsh-ma-fs-tip-left { left: -4px; }
522
+ button.dsh-ma-fab .dsh-ma-fs-tip-below { top: calc(100% + 10px); }
523
+ button.dsh-ma-fab .dsh-ma-fs-tip-above { bottom: calc(100% + 10px); }
524
+ /* 小尖角指向按钮(纯装饰,不占命中区) */
525
+ button.dsh-ma-fab .dsh-ma-fs-tip::after {
526
+ content: "";
527
+ position: absolute;
528
+ width: 8px; height: 8px;
529
+ background: var(--dsw-alias-bg-elevated, #fff);
530
+ border: .5px solid var(--dsw-alias-border-l3, rgba(127,127,127,.3));
531
+ transform: rotate(45deg);
532
+ }
533
+ button.dsh-ma-fab .dsh-ma-fs-tip-below::after { top: -5px; right: 14px; border-right: 0; border-bottom: 0; }
534
+ button.dsh-ma-fab .dsh-ma-fs-tip-above::after { bottom: -5px; right: 14px; border-left: 0; border-top: 0; }
535
+ button.dsh-ma-fab .dsh-ma-fs-tip-left::after { right: auto; left: 14px; }
536
+ @keyframes dsh-ma-fs-tip-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } }
452
537
  /* 菜单卡片:fixed 贴右下,最大高度受限可滚动 */
453
538
  div.dsh-ma-menu {
454
539
  position: fixed;
@@ -895,6 +980,134 @@ const SCRIPT = `(() => {
895
980
  // 受保护的 /api/* 请求被回 5xx 或直接网络错误(presence 不在镜像页这条链路上;
896
981
  // E2EE 死会话的 502 另带 x-dsh-e2ee-error,不算离线,见 maInstallNetSampler)。
897
982
  apiFails: 0, apiFailAt: 0, lastApiStatus: 0 };
983
+
984
+ /* ================= 首屏性能自测(2026-10-2x:只写诊断,**不上报**)=================
985
+ 背景:手机镜像页首屏 p50 3.9s / p90 21.6s、20.2% 打开 ≥12s(生产遥测 n=8903,事件名 mirror_load)。
986
+ 那 21.6s 的大头(HTML TTFB + 官方 13MB 级 bundle + 中继带宽)**不在适配层手里**;适配层能做、
987
+ 也有责任量清楚的是这两件事:
988
+ ① 适配层自己在主线程上花了多少时间(boot 耗时 / 全量序列化 DOM 的次数);
989
+ ② 它有没有少抢首屏带宽(图片懒加载真正省下多少张请求)。
990
+ 纪律(与 mirror_load 完全一致,2026-10-17 起就定下的规矩):
991
+ · **不新增遥测事件名**:服务端只认白名单,新名字会被直接丢弃 → 这里一律只写
992
+ window.__dshMaDiag.perf(用户上报现场时能读、mirror-diag 的复制诊断里也带着);
993
+ · 只用 performance 的只读接口,拿不到就留 -1(老 Safari 静默跳过,绝不影响页面);
994
+ · 每个入口 try/catch:诊断坏了绝不影响页面。 */
995
+ var maPerf = {
996
+ startAt: -1, // 注入脚本开始执行的时刻(performance.now,零点 = 导航开始)
997
+ bootCalls: 0, // 适配层 boot() 被调用次数
998
+ bootMs: 0, // 适配层 boot() 累计耗时(毫秒)—— 「适配层自身耗时」
999
+ hostishCalls: 0, // HOSTISH() 调用次数
1000
+ hostishSerializations: 0, // 其中真的把 documentElement.outerHTML 全量序列化了几次(最贵的那步)
1001
+ hostishMs: 0, // 序列化累计耗时(毫秒)
1002
+ imgScanCalls: 0, // 图片懒加载扫描次数
1003
+ imgScanPaused: 0, // 因为「用户正在打字/粘贴」而主动跳过的次数
1004
+ imgScanned: 0, // 扫到的(新)图片数
1005
+ imgLazy: 0, // 真正打了 loading=lazy 的图片数(≈ 首屏少发的请求数)
1006
+ imgKept: 0, // 在首屏附近/已加载完 → 一个字节都不动的图片数
1007
+ imgHook: 0, // 由「插入前」钩子提前打上属性的图片数(真正能省请求的那一层)
1008
+ imgUnlazy: 0, // 安全阀:打了 lazy 却渲染不出来 → 改回 eager 的图片数
1009
+ typingPauses: 0, // 「打字/粘贴期间暂停后台扫描」触发次数
1010
+ fcp: -1, ttfb: -1, dcl: -1, loadEnd: -1, firstInput: -1, // 导航/绘制里程碑(-1 = 拿不到)
1011
+ uiReadyMs: -1, // 首屏真正可用的时刻(= mirror_load 的 ms)
1012
+ longTasks: 0, longTaskMs: 0, // 页面级长任务(含官方代码,仅供对照,不是适配层独占)
1013
+ tipShown: false, // 首启「字号太小」提示是否展示过
1014
+ vvRepositions: 0 // 键盘弹出/收起(visualViewport 变化)触发的按钮重定位次数
1015
+ };
1016
+ try {
1017
+ if (typeof performance !== "undefined" && performance && typeof performance.now === "function") {
1018
+ var maPerfStart = performance.now();
1019
+ if (typeof maPerfStart === "number" && isFinite(maPerfStart) && maPerfStart >= 0) maPerf.startAt = maPerfStart;
1020
+ }
1021
+ } catch (e0) { /* 没有 performance(老浏览器)→ startAt 保持 -1 */ }
1022
+
1023
+ /** 采样导航/绘制里程碑(幂等:只补空位,不覆盖已经采到的值)。 */
1024
+ function maPerfSampleNav() {
1025
+ try {
1026
+ if (typeof performance === "undefined" || !performance) return;
1027
+ var nav = null, paints = null;
1028
+ try {
1029
+ if (typeof performance.getEntriesByType === "function") {
1030
+ var nv = performance.getEntriesByType("navigation");
1031
+ nav = (nv && nv.length) ? nv[0] : null;
1032
+ paints = performance.getEntriesByType("paint");
1033
+ }
1034
+ } catch (e1) { nav = null; paints = null; }
1035
+ if (nav) {
1036
+ try { if (maPerf.ttfb < 0 && nav.responseStart > 0) maPerf.ttfb = Math.round(nav.responseStart); } catch (e2) { /* 忽略 */ }
1037
+ try { if (maPerf.dcl < 0 && nav.domContentLoadedEventEnd > 0) maPerf.dcl = Math.round(nav.domContentLoadedEventEnd); } catch (e3) { /* 忽略 */ }
1038
+ try { if (maPerf.loadEnd < 0 && nav.loadEventEnd > 0) maPerf.loadEnd = Math.round(nav.loadEventEnd); } catch (e4) { /* 忽略 */ }
1039
+ }
1040
+ if (paints && paints.length) {
1041
+ for (var i = 0; i < paints.length; i++) {
1042
+ var pt = paints[i];
1043
+ if (pt && pt.name === "first-contentful-paint" && pt.startTime > 0) maPerf.fcp = Math.round(pt.startTime);
1044
+ }
1045
+ }
1046
+ } catch (e) { /* 采样失败 = 留 -1,绝不影响页面 */ }
1047
+ }
1048
+
1049
+ /** 对外只读快照(__dshMaDiag.perf)。返回的是**拷贝**:外面改不改都不影响内部计数。 */
1050
+ function maPerfSnapshot() {
1051
+ try {
1052
+ return {
1053
+ startAt: Math.round(maPerf.startAt),
1054
+ bootCalls: maPerf.bootCalls, bootMs: Math.round(maPerf.bootMs),
1055
+ hostishCalls: maPerf.hostishCalls,
1056
+ hostishSerializations: maPerf.hostishSerializations,
1057
+ hostishMs: Math.round(maPerf.hostishMs),
1058
+ imgScanCalls: maPerf.imgScanCalls, imgScanPaused: maPerf.imgScanPaused,
1059
+ imgScanned: maPerf.imgScanned, imgLazy: maPerf.imgLazy, imgKept: maPerf.imgKept,
1060
+ imgHook: maPerf.imgHook, imgUnlazy: maPerf.imgUnlazy,
1061
+ typingPauses: maPerf.typingPauses,
1062
+ fcp: maPerf.fcp, ttfb: maPerf.ttfb, dcl: maPerf.dcl, loadEnd: maPerf.loadEnd,
1063
+ firstInput: maPerf.firstInput, uiReadyMs: maPerf.uiReadyMs,
1064
+ longTasks: maPerf.longTasks, longTaskMs: maPerf.longTaskMs,
1065
+ tipShown: maPerf.tipShown, vvRepositions: maPerf.vvRepositions
1066
+ };
1067
+ } catch (e) { return null; }
1068
+ }
1069
+
1070
+ /* 采样时机:DOMContentLoaded / load / 1.5s / 4s 各补一次空位(幂等,最多补 4 次,不加定时器)。
1071
+ 解析期**不创建任何 DOM**(护栏要求),这里只有监听器与几个定时器。 */
1072
+ try {
1073
+ var maPerfKick = function () { try { maPerfSampleNav(); maPublishDiag(); } catch (e0) { /* 忽略 */ } };
1074
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", maPerfKick);
1075
+ else setTimeout(maPerfKick, 0);
1076
+ window.addEventListener("load", maPerfKick);
1077
+ setTimeout(maPerfKick, 1500);
1078
+ /* ⚠️ 刻意不用 2000/4000 这类"业务上已被占用"的延迟:测试按延迟数定时器
1079
+ (load-watch 用例断言 2000=就地重试、4000=整页重试的个数),撞上就会假红。 */
1080
+ setTimeout(maPerfKick, 3100);
1081
+ } catch (e1) { /* 忽略 */ }
1082
+ /* 首响与长任务:只有支持的浏览器才有(Safari 老版本没有 longtask / first-input)→ 静默跳过。
1083
+ 注意 longtask 是**页面级**的(含官方代码),只作对照,不能当成适配层开销。 */
1084
+ try {
1085
+ if (typeof PerformanceObserver === "function") {
1086
+ var maPerfObs = function (kind) {
1087
+ try {
1088
+ var po = new PerformanceObserver(function (list) {
1089
+ try {
1090
+ var es = (list && list.getEntries) ? list.getEntries() : [];
1091
+ for (var i = 0; i < es.length; i++) {
1092
+ var en = es[i];
1093
+ if (!en) continue;
1094
+ if (kind === "first-input") {
1095
+ if (maPerf.firstInput < 0 && en.startTime > 0) maPerf.firstInput = Math.round(en.startTime);
1096
+ } else if (kind === "longtask") {
1097
+ maPerf.longTasks += 1;
1098
+ if (en.duration > 0) maPerf.longTaskMs += Math.round(en.duration);
1099
+ }
1100
+ }
1101
+ } catch (e2) { /* 忽略 */ }
1102
+ });
1103
+ po.observe({ type: kind, buffered: true });
1104
+ } catch (e3) { /* 该类型不支持 → 跳过 */ }
1105
+ };
1106
+ maPerfObs("first-input");
1107
+ maPerfObs("longtask");
1108
+ }
1109
+ } catch (e4) { /* 老 Safari:静默跳过 */ }
1110
+
898
1111
  /* ===== 旧浏览器兼容提示条(2026-10-18)=====
899
1112
  只有两种触发:① 注入体最前面的 maCompat 探测到"补不了的"(语法级不支持);
900
1113
  ② 页面脚本报出 SyntaxError / is not a function 这类典型兼容错误且界面还没就绪。
@@ -1049,6 +1262,8 @@ const SCRIPT = `(() => {
1049
1262
  scrim: maScrimStats,
1050
1263
  retry: maRetryStats,
1051
1264
  load: { slow: maLoadWatch.slow, apiFails: maLoadWatch.apiFails, lastApiStatus: maLoadWatch.lastApiStatus },
1265
+ // 首屏性能自测(2026-10-2x):只读、只在本对象里(**不上报**,见上方 maPerf 的说明)。
1266
+ perf: (typeof maPerfSnapshot === "function") ? maPerfSnapshot() : null,
1052
1267
  // E2EE 死会话自动恢复 / 回前台重连(2026-10-23):只作诊断,不参与任何判定。
1053
1268
  // var 提升在此处可能尚未赋值(maPublishDiag 在恢复层定义之前就会被调用一次)→ 用 typeof 兜住。
1054
1269
  resume: (typeof maResumeState !== "undefined" && maResumeState)
@@ -1499,6 +1714,10 @@ const SCRIPT = `(() => {
1499
1714
  if (!isFinite(ms) || ms < 0) ms = 0;
1500
1715
  if (ms > 600000) ms = 600000;
1501
1716
  maLoadWatch.reportMs = ms;
1717
+ /* 首屏真正可用的时刻也进本地诊断(与上报给服务端的 mirror_load.ms 是同一个数)。
1718
+ 上报体**一个字都不改**:服务端契约只看 { ms, outcome, at }。 */
1719
+ try { maPerf.uiReadyMs = ms; } catch (ePerfUi) { /* 忽略 */ }
1720
+ try { maPublishDiag(); } catch (ePerfPub) { /* 忽略 */ }
1502
1721
  var body = JSON.stringify({ ms: ms, outcome: outcome === "slow" ? "slow" : "ok", at: Date.now() });
1503
1722
  var p = null;
1504
1723
  try {
@@ -2157,9 +2376,408 @@ const SCRIPT = `(() => {
2157
2376
  try {
2158
2377
  const HOST_RE = new RegExp(${JSON.stringify(RUNTIME_HOST_RE.source)});
2159
2378
  const NARROW = () => window.innerWidth <= 820;
2160
- const HOSTISH = () =>
2161
- HOST_RE.test(document.documentElement.outerHTML.slice(0, 200000)) ||
2162
- !!document.querySelector("[data-shell-overlay], [data-sidebar-collapsed], [data-details-collapsed], [data-rightbar-collapsed]");
2379
+ /* ===== HOSTISH 记忆化(2026-10-2x 首屏优化)=====
2380
+ 旧实现「outerHTML.slice(0, 200000)」写在**或**的左边:每调一次 boot 都先把整页序列化一遍
2381
+ (几十到 200KB 的主线程重活),然后才轮到那个便宜的 querySelector。而 boot 由 rAF 节流的
2382
+ 看门狗驱动 —— 官方流式输出时每帧都有 DOM 变更,首屏阶段(frame 还没挂上、fab 还没建)
2383
+ 这一次次全量序列化纯属白烧。
2384
+ 现在:① 先查便宜的结构信号(官方 frame/开合属性一挂上就命中);② 命中一次就**记住**
2385
+ (官方页面不会"变回不是官方",这个判据全程单调);③ 记录序列化次数与耗时到
2386
+ __dshMaDiag.perf,用来证明"少烧了多少"。语义与旧实现完全等价,只是不再反复做同一件事。 */
2387
+ let maHostishOk = false;
2388
+ const HOSTISH = () => {
2389
+ try {
2390
+ if (maHostishOk) return true;
2391
+ maPerf.hostishCalls += 1;
2392
+ if (document.querySelector("[data-shell-overlay], [data-sidebar-collapsed], [data-details-collapsed], [data-rightbar-collapsed]")) {
2393
+ maHostishOk = true;
2394
+ return true;
2395
+ }
2396
+ const t0 = maNowMs();
2397
+ const html = document.documentElement.outerHTML.slice(0, 200000);
2398
+ const hit = HOST_RE.test(html);
2399
+ maPerf.hostishSerializations += 1;
2400
+ try { maPerf.hostishMs += Math.max(0, maNowMs() - t0); } catch (eMs) { /* 忽略 */ }
2401
+ if (hit) maHostishOk = true;
2402
+ return hit;
2403
+ } catch (e) { return false; }
2404
+ };
2405
+
2406
+ /* ================= 首屏优化:图片按需加载 + 打字期间暂停自己的扫描(2026-10-2x)=================
2407
+ 现场(生产遥测 n=8903):手机镜像页首屏 p50 3.9s / p90 21.6s、20.2% ≥12s。TTFB 与官方
2408
+ 13MB 级 bundle 不在适配层手里,但下面两件事是**我们自己造的**、也由我们自己收回来:
2409
+
2410
+ ① 图片抢带宽:聊天/工具输出里一屏几十张图,官方 <img> 没有 loading 属性 → 插入 DOM 那一刻
2411
+ 浏览器就并发拉全部图片,和首屏关键资源(/assets、/plugins 的 JS/CSS)抢同一条中继带宽。
2412
+ 这里只**打属性**(不改 DOM 结构、不换 src、不碰官方组件):
2413
+ · **插入前**处理(关键,实测过):headless Chrome 实测同一份页面 ——
2414
+ 插入前带上 loading="lazy" → 请求数 24 → **11**(剩下的 11 张在视口/lazy 预加载距离内,
2415
+ 本来就应该拉);插入后(哪怕只晚一个微任务)再补属性 → 请求数仍是 **24**,
2416
+ 即"属性有了、请求照发"。所以真正省带宽的那一层是给 appendChild/insertBefore
2417
+ 包的一层**极窄的钩子**(只碰 IMG、只在 ≤820px、可 ?dsh_ma_img_lazy=0 关掉、可还原);
2418
+ 只观察 childList 的 MutationObserver 作为第二层保底(对晚一步的场景/其它引擎有效)。
2419
+ · 打 lazy 不等于"永远不加载":原生懒加载对**已在视口内**的图片本来就是立即加载,
2420
+ 所以首屏该出现的图照常出现,只是不再为"屏幕外那几十张"先占满带宽。
2421
+ · 已经是 complete / 官方自己给过 loading 的图片:一个字节都不动。
2422
+ · **不做几何测量**:插入路径里读 getBoundingClientRect 会强制同步布局,官方流式输出时
2423
+ 每帧一次就是新的卡顿源。可见性判断全部交给浏览器原生的 lazy 实现。
2424
+ · 另有看门狗里的低频补扫(≤1 次/800ms,仅针对"观察器安装之前就存在"的图片,
2425
+ 那批才需要几何判断,且一次扫描最多 120 张),以及打字/粘贴期间自动暂停。
2426
+
2427
+ ② 打字时和自己抢主线程:看门狗在官方流式输出时持续跑,其中"设置面板识别"要全文档
2428
+ querySelectorAll、"图片懒加载"要扫全部 <img>。用户敲字/粘贴长文本的那一刻主线程
2429
+ 本该全留给输入法 → 只要正在输入(input / compositionupdate)或在粘贴(字符越多停越久),
2430
+ 就暂停这些**非关键扫描**一小段时间。纯降级:暂停的是"找机会做的优化",不是任何交互。
2431
+
2432
+ 纪律:两者都是纯优化,任何异常都静默放弃;老浏览器缺 API 就跳过,绝不影响页面。 */
2433
+ var maTyping = { until: 0, count: 0 };
2434
+ function maNoteTyping(ms) {
2435
+ try {
2436
+ var d = Number(ms);
2437
+ if (!isFinite(d) || d <= 0) d = 1200;
2438
+ if (d > 8000) d = 8000;
2439
+ var t = Date.now() + d;
2440
+ if (t > maTyping.until) maTyping.until = t;
2441
+ maTyping.count += 1;
2442
+ maPerf.typingPauses += 1;
2443
+ } catch (e) { /* 忽略 */ }
2444
+ }
2445
+ function maTypingBusy() {
2446
+ try { return maTyping.until > Date.now(); } catch (e) { return false; }
2447
+ }
2448
+ try {
2449
+ var maTypingEvt = function () { try { maNoteTyping(1200); } catch (e) { /* 忽略 */ } };
2450
+ document.addEventListener("input", maTypingEvt, true);
2451
+ document.addEventListener("compositionupdate", maTypingEvt, true);
2452
+ /* 粘贴:只读字符数(不碰内容),字符越多暂停越久(长文本一次性灌进 Lexical 时主线程最吃紧)。 */
2453
+ document.addEventListener("paste", function (ev) {
2454
+ try {
2455
+ var cd = ev && ev.clipboardData;
2456
+ var txt = (cd && cd.getData) ? String(cd.getData("text") || "") : "";
2457
+ if (txt.length >= 4000) maNoteTyping(Math.min(8000, 800 + Math.round(txt.length / 4)));
2458
+ else maNoteTyping(1200);
2459
+ } catch (e) { try { maNoteTyping(1200); } catch (e2) { /* 忽略 */ } }
2460
+ }, true);
2461
+ } catch (e) { /* 老浏览器:不暂停也不会更糟 */ }
2462
+
2463
+ /* 输入框可用性小修补(只打属性,不动结构):
2464
+ · enterkeyhint="enter" —— 手机键盘的回车键标注「换行」,与适配层「手机 Enter 只换行、
2465
+ 发送交给发送按钮」的策略一致(否则键盘上写着"发送"、按下去却只换行,像坏了);
2466
+ · spellcheck="false" —— 中文会话里 markdown/代码片段被拼写检查画红线纯属噪音。
2467
+ 官方自己给过该属性就不动(只在缺失时补)。 */
2468
+ var MA_COMPOSER_TAG_SEL = '[data-composer-input],[role="textbox"][aria-multiline="true"],[data-lexical-editor="true"]';
2469
+ var maComposerTagged = null;
2470
+ function maTagComposer() {
2471
+ try {
2472
+ if (!document || typeof document.querySelector !== "function") return;
2473
+ var el = null;
2474
+ try { el = document.querySelector(MA_COMPOSER_TAG_SEL); } catch (e1) { el = null; }
2475
+ if (!el || !el.setAttribute) return;
2476
+ if (maComposerTagged === el) return;
2477
+ maComposerTagged = el;
2478
+ if (!el.hasAttribute || !el.hasAttribute("enterkeyhint")) el.setAttribute("enterkeyhint", "enter");
2479
+ if (!el.hasAttribute || !el.hasAttribute("spellcheck")) el.setAttribute("spellcheck", "false");
2480
+ } catch (e) { /* 忽略 */ }
2481
+ }
2482
+
2483
+ /* ---- 图片懒加载 ---- */
2484
+ var maImgLazyLastAt = 0;
2485
+ var maImgLazyIO = null;
2486
+ var maImgLazyIOInited = false;
2487
+ var maImgSeenSet = null;
2488
+ try { if (typeof WeakSet === "function") maImgSeenSet = new WeakSet(); } catch (eSeen) { maImgSeenSet = null; }
2489
+ var maImgSeenFallback = [];
2490
+ function maImgSeen(img) {
2491
+ try {
2492
+ if (!img) return true;
2493
+ if (maImgSeenSet) return maImgSeenSet.has(img);
2494
+ for (var i = 0; i < maImgSeenFallback.length; i++) { if (maImgSeenFallback[i] === img) return true; }
2495
+ return false;
2496
+ } catch (e) { return true; }
2497
+ }
2498
+ function maImgMarkSeen(img) {
2499
+ try {
2500
+ if (!img) return;
2501
+ if (maImgSeenSet) { maImgSeenSet.add(img); return; }
2502
+ if (maImgSeenFallback.length > 400) maImgSeenFallback.length = 0;
2503
+ maImgSeenFallback.push(img);
2504
+ } catch (e) { /* 忽略 */ }
2505
+ }
2506
+ /** 打属性(纯写,不做 seen / 几何判定):两个入口(插入即处理、低频补扫)共用同一套规则。 */
2507
+ function maImgApplyAttrs(img) {
2508
+ try {
2509
+ if (!img || !img.getAttribute || !img.setAttribute) return false;
2510
+ if (img.getAttribute("loading")) return false; // 官方/别的层已经给了 → 不动
2511
+ var done = false;
2512
+ try { done = !!img.complete; } catch (e1) { done = false; }
2513
+ if (done) { maPerf.imgKept += 1; return false; } // 已加载完/在加载 → 不动
2514
+ img.setAttribute("loading", "lazy");
2515
+ img.setAttribute("decoding", "async");
2516
+ maPerf.imgLazy += 1;
2517
+ return true;
2518
+ } catch (e2) { return false; }
2519
+ }
2520
+ /** 插入即处理(微任务里调用):给**新出现的**图片打按需加载属性。
2521
+ * 不读几何、不判可见性 —— 原生 lazy 自己会"视口内的立即加载、视口外的临近再加载",
2522
+ * 我们只负责把"属性"尽早打上(晚一拍就等于没打:浏览器已经开始拉了)。 */
2523
+ function maImgConsider(img) {
2524
+ try {
2525
+ if (!img) return false;
2526
+ if (String(img.tagName || "").toUpperCase() !== "IMG") return false;
2527
+ if (maImgSeen(img)) return false;
2528
+ maImgMarkSeen(img);
2529
+ maPerf.imgScanned += 1;
2530
+ return maImgApplyAttrs(img);
2531
+ } catch (e) { return false; }
2532
+ }
2533
+ /* 只观察 childList(**不观察 attributes**):我们自己改 loading 属性不会触发它,
2534
+ 不会变成自激循环;回调在每个 DOM 变更批次的微任务里跑,早于图片请求真正发出。 */
2535
+ var maImgObserverInstalled = false;
2536
+ var maImgMoRef = null;
2537
+ function maInstallImgObserver() {
2538
+ if (maImgObserverInstalled) return;
2539
+ maImgObserverInstalled = true;
2540
+ try {
2541
+ if (typeof MutationObserver !== "function") return;
2542
+ if (!document || !document.documentElement) return;
2543
+ var mo = new MutationObserver(function (records) {
2544
+ var tagged = false;
2545
+ try {
2546
+ for (var i = 0; i < records.length; i++) {
2547
+ var added = records[i] && records[i].addedNodes ? records[i].addedNodes : [];
2548
+ for (var j = 0; j < added.length; j++) {
2549
+ var node = added[j];
2550
+ if (!node) continue;
2551
+ if (maImgConsider(node)) tagged = true;
2552
+ var inner = null;
2553
+ try { inner = node.querySelectorAll ? node.querySelectorAll("img") : null; } catch (eQ) { inner = null; }
2554
+ if (inner && inner.length) {
2555
+ for (var k = 0; k < inner.length; k++) { if (maImgConsider(inner[k])) tagged = true; }
2556
+ }
2557
+ }
2558
+ }
2559
+ } catch (e1) { /* 单条记录失败不影响其余 */ }
2560
+ if (tagged) { try { maPublishDiag(); } catch (e2) { /* 忽略 */ } }
2561
+ });
2562
+ mo.observe(document.documentElement, { childList: true, subtree: true });
2563
+ maImgMoRef = mo;
2564
+ } catch (e3) {
2565
+ maImgMoRef = null;
2566
+ maImgObserverInstalled = false; // 退化:看门狗里的低频补扫仍然有效
2567
+ }
2568
+ }
2569
+
2570
+ /** 给一张「确定不在首屏附近」的图片打上按需加载属性(其余情况一律不动)。 */
2571
+ /** 补扫路径的打属性入口(此时已在扫描循环里 markSeen 过,不能再走 maImgConsider 的 seen 检查)。 */
2572
+ function maImgApplyLazy(img) { return maImgApplyAttrs(img); }
2573
+ function maInitImgLazyObserver() {
2574
+ if (maImgLazyIOInited) return;
2575
+ maImgLazyIOInited = true;
2576
+ try {
2577
+ if (typeof IntersectionObserver !== "function") return;
2578
+ maImgLazyIO = new IntersectionObserver(function (entries) {
2579
+ try {
2580
+ for (var i = 0; i < entries.length; i++) {
2581
+ var en = entries[i];
2582
+ if (!en || !en.target) continue;
2583
+ if (en.isIntersecting) maPerf.imgKept += 1; // 首屏附近 → 保持立即加载
2584
+ else maImgApplyLazy(en.target);
2585
+ try { maImgLazyIO.unobserve(en.target); } catch (eU) { /* 忽略 */ }
2586
+ }
2587
+ } catch (e2) { /* 忽略 */ }
2588
+ }, { rootMargin: "300px 0px", threshold: 0 });
2589
+ } catch (e3) { maImgLazyIO = null; }
2590
+ }
2591
+ /** 扫描新出现的 <img> 并交给观察器(节流;打字/粘贴期间暂停;一次最多 120 张)。 */
2592
+ /* 安全阀:打上了 lazy 却**压根渲染不出来**(藏在 display:none / 未挂载容器里)→ 改回 eager。
2593
+ 这条是给「官方在隐藏容器里等 onload 再显示」这类写法兜底的:宁可多拉一张图,
2594
+ 也绝不能让图片永远出不来(懒加载只是优化,功能正确性优先)。
2595
+ 判据用 getClientRects(没有盒子 = 没有布局),**不用 offsetParent**
2596
+ (position:fixed 的 offsetParent 也是 null,会误判)。 */
2597
+ function maImgSafetyValve() {
2598
+ try {
2599
+ var lazyList = null;
2600
+ try { lazyList = document.querySelectorAll('img[loading="lazy"]'); } catch (e0) { lazyList = null; }
2601
+ if (!lazyList || !lazyList.length) return;
2602
+ var n = Math.min(lazyList.length, 200); // 上限:长会话里也不会在一次补扫里卡住主线程
2603
+ for (var i = 0; i < n; i++) {
2604
+ var lim = lazyList[i];
2605
+ try {
2606
+ if (!lim || !lim.getAttribute || !lim.setAttribute) continue;
2607
+ var doneL = false;
2608
+ try { doneL = !!lim.complete; } catch (e1) { doneL = false; }
2609
+ if (doneL) continue;
2610
+ var boxes = 1;
2611
+ try { boxes = lim.getClientRects ? lim.getClientRects().length : 1; } catch (e2) { boxes = 1; }
2612
+ if (boxes === 0) {
2613
+ lim.setAttribute("loading", "eager");
2614
+ maPerf.imgUnlazy += 1;
2615
+ }
2616
+ } catch (e3) { /* 单张图失败不影响其余 */ }
2617
+ }
2618
+ } catch (e) { /* 忽略 */ }
2619
+ }
2620
+ function maTagLazyImages(force) {
2621
+ var lazyBefore = -1, keptBefore = -1;
2622
+ try {
2623
+ if (!document || typeof document.querySelectorAll !== "function") return;
2624
+ var now = Date.now();
2625
+ if (!force && now - maImgLazyLastAt < 800) return;
2626
+ if (maTypingBusy()) {
2627
+ maPerf.imgScanPaused += 1;
2628
+ try { maPublishDiag(); } catch (eP) { /* 忽略 */ } // 诊断是快照 → 计数变了要刷
2629
+ return;
2630
+ }
2631
+ maImgLazyLastAt = now;
2632
+ maPerf.imgScanCalls += 1;
2633
+ try { maInstallImgObserver(); } catch (eObs) { /* 忽略 */ }
2634
+ try { maImgSafetyValve(); } catch (eValve) { /* 忽略 */ }
2635
+ var list = null;
2636
+ try { list = document.querySelectorAll("img"); } catch (e1) { return; }
2637
+ if (!list || !list.length) return;
2638
+ lazyBefore = maPerf.imgLazy; keptBefore = maPerf.imgKept;
2639
+ var fresh = [];
2640
+ for (var i = 0; i < list.length && fresh.length < 120; i++) {
2641
+ var img = list[i];
2642
+ if (!img || maImgSeen(img)) continue;
2643
+ maImgMarkSeen(img);
2644
+ fresh.push(img);
2645
+ }
2646
+ maPerf.imgScanned += fresh.length;
2647
+ if (!fresh.length) return;
2648
+ maInitImgLazyObserver();
2649
+ var j;
2650
+ if (maImgLazyIO) {
2651
+ for (j = 0; j < fresh.length; j++) { try { maImgLazyIO.observe(fresh[j]); } catch (e4) { /* 忽略 */ } }
2652
+ return;
2653
+ }
2654
+ /* 没有 IntersectionObserver(老 Safari):先**批量读**几何、再**批量写**属性。
2655
+ 顺序很重要 —— 读一次写一次会每张图都触发一次强制布局(长会话里就是肉眼可见的卡)。 */
2656
+ var vh = 640;
2657
+ try { vh = window.innerHeight || 640; } catch (e5) { vh = 640; }
2658
+ var rects = [];
2659
+ for (j = 0; j < fresh.length; j++) {
2660
+ var rc = null;
2661
+ try { rc = fresh[j].getBoundingClientRect ? fresh[j].getBoundingClientRect() : null; } catch (e6) { rc = null; }
2662
+ rects.push(rc);
2663
+ }
2664
+ for (j = 0; j < fresh.length; j++) {
2665
+ var r = rects[j];
2666
+ var nearViewport = true;
2667
+ if (r && (r.width || r.height)) nearViewport = (r.bottom > -300 && r.top < vh + 300);
2668
+ if (nearViewport) { maPerf.imgKept += 1; continue; }
2669
+ maImgApplyLazy(fresh[j]);
2670
+ }
2671
+ } catch (e) { /* 纯优化:任何异常静默放弃,绝不把页面拖挂 */ }
2672
+ try {
2673
+ /* perf 是**快照**(maPublishDiag 每次都新建对象)→ 计数变了要刷一次,否则诊断里看不出效果。 */
2674
+ if (maPerf.imgLazy !== lazyBefore || maPerf.imgKept !== keptBefore) maPublishDiag();
2675
+ else if (maPerf.imgUnlazy) maPublishDiag();
2676
+ } catch (ePub) { /* 忽略 */ }
2677
+ }
2678
+ /* ===== 「插入前」那一层:给 Node.prototype.appendChild / insertBefore 包一层极窄的钩子 =====
2679
+ 为什么必须抢在插入前(2026-10-2x 实测,headless Chrome):
2680
+ · 插入**前**带 loading="lazy" → 24 张图只发 **11** 个请求;
2681
+ · 插入**后**(哪怕只晚一个微任务)再补属性 → **24 个请求一个不少**(属性有了、请求照发)。
2682
+ 也就是说:只靠"插入后观察器"是拿不到带宽收益的,这一层才是真正省首屏带宽的那一刀。
2683
+ 安全边界(每一条都重要 —— 这是唯一一处会碰官方页面全局对象的地方):
2684
+ · **只在 ≤820px** 安装;桌面镜像页一行都不碰;
2685
+ · 只处理 nodeName === "IMG"(DocumentFragment 只看一级子节点),其它节点原样透传;
2686
+ · 已经有 loading 属性 / 已经加载完的图 → 一个字节都不动;
2687
+ · 打属性失败也照常插入(try/catch),返回值原样透传;
2688
+ · 幂等;pagehide/bfcache 时**还原**原生方法(不留悬挂改写);
2689
+ · 逃生口:URL 带 ?dsh_ma_img_lazy=0 → 完全不装(线上出问题不用等发版)。
2690
+ 已知风险与对策:隐藏容器里"等 onload 再显示"的写法可能被懒加载卡住 ——
2691
+ 看门狗补扫里有一条安全阀:打了 lazy 却压根渲染不出来(getClientRects 为空)→ 改回 eager。 */
2692
+ var maImgHook = { on: false, restored: false, nativeAppend: null, nativeInsert: null };
2693
+ function maImgHookDisabled() {
2694
+ try {
2695
+ if (typeof window === "undefined" || !window.location) return false;
2696
+ return /(^|[?&])dsh_ma_img_lazy=0(&|$)/.test(String(window.location.search || ""));
2697
+ } catch (e) { return false; }
2698
+ }
2699
+ /** 钩子里的计数**合并刷新**诊断(一次渲染批量插很多图时,不要每张都建一次诊断对象)。 */
2700
+ var maImgDiagPending = false;
2701
+ function maImgHookPublishSoon() {
2702
+ if (maImgDiagPending) return;
2703
+ maImgDiagPending = true;
2704
+ try {
2705
+ setTimeout(function () {
2706
+ maImgDiagPending = false;
2707
+ try { maPublishDiag(); } catch (e) { /* 忽略 */ }
2708
+ }, 0);
2709
+ } catch (e2) { maImgDiagPending = false; }
2710
+ }
2711
+ function maImgHookTagNode(node) {
2712
+ try {
2713
+ if (!node) return;
2714
+ var name = "";
2715
+ try { name = String(node.nodeName || node.tagName || "").toUpperCase(); } catch (eN) { name = ""; }
2716
+ if (name === "IMG") {
2717
+ if (maImgConsider(node)) { maPerf.imgHook += 1; maImgHookPublishSoon(); }
2718
+ return;
2719
+ }
2720
+ /* DocumentFragment(React 列表批量插入常见):只看它的一级子节点 —— 够用,且不会递归整棵树。 */
2721
+ if (node.nodeType === 11 && node.childNodes) {
2722
+ var kids = node.childNodes;
2723
+ for (var i = 0; i < kids.length; i++) {
2724
+ var k = kids[i];
2725
+ var kN = "";
2726
+ try { kN = String((k && (k.nodeName || k.tagName)) || "").toUpperCase(); } catch (eKN) { kN = ""; }
2727
+ if (kN === "IMG" && maImgConsider(k)) {
2728
+ maPerf.imgHook += 1;
2729
+ maImgHookPublishSoon();
2730
+ }
2731
+ }
2732
+ }
2733
+ } catch (e) { /* 打属性失败绝不影响插入本身 */ }
2734
+ }
2735
+ function maInstallImgInsertHook() {
2736
+ if (maImgHook.on) return;
2737
+ maImgHook.restored = false;
2738
+ try {
2739
+ if (maImgHookDisabled()) return;
2740
+ if (typeof NARROW === "function" && !NARROW()) return; // 手机专属优化
2741
+ if (typeof Node !== "function" || !Node.prototype) return;
2742
+ var proto = Node.prototype;
2743
+ var nAppend = proto.appendChild, nInsert = proto.insertBefore;
2744
+ if (typeof nAppend !== "function") return;
2745
+ if (nAppend.__dshMaImgHook) return; // 幂等
2746
+ var wrap = function (native) {
2747
+ if (typeof native !== "function") return native;
2748
+ var W = function (node) {
2749
+ try { maImgHookTagNode(node); } catch (e) { /* 忽略 */ }
2750
+ return native.apply(this, arguments);
2751
+ };
2752
+ try { W.__dshMaImgHook = true; } catch (e2) { /* 忽略 */ }
2753
+ return W;
2754
+ };
2755
+ var wAppend = wrap(nAppend);
2756
+ var wInsert = wrap(nInsert);
2757
+ proto.appendChild = wAppend;
2758
+ if (typeof nInsert === "function") proto.insertBefore = wInsert;
2759
+ maImgHook.nativeAppend = nAppend;
2760
+ maImgHook.nativeInsert = typeof nInsert === "function" ? nInsert : null;
2761
+ maImgHook.on = true;
2762
+ } catch (e3) {
2763
+ /* 补不上去就算了:插入后的观察器这一层仍然在(对支持的引擎/场景有效) */
2764
+ maImgHook.on = false;
2765
+ }
2766
+ }
2767
+ function maRestoreImgInsertHook() {
2768
+ try {
2769
+ if (!maImgHook.on || typeof Node !== "function" || !Node.prototype) { maImgHook.on = false; return; }
2770
+ if (maImgHook.nativeAppend) Node.prototype.appendChild = maImgHook.nativeAppend;
2771
+ if (maImgHook.nativeInsert) Node.prototype.insertBefore = maImgHook.nativeInsert;
2772
+ } catch (e) { /* 忽略 */ }
2773
+ maImgHook.on = false;
2774
+ maImgHook.restored = true;
2775
+ }
2776
+
2777
+ /* 解析期就装上这两层(head 里、官方 bundle 执行之前):官方 SPA 一渲染出图片就会被处理。
2778
+ 不能等第一次看门狗/600ms 补扫 —— 那时候图早就开始拉了(实测微任务都算晚)。 */
2779
+ try { maInstallImgObserver(); } catch (eImgObs0) { /* 忽略 */ }
2780
+ try { maInstallImgInsertHook(); } catch (eImgHook0) { /* 忽略 */ }
2163
2781
 
2164
2782
  /* —— 鲸鱼余额挂件(dsh-whale-widget,第三方插件的浮动组件)窄屏协同 ——
2165
2783
  若挂件记忆的锚点是「左+上」(实测会盖住左上角会话标题/左上菜单按钮),在页面解析期
@@ -2846,6 +3464,17 @@ const SCRIPT = `(() => {
2846
3464
  可拖动,位置按"最近的角 + 距边偏移"记忆 —— 旋转屏幕/换窗口宽度都不会跑到屏幕外。 ---- */
2847
3465
  const FAB_KEY = "dsh-ma-fab-pos";
2848
3466
  const FAB_SIZE = 46, FAB_EDGE = 10;
3467
+ /* 手机键盘弹出时 「window.innerHeight」 **不变**(iOS Safari 尤其),只有 visualViewport
3468
+ 才是"用户真正看得见的区域"。用它换算高度,按钮/菜单才不会被算到键盘后面去
3469
+ (用户正在打字、想顺手调字号时,按钮却摸不到 —— 这就是"可视区适配")。
3470
+ 老浏览器没有 visualViewport → 退回 innerHeight,行为与以前完全一致。 */
3471
+ const viewportH = () => {
3472
+ try {
3473
+ const vv = window.visualViewport;
3474
+ if (vv && typeof vv.height === "number" && vv.height > 8) return vv.height;
3475
+ } catch (e) { /* 忽略 */ }
3476
+ return window.innerHeight || 640;
3477
+ };
2849
3478
  const readFabPos = () => {
2850
3479
  try {
2851
3480
  const raw = window.localStorage && window.localStorage.getItem(FAB_KEY);
@@ -2858,14 +3487,14 @@ const SCRIPT = `(() => {
2858
3487
  const defaultFabPos = () => ({ h: "right", v: "top", dx: FAB_EDGE, dy: FAB_EDGE });
2859
3488
  let fabPos = readFabPos() || defaultFabPos();
2860
3489
  const fabXY = (pos) => {
2861
- const vw = window.innerWidth || 360, vh = window.innerHeight || 640;
3490
+ const vw = window.innerWidth || 360, vh = viewportH();
2862
3491
  return {
2863
3492
  left: pos.h === "left" ? pos.dx : vw - FAB_SIZE - pos.dx,
2864
3493
  top: pos.v === "top" ? pos.dy : vh - FAB_SIZE - pos.dy,
2865
3494
  };
2866
3495
  };
2867
3496
  const clampFabXY = (xy) => {
2868
- const vw = window.innerWidth || 360, vh = window.innerHeight || 640;
3497
+ const vw = window.innerWidth || 360, vh = viewportH();
2869
3498
  return {
2870
3499
  left: Math.max(4, Math.min(xy.left, vw - FAB_SIZE - 4)),
2871
3500
  top: Math.max(4, Math.min(xy.top, vh - FAB_SIZE - 4)),
@@ -2882,7 +3511,7 @@ const SCRIPT = `(() => {
2882
3511
  };
2883
3512
  /** 拖动落点 → "最近的角 + 偏移"(保留用户的意图,同时保证换尺寸后仍在屏内)。 */
2884
3513
  const fabPosFromXY = (xy) => {
2885
- const vw = window.innerWidth || 360, vh = window.innerHeight || 640;
3514
+ const vw = window.innerWidth || 360, vh = viewportH();
2886
3515
  const h = xy.left + FAB_SIZE / 2 < vw / 2 ? "left" : "right";
2887
3516
  const v = xy.top + FAB_SIZE / 2 < vh / 2 ? "top" : "bottom";
2888
3517
  return {
@@ -2915,7 +3544,12 @@ const SCRIPT = `(() => {
2915
3544
  const L = applyFsLevel(level);
2916
3545
  if (fsVal) fsVal.textContent = L.name;
2917
3546
  if (fsRange && Number(fsRange.value) !== level) fsRange.value = String(level);
2918
- if (persist) { try { window.localStorage && window.localStorage.setItem(FS_KEY, String(level)); } catch (e3) { /* 忽略 */ } }
3547
+ if (persist) {
3548
+ try { window.localStorage && window.localStorage.setItem(FS_KEY, String(level)); } catch (e3) { /* 忽略 */ }
3549
+ /* 用户已经自己动过字号了 → 「字太小?」提示的目的已经达到,立刻收起并标记(别重复打扰)。
3550
+ 注意:这两句只在 persist 分支里,且此时 fsTipMark/hideFsTip 早已定义(用户交互才会走到这里)。 */
3551
+ try { fsTipMark(); hideFsTip(); } catch (eTip) { /* 忽略 */ }
3552
+ }
2919
3553
  };
2920
3554
  setLevel(readFsLevel(), false);
2921
3555
 
@@ -3041,7 +3675,7 @@ const SCRIPT = `(() => {
3041
3675
  const placeMenu = () => {
3042
3676
  try {
3043
3677
  const r = fab.getBoundingClientRect();
3044
- const vw = window.innerWidth || 360, vh = window.innerHeight || 640;
3678
+ const vw = window.innerWidth || 360, vh = viewportH();
3045
3679
  const mr = menu.getBoundingClientRect();
3046
3680
  const mw = mr.width || Math.min(vw * 0.86, 320);
3047
3681
  const mh = mr.height || 300;
@@ -3075,6 +3709,69 @@ const SCRIPT = `(() => {
3075
3709
  });
3076
3710
  menu.addEventListener("click", (e) => e.stopPropagation());
3077
3711
 
3712
+ /* ===== 首启「字号太小?」提示(2026-10-2x)=====
3713
+ 用户原声:「字体太小,因此不用」—— 字号档位其实**早就有**(就是这颗悬浮按钮的菜单里那颗滑块),
3714
+ 但用户不知道它存在。所以这里只做「告知」,**复用现有档位能力,不重做**:
3715
+ · 一次性:localStorage 里 dsh-ma-fs-tip-v1 = 1,同一浏览器只提示一次;
3716
+ · 不阻塞:小气泡**挂在 FAB 内部**(不新增 body 级浮层、不占整屏、不拦点击);
3717
+ · 有用:点气泡 = 直接打开字号菜单(不用先学会点按钮);✕ = 立刻关掉;
3718
+ · 不打扰:只在手机上、且用户**从没调过字号**时提示;调过就不提示;
3719
+ · 绝不在首屏关键路径上掺和:延迟 1.2 秒才出现,12 秒自动消失。
3720
+ ⚠️ 注入体是模板字符串:本段注释/文案不得出现反引号,一律用「」。 */
3721
+ const FS_TIP_KEY = "dsh-ma-fs-tip-v1";
3722
+ const FS_TIP_DELAY_MS = 1200;
3723
+ const FS_TIP_TTL_MS = 12000;
3724
+ let fsTipEl = null;
3725
+ const fsTipSeen = () => {
3726
+ try { return !!(window.localStorage && window.localStorage.getItem(FS_TIP_KEY) === "1"); }
3727
+ catch (e) { return false; } // 读不到存储(隐私模式)→ 按"没看过"处理,最多多提示一次
3728
+ };
3729
+ const fsTipMark = () => {
3730
+ try { window.localStorage && window.localStorage.setItem(FS_TIP_KEY, "1"); } catch (e) { /* 忽略 */ }
3731
+ };
3732
+ const hideFsTip = () => {
3733
+ try { if (fsTipEl && fsTipEl.parentNode) fsTipEl.parentNode.removeChild(fsTipEl); } catch (e) { /* 忽略 */ }
3734
+ fsTipEl = null;
3735
+ };
3736
+ const showFsTip = () => {
3737
+ try {
3738
+ if (fsTipEl) return;
3739
+ if (!NARROW()) return; // 「字太小」是手机上的诉求;电脑端不打扰
3740
+ if (document.hidden) return; // 页面在后台 → 不提示(回来也不打扰)
3741
+ if (fsTipSeen()) return;
3742
+ if (readFsLevel() !== 0) { fsTipMark(); return; } // 用户已经自己调过字号 → 不必告知
3743
+ const tip = document.createElement("span");
3744
+ tip.className = "dsh-ma-fs-tip"
3745
+ + (fabPos.v === "top" ? " dsh-ma-fs-tip-below" : " dsh-ma-fs-tip-above")
3746
+ + (fabPos.h === "left" ? " dsh-ma-fs-tip-left" : " dsh-ma-fs-tip-right");
3747
+ tip.setAttribute("role", "note");
3748
+ tip.setAttribute("aria-label", "字号提示:点这里可以调大字号");
3749
+ tip.innerHTML = '<span class="dsh-ma-fs-tip-txt">字太小?点这里调字号</span>'
3750
+ + '<span class="dsh-ma-fs-tip-x" role="button" aria-label="关闭字号提示">✕</span>';
3751
+ const closeTip = (alsoOpenMenu) => {
3752
+ try { if (alsoOpenMenu) openMenu(); } catch (e) { /* 忽略 */ }
3753
+ fsTipMark();
3754
+ hideFsTip();
3755
+ };
3756
+ /* 气泡在 FAB 内部 → 所有指针事件必须自己截住,否则会被 FAB 的"拖动/轻点开合"逻辑吃掉。 */
3757
+ const stopEv = (e) => { try { e.stopPropagation(); } catch (e2) { /* 忽略 */ } };
3758
+ tip.addEventListener("pointerdown", stopEv);
3759
+ tip.addEventListener("pointerup", stopEv);
3760
+ tip.addEventListener("pointercancel", stopEv);
3761
+ tip.addEventListener("click", (e) => { stopEv(e); closeTip(true); });
3762
+ const xBtn = tip.querySelector(".dsh-ma-fs-tip-x");
3763
+ if (xBtn) xBtn.addEventListener("click", (e) => { stopEv(e); closeTip(false); });
3764
+ fab.appendChild(tip);
3765
+ fsTipEl = tip;
3766
+ fsTipMark(); // 显示即消费:同一浏览器只提示一次(刷新不再打扰)
3767
+ maPerf.tipShown = true;
3768
+ try { maPublishDiag(); } catch (e7) { /* 忽略 */ }
3769
+ try { setTimeout(hideFsTip, FS_TIP_TTL_MS); } catch (e8) { /* 忽略 */ }
3770
+ } catch (e) { /* 提示失败绝不影响 FAB 的任何既有功能 */ }
3771
+ };
3772
+ /* 1.2 秒后再出现:绝不在首屏关键路径上创建节点/触发布局。 */
3773
+ try { setTimeout(showFsTip, FS_TIP_DELAY_MS); } catch (eTipTimer) { /* 忽略 */ }
3774
+
3078
3775
  /* ---- 拖动:pointer 事件 + 指针捕获(手指拖出按钮范围也不断线);拖动阈值 6px 区分"点击"与"拖动" ---- */
3079
3776
  let fabDrag = null;
3080
3777
  fab.addEventListener("pointerdown", (e) => {
@@ -3136,6 +3833,31 @@ const SCRIPT = `(() => {
3136
3833
  try {
3137
3834
  window.addEventListener("resize", () => { applyFabPos(); if (!menu.hidden) placeMenu(); }, { passive: true });
3138
3835
  window.addEventListener("orientationchange", () => { applyFabPos(); if (!menu.hidden) placeMenu(); }, { passive: true });
3836
+ /* 键盘弹出/收起(2026-10-2x):iOS Safari 上 window 的 resize 不一定发,visualViewport 才是
3837
+ 可靠的信号。键盘一起来,原本贴着屏幕底部的按钮就会落到键盘后面、点不到 —— 用户正在
3838
+ 打字时想调字号/返回列表却摸不到入口,正是这类"可视区不适配"。
3839
+ 处理:一帧只重算一次(rAF 合并),彻底失败也不影响其它功能。 */
3840
+ const vv = window.visualViewport;
3841
+ if (vv && typeof vv.addEventListener === "function") {
3842
+ let vvPending = 0;
3843
+ const onVvChange = () => {
3844
+ try {
3845
+ if (vvPending) return;
3846
+ const run = () => {
3847
+ vvPending = 0;
3848
+ try {
3849
+ applyFabPos();
3850
+ maPerf.vvRepositions += 1;
3851
+ maPublishDiag();
3852
+ } catch (e1) { /* 忽略 */ }
3853
+ try { if (!menu.hidden) placeMenu(); } catch (e2) { /* 忽略 */ }
3854
+ };
3855
+ vvPending = (typeof requestAnimationFrame === "function") ? requestAnimationFrame(run) : setTimeout(run, 16);
3856
+ } catch (e0) { /* 忽略 */ }
3857
+ };
3858
+ vv.addEventListener("resize", onVvChange);
3859
+ vv.addEventListener("scroll", onVvChange);
3860
+ }
3139
3861
  } catch (e9) { /* 忽略 */ }
3140
3862
  const devEl = menu.querySelector(".dsh-ma-menu-dev");
3141
3863
  if (devEl) {
@@ -3230,7 +3952,11 @@ const SCRIPT = `(() => {
3230
3952
  };
3231
3953
 
3232
3954
  let done = false;
3233
- const boot = () => {
3955
+ /* 首屏自测(2026-10-2x):boot 是适配层唯一的重活入口,这里统一计时 ——
3956
+ 计的是**适配层自己**在当前这一拍花了多少主线程时间(含 HOSTISH/建 FAB/列识别),
3957
+ 不含任何异步工作。数字进 __dshMaDiag.perf.bootMs,用来证明"适配层不是首屏慢的原因",
3958
+ 也用来盯住以后不要再往 boot 里塞重活(首屏 p90 21.6s 的锅不能是我们加的)。 */
3959
+ const bootImpl = () => {
3234
3960
  if (!HOSTISH()) return;
3235
3961
  /* 📶 **先**把延迟采样器挂上(2026-10-10):它必须在"任何请求发生之前"就位。
3236
3962
  即便 buildRemoteMenu() 因为任何原因没能建出菜单(或抛错),采样器也照挂不误 ——
@@ -3241,7 +3967,19 @@ const SCRIPT = `(() => {
3241
3967
  try { maInstallUploadTap(); } catch (eUploadTap0) { /* 忽略 */ }
3242
3968
  /* 悬浮按钮/菜单:任何宽度都要有(电脑端访问镜像页时同样需要返回设备列表的出口)。
3243
3969
  失败也不影响手机端布局适配 —— 两条链路各自独立。 */
3244
- if (!fabDone) { fabDone = true; try { buildRemoteMenu(); } catch (eFabDone) { /* 忽略 */ } }
3970
+ if (!fabDone) {
3971
+ fabDone = true;
3972
+ try { buildRemoteMenu(); } catch (eFabDone) { /* 忽略 */ }
3973
+ /* 首屏优化(2026-10-2x):**首屏之后**(600ms 后)再扫一遍图片 + 给输入框打可用性属性。
3974
+ 为什么不在这里同步做:boot 发生在官方 SPA 还在渲染的时候,此刻扫全文档/读几何只会
3975
+ 和首屏抢主线程。600ms 足够让首屏稳定下来,而"懒加载"本来就是为了之后的滚动。 */
3976
+ try {
3977
+ setTimeout(function () {
3978
+ try { maTagLazyImages(true); } catch (eLazyBoot) { /* 忽略 */ }
3979
+ try { maTagComposer(); } catch (eCompBoot) { /* 忽略 */ }
3980
+ }, 600);
3981
+ } catch (eBootTimer) { /* 忽略 */ }
3982
+ }
3245
3983
  if (done || !NARROW()) return;
3246
3984
  const frame = document.querySelector(".pI_x6G_frame, [data-sidebar-collapsed], [data-details-collapsed], [data-rightbar-collapsed]");
3247
3985
  if (!frame || !frame.querySelector) return;
@@ -3703,6 +4441,18 @@ const SCRIPT = `(() => {
3703
4441
  whaleHookInit();
3704
4442
  watchSettings();
3705
4443
  };
4444
+ const boot = () => {
4445
+ const t0 = maNowMs();
4446
+ try {
4447
+ bootImpl();
4448
+ } finally {
4449
+ try {
4450
+ maPerf.bootCalls += 1;
4451
+ maPerf.bootMs += Math.max(0, maNowMs() - t0);
4452
+ maPublishDiag();
4453
+ } catch (ePerfBoot) { /* 诊断失败绝不影响 boot */ }
4454
+ }
4455
+ };
3706
4456
 
3707
4457
  if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot); else boot();
3708
4458
 
@@ -3750,6 +4500,12 @@ const SCRIPT = `(() => {
3750
4500
  /* 设置面板结构识别(自带节流,幂等):官方每次重新打包都换混淆前缀,
3751
4501
  这一步保证"改名后仍然基本可用",而不是整段静默失效(2026-10-13 根因)。 */
3752
4502
  try { maSettingsMaybeTag(); } catch (eSetTag) { /* 忽略 */ }
4503
+ /* 首屏优化(2026-10-2x):图片按需加载 + 输入框属性小修补。
4504
+ 两者都自带节流/幂等(懒加载 ≤1 次/800ms、输入框只在元素换了才打一次),
4505
+ 并且打字/粘贴期间会自动暂停 —— 所以每拍调用是安全的、也是有意义的:
4506
+ 聊天里新出现的图片就是靠这一拍被"上锁"的。 */
4507
+ try { maTagLazyImages(); } catch (eImgLazy) { /* 忽略 */ }
4508
+ try { maTagComposer(); } catch (eCompTag) { /* 忽略 */ }
3753
4509
  };
3754
4510
  /* 节流:一次 DOM 变更往往产生几十条记录,逐条 boot() 就是忙轮询。
3755
4511
  统一合并成"本帧最多跑一次"(有 rAF 用 rAF,没有就退化成 0ms 宏任务) —— 天然去重。 */
@@ -3929,6 +4685,8 @@ const SCRIPT = `(() => {
3929
4685
  /* 挂上"叫醒"的两条路。做成可重入(先清旧的再建新的),这样 bfcache 恢复后能重新武装。 */
3930
4686
  const maArm = () => {
3931
4687
  maTorn = false;
4688
+ try { maInstallImgObserver(); } catch (eImgArm) { /* 忽略 */ }
4689
+ try { maInstallImgInsertHook(); } catch (eImgArm2) { /* 忽略 */ }
3932
4690
  maRafPending = false; // 冻结时若有一帧没跑到,不清掉就会永远挡住后续调度
3933
4691
  try { if (maObserver) maObserver.disconnect(); } catch (eA1) { /* 忽略 */ }
3934
4692
  maObserver = null;
@@ -3952,6 +4710,12 @@ const SCRIPT = `(() => {
3952
4710
  maTorn = true;
3953
4711
  try { if (maObserver) maObserver.disconnect(); } catch (eD1) { /* 忽略 */ }
3954
4712
  maObserver = null;
4713
+ /* 图片"插入即打属性"的观察器也一起断开:bfcache/长驻页面里不许留悬挂回调。 */
4714
+ try { if (maImgMoRef) maImgMoRef.disconnect(); } catch (eDImg) { /* 忽略 */ }
4715
+ maImgMoRef = null;
4716
+ maImgObserverInstalled = false;
4717
+ /* 「插入前」钩子也必须还原:绝不把改写过的 Node.prototype 留在 bfcache 里。 */
4718
+ try { maRestoreImgInsertHook(); } catch (eDHook) { /* 忽略 */ }
3955
4719
  try { if (maInterval) clearInterval(maInterval); } catch (eD2) { /* 忽略 */ }
3956
4720
  maInterval = 0;
3957
4721
  // 加载提示条的秒数刷新也一起停掉(页面卸载/进 bfcache 后不留悬挂回调)