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

📅
2 分钟阅读
·

上一篇说明 schema-basic 将三个列表节点交由 schema-list 提供,本文分析该包。参考代码为 prosemirror-schema-list 的 1501619。整个包只有 src/schema-list.ts,不足二百七十行,导出 orderedList、bulletList、listItem 三个节点 spec,addListNodes 装配函数,wrapInListsplitListItemsplitListItemKeepMarksliftListItemsinkListItem 五个命令,以及 wrapInList 的底层函数 wrapRangeInList。本文重点分析命令。四个主要命令(KeepMarks 是 splitListItem 的包装)都基于 ReplaceAroundStep 的 gap 语义;相关变换可结合 transform 文章中的 ReplaceAroundStep 与 structure.ts 阅读。

系列目录

日期标题
05-10ProseMirror 源码分析开篇:富文本编辑器到底难在哪
05-17ProseMirror 仓库全景:22 个包怎么分工
05-24跑通一个最小 ProseMirror:先看文档长什么样
06-07ProseMirror model(上):Node 与 Fragment,文档树的骨架
06-14ProseMirror model(中):Mark,内联格式怎么挂在文本上
06-21ProseMirror model(下):Schema 与 content expression,文档的类型系统
07-05ResolvedPos:一个数字位置怎么变成路径
07-12Slice 与 replace:切一块文档出来再塞回去
07-19DOMSerializer:文档怎么变成 DOM 和 HTML
08-02DOMParser:parseDOM 规则与 HTML 解析
08-09findDiffStart / findDiffEnd:两份文档怎么求差
08-16model 收官:Node 上的辅助方法与位置约定总结
09-06ProseMirror transform(上):Step 抽象,所有修改的最小单位
09-20ProseMirror transform(下):ReplaceStep 与 Fitter,最复杂的一步
10-03StepMap:一步修改怎么映射每个位置
10-11Mapping:多步映射的链式合并,rebase 的地基
10-18structure.ts:split/join/lift/wrap 的可达性判断
10-25Transform 类:构建修改的 API 层
11-08ProseMirror state(上):EditorState,不可变编辑器状态
11-15Selection 体系:四种选区与选区书签
11-22Transaction:Transform 加上状态语义
12-06Plugin 系统(上):StateField 与插件状态
12-13Plugin 系统(下):props、appendTransaction 与 filterTransaction
12-20state 收官:动手写三个插件验证理解
01-03ProseMirror view(上):EditorView,状态与 DOM 之间的桥
01-10ViewDesc(上):文档到 DOM 的描述树
01-17ViewDesc(下):增量更新怎么做到只改动的部分
02-07DOMObserver 与 readDOMChange:浏览器改了 DOM,怎么读回文档
02-14input.ts:从 keydown 到 dispatchTransaction 的输入管线
02-21选区同步:state 选区与 DOM 选区的双向对齐
02-28Composition 与 IME:中文输入法事件的处理
03-07NodeView 与 MarkView:把渲染权交给你
03-14Decoration 体系:不修改文档的视觉标注
03-21clipboard:复制粘贴的序列化与解析
04-04domcoords:屏幕坐标与文档位置的双向换算
04-11browser.ts:浏览器差异补丁集
04-18view 收官:不用官方扩展,手写一个最小可用编辑器
05-09扩展(上):keymap,最小的插件
05-16commands:命令的签名约定与组合器
05-23history:undo/redo 栈与 rebasing
06-06inputrules:「# 空格」变成标题是怎么实现的
06-13schema-basic:官方基础文档结构
06-20schema-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 会拆出空列表。调用方可以指定 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)splitDepth 只分裂 listType 以下的层级。例如 [bullet_list, list_item]splitDepth 为 1,每个块分配到独立的 list_item,外层 bullet_list 保持不变。每次 split 引入一对开闭 token,因此 splitPos2 * splitDepth 和当前块的 nodeSize 推进。未通过 canSplit 的块不分裂,但位置仍继续推进。

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、当前列表是外层项最后一个子节点)才继续;否则返回 false,由命令链中的下一个命令处理,通常为 liftListItem。深度等于 3 时光标位于最外层列表,空段落上的 Enter 应由 liftListItem 将该项提出列表。继续处理时,代码从外到内复制空包装结构,depthBefore 指定复制起点,再追加 createAndFill 创建的新项,并用 slice 替换当前结构,openStart 为 4 - depthBefore。替换范围起点为 $from.before($from.depth - (depthBefore - 1)),终点为 $from.after(-depthAfter)depthAfter 防止替换吞掉当前项后续兄弟。完成替换后,nodesBetween 找到第一个空文本块,Selection.near 将光标移入其中。depthBeforedepthAfter 各有三种取值,对应光标在列表项中的位置与列表在外层项中的位置。

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。

sinkListItem 不需要循环和额外的事后校验:前置条件完成结构判断,后续只执行一次 ReplaceAroundStep,将当前项包入前一项的嵌套列表。

列表命令的结构条件

这些命令遵守 commands 文章中的 dry-run 约定:dispatch 为空时只判断可行性,不修改文档。wrapRangeInList 将 tr 参数定义为可空,同一个函数可用于执行和菜单 enable 检查。

"paragraph block*" 仅约束首子,后续 block* 可容纳多种节点,因此命令需要处理不同嵌套深度。splitListItemdepthBeforedepthAfter 的分支对应光标在嵌套结构中的相对位置。

边界位置也会改变 ReplaceAroundStep 的 slice:liftOutOfList 的 atStart、atEnd 组合,liftToOuterList 是否存在后续兄弟,以及 wrapRangeInList 是否可并入前方列表,都会影响开放深度和替换范围。将中间列表项提升时,后续兄弟需要随最后一个提升项移动;拆分空项时,需要复制外层结构。这些列表操作约定由命令实现,schema 无法表达。

HTML 的 ol 可带 start 属性,li 允许直接文本或块内容。DOMParser 通过 definingfindWrapping 处理这些外部结构,并将其转换为 schema 定义的文档树。

下一篇分析 gapcursor 如何处理块之间没有文本容器的位置。


944 字 · 33 段落
ximing

Follow onGitHub

相关文章