Contents

富文本总结(二):Slate概念

架构

Slate 并非一个做到开箱即用的富文本应用,而是提供了基本富文本能力的框架。而其他功能都需要开发者自行通过插件的形式进行实现。

特性

  • Immutable:基于Immutable,利用Immer库,每次只会生成修改的数据,其他相同的部分和原有数据共享。

  • 非常轻量:不同于其他编辑器类的库,Slate并不提供譬如粗体、斜体、字体色等开箱即用的功能。Slate只是提供了一套自己定义的核心数据模型,以此一些操作数据和选区相关的API

  • 视图无关:视图层的渲染和行为完全由开发者基于React定制。

  • 协同编辑:从顶层设计上看,Slate的架构是典型的MVC模型,由自身定义数据模型(Model),暴露操作数据的方法(Controller),然后交由用户使用该数据在React中做渲染(View)。Slate.js 的模型设计天然就亲和协同编辑。

源码

slate项目使用monorepo的架构,共有三个包:

  1. slate

    slate内核,定义编辑器的数据结构(Model),提供操作数据的API(Controller)。

  2. slate React

    基于React实现的slate视图层(View)。

  3. 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节点树:

https://raw.githubusercontent.com/lxw15337674/PicGo_image/main/20220829213144.png

const editor = {
  children: [
    {
      type: 'paragraph',
      children: [
        {
          text: 'A line of text!',
        },
      ],
    },
  ],
}

slate的节点共有三种类型:

  • Editor:文档树的根节点。
  • Element:children属性,可以作为其他Node的父节点
  • Text:是树的叶子节点,包含文本信息。

用户可以自行扩展Elementtext类型的属性来扩展节点,例如增加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

https://cdn.jsdelivr.net/gh/lxw15337674/PicGo_image/20220829214621.png

Node是slate中最基础的抽象。

Node对象基本属性属性:

  • key:节点在当前文档中的索引

  • data:节点绑定的数据

  • nodes:节点的子孙

  • object:节点类型

  • text:这是一个计算属性,返回节点的文本内容

Element

https://raw.githubusercontent.com/lxw15337674/PicGo_image/main/202208292127484.png

节点类型:

  • Document Element:表示编辑器的整个文档树
  • Block Element: 表示编辑器中的块级元素
  • Inline Element:表示编辑器中的行内元素

节点的基本属性:

  • key:节点在当前文档中的索引

  • data:节点绑定的数据

  • nodes:节点的子孙

  • object:节点类型

  • text:一个计算属性,返回节点的文本内容

Text

https://cdn.jsdelivr.net/gh/lxw15337674/PicGo_image/20220829213346.png

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

https://raw.githubusercontent.com/lxw15337674/PicGo_image/main/v2-b517d7d331da8eb8d351ade004a9a245_720w.jpg

节点路径,相对于根节点的相对位置路径。

/**
 * `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:当前选区包含的文本格式(有助于我们实现格式刷/清除格式等功能)

https://raw.githubusercontent.com/lxw15337674/PicGo_image/main/v2-89c76dddf5818e2144dedbe1246a2486_720w.jpg

range

选取区间。分别用anchorfocus选取区间的开始位置和结束位置。

/**
 * `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

同时提供一些静态方法用来扩展选区信息:

  • endstart:如果说 anchor/focus 是 range 的「事实」起终点,那么 start/end 则是 range 的「视觉」起/终点,start 总在 end 之前(或者二者重叠)
  • isBackwardisForward:选区方向是向前还是向后
  • isCollapsedisExpanded:是否折叠
  • isSetisUnset:起点终点是否均被设置

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

PathPointRange 的联合类型

/**
 * 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有一些约束,针对这些约束也会做一些操作来保证规范化:

  1. 所有的Element节点内必须至少一个Text子节点。如果遇到不符合规范的节点,会自动加入一个空的Text节点。

    原因:为了确保编辑器的selection能够选中空元素。

  2. 会将相邻且属性相同的text节点合并成一个节点。

    原因:为了防止编辑器内的text 节点在新增、删除文字属性时造成节点无意义的拆分。

  3. 块节点的子节点(children)只能是块元素(Block)、行内块状元素(inline-block)、text节点(inline)的一种。例如paragraph 节点的子节点不能既有paragraphblock节点,还有textinline节点。slate会以子节点的第一个节点作为判断可接受类别的节点,删除其他不符合规范的子节点。

    原因:为了让拆分块节点相关的功能保持稳定的结果。

  4. 内联节点现在总是被文本节点包围。如果没有,slate会自动插入空的Text节点。

    原因:优化编辑器的内容结构。

  5. 第一层节点只能是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

运作流程

执行步骤

  1. customCommand
  2. Transform.xxx(editor, …)
  3. editor.apply(operation)
  4. 重新生成 model
  5. React 渲染

内部完整流程

  1. 通过Transform的api触发编辑器更新,执行多次opertaion
  2. 第一次的opertaion除了会执行transformnormalize 之外,也会将 FLUSHING 設為 true ,并将 onChange 的执行以 Promise 的 Micro-Task 包装起来。
  3. opertaion通过 getDirtyPath 取得并更新到 DIRTY_PATHS WeakMap variable。
  4. opertaion再通过 GeneralTransforms.transform Immer Draft State 调用applyToDraft 更新 childrenselection
  5. 执行TransformnormalizenormalizeNode 实现对脏路径的节点规范化,调用Transform 來更新节点以满足约束并重跑一次相同的 Transform 流程。
  6. 完成所有同步更新后,执行Micro-Task的内容将 FLUSHING 设为 false 并触发 onChange

https://raw.githubusercontent.com/lxw15337674/PicGo_image/main/201393593GX3OuA8NF.png

Transforms

一个 transform是多个 operation 组成。一般开发中使用高阶(High-level) 的 Transformapi 来替代 低阶(Low-level) 的 operation