ContentEditable困境与破局之法

2,778 阅读15分钟

首发于知乎: zhuanlan.zhihu.com/p/123341288

这篇文章是基于我个人对于ProseMirror设计思想的理解写出来的,暂时ProseMirror在国内的文档相对较少,希望能给大家一些启发。下面是prosemirror的文档。prosemirror.net/docs/prosem…

Background

现在市面上针对富文本编辑器的方案大概分为以下三种

  • Textarea
  • contentEditable
  • Google Doc

textarea一般是用来实现比较简单的富文本功能(@和#,基本上是评论功能的实现方案),如果作为创作者工具的话,一般会组合其他的表单功能实现。比如Instagram,Shopee的KOL创作工具。

contentEditable实际上是大家最为喜闻乐见的富文本编辑器实现方案,部分基础功能由浏览器实现。无数年轻的前端开发就美滋滋的用了起来,结果发现是一个无底的天坑。当前市面上比较流行的方案有:QuillwangEditorUEditorslatedraft-js

Google Doc在2010年修改了实现富文本的方案(处于一些原因 可以参考:drive.googleblog.com/2010/05/wha…

在实现难度上

textarea的方案相对简单,实际上都谈不上是一个富文本编辑器,就不过多赘余

Google Doc的新方案已经不是一般团队能够实现的了,完整的界面UI乃至光标的闪动都是用div标签重新绘制的。用户交互极其繁杂,边界case极其之多,如果要实现需要耗费的精力是十分大的。

所以我们的精力还是会集中在怎么改造浏览器原生实现的contentEditable,让其变得更好用的方向上。

困境

首先,我们来思考一下,一个编辑器要包含什么?

最基础也要有:可编辑的DOM,外部修改DOM的API

很幸运,这两个要素浏览器都提供了,分别是ContentEditable和document.execCommand

先了解一下什么是contentEditable,先上MDN的解释

developer.mozilla.org/en-US/docs/…

In HTML, any element can be editable. By using some JavaScript event handlers, you can transform your web page into a full and fast rich text editor.

大意就是,contentEditable实际上是浏览器厂商提供的一个富文本编辑器的实现方案。通过设置一个dom的contenteditable属性为true,可以让这个DOM变得可以编辑。

再看一下什么是document.execCommand

When an HTML document has been switched to designMode, its documentobject exposes an execCommand method to run commands that manipulate the current editable region, such as form inputs or contentEditable elements.

Most commands affect the document's selection(bold, italics, etc.), while others insert new elements (adding a link), or affect an entire line (indenting). When usingcontentEditable,execCommand() affects the currently active editable element.

说的就是,当一段document被转为designMode(设计模式 - contentEditable 和 input框)的时候,document.execCommand的command会作用于光标选区(加粗,斜体等操作),也可能会插入一个新的元素,也可能会影响当前行的内容,取决于command是啥。

有了上面的两个东西,一个contentEditable的DOM配合document.execCommand可以实现修改DOM的标签,调整DOM的背景色等等一系列操作。举个🌰

<div id="contentEditable" contenteditable style="height: 1000px;"></div>
<script>
    function handleClickTool (tool) {
        const $editor = document.getElementById('contentEditable')
        const {name, command = 'formatblock'} = tool
        $editor.focus()
        document.execCommand(command, false, name)
    }

    window.onload = function () {
       const $editor = document.getElementById('contentEditable')
       const $toolbar = document.createElement('div')
       const tools = [
           {name: 'h1', text: 'h1'},
           {name: 'h2', text: 'h2'},
           {name: 'h3', text: 'h3'},
           {name: 'h4', text: 'h4'},
           {name: 'h5', text: 'h5'},
           {name: 'p', text: 'p'},
       ]

       tools.map(tool => {
           const $btn = document.createElement('div')
           $btn.classList.add('toolItem')
           $btn.addEventListener('click', () => handleClickTool(tool))
           $btn.innerText = tool.text
           $toolbar.appendChild($btn)
       })

       $toolbar.classList.add('toolbar')
       document.body.insertBefore($toolbar, $editor)
    }
</script>

看起来没有很难是不是,调用一下浏览器API,就能轻轻松松实现一个富文本编辑器(我也能做!!)。听起来很美好,但现实却往往很残酷。如果这样就能做好一个富文本编辑器,那他也不配被称为前端领域几大天坑了(很多人都是被这样骗进坑里来杀的)

看下别人是怎么吐槽contentEditable的: www.oschina.net/translate/w…

对同一个标准(contentEditable,同时是相对不够完善的标准),各个浏览器厂商的实现方案是不同的。举个简单的🌰

当我们在一个空的contentEditable的dom里面打一个回车,那么预期的表现是什么?换行。那么承载这个新的行的标签是什么?

  • Chrome/Safari 是 div 标签
  • Firefox 在60版本之前,是在当前的行级标签中加一个
  • Firefox 在60版本之后,趋同于Chrome/Safari,是div标签
  • IE/Opera 是 p 标签

想要语义化的表达文档结构的你绝望么?想要通过标签选择器统一处理样式的你崩溃么?

当然这个问题是能解决的,在空的contentEditable的dom添加<p><br/></p>就可以解决

用户在一个块级标签里面输入\n(回车),就会根据当前的块级标签新建下一行的标签,实际上大部分编辑器都是通过这个方案解决新建行标签问题的。

还有一些奇怪的行为,比如

<div contenteditable>
  test rich text editor
</div>

你在这段文本中间输入几个回车,那么觉得会变成什么?

<div contenteditable>
  test
  <div>
    <br/>
  </div>
  <div>
    <br/>
  </div>
  <div>
     rich text editor
  </div>
</div>

Ok,好像可以接受,只是第一个文本没有标签嘛,那你试一下把这个几个回车删掉

<div contenteditable>
  test
  <span style={xxxxx}>
    rich text editor
  </span>
</div>

Surprise,惊不惊喜,意不意外。出现了额外的,不必要的标签

树状结构导致的层级问题

这是大家都熟知的,下面的几个标签最终的表示效果是一样的

<strong><em>aaaa</em></strong>
<em><strong>aaaa</strong></em>
<b><i>aaaa</i></b>
<i><b>aaaa</b></i>
<strong><em>aa</em><em>aa</em></strong>
...

这带来的问题是,用户在继续输入的时候,新增的文本到底应该是em,还是strong,还是em + strong的结构

上述的种种只是contentEditable坑的冰山一角,在没有明确的规则约束的前提下,用户在contentEditable的dom中编写出什么样的结构都是有可能的。这给我们带来的问题就是

  • 视觉上等价,但是在DOM结构上不是等价的。
  • contentEditable生成的DOM不总是符合我们的预期的。

至于document.execCommand,MDN明确声明,这是一个Obsolete的特性,浏览器厂商可以不再支持(虽然当前支持的也很差)

还有一个点,为什么大家都说各个平台的富文本编辑器很烂,因为

word可以xxxx,你怎么不行

破局

问题出现了,那我们就要尝试去解决这个问题,如何回避这些坑呢。

基于现代前端框架开发过的同学都一定知道以下这个公式

f(富文本编辑器.assets/equation-20200403180423856) = View

操作一个简单的JS对象一定是比操作DOM要简单的,这屏蔽了浏览器差异,规避了DOM复杂的特性。

State

首先,我们在View和command之间引入一个state。在数据存储层面,我们就不需要维护复杂的DOM结构了,可以用一个JS object的结构去维护当前结构

const state = [{
  type: 'p',
  style: '',
  children: []
}]

OK,可以看到这是一个树状的结构,那么针对下面这种结构

<p>
  text <span>span text</span>
</p>

在我们的Editor State中怎么表示呢?我们需要修改以下上边的那个状态

const state = [{
  type: 'p',
  style: '',
  children: [
    { type: 'textNode', style: '', content: 'text '},
    { type: 'span', style: '', children: [
      {type: 'textNode', style: '', content: 'span text'}
    ]}
  ]
}]

现在,我们拿这个state是不是就可以映射成完整的DOM结构了?但是看到这里,可能觉得加着一层没什么意义,继续往下看。

我们让问题变得稍微复杂一点,来说一下行内标签嵌套的问题

<p>
  text <strong>strong<em>italic text</em></strong>
</p>

转化为刚才说的state

const state = [{
  type: 'p',
  style: '',
  children: [
    { type: 'textNode', style: '', content: 'text '},
    { type: 'strong', style: '', children: [
      {type: 'textNode', style: '', content: 'strong'}
      {type: 'em', style: '', children: [
      	{text: 'textNode', style: '', content 'italic text'}
      ]}
    ]}
  ]
}]

好像没什么问题,看起来就是一个标标准准的DOM树的表示结构(联想React的V-Dom)。

那我们的DOM结构稍微变一下呢?

<p>
  text <strong>strong</strong><strong><em>italic text</em></strong>
</p>

再变一下

<p>
  text <strong>strong</strong><em><strong>italic text</strong></em>
</p>

把他转成state,你会发现,UI虽然是一样的,但是我们描述这个文档的结构却一直在变。这样会带来一个什么问题?

我们定位italic这段文本的路径一直在发生变化,

state[0].children[1].children[1].children[1]state[0].children[2].children[0].children[0]

这样的树状的结构导致我们操作跨层级的DOM十分不方便(更新这个state的时候更困难,确定边界困难)

那我们想一下,这段文本还可以怎么解释呢?

稍微思考一下,行内标签实际上并不会影响我们解释完整的DOM结构,那么我们实际上是可以把他作为style处理的,比如 strong 我们可以等价于 font-weight: bold, em 可以等价于 font-style: italic。

当然,这只是简单的举个🌰而已哈,我们还是要保持文档的语义化(chrome有的时候就是用span + style实现加粗的。。。说好的语义化呢),那让我们加一个叫marks的属性来表示这些修饰用的行内标签。

const state = [{
  type: 'p',
  style: '',
  marks: [],
  children: [
    { type: 'textNode', style: '', content: 'text '},
    { type: 'textNode', style: '', marks: ['strong'], content: 'strong'},
    { type: 'textNode', style: '', marks: ['strong','em'], content: 'italic text'}
  ]
}]

实际上,这样更符合我们人类对于这段文档的认知习惯,而且无论上面那个dom结构怎么变换嵌套形式,我们都会将其解释成同样的一个state。

与此同时,我们定位italic这段文本的路径可以是 state[0].children[3]

甚至我们可以用 state[0] + offset 来表示这段textNode。可能这里state[0]看起来也还很碍眼,如果我在每一个Node节点上添加一个parent这个属性的话,那么事情就变的更简单了。

----

消化一下

----

继续,可以看到我们上面还写了style,表示我们的标签可以有样式,既然如此,我们再扩展一下,加个东西叫attributes,既可以容纳样式,也可以容纳类,id,dataset等一系列东西。

const state = [{
  type: 'p',
  attrs: {},
  children: [
    { type: 'textNode', attrs: {}, content: 'text '},
    { type: 'textNode', attrs: {}, marks: ['strong'], content: 'strong'},
    { type: 'textNode', attrs: {}, marks: ['strong','em'], content: 'italic text'}
  ]
}]

但是,strong标签和em标签也可能有class呀,对吧,万一产品需求加粗变红怎么办,改一下

const NodeAndMarkGen = (nodeType, attrs) => {
  return {
    type: nodeType,
    attrs: attrs
  }
}
const paragraph = NodeAndMarkGen('p', {})
const textNode = NodeAndMarkGen('textNode', {})
const strong = NodeAndMarkGen('strong', {})
const em = NodeAndMarkGen('em', {})

const state = [{
  ...p
  children: [
    {...textNode, content: 'text '},
    {...textNode, marks: [strong], content: 'strong'},
    {...textNode, marks: [strong, em], content: 'italic text'},
  ]
}]

到这里,我们就解决了一下如何存储编辑器状态的问题,结构清晰易懂。但是实际上还没有解决任何真正的问题,比如上面说的回车啊,标签嵌套混乱。

Schema

先说标签嵌套混乱的问题;

用户的行为是不可以预测的,editable dom里面出现什么样的结构都是有可能的。这显然不是我们想要的。有序的,规则化的,可解析的结构才是我们开发喜欢的结构。

既然我们无法预测用户行为,但我们可以设定规则,可以通过指定什么样的DOM标签下可以出现什么样的DOM标签,什么DOM标签可以拥有什么样的marks来约束用户的输入,在这里称作Schema。如果用户的输入产生了不符合我们设定的schema的标签结构。我们就忽略它(or 转化成我们认可的标签)

还是从刚才那个🌰继续,现在的type只是一个简单的字符串,没办法表示太多的信息,我们把他扩展一下(mark仅仅是装饰用的,他不承载内容,也不允许有子集,所以这里会做区分)

interface NodeType { // 注意,textNode也是Node
  tag: string,
  content: string,
  marks: string,
  inline: boolean // (是否是叶子节点,如果是叶子节点就不包含子元素)
}

interface MarkType {
  tag: string
}

注意哈,我们这里声明的是NodeType(node.type),而不是node,这里的marks和content的意义不同。

可以看到比我们之前定义的NodeAndMarkGen多了一个content和marks,我们就可以在这个content里面通过某些方法(比如正则)声明,我们这个node下面可以渲染什么Node和允许使用什么marks

const paragraph = {
  tag: 'p',
  content: 'header1|textNode',
  marks: "em|strong"
}
const header1 = {
  tag: 'h1',
  content: 'textNode',
  marks: "em"
}
const textNode = {
  inline: true,
  marks: '-'
}

const em = {
  tag: 'em'
}
const strong = {
  tag: 'strong'
}

const schema = new Schema({
  nodes: {
    paragraph, header1, textNode
  },
  marks: {
    em, strong
  }
})

上边就声明了这个编辑器的规则(Schema还是要自己实现的哈,prosemirror里面有现成的实现)

  • 有一个Node类型叫做textNode,是一个inline的节点
  • 有一个header1类型,它允许内部(children)中存在textNode,允许用em装饰
  • 有一个paragraph类型,允许内部存在header1或者textNode,允许用em和strong装饰。

再通过这样的声明,并将其以某种形式应用到EditorState的更新过程中,我们的编辑器就应该只出现符合我们刚才schema定义的规则的内容了(schema的实现还是要自己实现的),同时,我们应该也可以通过这个schema去从已有的DOM结构中解析出对应的state了。

View

OK,现在结构有规则Schema约束了,编辑器状态也有EditorState表示了,那么

f(State) = View

这个等式中的f就是一个简单的映射过程,我们由此又得到了View。

看一下我们现在有什么,我们有了能够表示编辑过程中不同时间节点的DOM状态EditorState(被Schema约束),有了能够从State映射到View的方法。那么这个链条中差的就是从 StateA 到 StateB 的过程。

Transform

首先,我们需要约定EditorState是不可变的,一个不可变的对象对于追朔问题,对于状态管理(history)都是有益的。

这里我们引入一个概念叫做Transform(tr),由tr来描述一次变更,这个变更可能是代码中生成的,也有可能是用户在contentEditable进行交互的时候自动生成的。

当我们给一个state应用一个tr后,他应该生成一个新的state,新的state来生成新的View。

那思考一下这个tr中应该包含什么信息?

  1. 当前的选区信息
  2. 当前的文档对象
  3. 描述一系列动作的步骤
  4. 当前正在使用的marks

前两个很好理解

  • 选区信息这个东西实际上浏览器做的很好,我们就拿浏览器的selection和range来用。但是将其映射成为表示我们editorState中的位置信息就相对比较复杂,暂时就不展开了。
  • 当前的文档对象实际上就是刚才说的EditorState,拿来作为参照。

先说描述一系列动作的步骤

这实际有点类似batch的概念,并不是每做一次修改就会直接应用到UI上,而是一个完整的事件之后才会被应用到UI上。我们把步骤称为Step

step实际上就是我们之前提到的document.execCommand在EditorState上的实现,相当于我们这个编辑器的接口,一般来说要实现的功能集很大,比如

  • 替换当前行级元素的标签
  • 替换选区元素
  • 删除元素
  • 新增元素
  • 给当前选区元素添加mark
  • 给当前选区元素删除mark
  • 等等

这部分的话,实际上就是对于EditorState的操作啦,也是需要开发者着重实现的功能,所有的加粗啊,替换行级标签啊,快捷键啊之类的功能都是由这些基础的step组合而来的。具体的实现我们就不展开讲了,只是说一下思路。

再来说说什么是当前正在使用的marks

直接拿场景来说

  1. 当我们在一个<strong>标签包裹的文本之后输入,我们预期输入的是什么呢?
  2. 当我们点击了toolbar的加粗按钮之后,我们预期输入的是什么呢?

1实际很简单,我们找一下光标选区里的Node的marks有什么就可以了。但是涉及到第二种情况的话,现有的Node中是没有这个信息存储的,storedMarks 实际上就是提供了这样的一个位置存储这个数据,上面叙述的Step实现的功能集里面应该也要有addStoredMarks和removeStoredMarks这两个方法。

写在最后

本文叙述的只是最为简单的编辑器的实现思路,在我们实现了足够完整的功能集之后,整个编辑器应该可以像堆积木一样,用这些基础的东西组装成一个复杂的编辑器。

比如,卡片这种复杂的不可分割的DOM结构如何在富文本编辑器的State中表示?比如用户选择跨Node的选区进行格式替换甚至是复制粘贴?这些延展功能的支持实际上才是更为复杂的,需要对上面的结构做更进一步的扩展,约定才可以实现。

除此之外,编辑器的复杂性还在于用户行为不可预测,边界Case太多,要思考出一个完备的,囊括所有可能的逻辑实际上是相对困难的,这一方面就需要我们一点点的去慢慢完善我们的编辑器功能

PSSSS:暗搓搓的吐下槽,掘金的编辑器比知乎的好用多了