对于编辑器而言History历史操作管理是必不可少的能力通常来说实现历史记录的方法通常有两种:存储全量快照也就是说我我们每进行一个操作,都需要将全量的数据snapshot通常存到一个栈里。如果用户此时触发了redo就将全量的数据取出应用到编辑器状态当中。这种实现方式的优点是简单不需要过多的设计缺点就是一旦操作的多了就容易OOM。基于变更的实现Op就是对于一个操作的原子化记录例如insert(1)那么如果想要做回退操作依然很简单只需要将其反向操作应用到编辑器中就可以了例如delete(1)。这种方式的优点是粒度更细存储压力小缺点是需要复杂的设计以及计算。在本地编辑场景下基于Op实现基础的History功能只需要操作支持invert方法即可。而针对于协同编辑的场景需要重点考虑如何处理远程的变更也就是说A和B两个用户同时编辑同一个文档时A不应该撤销B的操作反之亦然这就依赖transform实现。此外即使非协同编辑的场景下也需要考虑不希望跳过某状态的回退情况。例如在图片上传的场景中由于上传的过程是异步的我们就需要在上传中加一个loading状态而在上传完成之后则需要将src的位置替换为正式的url初始的src则可以是blob的临时url。那么在这个过程中我们就需要blob - http的这个状态作为无法撤销的操作否则就会导致undo的时候会回退到loading的暂态。在这种情况下可以依赖transform实现操作变换将这个op变换到初始状态也可以merge状态loading - http的操作为一个op。在下面这个例子中我们先插入一个blob的临时图片op1然后将其替换为正式的http地址undoable。理论上而言在不实现协同依赖的transform操作变换的情况下则通常不会记录invert2即可这样可以直接将操作撤回到初始状态而跳过blob的临时态。Copy// https://quilljs.com/playground/snow const Delta Quill.imports.delta; let base new Delta(); const op1 new Delta().insert( , { src: blob }); const invert1 op1.invert(base); // { delete: 1 } base base.compose(op1); // { insert: , attributes: { src: blob } } const undoable new Delta().retain(1, { src: http }); base base.compose(undoable); // { insert: , attributes: { src: http } } base base.compose(invert1); // []看起来上述并没有什么问题但是这并不是完善的解决方案。在下面的例子中新的op如果存在位置偏移则会出现问题假如我们不进行transform的操作则会导致invert1只执行delete此时会删除插入的insert(1)操作而非实际操作blob状态。当然即使执行了transform操作也会由于实际并没有invert插入1字符的操作导致最终的base会滞留这个插入操作。这本身是符合预期的这就像远程的Op操作一样即使将本地的undo栈全部执行完毕也会将所有的远程Op保留在草稿内。Copy// https://quilljs.com/playground/snow const Delta Quill.imports.delta; let base new Delta(); const op1 new Delta().insert( , { src: blob }); let invert1 op1.invert(base); // { delete: 1 } base base.compose(op1); // { insert: , attributes: { src: blob } } const undoable new Delta().insert(1).retain(1, { src: http }); base base.compose(undoable); // { insert:1 }, { insert: , attributes: { src: http } } invert1 undoable.transform(invert1); // { retain: 1 }, { delete: 1 } base base.compose(invert1); // { insert: 1 }Undo#为了实现undo操作首先需要将操作的变更记录放置到undo栈中。通常来说我们只需要通过事件模块监听ContentChange事件将每次操作的变更记录下来即可不过需要注意的是记录的变更是invert后的操作而不能直接记录原始的操作。在这里的previous是应用操作之前的编辑器数据快照这个参数主要目的是记录原属性值。如果是类似OT-JSON这种数据结构设计则会将原始记录直接存储到op中这种情况下是不需要记录previous的这部分主要依赖数据结构的设计。Copylet inverted changes.invert(previous); this.undoStack.push({ delta: inverted, range: undoRange, id: idSet });此外在编辑器中通常都需要合并相邻的操作在这里我们实现的方式是在一段时间内合并相邻的操作预设的阈值为1s此外在栈内也需要记录最后的选区range。类似于slate编辑器中则是通过判断相邻的类型是否相同来合并是否合并。Copylet inverted changes.invert(previous); let undoRange this.currentRange; let idSet new Setstring([id]); const timestamp Date.now(); if ( // 如果触发时间在 delay 时间片内或者批量执行时, 需要合并上一个记录 (this.lastRecord this.DELAY timestamp || this.isBatching()) this.undoStack.length 0 ) { const item this.undoStack.pop(); if (item) { inverted inverted.compose(item.delta); undoRange item.range; idSet item.id.add(id); } } this.undoStack.push({ delta: inverted, range: undoRange, id: idSet });具体执行undo方法的时候只需要从栈内弹出一个记录然后将其应用到编辑器即可。这里需要注意的是我们仍然需要将当前编辑器的快照作为invert的previous以此来将redo的op放置到redo栈中。Copyconst item this.undoStack.pop(); const base this.editor.state.toBlock(); const inverted item.delta.invert(base); this.redoStack.push({ id: item.id, delta: inverted, range: this.transformRange(item.range, inverted), }); this.lastRecord 0; const { HISTORY } APPLY_SOURCE; this.editor.state.apply(item.delta, { source: HISTORY, autoCaret: false }); this.restoreSelection(item);Redo#redo则是与undo相反的操作基本的代码实现与undo的代码类似。Copyconst item this.redoStack.pop(); const base this.editor.state.toBlock(); const inverted item.delta.invert(base); this.undoStack.push({ id: item.id, delta: inverted, range: this.transformRange(item.range, inverted), }); this.lastRecord 0; const { HISTORY } APPLY_SOURCE; this.editor.state.apply(item.delta, { source: HISTORY, autoCaret: false }); this.restoreSelection(item);这里的transformRange则是将range的选区变换为inverted后的选区。举个例子假设此时undo delta为insert(xxx),range索引为3那么invert之后为delete 3,range需要变换到0的位置。Copyconst start delta.transformPosition(range.start); const end delta.transformPosition(range.start range.len); return new RawRange(start, end - start);协同基础#History模块本身需要设计协同基础能力先前已经提到对于远程的Op我们不能由非此Op产生的客户端撤销即A不应该撤销B的Op。那么在这里需要先回顾一下协同的基本实现即Delta的transform函数的使用ob1 transform(oa, ob)。Copy// https://quilljs.com/playground/snow // https://www.npmjs.com/package/quill-delta#transform const Delta Quill.imports.delta; let baseA new Delta().insert(12); let baseB new Delta().insert(12); const oa new Delta().retain(2).insert(A); const ob new Delta().retain(2).insert(B); baseA baseA.compose(oa); // [{insert:12A}] baseB baseB.compose(ob); // [{insert:12B}] const ob1 oa.transform(ob, true); // [{retain:3},{insert:B}] const oa1 ob.transform(oa); // [{retain:2},{insert:A}] baseA baseA.compose(ob1); // [{insert:12AB}] baseB baseB.compose(oa1); // [{insert:12AB}]如下面的示例中在得到inverted之后并且此时的undo栈则是存在两个值。如果此时得到了一个undoable的op例如远程操作或者图片的上传完成操作就需要为栈内的存量数据做变换操作类似于oa1 transform(remoteOp, a)将所有的栈内操作全部处理。Copy// https://www.npmjs.com/package/quill-delta#invert // https://github.com/slab/quill/blob/main/packages/quill/src/modules/history.ts const Delta Quill.imports.delta; let base new Delta(); const op1 new Delta().insert(1); const op2 new Delta().retain(1).insert(2); let invert1 op1.invert(base); // [{delete:1}] base base.compose(op1); // [{insert:1}] let invert2 op2.invert(base); // [{retain:1},{delete:1}] base base.compose(op2); // [{insert:12}] let undoable new Delta().retain(2).insert(3); base base.compose(undoable); // [{insert:123}] invert2 undoable.transform(invert2, true); // [{retain:1},{delete:1}] invert1 undoable.transform(invert1, true); // [{delete:1}]上述的算法实现其实存在一个问题我们的undoable op是一直处于原始状态而实际上由于假设inverted内容会实际应用到base因此这里的undoable同样也需要做变换。也就是说同样需要解决invert操作对于undoable的影响。在下面的例子上若不做undoable transform的话则invert1的结果则是retain: 3, delete: 1此时的基准是00031000则删除的字符是3。那这样明显是错误的而在做了transform之后是retain: 4, delete: 1则能正确删除1字符。Copyconst Delta Quill.imports.delta; let base new Delta().insert(000000); const op1 new Delta().retain(3).insert(1); const op2 new Delta().retain(3).insert(2); let invert1 op1.invert(base); // [{retain:3},{delete:1}] base base.compose(op1); // [{insert:0001000}] let invert2 op2.invert(base); // [{retain: 3},{delete:1}] base base.compose(op2); // [{insert:00021000}] let undoable new Delta().retain(4).insert(3); base base.compose(undoable); // [{insert:000231000}] invert2 undoable.transform(invert2, true); // [{retain:3},{delete:1}] undoable invert2.transform(undoable); // [{retain:3},{insert:3}] invert1 undoable.transform(invert1, true); // [{retain:4},{delete:1}]由此在远程操作下发到本地后我们需要对整个栈内的全部op做操作变换。这里实际上的实现是确保栈中的每一个历史操作仍然能正确应用在已经包含了远程变更的文档状态之上相当于处理掉远程操作对本地栈里的历史操作的影响使得本地能够正确回退。Copylet remoteDelta delta; for (let i stack.length - 1; i 0; i--) { const prevItem stack[i]; stack[i] { id: prevItem.id, delta: remoteDelta.transform(prevItem.delta, true), range: prevItem.range this.transformRange(prevItem.range, remoteDelta), }; remoteDelta prevItem.delta.transform(remoteDelta); if (!stack[i].delta.ops.length) { stack.splice(i, 1); } }客户端变更#Local ChangeSet指的是在本地的变更处理例如图片上传时的本地预览状态在没有实际上传到服务器之前其内容的属性是临时状态。那么对于协同类似这种情况就需要特殊处理:客户端属性:client-side属性值不会协同也就是常见的client-side-*属性对于客户端的属性处理例如代码块的高亮处理等类似仅限于本地处理的属性不会实际被协同。临时隐藏块: 以上述图片上传为例此时的临时状态是insert op而不是client-side属性因此这种情况下无法直接通过属性状态处理。因此这里我们可以实际将op协同但是协同到其他客户端仅限于数据视图上会将其隐藏起来因此是临时隐藏了块。临时关闭协同: 如果临时op是不希望被协同的而且最终状态是希望将状态合并起来再协同出去。那么最简单的办法就是在本地处理时关闭协同等到最终状态确定后再开启协同即i( , {src: blob}) r(1, {src: http}) i( , {src: http})。本地状态变更: 在easysync中调度协同的方法中提到了AXY的调度模型可以观察ot.js可视化工具以此来尽可能保持服务端无状态避免复杂状态图。而如果需要完整处理本地的变更则需要扩展Z即本地队列但由于队列内容已经本地应用需要实现op在队列前后移动的方法。除了协同之外还有关于History模块的处理也同样会存在上述的本地图片预览等状态的处理。远程操作: 将其作为remote op处理即undoable的操作相当于将器放置于快照最前方。我们遵循的原则是不能undo其他人的op因此将其放置于最前方相当于在所有操作被undo后的空白草稿留下的内容。合并状态: 由于本身这些op不会真正发送出去不需要额外的调度。因此相对需要服务端来调度协同来说这里的处理可以相对比较自由地合并类似于下面的形式:Copyconst id1 state.apply(i( , { src: blob })); const id2 state.apply(r(1, { src: http })); editor.history.merge(id1, id2);实际上对于最开始聊的case而言方案1是不适用的。因为执行这个操作的前提是需要有执行这个操作的前提即上述insert op仅undo retain op的话是没有意义的在执行undo的时候会将操作的基准删除。因此对于这种情况我们还是需要将主要的设计放在允许undo栈状态合并上。此外由于delta的数据结构设计我们不需要关心实际的顺序造成的问题只需要compose即可。操作合并#