阅读 575

富文本编辑器 L1 能力调研记录

如果你感觉一个功能设计起来非常复杂,自己都无法简单的说明白,那很可能是你的设计方向出错了!

背景

所谓 L1 能力,即弃用浏览器自带的 execCommand ,自己来实现富文本样式操作。
我从 4.3 开始陆续业余时间调研,目前已有 2-3 周,分享一些调研记录。

  • 先开始写 demo ,写了一部分
  • 调研经典的开源作品:Quill Slate ProseMirror

PS:目前只是一个中间阶段,会继续做调研、写 demo 。所以本次分享也会比较零散,而且内容较多。

写 demo

【重要提醒】这部分作为一个了解,demo 的一些设计是不合理的,后面我会有更正,不要被带歪了!!!

基于两个前提

  • 拆分 model 和 view ,使用 vdom 渲染页面
  • 抽象 selection 和 range

抽象 model

【注意】model 即数据模型,有时候也被称为 state content doc 等,我们这里统称为 model 。

定义 IBlockNode IIlineNode ,最顶层只能是 IBlockNode,下面才能是 IInlineNode model 就是 blockNode 的数组。(这些参考 slate)

完全基于 DOM 结构的抽象,很好理解。【注意】文本节点必须被 span 包裹,否则 model 不好修改。

        window.content1 = [
            {
                selector: 'p',
                children: [
                    {
                        selector: 'span',
                        children: ['欢迎使用 ']
                    },
                    {
                        selector: 'a',
                        props: {
                            href: 'https://www.wangeditor.com/',
                            target: '_blank'
                        },
                        children: [
                            {
                                selector: 'span',
                                children: ['wangEditor']
                            }
                        ]
                    },
                    {
                        selector: 'span',
                        children: [' 富文本编辑器']
                    }
                ]
            },
            {
                selector: 'p',
                children: [
                    {
                        selector: 'span',
                        children: ['欢迎使用 ']
                    },
                    {
                        selector: 'b',
                        children: [
                            {
                                selector: 'span',
                                children: ['wangEditor']
                            }
                        ]
                    },
                    {
                        selector: 'span',
                        children: [' 富文本编辑器']
                    }
                ]
            },
            {
                selector: 'p',
                children: [
                    {
                        selector: 'img',
                        props: {
                            src: 'http://www.wangeditor.com/imgs/logo.jpeg',
                            alt: 'logo'
                        }
                    }
                ]
            }
      ]
复制代码

vdom 和 diff patch 使用的是 github.com/snabbdom/sn…

抽象 selection range

参考的 Quill 的设计

interface IVRange {
    offset: number
    length: number
}
复制代码

计算规则:

  • 开始为 0
  • 到文本则 +textContent.length
  • 遇到图片等非文本,则 +1
  • 遇到 block 节点(p li td 等),也要 +1 —— 重要,否则无法区分是 N 行末尾,还是 N+1 行的开始

有两个重要的功能(都会涉及到 DOM 树的深度遍历)

  • 选区变化时,要能根据原生的 range 计算出 vRange
  • 要能根据 vRange 设置编辑器真实的选区 selection.updateSelection({ offset: 5, length: 0 })

command

基本思路

  • 调用 command 修改 model
  • model 修改 view
  • 重置 selection range

抽象基础的 command,可扩展 custom-command

  • nsertInlineNodes 如加粗、斜体等
  • replaceText 输入和删除文字
  • replaceBlockNode 如设置标题、list
  • removeNode
  • deleteText
  • insertBlockNodes
  • replaceInlineNodes 如取消 link
  • deleteBlockNodes
  • ……(还未考虑全,其实基础 command 应该是可枚举的才可以,否则就乱套了)

获取选中的节点

例如,要把 p 设置为 h1 ,或者要对一段文本加粗,你得知道你选中了那几个节点(model 中的节点)
所以,要根据 vRange 和 model ,找出选中的节点,以及他们的上级节点。
即代码中的 content.selectedNodeAndParentsArr ,这是一个二维数组

private selectedNodeAndParentsArr: Array<Array<IBlockNode | IInlineNode>> = []
复制代码

如,你点击选择了一个 p ,它就是 [ [p, span] ] (文本必须被 span 包裹)
如,你拖拽选择了一个 p 内的文本、加粗文本、文本,它就是 [ [p, span], [p, b, span], [p, span] ]
知道了选中的节点,就可以对这些节点进行修改,即修改了 model 。

修改 model ,更新视图

  • 执行相关的命令,如 command.do('bold', true) command.do('color', 'red')
  • 各个命令的代码,转化节点,如加粗时把 <span>x</span> 转换为 <b><span>x</span></b>
  • 执行基础命令,修改 model ,更新视图,更新 selection 和 range —— 【注意】对于加粗等文本格式操作,因为选区的问题,会非常复杂!!!

关于 contenteditable

想自己绘制光标,后放弃

一开始想要自己绘制光标,不用 contenteditable

  • 通过 range.getClientRects()可以获得选区的位置
  • 在该位置绘制一个 absolute div ,内部 append 一个闪烁的 div 和 input
  • 监听 input keydown ,然后执行文本输入、删除,回车,光标的上下变化

后来放弃,因为拖拽选择时,无法同时 focus input ,也就无法监听到 keydown 。
后来经过调研,自己绘制光标的编辑器,都需要使用 iframe (作为第三方 lib 我不想用 iframe),有些甚至需要自己绘制选区(有道云笔记??)
看过 proseMirror 作者的一个演讲视频,他也说这种方式将来带很多 bug 。

决定使用 contenteditable

经过调研,经典的编辑器 Quill slate proseMirror tinyMCE 等都使用 contenteditable 。所以选择它,方向应该不会错。

不过也有一些反对的声音:

  • 最经典的就是 why-contenteditable-is-terrible(中文翻译)
  • 还有有道云笔记的一片分享说:contenteditable 需要劫持 keydown 来修改 model ,万一劫持不到,就会导致 view 被偷偷修改 —— 按理说 view 必须通过 model 生成,不能自己修改

我一开始不想用 contenteditable 也是基于上述原因。但目前看来只能做一个取舍。
所以,最后决定使用 contenteditable

关于 mutation observer

依据我当初想弃用 contenteditable 的想法,就得自己处理文本。我设想的方式是:

  • 监听 input keydown (会考虑防抖)
  • 修改 model ,更新视图,更新 selection

内容的改动,分为两种类型

但使用 contenteditable 之后,编辑区域的修改就变的开放了,在想去劫持 keydown ,范围就会很大,很容易漏掉什么。
所以,我就看到这这张图。图中说,使用 mutation observer 来监听编辑器的改动。

image.png

于是我就把编辑器的修改内容的操作分为两类:

  • 外部调用 API ,例如 js 执行加粗、标题 command
  • 编辑区域内部的改动,因为是 contenteditable ,修改的是开放的,键盘可随便输入、删除、换行等

第二类,我可以用 mutation observer 来监听,不用再去劫持各种 keydown

mutation observer 无法监听所有的改动

但很快我就发现,当 contenteditable 编辑区域,对一个文本回车换行时,mutations 的监听结果是反人类的!!!

我花费了两天的业余时间,尝试去解读 mutation observer 对于回车换行的处理,但是没搞定。
两天之后我就放弃了,不是知难而退,而是这种情况,即便真的废力气解决了,那也是一个很复杂的设计。
好的设计应该是简单的,合理的,易读的,一点即通的。

大家可以自己试试。

看 Quill 源码,mutation observer 只能监听文本改动

Quill 只用 mutation observer 监听文本改动。其他的 enter delete 等,都还是劫持 keydown ,修改 model 。

mutations.length === 1 && mutations[0].type === 'characterData'
复制代码

demo 止于回车换行的问题

在回车换行的问题上我想了很久,但是最终都没有解决方案。demo 也就写到这里为止了。

<p>
  <span>aaa</span>
  <b><span>bbb</span></b>
  <a href="xxx">
    <i><span>ccc</span></i>
  </a>
</p>
<p>
  <span>ddd</span>
</p>
复制代码

例如上面的 html 结构,如果要随便在一个地方换行,model 该如何处理?
如果要拖拽选中一段,再回车换行,model 又该如何处理?—— 这些都不是当前的设计能轻松解释的。

所以,我断定,一定是我的设计反向出了问题。

调研经典的开源作品

调研其他产品,应该同时多看几个,可以相互弥补。因为只看一个你不可能看懂 100% 。
我花了几天业余时间看了看 Quill slate proseMirror ,从主流程上大概了解了,但还需要进一步探索细节。

这三款编辑器,都有一些共同的设计特点,也让我认识到自己之前设计的一些误区。这几点都非常重要!!!

  • 不是修改 model ,而是重新生成(不可变数据,如用 immer)。副作用会变得不可控,越大越乱。
  • command 不会直接修改 model ,而是 command -> operation -> model -> vdom & patchView
  • model 并不是 DOM 结构的样子,而是扁平化甚至线性化,这样才能更好的进行 range 操作

调研其他作品的关键是什么?

精力有限,请务必抓住核心、抓住主要矛盾。

  • 它的重要概念和数据结构,如 Quill 的 Delta
  • 它从 command 到最终 view 渲染的完整流程,以及各个中间阶段

不可变数据

利于拆分模块,降低复杂度
写纯函数,无副作用,利于测试 —— 重要
但要考虑性能,所以要使用合适的工具 immer (immutable.js 不建议使用,API 学习成本高)

Operation 的价值

原子操作,例如 juejin.cn/post/691712…
operation 应该是最底层的、可枚举、不可扩展的。

一个 command 可能会包含多个 operation 。例如拖蓝选中一段文字,点击回车。这个 command 就包含多个 Operation :先删除、再拆分节点。

协同编辑依赖于原子操作,需要把 operation 传递给 peers ,然后做合并。 如常见的 OT 算法,不过这块我还没开始调研。

撤销操作,需要将 operation 反转,然后重新 apply ,如这里inverse 函数。
【注意】如果考虑到协同编辑,撤销操作就能简单粗暴的去覆盖编辑器内容,而是“只能撤销自己的、不能撤销别人的”。所以,需要找出自己的 operation 然后反转。

model 的扁平化

image.png

上图是 proseMirror 的文档,slate 也是这样的。即,所有的文本,无论是 B I U link color bgColor fontSize 等,全部都是平铺的。这也是我刚开始做 demo 没想到的。

Quill 更狠,直接做成了一个线性的模型,用线性结构表示树结构。下文再说。

[
  {
    type: 'p',
    attrs: {},
    children: [
      {
        type: 'text',
        text: 'aaa'
      },
      {
        type: 'text',
        text: 'bbb',
        marks: { bold: true }
      },
      {
        type: 'text',
        text: 'ccc',
        marks: { bold: true, em: true },
        style: { color: 'red' }
      },
      {
        type: 'text',
        text: 'ddd',
        marks: { bold: true, em: true, link: 'xxxx' }
      }
    ]
  },
  {
    // ...
  }
]
复制代码

model 扁平化之后(即文本没有了 <b> <i> <u> <a> <span> 节点和嵌套的层级关系),就发生了质的变化:
model 即 node 树,树的深度最大就是 3 (table tr td),这样遍历起来会很快

  • 容易计算 range { offset, length } (有层级,计算是非常麻烦的,很容易出错)
  • 容易修改文本样式,如随便选中文本,加粗、设置颜色等,文本的选区很随意的
  • 容易拆分节点,如回车换行(上文重点提到的)其实就是 splitNode,非常简单
  • 容易 clean 和 merge (如果有层级,那这一步是非常复杂的)
  • text 节点如果内容为空,则清除掉
  • 两个相邻的 text 节点,属性相同时要 merge

model -> view

model 既然不是和 DOM 结构完全对应,那就无法直接渲染为 vdom 。
例如 { bold: true } 到底渲染为 <b> 还是 <strong>
再例如,当 bold em 同时存在时,渲染为 <b><em> 还是 <em><b> ?谁包裹谁?

所以,model 渲染为 vdom 的中间,还有一个 parser 。
quill 中是 formats 。slate 直接甩手给了用户,自己写 React 组件去实现。proseMirror 目前我还没搞清楚~

而且,model 就一定渲染为 html 吗?能否渲染为 markdown 呢?

Quill.js

content 数据结构

基于 OT 模型 { retain, insert, delete } (可以有 attributes

image.png

对 selection 的抽象

quill 的内容是基于文字的,图片、视频等 embed 也可以看作是一个特殊的文字。

  • 一个文字占据一个长度单位
  • 一个 embed 占据一个长度单位
  • block 占据一个长度单位 —— 否则分不清 N 行尾,还是 N+1 行头

所以,quill 把 selection 抽象为简单的 { index, length }

content 的线性结构【重要】

这里的 content 即上述的 model 。上述的 model 是 node 树,文字内容做扁平化处理。
而 quill 的 content 是线性结构(即数组),它能用线性结构最终渲染出 DOM —— 这是一个很伟大的设计!
而且,它天然就是 OT 模型的,天然就支持协同编辑

线性结构,更加容易基于 range 操作。如:修改文本属性、插入删除文本、回车换行等。
可以和上文的 node 结构比较一下。

content 如何最终表示 DOM 结构呢?它如何表示 <p> <ul><li> <b> 等?参考 demo

// demo 中加入以下代码,以随时查看 content
document.body.addEventListener('click', () => {
  console.log('contents', editor.getContents())
})
复制代码

可以看出 \n 占据了重要角色,quill 用 \n 来表达一个 block 的结束。
它还以表达 table ,更加复杂一点,但一个道理。

delta 即 operation

demo 演示 codepen.io/quill/pen/d… ,内容改变生成 delta ,然后生成新 content

delta 也是基于 OT 模型 { retain, insert, delete } (可以有 attributes),这一点它和 content 一样。但两者不要混了。

  • content 表示编辑器当前的内容
  • delta 表示一个内容的变化,它就是 operation 。一次改动,可能会有多个 delta 。
  • 【注意】content 并不是直接 concat delta ,要经过转换计算的。如 delta 可能有 delete 而 content 只有 insert

即,quill 支持协同编辑器,OT 数据模型是基础,而 delta 就是具体的实现者。

parchment 即 vdom

content 是 OT 模型的,无法直接渲染为 DOM ,所以还需要两步

  • formats ,如 bold link image 如何渲染
  • parchment blots(即 vdom 和 vnode)

quill 主流程大致是:command/format/textChange -> 生成 delta[] -> 重新计算 content -> 根据 foremats 重新生成 parchment -> 渲染 DOM

Slate.js

数据模型 model

Quill 是基于文字的线性结构,Slate 是基于 node 的树形结构。
不过,文本节点也是经过扁平化的。

示例看 juejin.cn/post/691712…

Selection 和 Range

Quill 是基于文字的线性结构,Slate 是基于 node 的树形结构。
Quill 使用 { index, length } 来抽象 range ,而 Slate 就不适合与这种方式。

  • Path 找到具体的节点
  • Point 确定该具体的位置,包括 Path 和 offset
  • 再通过两个 Point ( anchor focus )表示 Range

示例看 juejin.cn/post/691712…

其实,Slate 如果非要用 { index, length } 表示也没问题,也能算出来。但是有了 Path 就会计算的更快一些,Pach 更加适合与这种 node 树结构。

9 种 opreation

Quill 的 delta 是 OT 数据模型,属性只有三个 { remain, insert, delte } slate 用 9 种 operation ,它是为了适合于 node 树结构。

  • node 相关的 6 个
    • insert_node
    • merge_node
    • move_node
    • remove_node
    • set_node
    • split_node
  • text 相关的 2 个
    • insert_text
    • remove_text
  • selection 相关的 1 个
    • set_selection

源码参考 github.com/ianstormtay…

而且,每个 operation 都能找到它的反操作,便于撤销(上文讲过)。

renderElement renderLeaf 相当于 Quill 的 formats

slate 只是一个编辑器的 controller ,view 它不管。开发者自己去写。

示例参考 juejin.cn/post/691712…

主流程

主流程 customCommand -> Transform.xxx(editor, ...) -> editor.apply(operation) -> 重新生成 model -> React 渲染

  • Transform 相当于一些 base command ,在此基础上再去扩展自己的 custom command
  • 每一个 Transform 函数内,都有可能:
    • 执行其他 Transform
    • 生成至少一个 Operation ,然后 editor.apply(operation)
  • editor.apply 内部,会用 immer 来生成不可变数据

proseMirror

感觉 proseMirror 非常的抽象、难懂。但它具有一定的江湖地位,肯定有很多值得学习之处。
不过我目前看的还太少,只看了一天,所以没法写太多内容。

不过,看它的数据结构、内容修改的流程,和上述的主要思路都是对应的。

image.png

model 是 node 树结构,和 slate 类似

image.png

不仅仅是图文

虽然图文编辑是基础,绝不能仅考虑图文。设计要全面、闭环。

考虑什么?

  • 在 model 中的数据格式和结构
  • 如何渲染到 vdom 和 DOM
  • range 如何表示
  • operation 的类型是否能满足
  • 是否支持协同编辑

embed

我此前对 embed 的误解

我之前疑问,所有复杂的东西都可以作为 embed ,在之前的博客里,体现过这一点。
后来我慢慢发现,我搞错了方向。

embed 和是不是复杂没关系,它仅和文字有关系。
复杂的东西可以单独搞,但那是另外一件事儿,不是 embed 。

embed 一定是非文本的

图片是最典型的 embed ,它有一些原始数据 { href, alt, ... } ,最后渲染为一个非文字的“块”,且这个“块”是不可编辑、不可再分、不可拆解的。
例如,视频、音频、公式等。

所以,table codeBlock 都不是 embed 。

复杂的文本组织形式

  • table(如合并单元格)
  • codeBlock

这俩最重要,其他的还没想到。

目前我已经有一点点调研的结果,仅限于 Quill 。其他编辑器还需要再调研对比一下。

未来计划

L1 编辑器内核非常复杂,还需要再继续慢慢调研。接下来我会:

  • 深入到 Quill slate 和 proseMirror
  • 广泛了解其他编辑器 tinyMCE CKEditor editor.js 等
  • 详细学习一下协同编辑,否则 operation 无法详细的设计
文章分类
前端
文章标签