NodeView 与 MarkView:把渲染权交给你

3 分钟阅读
·

上一篇看了 composition 与 IME,组合期间编辑器要把写入方向绕开输入法占用的节点。这篇回到常规渲染路径,看一个正式的定制口:nodeViews。默认情况下文档节点按 schema 里 toDOM 的规格渲染,DOM 完全由编辑器管理,前面两篇讲的 ViewDesc 树和增量更新覆盖的就是这条默认路径。但图片外面要套一圈工具条、代码块想嵌一个带高亮的子编辑器、嵌入的投票卡片有自己的按钮,这些场景需要把某个节点的渲染和交互整个接管过来,NodeView 就是为此留的接口。参考代码是 prosemirror-view 的 ca4c78e,主场是 src/viewdesc.tsCustomNodeViewDesc,入口在 src/index.ts 的 nodeViews prop。

系列目录

日期 标题
05-10 ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17 ProseMirror 仓库全景:22 个包怎么分工
05-24 跑通一个最小 ProseMirror:先看文档长什么样
06-07 ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14 ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21 ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05 ResolvedPos:一个数字位置怎么变成路径
07-12 Slice 与 replace:切一块文档出来再塞回去
07-19 DOMSerializer:文档怎么变成 DOM 和 HTML
08-02 DOMParser:parseDOM 规则与 HTML 解析
08-09 findDiffStart / findDiffEnd:两份文档怎么求差
08-16 model 收官:Node 上的辅助方法与位置约定总结
09-06 ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20 ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03 StepMap:一步修改怎么映射每个位置
10-11 Mapping:多步映射的链式合并,rebase 的地基
10-18 structure.ts:split/join/lift/wrap 的可达性判断
10-25 Transform 类:构建修改的 API 层
11-08 ProseMirror state(上):EditorState,不可变编辑器状态
11-15 Selection 体系:四种选区与选区书签
11-22 Transaction:Transform 加上状态语义
12-06 Plugin 系统(上):StateField 与插件状态
12-13 Plugin 系统(下):props、appendTransaction 与 filterTransaction
12-20 state 收官:动手写三个插件验证理解
01-03 ProseMirror view(上):EditorView,状态与 DOM 之间的桥
01-10 ViewDesc(上):文档到 DOM 的描述树
01-17 ViewDesc(下):增量更新怎么做到只改动的部分
02-07 DOMObserver 与 readDOMChange:浏览器改了 DOM,怎么读回文档
02-14 input.ts:从 keydown 到 dispatchTransaction 的输入管线
02-21 选区同步:state 选区与 DOM 选区的双向对齐
02-28 Composition 与 IME:中文输入法事件的处理
03-07 NodeView 与 MarkView:把渲染权交给你(本篇)

nodeViews prop 怎么进视图

DirectEditorProps 上有一对 prop:nodeViewsmarkViewssrc/index.ts),都是以类型名为键、构造器为值的对象。buildNodeViews 把两者合并进同一张表,收集走 someProp 的顺序:先是直接传给 view 的 props,再是直接传给 view 的插件,最后是 state 里的插件。add 回调里有 hasOwnProperty 检查,同名字段先注册的生效,后面的来源覆盖不了前面的。也就是说插件提供一套 NodeView 时,使用者可以在自己的 props 里同名覆盖掉其中几个。markViews 合进同一个对象是向后兼容的安排,props 注释里写明了这一点,所以后面 MarkViewDesc 创建时查的也是 view.nodeViews[mark.type.name] 这一张表。

构造器签名有两种。NodeViewConstructor(node, view, getPos, outerDeco, innerDeco),返回一个符合 NodeView 接口的对象;MarkViewConstructor(mark, view, inline),第三个参数表示该 mark 的内容是否按 inline 渲染。getPos 值得单独说:它是一个闭包,构造期间 desc 还没建出来,这时调用返回传入的 pos;desc 建好挂进树以后,返回 parent.posBeforeChild(descObj),沿 children 数组累加兄弟的 size 算出节点当前的实时位置,文档改动后拿到的值自动是最新的;父 desc 被移除后返回 undefined,调用方要处理这个值,节点已经不在文档里时再发 transaction 就是基于过期位置。

这张表本身允许变化。updateStateInner 在插件或 nodeViews prop 变化时重建表,changedNodeViews 检出差异就把 redraw 置 true,同一次更新里跳过 docView.update,直接把整棵 docView 销毁重建。运行期换掉一批 NodeView 是支持的操作,代价是一次全量重绘。

NodeView 的创建分支与 CustomNodeViewDesc 的委托

创建:NodeViewDesc.create 的分支

每个节点的渲染都过 NodeViewDesc.createsrc/viewdesc.ts)。第一步查 view.nodeViews[node.type.name],有自定义构造器就调用,拿到 spec。

spec 里 dom 为空不会报错:文本节点回退为 document.createTextNode(node.text),非文本节点回退为按 schema 的 toDOM 渲染出的 dom 和 contentDOM。自定义视图可以只提供行为、沿用默认外观。反向有一条硬约束:文本节点如果返回了自定义 dom,必须是 DOM 文本节点,否则抛 RangeError,文本的光标定位和选区换算都建立在它是文本节点这个前提上。

对无 contentDOM 的非文本节点,create 里还有一步默认处理:dom 不是 BR 且没写 contenteditable 属性时设 contentEditable = "false",节点 spec 标了 draggable 就加 draggable = true。没有内容托管区的节点默认按原子对待,光标进不去,可以整体拖走。BR 被排除是因为 Chrome 遇到 <br contenteditable=false> 会出问题,源码注释里点了这件事。

这里还有一个 dom 与 nodeDOM 的区分值得记住。用户提供的 spec.dom 记作 nodeDOM,是节点自身的 DOM;create 的最后会把它交给 applyOuterDeco,按 node 装饰的 attrs 在外面再包若干层,包完的最外层才是 desc.dom。装饰层标了 pmIsDeco 标记,后续 updateOuterDeco 会按新旧装饰的差异做增删。NodeSelection 的默认选中样式加在 nodeDOM 上,位置换算看的是 dom,两者各司其职。

分支的最后:有 spec 就建 CustomNodeViewDesc,没有就按文本与否建 TextViewDesc 或普通 NodeViewDesc。CustomNodeViewDesc 继承 NodeViewDesc,覆写 update、selectNode、deselectNode、setSelection、stopEvent、ignoreMutation、destroy 七个方法,每个都是同一个模式:spec 提供了就委托给 spec,否则走基类默认实现。类上方的注释解释了为什么用委托加接口对象,没让用户去继承 NodeViewDesc:继承会把一堆内部实现细节暴露出去,而用户代码多数用不到它们。

update 与 multiType:复用判定

ViewDesc 树的增量更新(第 27 篇)依赖「旧 desc 能不能更新成新节点」的判定。普通 NodeViewDesc 的 update 要求 node.sameMarkup(this.node),类型和 attrs 完全一致才复用。CustomNodeViewDesc 把这个判定交给了用户:

update(node, outerDeco, innerDeco, view) {
  if (this.dirty == NODE_DIRTY) return false
  if (this.spec.update && (this.node.type == node.type || this.spec.multiType)) {
    let result = this.spec.update(node, outerDeco, innerDeco)
    if (result) this.updateInner(node, outerDeco, innerDeco, view)
    return result
  } else if (!this.contentDOM && !node.isLeaf) {
    return false
  } else {
    return super.update(node, outerDeco, innerDeco, view)
  }
}

三个分支。spec 提供了 update 且类型匹配(或开了 multiType 放行任意类型),调用户的 update:返回 true 则接着走 updateInner,它依次刷新外层装饰、替换 this.node、更新 innerDeco,有 contentDOM 时跑 updateChildren 同步子内容,最后清掉脏标记;返回 false 则复用失败。复用失败之后走到 ViewTreeUpdater 的后续策略:先问后面的兄弟 desc 能不能接,再接不住就 addNode 销毁旧的、建新的,自定义视图的 destroy 和构造在此时成对发生,所以 destroy 里一定要把自己加的监听和计时器清干净。spec 没提供 update、又没有 contentDOM、新节点还不是叶子,说明这个自定义视图管不了内容,直接返回 false 等重建。其余情况退回基类的 sameMarkup 判定。

multiType 对应一个视图类服务多种节点类型的场景,比如统一渲染所有嵌入卡片类节点。代价是 update 里拿到的可能是任何类型的节点,要自己判断接不接,接不了的类型返回 false。

update 收到的另两个参数是装饰:outerDeco 是贴在节点外面的 node 装饰数组,innerDecorations 是作用于节点内容的 DecorationSource。两者编辑器都会自己画好,接口注释明说用户视图可以忽略;想做嵌套编辑器这类场景时,可以把 innerDecorations 转交给内部的编辑器实例消费。

选中、选区与事件

NodeSelection 落到节点上时,selectNode 和 deselectNode 成对调用。默认实现(NodeViewDesc.selectNode)是给 nodeDOM 加减 ProseMirror-selectednode 这个 class,顺带维护 draggable 属性。自定义视图覆写后可以做自己的选中样式,约定是写了 selectNode 就要写 deselectNode 把效果撤掉。

setSelection 处理「选区整个落在该节点内部」的情况,anchor 和 head 是相对节点起点的局部位置。到达这里之前有一层下钻:基类 ViewDesc.setSelection 先扫 children,选区整个落在某个子 desc 里就减去偏移后递归给它,所以自定义视图的 setSelection 只在选区完全归它管的时候被调用。选区一端在自定义视图内、另一端在外面的跨界情况,注释里明说这套机制覆盖不了,退化为按 domFromPos 的结果尽力设置。默认实现按 domFromPos 换算后设置 DOM 选区;嵌套编辑器这类自定义视图可以覆写它,把选区转交给内部编辑器。

stopEvent 在事件分派前被 eventBelongsToViewsrc/input.ts)消费。这个判定有两个前置分支:不冒泡的事件直接算编辑器的,已经 defaultPrevented 的直接不算;之后从 event.target 沿祖先链向上走,途经每个带 pmViewDesc 的 DOM 节点都问一句 stopEvent(event),任何一个返回 true,编辑器就当这个事件不归自己管。自定义视图内部的输入框和按钮由此不会被编辑器的 keydown 管线拦截。spec 没提供时缺省返回 false,基类 ViewDesc 的 stopEvent 同样也是 false,即默认所有事件都归编辑器。

destroy 在视图被移除或编辑器销毁时调用,CustomNodeViewDesc 先调 spec.destroy(),再走基类清理:断开 parent、清掉 dom 上的 pmViewDesc 反向指针、递归销毁子 desc。自定义视图在这里解绑自己的事件监听、销毁内部组件。

contentDOM 的内容托管

NodeView 接口里最影响行为的是 contentDOM。提供了它,等于把内容区托管给编辑器:子节点由编辑器渲染进去,updateInner 照常跑 updateChildren,装饰照常画。不提供,子节点的渲染归自定义视图自己负责,编辑器连子 desc 都不建,updateInner 里 if (this.contentDOM) 那一步直接跳过,children 数组保持为空。

用带说明文字的图片节点把这个约定过一遍。假设 schema 里有一个图片节点,内容表达式是 inline*,放说明文字。NodeView 的 dom 建成 figure,里面放一个 img、一排操作按钮,再指定 figcaption 为 contentDOM。结果是:说明文字由编辑器渲染进 figcaption,光标可以进去正常编辑,diff 读回也只认 figcaption 里的内容;按钮在 figure 内、figcaption 外,点击事件用 stopEvent 拦下,按钮区的 mutation 用 ignoreMutation 返回 true 挡掉,整个外壳对文档透明。文档模型里只有一个图片节点和它的内容,视图层多出来的 DOM 一概不进模型。

托管约定在读回方向同样生效。NodeViewDesc.parseRule 给 domchange 的解析提供规则:有 contentDOM 且没丢(contentLost 为 false,即 contentDOM 仍在 dom 内部)时,规则带 contentElement: this.contentDOM,DOMParser 只从这里读内容;没有 contentDOM 时规则带 getContent: () => this.node.content,解析时直接取文档里的现有内容,不去 DOM 里找。自定义视图加在内容区之外的工具条和按钮,因此不会被读回文档。contentLost 为 true 说明浏览器把 contentDOM 弄丢了,Chrome 退格时偶尔会重建父节点,这时退回从子 desc 的 DOM 位置推断 contentElement,找不到就给空 Fragment。

mutation 的默认策略也按 contentDOM 分野。基类 ViewDesc.ignoreMutation!this.contentDOM && mutation.type != "selection":有内容托管区时,内容区的 mutation 默认不忽略,要读回;没有托管区时,内部 mutation 默认全部忽略,选区变化除外。自定义视图可以用 spec.ignoreMutation 覆写,返回 true 表示这条 mutation 可以安全忽略,返回 false 则编辑器会重读选区或重解析附近范围。DOMObserver 的 registerMutation 和 ignoreSelectionChange 两处(src/domobserver.ts)都是先 nearestDesc 找到负责的 desc,再把记录交给它判断。沿用上面的 figure 例子:figcaption 里的文字变化照常读回;按钮区的变化在 contentDOM 之外,默认策略不会忽略它,registerMutation 里有一条专门分支,mutation 落在 contentDOM 之外时按 posBefore 到 posAfter 的整个节点范围重读。所以带 contentDOM 的自定义视图通常要补一个 ignoreMutation,target 不在 contentDOM 内就返回 true,把外壳区的变化挡在读回之外。

把几个方法串起来看嵌套编辑器这个最重的场景:代码块节点配一个内部 EditorView 的 NodeView。dom 是外层容器,不给 contentDOM,内部编辑器自己渲染文档内容;update 里比较 attrs,语言标记变了就重建内部编辑器的高亮配置,返回 true 保住视图复用;stopEvent 把键盘事件全部拦给内部编辑器;setSelection 把选区换算后设进内部编辑器;ignoreMutation 对 selection 类型的记录返回 true,避免外层把内部选区变化当成要重读的信号;内部编辑器的 dispatchTransaction 里用 getPos 拿到自身在外层文档的位置,把内容变化翻译成外层的 transaction 写回去。七个方法各管一段,合起来外层编辑器把这个子文档当作一个普通的叶子看待。

MarkView 的差异

MarkView 接口是 NodeView 的子集:dom、contentDOM、ignoreMutation、destroy,没有 update、selectNode、setSelection、stopEvent。差异来自复用机制的不同。mark 的复用不靠 update:MarkViewDesc.matchesMarkmark.eq(this.mark) 判定,类型和 attrs 全等就整个复用,不等就在 syncToMarks 里销毁重建。mark 没有独立的 attrs 更新路径,所以不需要 update 方法。props 注释里也点了这个限制:markViews 只提供自定义渲染,给不了 NodeView 那种动态行为。

创建走 MarkViewDesc.create:从合并表查构造器,以 (mark, view, inline) 调用;没注册或返回的 spec 没有 dom,就按 MarkSpec 的 toDOM 渲染。contentDOM 缺省时直接用 dom 本身当内容容器,mark 的内容总是由编辑器渲染进 contentDOM,没有自管内容的选项。一个典型用法是给 link mark 换渲染:dom 建成带特定 class 的 a,href 从 mark.attrs 取,attrs 变了整个重建,成本由 mark 通常很短这个事实兜着。

委托只剩两个口子:MarkViewDesc.ignoreMutation 先问 spec.ignoreMutation,没有就走基类默认;destroy 先调 spec.destroy 再做基类清理。脏标记的处理也不一样:MarkViewDesc.markDirty 覆写后会把脏状态上移给最近的 node desc,自己恢复干净,mark desc 不持有脏状态,重绘统一由节点层发起。mark 的 parseRule 返回 {mark, attrs, contentElement} 供读回解析;desc 被标成 NODE_DIRTY 或 mark spec 开了 reparseInView 时返回 null,强制重新解析。

小结

NodeView 的机制可以收成三句话。nodeViews prop 提供构造器,create 时包成 CustomNodeViewDesc 挂进 ViewDesc 树;树对自定义视图的所有交互,更新复用、选中、选区、事件、mutation、销毁,都走委托,spec 没提供的方法落回基类默认行为;contentDOM 是托管边界,提供了它,内容渲染、装饰、读回解析全由编辑器接管,自定义视图只负责外壳。MarkView 是同一思路的简化版,只留渲染定制和 mutation 控制。下一篇看 Decoration,不改文档、只在视图上叠加标注的体系,widget 装饰和这里的自定义视图有一条相通的接缝。


1009 字 · 36 段落
xi ming

Written by xi mingFollow onGitHub