Decoration 体系:不修改文档的视觉标注

6 分钟阅读
·

上一篇看了 NodeView 和 MarkView,自定义渲染接管的是文档节点本身的画法。实践中还有一类需求和文档内容无关:空文档的占位符、协作者的光标、搜索命中的高亮、被选中节点的边框。这些标注写进文档会污染序列化结果和协作同步,ProseMirror 给它们的机制是 Decoration:挂在文档位置上的视觉标注,只参与渲染,文档数据不动。这篇把这个体系拆开:三种装饰类型、DecorationSet 怎么组织一批装饰、文档变化时装饰怎么跟随、渲染侧怎么消费。参考代码是 prosemirror-view 的 ca4c78e,主场是 src/decoration.ts,消费方在 src/viewdesc.tssrc/index.ts

系列目录

日期 标题
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:把渲染权交给你
03-14 Decoration 体系:不修改文档的视觉标注(本篇)

Decoration 类和三种类型

Decoration 本身只有三个字段:fromtotype。from 和 to 是文档位置,widget 的两个值相等,表示一个点。type 是 DecorationType 接口(src/decoration.ts)的实现,接口要求 map、valid、eq、destroy 四个方法,实现有三类,对应 Decoration 上的三个静态构造器。

Decoration.widget(pos, toDOM, spec) 在某个位置插入一个 DOM 节点。toDOM 可以直接传节点,更常见的写法是传函数 (view, getPos) => DOMNode,widget 真正被画出来时才调用,getPos 供它查询自己的当前位置,和 NodeView 的 getPos 约定一致。widget 不占文档长度,ViewDesc 树上对应的 WidgetViewDesc 没有子节点,size 为 0。

Decoration.inline(from, to, attrs, spec) 给范围内每个 inline 节点附加属性。attrs 的类型是 DecorationAttrs,三个键有特殊语义:nodeName 会把目标包进一个该标签的元素,其余属性落到这个包裹元素上;class 追加到目标已有的 class 后面;style 追加到已有 style 后面。其余键直接当 DOM 属性设置。

Decoration.node(from, to, attrs, spec) 要求 from 和 to 精确指向一个节点的前后边界,属性只加给那一个节点。NodeType.valid 用 findIndex 验证这个约定:span 起点必须是某个非文本子节点的起点,终点必须恰好是它的结尾,差一点都算非法,建树时直接丢弃。

三种类型都把 spec 原样挂在 decoration.spec 上,插件可以塞任意自定义字段。后面 find 的过滤谓词和 onRemove 回调拿到的都是这个 spec,插件靠它认回自己的装饰。eq 和 destroy 两个方法服务于增量更新:eq 判断两个装饰是否相同,相同就不重画;destroy 在装饰对应的 DOM 被拆掉时回调,目前只有 WidgetType 有实际实现,另外两种是空方法。

widget 的 spec 细节

widget 的 spec 选项最多,逐项过一遍:

  • side,默认 0。为负时 widget 画在该位置的光标之前,在该位置插入的内容落在 widget 之后;为零或正时相反。同一位置有多个 widget 时按 side 升序排(iterDeco 里的 compareSide)。spec.marks 缺省时 side 还决定 widget 被哪一侧的 mark 包裹:负值取位置之前节点的 mark,正值取之后的。
  • key,比较依据。默认两个 widget 的相等判断走 WidgetType.eq:toDOM 引用相同且 spec 逐项相等,或者 key 相同。装饰每次渲染即时生成、不想缓存 DOM 节点时,给一个稳定的 key 就能让增量更新认出它还是原来那个。key 相同的 widget 必须可互换,事件处理器行为不同就得给不同的 key。
  • stopEvent:从 widget 内部冒泡出来的 DOM 事件,返回 true 的编辑器不再处理。widget 里放按钮、输入框时靠它接管交互。
  • ignoreSelection:置 true 后 widget 内部的选区变化不触发编辑器的选区同步。
  • destroy:widget 被移除或编辑器销毁时回调,参数是 DOM 节点,清定时器和事件监听用。
  • rawWidgetViewDesc 默认把非元素节点包进一层 span,并加 contentEditable=false 和 ProseMirror-widget class;raw 跳过这层处理。updateCursorWrappersrc/index.ts)包光标的那张 img 就走 raw。

另有一个 relaxedSide:默认光标在 widget 位置时被严格限制在 side 指定的一侧,置 true 后允许 DOM 选区停在另一侧,但键盘移动不会自动访问 widget 两侧,需要自己补处理。

渲染一侧的 WidgetViewDescsrc/viewdesc.ts)也值得带一笔。toDOM 是函数时在构造 desc 的那一刻调用,拿到的 getPos 闭包靠 posBeforeChild 实时算位置。parseRule 返回 {ignore: true},读回和粘贴时 widget 的 DOM 不参与解析。matchesWidgetwidget.type.eq 判断旧 desc 能否复用,key 就是在这里发挥作用的。ignoreMutation 忽略除选区外的全部 mutation,选区变化看 ignoreSelection。widget 在 ViewDesc 树上被当作原子节点(domAtom),坐标换算和事件归属都把它当一个整体。

DecorationSet:局部数组加子树三元组

一个插件一次可能给出几十上百个装饰,渲染时每个 ViewDesc 只关心自己范围内的那几个。DecorationSet 就是为这个查询组织的结构,同时是不可变的:add、remove、map 都返回新集合,不碰旧值。

字段就两个。local 是属于当前层的装饰数组,按 byPos 排序(先比 from,再比 to)。children 是扁平数组,每三项一组:子节点起始偏移、结束偏移、子节点对应的 DecorationSet。整个集合沿文档结构递归:完全落在某个子节点内部的装饰沉到那个子节点的子集里,跨节点的 node 装饰和点位置的 widget 留在当前层的 local。

DecorationSet.create(doc, decorations)buildTree 建这棵树,它会消费传入的数组:takeSpansForNode 把被子节点收走的装饰从数组里置 null,剩下的进 local。要保留原数组得自己先拷贝一份。建树过程中每个装饰过一次 type.valid,非法的丢弃,传了 onRemove 就回调。

建好之后的增量维护还有两个方法。add(doc, decorations) 把一批新装饰并入现有集合:addInner 沿文档结构把能下沉的装饰沉到对应子树,子树不存在就现场 buildTree 建一个插进 children 的对应位置,沉不下去的进 local 后重排,进 local 前同样过一遍 valid。remove(decorations) 按 eq 逐条匹配删除,子树删空了就从 children 里摘掉,整层删空返回 empty。两个方法都保持不可变语义,没匹配到任何删除时 removeInner 直接返回原集合。装饰基数大、每轮只动一小部分时,remove 加 add 比整棵 create 重建便宜;基数小就直接 create,代码更简单。

DecorationSet 按文档结构分组

DecorationGroup 实现了同一个 DecorationSource 接口,把多个 DecorationSet 合成一个用:map、forChild、eq、locals 都是逐成员操作再合并。DecorationGroup.from 做了两个短路:零个成员返回 empty,一个成员直接返回它本身,避免无谓的包装。

map:装饰怎么跟着文档走

文档变了装饰不能丢,也不能瞎跟。DecorationSet.map(mapping, doc, options) 分两层:mapInner 处理 local,mapChildren 处理子树。三种类型的映射规则不一样,这是它们语义差异的落点。

WidgetType.map 用 mapResult 映射位置,assoc 取 side < 0 ? -1 : 1。位置被删除(deleted)就返回 null,widget 跟着消失。InlineType.map 给两端分别取 assoc:from 用 inclusiveStart ? -1 : 1,to 用 inclusiveEnd ? 1 : -1。默认行为是贴着边界插入的内容不进装饰范围,两个 inclusive 选项把它改成包含。映射完 from >= to 说明范围被删空,返回 null;InlineType.valid 也只查这一条。NodeType.map 两端都用 mapResult,from 取 assoc 1、to 取 -1,任一端 deleted 或 to <= from 就丢;映射完还要过 NodeType.valid,新文档里 from 和 to 之间必须仍然恰好是一个非文本节点。

mapInner 对 local 逐个跑这套规则,被丢弃的触发 options.onRemove(spec)。插件在 spec 里存了和装饰对应的外部状态时(比如匹配条目的 id),靠这个回调同步清理,避免装饰没了、状态还挂着。

举个具体例子把三种规则串一遍。协同光标是位置 10 的 widget,他人在位置 5 插入 5 个字符,widget map 到 15,照常显示;他人选中 8 到 12 删掉,mapResult 报 deleted,widget 消失,onRemove 通知插件。搜索高亮是 10 到 20 的 inline 装饰,他人在位置 10 处插入字符,默认 assoc 下新字符不进高亮,inclusiveStart 置 true 则进;整段 10 到 20 被删,from >= to,装饰丢弃。表格单元格的选中态是 node 装饰,单元格前后的内容随便改,map 后两端重新对齐,仍然恰好落在那个单元格上;单元格本身被删,deleted 或 valid 校验失败,装饰消失。

子树的映射在 mapChildren,整个文件里最绕的一段,分三轮。第一轮遍历 mapping 里每个 StepMap 的区间,给 children 三元组打标记:修改区间碰到子节点范围的,把三元组中间的 end 标成 -1(修改从内部碰的)或 -2(修改从边界或更早位置碰的);完全落在修改之后的子树,起止偏移直接加 dSize 平移。第二轮处理标 -1 的:映射起止位置,在新文档里 findIndex 找对应子节点,起止仍对齐同一个节点就递归 mapInner 复用子树;对不上就留着标记等第三轮,递归完子树变空的直接从 children 摘掉(里面的装饰在递归时已经按丢弃处理过)。第一轮标 -2 的不给复用机会,进第二轮时转回 -1 直接等重建。第三轮在 mustRebuild 时触发:把还挂着标记的子树里的装饰全部收集出来(mapAndGatherRemainingDecorations),逐个 map 后交给 buildTree 按新文档重建,再插回 children 数组。

这个分层的意图很实际:没被碰到的子树原样保留引用,被碰到的尽量递归复用,只有结构对不上的才整段重建。增量更新时 ViewDesc 拿 innerDeco.eq 做比较,引用没变就快速通过。

查询面:find、forChild、locals

find(start, end, predicate) 返回与范围相交的装饰。findInner 递归时累计 offset,子集的装饰 copy 出加上偏移的新对象,调用方拿到的是文档级坐标。predicate 按 spec 过滤,插件用它从一堆装饰里挑出自己的那部分。

forChild(offset, node) 提取和某个子节点相关的部分:子树直接取出,local 里与子节点范围相交的 inline 装饰切成相交段带下去,from 和 to 重算成子节点内部坐标,widget 和 node 装饰不下发。返回可能是单个 DecorationSet,也可能是 local 切片加子树组成的 DecorationGroup。

locals(node) 是渲染侧用的。localsInner 有个分支值得注意:node.inlineContent 为假(块套块的节点)时,local 里的 inline 装饰被滤掉,因为它们只对 inline 内容有意义,已经通过 forChild 下发到内层。locals 返回前再过 removeOverlap:扫描排序后的数组,把部分重叠的范围切开,切到只剩完全重叠和不相交两种关系,渲染时每个文本切片的装饰集合就是确定的。没有重叠时原数组直接返回,这是常见路径。

viewDecorations 与渲染消费

装饰从插件到 DOM 的入口是 viewDecorationssrc/decoration.ts 末尾):someProp(“decorations”) 收集所有插件和顶层 props 给出的 DecorationSource,加上 cursorWrapper(包光标的占位 widget,IME 那篇碰到过它),DecorationGroup.from 合成一个。EditorView 构造和 updateStateInner 都拿它和文档一起建或更新 docView。

编辑器外壳的属性也走装饰这条路:computeDocDecosrc/index.ts)把 attributes prop 变成一个挂在 doc 节点上的 node 装饰,范围是 0 到 doc.content.size。它作为 outerDeco 的初始值传入,和插件装饰走的 innerDeco 通道分开。

渲染侧的消费在 iterDecosrc/viewdesc.ts)。它先 locals 拿当前层装饰,遍历子节点时:位置处的 widget 回调出去,同一位置多个 widget 先按 side 排序;active 数组维护当前贯穿这个位置的范围装饰,连同压在子节点上的 node 装饰一起作为 outerDeco 传给子节点,forChild 的结果作为 innerDeco。inline 装饰的边界落在文本中间时,iterDeco 把文本节点 cut 成两段,restNode 留下一段下一轮处理,保证每段文本的装饰集合一致。

widget 的 mark 包裹在 updateChildren 的回调里决定:spec.marks 有值时按它包;缺省且 side 非负、又不嵌在别的节点内部时,用后面一个子节点的 mark 包(位置已在末尾就用空 mark 集);side 为负则沿用回调之前的 mark 上下文,也就是前面节点的 mark。之后 placeWidget 尝试复用已有 desc,复用条件就是上面说的 matchesWidget。

NodeViewDesc 一侧,computeOuterDeco 把 outerDeco 的属性合成若干层级:attrs.nodeName 每出现一次就多包一层该标签的元素;nodeDOM 是文本节点时挂不了属性,needsWrap 会补一层 span 或 div;class 和 style 按追加语义合并。增量更新时 matchesNode 里的 sameOuterDeco 加 innerDeco.eq 决定这棵子树要不要重画,DecorationSet 的不可变性让这两个比较都足够便宜。

典型场景

占位符:文档为空时在段落位置放一个 widget,画一行灰字提示。side 给负值,保证随后输入的字符落在 widget 之后,map 时也按 side 对应的 assoc 走。

协同光标:每个远端用户两个装饰,一个 widget 画光标竖线(key 用用户 id,stopEvent 吞掉内部事件),一个 inline 装饰给对方的选区范围套背景色。远端 transaction 改文档时靠 map 跟随,用户掉线或装饰被删时 onRemove 清理。

行内标注:搜索高亮、拼写检查的波浪线、评论标记,都是 inline 装饰加 class 或 style。范围可能部分重叠时(两个搜索结果首尾相接不算,真正的交叉才会)removeOverlap 负责切开,插件不用自己处理。

节点级状态:表格里选中的单元格、拖拽目标的提示框,用 node 装饰加 class。valid 的对齐检查保证装饰精确落在一个节点上,节点被删时装饰自动消失,不用额外清扫。

这一篇把装饰体系看完了:三种类型的语义差异、DecorationSet 的结构与映射、从插件 prop 到 DOM 属性的完整链路。下一篇离开渲染主题,看剪贴板:serializeForClipboard 怎么把选区序列化成能粘贴的 HTML,parseFromClipboard 又怎么接回 DOMParser。


1057 字 · 49 段落
xi ming

Written by xi ming You should follow him on Github