[Bug][Mobile] 语音输入永远追加到草稿末尾,光标位置被完全忽略,无法在已有内容前面插入
- Dominant language
- TypeScript
- Stars
- 2.7k
- Forks
- 401
- Avg merge
- 21h 40m
- Merged PRs (30d)
- 805
Description
## 环境
- 设备:iPhone Air / iOS 27
- 移动端:当前最新版(对应桌面端 v0.1.55)
- 语音链路:Cindy 原声语音输入
## 现象
输入框里已经有一段内容,我把光标放到这段内容的**前面**,想用语音在前面补一段。识别本身是准的,但识别结果最后全部被插到了**整段内容的末尾**,而不是光标所在位置。
## 根因
基于 `main` 当前快照,这不是偶发的位置漂移,而是**数据模型层就不支持光标插入** —— 落点函数压根没有接收光标位置的参数。
### 1. 落点函数的签名里没有光标
`apps/mobile/src/session/mobileVoiceInput.ts:99-122`:
```ts
export function appendVoiceTranscriptDraftWithRange(
current: string, // 当前全文
transcript: string, // 识别文本
): { draft: string; insertion: MobileVoiceDraftInsertion | null }
```
参数只有「当前全文」和「识别文本」,**没有 caret / selection offset**。
函数体三条分支,全部指向末尾:
```ts
if (!base) return { draft: text, ... }; // 空草稿
if (/[\s\n]$/.test(current)) return { draft: `${current}${text}`, ... }; // 拼末尾
return { draft: `${base}\n${text}`, ... }; // trimEnd 后加换行拼末尾
```
第三条分支还会**额外插一个换行**,所以不仅位置错,还多一个 `\n`。
### 2. 调用点也没传光标
`apps/mobile/src/session/mobileVoiceController.ts:319`:
```ts
const result = appendVoiceTranscriptDraftWithRange(currentDraft, normalized);
```
首次落地只传了 `currentDraft`(全文)。而 `readCurrentDraft()`(`:208-210`)**只返回文本字符串,不携带任何 selection / caret 信息**。
### 3. 后续增量识别被锁死在末尾区间
首次落点后,`voiceInsertion` 记住了那个末尾区间(`:321`)。之后所有增量识别都走 `:302-316` 的原地替换路径,锁在同一个区间上。
所以实际行为是:**从第一个字开始就在末尾**,光标从头到尾没参与过任何环节。用户感知到的「识别着识别着就跑到最后了」,是因为听写过程中看到的是实时覆盖层,落地时才看到真实位置。
## 不是从零开始
代码里**已经有 range / offset 的概念**:`mobileVoiceController.ts:384-388` 的 `range.startOffset`、`range.segmentIds`、`buildEditableRange(...)` 都在用区间坐标。缺的是把 **WKWebView contentEditable 里的 selection offset 回传给 RN 侧**,再作为首次落点的 anchor。
相关:`ComposerRichInput.tsx` / `composerRichInputHtml.ts` 已有 caret-placement 路径(见 #1813 分析中引用的 `composerRichInputHtml.ts:229-243`)。
## 期望行为
- 语音识别结果插入**开始录音时光标所在位置**,而不是无条件追加到末尾
- 若录音时有选中文本,按常规文本编辑语义替换选区
- 不应该自作主张插入换行;若确实需要分隔,应基于插入点上下文判断,而不是固定 `\n`
- 录音期间实时预览文本应在最终落点位置就地显示,所见即所得
## 验收标准
- [ ] 草稿为 `世界`,光标置于最前,语音说「你好」→ 结果为 `你好世界`(非 `世界\n你好`)
- [ ] 光标置于段落中间时,文本插入中间位置
- [ ] 光标在末尾时行为与现状一致(不回归)
- [ ] 选中一段文本后录音,识别结果替换选区
- [ ] 录音中途的增量识别始终锁定在首次落点区间,不会中途跳到末尾
- [ ] 补单测:`mobileVoiceInput.test.ts` 增加光标在首 / 中 / 尾 / 有选区 四种 case
## 相关代码
- `apps/mobile/src/session/mobileVoiceInput.ts:95-122`(append 三分支,全部指向末尾)
- `apps/mobile/src/session/mobileVoiceInput.ts:126-138`(`replaceVoiceTranscriptDraftRange`,失配时回退到 append)
- `apps/mobile/src/session/mobileVoiceController.ts:319-323`(首次落点调用点,未传光标)
- `apps/mobile/src/session/mobileVoiceController.ts:208-210`(`readCurrentDraft` 只返回纯文本)
- `apps/mobile/src/session/mobileVoiceController.ts:299-322`(增量替换锁定区间)
- `apps/mobile/src/session/mobileVoiceController.ts:384-388`(已有 range / segmentIds 坐标体系)
Contributor guide
Research direction
Start with apps/mobile/src/session/mobileVoiceInput.ts and mobileVoiceController.ts, then inspect the caret-placement path in ComposerRichInput.tsx and composerRichInputHtml.ts. Trace how selection offsets can reach the first insertion call while preserving the existing incremental range behavior. Add the four caret and selection cases to mobileVoiceInput.test.ts and verify all listed acceptance scenarios without regressing end-of-draft insertion.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- react-native, typescript
- Domain
- mobile
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100