schema-list:列表节点与最复杂的一批命令

3 分钟阅读
·

上一篇结尾说 schema-basic 把列表三个节点留给了 schema-list,这篇把这个包读完。参考代码是 prosemirror-schema-list 的 1501619。全包一个文件 src/schema-list.ts,不到两百七十行,导出三类东西:orderedList、bulletList、listItem 三个节点 spec,addListNodes 装配函数,以及 wrapInList、splitListItem、splitListItemKeepMarks、liftListItem、sinkListItem 这五个命令,外加 wrapInList 的底层函数 wrapRangeInList。spec 部分上一篇扫过,这篇的重点是命令。这四个命令(KeepMarks 是 splitListItem 的包装)是整个仓库里边界情况最密集的一组代码,全部建在 ReplaceAroundStep 的 gap 语义上,读之前建议把 transform 篇的 ReplaceAroundStep 和 structure.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 体系:不修改文档的视觉标注
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:官方基础文档结构
06-20 schema-list:列表节点与最复杂的一批命令(本篇)

节点 spec 与 addListNodes

orderedList 有唯一的 attr order,默认 1,validate 为 number。parseDOM 的 getAttrs 从 ol 元素的 start 属性读初值,用一元加号把属性字符串转成数字,没有 start 就取 1。toDOM 反过来:order 为 1 时直接返回文件顶部共享的 olDOM 常量数组,不为 1 时才生成带 start 属性的新数组。共享常量这个写法和 schema-basic 里的 pDOM 一样,序列化不为每个节点重新分配,返回值按约定只读。bulletList 和 listItem 的 toDOM 同样返回共享的 ulDOM、liDOM 常量。bulletList 和 listItem 连 attr 都没有,listItem 只多一个 defining: true,DOMParser 解析和切片跨越 li 边界时把它当整体对待。

三个 spec 都没有 content 和 group,不能直接进 Schema,补全靠 addListNodes。它把 ordered_list、bullet_list 的 content 固定补成 “list_item+“,list_item 的 content 用调用方传进来的 itemContent,listGroup 可选。注释给了两种建议形状:“paragraph block*” 和 “paragraph (ordered_list | bullet_list)*“,前者第二及以后的位置允许任意块,后者只允许列表。两种形状下这批命令都成立,它们对 itemContent 的硬性要求只有一条:首子是文本块。区别在于后者更严,列表项里除首段外只能再嵌列表,适合做严格大纲;前者宽松,引用块、代码块都能进列表项。

“paragraph block*” 的设计

列表嵌套结构

这个表达式有两层约束。第一层,首子必须是段落(或调用方指定的某个文本块):每个列表项都有一个可以直接打字的落点,光标不会落到没有文本容器的位置。第二层,之后的 block* 允许任意块,包括再嵌套一层列表。嵌套列表因此永远挂在第二及以后的位置,结构上不存在首子就是列表的项。

这个形状会被命令直接消费。splitListItem 在光标位于文本块末尾时,用 grandParent.contentMatchAt(0).defaultType 取「新一项的首块该是什么类型」,contentMatchAt(0) 就是表达式里第一个位置,答案固定是段落。如果首子允许是列表,这个调用可能返回列表类型,Enter 就会拆出一层空列表。上一篇说 schema-basic 把 itemContent 的决定权留给调用方,这里能看到自由的边界:想让这批命令正常工作,首子必须保证是文本块。

wrapInList:包住,或并入外层列表

wrapInList(listType, attrs) 返回命令,先取选区的 blockRange,取不到就 false。真正的工作在 wrapRangeInList,它先处理一个特例:选区从一个已存在的嵌套列表的第一项开始(range.depth >= 2、这个列表的父节点内容与 listType 兼容、startIndex 为 0),就不再新建列表,改为把选中项提出这层列表、并入外层结构。列表已经是父节点的第一个孩子时无处可并,返回 false。选区没覆盖到列表末尾时,range 会扩展到末尾,把后面的项一起带走;doJoin 标记让后面的 ReplaceAroundStep 起点前移 2,把这层列表壳剥掉。

一般路径走 findWrapping(outerRange, listType, attrs, range) 算包裹序列,目标位置按内容表达式放不下列表时算不出来,返回 false。tr 为 null 时 doWrapInList 不会被调用,整个函数退化成纯查询。整条命令最后 dispatch 的是 tr.scrollIntoView(),执行完光标附近滚进视口。doWrapInList 把包裹序列从里向外逐个 create 成嵌套 Fragment,然后一步 ReplaceAroundStep:gap 是 range.start 到 range.end,slice 的 openStart 和 openEnd 都是 0,insert 深度是 wrappers.length,structure 标记为 true。一步把选中的整段块包进 list 加 list_item 的结构里。

包完还没结束。findWrapping 给出的结构里所有选中的块共享同一个 list_item,但列表语义要求每块各占一项。doWrapInList 后半段循环处理:先找出 listType 在包裹序列里最后出现的位置,splitDepth 等于包裹层数减去这个位置,再从第二块起逐块 canSplit 检查后 tr.split(splitPos, splitDepth),把共享的 list_item 逐个掰开。splitDepth 的含义是只掰 listType 之下那一层:以 [bullet_list, list_item] 为例,splitDepth 是 1,每块分到独立的 list_item,外层的 bullet_list 保持共享。splitPos 的推进是加上 2 * splitDepth 再加当前块的 nodeSize,2 倍是因为每次 split 会引入一对开闭 token。canSplit 不过的块会被跳过不拆,splitPos 仍照常推进。

splitListItem:Enter 在列表里的行为

splitListItem(itemType, itemAttrs) 是绑到 Enter 的命令。守卫先排掉三种情况:NodeSelection 选中了块节点、深度小于 2、选区跨父节点。然后 $from.node(-1),也就是光标所在文本块的祖父,必须是 itemType。

普通路径在拆分前还有一步 tr.delete(to.pos):选区非空时先删掉选中内容,后面的判断都按坍缩后的光标位置来。光标在文本块末尾时,nextType 取 grandParent.contentMatchAt(0).defaultType,即新一项首块的默认类型;不在末尾时 nextType 为 null,连 types 数组都不构造,tr.split 按默认行为把两个块都拆成原类型。types 构造出来时,第一个元素在传了 itemAttrs 时是 {type: itemType, attrs: itemAttrs},否则是 null,null 的含义是保留原有的类型和 attrs。canSplit(tr.doc, from.pos, 2, types),深度 2 表示同时拆开文本块和 list_item。

特殊路径处理「光标在空段落里,且这个段落是列表项的最后一个子节点」。这时再拆出一个空项没有意义,代码转而处理嵌套列表的缩进退出:只有光标确实在嵌套列表里(深度大于 3、外层祖父也是 itemType、当前列表是外层项的最后一个子节点)才继续,否则 return false,把机会让给链上的下一个命令,通常是 liftListItem。深度等于 3 时光标就在最外层列表里,空段落上按 Enter 的合理行为是把这一项提出列表,那正是 liftListItem 的处理范围。继续时的做法是从外到内复制一份空的包装结构,depthBefore 决定从哪一层开始复制,再 append 一个 createAndFill 出来的新项,用 slice 替换掉当前结构,openStart 是 4 - depthBefore。替换范围两端也由这两个数字定:起点是 from.depth - (depthBefore - 1)),终点是 $from.after(-depthAfter),depthAfter 保证替换不会吞掉当前项后面还存在的兄弟。替换完光标位置要显式找:nodesBetween 扫描第一个空文本块,Selection.near 把光标放进去。这段是全文件里魔法数字最密集的地方,depthBefore 和 depthAfter 各有三种取值,对应光标在项里的相对位置和列表在外层项里的相对位置。

splitListItemKeepMarks 是同一命令的包装:执行后调 tr.ensureMarks,让拆出来的新行保留输入时的加粗斜体状态,普通版本会按默认行为丢掉这些 mark。marks 的来源有优先级:storedMarks 优先;没有时要求 from.marks(),光标在文本块最开头时取不到有意义的输入 mark,就不强行保留。

liftListItem:两个方向

liftListItem 先用带谓词的 blockRange 把选区扩到整项边界,谓词是 node.firstChild.type == itemType,保证扩出来的边界落在列表项上。dispatch 为空时命令在这里直接 return true,可行性判断只看能不能框出整项范围,不预演后面的 lift。真实执行时按 $from.node(range.depth - 1) 是不是 itemType 分两个方向:当前列表本身嵌在某个列表项里,走 liftToOuterList;当前列表直接挂在 doc 这类父级下,走 liftOutOfList。

liftToOuterList 有一个容易漏掉的预处理:被提升的项后面还有兄弟时(end < endOfList),这些兄弟不能留在原地,按列表语义它们要跟着被提升的最后一项走。代码先用一步 ReplaceAroundStep 处理,slice 内容是一个 list_item 包着整份列表的副本,openStart 取 1,效果是尾部兄弟成为最后一项的子列表。然后重新 resolve range,liftTarget 算目标深度,tr.lift 完成提升。最后有收尾:join 的位置用 tr.mapping.map(end, -1) - 1 找回,end 是提升前的旧位置,映射减一后落在新文档里最后一个提升项的末尾,从这个位置前后各探一个节点,两边是同类型列表且 canJoin 通过就 tr.join 合并,避免出现两个相邻的同类列表。

liftOutOfList 处理把项提出列表、变成外层普通块。先把选中的多个项合并成一个大项:从后往前删掉相邻项之间的边界(pos - 1 到 pos + 1 两个位置,正好是前一项的闭 token 加后一项的开 token)。合并后有一项完整性校验:tr.mapping.map(range.end) 必须等于 range.start 加上合并后节点的 nodeSize,不等说明中间过程偏离预期,直接 false。剥壳前还有一道 canReplace 预检:假设剥壳完成,父节点在列表原位置能否容纳「大项内容拼接剩余列表」这个结果,容不下就 false,避免一步打出一个非法文档。然后按 atStart、atEnd(选中的项是否覆盖列表的首尾)分四种组合剥壳:ReplaceAroundStep 的 slice 在没覆盖到的那一侧放一个空列表副本(list.copy(Fragment.empty)),让剩余兄弟项仍然有列表可呆,openStart 和 openEnd 相应取 0 或 1;覆盖到边缘的那一侧不留壳,step 的范围多向外扩一个 token,把列表的开闭 token 一起吃掉。gap 固定在 start + 1 到 end - 1,落在大项内容内部,四种组合改的只是外层范围的扩缩和 slice 两侧的壳。

sinkListItem:最短的逆操作

sinkListItem 是 lift 的逆操作,实现反而最短。开头的 blockRange 谓词和 liftListItem 相同,选区同样先扩到整项边界。前置条件两个:startIndex 为 0 时前面没有兄弟,无处可沉,false;前一个兄弟必须是 itemType,false。

沉的方向是把当前项塞进前一项的末尾,分两种情况。前一项的最后一个孩子已经是同类型列表时(nestedBefore),slice 的 openStart 取 3,打开 item、list、item 三层,让新项直接并入已有的嵌套列表;step 的起点相应前移 3 个 token,正好覆盖前一项末尾的三个闭 token(嵌套项、嵌套列表、前一项本身),替换后这些壳由 slice 打开的三层重新接续。否则 inner 放一个空的 itemType 占位,openStart 取 1,在前一项末尾新建一层嵌套列表再放进去。两种情况都是一步 ReplaceAroundStep,gap 取 range.start 到 range.end,正好是当前项的开闭 token 之间,insert 深度 1,structure true。

sink 是这批命令里唯一不需要循环和事后校验的,因为结构判断全被前置条件挡掉,剩下的就是一个标准的包一层再塞进去。

为什么列表是富文本最难的部分之一

读完这四个命令可以归纳复杂性的来源。另外值得记一句,这批命令全部遵守 commands 篇讲的 dry-run 惯例:dispatch 为空时只做可行性判断,不改文档。wrapRangeInList 甚至把 tr 参数本身做成可空,同一个函数既服务真实执行也服务菜单的 enable 检查。

一个来源是结构约束与表达自由的组合。“paragraph block*” 只限制首子,block* 是开放的,每个命令都要在任意嵌套深度上成立。splitListItem 的 depthBefore 和 depthAfter 三取值分支,就是光标在嵌套结构里相对位置的组合数。

第二个来源是端点组合。liftOutOfList 的 atStart 和 atEnd 四种组合、liftToOuterList 的「后面还有没有兄弟」、wrapRangeInList 的「前面有没有可并入的列表」,每个命令都要为边界位置准备不同的 slice 形状。这类代码没法抽象掉,差异就在 slice 的开放深度和替换范围上。

第三个来源是列表语义自带的拖拽效应。提升中间一项时它后面的兄弟要跟走,拆空项时要把外层结构一起复制,这些行为来自用户对列表的心智模型,schema 表达不了这类约定,只能写进命令。

最后是 HTML 列表模型本身就很乱。ol 的 start 属性、li 里既允许直接放文本也允许放块,浏览器和剪贴板送来的列表形状五花八门,解析那一侧靠 defining 和 DOMParser 的 findWrapping 兜底。框架能做的,是像这个包一样把全部复杂性隔离在一组命令里,让其余代码只面对规整的文档树。

下一篇看 gapcursor:光标落在块与块之间、没有文本容器可去的时候怎么办。


1038 字 · 34 段落
xi ming

Written by xi mingFollow onGitHub