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)}
|
||||
|
||||
Reference in New Issue
Block a user