阅读 4090

富文本编辑器Prosemirror - 入门

一、了解prosemirror

prosemirorr本身并不是一个开箱即用的编辑器,而是通过一系列模块配合搭建起来的一个富文本编辑器,核心的模块主要有以下四个

  • prosemirror-model:负责prosemirror的内容结构。定义了编辑器的文档模型,用于描述编辑器内容的数据结构,并实现了对编辑器内容的一原子的操作。实现了一套索引系统,用于处理位置信息。同时提供了从DOM -> ProsemirrNode的Parser以及反向的Serilizer。

  • prosemirror-transfrom:负责对编辑内容的修改操作。文档的改动由step实现。transform基于step封装了一系列对内容进行操作的API。step执行了对文档内容的操作,通过StepMap记录了改动的信息,可用于追溯位置的变化。

  • prosemirror-state:负责描述整个编辑器的状态。包括文档内容,选区信息,所有的节点类型以及并基于Transform实现了Transaction,Transaction主要增加了对选区的管理以及状态记录。同时提供了强大的插件系统,实现用户对状态更新流程干预的可能性。

  • prosemirror-view:负责视图的渲染,实现了从state到视图的渲染。监听或者劫持用户的操作并修正,并创建相应的对state的改动,最终比对dom与state的决定最终渲染结果。

这四个模块实现了prosemirror的核心功能,但诸如什么按键有什么行为的规定,都需要由使用方自己实现,好在官方提供了prosemirror-commands、prosemirror-history、prosemirror-keymap等库来帮助我们更方便地去实现一个编辑器。

二、节点定义

prosemirror中渲染出的节点必须在Schema中有相应的定义,而Scheme中的可以定义的节点分为两种,nodemark

Node

node即为常规意义上的节点,分为块级或者是行内。我们可以定义一个节点在prosemirror中表现:

paragraph: {
	group: "block", 
	content: "text*",
	parseDOM: [{tag: 'p'}],
    toDOM(node) { return ['p', 0] }
}
复制代码

如上述定义了'paragraph'节点:group表示其为block组;content表示其可以包含任意文字;parseDOM表示其解析<p>xxx</p>;toDOM表示其渲染为<p>xxx</p>,子节点从0的位置开始插入,更多的配置可以查看NodeSpec,content是一个类正则字符串,可以使用node的名称或者是group名。

Mark

Mark是一种比较特殊的Schema类型,他的表现不同于Node,Mark主要作用于行内节点,用于给行内元素增加样式或者附加其他信息,他不像Node会占据文档位置,更像是一种对文档描述的补充。

bold: {
	parseDOM: [{tag: 'strong'}],
    toDOM(node) { return ['strong', 0]}
}
复制代码

如上定义bold类型的Mark,解析以及渲染时候的DOM。更多定义参考:MarkSpec

Attributes

Node以及Mark都可以附加一些信息,但需要在初始化是定义好支持的属性

paragraph: {
	group: "block", 
	content: "text*",
	attrs: {
		align: {
			default: 'left'
		}
	}
}
复制代码

如上定义了一个attrs中包含align的paragraph节点,可以用作单纯的信息存储,也可以配合toDOM修改渲染输出。

三、文档结构

内容

prosemiror的内容被描述成一棵树,他的特征属性(isBlock、isInline等)由我们所定义的节点特征来确定

Node的content由一个Fragment表示,Fragment的content则是由Node组成的数组,prosemirror通过这样嵌套形成的树形结构来描述一个文档。

索引

prosemirror实现了一套索引系统用于表示文档中某个位置,主要分为两种:

第一种是比较像是访问DOM,利用content的数组的特性去访问节点,把文档当成一棵树去遍历。

第二种是强大的索引系统,把文档打平后的索引,prosemirror文档中的任何位置,都可以用一个唯一的整数表示。

对于正常的DOM,它是树形的结构

在prosemirror的索引系统中,把这棵树打平了,规定:

  • 整个文档的第一个节点前的位置为零。
  • 进入或离开不是叶节点(即可以包含其他内容)的节点视为一个token。因此,如果文档以一个段落开头,则该段落的内容开头算作位置1。
  • 文本节点中的每个内容都使做是一个token。
  • 叶子节点(不能包含其他节点内容)也视作是一个token。

按照这个规则,想象我们有一个指针,从开头0开始进入一个节点时索引加1,每越过一个文本内容加1,退出一个节点时也加1,通过这样的形式,就可以描述文档中的每个位置。

如上所示,nodeSize可以理解为我们的指针从进入到退出节点时走过的距离,所以对应<p>的nodeSize为5,<blockquote>的nodeSize为8,通过这样的方式,我们就可以描述每个节点的位置以及大小。prosemirror中的很多操作都需要使用到这些信息。

四、如何修改文档

了解了上面的内容之后,我们对prosemirror的文档结构有了一个大致的认识,下一步我们来尝试修改文档的数据。我们来把官网的内容替换成Hello Prosemirror!。

分析

根据上面的分析,我们要做的可以是修改doc的content属性或者是直接修改文字内容亦或是删除内容后再插入,我们选择第一种方式来实现。

prosemirror中的数据更新实际上都是对state的修改,通过state提供的updateState的API接受一个新的state来更新state,编辑器实例view中帮我封装好了这一步操作,对外暴露出来的API是dispatch。

上面我们说到修改文档的操作是由prosemirror-transform来实现的,而prosemirror-state中的tr属性继承了transform,state又是作为prosemirror-view实例的一个属性。

所以更新操作都可以通过view来实现,翻阅API文档,看到replaceRangeWith这个API符合我们的需求,

from和to就是上文介绍到的索引数字,代表替换的位置,node即为新的节点,对目前的操作来说,替换的起点from为起始位置0,替换结束的位置为内容终点即为doc的content的大小,node则可以通过schema创建。

实现

根据上面的分析,实现节点替换的操作为:

const { dispatch, state } = view;
const { schema, tr, doc } = state;
const { paragraph } = schema.nodes;
const textNode = schema.text('Hello Prosemirror!');
const newParagraph = paragraph.create(undefined, textNode);
dispatch(tr.replaceRangeWith(0, doc.content.size, newParagraph));
复制代码

这样就实现了对内容的替换!

原理

所以从prosemirror的角度看,replaceRangeWith做了什么操作呢?

上面说到transform对文档的操作都是通过step去实现,所以这一步实际上创建了一个ReplaceStep去修改文档。

step对文档的修改不一定是成功的,结果由StepResult表示,如果失败了会抛出一个TransformError,如果成功了,则会返回新的文档的内容,transform会把旧文档内容保存在docs属性中,新的应用到doc属性中,并把step保存在steps属性中,可以实现撤销的操作。最后通过distpach更新到state。

因此,我们可以把state.tr可以看成是一个事务,每一个step可以看作是一次原子操作,通过dispatch提交事务并应用到state上生效,实现了对文档的修改。

五、总结

通过上文,简单的介绍prosemirror的一些概念,API使用以及文档更新的原理,可以看到prosemirror通过对数据的抽象,可以把文档的结构描述得很清晰。把对文档的操作封装得很好,隐藏了很多细节的东西,并提供了各种方便使用的API。

本文目前还只是停留在对prosemirror粗浅的介绍,诸如文档具体如何更新,视图是怎么去渲染的,用户行为是怎么捕获并分析……还有很多内容值得研究,后续会有一系列文章来介绍它们。

欢迎指出错误或提出问题,互相交流,共同发展😁

文章分类
前端
文章标签