本期作者
胡炜轩
资深开发工程师
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都是这么做的。
如果放在目前这个时代还这么做,会遇到很多问题:
内容审核日趋严格,在审核工具的构建上,需要很方便的提取出文本内容。
推荐算法日益发达,除文字内容外,需要很方便的提取出文章内的其他元素,如图片等。
迭代速度越来越快,如果遇到大型的版式修改,如何去兼容以往的html内容是一个大问题,而且问题往往无法收敛(因为是用户提交)。
文章展现的地方不再是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/