@yfwu2020/dsh-selection-explain 0.10.0 → 0.10.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/README.md +129 -94
- package/assets/detail.png +0 -0
- package/assets/quotes.png +0 -0
- package/assets/translation.png +0 -0
- package/assets/voice-card-light.png +0 -0
- package/assets/voice-settings.png +0 -0
- package/assets/voice.png +0 -0
- package/lib/client.js +51 -17
- package/package.json +1 -1
- package/scripts/smoke-panel.mjs +57 -1
- package/scripts/test-client.mjs +64 -9
- package/src/client/index.js +51 -17
package/README.md
CHANGED
|
@@ -1,154 +1,189 @@
|
|
|
1
|
-
|
|
1
|
+
<div align="center">
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
# 划词解读
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
**划一下,说一句。把不懂的地方,就地问明白。**
|
|
6
|
+
|
|
7
|
+
DSH 中的翻译、上下文解读与语音追问。
|
|
8
|
+
|
|
9
|
+
[快速安装](#install) · [语音输入](#voice) · [功能一览](#read) · [使用与设置](#settings)
|
|
10
|
+
|
|
11
|
+
`DSH 插件` · `v0.10.2` · `MIT`
|
|
12
|
+
|
|
13
|
+
</div>
|
|
9
14
|
|
|
10
15
|
---
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
<a id="voice"></a>
|
|
13
18
|
|
|
14
|
-
|
|
19
|
+
## 01 / 划完,直接说
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
| --- | --- |
|
|
18
|
-
| **小窗没开**,刚划了一段文字 | 选中的地方**就地弹出一张临时卡片**,说的话直接落进卡片;松手定稿后**自动进小窗**追问(跳过翻译与详解) |
|
|
19
|
-
| **小窗开着**,刚划了一段文字 | 等于**替你点了一下 `❝ 引用`**:那段文字挂进小窗、光标进输入框,接着说的话直接落在小窗里,和下一条提问一起发出去 |
|
|
20
|
-
| 想直接对输入框说话 | 点输入框右边的 🎤 —— 和主会话**同一套**语音输入:实时波形、边说边出字、`■` 结束、`Esc` 取消(一次最长 60 秒) |
|
|
21
|
+
选中一句话,直接用语音问“它在这里是什么意思?”——问题留在选区旁,回答接着出现在小窗里。
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
### 从划词到提问,一口气完成
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
**小窗还没打开**:划选文字后启动输入法语音,选区旁会出现临时卡片,承接你说的话。结束语音、文字定稿后,默认自动发送,直接进入追问。
|
|
25
26
|
|
|
26
|
-
-
|
|
27
|
-
- **不读输入法的转写结果**:开麦时插件只是把自己的输入框抢到焦点上,字是输入法自己"上屏"进来的——所以不需要任何输入法权限,也不经第三方。
|
|
28
|
-
- **音频不落盘、不进会话**:只在这一次识别里用一下,转写完即丢。
|
|
29
|
-
- 需要:macOS 14.4+(用来检测"输入法正在用麦克风",插件自带一个小探针)+ 一个输入法(实测豆包)。识别在 host 上做,本机模型 / 云端服务都能用。
|
|
27
|
+

|
|
30
28
|
|
|
31
|
-
|
|
29
|
+
*选区通过青色下划线和虚线连接到临时输入卡片。*
|
|
30
|
+
|
|
31
|
+
**小窗已经打开**:划选新的文字,再启动输入法语音。这段文字会作为引用加入小窗,你说的话写入追问框。
|
|
32
|
+
|
|
33
|
+
### 在小窗里,也能直接开口
|
|
34
|
+
|
|
35
|
+
点击输入框旁的麦克风开始录音,查看实时波形与转写;点击停止完成输入,或按 `Esc` 取消本次录音。实时转写不可用时,会在停止后识别。
|
|
36
|
+
|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
*英文例句的解读小窗中,直接启动语音追问。*
|
|
40
|
+
|
|
41
|
+
语音卡片默认开启**说完直接发送**和**空录音时收起**。在 **设置 → 界面 → 语音输入** 中可以分别关闭。
|
|
42
|
+
|
|
43
|
+

|
|
44
|
+
|
|
45
|
+
> 输入法联动需要 **macOS 14.4+** 与支持的语音输入法(已适配豆包)。小窗麦克风需要 DSH 启用语音识别服务。未配置语音时,文字解读与键盘追问仍可使用。
|
|
46
|
+
|
|
47
|
+
<details>
|
|
48
|
+
<summary>小窗麦克风提示不可用?</summary>
|
|
49
|
+
|
|
50
|
+
请检查 DSH 宿主是否启用了 `@deepseek-ai/dsh-experimental-speech-to-text`,以及识别模型是否已就绪。首次使用时,如果小窗显示 **准备模型**,点击后按提示准备;下载与识别在宿主运行。
|
|
51
|
+
|
|
52
|
+
这与输入法语音是两个独立入口:输入法联动使用输入法本身的识别能力。
|
|
53
|
+
|
|
54
|
+
</details>
|
|
55
|
+
|
|
56
|
+
<a id="read"></a>
|
|
57
|
+
|
|
58
|
+
## 02 / 先读懂,再深入
|
|
59
|
+
|
|
60
|
+
在 DSH 中选中文字,点击 **解读**。英文先给译文,中文按内容给解读,代码则生成注释;需要更多背景时,再点 **展开详解**。
|
|
61
|
+
|
|
62
|
+

|
|
63
|
+
|
|
64
|
+
*选中 “The migration ran long, so we will ship on Wednesday”,先得到译文。*
|
|
65
|
+
|
|
66
|
+
展开后,模型结合前后文与可用的会话背景解释术语、语气和承接关系。你可以直接在底部继续追问。
|
|
32
67
|
|
|
33
|
-
|
|
68
|
+

|
|
34
69
|
|
|
35
|
-
|
|
70
|
+
*接着问“ran long 是说迁移失败了吗?”,把问题留在同一段讨论里。*
|
|
36
71
|
|
|
37
|
-
|
|
38
|
-
- **翻译按内容自适应**:中文句子只译其中夹着的英文;中文词列义项;英文句给译文;英文单词带音标逐义项;其他语言译成中文。最后固定一条**结论条**:`> 在本句中:<它在这里的确切含义>`。
|
|
39
|
-
- **详解讲透"在这里"**:先判断这段文字是真实内容还是示例 / 引用,再讲专业名词的两层含义(通用定义 + 此处的具体所指)与承接关系;拿不准的会**先联网查证**再解释,事实融进解释里。
|
|
40
|
-
- **选中代码 → 出注释**:整篇回答是一个代码块,**注释与原代码交替**,每条语句前面一行该语言的注释符号,原代码逐字不变。
|
|
72
|
+
<a id="quotes"></a>
|
|
41
73
|
|
|
42
|
-
|
|
74
|
+
## 03 / 把相关内容,一起带进来
|
|
43
75
|
|
|
44
|
-
|
|
76
|
+
小窗打开时,在主界面、小窗正文或支持的侧边栏网页预览中划选文字,点 **引用**,就能带着新材料继续问。一次最多加入 **4 段引用**,每段都可以单独移除。
|
|
45
77
|
|
|
46
|
-
|
|
78
|
+

|
|
79
|
+
|
|
80
|
+
不必每次从头来:
|
|
81
|
+
|
|
82
|
+
| 想做什么 | 入口 |
|
|
47
83
|
| --- | --- |
|
|
48
|
-
|
|
|
49
|
-
|
|
|
50
|
-
|
|
|
51
|
-
|
|
|
52
|
-
| **打断生成** | 生成中点 `停止`;关窗不会丢,胶囊点回来还是原样 |
|
|
84
|
+
| 回到刚才的解读 | 右下角悬浮胶囊,或小窗里的 **最近** |
|
|
85
|
+
| 把讨论继续做下去 | **升格**,将解读与追问带入正式会话 |
|
|
86
|
+
| 阅读代码 | 选中代码,查看注释与原代码交替呈现的解释 |
|
|
87
|
+
| 用图表理解复杂问题 | 将**输出偏好**切为网页,模型可生成 HTML 页面 |
|
|
53
88
|
|
|
54
|
-
|
|
89
|
+
<a id="install"></a>
|
|
55
90
|
|
|
56
|
-
##
|
|
91
|
+
## 快速安装
|
|
57
92
|
|
|
58
|
-
|
|
93
|
+
这是 DSH 环境中的插件。请先安装 DSH、配置一个可用模型,再运行:
|
|
59
94
|
|
|
60
95
|
```bash
|
|
61
96
|
dsh plugin --profile web add @yfwu2020/dsh-selection-explain
|
|
62
97
|
```
|
|
63
98
|
|
|
64
|
-
|
|
99
|
+
安装后刷新 DSH 页面,选中一段文字,点击 **解读** 即可。
|
|
65
100
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
tar -xzf "yfwu2020-dsh-selection-explain-$VER.tgz" -C ~/.dsh/plugins/dsh-selection-explain --strip-components=1
|
|
71
|
-
dsh plugin --profile web add ~/.dsh/plugins/dsh-selection-explain
|
|
72
|
-
```
|
|
101
|
+
也可在 [GitHub Releases](https://github.com/yfwu2020/dsh-selection-explain/releases) 下载构建包,按下方说明安装。
|
|
102
|
+
|
|
103
|
+
<details>
|
|
104
|
+
<summary>安装 Release 构建包</summary>
|
|
73
105
|
|
|
74
|
-
|
|
106
|
+
将下载的 `.tgz` 放到当前目录,以 v0.10.2 为例:
|
|
75
107
|
|
|
76
108
|
```bash
|
|
77
|
-
|
|
78
|
-
|
|
109
|
+
mkdir -p ~/.dsh/plugins/dsh-selection-explain
|
|
110
|
+
tar -xzf yfwu2020-dsh-selection-explain-0.10.2.tgz -C ~/.dsh/plugins/dsh-selection-explain --strip-components=1
|
|
111
|
+
dsh plugin --profile web add ~/.dsh/plugins/dsh-selection-explain
|
|
79
112
|
```
|
|
80
113
|
|
|
81
|
-
|
|
114
|
+
安装完成后刷新 DSH 页面。其他版本请替换文件名中的版本号。
|
|
82
115
|
|
|
83
|
-
|
|
116
|
+
</details>
|
|
84
117
|
|
|
85
|
-
|
|
118
|
+
<details>
|
|
119
|
+
<summary>从源码安装 / 卸载</summary>
|
|
86
120
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
| `✕` / `Esc` | 关面板。**语音进行中按 `Esc` 只取消这次语音**(已说出来的字留着),面板留着;再按一次才关窗 |
|
|
95
|
-
| 右下角悬浮胶囊 | 点一下:回到最近一次小窗 / 收起 |
|
|
121
|
+
```bash
|
|
122
|
+
git clone https://github.com/yfwu2020/dsh-selection-explain.git
|
|
123
|
+
cd dsh-selection-explain
|
|
124
|
+
npm install
|
|
125
|
+
npm run build
|
|
126
|
+
dsh plugin --profile web add .
|
|
127
|
+
```
|
|
96
128
|
|
|
97
|
-
|
|
129
|
+
源码构建需要 package.json 中列出的 DSH 开发依赖可用。
|
|
98
130
|
|
|
99
|
-
|
|
131
|
+
卸载 npm 安装的插件:
|
|
100
132
|
|
|
101
|
-
|
|
133
|
+
```bash
|
|
134
|
+
dsh plugin --profile web remove @yfwu2020/dsh-selection-explain
|
|
135
|
+
```
|
|
102
136
|
|
|
103
|
-
|
|
137
|
+
</details>
|
|
104
138
|
|
|
105
|
-
|
|
106
|
-
- **思考强度**:三个阶段各自一格 —— `关 / 低 / 高 / 最高`。
|
|
107
|
-
各家的档位名字并不统一(`off>low>high>max`、`low>high>max`、`off>minimal>…>high`、只有 `max`…),所以插件**每次都按当前模型自己声明的档位现算**,把你这四格落到它真实拥有的档上(悬停格子能看到"该模型发 xxx",设置页那行也会实时写出四格的落点)。**小窗里的「推理等级(追问档)」是同一套四格、同一份数据**,两边改哪边都一样。模型不吃某一档时会自动换档重试并记住结论,下次不再撞。
|
|
108
|
-
- **背景 / 工具 / 上限**:是否附带会话消息、引用是否带上文、能不能联网(`advanced_search`、`platform_search`)、超时与频率上限——都有默认值,不改也能用。
|
|
109
|
-
- **界面 · 语音输入**:上面说的两个收尾开关、右下角胶囊、侧边栏网页划词桥(`bridgeSidebarPreview`,默认开)。
|
|
139
|
+
<a id="settings"></a>
|
|
110
140
|
|
|
111
|
-
|
|
141
|
+
## 使用与设置
|
|
112
142
|
|
|
113
|
-
|
|
143
|
+
`Enter` 发送 · `Shift+Enter` 换行 · `Esc` 收起小窗。录音期间,第一次 `Esc` 只取消录音,再按一次才关闭小窗。
|
|
114
144
|
|
|
115
|
-
|
|
116
|
-
- **会话背景**:可以关(`sessionContext: false`);开启时只带你选中文字所在的消息及其之前若干条,工具调用 / 思考 / 系统注入都会被滤掉。
|
|
117
|
-
- **联网**:只在你启用的工具范围内查证;工具日志不进小窗,只给结论。
|
|
118
|
-
- **本地数据**:解读历史 `~/.dsh/selection-explain/history.json`;"哪些模型不吃关档 / 关档会泄漏"的记忆 `leaky-off.json`。都是明文,随时可删。
|
|
145
|
+
点击小窗顶部的 **设置**,修改会自动保存:
|
|
119
146
|
|
|
120
|
-
|
|
147
|
+
| 设置 | 可以调整什么 |
|
|
148
|
+
| --- | --- |
|
|
149
|
+
| 模型 | 跟随主会话,或为解读单独指定模型 |
|
|
150
|
+
| 思考强度 | 首轮、次轮与追问分别选择 **关 / 低 / 高 / 最高**;按模型实际能力映射 |
|
|
151
|
+
| 背景 | 是否附带会话消息,以及引用的上下文范围 |
|
|
152
|
+
| 联网 | 是否允许模型调用工具,以及允许的工具范围 |
|
|
153
|
+
| 界面 | 语音卡片收尾、侧边栏网页划词与悬浮胶囊 |
|
|
154
|
+
| 数据 | 保留多少条最近解读 |
|
|
121
155
|
|
|
122
|
-
|
|
156
|
+
### 与完整视图、最近会话共存(0.10.2)
|
|
123
157
|
|
|
124
|
-
|
|
158
|
+
解读小窗使用自己的浮层,显示在最近会话列表和完整视图工作面板上方,空白区域仍可点击原界面。在完整视图里,解读插件自行隐藏悬浮胶囊;返回分栏后恢复。
|
|
125
159
|
|
|
126
|
-
|
|
160
|
+
聊天与解读可以同时打开。与 `dsh-full-view` **0.1.6**、`dsh-recent-sessions` **0.2.3** 配合时,`Esc` 先退出最后操作的小窗;点击两窗之外后先退解读,再按一次收起聊天。此次调整需一起更新两个插件,未改变 Harness 的公共浮层或设置。
|
|
127
161
|
|
|
128
|
-
|
|
162
|
+
## 数据与使用范围
|
|
129
163
|
|
|
130
|
-
|
|
164
|
+
选中文字、必要的前后文,以及开启的会话背景会交给你配置的模型。可在设置中关闭会话背景或工具调用。
|
|
131
165
|
|
|
132
|
-
|
|
166
|
+
插件不将录音保存为音频文件,也不把音频附到会话;小窗录音由 DSH 配置的识别服务处理,是否经过云端取决于该服务。输入法语音由输入法本身处理,插件接收输入框中写入的文字。
|
|
133
167
|
|
|
134
|
-
|
|
168
|
+
解读历史默认保留最近 **20 条**,以明文存储在 `~/.dsh/selection-explain/history.json`。可在 **设置 → 数据** 中调整,设为 `0` 停止保存新历史;已有历史不会因此清空。
|
|
135
169
|
|
|
136
|
-
|
|
137
|
-
- 用**路由 URL**(而非 `srcdoc`/`blob`)的第三方预览器不接管(不动别人的资源改写与生命周期);小窗里自己生成的网页预览(iframe 内部)也不划词——想引用它就用末尾的 `❝ 引用整条`。
|
|
138
|
-
- 语音卡依赖 `macOS 14.4+` 的麦克风探针;🎤 语音输入需要宿主装有语音识别服务(如本机 SenseVoice)。
|
|
139
|
-
- 依赖 DSH 的 `webServer` 与 `llm` 服务;宿主过旧可能不兼容(见 `package.json` 的 peerDependencies)。
|
|
170
|
+
支持 DSH 界面与可注入的侧边栏 HTML 预览。输入框、源码视图、外部站点浏览器标签页,以及部分第三方预览不触发划词(直接通过独立 URL 加载、无法注入划词桥的页面)。模型回答与联网查证仍可能出错。
|
|
140
171
|
|
|
141
|
-
|
|
172
|
+
<details>
|
|
173
|
+
<summary>开发与兼容性</summary>
|
|
142
174
|
|
|
143
|
-
|
|
175
|
+
依赖 DSH 的 `webServer`、`llm` 等宿主服务;具体版本范围见 [package.json](https://github.com/yfwu2020/dsh-selection-explain/blob/main/package.json)。
|
|
144
176
|
|
|
145
177
|
```bash
|
|
146
|
-
npm test
|
|
147
|
-
|
|
178
|
+
npm test
|
|
179
|
+
npm run typecheck
|
|
180
|
+
npm run build
|
|
148
181
|
```
|
|
149
182
|
|
|
150
|
-
|
|
183
|
+
配置项见 [src/settings.ts](https://github.com/yfwu2020/dsh-selection-explain/blob/main/src/settings.ts),默认值见 [src/index.ts](https://github.com/yfwu2020/dsh-selection-explain/blob/main/src/index.ts)。
|
|
184
|
+
|
|
185
|
+
</details>
|
|
151
186
|
|
|
152
|
-
|
|
187
|
+
---
|
|
153
188
|
|
|
154
|
-
MIT
|
|
189
|
+
[MIT License](https://github.com/yfwu2020/dsh-selection-explain/blob/main/LICENSE) · [反馈问题](https://github.com/yfwu2020/dsh-selection-explain/issues)
|
|
Binary file
|
package/assets/quotes.png
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/assets/voice.png
CHANGED
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* 交互:在页面任意位置选中文字 → 选区末端浮出「解读」按钮 → 点击弹出面板,
|
|
5
5
|
* 面板流式显示两节:① 专业中英翻译 ② 这段文字在当前上下文中的含义详解。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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{
|
|
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)}',
|
|
@@ -688,6 +689,9 @@ window.__ModuleLoader__.load({
|
|
|
688
689
|
// flex-wrap 是给窄屏兜底的:这一行现在有 4 件东西(输出偏好 / 模型 / 麦克风 / 发送),
|
|
689
690
|
// 窄面板下宁可折成两行,也不能把发送键挤出可视区(挤出去就点不到了)。
|
|
690
691
|
'.dsh-sel-asktools{display:flex;align-items:center;gap:8px;flex-wrap:wrap;row-gap:6px}',
|
|
692
|
+
// 录音行出现时**不许换行**:发送键必须留在同一行
|
|
693
|
+
//(用户报:一开麦,↑ 就被顶到下面单独一行去了)
|
|
694
|
+
'.dsh-sel-asktools[data-capturing="1"]{flex-wrap:nowrap}',
|
|
691
695
|
'.dsh-sel-askspace{flex:1 1 auto}',
|
|
692
696
|
// 页眉里的撑开占位(标题与右侧按钮之间)
|
|
693
697
|
'.dsh-sel-headspace{flex:1 1 auto;min-width:8px}',
|
|
@@ -764,7 +768,11 @@ window.__ModuleLoader__.load({
|
|
|
764
768
|
'.dsh-sel-mic[data-state="requesting"] svg{animation:dsh-sel-spin .9s linear infinite}',
|
|
765
769
|
'.dsh-sel-mic:disabled{cursor:default;opacity:.35}',
|
|
766
770
|
// 录音行:flex:1 让它吃掉中间那截(主会话也是 waveform flex:1)
|
|
767
|
-
|
|
771
|
+
// flex-basis 必须给 **0%**(不是 auto):行内换行的判定用的是**收缩前**的假设宽度,
|
|
772
|
+
// 而波形那个 svg 的固有宽度约 300px —— 给 auto 时"这一行放不下"会被判成真,发送键于是
|
|
773
|
+
// 掉到下一行。给 0% 之后两者按可用宽度分;min-width:0 + 波形的 min-width:24px 保证
|
|
774
|
+
// 录音行照旧撑满、也不会塌成 0。
|
|
775
|
+
'.dsh-sel-capture{display:none;flex:1 1 0%;min-width:0;align-items:center;gap:8px}',
|
|
768
776
|
'.dsh-sel-capture[data-show="1"]{display:flex}',
|
|
769
777
|
// 圆键:和发送键同尺寸、同圆度;浅底 + hover 变深(主会话的 roundButton 也是这套)
|
|
770
778
|
'.dsh-sel-round{flex:0 0 auto;width:26px;height:26px;padding:0;border:0;border-radius:13px;cursor:pointer;',
|
|
@@ -777,7 +785,7 @@ window.__ModuleLoader__.load({
|
|
|
777
785
|
// 停止键:图标是实心方块(主会话的 IconStopFill),颜色取主文字色 —— 一眼是"停"
|
|
778
786
|
'.dsh-sel-stop{color:var(--dsw-alias-label-primary,inherit)}',
|
|
779
787
|
// 波形:80 根竖线、中间对齐、静音时是一条虚线(算法与主会话逐行一致,见 paintWave)
|
|
780
|
-
'.dsh-sel-vwave{flex:1 1
|
|
788
|
+
'.dsh-sel-vwave{flex:1 1 0%;min-width:24px;height:24px;display:block;color:var(--dsw-alias-label-secondary,inherit)}',
|
|
781
789
|
// 状态文案(请允许使用麦克风… / 识别中… / 没听清…):12px 次要色 + 可选呼吸点
|
|
782
790
|
'.dsh-sel-vactivity{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:8px;font-size:12px;line-height:1.4;',
|
|
783
791
|
'color:var(--dsw-alias-label-secondary,inherit);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
|
@@ -2485,9 +2493,10 @@ window.__ModuleLoader__.load({
|
|
|
2485
2493
|
styleEl.textContent = CSS
|
|
2486
2494
|
document.head.appendChild(styleEl)
|
|
2487
2495
|
|
|
2488
|
-
/**
|
|
2496
|
+
/** 自有浮层根:位于 body,公共父层的堆叠上下文不影响它。 */
|
|
2489
2497
|
var layer = el('div', 'dsh-sel-layer')
|
|
2490
2498
|
layer.style.pointerEvents = 'none'
|
|
2499
|
+
layer.setAttribute('data-dsh-floating-owner', 'explanation')
|
|
2491
2500
|
|
|
2492
2501
|
var button = el('button', 'dsh-sel-btn')
|
|
2493
2502
|
button.type = 'button'
|
|
@@ -3751,10 +3760,27 @@ window.__ModuleLoader__.load({
|
|
|
3751
3760
|
layer.appendChild(historyList)
|
|
3752
3761
|
|
|
3753
3762
|
/**
|
|
3754
|
-
*
|
|
3755
|
-
*
|
|
3756
|
-
* React 不接管它们的子树(容器本身无 children,不会被 diff 清空)。
|
|
3763
|
+
* 官方槽仅保留不可见的生命周期锚点;界面根由本插件挂到 body。
|
|
3764
|
+
* 稳定 ref 避免 React 重渲染时摘挂根节点、丢失输入焦点。
|
|
3757
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
|
+
}
|
|
3758
3784
|
/** 开窗后"还没聚焦过输入框"(见 focusComposerOnce)。 */
|
|
3759
3785
|
var askFocusPending = false
|
|
3760
3786
|
/** 调试:最后一次"没聚焦"卡在哪一步(-1 表示聚焦成功)。 */
|
|
@@ -3773,16 +3799,14 @@ window.__ModuleLoader__.load({
|
|
|
3773
3799
|
},
|
|
3774
3800
|
}, function SelectionExplainLayer() {
|
|
3775
3801
|
return React.createElement('div', {
|
|
3776
|
-
className: 'dsh-sel-
|
|
3777
|
-
style: { pointerEvents: 'none' },
|
|
3778
|
-
ref:
|
|
3779
|
-
if (node && layer.parentNode !== node) node.appendChild(layer)
|
|
3780
|
-
},
|
|
3802
|
+
className: 'dsh-sel-anchor',
|
|
3803
|
+
style: { display: 'none', pointerEvents: 'none' },
|
|
3804
|
+
ref: mountFloatingLayer,
|
|
3781
3805
|
})
|
|
3782
3806
|
})
|
|
3783
3807
|
})
|
|
3784
3808
|
} catch (error) {
|
|
3785
|
-
//
|
|
3809
|
+
// 槽注册失败仅报错,自有界面根保持未挂载,不影响其他插件。
|
|
3786
3810
|
console.error('[dsh-selection-explain] shell.overlay 槽注册失败:', error)
|
|
3787
3811
|
}
|
|
3788
3812
|
} else {
|
|
@@ -4533,7 +4557,11 @@ window.__ModuleLoader__.load({
|
|
|
4533
4557
|
vcard.setAttribute('data-voiced', on ? '1' : '0')
|
|
4534
4558
|
if (!vcardBox) return
|
|
4535
4559
|
if (String(vcardBox.value || '').trim()) return
|
|
4536
|
-
|
|
4560
|
+
// 松手之后、框还空着时显示什么:
|
|
4561
|
+
// · 「空录音时收起」开着(默认)→ 「正在关闭…」:这张卡马上要被收掉,
|
|
4562
|
+
// 如实说它正在走(而不是"直接打字也行…"那种假邀请);
|
|
4563
|
+
// · 两个收尾开关都关着(卡会留着)→ 才给"直接打字也行…"这句邀请。
|
|
4564
|
+
vcardBox.placeholder = on ? '正在听…' : settingsLive.voiceCancelOnSilence !== false ? '正在关闭…' : '直接打字也行…'
|
|
4537
4565
|
}
|
|
4538
4566
|
|
|
4539
4567
|
function focusVCard() {
|
|
@@ -4924,6 +4952,9 @@ window.__ModuleLoader__.load({
|
|
|
4924
4952
|
function closeVCard(animate) {
|
|
4925
4953
|
if (!vcardState.open) return
|
|
4926
4954
|
vcardState.open = false
|
|
4955
|
+
// 取消的那一刻就把空框的占位换成「正在关闭…」:卡正在塌缩,空框里挂着"直接打字也行…"
|
|
4956
|
+
// 是句假话。已经打出来的字**不动** —— 它就是那个塌缩动画要收掉的东西。
|
|
4957
|
+
if (vcardBox && !String(vcardBox.value || '').trim()) vcardBox.placeholder = '正在关闭…'
|
|
4927
4958
|
vcardState.range = null
|
|
4928
4959
|
vcardState.anchorRect = null
|
|
4929
4960
|
// ⚠️ 必须**显式 blur**,不能只把卡片 display:none 掉。
|
|
@@ -10379,6 +10410,8 @@ window.__ModuleLoader__.load({
|
|
|
10379
10410
|
var capturing = phase !== 'idle'
|
|
10380
10411
|
captureRow.setAttribute('data-show', capturing ? '1' : '0')
|
|
10381
10412
|
captureRow.style.display = capturing ? 'flex' : 'none'
|
|
10413
|
+
// 工具行也标记一下:录音时 flex-wrap 锁死(发送键必须还在这一行)
|
|
10414
|
+
askTools.setAttribute('data-capturing', capturing ? '1' : '0')
|
|
10382
10415
|
// 录音行出现时,工具行左边那三样(输出偏好 / 模型 / 🎤)让位——和主会话一样,
|
|
10383
10416
|
// 录音时那行只讲一件事:怎么结束、结束之后会得到什么。
|
|
10384
10417
|
webMode.style.display = capturing ? 'none' : ''
|
|
@@ -12111,6 +12144,7 @@ window.__ModuleLoader__.load({
|
|
|
12111
12144
|
}
|
|
12112
12145
|
pillObserver = null
|
|
12113
12146
|
}
|
|
12147
|
+
if (fullViewObserver) fullViewObserver.disconnect()
|
|
12114
12148
|
if (typeof offSlot === 'function') offSlot()
|
|
12115
12149
|
// 划词桥:摘观察者与消息监听,并把我们自己建的预览 blob 全部回收
|
|
12116
12150
|
if (bridgeScanId) clearTimeout(bridgeScanId)
|
package/package.json
CHANGED
package/scripts/smoke-panel.mjs
CHANGED
|
@@ -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('
|
|
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()
|
|
@@ -337,6 +349,50 @@ async function main() {
|
|
|
337
349
|
// 因为 CSS 声明块漏了收尾花括号被整条丢弃(属性对、规则不生效,单测照样全绿);
|
|
338
350
|
// 修好闭合后它又暴露了"追问一下宽度就从 440 跳到 540"的问题 —— 两件事都只有真浏览器量得出来。
|
|
339
351
|
// 现在宽度**不随阶段变**(恒 540),下面同时钉住"首轮就是 540"和"追问之后仍是 540"。
|
|
352
|
+
// ── 录音行不许把发送键顶到下一行(用户报:一开麦 ↑ 就掉到下面单独一行)──
|
|
353
|
+
// 真浏览器量布局:按录音态把 DOM 摆成插件摆的样子(亮出录音行、让位那几样工具),
|
|
354
|
+
// 再量发送键是否还与录音行同处一行。CSS 里那两条 flex-basis:0% + nowrap 就是为它服务的。
|
|
355
|
+
{
|
|
356
|
+
var toolsEl = document.querySelector('.dsh-sel-asktools')
|
|
357
|
+
var captureEl = document.querySelector('.dsh-sel-capture')
|
|
358
|
+
var sendEl = document.querySelector('.dsh-sel-asksend')
|
|
359
|
+
var waveEl = document.querySelector('.dsh-sel-vwave')
|
|
360
|
+
if (toolsEl && captureEl && sendEl) {
|
|
361
|
+
var yielding = ['.dsh-sel-pref', '.dsh-sel-askspace', '.dsh-sel-picker', '.dsh-sel-mic']
|
|
362
|
+
.map(function (sel) { return document.querySelector(sel) })
|
|
363
|
+
.filter(Boolean)
|
|
364
|
+
var restore = yielding.map(function (n) { return [n, n.style.display] })
|
|
365
|
+
// 必须**窄一点**量:波形那个 svg 的固有宽度是 384px(viewBox 640x40 按 24px 高推出来的),
|
|
366
|
+
// 540px 的面板刚好放得下、量不出问题;用户那个宽度才会露出来。面板本来就可调窄,这是真实场景。
|
|
367
|
+
var panelWide = document.querySelector('.dsh-sel-panel')
|
|
368
|
+
var widthBefore = panelWide ? panelWide.style.width : ''
|
|
369
|
+
if (panelWide) panelWide.style.width = '420px'
|
|
370
|
+
toolsEl.setAttribute('data-capturing', '1')
|
|
371
|
+
captureEl.setAttribute('data-show', '1')
|
|
372
|
+
captureEl.style.display = 'flex'
|
|
373
|
+
if (waveEl) waveEl.style.display = ''
|
|
374
|
+
yielding.forEach(function (n) { n.style.display = 'none' })
|
|
375
|
+
var captureRect = captureEl.getBoundingClientRect()
|
|
376
|
+
var sendRect = sendEl.getBoundingClientRect()
|
|
377
|
+
check(
|
|
378
|
+
'录音行出现时发送键仍在同一行(不被顶到下面)',
|
|
379
|
+
sendRect.height > 0 && sendRect.top < captureRect.bottom && Math.abs(sendRect.top - captureRect.top) <= 8,
|
|
380
|
+
JSON.stringify({
|
|
381
|
+
capture: [Math.round(captureRect.top), Math.round(captureRect.bottom)],
|
|
382
|
+
send: [Math.round(sendRect.top), Math.round(sendRect.bottom)],
|
|
383
|
+
}),
|
|
384
|
+
)
|
|
385
|
+
restore.forEach(function (pair) { pair[0].style.display = pair[1] })
|
|
386
|
+
if (panelWide) panelWide.style.width = widthBefore
|
|
387
|
+
captureEl.style.display = 'none'
|
|
388
|
+
captureEl.setAttribute('data-show', '0')
|
|
389
|
+
toolsEl.setAttribute('data-capturing', '0')
|
|
390
|
+
if (waveEl) waveEl.style.display = 'none'
|
|
391
|
+
} else {
|
|
392
|
+
check('录音行出现时发送键仍在同一行(不被顶到下面)', false, '没找到工具行/录音行/发送键')
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
340
396
|
var WIDE_WIDTH = 540
|
|
341
397
|
{
|
|
342
398
|
var panelEl = document.querySelector('.dsh-sel-panel')
|
package/scripts/test-client.mjs
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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(
|
|
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 =
|
|
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 =
|
|
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(
|
|
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',
|
|
@@ -3646,6 +3652,16 @@ assert('点回来看到"已停止"的状态与可重试入口', hook.state().pha
|
|
|
3646
3652
|
|
|
3647
3653
|
assert('CSS 契约:模型胶囊紧挨发送键、菜单向上弹', /\.dsh-sel-picker\{display:inline-flex/.test(css) && /\.dsh-sel-pickermenu\{position:absolute;right:10px;bottom:calc\(100% \+ 8px\)/.test(css), '')
|
|
3648
3654
|
assert('CSS 契约:档位选中态为主色实心', /\.dsh-sel-tierbtn\[data-on="1"\]\{background:var\(--sel-fill/.test(css), '')
|
|
3655
|
+
// 用户报:一开麦,输入框右下角那个 ↑ 就被顶到下面单独一行去了。
|
|
3656
|
+
// 行内换行的判定用的是**收缩前**的假设宽度,而波形 svg 的固有宽度约 300px ——
|
|
3657
|
+
// 所以录音行/波形必须 flex-basis:0%(不是 auto),并且录音时工具行锁死 nowrap。
|
|
3658
|
+
assert(
|
|
3659
|
+
'CSS 契约:录音时工具行不换行 + 录音行与波形按可用宽度分(flex-basis 0%)',
|
|
3660
|
+
/\.dsh-sel-asktools\[data-capturing="1"\]\{flex-wrap:nowrap\}/.test(css) &&
|
|
3661
|
+
/\.dsh-sel-capture\{display:none;flex:1 1 0%;min-width:0/.test(css) &&
|
|
3662
|
+
/\.dsh-sel-vwave\{flex:1 1 0%;min-width:24px/.test(css),
|
|
3663
|
+
'',
|
|
3664
|
+
)
|
|
3649
3665
|
|
|
3650
3666
|
const nodes = hook.modelNodes()
|
|
3651
3667
|
// 桩里没有 querySelectorAll(客户端也不用它),用 walk 数节点
|
|
@@ -5306,7 +5322,7 @@ assert('Esc/关闭后隐藏', panel.style.display === 'none')
|
|
|
5306
5322
|
{
|
|
5307
5323
|
// 也认 setAttribute('class') —— SVG 元素的 className 是只读对象,插件只能用 setAttribute
|
|
5308
5324
|
const find = (cls) =>
|
|
5309
|
-
Array.from(walk(
|
|
5325
|
+
Array.from(walk(floatingLayer)).find(
|
|
5310
5326
|
(n) => n.className === cls || (n.getAttribute && n.getAttribute('class') === cls),
|
|
5311
5327
|
)
|
|
5312
5328
|
|
|
@@ -5424,7 +5440,7 @@ assert('Esc/关闭后隐藏', panel.style.display === 'none')
|
|
|
5424
5440
|
threadDot ? 'cx=' + String(threadDot.getAttribute('cx')) + ' cy=' + String(threadDot.getAttribute('cy')) : 'none',
|
|
5425
5441
|
)
|
|
5426
5442
|
|
|
5427
|
-
const merged = Array.from(walk(
|
|
5443
|
+
const merged = Array.from(walk(floatingLayer)).filter((n) => n.className === 'dsh-sel-uline')
|
|
5428
5444
|
assert(
|
|
5429
5445
|
'块级盒子被丢掉,两行各有一条下划线(不再整段只剩一条)',
|
|
5430
5446
|
merged.length === 2,
|
|
@@ -5681,6 +5697,13 @@ assert('Esc/关闭后隐藏', panel.style.display === 'none')
|
|
|
5681
5697
|
hook.voiceCard().open === true,
|
|
5682
5698
|
JSON.stringify(hook.voiceCard()),
|
|
5683
5699
|
)
|
|
5700
|
+
// 开着「空录音时收起」时,这段"等定稿"的窗口里框里显示「正在关闭…」:
|
|
5701
|
+
// 卡片马上要被收掉,写"直接打字也行…"是句假话(用户明确要求取消时不要显示任何文字)
|
|
5702
|
+
assert(
|
|
5703
|
+
'(开着「空录音时收起」)空框里如实显示「正在关闭…」(不是"直接打字也行…"那种假邀请)',
|
|
5704
|
+
!!box && String(box.placeholder) === '正在关闭…' && String(box.value) === '',
|
|
5705
|
+
JSON.stringify({ placeholder: box ? String(box.placeholder) : '(无卡片)', value: box ? String(box.value) : '' }),
|
|
5706
|
+
)
|
|
5684
5707
|
await sleep(900)
|
|
5685
5708
|
const cardSilent = find('dsh-sel-vcard')
|
|
5686
5709
|
assert('定稿后才取消(空录音)', hook.voiceCard().open === false, JSON.stringify(hook.voiceCard()))
|
|
@@ -5761,6 +5784,38 @@ assert('Esc/关闭后隐藏', panel.style.display === 'none')
|
|
|
5761
5784
|
JSON.stringify(hook.voiceCard()),
|
|
5762
5785
|
)
|
|
5763
5786
|
|
|
5787
|
+
// 关掉「空录音时收起」(卡会留着)→ 才给"直接打字也行…"那句邀请
|
|
5788
|
+
settingsStub.value = { voiceCancelOnSilence: false, voiceAutoSend: false }
|
|
5789
|
+
hook.reloadSettings()
|
|
5790
|
+
await sleep(320)
|
|
5791
|
+
hook.close()
|
|
5792
|
+
await sleep(20)
|
|
5793
|
+
micStub.value = recording
|
|
5794
|
+
// 走真实路径把卡开出来(与上面几段同一套:选区还在 → mouseup → 浮标 → 这一拍轮询发现开麦)
|
|
5795
|
+
documentStub.dispatch('mouseup', { target: body })
|
|
5796
|
+
await sleep(320)
|
|
5797
|
+
if (box) {
|
|
5798
|
+
box.value = ''
|
|
5799
|
+
box.dispatch('input', { target: box, preventDefault() {}, stopPropagation() {} })
|
|
5800
|
+
}
|
|
5801
|
+
await sleep(80)
|
|
5802
|
+
micStub.value = stopped
|
|
5803
|
+
await sleep(400)
|
|
5804
|
+
assert(
|
|
5805
|
+
'(关掉「空录音时收起」)卡片要留着,这时才显示"直接打字也行…"',
|
|
5806
|
+
hook.voiceCard().open === true && !!box && String(box.placeholder).indexOf('直接打字') >= 0,
|
|
5807
|
+
JSON.stringify({ open: hook.voiceCard().open, placeholder: box ? String(box.placeholder) : '(无)' }),
|
|
5808
|
+
)
|
|
5809
|
+
// Esc 取消:那一瞬间空框里就该是空的(不留占位)
|
|
5810
|
+
documentStub.dispatch('keydown', { key: 'Escape', target: body, preventDefault() {}, stopPropagation() {} })
|
|
5811
|
+
await sleep(20)
|
|
5812
|
+
assert(
|
|
5813
|
+
'Esc 取消录音:那一刻空框就换成「正在关闭…」(不等动画结束)',
|
|
5814
|
+
!!box && String(box.placeholder) === '正在关闭…' && String(box.value) === '',
|
|
5815
|
+
JSON.stringify({ placeholder: box ? String(box.placeholder) : '(无)', value: box ? String(box.value) : '' }),
|
|
5816
|
+
)
|
|
5817
|
+
await sleep(800) // 等关机动画收尾
|
|
5818
|
+
|
|
5764
5819
|
// 收尾:设置清回默认,别影响后面的用例
|
|
5765
5820
|
settingsStub.value = {}
|
|
5766
5821
|
hook.reloadSettings()
|
|
@@ -6105,7 +6160,7 @@ for (const dispose of disposers.reverse()) {
|
|
|
6105
6160
|
}
|
|
6106
6161
|
}
|
|
6107
6162
|
|
|
6108
|
-
assert('
|
|
6163
|
+
assert('清理后自有根摘除,保留宿主 DOM', floatingLayer.parentNode === null && mount.children.length === 0 && body.children.indexOf(container) >= 0)
|
|
6109
6164
|
assert('清理后划词桥自己建的预览 blob 被回收', bridgeOwnedBlob !== '' && !blobStore.has(bridgeOwnedBlob), bridgeOwnedBlob)
|
|
6110
6165
|
assert('清理后钩子移除', windowStub.__dshSelectionExplain === undefined)
|
|
6111
6166
|
console.log('\n=== 客户端集成测试结束 ===')
|
package/src/client/index.js
CHANGED
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
* 交互:在页面任意位置选中文字 → 选区末端浮出「解读」按钮 → 点击弹出面板,
|
|
5
5
|
* 面板流式显示两节:① 专业中英翻译 ② 这段文字在当前上下文中的含义详解。
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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{
|
|
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)}',
|
|
@@ -688,6 +689,9 @@ window.__ModuleLoader__.load({
|
|
|
688
689
|
// flex-wrap 是给窄屏兜底的:这一行现在有 4 件东西(输出偏好 / 模型 / 麦克风 / 发送),
|
|
689
690
|
// 窄面板下宁可折成两行,也不能把发送键挤出可视区(挤出去就点不到了)。
|
|
690
691
|
'.dsh-sel-asktools{display:flex;align-items:center;gap:8px;flex-wrap:wrap;row-gap:6px}',
|
|
692
|
+
// 录音行出现时**不许换行**:发送键必须留在同一行
|
|
693
|
+
//(用户报:一开麦,↑ 就被顶到下面单独一行去了)
|
|
694
|
+
'.dsh-sel-asktools[data-capturing="1"]{flex-wrap:nowrap}',
|
|
691
695
|
'.dsh-sel-askspace{flex:1 1 auto}',
|
|
692
696
|
// 页眉里的撑开占位(标题与右侧按钮之间)
|
|
693
697
|
'.dsh-sel-headspace{flex:1 1 auto;min-width:8px}',
|
|
@@ -764,7 +768,11 @@ window.__ModuleLoader__.load({
|
|
|
764
768
|
'.dsh-sel-mic[data-state="requesting"] svg{animation:dsh-sel-spin .9s linear infinite}',
|
|
765
769
|
'.dsh-sel-mic:disabled{cursor:default;opacity:.35}',
|
|
766
770
|
// 录音行:flex:1 让它吃掉中间那截(主会话也是 waveform flex:1)
|
|
767
|
-
|
|
771
|
+
// flex-basis 必须给 **0%**(不是 auto):行内换行的判定用的是**收缩前**的假设宽度,
|
|
772
|
+
// 而波形那个 svg 的固有宽度约 300px —— 给 auto 时"这一行放不下"会被判成真,发送键于是
|
|
773
|
+
// 掉到下一行。给 0% 之后两者按可用宽度分;min-width:0 + 波形的 min-width:24px 保证
|
|
774
|
+
// 录音行照旧撑满、也不会塌成 0。
|
|
775
|
+
'.dsh-sel-capture{display:none;flex:1 1 0%;min-width:0;align-items:center;gap:8px}',
|
|
768
776
|
'.dsh-sel-capture[data-show="1"]{display:flex}',
|
|
769
777
|
// 圆键:和发送键同尺寸、同圆度;浅底 + hover 变深(主会话的 roundButton 也是这套)
|
|
770
778
|
'.dsh-sel-round{flex:0 0 auto;width:26px;height:26px;padding:0;border:0;border-radius:13px;cursor:pointer;',
|
|
@@ -777,7 +785,7 @@ window.__ModuleLoader__.load({
|
|
|
777
785
|
// 停止键:图标是实心方块(主会话的 IconStopFill),颜色取主文字色 —— 一眼是"停"
|
|
778
786
|
'.dsh-sel-stop{color:var(--dsw-alias-label-primary,inherit)}',
|
|
779
787
|
// 波形:80 根竖线、中间对齐、静音时是一条虚线(算法与主会话逐行一致,见 paintWave)
|
|
780
|
-
'.dsh-sel-vwave{flex:1 1
|
|
788
|
+
'.dsh-sel-vwave{flex:1 1 0%;min-width:24px;height:24px;display:block;color:var(--dsw-alias-label-secondary,inherit)}',
|
|
781
789
|
// 状态文案(请允许使用麦克风… / 识别中… / 没听清…):12px 次要色 + 可选呼吸点
|
|
782
790
|
'.dsh-sel-vactivity{flex:1 1 auto;min-width:0;display:flex;align-items:center;gap:8px;font-size:12px;line-height:1.4;',
|
|
783
791
|
'color:var(--dsw-alias-label-secondary,inherit);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
|
|
@@ -2485,9 +2493,10 @@ window.__ModuleLoader__.load({
|
|
|
2485
2493
|
styleEl.textContent = CSS
|
|
2486
2494
|
document.head.appendChild(styleEl)
|
|
2487
2495
|
|
|
2488
|
-
/**
|
|
2496
|
+
/** 自有浮层根:位于 body,公共父层的堆叠上下文不影响它。 */
|
|
2489
2497
|
var layer = el('div', 'dsh-sel-layer')
|
|
2490
2498
|
layer.style.pointerEvents = 'none'
|
|
2499
|
+
layer.setAttribute('data-dsh-floating-owner', 'explanation')
|
|
2491
2500
|
|
|
2492
2501
|
var button = el('button', 'dsh-sel-btn')
|
|
2493
2502
|
button.type = 'button'
|
|
@@ -3751,10 +3760,27 @@ window.__ModuleLoader__.load({
|
|
|
3751
3760
|
layer.appendChild(historyList)
|
|
3752
3761
|
|
|
3753
3762
|
/**
|
|
3754
|
-
*
|
|
3755
|
-
*
|
|
3756
|
-
* React 不接管它们的子树(容器本身无 children,不会被 diff 清空)。
|
|
3763
|
+
* 官方槽仅保留不可见的生命周期锚点;界面根由本插件挂到 body。
|
|
3764
|
+
* 稳定 ref 避免 React 重渲染时摘挂根节点、丢失输入焦点。
|
|
3757
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
|
+
}
|
|
3758
3784
|
/** 开窗后"还没聚焦过输入框"(见 focusComposerOnce)。 */
|
|
3759
3785
|
var askFocusPending = false
|
|
3760
3786
|
/** 调试:最后一次"没聚焦"卡在哪一步(-1 表示聚焦成功)。 */
|
|
@@ -3773,16 +3799,14 @@ window.__ModuleLoader__.load({
|
|
|
3773
3799
|
},
|
|
3774
3800
|
}, function SelectionExplainLayer() {
|
|
3775
3801
|
return React.createElement('div', {
|
|
3776
|
-
className: 'dsh-sel-
|
|
3777
|
-
style: { pointerEvents: 'none' },
|
|
3778
|
-
ref:
|
|
3779
|
-
if (node && layer.parentNode !== node) node.appendChild(layer)
|
|
3780
|
-
},
|
|
3802
|
+
className: 'dsh-sel-anchor',
|
|
3803
|
+
style: { display: 'none', pointerEvents: 'none' },
|
|
3804
|
+
ref: mountFloatingLayer,
|
|
3781
3805
|
})
|
|
3782
3806
|
})
|
|
3783
3807
|
})
|
|
3784
3808
|
} catch (error) {
|
|
3785
|
-
//
|
|
3809
|
+
// 槽注册失败仅报错,自有界面根保持未挂载,不影响其他插件。
|
|
3786
3810
|
console.error('[dsh-selection-explain] shell.overlay 槽注册失败:', error)
|
|
3787
3811
|
}
|
|
3788
3812
|
} else {
|
|
@@ -4533,7 +4557,11 @@ window.__ModuleLoader__.load({
|
|
|
4533
4557
|
vcard.setAttribute('data-voiced', on ? '1' : '0')
|
|
4534
4558
|
if (!vcardBox) return
|
|
4535
4559
|
if (String(vcardBox.value || '').trim()) return
|
|
4536
|
-
|
|
4560
|
+
// 松手之后、框还空着时显示什么:
|
|
4561
|
+
// · 「空录音时收起」开着(默认)→ 「正在关闭…」:这张卡马上要被收掉,
|
|
4562
|
+
// 如实说它正在走(而不是"直接打字也行…"那种假邀请);
|
|
4563
|
+
// · 两个收尾开关都关着(卡会留着)→ 才给"直接打字也行…"这句邀请。
|
|
4564
|
+
vcardBox.placeholder = on ? '正在听…' : settingsLive.voiceCancelOnSilence !== false ? '正在关闭…' : '直接打字也行…'
|
|
4537
4565
|
}
|
|
4538
4566
|
|
|
4539
4567
|
function focusVCard() {
|
|
@@ -4924,6 +4952,9 @@ window.__ModuleLoader__.load({
|
|
|
4924
4952
|
function closeVCard(animate) {
|
|
4925
4953
|
if (!vcardState.open) return
|
|
4926
4954
|
vcardState.open = false
|
|
4955
|
+
// 取消的那一刻就把空框的占位换成「正在关闭…」:卡正在塌缩,空框里挂着"直接打字也行…"
|
|
4956
|
+
// 是句假话。已经打出来的字**不动** —— 它就是那个塌缩动画要收掉的东西。
|
|
4957
|
+
if (vcardBox && !String(vcardBox.value || '').trim()) vcardBox.placeholder = '正在关闭…'
|
|
4927
4958
|
vcardState.range = null
|
|
4928
4959
|
vcardState.anchorRect = null
|
|
4929
4960
|
// ⚠️ 必须**显式 blur**,不能只把卡片 display:none 掉。
|
|
@@ -10379,6 +10410,8 @@ window.__ModuleLoader__.load({
|
|
|
10379
10410
|
var capturing = phase !== 'idle'
|
|
10380
10411
|
captureRow.setAttribute('data-show', capturing ? '1' : '0')
|
|
10381
10412
|
captureRow.style.display = capturing ? 'flex' : 'none'
|
|
10413
|
+
// 工具行也标记一下:录音时 flex-wrap 锁死(发送键必须还在这一行)
|
|
10414
|
+
askTools.setAttribute('data-capturing', capturing ? '1' : '0')
|
|
10382
10415
|
// 录音行出现时,工具行左边那三样(输出偏好 / 模型 / 🎤)让位——和主会话一样,
|
|
10383
10416
|
// 录音时那行只讲一件事:怎么结束、结束之后会得到什么。
|
|
10384
10417
|
webMode.style.display = capturing ? 'none' : ''
|
|
@@ -12111,6 +12144,7 @@ window.__ModuleLoader__.load({
|
|
|
12111
12144
|
}
|
|
12112
12145
|
pillObserver = null
|
|
12113
12146
|
}
|
|
12147
|
+
if (fullViewObserver) fullViewObserver.disconnect()
|
|
12114
12148
|
if (typeof offSlot === 'function') offSlot()
|
|
12115
12149
|
// 划词桥:摘观察者与消息监听,并把我们自己建的预览 blob 全部回收
|
|
12116
12150
|
if (bridgeScanId) clearTimeout(bridgeScanId)
|