fix(editor): AI注入后Ctrl+Z失效 + 字体格式统一
- confirmAiInjection改用Range.selectNodeContents + execCommand('insertHTML')保留浏览器撤销栈
- handleAIGenerate中对cleanHtml增加<p>标签内联样式注入:padding 0px、font-family SimSun、font-size 12pt、line-height 1.5
- 确保AI替换后的文字字体与原有文字完全一致
This commit is contained in:
@@ -957,6 +957,7 @@ export default function ReportEditor() {
|
|||||||
cleanHtml = cleanHtml.replace(/<br\s*\/?>/gi, '');
|
cleanHtml = cleanHtml.replace(/<br\s*\/?>/gi, '');
|
||||||
cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>');
|
cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>');
|
||||||
cleanHtml = cleanHtml.trim();
|
cleanHtml = cleanHtml.trim();
|
||||||
|
cleanHtml = cleanHtml.replace(/<p>/gi, '<p style="padding: 0px; font-family: SimSun; font-size: 12pt; line-height: 1.5;">');
|
||||||
if (targetRegionEl) {
|
if (targetRegionEl) {
|
||||||
setDiffModal({
|
setDiffModal({
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
@@ -982,7 +983,13 @@ export default function ReportEditor() {
|
|||||||
if (!editorRef.current) return;
|
if (!editorRef.current) return;
|
||||||
const targetContent = editorRef.current.querySelector(`.ai-region[data-ai-id="${regionId}"] .ai-content`) as HTMLElement;
|
const targetContent = editorRef.current.querySelector(`.ai-region[data-ai-id="${regionId}"] .ai-content`) as HTMLElement;
|
||||||
if (targetContent) {
|
if (targetContent) {
|
||||||
targetContent.innerHTML = newHtml;
|
targetContent.focus();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(targetContent);
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
document.execCommand('insertHTML', false, newHtml);
|
||||||
targetContent.style.transition = 'background-color 0.3s ease';
|
targetContent.style.transition = 'background-color 0.3s ease';
|
||||||
targetContent.style.backgroundColor = '#bfdbfe';
|
targetContent.style.backgroundColor = '#bfdbfe';
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
94
工程分析/20260419_2030/实现方案.md
Normal file
94
工程分析/20260419_2030/实现方案.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# 实现方案
|
||||||
|
|
||||||
|
## 修改文件
|
||||||
|
- `src/pages/ReportEditor.tsx`
|
||||||
|
|
||||||
|
## 修改 1:`confirmAiInjection` 保留撤销栈(约 line 981-998)
|
||||||
|
|
||||||
|
**原代码**:
|
||||||
|
```tsx
|
||||||
|
const confirmAiInjection = (newHtml: string, regionId: string) => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
const targetContent = editorRef.current.querySelector(`.ai-region[data-ai-id="${regionId}"] .ai-content`) as HTMLElement;
|
||||||
|
if (targetContent) {
|
||||||
|
targetContent.innerHTML = newHtml;
|
||||||
|
targetContent.style.transition = 'background-color 0.3s ease';
|
||||||
|
targetContent.style.backgroundColor = '#bfdbfe';
|
||||||
|
setTimeout(() => {
|
||||||
|
targetContent.style.backgroundColor = '#eff6ff';
|
||||||
|
setTimeout(() => {
|
||||||
|
targetContent.style.backgroundColor = 'transparent';
|
||||||
|
}, 800);
|
||||||
|
}, 400);
|
||||||
|
contentRef.current = editorRef.current.innerHTML;
|
||||||
|
saveDraftToStorage();
|
||||||
|
}
|
||||||
|
setDiffModal(null);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**新代码**:
|
||||||
|
```tsx
|
||||||
|
const confirmAiInjection = (newHtml: string, regionId: string) => {
|
||||||
|
if (!editorRef.current) return;
|
||||||
|
const targetContent = editorRef.current.querySelector(`.ai-region[data-ai-id="${regionId}"] .ai-content`) as HTMLElement;
|
||||||
|
if (targetContent) {
|
||||||
|
targetContent.focus();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(targetContent);
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
document.execCommand('insertHTML', false, newHtml);
|
||||||
|
targetContent.style.transition = 'background-color 0.3s ease';
|
||||||
|
targetContent.style.backgroundColor = '#bfdbfe';
|
||||||
|
setTimeout(() => {
|
||||||
|
targetContent.style.backgroundColor = '#eff6ff';
|
||||||
|
setTimeout(() => {
|
||||||
|
targetContent.style.backgroundColor = 'transparent';
|
||||||
|
}, 800);
|
||||||
|
}, 400);
|
||||||
|
contentRef.current = editorRef.current.innerHTML;
|
||||||
|
saveDraftToStorage();
|
||||||
|
}
|
||||||
|
setDiffModal(null);
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**变更点**:
|
||||||
|
1. 去掉 `targetContent.innerHTML = newHtml;`
|
||||||
|
2. 增加 `targetContent.focus()`
|
||||||
|
3. 使用 `Range.selectNodeContents(targetContent)` 选中区域内所有旧内容
|
||||||
|
4. 使用 `document.execCommand('insertHTML', false, newHtml)` 执行替换
|
||||||
|
5. 浏览器撤销栈会记录这次替换,Ctrl+Z 可正常撤销
|
||||||
|
|
||||||
|
## 修改 2:`handleAIGenerate` 中 `<p>` 标签注入样式(约 line 955-970)
|
||||||
|
|
||||||
|
**原代码**:
|
||||||
|
```tsx
|
||||||
|
if (responseJson.updatedHtml && aiModifyEnabled) {
|
||||||
|
let cleanHtml = responseJson.updatedHtml;
|
||||||
|
cleanHtml = cleanHtml.replace(/<br\s*\/?>/gi, '');
|
||||||
|
cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>');
|
||||||
|
cleanHtml = cleanHtml.trim();
|
||||||
|
if (targetRegionEl) {
|
||||||
|
```
|
||||||
|
|
||||||
|
**新代码**:
|
||||||
|
```tsx
|
||||||
|
if (responseJson.updatedHtml && aiModifyEnabled) {
|
||||||
|
let cleanHtml = responseJson.updatedHtml;
|
||||||
|
cleanHtml = cleanHtml.replace(/<br\s*\/?>/gi, '');
|
||||||
|
cleanHtml = cleanHtml.replace(/<\/p>\s*<p>/gi, '</p><p>');
|
||||||
|
cleanHtml = cleanHtml.trim();
|
||||||
|
cleanHtml = cleanHtml.replace(/<p>/gi, '<p style="padding: 0px; font-family: SimSun; font-size: 12pt; line-height: 1.5;">');
|
||||||
|
if (targetRegionEl) {
|
||||||
|
```
|
||||||
|
|
||||||
|
**变更点**:在 `cleanHtml.trim()` 后增加一行正则替换,将所有 `<p>` 标签替换为带标准内联样式的 `<p>` 标签。
|
||||||
|
|
||||||
|
**样式值说明**:
|
||||||
|
- `padding: 0px`:与原始段落一致
|
||||||
|
- `font-family: SimSun`:宋体
|
||||||
|
- `font-size: 12pt`:12pt 字号
|
||||||
|
- `line-height: 1.5`:1.5 倍行高
|
||||||
42
工程分析/20260419_2030/测试方案.md
Normal file
42
工程分析/20260419_2030/测试方案.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# 测试方案
|
||||||
|
|
||||||
|
## 测试环境
|
||||||
|
- 浏览器访问 `http://localhost:4173/`
|
||||||
|
- 进入「图文报告生成」→ 新建报告
|
||||||
|
|
||||||
|
## 测试用例 1:Ctrl+Z 可撤销 AI 修改
|
||||||
|
|
||||||
|
**步骤**:
|
||||||
|
1. 编辑器中插入 AI 可编辑区域,写入一些内容
|
||||||
|
2. 勾选「允许修改正文」→ 发送修改指令
|
||||||
|
3. 在 diff 弹窗中点击「确认并写入报告」
|
||||||
|
4. 按 Ctrl+Z(或点击工具栏撤销按钮)
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- AI 修改的内容被撤销,恢复到修改前的状态
|
||||||
|
- 可连续按 Ctrl+Z 继续撤销更早的操作
|
||||||
|
- 撤销后内容完整,无 DOM 结构损坏
|
||||||
|
|
||||||
|
## 测试用例 2:替换后字体格式一致
|
||||||
|
|
||||||
|
**步骤**:
|
||||||
|
1. 编辑器中 AI 可编辑区域内原有内容带宋体 12pt 样式
|
||||||
|
2. 发送 AI 修改指令
|
||||||
|
3. 观察 diff 弹窗左右两侧
|
||||||
|
4. 确认注入后观察编辑器中该区域内容
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- diff 弹窗右侧「AI 提议版本」的字体为宋体 12pt,与左侧一致
|
||||||
|
- 确认注入后,编辑器中 AI 区域的文字字体与周边/原有文字一致
|
||||||
|
- 无视觉割裂感
|
||||||
|
|
||||||
|
## 测试用例 3:编译与部署
|
||||||
|
|
||||||
|
**步骤**:
|
||||||
|
1. 执行 `npm run build`
|
||||||
|
2. 确认无 TypeScript 编译错误
|
||||||
|
3. 预览服务正常启动并返回 200
|
||||||
|
|
||||||
|
**预期结果**:
|
||||||
|
- `vite build` 成功完成
|
||||||
|
- 预览页面可正常访问
|
||||||
29
工程分析/20260419_2030/需求分析.md
Normal file
29
工程分析/20260419_2030/需求分析.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# 需求分析
|
||||||
|
|
||||||
|
## 时间戳
|
||||||
|
2026-04-19 20:30
|
||||||
|
|
||||||
|
## 需求来源
|
||||||
|
用户在 AI 修改确认后遇到两个问题:
|
||||||
|
1. 点击「确认并写入报告」后,Ctrl+Z 撤销按钮无法撤销 AI 的修改
|
||||||
|
2. AI 替换后的文字字体格式与原有文字不一致(原有宋体 12pt,替换后变为浏览器默认字体)
|
||||||
|
|
||||||
|
## 问题 1:Ctrl+Z 撤销失效
|
||||||
|
|
||||||
|
**现象**:AI 修改确认注入后,按 Ctrl+Z 无法撤销。
|
||||||
|
|
||||||
|
**根因分析**:
|
||||||
|
`confirmAiInjection` 使用 `targetContent.innerHTML = newHtml;` 直接修改 DOM 属性。这种方式绕过了浏览器 `contentEditable` 的原生撤销/重做历史栈,导致浏览器无法追踪这次更改。
|
||||||
|
|
||||||
|
## 问题 2:字体格式不一致
|
||||||
|
|
||||||
|
**现象**:
|
||||||
|
- 替换前:`<p style="padding: 0px; font-family: SimSun; font-size: 12pt; line-height: 1.5;">内容</p>`
|
||||||
|
- 替换后:`<p>内容</p>`(无内联样式,显示为浏览器默认字体)
|
||||||
|
|
||||||
|
**根因分析**:
|
||||||
|
大模型严格按照指令返回纯净的 `<p>` 标签,没有内联样式。注入后浏览器使用默认字体渲染,与原有宋体 12pt 不一致。
|
||||||
|
|
||||||
|
## 解决方向
|
||||||
|
1. `confirmAiInjection` 改用 `Range.selectNodeContents` + `document.execCommand('insertHTML')`,让浏览器原生撤销栈记录这次替换
|
||||||
|
2. `handleAIGenerate` 中对 `cleanHtml` 进行正则替换,给 `<p>` 标签注入标准内联样式
|
||||||
35
工程分析/经验记录.md
35
工程分析/经验记录.md
@@ -845,3 +845,38 @@ if (aiRegion && targetRegionEl) {
|
|||||||
**D. 后续如何避免问题**
|
**D. 后续如何避免问题**
|
||||||
- 任何通过 iframe 或独立文档实现的打印/导出功能,都必须在 iframe 的 `<style>` 中独立维护打印样式,不能依赖外部 CSS 文件(因为外部样式不会自动注入 iframe)。
|
- 任何通过 iframe 或独立文档实现的打印/导出功能,都必须在 iframe 的 `<style>` 中独立维护打印样式,不能依赖外部 CSS 文件(因为外部样式不会自动注入 iframe)。
|
||||||
- 对于 diff 对比类 UI,左右两侧容器应显式设置相同的默认字体样式,避免依赖内容自带的内联样式造成视觉不一致。
|
- 对于 diff 对比类 UI,左右两侧容器应显式设置相同的默认字体样式,避免依赖内容自带的内联样式造成视觉不一致。
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 记录 36:AI 注入后 Ctrl+Z 失效 + 字体格式丢失
|
||||||
|
|
||||||
|
**A. 具体问题**
|
||||||
|
1. 点击「确认并写入报告」后,Ctrl+Z 无法撤销 AI 的修改。
|
||||||
|
2. AI 替换后的文字丢失了原有内联样式(宋体 12pt),显示为浏览器默认字体。
|
||||||
|
|
||||||
|
**B. 产生问题原因**
|
||||||
|
1. **撤销失效**:`confirmAiInjection` 使用 `targetContent.innerHTML = newHtml;` 直接修改 DOM 属性。这种方式完全绕过了浏览器 `contentEditable` 的原生撤销/重做历史栈。
|
||||||
|
2. **字体丢失**:大模型返回的是纯净的 `<p>` 标签(如 `<p>内容</p>`),没有内联样式。替换后浏览器使用默认字体渲染,与原有 `<p style="font-family: SimSun; font-size: 12pt;">` 不一致。
|
||||||
|
|
||||||
|
**C. 解决问题方案**
|
||||||
|
1. **保留撤销栈**:将 `innerHTML = newHtml` 替换为:
|
||||||
|
```ts
|
||||||
|
targetContent.focus();
|
||||||
|
const sel = window.getSelection();
|
||||||
|
const range = document.createRange();
|
||||||
|
range.selectNodeContents(targetContent);
|
||||||
|
sel?.removeAllRanges();
|
||||||
|
sel?.addRange(range);
|
||||||
|
document.execCommand('insertHTML', false, newHtml);
|
||||||
|
```
|
||||||
|
`Range.selectNodeContents` 选中区域内所有旧内容,`execCommand('insertHTML')` 让浏览器原生撤销栈记录这次替换。
|
||||||
|
2. **注入内联样式**:在 `handleAIGenerate` 的 `cleanHtml` 清洗后增加:
|
||||||
|
```ts
|
||||||
|
cleanHtml = cleanHtml.replace(/<p>/gi, '<p style="padding: 0px; font-family: SimSun; font-size: 12pt; line-height: 1.5;">');
|
||||||
|
```
|
||||||
|
给所有 `<p>` 标签注入标准内联样式,确保替换后字体与原有文字一致。
|
||||||
|
|
||||||
|
**D. 后续如何避免问题**
|
||||||
|
- 在 `contentEditable` 环境中修改内容时,**优先使用 `Range.selectNodeContents` + `execCommand('insertHTML')` 而非直接 `innerHTML` 赋值**,前者能让浏览器原生撤销/重做栈正常工作。
|
||||||
|
- 当大模型返回的 HTML 缺少必要的内联样式时,应在**前端后处理阶段**统一注入样式,而不是依赖大模型生成完整的样式代码(大模型对样式生成的稳定性较差)。
|
||||||
|
|||||||
Reference in New Issue
Block a user