撤销与重做
我们提供了 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 = 2与end = 2。
解决方案
UndoManager 支持为每个撤销/重做项存储 cursor。它会根据远端编辑或其他撤销/重做带来的变更自动调整这些光标,确保它们始终对应当前文档状态。
光标的存取由 onPush 与 onPop 回调处理。
在 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 原语。
在实现撤销/重做时,需要保证以下特性:
- 不撤销远端插入;
- 撤销之后重做应恢复到原始状态;
- 不存在并发编辑时,撤销应还原到上一个版本。
为此我们在内部模糊测试中加入了相关校验,确保逻辑正确。
演示
理解撤销/重做栈
UndoManager 会维护两个栈:
- 撤销栈(Undo Stack):存放可以被撤销的操作
- 重做栈(Redo Stack):存放已被撤销、可再次执行的操作
回调如何运作
当栈发生变化时会触发 onPush 与 onPop:
-
onPush(isUndo, range, event):当有新条目推入任意一个栈时触发
isUndo: boolean:true表示推入撤销栈,false表示推入重做栈range: (number, number):与此次撤销/重做关联的操作计数范围- 返回值:包含
value(希望持久化的任意数据)与cursors(光标位置)
-
onPop(isUndo, value):当栈顶条目被弹出时触发
isUndo:true表示来自撤销栈,false表示来自重做栈value:创建该条目时从onPush返回的数据
理解操作合并
UndoManager 中的 mergeInterval 选项用于控制将时间上相近的操作合并为同一个撤销项:
declare const : ;
const = new (, {
: 1000, // 默认 1000ms = 1 秒
});mergeInterval 的行为:
- 在该毫秒级间隔内发生的操作会被合并为同一个撤销动作;
- 即便合并,
onPush仍会在每个操作上触发; - 撤销时会一次性撤销所有被合并的操作;
- 数值越小,撤销越细粒化;数值越大,撤销步骤越少但覆盖范围更大;
- 设为
0可以关闭合并(每次操作都独立为一个撤销步骤)。
栈的操作流程
- 当本地事务提交时,会向撤销栈推入一条新撤销项(触发
onPush,isUndo=true)。 - 调用
.undo()时:- 从撤销栈弹出一条记录(触发
onPop,isUndo=true); - 同时向重做栈推入对应记录(触发
onPush,isUndo=false)。
- 从撤销栈弹出一条记录(触发
- 调用
.redo()时:- 从重做栈弹出一条记录(触发
onPop,isUndo=false); - 再向撤销栈推入对应记录(触发
onPush,isUndo=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"当用户执行撤销时,会发生两件事:
- 撤销栈弹出最后一条记录(移除 ” World”)。
- 同一条记录被推入重做栈,方便日后重做。
这种方式确保只撤销本地改动,不会影响其他协作者,非常适合协同编辑。
光标效率
内置光标方案在性能上做了优化,能够高效处理协作场景,包括在撤销/重做期间其他节点并发修改文档的情况。对于富文本等复杂编辑器,这套实现兼顾了性能与正确性。