@mrrisega/dsh-remote 0.6.9-beta.1 → 0.6.9-beta.2
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/clients/dsh-remote/mobile-adapter.mjs +274 -21
- package/clients/dsh-remote/test/mobile-adapter-guards.test.mjs +682 -0
- package/package.json +1 -1
- package/packages/dsh-remote-web/lib/client.js +898 -240
- package/packages/dsh-remote-web/lib/index.js +1 -1
- package/packages/dsh-remote-web/package.json +1 -1
|
@@ -57,6 +57,29 @@
|
|
|
57
57
|
* ② 折叠态同时认两个属性名,认不到就只信几何;③ 抽屉必须是"不是全屏的、真的在视口里的"元素,
|
|
58
58
|
* 并加常驻看门狗:遮罩拦截时若没有任何抽屉在视口内,立刻摘掉(任何未来改名都不会再变成死局)。
|
|
59
59
|
*
|
|
60
|
+
* ⚠️ 2026-09-19 手机端实测第二波修复(用户反馈,真机 390×844 + headless 复现):
|
|
61
|
+
* A) 微信语音转文字会**提前发送半截话**:官方输入区是 Lexical contenteditable
|
|
62
|
+
* ([data-composer-input]),它的 Enter 键位图自带的 IME 守卫只看
|
|
63
|
+
* `isComposing || keyCode===229 || (官方 compositionend 后 **10ms** 窗口)` ——
|
|
64
|
+
* 而微信语音转文字没有标准的 compositionend 时序:实测 compositionend 之后 43ms 到达的
|
|
65
|
+
* Enter 已经 isComposing=false 且超出 10ms 窗口 → 官方按"用户要发送"处理。
|
|
66
|
+
* 现在适配层在 **捕获阶段** 加了一层输入框专用守卫:① 任何宽度下,识别为"输入法确认"的
|
|
67
|
+
* Enter 一律 stopPropagation(官方收不到 → 不会发送;不 preventDefault,IME 自己的提交不受影响);
|
|
68
|
+
* ② ≤820px 时输入框里的 Enter **只换行不发送**(微信/Telegram 等手机 IM 的语义),
|
|
69
|
+
* 发送只由官方发送按钮负责 —— 对"没有标准时序"的输入法一并根治。
|
|
70
|
+
* 实测:守卫只 stopPropagation 时换行仍能插入、继续打字正常,点官方「发送」按钮仍能正常发出。
|
|
71
|
+
* B) 打开抽屉后点任何东西都没反应 + 右边一个白色框:根因是 `isDrawer()` 的几何判定
|
|
72
|
+
* 只查 `r.right > 8`,而**向右滑出屏外的第三列**右边的坐标是很大的正数(实测 759),
|
|
73
|
+
* 于是"完全看不见的详情列"被判成"展开的详情列" → `dsh-ma-details-open` 常亮 →
|
|
74
|
+
* 那个空的 Details 面板(白色)滑进来盖住整屏,且它的 z-index(300) 与抽屉相同、
|
|
75
|
+
* DOM 顺序在后 → **压住抽屉吃掉了抽屉里所有点击**;scrim 同时也常亮拦掉剩余区域。
|
|
76
|
+
* 现在:① `isDrawer()` 必须与视口**真正相交**(左右上下四边都查);
|
|
77
|
+
* ② 详情列不再"官方说展开就滑进来":手机端滑动显示它的唯一开关是**用户在正文区点过一下**
|
|
78
|
+
* (点工具行必然落在正文区)——"载入时官方就是展开的"多半是宽屏/上次会话遗留状态,
|
|
79
|
+
* 手机端一进来就弹一个空白白面板正是用户报的白框;点空白(遮罩)即收回这个意图;
|
|
80
|
+
* ③ 遮罩不变量:只要遮罩在拦截点击而视口内没有任何可交互抽屉,立刻摘掉(800ms 看门狗 +
|
|
81
|
+
* 300ms 复核),且**只摘遮罩不动展开 class**(不破坏抽屉的滑入动画与"点一下就打开")。
|
|
82
|
+
*
|
|
60
83
|
* 开关:环境变量 DSH_MOBILE_ADAPTER=0 整体关闭(默认开启)。
|
|
61
84
|
*/
|
|
62
85
|
|
|
@@ -97,6 +120,13 @@ const STYLE = `
|
|
|
97
120
|
}
|
|
98
121
|
html.dsh-ma-sidebar-open div.pI_x6G_sidebarCol,
|
|
99
122
|
html.dsh-ma-sidebar-open div.dsh-ma-sidebar { transform: none; }
|
|
123
|
+
/* ⚠️ 抽屉展开时必须抬到详情列(第三列)之上 —— 两者同为 z-index:300 时按 DOM 顺序绘制,
|
|
124
|
+
而第三列在 frame 里排在侧栏**之后**,于是"详情列滑进来"会整块压住抽屉:
|
|
125
|
+
实测(2026-09-19 手机端)抽屉里每一行点下去命中的都是详情面板(_2ctAZa_empty),
|
|
126
|
+
用户表现为「展开左边抽屉,无法选择历史会话,点任何东西都没有反应」。
|
|
127
|
+
这里把抽屉抬到 310(>300 详情列,>290 遮罩):抽屉内的点击**永远**有效。 */
|
|
128
|
+
html.dsh-ma-sidebar-open div.pI_x6G_sidebarCol,
|
|
129
|
+
html.dsh-ma-sidebar-open div.dsh-ma-sidebar { z-index: 310; }
|
|
100
130
|
|
|
101
131
|
div.pI_x6G_detailsCol, div.pI_x6G_rightbarCol, div.dsh-ma-details, div.dsh-ma-rightbar {
|
|
102
132
|
position: fixed; right: 0; top: 0; bottom: 0; margin: 0;
|
|
@@ -450,6 +480,114 @@ const SCRIPT = `(() => {
|
|
|
450
480
|
}
|
|
451
481
|
} catch (e3) { /* 私有模式等:忽略 */ }
|
|
452
482
|
|
|
483
|
+
/* ================= 手机端输入框(输入法/IME)防误发 =================
|
|
484
|
+
用户实测反馈(2026-09-19,微信内置浏览器):「用微信语音转文字输入时,它在自动整理文字的
|
|
485
|
+
过程中会直接触发输入框的发送」—— 半截话被发出去。
|
|
486
|
+
|
|
487
|
+
取证(真机官方 GUI 390×844 + CDP 注入 composition 复现):
|
|
488
|
+
· 官方输入区 = Lexical contenteditable,根元素带 data-composer-input / role=textbox /
|
|
489
|
+
aria-multiline / data-lexical-editor;官方键位图自己有一层 IME 守卫
|
|
490
|
+
'isComposingEvent(event, recentlyComposing) = event.isComposing || event.keyCode===229
|
|
491
|
+
|| (composing || Date.now() < composingUntil)';但 compositionstart/end 只挂在编辑器根上,
|
|
492
|
+
**composingUntil 窗口只有 10ms**;
|
|
493
|
+
· 实测 compositionend → 43ms 后到达的 Enter:isComposing 已是 false、超出 10ms 窗口
|
|
494
|
+
→ 官方把它当成"用户点了发送" → 真的发出 session/prompt(带半截话);
|
|
495
|
+
· 微信语音转文字的"自动整理"正是这个时序:它**没有标准的 compositionend 时序**
|
|
496
|
+
(确认键常在 compositionend 之后几十上百毫秒才到,isComposing 也常为 false),
|
|
497
|
+
所以官方那 10ms 窗口挡不住它。
|
|
498
|
+
|
|
499
|
+
本层策略(两条一起,第二条是主推):
|
|
500
|
+
① **任何宽度**都装一层输入框专用守卫:识别出"这一下 Enter 其实是输入法确认"时,
|
|
501
|
+
在**捕获阶段** stopPropagation —— 官方那一层根本收不到这个 keydown,自然不会发送。
|
|
502
|
+
不改写事件、不 preventDefault:IME 自己的"提交候选/结束合成"动作照旧(乱 preventDefault
|
|
503
|
+
反而会让某些输入法提交不了文本)。
|
|
504
|
+
判定覆盖三种取证:event.isComposing===true / keyCode===229 / compositionend 之后 60ms 内。
|
|
505
|
+
② **≤820px(手机)时输入框里的 Enter 一律不发送,只换行**;发送交给官方发送按钮
|
|
506
|
+
(微信/Telegram 等主流手机 IM 就是这个语义,用户按 Enter 的期望也是换行)。
|
|
507
|
+
这样"没有标准 compositionend 时序"的输入法被**整类根治**:不再依赖任何 IME 事件
|
|
508
|
+
来判断"能不能发",而是根本不给 Enter 发送语义。
|
|
509
|
+
|
|
510
|
+
实现要点(为什么是"捕获阶段 + stopPropagation"):
|
|
511
|
+
· 官方键位图挂在编辑器根(冒泡)与 React 根容器上;document 捕获阶段早于它们,
|
|
512
|
+
stopPropagation 之后官方与 React 都收不到 → 发送逻辑不执行;
|
|
513
|
+
· **只 stopPropagation、不 preventDefault**:浏览器默认动作不受影响,contenteditable 照旧
|
|
514
|
+
插入换行 —— 实测真机 390×844:官方不发送,换行正常插入,继续打字正常;
|
|
515
|
+
(若同时 preventDefault,则是"既不发也不换行",用户按 Enter 像坏了 —— 实测确认。)
|
|
516
|
+
· 组合键(Ctrl/Cmd+Enter = 官方"强制提交")不拦,保留桌面/外接键盘语义;
|
|
517
|
+
· **只作用于输入框**:命中判定走 [data-composer-input] /
|
|
518
|
+
[role=textbox][aria-multiline] / Lexical 根(data-lexical-editor);
|
|
519
|
+
其它地方(弹窗确认、表单提交、快捷键、侧栏搜索框)一律放行 —— 这是硬要求。
|
|
520
|
+
· 非 Enter 的按键不拦(含合成期的其余 keydown):官方发送只认 event.key === "Enter",
|
|
521
|
+
乱拦合成期的其它按键会伤到输入法自身(它与 Lexical 的合成记账有关)。
|
|
522
|
+
实测验证(官方 GUI 390×844,CDP Input.imeSetComposition + dispatchKeyEvent):
|
|
523
|
+
· 无守卫:compositionend 后 43ms 的 Enter → 真的发出(session/prompt) —— 复现用户 bug;
|
|
524
|
+
· 有守卫:同一时序 → 不发送;合成中(isComposing=true)的 Enter → 不发送;
|
|
525
|
+
· 有守卫 + 普通打字后按 Enter(手机窗口)→ 不发送,插入换行,可继续输入;
|
|
526
|
+
· 有守卫时点官方 button[aria-label="Send message"] → 正常发送 —— 发送按钮没被改坏。 */
|
|
527
|
+
(function installComposerEnterGuard() {
|
|
528
|
+
/* compositionend 之后多久内的 Enter 仍算"输入法确认":官方只有 10ms(实测 43ms 就漏),
|
|
529
|
+
这里取 60ms —— 几十毫秒级,只覆盖"合成刚结束"的那一下,毫秒级之外用户正常
|
|
530
|
+
打字后按 Enter 不受影响(手机端本来就是换行,不受此窗口影响)。 */
|
|
531
|
+
const IME_TAIL_MS = 60;
|
|
532
|
+
const COMPOSER_SEL = '[data-composer-input],[role="textbox"][aria-multiline="true"],[data-lexical-editor="true"]';
|
|
533
|
+
let composing = false;
|
|
534
|
+
let composingEndedAt = 0;
|
|
535
|
+
|
|
536
|
+
/** 事件目标是否落在官方输入框里;是则返回输入框宿主元素,否则 null。 */
|
|
537
|
+
function composerHost(node) {
|
|
538
|
+
try {
|
|
539
|
+
if (!node || typeof node.closest !== "function") return null;
|
|
540
|
+
const el = node.closest(COMPOSER_SEL);
|
|
541
|
+
if (!el) return null;
|
|
542
|
+
const ce = el.getAttribute("contenteditable");
|
|
543
|
+
if (ce === "false") return null; // 非可编辑态 → 不需要守卫
|
|
544
|
+
if (ce === null && !el.hasAttribute("data-composer-input") && !el.hasAttribute("data-lexical-editor")) return null;
|
|
545
|
+
return el;
|
|
546
|
+
} catch (e) { return null; }
|
|
547
|
+
}
|
|
548
|
+
/** 先用 composedPath(兼容将来把输入区放进 shadow DOM 的改版),退回 e.target。 */
|
|
549
|
+
function composerOf(e) {
|
|
550
|
+
try {
|
|
551
|
+
if (typeof e.composedPath === "function") {
|
|
552
|
+
const path = e.composedPath();
|
|
553
|
+
if (path && path.length) {
|
|
554
|
+
for (let i = 0; i < path.length; i++) { if (composerHost(path[i])) return true; }
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
} catch (e2) { /* 忽略 */ }
|
|
559
|
+
return !!composerHost(e.target);
|
|
560
|
+
}
|
|
561
|
+
const isImeEnter = (e) => {
|
|
562
|
+
if (e.isComposing === true || e.keyCode === 229) return true; // ① 标准 ② 部分安卓 IME 只给 229
|
|
563
|
+
if (composing) return true; // ③ 合成进行中
|
|
564
|
+
return composingEndedAt !== 0 && (Date.now() - composingEndedAt) <= IME_TAIL_MS; // ④ 合成刚结束
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
/* 捕获阶段监听:输入区自己的 composition 事件先经过 document,不受 stopPropagation 影响 */
|
|
569
|
+
document.addEventListener("compositionstart", function () { composing = true; composingEndedAt = 0; }, true);
|
|
570
|
+
document.addEventListener("compositionend", function () { composing = false; composingEndedAt = Date.now(); }, true);
|
|
571
|
+
} catch (e) { /* 忽略 */ }
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
document.addEventListener("keydown", function (e) {
|
|
575
|
+
try {
|
|
576
|
+
if (!e || e.key !== "Enter") return; // 官方发送只认 Enter;其它键一律不碰
|
|
577
|
+
if (!composerOf(e)) return; // 不是输入框 → 弹窗/表单/快捷键一律放行
|
|
578
|
+
const ime = isImeEnter(e);
|
|
579
|
+
if (!ime) {
|
|
580
|
+
if (e.ctrlKey || e.metaKey || e.altKey) return; // Ctrl/Cmd+Enter = 官方"强制提交",保留
|
|
581
|
+
if (!NARROW()) return; // 桌面宽屏:非 IME 的 Enter 保持官方语义(发送)
|
|
582
|
+
}
|
|
583
|
+
/* 只截断传播,不 preventDefault:
|
|
584
|
+
官方(以及 React 根容器)收不到这一下 → 不会走发送逻辑;默认动作保留 → 该换行就换行。 */
|
|
585
|
+
e.stopPropagation();
|
|
586
|
+
} catch (e2) { /* 守卫出错绝不能把页面弄挂 */ }
|
|
587
|
+
}, true);
|
|
588
|
+
} catch (e) { /* 忽略 */ }
|
|
589
|
+
})();
|
|
590
|
+
|
|
453
591
|
/* ================= 鲸鱼挂件手机辅助(仅 ≤820 生效;挂件类名 .dshwv-* 稳定) ========= */
|
|
454
592
|
let whaleGuardsOn = false;
|
|
455
593
|
let whaleHitMap = null; let whaleUnstuckDone = false;
|
|
@@ -1027,7 +1165,16 @@ const SCRIPT = `(() => {
|
|
|
1027
1165
|
const s = String(v).trim().toLowerCase();
|
|
1028
1166
|
return !(s === "" || s === "false" || s === "0" || s === "off" || s === "no");
|
|
1029
1167
|
};
|
|
1030
|
-
const collapsed = () =>
|
|
1168
|
+
const collapsed = () => {
|
|
1169
|
+
/* 官方 0.1.2-rc.1 用 data-sidebar-collapsed 表达折叠态(折叠时输出,展开时**移除**该属性),
|
|
1170
|
+
也能输出带值写法(=false / =true)。两种都按"读值"处理。
|
|
1171
|
+
⚠️ 两个属性都认不到时(官方再改名/尚未挂载)**当作收起**,而不是当作展开:
|
|
1172
|
+
展开态会让离屏抽屉盖在内容上(scrim 也可能跟着下闸),而收起态至少还能用汉堡按钮打开 ——
|
|
1173
|
+
"拿不准就选不会困住用户的那一边"。 */
|
|
1174
|
+
if (frame.hasAttribute("data-sidebar-collapsed")) return truthyAttr(frame, "data-sidebar-collapsed");
|
|
1175
|
+
if (frame.hasAttribute("data-sidebar-open")) return !truthyAttr(frame, "data-sidebar-open");
|
|
1176
|
+
return true;
|
|
1177
|
+
};
|
|
1031
1178
|
const expanded = () => !collapsed();
|
|
1032
1179
|
|
|
1033
1180
|
/* 遮罩:点击 = 点官方 toggle(走官方 store,无私有状态) */
|
|
@@ -1037,6 +1184,7 @@ const SCRIPT = `(() => {
|
|
|
1037
1184
|
scrim.addEventListener("click", (e) => {
|
|
1038
1185
|
e.stopPropagation();
|
|
1039
1186
|
userWantsOpen = false; // 用户明确要关(遮罩的作用就是关抽屉)
|
|
1187
|
+
centerTouchedAt = 0; detailsSticky = false; // 顺带收回"要看详情"的意图(下次在正文里点一下即可恢复)
|
|
1040
1188
|
const t = toggleOf();
|
|
1041
1189
|
if (t) { t.click(); }
|
|
1042
1190
|
/* 兜底 + 保险:无论官方 toggle 在不在,都确保"点空白处"能关掉/不再拦截 */
|
|
@@ -1063,30 +1211,47 @@ const SCRIPT = `(() => {
|
|
|
1063
1211
|
|
|
1064
1212
|
/* 侧栏是否**真的**滑进了视口。这是"遮罩能不能拦截点击"的最后一道保险:
|
|
1065
1213
|
只要官方属性语义与我们理解的不一致,光看属性就可能误判;而遮罩一旦拦截整屏,
|
|
1066
|
-
用户看到的就是"整屏阴影 + 点哪都没反应"
|
|
1067
|
-
const inView = (el) => {
|
|
1068
|
-
if (!el) return false;
|
|
1069
|
-
try { const r = el.getBoundingClientRect(); return !!r && r.right > 8 && r.width > 40; }
|
|
1070
|
-
catch (e2) { return false; }
|
|
1071
|
-
};
|
|
1214
|
+
用户看到的就是"整屏阴影 + 点哪都没反应"。以实测几何为准(isDrawer),误判也不会挡住用户。 */
|
|
1072
1215
|
/* 抽屉必须"像抽屉":不是全屏层(overlay 层/主内容区都被排除在外)、不是拖拽把手、
|
|
1073
|
-
|
|
1216
|
+
**与视口真正相交**。任一不满足 → 它就不该让遮罩拦截点击(0.1.5 事故的结构性防线)。
|
|
1217
|
+
⚠️ 2026-09-19 手机实测第二波:旧实现只查 r.right > 8,这对"向右滑出屏外"的第三列是
|
|
1218
|
+
**恒真**的 —— 抽屉滑出时右边坐标是很大的正数(实测 left=400,right=759,width=358),
|
|
1219
|
+
于是"完全看不见、一个像素都没露"的第三列被判成"展开的详情列":
|
|
1220
|
+
· dsh-ma-details-open 常亮 → 那个空的 Details 面板(白框)滑进来盖住整屏;
|
|
1221
|
+
· 它的 z-index(300) 与侧栏相同、DOM 顺序在侧栏之后 → 压住抽屉,抽屉里所有点击
|
|
1222
|
+
都命中详情面板(实测 elementFromPoint 命中 _2ctAZa_empty);
|
|
1223
|
+
· 同时 scrim 常亮 → 剩下没被盖住的地方也被拦掉。
|
|
1224
|
+
用户看到的就是「展开左边抽屉,无法选择历史会话,点任何东西都没反应 + 右边一个白框」。
|
|
1225
|
+
现在四边都查:必须与视口有实质重叠,整体滑出(左/右/上/下)一律不算"在视口里"。 */
|
|
1074
1226
|
const isDrawer = (el) => {
|
|
1075
1227
|
if (!el || isOverlayLayer(el) || isHandle(el)) return false;
|
|
1076
1228
|
try {
|
|
1077
1229
|
const r = el.getBoundingClientRect();
|
|
1078
|
-
if (!r || r.width <= 40 || r.
|
|
1079
|
-
|
|
1230
|
+
if (!r || r.width <= 40 || r.height <= 20) return false;
|
|
1231
|
+
const vw = window.innerWidth || 0;
|
|
1232
|
+
const vh = window.innerHeight || 0;
|
|
1233
|
+
if (vw > 0 && r.left >= vw - 8) return false; // 整体在视口右侧之外(滑出的抽屉) —— 旧版漏的就是这条
|
|
1234
|
+
if (r.right <= 8) return false; // 整体在视口左侧之外
|
|
1235
|
+
if (vh > 0 && r.top >= vh - 8) return false; // 整体在视口下方之外
|
|
1236
|
+
if (r.bottom <= 8) return false; // 整体在视口上方之外
|
|
1237
|
+
return r.width < vw * 0.98; // 全屏宽 = 主界面,不是抽屉
|
|
1080
1238
|
} catch (e2) { return false; }
|
|
1081
1239
|
};
|
|
1082
1240
|
const sidebarInView = () => isDrawer(sidebar || document.querySelector(".dsh-ma-sidebar"));
|
|
1083
1241
|
const detailsInView = () => isDrawer(details || document.querySelector(".dsh-ma-details, .dsh-ma-rightbar"));
|
|
1084
1242
|
/* 第三列折叠态:官方 0.1.2-rc.1 是 data-details-collapsed,0.1.5-rc.2 起是 data-rightbar-collapsed;
|
|
1085
|
-
两个都认;都不存在(未来再改名)
|
|
1243
|
+
两个都认;都不存在(未来再改名)时**不当作展开**(见下面 detailsAttrLive)。 */
|
|
1086
1244
|
const detailsCollapsed = () =>
|
|
1087
1245
|
truthyAttr(frame, "data-details-collapsed") || truthyAttr(frame, "data-rightbar-collapsed");
|
|
1088
1246
|
const detailsAttrKnown = () =>
|
|
1089
1247
|
frame.hasAttribute("data-details-collapsed") || frame.hasAttribute("data-rightbar-collapsed");
|
|
1248
|
+
/* 官方这两个属性都是"折叠时才写,展开时**移除**"(实测 0.1.2-rc.1:展开态下属性消失)。
|
|
1249
|
+
所以"属性不在"既可能是"展开",也可能是"官方改名了,我们根本不认识这个属性"——必须区分:
|
|
1250
|
+
· 只要**见过**它出现过(detailsSeenCollapsed),就说明这个名字是活的 → 之后的"不在"= 展开;
|
|
1251
|
+
· 从没见过 → 不认识 → 一律不认(绝不把未知结构当成"展开的详情列",这是白框事故的根因)。
|
|
1252
|
+
data-rightbar-fullscreen 一并算作"这个名字是活的"的旁证(0.1.5 起第三列的另一种开合表达)。 */
|
|
1253
|
+
const detailsAttrLive = () =>
|
|
1254
|
+
detailsAttrKnown() || detailsSeenCollapsed || frame.hasAttribute("data-rightbar-fullscreen");
|
|
1090
1255
|
/* 状态来源(按优先级):
|
|
1091
1256
|
① 用户意图 userWantsOpen —— 点菜单按钮打开 / 点遮罩关闭,期间**不被几何判定推翻**;
|
|
1092
1257
|
② 官方 frame 的 data 属性 —— 用户没表达意图时(初始、官方自己切换)以它为准。
|
|
@@ -1095,6 +1260,43 @@ const SCRIPT = `(() => {
|
|
|
1095
1260
|
现在只让几何判定决定**遮罩能否拦截点击**(最后一道保险),不再否决用户的展开意图。 */
|
|
1096
1261
|
let userWantsOpen = null; // null=未表达意图(跟随官方); true/false=用户已明确开/关
|
|
1097
1262
|
let scrimTimer = 0, scrimWatchdog = 0;
|
|
1263
|
+
/* ---- 详情列(第三列)在手机端的显示条件 ----------------------------------------
|
|
1264
|
+
⚠️ 为什么不能"官方属性说展开就滑进来"(2026-09-19 白框事故的根因之一):
|
|
1265
|
+
详情列在手机端是**离屏浮层**,我们自己的 CSS 用 translateX(103%) 把它推到屏幕右侧外面,
|
|
1266
|
+
到底显不显示**完全由 dsh-ma-details-open 决定**。所以几何永远无法充当"要不要打开"的依据
|
|
1267
|
+
(它永远在屏外);反过来,旧实现用带缺陷的几何判定去决定是否滑入,就把
|
|
1268
|
+
"官方在宽屏/上次会话遗留的展开态(attr 缺失或 =false)"读成"现在就该显示",
|
|
1269
|
+
于是手机一载入就滑出一个**空的白色 Details 面板**盖住界面。
|
|
1270
|
+
现在的规则(唯一一条,很保守):
|
|
1271
|
+
官方说得清"此刻是展开的" **且** 用户在**主内容列**里点过至少一次 → 才滑入。
|
|
1272
|
+
为什么要求"用户点过主内容列":官方表达展开的方式有两种 —— 属性被移除,或写成 =false
|
|
1273
|
+
(实测 0.1.2-rc.1 是"收起才写属性";0.1.5 起第三列改名 rightbar,写法可能与旧版不同)。
|
|
1274
|
+
"载入时官方就是展开的"多半是宽屏/上次会话留下的状态,手机端一进来就弹一个空白白面板
|
|
1275
|
+
正是用户报的那个白框;而"用户在正文里点了一下"是**可靠且唯一的**用户意图信号
|
|
1276
|
+
(点工具行必然落在主内容列里)。这样两种写法都能正确处理,也不需要猜官方的时序。
|
|
1277
|
+
注:用户点过之后,官方若把属性收回(=收起),下面的 !detailsCollapsed() 立刻让它退出 ——
|
|
1278
|
+
关掉详情面板也是即时的。 */
|
|
1279
|
+
let detailsSeenCollapsed = false; // 是否见过官方写出折叠态(证明这个属性名是活的)
|
|
1280
|
+
/* 手机端显示详情列的**唯一**依据:用户在主内容列里的点击(见下面 click 委托)。
|
|
1281
|
+
· centerTouchedAt:最近一次点击时间 —— 点工具行后官方通常在同一帧内把详情置为展开,
|
|
1282
|
+
给一个几秒窗口足够覆盖"点击 → 官方改属性 → 我们收到通知"的时序;
|
|
1283
|
+
· detailsSticky:一经用户动作显示过就粘住(官方没说收起之前不自己缩回去),
|
|
1284
|
+
否则那些 700/1600/…/12000ms 的兜底 re-sync 会在窗口过期后把面板又收起来。 */
|
|
1285
|
+
const CENTER_TOUCH_MS = 8000;
|
|
1286
|
+
let centerTouchedAt = 0;
|
|
1287
|
+
let detailsSticky = false;
|
|
1288
|
+
/* scrim 的"到底有没有在拦"以浏览器算出来的 pointer-events 为准(class 只是我们的意图) */
|
|
1289
|
+
const scrimBlocks = () => {
|
|
1290
|
+
const html = document.documentElement;
|
|
1291
|
+
if (!html.classList.contains("dsh-ma-scrim-on")) return false;
|
|
1292
|
+
try {
|
|
1293
|
+
const cs = window.getComputedStyle && scrim ? window.getComputedStyle(scrim) : null;
|
|
1294
|
+
const pe = cs && cs.pointerEvents;
|
|
1295
|
+
if (typeof pe === "string" && pe !== "") return pe === "auto";
|
|
1296
|
+
} catch (e2) { /* 忽略 */ }
|
|
1297
|
+
return true; // 认不出实际样式 → 以 class 为准(保守:当成在拦)
|
|
1298
|
+
};
|
|
1299
|
+
const anyDrawerInView = () => sidebarInView() || detailsInView();
|
|
1098
1300
|
/* 遮罩闸门:**只有此刻真的看得见抽屉**才允许拦截点击。
|
|
1099
1301
|
抽屉是滑入动画(.22s),所以同步判定必然"还看不见" —— 那一瞬间不下闸即可,
|
|
1100
1302
|
真正的下闸交给下一帧与动画结束后的复核(applyScrim),既不误拦也不影响手感。
|
|
@@ -1109,6 +1311,19 @@ const SCRIPT = `(() => {
|
|
|
1109
1311
|
try { document.documentElement.classList.toggle("dsh-ma-scrim-on", scrimShouldBeOn()); }
|
|
1110
1312
|
catch (e2) { /* 忽略 */ }
|
|
1111
1313
|
};
|
|
1314
|
+
/* 🔒 不变量(硬要求):遮罩只要在拦截点击,视口内就必须**存在**至少一层可交互抽屉;
|
|
1315
|
+
否则立刻摘掉遮罩。它不看任何判定链(属性/几何/class)的自洽性,只认"实际在拦 + 实际没有抽屉"
|
|
1316
|
+
这一对事实 —— 任何未来官方改名、结构变化都不会再变成"点哪都没反应"的死局。
|
|
1317
|
+
注意:**只摘遮罩,不动 dsh-ma-sidebar-open** —— 抽屉是滑入动画,动画途中本来就"还没进视口",
|
|
1318
|
+
顺手摘掉展开 class 会让抽屉永远打不开(0.6.6-beta.3 的回归点)。 */
|
|
1319
|
+
const enforceScrimInvariant = () => {
|
|
1320
|
+
try {
|
|
1321
|
+
if (!scrimBlocks()) return false;
|
|
1322
|
+
if (anyDrawerInView()) return false;
|
|
1323
|
+
document.documentElement.classList.remove("dsh-ma-scrim-on");
|
|
1324
|
+
return true;
|
|
1325
|
+
} catch (e2) { return false; }
|
|
1326
|
+
};
|
|
1112
1327
|
/* 看门狗:遮罩一旦在拦截点击,就必须**始终**有抽屉在视口里;否则立刻摘掉。
|
|
1113
1328
|
它防的是"未来官方再改名/再改结构"导致的同类死局 —— 用户被遮罩困住的代价太高,
|
|
1114
1329
|
宁可多这一个每 800ms 的轻量校验(没有遮罩时自会停表)。 */
|
|
@@ -1116,6 +1331,7 @@ const SCRIPT = `(() => {
|
|
|
1116
1331
|
if (scrimWatchdog) return;
|
|
1117
1332
|
try {
|
|
1118
1333
|
scrimWatchdog = setInterval(() => {
|
|
1334
|
+
if (enforceScrimInvariant()) { /* 不变量被触发:已摘掉遮罩 */ }
|
|
1119
1335
|
applyScrim();
|
|
1120
1336
|
const html = document.documentElement;
|
|
1121
1337
|
if (!html.classList.contains("dsh-ma-scrim-on") && !html.classList.contains("dsh-ma-sidebar-open") && !html.classList.contains("dsh-ma-details-open")) {
|
|
@@ -1129,27 +1345,64 @@ const SCRIPT = `(() => {
|
|
|
1129
1345
|
document.documentElement.classList.toggle("dsh-ma-sidebar-open", open);
|
|
1130
1346
|
/* 汉堡按钮**始终可见**:以前判定为"展开"时会把它 display:none 隐藏,
|
|
1131
1347
|
于是判定一旦出错(或遮罩因异常常亮),用户既点不动内容、也没有任何入口 —— 死局。
|
|
1132
|
-
保持可见 + 点它可开可关(见其 click 处理),任何异常状态下都留一条出路。
|
|
1348
|
+
保持可见 + 点它可开可关(见其 click 处理),任何异常状态下都留一条出路。
|
|
1349
|
+
它的 z-index(320) 高于抽屉(310)/详情列(300)/遮罩(290) —— 判定怎么错都点得到。 */
|
|
1133
1350
|
if (hamburger) hamburger.style.display = "";
|
|
1134
|
-
/* 第三列(详情/rightbar)
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1351
|
+
/* 第三列(详情/rightbar)在手机端要不要滑进来:
|
|
1352
|
+
① 属性说不清(官方未来再改名) → **不显示**,绝不靠几何去猜
|
|
1353
|
+
(0.1.5 事故:被误标成 details 的 overlay 层/滑出屏外的列满足"几何可见" → 遮罩常亮 + 白框);
|
|
1354
|
+
② 见过官方写出折叠态 → 证明 details/rightbar 里有一个属性名是活的(见 detailsAttrLive);
|
|
1355
|
+
③ 官方收起 → 立刻退出,并复位"用户要看详情"的粘性标记(下次点正文即可恢复)。 */
|
|
1356
|
+
if (detailsCollapsed()) { detailsSeenCollapsed = true; detailsSticky = false; }
|
|
1357
|
+
const touchedRecently = centerTouchedAt !== 0 && (Date.now() - centerTouchedAt) <= CENTER_TOUCH_MS;
|
|
1358
|
+
const dOpen = detailsAttrLive() && !detailsCollapsed() && (touchedRecently || detailsSticky);
|
|
1359
|
+
if (dOpen) detailsSticky = true;
|
|
1138
1360
|
document.documentElement.classList.toggle("dsh-ma-details-open", dOpen);
|
|
1139
1361
|
// 遮罩:同步先按当前几何下闸(抽屉已在视口内时无延迟),再在动画开始/结束后复核两次
|
|
1140
1362
|
applyScrim();
|
|
1141
1363
|
try {
|
|
1142
1364
|
if (typeof requestAnimationFrame === "function") requestAnimationFrame(applyScrim);
|
|
1143
1365
|
clearTimeout(scrimTimer);
|
|
1144
|
-
scrimTimer = setTimeout(applyScrim, 300);
|
|
1366
|
+
scrimTimer = setTimeout(() => { enforceScrimInvariant(); applyScrim(); }, 300);
|
|
1145
1367
|
} catch (e2) { /* 忽略 */ }
|
|
1146
1368
|
armScrimWatchdog();
|
|
1147
1369
|
};
|
|
1370
|
+
/* 用户在主内容列里点一下 → 视为"他要看详情"(手机端显示详情列的唯一开关)。
|
|
1371
|
+
为什么必须由用户动作来解锁:官方表达"详情展开"的方式有两种 —— 属性被移除,或写成 =false,
|
|
1372
|
+
而"载入时官方就是展开的"多半是宽屏/上次会话留下的状态;手机端一进来就滑出一个空白白面板
|
|
1373
|
+
正是用户报的那个白框。点了正文之后才显示,两种写法都能正确处理,也不用去猜官方时序;
|
|
1374
|
+
用户点空白(遮罩)即收回这个意图(见 scrim 的 click 处理)。
|
|
1375
|
+
只认主内容列(.dsh-ma-center),不认抽屉/菜单/适配层自己的 UI —— 不会误触发。 */
|
|
1148
1376
|
try {
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1377
|
+
document.addEventListener("click", (e) => {
|
|
1378
|
+
try {
|
|
1379
|
+
if (!NARROW()) return;
|
|
1380
|
+
const t = e.target;
|
|
1381
|
+
if (!t || !t.closest) return;
|
|
1382
|
+
if (t.closest(".dsh-ma-sidebar, .dsh-ma-scrim, .dsh-ma-hamburger, .dsh-ma-fab, .dsh-ma-menu")) return;
|
|
1383
|
+
if (!t.closest(".dsh-ma-center, .pI_x6G_centerCol")) return;
|
|
1384
|
+
centerTouchedAt = Date.now();
|
|
1385
|
+
sync(); // 由 sync 统一判定要不要显示详情列
|
|
1386
|
+
} catch (e2) { /* 忽略 */ }
|
|
1387
|
+
}, true);
|
|
1388
|
+
} catch (e2) { /* 忽略 */ }
|
|
1389
|
+
try {
|
|
1390
|
+
/* 观察 frame 的**全部属性**,再在回调里按名字筛(2026-09-19 手机实测后的加固):
|
|
1391
|
+
官方 0.1.5-rc.2 起表达抽屉开合的属性改过名(details→rightbar),将来还可能再改;
|
|
1392
|
+
用 attributeFilter 白名单就会"官方自己开了抽屉、适配层不知道" → 遮罩/class 与实际不符。
|
|
1393
|
+
这里改成名字级正则:任何含 sidebar/details/rightbar/collapsed/expand/open/fullscreen/shell
|
|
1394
|
+
的属性变动都会触发一次 sync —— 涵盖官方已知的两种命名(details / rightbar)与任何未来改名,
|
|
1395
|
+
而 window.addEventListener("resize"/"orientationchange") 与 700ms~12s 的兜底 re-sync
|
|
1396
|
+
继续覆盖"官方只改内联 gridTemplateColumns 而不动属性"的情况。 */
|
|
1397
|
+
const FRAME_ATTR_RE = /sidebar|details|rightbar|collapsed|expand|open|fullscreen|shell/i;
|
|
1398
|
+
new MutationObserver((records) => {
|
|
1399
|
+
try {
|
|
1400
|
+
for (let i = 0; i < records.length; i++) {
|
|
1401
|
+
const n = records[i].attributeName;
|
|
1402
|
+
if (n && FRAME_ATTR_RE.test(n)) { sync(); return; }
|
|
1403
|
+
}
|
|
1404
|
+
} catch (e3) { sync(); }
|
|
1405
|
+
}).observe(frame, { attributes: true });
|
|
1153
1406
|
} catch (e2) { /* 退化:仅在下次 boot 同步 */ }
|
|
1154
1407
|
// 几何变化(旋转屏幕 / 窗口缩放 / 侧栏动画结束)也要重算,否则会把 boot 时的判定一直沿用
|
|
1155
1408
|
try {
|