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:
@@ -18,6 +18,8 @@ export default function TemplateManage() {
|
||||
const [isSaved, setIsSaved] = useState(false);
|
||||
const editorRef = useRef<HTMLDivElement>(null);
|
||||
const savedRangeRef = useRef<Range | null>(null);
|
||||
const undoStack = useRef<string[]>([]);
|
||||
const redoStack = useRef<string[]>([]);
|
||||
const [fieldLibTab, setFieldLibTab] = useState<'insert' | 'manage'>('insert');
|
||||
const [formFields, setFormFields] = useState<FormField[]>([]);
|
||||
const [newFieldForm, setNewFieldForm] = useState({ label: '', category: '填空', type: 'text' as FieldType });
|
||||
@@ -125,6 +127,7 @@ export default function TemplateManage() {
|
||||
if (smartField && targetEl.closest('.delete-btn')) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
pushHistory();
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNode(smartField);
|
||||
@@ -141,6 +144,7 @@ export default function TemplateManage() {
|
||||
if (targetEl.closest('.delete-btn')) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
pushHistory();
|
||||
if (placeholder.classList.contains('has-image')) {
|
||||
placeholder.classList.remove('has-image');
|
||||
placeholder.innerHTML = `
|
||||
@@ -221,6 +225,7 @@ export default function TemplateManage() {
|
||||
|
||||
if (target) {
|
||||
e.preventDefault();
|
||||
pushHistory();
|
||||
const sel = window.getSelection();
|
||||
const range = document.createRange();
|
||||
range.selectNode(target);
|
||||
@@ -237,7 +242,50 @@ export default function TemplateManage() {
|
||||
};
|
||||
}, [currentTemplateId]);
|
||||
|
||||
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);
|
||||
};
|
||||
|
||||
const pushHistory = () => {
|
||||
if (!editorRef.current) return;
|
||||
undoStack.current.push(editorRef.current.innerHTML);
|
||||
redoStack.current = [];
|
||||
};
|
||||
|
||||
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();
|
||||
}
|
||||
};
|
||||
|
||||
const execCmd = (command: string, value: string | undefined = undefined) => {
|
||||
if (command !== 'undo' && command !== 'redo') {
|
||||
pushHistory();
|
||||
}
|
||||
editorRef.current?.focus();
|
||||
document.execCommand(command, false, value);
|
||||
editorRef.current?.focus();
|
||||
@@ -255,10 +303,12 @@ export default function TemplateManage() {
|
||||
|
||||
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>​`;
|
||||
document.execCommand('insertHTML', false, html);
|
||||
editorRef.current?.focus();
|
||||
@@ -328,12 +378,14 @@ export default function TemplateManage() {
|
||||
table += '</tr>';
|
||||
}
|
||||
table += '</table><p></p>';
|
||||
pushHistory();
|
||||
execCmd('insertHTML', table);
|
||||
}
|
||||
};
|
||||
|
||||
const insertImage = () => {
|
||||
editorRef.current?.focus();
|
||||
restoreSelection();
|
||||
const id = 'ph_' + Date.now();
|
||||
const html = `
|
||||
<div id="${id}" class="image-placeholder" data-placeholder="true" contenteditable="false">
|
||||
@@ -341,6 +393,7 @@ export default function TemplateManage() {
|
||||
<p class="placeholder-text" style="color: #94a3b8; font-size: 11px; margin: 0; pointer-events: none;">插入/点击放置图片</p>
|
||||
</div>
|
||||
`;
|
||||
pushHistory();
|
||||
execCmd('insertHTML', html);
|
||||
};
|
||||
|
||||
@@ -557,11 +610,12 @@ export default function TemplateManage() {
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-1 p-3 border-b border-border bg-slate-50 shrink-0 overflow-x-auto no-scrollbar">
|
||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||
<button onClick={() => execCmd('undo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="撤销"><Undo size={16} /></button>
|
||||
<button onClick={() => execCmd('redo')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="重做"><Redo size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={handleUndo} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="撤销"><Undo size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={handleRedo} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="重做"><Redo size={16} /></button>
|
||||
</div>
|
||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||
<select
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onChange={(e) => { execCmd('fontName', e.target.value); e.target.value = ''; }}
|
||||
className="h-9 px-3 border border-border rounded-lg text-xs bg-white cursor-pointer focus:outline-hidden focus:border-accent"
|
||||
>
|
||||
@@ -573,12 +627,13 @@ export default function TemplateManage() {
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||
<button onClick={() => execCmd('bold')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="粗体"><Bold size={16} /></button>
|
||||
<button onClick={() => execCmd('italic')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="斜体"><Italic size={16} /></button>
|
||||
<button onClick={() => execCmd('underline')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="下划线"><Underline size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('bold')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="粗体"><Bold size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('italic')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="斜体"><Italic size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('underline')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="下划线"><Underline size={16} /></button>
|
||||
<div className="relative flex items-center">
|
||||
<input
|
||||
type="color"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onChange={(e) => execCmd('foreColor', e.target.value)}
|
||||
className="w-9 h-9 p-1.5 bg-transparent border-none cursor-pointer rounded-lg hover:bg-white transition-colors"
|
||||
title="文字颜色"
|
||||
@@ -586,13 +641,13 @@ export default function TemplateManage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1 pr-3 mr-3 border-r border-border">
|
||||
<button onClick={() => execCmd('justifyLeft')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
|
||||
<button onClick={() => execCmd('justifyCenter')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
|
||||
<button onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('justifyLeft')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="左对齐"><AlignLeft size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('justifyCenter')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="居中"><AlignCenter size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={() => execCmd('justifyRight')} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="右对齐"><AlignRight size={16} /></button>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
|
||||
<button onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={insertTable} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入表格"><Table size={16} /></button>
|
||||
<button onMouseDown={(e) => e.preventDefault()} onClick={insertImage} className="w-9 h-9 flex items-center justify-center rounded-lg hover:bg-white text-text-muted hover:text-text-main transition-colors" title="插入图片占位符"><ImageIcon size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -602,6 +657,9 @@ export default function TemplateManage() {
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
className="editor-content print-content template-editor-mode"
|
||||
onBlur={saveSelection}
|
||||
onMouseUp={saveSelection}
|
||||
onKeyUp={saveSelection}
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -636,6 +694,7 @@ export default function TemplateManage() {
|
||||
<button
|
||||
key={field.key}
|
||||
type="button"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => insertSmartField(field)}
|
||||
onMouseEnter={() => highlightField(field.key, true)}
|
||||
onMouseLeave={() => highlightField(field.key, false)}
|
||||
|
||||
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