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:
2026-04-17 13:18:54 +08:00
parent f7c7270053
commit b822bb1b47
4 changed files with 262 additions and 10 deletions

View File

@@ -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>&#8203;`;
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)}