多人同时编辑,Ctrl+Z 该撤销什么?Notedeep 的协同撤销设计

多人同时编辑,Ctrl+Z 该撤销什么?Notedeep 的协同撤销设计

单人编辑器里,撤销可以理解为“回到上一个状态”;多人同时编辑时,这个定义就不成立了。本文分享 Notedeep 编辑器如何把撤销做成“只撤销自己的修改”,以及这套做法目前的边界。
使用 NoteDeep 整理你的技术实践

背景与目标

假设我在一段文字里输入了几个字,同一时间,另一位协作者在这段文字的前面也插入了内容。这时我按下 Ctrl+Z,期望的结果很明确:删掉我刚输入的字,对方的内容原样保留。
如果撤销仍然是“回到我输入之前的快照”,对方刚写的内容也会一起消失。所以在协同场景下,我给撤销定了两个目标:
  • 只撤销当前用户自己的修改,不碰别人的。
  • 撤销本身也是一次普通编辑,要同步给其他协作者。

核心架构

Notedeep 编辑器基于 contenteditable,DOM 本身就是数据模型。用户的修改先体现在 DOM 上,再被翻译成操作记录:
  • MutationObserver 收集 DOM 变化,经过 500ms 防抖,先做结构规范化,再统一处理。
  • 一批变化被转换成 JSON0 操作。JSON0 是一种可以编辑任意 JSON 文档的协同操作格式;文档本身用 JsonML 表示,也就是把 DOM 写成嵌套数组。
  • 这批操作打包成一个 Delta,进入编辑器的全局操作序列,按顺序编号。
撤销就建立在这条序列上。本地产生的 Delta 进入撤销栈,同时发给协同服务;远端发来的操作也会追加到同一条序列里,但不会进入撤销栈。所以撤销栈里只有自己的修改,而序列完整记录了“这之后又发生了什么”。
sequenceDiagram autonumber participant User as 用户 participant E as Editor participant UM as UndoManager participant MO as MutationObserver participant DOM as DOM participant S as 协作服务 User->>E: Ctrl/Meta+Z E->>UM: undo() UM->>UM: pop undo 栈 得到 delta UM->>UM: iDelta = delta.inverse(nextDeltas) UM->>E: applyDelta(iDelta, UndoRedo) E->>MO: 暂停观察 E->>DOM: 应用 iDelta 到 DOM E->>MO: 恢复观察 E->>E: appendDelta(iDelta) / 更新VirtualNode E-->>S: customCallback(iDelta.ops)
撤销不回滚快照,而是生成一个逆操作,作为新的编辑应用到当前文档上。插入变删除,删除变插入,属性的写入和移除互换。逆操作会正常追加到序列、发给协同服务;重做也是同一套逻辑,只是对逆操作再取一次逆。

关键实现

取逆之前,先修正路径

JSON0 用路径定位修改位置,比如 `[2, 2, 1]` 表示某个文本节点的第 1 个字符。问题在于,Delta 里记录的是修改发生那一刻的路径。之后别人的操作可能已经让位置发生偏移。
还是开头的例子:我在第 1 个字符处插入 "1",远端随后也在第 1 个字符处插入 "2"。文本从 `11` 变成 `121`,我那个 "1" 已经移到了第 2 位。直接按原路径删除,删掉的就是别人的字。
所以撤销时,编辑器会取出这个 Delta 之后发生的所有 Delta,逐个修正路径:
  • 前面插入了同级节点,节点下标加一;前面删除了同级节点,下标减一。
  • 同一文本节点里,前面插入了文字,偏移量加上插入长度;前面删除了文字,偏移量减去删除长度。
修正后再取逆。上面这个例子,撤销生成的是“在第 2 位删除 1”。我用当前代码验证过这个场景,结果符合预期。

已经撤销过的成对操作要跳过

序列里不只有新编辑,还有撤销产生的逆操作。假设依次发生 a、b,然后撤销了 b,序列就是 `a b b'`。
如果 b 删除了 a 输入的内容,修正 a 的路径时把 b 当成普通操作,就会得出“a 的内容已被删除,无法撤销”的结论。但 b 已经被 b' 抵消了。
因此每个被撤销的 Delta 会记住它对应的逆操作。修正路径时遇到这样的成对操作,整对忽略;夹在这一对中间的其他操作,会先按逆操作重新计算位置,再参与修正。这个场景我也用代码验证过:不跳过时返回“无法撤销”,跳过成对操作后能正确生成逆操作。

删除操作要带上完整内容

逆操作要能把删掉的节点插回去,前提是删除记录里有节点的完整内容。
这里踩过一个坑。为了减少发出去的操作,mutation 转换有一个优化:先删子节点、再删父节点时,只记录删除父节点,因为对协作者来说效果一样。可等到处理父节点时,DOM 里它的子节点已经没了,记录下来的是一个空壳,撤销时子节点恢复不回来。
现在编辑器维护一份虚拟节点树,对应上一次处理后的文档结构。生成删除操作时,从这棵树里取被删节点的完整 JsonML,而不是读取当前 DOM。

不产生数据的变化不进撤销栈

Mermaid、draw.io、思维导图等块有渲染出来的预览区域。编辑代码会触发预览重绘,也会产生 DOM 变化。如果这些变化也变成操作,按 Ctrl+Z 时会先撤销一次预览,而不是代码。
现在,处于预览区域内的 DOM 变化在转换前就被过滤,不进撤销栈,也不发给协作者。撤销或远端修改应用后,编辑器只对受影响的块主动刷新预览;刷新产生的 DOM 变化同样被过滤,不会生成新的操作。

一次 AI 修改,一次撤销

AI 流式修改页面时,内容会分多次写入,每次都是一个 Delta。逐个撤销体验很差,所以这些 Delta 会作为一组压入撤销栈。撤销时按相反顺序逐个取逆,重做时再按原顺序恢复。

方案取舍

这套方案没有做完整的 OT 变换,而是针对节点插入/删除、文字插入/删除写了一组路径修正规则。这样实现直接、好排查,代价是覆盖面有限,不能处理所有组合。
遇到无法安全修正的情况,策略是“宁可不撤销,也不撤错”:
  • 自己插入的节点被别人删了。
  • 自己输入的一段文字中间被别人插入了内容,或其中一部分被别人删除了。
这两种情况,该项撤销会被直接跳过。这样避免了误删别人的内容,但用户此时按下 Ctrl+Z,可能看不到任何变化,也没有提示。
还有一层容量取舍:撤销栈最多保留 50 步,超出时丢弃最旧的一步,并同步清理已经不再需要的历史操作,避免序列无限增长。

结果与边界

从当前实现可以确认:撤销只针对本地修改;远端操作会参与路径修正;撤销和重做产生的操作会同步给协作者;预览重绘和 AI 分段写入不会拆散用户的撤销步骤。相关单元测试覆盖了单步撤销和成组撤销、重做的顺序,上面提到的三个并发场景我也用当前代码单独验证过。
需要说清楚的边界:
  • 成对跳过只认本端产生的撤销。其他协作者的撤销操作同步过来后,目前被当作普通操作参与修正。
  • 无法撤销时直接跳过,没有用户提示。
  • 本文没有多人长时间并发编辑的稳定性数据,也不代表所有复杂结构变化都能正确撤销。

后续规划

一是把其他协作者的撤销操作也识别成成对关系,减少不必要的“无法撤销”。二是在撤销被跳过时给出明确反馈,而不是让用户以为按键失灵。
协同场景下,撤销不再是“回到过去”,而是“在当下抵消自己做过的事”;把这个定义想清楚,后面的路径修正、成对跳过和内容快照都只是围绕它补齐的细节。
使用 NoteDeep 开始整理你的知识库
#架构设计 #技术实现 #产品设计 #Notedeep