history:undo/redo 栈与 rebasing

📅
3 分钟阅读
·

前两篇分析 keymap 和 commands,说明按键如何生成 transaction。本文分析 transaction 的记录与撤销。参考代码为 prosemirror-history 的 445409b,源码只有 src/history.ts 一个文件,约四百行。文件开头说明:历史不能简单回滚到旧状态,因为有些修改不进入历史,例如协作场景的远端步骤;回滚旧文档会同时丢弃这些修改。因此历史栈存储逆步和位置映射。撤销时,逆步应用于当前文档,栈中后续内容通过映射保持位置对齐。

系列目录

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

Branch 与 Item:栈里存什么

HistoryState 是插件的 state 字段,包含 done、undone 两个 Branch,以及用于分组的 prevRanges、prevTime、prevComposition。Branch 只有 items 和 eventCount 两个字段。items 的类型为 RopeSequence<Item>;rope-sequence 是外部持久化序列库,sliceappend 使用结构共享,因此 Branch 每次变更都会返回新对象,不会原地修改数组。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,该 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 个事件后再截断可减少频繁切分序列的成本,截断后 eventCount 会略高于 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 毫秒,也会形成两个事件。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 composition ID。协作场景的 preserveItemsmirrorOffsetcompress 用于在 rebase 后重写历史栈并保持与当前文档对齐。单机场景主要涉及 addTransform 与事件分组。


1124 字 · 42 段落
ximing

Follow onGitHub

相关文章