- Intercept Ctrl+Z/Y keyboard shortcuts in keydown listener and route
to custom undoStack/redoStack to fix undo inconsistency.
- Replace execCommand('insertHTML') with precise Range.insertNode()
in insertSmartField to prevent <span> escaping out of <p> when
preceded by <br>.
3.6 KiB
3.6 KiB
实现方案 — 2026-04-17-13-32-07
目标
修复 TemplateManage.tsx 中两个编辑器交互问题:
Ctrl+Z快捷键无法撤销自定义删除行为。- 在特定 HTML 结构(段落以
<br>结尾)下插入smart-field-wrapper会导致换行错位。
变更文件
src/pages/TemplateManage.tsx
具体改动
改动 A:拦截键盘 Undo/Redo 快捷键
位置:TemplateManage.tsx 中现有的 keydown 事件监听 useEffect(第 184~243 行附近)。
做法:
在 handleKeyDown 函数的最开头,增加对 Ctrl+Z / Cmd+Z / Ctrl+Shift+Z / Ctrl+Y / Cmd+Y 的拦截:
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
e.preventDefault();
if (e.shiftKey) {
handleRedo();
} else {
handleUndo();
}
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'y') {
e.preventDefault();
handleRedo();
return;
}
注意:handleUndo / handleRedo 在该组件内是稳定函数(通过 ref 访问 DOM 状态),因此可直接在原生事件监听器闭包中调用,无需额外依赖。
改动 B:替换 insertSmartField 的插入方式
位置:insertSmartField 函数(第 304~315 行附近)。
原逻辑:
document.execCommand('insertHTML', false, html);
新逻辑:
使用 Range.insertNode() 精确插入,避免 execCommand 在 <br> 边界处的标签逃逸。
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>​`;
const sel = window.getSelection();
if (sel && sel.rangeCount > 0) {
const range = sel.getRangeAt(0);
range.deleteContents();
const wrapper = document.createElement('div');
wrapper.innerHTML = html;
const fragment = document.createDocumentFragment();
while (wrapper.firstChild) {
fragment.appendChild(wrapper.firstChild);
}
range.insertNode(fragment);
// 将光标移动到插入内容末尾
const lastNode = fragment.lastChild;
if (lastNode) {
const newRange = document.createRange();
newRange.setStartAfter(lastNode);
newRange.collapse(true);
sel.removeAllRanges();
sel.addRange(newRange);
}
}
editorRef.current?.focus();
saveTemplateContent();
};
说明:
range.deleteContents()对 collapsed 的光标无实际影响,安全。fragment.lastChild引用的是已被移入文档的具体节点,setStartAfter可正确定位光标。- 末尾的
​(零宽空格)作为TextNode被一同插入,依然起到防止字段被意外吞并的作用。
回滚策略
- 修改前
git仓库已处于干净状态(最新提交b822bb1)。 - 若验证失败,可直接
git checkout -- src/pages/TemplateManage.tsx回滚到上一版本,重新分析。
无其他依赖变更
- 不新增 npm 依赖。
- 不修改
defaultContent.ts或index.css。