聊聊富文本编辑器

Aug 14 2018

开发公司知识库的编辑器已有大半年了,作为RD,我也只停留在熟悉框架所提供的API层面,实现一些功能模块,但对内部实现细节却不甚了解,借此机会希望能对 Web 富文本编辑器原理有更深入的认识。

编辑容器

说起 Web 上的编辑,首先都会想到 inputtextarea 两大输入框标签,但它们都只支持纯文本输入,而不支持包含其他 HTML 标签,如果在其中增加带格式的内容,则会被去格式,只保留文本内容。

通常为了创建一个能内嵌 HTML 并能够编辑的容器,有以下几种方法:

contenteditable=”true”

contentEditable 属性规定了是否可编辑元素内的内容,它可以作用于页面的任何标签,值可以设置为 true 或 false,如果没有值的话,会从父元素继承。
另外,通过 isContentEditable 返回 true 或 false,可以判断某个元素是否可编辑。

 <div class="editor" contenteditable>
    <p><br/></p>
    <hr/>
    <p contenteditable="false"></p>
 </div>

据说 IE/Edge 在源码层依赖 Word,于是 contentEditable 就相当于开启了 “Word” 的编辑模式,大家可以去感受一下。

document.designMode = “on”

通过设置文档的 designMode 属性为 “on” ,可以让整个页面都可以编辑,这时页面中所有元素的 isContentEditable 属性都会变为 true。
通常在使用这个方法时会把要编辑的区域放在一个 iframe 元素中。

  <iframe id="editor" src=editor.html"></iframe>
window.addEventListener("load", function() {
  var editorDocument = document.getElementById("editor").contentWindow.document
  editorDocument.designMode = "on"
  editorDocument.documentElement.isContentEditable   //true
  editorDocument.body.isContentEditable   //true
  editorDocument.querySelector("p").isContentEditable   //true
})

user-modify: read-only;

这家伙是一个 CSS 属性!但其功能不可小觑,称得上是 CSS 版的 contentEditable。但由于兼容性太差,基本上很少会用这个属性。

user-modify: read-only;   // 默认值,只读
user-modify: read-write;  // 读写,支持富文本
user-modify: read-write-plaintext-only;  // 读写,只支持纯文本

注意:在使用这个属性的时候,isContentEditable 是不会变化的。

综述这几种方法,contenteditable="true" 在兼容性和易用性上都是最好的,因此也是大部分富文本编辑器所采用的方式。

光标选区

在富文本编辑器中,开发者需要有能力来控制编辑框中光标的各种状态信息,位置信息等。浏览器提供了 Selection 对象和 Range 对象来操作光标。

selection

Selection 对象表示用户选择的文本范围或插入字符的当前位置。它代表页面中的文本选区,可能横跨多个元素。文本选区由用户拖拽鼠标经过文字而产生。

我们可以通过 window.getSelection() 获取到当前页面的 Selection 对象。该对象上有一系列属性和方法,可用于检查或修改光标选区。

术语

锚点(anchor)
锚指的是一个选区的起始点。当我们使用鼠标框选一个区域的时候,锚点就是我们鼠标按下瞬间的那个点。在用户拖动鼠标时,锚点是不会变的。

焦点(focus)
选区的焦点是该选区的终点,当您用鼠标框选一个选区的时候,焦点是你的鼠标松开瞬间所记录的那个点。随着用户拖动鼠标,焦点的位置会随着改变。

范围(range)
范围指的是文档中连续的一部分。一个范围包括整个节点,也可以包含节点的一部分,例如文本节点的一部分。用户通常下只能选择一个范围,但是有的时候用户也有可能选择多个范围(例如当用户按下 Control 按键并框选多个区域时,Chrome 中禁止了这个操作)。这个范围会被作为 Range 对象返回。Range 对象也能通过DOM创建、增加、删减。

主要属性

anchorNode 和 focusNode:该选区起点和终点所在的节点(Node)。

anchorOffset 和 focusOffset:返回一个数字,其表示的是选区起点或终点在 anchorNode 或 focusNode 中的位置偏移量。
1.如果 anchorNode 是文字节点,那么返回的就是从该文字节点的第一个字开始,直到被选中的第一个字之间的字数(如果第一个字就被选中,那么偏移量为零)。
2.如果 anchorNode 是一个元素,那么返回的就是在选区第一个节点之前的同级节点总数。(这些节点都是 anchorNode 的子节点)。
3.focusOffset 以此类推。

主要方法

getRangeAt(index):从当前 Selection 对象中根据下标返回相应的 Range 对象。

addRange(range):将一个 range 添加到 selection 当中。

removeRange(range):从当前 selection 中移除一个 range。

Range

通常情况下我们不会直接操作 Selection 对象,而是操作 Seleciton 对象下所对应的 Range 对象,它才是我们操作光标的重点。

Range表示包含节点和部分文本节点的文档片段。

Range 可以用 Document 对象的 createRange 方法创建,也可以用 Selection 对象的 getRangeAt 方法取得。

主要属性

startContainer 和 endContainer:范围的起点和终点所在的节点(Node)。

startOffset 和 endOffset:range 起点和终点位置的偏移量。

collapsed:标识选区是否闭合(此时为单个光标)。

主要方法

setStart(startContainer, startOffset):设置 range 的起点。

setEnd(endContainer, endOffset):设置 range 的终点。

selectNode(referenceNode):设定一个包含节点和节点内容的 range。

示例
// 获取当前光标选区
function getSelection () {
    // 获取selection对象
    const selection = window.getSelection ? window.getSelection() : document.getSelection()
    // 从selection中获取第一个Range对象
    const range = selection.getRangeAt(0)
    let startNode = range.startContainer
    let endNode = range.endContainer
    // 兼容IE11 node.contains(textNode) 永远 return false 的bug
     startNode = startNode.nodeType === Node.TEXT_NODE ? startNode.parentNode : startNode
    endNode = endNode.nodeType === Node.TEXT_NODE ? endNode.parentNode : endNode
    // 光标选区是否在编辑器内
    if (editorNode.contains(startNode) && editorNode.contains(endNode)) {
        return range
    }
    return null
}

// 设置光标选区
function setSelection (selectNode, startPos, endPos) {
    // 首先获取selection对象并清除当前的Range
   const selection = window.getSelection ? window.getSelection() : document.getSelection()
    selection.removeAllRanges()
    // 重新设置Range
    const range = document.createRange()
    range.setStart(selectNode, startPos)
    range.setEnd(selectNode, endPos)
    selection.addRange(range)
}

有了这两个方法,我们只需要为编辑器注册 mouseup keyup mouseout 等事件监听光标选区变化以及当对内容进行操作的时候设置新的光标选区。

操作内容

实现富文本编辑器,我们就要能够有修改文档内容的能力,从常规的设置文字格式、添加标题,插入超链接等,到高级的插入图片、视频及自定义元素等丰富多样的操作。在众多编辑器中,实现这些功能的主要方式有两种。

document.execCommand()

常见的富文本编辑器插件,如 CKEditor、UEditor、TinyMCE 等,实现与编辑区进行交互的主要方式是利用 document.execCommand API。该方法允许运行指定指令来操作可编辑区域的光标选区处的内容,浏览器把大部分我们想到的富文本编辑器需要的功能都实现了。

语法
bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)
  • 返回值:布尔类型,false 表示操作不支持或未被启用。
  • aCommandName:字符串类型,一个指令名称,如“bold”、“fontSize”,可用指令列表
  • aShowDefaultUI:布尔类型,是否为该指令提供用户界面,通常设为false,大部分浏览器未实现该功能。
  • aValueArgument:某些指令的额外参数(例如 insertImage 指令需要提供插入的图片的 url),默认为null。
相关方法
  • document.queryCommandEnabled(String command): Boolean 可查询浏览器中指定指令是否可用,可用于工具栏置灰。

  • document.queryCommandState(String command): Boolean 可判断当前光标选区位置是否已经应用了指定指令,可用与工具栏高亮。

缺陷

浏览器自带的这套API虽然功能强大且使用方便,但在于不同浏览器仍有不少小差异,表现在剪切板API,换行处理,键盘事件,指令行为等等的不一致上。
如 bold 指令,IE 和 Opera 会使用 \<strong> 标签包裹文本,而 Safari 和 Chrome则 使用 \<b> 标签,firefox 使用 \<span>。

另一个问题在于数据的一致性。如加粗,下面的几种 DOM 结构的视觉表现是等效的:

<!--正常-->
<p>富文本<b>编辑器</b></p>
<!--分离的 b 标签-->
<p>富文本<b>编</b><b>辑器</b></p>
<!--嵌套的 b 标签-->
<p>富文本<b><b>编辑器</b></b></p>
<!--空的 b 标签-->
<p>富文本<b>编</b><b></b><b>辑器</b></p>
<!--span 代替 b 标签-->
<p>富文本<span style="font-weight: bold">编辑器</span></p>

虽然看上去一样,但对它们的编辑行为会产生显著的区别。contentEditable 编辑器的设计原则之一是编辑器内的一切内容皆可自由编辑,这会带来很多问题,由于一切皆可编辑,用户有太多方法可以破坏你预设的结构。这不仅会导致编辑器出现混乱,更有可能带了一些安全方面的问题。

解决

使用这种方式的编辑器通常都需要监听 DOM 的修改(如 Mutation Observer),然后对 DOM 进行检查并修正,这样毫无疑问会耗费许多资源。

自定义渲染

为了解决前述的这些问题,MediumEditor、draft.js、Slate 等编辑器采用了另一种方法。

原理
  • 首先实现自定义的 Model,它用来描述编辑器每种内容类型的合法 DOM 结构。
  • 编辑时基于这个 Model 去维护一个 State,即对当前富文本内容结构化的对象(类似 Virtual DOM)。
  • 捕获浏览器事件,对编辑器的操作即是对这个 State 的修改,然后再把 State 通过 Model 的规则映射成 DOM

这就严格控制了元素的渲染和行为,使得每个操作将保证它的一致性,对于所有视觉上相等的内容有相同的 DOM 结构。

draft

示例

这是 PromiseMirror 编辑器中的一处 DOM 节点:

HTML

对应的 DOM 结构:

DOM

ProseMirror 中结构的表示:

PM

下面即 ProseMirror 中被称为 Schema 的数据结构,即上文说的 Model,描述编辑器中每种内容对应的解析和渲染的方式,也就是 DOM 结构和 ProseMirror 结构的转换规则,编辑器会根据 Schema 去做校验和相应标签过滤和分割。

model

假设当我们按下删除键删除文本中的两个字时,ProseMirror 会捕捉我们的操作并发起一个 transcation,改变当前文档表示的 ProseMirror 结构,最后再更新到实际的 DOM 上。

tr

如上图所示,在 ProseMirror 中,编辑器内容被建模为扁平的索引,而不同于 DOM 的树形结构,这样我们便不用关心各种各样的嵌套问题。

const tr = new Transform(doc)
tr.delete(3, 5)

这样就在编辑器中形成了一套有序的状态管理体系:

flow

优点

由于用户对编辑器的各种操作都将转化为对 State 的修改并最终映射为 DOM,使得对编辑器的数据变得非常可控,有助于对内容进行检查和过滤。通过结合 Immutable 数据更能方便实现撤销重做等功能。

另一个好处在于多端数据同步,它可以适用于多人在线协作的业务场景。由于编辑器数据都存储在一个自定义数据结构,用户操作实际影响的是该数据结构,我们便可以较为容易的通过锁和 diff 算法来合并短时间内的多次修改。

结尾

到这里,你可能觉得已经可以做出一个像模像样的富文本编辑器了。想想还挺激动的。但是呢,富文本编辑器的坑远远比想象中要大(比如我就经常被 QA 和 PM教做人:“别人家的编辑器可以xxxx,你怎么不行”)。
在实际做的时候仍可能遇到各种各样奇怪的 case,比如从外部复制粘贴过来的内容,中文输入法引起的异常行为,大文档(尤其表格)的性能问题,各种安全问题,还有就是浏览器兼容性,移动端就更别说了。
如果是要做一个编辑器插件,那如何分离出不同职责的模块,怎么抽象出可扩展的接口,方便扩充功能,也是要思考的问题。

参考资料