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 架构简介
架构图
在 slate 代码仓库下包含四个 package
包:
- Slate History: 历史插件,提供了undo/redo支持;
- slate-hyperscript: 能够使用 JSX 语法来创建 slate 的数据;
- slate-react: 视图层;
- slate: 编辑器核心抽象,定义了 Editor,Path,Node,Text,Operation 等基础类,Transforms 操作;
slate (model)
slate package
是 slate 的核心,定义了编辑器的数据模型
、操作这些模型的基本操作、以及创建编辑器实例对象的方法。
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 等等),来描述富文本中的文字和段落。
路径(Path)
路径是引用一个位置的最底层方式。每个路径都是一个简单的数字数组,它通过文档树中每个祖先节点的索引来引用一个节点:
type Path = number[]
点(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 进行变更的过程主要分为以下两步:
- 通过 Transforms 提供的一系列方法生成 Operation
- Operation 进入 apply 流程
在 Operation apply 流程中有4 个主要步骤:
- 记录变更脏区
- 对 Operation 进行 transform
- 对 model 正确性进行校验
- 触发变更回调
以 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 一共有以下两种生成机制:
- 一种是在 operation apply 之前的
oldDirtypath
- 一种由
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++
}
})
},
.....
}
简图
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 值状态的更改,并启用撤消和重做功能。
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
}
官网例子
自定义渲染
传递渲染函数 renderElement
和 renderLeaf
给 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 工具。
总结
- Slate 目前处于测试状态,它的一些 APIs 还没有 "最终确定";
- 使用了 contenteditable 导致无法处理部分选区和输入事件;