fix(editor): contentEditable回车导致段落溢出.ai-content

- handleAIGenerate中获取currentHtml前增加溢出段落合并逻辑
- 遍历.ai-content之后的兄弟<p>节点,移回.ai-content内
- 合并后同步更新contentRef和saveDraftToStorage
- 确保diff弹窗左侧能显示AI可编辑区域内的全部段落
This commit is contained in:
2026-04-19 18:10:40 +08:00
parent a3cafcb672
commit 6abd7d1e3a
5 changed files with 191 additions and 0 deletions

View File

@@ -883,6 +883,23 @@ export default function ReportEditor() {
} }
} }
const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"] .ai-content`) as HTMLElement | null; const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"] .ai-content`) as HTMLElement | null;
// 合并溢出的段落:浏览器 contentEditable 可能在回车时把 <p> 生成到 .ai-content 之外
const aiRegion = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"]`);
if (aiRegion && targetRegionEl) {
let nextSibling = targetRegionEl.nextElementSibling;
while (nextSibling) {
const toMove = nextSibling;
nextSibling = nextSibling.nextElementSibling;
if (toMove.tagName === 'P') {
targetRegionEl.appendChild(toMove);
}
}
// 同步更新 contentRef 和草稿
if (editorRef.current) {
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
}
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(/&#8203;/g, '').trim() : ''; const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(/&#8203;/g, '').trim() : '';
const globalContextText = editorRef.current?.innerText || ''; const globalContextText = editorRef.current?.innerText || '';
let messageContent: any; let messageContent: any;

View File

@@ -0,0 +1,46 @@
# 实现方案
## 修改文件
- `src/pages/ReportEditor.tsx`
## 修改位置:`handleAIGenerate` 函数内,获取 `currentHtml` 之前(约 line 885
**原代码**
```tsx
const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"] .ai-content`) as HTMLElement | null;
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(/&#8203;/g, '').trim() : '';
```
**新代码**
```tsx
const targetRegionEl = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"] .ai-content`) as HTMLElement | null;
// 合并溢出的段落:浏览器 contentEditable 可能在回车时把 <p> 生成到 .ai-content 之外
const aiRegion = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"]`);
if (aiRegion && targetRegionEl) {
let nextSibling = targetRegionEl.nextElementSibling;
while (nextSibling) {
const toMove = nextSibling;
nextSibling = nextSibling.nextElementSibling;
if (toMove.tagName === 'P') {
targetRegionEl.appendChild(toMove);
}
}
// 同步更新 contentRef 和草稿
if (editorRef.current) {
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
}
const currentHtml = targetRegionEl ? targetRegionEl.innerHTML.replace(/&#8203;/g, '').trim() : '';
```
**变更点**
1. 新增 `aiRegion` 查询,获取 `.ai-region` 容器
2. 遍历 `targetRegionEl.nextElementSibling`,把所有 `<p>` 标签移回 `targetRegionEl`
3. 移动完成后同步更新 `contentRef.current` 和调用 `saveDraftToStorage()`
4. 然后才获取 `currentHtml`
**为什么这样可行**
- `confirmAiInjection` 只替换 `.ai-content` 的 innerHTML
- 修复后 `.ai-content` 已包含所有段落,注入时自然替换全部
- 溢出的段落不会再留在 `.ai-region` 内造成重复

View File

@@ -0,0 +1,45 @@
# 测试方案
## 测试环境
- 浏览器访问 `http://localhost:4173/`
- 进入「图文报告生成」→ 新建报告
## 测试用例 1溢出段落自动合并
**前置条件**
手动构造一个 DOM 结构被「破坏」的 AI 区域(模拟用户回车导致段落溢出):
1. 插入 AI 可编辑区域「手术步骤」
2. 在区域内输入第 2 段内容
3. 在区域末尾按回车,输入第 3、4、5 段(观察 DOM确认 `<p>` 标签变成了 `.ai-content` 的兄弟节点)
**步骤**
1. 勾选「允许修改正文」→ 选中「手术步骤」区域
2. 发送「请完善手术步骤描述」
**预期结果**
- diff 弹窗左侧「原始版本」应显示全部 2-5 段内容,而不只是第 2 段
- diff 弹窗右侧「AI 提议版本」也显示完整的多段落内容
- 确认注入后,编辑器中 AI 区域的全部内容被替换,没有重复段落
## 测试用例 2正常结构不受影响
**前置条件**
AI 可编辑区域内的所有段落都在 `.ai-content` 内部(正常结构)。
**步骤**
1. 发送修改指令
**预期结果**
- diff 弹窗左侧正常显示所有段落
- 合并逻辑不会误删或误移任何内容
## 测试用例 3编译与部署
**步骤**
1. 执行 `npm run build`
2. 确认无 TypeScript 编译错误
3. 预览服务正常启动并返回 200
**预期结果**
- `vite build` 成功完成
- 预览页面可正常访问

View File

@@ -0,0 +1,40 @@
# 需求分析
## 时间戳
2026-04-19 18:05
## 需求来源
用户发现 AI 修改确认弹窗的「原始版本」左侧只显示了一段内容,而编辑器中的 AI 可编辑区域实际上有 2-5 段内容。
## 现象
从用户提供的 DOM 源码可以清楚看到:
```html
<div class="ai-content">
<p><span>2腹腔镜探查...</span></p>
</div>
<p>3.切除胆囊:...</p>
<p>4.检查腹腔内无活动性出血及漏胆后:...</p>
<p>5.手术顺利,麻醉满意:...</p>
```
- `.ai-content` 内只有第 2 段
- 第 3、4、5 段变成了 `.ai-content` 的**兄弟节点**(在 `.ai-region` 内但在 `.ai-content` 外)
- 左侧 diff 弹窗只显示 `.ai-content.innerHTML`,所以只有第 2 段
## 根因分析
**浏览器 contentEditable 机制的锅**
当用户在 `.ai-content`(一个 contentEditable 的 div内按回车换行或粘贴包含多个段落的外部文本时浏览器的默认行为会截断当前的 `div`,在同级生成新的 `<p>` 标签。这导致后续的段落脱离了 `.ai-content` 父容器,变成了 `.ai-region` 的直接子节点。
## 解决方向
**代码层面修复**:在 `handleAIGenerate` 获取 `currentHtml` 之前,自动把 `.ai-region``.ai-content` 之外的 `<p>` 节点移回 `.ai-content`。这样:
1. `currentHtml` 就能包含所有段落
2. 左侧 diff 弹窗显示全部内容
3. `confirmAiInjection` 注入时替换 `.ai-content`,此时 `.ai-content` 已包含所有段落
## 约束条件
- 只移动 `.ai-content` 之后的 `<p>` 节点,不移动 `.ai-region-label` 或其他元素
- 移动后要同步更新 `contentRef.current``saveDraftToStorage()`
- 不改变现有 diff 弹窗和注入逻辑

View File

@@ -774,3 +774,46 @@ AI 修改确认弹窗右侧出现了不属于目标区域的内容:术后情
**D. 后续如何避免问题** **D. 后续如何避免问题**
- 在向大模型发送局部修改请求时,**必须设置严格的内容边界Fencing**。全局上下文可以提供给 AI 作为背景理解,但必须在 Prompt 中明确声明"仅供理解,严禁输出"。 - 在向大模型发送局部修改请求时,**必须设置严格的内容边界Fencing**。全局上下文可以提供给 AI 作为背景理解,但必须在 Prompt 中明确声明"仅供理解,严禁输出"。
- 避免使用"补充完善""基于全局信息扩展"等容易被大模型过度解读的措辞。大模型会尽其所能地"满足"用户的指令,即使这意味着越界生成。 - 避免使用"补充完善""基于全局信息扩展"等容易被大模型过度解读的措辞。大模型会尽其所能地"满足"用户的指令,即使这意味着越界生成。
---
## 记录 34contentEditable 回车导致段落溢出 .ai-content
**A. 具体问题**
AI 修改确认弹窗的「原始版本」左侧只显示了 AI 可编辑区域中的一段内容,但编辑器中该区域实际上有 2-5 段。从 DOM 源码可以看到:
```html
<div class="ai-content"><p>第2段</p></div>
<p>第3段</p>
<p>第4段</p>
<p>第5段</p>
```
第 3-5 段变成了 `.ai-content` 的兄弟节点,不在 `.ai-content` 内部。
**B. 产生问题原因**
浏览器原生 `contentEditable` 机制在用户按回车换行时,会截断当前的块级容器(`.ai-content` div在同级生成新的 `<p>` 标签。这导致后续段落脱离了 `.ai-content` 父容器,变成了 `.ai-region` 的直接子节点。
**C. 解决问题方案**
在 `handleAIGenerate` 获取 `currentHtml` 之前,增加溢出段落合并逻辑:
```ts
const aiRegion = editorRef.current?.querySelector(`.ai-region[data-ai-id="${actualTargetId}"]`);
if (aiRegion && targetRegionEl) {
let nextSibling = targetRegionEl.nextElementSibling;
while (nextSibling) {
const toMove = nextSibling;
nextSibling = nextSibling.nextElementSibling;
if (toMove.tagName === 'P') {
targetRegionEl.appendChild(toMove);
}
}
if (editorRef.current) {
contentRef.current = editorRef.current.innerHTML;
saveDraftToStorage();
}
}
```
遍历 `.ai-content` 之后的所有兄弟节点,把 `<p>` 标签移回 `.ai-content` 内,然后同步更新 contentRef 和草稿。
**D. 后续如何避免问题**
- `contentEditable` 中的嵌套容器(如 `.ai-content`)在用户输入时极易被浏览器原生编辑行为破坏结构。任何依赖特定 DOM 层级关系的功能,都必须在读取数据前做**结构完整性检查和修复**。
- 对于 AI 区域这类核心功能,应考虑在编辑器层面增加 `keydown`/`paste` 事件拦截,或改用更可控的编辑方案(如 ProseMirror/Slate来替代原生 `contentEditable`。