LOG // 045 · 编辑器 · 架构

给内联编辑器做一次撤销栈

结论先行撤销栈的难点从来不是栈,而是怎么定义一次操作。 按字符存会爆炸,按事务存才可用。真正要写的代码只有两个方法:apply 和 invert。

我们要做的是行内编辑——改一句文案,回车提交。富文本库那一整套 OT / CRDT 对这个场景完全是过载。

01为什么不用现成的库

试了两个库,都卡在同一个地方:它们假设你在编辑一棵树,而我们编辑的是一段带元信息的文本。 适配层的代码比自己写还多。

02命令模式的最小形态

一个命令只需要知道自己怎么执行、怎么回滚:

editor/commands.tsts
1interface Command {
2  apply(ctx: Ctx): void;
3  invert(ctx: Ctx): void;
4}
5
6class ReplaceRange implements Command {
7  constructor(
8    private from: number,
9    private to: number,
10    private next: string,
11    private prev: string,
12  ) {}
13
14  apply(ctx: Ctx) { ctx.splice(this.from, this.to - this.from, this.next); }
15  invert(ctx: Ctx) { ctx.splice(this.from, this.next.length, this.prev); }
16}

栈本身只有十几行:push、pop、以及一个 redo 栈。关键是切换编辑目标时清空栈, 否则回滚会把另一个字段改掉。

03合并连续的同类操作

如果不合并,敲十个字符就要按十次 Ctrl+Z。做法是给命令一个 mergeKey, 时间间隔小于 500ms 且 key 相同的,直接改上一个命令的 next,不 push 新的。

editor/history.tsts
1push(cmd: Command) {
2  const last = this.undoStack[this.undoStack.length - 1];
3  const mergeable =
4    last && last.mergeKey === cmd.mergeKey && Date.now() - last.at < 500;
5
6  if (mergeable) { last.next = cmd.next; last.at = Date.now(); return; }
7  this.undoStack.push(cmd);
8  this.redoStack.length = 0;   // 新操作一律清空 redo
9}

04两个没想到的坑

  • 选区要一起存。只恢复文本不恢复光标位置,用户会觉得「撤销了一半」。
  • 异步提交会让栈错位。保存请求失败回滚时,必须把对应的命令也弹掉。
Note

如果哪天需求变成多人同时编辑,这套东西就得整体换掉。但那个「如果」一年多了还没发生。


下一篇记录 CSS 容器查询,同样是「以为很简单,结果踩了两次」。