惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

P
Proofpoint News Feed
The Last Watchdog
The Last Watchdog
Security Latest
Security Latest
P
Privacy International News Feed
T
Threat Research - Cisco Blogs
H
Help Net Security
T
The Exploit Database - CXSecurity.com
Know Your Adversary
Know Your Adversary
博客园_首页
S
Securelist
S
Schneier on Security
G
GRAHAM CLULEY
Cisco Talos Blog
Cisco Talos Blog
V
Visual Studio Blog
博客园 - 叶小钗
C
Cybersecurity and Infrastructure Security Agency CISA
有赞技术团队
有赞技术团队
Recent Announcements
Recent Announcements
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
Microsoft Azure Blog
Microsoft Azure Blog
A
About on SuperTechFans
博客园 - 三生石上(FineUI控件)
Stack Overflow Blog
Stack Overflow Blog
量子位
L
Lohrmann on Cybersecurity
Hugging Face - Blog
Hugging Face - Blog
Engineering at Meta
Engineering at Meta
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
C
CXSECURITY Database RSS Feed - CXSecurity.com
A
Arctic Wolf
P
Privacy & Cybersecurity Law Blog
Simon Willison's Weblog
Simon Willison's Weblog
S
SegmentFault 最新的问题
The Hacker News
The Hacker News
罗磊的独立博客
博客园 - 司徒正美
D
Darknet – Hacking Tools, Hacker News & Cyber Security
博客园 - 【当耐特】
Microsoft Security Blog
Microsoft Security Blog
K
Kaspersky official blog
人人都是产品经理
人人都是产品经理
博客园 - 聂微东
L
LINUX DO - 热门话题
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
V
V2EX
V
Vulnerabilities – Threatpost
AWS News Blog
AWS News Blog
小众软件
小众软件
Project Zero
Project Zero

博客园_首页

Plist 二进制格式 Milvus 和 PGVector,哪个更好? OpenClaw 已过时?在 VS Code 中运行 Hermes Agent! 第30篇文章:一个大三计科生的自白 Manim如何在数学公式中完美显示中文? Docker 部署 RocketMQ 5 并发编程核心概念辨析 C#事务处理最佳实践:别再让“主表存了、明细丢了”的破事发生 CLI 是什么?为什么大厂突然集体卷命令行? 【从0到1构建一个ClaudeAgent】协作-自主Agent UIImageView 设置图片不生效的原因排查 最小二乘问题详解20:无先验约束下的增量式SFM自由网平差 痞子衡嵌入式:大话双核i.MXRT1180之XIP应用里借助MU实现可靠Flash IAP的方法 AI Chat 封装, SemanticKerne.AiProvider.Unified 已发布 Windows下右键编辑js文件无法打开记事本——在注册表中使用环境变量 在后台服务中使用 Scoped 服务,为什么总是报错? H200 安装驱动并使用sglang启动模型 wireshark 抓包Trap上报告警内容 我用 AI 辅助开发了一系列小工具(2):图片压缩工具 [A Primer On MC and CC] 2.1 Memory Consistency 1 - 指令重排序和 SC 模型 Oracle数据库SCN推进技术详解与实践指南 玩转控件:封装个带图片的Label控件 Claude Code 4.7 真正该升级的不是模型,而是你的工作流 前端小白一句话,AI 帮我做了个颜值拉满的桌面媒体播放器。当代码不再是门槛,一句话编程就是现实。 5. WorkBuddy: 小龙虾的灵魂三件套,让你的小龙虾不只是工具 SQLite 分片方案实战:三种分片策略的深度对比 告别简陋 UI!一款基于 Fluent Design 和基于 WinUI 的开源免费、现代化的 Avalonia UI 控件库 关于二进制排列组合枚举的总结 AI开发-python-LangGraph框架(3-27-LangGraph从零实现大模型智能决策工作流) ElasticSearch主分片和副本分片概念详解 【002】HTTPS 粗解:证书、TLS 握手与对后端配置的影响 Hermes Agent 一周暴涨五万 Star,但我劝你别急着追 明明连接的是Redis的DB0,为什么能查到DB3的数据? 【从0到1构建一个ClaudeAgent】协作-Agent团队 熟悉电子元器件之后,电子小白下一步该怎么走? MAF快速入门(23)通过C#类定义Skills .NET 高级开发 | 手写一个对象映射框架 FastAPI数据库ORM怎么选?我肝了三个Demo后,终于不再纠结了 mysqldump 参数拾遗:在遗忘与铭记之间 C# .NET 周刊|2026年3月5期 Claude code入门 - 陈彦斌 一文学习入门 ThingsBoard 开源物联网平台 GitHub 热门项目 | 2026年04月16日 如何为GIT设置全局勾子,为每次提交追加信息 Number.isFinite和isFinite与isNaN()和Number.isNaN的区别 PortSwigger SQL注入LAB2 推荐一个测试人必备的Skills,从功能到性能全搞定(附详细实操和安装下载方式) 筑基期:掌握Odoo基础核心知识点02(Odoo XML 开发方式详解) GLM模型这么火,咱们用vllm也咧一个呗! 深入理解 AbortController:从底层原理到跨语言设计哲学 字符串学习笔记 多租户系统框架的基础模块设计和分析设计 Apache SeaTunnel Zeta 为什么能做到“又快又稳”? AI开发-python-LangGraph框架(3-26-LangGraph基本概念及第一个简单样例) Vue 3 组件通信,别只会用 Props 和 Emits 了,这几个狠活儿你得看看 ElasticSearch7.X版本配置密码 用Manim实现动态交点计算--从一个动点问题说起 团结引擎+Addressable+Instant Game打包抖音小游戏 function call 实战:让 LLM 自动判断 pod 异常、调用日志工具并完成故障分析 bubseek —— 让 Agent 的足迹,变成团队的洞察 通过 C# 读取并导出 PDF 书签 如何用 GitHub Actions 实现 Steam 自动化发布 【从0到1构建一个ClaudeAgent】并发-后台任务 .NET 高级开发 | 定制 ASP.NET Core 框架 电子小白:什么是运算放大器(运放) zero2Agent:面向大厂面试的 Agent 工程教程,从概念到生产的完整学习路线 堆上的ORW HC32F460 USB CDC通信异常:非对齐访问异常排查 20260413-Hyperbridge 攻击事件:发生在默克尔山上的验证绕过 那些喊着AI 要淘汰你的人,正在靠你的焦虑赚大钱! 深度学习进阶(八)Swin Transformer 最小二乘问题详解19:带先验约束的增量式SFM优化与实现 SnapTranslate 3.0 正式发布:全局划词翻译 + 完整英语学习闭环,一站式搞定查词、记词、复习 工作的意义、工作的困难认知再思考 .NET + AI 进阶实战:基于类的技能开发 - 打造可治理的 Agent 能力模块 【从0到1构建一个ClaudeAgent】规划与协调-技能 上周热点回顾(4.6-4.12) 电子小白的工具三件套:面包板、杜邦线、万能板 单表五亿数据的查询优化 | Mysql、StarRocks 2. WorkBuddy:从“我是谁”到“帮我干活” C# 如何减少代码运行时间:7 个实战技巧 基于HelixToolkit.SharpDX 渲染3D模型 - 笺上知微 从零开始的双臂具身VLA起源及现阶段发展综述 - SkyXZ 记对 xonsh shell 的使用, 脚本编写, 迁移及调优 - pluvium27 受够了Vibe Coding的失控?换个起点,让AI事半功倍 从开始配置漏洞环境到漏洞复现流程 - 難しい 关于10年工作经验的程序员对OpenClaw的实战经验分享以及看法 - 虚无境 Any metadata 的内存布局 C# .NET 周刊|2026年3月2期 - InCerry 我帮你测过了,测试圈排名第二的 Skill 依然很牛逼 Skill Discovery | 无监督技能发现的经典工作总结 - MoonOut 上下文工程是什么?过时了么?一文讲明白! - 一枫说码 开了 TUN 模式还是直连?90% 的人都踩过这个坑 AScript扩展多种脚本语言 - rockey627 AI 学习笔记:Agent 的记忆机制 你能被装进一个文件里吗?——7 万人把同事"蒸馏"成了 AI - 我没有三颗心脏 Claude Code 通关手册(七):给 AI 装上技能包——Skills 完全指南 - 暮色之狐 在浏览器中快速编辑代码:VSCode Web 集成实践 - Newbe36524 蒸馏自己 skill?基于 Deepseek 的蒸馏器,丐版蒸馏方式,简单便捷 - To_Carpe_Diem Spring AI Aliababa和AgentScope,哪个更好? - 苏三说技术
从零实现富文本编辑器#14-编辑器历史变更管理与状态回溯
WindRunnerMax · 2026-06-15 · via 博客园_首页

先前我们基于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模块,来以增量变更的模式管理编辑器的状态。

每日一题

参考