(内容较多,请你们耐心阅读)css
介绍slate.js 提供了 Web 富文本编辑器的底层能力,并非开箱即用的,须要本身二次开发许多内容。前端
也正是这个特色,使得它的扩展性特别好,许多想要定制开发编辑器的,都会选择基于 slate.js 进行二次开发。node
slate.js 能知足全世界用户进行定制开发、扩展功能,说明它自己底层能力强大且完善,能将编辑器的经常使用 API 高度抽象。这也正是我要使用它、解读它、学习它的部分。react
能够直接看一些 demo 和源码,从这里能够体会到,使用 slate.js 须要大量的二次开发工做。git
PS:从实现原理上,slate.js 是 L1 级编辑器(具体可参考语雀编辑器的 ppt 分享)。若是仅仅是使用者,则不用关心这个。github
slate.js 是基于 React 的渲染机制,用于其余框架须要本身二次开发。算法
npm 安装 slate slate-react ,编写以下代码,便可生成一个简单的编辑器。npm
import React, { useState, useMemo } from 'react'import { createEditor } from 'slate'import { Slate, Editable, withReact } from 'slate-react'function BasicEditor() {// Create a Slate editor object that won't change across renders.// editor 即该编辑器的对象实例const editor = useMemo(() => withReact(createEditor()) ,[])// 初始化 value ,即编辑器的内容。其数据格式相似于 vnode ,下文会详细结实。const initialValue = [ {type: 'paragraph',children: [ { text: '我是一行文字' } ] } ]const [value, setValue] = useState(initialValue)return (<div style={{ border: '1px solid #ccc', padding: '10px' }}><Slateeditor={editor}onChange={newValue => setValue(newValue)} ><Editable/></Slate></div>) }复制代码
但这个编辑器什么都没有,你能够输入文字,而后经过 onchange 能够获取内容。api
PS:以上代码中的 editor 变量比较重要,它是编辑器的对象实例,可使用它的 API 或者继续扩展其余插件。数组
上面的 demo ,输入几行文字,看一下 DOM 结构,会发现每一行都是 div 展现的。
但文字内容最好使用 p 标签来展现。语义化标准一些,这样也好扩展其余类型,例如 ul ol table quote image 等。
slate.js 提供了 renderElement 让咱们来自定义渲染逻辑,不过先别着急。富文本编辑器嘛,确定不只仅只有文字,还有不少数据类型,这些都是须要渲染的,因此都要依赖于这个 renderElement 。
例如,须要渲染文本和代码块。此时的 initialValue 也应该包含代码块的数据。代码以下,内有注释。
import React, { useState, useMemo, useCallback } from 'react'import { createEditor } from 'slate'import { Slate, Editable, withReact } from 'slate-react'// 第一,定义两个基础组件,分别用来渲染文本和代码块// 默认文本段落function DefaultElement(props) {return <p {...props.attributes}>{props.children}</p>}// 渲染代码块function CodeElement(props) {return <pre {...props.attributes}><code>{props.children}</code></pre>}function BasicEditor() {// Create a Slate editor object that won't change across renders.const editor = useMemo(() => withReact(createEditor()), [])// 初始化 valueconst initialValue = [ {type: 'paragraph',children: [{ text: '我是一行文字' }] },// 第二,数据中包含代码块{type: 'code',children: [{ text: 'hello world' }] } ]const [value, setValue] = useState(initialValue)// 第三,定义一个函数,用来判断如何渲染// Define a rendering function based on the element passed to `props`. We use// `useCallback` here to memoize the function for subsequent renders.const renderElement = useCallback(props => {switch(props.element.type) {case 'code':return <CodeElement {...props}/>default:return <DefaultElement {...props}/>} }, [])return (<div style={{ border: '1px solid #ccc', padding: '10px' }}><Slateeditor={editor}value={value}onChange={newValue => setValue(newValue)} ><Editable renderElement={renderElement}/> {/* 第四,使用 renderElement */}</Slate></div>) }复制代码
而后,就能够看到渲染效果。固然了,此时仍是一个比较基础的编辑器,除了能输入文字,啥功能没有。
富文本编辑器最多见的文本操做就是:加粗、斜体、下划线、删除线。slate.js 如何实现这些呢?咱们先无论怎么是操做,先看看如何去渲染。
上文的 renderElement 是渲染 Element ,可是它关不了更底层的 Text (Editor ELement Text 的关系,后面会详细结实,此处先实践起来),因此 slate.js 提供了 renderLeaf ,用来控制文本格式。
import React, { useState, useMemo, useCallback } from 'react'import { createEditor } from 'slate'import { Slate, Editable, withReact } from 'slate-react'// 第一,定义一个组件,用于渲染文本样式function Leaf(props) {return (<span{...props.attributes}style={{fontWeight: props.leaf.bold ? 'bold' : 'normal',textDecoration: props.leaf.underline ? 'underline': null/* 其余样式可继续补充…… */ }} >{props.children}</span>) }function BasicEditor() {// Create a Slate editor object that won't change across renders.const editor = useMemo(() => withReact(createEditor()), [])// 初始化 valueconst initialValue = [ {type: 'paragraph',// 第二,这里存储文本的样式children: [ { text: '我是' }, { text: '一行', bold: true }, { text: '文本', underline: true } ] } ]const [value, setValue] = useState(initialValue)const renderLeaf = useCallback(props => {return <Leaf {...props}/>}, [])return (<div style={{ border: '1px solid #ccc', padding: '10px' }}><Slateeditor={editor}value={value}onChange={newValue => setValue(newValue)} ><Editable renderLeaf={renderLeaf}/> {/* 第三,使用 reader */}</Slate></div>) }复制代码
PS:renderElement 和 renderLeaf 并不冲突,能够一块儿用,并且通常要一块儿用。这里为了演示简洁,没有用 renderElement 。
【预警】这一部分比较麻烦,涉及到 slate.js 的不少 API 。本文只能演示一两个,剩下的本身去看文档和 demo 。
上述只介绍了如何渲染,还未介绍如何设置样式。以加粗和代码块为例,自定义一个命令集合,代码以下。
这其中会设计到 Editor 和 Transforms 的一些 API ,其实光看名字,就能猜出什么意思。
import { Transforms, Text, Editor } from 'slate'// Define our own custom set of helpers.const CustomCommand = {// 当前光标的文字,是否加粗?isBoldMarkActive(editor) {const [ match ] = Editor.nodes(editor, {match: n => n.bold === true,universal: true})return !!match },// 当前光标的文字,是不是代码块?isCodeBlockActive(editor) {const [ match ] = Editor.nodes(editor, {match: n => n.type === 'code'})return !!match },// 设置/取消 加粗toggleBoldMark(editor) {const isActive = CustomCommand.isBoldMarkActive(editor) Transforms.setNodes( editor, { bold: isActive ? null : true }, {match: n => Text.isText(n),split: true} ) },// 设置/取消 代码块toggleCodeBlock(editor) {const isActive = CustomCommand.isCodeBlockActive(editor) Transforms.setNodes( editor, { type: isActive ? null : 'code' }, { match: n => Editor.isBlock(editor, n) } ) } }export default CustomCommand复制代码
而后本身写一个菜单栏,定义加粗和代码块的按钮。
return (<div> <div style={{ background: '#f1f1f1', padding: '3px 5px' }}> <button onMouseDown={event => { event.preventDefault() CustomCommand.toggleBoldMark(editor) }} >B</button> <button onMouseDown={event => { event.preventDefault() CustomCommand.toggleCodeBlock(editor) }} >code</button> </div> <Slate editor={editor} value={value} onChange={changeHandler}> <Editable renderElement={renderElement} renderLeaf={renderLeaf} /> </Slate></div>)复制代码
折腾了这么半天,就只能实现一个很是简单的 demo ,确实很急人。但它就是这样的,没办法。
比较简单,直接在 <Editable> 组件监听 DOM 事件便可。一些快捷键,能够经过这种方式来设置。
<Slate editor={editor} value={value} onChange={value => setValue(value)}> <EditableonKeyDown={event => { // `ctrl + b` 加粗的快捷键 if (!event.ctrlKey) return if (event.key === 'b') { event.preventDefault() CustomCommand.toggleBoldMark(editor) } }} /></Slate>复制代码
富文本编辑器,最基本的就是图文编辑,图片是最基本的内容。但图片确实和文本彻底不同的东西。
文本是可编辑、可选中、可输入的、很是灵活的编辑方式,而图片咱们不指望它能够像文本同样灵活,最好能按照咱们既定的方式来操做。
不只仅是图片,还有代码块(虽然上面也是看成文字处理的,但现实场景不是这样)、表格、视频等。这些咱们通常都称之为“卡片”。若是将编辑器比做海洋,那么文本就是海水,卡片就是一个一个的小岛。水是灵活的,而小岛是封闭的,不和水掺和在一块儿。
插入图片能够直接参考 demo ,能够看出,图片插入以后是不可像文本同样编辑的。从 源码 中能够看出,渲染图片时设置了 contentEditable={false}
const ImageElement = ({ attributes, children, element }) => { const selected = useSelected() const focused = useFocused() return (<div {...attributes}> <div contentEditable={false}><img src={element.url} className={css`display: block;max-width: 100%;max-height: 20em;box-shadow: ${selected && focused ? '0 0 0 3px #B4D5FF' : 'none'}; `} /> </div> {children}</div> ) }复制代码
slate.js 还专门作了一个 embeds demo ,它是拿视频做为例子来作的,比图片的 demo 更加复杂一点。
插件slate.js 提供的是编辑器的基本能力,若是不能知足使用,它提供了插件机制供用户去自行扩展。
另外,有了规范的插件机制,还能够造成本身的社区,能够直接下载使用第三方插件。
咱们研发的开源富文本编辑器 wangEditor 也会尽快扩展插件机制,扩展更多功能。
插件开发其实很简单,就是对 editor 的扩展和装饰。你想要作什么,能够充分返回本身的想象力。slate.js 提供的 demo 也都是用插件实现的,功能很强大。
const withImages = editor => { const { isVoid } = editor editor.isVoid = element => {return element.type === 'image' ? true : isVoid(element) } return editor }复制代码
单个插件使用很是方便,代码以下。但若是插件多了,想一块儿叠加使用,那就有点难看了,如 a(b(c(d(e(editor)))))
import { createEditor } from 'slate'const editor = withImages(createEditor())复制代码
能够 github 或者搜索引擎上搜一下 “slate plugins” 能够获得一些结果。例如
核心概念回顾一下上面代码中的初始化数据。
const initialValue = [ {type: 'paragraph',children: [{ text: '我是', bold: true }, { text: '一行', underline: true }, {text: '文字'}] }, {type: 'code',children: [{ text: 'hello world' }] }, { type: 'image',children: [],url: 'xxx.png'}// 其余的继续扩展]复制代码
slate.js 的数据模型模拟 DOM 的构造函数,结构很是简单,也很好理解。
整个编辑区域是 Editor 实例,下面就是一个单层的 Element 实例列表。Element 里的子元素是 Text 实例列表。Element 能够经过上述的 renderElement 自定义渲染,Text 能够经过上述的 renderLeaf 渲染样式。
PS:Element 也不是必须单层的,Element 实例下,还能够继续扩展 Element 。
Element 默认都是 block 的,Text 是 inline 的。不过,有些时候 Element 须要是 inline 的,例如文本连接。
参考 demo 源码 能看到,能够经过扩展插件,将 link 变为 inline Element。
const withLinks = editor => { const { isInline } = editor editor.isInline = element => {return element.type === 'link' ? true : isInline(element) } // 其余代码省略了……}复制代码
而后,渲染的时候直接输出 <a> 标签。
const renderElement = ({ attributes, children, element }) => { switch (element.type) {case 'link': return (<a {...attributes} href={element.url}> {children}</a> )default: return <p {...attributes}>{children}</p> } }复制代码
此处你可能会有一个疑问:渲染是 <a> 标签原本就是 inline 的,这是浏览器的默认渲染逻辑,那为什么还要重写 editor.isInline 方法呢?
答案其实很简单,slate.js 是 model view 分离的,<a> 在浏览器默认是 inline 这是 view 的,而后还要将其同步到 model ,因此要修改 editor.isInline 。
Selection 和 Range 是 L1 级 Web 富文本编辑器的核心 API 。用于找到选区范围,都包含哪些 DOM 节点,开始在哪里,结束在哪里。
slate.js 封装了原生 API ,提供了本身的 API ,供用户二次开发使用。
slate.js 在原生 API 的基础上进行了更细节的拆分,分红了 Path Point Range Selection ,文档在这里。 它们层层依赖,又简洁易懂,设计的很是的巧妙合理。
Path 是一个数组,用于在组件树中找到某个具体的节点。例以下图的树中,找到红色的节点,就能够表示为 [0, 1, 0]
Point 在 Path 的基础上,进一步肯定选区对应的 offset 偏移量,即具体选中了哪一个文本。offset 在原生 API 也有,概念是同样的。
const start = { path: [0, 0], offset: 0, }const end = { path: [0, 0], offset: 15, }复制代码
Range 即表示某一段范围,它和选区还不同,仅仅表示一段范围,无它功能意义。既然是范围,用两个 Point 表示便可,一个开始,一个结束。
interface Range { anchor: Point focus: Point }复制代码
最后,Selection 其实就是一个 Range 对象,用 Range 表示选区,代码以下。
原生 API 中 Selection 可包含多个 Range ,而 slate.js 不支持,仅是一对一的关系。
其实大部分状况下 Selection 和 Range 一对一没问题,除了特殊场景,例如 vscode 中使用 ctrl+d 多选。
const editor = { selection: {anchor: { path: [0, 0], offset: 0 },focus: { path: [0, 0], offset: 15 }, }, // 其余属性……}复制代码
slate.js 做为一个富文本编辑器的底层能力提供者,Selection 和 Range 很是重要的 API ,它也提供了详细的 API 文档 供用户参考。
commands 就是对原生 execCommand API 的重写。由于原生 API 在 MDN 中已宣布过期,并且这个 API 确实不太友好,具体能够看一篇老博客《Why ContentEditable is Terrible》。
commands 即对富文本操做的命令,例如插入文本、删除文本等。slate.js 内置了一些经常使用 command ,可参考 Editor API 。
Editor.insertText(editor, 'A new string of text to be inserted.') Editor.deleteBackward(editor, { unit: 'word' }) Editor.insertBreak(editor)复制代码
slate.js 很是推荐用户本身分装 command ,其实就是对 Editor 扩展本身的 helper API ,内部可使用强大的 Transforms API 来实现。
const MyEditor = { ...Editor, insertParagraph(editor) {// ... }, }复制代码
要理解 Operations 存在的意义,还须要配合理解 OT 算法,以实现多人协同编辑。
执行 command 会生成 operations ,而后经过 editor.apply(operation) 来生效。
operation 是不可扩展的,就这三种类型,并且是原子的。有了 operation 可方便支持撤销、多人协同编辑。
editor.apply({ type: 'insert_text', path: [0, 0], offset: 15, text: 'A new string of text to be inserted.', }) editor.apply({ type: 'remove_node', path: [0, 0], node: {text: 'A line of text!', }, }) editor.apply({ type: 'set_selection', properties: {anchor: { path: [0, 0], offset: 0 }, }, newProperties: {anchor: { path: [0, 0], offset: 15 }, }, })复制代码
operation 的 type 和 Quill 编辑器 Delt 基本一致,都符合 OT 算法的基本类型。但这里的 operation 更适合富文本树结构。
PS:若是你不考虑多人协同编辑,那这部分不用关心,都是 slate.js 内部封装的。
富文本编辑器,内容是复杂的、嵌套的、不可枚举的。因此,须要有一些规则来保证数据格式的规范,这就是数据校验。
可能会引起数据格式混乱的状况有不少,常见的有
slate.js 内置了一些校验规则,来确保最基本的数据格式
slate.js 也容许用户自定义校验规则,例如写一个插件,来校验:paragraph 的自元素只能是 Text 或者 inline Element 。
import { Transforms, Element, Node } from 'slate'const withParagraphs = editor => { const { normalizeNode } = editor editor.normalizeNode = entry => {const [node, path] = entry// If the element is a paragraph, ensure its children are valid.if (Element.isElement(node) && node.type === 'paragraph') { for (const [child, childPath] of Node.children(editor, path)) {if (Element.isElement(child) && !editor.isInline(child)) { Transforms.unwrapNodes(editor, { at: childPath }) return} } }// Fall back to the original `normalizeNode` to enforce other constraints.normalizeNode(entry) } return editor }复制代码总结
到此应该能体会到,slate.js 是一个功能强大的富文本编辑器框架,须要大量的二次开发。
关于 Web 富文本编辑器和 slate.js 的内容还有不少。包括本文提到未深刻的,如历史记录、协同编辑;也包括未提到的,如内部实现过程、不可变数据等。有广度和有深度。之后我还会写文章分享。
曾经有人戏称 “Web 富文本编辑器是前端技术的天花板” ,有些绝对,但也是有必定的道理。