|
本期作者胡炜轩资深开发工程师01 什么是contenteditable?1.1 属性介绍contenteditable 是一个枚举属性,表示元素是否可被用户编辑。如果可以,浏览器会修改元素的部件以允许编辑。该属性必须是下面的值之一:true 或空字符串,表示元素是可编辑的。false 表示元素不是可编辑的。如果没有设置该属性的值(例如:Example Label),则其值被视为空字符串。如果没给出该属性或设置了无效的属性值,则其默认值继承自父元素:即,如果父元素可编辑,该子元素也可编辑。注意,虽然该属性允许设定的值包括 true 和 false,但该属性仍是一个枚举属性而非布尔属性。通过这个属性,我们能够使任意元素变成一个textarea,比如: ?This text can be edited by the user.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 | 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、LocationPath是一个简单的数组,它通过其在树下每个祖先节点中的索引来引用文档树中的节点。比如以下这个例子: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: '', 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/
|
|