@yfwu2020/dsh-selection-explain 0.8.3 → 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` 也能摸到输入框和 🎤。
@@ -356,6 +357,42 @@ curl -s http://127.0.0.1:3080/selection-explain/api/ping
356
357
 
357
358
  ---
358
359
 
360
+ ## 档位兜底:「关」档不被支持时自动降级
361
+
362
+ 有些模型**不接受关闭推理**(上游直接 `400 invalid_request_error`)。首轮默认就是 `off`,
363
+ 撞上这种模型时整个划词解读会全挂。
364
+
365
+ 处理方式:
366
+
367
+ - 撞到一次就**记住这个 provider/model**(`${DSH_HOME:-~/.dsh}/selection-explain/leaky-off.json`
368
+ 的 `noOff` 字段),改用 **`low`**(最接近"关"的可接受档位)重试同一轮,并推一条 notice 说明。
369
+ - **下次不再尝试 `off`** —— 直接发 `low`,不再先失败一轮(省一个来回的时延,也不闪报错)。
370
+ - 重试只在"这一轮一个字都还没吐出来"时进行:已经出正文的失败不重试,避免把半截答案丢掉。
371
+ - 兜底覆盖**三种轮次**(首轮 / 详解 / 追问)。以前只对追问生效、而且要客户端显式传了档位才触发,
372
+ 所以首轮撞上这种模型时无人兜底。
373
+
374
+ ## 拖动与改大小
375
+
376
+ 小窗可以拖标题栏挪位置,拖右下角(或右/下边缘)改大小。
377
+
378
+ **位置和尺寸是两回事,只有尺寸会记住:**
379
+
380
+ - **位置不记**。每次开窗都按选区重新锚定:落在选区下方,下方放不下就翻到选区上方,
381
+ 再夹进视口。拖动只对这一次有效,关掉重开就回到划词的地方 —— 这是刻意的设计。
382
+ - **尺寸记**。拖角调过的大小存在 host 上(`${DSH_HOME:-~/.dsh}/selection-explain/panel.json`,
383
+ 接口 `GET/POST /selection-explain/api/panel`,载荷只有 `{w,h}`),
384
+ 刷新、换标签页、换浏览器 profile 都回到那个大小。
385
+ - **双击右下角角标**恢复默认尺寸(清掉存档)。
386
+
387
+ 其余约定:
388
+
389
+ - 尺寸下限 320×240,且始终夹在视口内(窗口缩小也不会超出)。上限是视口大小。
390
+ - 用户拉过大小后,设置页那条约 560px 的高度下限就**不再插手**(`data-resized` 标记)。
391
+ - 改动过程中不写盘,松手后防抖 300ms 再存 —— 不然每帧一个请求。
392
+ - 改大小用 **pointer 事件**(不是 mouse):触控板/触屏一样能拖,并且 `setPointerCapture`
393
+ 保证鼠标划出窗口也不会丢掉这一轮。
394
+ - 只有**真的拖动过**才会触发存档逻辑(点一下角标不算改了大小)。
395
+
359
396
  ## 配置
360
397
 
361
398
  ### 方式一:在小窗里改(推荐)
package/lib/client.js CHANGED
@@ -29,6 +29,8 @@ window.__ModuleLoader__.load({
29
29
  var PROMOTE = '/selection-explain/api/promote'
30
30
  /** 胶囊拖到别处后的位置(记在 host 上:拖到别处→存,吸附回右下角→清,挂载→读回来)。 */
31
31
  var PILL_POS = '/selection-explain/api/pill'
32
+ /** 小窗的**尺寸**(改过大小之后记在 host 上,刷新后回到那个大小)。位置不记。 */
33
+ var PANEL_SIZE = '/selection-explain/api/panel'
32
34
  var HISTORY = '/selection-explain/api/history'
33
35
  /** 模型清单(带每个模型支持的推理等级)。 */
34
36
  var MODELS = '/selection-explain/api/models'
@@ -215,6 +217,22 @@ window.__ModuleLoader__.load({
215
217
  return value < min ? min : value > max ? max : value
216
218
  }
217
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
+
218
236
  /** 选区是否落在输入框里(那里不该弹按钮,避免干扰输入)。 */
219
237
  function insideEditable(node) {
220
238
  var element = node && node.nodeType === 1 ? node : node && node.parentElement
@@ -330,11 +348,47 @@ window.__ModuleLoader__.load({
330
348
  '.dsh-sel-layer[data-theme=dark]{--sel-a1:#4ecdc4;--sel-a1-text:#6fe3d8;--sel-a2:#4ecdc4;--sel-a2-text:#6fe3d8;',
331
349
  '--sel-fill:#4ecdc4;--sel-fill-fg:#052b26;',
332
350
  '--sel-a2-soft:color-mix(in srgb,var(--sel-a2) 16%,transparent);--sel-a1-soft:color-mix(in srgb,var(--sel-a1) 16%,transparent)}',
333
- '.dsh-sel-panel{position:fixed;display:none;flex-direction:column;width:min(540px,calc(100vw - 20px));max-height:min(78vh,720px);',
334
- '.dsh-sel-panel[data-stage=translation]{width:min(440px,calc(100vw - 20px))}',
351
+ // ⚠️ 声明块必须在**同一段字符串里收尾**。
352
+ // 以前这里是 `...max-height:...;` 后面直接接了一条完整规则 `[data-stage=translation]{...}`,
353
+ // `.dsh-sel-panel{` 的 `}` 于是永远没出现 —— 那条阶段规则被当成块内的**非法声明**整条丢弃,
354
+ // "首轮收窄到 440" 从来没生效过(实测计算宽度一直是 540)。后面几段的 border-radius /
355
+ // overflow 之所以没事,是因为 CSS 出错恢复会跳过坏声明继续往下读。
356
+ // 单测只断言了 data-stage 这个**属性**,而桩 DOM 没有布局,所以一直没暴露。
357
+ // box-sizing:border-box 是**必须**的:拖角改大小时我们把 getBoundingClientRect()(含边框)
358
+ // 的宽高写回 style.width/height,而它们默认按 content-box 解释 —— 差一个边框宽(1px),
359
+ // 于是"存进去 → 读回来"每来一轮就胖 1px,拖几次就明显走形(实测 584 存进去、读回来 586)。
360
+ '.dsh-sel-panel{position:fixed;display:none;flex-direction:column;box-sizing:border-box;width:min(540px,calc(100vw - 20px));max-height:min(78vh,720px);',
335
361
  'border-radius:16px;overflow:hidden;background:var(--dsw-alias-bg-layer-1,Canvas);color:var(--dsw-alias-label-primary,CanvasText);',
336
362
  'border:.5px solid var(--dsw-alias-border-l3,rgba(140,140,140,.35));box-shadow:0 20px 52px rgba(0,0,0,.3);',
337
363
  'font:400 13.5px/1.7 ' + FONT + '}',
364
+ // 这里曾经有一条 `.dsh-sel-panel[data-stage=translation]{width:440px}`:
365
+ // "首轮只出翻译时收窄一点"。它因为上面那个漏掉的 `}` 一直是死代码,修好闭合之后才真正生效,
366
+ // 一生效就暴露了问题 —— 宽度会**在追问那一刻从 440 跳到 540**(见 syncPanelStage 的判定),
367
+ // 用户看到同一个窗口自己变宽,问"怎么有追问和没追问的宽度不一样"。
368
+ // 决定:**统一成 540,宽度不随阶段变**。少一档就没这档子事,也没有"什么时候会跳"的心智负担。
369
+ // (data-stage 本身保留 —— 它还决定追问输入框显不显示,见 renderPanel。)
370
+ // 设置抽屉开着时只补**高度**下限,**不动宽度**。
371
+ //
372
+ // 抽屉是 inset:0 贴在面板上的:面板多宽,抽屉就多宽 —— 这是要的(设置窗口与小窗等宽,
373
+ // 开抽屉时小窗不该"胖一圈")。但高度不能不管:面板高度是由"解读内容有多长"撑出来的,
374
+ // 刚开窗(只出了翻译)时面板才 ~115px 高,设置列表只剩 17px 可视高度(实测)。
375
+ // 所以只给 min-height;宽度交给面板自己的规则(首轮 440 / 其余 540),抽屉自动跟随。
376
+ // min-height 用 min() 与 max-height 同源,视口矮时两者相等,不会顶出屏幕。
377
+ // 只在**用户没自己改过尺寸**时才补这个下限:他拉过大小就按他的来(min-height 会压过 inline height)
378
+ '.dsh-sel-panel[data-settings="1"]:not([data-resized="1"]){min-height:min(560px,78vh)}',
379
+ // 改大小手柄:右下角一个角标 + 右边/下边各一条(细,不抢视线;hover/拖动时才明显)
380
+ // z-index 比设置抽屉(6)高 —— 但抽屉开着时整体隐藏(见下面的规则):
381
+ // 抽屉页脚右侧就是「关闭」按钮,角标压上去会误点。
382
+ '.dsh-sel-grip{position:absolute;z-index:7;background:transparent;touch-action:none}',
383
+ '.dsh-sel-grip-corner{right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize}',
384
+ '.dsh-sel-grip-r{right:0;top:12px;bottom:16px;width:6px;cursor:ew-resize}',
385
+ '.dsh-sel-grip-b{left:12px;right:16px;bottom:0;height:6px;cursor:ns-resize}',
386
+ // 手柄**不画任何常驻标识**:以前角上有一道小斜纹(两条 1.5px 边框拼的直角),
387
+ // 在浅色面板上看着像多出来的一笔,用户明确要求去掉。
388
+ // 保留 hover 时边缘的高亮(只在鼠标移上去时出现,不占视觉)——
389
+ // 加上 cursor: nwse-resize / ew-resize / ns-resize,可发现性还在。
390
+ // 抽屉开着时收起手柄(页脚右侧是「关闭」按钮,别让角标压上去)
391
+ '.dsh-sel-panel[data-settings="1"] .dsh-sel-grip{display:none}',
338
392
  '.dsh-sel-head{display:flex;align-items:center;gap:8px;padding:10px 10px 10px 14px;cursor:grab;user-select:none;',
339
393
  'border-bottom:.5px solid var(--dsw-alias-border-l4,rgba(140,140,140,.22))}',
340
394
  '.dsh-sel-head:active{cursor:grabbing}',
@@ -2481,6 +2535,15 @@ window.__ModuleLoader__.load({
2481
2535
  settingsSheet.appendChild(settingsHead)
2482
2536
  settingsSheet.appendChild(settingsMain)
2483
2537
  settingsSheet.appendChild(settingsFoot)
2538
+ // ── 改大小手柄(右下角 + 右边缘 + 下边缘)──
2539
+ // 小窗的尺寸/位置都由这里驱动;拖完记在 host 上(见 savePanelBox)。
2540
+ var gripCorner = el('div', 'dsh-sel-grip dsh-sel-grip-corner')
2541
+ var gripRight = el('div', 'dsh-sel-grip dsh-sel-grip-r')
2542
+ var gripBottom = el('div', 'dsh-sel-grip dsh-sel-grip-b')
2543
+ gripCorner.title = '拖动改大小(双击恢复默认)'
2544
+ panel.appendChild(gripCorner)
2545
+ panel.appendChild(gripRight)
2546
+ panel.appendChild(gripBottom)
2484
2547
  panel.appendChild(settingsScrim)
2485
2548
  panel.appendChild(settingsSheet)
2486
2549
 
@@ -3292,6 +3355,8 @@ window.__ModuleLoader__.load({
3292
3355
  function openSettings() {
3293
3356
  if (settingsOpen) return
3294
3357
  settingsOpen = true
3358
+ // 抽屉贴着面板(inset:0),所以给面板一个合适的尺寸下限 —— 否则内容短时抽屉被压成一条
3359
+ panel.setAttribute('data-settings', '1')
3295
3360
  settingsSheet.setAttribute('data-open', '1')
3296
3361
  settingsScrim.setAttribute('data-open', '1')
3297
3362
  settingsButton.setAttribute('aria-pressed', 'true')
@@ -3306,6 +3371,7 @@ window.__ModuleLoader__.load({
3306
3371
  function closeSettings() {
3307
3372
  if (!settingsOpen) return
3308
3373
  settingsOpen = false
3374
+ panel.removeAttribute('data-settings')
3309
3375
  closeModelPicks()
3310
3376
  settingsSheet.setAttribute('data-open', '0')
3311
3377
  settingsScrim.setAttribute('data-open', '0')
@@ -3463,8 +3529,23 @@ window.__ModuleLoader__.load({
3463
3529
 
3464
3530
  // —— 选区 → 浮标 ——
3465
3531
 
3532
+ /**
3533
+ * 排一次选区检查(下一拍执行)。
3534
+ *
3535
+ * 同一拍里的多次请求**合并成一次**:`selectionchange` 在打字 / 流式重绘时会连发很多下,
3536
+ * 每下都排一个计时器的话 `timers` 数组会一直长(文件里别处也刻意避开这种写法,
3537
+ * 见 schedulePillIdle 的注释)。合并是安全的 —— 检查读的是"当下"的选区,
3538
+ * 而不是排队那一刻的快照。
3539
+ */
3540
+ var checkPending = false
3541
+
3466
3542
  function scheduleCheck() {
3467
- later(checkSelection, 0)
3543
+ if (checkPending) return
3544
+ checkPending = true
3545
+ later(function () {
3546
+ checkPending = false
3547
+ checkSelection()
3548
+ }, 0)
3468
3549
  }
3469
3550
 
3470
3551
  /**
@@ -3564,6 +3645,12 @@ window.__ModuleLoader__.load({
3564
3645
  } catch (error) {
3565
3646
  return
3566
3647
  }
3648
+ // 选中的节点被整个摘掉了(流式回答重渲染 / 消息换掉):那段文字已经不在屏幕上
3649
+ if (selectionDetached(range)) {
3650
+ state.quoteSelection = null
3651
+ hideQuoteButton()
3652
+ return
3653
+ }
3567
3654
  var text = String(selection.toString() || '')
3568
3655
  if (!text.trim() || text.length > MAX_QUOTE_SELECTION) {
3569
3656
  state.quoteSelection = null
@@ -3648,6 +3735,12 @@ window.__ModuleLoader__.load({
3648
3735
  return
3649
3736
  }
3650
3737
  var range = selection.getRangeAt(0)
3738
+ // 选中的节点被整个摘掉了(流式回答重渲染 / 消息换掉):那段文字已经不在屏幕上,
3739
+ // 浮标不该留着(否则点下去解读的是一段不存在的文字)
3740
+ if (selectionDetached(range)) {
3741
+ hideButton()
3742
+ return
3743
+ }
3651
3744
  if (insideEditable(range.startContainer)) {
3652
3745
  hideButton()
3653
3746
  return
@@ -5225,6 +5318,209 @@ window.__ModuleLoader__.load({
5225
5318
  return { left: rect.left, top: rect.top, right: rect.right, bottom: rect.bottom, width: rect.width, height: rect.height }
5226
5319
  }
5227
5320
 
5321
+ /**
5322
+ * 小窗的尺寸(用户拖角改过之后才有值;null = 用 CSS 里的默认尺寸)。
5323
+ *
5324
+ * **只记尺寸,不记位置**:小窗每次开窗都必须按选区重新锚定(见 showPanel),
5325
+ * 这是既有的设计 —— 位置一旦被记住,开窗就不再跟着划词的地方走,等于把那套锚定架空了。
5326
+ * 尺寸是用户明确调过的东西,跨开窗保留才合理。
5327
+ */
5328
+ var panelSize = null
5329
+ var panelSizeAsked = false
5330
+ var panelSizeTimer = 0
5331
+ /** 正在拖手柄 / 拖标题栏时,别让 window resize 的夹取插一脚。 */
5332
+ var panelInteracting = false
5333
+ /**
5334
+ * 尺寸下限:比"设置页要放得下"低一点就行。真实约束在 CSS/JS 里:
5335
+ * 太窄了模型下拉和提示文案会挤成一团,太矮了正文没得看。
5336
+ */
5337
+ var PANEL_MIN_W = 320
5338
+ var PANEL_MIN_H = 240
5339
+
5340
+ /**
5341
+ * 开窗时应用"记住的尺寸"。
5342
+ *
5343
+ * ⚠️ 宽度是**固定值**,高度只能是**上限**(max-height)。
5344
+ *
5345
+ * 高度要是写成固定值,新开一个会话(内容只有一小段翻译)时面板会硬撑到上次那么大,
5346
+ * 下面留一大片空白 —— 那是回退。以前高度一直是内容撑的(`max-height` 只是个天花板),
5347
+ * 这个手感必须保住:内容短就短,内容长才长到上限、再滚动。
5348
+ *
5349
+ * 所以每次开窗都把 `height` **清掉**(回到自适应),只把用户调过的高度当作新的天花板。
5350
+ * 用户当场拖出来的尺寸在**本次**打开里照旧生效(拖动时直接写 inline height),
5351
+ * 只是下次开窗会重新按内容自适应 —— 既没有空白,拖动也看得见反馈。
5352
+ */
5353
+ function applyPanelSizeOnOpen() {
5354
+ // 先还原成"内容撑高 + CSS 默认天花板"
5355
+ panel.style.height = ''
5356
+ panel.style.maxHeight = ''
5357
+ if (!panelSize) {
5358
+ panel.style.width = ''
5359
+ panel.removeAttribute('data-resized')
5360
+ return
5361
+ }
5362
+ panel.style.width = Math.round(panelSize.w) + 'px'
5363
+ panel.style.maxHeight = Math.round(panelSize.h) + 'px'
5364
+ // 打过标记:设置页那条约 560 的 min-height 就别再插手了(用户已经自己定了高)
5365
+ panel.setAttribute('data-resized', '1')
5366
+ }
5367
+
5368
+ /** 当前尺寸(读实际渲染值,比拿变量算更稳 —— 拖拽过程中变量会漂)。 */
5369
+ function currentPanelSize() {
5370
+ var rect = panel.getBoundingClientRect()
5371
+ return { w: rect.width, h: rect.height }
5372
+ }
5373
+
5374
+ /** 夹进视口:改大小/窗口变化后都调,保证手柄还在屏幕里、小窗不会超出可视区。 */
5375
+ function clampPanelSize(size) {
5376
+ var maxW = Math.max(PANEL_MIN_W, window.innerWidth - 8)
5377
+ var maxH = Math.max(PANEL_MIN_H, window.innerHeight - 8)
5378
+ return {
5379
+ w: Math.min(maxW, Math.max(PANEL_MIN_W, size.w)),
5380
+ h: Math.min(maxH, Math.max(PANEL_MIN_H, size.h)),
5381
+ }
5382
+ }
5383
+
5384
+ /**
5385
+ * 把面板夹回可视区。
5386
+ *
5387
+ * **每次尺寸变化之后都要调**:位置是当初按旧尺寸算好的,尺寸一变就可能把面板顶出屏幕。
5388
+ * 用户报的就是这个 —— 小窗贴着屏幕边缘时双击角标恢复默认尺寸,一部分直接跑到屏幕外。
5389
+ * 优先保住左上角(那是用户看到的锚点),右边/下边放不下才往回收。
5390
+ */
5391
+ function clampPanelPosition() {
5392
+ var rect = panel.getBoundingClientRect()
5393
+ var maxLeft = Math.max(4, window.innerWidth - rect.width - 4)
5394
+ var maxTop = Math.max(4, window.innerHeight - rect.height - 4)
5395
+ panel.style.left = Math.round(Math.min(Math.max(rect.left, 4), maxLeft)) + 'px'
5396
+ panel.style.top = Math.round(Math.min(Math.max(rect.top, 4), maxTop)) + 'px'
5397
+ if (historyList.style.display === 'flex') placeHistoryList()
5398
+ }
5399
+
5400
+ /** 存到 host(防抖:拖动过程中每帧都存会把接口打爆)。 */
5401
+ function savePanelSize() {
5402
+ if (panelSizeTimer) clearTimeout(panelSizeTimer)
5403
+ panelSizeTimer = setTimeout(function () {
5404
+ panelSizeTimer = 0
5405
+ var size = clampPanelSize(currentPanelSize())
5406
+ panelSize = size
5407
+ fetch(PANEL_SIZE, {
5408
+ method: 'POST',
5409
+ headers: { 'content-type': 'application/json' },
5410
+ body: JSON.stringify({ size: size }),
5411
+ }).catch(function () { /* 存不上不影响使用,下次再改还会存 */ })
5412
+ }, 300)
5413
+ }
5414
+
5415
+ /** 挂载时问一次(异步,回来再摆;用户已经动过就以用户的为准)。 */
5416
+ function loadPanelSize() {
5417
+ if (panelSizeAsked) return
5418
+ panelSizeAsked = true
5419
+ fetch(PANEL_SIZE)
5420
+ .then(function (res) { return res.json() })
5421
+ .then(function (data) {
5422
+ var size = data && data.size
5423
+ if (!size || panelInteracting) return
5424
+ if (typeof size.w !== 'number' || typeof size.h !== 'number') return
5425
+ panelSize = clampPanelSize(size)
5426
+ if (panelOpen) {
5427
+ applyPanelSizeOnOpen()
5428
+ clampPanelPosition()
5429
+ }
5430
+ })
5431
+ .catch(function () {})
5432
+ }
5433
+
5434
+ /**
5435
+ * 恢复默认尺寸(双击右下角)。
5436
+ *
5437
+ * 和"高度自适应"是**同一个动作**:把整份存档清掉 ——
5438
+ * 宽度回到 CSS 默认(540 / 首轮阶段 440),高度清成 auto 由内容撑(也就是自适应)。
5439
+ *
5440
+ * ⚠️ 必须走 applyPanelSizeOnOpen() 来清,而不是手写两行 `style.width = ''`:
5441
+ * 高度上限是写在 `style.maxHeight` 上的,只清 width/height 会把它留下 ——
5442
+ * 用户先前把窗口**拉小**过的话,双击之后高度仍被旧上限卡着,看着就不像"恢复默认"。
5443
+ */
5444
+ function resetPanelSize() {
5445
+ panelSize = null
5446
+ // ⚠️ 先掐掉还没落盘的那次保存。
5447
+ // savePanelSize 是防抖的,而且**在定时器触发时**才去读当前尺寸 ——
5448
+ // 刚拖完角标马上双击还原时,这一次来不及取消的保存会在 300ms 后读到
5449
+ // "已经还原好的默认尺寸",又把它当成"用户选的尺寸"写回去:
5450
+ // 还原被无声撤销,宽度还被钉死在那一刻(连带把按阶段收窄那套也废掉)。
5451
+ if (panelSizeTimer) {
5452
+ clearTimeout(panelSizeTimer)
5453
+ panelSizeTimer = 0
5454
+ }
5455
+ applyPanelSizeOnOpen() // 宽度 / 高度 / 高度上限 一次全还原
5456
+ fetch(PANEL_SIZE, {
5457
+ method: 'POST',
5458
+ headers: { 'content-type': 'application/json' },
5459
+ body: JSON.stringify({ clear: true }),
5460
+ }).catch(function () {})
5461
+ // 宽度/高度都变了 → 位置得重夹,否则贴着屏幕边缘时会被顶出去
5462
+ clampPanelPosition()
5463
+ autoGrowAskBox()
5464
+ }
5465
+
5466
+ /**
5467
+ * 拖手柄改大小。
5468
+ *
5469
+ * edge 决定这次动哪几个方向:'se' 角标两个方向都能动,'e' 只动宽,'s' 只动高。
5470
+ * 用 pointer 事件(不是 mouse):触控板/触屏一样能拖,而且能 setPointerCapture,
5471
+ * 鼠标划出窗口也不会丢掉这一轮(mouse 版本会卡在中途)。
5472
+ */
5473
+ function bindResizeGrip(node, edge) {
5474
+ listen(node, 'pointerdown', function (event) {
5475
+ if (event.button !== undefined && event.button !== 0) return
5476
+ event.preventDefault()
5477
+ event.stopPropagation()
5478
+ var startSize = currentPanelSize()
5479
+ var startX = event.clientX
5480
+ var startY = event.clientY
5481
+ panelInteracting = true
5482
+ var layer = panel.parentNode
5483
+ if (layer) layer.setAttribute('data-resizing', '1')
5484
+ try { node.setPointerCapture(event.pointerId) } catch (error) { /* 老浏览器没有就算了 */ }
5485
+ var changed = 0
5486
+ var move = function (moveEvent) {
5487
+ var next = { w: startSize.w, h: startSize.h }
5488
+ if (edge.indexOf('e') >= 0) next.w = startSize.w + (moveEvent.clientX - startX)
5489
+ if (edge.indexOf('s') >= 0) next.h = startSize.h + (moveEvent.clientY - startY)
5490
+ var clamped = clampPanelSize(next)
5491
+ changed = Math.abs(clamped.w - startSize.w) + Math.abs(clamped.h - startSize.h)
5492
+ panel.style.width = Math.round(clamped.w) + 'px'
5493
+ panel.style.height = Math.round(clamped.h) + 'px'
5494
+ panelSize = clamped
5495
+ panel.setAttribute('data-resized', '1')
5496
+ if (historyList.style.display === 'flex') placeHistoryList()
5497
+ }
5498
+ var up = function () {
5499
+ window.removeEventListener('pointermove', move, true)
5500
+ window.removeEventListener('pointerup', up, true)
5501
+ panelInteracting = false
5502
+ if (layer) layer.removeAttribute('data-resizing')
5503
+ // 尺寸定下来后,正文/输入框要按新高度重新分配(composer 自动撑高那套)
5504
+ autoGrowAskBox()
5505
+ // 尺寸变了 → 位置重夹,别让面板被顶出屏幕
5506
+ if (changed >= 2) {
5507
+ clampPanelPosition()
5508
+ savePanelSize()
5509
+ }
5510
+ }
5511
+ window.addEventListener('pointermove', move, true)
5512
+ window.addEventListener('pointerup', up, true)
5513
+ })
5514
+ // 双击角标 = 恢复默认大小
5515
+ if (edge === 'se') {
5516
+ listen(node, 'dblclick', function (event) {
5517
+ event.preventDefault()
5518
+ event.stopPropagation()
5519
+ resetPanelSize()
5520
+ })
5521
+ }
5522
+ }
5523
+
5228
5524
  function showPanel(anchor) {
5229
5525
  panelOpen = true
5230
5526
  // 键盘用户开窗即可打字:这一轮的输入框一旦可见就聚焦一次(见 focusComposerOnce)
@@ -5236,6 +5532,11 @@ window.__ModuleLoader__.load({
5236
5532
  panel.style.display = 'flex'
5237
5533
  panel.style.left = '0px'
5238
5534
  panel.style.top = '0px'
5535
+ // 每次都按选区重新锚定 —— **不要**在这里插"用上次的位置"的分支:
5536
+ // 位置一旦被记住,开窗就不再跟着划词的地方走,这套锚定逻辑等于被架空。
5537
+ // 尺寸是另一回事:宽度沿用用户调过的,高度**每次开窗都回到自适应**(只保留天花板),
5538
+ // 否则新会话内容短、面板却硬撑成上次那么高,下面留一大片空白。
5539
+ applyPanelSizeOnOpen()
5239
5540
  // 面板刚显示出来才量得到 scrollHeight:草稿里原来的几行要立刻撑开
5240
5541
  autoGrowAskBox()
5241
5542
  var width = panel.offsetWidth
@@ -5399,7 +5700,8 @@ window.__ModuleLoader__.load({
5399
5700
  }
5400
5701
 
5401
5702
  // —— 按阶段重排 ——
5402
- // 只有翻译时:更窄的面板、不显示小节序号(轻量卡片形态)
5703
+ // 只有翻译时:不显示小节序号(轻量卡片形态)。宽度**不随阶段变**(恒 540)——
5704
+ // 以前这里还顺手收窄到 440,追问一下又跳回 540,用户看着像窗口自己抖了一下。
5403
5705
  var hasDetail = !!(state.parts.detail || stage === 'detail')
5404
5706
  // 追问输入框:**翻译一出来就能问,不必先展开详解**
5405
5707
  var translationReady = state.phase === 'done' && !!state.parts.translation
@@ -7394,7 +7696,25 @@ window.__ModuleLoader__.load({
7394
7696
  setQuotePop(false)
7395
7697
  })
7396
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
+
7397
7716
  var offMouseUp = listen(document, 'mouseup', function (event) {
7717
+ selectPressedAt = 0
7398
7718
  // 面板里的 mouseup **也要走一次检查**:小窗开着时"划小窗正文"正是「引用」的主入口
7399
7719
  // (早先这里对面板直接 return —— 那是"面板里划词一律不管"年代的写法,
7400
7720
  // 结果就是小窗里划词毫无反应,只有气泡末尾的「引用整条」能用)。
@@ -7408,7 +7728,62 @@ window.__ModuleLoader__.load({
7408
7728
  }
7409
7729
  }, true)
7410
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
+
7411
7785
  var offMouseDown = listen(document, 'mousedown', function (event) {
7786
+ selectPressedAt = Date.now()
7412
7787
  var target = event.target
7413
7788
  // 模型菜单:点它自己和胶囊之外**任何地方**都收起。
7414
7789
  // 必须放在最前面 —— 下面的 historyButton / pill / panel 分支都会 return,
@@ -7979,6 +8354,7 @@ window.__ModuleLoader__.load({
7979
8354
 
7980
8355
  // 费用胶囊的位置/尺寸会变(展开、缩放、数字变长、插件迟到挂载)→ 自适应频率地贴上去
7981
8356
  loadPillPos() // 先问一句上次拖到哪儿(异步,回来再摆)
8357
+ loadPanelSize() // 小窗的尺寸也问一句(拖角改过之后才有的值;位置不记)
7982
8358
  placePill()
7983
8359
  ensureResizeWatch()
7984
8360
  schedulePillPlacer()
@@ -8007,7 +8383,20 @@ window.__ModuleLoader__.load({
8007
8383
  })
8008
8384
 
8009
8385
  var offKeyDown = listen(document, 'keydown', function (event) {
8010
- 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
+ }
8011
8400
  // Esc 分三级:**从最内层往外退**,一次只退一层(用户按一次不该连关两层)。
8012
8401
  // ① 设置抽屉开着 → 只收抽屉,小窗留着(用户多半只是改完想接着看解读);
8013
8402
  // ② 语音进行中(录音行还在)→ 只取消这一次语音(等价于点 ✕,已经说出来的字留着);
@@ -9371,10 +9760,12 @@ window.__ModuleLoader__.load({
9371
9760
 
9372
9761
  var offDrag = listen(head, 'mousedown', function (event) {
9373
9762
  if (promoteButton.contains(event.target) || closeButton.contains(event.target)) return
9763
+ if (settingsButton.contains(event.target) || historyButton.contains(event.target)) return
9374
9764
  event.preventDefault()
9375
9765
  var rect = panel.getBoundingClientRect()
9376
9766
  var offsetX = event.clientX - rect.left
9377
9767
  var offsetY = event.clientY - rect.top
9768
+ panelInteracting = true
9378
9769
  var move = function (moveEvent) {
9379
9770
  panel.style.left = clamp(moveEvent.clientX - offsetX, 4, window.innerWidth - rect.width - 4) + 'px'
9380
9771
  panel.style.top = clamp(moveEvent.clientY - offsetY, 4, window.innerHeight - 40) + 'px'
@@ -9383,13 +9774,41 @@ window.__ModuleLoader__.load({
9383
9774
  var up = function () {
9384
9775
  window.removeEventListener('mousemove', move, true)
9385
9776
  window.removeEventListener('mouseup', up, true)
9777
+ panelInteracting = false
9778
+ // **不存位置**:拖动只对这一次有效;下次开窗仍按选区重新锚定。
9386
9779
  }
9387
9780
  window.addEventListener('mousemove', move, true)
9388
9781
  window.addEventListener('mouseup', up, true)
9389
9782
  })
9390
9783
 
9784
+ bindResizeGrip(gripCorner, 'se')
9785
+ bindResizeGrip(gripRight, 'e')
9786
+ bindResizeGrip(gripBottom, 's')
9787
+
9788
+ // 窗口变小:把**尺寸**夹回可视区(位置不归这里管 —— 它由 showPanel 的锚定决定)
9789
+ var offPanelResize = listen(window, 'resize', function () {
9790
+ if (!panelSize || panelInteracting) return
9791
+ var next = clampPanelSize({ w: panelSize.w, h: panelSize.h })
9792
+ if (next.w === panelSize.w && next.h === panelSize.h) return
9793
+ panelSize = next
9794
+ applyPanelSizeOnOpen()
9795
+ clampPanelPosition()
9796
+ savePanelSize()
9797
+ })
9798
+
9391
9799
  // —— 调试钩子(自动化验证用) ——
9392
9800
  window.__dshSelectionExplain = {
9801
+ /** 重新读一次 host 上的小窗尺寸(测试用;正常路径只在挂载时读一次)。 */
9802
+ loadPanelSize: function () {
9803
+ panelSizeAsked = false
9804
+ loadPanelSize()
9805
+ return true
9806
+ },
9807
+ /** 当前小窗几何(测试用:直接读渲染值,不经过变量)。 */
9808
+ panelGeom: function () {
9809
+ var rect = panel.getBoundingClientRect()
9810
+ return { x: rect.left, y: rect.top, w: rect.width, h: rect.height, resized: panel.getAttribute('data-resized') === '1' }
9811
+ },
9393
9812
  /** 重新读一次 host 上的胶囊位置(测试用;正常路径只在挂载时读一次)。 */
9394
9813
  loadPillPos: function () {
9395
9814
  pillPosAsked = false
@@ -9857,6 +10276,8 @@ window.__ModuleLoader__.load({
9857
10276
  offQuotePopEnd()
9858
10277
  offMouseUp()
9859
10278
  offKeyUp()
10279
+ offSelectionChange()
10280
+ if (floatWatchId) clearInterval(floatWatchId)
9860
10281
  offMouseDown()
9861
10282
  offButtonDown()
9862
10283
  offButtonClick()
@@ -9887,6 +10308,7 @@ window.__ModuleLoader__.load({
9887
10308
  cancelVoice()
9888
10309
  offClose()
9889
10310
  offDrag()
10311
+ offPanelResize()
9890
10312
  offPillDown()
9891
10313
  offPillClick()
9892
10314
  offPillEnter()