从零实现富文本编辑器#14-编辑器历史变更管理与状态回溯

先前我们基于React实现了视图层的适配,以此实现React组件生态的复用,降低了开发成本。接下来我们需要讨论的是,编辑器的操作管理并且支持回溯,通常来说可以称之为Redo/Undo功能,而在协同编辑场景下,本地和远程变更同步的实现会更加复杂。

从零实现富文本编辑器系列文章

概述

对于编辑器而言,History历史操作管理是必不可少的能力,通常来说实现历史记录的方法通常有两种:

  1. 存储全量快照,也就是说我我们每进行一个操作,都需要将全量的数据snapshot通常存到一个栈里。如果用户此时触发了redo就将全量的数据取出应用到编辑器状态当中。这种实现方式的优点是简单,不需要过多的设计,缺点就是一旦操作的多了就容易OOM
  2. 基于变更的实现,Op就是对于一个操作的原子化记录,例如insert("1"),那么如果想要做回退操作依然很简单,只需要将其反向操作应用到编辑器中就可以了,例如delete(1)。这种方式的优点是粒度更细,存储压力小,缺点是需要复杂的设计以及计算。

在本地编辑场景下,基于Op实现基础的History功能,只需要操作支持invert方法即可。而针对于协同编辑的场景,需要重点考虑如何处理远程的变更,也就是说AB两个用户同时编辑同一个文档时,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的临时态。

// 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保留在草稿内。

// 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的,这部分主要依赖数据结构的设计。

let inverted = changes.invert(previous);
this.undoStack.push({ delta: inverted, range: undoRange, id: idSet });

此外,在编辑器中通常都需要合并相邻的操作,在这里我们实现的方式是在一段时间内合并相邻的操作,预设的阈值为1s,此外在栈内也需要记录最后的选区range。类似于slate编辑器中,则是通过判断相邻的类型是否相同来合并是否合并。

let inverted = changes.invert(previous);
let undoRange = this.currentRange;
let idSet = new Set<string>([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方法的时候,只需要从栈内弹出一个记录,然后将其应用到编辑器即可。这里需要注意的是,我们仍然需要将当前编辑器的快照作为invertprevious,以此来将redoop放置到redo栈中。

const 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的代码类似。

const 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 deltainsert("xxx"),range索引为3,那么invert之后为delete 3, range需要变换到0的位置。

const start = delta.transformPosition(range.start);
const end = delta.transformPosition(range.start + range.len);
return new RawRange(start, end - start);

协同基础

History模块本身需要设计协同基础能力,先前已经提到,对于远程的Op,我们不能由非此Op产生的客户端撤销,即A不应该撤销BOp。那么在这里需要先回顾一下协同的基本实现,即Deltatransform函数的使用ob1 = transform(oa, ob)

// 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栈则是存在两个值。如果此时得到了一个undoableop例如远程操作或者图片的上传完成操作,就需要为栈内的存量数据做变换操作,类似于oa1 = transform(remoteOp, a)将所有的栈内操作全部处理。

// 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字符。

const 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做操作变换。这里实际上的实现是,确保栈中的每一个历史操作,仍然能正确应用在已经包含了远程变更的文档状态之上,相当于处理掉远程操作对本地栈里的历史操作的影响,使得本地能够正确回退。

let 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模块的处理,也同样会存在上述的本地图片预览等状态的处理。

  1. 远程操作: 将其作为remote op处理,即undoable的操作,相当于将器放置于快照最前方。我们遵循的原则是不能undo其他人的op,因此将其放置于最前方相当于在所有操作被undo后的空白草稿留下的内容。
  2. 合并状态: 由于本身这些op不会真正发送出去,不需要额外的调度。因此相对需要服务端来调度协同来说,这里的处理可以相对比较自由地合并,类似于下面的形式:
    const 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即可。

操作合并

如果只是正常的History模块实现,我们之前已经基本设计完成了。但是存在一些特殊的情况,需要合并undo栈的数据,仍然以图片上传为例,上传时会存在一个临时状态,但是我们现在换个解决方案,从主动合并Op的角度来考虑暂态问题。

那么这种情况下,如果触发ctrl+z的话,会导致上传回到loading状态而不是撤销insert。因此明显这里应该将retain attrs这个opHistory模块中合并到insert上,这样就可以保证undo的时候是撤销insert而不是retain attrs

我们先来实现合并,因为我们这些模块都是分离的,所以没有办法直接跟History模块通信,这里需要改造一下apply,并且将标识写入undo栈。但是仅仅是移除retainop并且将其合并到insert上是不够的,这里还需要transform的数据处理。

const { id: id1 } = state.apply(new Delta().insert());
const { id: id2 } = state.apply(new Delta().retain());
const index1 = editor.history.stack.findIndex(it => it.id === id1);
const index2 = editor.history.stack.findIndex(it => it.id === id2);
const delta1 = editor.history.stack[index1].delta;
const delta2 = editor.history.stack[index2].delta;
const delta = delta1.compose(delta2);
editor.history.stack[index1] = { id: id1, delta };
editor.history.stack.splice(index2, 1);

在这里需要先看看transform的具体含义,如果是在协同中的话,b'=a.t(b)的意思是,假设ab都是从相同的draft分支出来的,那么b'就是假设a已经应用了,此时b需要在a的基础上变换出b'才能直接应用,我们也可以理解为tf解决了a操作对b操作造成的影响。

那么先前的undoable实现,需要将历史所有的undo栈处理一遍,这里的假设是undoable op是早已存在draft中。也就是说此时即使undo栈内的所有op都以执行,那么此时的draft中还是存在undoable op。那么由于这个假设存在,就会将所有历史数据影响到,由此需要做变换。

那么假设此时我们此时存在abc三个记录,c为栈顶,目标是合并ac记录。那么我们先来看c这个op,因为b可能会插入新的内容,导致a/cretain并不一致,做了inverted之后cretain会比a大,因此我们需要消除b带来的影响。

举个具体的例子,假设此时我们的内容为132,文本的插入顺序是123,那么我们可以构造出相关的inverted op。此时我们来将4合并到2上,但是明显如果直接取出来并且compose结果是不对的,retain的值并不能对到2上。因此就需要对其之间所有的操作进行变换,这里24之间只有3, 就只需要处理3带来的影响。

const op1 = new Delta().insert("1");
const op2 = new Delta().retain(1).insert("2");
const op3 = new Delta().retain(1).insert("3");
const op4 = new Delta().retain(2).retain(1, { src: "2" });

const invert1 = new Delta().delete(1);
const invert2 = new Delta().retain(1).delete(1);
const invert3 = new Delta().retain(1).delete(1);
const invert4 = new Delta().retain(2).retain(1, { src: "1" });

invert3.transform(invert4); // [{"retain":1},{"retain":1,"attributes":{"src":"1"}}]

这里其实还有个问题,设想一下为什么先前处理undoable的时候,做的变换是针对历史记录的,而这里的变化就是针对新来的记录了。实际上我们还是需要处理历史记录的,而undoableop因为根本不会实际参与到我们的undo进程中,其处理完后直接就消失了,所以可以不需要处理。

再举个例子,假如此时我们的内容是312,写入的顺序是123,由此inverted op则可以推断出来。此时如果我们只是将invert3移除,并且合并到先前的某个op上,之后执行invert2的时候,就会发现删除的是1而不是2,这就导致了索引指向的问题。

const op1 = new Delta().insert("1");
const op2 = new Delta().retain(1).insert("2");
const op3 = new Delta().insert("3");

const invert1 = new Delta().delete(1);
const invert2 = new Delta().retain(1).delete(1);
const invert3 = new Delta().delete(1);

由此可知,最开始那个例子仅仅适用于处理attrs的场景,因为被merge的这个op本身不会影响到其他的op,但是实际的场景基本也只有这个。又会影响到历史记录索引,又会被先前操作过的op影响本身的索引,就像是xxx|yyy。这种情况并不常见,倒是在Local CS中会用的上。

因此我们还需要与undoable一样,将其变换应用到历史记录上。但是因为这里是互相影响的,究竟应该是以被合并op变换后的值为基准,还是原始的值为准。考虑了一下我觉得还是应该以原始值为准,毕竟互相影响的时候是初始值。

依然是上面的例子,假如此时我们的内容是312,写入的顺序是123。这里需要注意的是,我们是假设新op不存在来做的变换,因此应该是先将其再次invert后再变换,相当于需要在当前的基准上将invert3做了undo,也就是下面例子中的op3

const op1 = new Delta().insert("1");
const op2 = new Delta().retain(1).insert("2");
const op3 = new Delta().insert("3");

const invert1 = new Delta().delete(1);
const invert2 = new Delta().retain(1).delete(1);
const invert3 = new Delta().delete(1);

const invert21 = op3.transform(invert2); // [{"retain":2},{"delete":1}]
const invert11 = op3.transform(invert1); // [{"retain":1},{"delete":1}]

多插件设计

最开始我们还聊过Blocks设计的扩展性,典型的表现就是飞书文档的画板扩展,在变更的时候可以发现其协同算法明显不是OT-JSON,而初始化数据时也仅有画板的id。也就是说画板完全脱离文档本身的数据结构设计,协同是自行实现的而非依赖飞书,数据也不需要存储于飞书。

数据完全不存储于飞书其实也是存在优劣的,优点很明显是完全不需要飞书文档来处理相关的变更,只需要提供足够的扩展性即可,分离的模块开发意味着可以有更高的效率。而缺点则是扩展性本身的实现会更复杂,例如操作变换需要提供扩展能力,多种结构设计也会导致体积变大,架构设计需要更加谨慎。

虽然整体数据并不在飞书文档中,但是当关闭画板编辑模式后,可以是可以执行Ctrl+Z来撤销画板的变更,这也就是说历史操作是存在于文档本身的操作栈当中的。而由于本身数据结构并不一致,操作变换也是需要画板本身提供的,因此这里同样是需要文档提供扩展性。

通过断点可以发现飞书提供的扩展性是通过不同的module来体现的,文档本身的undo模块也是独立注册到编辑器的,而画板也是通过独立的模块注册到编辑器。两者分别维护自己的undo栈,而主模块自己维护的栈是完整的内容,因此这种情况下是可以根据id独立调度对应模块来执行撤销。

undoStack: [
  {_id: 'module1'},
  {_id: 'module2'}
]
// module1 => undoStack: [{...}]
// module2 => undoStack: [{...}]

总结

在这里,我们详细讨论了编辑器中History模块的设计与实现,主要围绕Redo/Undo功能的实现,以及在协同编辑场景下的复杂处理策略,包括栈结构设计、操作合并、客户端变更、受控合并、多插件设计、协同基础架构等。

至此我们已经实现了历史模块的设计,关于Core服务的剪贴板的处理,在初探富文本之序列化与反序列化文章中已经讨论。那么接下来我们需要讲述Core核心服务中的State模块,来以增量变更的模式管理编辑器的状态。

每日一题

参考

posted @ 2026-06-15 10:57  WindRunnerMax  阅读(0)  评论(0)    收藏  举报
©Copyright    @Blog    @WindRunnerMax