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 <span> escaping out of <p> when
preceded by <br>.
This commit is contained in:
@@ -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 = `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value" data-bind="${field.key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>​`;
|
||||
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) => {
|
||||
|
||||
115
工程分析/实现方案-2026-04-17-13-32-07.md
Normal file
115
工程分析/实现方案-2026-04-17-13-32-07.md
Normal file
@@ -0,0 +1,115 @@
|
||||
# 实现方案 — 2026-04-17-13-32-07
|
||||
|
||||
## 目标
|
||||
|
||||
修复 `TemplateManage.tsx` 中两个编辑器交互问题:
|
||||
1. `Ctrl+Z` 快捷键无法撤销自定义删除行为。
|
||||
2. 在特定 HTML 结构(段落以 `<br>` 结尾)下插入 `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` 在 `<br>` 边界处的标签逃逸。
|
||||
|
||||
```typescript
|
||||
const insertSmartField = (field: FormField) => {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
if (editorRef.current?.querySelector(`[data-bind="${field.key}"]`)) {
|
||||
alert(`字段 "${field.label}" 已存在,请勿重复插入。`);
|
||||
return;
|
||||
}
|
||||
pushHistory();
|
||||
const html = `<span class="smart-field-wrapper" contenteditable="false" style="white-space:nowrap;position:relative;"><span class="field-value" data-bind="${field.key}" contenteditable="true" style="min-width:32px;padding:0 4px;margin:0 2px;border:1px solid #cbd5e1;border-radius:2px;display:inline-block;background:#f8fafc;color:#0f172a;line-height:1.2;font-size:inherit;vertical-align:text-bottom;box-sizing:border-box;min-height:1.2em;outline:none;"> </span><span class="delete-btn" contenteditable="false">×</span></span>​`;
|
||||
|
||||
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`。
|
||||
82
工程分析/测试方案-2026-04-17-13-32-07.md
Normal file
82
工程分析/测试方案-2026-04-17-13-32-07.md
Normal file
@@ -0,0 +1,82 @@
|
||||
# 测试方案 — 2026-04-17-13-32-07
|
||||
|
||||
## 测试目标
|
||||
|
||||
验证 `TemplateManage.tsx` 中以下两项修复是否生效且无副作用:
|
||||
1. `Ctrl+Z` / `Ctrl+Y` / `Ctrl+Shift+Z` 快捷键正确调用自定义 Undo/Redo。
|
||||
2. 在段落末尾(含 `<br>`)插入 `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. 插入字段排版测试
|
||||
|
||||
**前置条件**:编辑器中存在一个以 `<br>` 结尾的 `<p>` 标签,例如:
|
||||
```html
|
||||
<p style="font-family: SimSun;">
|
||||
<strong>手术日期:</strong><br>
|
||||
</p>
|
||||
```
|
||||
|
||||
#### 3.1 在 `<br>` 后插入字段
|
||||
1. 将光标放到 `<strong>手术日期:</strong>` 后面的空白处(即 `<br>` 附近)。
|
||||
2. 从右侧字段库点击 `手术日期` 字段插入。
|
||||
- **预期结果**:`smart-field-wrapper` 出现在 `<p>` 标签内部,与 `<strong>手术日期:</strong>` 保持在同一行,**不会**跑到 `<p>` 外面形成新段落。
|
||||
|
||||
#### 3.2 段中插入字段
|
||||
1. 在正常段落(如 `姓名:xxx`)中间插入字段。
|
||||
- **预期结果**:字段与前后文字保持在同一行,排版正常。
|
||||
|
||||
#### 3.3 已有字段附近插入
|
||||
1. 在已有的 `smart-field-wrapper` 前后插入新字段(只要字段 key 不同,允许插入)。
|
||||
- **预期结果**:新字段正确插入,不会与已有字段重叠或被挤到下一行。
|
||||
|
||||
---
|
||||
|
||||
### 4. 回归测试
|
||||
|
||||
1. **保存模板**:进行任意编辑后点击「保存模板」,刷新页面,确认内容已持久化。
|
||||
2. **打印预览**:点击打印预览按钮,确认字段显示正常。
|
||||
3. **工具栏撤销/重做按钮**:确认点击工具栏的撤销/重做按钮依然工作正常。
|
||||
4. **Backspace/Delete 边界拦截**:确认光标紧邻字段时按 Backspace/Delete 仍然只删除字段本身,不会误删整段。
|
||||
|
||||
---
|
||||
|
||||
## 判定标准
|
||||
|
||||
- 所有编译检查和手工测试均通过,方可认为任务完成。
|
||||
- 若任一测试失败,回滚修改并重新分析根因。
|
||||
31
工程分析/经验记录.md
31
工程分析/经验记录.md
@@ -544,3 +544,34 @@ if ((settings.autoInsertDelay || 0) > 0) {
|
||||
- 在 `contentEditable` 中使用 `document.execCommand('insertHTML', ...)` 插入 HTML 时,**传入的字符串必须是无多余空白的紧凑单行**,否则浏览器会将其中的换行符解析为额外的文本节点,破坏排版和光标行为。
|
||||
- 对于 `contenteditable="false"` 的内联控件,若放置在块级边界(如 `<p>` 开头/结尾),务必增加键盘事件拦截,防止浏览器默认行为误删父级块。
|
||||
- 默认模板或任何通过代码生成的 HTML,应避免为了代码可读性而牺牲运行时 DOM 的纯净性;必要时在生成后对字符串进行 `.replace(/\s+/g, ' ').trim()` 处理。
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 记录 21:TemplateManage 快捷键 Undo/Redo 与字段插入排版修复
|
||||
|
||||
**A. 具体问题**
|
||||
1. TemplateManage 中删除 smart-field-wrapper 后按键盘 Ctrl+Z 无法撤销,但点击工具栏撤销按钮可以恢复。
|
||||
2. 当目标段落以 <br> 结尾时,从字段库插入 smart-field-wrapper 会被拆到下一行(<span> 跑到了 <p> 外部)。
|
||||
|
||||
**B. 问题产生原因**
|
||||
1. keydown 事件监听器只拦截了 Backspace/Delete,未拦截 Ctrl+Z/Ctrl+Y,导致浏览器原生 undo 与自定义 undoStack/
|
||||
edoStack 完全脱节。
|
||||
2. insertSmartField 使用 document.execCommand('insertHTML'),WebKit/Blink 在块级元素末尾存在 <br> 时,会自动将插入的 inline <span> 修正到块级元素外部,造成排版错位。
|
||||
|
||||
**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') 对块级元素边界(尤其是 <br> 结尾)的自动修正行为不可控;需要精确插入时,应优先使用 Range.insertNode() 手动操作 DOM。
|
||||
- 任何对 contentEditable 的 DOM 修改后,都应同步保存内容(saveTemplateContent),确保 localStorage 中的模板数据与编辑器状态一致。
|
||||
|
||||
73
工程分析/需求分析-2026-04-17-13-32-07.md
Normal file
73
工程分析/需求分析-2026-04-17-13-32-07.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# 需求分析 — 2026-04-17-13-32-07
|
||||
|
||||
## 用户反馈
|
||||
|
||||
1. **Ctrl+Z 快捷键无法撤销对 `smart-field-wrapper` 的删除**,但点击工具栏的撤销按钮可以正常撤销。
|
||||
2. **插入 `smart-field-wrapper` 时仍会分成两行**。用户怀疑是原本文本结构的问题,并提供了出现问题的 HTML 片段:
|
||||
```html
|
||||
<p style="font-family: SimSun;">
|
||||
<strong>手术日期:</strong><br>
|
||||
</p>
|
||||
<span class="smart-field-wrapper" ...>...</span>
|
||||
```
|
||||
用户补充:把字段插到下面的内容中(即上述 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)` 插入字段。
|
||||
- 当光标位于一个以 `<br>` 结尾的 `<p>` 标签末尾时,WebKit/Blink 内核的 `insertHTML` 会把 `<span>` 插到 `<p>` **外部**,导致字段独自占据新行。
|
||||
|
||||
### 根因
|
||||
- 用户提供的 HTML 明确显示了这一现象:`<span>` 跑到了 `</p>` 之后。
|
||||
- `execCommand('insertHTML')` 对块级元素边界(尤其是末尾存在 `<br>` 时)的自动修正行为不可控。
|
||||
|
||||
### 需求
|
||||
- 将 `insertSmartField()` 的插入方式从 `execCommand('insertHTML')` 替换为**精确的 `Range.insertNode()` 手动插入**:
|
||||
1. `restoreSelection()` 恢复光标;
|
||||
2. 获取当前 `Selection` 的 `Range`;
|
||||
3. `range.deleteContents()`(对 collapsed 光标无实际删除);
|
||||
4. 将 HTML 字符串转为 `DocumentFragment`;
|
||||
5. `range.insertNode(fragment)` 精确插入到 Range 位置;
|
||||
6. 把光标移动到插入内容的末尾(最后一个节点之后),保持编辑连贯性。
|
||||
- 该方式不依赖浏览器的 `execCommand` 自动修正,可避免 `<span>` 被抛到 `<p>` 外部。
|
||||
|
||||
---
|
||||
|
||||
## 影响范围
|
||||
|
||||
- **仅 `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. 在 `<p>` 标签末尾(尤其是存在 `<br>` 的情况下)插入 `smart-field-wrapper`,字段与段落保持在同一行,不再被拆成两行。
|
||||
3. `npm run lint` 通过,无 TypeScript 编译错误。
|
||||
Reference in New Issue
Block a user