fix: custom undo/redo stack and cursor positioning in TemplateManage

- Add undoStack/redoStack refs with pushHistory/handleUndo/handleRedo
- Replace execCmd('undo')/execCmd('redo') with custom stack handlers
- Call pushHistory before structural changes (delete, insert field/table/image, formatting)
- Add onMouseDown preventDefault to toolbar and field library buttons to stop focus loss
- Implement saveSelection/restoreSelection using savedRangeRef
- Bind onBlur/onMouseUp/onKeyUp on editor to persist cursor position
- Restore selection in insertSmartField and insertImage before insertHTML
- Update experience record (#20)
This commit is contained in:
2026-04-17 13:18:54 +08:00
parent f7c7270053
commit b822bb1b47
4 changed files with 262 additions and 10 deletions

View File

@@ -0,0 +1,123 @@
# 实现方案 — TemplateManage 撤销/重做修复与插入字段光标定位2026-04-17-12-51-47
## 一、修改文件清单
1. `src/pages/TemplateManage.tsx` — 核心改动:自定义 undo/redo 栈 + 光标保存恢复 + 阻止焦点流失
## 二、详细改动
### 2.1 自定义 Undo/Redo 栈
#### A. 新增 Refs
在组件顶部增加:
```ts
const undoStack = useRef<string[]>([]);
const redoStack = useRef<string[]>([]);
```
#### B. `pushHistory` 函数
在执行任何会改变编辑器内容的操作前调用,将当前 `innerHTML` 推入 undo 栈,并清空 redo 栈:
```ts
const pushHistory = () => {
if (!editorRef.current) return;
undoStack.current.push(editorRef.current.innerHTML);
redoStack.current = [];
};
```
#### C. `handleUndo` / `handleRedo`
替换原来调用的 `execCmd('undo')` / `execCmd('redo')`
```ts
const handleUndo = () => {
if (undoStack.current.length === 0 || !editorRef.current) return;
redoStack.current.push(editorRef.current.innerHTML);
const prev = undoStack.current.pop();
if (prev !== undefined) {
editorRef.current.innerHTML = prev;
saveTemplateContent();
}
};
const handleRedo = () => {
if (redoStack.current.length === 0 || !editorRef.current) return;
undoStack.current.push(editorRef.current.innerHTML);
const next = redoStack.current.pop();
if (next !== undefined) {
editorRef.current.innerHTML = next;
saveTemplateContent();
}
};
```
#### D. 埋点位置
在以下操作**执行前**调用 `pushHistory()`
- `handleEditorClick` 中删除 smart field 之前
- `handleKeyDown` 中删除 smart field 之前
- `insertSmartField` 执行 `insertHTML` 之前
- `insertTable` 执行 `insertHTML` 之前
- `insertImage` 执行 `insertHTML` 之前
- 工具栏按钮(粗体/斜体/下划线/对齐/颜色/字体等)操作前
**注意**:为了不过度累积历史记录,键盘输入不需要每次按键都 pushHistory浏览器原生 undo 可以处理普通文本输入。我们的自定义栈主要负责保护“结构性变更”(插入/删除字段、表格、图片等)。
### 2.2 阻止焦点流失 + 恢复光标位置
#### A. 阻止焦点流失
在字段库按钮和工具栏按钮上增加 `onMouseDown={(e) => e.preventDefault()}`,这是最简洁有效的办法:
```tsx
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertSmartField(field)}
...
>
```
#### B. 保存/恢复光标位置
利用已有的 `savedRangeRef`
```ts
const saveSelection = () => {
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
savedRangeRef.current = sel.getRangeAt(0);
}
};
const restoreSelection = () => {
if (!savedRangeRef.current) return;
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(savedRangeRef.current);
};
```
在编辑器 `<div>` 上绑定事件:
```tsx
<div
ref={editorRef}
contentEditable
className="..."
onBlur={saveSelection}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
>
```
`insertSmartField` 中恢复光标:
```ts
const insertSmartField = (field: FormField) => {
editorRef.current?.focus();
restoreSelection();
// ... 唯一性校验 + insertHTML
};
```
### 2.3 工具栏按钮改造
所有工具栏按钮undo/redo 除外)增加 `onMouseDown={(e) => e.preventDefault()}`,并在 `onClick` 中先 `pushHistory()` 再执行命令。Undo/Redo 按钮不需要 `preventDefault`,因为它们本身不需要保持编辑器焦点,但也可以加上统一处理。
## 三、风险与回滚
- **风险**:自定义 undo/redo 栈会占用少量内存(存储 HTML 字符串),但模板内容通常不大,几十步历史记录的内存开销可忽略。
- **风险**`onMouseDown={e => e.preventDefault()}` 会阻止按钮的默认按下行为,但不会影响 `onClick` 的触发,这是 React 中阻止焦点流失的标准做法。
- **回滚**:如出现问题,可回退 `TemplateManage.tsx` 的修改。

View File

@@ -494,6 +494,35 @@ if ((settings.autoInsertDelay || 0) > 0) {
---
## 记录 20TemplateManage 自定义 Undo/Redo 与插入字段光标定位修复
**A. 具体问题**
1. `TemplateManage` 中删除智能字段(通过红 × 或 Backspace/Delete点击工具栏的"撤销"按钮无法恢复字段,"重做"也失效。
2. 点击右侧字段库按钮插入字段时,字段经常跳到下一行或文档末尾。
**B. 产生问题原因**
1. 即使将删除逻辑改为 `execCommand('delete')`,浏览器原生的 undo stack 在 `contentEditable` 中结合 React 状态更新时仍然非常脆弱,容易被清空。
2. 点击侧边栏按钮会导致编辑器 `blur`浏览器内部的光标位置Selection/Range丢失再次 `focus()` 后光标被重置,导致 `insertHTML` 插入位置错误。
**C. 解决问题方案**
1. **自定义 Undo/Redo 栈**
- 在 `TemplateManage.tsx` 中引入 `undoStack` 和 `redoStack` 两个 `useRef<string[]>([])`。
- 实现 `pushHistory()`,在执行任何结构性变更(删除字段、插入字段、插入表格/图片、格式化命令)前将当前 `editorRef.current.innerHTML` 推入 undo 栈并清空 redo 栈。
- 实现 `handleUndo()` / `handleRedo()`,直接替换工具栏按钮的 `execCmd('undo')` / `execCmd('redo')` 调用。从栈中取出历史 HTML 字符串并赋值给 `editorRef.current.innerHTML`,再调用 `saveTemplateContent()` 同步到 React state 和 `localStorage`。
2. **阻止焦点流失**
- 在所有工具栏按钮和字段库插入按钮上增加 `onMouseDown={(e) => e.preventDefault()}`,阻止 mousedown 默认行为导致编辑器失去焦点。
3. **光标位置记忆与恢复**
- 利用已有的 `savedRangeRef`,实现 `saveSelection()` 和 `restoreSelection()`。
- 在编辑器 `<div>` 上绑定 `onBlur={saveSelection}`、`onMouseUp={saveSelection}`、`onKeyUp={saveSelection}`,持续记录光标位置。
- 在 `insertSmartField` 和 `insertImage` 中,执行 `insertHTML` 前先调用 `restoreSelection()` 恢复光标,确保字段插入到正确的位置。
**D. 后续如何避免问题**
- 对于 `contentEditable` 编辑器中的结构性变更(插入/删除特殊节点),如果原生 undo 不可靠,应尽早实现自定义历史栈(基于 HTML 字符串快照),完全接管撤销/重做逻辑。
- 侧边栏/工具栏按钮与编辑器共存时,**必须**通过 `onMouseDown={e => e.preventDefault()}` 或等价手段阻止焦点流失,这是保证光标位置不丢失的最简单有效方案。
- 插入操作前恢复 `savedRangeRef` 可以作为焦点流失后的兜底保险,两者结合使用效果最佳。
---
## 记录 14智能字段插入间距修复与 Backspace 防误删
**A. 具体问题**

View File

@@ -0,0 +1,41 @@
# 需求分析 — TemplateManage 撤销/重做修复与插入字段光标定位2026-04-17-12-51-47
## 一、需求来源
用户反馈 TemplateManage 中存在两个严重的交互体验问题:
1. 删除智能字段(包括默认模板自带的和手动插入的)后,撤销/重做按钮完全失效。
2. 点击右侧字段库插入字段时,字段经常跳到下一行或错误位置。
## 二、具体需求拆解
### 需求 1撤销/重做功能修复
**问题**:即使已经将删除逻辑改为 `execCommand('delete')`,撤销/重做按钮仍然无法恢复被删除的字段。
**原因**:浏览器原生的 `undo stack``contentEditable` 中结合 React 状态更新和强制 Range 操作时非常脆弱,容易被清空或打断。
**期望**:实现一个自定义的 undo/redo 历史栈,完全接管撤销/重做逻辑,确保任何内容变更(键盘输入、插入字段、删除字段)都能被正确撤销和恢复。
### 需求 2插入字段光标定位修复
**问题**点击右侧字段库按钮时编辑器失去焦点blur浏览器内部光标位置丢失。再次 `focus()` 后,光标往往被重置到文档开头/末尾或新块级位置,导致 `insertHTML` 插入的字段跳到下一行。
**期望**
- 点击字段库按钮时不让编辑器失去焦点。
- 若焦点仍丢失则在插入前恢复上一次保存的光标位置Range
- 插入的字段必须紧跟在插入前的光标位置,不强制换行。
## 三、影响范围分析
| 文件 | 改动说明 |
|------|----------|
| `src/pages/TemplateManage.tsx` | 新增 `undoStack` / `redoStack` refs重写 `handleUndo` / `handleRedo`;替换 `execCmd('undo')` / `execCmd('redo')` 的调用;在关键操作(删除、插入)前增加 `pushHistory`;增加 `saveSelection``restoreSelection`;字段按钮增加 `onMouseDown={(e) => e.preventDefault()}` 阻止焦点流失。 |
## 四、验收标准
- [ ] 在模板中删除任意智能字段后,点击"撤销"按钮能立即恢复该字段。
- [ ] 撤销恢复后,点击"重做"按钮能再次删除该字段。
- [ ] 连续输入文字、插入字段、删除字段后,撤销/重做能按正确的历史顺序回退/前进。
- [ ] 在文字中间点击插入字段,字段紧跟光标位置,不跳到下一行或文档末尾。
- [ ] 多次在不同位置插入字段,每次都能准确定位。
- [ ] `npm run lint` 无编译错误。