文档进阶主题撤销/重做

撤销与重做

我们提供了 UndoManager 来帮助你管理撤销/重做栈,并支持并发编辑场景。它还能处理光标位置转换。在多人协同时,我们希望撤销/重做是本地化的,也就是只作用于本地操作,不影响其他协作者的修改。

为什么需要本地撤销/重做?

如果撤销/重做是全局行为,往往会与预期不符。想象以下场景:

  • 用户 A 与用户 B 正在协作:他们很可能在编辑文档的不同部分。
  • 用户 A 执行撤销:如果这一步撤销了用户 B 的操作,用户 A 可能看不到任何变化,以为撤销失败,因为用户 B 的修改不在他的视野内。
  • 用户 B 的视角:用户 B 会发现自己的最新编辑莫名被删除,完全不知道发生了什么。

使用方式

创建 UndoManager 时,可以指定以下配置:

  • 哪些本地操作不需要被记录;
  • 撤销操作的合并时间范围;
  • 栈的最大深度。
const  = new (, {
    : 100, // 默认 100
    : 1000, // 默认 1000ms
    : ["sys:"], // 默认 []
    : (, , ) => {
        return { : null, : [] };
    },
    : (, , ) => {
        return;
    },
});

使用限制

UndoManager 只能追踪单个 Peer。当文档的 Peer ID 发生变化时,会清空撤销栈与重做栈,并开始跟踪新的 Peer ID。

恢复选区

在使用撤销/重做功能时,应用需要把选区恢复到执行操作前的位置。协作场景下这尤其困难,因为远端操作可能改变光标位置(例如光标需要回到第 5 个字符,但远端在此之前插入了字符)。

难点
  • 本地撤销/重做:本地撤销与重做会删除并重新创建元素。如果直接使用基于 CRDT 的稳定位置,光标可能仍停留在被删除的元素上,但用户期望在重做后光标回到新创建的元素。
  • 示例
    A fox jumped
    • 选中 “fox” 并删除。
    • 执行撤销。
    • 撤销后应重新出现 “fox” 三个字符,并被选中。但如果使用稳定位置,光标仍指向最早被删除的字符,撤销完成后得到的绝对位置会是 start = 2end = 2

解决方案

UndoManager 支持为每个撤销/重做项存储 cursor。它会根据远端编辑或其他撤销/重做带来的变更自动调整这些光标,确保它们始终对应当前文档状态。

光标的存取由 onPushonPop 回调处理。

onPush 中返回需要保存的 Cursor 列表;在 onPop 中取回这些光标,再将选区恢复到目标位置。

通常在以下场景需要在撤销/重做项中保存选区:

  • 当本地有新的变更提交时,需要记录一条新的撤销项,并在操作执行之前保存选区。
    • 原因:操作执行后选区可能丢失,例如用户选中文本并删除,撤销时 onPop 需要拿到删除前的选区状态。
  • 第一次执行撤销:为对应的重做项保存当前文档的选区。
    • 原因:重做后才能恢复到最初的选区状态。

在内部,我们也会自动处理撤销/重做循环中的光标存储与重置。

const  = new ();
let : [] = [];
let : [] = [];
const  = new (, {
  : 0,
  : (, , ) => {
     = .;
  },
  : () => {
    return { : null, :  };
  },
});
 
.("text").(0, "hello world");
.();
 = [
  .("text").(0)!,
  .("text").(5)!,
];
// 删除 "hello ",光标应随之转换
.("text").(0, 6);
.();
(.).(0);
.();
(.).(2);
(.()).({
  : "hello world",
});
// 撤销后光标应回到原来的位置
(.([0]).).(0);
(.([1]).).(5);
实现细节

撤销/重做的实现 参考了 ProseMirror 与 Quill 的思路,它们基于 OT(Operational Transformation)算法,因此我们也在内部实现了基础的 OT 原语。

在实现撤销/重做时,需要保证以下特性:

  • 不撤销远端插入;
  • 撤销之后重做应恢复到原始状态;
  • 不存在并发编辑时,撤销应还原到上一个版本。

为此我们在内部模糊测试中加入了相关校验,确保逻辑正确。

演示

在 ProseMirror 中绑定 Loro

理解撤销/重做栈

UndoManager 会维护两个栈:

  1. 撤销栈(Undo Stack):存放可以被撤销的操作
  2. 重做栈(Redo Stack):存放已被撤销、可再次执行的操作

回调如何运作

当栈发生变化时会触发 onPushonPop

  • onPush(isUndo, range, event):当有新条目推入任意一个栈时触发

    • isUndo: booleantrue 表示推入撤销栈,false 表示推入重做栈
    • range: (number, number):与此次撤销/重做关联的操作计数范围
    • 返回值:包含 value(希望持久化的任意数据)与 cursors(光标位置)
  • onPop(isUndo, value):当栈顶条目被弹出时触发

    • isUndotrue 表示来自撤销栈,false 表示来自重做栈
    • value:创建该条目时从 onPush 返回的数据

理解操作合并

UndoManager 中的 mergeInterval 选项用于控制将时间上相近的操作合并为同一个撤销项:

declare const : ;
const  = new (, {
  : 1000, // 默认 1000ms = 1 秒
});

mergeInterval 的行为:

  • 在该毫秒级间隔内发生的操作会被合并为同一个撤销动作;
  • 即便合并,onPush 仍会在每个操作上触发;
  • 撤销时会一次性撤销所有被合并的操作;
  • 数值越小,撤销越细粒化;数值越大,撤销步骤越少但覆盖范围更大;
  • 设为 0 可以关闭合并(每次操作都独立为一个撤销步骤)。

栈的操作流程

  1. 当本地事务提交时,会向撤销栈推入一条新撤销项(触发 onPushisUndo=true)。
  2. 调用 .undo() 时:
    • 从撤销栈弹出一条记录(触发 onPopisUndo=true);
    • 同时向重做栈推入对应记录(触发 onPushisUndo=false)。
  3. 调用 .redo() 时:
    • 从重做栈弹出一条记录(触发 onPopisUndo=false);
    • 再向撤销栈推入对应记录(触发 onPushisUndo=true)。

使用 groupStart / groupEnd 手动分组

有时仅靠 mergeInterval 无法满足撤销粒度控制。UndoManager.groupStart()UndoManager.groupEnd() 允许显式包裹一组提交,使其成为单独的撤销步骤。

  • 在一批相关编辑开始前调用 groupStart(),在对应的 groupEnd() 之前发生的提交会被视为同一撤销单元。
  • 若已有分组未结束再调用 groupStart() 会抛出错误,并保留原有分组,可帮助捕捉意外嵌套。
  • 远端更新可能影响分组:冲突的导入会提前结束当前分组,而无冲突的导入会继续合并。请始终调用 groupEnd()——如果分组已自动结束,调用将成为空操作。
const  = new ();
const  = .("text");
const  = new (, {});
 
.();
 
.("hello", );
.();
.("hello world", );
.();
 
.();
 
.(); // 一次性撤销两个提交

示例:文本编辑中的撤销/重做

假设我们构建了一个使用 Loro 协作的简易文本编辑器,下面按步骤展示常见操作:

// 创建文档与撤销管理器
const  = new ();
const  = .("textField");
 
// 配置撤销管理器的回调以追踪变化
const  = new (, {
  // 存储光标位置或其他需要的状态
  : (, ) => {
    if () {
      .("记录一条撤销动作");
    } else {
      .("记录一条重做动作");
    }
    // 返回希望与该动作关联的数据
    return {
      : { :  },
      : [/* your cursor positions */]
    };
  },
  
  : (, ) => {
    // 取回 onPush 时存储的数据
    const { ,  } = ;
    
    if () {
      .("获取撤销数据");
    } else {
      .("获取重做数据");
    }
    
    // 使用保存的光标恢复选区
    // applyStoredCursors(cursors);
  },
 
  : 0,
});
 
// 用户输入 "Hello"
.(0, "Hello");
.();
// → 触发 onPush(isUndo=true),加入撤销栈
 
// 用户继续输入 " World"
.(5, " World");
.();
// → 再次触发 onPush(isUndo=true),加入撤销栈
 
// 用户点击“撤销”
.();
// → 触发 onPop(isUndo=true),文档移除 " World"
// → 触发 onPush(isUndo=false),加入重做栈
 
// 文档现在只剩 "Hello"
 
// 用户点击“重做”
.();
// → 触发 onPop(isUndo=false),取回 " World" 操作
// → 触发 onPush(isUndo=true),重新加入撤销栈
 
// 文档恢复为 "Hello World"

当用户执行撤销时,会发生两件事:

  1. 撤销栈弹出最后一条记录(移除 ” World”)。
  2. 同一条记录被推入重做栈,方便日后重做。

这种方式确保只撤销本地改动,不会影响其他协作者,非常适合协同编辑。

光标效率

内置光标方案在性能上做了优化,能够高效处理协作场景,包括在撤销/重做期间其他节点并发修改文档的情况。对于富文本等复杂编辑器,这套实现兼顾了性能与正确性。