- 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)
3.9 KiB
3.9 KiB
实现方案 — TemplateManage 撤销/重做修复与插入字段光标定位(2026-04-17-12-51-47)
一、修改文件清单
src/pages/TemplateManage.tsx— 核心改动:自定义 undo/redo 栈 + 光标保存恢复 + 阻止焦点流失
二、详细改动
2.1 自定义 Undo/Redo 栈
A. 新增 Refs
在组件顶部增加:
const undoStack = useRef<string[]>([]);
const redoStack = useRef<string[]>([]);
B. pushHistory 函数
在执行任何会改变编辑器内容的操作前调用,将当前 innerHTML 推入 undo 栈,并清空 redo 栈:
const pushHistory = () => {
if (!editorRef.current) return;
undoStack.current.push(editorRef.current.innerHTML);
redoStack.current = [];
};
C. handleUndo / handleRedo
替换原来调用的 execCmd('undo') / execCmd('redo'):
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()},这是最简洁有效的办法:
<button
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertSmartField(field)}
...
>
B. 保存/恢复光标位置
利用已有的 savedRangeRef:
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> 上绑定事件:
<div
ref={editorRef}
contentEditable
className="..."
onBlur={saveSelection}
onMouseUp={saveSelection}
onKeyUp={saveSelection}
>
在 insertSmartField 中恢复光标:
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的修改。