structure.ts:split/join/lift/wrap 的可达性判断

📅
3 分钟阅读
·

这是 transform 阶段的第四篇。前几篇介绍了 Step 抽象、ReplaceStep 的内部实现和位置映射,分别说明一步修改如何表示、应用和换算位置。本文讨论另一组判断:当前位置能否分段、两个块能否合并、选中的块能否提升、能否包进引用块。这些判断位于 src/structure.ts;该文件还包含 split、join、lift、wrap 等执行函数,Transform 类上的同名方法直接转调它们。参考代码是 prosemirror-transform 的 662b7a9。

系列目录

判断函数与执行函数的分工

structure.ts 的导出分为判断函数和执行函数。canSplit、canJoin、joinPoint、liftTarget、findWrapping、insertPoint、dropPoint 都是纯函数:输入文档和位置,返回布尔值、深度、位置或 null,不修改文档。split、join、lift、wrap、setBlockType、setNodeMarkup 的第一个参数都是 Transform,内部构造 ReplaceStep 或 ReplaceAroundStep 并加入 tr.step。src/transform.ts 中 Transform 类的 split、join、lift、wrap、setBlockType、setNodeMarkup 方法各自直接转调。

命令体系使用这一拆分。prosemirror-commands 的每个命令先调用判断函数检查是否适用;不适用时返回 false,让 chainCommands 尝试下一个命令,适用时才 dispatch 执行。判断与执行共用 Node.canReplace、Node.canReplaceWith、ContentMatch 等 schema 校验原语,均来自 prosemirror-model(参考代码是 prosemirror-model 的 6264de0)。执行过程沿用相同的校验条件。

canSplit:每一层都合法才算能分

src/structure.ts 的 canSplit(doc, pos, depth, typesAfter) 回答「在 pos 处把 depth 层祖先各自切成前后两个节点,合法吗」。depth 默认 1,typesAfter 可选,指定分裂后新节点用什么类型,数组顺序由外到内。

先看最内层的四个拦截条件:

if (base < 0 || $pos.parent.type.spec.isolating ||
    !$pos.parent.canReplace($pos.index(), $pos.parent.childCount) ||
    !innerType.type.validContent($pos.parent.content.cutByIndex($pos.index(), $pos.parent.childCount)))
  return false

分裂把父节点切成两半,前半留在原节点里,后半要成为新节点的内容。后两个条件分别问两件事:把后半从原节点拿掉是否合法(canReplace(index, childCount),即把 index 到末尾替换成空);后半作为新节点的内容是否合法(validContent,新节点类型取 typesAfter 末项,没给就同类型)。isolating 节点不许分裂穿透,base 小于 0 说明 depth 超过了当前深度,直接否。

最内层过了之后循环向上,每层做同样的两半检查,isolating 也逐层再问一次,差别在于 typesAfter 会参与构造:这一层指定了新类型时,后半段 rest 的第一个孩子要先换成下层的新类型节点,再拿 after.type.validContent(rest) 验。最后落到 base 层,问 canReplaceWith(index, index, baseType),即分裂产生的两个并排节点能不能立在 base 层里。整条链任何一环不过都是 false。

typesAfter 的典型场景是回车:在 heading 末尾按回车,新分裂出来的块应该变回 paragraph 而不是再来一个 heading。splitBlock 会给 typesAfter[0] 填上默认块类型。

拿一个具体例子过一遍。文档是 doc(blockquote(p(“ab”), p(“cd”))),光标在 “cd” 中间,pos 指向段落内部,$pos.depth 为 2,调用 canSplit(doc, pos, 2)。最内层检查段落 p(“cd”):分裂点之后的内容能从原段落拿掉,作为新段落的内容也合法。注意这两个检查按 child 粒度进行,光标在 “cd” 中间时,被检查的是分裂点所在的整个文本节点而不是只有 “d”,文本节点可以整进整出,段落也允许变短,两关都过。循环向上一层到 blockquote:分裂点所在段落之后没有其他兄弟,canReplace(index + 1, childCount) 平凡成立;rest 是分裂点所在段落本身,作为分裂后新 blockquote 的内容也合法。最后落到 doc 层:canReplaceWith 确认两个并排的 blockquote 能立在 doc 里。三关都过,返回 true。如果 schema 里 blockquote 的内容表达式是「恰好一个段落」之类不允许截断的写法,第二层就会卡住,整个判断返回 false,回车键的命令链接着往下走。

执行的 split(tr, pos, depth, typesAfter) 做的事和检查对应:从 $pos.depth 往下走 depth 层,逐层复制 before 壳和 after 壳(after 侧用 typesAfter 指定的类型,没给就复制原类型),最后一步 ReplaceStep(pos, pos, Slice(before.append(after), depth, depth), true)。slice 的 openStart 和 openEnd 都等于 depth,意思是这条 slice 的两端各打开 depth 层,嵌进 pos 时正好把原节点撑成两半。

canJoin 与 joinPoint:合并的门槛

canJoin(doc, pos) 很短:

let $pos = doc.resolve(pos), index = $pos.index()
return joinable($pos.nodeBefore, $pos.nodeAfter) &&
  $pos.parent.canReplace(index, index + 1)

两个条件。parent.canReplace(index, index + 1) 问父节点是否允许把 after 这个节点拿掉,合并之后 after 的内容进了 before,after 本体消失,父节点的内容表达式要仍然成立。joinable 要求两侧节点都存在、before 一侧不是叶节点,并且内部函数 canAppendWithSubstitutedLinebreaks 通过:取 before 末尾的 ContentMatch,逐个匹配 after 的子节点,最后 match.validEnd 必须成立。匹配时把 linebreakReplacement 类型的孩子折算成 text 再匹配,因为 join 会在 pre 块和普通块之间合并时做 hard_break 与 \n 的互转,校验要按折算后的类型走;每个孩子的 marks 也要被 before 的类型允许,allowsMarks 不过就合并不了。

joinPoint(doc, pos, dir) 在 canJoin 之上加了一层搜索:从 pos 最深一层开始向上找可合并的边界。最深一层取 nodeBefore 和 nodeAfter;往上每层按 dir 取相邻的两个节点,dir 为 -1 找前面的,为 1 找后面的。命中条件里有一条值得注意:before.isTextblock 为真时跳过。文本块的合并不走 tr.join,那条路由 joinBackward 和 joinForward 里直接构造 ReplaceStep 的 joinTextblocksAround 处理,join 只负责块容器之间的合并。

执行的 join(tr, pos, depth) 本体很短:一步 ReplaceStep(start, mapping.map(pos + depth, -1), Slice.empty, true),删掉的就是两个节点之间 2 * depth 个边界位置,每层一对开闭标签。真正占篇幅的是删除前后的清理:目标块是普通文本块且支持 linebreakReplacement 时,合并完成后把并入内容里的 \n 换成 hard_break;目标是 pre 块且不支持 linebreak 时反过来,合并之前先把 after 里的 hard_break 换成 \n。marks 层面还要调 clearIncompatible(src/mark.ts),把 before 类型不允许的 mark 从即将并入的内容里清掉。清理本身也是 step,所以后面取位置时一律走 tr.mapping.slice(mapFrom) 映射,不直接用旧位置。这套「先清理、再映射、最后删边界」的顺序在 setBlockType 里又出现了一次。

同文件的 setBlockType 和 setNodeMarkup 不算结构移动,也走 ReplaceAroundStep:gap 取节点内容区间(pos + 1 到 pos + nodeSize - 1),slice 放一个只带新类型和 attrs 的空壳,内容保留、外壳换掉。setBlockType 多了两步防御:canChangeType 先确认父节点容得下新类型,clearIncompatible 清掉新类型容不下的 mark 和节点,换向 pre 类型时同样处理换行符折算。setNodeMarkup 在替换前用 type.validContent(node.content) 兜底,内容不合法直接抛 RangeError,叶节点则退化成普通的 replaceWith。

liftTarget:向上找能容纳的深度

lift 把 range 覆盖的内容从嵌套层里提出来,liftTarget(range) 负责算出能提到哪一层。代码不长,信息量集中在循环里:

let parent = range.parent
let content = parent.content.cutByIndex(range.startIndex, range.endIndex)
for (let depth = range.depth, contentBefore = 0, contentAfter = 0;; --depth) {
  let node = range.$from.node(depth)
  let index = range.$from.index(depth) + contentBefore, endIndex = range.$to.indexAfter(depth) - contentAfter
  if (depth < range.depth && node.canReplace(index, endIndex, content))
    return depth
  if (depth == 0 || node.type.spec.isolating || !canCut(node, index, endIndex)) break
  if (index) contentBefore = 1
  if (endIndex < node.childCount) contentAfter = 1
}
return null

content 是要往上提的那串兄弟节点。循环从 range.depth 逐层向上,每层问两个问题。注意第一轮 depth 等于 range.depth 时,返回条件 depth < range.depth 不成立,这一轮只做 canCut 检查和标志设置,命中最早发生在第二轮,也就是说 lift 至少要把内容提出一层。先问这一层之上容不容得下 content:node.canReplace(index, endIndex, content) 的意思是用 content 替换掉包住它的那一层节点,等价于问 content 能不能立在上一层的这个位置。容得下就返回当前 depth。容不下再问能不能把当前层切开继续向上:内部函数 canCut(node, index, endIndex) 检查切点前后各自剩下的内容对当前层类型仍然合法,切得动就把 contentBefore 或 contentAfter 置 1 再往上走。到达顶层(depth == 0)、遇到 isolating 节点或当前层无法切开时,函数返回 null。

contentBefore 和 contentAfter 这对标志值得停一下。index 和 endIndex 描述的是当前层里「包住 content 的节点区间」。往上一层之后,如果当前层在切点前面还有内容,切开后当前层节点变成前后两个壳,content 前面多出一个壳的位置,上一层的 index 就要加 1;endIndex 对称地减 1。两个布尔值把上层区间的偏移压缩成了两行赋值。

liftTarget 的逐层查找

执行的 lift(tr, range, target) 是这个判断的镜像。它从 depth 往 target 走两个循环:切点前面有内容的层,复制一份壳塞进 before 片段并把 openStart 加一;前面没有内容的层,把替换起点 start 往前挪一格,让空壳直接被删。还有一个 splitting 标志负责传递:只要某个内层进入过复制壳的分支,更外层即使切点前没有内容也得跟着复制壳,因为内层已经断开,外层的壳不可能再保持完整。after 一侧对称。最后构造 ReplaceAroundStep(start, end, gapStart, gapEnd, Slice(before.append(after), openStart, openEnd), before.size - openStart, true)。insert 取 before.size - openStart,因为 before 里有 openStart 层是打开的壳,剩下的才是插在 gap 前的实体内容长度。ReplaceAroundStep 的 gap 语义第 14 篇看过,lift 是它的标准用例。

findWrapping:检查外侧与内侧内容表达式

findWrapping(range, nodeType, attrs, innerRange) 回答「把 range 的内容包进 nodeType,需要哪些包裹节点」。返回值是一条节点链,由三段拼成:around 加目标类型加 inner。两段查找各问一次内容表达式自动机,用的是 model 层 ContentMatch 上的 findWrapping 方法(prosemirror-model/src/content.ts),第 6 篇讲的自动机在这里承担结构查询。

findWrappingOutside 问外侧:parent.contentMatchAt(startIndex).findWrapping(nodeType),从 range 所在位置的匹配状态出发,要到达「能放 nodeType」的状态,中间需要垫哪几层节点。这条链是 around。链的最外层还要过一道 canReplaceWith(startIndex, endIndex, outer),确认它能替换掉 range 覆盖的现有节点。

findWrappingInside 问内侧:nodeType.contentMatch.findWrapping(第一个孩子的类型),从 wrapper 的内容起点出发,到能放下 innerRange 的第一个子节点,中间需要垫哪几层。拿到链之后还不算完,用链上最后一层的 contentMatch 从 innerRange 的第一个子节点开始逐个匹配,全部匹配且 validEnd 成立才返回。

两个方向都通才拼出结果,任何一段找不到就是 null。

innerRange 参数默认等于 range,分开传的场景在 prosemirror-schema-list 的 wrapRangeInList:选区在一个列表的第一项开头时,目标是把内容并进前一个同类型列表,于是 outerRange 取前一个列表末尾的空 range(决定壳放在哪),innerRange 仍是真正的选区(决定壳里要容下什么)。外侧的放置位置和内侧的容纳对象分开算,一个函数就覆盖了「新建列表」和「并入现有列表」两种形态。

举例:把两个并排段落包进 blockquote,schema 里 doc 和 blockquote 的内容都是 block+,around 为空(当前位置直接能放 blockquote),inner 为空(blockquote 直接能放 p),结果就是单层 [{type: blockquote}]。包进列表类型时,list 要求内容是 list_item,inner 会给出 [list_item],结果是两层链。执行的 wrap(tr, range, wrappers) 把这条链从里向外逐层 create,造出一条嵌套壳,用 ReplaceAroundStep 套在 range 内容外面。注意 wrap 是整体套一层链,range 里的多个块一起进同一个壳;列表那种每一项各套一层的效果由 schema-list 的命令自己分组后再调 wrap,本篇不展开。

commands 如何使用这些判断

prosemirror-commands 的 src/commands.ts(参考代码是 prosemirror-commands 的 52a84a8)第一行 import 就是 joinPoint、canJoin、findWrapping、liftTarget、canSplit 这一串。逐个看消费点:

  • splitBlock 由 splitBlockAs 生成,Enter 键链的最后一棒。它先按当前块类型组装 types,末尾分裂时用 defaultBlockAt 找默认块类型填进 types,然后 canSplit(tr.doc, splitPos, types.length, types)。第一次不过会把 types[0] 换成默认类型再试一次,还不过才返回 false。canSplit 的 typesAfter 参数主要就是为它准备的。
  • lift 命令和 liftEmptyBlock 共用 blockRange 加 liftTarget 的组合:to) 造出 NodeRange,liftTarget 算出目标深度,null 就返回 false。liftEmptyBlock 多一步:空块后面还有兄弟时先 canSplit(state.doc, before) 试着把父节点分裂开,分不动才走 lift。
  • wrapIn 就是 findWrapping 加 tr.wrap,wrapping 为 null 返回 false。
  • joinUp 和 joinDown 按选区类型分流。NodeSelection 选中整个块时直接 canJoin;文本选区用 joinPoint 向上搜可合并的边界,搜不到返回 false。
  • joinBackward 里,光标在块首且 findCutBefore 找不到前序兄弟时,说明这个块是父节点的第一个孩子,后退一步的语义变成把它提出去,走 liftTarget。deleteBarrier 处理更复杂的合并:前一块末尾的 ContentMatch 能包住后一块时,用 ReplaceAroundStep 把后一块塞进去,然后 canJoin 检查塞进去的节点能不能顺手和邻居合并。ContentMatch.findWrapping 在这里又出现了一次,可见这个自动机查询是结构判断的通用零件。
  • autoJoin 包装别的命令的 dispatch:扫描 transaction 改动区间里相邻的同类型节点,再过一个调用方传入的 isJoinable 谓词(或者直接给一个节点类型名数组),都通过且 canJoin 成立就补一个 tr.join。它的用途是把「改完文档后留下两个本该合并的相邻块」收尾成净结果。

文件里还有 insertPoint 和 dropPoint 两个找插入位置的判断。insertPoint 在 pos 处直接试 canReplaceWith,不行且光标在节点开头或结尾时逐层向上找能插入的边界,中途遇到前面还有兄弟就放弃。commands 没有消费它,留给外部命令用。dropPoint 针对 slice 找落点:先沿 slice 的 openStart 逐层下钻到实际内容,再从 mouse.pos, slice) 算的(参考代码是 prosemirror-view 的 ca4c78e)。

小结

structure.ts 的判断函数将操作条件拆为 canReplace、canReplaceWith、validContent 与 ContentMatch 匹配,并从最深层逐层向上验证;任一条件不满足时返回失败。执行函数根据这些结构关系构造 lift、wrap、split、join 操作,最终使用 ReplaceStep 或 ReplaceAroundStep。下一篇介绍 Transform 类如何维护随 step 增长的 docs 和 mapping 数组,以及 replace、delete、insert 如何归一到 replaceStep。


1147 字 · 44 段落
ximing

Follow onGitHub

相关文章