@yfwu2020/dsh-selection-explain 0.10.1 → 0.10.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.
package/README.md CHANGED
@@ -8,7 +8,7 @@ DSH 中的翻译、上下文解读与语音追问。
8
8
 
9
9
  [快速安装](#install) · [语音输入](#voice) · [功能一览](#read) · [使用与设置](#settings)
10
10
 
11
- `DSH 插件` · `v0.10.1` · `MIT`
11
+ `DSH 插件` · `v0.10.3` · `MIT`
12
12
 
13
13
  </div>
14
14
 
@@ -103,11 +103,11 @@ dsh plugin --profile web add @yfwu2020/dsh-selection-explain
103
103
  <details>
104
104
  <summary>安装 Release 构建包</summary>
105
105
 
106
- 将下载的 `.tgz` 放到当前目录,以 v0.10.1 为例:
106
+ 将下载的 `.tgz` 放到当前目录,以 v0.10.3 为例:
107
107
 
108
108
  ```bash
109
109
  mkdir -p ~/.dsh/plugins/dsh-selection-explain
110
- tar -xzf yfwu2020-dsh-selection-explain-0.10.1.tgz -C ~/.dsh/plugins/dsh-selection-explain --strip-components=1
110
+ tar -xzf yfwu2020-dsh-selection-explain-0.10.3.tgz -C ~/.dsh/plugins/dsh-selection-explain --strip-components=1
111
111
  dsh plugin --profile web add ~/.dsh/plugins/dsh-selection-explain
112
112
  ```
113
113
 
@@ -153,6 +153,16 @@ dsh plugin --profile web remove @yfwu2020/dsh-selection-explain
153
153
  | 界面 | 语音卡片收尾、侧边栏网页划词与悬浮胶囊 |
154
154
  | 数据 | 保留多少条最近解读 |
155
155
 
156
+ ### 与完整视图、最近会话共存(0.10.2)
157
+
158
+ 解读小窗使用自己的浮层,显示在最近会话列表和完整视图工作面板上方,空白区域仍可点击原界面。在完整视图里,解读插件自行隐藏悬浮胶囊;返回分栏后恢复。
159
+
160
+ 聊天与解读可以同时打开。与 `dsh-full-view` **0.1.6**、`dsh-recent-sessions` **0.2.3** 配合时,`Esc` 先退出最后操作的小窗;点击两窗之外后先退解读,再按一次收起聊天。此次调整需一起更新两个插件,未改变 Harness 的公共浮层或设置。
161
+
162
+ ### 快捷键与 Esc 层级(0.10.3)
163
+
164
+ 模型菜单收起后不再被 DSH 识别为弹层,避免设置等快捷键失效。官方设置等模态浮层打开时,`Esc` 优先交给官方浮层处理,保留解读小窗、插件设置抽屉、模型菜单和语音卡;官方浮层关闭后,再按 `Esc` 继续退出插件层级。
165
+
156
166
  ## 数据与使用范围
157
167
 
158
168
  选中文字、必要的前后文,以及开启的会话背景会交给你配置的模型。可在设置中关闭会话背景或工具调用。
package/lib/client.js CHANGED
@@ -4,9 +4,9 @@
4
4
  * 交互:在页面任意位置选中文字 → 选区末端浮出「解读」按钮 → 点击弹出面板,
5
5
  * 面板流式显示两节:① 专业中英翻译 ② 这段文字在当前上下文中的含义详解。
6
6
  *
7
- * 挂载点走官方 shell.overlay 槽(帧级浮层:三栏之外、滚动容器之外、默认
8
- * click-through,条目自己 opt-in pointer-events)——因此 UI 不会挡住 App。
9
- * 浮标与面板用 position:fixed 定位到选区坐标,DOM 归 shell.overlay 条目所有。
7
+ * 官方 shell.overlay 槽管理挂载生命周期;界面由插件自己挂到 body 的独立根。
8
+ * 根层固定为 70,越过全屏工作面板,不改写宿主公共浮层。空白区域鼠标穿透。
9
+ * 浮标与面板继续用 position:fixed 定位到选区坐标。
10
10
  *
11
11
  * 本文件是 DSH 客户端 bundle 的源文件(ModuleLoader 懒加载 CJS 表格式):
12
12
  * 顶层只注册工厂,真正的副作用(React 组件、DOM/CSS、监听)都在 factory 被
@@ -435,9 +435,10 @@ window.__ModuleLoader__.load({
435
435
  // --sel-fill(-fg) 实心块:浮标按钮 / 发送键 / 结论条标签——块上文字另算一套,
436
436
  // 否则青底配白字只有 3.6(不达标),浅色下换成深青 5.4、深色下反白 7.4
437
437
  // 变量挂在浮层容器上 → 浮标与面板共用;深浅由**面板实际底色**决定(data-theme)。
438
- '.dsh-sel-layer{--sel-a1:#0d9488;--sel-a1-text:#0f766e;--sel-a2:#0d9488;--sel-a2-text:#0f766e;',
438
+ '.dsh-sel-layer{position:fixed;inset:0;z-index:70;pointer-events:none;--sel-a1:#0d9488;--sel-a1-text:#0f766e;--sel-a2:#0d9488;--sel-a2-text:#0f766e;',
439
439
  '--sel-fill:#0f766e;--sel-fill-fg:#ffffff;',
440
440
  '--sel-a2-soft:color-mix(in srgb,var(--sel-a2) 12%,transparent);--sel-a1-soft:color-mix(in srgb,var(--sel-a1) 10%,transparent)}',
441
+ '.dsh-sel-layer[data-full-view] .dsh-sel-pill{display:none!important}',
441
442
  '.dsh-sel-layer[data-theme=dark]{--sel-a1:#4ecdc4;--sel-a1-text:#6fe3d8;--sel-a2:#4ecdc4;--sel-a2-text:#6fe3d8;',
442
443
  '--sel-fill:#4ecdc4;--sel-fill-fg:#052b26;',
443
444
  '--sel-a2-soft:color-mix(in srgb,var(--sel-a2) 16%,transparent);--sel-a1-soft:color-mix(in srgb,var(--sel-a1) 16%,transparent)}',
@@ -2492,9 +2493,10 @@ window.__ModuleLoader__.load({
2492
2493
  styleEl.textContent = CSS
2493
2494
  document.head.appendChild(styleEl)
2494
2495
 
2495
- /** 浮层容器:挂在 shell.overlay 条目里,自身 click-through。 */
2496
+ /** 自有浮层根:位于 body,公共父层的堆叠上下文不影响它。 */
2496
2497
  var layer = el('div', 'dsh-sel-layer')
2497
2498
  layer.style.pointerEvents = 'none'
2499
+ layer.setAttribute('data-dsh-floating-owner', 'explanation')
2498
2500
 
2499
2501
  var button = el('button', 'dsh-sel-btn')
2500
2502
  button.type = 'button'
@@ -2646,7 +2648,7 @@ window.__ModuleLoader__.load({
2646
2648
  modelPill.appendChild(modelPillTier)
2647
2649
  modelPill.appendChild(el('span', 'dsh-sel-picker-caret', '▼'))
2648
2650
  var modelMenu = el('div', 'dsh-sel-pickermenu')
2649
- modelMenu.setAttribute('role', 'menu')
2651
+ // role 随开合同步:DSH 按 [role="menu"] 判定弹层,不检查 CSS 可见性。
2650
2652
 
2651
2653
  // ── 录音行(与主会话 ui-voice-input 同一套结构)──
2652
2654
  // [✕ 取消] [实时波形 / 状态文案] [■ 停止 或 行内动作] …… [↑ 发送(原位不动)]
@@ -3758,10 +3760,27 @@ window.__ModuleLoader__.load({
3758
3760
  layer.appendChild(historyList)
3759
3761
 
3760
3762
  /**
3761
- * 注册进官方 shell.overlay 槽(帧级浮层)。
3762
- * React 组件只负责把 layer 容器挂进浮层;浮标/面板由上面的命令式代码管理,
3763
- * React 不接管它们的子树(容器本身无 children,不会被 diff 清空)。
3763
+ * 官方槽仅保留不可见的生命周期锚点;界面根由本插件挂到 body。
3764
+ * 稳定 ref 避免 React 重渲染时摘挂根节点、丢失输入焦点。
3764
3765
  */
3766
+ function syncFullViewLayer() {
3767
+ var fullView = !!document.querySelector('[data-dsh-full-view]')
3768
+ if (fullView !== layer.hasAttribute('data-full-view')) {
3769
+ if (fullView) layer.setAttribute('data-full-view', '')
3770
+ else layer.removeAttribute('data-full-view')
3771
+ }
3772
+ }
3773
+ function mountFloatingLayer(node) {
3774
+ if (node) {
3775
+ if (layer.parentNode !== document.body) document.body.appendChild(layer)
3776
+ syncFullViewLayer()
3777
+ } else if (layer.parentNode) layer.parentNode.removeChild(layer)
3778
+ }
3779
+ var fullViewObserver = null
3780
+ if (typeof MutationObserver === 'function') {
3781
+ fullViewObserver = new MutationObserver(syncFullViewLayer)
3782
+ fullViewObserver.observe(document.body, {subtree:true, childList:true, attributes:true, attributeFilter:['data-dsh-full-view']})
3783
+ }
3765
3784
  /** 开窗后"还没聚焦过输入框"(见 focusComposerOnce)。 */
3766
3785
  var askFocusPending = false
3767
3786
  /** 调试:最后一次"没聚焦"卡在哪一步(-1 表示聚焦成功)。 */
@@ -3780,16 +3799,14 @@ window.__ModuleLoader__.load({
3780
3799
  },
3781
3800
  }, function SelectionExplainLayer() {
3782
3801
  return React.createElement('div', {
3783
- className: 'dsh-sel-mount',
3784
- style: { pointerEvents: 'none' },
3785
- ref: function (node) {
3786
- if (node && layer.parentNode !== node) node.appendChild(layer)
3787
- },
3802
+ className: 'dsh-sel-anchor',
3803
+ style: { display: 'none', pointerEvents: 'none' },
3804
+ ref: mountFloatingLayer,
3788
3805
  })
3789
3806
  })
3790
3807
  })
3791
3808
  } catch (error) {
3792
- // 槽注册失败不应把整页插件装载一起拖垮:降级为仅报错(面板挂在浮层外不可见)
3809
+ // 槽注册失败仅报错,自有界面根保持未挂载,不影响其他插件。
3793
3810
  console.error('[dsh-selection-explain] shell.overlay 槽注册失败:', error)
3794
3811
  }
3795
3812
  } else {
@@ -9467,6 +9484,7 @@ window.__ModuleLoader__.load({
9467
9484
  */
9468
9485
  var offVoiceEscape = listen(document, 'keydown', function (event) {
9469
9486
  if (event.key !== 'Escape' || !vcardState.open || event.defaultPrevented) return
9487
+ if (document.querySelector('[role="dialog"][aria-modal="true"]')) return
9470
9488
  event.preventDefault()
9471
9489
  event.stopPropagation()
9472
9490
  vcardState.suppress = true
@@ -9919,11 +9937,13 @@ window.__ModuleLoader__.load({
9919
9937
 
9920
9938
  function closeModelMenu() {
9921
9939
  modelMenu.setAttribute('data-open', '0')
9940
+ modelMenu.removeAttribute('role')
9922
9941
  modelPill.setAttribute('aria-expanded', 'false')
9923
9942
  }
9924
9943
 
9925
9944
  function openModelMenu() {
9926
9945
  modelMenu.setAttribute('data-open', '1')
9946
+ modelMenu.setAttribute('role', 'menu')
9927
9947
  modelPill.setAttribute('aria-expanded', 'true')
9928
9948
  renderModelMenu()
9929
9949
  loadModelCatalog(false).then(function () {
@@ -9945,7 +9965,7 @@ window.__ModuleLoader__.load({
9945
9965
  event.stopPropagation() // 菜单内部点击不要触发"点到外面"
9946
9966
  })
9947
9967
  var offModelKeys = listen(modelMenu, 'keydown', function (event) {
9948
- if (event.key === 'Escape') closeModelMenu()
9968
+ if (event.key === 'Escape' && !document.querySelector('[role="dialog"][aria-modal="true"]')) closeModelMenu()
9949
9969
  })
9950
9970
 
9951
9971
  // —— 网页模式开关 ——
@@ -10123,6 +10143,9 @@ window.__ModuleLoader__.load({
10123
10143
 
10124
10144
  var offKeyDown = listen(document, 'keydown', function (event) {
10125
10145
  if (event.key !== 'Escape' || !panelOpen || event.defaultPrevented || event.isComposing || event.keyCode === 229) return
10146
+ // 官方模态浮层的 Esc 在冒泡阶段处理;capture 不消费,先让官方设置等浮层退出。
10147
+ // 插件面板和设置抽屉是非模态 dialog,不带 aria-modal,不会命中这里。
10148
+ if (document.querySelector('[role="dialog"][aria-modal="true"]')) return
10126
10149
  // 完整视图记录最后点击/操作的小窗;不能只看可能停在另一窗的键盘焦点。
10127
10150
  var fullViewFrame = document.querySelector('[data-dsh-full-view]')
10128
10151
  if (fullViewFrame) {
@@ -12127,6 +12150,7 @@ window.__ModuleLoader__.load({
12127
12150
  }
12128
12151
  pillObserver = null
12129
12152
  }
12153
+ if (fullViewObserver) fullViewObserver.disconnect()
12130
12154
  if (typeof offSlot === 'function') offSlot()
12131
12155
  // 划词桥:摘观察者与消息监听,并把我们自己建的预览 blob 全部回收
12132
12156
  if (bridgeScanId) clearTimeout(bridgeScanId)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yfwu2020/dsh-selection-explain",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "DSH 划词解读:选中文字浮出按钮,点击弹窗流式显示专业中英翻译 + 该文字在当前上下文中的含义详解",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -264,7 +264,19 @@ async function main() {
264
264
  check('插件装载(槽注册 + apply)', !!registration && registration.options.id === '@yfwu2020/dsh-selection-explain', registration && registration.options.id)
265
265
  registration.component().props.ref(mount)
266
266
  hook = window.__dshSelectionExplain
267
- check('浮层挂进 mount', !!document.querySelector('.dsh-sel-layer'), '')
267
+ check('解读自有浮层已挂载', !!document.querySelector('.dsh-sel-layer'), '')
268
+ var ownedLayer = document.querySelector('.dsh-sel-layer')
269
+ check('解读使用 body 自有根,公开归属且空白穿透', ownedLayer.parentNode === document.body && ownedLayer.getAttribute('data-dsh-floating-owner') === 'explanation' && getComputedStyle(ownedLayer).pointerEvents === 'none', '')
270
+ check('解读独立根越过完整视图工作面板', Number(getComputedStyle(ownedLayer).zIndex) > 60, getComputedStyle(ownedLayer).zIndex)
271
+ var modeFrame = document.createElement('div')
272
+ document.body.appendChild(modeFrame)
273
+ modeFrame.setAttribute('data-dsh-full-view', '')
274
+ await sleep(40)
275
+ check('解读自身响应完整视图并隐藏胶囊', getComputedStyle(document.querySelector('.dsh-sel-pill')).display === 'none', '')
276
+ modeFrame.removeAttribute('data-dsh-full-view')
277
+ await sleep(40)
278
+ check('返回分栏由解读自身恢复胶囊', getComputedStyle(document.querySelector('.dsh-sel-pill')).display !== 'none', '')
279
+ modeFrame.remove()
268
280
  } catch (error) {
269
281
  check('插件装载(槽注册 + apply)', false, error && error.message)
270
282
  finish()
@@ -1492,8 +1504,57 @@ async function main() {
1492
1504
  hook.open('模型菜单勾选', '', '菜单冒烟')
1493
1505
  await waitFor(function () { return hook.state().phase === 'done' }, 8000)
1494
1506
  await hook.loadModels(true)
1507
+ var modelNodes = hook.modelNodes()
1508
+ // DSH 按属性判定弹层,不检查 CSS 可见性(Issue #1)。
1509
+ var modalSelector = '[role="dialog"][aria-modal="true"], [role="menu"]'
1510
+ function modelMenuOwnsShortcuts() {
1511
+ return Array.from(document.querySelectorAll(modalSelector)).includes(modelNodes.menu)
1512
+ }
1513
+ function checkModelMenuClosed(reason) {
1514
+ check(reason + ':模型菜单不再占用 DSH 快捷键',
1515
+ !modelMenuOwnsShortcuts() && modelNodes.menu.getAttribute('data-open') !== '1' &&
1516
+ modelNodes.pill.getAttribute('aria-expanded') === 'false')
1517
+ }
1518
+ checkModelMenuClosed('初始收起')
1519
+ // 官方 useModalLayer 在 document 冒泡阶段处理 Esc;插件 capture 必须让行。
1520
+ function checkHostEscapeFirst(target, label) {
1521
+ var hostSettings = document.createElement('div')
1522
+ hostSettings.setAttribute('role', 'dialog')
1523
+ hostSettings.setAttribute('aria-modal', 'true')
1524
+ hostSettings.setAttribute('data-shortcut-modal', 'settings')
1525
+ var hostInput = document.createElement('input')
1526
+ hostSettings.appendChild(hostInput)
1527
+ document.body.appendChild(hostSettings)
1528
+ function dismissHost(event) {
1529
+ if (event.key !== 'Escape' || event.defaultPrevented) return
1530
+ event.preventDefault()
1531
+ hostSettings.remove()
1532
+ }
1533
+ document.addEventListener('keydown', dismissHost)
1534
+ try {
1535
+ var event = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
1536
+ ;(target || hostInput).dispatchEvent(event)
1537
+ check(label + ':Esc 到达官方浮层并只关闭官方设置',
1538
+ event.defaultPrevented && !hostSettings.isConnected && panel.style.display === 'flex')
1539
+ } finally {
1540
+ document.removeEventListener('keydown', dismissHost)
1541
+ hostSettings.remove()
1542
+ }
1543
+ }
1544
+ var escapeModeFrame = document.createElement('div')
1545
+ escapeModeFrame.setAttribute('data-dsh-full-view', '')
1546
+ escapeModeFrame.setAttribute('data-dsh-active-floating-window', 'explanation')
1547
+ for (var fullViewMode of [false, true]) {
1548
+ if (fullViewMode) document.body.appendChild(escapeModeFrame)
1549
+ var modeLabel = fullViewMode ? '完整视图' : '普通分栏'
1550
+ checkHostEscapeFirst(null, modeLabel + '/焦点在官方设置')
1551
+ checkHostEscapeFirst(document.querySelector('.dsh-sel-askbox'), modeLabel + '/焦点仍在解读输入框')
1552
+ }
1553
+ escapeModeFrame.remove()
1495
1554
  hook.openModel()
1496
1555
  await sleep(120)
1556
+ check('模型菜单展开时占用 DSH 快捷键',
1557
+ modelMenuOwnsShortcuts() && modelNodes.pill.getAttribute('aria-expanded') === 'true')
1497
1558
  var menu = hook.modelNodes().menu
1498
1559
  var rows = Array.prototype.slice.call(menu.querySelectorAll('.dsh-sel-pickerrow'))
1499
1560
  check('模型菜单列出 3 个模型(含同名 id 的另一 provider)', rows.length === 3, String(rows.length))
@@ -1517,7 +1578,24 @@ async function main() {
1517
1578
  var rowBox = onRows[0] ? onRows[0].getBoundingClientRect() : null
1518
1579
  check('打勾那一行仍是单行(高度不超过 30px,没折成两行)',
1519
1580
  !!rowBox && rowBox.height <= 30, rowBox ? Math.round(rowBox.height) + 'px' : '没找到那一行')
1581
+
1582
+ checkHostEscapeFirst(modelNodes.menu, '模型菜单仍展开')
1583
+ check('官方设置关闭时保留插件模型菜单', modelNodes.menu.getAttribute('data-open') === '1')
1584
+
1585
+ clickOn(modelNodes.pill)
1586
+ checkModelMenuClosed('再次点击胶囊')
1587
+ hook.openModel()
1588
+ modelNodes.menu.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
1589
+ checkModelMenuClosed('按 Esc')
1590
+ hook.openModel()
1591
+ document.getElementById('docp').dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
1592
+ checkModelMenuClosed('点击菜单外部')
1593
+ hook.openModel()
1594
+ clickOn(modelNodes.menu.querySelector('.dsh-sel-pickerrow'))
1595
+ checkModelMenuClosed('选择模型')
1596
+ hook.openModel()
1520
1597
  hook.close() // 关窗会顺手收起模型菜单
1598
+ checkModelMenuClosed('关闭解读面板')
1521
1599
  await sleep(30)
1522
1600
  }
1523
1601
 
@@ -103,6 +103,9 @@ class FakeEl {
103
103
  removeAttribute(key) {
104
104
  delete this.attrs[key]
105
105
  }
106
+ hasAttribute(key) {
107
+ return this.getAttribute(key) !== null
108
+ }
106
109
  getAttribute(key) {
107
110
  return this.attrs[key] ?? null
108
111
  }
@@ -1242,7 +1245,10 @@ const mount = new FakeEl('div')
1242
1245
  const element = registration.component()
1243
1246
  assert('浮层组件返回 React 元素', !!element && element.type === 'div')
1244
1247
  element.props.ref(mount)
1245
- assert('layer 容器已挂进浮层', mount.children.length === 1 && mount.children[0].className === 'dsh-sel-layer')
1248
+ const floatingLayer = body.children.find((c) => c.className === 'dsh-sel-layer')
1249
+ assert('界面挂在 body 自有根,不提升公共槽', !!floatingLayer && floatingLayer.parentNode === body && mount.children.length === 0)
1250
+ assert('自有根声明公开的小窗归属', floatingLayer && floatingLayer.getAttribute('data-dsh-floating-owner') === 'explanation')
1251
+ assert('重渲染使用稳定挂载 ref', registration.component().props.ref === element.props.ref)
1246
1252
 
1247
1253
  // ───────────────────────── 划词 → 浮标 ─────────────────────────
1248
1254
  const container = new FakeEl('div')
@@ -1264,7 +1270,7 @@ const selection = {
1264
1270
  windowStub.getSelection = () => selection
1265
1271
  documentStub.dispatch('mouseup', { target: body })
1266
1272
  await new Promise((r) => setTimeout(r, 20))
1267
- const button = mount.children[0].children.find((c) => c.className === 'dsh-sel-btn')
1273
+ const button = floatingLayer.children.find((c) => c.className === 'dsh-sel-btn')
1268
1274
  assert('选中文字后浮标出现', !!button && button.style.display === 'inline-flex', button && `left=${button.style.left} top=${button.style.top}`)
1269
1275
  // 浮现动效:只在"隐藏 → 显示"那一次挂动效类;已可见时只平移;animationend 后自动摘掉
1270
1276
  {
@@ -1335,7 +1341,7 @@ assert('选中文字后浮标出现', !!button && button.style.display === 'inli
1335
1341
  // 引用(❝)的**离线**契约:浮标进了浮层、CSS 规则齐全(真实交互在宿主可达时另有一段)
1336
1342
  {
1337
1343
  const css = readFileSync(BUNDLE, 'utf8')
1338
- const quoteBtn = Array.from(walk(mount)).find((n) => String(n.className).indexOf('dsh-sel-quotebtn') >= 0)
1344
+ const quoteBtn = Array.from(walk(floatingLayer)).find((n) => String(n.className).indexOf('dsh-sel-quotebtn') >= 0)
1339
1345
  assert('浮层里有「❝ 引用」浮标,且默认藏着', !!quoteBtn && quoteBtn.style.display === 'none' && textOf(quoteBtn).indexOf('引用') >= 0, quoteBtn ? textOf(quoteBtn) : '未找到')
1340
1346
  assert('CSS 契约:引用区默认收起、data-show 控制显隐', /\.dsh-sel-quotes\{display:none;flex-direction:column/.test(css) && /\.dsh-sel-quotes\[data-show="1"\]\{display:flex\}/.test(css), '')
1341
1347
  assert('CSS 契约:引用卡片三段(来源 / 摘要 / 删除)', /\.dsh-sel-quotechip-src\{/.test(css) && /\.dsh-sel-quotechip-text\{/.test(css) && /\.dsh-sel-quotechip-x\{/.test(css), '')
@@ -1361,12 +1367,12 @@ try {
1361
1367
  }
1362
1368
 
1363
1369
  button.dispatch('click', { preventDefault() {}, stopPropagation() {} })
1364
- const panel = mount.children[0].children.find((c) => c.className === 'dsh-sel-panel')
1370
+ const panel = floatingLayer.children.find((c) => c.className === 'dsh-sel-panel')
1365
1371
  // 面板内的固定引用点(后续断言共用)
1366
1372
  const sections = Array.from(walk(panel)).filter((node) => node.className.indexOf('dsh-sel-sec') >= 0)
1367
1373
  const panelHead = Array.from(walk(panel)).find((node) => node.className === 'dsh-sel-head')
1368
1374
  const histBtn = Array.from(walk(panelHead)).find((n) => n.className.indexOf('dsh-sel-action') >= 0 && textOf(n).indexOf('最近') >= 0)
1369
- const histList = mount.children[0].children.find((n) => n.className === 'dsh-sel-history')
1375
+ const histList = floatingLayer.children.find((n) => n.className === 'dsh-sel-history')
1370
1376
  assert('「最近聊过的」是独立浮层,不在消息滚动区里(B 方案)', !!histList && histList.parentNode.className === 'dsh-sel-layer' && !panel.contains(histList), histList ? histList.parentNode.className : '未找到')
1371
1377
  const detailCard = sections.find((node) => node.getAttribute('data-sec') === 'detail')
1372
1378
  const expandBtn = Array.from(walk(panel)).find((node) => node.className === 'dsh-sel-expand')
@@ -1378,7 +1384,7 @@ assert('弹窗显示选中文字', !!panel && textOf(panel).indexOf('the migrati
1378
1384
  // 指的是刚才解读的那段文字(解读要的文字与上下文在 mouseup 那刻就抓好了,见 openForSelection)
1379
1385
  assert('点「解读」后本文档选区被收掉', clearedSelections.count >= 1, `removeAllRanges 调用 ${clearedSelections.count} 次`)
1380
1386
  {
1381
- const quoteBtnNode = Array.from(walk(mount)).find((n) => String(n.className).indexOf('dsh-sel-quotebtn') >= 0)
1387
+ const quoteBtnNode = Array.from(walk(floatingLayer)).find((n) => String(n.className).indexOf('dsh-sel-quotebtn') >= 0)
1382
1388
  assert(
1383
1389
  '点「解读」后引用浮标不出现(旧选区不该复活它)',
1384
1390
  !quoteBtnNode || quoteBtnNode.style.display === 'none',
@@ -5316,7 +5322,7 @@ assert('Esc/关闭后隐藏', panel.style.display === 'none')
5316
5322
  {
5317
5323
  // 也认 setAttribute('class') —— SVG 元素的 className 是只读对象,插件只能用 setAttribute
5318
5324
  const find = (cls) =>
5319
- Array.from(walk(mount)).find(
5325
+ Array.from(walk(floatingLayer)).find(
5320
5326
  (n) => n.className === cls || (n.getAttribute && n.getAttribute('class') === cls),
5321
5327
  )
5322
5328
 
@@ -5434,7 +5440,7 @@ assert('Esc/关闭后隐藏', panel.style.display === 'none')
5434
5440
  threadDot ? 'cx=' + String(threadDot.getAttribute('cx')) + ' cy=' + String(threadDot.getAttribute('cy')) : 'none',
5435
5441
  )
5436
5442
 
5437
- const merged = Array.from(walk(mount)).filter((n) => n.className === 'dsh-sel-uline')
5443
+ const merged = Array.from(walk(floatingLayer)).filter((n) => n.className === 'dsh-sel-uline')
5438
5444
  assert(
5439
5445
  '块级盒子被丢掉,两行各有一条下划线(不再整段只剩一条)',
5440
5446
  merged.length === 2,
@@ -6154,7 +6160,7 @@ for (const dispose of disposers.reverse()) {
6154
6160
  }
6155
6161
  }
6156
6162
 
6157
- assert('清理后 DOM 归零', mount.children.length === 0 && body.children.indexOf(container) >= 0)
6163
+ assert('清理后自有根摘除,保留宿主 DOM', floatingLayer.parentNode === null && mount.children.length === 0 && body.children.indexOf(container) >= 0)
6158
6164
  assert('清理后划词桥自己建的预览 blob 被回收', bridgeOwnedBlob !== '' && !blobStore.has(bridgeOwnedBlob), bridgeOwnedBlob)
6159
6165
  assert('清理后钩子移除', windowStub.__dshSelectionExplain === undefined)
6160
6166
  console.log('\n=== 客户端集成测试结束 ===')
@@ -7,12 +7,14 @@ const start = source.indexOf(' var offKeyDown = listen(document,')
7
7
  const end = source.indexOf('\n\n var offSettingsOpen', start)
8
8
  assert.ok(start >= 0 && end > start)
9
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
10
+ let handler, fullView = true, calls = [], activeWindow = null, chatExpanded = true, hostModalOpen = false
11
11
  const context = vm.createContext({
12
12
  panelOpen: true, settingsOpen: false,
13
13
  panel: { contains: node => node === ask },
14
14
  historyList: { contains: node => node === historyInput },
15
- document: { querySelector: selector => selector === '[data-dsh-full-view]' && fullView
15
+ document: { querySelector: selector => selector === '[role="dialog"][aria-modal="true"]'
16
+ ? hostModalOpen ? {} : null
17
+ : selector === '[data-dsh-full-view]' && fullView
16
18
  ? { getAttribute: () => activeWindow }
17
19
  : selector.startsWith('[data-dsh-floating-chat]') && chatExpanded ? {} : null },
18
20
  listen(target, type, callback, capture) { assert.equal(type, 'keydown'); assert.equal(capture, true); handler = callback },
@@ -27,7 +29,22 @@ function escape(target, extra = {}) {
27
29
  const event = { key: 'Escape', target, ...extra, preventDefault() { calls.push('prevent') }, stopPropagation() { calls.push('stop') } }
28
30
  handler(event)
29
31
  }
30
- function reset() { calls = []; activeWindow = null; chatExpanded = true; context.panelOpen = true; context.settingsOpen = false; context.settingsByKey = {}; context.captureRow.getAttribute = () => '0' }
32
+ function reset() { calls = []; activeWindow = null; chatExpanded = true; hostModalOpen = false; context.panelOpen = true; context.settingsOpen = false; context.settingsByKey = {}; context.captureRow.getAttribute = () => '0' }
33
+ for (const mode of [true, false]) {
34
+ for (const target of [ask, historyInput, outside, expandedChat]) {
35
+ reset(); fullView = mode; hostModalOpen = true; activeWindow = 'explanation'
36
+ escape(target)
37
+ assert.deepEqual(calls, [], '官方模态设置浮层优先消费 Esc,不受视图模式、焦点或最后操作窗口影响')
38
+ assert.equal(context.panelOpen, true)
39
+ }
40
+ }
41
+ reset(); fullView = true; hostModalOpen = true; context.settingsOpen = true; escape(ask)
42
+ assert.deepEqual(calls, [], '官方设置打开时不关闭插件设置抽屉')
43
+ assert.equal(context.settingsOpen, true)
44
+ reset(); hostModalOpen = true; context.captureRow.getAttribute = () => '1'; escape(ask)
45
+ assert.deepEqual(calls, [], '官方设置打开时不取消插件录音')
46
+ hostModalOpen = false; escape(ask)
47
+ assert.deepEqual(calls, ['prevent', 'stop', 'voice'], '官方设置退出后,下一次 Esc 恢复插件的内层退出顺序')
31
48
  reset(); activeWindow = 'chat'; escape(ask)
32
49
  assert.deepEqual(calls, [], '最后点击聊天后,即使键盘仍在解读输入框,也让聊天先退出')
33
50
  reset(); activeWindow = 'explanation'; escape(expandedChat)
@@ -63,4 +80,22 @@ for (const target of [ask, historyInput]) {
63
80
  }
64
81
  reset(); fullView = false; escape(outside)
65
82
  assert.equal(context.panelOpen, false, '普通分栏保留解读原来的全局 Esc 行为')
66
- console.log('PASS focused Esc: independent windows, IME, nested settings, voice, history and split-mode behavior')
83
+
84
+ // 语音卡另有一个更早注册的 capture 监听,也必须给官方浮层让出 Esc。
85
+ const panelHandler = handler
86
+ const voiceStart = source.indexOf(' var offVoiceEscape = listen(document,')
87
+ const voiceEnd = source.indexOf('\n }, true)', voiceStart) + '\n }, true)'.length
88
+ assert.ok(voiceStart >= 0 && voiceEnd > voiceStart)
89
+ context.vcardState = { open: true, suppress: false }
90
+ context.closeVCard = () => { calls.push('vcard'); context.vcardState.open = false }
91
+ new vm.Script(source.slice(voiceStart, voiceEnd)).runInContext(context)
92
+ const voiceHandler = handler
93
+ handler = panelHandler
94
+ reset(); hostModalOpen = true
95
+ voiceHandler({ key: 'Escape', preventDefault() { calls.push('prevent') }, stopPropagation() { calls.push('stop') } })
96
+ assert.deepEqual(calls, [], '官方设置打开时,语音卡不抢先消费 Esc')
97
+ assert.equal(context.vcardState.open, true)
98
+ hostModalOpen = false
99
+ voiceHandler({ key: 'Escape', preventDefault() { calls.push('prevent') }, stopPropagation() { calls.push('stop') } })
100
+ assert.deepEqual(calls, ['prevent', 'stop', 'vcard'], '官方设置退出后,Esc 可以关闭语音卡')
101
+ console.log('PASS focused Esc: host modals, independent windows, IME, nested settings, voice, history and split-mode behavior')
@@ -4,9 +4,9 @@
4
4
  * 交互:在页面任意位置选中文字 → 选区末端浮出「解读」按钮 → 点击弹出面板,
5
5
  * 面板流式显示两节:① 专业中英翻译 ② 这段文字在当前上下文中的含义详解。
6
6
  *
7
- * 挂载点走官方 shell.overlay 槽(帧级浮层:三栏之外、滚动容器之外、默认
8
- * click-through,条目自己 opt-in pointer-events)——因此 UI 不会挡住 App。
9
- * 浮标与面板用 position:fixed 定位到选区坐标,DOM 归 shell.overlay 条目所有。
7
+ * 官方 shell.overlay 槽管理挂载生命周期;界面由插件自己挂到 body 的独立根。
8
+ * 根层固定为 70,越过全屏工作面板,不改写宿主公共浮层。空白区域鼠标穿透。
9
+ * 浮标与面板继续用 position:fixed 定位到选区坐标。
10
10
  *
11
11
  * 本文件是 DSH 客户端 bundle 的源文件(ModuleLoader 懒加载 CJS 表格式):
12
12
  * 顶层只注册工厂,真正的副作用(React 组件、DOM/CSS、监听)都在 factory 被
@@ -435,9 +435,10 @@ window.__ModuleLoader__.load({
435
435
  // --sel-fill(-fg) 实心块:浮标按钮 / 发送键 / 结论条标签——块上文字另算一套,
436
436
  // 否则青底配白字只有 3.6(不达标),浅色下换成深青 5.4、深色下反白 7.4
437
437
  // 变量挂在浮层容器上 → 浮标与面板共用;深浅由**面板实际底色**决定(data-theme)。
438
- '.dsh-sel-layer{--sel-a1:#0d9488;--sel-a1-text:#0f766e;--sel-a2:#0d9488;--sel-a2-text:#0f766e;',
438
+ '.dsh-sel-layer{position:fixed;inset:0;z-index:70;pointer-events:none;--sel-a1:#0d9488;--sel-a1-text:#0f766e;--sel-a2:#0d9488;--sel-a2-text:#0f766e;',
439
439
  '--sel-fill:#0f766e;--sel-fill-fg:#ffffff;',
440
440
  '--sel-a2-soft:color-mix(in srgb,var(--sel-a2) 12%,transparent);--sel-a1-soft:color-mix(in srgb,var(--sel-a1) 10%,transparent)}',
441
+ '.dsh-sel-layer[data-full-view] .dsh-sel-pill{display:none!important}',
441
442
  '.dsh-sel-layer[data-theme=dark]{--sel-a1:#4ecdc4;--sel-a1-text:#6fe3d8;--sel-a2:#4ecdc4;--sel-a2-text:#6fe3d8;',
442
443
  '--sel-fill:#4ecdc4;--sel-fill-fg:#052b26;',
443
444
  '--sel-a2-soft:color-mix(in srgb,var(--sel-a2) 16%,transparent);--sel-a1-soft:color-mix(in srgb,var(--sel-a1) 16%,transparent)}',
@@ -2492,9 +2493,10 @@ window.__ModuleLoader__.load({
2492
2493
  styleEl.textContent = CSS
2493
2494
  document.head.appendChild(styleEl)
2494
2495
 
2495
- /** 浮层容器:挂在 shell.overlay 条目里,自身 click-through。 */
2496
+ /** 自有浮层根:位于 body,公共父层的堆叠上下文不影响它。 */
2496
2497
  var layer = el('div', 'dsh-sel-layer')
2497
2498
  layer.style.pointerEvents = 'none'
2499
+ layer.setAttribute('data-dsh-floating-owner', 'explanation')
2498
2500
 
2499
2501
  var button = el('button', 'dsh-sel-btn')
2500
2502
  button.type = 'button'
@@ -2646,7 +2648,7 @@ window.__ModuleLoader__.load({
2646
2648
  modelPill.appendChild(modelPillTier)
2647
2649
  modelPill.appendChild(el('span', 'dsh-sel-picker-caret', '▼'))
2648
2650
  var modelMenu = el('div', 'dsh-sel-pickermenu')
2649
- modelMenu.setAttribute('role', 'menu')
2651
+ // role 随开合同步:DSH 按 [role="menu"] 判定弹层,不检查 CSS 可见性。
2650
2652
 
2651
2653
  // ── 录音行(与主会话 ui-voice-input 同一套结构)──
2652
2654
  // [✕ 取消] [实时波形 / 状态文案] [■ 停止 或 行内动作] …… [↑ 发送(原位不动)]
@@ -3758,10 +3760,27 @@ window.__ModuleLoader__.load({
3758
3760
  layer.appendChild(historyList)
3759
3761
 
3760
3762
  /**
3761
- * 注册进官方 shell.overlay 槽(帧级浮层)。
3762
- * React 组件只负责把 layer 容器挂进浮层;浮标/面板由上面的命令式代码管理,
3763
- * React 不接管它们的子树(容器本身无 children,不会被 diff 清空)。
3763
+ * 官方槽仅保留不可见的生命周期锚点;界面根由本插件挂到 body。
3764
+ * 稳定 ref 避免 React 重渲染时摘挂根节点、丢失输入焦点。
3764
3765
  */
3766
+ function syncFullViewLayer() {
3767
+ var fullView = !!document.querySelector('[data-dsh-full-view]')
3768
+ if (fullView !== layer.hasAttribute('data-full-view')) {
3769
+ if (fullView) layer.setAttribute('data-full-view', '')
3770
+ else layer.removeAttribute('data-full-view')
3771
+ }
3772
+ }
3773
+ function mountFloatingLayer(node) {
3774
+ if (node) {
3775
+ if (layer.parentNode !== document.body) document.body.appendChild(layer)
3776
+ syncFullViewLayer()
3777
+ } else if (layer.parentNode) layer.parentNode.removeChild(layer)
3778
+ }
3779
+ var fullViewObserver = null
3780
+ if (typeof MutationObserver === 'function') {
3781
+ fullViewObserver = new MutationObserver(syncFullViewLayer)
3782
+ fullViewObserver.observe(document.body, {subtree:true, childList:true, attributes:true, attributeFilter:['data-dsh-full-view']})
3783
+ }
3765
3784
  /** 开窗后"还没聚焦过输入框"(见 focusComposerOnce)。 */
3766
3785
  var askFocusPending = false
3767
3786
  /** 调试:最后一次"没聚焦"卡在哪一步(-1 表示聚焦成功)。 */
@@ -3780,16 +3799,14 @@ window.__ModuleLoader__.load({
3780
3799
  },
3781
3800
  }, function SelectionExplainLayer() {
3782
3801
  return React.createElement('div', {
3783
- className: 'dsh-sel-mount',
3784
- style: { pointerEvents: 'none' },
3785
- ref: function (node) {
3786
- if (node && layer.parentNode !== node) node.appendChild(layer)
3787
- },
3802
+ className: 'dsh-sel-anchor',
3803
+ style: { display: 'none', pointerEvents: 'none' },
3804
+ ref: mountFloatingLayer,
3788
3805
  })
3789
3806
  })
3790
3807
  })
3791
3808
  } catch (error) {
3792
- // 槽注册失败不应把整页插件装载一起拖垮:降级为仅报错(面板挂在浮层外不可见)
3809
+ // 槽注册失败仅报错,自有界面根保持未挂载,不影响其他插件。
3793
3810
  console.error('[dsh-selection-explain] shell.overlay 槽注册失败:', error)
3794
3811
  }
3795
3812
  } else {
@@ -9467,6 +9484,7 @@ window.__ModuleLoader__.load({
9467
9484
  */
9468
9485
  var offVoiceEscape = listen(document, 'keydown', function (event) {
9469
9486
  if (event.key !== 'Escape' || !vcardState.open || event.defaultPrevented) return
9487
+ if (document.querySelector('[role="dialog"][aria-modal="true"]')) return
9470
9488
  event.preventDefault()
9471
9489
  event.stopPropagation()
9472
9490
  vcardState.suppress = true
@@ -9919,11 +9937,13 @@ window.__ModuleLoader__.load({
9919
9937
 
9920
9938
  function closeModelMenu() {
9921
9939
  modelMenu.setAttribute('data-open', '0')
9940
+ modelMenu.removeAttribute('role')
9922
9941
  modelPill.setAttribute('aria-expanded', 'false')
9923
9942
  }
9924
9943
 
9925
9944
  function openModelMenu() {
9926
9945
  modelMenu.setAttribute('data-open', '1')
9946
+ modelMenu.setAttribute('role', 'menu')
9927
9947
  modelPill.setAttribute('aria-expanded', 'true')
9928
9948
  renderModelMenu()
9929
9949
  loadModelCatalog(false).then(function () {
@@ -9945,7 +9965,7 @@ window.__ModuleLoader__.load({
9945
9965
  event.stopPropagation() // 菜单内部点击不要触发"点到外面"
9946
9966
  })
9947
9967
  var offModelKeys = listen(modelMenu, 'keydown', function (event) {
9948
- if (event.key === 'Escape') closeModelMenu()
9968
+ if (event.key === 'Escape' && !document.querySelector('[role="dialog"][aria-modal="true"]')) closeModelMenu()
9949
9969
  })
9950
9970
 
9951
9971
  // —— 网页模式开关 ——
@@ -10123,6 +10143,9 @@ window.__ModuleLoader__.load({
10123
10143
 
10124
10144
  var offKeyDown = listen(document, 'keydown', function (event) {
10125
10145
  if (event.key !== 'Escape' || !panelOpen || event.defaultPrevented || event.isComposing || event.keyCode === 229) return
10146
+ // 官方模态浮层的 Esc 在冒泡阶段处理;capture 不消费,先让官方设置等浮层退出。
10147
+ // 插件面板和设置抽屉是非模态 dialog,不带 aria-modal,不会命中这里。
10148
+ if (document.querySelector('[role="dialog"][aria-modal="true"]')) return
10126
10149
  // 完整视图记录最后点击/操作的小窗;不能只看可能停在另一窗的键盘焦点。
10127
10150
  var fullViewFrame = document.querySelector('[data-dsh-full-view]')
10128
10151
  if (fullViewFrame) {
@@ -12127,6 +12150,7 @@ window.__ModuleLoader__.load({
12127
12150
  }
12128
12151
  pillObserver = null
12129
12152
  }
12153
+ if (fullViewObserver) fullViewObserver.disconnect()
12130
12154
  if (typeof offSlot === 'function') offSlot()
12131
12155
  // 划词桥:摘观察者与消息监听,并把我们自己建的预览 blob 全部回收
12132
12156
  if (bridgeScanId) clearTimeout(bridgeScanId)