ProseMirror 简明教程

1,464 阅读21分钟

声明

这是一篇科普文章,其中描述的任何技术细节,不会超出官方网站 prosemirror.net 以及开源项目 ProseMirror 的范围,亦不涉及任何当前市面上产品所独有的未公开的实现。

简介

ProseMirror 是一款用于构建富文本编辑器的开发框架,核心部分完成了数据处理和工作调度,开发者只要按照其定义的概念来进行开发,即可实现高度定制的富文本编辑器。

核心

模块功能
prosemirror-model描述文档内容的数据结构
prosemirror-state编辑器状态的描述,以及编辑器状态更新机制
prosemirror-view用于图形化文档数据和编辑器状态,以及响应交互
prosemirror-transform提供可记录和可重现的model修改能力

最简单的编辑器

import {schema} from "prosemirror-schema-basic"
import {EditorState} from "prosemirror-state"
import {EditorView} from "prosemirror-view"

let state = EditorState.create({schema})
let view = new EditorView(document.body, {state})

ProseMirror 中文档的生成规则称为 schemashema 定义了文档中各种节点之间的关系和组织方式,而文档数据模型作为编辑器状态的核心,也是根据 schema 来生成的。最后,编辑器状态被用于生成视图组件。

Transactions

当用户在编辑器中输入,或者与编辑器进行交互,EditorView 就负责接收和响应。接收时,会根据插件定义的交互细节,决定如何更新编辑器状态。而更新状态的媒介,就是这名为 Transaction 的数据结构:
const newState = view.state.apply(tr: Transaction)
这样就生成了一个包含了变化的新的 state,而视图层再次使用这个 newState 来更新视图:
view.updateState(newState)
这样就将操作的结果反馈给了用户,ProserMirror 中对文档的每一个修改,都以这样的形式进行应用,因此非常便于追踪和记录 。

Plugins

Plugin是ProseMirror交给开发者最强大的工具,因为编辑器中发生的大小事务,都可以被每一个 Plugin 监听和处理.

Commands

用于响应键盘事件的组件。

Content

根据schema来生成的内容数据模型。

Documents

Structure

ProseMirror为了便于文档的频繁修改和重组,自定义了与dom类似的节点组织方式。

image.png

在ProseMirror定义的文档模型中,行内节点会被展平为一个数组,数组中的每个部分代表一段内容,而具体的表现和行为则是用 mark 来定义。这样做的好处有很多:

  • It allows us to represent positions in a paragraph using a character offset rather than a path in a tree
  • makes it easier to perform operations like splitting or changing the style of the content without performing awkward tree manipulation
  • This also means each document has one valid representation.

ProseMirror同时构建了自定义文档对象模型和浏览器DOM的转换,以实现:

  • Adjacent text nodes with the same set of marks are always combined together
  • empty text nodes are not allowed
  • The order in which marks appear is specified by the schema

Document模型的典型特性如下:

  • A ProseMirror document is a tree of block nodes
  • most of the leaf nodes being textblocks
  • You can also have leaf blocks that are simply empty, for example a horizontal rule or a video element

文档中节点的属性体现了其角色:

  • isBlock / isInline 节点是块级还是行内
  • inlineContent 是否要求当前节点的内容都得是行内节点
  • isTextblock 当前块级节点是否包含行内内容
  • isLeaf 表示当前节点不能以任何节点为子节点

Identity and persistence

DOM节点是一个唯一的可变的对象
而 ProseMirror 节点是不可变的,可复制的对象
因为它不与任何真实Dom强绑定,而仅仅被 ProseMirror-View 用于生成真实 Dom
每次更新文档对象,都是一次新文档对象的创建
这个新文档对象的创建并不是从0开始
而是根据修改的情况对旧的数据尽可能的复用
不可变的意义重大
它确保了文档 model 在任一时刻都是确定的
即两次状态更新之间不存在中间状态
这也是多人协作能够实现的基础
并且,ProseMirror 会根据新旧 model 的对比来高效地更新组件
出于性能的考量,文档节点数据是可以被程序修改的
但一定不要这样做,这会让事情变糟

Data structures

image.png

上图就是文档节点的数据结构
content字段是一个非空的值
即使对于空节点来说,这个属性也会被一个通用的 Empty Fragment 填充
一个完整的文档就是一个节点
文档内容的组织方式根据 schema 来定义
在程序中创建节点,也需要使用相应的 schema 为基础

Indexing

image.png 上图示意了 ProseMirror 对文档中每一个元素或节点进行定位的规则
ProseMirror 底层使用这种定位系统进行计算
并封装了能够覆盖大多数应用场景的便捷方法
如 Node.resolve、domAtPos、posAtDom、nodeAt、nodeDom 等

Slice

Slice 代表着文档中任意两个位置之间的内容
这种数据结构常用于对部分文档内容的复制或者替换操作

Changing

前文已经提到过,更新文档的方式,只能是生成一个全新的文档
ProseMirror 为开发者提供了许多便捷的方式来生成文档
这些方式都属于 transformation 的范畴
transformation 描述了更新文档的机制,后续文档中会详细介绍

Schemas

描述出现在文档中的节点类型,这些类型定义了节点的渲染方式和其编辑行为

Node Types

节点的描述对象,ProseMirror提供了这个类型的接口,来实现自定义节点。通过类型的定义,可以实现不同节点的嵌套关系,和转换规则等常见的需求。每个文档都至少需要定义一个顶级类型节点(默认为doc,可以配置为其他的子类型)用以承载内容以及一个text类型节点用以表示文本

Content Expressions

在定义一个 NodeType 的时候,需要指定其 content 属性,这个属性就是代表,这个类型的节点能够以怎样的规则容纳其他类型的节点, content 属性的书写方式和正则表达式类似,但是由 ProseMirror 内部进行解析,所以要使用其规定的语法来表述

使用 group 字段来定义一组内容时,需要注意不要出现自引用的类型(内容需要至少一个自身类型的节点类型),以免(内存)爆炸

在创建节点的时候,默认情况下PM不会检查内容是否合法,这在创建Slice的时候很重要

节点类型Node同时也提供了 check 方法来检测节点是否符合schema的定义,当发现内容非法的时候,这个方法会直接抛出错误

节点基类 NodeType 提供了 createChecked、createAndFill 方法来帮助创建内容合法的节点

Marks

mark 用于为 inline content 添加额外的样式和信息,在 schema 中应当声明当前描述的类型能够支持的所有 mark 类型

默认情况下,inline content 支持所有类型的 mark,marks 字段的表述语法为,以空格分开的 mark 名称组成的字符串,空字符串代表不支持任何 mark, ‘_’ 代表支持所有mark

Attributes

schema 同时还声明了 节点/Mark 具备哪些属性,如果想要在节点中保存额外的信息,比如在 heading 节点中记录其 level,最好就是用 attribute 来实现 必须为属性设置一个默认值,否则在缺省状态下初始化节点会报错,并进一步影响ProseMirror 通过 createAndFill 等方法来创建节点过程中的自动补全行为。

Serialization and Parsing

为了能够在浏览器中呈现和编辑节点,需要将节点转换为Dom元素,同时,在保存编辑内容,以及处理粘贴等事件时,则需要将Dom元素转化为节点数据

以上两种场景分别对应了 spec 中的 toDOM(node) 方法和 parseDOM(ParseRule[]) 方法 toDOM 返回一个dom的描述对象,对于 mark 来说,需要返回一个标签名称,来表示用来包裹内容的元素类型(即 string | [string, any])

编辑器会利用schema中定义的parseDOM来自动生成一个 DOMParser 并将其作为粘贴文本的处理器,这个默认行为可以通过修改编辑器的 clipboardParser 配置来定制

Extending a schema

创建好的 schema 的 nodes 和 marks 都被作为普通对象保存在一个 OrderedMap 中(便于操作,其内部维护了键名的顺序),同时 nodes 和 marks 的内容也使用 OrderedMap 来保存

这种方式便于进行扩展,也就是以某个schema为基础,生成新的schema,例如,模块 schema-list 就提供了方法用于将其提供的节点 schema 添加到使用者自己的 schema 中(增强)

Document transformations 

transform 是 transaction 的基础,也是 ProseMirror 工作的核心,正是 transform 使得历史记录和协作编辑成为可能

Why

⁃ 不可变数据的优势 —— 代码逻辑更加清晰

⁃ 通过 step 来使得更新记录可追溯

⁃ 协作者只需要发送 step 到其他编辑者,就可以让大家的文档保持一致

⁃ 支持自定义插件来相应

Steps

step 是对文档修改行为的抽象,例如 ReplaceStep 是将文档中某一部分进行替换,而 AddMarkStep 则是对文档中的一个片段添加 mark

step 应用到文档对象后,会返回一个包含新的文档对象或者错误信息的 result 对象,也就是说,step.apply 是可能会出错的,因此对 step 的构造通常需要十分严谨

所幸大多数时候不需要开发者亲自去实现某个 step 的构造和应用逻辑,另外 PM 提供了许多 helper functions 来生成自定义的step,这可以尽可能避免错误

Transforms

一个编辑行为有可能会产生多个 step,而组织这些 step 的最便捷的方式就是创建一个Transform 对象,或者,如果是针对整个文档的状态,一个 Transaction 对象

Transform 对象封装了生成 step 的方法,并且将生成的 step 保存在内部,开发者只需要在 Transform 对象上执行语义化的操作,然后以 state.apply(transform) 的方式将其应用,就可以得到修改后的文档数据

同时,transform 内部也实时维护着一份新文档数据,这份文档数据是 oldValue 执行 steps 后的结果 

Mapping

记得前面提到过文档的 index 系统,每个元素,每一个字符,都对应着 state.doc 的一个位置,而在文档内容不断变化的过程中,这些位置也是会变化的,那么如何在 transform 应用之前就知道当前 tr 对位置的影响呢

step 对象提供了 getMap 方法,这个方法返回一个 Map 对象,而 Map.map 则可以根据旧的位置信息计算出新的位置信息

在 Transform 对象中维护者一串 step,每个step中都有map,为了便于操作, Transform 对象提供了一个 Mapping 属性,这个 Mapping 的map 方法能够计算出内部所有 step 对位置的影响结果

边界情景:当我们将一个节点一分为二,处于分割点的位置应该被map到原先位置的前面还是后面呢,毕竟这两种方式都是合理的, map 方法提供了第二个参数 bias,用来自定义这种行为

Rebasing

变基常用与在协作过程中合并修改,PM 并没有直接提供变基的方法,而只是提供了实现的思路

当我们想要合并对同一文档的不同修改的时候,需要对存在冲突的修改本身进行调整,这通过程序实现这种调整就是变基

详细内容待后续补充

The editor state

编辑器状态的四个主要部分为:

⁃ doc 文档对象模型

⁃ selection 当前光标选区

⁃ storedMarks 将要应用到编辑行为的 mark

⁃ plugin 为编辑器引入的自定义状态数据

Selection

ProseMirror 支持自定义类型的选区,并内置了常用的 Text Selection 和 Node Selection。 选区至少需要一个开始和一个结束的标记,对应属性名为 from 和 to

有些选区支持 anchor 和 head,分别指选区固定的一端和不固定的一端

Transactions

创建 state 的情景有两种:初始化文档和编辑行为,此处主要讨论编辑行为创建state的情况。

newState = oldState.apply(transaction)

transaction 会应用到状态中的每个组件中,最后组成新的状态

transaction 是 Transform 的子类型,除了具备修改旧文档生成新文档的能力外,还拥有追踪 selection 和其他状态相关组件的能力,并暴露了很多便捷的方法,比如 replaceSelection

创建一个 transaction ,最简单的方式就是访问 state 的 tr 属性,这个访问器会返回空的 transaction ,然后你就可以在这个 tr 中添加 steps 或者进行其他更新操作

默认情况下,tr 中的 selection 会在添加操作的过程中自动根据 step 来 map 到新的 selection。这个行为可以使用 tr.setSelection 进行修改

在文档状态修改后,storedMarks 会被清空,也就是说,如果你想在特定的情况下设置编辑行为将要应用的 mark,你需要在每次满足条件时进行手动添加,可供选择的方法有 tr.setStoredMarks 和 tr.ensureMarks 

为了使得选区变化后,出现在用户可见的范围内,PM提供了 scrollIntoView 方法,调用后会确保编辑器状态在下一次应用的时候,会将 selection 滚动到视口中

和 Transform 一样,许多 transaction 的方法会返回本身,以便进行链式操作

Plugins

初始化 state 时,可以传入一个 plugin 的列表作为参数,这些 plugin 会被保存在 state 中,并作为状态记录的一部分,并影响编辑器的编辑行为

插件作为编辑器事件分发的管道,可以监听编辑器中发生的编辑行为

插件也可以定义自己的私有状态数据,插件实例的 getState 方法可以从全局 state 中获取到相应的状态数据

由于插件的数据也作为 state 的一部分被保存和更新,因此也需要是不可变的,Plugin.state.apply 方法应该在需要更新状态的情况下返回一个新的数据,而不是修改原有的

状态更新只应该发生在 apply 方法中,其他地方可以通过设置 transaction 的meta信息来告诉插件在apply的时候应该如何更新

tr.setMeta(key, value) ,其中 key 可以是引用类型,因此为每个 plugin 指定一个唯一的 key 值是有必要的

值得注意的是,用于初始化 state 的是 plugin 实例的列表,因此用户定义的 plugin 通常只有一个实例。

The view component

view 用于呈现编辑器的状态并提供编辑行为的载体

view内部处理的编辑行为并未有很多,只包含了 输入、点击、复制、粘贴和拖拽和其他少量的操作。这意味着有些功能比如显示一个菜单或者或者组合键操作,都需要依赖外部定义的 plugin 来实现

Editable DOM

浏览器允许我们指定页面的某些元素是可以编辑的,这使得被定义为可编辑的元素具备获取焦点、输出文字和设置选区的能力。view 创建了一个这样的元素,它代表了文档对象的 state 数据内涵,并使之可以进行编辑。每当这个元素获得焦点和选区,view 组件都会确保 DOM Selection 和文档状态数据中记录的响应数据保持一致

同时在这个 dom 上,还注册了许多的事件监听,这些事件监听会将发生在元素上的事件翻译为恰当的 transaction。例如,当在 dom 中粘贴时,view 会将粘贴内容 parse 为pm文档片段,然后插入到编辑器文档对象中,最后才通过view呈现出粘贴的结果

有些事件不会被 pm 劫持,比如文字输入、光标切换、选区设置(当考虑双向文字的情况时,这事儿会非常棘手),也就是说,大多数光标移动相关的键盘和鼠标事件会直接交给浏览器处理,在那之后,pm 才会检查当前真实 dom 的选区状态,然后再通过diff算法来生成一个 transaction 来更新 editor 的 state

打字操作也是交给浏览器去处理的,因为中断这些操作可能会影响拼写检查、自动首字母大写(某些移动端设备),以及其他一些原生特性。当浏览器更新 dom 之后,编辑器会收到通知,并重新 parse 文档中修改过的部分,然后通过对比 state 计算出 transaction

Data flow

在 EditorView 中,暴露了一个可配置的声明周期,那就是 dispatchTransaction ,这里是整个编辑器更新和应用状态的地方,默认配置中,这个生命周期会使用 tr 和编辑器当前的 state 生成新的 state,即 state.apply(tr)。然后调用 view.updateState 传入新的state 来完成最终的更新

如果想要将编辑器状态集成到整个应用的状态方案中,就可以在这个生命周期钩子里进行全局状态的更新操作

Efficient updating

view.updateState 内部会对新、旧 state 进行对比,然后得出所需的最小 dom 操作,也就是只会让改变了的部分发生变化,不会操作未改变的节点,这大大提升了性能

有些情况,比如输入文本,由于更新状态时文本已经被浏览器原生能力添加到了 dom 的内容中,所以 view 不会再去更新相应的 dom 了,相应的,当这样一个 tr 被取消或者修改的时候,view 将会对 dom 的修改进行 undo 操作

类似地,DOM selection 只有在与 state 选区不一致时才会进行更新,这是为了避免干扰浏览器的原生功能,比如在行与行之间切换光标的时候,保持光标水平位置等

Props

pm 支持一系列的属性定义,在 interface EditorProps 中列出了 pm 支持的属性列表。这些属性的配置能够定义编辑器的行为细节

属性配置的方式有两种,一种是直接在 view = new EditorView({props}) 中直接定义,另外一种是定义在 plugin = new Plugin({props}) 中

这两种方式定义的 prop 都能够通过 view.props.[name] 访问,获取到的数据取决于属性本身,一般以先 view,然后遍历 plugin 的顺序来搜索,将第一个返回的值作为结果返回

属性的类型大体上有以下几种:

第一种是原生dom事件监听器,通过配置view或者plugin的props.handleDOMEvents 来设定,这里面支持的事件在默认情况下会在 ProseMirror 处理相应事件之前触发,并冒泡给每一个 plugin,然后再由直接定义在 view.props 或 plugin.props 的事件监听器处理,只要 handleDOMEvents 中配置的某个监听器中对事件执行了 preventDefault 就会将上述流程终止

第二种是直接定义在 view.props 或 plugin.props 上的原生事件监听器,这些事件也会以 view -> view plugins -> state plugins 的顺序触发,不同之处是,他们会在原生 dom 事件执行结束后触发,并且,一旦某个监听程序返回了 true,pm 就会自动终止事件的传播

第三种是pm定义的工具函数,比如在复制、粘贴、内容变化过程中需要pm调用的序列化、内容转换以及内容解析的方法,以及用来确定编辑器能否进行编辑的方法,这些方法会使用以 view -> view plugins -> state plugins的顺序获取到的第一个值来作为结果

第四种是控制自定义渲染行为的配置,比如 nodeViews、decorations、markViews,值得注意的是,plugin 和我们在 mvvm 中理解的组件不同,一个plugin在一个编辑器实例中,一般只会实例化一次,而plugin产生的视图节点可能是多个,这些节点如果要各自维护内部状态,则需要定义在 nodeViews 中,plugin中定义的 state 是针对整个编辑器的

Decorations

这个是一个编辑器的属性(也可以定义在 plugin 中),属性值应该是一个返回 DecorationSet 实例的方法。DecorationSet 是一种为了便于绘制程序读取和比较的树形数据结构,而这个树形结构的节点,就是 Decoration 

Decoration 被用于自定义某些 dom 的渲染方式,默认情况下,schema 中定义的组件都被要求提供一组映射为真实 dom 元素的方法,并且将按照这些方法得到的结果渲染到浏览器中。而Decoration 正如其名字所表达的一样,主要作用是装饰,即它依赖于已有的渲染内容,并影响文档中各个渲染部分的具体效果

Decoration 主要有三种类型:

  1. Node decorations 为文档中某个节点添加样式或者其他的 dom 属性

  2. Widget decorations 在文档的渲染结果中插入自定义的 dom 元素,插入的元素不需要 schema 定义,不属于文档可编辑内容的一部分,只适用于提供额外的交互,如菜单等

  3. Inline decorations 和 Node decorations 类似,也是添加样式或者其他 dom 属性到节点,不同之处是,其针对的是一段 range 内的所有 inline 元素

性能优化建议:当文档中存在很多 decoration 的时候,每次文档更新都从头开始创建 decoration 树会造成很大的性能损失(因为 plugin.props.decorations 在每次文档更新的过程中都会被执行一遍),建议将 decorations 数据保存在 plugin 内部 state 中,并在 plugin 的 state.apply 生命周期中进行有条件的更新。tip: Decoration.map(tr.mapping, tr.doc)可以自动调整 DecorationSet 中的Decoration 以适应文档内容变化引起的 index 变化

Node views

有更多的方式能够影响编辑器渲染文档的行为。NodeViews 允许你定义小型的 UI 组件来渲染文档中的节点,并提供自定义渲染行为、定义更新方式和自定义响应事件逻辑的能力。

示例用法:

⁃ 使用 stopEvent 来全权接管自定义节点的事件,而不让 pm-view 来处理

⁃ 使用 getPos()、setNodeMarkup(pos, type, attrs, marks) 来定义自定义事件中对文档派发更新任务的自定义逻辑

当一个节点更新后,默认的行为是,保持外部节点不变,比较新旧节点的子节点,按照实际的需要进行更新或者替换子节点,而通过定义 nodeViews 的 update 接口可以使用自定义的逻辑来替换这种默认行为

另一个默认行为是关于处理内容的,如果给 nodeViews 提供了 contentDOM 属性,那么 pm 将会渲染这个自定义节点的内容到这个 contentDOM 中,并自动处理内容的更新。如果没有设定 contentDOM 属性,那么节点内容的渲染和更新都需要开发者自己实现(通过实现 update 接口)

Commands

很多情况下,我们需要将一组复杂的编辑步骤封装成一个过程来统一调用,并提供快捷方式来触发这些调用,这就需要 command 出场了。

Command 并不是 pm 提供的开箱即用的基础设施,而是一种功能实现的设计模式,具体的实现方法完全由开发者自己来决定,其大致的规范如下:

⁃ 至少需要接收 state、dispatch、view 作为参数

⁃ 支持查询 command 是否可用的能力

PM 官方提供了许多封装好的命令便于开发者使用和参考,详情见 prosemirror-commands 

chainCommands: 有时候,针对同一个操作,需要连续触发不同的命令。比如退格键,就需要 deleteSelection、joinBackward、selectNodeBackward 三个步骤的执行,为了便于维护,可以使用 chainCommands 直接对这三个命令进行组合,得到新的命令