从零设计实现富文本编辑器

富文本编辑器是允许用户在输入和编辑文本内容时,可以应用不同的格式、样式等功能,例如图文混排等,具有所见即所得的能力。与简单的纯文本编辑组件<input>等不同,富文本编辑器提供了更多的功能和灵活性,让用户可以创建更丰富和结构化的内容。现代的富文本编辑器也已经不仅限于文字和图片,还包括视频、表格、代码块、附件、公式等等比较复杂的模块。

Why?

那么为什么要从零设计实现新的富文本编辑器,编辑器是公认的天坑,且当前已经有很多优秀的编辑器实现。例如极具表现力的数据结构设计Quill、结合React视图层的Draft、纯粹的编辑器引擎Slate、高度模块化的ProseMirror、开箱即用的TinyMCE/TipTap、集成协同解决方案的EtherPad等等。

我也算是比较关注于各类富文本编辑器的实现,包括在各个站点上的编辑器实现文章我也会看。但是我发现这其中极少有讲富文本编辑器的底层设计,绝大多数都是讲的应用层,例如如何使用编辑器引擎实现某某功能等。虽然这些应用层的实现本身也会有一定复杂性,但是底层的设计却是更值得探讨的问题。

此外,我觉得富文本编辑器很类似于低代码的设计,准确来说是No Code的一种实现。本质上低代码和富文本都是基于DSL的描述来操作DOM结构,只不过富文本主要是通过键盘输入来操作DOM,而无代码则是通过拖拽等方式来操作DOM,我想这里应该是有些共通的设计思路。

而我恰好前段时间都在专注于编辑器的应用层实现,在具体实现的过程中也遇到了很多问题,并且记录了相关文章。然而在应用层实现的过程中,遇到了很多我个人觉得可以优化的地方,特别是在数据结构层面上,希望能够将我的一些想法应用出来。而具体来说,主要有下面的几个原因:

编辑器专栏

纸上得来终觉浅,绝知此事要躬行。

我的博客是从20年开始写的,记录的内容很多,基本上是想到什么就写什么,毕竟是作为平时学习的记录。然后在24年写了比较多的富文本编辑器的文章,主要是整理了平时遇到的问题以及解决方案,集中在应用层的设计上,例如:

此外,前段时间还研究了slate富文本编辑器相关的实现,并且也给slate的仓库提过一些PR。还写了一些slate相关的文章,并且还基于slate实现了一个文档编辑器,同样也是比较关注于应用层的实现,例如:

在实现了诸多的应用层的功能之后,发现整个编辑器有很多可以深入研究的地方。特别是有些实现看似很理所当然,但是仔细研究起来会发现这其中有很多细节可以探究,例如在DOM结构后常见的零宽字符、Mention节点的渲染等等,这些内容都可以单独拿出来记录文章,这其实就是我想从零实现编辑器的最重要原因。

24年开始写了很多业务上的东西,到了25年就略感题穷,而目前我也没有别的擅长的方面,由此写编辑器相关的内容是比较好的选择,这样对于文章的选题也会简单些。不过,虽然想的是深入写编辑器相关的内容,但是在平时遇到问题的时候,还是会记录下来,例如最近有个基于immer配合OT-JSON实现的状态管理的想法可以实现。

而对于编辑器的具体实现,我目前的目标是实现可用的编辑器,而不是兼容性非常好且功能完备的编辑器。主要是现在已经有非常多优秀的编辑器实现,且有很多生态插件可以支持,能够满足大部分的需求。目前我想实现的编辑器主要是兼容Chrome浏览器即可,移动端的问题暂时不会考虑。不过,如果能够将编辑器做得比较好的话,自然可以去做兼容性适配。

不过目前还是试探性地来设计并实现编辑器,期间必然会遇到很多问题,这些问题也将会成为专栏的主体内容。最开始的时候,我是准备将编辑器完善后再开始撰写文章,后来发现设计过程中的历史方案同样很有价值,因此决定将设计过程也一并记录下来。如果将来真的能够将编辑器适用于生产环境,那么这些文章就能够溯源到模块为什么这么设计,想必也是极好的。整体来说,我们不能一口吃成胖子,但是一口一口吃却是可以的。

深入编辑器

这部分是让我想起来一句话:我们富文本编辑器是这样的,你不写你不懂。

编辑器是个非常注重细节的工程,很多时候都需要深入研究浏览器的API,例如document上的caretPositionFromPoint方法,用以获取当前某个点所在的选区位置,通常用于拖拽文本后的落点定位。除此之外,还有很多选区相关的API,例如SelectionRange等等,这些都是编辑器实现的基础。

那么深入编辑器底层就是很有意义的事情,很多时候我们都需要跟浏览器打交道,即使是对我们平时的业务开发也会有价值。在这里我想聊一下编辑器中的零宽字符,以此例学习编辑器的细节设计,这是一个非常有意思的话题,类似这种内容就是不研究则不会关注到的有趣事情。

零宽字符顾名思义是没有宽度的字符,因此就很容易推断出这些字符在视觉上是不显示的。因此这些字符就可以作为不可见的占位内容,实现特殊的效果。例如可以实现信息隐藏,以此来实现水印的功能,以及加密的信息分享等等,某些小说站点会通过这种方式以及字形替换来追溯盗版。

而在富文本编辑器中,如果我们在开发者工具检查元素时,可能会发现一些类似于&ZeroWidthSpace;U+200B类似的字符,这就是常见的零宽字符。例如在飞书文档的编辑器中,我们通过$("[data-enter]")就可以检查到其中存在的零宽字符。

<!-- document.querySelectorAll("[data-enter]") -->
<span data-string="true" data-enter="true" data-leaf="true">\u200B</span>
<span data-string="true" data-enter="true" data-leaf="true">&ZeroWidthSpace;</span>

那么从名字上来看,这个零宽字符在视觉上是不显示的,因为其是零宽度。但是在编辑器中,这个字符却是很重要的。简单来说,我们需要这个字符来放置光标,以及做额外的显示效果。需要注意的是我们在这里指的是ContentEditable实现的编辑器,如果是自绘选区的编辑器则不一定需要这部分设计。

我们先来聊一下额外的显示效果,举个例子,我们在选择飞书文档文本内容,如果选中到文本末尾时,会发现末尾会额外多出形似xxx|的效果。在平时不关注的话可能会觉得这是编辑器默认行为,但是实际上这个效果无论是slate还是quill中都是不存在的。

实际上这个效果就是使用零宽字符来实现的,在行内容的末尾后面插入零宽字符,就可以做到末尾的文本选中效果。实际上这个效果在word中更常见,也就是额外渲染的回车符号。

<div contenteditable="true">
  <div><span>末尾零宽字符 Line 1</span><span>&#8203;</span></div>
  <div><span>末尾零宽字符 Line 2</span><span>&#8203;</span></div>
  <div><span>末尾纯文本 Line 1</span></div>
  <div><span>末尾纯文本 Line 2</span></div>
</div>

那么在这个零宽字符如果只是渲染效果的话,那么可能实际上起的作用并不很必要。但是在交互上这个效果却很有用,例如此时我们有3行文本,如果此时从第1行末尾选到第2行时,并且按下Tab键,那么此时这两行的内容就会缩进。

那么如果没有这个显示效果,此时进行缩进操作,用户可能认为仅仅是选中了第2行,但是实际上是选中了1/2两行文本。这样的话用户可能会以为是BUG,而我们也实际接受过这个交互效果的反馈。

123|
4|x56

也对各个在线文档实现进行了简单调研: 基于contenteditable实现的编辑器中,飞书文档、早期EtherPad存在这个交互实现;自绘选区的编辑器中,钉钉文档存在这个实现;Canvas引擎实现的编辑器中,腾讯文档、Google Doc存在这个实现。

在渲染效果部分,零宽字符还有一个重要的作用是撑起行内容。当我们的行内容为空时,此时这个行DOM结构的内容就是空,这就导致此行的高度塌陷为0,且无法放置光标。为了解决这个问题,我们可以选择在行内容中插入零宽字符,这样就可以撑起行内容且可以放置光标。当然使用<br>来撑起行高也是可以的,使用这两种方案会各有优劣,且兼容性方面也有所不同。

<div data-line-node></div>
<div data-line-node><br></div>
<div data-line-node><span>&#8203;</span></div>

在类似于Notion这种块结构的编辑器中,还有个比较重要的交互效果。即块级结构独立选择,例如我们可以直接将整个代码块独立选出来,而不是仅仅能选择其中的文本。这种效果在目前的开源编辑器很少有实现,都是需要自行以块结构重新组织设计选区。

通常来说,这个交互同样可以使用零宽字符来实现。因为我们的选区通常是需要放置在文本节点上的,因此我们很容易可以想到,可以在块结构所在行的末尾放置零宽字符,当选区在零宽字符上时就将整个块选中。这里用零宽字符而不是<br>的好处是,零宽字符本身就是零宽,不会引起额外的换行。

<div>
  <pre><code>
    xxx
  </code></pre>
  <span data-zero-block>&#8203;</span>
</div>

在结构上,零宽字符还有个非常重要的实现。在编辑器内的contenteditable=false节点会存在特殊的表现,在类似于inline-block节点中,例如Mention节点中,当节点前后没有任何内容时,我们就需要在其前后增加零宽字符,用以放置光标。

在下面的例子中,line-1是无法将光标放置在@xxx内容后的,虽然我们能够将光标放置之前,但此时光标位置是在line node上,是不符合我们预期的文本节点的。那么我们就必须要在其后加入零宽字符,在line-2/3中我们就可以看到正确的光标放置效果。这里的0.1px也是个为了兼容光标的放置的magic,没有这个hack的话,非同级节点光标同样无法放置在inline-block节点后。

<div contenteditable style="outline: none">
  <div data-line-node="1">
    <span data-leaf><span contenteditable="false" style="margin: 0 0.1px;">@xxx</span></span>
  </div>
  <div data-line-node="2">
    <span data-leaf>&#8203;</span>
    <span data-leaf><span contenteditable="false" style="margin: 0 0.1px;">@xxx</span></span>
    <span data-leaf>&#8203;</span>
  </div>
  <div data-line-node="3">
    <span data-leaf>&#8203;<span contenteditable="false">@xxx</span>&#8203;</span>
  </div>
</div>

除此之外,编辑器自然是需要跟字符打交道的,那么在js表现出来的Unicode编码实现中,emoji就是最常见且容易出问题的表达。除了其单个长度为2这种情况外,组合的emoji也是使用独特的零宽连字符\u200d来表示的。

"🎨".length
// 2
"🧑" + "\u200d" + "🎨"
// 🧑‍🎨

数据结构设计

编辑器数据结构的设计是影响面非常广的事情,无论是在维护编辑器的文本内容、块结构嵌套、序列化反序列化等,还是平台应用层面上的diff算法、查找替换、协同算法等,以及后端服务的数据转换、导出md/word/pdf、数据存储等,都会涉及到编辑器的数据结构设计。

通常来说,基于JSON嵌套的数据结构来表达编辑器Model是很常见的,例如SlateProseMirrorLexical等等。以slate编辑器为例,无论是数据结构还是选区的设计,都尽可能倾向于HTML的设计,因此可以存在诸多层级节点的嵌套。

[
  {
    type: "paragraph",
    children: [{ text: "editable" }],
  },
  {
    type: "ul",
    children: [
      {
        type: "li",
        children: [{ text: "list" }],
      },
    ],
  },
];

通过线性的扁平结构来表达文档内容也是常见的实现方案,例如QuillEtherPadGoogle Doc等等。以quill编辑器为例,其内容上的数据结构表达不会存在嵌套,当然本质上还是JSON结构,而选区则采用了更精简的表达。

[
  { insert: "editable\n" },
  { insert: "list\n", attributes: { list: "bullet" } },
];

当然还有很多特别的数据结构设计,例如vscode/monacopiece table数据结构。代码编辑器又何尝不是一种富文本编辑器,毕竟其是可以支持代码高亮的功能的,只不过类似piece table的结构我还没有太深入研究。

在这里我希望能够以线性的数据结构来表达整个富文本结构,虽然嵌套的结构能够更加直观地表达文档内容,但是对于内容的操作起来会更加复杂,特别是存在嵌套的内容时。以slate为例,在0.50之前的版本API设计非常复杂,需要比较大的理解成本,虽然之后将其简化了不少:

// https://github.com/ianstormtaylor/slate/blob/6aace0/packages/slate/src/interfaces/operation.ts
export type NodeOperation =
  | InsertNodeOperation
  | MergeNodeOperation
  | MoveNodeOperation
  | RemoveNodeOperation
  | SetNodeOperation
  | SplitNodeOperation;
export type TextOperation = InsertTextOperation | RemoveTextOperation;

从这里可以看出来,slate对于文档内容的完整操作是需要9种类型的Op。而如果是基于线性结构的话,我们就只需要三种类型的操作,即可表达整个文档的操作。当然对于一些类似Move的操作,则需要额外的选区Range计算处理,相当于将计算成本移交到了应用层。

// https://github.com/WindRunnerMax/BlockKit/blob/c24b9e/packages/delta/src/delta/interface.ts
export interface Op {
  // Only one property out of {insert, delete, retain} will be present
  insert?: string;
  delete?: number;
  retain?: number;

  attributes?: AttributeMap;
}

此外,嵌套结构的normalize会变得很复杂,且变更造成的时间复杂度也会变高,特别是脏路径标记算法,以及标记后的数据处理也需要由上述Op处理。还有用户操作导致的嵌套层级无法非常好地控制,就要normalize过程时规范数据,否则下面例如粘贴HTML时就可能会出现大量的数据嵌套。

[{
  children: [{
    children: [{
      children: [{
        children: [{
          // ...
          text: "content"
        }]
      }]
    }]
  }]
}]

再举个更加实用的例子,如果我们此时存在格式的嵌套内容。例如quotelist两种格式嵌套,如果此时我们文档的数据结构是嵌套结构,那么操作内容就会存在ul > quote或者quote > ul的两种情况,正常情况下我们必须要设计规则来做normalize;而扁平结构下,属性全部写在attrs内,不同操作造成的数据格式变更是完全幂等的。

// slate
[{
  type: "quote",
  children: [{
    type: "ul",
    children: [{ text: "text" }]
  }],
}, {
  type: "ul",
  children: [{
    type: "quote",
    children: [{ text: "text" }]
  }],
}]

// quill
[{
  insert: "text",
  attributes: { blockquote: true, list: "bullet" }
}]

扁平的数据结构在数据处理方面会存在优势,而在视图层面上,扁平的数据结构表达结构化的数据会是比较困难的,例如表达代码块、表格等嵌套结构。但是这件事并非是不可行的,例如Google Doc的复杂表格嵌套就是完全的线性结构,这其中是存在很巧妙的设计在里边的,在这里先不展开了。

此外,如果我们需要实现在线文档的编辑器的话,在整个管理流程中可能会需要diff,即取得两边数据结构的增删改。这种情况下扁平的数据结构能够更好地处理文本内容,而JSON嵌套结构的数据则会麻烦很多。还有一些其他关于数据处理方面的周边应用,整体复杂度都要提升不少。

最后还是有协同相关的实现,协同算法是富文本编辑器的可选模块。无论是基于OT的协同算法,还是Op-Based CRDT的协同算法,都是需要传输上述的op类型与数据的,那么很显然9种操作的op类型会比3种操作的op类型更加复杂。

因此,我希望能够以线性的数据结构来实现整个编辑器结构,这样quilldelta就是非常好的选择。但是quill是自行实现的视图层结构,并非是可以组合react等视图层的形式,组合这些视图层的优势就是可以直接使用组件库样式来实现编辑器,而避免了每个组件都需要自行实现。那么这里我准备基于quill的数据结构,来从零实现富文本编辑器核心层,并且像slate一样以此组合基本的视图层。

方案选型

其实这里有个有趣的问题,为什么用不到1mb的代码量就可以实现部分类似office word编辑器的能力,是因为浏览器已经帮我们做了很多事情,并通过API提供给开发者,包括输入法处理、字体解析、排版引擎、视图渲染等等。

因此我们是需要设计出如何跟浏览器交互的方案,毕竟我们实际上是需要跟浏览器交互的。而对于富文本编辑器最经典的描述则是分为了三级:

  • L0: 基于浏览器提供的ContentEditable实现富文本编辑,使用浏览器的document.execCommand执行命令操作。 是作为早期轻量编辑器,可以较短时间内快速完成开发,但可定制的空间非常有限。
  • L1: 同样基于浏览器提供的ContentEditable实现富文本编辑,但数据驱动可以自定义数据模型与命令的执行。常见的实现有语雀、飞书文档等等,可以满足绝大部分使用场景,但无法突破浏览器自身的排版效果。 |
  • L2: 基于Canvas自主实现排版引擎,只依赖少量的浏览器API。常见的实现有Google Docs、腾讯文档等等,具体实现需要完全由自己控制排版,相当于使用画板而不是DOM来绘制富文本,技术难度相当高。

实际上在目前的开源产品中,这三种类型的编辑器都有涉及到,特别是绝大多数开源的都是L1类型的实现。而这其中还分化了不依赖ContentEditable却也不是完全自绘引擎,而是依赖DOM呈现内容外加自绘选区的实现,实际上倒是可以算作L1.5的级别。

本着学习的目的,自然要选择开源产品多的实现,这样遇到问题可以更好地借鉴和分析相关内容。因此我同样打算选择基于ContentEditable,实现数据驱动的标准MVC模型的富文本编辑器,基于这种方式来与浏览器交互,实现基本的富文本编辑能力。在此之前,我们还是先了解一下基本的编辑器实现:

ExecCommand

如果我们仅仅需要最基本的行内样式,例如加粗、斜体、下划线等,这可能在一些基本输入框中是足够的,那么我们自然可以选择使用execCommand来实现。甚至直接基于execCommand的好处就是,其体积会非常小,例如 pell 的实现,仅仅需要3.54KB的代码体积,此外还有 react-contenteditable 等实现。

我们也可以实现可以加粗的最小DEMOexecCommand命令可以在contenteditable元素中选区内的元素执行,document.execCommand方法接受三个参数,分别是命令名称、显示用户界面、命令参数。显示用户界面一般都是falseMozilla没有实现,而命令参数则是可选的,例如超链接命令则需要传递具体链接地址。

<div>
  <button id="$1">加粗</button>
  <div style="border: 1px solid #eee; outline: none" contenteditable>
    123123
  </div>
</div>
<script>
  $1.onclick = () => {
    document.execCommand("bold");
  };
</script>

当然,这个示例过于简单,我们还可以在选区变换的时候,来判断加粗按钮的加粗状态,以此来显示当前选区状态。不过我们需要对齐execCommand的命令行为,前边也提到了可控性非常差,因此我们需要通过document.createTreeWalker迭代所有的选区节点,以此来判断当前选区的状态。

其实这里还需要注意的是,execCommand命令的行为在各个浏览器的表现是不一致的,这也是之前我们提到的浏览器兼容行为的一种,然而这些行为我们也没有任何办法去控制,这都是其默认的行为:

  • 在空contenteditable编辑器的情况下,直接按下回车键,在Chrome中的表现是会插入<div><br></div>,而在FireFox(<60)中的表现是会插入<br>IE中的表现是会插入<p><br></p>
  • 在有文本的编辑器中,如果在文本中间插入回车例如123|123,在Chrome中的表现内容是123<div>123</div>,而在FireFox中的表现则是会将内容格式化为<div>123</div><div>123</div>
  • 同样在有文本的编辑器中,如果在文本中间插入回车后再删除回车,例如123|123->123123,在Chrome中的表现内容会恢复原本的123123,而在FireFox中的表现则是会变为<div>123123</div>
  • 在同时存在两行文本的时候,如果同时选中两行内容再执行("formatBlock", false, "P")命令,在Chrome中的表现是会将两行内容包裹在同个<p>中,而在FireFox中的表现则是会将两行内容分别包裹<p>标签。
  • ...

此外还有类似于实现加粗的功能,我们无法控制是使用<b></b>来实现加粗还是<strong></strong>来实现加粗。还有浏览器的兼容性问题,例如在IE浏览器中是使用<strong></strong>来实现加粗,在Chrome中是使用<b></b>来实现加粗,IESafari不支持通过heading命令来实现标题命令等等。且对于一些比较复杂的功能,例如图片、代码块等等,是无法很好实现的。

当然,默认的行为并不是完全没有用的,在某些情况下,我们可能要实现纯HTML的编辑器。毕竟如果在基于MVC模式的编辑器实现中,会处理掉对Model来说无效的数据内容,这样就导致原本的HTML内容丢失,因此在这种需求背景下依赖浏览器的默认行为可能是最有效的,这种情况下我们可能主要关注的就是XSS的处理了。

ContentEditable

ContentEditableHTML5中的一个属性,可以让元素变得可编辑,再配合上内置的execCommand就是我们上边聊的最基本DEMO。那么如果要实现最基本的文本编辑器,就只需要在地址栏中输入下面的内容:

data:text/html,<div contenteditable style="border: 1px solid black"></div>

那么通过document.execCommand来执行命令修改HTML的方案虽然简单,我们也聊过了其可控性很差。除了上述的execCommand命令执行兼容性问题之外,还有很多DOM上的需要兼容处理的行为,例如下面存在简单加粗格式的句子:

123**456**789

有许多方式可以表达这样的内容,编辑器可以认为显示效果是等价的,此时可能也需要对此类DOM结构等同处理:

<span>123<b>456</b>789</span>
<span>123<strong>456</strong>789</span>
<span>123<span style="font-weight: bold;">456</span>789</span>

但是这里仅仅是视觉上相等,将其完整对应到Model上时,自然会是件麻烦的事。除此之外,选区的表达同样也是复杂的问题,以下面的DOM结构为例:

<span>123</span><b><em>456</em></b><span>789</span>

如果我们要表达选区折叠在4这个字符左侧时,同样会出现多种表达可以实现这个位置,这实际上就会很依赖浏览器的默认行为:

{ node: 123, offset: 3 }
{ node: <em></em>, offset: 0 }
{ node: <b></b>, offset: 0 }

因此为了更强的扩展以及可控性,也解决数据与视图无法对应的问题,L1的富文本编辑器使用了自定义数据模型的概念。即在DOM树的基础上抽离出来的数据结构,相同的数据结构可以保证渲染的HTML也是相同的,配合自定义的命令直接控制数据模型,最终保证渲染的HTML文档的一致性。对于选区的表达,则需要根据DOM选区来不断normalize选区Model

其实这也就是我们常见的MVC模型,当执行命令时会修改当前的模型,进而表现到视图的渲染上。简单来说就是构建一个描述文档结构与内容的数据模型,并且使用自定义的execCommand对数据描述模型进行修改。在这个阶段的富文本编辑器,通过抽离数据模型,解决了富文本中脏数据、复杂功能难以实现的问题。我们也可以大概描述流程:

<script>
  const editor = {
    // Model 选区
    selection: {},
    execCommand: (command, value) => {
      // 执行具体的命令, 例如 bold
      // 命令执行后, 更新 Model 以及 调用 DOM 渲染
    },
  }
  const model = [
    // 数据模型
    { type: "bold", text: "123" },
    { type: "span", text: "123123" },
  ];
  const render = () => {
    // 根据 type 渲染具体 DOM
  };
  document.addEventListener("selectionchange", () => {
    // 选区变换时
    // 根据 dom 选区来更新 model 选区
  });
</script>

而类似这种方案,无论是 quill 还是 slate 都是这样的调度。而类似于slate的实现,通过适配器来连接React之后,就需要更复杂的兼容处理。在React节点中加入ContentEditable后,会出现类似下面的warning:

<div
  contentEditable
  suppressContentEditableWarning
></div>
//  A component is `contentEditable` and contains `children` managed by React. It is now your responsibility to guarantee that none of those nodes are unexpectedly modified or duplicated. This is probably not intentional.

这个warning的意思是,React无法保证ContentEditable中的children不会被意外修改或复制,这可能是不被意料到的。也就是说除了React本身是会需要执行DOM操作的,使用了ContentEditable之后,这个行为就变的不受控了,自然这个问题同样会出现在我们的编辑器中。

此外还有一些其他的行为,例如下面的例子中,我们无法从123字符选中到456上。也就是这里存在跨越ContentEditable节点了,就不能够正常使用浏览器的默认行为来处理,虽然这个处理是很合理的,但毕竟也会对我们实现blocks编辑器造成一些困扰。

<div contenteditable="false">
  <div contenteditable="true">123</div>
  <div contenteditable="true">456</div>
</div>

那么其实我们是可以避免使用ContentEditable的,设想一下即使我们没有实现编辑器,同样是可以选择页面上的文本内容的,就是我们普通的选区实现。那么如果借助原生的选区实现,然后在此基础上实现控制器层,就可以实现完全受控的编辑器。

但是这里存在一个很大的问题,就是内容的输入,因为不启用ContentEditable的话是无法出现光标的,自然也无法输入内容。而如果我们想唤醒内容输入,特别是需要唤醒IME输入法的话,浏览器给予的常规API就是借助<input>来完成,因此我们就必须要实现隐藏的<input>来实现输入,实际上很多代码编辑器例如 CodeMirror 就是类似实现。

但是使用隐藏的<input>就会出现其他问题,因为焦点在input上时,浏览器的文本就无法选中了。因为在同个页面中,焦点只会存在一个位置,因此在这种情况下,我们就必须要自绘选区的实现了。例如钉钉文档、有道云笔记就是自绘选区,开源的 Monaco Editor 同样是自绘选区,TextBus 则绘制了光标,选区则是借助了浏览器实现。

在这里可以总结一下,使用ContentEditable需要处理很多DOM的特异行为,但是明显我们是不需要太过于处理唤醒输入这个行为。而如果不使用ContentEditable,却使用DOM来呈现富文本内容,则必须要借助额外的隐藏input节点来实现输入,由于焦点问题在这种情况下就不能使用浏览器的选区行为,因此就需要自绘选区的实现。

Canvas

基于Canvas绘制我们需要的内容,颇有些文艺复兴的感觉,这种实现方式是完全不依赖DOM的,因此可以完全控制排版引擎。那么文艺复兴指的是,基于DOM兼容实现的任何生态都会失效,例如无障碍、SEO、开发工具支持等等。

那么为什么要抛弃现有的DOM生态,转而用Canvas来绘制富文本内容。特别是富文本会是非常复杂的内容,因为除了文本外,还有图片的内容,以及很多结构话格式的内容,例如表格等。这些内容都需要自行实现,那么在Canvas中实现这些内容其实相当于重新实现了部分skia

基于Canvas绘制的编辑器,当前主要有腾讯文档、Google Doc等,而开源的编辑器实现有 Canvas Editor。而除了文档编辑器之外,在线表格的实现基本都是Canvas实现,例如腾讯文档Sheet、飞书多维表格等,开源的实现有 LuckySheet

Google Doc发布的Blog中,对于使用Canvas绘制文档主要选了两个原因:

  • 文档的一致性: 这里的一致性指的是浏览器对于类似行为的兼容,举个例子: 在Chrome中双击某段文本的内容,选区会自动选择为整个单词,而在早期FireFox中则是会自动选择一句话。类似这种行为的不一致会导致用户体验的不一致,而使用Canvas绘制文档可以自行实现保证这种一致性。
  • 高效的绘制性能: 通过Canvas绘制文档,可以更好地控制绘制时机,而不需要等待重绘回流,更不需要考虑DOM本身复杂的兼容性考量,以此造成的性能损失。此外,Canvas绘图替代繁重的DOM操作,通过逐帧渲染和硬件加速可以提升渲染性能,从而提高用户操作的响应速度和整体使用体验。

此外,排版引擎还可以控制文档的排版效果,做富文本的各种需求,我们就可能面临产品为什么不能支持像office word那样的效果。例如如果我们编写的文字正好排满了一行,假如在这里再加一个句号,那么前边的字就会挤一挤,从而可以使这个句号是不需要换行。而如果我们再敲一个字的话,这个字是会换行的。在浏览器的排版中是不会出现这个状态的,所以假如需要突破浏览器的排版限制,就需要自己实现排版能力。

<!-- word -->
文本文本文本文本文本文本文本文本文本文本文本文本文本文本。
<!-- 浏览器 -->
文本文本文本文本文本文本文本文本文本文本文本文本文本文本

也就是说,在word中通常是不会出现句号在段落起始的,而在浏览器中是会存在这种情况的,特别是在纯ASCII字符的情况下。如果想规避这种排版状态的差异,就必须要自行实现排版引擎,以此来控制文档的排版效果。

此外,还有一些其他的功能,例如受控的RTL布局、分页、页码、页眉、脚注、字体字形控制等等。特别是分页的能力,在某些需要打印的情况下,这个效果是很必要的,但是DOM的实现在绘制前是无法得知其高度的,因此也就无法很好地实现分页的效果。除此之外,还有大表格的分页渲染效果等等,都变得难以控制。

因此,这些如果希望对齐word的实现,就必须要用Canvas从头造一遍。除了这些额外的功能,还有原本的浏览器基于DOM实现的基本功能,例如输入法的支持、复制粘贴的支持、拖拽的支持等等。而基本的Canvas是无法支持这些功能的,特别是输入法IME的支持,以及文本选区的实现,都需要很复杂的交互实现,这样的成本自然不会是很容易接受的。

总结

在本文我们聊了很多关于富文本编辑器基础能力的实现,特别是在DOM结构表现和数据结构之间的设计。并且在浏览器交互的方案上,我们也聊了ExecCommandContentEditableCanvas实现方案的特点,简单总结了当前成熟产品以及开源编辑器,并且描述了相关实现的优缺点。

在后边我们会基于ContentEditable实现基本的富文本编辑器,首先对于整体的架构设计,以及数据结构的操作做概述。然后开始分别实现具体的模块,例如输入模块、剪贴板模块、选区模块等等。实现编辑器从来都不是一件简单的事情,除了在核心层面的基础框架设计,应用层上也会有很多问题需要兼容处理,因此这将会是一份大工程,需要慢慢积累。

每日一题

参考