@yfwu2020/dsh-selection-explain 0.9.0 → 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.
package/README.md CHANGED
@@ -290,6 +290,7 @@ dsh plugin --profile web remove @yfwu2020/dsh-selection-explain
290
290
 
291
291
  **几个不别扭的细节:**
292
292
 
293
+ - **与完整视图聊天小窗共存(0.9.1)**:Esc 优先退出最后点击或操作的展开小窗。点标题、消息或窗内空白就算激活,不要求点中输入框。聊天先收回紧凑条,解读按设置下拉、设置、录音、面板逐层退出;点击两窗之外会清除活动归属,之后先退解读,再按一次收起聊天。每次只退一层,无需重新点击;历史列表属于解读,原生菜单和输入法仍优先。需要解读插件 0.9.1 与完整视图插件 0.1.5 配合,普通分栏保留原 Esc 行为。
293
294
  - **点面板外不会关闭面板**。答案留在原地,只有 `✕` / `Esc` / 右下角胶囊能收起——不会因为手滑点一下就得重新问。
294
295
  - **输入框跟着内容长高**:写(或说)到第二行就自己撑开,最多约 7 行(132px);再长就内部滚动并**自动停在最新一行**——不会像以前那样第二行以下的字被裁掉。
295
296
  - **开窗即能打字**:小窗打开、输入框出现后会自动获得焦点(只有"你正在别处打字"时才不抢)。`Tab` 也能摸到输入框和 🎤。
package/lib/client.js CHANGED
@@ -217,6 +217,22 @@ window.__ModuleLoader__.load({
217
217
  return value < min ? min : value > max ? max : value
218
218
  }
219
219
 
220
+ /**
221
+ * 选区是不是"指着一段已经不在文档里的文字"。
222
+ *
223
+ * 这是"选中的文字被删掉"的第二种形态,跟塌陷(`isCollapsed`)**不是**一回事:
224
+ * 节点被整个摘掉时(流式回答重渲染 / 消息被替换 / 内容区刷新),Firefox 里选区并不塌陷 ——
225
+ * `isCollapsed` 仍是 false、`toString()` 还吐得出那段旧文字,可那段文字屏幕上已经没有了。
226
+ * 只认塌陷的话浮标会赖着不走,点下去解读的是一段**不存在的文字**。
227
+ *
228
+ * 取不到 `isConnected`(老环境 / 无浏览器测试的桩 DOM)时一律当"还在":
229
+ * 宁可多留一会儿,也不误收一个正常选区。
230
+ */
231
+ function selectionDetached(range) {
232
+ var node = range && range.startContainer
233
+ return !!node && node.isConnected === false
234
+ }
235
+
220
236
  /** 选区是否落在输入框里(那里不该弹按钮,避免干扰输入)。 */
221
237
  function insideEditable(node) {
222
238
  var element = node && node.nodeType === 1 ? node : node && node.parentElement
@@ -3513,8 +3529,23 @@ window.__ModuleLoader__.load({
3513
3529
 
3514
3530
  // —— 选区 → 浮标 ——
3515
3531
 
3532
+ /**
3533
+ * 排一次选区检查(下一拍执行)。
3534
+ *
3535
+ * 同一拍里的多次请求**合并成一次**:`selectionchange` 在打字 / 流式重绘时会连发很多下,
3536
+ * 每下都排一个计时器的话 `timers` 数组会一直长(文件里别处也刻意避开这种写法,
3537
+ * 见 schedulePillIdle 的注释)。合并是安全的 —— 检查读的是"当下"的选区,
3538
+ * 而不是排队那一刻的快照。
3539
+ */
3540
+ var checkPending = false
3541
+
3516
3542
  function scheduleCheck() {
3517
- later(checkSelection, 0)
3543
+ if (checkPending) return
3544
+ checkPending = true
3545
+ later(function () {
3546
+ checkPending = false
3547
+ checkSelection()
3548
+ }, 0)
3518
3549
  }
3519
3550
 
3520
3551
  /**
@@ -3614,6 +3645,12 @@ window.__ModuleLoader__.load({
3614
3645
  } catch (error) {
3615
3646
  return
3616
3647
  }
3648
+ // 选中的节点被整个摘掉了(流式回答重渲染 / 消息换掉):那段文字已经不在屏幕上
3649
+ if (selectionDetached(range)) {
3650
+ state.quoteSelection = null
3651
+ hideQuoteButton()
3652
+ return
3653
+ }
3617
3654
  var text = String(selection.toString() || '')
3618
3655
  if (!text.trim() || text.length > MAX_QUOTE_SELECTION) {
3619
3656
  state.quoteSelection = null
@@ -3698,6 +3735,12 @@ window.__ModuleLoader__.load({
3698
3735
  return
3699
3736
  }
3700
3737
  var range = selection.getRangeAt(0)
3738
+ // 选中的节点被整个摘掉了(流式回答重渲染 / 消息换掉):那段文字已经不在屏幕上,
3739
+ // 浮标不该留着(否则点下去解读的是一段不存在的文字)
3740
+ if (selectionDetached(range)) {
3741
+ hideButton()
3742
+ return
3743
+ }
3701
3744
  if (insideEditable(range.startContainer)) {
3702
3745
  hideButton()
3703
3746
  return
@@ -7653,7 +7696,25 @@ window.__ModuleLoader__.load({
7653
7696
  setQuotePop(false)
7654
7697
  })
7655
7698
 
7699
+ /**
7700
+ * 鼠标是不是按着(拖拽划词进行中)—— 记的是"按下那一刻"。
7701
+ *
7702
+ * 为什么记时刻而不是记一个布尔:松手落在浏览器窗口外时**不会补发 mouseup**,
7703
+ * 布尔一旦卡在 true,`selectionchange` 就被永久忽略 —— 浮标从此再也不会自己收了。
7704
+ * 超过 SELECT_PRESS_STALE_MS 就当已经松开(帧内那条桥用的是同一个兜底,见 bridgeBody)。
7705
+ *
7706
+ * 拖拽期间一律不查选区:时机要和"松手才弹浮标"一致,否则拖到一半浮标就冒出来,
7707
+ * 还跟着手指跑。
7708
+ */
7709
+ var SELECT_PRESS_STALE_MS = 6000
7710
+ var selectPressedAt = 0
7711
+
7712
+ function selectPressing() {
7713
+ return selectPressedAt > 0 && Date.now() - selectPressedAt < SELECT_PRESS_STALE_MS
7714
+ }
7715
+
7656
7716
  var offMouseUp = listen(document, 'mouseup', function (event) {
7717
+ selectPressedAt = 0
7657
7718
  // 面板里的 mouseup **也要走一次检查**:小窗开着时"划小窗正文"正是「引用」的主入口
7658
7719
  // (早先这里对面板直接 return —— 那是"面板里划词一律不管"年代的写法,
7659
7720
  // 结果就是小窗里划词毫无反应,只有气泡末尾的「引用整条」能用)。
@@ -7667,7 +7728,62 @@ window.__ModuleLoader__.load({
7667
7728
  }
7668
7729
  }, true)
7669
7730
 
7731
+ /**
7732
+ * 选中的文字**没了** → 浮标跟着消失。
7733
+ *
7734
+ * 为什么非要有这一条:浮标只在 mouseup 和少数几个键的 keyup 之后被检查,
7735
+ * 而"把选中的字删掉"这两种动作都不在那条路径上 ——
7736
+ * · Backspace / Delete / 直接打字覆盖 / 剪切 / 撤回(⌘Z);
7737
+ * · 内容刷新把选中的节点整个换掉(流式回答重渲染、消息重画)。
7738
+ * 于是浮标赖在屏幕上不走,点下去还会解读一段**屏幕上已经不存在**的文字(用户报的正是这个)。
7739
+ *
7740
+ * `selectionchange` 是浏览器对"选区变了"最权威的信号(塌陷 / 被删 / 被程序改动都会发),
7741
+ * 用它兜住所有非鼠标路径;节点被摘掉但选区没塌陷那种(Firefox)另有 selectionDetached 判死。
7742
+ */
7743
+ var offSelectionChange = listen(document, 'selectionchange', function () {
7744
+ if (selectPressing()) return
7745
+ scheduleCheck()
7746
+ }, true)
7747
+
7748
+ /**
7749
+ * 浮标亮着的时候,隔几拍确认一次"那段文字还在不在"。
7750
+ *
7751
+ * 为什么光有事件还不够:**Chrome 把选中的节点整个摘掉时会悄悄把选区塌陷,却不发
7752
+ * `selectionchange`**(真浏览器实测:替换 textContent 后 `rangeCount` 仍是 1、
7753
+ * `isCollapsed` 变成 true,而事件计数一个都不涨)。流式回答重渲染 / 消息重画走的正是这条路 ——
7754
+ * 只靠事件的话,浮标会一直亮着,点下去解读的是一段屏幕上已经没有的文字。
7755
+ *
7756
+ * 代价是可控的:**只在浮标可见的那几拍里干活**(它不可见时这一拍立刻返回,
7757
+ * 连选区都不读),而且只有发现选区没了/锚点被摘掉时才去排一次检查 ——
7758
+ * 正常亮着的那几拍不做任何 DOM 写。
7759
+ */
7760
+ var FLOAT_WATCH_MS = 400
7761
+
7762
+ function watchFloatLiveness() {
7763
+ // 拖拽划词进行中不查:浮标要等松手才出现(与 mouseup 那条路径同一套时机)
7764
+ if (selectPressing()) return
7765
+ if (button.style.display !== 'inline-flex' && quoteButton.style.display !== 'inline-flex') return
7766
+ var selection = window.getSelection && window.getSelection()
7767
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
7768
+ // 帧内那条选区**不在本文档**:点浮标会让本文档的选区塌掉,别把它误当成"文字没了"
7769
+ if (state.bridgeActive && state.selection && state.selection.source === 'iframe') return
7770
+ scheduleCheck()
7771
+ return
7772
+ }
7773
+ var range = null
7774
+ try {
7775
+ range = selection.getRangeAt(0)
7776
+ } catch (error) {
7777
+ return
7778
+ }
7779
+ // 选区没塌陷、锚点却被摘掉了(Firefox 的行为):也当"文字没了"
7780
+ if (selectionDetached(range)) scheduleCheck()
7781
+ }
7782
+
7783
+ var floatWatchId = setInterval(watchFloatLiveness, FLOAT_WATCH_MS)
7784
+
7670
7785
  var offMouseDown = listen(document, 'mousedown', function (event) {
7786
+ selectPressedAt = Date.now()
7671
7787
  var target = event.target
7672
7788
  // 模型菜单:点它自己和胶囊之外**任何地方**都收起。
7673
7789
  // 必须放在最前面 —— 下面的 historyButton / pill / panel 分支都会 return,
@@ -8267,7 +8383,20 @@ window.__ModuleLoader__.load({
8267
8383
  })
8268
8384
 
8269
8385
  var offKeyDown = listen(document, 'keydown', function (event) {
8270
- if (event.key !== 'Escape' || !panelOpen) return
8386
+ if (event.key !== 'Escape' || !panelOpen || event.defaultPrevented || event.isComposing || event.keyCode === 229) return
8387
+ // 完整视图记录最后点击/操作的小窗;不能只看可能停在另一窗的键盘焦点。
8388
+ var fullViewFrame = document.querySelector('[data-dsh-full-view]')
8389
+ if (fullViewFrame) {
8390
+ var escapeTarget = event.target
8391
+ var canFindEscapeOwner = escapeTarget && typeof escapeTarget.closest === 'function'
8392
+ var expandedChatSelector = '[data-dsh-floating-chat]:not([data-dsh-chat-minimized]):not([data-dsh-chat-hidden])'
8393
+ var activeFloatingWindow = fullViewFrame.getAttribute('data-dsh-active-floating-window')
8394
+ if (activeFloatingWindow === 'chat' && document.querySelector(expandedChatSelector)) return
8395
+ // 与旧版完整视图共存时仍保留原来的键盘焦点判断。
8396
+ if (activeFloatingWindow === null && canFindEscapeOwner && escapeTarget.closest(expandedChatSelector)) return
8397
+ // 宿主菜单可能挂在聊天外;让它先消费 Esc,不连带关闭解读。
8398
+ if (!panel.contains(escapeTarget) && !historyList.contains(escapeTarget) && canFindEscapeOwner && escapeTarget.closest('[role="menu"], [role="listbox"], [data-trigger-menu]')) return
8399
+ }
8271
8400
  // Esc 分三级:**从最内层往外退**,一次只退一层(用户按一次不该连关两层)。
8272
8401
  // ① 设置抽屉开着 → 只收抽屉,小窗留着(用户多半只是改完想接着看解读);
8273
8402
  // ② 语音进行中(录音行还在)→ 只取消这一次语音(等价于点 ✕,已经说出来的字留着);
@@ -10147,6 +10276,8 @@ window.__ModuleLoader__.load({
10147
10276
  offQuotePopEnd()
10148
10277
  offMouseUp()
10149
10278
  offKeyUp()
10279
+ offSelectionChange()
10280
+ if (floatWatchId) clearInterval(floatWatchId)
10150
10281
  offMouseDown()
10151
10282
  offButtonDown()
10152
10283
  offButtonClick()
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yfwu2020/dsh-selection-explain",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "DSH 划词解读:选中文字浮出按钮,点击弹窗流式显示专业中英翻译 + 该文字在当前上下文中的含义详解",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -42,7 +42,7 @@
42
42
  "scripts": {
43
43
  "build": "bash scripts/build.sh",
44
44
  "typecheck": "tsc -p tsconfig.json --noEmit",
45
- "test": "node scripts/test-filters.mjs && node scripts/test-prompt.mjs && node scripts/test-model-follow.mjs && node scripts/test-bridge.mjs && node scripts/smoke-bridge.mjs && node scripts/smoke-panel.mjs && node scripts/test-client.mjs && node scripts/test-settings-host.mjs && node scripts/test-settings-behavior.mjs && node scripts/test-route-cache.mjs && node scripts/test-transcript.mjs && node scripts/test-guard.mjs && node scripts/test-settings.mjs && node scripts/test-format.mjs && node scripts/test-speech.mjs && node scripts/smoke-voice.mjs",
45
+ "test": "node scripts/test-filters.mjs && node scripts/test-prompt.mjs && node scripts/test-model-follow.mjs && node scripts/test-bridge.mjs && node scripts/smoke-bridge.mjs && node scripts/smoke-panel.mjs && node scripts/test-escape-focus.mjs && node scripts/test-client.mjs && node scripts/test-settings-host.mjs && node scripts/test-settings-behavior.mjs && node scripts/test-route-cache.mjs && node scripts/test-transcript.mjs && node scripts/test-guard.mjs && node scripts/test-settings.mjs && node scripts/test-format.mjs && node scripts/test-speech.mjs && node scripts/smoke-voice.mjs",
46
46
  "test:transcript": "node scripts/test-transcript.mjs",
47
47
  "test:guard": "node scripts/test-guard.mjs",
48
48
  "test:format": "node scripts/test-format.mjs",
@@ -734,6 +734,42 @@ async function main() {
734
734
  }
735
735
  }
736
736
 
737
+ // ⑨-b 选中的文字被删掉 → 浮标跟着消失(用户报的:文字没了,浮标还赖在屏幕上)
738
+ {
739
+ hook.close()
740
+ await sleep(80)
741
+ var planBtn2 = document.querySelector('.dsh-sel-btn:not(.dsh-sel-quotebtn)')
742
+ var docp3 = document.getElementById('docp')
743
+ selectIn(docp3, 'the migration ran long')
744
+ mouseUpOn(docp3)
745
+ var lit = await waitFor(function () {
746
+ return planBtn2 && planBtn2.style.display !== 'none' && planBtn2.getBoundingClientRect().width > 0
747
+ }, 1500)
748
+ check('(准备)主界面划词浮出「✦ 解读」', lit === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
749
+
750
+ // ① 选区塌陷(Backspace / Delete / 打字覆盖 / 剪切走的都是这条):浏览器会发 selectionchange
751
+ window.getSelection().removeAllRanges()
752
+ var gone = await waitFor(function () { return planBtn2.style.display === 'none' }, 800)
753
+ check('选中的文字被删掉(选区塌陷)→ 浮标消失', gone === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
754
+
755
+ // ② 选中的节点被整个换掉(流式回答重渲染就是这条):那段文字没了,浮标也不该留着。
756
+ // ⚠️ 先确认浮标**真的收掉了**再往下走:不然 ① 漏掉时浮标一直亮着,
757
+ // 下面的"再次亮着"会白捡一个 PASS(这条用例就测不出东西了)。
758
+ var settled = await waitFor(function () { return planBtn2.style.display === 'none' }, 800)
759
+ check('(准备 ②)① 之后浮标确实是收着的', settled === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
760
+ selectIn(docp3, 'the migration ran long')
761
+ mouseUpOn(docp3)
762
+ var lit2 = await waitFor(function () {
763
+ return planBtn2 && planBtn2.style.display !== 'none' && planBtn2.getBoundingClientRect().width > 0
764
+ }, 1500)
765
+ check('(准备 ②)浮标再次亮着', lit2 === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
766
+ docp3.textContent = '(这段文字已经被换掉了)'
767
+ var gone2 = await waitFor(function () { return planBtn2.style.display === 'none' }, 800)
768
+ check('选中的节点被摘掉(文字被换掉)→ 浮标也消失', gone2 === true, planBtn2 ? planBtn2.style.display : '未找到按钮')
769
+ docp3.textContent = 'Dev: the migration ran long, so we ship Wednesday.'
770
+ await sleep(30)
771
+ }
772
+
737
773
  // ⑩ 浮标图标的**墨迹**要在画布里居中(用户报的:"✦ 和 解读 好像没对齐,也没在按钮里居中")
738
774
  // 为什么量 getBBox 而不是截图:浮动按钮是 align-items:center 的 flex,居中的是**画布盒子**,
739
775
  // 图形在画布里偏一点,图标就整体偏一点 —— 老星形路径 y 占 1.2~12.4(中心 6.8,不是 8),
@@ -1091,6 +1091,62 @@ assert('选中文字后浮标出现', !!button && button.style.display === 'inli
1091
1091
 
1092
1092
  }
1093
1093
 
1094
+ // ───────────────────────── 选中的文字被删掉 → 浮标必须消失 ─────────────────────────
1095
+ // 用户报的:划词浮标出来之后,把那段文字删了(Backspace / 打字覆盖 / 内容刷新把节点换掉),
1096
+ // 浮标还赖在屏幕上不走,点下去解读的是一段**屏幕上已经不存在**的文字。
1097
+ {
1098
+ const rect = { left: 40, top: 100, right: 200, bottom: 118, width: 160, height: 18 }
1099
+ /** 造一段"落在 node 上"的选区(锚点就是 node —— isConnected 那条判定要用它)。 */
1100
+ const selectOn = (node, text) => {
1101
+ const range = {
1102
+ startContainer: node,
1103
+ endContainer: node,
1104
+ cloneRange: () => range,
1105
+ getClientRects: () => [rect],
1106
+ getBoundingClientRect: () => rect,
1107
+ }
1108
+ windowStub.getSelection = () => ({
1109
+ isCollapsed: false,
1110
+ rangeCount: 1,
1111
+ toString: () => text,
1112
+ getRangeAt: () => range,
1113
+ })
1114
+ }
1115
+
1116
+ // ① 拖拽划词进行中(鼠标按着):不弹浮标 —— 时机要和"松手才弹"一致
1117
+ selectOn(textEl, 'the migration ran long')
1118
+ documentStub.dispatch('mousedown', { target: container })
1119
+ documentStub.dispatch('selectionchange', {})
1120
+ await sleep(20)
1121
+ assert('拖拽中(鼠标按着)不弹浮标', button.style.display === 'none', String(button.style.display))
1122
+ documentStub.dispatch('mouseup', { target: container })
1123
+ await sleep(20)
1124
+ assert('松手后照常弹浮标', button.style.display === 'inline-flex', String(button.style.display))
1125
+
1126
+ // ② 选区塌陷(Backspace / Delete / 打字覆盖 / 剪切走的都是这条):浏览器发 selectionchange
1127
+ windowStub.getSelection = () => ({ isCollapsed: true, rangeCount: 0, toString: () => '' })
1128
+ documentStub.dispatch('selectionchange', {})
1129
+ await sleep(20)
1130
+ assert('选中的文字被删掉(选区塌陷)→ 浮标消失', button.style.display === 'none', String(button.style.display))
1131
+
1132
+ // ③ 选区**没塌陷**、锚点节点却已经被摘掉(Firefox 的行为):靠 isConnected 判死
1133
+ selectOn(textEl, 'the migration ran long')
1134
+ documentStub.dispatch('mouseup', { target: container })
1135
+ await sleep(20)
1136
+ assert('(准备)浮标重新亮着', button.style.display === 'inline-flex', String(button.style.display))
1137
+ textEl.isConnected = false
1138
+ documentStub.dispatch('selectionchange', {})
1139
+ await sleep(20)
1140
+ assert('选中的节点被摘掉(选区没塌陷)→ 浮标也消失', button.style.display === 'none', String(button.style.display))
1141
+ delete textEl.isConnected
1142
+
1143
+ // 还原成开头那段选区:后面的用例还要点这个浮标开小窗
1144
+ windowStub.getSelection = () => selection
1145
+ documentStub.dispatch('mouseup', { target: body })
1146
+ await sleep(20)
1147
+ assert('(还原)浮标回到可见', button.style.display === 'inline-flex', String(button.style.display))
1148
+ }
1149
+
1094
1150
  // 引用(❝)的**离线**契约:浮标进了浮层、CSS 规则齐全(真实交互在宿主可达时另有一段)
1095
1151
  {
1096
1152
  const css = readFileSync(BUNDLE, 'utf8')
@@ -0,0 +1,66 @@
1
+ /** Run the actual document Esc handler with isolated focus and panel state. No host/API access. */
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+ import vm from 'node:vm'
5
+ const source = readFileSync(new URL('../src/client/index.js', import.meta.url), 'utf8')
6
+ const start = source.indexOf(' var offKeyDown = listen(document,')
7
+ const end = source.indexOf('\n\n var offSettingsOpen', start)
8
+ assert.ok(start >= 0 && end > start)
9
+ const ask = {}, historyInput = {}, outside = {}, expandedChat = { closest: selector => selector.startsWith('[data-dsh-floating-chat]') ? {} : null }, compactChat = { closest: () => null }, nativeMenu = { closest: selector => selector.startsWith('[role=') ? {} : null }
10
+ let handler, fullView = true, calls = [], activeWindow = null, chatExpanded = true
11
+ const context = vm.createContext({
12
+ panelOpen: true, settingsOpen: false,
13
+ panel: { contains: node => node === ask },
14
+ historyList: { contains: node => node === historyInput },
15
+ document: { querySelector: selector => selector === '[data-dsh-full-view]' && fullView
16
+ ? { getAttribute: () => activeWindow }
17
+ : selector.startsWith('[data-dsh-floating-chat]') && chatExpanded ? {} : null },
18
+ listen(target, type, callback, capture) { assert.equal(type, 'keydown'); assert.equal(capture, true); handler = callback },
19
+ captureRow: { getAttribute: () => '0' }, settingsByKey: {},
20
+ closeModelPicks() { calls.push('menu') },
21
+ closeSettings() { calls.push('settings'); context.settingsOpen = false },
22
+ cancelVoice() { calls.push('voice') },
23
+ closePanel() { calls.push('panel'); context.panelOpen = false },
24
+ })
25
+ new vm.Script(source.slice(start, end)).runInContext(context)
26
+ function escape(target, extra = {}) {
27
+ const event = { key: 'Escape', target, ...extra, preventDefault() { calls.push('prevent') }, stopPropagation() { calls.push('stop') } }
28
+ handler(event)
29
+ }
30
+ function reset() { calls = []; activeWindow = null; chatExpanded = true; context.panelOpen = true; context.settingsOpen = false; context.settingsByKey = {}; context.captureRow.getAttribute = () => '0' }
31
+ reset(); activeWindow = 'chat'; escape(ask)
32
+ assert.deepEqual(calls, [], '最后点击聊天后,即使键盘仍在解读输入框,也让聊天先退出')
33
+ reset(); activeWindow = 'explanation'; escape(expandedChat)
34
+ assert.equal(context.panelOpen, false, '最后点击解读消息后,即使键盘仍在聊天,也先退解读')
35
+ reset(); activeWindow = 'none'; escape(expandedChat)
36
+ assert.equal(context.panelOpen, false, '点击两窗外后不使用旧键盘焦点,优先退解读')
37
+ reset(); activeWindow = 'chat'; chatExpanded = false; escape(compactChat)
38
+ assert.equal(context.panelOpen, false, '已收起的聊天不占下一次退出顺序')
39
+ reset()
40
+ escape(outside)
41
+ assert.equal(context.panelOpen, false, '焦点在两窗之外时优先关闭解读')
42
+ reset(); escape(expandedChat)
43
+ assert.deepEqual(calls, [], '展开聊天的 Esc 交给聊天,不关闭解读')
44
+ assert.equal(context.panelOpen, true)
45
+ reset(); escape(compactChat)
46
+ assert.equal(context.panelOpen, false, '聊天收起后焦点留在紧凑条,再按 Esc 仍能关闭解读')
47
+ reset(); escape(nativeMenu)
48
+ assert.deepEqual(calls, [], '原生菜单先处理自身 Esc,不连带关闭解读')
49
+ for (const extra of [{ isComposing: true }, { keyCode: 229 }, { defaultPrevented: true }]) {
50
+ reset(); escape(ask, extra); assert.deepEqual(calls, [], '输入法或已处理的 Esc 不关闭解读')
51
+ }
52
+ reset(); context.settingsOpen = true; context.settingsByKey = { model: { menu: { getAttribute: () => '1' } } }; escape(ask)
53
+ assert.deepEqual(calls, ['prevent', 'stop', 'menu'])
54
+ assert.equal(context.panelOpen, true)
55
+ reset(); context.settingsOpen = true; escape(ask)
56
+ assert.deepEqual(calls, ['prevent', 'stop', 'settings'])
57
+ assert.equal(context.panelOpen, true)
58
+ reset(); context.captureRow.getAttribute = () => '1'; escape(ask)
59
+ assert.deepEqual(calls, ['prevent', 'stop', 'voice'])
60
+ assert.equal(context.panelOpen, true)
61
+ for (const target of [ask, historyInput]) {
62
+ reset(); escape(target); assert.deepEqual(calls, ['prevent', 'stop', 'panel']); assert.equal(context.panelOpen, false)
63
+ }
64
+ reset(); fullView = false; escape(outside)
65
+ assert.equal(context.panelOpen, false, '普通分栏保留解读原来的全局 Esc 行为')
66
+ console.log('PASS focused Esc: independent windows, IME, nested settings, voice, history and split-mode behavior')
@@ -217,6 +217,22 @@ window.__ModuleLoader__.load({
217
217
  return value < min ? min : value > max ? max : value
218
218
  }
219
219
 
220
+ /**
221
+ * 选区是不是"指着一段已经不在文档里的文字"。
222
+ *
223
+ * 这是"选中的文字被删掉"的第二种形态,跟塌陷(`isCollapsed`)**不是**一回事:
224
+ * 节点被整个摘掉时(流式回答重渲染 / 消息被替换 / 内容区刷新),Firefox 里选区并不塌陷 ——
225
+ * `isCollapsed` 仍是 false、`toString()` 还吐得出那段旧文字,可那段文字屏幕上已经没有了。
226
+ * 只认塌陷的话浮标会赖着不走,点下去解读的是一段**不存在的文字**。
227
+ *
228
+ * 取不到 `isConnected`(老环境 / 无浏览器测试的桩 DOM)时一律当"还在":
229
+ * 宁可多留一会儿,也不误收一个正常选区。
230
+ */
231
+ function selectionDetached(range) {
232
+ var node = range && range.startContainer
233
+ return !!node && node.isConnected === false
234
+ }
235
+
220
236
  /** 选区是否落在输入框里(那里不该弹按钮,避免干扰输入)。 */
221
237
  function insideEditable(node) {
222
238
  var element = node && node.nodeType === 1 ? node : node && node.parentElement
@@ -3513,8 +3529,23 @@ window.__ModuleLoader__.load({
3513
3529
 
3514
3530
  // —— 选区 → 浮标 ——
3515
3531
 
3532
+ /**
3533
+ * 排一次选区检查(下一拍执行)。
3534
+ *
3535
+ * 同一拍里的多次请求**合并成一次**:`selectionchange` 在打字 / 流式重绘时会连发很多下,
3536
+ * 每下都排一个计时器的话 `timers` 数组会一直长(文件里别处也刻意避开这种写法,
3537
+ * 见 schedulePillIdle 的注释)。合并是安全的 —— 检查读的是"当下"的选区,
3538
+ * 而不是排队那一刻的快照。
3539
+ */
3540
+ var checkPending = false
3541
+
3516
3542
  function scheduleCheck() {
3517
- later(checkSelection, 0)
3543
+ if (checkPending) return
3544
+ checkPending = true
3545
+ later(function () {
3546
+ checkPending = false
3547
+ checkSelection()
3548
+ }, 0)
3518
3549
  }
3519
3550
 
3520
3551
  /**
@@ -3614,6 +3645,12 @@ window.__ModuleLoader__.load({
3614
3645
  } catch (error) {
3615
3646
  return
3616
3647
  }
3648
+ // 选中的节点被整个摘掉了(流式回答重渲染 / 消息换掉):那段文字已经不在屏幕上
3649
+ if (selectionDetached(range)) {
3650
+ state.quoteSelection = null
3651
+ hideQuoteButton()
3652
+ return
3653
+ }
3617
3654
  var text = String(selection.toString() || '')
3618
3655
  if (!text.trim() || text.length > MAX_QUOTE_SELECTION) {
3619
3656
  state.quoteSelection = null
@@ -3698,6 +3735,12 @@ window.__ModuleLoader__.load({
3698
3735
  return
3699
3736
  }
3700
3737
  var range = selection.getRangeAt(0)
3738
+ // 选中的节点被整个摘掉了(流式回答重渲染 / 消息换掉):那段文字已经不在屏幕上,
3739
+ // 浮标不该留着(否则点下去解读的是一段不存在的文字)
3740
+ if (selectionDetached(range)) {
3741
+ hideButton()
3742
+ return
3743
+ }
3701
3744
  if (insideEditable(range.startContainer)) {
3702
3745
  hideButton()
3703
3746
  return
@@ -7653,7 +7696,25 @@ window.__ModuleLoader__.load({
7653
7696
  setQuotePop(false)
7654
7697
  })
7655
7698
 
7699
+ /**
7700
+ * 鼠标是不是按着(拖拽划词进行中)—— 记的是"按下那一刻"。
7701
+ *
7702
+ * 为什么记时刻而不是记一个布尔:松手落在浏览器窗口外时**不会补发 mouseup**,
7703
+ * 布尔一旦卡在 true,`selectionchange` 就被永久忽略 —— 浮标从此再也不会自己收了。
7704
+ * 超过 SELECT_PRESS_STALE_MS 就当已经松开(帧内那条桥用的是同一个兜底,见 bridgeBody)。
7705
+ *
7706
+ * 拖拽期间一律不查选区:时机要和"松手才弹浮标"一致,否则拖到一半浮标就冒出来,
7707
+ * 还跟着手指跑。
7708
+ */
7709
+ var SELECT_PRESS_STALE_MS = 6000
7710
+ var selectPressedAt = 0
7711
+
7712
+ function selectPressing() {
7713
+ return selectPressedAt > 0 && Date.now() - selectPressedAt < SELECT_PRESS_STALE_MS
7714
+ }
7715
+
7656
7716
  var offMouseUp = listen(document, 'mouseup', function (event) {
7717
+ selectPressedAt = 0
7657
7718
  // 面板里的 mouseup **也要走一次检查**:小窗开着时"划小窗正文"正是「引用」的主入口
7658
7719
  // (早先这里对面板直接 return —— 那是"面板里划词一律不管"年代的写法,
7659
7720
  // 结果就是小窗里划词毫无反应,只有气泡末尾的「引用整条」能用)。
@@ -7667,7 +7728,62 @@ window.__ModuleLoader__.load({
7667
7728
  }
7668
7729
  }, true)
7669
7730
 
7731
+ /**
7732
+ * 选中的文字**没了** → 浮标跟着消失。
7733
+ *
7734
+ * 为什么非要有这一条:浮标只在 mouseup 和少数几个键的 keyup 之后被检查,
7735
+ * 而"把选中的字删掉"这两种动作都不在那条路径上 ——
7736
+ * · Backspace / Delete / 直接打字覆盖 / 剪切 / 撤回(⌘Z);
7737
+ * · 内容刷新把选中的节点整个换掉(流式回答重渲染、消息重画)。
7738
+ * 于是浮标赖在屏幕上不走,点下去还会解读一段**屏幕上已经不存在**的文字(用户报的正是这个)。
7739
+ *
7740
+ * `selectionchange` 是浏览器对"选区变了"最权威的信号(塌陷 / 被删 / 被程序改动都会发),
7741
+ * 用它兜住所有非鼠标路径;节点被摘掉但选区没塌陷那种(Firefox)另有 selectionDetached 判死。
7742
+ */
7743
+ var offSelectionChange = listen(document, 'selectionchange', function () {
7744
+ if (selectPressing()) return
7745
+ scheduleCheck()
7746
+ }, true)
7747
+
7748
+ /**
7749
+ * 浮标亮着的时候,隔几拍确认一次"那段文字还在不在"。
7750
+ *
7751
+ * 为什么光有事件还不够:**Chrome 把选中的节点整个摘掉时会悄悄把选区塌陷,却不发
7752
+ * `selectionchange`**(真浏览器实测:替换 textContent 后 `rangeCount` 仍是 1、
7753
+ * `isCollapsed` 变成 true,而事件计数一个都不涨)。流式回答重渲染 / 消息重画走的正是这条路 ——
7754
+ * 只靠事件的话,浮标会一直亮着,点下去解读的是一段屏幕上已经没有的文字。
7755
+ *
7756
+ * 代价是可控的:**只在浮标可见的那几拍里干活**(它不可见时这一拍立刻返回,
7757
+ * 连选区都不读),而且只有发现选区没了/锚点被摘掉时才去排一次检查 ——
7758
+ * 正常亮着的那几拍不做任何 DOM 写。
7759
+ */
7760
+ var FLOAT_WATCH_MS = 400
7761
+
7762
+ function watchFloatLiveness() {
7763
+ // 拖拽划词进行中不查:浮标要等松手才出现(与 mouseup 那条路径同一套时机)
7764
+ if (selectPressing()) return
7765
+ if (button.style.display !== 'inline-flex' && quoteButton.style.display !== 'inline-flex') return
7766
+ var selection = window.getSelection && window.getSelection()
7767
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) {
7768
+ // 帧内那条选区**不在本文档**:点浮标会让本文档的选区塌掉,别把它误当成"文字没了"
7769
+ if (state.bridgeActive && state.selection && state.selection.source === 'iframe') return
7770
+ scheduleCheck()
7771
+ return
7772
+ }
7773
+ var range = null
7774
+ try {
7775
+ range = selection.getRangeAt(0)
7776
+ } catch (error) {
7777
+ return
7778
+ }
7779
+ // 选区没塌陷、锚点却被摘掉了(Firefox 的行为):也当"文字没了"
7780
+ if (selectionDetached(range)) scheduleCheck()
7781
+ }
7782
+
7783
+ var floatWatchId = setInterval(watchFloatLiveness, FLOAT_WATCH_MS)
7784
+
7670
7785
  var offMouseDown = listen(document, 'mousedown', function (event) {
7786
+ selectPressedAt = Date.now()
7671
7787
  var target = event.target
7672
7788
  // 模型菜单:点它自己和胶囊之外**任何地方**都收起。
7673
7789
  // 必须放在最前面 —— 下面的 historyButton / pill / panel 分支都会 return,
@@ -8267,7 +8383,20 @@ window.__ModuleLoader__.load({
8267
8383
  })
8268
8384
 
8269
8385
  var offKeyDown = listen(document, 'keydown', function (event) {
8270
- if (event.key !== 'Escape' || !panelOpen) return
8386
+ if (event.key !== 'Escape' || !panelOpen || event.defaultPrevented || event.isComposing || event.keyCode === 229) return
8387
+ // 完整视图记录最后点击/操作的小窗;不能只看可能停在另一窗的键盘焦点。
8388
+ var fullViewFrame = document.querySelector('[data-dsh-full-view]')
8389
+ if (fullViewFrame) {
8390
+ var escapeTarget = event.target
8391
+ var canFindEscapeOwner = escapeTarget && typeof escapeTarget.closest === 'function'
8392
+ var expandedChatSelector = '[data-dsh-floating-chat]:not([data-dsh-chat-minimized]):not([data-dsh-chat-hidden])'
8393
+ var activeFloatingWindow = fullViewFrame.getAttribute('data-dsh-active-floating-window')
8394
+ if (activeFloatingWindow === 'chat' && document.querySelector(expandedChatSelector)) return
8395
+ // 与旧版完整视图共存时仍保留原来的键盘焦点判断。
8396
+ if (activeFloatingWindow === null && canFindEscapeOwner && escapeTarget.closest(expandedChatSelector)) return
8397
+ // 宿主菜单可能挂在聊天外;让它先消费 Esc,不连带关闭解读。
8398
+ if (!panel.contains(escapeTarget) && !historyList.contains(escapeTarget) && canFindEscapeOwner && escapeTarget.closest('[role="menu"], [role="listbox"], [data-trigger-menu]')) return
8399
+ }
8271
8400
  // Esc 分三级:**从最内层往外退**,一次只退一层(用户按一次不该连关两层)。
8272
8401
  // ① 设置抽屉开着 → 只收抽屉,小窗留着(用户多半只是改完想接着看解读);
8273
8402
  // ② 语音进行中(录音行还在)→ 只取消这一次语音(等价于点 ✕,已经说出来的字留着);
@@ -10147,6 +10276,8 @@ window.__ModuleLoader__.load({
10147
10276
  offQuotePopEnd()
10148
10277
  offMouseUp()
10149
10278
  offKeyUp()
10279
+ offSelectionChange()
10280
+ if (floatWatchId) clearInterval(floatWatchId)
10150
10281
  offMouseDown()
10151
10282
  offButtonDown()
10152
10283
  offButtonClick()