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:
123
工程分析/实现方案-2026-04-17-12-51-47.md
Normal file
123
工程分析/实现方案-2026-04-17-12-51-47.md
Normal 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` 的修改。
|
||||
29
工程分析/经验记录.md
29
工程分析/经验记录.md
@@ -494,6 +494,35 @@ if ((settings.autoInsertDelay || 0) > 0) {
|
||||
|
||||
---
|
||||
|
||||
## 记录 20:TemplateManage 自定义 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. 具体问题**
|
||||
|
||||
41
工程分析/需求分析-2026-04-17-12-51-47.md
Normal file
41
工程分析/需求分析-2026-04-17-12-51-47.md
Normal 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` 无编译错误。
|
||||
Reference in New Issue
Block a user