一起动手撸一个富文本编辑器吧

7,874 阅读5分钟

前言

公司要做一个笔记模块,需要用到富文本编辑器。之前有耳闻富文本编辑器是天坑。知乎-为什么说富文本编辑器是个天坑? 在试过了市面上主流的编辑器后,发现或多或少都不符合要求。主要有以下问题:

  1. CKEditor功能很强大,但是太复杂,有很多用不到的地方。
  2. 项目前端框架是Vue,最好是基于Vue2.x的编辑器
  3. 网上开源的编辑器体验或多或少有不满足的地方。

还好开发时间比较富足,于是决定在vue-html5-editor基础上二次开发,最后完成上线的作品,呼唤star✨ 🙋 Github:my-vue-editor

实现套路

web端实现富文本编辑器主要有2个套路:

  1. 利用contenteditable属性结合document.execCommand API实现,比如国外的CKEditor、百度的UEditor、优秀的后起之秀wangEditor。
  2. 完全自己模拟实现selection、视图渲染等一切。比如Google Doc、有道云笔记、基于electron开发的VS Code。

这里我们很理智的选择了第一种实现方式。先简单介绍下编辑器很重要的几个概念:

Range/Selection

Range 翻译过来是范围,幅度的意思,与数学上的“区间”这以概念类似。浏览器提供的Range对象用来描述DOM树中的一段连续的范围。

startContainerstartOffset描述Range的起始处,endContainerendOffset描述Range的结尾处。当一个Range的起始处和结尾处是同一个位置时,该Range就处于collapsed状态。

Selection(选区)管理整个页面当前的RangeRange的绘制。当Selection中的Range处于collapsed状态时,即是日常所说的光标。光标其实是Selection的一种特殊状态。

document.execCommand

浏览器原生为我们提供了一些对Range内节点进行富文本操作的方法,这些方法都是通过document.execCommand调用。

bool = document.execCommand(aCommandName, aShowDefaultUI, aValueArgument)

比如

// 向当前插入点插入一个p标签。
document.execCommand('insertHTML', false, '<p></p>')

// 将框选部分字体变为绿色,如果是collapsep状态则接下来输入的文字为绿色
document.execCommand('foreColor', false, '#00ff00')

我们编辑器的架都是围绕这两个概念展开的:

  1. 当我们点击编辑器各种功能按钮(比如插入图片、加粗、下划线)时内容区域会失去焦点,所以我们需要一种能够保存当前Range对象,并在需要时可以调用的机制。
  2. 我们已经知道document.execCommand是用来操纵选区HTML结构的,但是原生提供的方法的逻辑大多数都不完全符合我们的需要,或者存在兼容性问题。所以我们封装我们自己的构造函数Command用来操纵富文本,不同的按钮点击后就会实例化相应的Command并执行相关操作。

对于第一点,只需要定义一个保存,一个设置方法。

// 保存当前Range
function saveCurrentRange () {
    // 获取selection对象
    const selection = window.getSelection ? window.getSelection() : document.getSelection()
      if (!selection.rangeCount) {
        return
      }
      const content = this.$refs.content
      for (let i = 0; i < selection.rangeCount; i++) {
        // 从selection中获取第一个Range对象
        const range = selection.getRangeAt(0)
        let start = range.startContainer
        let end = range.endContainer
        // 兼容IE11 node.contains(textNode) 永远 return false的bug
        start = start.nodeType === Node.TEXT_NODE ? start.parentNode : start
        end = end.nodeType === Node.TEXT_NODE ? end.parentNode : end
        if (content.contains(start) && content.contains(end)) {
        // Range对象被保存在this.range 
          this.range = range
          break
        }
      }
}

// 设置Range对象
function restoreSelection () {
    // 首先获取selection对象并清除当前的Range
      const selection = window.getSelection ? window.getSelection() : document.getSelection()
      selection.removeAllRanges()
      // 从this.range中获得保存的Range设置为Selection的Range对象
      if (this.range) {
        selection.addRange(this.range)
      } else {
        // 如果之前没有保存Range则新建一个
        const content = this.$refs.content
        const row = RH.prototype.newRow({br: true})
        const range = document.createRange()
        content.appendChild(row)
        range.setStart(row, 0)
        range.setEnd(row, 0)
        selection.addRange(range)
        this.range = range
      }
    }

有了这两个方法,我们只需要为编辑器的内容区域注册mouseup keyup mouseout事件监听来实时执行saveCurrentRange,当点击按钮后在实例化Command前执行restoreSelection

对于第二点,封装execCommand方法很好理解,比如我要实现"缩进indent"的功能,document.execCommand 就提供了indent这个参数可以直接使用,当Range处于ul>li,中执行indent会让ul嵌套ul,变成ul>ul>li,多个缩进就执行多个嵌套。这满足我们的需要。

// 缩进前
<ul>
    <li>当前光标位置</li>
</ul>

// 缩进后
<ul>
    <ul>
        <li>当前光标位置</li>
    </ul>
</ul>

但是当Range处于一般的块级元素中,执行indent会让块级元素外面嵌套blockquote元素,我们想通过在块级元素上增加margin-left来处理一般块级元素的缩进。

// 缩进前
<p>当前光标位置</p>

// 缩进后
<blockquote>
    <p>当前光标位置</p>
</blockquote>

// 我们希望的情况
<p style='margin-left: 8%;'>当前光标位置</p>

我们只需要封装execCommand方法,当其参数为indent时,执行对应封装好的indent方法,判断Range是处于列表元素还是其他块级元素中分别对待就行。 这里之所以要采用构造函数而不是普通函数的形式,是因为所有原生的execCommand方法,当执行时浏览器内部会对该contenteditable区域维护一个undo栈和一个redo栈,使得每一个修改行为可以撤销和重做。

我们封装的方法覆写了原生的方法,就会破坏undo/redo栈的连续性,导致撤销和重做出错或失效。所以我们需要在每个Command实例上保存执行前编辑器区域的DOM结构(快照)和执行后编辑器区域的DOM结构(快照),并把这个实例推入相应的undo/redo栈。当我们执行撤销和重做操作时只需要从相应的栈中取出保存的快照恢复到内容区域即可。 所以你发现啦,undoredo也是两个需要重写的Command

到这里一个富文本编辑器的雏形就出来了,我们只需要在这个基础上不断完善我们的Command,再处理需要过滤的样式、多端数据结构同步、各种浏览器的兼容性等一个又一个坑就能做出功能丰富的编辑器啦。👏👏👏😄

都看到这里啦,来试试我们的编辑器吧,Github:my-vue-editor 觉得好用给个star呗老铁