ContentEditable困境与破局

ContentEditable困境与破局

!!我们的富文本编辑器开源啦!!欢迎使用


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

背景

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

  • Textarea
  • contentEditable
  • Google Doc

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

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

Google Doc在2010年修改了实现富文本的方案(处于一些原因 可以参考: drive.googleblog.com/20 ),由contentEditable转到了监听用户交互同时在DOM上通过div等标签绘制的方案。


在实现难度上

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

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

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

困境

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

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


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

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

developer.mozilla.org/e
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. This article provides some information about this functionality.

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

那么我们再看一下什么是document.execCommand

developer.mozilla.org/e
When an HTML document has been switched to designMode , its document object 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 using contentEditable, execCommand() affects the currently active editable element.

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


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

<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的: oschina.net/translate/w ,我觉得这篇文章用了太多学术词语导致晦涩难懂,但有些思想是很贴合实际的


坑来了

厂商实现差异

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

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

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

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

当然这个问题是能解决的,在空的contentEditable的dom添加 <p><br/></p> 就可以解决。用户在一个块级标签里面输入\n(回车),就会根据当前的块级标签新建下一行的标签,实际上大部分编辑器都是通过这个方案解决新建行标签问题的。

不可预测的表现

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

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

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

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

<div contenteditable>
  <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 的特性,浏览器厂商可以不再支持(虽然当前支持的也很差)

破局

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

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

f(State) = 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