Skip to content

富文本编辑器探索记录

· 22 min

为什么要搞一个富文本编辑器?起初是我和朋友 谋划想做一个双链笔记应用带 SRS 且支持 Incremental Reading 时,发现现有的编辑器都不太符合我们的需求。

像是 RemNote 就能很好的处理这些问题,但 RemNote 是商业闭源软件,我们无法得知 RemNote 是如何实现编辑器的。而且在 Incremental Reading 方面支持也相当羸弱,我们希望能做一个开源的版本,且具备需要的功能。

话说回来,富文本编辑器的实现难度也不小,这可是公认的天坑。当前也已经有了很多的优秀的编辑器实现,比如 Quill, Draft, Slate, ProseMirror、TipTap 还有 wangEditor 等等。或许在这些现有的编辑器基础上进行二次开发会是一个不错的选择。 但我还是想从零开始实现一个编辑器,主要是为了更好的理解富文本编辑器的实现原理,同时也能更灵活的定制功能。

我之前也在 V2EX 上询问过有关富文本编辑器的学习资料,但收获并不大。总归还是要靠自己摸索。开这个 Post 的目的也是为了 记录我在实现富文本编辑器过程中遇到的问题和解决方案,也希望能和大家交流讨论。而对于编辑器的具体实现,我目前的目标是实现可用的编辑器,给笔记软件用上,再逐步优化和完善功能。 目前我还是 处于探索阶段,很多东西和想法还不成熟,希望能通过不断的尝试和实践,最终实现一个符合需求的富文本编辑器。这一阶段的过程和结果都会记录在这里,或许能提供一些有用的参考。


实现方案#

现在的富文本编辑器实现方案大致可以分为3类:

类型描述代表性编辑器
L0依赖浏览器 document.execCommand API 实现的编辑器wangEditor
L1编辑器自行维护的数据结构Quill, Draft, Slate
L2编辑器自行维护的数据结构 + Canvas 实现的编辑器Google Doc

L0 编辑器#

L0 的编辑器实现方案是最简单的,主要依赖浏览器提供的 document.execCommand API 来实现文本的编辑和格式化功能。通过调用这个 API,可以实现诸如加粗、斜体、插入链接等基本的富文本功能。 但是 document.execCommand API 已经被废弃。最重要的是, document.execCommand 在不同浏览器的表现不一致,而且他的表现我们无法控制。

组件代码
<script lang="ts">
  import { onMount } from "svelte"

    let editor: HTMLDivElement;
    let code = $state("")
    function refreshCode(){
        code = editor.innerHTML;
    }
    function execBoldCmd(){
        document.execCommand('bold');
        refreshCode();
    }
    function execItalicCmd(){
        document.execCommand('italic');
        refreshCode();
    }
    onMount(()=>{
        refreshCode();
    })
</script>
<div class="border rounded flex">
    <div class="flex-1 border-r p-4" contenteditable="true" bind:this={editor} oninput={refreshCode}>
        <p>Hello, World</p>
    </div>
    <div class="flex-1 flex flex-col">
        <div class="flex-1 border-b p-2" >
            <button type="button" onclick={execBoldCmd} class="border p-1">Bold</button>
            <button type="button" onclick={execItalicCmd} class="border p-1">Italic</button>
        </div>
        <div class="flex-1 p-2">
            <textarea class="w-full h-full border" readonly>{code}</textarea>
        </div>
    </div>
</div>

通过这种方法实现的编辑器问题很明显:

如果要继续使用 L0 方案实现富文本编辑器,可能需要做大量的兼容性处理工作,且功能上也会受到很大限制。

L1 编辑器#

L1 编辑器的实现方案是编辑器自行维护一套数据结构来表示文档内容。通过监听用户的输入事件,编辑器可以根据用户的操作更新内部的数据结构,并同步更新显示的内容。

像是 CodeMirror(代码编辑器也是富文本编辑器)、 Quill 等都是采用这种方案。整个编辑器以 MVC 架构来设计,数据模型(Model)负责存储和管理文档内容,视图(View)负责显示内容,控制器(Controller)负责处理用户输入并更新模型和视图。用户所有的输入 会被应用到 Model 上,Model 发生变化后,View 会重新渲染以反映最新的内容。

使用 contenteditable 属性来实现文本的编辑功能是一种常见的实现文本编辑的方案,但使用 contenteditable 也会带来一些选区上的问题,尤其是在跨越 contenteditable 节点时无法从一个节点选择到另一个节点。

Node 1
Node 2
组件代码
<div class="border p-2">
    <div contenteditable="false">
        <div contenteditable="true">Node 1</div>
        <div contenteditable="true">Node 2</div>
    </div>
</div>

但如果不使用 contenteditable,那么就无法出现光标,也无法拉出 IME。一种常见的方法是通过隐藏一个 input 元素来拉出 IME,然后通过监听 input 事件来获取用户的输入内容,再将这些内容应用到编辑器的 Model 上。 同时,由于用户的焦点在 input 上,用户无法进行选择操作,对于文字的选择需要自行实现,通过计算用户选择的位置,通过 CSS 或者其他方式自绘选区,这部分的逻辑会比较复杂。不过似乎 Monaco Editor 就是这样实现的。

L2 编辑器#

这是最复杂的一种实现方案,类似于 Google Docs 这种在线文档编辑器。它能保证在多个浏览器和设备上有一致的表现,逃避 DOM 诡异的表现,同时也能实现高度定制化的编辑功能。

L2 编辑器是通过 Canvas 来实现的,编辑器完全控制内容的渲染和交互逻辑,不依赖浏览器的 contenteditable 功能。通过监听用户的输入事件,编辑器可以根据用户的操作更新内部的数据结构,并使用 Canvas API 来绘制内容。

但是这种方案实现起来非常复杂,它脱离了浏览器的 DOM,需要处理文本的布局、光标的渲染、选择范围的管理等问题。虽然 L2 方案可以实现高度定制化的编辑器,但开发成本和维护成本都非常高。这种方式几乎等于重新实现一个 Skia。

此外,为了处理 IME 的问题,同样需要隐藏一个 input 元素来拉出 IME,然后通过监听 input 事件来获取用户的输入内容。更多内容这里不再阐述了,因为它实在是太复杂了。

数据结构#

对于需要自行实现数据结构的编辑器,如 L1 和 L2 方案,选择合适的数据结构是非常重要的。常见的有以下几种:

树形结构#

通常来说用树形结构来模仿 HTML 的 DOM 结构是很常见的做法。 Slate 编辑器就是使用这种方式来表示文档内容的。

{
"type": "document",
"children": [
{
"type": "paragraph",
"children": [
{
"text": "这是一个段落。"
}
]
},
{
"type": "heading",
"level": 1,
"children": [
{
"text": "这是一个标题。"
},
{
"type": "list",
"ordered": false,
"children": [
{
"type": "list-item",
"children": [{ "text": "这是一个无序列表项。" }]
}
]
}
]
}
]
}

但在使用树形结构去表述文档时,也将树上操作的复杂性引入进来。比如在一个树上插入文本,需要找到对应的节点,然后更新节点的内容。如果需要插入一个新的块(比如段落),还需要调整树的结构,确保新块被正确地插入到树中。而且同一种显示结果的背后可能对应多种树形结构的表示方式(例如斜体加粗可以是 bold > italic > text 或者 italic > bold > text),如果用户进行后续操作时可能会出现进行相同的操作而造成不同的结果,这就需要在操作时进行规范化处理,确保树的结构符合预期。

此外,为了处理格式的嵌套,必须处理很多边缘情况。例如 ul 里嵌套一个 quotequote 里嵌套一个 ul,这种嵌套关系会使得树的结构变得复杂,操作起来也更加困难。

树形结构为视图层面上带来了丰富的表达力,但在操作层面上也带来了复杂性。

块级结构#

块级结构相对来说要简单一些,文档内容被划分为多个块,每个块可以有自己的格式和样式。每个块内部的文本可以有不同的属性,比如加粗、斜体、颜色等。

[
{ "insert": "Gandalf", "attributes": { "bold": true } },
{ "insert": " the " },
{ "insert": "Grey", "attributes": { "color": "#cccccc" } }
]

块级结构的优点是操作相对简单,因为每个块都是独立的,可以单独进行操作。插入、删除、修改块时,只需要更新对应的块即可,不需要调整整个树的结构。缺点是表达力相对较弱,灵活度不足,无法很好地表示复杂的嵌套关系。

数据结构实现#

Quill 1 2#

Quill 采用块级结构来表示文档内容,结构称为 Quill-Delta。 Quill 的官方文档对 Delta 的设计有详细的介绍: Delta 用一系列操作组成,它可以描述对文档的修改,也可以描述整个文档。

基础要求#

Delta 要求结构必需满足以下条件:

紧凑是指同样的内容应该使用最少的操作来表示。例如,连续的文本插入应该合并为一个块,而不是多个单独的块。同时,块的属性也应该保持最简,避免冗余。下面的是一个非紧凑的 Delta:

const content = [
{ text: 'Hel' },
{ text: 'lo' },
{ text: 'World', attributes: { bold: true, italic: false } },
]

它的问题有两个: Hello 的属性相同,且合并后不会对渲染结果产生影响,因此可以合并为一个块; Worlditalic 属性为 false,这是冗余的,可以去掉。因此,紧凑后的 Delta 应该是:

const content = [
{ text: 'Hello' },
{ text: 'World', attributes: { bold: true } },
]

规范是指同样的内容应该有唯一的表示方式,避免出现多种不同的表示方式导致的歧义。例如,加粗和斜体的顺序不应该影响最终的渲染结果,因此应该有一个统一的顺序来表示这些属性。下面的是一个非规范的 Delta:

const content = [
{ text: 'Hello', attributes: { bold: true } },
{ text: 'World', attributes: { fontWeight: 'bold' } },
]

它的问题是 boldfontWeight: 'bold' 都表示加粗,但使用了不同的属性名称,导致不规范。

const content = [
{ text: 'Hello', attributes: { color: 'red' } },
{ text: 'World', attributes: { color: '#F00' } },
]

这也是一个非规范的 Delta,因为 red#F00 都表示红色,但使用了不同的表示方式。

规范要求 Delta 的设计的运行时的自动规范都需要遵守上述规则,以确保 Delta 的一致性和可预测性。

Quill 默认要求 Delta 的规范在运行时满足:

除此之外,Delta 还处理了一些其它问题。

行格式化#

Quill 使用 \n 来表示行的结束,因此每个块级元素(如段落、标题、列表项等)都以 \n 结尾。有一些块级元素的属性可能与内容有送

const content = [
{ text: 'Hello', attributes: { align: 'center' } },
{ text: '\nWorld' },
]

这里 align: 'center' 应用到整行,使 Hello 居中对齐。但若用户删除 \World 中的换行符,这时文本结构就出现了问题。

const content = [
{ text: 'Hello', attributes: { align: 'center' } },
{ text: 'World' },
]

如果 HelloWorld 之间没有换行符,不管是 Hello 不再居中,还是 HelloWorld 不再居中,形成的 Delta 都是非规范非紧凑的。此外,假如在下面的例子中删除 \n,剩下的又该是什么?

const content = [
{ text: 'Hello', attributes: { align: 'center' } },
{ text: '\n' },
{ text: 'World', attributes: { align: 'right' } },
]

不管是 HelloWorld 居中,还是 HelloWorld 右对齐,都会导致 Delta 非规范非紧凑。遵循特定的规则处理则会使 Delta 变得复杂。 Delta 通过将所有的 \n 独立出来,并将行属性应用到 \n 上来解决这个问题。

const content = [
{ text: 'Hello' },
{ text: '\n', attributes: { align: 'center' } },
{ text: 'World' },
{ text: '\n', attributes: { align: 'right' } }, // Deltas 总是以 `\n` 结尾
]

描述变更#

Delta 除了在描述文档外,还可以用来描述对文档的变更。 Delta 支持三种基本的操作: 插入(insert)、删除(delete)、格式化(format)和保留(retain)。通过组合这些操作,可以描述对文档的各种修改。 实际上,Delta 描述文档就是描述对空白文档的修改。

Delete#

描述删除文本,需要描述在哪里删除以及删除多少字符。若要删除 embds,其删除操作的长度恒为 1

const delta = [
{
delete: {
index: 4,
length: 1,
},
},
{
delete: {
index: 12,
length: 3,
},
},
]

删除操作是基于索引的,并确保没有范围重叠,否则就会违反 规范性

Insert#

将上文的 text 改为 insert 还不够,因为 Delta 还需要描述插入的位置。 Delta 可以通过在插入操作中添加类似 delete 操作的 index 来解决这个问题。 但插入 index 可能会在 index 的处理上引入大量的复杂性,index 必须指向文档在未应用任何操作前的位置,否则后续操作可能会删除之前的内容,取消正确的格式化,这可能会违背紧凑性。所有的操作还必须被严格的排序,先按 index 排序,再按 length 排序,再按类型排序。

我们还可以采用 Retain 中描述的方式来简化插入操作。

Format#

与删除类似,格式化需要指定文本凡未以及格式本身。在原本的 attributes 对象中本身存在格式,因此格式化操作提供了一个额外的 attributes 对像与现有的对象合并。 有一个特殊情况是要去除格式,quill 使用 null 表示删除格式,即 { bold: null } 表示删除加粗格式。

这里同样借助 Retain 中描述的方式来简化格式化操作。避免使用 index 引入复杂性。

const delta = [
{
format: /* 临时省略 */,
attributes: {
bold: true,
},
},
]
Retain#

如果我们暂时放下紧凑性的形式,可以描述一种更简单的格式来表达插入、删除和格式化:

若文档原本为 HelloWorld,我们想要加粗 Hello 并在其后插入一个空格,可以这样描述:

const change = [
{ format: 5, attributes: { bold: true } }, // Hello
{ insert: ' ' },
]

由于每个字符都被描述,因此不再需要明确的索引和长度。这使得重叠区间和错序索引无法表达,从而简化了 Delta 的处理逻辑。

Slate#

TODO

Slate 为了操作树形结构,提供了 2 类操作:


后续内容将缓慢学习和探索 TO BE CONTINUED…

Footnotes#

  1. Designing the Delta Format - Quill Rich Text Editor

  2. slab/delta: Simple and expressive JSON format for describing rich-text content and their changes