From b155dd42d6ca858990794cca15b54ecc228f618d Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Fri, 17 Apr 2026 13:39:16 +0800 Subject: [PATCH] fix(TemplateManage): Ctrl+Z undo and smart-field insertion layout - Intercept Ctrl+Z/Y keyboard shortcuts in keydown listener and route to custom undoStack/redoStack to fix undo inconsistency. - Replace execCommand('insertHTML') with precise Range.insertNode() in insertSmartField to prevent escaping out of

when preceded by
. --- src/pages/TemplateManage.tsx | 39 +++++++- 工程分析/实现方案-2026-04-17-13-32-07.md | 115 +++++++++++++++++++++++ 工程分析/测试方案-2026-04-17-13-32-07.md | 82 ++++++++++++++++ 工程分析/经验记录.md | 31 ++++++ 工程分析/需求分析-2026-04-17-13-32-07.md | 73 ++++++++++++++ 5 files changed, 339 insertions(+), 1 deletion(-) create mode 100644 工程分析/实现方案-2026-04-17-13-32-07.md create mode 100644 工程分析/测试方案-2026-04-17-13-32-07.md create mode 100644 工程分析/需求分析-2026-04-17-13-32-07.md diff --git a/src/pages/TemplateManage.tsx b/src/pages/TemplateManage.tsx index d439ea0..38a2ed5 100644 --- a/src/pages/TemplateManage.tsx +++ b/src/pages/TemplateManage.tsx @@ -186,6 +186,20 @@ export default function TemplateManage() { if (!editor) return; const handleKeyDown = (e: KeyboardEvent) => { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { + e.preventDefault(); + if (e.shiftKey) { + handleRedo(); + } else { + handleUndo(); + } + return; + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') { + e.preventDefault(); + handleRedo(); + return; + } if (e.key !== 'Backspace' && e.key !== 'Delete') return; const sel = window.getSelection(); if (!sel || !sel.isCollapsed || sel.rangeCount === 0) return; @@ -310,8 +324,31 @@ export default function TemplateManage() { } pushHistory(); const html = ` ×​`; - document.execCommand('insertHTML', false, html); + + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0); + range.deleteContents(); + const wrapper = document.createElement('div'); + wrapper.innerHTML = html; + const fragment = document.createDocumentFragment(); + while (wrapper.firstChild) { + fragment.appendChild(wrapper.firstChild); + } + range.insertNode(fragment); + + const lastNode = fragment.lastChild; + if (lastNode) { + const newRange = document.createRange(); + newRange.setStartAfter(lastNode); + newRange.collapse(true); + sel.removeAllRanges(); + sel.addRange(newRange); + } + } + editorRef.current?.focus(); + saveTemplateContent(); }; const highlightField = (key: string, active: boolean) => { diff --git a/工程分析/实现方案-2026-04-17-13-32-07.md b/工程分析/实现方案-2026-04-17-13-32-07.md new file mode 100644 index 0000000..979b4c4 --- /dev/null +++ b/工程分析/实现方案-2026-04-17-13-32-07.md @@ -0,0 +1,115 @@ +# 实现方案 — 2026-04-17-13-32-07 + +## 目标 + +修复 `TemplateManage.tsx` 中两个编辑器交互问题: +1. `Ctrl+Z` 快捷键无法撤销自定义删除行为。 +2. 在特定 HTML 结构(段落以 `
` 结尾)下插入 `smart-field-wrapper` 会导致换行错位。 + +--- + +## 变更文件 + +- `src/pages/TemplateManage.tsx` + +--- + +## 具体改动 + +### 改动 A:拦截键盘 Undo/Redo 快捷键 + +**位置**:`TemplateManage.tsx` 中现有的 `keydown` 事件监听 `useEffect`(第 184~243 行附近)。 + +**做法**: +在 `handleKeyDown` 函数的最开头,增加对 `Ctrl+Z` / `Cmd+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` / `Cmd+Y` 的拦截: + +```typescript +if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') { + e.preventDefault(); + if (e.shiftKey) { + handleRedo(); + } else { + handleUndo(); + } + return; +} +if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') { + e.preventDefault(); + handleRedo(); + return; +} +``` + +**注意**:`handleUndo` / `handleRedo` 在该组件内是稳定函数(通过 ref 访问 DOM 状态),因此可直接在原生事件监听器闭包中调用,无需额外依赖。 + +--- + +### 改动 B:替换 `insertSmartField` 的插入方式 + +**位置**:`insertSmartField` 函数(第 304~315 行附近)。 + +**原逻辑**: +```typescript +document.execCommand('insertHTML', false, html); +``` + +**新逻辑**: +使用 `Range.insertNode()` 精确插入,避免 `execCommand` 在 `
` 边界处的标签逃逸。 + +```typescript +const insertSmartField = (field: FormField) => { + editorRef.current?.focus(); + restoreSelection(); + if (editorRef.current?.querySelector(`[data-bind="${field.key}"]`)) { + alert(`字段 "${field.label}" 已存在,请勿重复插入。`); + return; + } + pushHistory(); + const html = ` ×​`; + + const sel = window.getSelection(); + if (sel && sel.rangeCount > 0) { + const range = sel.getRangeAt(0); + range.deleteContents(); + const wrapper = document.createElement('div'); + wrapper.innerHTML = html; + const fragment = document.createDocumentFragment(); + while (wrapper.firstChild) { + fragment.appendChild(wrapper.firstChild); + } + range.insertNode(fragment); + + // 将光标移动到插入内容末尾 + const lastNode = fragment.lastChild; + if (lastNode) { + const newRange = document.createRange(); + newRange.setStartAfter(lastNode); + newRange.collapse(true); + sel.removeAllRanges(); + sel.addRange(newRange); + } + } + + editorRef.current?.focus(); + saveTemplateContent(); +}; +``` + +**说明**: +- `range.deleteContents()` 对 collapsed 的光标无实际影响,安全。 +- `fragment.lastChild` 引用的是已被移入文档的具体节点,`setStartAfter` 可正确定位光标。 +- 末尾的 `​`(零宽空格)作为 `TextNode` 被一同插入,依然起到防止字段被意外吞并的作用。 + +--- + +## 回滚策略 + +- 修改前 `git` 仓库已处于干净状态(最新提交 `b822bb1`)。 +- 若验证失败,可直接 `git checkout -- src/pages/TemplateManage.tsx` 回滚到上一版本,重新分析。 + +--- + +## 无其他依赖变更 + +- 不新增 npm 依赖。 +- 不修改 `defaultContent.ts` 或 `index.css`。 diff --git a/工程分析/测试方案-2026-04-17-13-32-07.md b/工程分析/测试方案-2026-04-17-13-32-07.md new file mode 100644 index 0000000..8cb2f97 --- /dev/null +++ b/工程分析/测试方案-2026-04-17-13-32-07.md @@ -0,0 +1,82 @@ +# 测试方案 — 2026-04-17-13-32-07 + +## 测试目标 + +验证 `TemplateManage.tsx` 中以下两项修复是否生效且无副作用: +1. `Ctrl+Z` / `Ctrl+Y` / `Ctrl+Shift+Z` 快捷键正确调用自定义 Undo/Redo。 +2. 在段落末尾(含 `
`)插入 `smart-field-wrapper` 不再换行错位。 + +--- + +## 测试步骤 + +### 1. 编译检查 +```bash +npm run lint +``` +- **预期结果**:`tsc --noEmit` 通过,0 errors。 + +--- + +### 2. 快捷键 Undo/Redo 测试 + +**前置条件**:登录后进入 `/template-manage`,选中任意模板。 + +#### 2.1 删除字段后撤销 +1. 在编辑器中点击一个已有的 `smart-field-wrapper` 的 × 按钮(或通过 Backspace/Delete 删除字段)。 +2. **立刻按下 `Ctrl+Z`**。 +- **预期结果**:被删除的字段完整恢复,内容与样式均正常。 + +#### 2.2 撤销后再重做 +1. 完成步骤 2.1 后,**按下 `Ctrl+Y`**(或 `Ctrl+Shift+Z`)。 +- **预期结果**:刚刚恢复的字段再次被删除。 + +#### 2.3 插入字段后撤销 +1. 从右侧字段库点击任意字段插入到编辑器。 +2. **按下 `Ctrl+Z`**。 +- **预期结果**:刚插入的字段消失,编辑器恢复到插入前的状态。 + +#### 2.4 多次撤销 +1. 连续进行多次编辑操作(插入字段、删除字段、输入文字)。 +2. 连续多次按 `Ctrl+Z`。 +- **预期结果**:每次按 `Ctrl+Z` 都按自定义历史栈顺序回退一步;不会触发浏览器原生 undo 造成状态混乱。 + +--- + +### 3. 插入字段排版测试 + +**前置条件**:编辑器中存在一个以 `
` 结尾的 `

` 标签,例如: +```html +

+ 手术日期:
+

+``` + +#### 3.1 在 `
` 后插入字段 +1. 将光标放到 `手术日期:` 后面的空白处(即 `
` 附近)。 +2. 从右侧字段库点击 `手术日期` 字段插入。 +- **预期结果**:`smart-field-wrapper` 出现在 `

` 标签内部,与 `手术日期:` 保持在同一行,**不会**跑到 `

` 外面形成新段落。 + +#### 3.2 段中插入字段 +1. 在正常段落(如 `姓名:xxx`)中间插入字段。 +- **预期结果**:字段与前后文字保持在同一行,排版正常。 + +#### 3.3 已有字段附近插入 +1. 在已有的 `smart-field-wrapper` 前后插入新字段(只要字段 key 不同,允许插入)。 +- **预期结果**:新字段正确插入,不会与已有字段重叠或被挤到下一行。 + +--- + +### 4. 回归测试 + +1. **保存模板**:进行任意编辑后点击「保存模板」,刷新页面,确认内容已持久化。 +2. **打印预览**:点击打印预览按钮,确认字段显示正常。 +3. **工具栏撤销/重做按钮**:确认点击工具栏的撤销/重做按钮依然工作正常。 +4. **Backspace/Delete 边界拦截**:确认光标紧邻字段时按 Backspace/Delete 仍然只删除字段本身,不会误删整段。 + +--- + +## 判定标准 + +- 所有编译检查和手工测试均通过,方可认为任务完成。 +- 若任一测试失败,回滚修改并重新分析根因。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index a071291..7fbaf3a 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -544,3 +544,34 @@ if ((settings.autoInsertDelay || 0) > 0) { - 在 `contentEditable` 中使用 `document.execCommand('insertHTML', ...)` 插入 HTML 时,**传入的字符串必须是无多余空白的紧凑单行**,否则浏览器会将其中的换行符解析为额外的文本节点,破坏排版和光标行为。 - 对于 `contenteditable="false"` 的内联控件,若放置在块级边界(如 `

` 开头/结尾),务必增加键盘事件拦截,防止浏览器默认行为误删父级块。 - 默认模板或任何通过代码生成的 HTML,应避免为了代码可读性而牺牲运行时 DOM 的纯净性;必要时在生成后对字符串进行 `.replace(/\s+/g, ' ').trim()` 处理。 + + +--- + +## 记录 21:TemplateManage 快捷键 Undo/Redo 与字段插入排版修复 + +**A. 具体问题** +1. TemplateManage 中删除 smart-field-wrapper 后按键盘 Ctrl+Z 无法撤销,但点击工具栏撤销按钮可以恢复。 +2. 当目标段落以
结尾时,从字段库插入 smart-field-wrapper 会被拆到下一行( 跑到了

外部)。 + +**B. 问题产生原因** +1. keydown 事件监听器只拦截了 Backspace/Delete,未拦截 Ctrl+Z/Ctrl+Y,导致浏览器原生 undo 与自定义 undoStack/ +edoStack 完全脱节。 +2. insertSmartField 使用 document.execCommand('insertHTML'),WebKit/Blink 在块级元素末尾存在
时,会自动将插入的 inline 修正到块级元素外部,造成排版错位。 + +**C. 解决问题方法** +1. **快捷键拦截**:在 keydown 监听的最开头增加 Ctrl+Z / Cmd+Z / Ctrl+Shift+Z / Ctrl+Y 的拦截,调用 e.preventDefault() 后路由到 handleUndo() 或 handleRedo()。 +2. **精确 Range 插入**:将 insertSmartField 的插入方式从 execCommand('insertHTML') 替换为手动 Range.insertNode(): + - +estoreSelection() 恢复光标; + - +ange.deleteContents() 清空当前选区; + - 将 HTML 字符串转为 DocumentFragment; + - +ange.insertNode(fragment) 精确插入到 Range 位置; + - setStartAfter(lastNode) 把光标移动到插入内容末尾。 + +**D. 经验与教训总结** +- 在 contentEditable 中实现自定义撤销栈时,必须**同时拦截界面按钮和键盘快捷键**的 undo/redo,否则两套历史机制会互相冲突。 +- document.execCommand('insertHTML') 对块级元素边界(尤其是
结尾)的自动修正行为不可控;需要精确插入时,应优先使用 Range.insertNode() 手动操作 DOM。 +- 任何对 contentEditable 的 DOM 修改后,都应同步保存内容(saveTemplateContent),确保 localStorage 中的模板数据与编辑器状态一致。 diff --git a/工程分析/需求分析-2026-04-17-13-32-07.md b/工程分析/需求分析-2026-04-17-13-32-07.md new file mode 100644 index 0000000..2a1518b --- /dev/null +++ b/工程分析/需求分析-2026-04-17-13-32-07.md @@ -0,0 +1,73 @@ +# 需求分析 — 2026-04-17-13-32-07 + +## 用户反馈 + +1. **Ctrl+Z 快捷键无法撤销对 `smart-field-wrapper` 的删除**,但点击工具栏的撤销按钮可以正常撤销。 +2. **插入 `smart-field-wrapper` 时仍会分成两行**。用户怀疑是原本文本结构的问题,并提供了出现问题的 HTML 片段: + ```html +

+ 手术日期:
+

+ ... + ``` + 用户补充:把字段插到下面的内容中(即上述 HTML 中已有字段的位置)则没有问题。 + +--- + +## 问题 1:Ctrl+Z 快捷键撤销失效 + +### 现状 +- `TemplateManage.tsx` 已实现了自定义的 `undoStack` / `redoStack` 以及 `handleUndo()` / `handleRedo()`。 +- 工具栏的撤销/重做按钮调用的是自定义函数,能够正确恢复 `innerHTML` 历史快照。 +- 键盘按下 `Ctrl+Z` 时,浏览器会触发**原生 `undo`**(`document.execCommand('undo')`),它与自定义历史栈完全脱节,因此: + - 若原生栈为空(或已耗尽),则没有任何反应; + - 若原生栈有记录,可能恢复出意料之外的状态。 + +### 根因 +- 当前只在编辑器上拦截了 `Backspace` / `Delete` 键(用于防止误删整段),**没有拦截 `Ctrl+Z` / `Ctrl+Y` 快捷键**。 + +### 需求 +- 在 `TemplateManage.tsx` 的编辑器 `keydown` 事件中,拦截以下快捷键并路由到自定义 Undo/Redo 逻辑: + - `Ctrl+Z` / `Cmd+Z` → `handleUndo()` + - `Ctrl+Shift+Z` / `Cmd+Shift+Z` → `handleRedo()` + - `Ctrl+Y` / `Cmd+Y` → `handleRedo()` +- 拦截后调用 `e.preventDefault()` 阻止浏览器原生行为。 + +--- + +## 问题 2:插入 smart-field-wrapper 分成两行 + +### 现状 +- `insertSmartField()` 使用 `document.execCommand('insertHTML', false, html)` 插入字段。 +- 当光标位于一个以 `
` 结尾的 `

` 标签末尾时,WebKit/Blink 内核的 `insertHTML` 会把 `` 插到 `

` **外部**,导致字段独自占据新行。 + +### 根因 +- 用户提供的 HTML 明确显示了这一现象:`` 跑到了 `

` 之后。 +- `execCommand('insertHTML')` 对块级元素边界(尤其是末尾存在 `
` 时)的自动修正行为不可控。 + +### 需求 +- 将 `insertSmartField()` 的插入方式从 `execCommand('insertHTML')` 替换为**精确的 `Range.insertNode()` 手动插入**: + 1. `restoreSelection()` 恢复光标; + 2. 获取当前 `Selection` 的 `Range`; + 3. `range.deleteContents()`(对 collapsed 光标无实际删除); + 4. 将 HTML 字符串转为 `DocumentFragment`; + 5. `range.insertNode(fragment)` 精确插入到 Range 位置; + 6. 把光标移动到插入内容的末尾(最后一个节点之后),保持编辑连贯性。 +- 该方式不依赖浏览器的 `execCommand` 自动修正,可避免 `` 被抛到 `

` 外部。 + +--- + +## 影响范围 + +- **仅 `src/pages/TemplateManage.tsx`** 需要修改: + - `insertSmartField()` 函数(替换插入逻辑)。 + - `keydown` 事件监听 `useEffect`(增加快捷键拦截)。 +- 其他页面(`ReportEditor.tsx`、`ReportManage.tsx` 等)不受影响。 + +--- + +## 验收标准 + +1. 在 `TemplateManage` 编辑器中删除一个 `smart-field-wrapper`(通过点击 × 或 Backspace/Delete)后,立即按 `Ctrl+Z`,字段能够完整恢复;按 `Ctrl+Y`(或 `Ctrl+Shift+Z`)能够重做删除。 +2. 在 `

` 标签末尾(尤其是存在 `
` 的情况下)插入 `smart-field-wrapper`,字段与段落保持在同一行,不再被拆成两行。 +3. `npm run lint` 通过,无 TypeScript 编译错误。