ProseMirror model(中):Mark,内联格式怎么挂在文本上

2 分钟阅读
·

上一篇把 Node 与 Fragment 看完了,Node 的四个构造参数里 type、attrs、content 都讲过,剩下 marks 没有展开。粗体、斜体、链接这类内联格式就存在这个字段里,这一篇专门看它:Mark 的数据结构,mark 集合的增删和比较,schema 层面的 excludes 互斥规则,以及一个更根本的问题:这些格式为什么不做成嵌套节点。参考代码是 prosemirror-model 的 6264de0,举例用的基础 schema 来自 prosemirror-schema-basic 的 756726f。

系列目录

日期 标题
05-10 ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17 ProseMirror 仓库全景:22 个包怎么分工
05-24 跑通一个最小 ProseMirror:先看文档长什么样
06-07 ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14 ProseMirror model(中):Mark,内联格式怎么挂在文本上(本篇)

Mark 不进树

先看 src/mark.ts 的 Mark 类本身,全部字段就两个:type(MarkType)和 attrs。没有 content,没有 children,没有指向文档任何位置的指针。和 Node 对照着看,不对称很明显:Node 通过 content 递归组成整棵树,Mark 只是个标签,记录着「这段内容带某种格式」以及格式的参数(比如链接的 href)。

Mark 的存放位置在 Node 上。src/node.ts 的 Node 构造器第四个参数 marks = Mark.none,默认是空数组常量。换 marks 走 mark(marks) 方法,返回一个共享 type、attrs、content 的新节点,TextNode 上有个同名的 override 返回 TextNode。这和 Node 其他修改方法一个路子:不可变,改动即新建,旧节点不变。

schema 控制哪些节点允许带 mark。src/schema.ts 构造 Schema 时给每个 NodeType 算出 markSet 字段:NodeSpec.marks 写 "_" 时 markSet 为 null,表示全部 mark 都允许;写了具体表达式(空格分隔的 mark 名或组名)时用 gatherMarks 收集成数组;空字符串或者这个节点根本没有 inline 内容时,markSet 是空数组,一个 mark 都不许挂。校验入口是 NodeType.allowsMarks,反向操作是 allowedMarks,把集合里不允许的 mark 滤掉。所以「Mark 挂在 inline 节点上」这个说法准确讲是 schema 的约定:模型层的字段任何节点都有,只是默认配置下不含 inline 内容的节点拿不到挂 mark 的资格。schema-basic 里 code_block 的 marks: "" 就是这个开关的典型用法,代码块内的文字不允许带任何格式。

序列化时 marks 的位置也能印证这个结构。Node.toJSON 把 marks 序列化成节点对象上的一个数组,空集合时整个字段省略;Mark.toJSON 产出 {type, attrs?},attrs 为空时同样省略。反序列化走 Mark.fromJSON:按名字从 schema.marks 里查 MarkType,查不到直接抛 RangeError,然后用 type.create(json.attrs) 建 mark,再过一遍 checkAttrs 校验属性齐全。文档 JSON 里看不到任何 mark 组成的层级,只有每个节点自带的一份清单。

构造入口还有一个 Mark.setFrom,把外部传入的 null、单个 mark 或未排序数组统一成规范集合:空值归一到 Mark.none,单个 mark 包成数组,数组则拷一份按 rank 排序。调用点很集中:Schema.textNodeType.createcreateChecked 收到的 marks 参数都经它过一道,Node 构造器本身不整理传入的数组,默认调用方给的就是规范集合。经过这层收口,进到节点上的集合一定满足下面要说的不变式。

mark set 的三个操作

一个节点上的 marks 是个数组,但代码里处处把它当集合处理,叫 mark set。集合有两个不变式:元素按 MarkType.rank 排序,没有重复。rank 是 MarkType.compile 时按 mark 在 schema 里声明的顺序编的号,0、1、2 依次递增。这个编号只在集合内部当排序键用,不参与 eqexcludes 的判断;同一 schema 内它固定不变,两个集合逐位置比较才有意义,DOM 序列化时也有稳定的包裹顺序,这些后面都会用到。

围绕这个集合有三个基本操作,都在 src/mark.ts

addToSet 往集合里加一个 mark,一次循环处理四种情况:集合里已有相等的 mark(eq 判定),直接返回原数组,引用都不变;新 mark 排除某个已有 mark(this.type.excludes(other.type)),把那个已有 mark 剔除;已有 mark 排除新 mark 且新 mark 不反过来排除它,添加失败,返回原数组;其余情况按 rank 找到插入位置。整个方法写得很节俭,只在确实要改动时才 slice(0, i) 拷一份前缀,没改动就原样返回输入。

拿 schema-basic 走一遍具体例子。它的 marks 按 link、em、strong、code 的顺序声明,rank 依次是 0 到 3。一个文本节点当前集合是 [em, code],往里加 strong:strong 默认只排除自己,集合里 em 和 code 都不排除它,没有冲突;扫到 code 时 code 的 rank 3 大于 strong 的 rank 2,strong 插到 code 前面,结果是 [em, strong, code]。如果在 [em, strong, code] 上再加一次 strong,循环第一步就命中 eq 分支,原数组直接返回,这也是「重复加粗不累积」的实现方式。

removeFromSet 简单得多,找到第一个 eq 相等的元素剔掉,找不到就返回原数组。MarkType 上也有一个同名方法 removeFromSet,语义不同,是按类型剔除集合里所有同类型的 mark,不看 attrs。加粗一段文字时用的是后者:不管这段文字上 strong 的参数是什么,统一摘掉。

Mark.sameSet 比较两个集合,一层层往下有三级短路:两个数组引用相同(a == b)直接 true;长度不等直接 false;然后才逐位置 eqeq 同样是先比引用,引用相同直接 true,否则比 type 相同且 compareDeep(attrs)src/comparedeep.ts 的 compareDeep 是个递归的深比较,数组比长度逐元素,对象比键集合逐值。这里有个性能上的安排值得注意:MarkType 构造时,如果所有属性都有默认值(没有属性的 mark 自然也满足),就预建一个 instance 存在类型上,create 不传 attrs 时直接返回这个对象,不走 new。粗体、斜体这种没有参数的 mark,常规路径下同一个 MarkType 只存在一个 Mark 对象,集合比较时绝大多数命中 eq 的第一支 this == other;再往上,没动过 marks 的节点共享同一个数组引用,sameSet 的第一支就返回了,深比较根本轮不到执行。

这些操作的一个实际消费方是 Node.checksrc/node.ts):它用 addToSet 把节点的 marks 逐个加进空集合重建一遍,再和原集合做 sameSet,不等就说明这个集合没排好序或者混进了互相排除的 mark,直接抛 RangeError。不变式的维护成本因此集中在了 addToSet 一个函数里,别处只管调用。

查询一侧的入口是 isInSet,同样分两个版本:Mark.isInSeteq 找完全相等的,MarkType.isInSet 只按类型找,返回找到的那个 mark(拿不到就拿 undefined)。Node.rangeHasMark(from, to, type)nodesBetween 遍历一个范围,对每个节点调 type.isInSet(node.marks),命中即停。「选区里是不是已经有 strong」这类判断在模型层就靠它,命令层的加粗切换逻辑建在这个查询上面。

excludes:单向声明的互斥

MarkSpec.excludes 决定一个 mark 能和哪些 mark 共存,值是空格分隔的 mark 名或组名。Schema 构造器里有一段编译逻辑(src/schema.ts):没写 excludes 时默认只排除自己,写成空字符串则谁都不排除,"_" 排除 schema 里所有 mark。编译结果存在 MarkType 的 excluded 数组里,运行期的 excludes(other) 就是一次 indexOf

互斥是单向声明的,两个方向要分开看。假设 mark A 声明排除 B,B 没有声明排除 A:往一个已经带 B 的集合加 A,B 被剔掉,A 留下;反过来往已经带 A 的集合加 B,addToSet 走到「对方排除我而我不排除对方」的分支,添加失败,集合原样返回。谁说了算由排除声明的方向决定。

默认行为(只排除自己)覆盖了一个常见需求:同类型的 mark 不能叠加。schema-basic 的 link 带 href 和 title 两个 attrs,一段文字不可能同时是两个链接,靠默认的 excludes 就保证了集合里 link 至多一个,再加新 link 时旧的被替换。想让同类型 mark 共存(比如允许一段文字叠多个不同颜色的批注 mark),把 excludes 显式设成空字符串,同类型不同 attrs 的 mark 就能挂在同一集合里。这里有个边角值得记住:判断只看编译出的 excluded 数组里有没有自己,所以 excludes 写成任何不含自己名字的字符串(比如只列了别人),效果都等同于允许同类型共存,空字符串只是最直白的写法。

excludes 的值里除了 mark 名还能写组名。gatherMarks 解析名单时,先按名字查 schema.marks,查不到就把这个条目当组名,收集所有 group 声明里含它的 mark;"_" 是兜底,匹配全部。MarkSpec.group 和 NodeSpec.group 是同一套组机制,给一个自定义 mark 声明 group: "annotation",别的 mark 写 excludes: "annotation" 就能整组互斥,不用逐个列名字。

spanning 与 inclusive:留给下游的两个字段

MarkSpec 上还有两个字段,model 层只负责存储,消费方在别处,这里先记下语义。

spanning 默认为 true,消费点在 src/to_dom.tsserializeFragment。序列化一个 fragment 时维护一个 active 栈记录当前打开着哪些 mark 元素,逐个节点比较 marks 前缀:相邻两个节点开头几个 mark 相同,这些 mark 的 DOM 元素就保持打开,内容继续往里填,一份 mark 跨多个文本节点只渲染一层元素。spanning === false 时这个前缀比较直接断开,每个节点重新开关一次元素。跨度语义因此只是个序列化提示,文档结构里 mark 本来就存在每个节点上,跨不跨节点渲染不产生结构差异。渲染顺序顺带说一下:集合按 rank 排序,序列化按数组顺序逐层包裹,rank 小的 mark 在最外层,所以 schema 里 mark 的声明顺序会反映到最终的 HTML 嵌套上。

inclusive 默认为 true,控制光标停在 mark 边界(结尾处,或恰好是父节点开头的开头处)时这个 mark 是否算「激活」,决定接着输入的文字带不带这个格式。schema-basic 的 link 写了 inclusive: false,所以光标停在链接末尾再打字,新文字不会并进链接。消费它的代码在 state 和 transform 层,等讲到 storedMarks 再展开。

为什么粗体斜体不做成嵌套节点

回到开头那个问题。HTML 里格式是嵌套元素,<strong> 套着 <em>,照搬到文档模型里似乎也行:strong 节点包一段内容,em 节点再包一段。ProseMirror 没这么做,原因是区间交叉。

Mark 的扁平存储与假想嵌套节点的对照

考虑一段文字 “abcde”,strong 覆盖 “abc”,em 覆盖 “cde”,两个区间在 “c” 上重叠。嵌套表示里 em 节点的内容要么完整落在 strong 内部,要么完整在外部,[2, 5) 这个区间横跨 strong 的右边界,两种位置都放不进去。唯一的出路是沿格式边界把内容切碎,strong 包 “ab”,strong 和 em 共同包 “c”(还得选谁在外层),em 包 “de”。这样每个格式都是规整的嵌套,但格式边界全部固化成了树的结构:把 em 的右边界从 5 挪到 4,对应的是拆掉两层节点重新拼。插入一个字符、在格式边界处断段、合并两个段落,每个操作都要先处理格式嵌套的重组。

扁平表示把格式和结构解开了。每个文本节点自带一份 marks 数组,strong 覆盖哪里就把范围内文本节点的数组里放上 strong,范围怎么重叠都行,树的形状从头到尾不变。加粗一段文字的操作退化成:找到范围内的文本节点,按边界切开,给中间部分的 marks 数组各加一个元素。这个操作不涉及任何父子关系调整,后面 transform 篇会看到 AddMarkStep 就是一个范围加一个 mark 的纯数据描述,撤销和协作合并都因此简单。

代价也有:相邻文本节点各自存一份集合,“ab” 和 “c” 都带着 strong,数据上有重复。模型层用几个办法摊薄这份开销。结构层面,Fragment.fromArraysrc/fragment.ts)组装子节点数组时会检查相邻文本节点的 sameMarkup,type、attrs、marks 全同的直接合并成一个文本节点,marks 完全一致的连续文字在树里本来就只占一个节点,图里 “ab” 和 “c” 分成两个节点正是因为它们的集合不同。引用层面,addToSet 这类方法不改动就返回原数组,大量节点共享同一个集合引用;无参数的 mark 又走 MarkType 上的 instance 单例。整段加粗这种常见场景下,一排文本节点的 marks 字段指向同一个数组,数组里的 strong 是同一个对象,重复的只是引用本身。

扁平存储也不妨碍和 HTML 互转,嵌套只出现在序列化格式里。导出方向前面 spanning 那节已经看过,to_dom 把 marks 重新包成嵌套元素;导入方向走 src/from_dom.ts,解析器沿 DOM 树向下时把命中的 mark 一路收集进一个数组,遇到文本就调 schema.text(value, marks) 挂到文本节点上,<strong><em>c</em></strong> 解析回来就是 marks 为 [em, strong] 的一个文本节点。HTML 的嵌套在进出两个方向都被翻译成扁平集合,内存里的树从头到尾只有一种形状。

这一篇把 Mark 看完了:它不进树,以按 rank 排序的数组挂在节点上,addToSet 维护集合不变式,excludes 提供单向互斥,spanning 和 inclusive 留给序列化和输入层消费。至此 Node 的四个字段都讲完了。下一篇看 schema 本身:NodeSpec 和 MarkSpec 的完整字段,content expression 怎么编译成匹配器,文档的「类型系统」怎么校验内容。


869 字 · 35 段落
xi ming

Written by xi mingFollow onGitHub