代码编辑器撤销与重做完全指南

撤销与重做是编辑器最基础也是最重要的功能之一。本文详细介绍命令模式实现、操作逆反、选择性撤销等高级特性。

命令模式基础

使用命令模式将每个编辑操作封装为可撤销的命令对象:

/* 命令接口 */
interface Command {
    execute(): void;      // 执行命令
    undo(): void;         // 撤销命令
    redo(): void;         // 重做命令
    getDescription(): string;
}

/* 插入文本命令 */
class InsertCommand implements Command {
    constructor(document, position, text) {
        this.document = document;
        this.position = position;
        this.text = text;
    }

    execute() {
        this.document.insert(this.position, this.text);
    }

    undo() {
        this.document.delete(this.position, this.position + this.text.length);
    }

    redo() {
        this.execute();
    }

    getDescription() {
        return `插入 "${this.text}"`;
    }
}

历史栈管理

使用两个栈分别管理撤销和重做历史:

/* 撤销/重做管理器 */
class UndoRedoManager {
    constructor(maxHistory = 1000) {
        this.undoStack: Command[] = [];
        this.redoStack: Command[] = [];
        this.maxHistory = maxHistory;
    }

    /* 执行新命令并清除重做栈 */
    execute(command: Command) {
        command.execute();
        this.undoStack.push(command);
        this.redoStack = [];  // 新操作清除重做历史

        // 限制历史长度
        if (this.undoStack.length > this.maxHistory) {
            this.undoStack.shift();
        }
    }

    undo() {
        if (this.canUndo()) {
            const command = this.undoStack.pop();
            command.undo();
            this.redoStack.push(command);
        }
    }

    redo() {
        if (this.canRedo()) {
            const command = this.redoStack.pop();
            command.redo();
            this.undoStack.push(command);
        }
    }

    canUndo(): boolean {
        return this.undoStack.length > 0;
    }

    canRedo(): boolean {
        return this.redoStack.length > 0;
    }
}

复合命令

将多个操作合并为一个原子命令:

/* 复合命令 - 将多个命令合并执行 */
class CompositeCommand implements Command {
    constructor(commands: Command[]) {
        this.commands = commands;
    }

    execute() {
        this.commands.forEach(cmd => cmd.execute());
    }

    undo() {
        // 逆序撤销
        for (let i = this.commands.length - 1; i >= 0; i--) {
            this.commands[i].undo();
        }
    }

    redo() {
        this.commands.forEach(cmd => cmd.redo());
    }

    getDescription() {
        const descriptions = this.commands.map(c => c.getDescription());
        return `复合操作: ${descriptions.join(', ')}`;
    }
}

/* 查找并替换示例 */
function createReplaceCommand(doc, find, replace) {
    const positions = doc.findAll(find);
    const commands = positions.map(pos =>
        new ReplaceCommand(doc, pos, find.length, replace)
    );
    return new CompositeCommand(commands);
}

选择性撤销

支持撤销到特定的编辑点:

/* 选择性撤销 - 撤销到指定历史位置 */
class SelectiveUndoManager extends UndoRedoManager {
    /* 标记保存点 */
    createCheckpoint(name: string) {
        this.undoStack.push({
            isCheckpoint: true,
            name: name,
            undo: () => {},
            redo: () => {},
            getDescription: () => `检查点: ${name}`
        } as any);
    }

    /* 撤销到上一个检查点 */
    undoToCheckpoint() {
        while (this.canUndo()) {
            const cmd = this.undoStack[this.undoStack.length - 1];
            if (cmd['isCheckpoint']) {
                this.undoStack.pop();
                break;
            }
            this.undo();
        }
    }

    /* 获取历史摘要 */
    getHistorySummary() {
        return this.undoStack.map((cmd, i) => ({
            index: this.undoStack.length - i,
            description: cmd.getDescription(),
            isCheckpoint: cmd['isCheckpoint'] || false
        }));
    }
}

性能优化

  • 限制历史栈最大长度,防止内存溢出
  • 批量操作使用复合命令减少历史条目
  • 长时间操作使用检查点标记

鼠标拖拽撤销

支持通过鼠标操作历史记录:

/* 时间线组件 - 可视化历史 */
class HistoryTimeline {
    constructor(undoManager) {
        this.manager = undoManager;
        this.currentIndex = -1;
    }

    /* 渲染时间线 UI */
    render() {
        const history = this.manager.getHistorySummary();
        const container = createDiv();

        history.forEach((item, i) => {
            const node = createHistoryNode(item);
            if (i <= this.currentIndex) {
                node.classList.add('applied');
            }
            node.onClick(() => this.jumpTo(i));
            container.appendChild(node);
        });

        return container;
    }

    /* 跳转到指定历史位置 */
    jumpTo(targetIndex: number) {
        while (this.currentIndex > targetIndex) {
            this.manager.undo();
            this.currentIndex--;
        }
        while (this.currentIndex < targetIndex) {
            this.manager.redo();
            this.currentIndex++;
        }
    }
}

总结

撤销重做系统设计要点:

  • 使用命令模式封装操作为可逆对象
  • 使用双栈结构管理撤销和重做历史
  • 复合命令实现原子性批量操作
  • 检查点机制支持选择性撤销
  • 合理限制历史长度防止内存问题