- 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)
124 lines
3.9 KiB
Markdown
124 lines
3.9 KiB
Markdown
# 实现方案 — 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` 的修改。
|