schema-basic:官方基础文档结构

3 分钟阅读
·

上一篇结尾说要看 schema-basic。前四十一篇里 schema 一直是抽象概念:model 篇讲过 NodeSpec 的字段、内容表达式怎么编译成匹配自动机,但一份真实可用的 schema 长什么样,还没有完整看过一个实例。prosemirror-schema-basic 就是官方给出的实例,example-setup 和多数 demo 用的都是它再加列表。参考代码是 prosemirror-schema-basic 的 756726f。全包只有一个文件 src/schema-basic.ts,一百七十行不到,导出一个 nodes 表、一个 marks 表和一份组装好的 schema。没有插件,没有命令,没有任何运行时代码,整包就是数据。这份纯数据恰好是检验 model 篇知识的材料:每个字段都能在 NodeSpec 和 MarkSpec 的定义里对上号。

系列目录

日期 标题
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 体系:不修改文档的视觉标注
03-21 clipboard:复制粘贴的序列化与解析
04-04 domcoords:屏幕坐标与文档位置的双向换算
04-11 browser.ts:浏览器差异补丁集
04-18 view 收官:不用官方扩展,手写一个最小可用编辑器
05-09 扩展(上):keymap,最小的插件
05-16 commands:命令的签名约定与组合器
05-23 history:undo/redo 栈与 rebasing
06-06 inputrules:「# 空格」变成标题是怎么实现的
06-13 schema-basic:官方基础文档结构(本篇)

导出与组装

文件导出三样东西:nodesmarks 两个普通对象,以及 schema,一行 new Schema({nodes, marks})。两个表都只是字面量对象,每条 spec 后面跟一个 as NodeSpecas MarkSpec 的类型标注,本身不含任何框架运行时;Schema 构造时(model 篇拆过)用 OrderedMap.from 把它们转成有序映射,顺序按对象的书写顺序保留。顺序有实际意义:内容表达式求默认类型时按节点表顺序取第一个能匹配的类型,paragraph 排在所有 block 节点最前,所以 splitBlock 这类命令新开的块默认是段落。

文件顶部先定义了五个常量 pDOM、blockquoteDOM、hrDOM、preDOM、brDOM,各节点的 toDOM 返回的都是同一个数组实例,序列化时不为每个节点重新分配。小优化,但也说明 toDOM 的返回值被约定为只读。其中 preDOM 值得单独看一眼:["pre", ["code", 0]],是两层嵌套的 DOMOutputSpec,pre 里再包一个 code 标签,内容洞 0 在内层。code_block 序列化出去就是 <pre><code>...</code></pre>,和它 parseDOM 里认领 pre 标签的规则正好互逆。marks 表上方还有同样思路的 emDOM、strongDOM、codeDOM 三个常量。

schema 常量上方的注释交代了这份结构的来历:大致对应 CommonMark 的文档 schema,列表元素除外,列表在 prosemirror-schema-list 里。为什么这样切,后面单独说。注释还给出了复用方式:在自己的 schema 里扩展或读取 spec.nodesspec.marks,不必从头写。

block 组六个节点

schema-basic 结构总览

doc 只有一行 content: "block+"。它是默认的 topNode:Schema 构造时 spec.topNode 缺省取名字 “doc” 的节点。文档就是一个或多个块,没有别的约束。

paragraph 是 content: "inline*"group: "block"。group 把它登记进 block 组,doc 的 “block+” 才能引用到它。解析规则一条 {tag: "p"},所有没被更具体规则认领的 p 元素都落成段落。

blockquote 的 content 也是 “block+“,自身又在 block 组,所以引用块可以嵌套引用块。它多了一个 defining: true。defining 的效果在两个地方可见:DOMParser 解析时遇到不匹配的子孙内容会保持这个节点的完整,不会为了塞进内容把它拆穿;切片和粘贴跨越它边界时,它被当作一个整体结构对待,不会只切出半个引用块。在 model 的 NodeSpec 定义里,defining 其实是 definingAsContext 和 definingForContent 两个细粒度开关的合并写法,前者管粘贴替换时节点作为容器是否保留,后者管插入内容时节点作为父级是否被保持,schema-basic 里两处都要,所以直接用合并字段。

horizontal_rule 没有 content 字段,内容表达式为空,是一个叶块节点。叶块节点没有内容可编辑,交互上靠 NodeSelection 整节点选中,删除一次一个。整个 spec 只有 group、parseDOM、toDOM 三个字段:group 把它登记进 block,parseDOM 一条 {tag: "hr"},toDOM 用共享的 hrDOM。这是「块级叶子」的最小写法,也直观说明了一个事实:NodeSpec 里 content、attrs、defining 这些字段全都是可选的,一个节点最少只要交代自己属于哪个组、怎么进出 DOM。

heading 带一个 attr:level: {default: 1, validate: "number"}。parseDOM 列了六条静态规则,h1 到 h6 各一条,attrs 直接写死 level 的值,不需要 getAttrs 函数。toDOM 反过来用 "h" + node.attrs.level 拼标签。heading 也开着 defining: true,和 blockquote 同一个字段:粘贴进来的内容如果和标题的内容表达式不匹配,解析和切片都倾向保持标题完整,而不是把标题拆散去迁就内容。有一个细节值得记:validate 只保证 level 是数字,1 到 6 的范围没有任何代码强约束,注释里写的是 “should hold the number 1 to 6”,属于约定。createChecked 检查的是类型,不是取值范围,level 传 9 也能建出节点,只是序列化时会产出一个 HTML 里不存在的 h9 标签。

code_block 是这份文件里字段最多的节点。content: "text*" 只允许纯文本,image 和 hard_break 这两个 inline 节点进不来,因为内容表达式点名只要 text。marks: "" 把 mark 整体关掉,加粗斜体代码字体在代码块里都不存在,具体机制下一节讲。code: true 把这个节点标记为代码,inputrules 篇的 inCode 选项和一些命令会查这个标记来决定行为。它还有一个连带效果:NodeSpec 的 whitespace 字段缺省时,code 为 true 的节点会被 DOMParser 按 “pre” 档处理,空格默认保留。defining 同样打开。解析规则上还有一个 preserveWhitespace: "full"。这个选项在 DOMParser 里接受布尔值或 “full” 两档:默认行为是按 HTML 惯例折叠空白,连续空格并成一个、换行当普通空白处理;传 true 保留空白字符;“full” 再进一步,连换行符也原样保留。pre 里的缩进和换行本身就是内容,所以 code_block 必须开到最高一档。

inline 组三个节点

text 只有 group: "inline"。文本节点的序列化和解析由框架默认处理,不需要 toDOM 和 parseDOM,spec 里也就没有可写的东西。paragraph 和 heading 的内容表达式 "inline*" 引用的就是这个组:text、image、hard_break 各自用 group 登记进 inline,表达式里出现的只有组名,节点名本身不直接出现。

image 是内联叶节点:inline: truegroup: "inline"。attrs 三个:src 只有 validate: "string" 没有 default,alt 和 title 都是 default: null, validate: "string|null"。有没有 default 的区别在 model 的 computeAttrs 里能直接看到:创建节点时某个 attr 没给值,有 default 就填默认值,没有 default 直接抛 RangeError(“No value supplied for attribute …“)。也就是说 schema 上「必填」这个概念就是靠省略 default 表达的,schema-basic 里 src 是唯一一个必填 attr。draggable: true 让 view 层允许把图片整体拖走。parseDOM 用 "img[src]" 选择器,带一个 getAttrs 用 getAttribute 把三个属性从 DOM 元素上读出来,读不到就是 null,正好落在 alt 和 title 的 validate 允许范围内;toDOM 反过来把 attrs 展开成属性对象挂到 img 标签上。这个节点展示了 getAttrs 最普通的用法:HTML 属性到文档 attr 的一对一搬运。

hard_break 也是内联叶节点,inline: truegroup: "inline" 同时出现,前者决定节点在文档里按内联摆放,后者决定 "inline*" 这类表达式能引用到它,两个字段各管一件事。解析就是一条 {tag: "br"}。它设了 selectable: false。叶节点默认可以被 NodeSelection 当整体选中,hard_break 关掉了这一点:光标无法选中一个换行符,只能从它旁边经过。selectable 这个字段的存在感很低,但对换行这类「存在却不该成为操作对象」的节点,它是必要的开关。

四个 mark

link 有 href 和 title 两个 attr,href 和 image 的 src 一样没有 default,是必填项,title 默认 null。解析规则 "a[href]" 配 getAttrs,选择器里的 [href] 顺带把没有 href 的 a 标签(比如锚点)排除在规则之外。关键字段是 inclusive: false:光标停在链接末尾继续打字,新输入的文字不带 link。inclusive 默认是 true,光标在 mark 边缘输入会延续 mark,这对加粗是期望行为,对链接通常是惊吓,所以链接把它关掉。toDOM 是 ["a", {...}, 0],末尾的 0 是内容洞,被标记的文本进 a 标签内部。

em 有四条解析规则,两条认定、一条样式认定、一条剥除。标签 i 和 em 直接匹配;{style: "font-style=italic"} 匹配内联样式;最后一条 {style: "font-style=normal", clearMark: m => m.type.name == "em"} 是反向操作:遇到 font-style 为 normal 的元素,把当前 mark 集里的 em 过滤掉。它应付的是「整段斜体里嵌一段正体」的 HTML,没有这条规则,正体部分会继承外层的 em。

strong 也是认定加剥除的组合。b 标签那条规则带 getAttrs:node.style.fontWeight != "normal" && null,font-weight 是 normal 时整个表达式得 false,规则作废。注释交代了原因:Google Docs 粘贴出来的内容会莫名用 font-weight 为 normal 的 b 标签包裹,直接认 b 标签会把这些内容误判成粗体。这里同时演示了 getAttrs 的两个返回约定:返回 false 表示规则不匹配,返回 null 表示匹配但没有额外 attr 要算。样式规则两条:font-weight=400 配 clearMark 剥掉 strong;其余 font-weight 值用正则 /^(bold(er)?|[5-9]\d{2,})$/ 认定,bold、bolder、500 及以上的数字权重都算粗体。

code 只有 code: true 加一条 {tag: "code"}。code 标记的含义是「这是代码字体」,inputrules 的 inCodeMark 选项和 example-setup 的标点规则靠它识别代码 span,在里面不做弯引号、破折号这类替换。注意 code 没写 excludes,走默认的同类互斥:一段文字上 code 可以和 em、strong、link 任意叠加,只有再加一个 code 才会替换掉原来的。代码块里一个 mark 都没有,靠的是 code_block 节点层的 marks: "",和 mark 自己的排除规则无关,两条机制各管一段。

marks 排除规则的三层实例

schema-basic 的四个 mark 都没有写 excludes 字段,但排除行为真实存在,分三层生效,这个文件恰好每一层都有实例。

第一层在 MarkSpec.excludes 的默认值。Schema 构造时给每个 MarkType 算 excluded 集合:spec 没写 excludes 就取 [type],即排除同类型 mark;写 "_" 排除 schema 里所有 mark;写空字符串则连同类都允许共存(attrs 不同时可以叠两个 link)。schema-basic 全走默认,所以一段文字上不会有两个 link,后加的会把先加的替换掉,这是 addToSet 消费 excluded 集合的结果,model 篇讲过。

第二层在 NodeSpec.marks。Schema 构造时对每个节点看 markExpr:写 "_" 或者干脆不写但节点有 inline 内容时,markSet 是 null,含义是全部 mark 都允许;写 "" 时 markSet 是空数组,一个 mark 都不允许;写具体名字就按名字收集。code_block 的 marks: "" 走的就是空数组分支,在节点层把 mark 整体关掉。paragraph 和 heading 不写 marks,全部 mark 可用。

第三层在解析入口。em 的 clearMark 和 strong 的 getAttrs 返回 false,都是 DOMParser 读入外部 HTML 时对 mark 的动态剥除和拒绝。拿 em 的最后一条规则走一遍流程:DOMParser 进入一个带 style 的元素时逐条试样式规则,font-style 为 normal 时命中 clearMark,把当前累积的 mark 集合里的 em 过滤掉,再带着过滤后的集合去解析这个元素的子内容;剥除只作用于这个元素圈出的范围,外层 em 在其他兄弟内容上继续生效。排除不只能静态声明,还能在数据进门的时刻按上下文执行。

列表为什么留在 schema-list

文件末尾的注释说这份 schema 对应 CommonMark 减去列表元素,ordered_list、bullet_list、list_item 三个节点由 prosemirror-schema-list 提供(参考代码是 prosemirror-schema-list 的 1501619)。看 src/schema-list.ts 里这三个 spec 会发现它们都不完整:orderedList 写了 order attr、parseDOM 和 toDOM,bulletList 和 listItem 连 attr 都没有(listItem 倒是带了一个 defining: true,和 blockquote 同款,保证列表项在解析和切片中不被拆散),三个都没有 content 和 group。它们没法直接进 Schema。

补全由 addListNodes(nodes, itemContent, listGroup) 完成。它把三个节点 append 进调用方的节点表,ordered_list 和 bullet_list 的 content 固定补成 "list_item+",list_item 的 content 用调用方传进来的 itemContent。第三个参数 listGroup 可选,给了就把两个列表节点登记进对应的组,典型用法是传 "block",否则 doc 的 "block+" 根本容不下列表。注释给了 itemContent 建议的形状:"paragraph block*",列表项首段必须是段落,之后可以跟任意块,包括再嵌套一层列表。orderedList 的 order attr 从 HTML 的 start 属性读取,getAttrs 先用 hasAttribute 判断,元素上没有 start 就填默认值 1;toDOM 在 order 为 1 时返回共享的 olDOM、不输出 start,序列化结果和默认值对齐。

为什么这样切。列表项的内容形状没有唯一答案:只许放段落,还是允许嵌套列表,取决于使用方的文档设计;而 schema-list 的 splitListItem、liftListItem、sinkListItem 这批命令全部依赖内容形状做结构判断。把内容表达式的决定权交给调用方,schema-basic 就能保持只有数据的状态,所有命令留在 schema-list。这个切法也解释了 addListNodes 的签名为什么长这样:itemContent 直接以字符串形式收一段内容表达式,因为它是整套列表设计里唯一留给使用方的变量。

小结

这份一百七十行不到的文件可以当 NodeSpec 和 MarkSpec 的实例手册用:group、content、inline、draggable、selectable、defining、code、marks、attrs、parseDOM、toDOM 每个字段都至少出现一次,而且每次出现都对应一个真实的文档设计决定。两种 spec 的组装成本只有一行 new Schema({nodes, marks}),model 层的编译、校验、解析规则注册全在构造时完成。下一篇看 schema-list,列表项的内容表达式和那批全库最复杂的命令。


1157 字 · 35 段落
xi ming

Written by xi mingFollow onGitHub