富文本总结(二):Slate概念
架构
Slate 并非一个做到开箱即用的富文本应用,而是提供了基本富文本能力的框架。而其他功能都需要开发者自行通过插件的形式进行实现。
特性
-
Immutable:基于Immutable,利用
Immer
库,每次只会生成修改的数据,其他相同的部分和原有数据共享。 -
非常轻量:不同于其他编辑器类的库,Slate并不提供譬如粗体、斜体、字体色等开箱即用的功能。Slate只是提供了一套自己定义的核心数据模型,以此一些操作数据和选区相关的API
-
视图无关:视图层的渲染和行为完全由开发者基于React定制。
-
协同编辑:从顶层设计上看,Slate的架构是典型的MVC模型,由自身定义数据模型(Model),暴露操作数据的方法(Controller),然后交由用户使用该数据在React中做渲染(View)。Slate.js 的模型设计天然就亲和协同编辑。
源码
slate项目使用monorepo的架构,共有三个包:
-
slate
slate内核,定义编辑器的数据结构(Model),提供操作数据的API(Controller)。
-
slate React
基于React实现的slate视图层(View)。
-
slate History
一个slate的插件,实现slate的undo、redo功能
数据结构
Slate.js 的数据结构设计大量参考了 HTML 中对于 DOM 的设计,
Web 富文本,其实就是一段 HTML 内容,它由两个部分组成:
- 节点(Node):节点容纳了我们能看到的富文本内容,富文本容器也是一个节点,容纳了其他节点。
- 选区(Selection):当前选中的区域,如果区域的起点和终点重合,那就是一个光标。
Editor
Editor
存储了Slate编辑器的所有状态,并可以通过插件来进行扩展。
interface Editor {
children: Node[] //节点的结构
selection: Range | null/ // 选区
operations: Operation[] //即将执行的操作
marks: Omit<Text, 'text'> | null // text的标记
// 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 //插入换行符
insertFragment: (fragment: Node[]) => void //在当前选定内容除插入一个片段
insertNode: (node: Node) => void //插入节点
insertText: (text: string) => void //插入文本
removeMark: (key: string) => void //删除标记
}
节点
一个完整的slate节点树:
const editor = {
children: [
{
type: 'paragraph',
children: [
{
text: 'A line of text!',
},
],
},
],
}
slate的节点共有三种类型:
Editor
:文档树的根节点。Element
:children属性,可以作为其他Node的父节点Text
:是树的叶子节点,包含文本信息。
用户可以自行扩展Element
和text
类型的属性来扩展节点,例如增加type
属性表示Node的类型(link
,paragraph
),增加style
属性表示文本的样式(bold
,color
)等
type Node = Editor | Element | Text;
interface Editor{
children:Node[]
}
interface Element{
children:Node[]
[key:string]:unknown
}
interface Text{
text:string,
[key:string]:unknown
}
Node
Node是slate中最基础的抽象。
Node对象基本属性属性:
-
key:节点在当前文档中的索引
-
data:节点绑定的数据
-
nodes:节点的子孙
-
object:节点类型
-
text:这是一个计算属性,返回节点的文本内容
Element
节点类型:
- Document Element:表示编辑器的整个文档树
- Block Element: 表示编辑器中的块级元素
- Inline Element:表示编辑器中的行内元素
节点的基本属性:
-
key:节点在当前文档中的索引
-
data:节点绑定的数据
-
nodes:节点的子孙
-
object:节点类型
-
text:一个计算属性,返回节点的文本内容
Text
Text Model 的基本属性:
- key:节点在当前文档中的索引
- object:节点类型
- text:一个计算属性,返回节点的文本内容
- leaves: 文本叶子节点,不同格式(例如加粗,斜体等)的文本,将会被分拆为若干个 leaf
- marks:文本节点所包含的所有 mark(标记)
Slate.js 是通过 mark 来标记文本格式,在视图层,开发者可以通过 CSS 或者 这样的 tag 来展示格式化文本。
Slate.js 根据 mark 类型的不同,将 Text Node 拆分为了若干 Leaf。每个 Leaf 对象含有这些属性:
-
text: string
:leaf 的文本内容。 -
mark: Mark
:leaf 被标记上的 mark。
关于判断Text、Element:
text属性优先级更高,当同时存在text、children属性时会被判定为Text节点。
{ type: 'button', text: '123', children: [], },
定位
path
节点路径,相对于根节点的相对位置路径。
/**
* `Path` arrays are a list of indexes that describe a node's exact position in
* a Slate node tree. Although they are usually relative to the root `Editor`
* object, they can be relative to any `Node` object.
*/
export type Path = number[]
例子
const editor = {
children: [
// Path: [0]
{
type: 'paragraph',
children: [
// Path: [0, 0]
{
text: 'A line of text!',
},
// Path: [0, 1]
{
text: 'Another line of text!',
bold: true,
},
],
},
// Path: [1]
{
type: 'paragraph',
children: [
// Path: [1, 0]
{
text: 'A line of text!',
},
],
},
],
}
point
定位单一字符的位置。先用path
表示字符节点,再用offset
表示字符在节点的位置。
/**
* `Point` objects refer to a specific location in a text node in a Slate
* document. Its path refers to the location of the node in the tree, and its
* offset refers to the distance into the node's string of text. Points can
* only refer to `Text` nodes.
*/
export interface BasePoint {
path: Path
offset: number
}
例子
const editor = {
children: [
{
type: 'paragraph',
children: [
{
// "!" is { path: [0, 0], offset: 14 }
text: 'A line of text!',
},
],
},
{
type: 'paragraph',
children: [
{
// The point of the character "l" is { path: [1, 0], offset: 2 }
text: 'A line of text!',
},
],
},
],
}
同时提供一些静态方法:
- 比较方法
- 检查方法
选区(selection)
Slate.js 中的 Selection 遵循了现代浏览器的设计,一个 Selection 对象不再允许含有多个 Range 对象,它含有属性:
anchor
:选区起点focus
: 选区终点isFocused
: 当前选区是否被聚焦marks
:当前选区包含的文本格式(有助于我们实现格式刷/清除格式等功能)
range
选取区间。分别用anchor
, focus
选取区间的开始位置和结束位置。
/**
* `Range` objects are a set of points that refer to a specific span of a Slate
* document. They can define a span inside a single node or a can span across
* multiple nodes.
*/
export interface BaseRange {
anchor: Point
focus: Point
}
例子
// mynameisbhwa233
{
anchor: {
path: [0, 0],
offset: 0,
},
focus: {
path: [0, 0],
offset:5,
},
}
// 表示选取内容为:myname
同时提供一些静态方法用来扩展选区信息:
end
与start
:如果说anchor
/focus
是 range 的「事实」起终点,那么start
/end
则是 range 的「视觉」起/终点,start
总在end
之前(或者二者重叠)isBackward
与isForward
:选区方向是向前还是向后isCollapsed
与isExpanded
:是否折叠isSet
与isUnset
:起点终点是否均被设置
span
用于表示没有文本的选区区间。例如选取两个图片元素。
/**
* The `Span` interface is a low-level way to refer to locations in nodes
* without using `Point` which requires leaf text nodes to be present.
*/
export type Span = [Path, Path]
location
Path
、 Point
、 Range
的联合类型
/**
* The `Location` interface is a union of the ways to refer to a specific
* location in a Slate document: paths, points or ranges.
*
* Methods will often accept a `Location` instead of requiring only a `Path`,
* `Point` or `Range`. This eliminates the need for developers to manage
* converting between the different interfaces in their own code base.
*/
export type Location = Path | Point | Range
refs
slate通过refs来指向某个节点(类似于React Refs)的定位。当节点更新时,对应的定位会跟着变化。
export interface PathRef {
current: Path | null
affinity: 'forward' | 'backward' | null
unref(): Path | null
}
export interface PointRef {
current: Point | null
affinity: 'forward' | 'backward' | null
unref(): Point | null
}
export interface RangeRef {
current: Range | null
affinity: 'forward' | 'backward' | 'outward' | 'inward' | null
unref(): Range | null
}
current
:节点定位。affinity
:作为执行opeeration时transform
函数的参数。unref
:取消指向。
设置refs
export interface EditorInterface {
pathRef: (
editor: Editor,
path: Path,
options?: {
affinity?: 'backward' | 'forward' | null
}
) => PathRef
pointRef: (
editor: Editor,
point: Point,
options?: {
affinity?: 'backward' | 'forward' | null
}
) => PointRef
rangeRef: (
editor: Editor,
range: Range,
options?: {
affinity?: 'backward' | 'forward' | 'outward' | 'inward' | null
}
) => RangeRef
}
获取refs
export interface EditorInterface {
pathRefs: (editor: Editor) => Set<PathRef>
pointRefs: (editor: Editor) => Set<PointRef>
rangeRefs: (editor: Editor) => Set<RangeRef>
}
operation
operation
是slate中最基础的核心操作(即原子操作),对编辑器的一切修改都是通过一个或多个opertaion
来实现的。
类型
operation
可以分为三类:
Node
负责与节点(node)相关的操作:
insert_node
:插入节点merge_node
:合并节点move_node
:移动节点remove_node
:删除节点set_node
:设置节点属性split_node
:拆分节点
Selection
负责与选区(selection)相关的操作:
set_selection
:设置选区
Text
负责与纯文字相关的操作:
-
insert_text
:插入文本 -
remove_text
:删除文本
apply
operation是通过editor.apply()
调用。
例子:
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 },
},
})
apply()
的工作流程:
Normalizing
slate规范化是通过一组完整的FLUSHING搭配一次Normalize。
为了确保slate能够正确的解析,slate有一些约束,针对这些约束也会做一些操作来保证规范化:
-
所有的
Element
节点内必须至少一个Text子节点。如果遇到不符合规范的节点,会自动加入一个空的Text
节点。原因:为了确保编辑器的
selection
能够选中空元素。 -
会将相邻且属性相同的
text
节点合并成一个节点。原因:为了防止编辑器内的
text
节点在新增、删除文字属性时造成节点无意义的拆分。 -
块节点的子节点(children)只能是块元素(Block)、行内块状元素(inline-block)、text节点(inline)的一种。例如
paragraph
节点的子节点不能既有paragraph
block节点,还有text
inline节点。slate会以子节点的第一个节点作为判断可接受类别的节点,删除其他不符合规范的子节点。原因:为了让拆分块节点相关的功能保持稳定的结果。
-
内联节点现在总是被文本节点包围。如果没有,slate会自动插入空的
Text
节点。原因:优化编辑器的内容结构。
-
第一层节点只能是
Block
节点,其他类型的节点会被直接删除。原因:确保编辑器存在Block节点,确保拆分节点功能正常。
const initialValue: Descendant[] = [
//是block节点,正常。
{
type: 'paragraph',
children: [
{ text: 'This is editable plain text, just like a <textarea>!' },
{
type: 'link',
url: 'www.baidu.com',
text: '123',
},
],
},
// 是text节点,会被直接删除。
{ text: 'This is editable plain text, just like a <textarea>!' },
];
自定义规范化
规范化是通过editor 里的 normalizeNode()
来实现 , 如果需要进行定制化,可以通过插件对normalizeNode
进行重写。但需要注意几点:
normalizing是重复执行的
slate是通过递归实现对内容深度遍历,即会从子节点开始normalizing
再到父节点逐级进行规范化。
避免对无子节点的节点进行规范化
slate在normalizeNode
前会遍历节点,没有子节点的节点会自动加入一个空的Text
作为子节点。
避免无法满足约束
应避免自定义的约束,在修正后仍无法满足约束,导致无限循环normalizeNode
。
运作流程
执行步骤
- customCommand
- Transform.xxx(editor, …)
- editor.apply(operation)
- 重新生成 model
- React 渲染
内部完整流程
- 通过
Transform
的api触发编辑器更新,执行多次opertaion
。 - 第一次的
opertaion
除了会执行transform
与normalize
之外,也会将FLUSHING
設為true
,并将onChange
的执行以 Promise 的 Micro-Task 包装起来。 opertaion
通过getDirtyPath
取得并更新到DIRTY_PATHS
WeakMap variable。opertaion
再通过GeneralTransforms.transform
和Immer Draft State
调用applyToDraft
更新children
与selection
。- 执行
Transform
的normalize
与normalizeNode
实现对脏路径的节点规范化,调用Transform
來更新节点以满足约束并重跑一次相同的 Transform 流程。 - 完成所有同步更新后,执行Micro-Task的内容将
FLUSHING
设为false
并触发onChange
。
Transforms
一个 transform
是多个 operation
组成。一般开发中使用高阶(High-level) 的 Transform
api 来替代 低阶(Low-level) 的 operation
。