撤消/重做实施


81

让我思考一下如何实现撤消/重做功能-就像我们在文本编辑器中一样。我应该使用什么算法以及可能阅读什么。谢谢。


3
也许添加一些有关软件工作领域的更多详细信息(文本处理?图形?数据库?),以及可能的平台/编程语言。
Pekka

Answers:


92

我知道撤消类型的两个主要方面

  • 保存状态: 撤销的一种类别是您实际保存历史状态的位置。在这种情况下,发生的情况是,您在每一点上都将状态保存在内存的某个位置。当您想撤消操作时,只需换出当前状态并换成内存中已经存在的状态即可。例如,这是通过Adobe Photoshop中的“历史记录”或在Google Chrome中重新打开已关闭的标签页来完成的。

替代文字

  • 生成状态: 另一类是您无需记住状态本身,而只需记住操作是什么。当您需要撤消操作时,您需要对该特定操作进行逻辑逆转。举一个简单的例子,当您在支持撤消功能的文本编辑器中执行Ctrl+B时,它会被记住为粗体操作。现在,每个动作都是其逻辑反向的映射。因此,当您执行Ctrl+时Z,它将从逆操作表中查找并发现撤消操作再次为Ctrl+ B。执行该操作,您将获得以前的状态。因此,这里以前的状态没有存储在内存中,而是在需要时生成。

对于文本编辑器来说,以这种方式生成状态并不需要太多的计算,但是对于Adobe Photoshop之类的程序而言,它可能需要太多的计算或根本不可能。例如,对于Blur动作,您将指定一个de-Blur动作,但是由于数据已经丢失,它永远无法使您回到原始状态。因此,根据情况-逻辑逆操作的可能性及其可行性,您需要在这两个大类之间进行选择,然后以所需的方式实现它们。当然,有可能有一个适合您的混合策略。

此外,有时(例如在Gmail中)可能会执行有时间限制的撤消操作,因为操作(发送邮件)从来没有做过。因此,您不是在“撤消”操作,而是在“不做”操作本身。


9
有时,将保存状态和“转发”操作混合在一起可能会有所帮助。作为一种简单的方法,如果一个人每5个动作都保持一个“主要保存状态”,并且在最后一个“主要保存状态”之后还保存一个保存状态,则可以通过还原保存状态来执行前几个撤消操作,而一个可以执行通过重复上一次主要保存中的4个操作来撤消下一个撤消操作。一种更通用的方法是对不同级别的保存状态使用2的幂进行级数,因此需要使用O(1)前向迭代为N级撤消存储lg(N)保存状态。
超级猫

答案也应该添加这种混合方法。在通过生成先前状态进行撤消的操作过于复杂且正在编辑的数据太大的情况下,这是非常可行的。两者之间的平衡。人们可以采用许多策略,而不是采用固定的4长度或2的幂幂级数。就像保存状态一样,只要生成先前状态太复杂即可。
zookastos

20

我从头开始编写了两个文本编辑器,它们都采用了非常原始的撤消/重做功能。所谓“原始”,是指该功能非常易于实现,但是对于非常大的文件(例如>> 10 MB)来说,这是不经济的。但是,该系统非常灵活。例如,它支持无限级别的撤消。

基本上,我定义一个像

type
  TUndoDataItem = record
    text: /array of/ string;
    selBegin: integer;
    selEnd: integer;
    scrollPos: TPoint;
  end;

然后定义一个数组

var
  UndoData: array of TUndoDataItem;

然后,此数组的每个成员指定文本的保存状态。现在,在每次编辑文本时(按下字符键,按下退格键,删除键按下,剪切/粘贴,鼠标移动选择等),我(重新)启动(例如)一秒钟的计时器。触发后,计时器会将当前状态另存为UndoData阵列的新成员。

在撤消(Ctrl + Z)时,我将编辑器还原到状态UndoData[UndoLevel - 1]并将其减少UndoLevel一。默认情况下,UndoLevel等于UndoData数组最后一个成员的索引。在重做(Ctrl + Y或Shift + Ctrl + Z)时,我将编辑器还原到状态UndoData[UndoLevel + 1],并将编辑器增加UndoLevel一。当然,如果在UndoLevel不等于UndoData数组长度(减一)时触发编辑计时器UndoLevel,则在Microsoft Windows平台上常见的情况下,我会清除此数组的所有项目(但我记得Emacs更好)正确-Microsoft Windows方法的缺点是,如果您撤消了许多更改,然后不小心编辑了缓冲区,则先前的内容(未使用的内容将永久丢失)。您可能要跳过此数组缩减。

在不同类型的程序(例如图像编辑器)中,可以应用相同的技术,但是当然可以使用完全不同的UndoDataItem结构。不需要太多内存的更高级的方法是仅保存撤消级别之间的更改(也就是说,您可以保存“ alpha \ nbeta \ gamma”和“ alpha \ nbeta \ ngamma \ ndelta”而不是保存“ alpha \ nbeta \ ngamma”和“ ADD \ ndelta”(如果您明白我的意思的话)。在非常大的文件中,与文件大小相比,每个更改都很小,这将大大减少撤消数据的内存使用,但是实现起来比较棘手,并且可能更容易出错。


@AlexanderSuraphel:我猜他们会使用“更高级”的方法。
Andreas Rejbrand 2015年


8

有点晚了,但是这里有:您专门指的是文本编辑器,以下内容解释了一种可以适应您正在编辑的算法的算法。涉及的原则是保留一系列操作/指令,这些操作/指令可以自动重新创建您所做的每个更改。不要对原始文件进行更改(如果不为空),请保留它作为备份。

保留对原始文件所做的更改的前后链接列表。此列表会间歇性地保存到一个临时文件中,直到用户实际保存更改为止:在这种情况下,您将更改应用于新文件,复制旧文件并同时应用更改;然后将原始文件重命名为备份,然后将新文件的名称更改为正确的名称。(您可以保留已保存的更改列表,也可以将其删除并替换为后续更改列表。)

链表中的每个节点都包含以下信息:。

  • 更改类型:您插入数据,或删除数据:“更改”数据表示delete后跟一个insert
  • 文件中的位置:可以是偏移量或行/列对
  • 数据缓冲区:这是动作所涉及的数据;如果insert是插入的数据;如果delete为,则删除的数据。

要实现Undo,您需要使用“当前节点”指针或索引从链表的末尾开始进行操作:更改为insert,您可以删除但不更新链表;并在其中delete将数据从链表缓冲区中插入。对用户的每个“撤消”命令执行此操作。Redo向前移动“当前节点”指针并根据该节点执行更改。如果用户在撤消后对代码进行更改,则删除“ current-node”指示符后面的所有节点,并将tail设置为“ current-node”指示符。然后将用户的新更改插入尾部。就是这样。


8

我仅有的两分钱是,您需要使用两个堆栈来跟踪操作。每次用户执行某些操作时,您的程序应将这些操作放在“执行的”堆栈上。当用户想要撤消那些操作时,只需将操作从“执行”堆栈弹出到“调用”堆栈即可。当用户想要重做这些操作时,请从“调用”堆栈中弹出项目并将其推回到“执行”堆栈中。

希望能帮助到你。



2

您可以研究一个现有撤消/重做框架的示例,第一个Google命中是在Codeplex上(用于.NET)。我不知道这是否比其他任何框架更好或更糟,其中有很多。

如果您的目标是在应用程序中具有撤消/重做功能,则不妨选择一个看起来适合您的应用程序的现有框架。
如果您想学习如何构建自己的撤消/重做操作,则可以下载源代码,并了解两种模式以及如何进行连接的详细信息。




0

实现基本撤消/重做功能的一种方法是同时使用记忆和命令设计模式。

例如,Memento旨在保持对象的状态以备以后恢复。出于优化目的,此纪念品应尽可能小。

命令中的对象(一个命令)模式封装一些指令需要时执行。

基于这两个概念,您可以编写基本的撤消/重做历史记录,例如以下以TypeScript编码的历史记录(从前端库Interacto提取并改编而成)。

这样的历史取决于两个堆栈:

  • 可以撤消的对象的堆栈
  • 可以重做的对象的堆栈

在算法中提供了注释。请注意,在撤消操作中,必须清除重做堆栈!原因是让应用程序处于稳定状态:如果您回溯过去重做所做的某些操作,则在更改未来时,以前的操作将不再存在。

export class UndoHistory {
    /** The undoable objects. */
    private readonly undos: Array<Undoable>;

    /** The redoable objects. */
    private readonly redos: Array<Undoable>;

    /** The maximal number of undo. */
    private sizeMax: number;

    public constructor() {
        this.sizeMax = 0;
        this.undos = [];
        this.redos = [];
        this.sizeMax = 30;
    }

    /** Adds an undoable object to the collector. */
    public add(undoable: Undoable): void {
        if (this.sizeMax > 0) {
            // Cleaning the oldest undoable object
            if (this.undos.length === this.sizeMax) {
                this.undos.pop();
            }

            this.undos.push(undoable);
            // You must clear the redo stack!
            this.clearRedo();
        }
    }

    private clearRedo(): void {
        if (this.redos.length > 0) {
            this.redos.length = 0;
        }
    }

    /** Undoes the last undoable object. */
    public undo(): void {
        const undoable = this.undos.pop();
        if (undoable !== undefined) {
            undoable.undo();
            this.redos.push(undoable);
        }
    }

    /** Redoes the last undoable object. */
    public redo(): void {
        const undoable = this.redos.pop();
        if (undoable !== undefined) {
            undoable.redo();
            this.undos.push(undoable);
        }
    }
}

Undoable界面相当简单:

export interface Undoable {
    /** Undoes the command */
    undo(): void;
    /** Redoes the undone command */
    redo(): void;
}

现在,您可以编写在应用程序上运行的可撤消命令。

例如(仍然基于Interacto示例),您可以编写如下命令:

export class ClearTextCmd implements Undoable {
   // The memento that saves the previous state of the text data
   private memento: string;

   public constructor(private text: TextData) {}
   
   // Executes the command
   public execute() void {
     // Creating the memento
     this.memento = this.text.text;
     // Applying the changes (in many 
     // cases do and redo are similar, but the memento creation)
     redo();
   }

   public undo(): void {
     this.text.text = this.memento;
   }

   public redo(): void {
     this.text.text = '';
   }
}

现在,您可以执行命令并将其添加到UndoHistory实例:

const cmd = new ClearTextCmd(...);
//...
undoHistory.add(cmd);

最后,您可以将撤消按钮(或快捷方式)绑定到此历史记录(与重做相同)。

这样的示例在Interacto文档页面上进行了详细说明

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.