- 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>.
116 lines
3.6 KiB
Markdown
116 lines
3.6 KiB
Markdown
# 实现方案 — 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`。
|