本文研究表示编辑器状态的prosemirror-view包。

视图的状态

万物皆有状态,状态可以嵌套:

  • pm-model定义了文档模型,用来管理文档的状态
  • pm-state定义了编辑器状态类型(state),用来管理编辑器的状态,也就是文档状态加上插件状态。
  • pm-view定义了编辑器视图属性(props),用来管理编辑器视图的状态,也就是编辑器状态加上相应的视图状态。

pm-view包含的状态大于pm-state包含的状态,大于pm-model实例的状态。

pm-model定义EditorView类型,用来表示编辑器视图。EditorView在实例化的时候,需要两个参数,一个是挂载的DOM节点,也就是在哪里呈现编辑器(决定方式由三种:可以是DOM节点,一个函数或者是一个对象的mount属性);二是视图状态,也就是props。pm-view中把props分为两类,一个是DirectEditorProps,另一个是EditorProps。DirectEditorProps是EditorProps的超集,前者包含了编辑器状态(state)以及dispatchTransaction这两个只能由编辑器视图来处理的属性;后者包含的属性则可以由插件参与或贡献。

前面我们知道,ProseMirror的编辑器状态其实就是插件状态的集合,之所以区分DirectEditorProps和EditorProps,无非是想保留一两个视图属性,不让外部的插件使用。EditorView提供了一个someProp方法,可以按次序(先EditorView然后依次注册的Plugin集合)以一定的逻辑(不同属性逻辑不同)遍历Props集合,来决定视图的行为。因此任意一个插件都可以决定编辑器是否处于允许编辑状态,这是在视图的getEditable()中实现,其实现方式如下:

  return !view.someProp("editable", value => value(view.state) === false)

简单的说,就是问下所有的插件,看有没有插件不同意当前文档不可编辑的。如果有一个不同意的,那么一票否决,编辑器就不能处于编辑状态。

EditorViewprops()可以返回DirectEditorProps;update(props)可以用来替换既有的DirectEditorProps;setProps(props)则可以用来部分更新DirectEditorProps。如果只想更新DirectEditorProps中的EditorState的话,可以使用updateState(state)

DirectEditorProps中的编辑器状态(state)和dispatchTransaction是需要特殊处理的。首先编辑器状态是immutable的,需要从DirectEditorProps单独拎出来作为编辑视图的一个属性;其次对于dispatchTransaction的话,如果存在,编辑器视图的dispatch(transaction)方法会调用dispatchTransaction来处理transaction,否则直接使用EditorState.apply()来处理transaction。

既然EditorView是挂载到DOM树上的,那么它就有一个根节点,这个根节点可以是普通的DOM节点,也可以是ShadowDOM节点。

文档的绘制

由于文档是有pm-model中定义的模型所控制的,而模型中对文档的每个节点如何转化为DOM元素已有规定。编辑器视图所要做的就是按模型中的描述将文档转化为DOM元素,并且在此过程添加上各种装饰(Decoration),就是在文档转化成DOM的时候加上样式的活。

文档的装饰

文档的装饰可以用于高亮文档之类的目的。

将文档转化为DOM需要自上而下的遍历,所以装饰的过程也是自上而下。装饰对文档节点的类型是对应的,有节点装饰(Node Decoration)和平铺装饰(inline decoration),后者针对像文本这样的平铺内容。另外,还有一种装饰叫做小部件(Widget),可以在文档的任意位置插入,且跟文档没有直接关系。

既然装饰是文档节点的附属,装饰的组织方式要匹配文档的组织方式。而将多个装饰对应到一个文档节点则是采用数据结构装饰集DecorationSet,下面是一个例子:

let purplePlugin = new Plugin({
  props: {
    decorations(state) {
      return DecorationSet.create(state.doc, [
        Decoration.inline(0, state.doc.content.size, {style: "color: purple"})
      ])
    }
  }
})

上面通过EditorProps.decorations来指定文档相关的装饰集,作用于整个文档,让文档的字体变成紫色。

在使用DecorationSet.create创建装饰集的时候,会按照文档树的结构形成一套与之对应的装饰树。装饰集其实是嵌套的,每个装饰集对应文档树的一个层级,并保留有能够应用到当前层级的装饰列表;此外装饰集还有一个子装饰集做属性,用于表示那些应用于文档下一层级的装饰。每个装饰(Decoration)由一个from和to定义的文档位移构成。这两个位移指明了该装饰所能应用于的文档节点层级。装饰集越上层颗粒越粗,越往下颗粒越细,像一个倒三角形。每层装饰集所拥有的装饰列表所代表的文档位移(from和to)会被调整成相对于当前文档节点层级的位移。并且上层装饰集会被切块,使之边界上与下层装饰集对齐,这样一来文档的某个范围内,可能有若干个不同层级的装饰集应用于之上。层级最深的装饰集所带有的装饰列表,可能只应用于文档的几个字符。pm-view中使用一个buildTree函数来构建装饰集树。值得注意的是,生成装饰集树的时候,如果某装饰不能应用于当前文档节点(比如说类型不匹配),则会被丢弃。

         0~2 3~5 6~8 文档位移
装饰集层1 --- --- ---
装饰集层2 --- ---
装饰集层3 ---

需要注意的是,装饰集是作用于文档内容的,而不是作用于文档模型的。和文档树一样,装饰集也是immutable数据类型。

Leaf类型的节点是没有装饰的。

第一层装饰是加在文档的外层节点上的。这个节点的contenteditable会被设置。另外,这个节点默认会被设置一个ProseMirror的css类。可以通过指定EditorProps.attributes来向这个节点添加额外CSS类,或者其他节点属性。EditorProps.attributes可以直接是一个包含待添加属性的对象,或者一个函数。如果是一个函数,则这个函数接受编辑器状态为参数,并生成待添加属性对象。这种细节上的区分对待允许根据编辑器状态的不同来对文档的外层节点设置不同的属性,比如当文档失去焦点时将背景设成灰色等等。

视图的描述

ProseMirror是根据视图描述来管理视图的DOM,视图描述可以看成是虚拟DOM。ProseMirror会捕捉处于编辑区域的事件,然后更新相应的文档状态,然后再更新相应的视图描述,以此来匹配更新后的文档状态。

视图描述包括几类:

  • 节点描述 (Node ViewDesc),用于显示文档节点
  • 文本描述(Text ViewDesc),从节点描述继承出来的
  • 标记描述(Mark ViewDesc),用于显示平铺内容的标记
  • 部件描述(Widget ViewDesc),用于在文档任意位置插入自定义部件

其他还有自定义的视图描述,光标编辑区描述等等。

视图描述采用跟DOM类似的层级结构的,每个描述对象既包含了指向父对象的描述,又包含了子对象的集合。此外,每个视图描述对象使用dom属性指向它所使用的DOM节点,并且在该DOM节点上添加了一个pmViewDesc属性来反向索引。这看起来像是一个双向链表。为了支持更复杂的视图表示,视图描述还带有一个contentDOM属性,用来表示当前文档节点的子内容应该放置何处。contentDOM可以和dom一致,指向同一个DOM节点。对于文档的叶子节点,contentDOM为空。自定义的视图描述可以指定不同的domcontentDOM,这样可以在文档的当前节点和其子节点之间增加一层用于其他目的的DOM。

contentDOM就是在创建文档节点规格定义中toDOM属性中的0(空洞)所在位置。

不同的描述类型还带有一些其他的属性,比如节点描述带有当前文档节点,以及其对应的DOM节点,还是相应的内外装饰;标记描述则带有当前节点的标记。

如何在视图描述和文档节点树之间同步呢?这用到了一个辅助类ViewTreeUpdaterViewTreeUpdater基本上是采用深度遍历的方式来将视图描述同步到文档节点树的。

以文档标记(Marks)为例。ViewTreeUpdater有一个专门的syncToMarks()来将视图标记描述对应到文档节点的标记。

假如有下面一个文档:

  • paragraph

    • text (内容:“hi”,标记:[em, strong])
  • NodeViewDesc (对应paragraph)

    • MarkViewDesc (对应em)
      • MarkViewDesc (对应strong)
        • TextViewDesc (对应"hi")

em和strong的嵌套顺序得看这两个标记在schema中的定义顺序。

上面可以看到,syncToMarks()要将文本节点的内容描述,放置到标记描述之下。syncToMarks()是通过一个栈结构来实现这种效果的:

  • 当遇到带有[em, strong]这两个标记的hi文本节点,syncToMarks()会将对应em的MarkViewDesc和对应strong的MarkViewDesc依次压栈(虽然采用的是深度遍历,但是压栈的时候还是要考虑一些细节,比如MarkViewDesc是否需要创建;还是如果下一个节点是MarkViewDesc的话,则复用下一个节点的MarkViewDesc)。
  • 因为深度遍历的时候,有时候需要触底反弹才能去到下一个节点。所以压栈的时候要保存路径信息,每一层信息包含有两个元素,用于表示下一个需要遍及的节点在其父节点中的位置。
  • 并不是所有的时候能够复用既有的描述,有时候需要插入新的描述,然后删除冗余的旧描述。
  • 视图描述只是虚拟DOM,如果一个视图描述节点需要更新时,它会为自己设置一个dirty属性,表示需要更新。
  • 视图描述指示文档DOM的更新,深度遍历从叶子节点往上遍历,完成一个层级就调用renderDescs()来更新相应的DOM。

(本篇完)