富文本编辑器框架 – slate.js

我心飞翔 分类:javascript

preview.png

图片来源:github.com/ianstormtay…

Slate 简介

Slate 是一个使用 TypeScript 开发的富文本编辑器开发框架,诞生于 2016 年,作者是 Ian Storm Taylor。Slate 是一个完全可定制的富文本编辑器框架。Slate 让你构建像 Medium, Dropbox Paper 或者是 Google Docs 这样丰富,直观的编辑器。你可以认为它是基于 React 的一种可拔插的 contenteditable 实现。它的灵感来源于 Draft.js,Prosemirror 和 Quill 这样的库。slate 比较知名的用户有 GitBook语雀等。

Slate 在线 Demo

特点

  • 插件作为一等公民,能够完全修改编辑器行为;
  • 数据层和渲染层分离,更新数据触发渲染;
  • 文档数据类似于 DOM 树,可嵌套;
  • 具有原子化操作 API,支持协同编辑;
  • 使用 React 作为渲染层;

slate 架构简介

架构图

未命名绘图 (14).png

在 slate 代码仓库下包含四个 package 包:

  1. Slate History: 历史插件,提供了undo/redo支持;
  2. slate-hyperscript: 能够使用 JSX 语法来创建 slate 的数据;
  3. slate-react: 视图层;
  4. slate: 编辑器核心抽象,定义了 Editor,Path,Node,Text,Operation 等基础类,Transforms 操作;

WeChat86c41084f9e8169093c53fb1d3201919.png

slate (model)

slate package 是 slate 的核心,定义了编辑器的数据模型、操作这些模型的基本操作、以及创建编辑器实例对象的方法。

未命名绘图 (15).png

Interfaces

intefaces 目录下是 Slate 定义的数据模型,定义了 editor 、element、text、path、point、range、operation、location 等。

export type Node = Editor | Element | Text

export interface Element {
  children: Node[]
  [key: string]: unknown
}

export interface Text {
  text: string
  [key: string]: unknown
}
 
  • Editor Slate 的顶级节点就是 Editor 。它封装了文档的所有富文本内容,但是对于节点来说最重要的部分是它的 children 属性,其中包含一个 Node 对象树。
  • Element 类型含有 children 属性。
  • Text 文本节点是树中的最低级节点,包含文档的文本内容以及任何格式。

用户可以自行拓展 Node 的属性,例如通过添加 type 字段标识 Node 的类型(paragraph, ordered list, heading 等等),或者是文本的属性(italic, bold 等等),来描述富文本中的文字和段落。

截屏2021-06-02 上午8.11.47.png

路径(Path)

路径是引用一个位置的最底层方式。每个路径都是一个简单的数字数组,它通过文档树中每个祖先节点的索引来引用一个节点:

type Path = number[]
 

截屏2021-06-03 上午7.27.29.png

点(Point)

Point 包含一个 offset 属性(偏移量)对于特定的文本节点:

interface Point {
  path: Path
  offset: number
  [key: string]: unknown
}
 

文档范围(Range)

文档范围不仅指文档中的一个点,它是指两点之间的内容。

interface Range {
  anchor: Point
  focus: Point
  [key: string]: unknown
}
 

锚点焦点是通过用户交互建立的。锚点并不一定在焦点的 前面。就像在 DOM 一样,锚点和焦点的排序取决于选区的方向(向前或向后)。

Operation

Operation 对象是 Slate 用来更改内部状态的低级指令,作为文档的最小抽象, Slate 将所有变化表示为 Operation。你可以从 这里 看到源码。

export interface OperationInterface {
isNodeOperation: (value: any) => value is NodeOperation
isOperation: (value: any) => value is Operation
isOperationList: (value: any) => value is Operation[]
isSelectionOperation: (value: any) => value is SelectionOperation
isTextOperation: (value: any) => value is TextOperation
inverse: (op: Operation) => Operation
}
export const Operation: OperationInterface = {
.....
isOperation(value: any): value is Operation {
if (!isPlainObject(value)) {
return false
}
switch (value.type) {
case 'insert_node':
return Path.isPath(value.path) && Node.isNode(value.node)
case 'insert_text':
return (
typeof value.offset === 'number' &&
typeof value.text === 'string' &&
Path.isPath(value.path)
)
case 'merge_node':
return (
typeof value.position === 'number' &&
Path.isPath(value.path) &&
isPlainObject(value.properties)
)
case 'move_node':
return Path.isPath(value.path) && Path.isPath(value.newPath)
case 'remove_node':
return Path.isPath(value.path) && Node.isNode(value.node)
case 'remove_text':
return (
typeof value.offset === 'number' &&
typeof value.text === 'string' &&
Path.isPath(value.path)
)
case 'set_node':
return (
Path.isPath(value.path) &&
isPlainObject(value.properties) &&
isPlainObject(value.newProperties)
)
case 'set_selection':
return (
(value.properties === null && Range.isRange(value.newProperties)) ||
(value.newProperties === null && Range.isRange(value.properties)) ||
(isPlainObject(value.properties) &&
isPlainObject(value.newProperties))
)
case 'split_node':
return (
Path.isPath(value.path) &&
typeof value.position === 'number' &&
isPlainObject(value.properties)
)
default:
return false
}
},
......
}

从上面的代码中可以看出,Operation 类型有 9 个:

  • insert_node:插入一个 Node。 包含 插入位置(path),插入节点(node)信息
 case 'insert_node':
return Path.isPath(value.path) && Node.isNode(value.node)
  • insert_text:插入一段文本,所在 节点(path),插入内容(text),偏移量(offset)
case 'insert_text':
return (
typeof value.offset === 'number' &&
typeof value.text === 'string' &&
Path.isPath(value.path)
)
  • merge_node:将两个 Node 组合成一个,包含待合并的 节点(path),合并目的地位置(position),合并后节点属性(properties)信息。
case 'merge_node':
return (
typeof value.position === 'number' &&
Path.isPath(value.path) &&
isPlainObject(value.properties)
)
  • move_node:移动 Node,包含 移动位置(path),移动目的地(newPath)信息
case 'move_node':
return Path.isPath(value.path) && Path.isPath(value.newPath)
  • remove_node:移除 Node,包含 删除位置(path),删除节点(node)信息
 case 'remove_node':
return Path.isPath(value.path) && Node.isNode(value.node)
  • remove_text:移除文本,包含 所在节点(path),删除内容(text),偏移量(offset)信息
case 'remove_text':
return (
typeof value.offset === 'number' &&
typeof value.text === 'string' &&
Path.isPath(value.path)
)
  • set_node:设置 Node 属性,包含 所在节点(path),被设置节点(node),节点属性(properties)信息
case 'set_node':
return (
Path.isPath(value.path) &&
isPlainObject(value.properties) &&
isPlainObject(value.newProperties)
)
  • set_selection:设置选区位置,包含 新旧节点属性(properties,newProperties)信息
case 'set_selection':
return (
(value.properties === null && Range.isRange(value.newProperties)) ||
(value.newProperties === null && Range.isRange(value.properties)) ||
(isPlainObject(value.properties) &&
isPlainObject(value.newProperties))
)
  • split_node:拆分 Node ,包含 所在节点(path),节点位置(position),节点属性(properties)信息
case 'split_node':
return (
Path.isPath(value.path) &&
typeof value.position === 'number' &&
isPlainObject(value.properties)
)

Transforms

Transforms 是对文档进行操作的辅助函数,包括选区转换节点转换文本转换通用转换。你可以从 这里 看到源码。

export const Transforms: GeneralTransforms &
NodeTransforms &
SelectionTransforms &
TextTransforms = {
...GeneralTransforms,// 操作 Operation 指令
...NodeTransforms,// 操作节点指令
...SelectionTransforms,// 操作选区指令
...TextTransforms,// 操作文本指令
}

GeneralTransforms 比较特殊,它并不生成 Operation ,而是对 Operation 进行处理,只有它能直接修改 model,其他 transforms 最终都会转换成 GeneralTransforms 中的一种。

createEditor

创建编辑器实例的方法,返回一个实现了 Editor 接口的编辑器实例对象。你可以从 这里 看到源码。

export const createEditor = (): Editor => {
const editor: Editor = {
.....
}
return editor
}

更新 model

对 model 进行变更的过程主要分为以下两步:

  1. 通过 Transforms 提供的一系列方法生成 Operation
  2. Operation 进入 apply 流程

在 Operation apply 流程中有4 个主要步骤:

  1. 记录变更脏区
  2. 对 Operation 进行 transform
  3. 对 model 正确性进行校验
  4. 触发变更回调

Transforms.insertText 为例,你可以从 这里 看到源码。

export const TextTransforms: TextTransforms = {
.....
insertText(
editor: Editor,
text: string,
options: {
at?: Location
voids?: boolean
} = {}
): void {
Editor.withoutNormalizing(editor, () => {
const { voids = false } = options
let { at = editor.selection } = options
.....
const { path, offset } = at
if (text.length > 0)
editor.apply({ type: 'insert_text', path, offset, text })
})
}
}

Transforms.insertText 的最后生成了一个 type 为 insert_text 的 Operation 并调用 Editor 实例的 apply 方法。

editor.apply 方法

你可以从 这里 看到源码。

export const createEditor = (): Editor => {
const editor: Editor ={
children: [],
operations: [],
selection: null,
marks: null,
isInline: () => false,
isVoid: () => false,
onChange: () => {},
apply: (op: Operation) => {
for (const ref of Editor.pathRefs(editor)) {
PathRef.transform(ref, op)
}
for (const ref of Editor.pointRefs(editor)) {
PointRef.transform(ref, op)
}
for (const ref of Editor.rangeRefs(editor)) {
RangeRef.transform(ref, op)
}
const set = new Set()
const dirtyPaths: Path[] = []
const add = (path: Path | null) => {
if (path) {
const key = path.join(',')
if (!set.has(key)) {
set.add(key)
dirtyPaths.push(path)
}
}
}
const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const newDirtyPaths = getDirtyPaths(op)
for (const path of oldDirtyPaths) {
const newPath = Path.transform(path, op)
add(newPath)
}
for (const path of newDirtyPaths) {
add(path)
}
DIRTY_PATHS.set(editor, dirtyPaths)
Transforms.transform(editor, op)
editor.operations.push(op)
Editor.normalize(editor)
// Clear any formats applied to the cursor if the selection changes.
if (op.type === 'set_selection') {
editor.marks = null
}
if (!FLUSHING.get(editor)) {
FLUSHING.set(editor, true)
Promise.resolve().then(() => {
FLUSHING.set(editor, false)
editor.onChange()
editor.operations = []
})
}
},
......
}
return editor
}
转换坐标
for (const ref of Editor.pathRefs(editor)) {
PathRef.transform(ref, op)
}
for (const ref of Editor.pointRefs(editor)) {
PointRef.transform(ref, op)
}
for (const ref of Editor.rangeRefs(editor)) {
RangeRef.transform(ref, op)
}
dirtyPaths

dirtyPaths 一共有以下两种生成机制:

  1. 一种是在 operation apply 之前的 oldDirtypath
  2. 一种由 getDirthPaths 方法获取
const set = new Set()
const dirtyPaths: Path[] = []
const add = (path: Path | null) => {
if (path) {
const key = path.join(',')
if (!set.has(key)) {
set.add(key)
dirtyPaths.push(path)
}
}
}
const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const newDirtyPaths = getDirtyPaths(op)
for (const path of oldDirtyPaths) {
const newPath = Path.transform(path, op)
add(newPath)
}
for (const path of newDirtyPaths) {
add(path)
}
执行变更操作
Transforms.transform(editor, op)

Transforms.transform(editor, op) 就是在调用 GeneralTransforms 处理 Operation。

const applyToDraft = (editor: Editor, selection: Selection, op: Operation) => {
switch (op.type) {
...
case 'insert_text': {
const { path, offset, text } = op
if (text.length === 0) break
const node = Node.leaf(editor, path)
const before = node.text.slice(0, offset)
const after = node.text.slice(offset)
node.text = before + text + after
if (selection) {
for (const [point, key] of Range.points(selection)) {
selection[key] = Point.transform(point, op)!
}
}
break
}
....
}
}
记录 operation
editor.operations.push(op)
数据校验
Editor.normalize(editor)
触发变更回调
if (!FLUSHING.get(editor)) {
// 表示需要清空 operations
FLUSHING.set(editor, true)
Promise.resolve().then(() => {
// 清空完毕
FLUSHING.set(editor, false)
// 通知变更回调函数
editor.onChange()
// 清空 operations
editor.operations = []
})
}

model 数据校验

对 model 进行变更之后还需要对 model 进行数据校验,避免内容出错。数据校验的机制有两个重点,一是对 dirtyPaths 的管理,一个是 withoutNormalizing 机制。

withoutNormalizing

你可以从 这里 看到源码。

export const Editor: EditorInterface = {
.....
withoutNormalizing(editor: Editor, fn: () => void): void {
const value = Editor.isNormalizing(editor)
NORMALIZING.set(editor, false)
try {
fn()
} finally {
NORMALIZING.set(editor, value)
}
Editor.normalize(editor)
}
}
export const NORMALIZING: WeakMap<Editor, boolean> = new WeakMap()

可以看到这段代码通过 WeakMap 保存了是否需要数据校验的状态。

dirtyPaths

dirtyPaths 通过 editor.apply 方法形成

const set = new Set()
const dirtyPaths: Path[] = []
const add = (path: Path | null) => {
if (path) {
const key = path.join(',')
if (!set.has(key)) {
set.add(key)
dirtyPaths.push(path)
}
}
}
const oldDirtyPaths = DIRTY_PATHS.get(editor) || []
const newDirtyPaths = getDirtyPaths(op)
for (const path of oldDirtyPaths) {
const newPath = Path.transform(path, op)
add(newPath)
}
for (const path of newDirtyPaths) {
add(path)
}

Editor.normalize(editor) 的 normalize 方法,它创建一个循环,从 model 树的叶节点自底向上地不断获取脏路径并调用 nomalizeNode 检验路径所对应的节点是否合法。

export const Editor: EditorInterface = {
.....
normalize(
editor: Editor,
options: {
force?: boolean
} = {}
): void {
....
Editor.withoutNormalizing(editor, () => {
.....
const max = getDirtyPaths(editor).length * 42 // HACK: better way?
let m = 0
while (getDirtyPaths(editor).length !== 0) {
if (m > max) {
throw new Error(`
Could not completely normalize the editor after ${max} iterations! This is usually due to incorrect normalization logic that leaves a node in an invalid state.
`)
}
const dirtyPath = getDirtyPaths(editor).pop()!
// If the node doesn't exist in the tree, it does not need to be normalized.
if (Node.has(editor, dirtyPath)) {
const entry = Editor.node(editor, dirtyPath)
editor.normalizeNode(entry)
}
m++
}
})
},
.....
}

简图

未命名绘图 (18).png

Slate.js 插件体系

Slate 的插件只是一个返回 editor 实例的函数,在这个函数中通过重写编辑器实例方法,修改编辑器行为。 在创建编辑器实例的时候调用插件函数即可。

const withImages = editor => {
const { isVoid } = editor
editor.isVoid = element => {
return element.type === 'image' ? true : isVoid(element)
}
return editor
}

然后可以这样使用它:

import { createEditor } from 'slate'
const editor = withImages(createEditor())

slate-history

slate-history 踪随着时间推移对 Slate 值状态的更改,并启用撤消和重做功能。

截屏2021-06-05 下午6.12.00.png

withHistory

你可以从 这里 看到源码。

export const withHistory = <T extends Editor>(editor: T) => {
const e = editor as T & HistoryEditor
const { apply } = e
e.history = { undos: [], redos: [] }
e.redo = () => {
const { history } = e
const { redos } = history
if (redos.length > 0) {
const batch = redos[redos.length - 1]
HistoryEditor.withoutSaving(e, () => {
Editor.withoutNormalizing(e, () => {
for (const op of batch) {
e.apply(op)
}
})
})
history.redos.pop()
history.undos.push(batch)
}
}
e.undo = () => {
const { history } = e
const { undos } = history
if (undos.length > 0) {
const batch = undos[undos.length - 1]
HistoryEditor.withoutSaving(e, () => {
Editor.withoutNormalizing(e, () => {
const inverseOps = batch.map(Operation.inverse).reverse()
for (const op of inverseOps) {
e.apply(op)
}
})
})
history.redos.push(batch)
history.undos.pop()
}
}
e.apply = (op: Operation) => {
const { operations, history } = e
const { undos } = history
const lastBatch = undos[undos.length - 1]
const lastOp = lastBatch && lastBatch[lastBatch.length - 1]
const overwrite = shouldOverwrite(op, lastOp)
let save = HistoryEditor.isSaving(e)
let merge = HistoryEditor.isMerging(e)
if (save == null) {
save = shouldSave(op, lastOp)
}
if (save) {
if (merge == null) {
if (lastBatch == null) {
merge = false
} else if (operations.length !== 0) {
merge = true
} else {
merge = shouldMerge(op, lastOp) || overwrite
}
}
if (lastBatch && merge) {
if (overwrite) {
lastBatch.pop()
}
lastBatch.push(op)
} else {
const batch = [op]
undos.push(batch)
}
while (undos.length > 100) {
undos.shift()
}
if (shouldClear(op)) {
history.redos = []
}
}
apply(op)
}
return e
}
 

withHistory 方法中,slate-history 在 editor 上创建了两个数组用来存储历史操作:

e.history = { undos: [], redos: [] }

它们的类型都是 Operation[][],即 Operation 的二维数组,其中的每一项代表了一批操作(在代码上称作 batch), batch 可含有多个 Operation。

slate-history 通过覆写 apply 方法来在 Operation 的 apply 流程之前插入 undo/redo 的相关逻辑,最后调用原来的 apply 方法。

 e.apply = (op: Operation) => {
const { operations, history } = e
const { undos } = history
const lastBatch = undos[undos.length - 1]
const lastOp = lastBatch && lastBatch[lastBatch.length - 1]
const overwrite = shouldOverwrite(op, lastOp)
let save = HistoryEditor.isSaving(e)
let merge = HistoryEditor.isMerging(e)
if (save == null) {
save = shouldSave(op, lastOp)
}
if (save) {
if (merge == null) {
if (lastBatch == null) {
merge = false
} else if (operations.length !== 0) {
merge = true
} else {
merge = shouldMerge(op, lastOp) || overwrite
}
}
if (lastBatch && merge) {
if (overwrite) {
lastBatch.pop()
}
lastBatch.push(op)
} else {
const batch = [op]
undos.push(batch)
}
while (undos.length > 100) {
undos.shift()
}
if (shouldClear(op)) {
history.redos = []
}
}
apply(op)
}

undo 方法

e.undo = () => {
const { history } = e
const { undos } = history
if (undos.length > 0) {
const batch = undos[undos.length - 1]
HistoryEditor.withoutSaving(e, () => {
Editor.withoutNormalizing(e, () => {
const inverseOps = batch.map(Operation.inverse).reverse()
for (const op of inverseOps) {
// If the final operation is deselecting the editor, skip it. This is
if (
op === inverseOps[inverseOps.length - 1] &&
op.type === 'set_selection' &&
op.newProperties == null
) {
continue
} else {
e.apply(op)
}
}
})
})
history.redos.push(batch)
history.undos.pop()
}
}

redo 方法

e.redo = () => {
const { history } = e
const { redos } = history
if (redos.length > 0) {
const batch = redos[redos.length - 1]
HistoryEditor.withoutSaving(e, () => {
Editor.withoutNormalizing(e, () => {
for (const op of batch) {
e.apply(op)
}
})
})
history.redos.pop()
history.undos.push(batch)
}
}

slate-react

slate-react 编辑器的 React 组件,渲染文档数据。

渲染原理

Slate 的文档数据是一颗类似 DOM 的节点树结构,slate-react 通过递归这颗树生成 children 数组 , 最终 react 将 children 数组中的组件渲染到页面上。

  • 设置编辑器实例的 children 属性
// https://github.com/ianstormtaylor/slate/blob/main/packages/slate-react/src/components/slate.tsx#L17
export const Slate = (props: {
editor: ReactEditor
value: Descendant[]
children: React.ReactNode
onChange: (value: Descendant[]) => void
}) => {
....
const context: [ReactEditor] = useMemo(() => {
// 设置 editor 实例的 children 属性为 value
editor.children = value
.....
}, [key, value, ...Object.values(rest)])
.....
}
  • Editable 组件传递 editor 实例给 useChildren Hooks 组件。
// https://github.com/ianstormtaylor/slate/blob/main/packages/slate-react/src/components/editable.tsx#L100
export const Editable = (props: EditableProps) => {
const editor = useSlate()
....
return (
<ReadOnlyContext.Provider value={readOnly}>
<DecorateContext.Provider value={decorate}>
<Component
....
>
{useChildren({
decorations,
node: editor,
renderElement,
renderPlaceholder,
renderLeaf,
selection: editor.selection,
})}
</Component>
</DecorateContext.Provider>
</ReadOnlyContext.Provider>
)
}
  • useChildren 生成渲染数组,交给 React 渲染组件。

useChildren 组件会根据 children 中各个 Node 的类型,生成对应的 ElementComponent 或者 TextComponent

const useChildren = (props: {
decorations: Range[]
node: Ancestor
renderElement?: (props: RenderElementProps) => JSX.Element
renderPlaceholder: (props: RenderPlaceholderProps) => JSX.Element
renderLeaf?: (props: RenderLeafProps) => JSX.Element
selection: Range | null
}) => {
const decorate = useDecorate()
const editor = useSlateStatic()
const path = ReactEditor.findPath(editor, node)
const children = []
const isLeafBlock =
Element.isElement(node) &&
!editor.isInline(node) &&
Editor.hasInlines(editor, node)
for (let i = 0; i < node.children.length; i++) {
const p = path.concat(i)
const n = node.children[i] as Descendant
const key = ReactEditor.findKey(editor, n)
const range = Editor.range(editor, p)
const sel = selection && Range.intersection(range, selection)
const ds = decorate([n, p])
for (const dec of decorations) {
const d = Range.intersection(dec, range)
if (d) {
ds.push(d)
}
}
if (Element.isElement(n)) {
children.push(
<ElementComponent
decorations={ds}
element={n}
key={key.id}
renderElement={renderElement}
renderPlaceholder={renderPlaceholder}
renderLeaf={renderLeaf}
selection={sel}
/>
)
} else {
children.push(
<TextComponent
decorations={ds}
key={key.id}
isLast={isLeafBlock && i === node.children.length - 1}
parent={node}
renderPlaceholder={renderPlaceholder}
renderLeaf={renderLeaf}
text={n}
/>
)
}
NODE_TO_INDEX.set(n, i)
NODE_TO_PARENT.set(n, node)
}
return children
}

未命名绘图 (19).png

官网例子

截屏2021-06-05 下午7.58.40.png

自定义渲染

传递渲染函数 renderElementrenderLeaf 给 Editable 组件,用户可以通过提供这两个参数来自行决定如何渲染 model 中的一个 Node。我们以官网 richtext demo 为例。

const RichTextExample = () => {
...
const renderElement = useCallback(props => <Element {...props} />, [])
const renderLeaf = useCallback(props => <Leaf {...props} />, [])
return (
<Slate editor={editor} value={value} onChange={value => setValue(value)}>
....
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
.....
/>
</Slate>
)
}
 const Element = ({ attributes, children, element }) => {
switch (element.type) {
case 'block-quote':
return <blockquote {...attributes}>{children}</blockquote>
case 'bulleted-list':
return <ul {...attributes}>{children}</ul>
case 'heading-one':
return <h1 {...attributes}>{children}</h1>
case 'heading-two':
return <h2 {...attributes}>{children}</h2>
case 'list-item':
return <li {...attributes}>{children}</li>
case 'numbered-list':
return <ol {...attributes}>{children}</ol>
default:
return <p {...attributes}>{children}</p>
}
}

这个 demo 就拓展了 Element 节点的 type 属性,让 Element 能够渲染为不同的标签。

slate-hyperscript

slate-hyperscript 使用 JSX 编写 Slate 文档的 hyperscript 工具。

总结

  1. Slate 目前处于测试状态,它的一些 APIs 还没有 "最终确定";
  2. 使用了 contenteditable 导致无法处理部分选区和输入事件;

参考资料

Slate 中文文档

slate 架构设计分析

回复

我来回复
  • 暂无回复内容