history:undo/redo 栈与 rebasing

3 分钟阅读
·

前两篇看了 keymap 和 commands,解决的是按键怎么变成 transaction。这篇看这些 transaction 怎么被记住、怎么被撤销。参考代码是 prosemirror-history 的 445409b,整个包的源码只有 src/history.ts 一个文件,四百多行。文件开头的注释先把设计前提写死:ProseMirror 的历史没法做成简单的状态回滚,因为存在不进历史的修改,协作时远端来的步骤就是例子,回滚到旧文档会把这些修改一起抹掉。所以栈里存的是逆步加位置映射:撤销时把逆步应用到当前文档上,栈里排在后面的内容靠映射继续对齐。

系列目录

日期 标题
05-10 ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17 ProseMirror 仓库全景:22 个包怎么分工
05-24 跑通一个最小 ProseMirror:先看文档长什么样
06-07 ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14 ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21 ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05 ResolvedPos:一个数字位置怎么变成路径
07-12 Slice 与 replace:切一块文档出来再塞回去
07-19 DOMSerializer:文档怎么变成 DOM 和 HTML
08-02 DOMParser:parseDOM 规则与 HTML 解析
08-09 findDiffStart / findDiffEnd:两份文档怎么求差
08-16 model 收官:Node 上的辅助方法与位置约定总结
09-06 ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20 ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03 StepMap:一步修改怎么映射每个位置
10-11 Mapping:多步映射的链式合并,rebase 的地基
10-18 structure.ts:split/join/lift/wrap 的可达性判断
10-25 Transform 类:构建修改的 API 层
11-08 ProseMirror state(上):EditorState,不可变编辑器状态
11-15 Selection 体系:四种选区与选区书签
11-22 Transaction:Transform 加上状态语义
12-06 Plugin 系统(上):StateField 与插件状态
12-13 Plugin 系统(下):props、appendTransaction 与 filterTransaction
12-20 state 收官:动手写三个插件验证理解
01-03 ProseMirror view(上):EditorView,状态与 DOM 之间的桥
01-10 ViewDesc(上):文档到 DOM 的描述树
01-17 ViewDesc(下):增量更新怎么做到只改动的部分
02-07 DOMObserver 与 readDOMChange:浏览器改了 DOM,怎么读回文档
02-14 input.ts:从 keydown 到 dispatchTransaction 的输入管线
02-21 选区同步:state 选区与 DOM 选区的双向对齐
02-28 Composition 与 IME:中文输入法事件的处理
03-07 NodeView 与 MarkView:把渲染权交给你
03-14 Decoration 体系:不修改文档的视觉标注
03-21 clipboard:复制粘贴的序列化与解析
04-04 domcoords:屏幕坐标与文档位置的双向换算
04-11 browser.ts:浏览器差异补丁集
04-18 view 收官:不用官方扩展,手写一个最小可用编辑器
05-09 扩展(上):keymap,最小的插件
05-16 commands:命令的签名约定与组合器
05-23 history:undo/redo 栈与 rebasing(本篇)

Branch 与 Item:栈里存什么

HistoryState 是插件的 state 字段,五个字段:done、undone 两个 Branch,加上 prevRanges、prevTime、prevComposition 三个分组用的缓存。Branch 本身只有两个字段:items 和 eventCount。items 的类型是 RopeSequence,rope-sequence 是一个外部持久化序列库,slice 和 append 走结构共享,Branch 每次变更都返回新对象,不原地改数组,这和整个 ProseMirror 的不可变数据风格一致。eventCount 数的是事件个数,实现上等于 items 里带书签的 item 个数,undo 命令判断可不可用、undoDepth 给菜单报数,读的都是它。初始状态由 Branch.empty 给出:空序列加零事件。

Item 是历史的最小单位,四个字段:

  • map:这个 item 对应修改的正向 StepMap。不管 item 有没有步骤,map 一定有,作用是让栈里排在它后面的位置信息能穿过这次修改。
  • step:原步骤的逆步,可以没有。没有 step 的 item 是纯映射项,来源后面讲。
  • selection:选区书签(SelectionBookmark)。一个 item 同时带 step 和书签,它就是一个「事件」的起点。事件是 undo/redo 的粒度,一次撤销退掉整个事件。
  • mirrorOffset:协作模式用,指向栈里前面某个 item,声明两者的 map 互逆。popEvent 和 compress 重建映射时读它。

文件注释把「为什么存书签而不是 Selection」也交代了:书签是惰性表示,应用时才拿文档 resolve,压缩栈的时候不需要提供文档。第 20 篇讲过 SelectionBookmark 的 map 和 resolve 两个方法,这里全部用上了。

done 栈的 Item 序列与 undo 弹出

事件边界完全由书签决定。栈里可能长这样:[A(逆步+书签), B(逆步), C(纯映射), D(逆步+书签), E(逆步)]。这是两个事件:A、B、C 一组,D、E 一组。popEvent 找最后一个事件起点的方式是从栈尾往前扫,遇到第一个带书签的 item 就停,它到栈尾就是最后一个事件。注意纯映射项 C 落在事件内部:它对应的修改不进历史,但它改过文档,位置必须算进事件的映射链里,撤销这个事件时逆步先穿过它。

addTransform:修改怎么进栈

普通修改到达时走 Branch.addTransform。逻辑不复杂:遍历 transform.steps,每一步用 transform.steps[i].invert(transform.docs[i]) 求逆。能求逆的前提是 Transform 维护了 docs 数组,第 18 篇讲过,每应用一步就把当时的文档存一份,逆步需要的「应用前文档」从这里来。每个逆步和对应的正向 StepMap 包成一个 Item。

进栈前有一次合并尝试:lastItem.merge(item)。Item.merge 要求新旧 item 都带 step、且新 item 不带书签,然后调 step.merge。ReplaceStep 的 merge 只接区间首尾相邻、且两边都不带 structure 标记的替换,连续打字产生的逆步恰好满足:插入 a 的逆步是删 [5,6),插入 b 的逆步是删 [6,7),合成删 [5,7)。合并成功后栈里少一个 item,eventCount 不变。连续输入的一组字符能一次撤销,一半靠分组(下节),另一半靠这里的逐步合并。

合并有个方向细节。Item.merge 里写的是 other.step.merge(this.step),other 是新 item。Step.merge 的语义是把参数合并到本步之后应用,撤销时逆步是新的先用、旧的后用,所以合成步等于新逆步在前、旧逆步在后,顺序不能反。合并成功后的账面处理也分两种:合并发生在 transform 的第一步,旧 item 还在 oldItems 末尾,要 slice 掉;发生在后续步,合并对象是上一轮刚推进 newItems 的那一项,改弹出来。两条路径都保证合并结果在序列里只出现一次。

书签只挂在每个事件的第一个 item 上。addTransform 收到 selection 参数时把它放在第一个新 item 上,然后置空,eventCount 加一。这次修改算不算新事件由下一节的分组逻辑决定,addTransform 只负责执行。

深度控制也在 addTransform 末尾。eventCount 超过配置的 depth(默认 100),且超出量大于 DEPTH_OVERFLOW(20)时,cutOffEvents 从栈头数起,找到第 overflow + 1 个带书签的 item,从那里把序列切成两段,前面的事件整体丢弃。攒到 20 才切是为了摊薄成本:每多一个事件就重切一次数组太贵,攒一批再切,切完 depth 附近还有余量。

事件分组:500 毫秒与位置相邻

applyTransaction 是插件 state 的 apply 实现。进分组判断之前有几道前置分支,按顺序排:tr 带 historyKey meta 时直接采纳 meta 里预算好的 historyState,这是 undo/redo 自己产生的 transaction,后面讲;tr 带 closeHistoryKey meta 时先把三个分组缓存清掉;tr.steps 为空时原样返回,纯选区变化的 transaction 不动历史;tr 是被追加出来的(appendedTransaction meta)且根 transaction 带 historyKey,说明根 transaction 是 undo/redo 产生的,别的插件在它之上用 appendTransaction 追加了修改;这些追加的修改按 redo 或 undo 的方向加进 done 或 undone,保持两栈之间的事件转移语义。这些都排除完才到普通修改,分组判断是这一段:

let newGroup = history.prevTime == 0 ||
  (!appended && history.prevComposition != composition &&
   (history.prevTime < (tr.time || 0) - options.newGroupDelay || !isAdjacentTo(tr, history.prevRanges!)))

三种情况开新事件。第一,prevTime 为 0,也就是还没有任何记录,或者刚被 closeHistory 清掉。第二,距上次修改超过 newGroupDelay,默认 500 毫秒,停顿之后的输入另起一组。第三,修改位置和上次不相邻:isAdjacentTo 拿这次 transaction 第一张 StepMap 的变更区间,和 prevRanges 里存的区间对逐个比对,有交叠才算相邻。在段落开头打几个字,光标挪到段落结尾再打,间隔不到 500 毫秒也是两个事件,undo 分两次退。prevRanges 由 rangesFor 从映射里取出,取的是最后一个有变更区间的 StepMap 的新位置对。

composition 是 IME 的特例。view 读回输入法产生的 DOM 变化时会给 transaction 打上 composition meta(prosemirror-view 的 src/domchange.ts,ID 在 compositionend 时递增,一次会话内不变),同一次输入法会话里的 transaction 带同一个 compositionID。分组条件里 prevComposition != composition 这个前置意味着:同一次 composition 内的修改永远不分组,时间再长也在同一个事件里。中文输入打一整句话再选词上屏,一次 undo 整句退掉,行为来自这里。

appendedTransaction 也在条件里。插件用 appendTransaction 追加的 transaction 会带 appendedTransaction meta 指向根 transaction(prosemirror-state 的 src/state.ts)。分组条件里的 !appended 让追加的修改永远不会自己开新组,它并进根 transaction 所在的事件:一次「修改加修正」对应一次 undo。

主路径返回的 HistoryState 里,undone 直接换成 Branch.empty。任何普通的进栈修改都会清空 redo 栈,这是标准的 redo 失效语义:新分支出现后,旧分支上记录的未来没有意义。

另外还有两条分支对应「不进历史」和「被 rebase」。tr.getMeta(“addToHistory”) === false 时,这次修改不进栈,但它的 StepMap 要通过 addMaps 作为纯映射项追加到两个栈上,栈里已有的逆步才能继续对齐当前文档。前面事件内部那个纯映射项 C 就是这么来的。rebased 分支单独一节讲。

undo 与 redo:popEvent 与 histTransaction

undo 命令的本体是 histTransaction。流程:从 done 栈 popEvent 弹出最后一个事件,把事件的逆步逐个应用到一个新 transaction 上,弹出的书签 resolve 成选区挂上,然后把这个 transaction 用 addTransform 存进 undone 栈,附带当前选区的书签。redo 完全对称,两个栈角色互换。undo 之后 redo 能回到撤销前的光标位置,靠的就是存进 undone 时这个书签。

popEvent 有个值得注意的实现选择:逆步的应用顺序。它用 forEach 从栈尾向栈头遍历,事件内的逆步新的先应用。插入 a 再插入 b,撤销时先应用删 b 的逆步,再应用删 a 的,每个逆步面对的文档正是它求逆时的形状,中间不需要任何映射。栈把「逆序撤销」变成了纯遍历顺序问题。

上面说的是普通模式,而且事件内部没有夹纯映射项的情况。事件内部夹着纯映射项时,逆步和书签都要先穿过一张由 remapping 拼出的映射再应用,普通模式也会走这条路。preserveItems 开启时(协作模式,下节讲开关)则一律建映射,并且事件内的 item 弹出后不丢弃:旧 item 全部改写成纯映射项留下(addBefore),每次成功应用逆步产生的新 map 也追加一个纯映射项(addAfter),addAfter 里的 item 用 mirrorOffset 指向它在 addBefore 里对应的那个,声明互逆。普通模式没有这个负担,item 弹出即弃,栈也短。

histTransaction 构造的 transaction 最后会 setMeta(historyKey, {redo, historyState: newHist})。这个 meta 有两个消费者。一个是 applyTransaction 自己,它的第一个分支就是 if (historyTr) return historyTr.historyState:undo/redo transaction 流经插件 apply 时直接采纳预算好的历史状态,不再走一遍进栈逻辑,否则 undo 操作本身又被记进历史了。另一个消费者是对外的 isHistoryTransaction 函数,想和 undo 联动的插件用它识别历史操作。

还有一条原生入口。history() 插件在 props 的 handleDOMEvents 里挂了 beforeinput 监听,inputType 是 historyUndo 或 historyRedo 时调对应命令并 preventDefault。这条入口覆盖浏览器原生的 undo 信号,比如移动端键盘的撤销按钮、系统菜单的编辑项,它们不产生 Mod-Z 按键,keymap 管不到。

rebase:远端修改到达后栈怎么重写

协作场景下,本地有几步还没被服务器确认的修改时,远端步骤到达了,collab 模块会把本地未确认步骤 rebase 到远端步骤之上,然后把结果 transaction 打上 rebased meta(值是未确认步骤数)和 addToHistory: false 一起 dispatch。历史栈里存的还是 rebase 之前的逆步,和当前文档已经对不上了,Branch.rebased 负责把栈尾重写。

重写依赖第 16 篇讲过的 Mapping 镜像。collab 的 rebaseSteps 拼出的 transform 分三段:先把本地未确认步骤逆序逆应用,再应用远端步骤,最后把本地步骤 rebase 之后重新应用;第一段每张逆应用 map 和第三段对应的新步骤 map 用 setMirror 登记为镜像。rebased 遍历栈尾 rebasedCount 个 item,每个 item 对应第一段里的一张逆应用 map,用 mapping.getMirror 找回它的镜像下标,也就是 rebase 后步骤在 transform 里的位置,取那个步骤重新求逆、取新 map,包成新 item。getMirror 返回空,说明这个本地步骤在 rebase 中被丢掉了,对应的 item 直接从栈里消失。事件起点上的书签也要用 mapping.slice 出的子映射搬运一遍。远端步骤本身不进历史,它们的 map 变成纯映射项插在重写后的 item 前面。

eventCount 在这个过程中要重算:重写区间里原有几个书签先减掉,重写后的新 item 上保留下来几个再加回去,被丢弃步骤带走的书签就此核销。newMaps 那段循环负责补没被任何旧 item 认领的 map:下标从 rebasedCount 到第一个被认领步骤之间,主体是远端步骤的 map,它们以纯映射项的形式插在重写后的 item 前面,栈里的映射链在重写区间保持完整。两条分支(addToHistory 为 false 的普通路径和 rebased 路径)都会顺手用 mapRanges 把 prevRanges 映射到新坐标系,分组的相邻判断在远端修改之后仍然成立。

rebased:远端步骤到达后的栈尾重写

这里能解释前面埋的几个伏笔。纯映射项为什么必须有:协作时每个远端步骤都往栈上加一个。mirrorOffset 为什么存在:popEvent 在 preserveItems 模式下不删 item,弹出的 item 改写成纯映射项留在栈里,新应用的逆步也留下 map,两者之间用 mirrorOffset 声明互逆,后续 remapping 重建映射链时靠它把对应关系接上。preserveItems 由 mustPreserveItems 检测,只要任一插件的 spec 里有 historyPreserveItems 标记就开启,collab 插件带这个标记,作用是禁止逐步合并,保证栈里 item 和原始步骤一一对应,rebase 时才对得上号。代价是纯映射项会不停累积,rebased 末尾检查 emptyItemCount,超过 max_empty_items(500)就 compress 一次:把指定深度以下的 item 全部重写,纯映射项折叠进步骤里,逆步映射到当前坐标系重新存。压缩换来栈长度可控,代价是一次全量重写。

对外 API 面

文件尾部是一圈小函数。history(config) 收两个配置:depth(事件数上限,默认 100)和 newGroupDelay(分组时间阈值,默认 500 毫秒)。undo/redo 和 undoNoScroll/redoNoScroll 四个命令由 buildCommand 生成,区别只在方向和滚不滚动,签名是标准 Command,可以直接挂 keymap。undoDepth/redoDepth 读两个栈的 eventCount,给菜单置灰用。closeHistory(tr) 给 transaction 打一个 meta,效果是清空 prevTime、prevRanges、prevComposition 三个分组缓存,下一笔修改强制开新事件;某个操作希望「从这里开始单独可撤销」时用它。isHistoryTransaction 上面讲过。

读完这个文件可以对一对账。undo 的本质是把逆步当普通步骤重新应用一遍,所以历史的内存占用和事件数成正比,和文档大小无关。分组是启发式,时间阈值加位置相邻再加 IME 特例,不完美但符合直觉。真正绕的部分全在协作那半边:preserveItems、mirrorOffset、compress 三件套的唯一动机,是让 undo 栈在 rebase 之后还能对齐当前文档。单机场景可以完全不理会它们,addTransform 加分组两条路径就够用了。


1156 字 · 41 段落
xi ming

Written by xi mingFollow onGitHub