富文本编辑器Prosemirror - 入门
一、了解 prosemirror
prosemirror本身并不是一个开箱即用的编辑器,而是通过一系列模块配合搭建起来的一个富文本编辑器,核心的模块主要有以下四个
prosemirror-model:负责 prosemirror 的内容结构。定义了编辑器的文档模型,用于描述编辑器内容的数据结构,并实现了对编辑器内容的一原子的操作。实现了一套索引系统,用于处理位置信息 。同时提供了从 DOM -> ProsemirrorNode 的 Parser 以及反向的 Serializer。
prosemirror-transform:负责对编辑内容的修改操作。文档的改动由 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 中的可以定义的节点分为两种,node
和mark
,
Node
node 即为常规意义上的节点,分为块级或者是行内。我们可以定义一个节点在 prosemirror 中表现:
1 | paragraph: { |
如上述定义了’paragraph’节点:group 表示其为 block 组;content 表示其可以包含任意文字;parseDOM 表示其解析<p>xxx</p>
;toDOM 表示其渲染为<p>xxx</p>
,子节点从 0 的位置开始插入,更多的配置可以查看NodeSpec,content 是一个类正则字符串,可以使用 node 的名称或者是 group 名。
Mark
Mark 是一种比较特殊的 Schema 类型,他的表现不同于 Node,Mark 主要作用于行内节点,用于给行内元素增加样式或者附加其他信息,他不像 Node 会占据文档位置,更像是一种对文档描述的补充。
1 | bold: { |
如上定义 bold 类型的 Mark,解析以及渲染时候的 DOM。更多定义参考:MarkSpec
Attributes
Node 以及 Mark 都可以附加一些信息,但需要在初始化是定义好支持的属性
1 | paragraph: { |
如上定义了一个 attrs 中包含 align 的 paragraph 节点,可以用作单纯的信息存储,也可以配合 toDOM 修改渲染输出。
三、文档结构
内容
prosemirror 的内容被描述成一棵树,他的特征属性(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 创建。
实现
根据上面的分析,实现节点替换的操作为:
1 | const { dispatch, state } = view; |
这样就实现了对内容的替换!
原理
所以从 prosemirror 的角度看,replaceRangeWith 做了什么操作呢?
上面说到 transform 对文档的操作都是通过 step 去实现,所以这一步实际上创建了一个 ReplaceStep 去修改文档。
step 对文档的修改不一定是成功的,结果由 StepResult 表示,如果失败了会抛出一个 TransformError,如果成功了,则会返回新的文档的内容,transform 会把旧文档内容保存在 docs 属性中,新的应用到 doc 属性中,并把 step 保存在 steps 属性中,可以实现撤销的操作。最后通过 dispatch 更新到 state。
因此,我们可以把 state.tr 可以看成是一个事务,每一个 step 可以看作是一次原子操作,通过 dispatch 提交事务并应用到 state 上生效,实现了对文档的修改。
五、总结
通过上文,简单的介绍 prosemirror 的一些概念,API 使用以及文档更新的原理,可以看到 prosemirror 通过对数据的抽象,可以把文档的结构描述得很清晰。把对文档的操作封装得很好,隐藏了很多细节的东西,并提供了各种方便使用的 API。
本文目前还只是停留在对 prosemirror 粗浅的介绍,诸如文档具体如何更新,视图是怎么去渲染的,用户行为是怎么捕获并分析……还有很多内容值得研究,后续会有一系列文章来介绍它们。
欢迎指出错误或提出问题,互相交流,共同发展 😁