cover_image

从零开始的富文本编辑器(上)

胡炜轩 哔哩哔哩技术
2022年09月16日 04:00

本期作者

图片


胡炜轩

资深开发工程师


01 什么是contenteditable


 1.1 属性介绍


contenteditable 是一个枚举属性,表示元素是否可被用户编辑。如果可以,浏览器会修改元素的部件以允许编辑。


该属性必须是下面的值之一:

  • true 或空字符串,表示元素是可编辑的。

  • false 表示元素不是可编辑的。


如果没有设置该属性的值(例如:Example Label),则其值被视为空字符串。

如果没给出该属性或设置了无效的属性值,则其默认值继承自父元素:即,如果父元素可编辑,该子元素也可编辑。

注意,虽然该属性允许设定的值包括 true 和 false,但该属性仍是一个枚举属性而非布尔属性。


通过这个属性,我们能够使任意元素变成一个textarea,比如:

<div contenteditable="true">  This text can be edited by the user.</div>


contenteditable的兼容性如下:

图片


可以看到,绝大部分现代浏览器都是支持的。


 1.2 使用contenteditable

制作一个简单的富文本编辑器


这里我们直接引用mozilla上的例子:


(点击文末“阅读原文”跳转例子)


这个demo的原理为:通过执行 document.execCommand 方法,在标记为 contenteditable 的元素中执行对应的命令,如格式化、插入元素、复制粘贴等。

function formatDoc(sCmd, sValue) {  if (validateMode()) { document.execCommand(sCmd, false, sValue); oDoc.focus(); }}


但是请注意,document.execCommand 方法在最新的标准里已经被标记为不赞成使用。

图片


02 传统编辑器方案的痛点


在上一节中我们介绍了如何使用元素的 contentEditable 属性配合 document.execCommand 方法来实现一个富文本编辑器。

但富文本编辑器,长期以来被称为前端的天坑之一,是有它的说法的。文末我们也提到了, document.execCommand 方法已经被标注为不推荐使用,这意味着在未来的浏览器版本中它有可能被彻底移除。

其实除了这个问题,现在也有很多其他的痛点:


 2.1 执行效果不可控


我们知道,前端是依托于浏览器而生的,那么使用 document.execCommand 调用对应的命令,会调用到不同浏览器的native实现,故而没法完全一致。

比如:当你按下 Enter/Return 键在可编辑区域中创建一个新的文本行时,不同主流浏览器对此有不同处理 (Firefox 插入、IE/Opera 将使用、 Chrome/Safari 将使用)。

同样的,如果你想定制一个命令、一个特殊的元素如链接或图片,或者正好浏览器对应的命令存在某些bug,想要实现或者修复它的困难也是可想而知的。


 2.2 繁琐的DOM操作


自从mvvm框架诞生以来,传统的dom操作越来越被人嫌弃:代码繁琐,容易出错,不易维护。

尤其是vue与react框架出现之后,越来越多的人习惯于将数据绑定在viewmodel上并交由框架自动渲染。使用 document.execCommand 去在html中插入、改变元素的方式无疑是过时的。


 2.3 如何持久化


我们知道,web2时代,大部分前端应用的数据需要通过服务端保存。这里最简单的处理方案,是获取到编辑器的innerHTML直接发送给后端保存。事实上,早期的编辑器如UEditor都是这么做的。


如果放在目前这个时代还这么做,会遇到很多问题:

  1. 内容审核日趋严格,在审核工具的构建上,需要很方便的提取出文本内容。

  2. 推荐算法日益发达,除文字内容外,需要很方便的提取出文章内的其他元素,如图片等。

  3. 迭代速度越来越快,如果遇到大型的版式修改,如何去兼容以往的html内容是一个大问题,而且问题往往无法收敛(因为是用户提交)。

  4. 文章展现的地方不再是pc,更多变成了移动端,可能会交由客户端去实现。


将编辑的内容,转化为一个html无关的结构化数据,是一个比较自然的解决方案。

此时,诸如quilljs、draftjs之类的编辑器开始出现,它提供了一套通用的基于编辑器状态、格式化及选中区的API来屏蔽dom操作,提供了内容的格式化能力,同时在框架内部做了多平台兼容。

这些努力,终于让富文本编辑器的开发变得简单了一些。


03 slatejs介绍


slatejs是迄今为止仍然还在维护的,基于contenteditable的编辑器框架。它借鉴了许多quilljs与draftjs的api设计和插件思想。(这两位前辈的最后一次提交都在一两年前了)



 3.1 框架而非应用


Slate 并非一个编辑器应用,而是一套在 React 和 Immutable 的基础上,用于操作富文本数据的框架。基于 Slate 实现一个富文本编辑器,只相当于使用 React(视图层)+ Immutable(数据层)开发一个普通 Web 应用。

下图中展示了一个基于 Slate 实现的编辑器架构,数据的流动非常简单易懂:

图片


 3.2 核心API简介

 3.2.1 Transform,文档操作


如前文所属,Slate 依赖的数据结构为 Immutable,它是不可变的,必须通过 Transform 提供的一系列API去做转换。

比如,想让选中区域的文字变为标题:

Transforms.setNodes(editor, { type: 'heading-one', })


以及变回普通文字:

Transforms.unwrapNodes(editor, {  match: node =>    !Editor.isEditor(node) &&    node.children?.every(child => Editor.isBlock(editor, child)),  mode: 'all',})


上面这个例子中,使用了match来匹配对应的结点,可以根据匹配结构走不同的逻辑,方便定制。

在Transform中,你可以处理文字、处理整个元素、处理光标。

比如你想在文档的特定位置插入文字:

Transforms.insertText(editor, 'some words', {  at: { path: [0, 0], offset: 3 },})


at配置对应的数据结构,详见后文的Location。


 3.2.2 Node,元素类型


首先,最上层的Editor是一种特殊的元素,它是富文本编辑器本身,封装了整个富文本编辑器的内容及一些帮助方法。

interface Editor {  // Current editor state  children: Node[]  selection: Range | null  operations: Operation[]  marks: Omit<Text, 'text'> | null  // Schema-specific node behaviors.  isInline: (element: Element) => boolean  isVoid: (element: Element) => boolean  normalizeNode: (entry: NodeEntry) => void  onChange: () => void  // Overrideable core actions.  addMark: (key: string, value: any) => void  apply: (operation: Operation) => void  deleteBackward: (unit: 'character' | 'word' | 'line' | 'block') => void  deleteForward: (unit: 'character' | 'word' | 'line' | 'block') => void  deleteFragment: () => void  insertBreak: () => void  insertSoftBreak: () => void  insertFragment: (fragment: Node[]) => void  insertNode: (node: Node) => void  insertText: (text: string) => void  removeMark: (key: string) => void}


可以看到,它也提供了一些类Transform的能力如insertText等。不同于Transform中可以通过at参数指定位置,Editor提供的方法只能够处理文末或光标所在位置。


除叶子结点外,中间的所有元素统一叫做 Element ,我们扩展一些自定义功能,比如链接、图片等都在这一层。比如:

const paragraph = {  type: 'paragraph',  children: [...],}  const quote = {  type: 'quote',  children: [...],}  const link = {  type: 'link',  url: 'https://example.com',  children: [...],}


借鉴DOM的思路,这里的 Element 同样被定义为inline与block两种,其表现形式也与之相同。所有元素默认为block元素,独占一行,如链接这种需要文字混排的元素,则可设置其为inline。

除此之外,新定义了一种void类型,如果元素被标记为void,slate会将其内部处理为不可编辑,视其为一个统一的黑盒。


所有的叶子结点叫 Text ,顾名思义,存放结点的文本内容及格式。比如:

const text = {  text: 'A string of bold text',  bold: true,}


 3.2.3 Locations,定位元素


常用的定位方式有三种:Path、Point、Location

Path是一个简单的数组,它通过其在树下每个祖先节点中的索引来引用文档树中的节点。比如以下这个例子:

const editor = {  children: [    {      type: 'paragraph',      children: [        {          text: 'A line of text!',        },      ],    },  ],}


叶子(Text)结点的Path是 [0, 0]。

而Editor拥有一个特殊的Path:[]。


Point在Path的基础上,增加了offset,代表光标具体的位置:

interface Point {  path: Path  offset: number}


Range则是代表两个点之间的区域:

interface Range {  anchor: Point  focus: Point}


Selection是一种特殊的Range。由于编辑器中最常见的操作是处理用户的选择区,所以其被单独提了出来,同时Transform与Editor的许多方法默认指向选择区。


 3.2.4 Descendant,数据结构


在Editor的onChange方法中,可以获取到当前编辑器内的所有内容。它用一个数组表达,类型为Descendant[]。

图片


其具体每种元素的数据可参考第2节Node中描述的数据结构。在开发编辑器应用时,可以将这个对象做stringify后传到服务端进行保存。

一份具体的编辑器数据如下:

const initialValue: Descendant[] = [  {    type: 'paragraph',    children: [      { text: 'This is editable ' },      { text: 'rich', bold: true },      { text: ' text, ' },      { text: 'much', italic: true },      { text: ' better than a ' },      { text: '<textarea>', code: true },      { text: '!' },    ],  },  {    type: 'paragraph',    children: [      {        text:          "Since it's rich text, you can do things like turn a selection of text ",      },      { text: 'bold', bold: true },      {        text:          ', or add a semantically rendered block quote in the middle of the page, like this:',      },    ],  },  {    type: 'block-quote',    children: [{ text: 'A wise quote.' }],  },  {    type: 'paragraph',    align: 'center',    children: [{ text: 'Try it out for yourself!' }],  },]


后续我们将介绍如何用slatejs来构建一个现代化的富文本编辑器。


参考资料:

[1] https://developer.mozilla.org/zh-CN/docs/Web/HTML/Global_attributes/contenteditable

[2] https://developer.mozilla.org/zh-CN/docs/Web/Guide/HTML/Editable_content

[3] https://developer.mozilla.org/zh-CN/docs/Web/API/Document/execCommand

[4] https://zhuanlan.zhihu.com/p/123341288

[5] https://kang-bing-kui.gitbook.io/quill/zhi-nan-guides/whyquill

[6] https://docs.slatejs.org/

[7] https://juejin.cn/post/6844903504478208007

[8] https://immutable-js.com/



大前端 · 目录
上一篇哔哩哔哩 Android 同步优化•Jetifier下一篇从零开始的富文本编辑器(下)
继续滑动看下一个
哔哩哔哩技术
向上滑动看下一个