【长文】Web 富文本编辑器框架 slate.js - 从基本使用到核心概念

(内容较多,请你们耐心阅读)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 或者继续扩展其余插件。数组

renderElement

上面的 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>)
}复制代码

而后,就能够看到渲染效果。固然了,此时仍是一个比较基础的编辑器,除了能输入文字,啥功能没有。

renderLeaf

富文本编辑器最多见的文本操做就是:加粗、斜体、下划线、删除线。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 。

block 和 inline

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

SelectionRange 是 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 和 operations

  • commands high-level 可扩展,内部使用 Transforms API 实现
  • operations low-level 原子 不可扩展
  • 一个 command 可包含多个 operation

commands

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

要理解 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 内部封装的。

Normalizing 数据校验

富文本编辑器,内容是复杂的、嵌套的、不可枚举的。因此,须要有一些规则来保证数据格式的规范,这就是数据校验。

可能会引起数据格式混乱的状况有不少,常见的有

  • 复杂的、重复、连续的的文本格式操做,例如加粗、斜体、设置颜色、设置连接、换行等……会让数据格式变的很是复杂
  • 粘贴。从各类网页拷贝、从 word 拷贝、从 excel 拷贝、从微信 qq 拷贝…… 即,粘贴的数据来源没法肯定,因此粘贴过来的数据也就没法保证格式统一,混乱是很正常的。

slate.js 内置了一些校验规则,来确保最基本的数据格式

  • 每一个 Element 必须包含至少一个子孙 Text 节点。即,若是一个 Element 是空的,则默认给一个空 Text 子节点。
  • 两个连续的 Text 若是有相同属性,则合并为一个 Text 。
  • block 节点只能包含另外一个 block 节点,或者 inline 节点和 Text 。即一个 block 节点不能同时包含一个 block 节点外加一个 inline 节点。
  • Inline nodes cannot be the first or last child of a parent block, nor can it be next to another inline node in the children array. If this is the case, an empty text node will be added to correct this to be in complience with the constraint.(笔者:这一个没看明白,就直接贴过来,就不翻译了)
  • 最顶级的 Element 只能是 block 类型

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 富文本编辑器是前端技术的天花板” ,有些绝对,但也是有必定的道理。

相关文章
相关标签/搜索