上一篇拆完 schema-list 的命令群,结尾留了这个话题:光标落在块与块之间、没有文本容器可去的时候怎么办。这篇看 gapcursor,整个包 src 下只有两个源文件,src/gapcursor.ts 定义选区类型,src/index.ts 定义插件,合计 230 来行。参考代码是 prosemirror-gapcursor 的 72657d0;顺带引用的 prosemirror-state、prosemirror-view、prosemirror-model、prosemirror-keymap 分别是 ffad5d9、ca4c78e、6264de0、d60e244。
问题用 schema-basic 就能构造。horizontal_rule 和 image 都是 atom 的块级节点,文档里放两个挨着的 horizontal_rule,它们中间存在一个合法的文档位置,但这个位置的 parent 是 doc,doc 没有 inlineContent。第 20 篇讲选区体系时提过 TextSelection 的 $cursor 约定:光标位置的父节点必须能容纳 inline 内容,否则光标无处落脚。NodeSelection 是另一个极端,它选中某个节点整体,管不到节点之间的缝。TextSelection 与 NodeSelection 于是都管不到两个 atom 之间的这个位置。浏览器对块元素之间的原生 caret 支持也不一致,有的干脆拒绝把 caret 放到两个块元素中间。不装 gapcursor 的实际表现:方向键直接跳过这段缝,鼠标点过去选中的是节点,用户没有办法在两个块之间插入新段落。上一篇讲列表时说「每个列表项都有一个可以直接打字的落点」,落点缺失正是这类位置的特征。
系列目录
GapCursor:一个不含内容的空选区
src/gapcursor.ts 的 GapCursor 继承 prosemirror-state 的 Selection(选区体系见第 20 篇)。head 指向同一个位置,是空选区。几个接口都写得很薄:content() 返回 Slice.empty,选区不含任何文档内容;eq 只比 head 一个数字;toJSON 序列化成 {type: “gapcursor”, pos},靠文件末尾的 Selection.jsonID(“gapcursor”, GapCursor) 注册,fromJSON 负责还原;getBookmark 返回 GapBookmark,书签只记一个 pos,resolve 时重新校验。
map 和 GapBookmark.resolve 共用一条降级路径:
let $pos = doc.resolve(mapping.map(this.head))
return GapCursor.valid($pos) ? new GapCursor($pos) : Selection.near($pos)文档变更把缝隙改没了(比如旁边的 atom 被删掉),映射后就降级成 Selection.near 找最近的可放位置。自定义选区类型对文档变更的容错,核心就是这一类降级分支。
书签的消费者在 history 包里。第 40 篇讲过 Branch 上的每个 Item 会存一份选区书签,history.ts 里落盘时调的就是 state.selection.getBookmark(),undo 回来靠书签的 resolve 还原选区。GapBookmark.resolve 同样先查 GapCursor.valid,缝隙还在就还原成 GapCursor,不在了降级为 Selection.near。undo 一组删除操作把两个 atom 恢复出来时,光标能准确回到它们之间的缝隙上,靠的就是这条链路。
类声明外面还有一行 GapCursor.prototype.visible = false。Selection 基类上 visible 默认是 true(state 的 src/selection.ts),NodeSelection 同样覆写成 false。这个标记告诉 view:不要把这个选区画成浏览器可见的选区。它如何与假光标配合,放到 DOM 伪装一节讲。
valid:什么位置才算缝隙
static valid($pos) 回答「这个位置允不允许放 gap cursor」,四个条件依次是:
- parent.inlineContent 为假,缝隙的父节点必须是块容器;
- closedBefore(pos) 都为真,位置两侧都封闭,下面展开;
- parent.type.spec.allowGapCursor 不为 null 时直接采用它的值,这是留给 schema 作者的显式开关;
- 否则要求 parent.contentMatchAt($pos.index()).defaultType 是 textblock。
最后一条值得想一下。contentMatchAt 拿到该位置的内容匹配状态(第 6 篇讲过 ContentMatch),defaultType 回答「在这里默认能造出什么节点」。要求它是 textblock,含义是 gap cursor 只出现在本来就能放段落的缝隙里。如果这个位置按 schema 只能放别的块,光标造出来用户也输入不了任何东西,放了也白放。allowGapCursor 和下文 needsGap 里的 createGapCursor 都是插件直接读 type.spec 的原始字段,prosemirror-model 的 schema.ts 没有为它们声明类型,属于插件与 schema 作者之间的约定字段。
closedBefore 与 closedAfter 就是标题里的方向语义,两者镜像。看 closedBefore(src/gapcursor.ts):
for (let d = $pos.depth; d >= 0; d--) {
let index = $pos.index(d), parent = $pos.node(d)
if (index == 0) {
if (parent.type.spec.isolating) return true
continue
}
for (let before = parent.child(index - 1);; before = before.lastChild!) {
if ((before.childCount == 0 && !before.inlineContent) || needsGap(before.type)) return true
if (before.inlineContent) return false
}
}
return true从光标所在层逐层向外。index 为 0 说明位置在该层第一个子节点之前,本层没有前面的兄弟可查:parent 是 isolating 就直接算封闭(isolating 边界本来就把内外的选区行为隔开),否则继续向上一层。有前兄弟时沿 lastChild 链一路下钻到最深的右下角:途中遇到「空且非 inline」的节点或 needsGap 的节点(isAtom、isolating、createGapCursor 满足其一)算封闭;遇到有 inlineContent 的节点算开放。一路走到文档顶也算封闭。
封闭的直观含义:光标这一侧没有贴着一个能接收文本的位置。拿两个具体文档走一遍。doc(horizontal_rule, horizontal_rule) 中间的位置:parent 是 doc,没有 inlineContent;closedBefore 在 depth 0 层查到 index 为 1,前兄弟是第一个 hr,它是叶子,childCount 为 0 且非 inline,返回 true;closedAfter 对称地命中第二个 hr,也是 true;contentMatchAt(1).defaultType 是 paragraph,属于 textblock,valid 通过。对照 doc(paragraph, horizontal_rule) 里段落与 hr 之间的位置:closedBefore 沿段落的 lastChild 钻到文本,段落有 inlineContent,直接返回 false,valid 不通过。后一种情况不需要 gap cursor,段落末尾本身就能放 TextSelection,光标有地方去。closedAfter 换成 indexAfter 和 firstChild 链,逻辑完全对称。test/test-gapcursor.ts 的用例可以当判定表读:文档首尾贴 atom 合法、贴段落不合法,空块内部合法(index 为 0 一路走到文档顶,两侧都算封闭)。
findGapCursorFrom:方向键怎么找到下一条缝
static findGapCursorFrom($pos, dir, mustMove) 是移动逻辑。dir 取 ±1,mustMove 表示当前位置必须离开(已经站在 gap cursor 上再按方向键时不能原地不动)。外层是一个带 search 标签的循环,每轮分两段。
向上扫:从 $pos.depth 逐层向外,找方向上还有兄弟的那一层。找到就把兄弟记为 next,转入下钻;扫到 d == 0 仍没有,返回 null。每跨越一层边界 pos 加 dir,跨过的每个位置都顺手查一次 valid,所以缝隙出现在上一层时也能被接住。
向下钻:拿到 next 后沿 firstChild(dir 为正)或 lastChild 链钻到叶子,途中每个位置查 valid。钻到叶子有个特例:叶子是 atom、不是文本、且 NodeSelection.isSelectable 为假,这种节点既不能放文本光标也不能被选中,直接整体跳过(pos 加 next.nodeSize 乘 dir,mustMove 置假,continue search)接着找。其余情况返回 null。
返回 null 的语义是「这里管不了」,调用方会把按键放行给后面的 handler。于是可选中的 atom(比如默认的 image)在钻到它面前时返回 null,方向键交给浏览器默认行为或 baseKeymap 去选节点;不可选中的 atom 被跳过,搜索继续。
把这个逻辑放回两个 hr 的场景走一条完整的导航链。NodeSelection 选中第二个 hr 时按 ArrowLeft,arrow 命令里 sel 不是 TextSelection,跳过文本块分支,from,正好是两个 hr 之间的缝隙位置,mustMove 是 sel.empty 即 false。findGapCursorFrom 第一步 valid 检查直接命中,dispatch 出 GapCursor。站在缝隙上再按 ArrowLeft,mustMove 为真,当前位置被跳过,向上扫找到第一个 hr,下钻时发现它是可选中的 atom 叶子,返回 null,按键放行,最终选中第一个 hr。整条链走下来,gap cursor 是导航上的一站,插在两个 NodeSelection 之间,可选中节点本身的选中行为没有被它接管。
插件装配:五个入口
src/index.ts 的 gapCursor() 返回一个 Plugin,props 挂了五项。decorations 留到下一节,先讲另外四个。
createSelectionBetween 是 view 提供的 prop。view 从 DOM 读回选区时(src/selection.ts 的 selectionBetween,第 30 篇讲过这条读回链路)先依次问各插件,都返回 null 再退回 TextSelection.between。gapcursor 的实现只有一行:head.pos 且 GapCursor.valid($head) 就造 GapCursor,否则放行。鼠标点进缝隙、DOM 选区读回来后变成 gap cursor,走的就是这里。
handleClick 是对点击的主动处理。先 resolve 点击落点,valid 才继续;再用 posAtCoords 按像素坐标反查文档位置(坐标换算见第 35 篇),如果点击实际落在某个可选中节点内部,返回 false 放行,让正常流程产出 NodeSelection。两个检查都通过,才 dispatch 一个把选区设成 GapCursor 的 transaction。第二个检查的存在是因为点击和缝隙经常共享同一片屏幕区域:image 这类 atom 节点本身就渲染在缝隙旁边,用户点击图片期望选中图片,点图片旁边的空白才期望落光标。posAtCoords 返回的 inside 字段标出坐标是否落在某个节点边界内部,配合 NodeSelection.isSelectable 正好把这两种意图分开。
handleKeyDown 复用 keymap 包的 keydownHandler(第 38 篇),注册四个方向键,共用 arrow(axis, dir) 生成的 Command:
let $start = dir > 0 ? sel.$to : sel.$from, mustMove = sel.empty
if (sel instanceof TextSelection) {
if (!view!.endOfTextblock(dirStr) || $start.depth == 0) return false
mustMove = false
$start = state.doc.resolve(dir > 0 ? $start.after() : $start.before())
}
let $found = GapCursor.findGapCursorFrom($start, dir, mustMove)TextSelection 先问 endOfTextblock(第 35 篇拆过这个函数):光标不在文本块该方向的边缘,块内还有位置可走,返回 false 放行。在边缘时用 before() 或 after() 跨出文本块一格,mustMove 置假,从新位置查起。$start.depth == 0 的排除有实际作用:光标所在文本块直接挂在文档顶层时,before() 会越界抛错,这里提前放行。已经在 GapCursor 上时它是空选区,mustMove 为真,必须移动。
handleDOMEvents.beforeinput 是给 IME 的补救,注释里自己写明是 hack。选区是 GapCursor 时收到 insertCompositionText,先用 contentMatchAt($from.index()).findWrapping(schema.nodes.text) 找到能包住文本的节点链(findWrapping 见第 17 篇),然后一个从里向外的循环把节点链逐个 createAndFill 成嵌套 Fragment,replace 进缝隙,再用 TextSelection.near 把选区放进新段落,让 composition 有 inline 上下文可用。背景在第 31 篇:composition 期间选区被搬进非法位置,浏览器会中止这次输入,所以要在 insertCompositionText 到达的这一刻先把落点换成合法的文本块内部。handler 最后返回 false:上下文已经造好,事件本身照常走原有管线,由正常的 composition 流程接管后续输入。
DOM 伪装:假光标是怎么画出来的
GapCursor 落在文档里只是状态,屏幕上那条闪烁的线完全是画出来的,分三层配合。
第一层是 decorations。drawGapCursor(src/index.ts)在选区是 GapCursor 时创建一个 div,className 为 ProseMirror-gapcursor,以 Decoration.widget 的形式插在 selection.head 处,key 固定为 “gapcursor”(widget 装饰的机制见第 33 篇)。固定 key 的作用是复用:选区在缝隙之间移动时,新旧的 widget 装饰被判定为同一个,DOM 节点跟着移动位置即可,不用销毁重建。包的 style/gapcursor.css 给这个 div 的 :after 伪元素画一条 20px 宽、1px 高的横线,挂一个 1.1 秒的闪烁动画,且只在 .ProseMirror-focused 下显示,编辑器失焦时光标跟着消失。用户看到的光标就是这个 widget。
第二层是藏起真光标。前面提到 visible = false,view 的 selectionToDOM 读到这个标记后给编辑器根节点加 ProseMirror-hideselection 类,view 包 style/prosemirror.css 里这个类把 caret-color 设为 transparent、::selection 背景设为透明。浏览器原生 caret 和选区高亮都被藏起来,不会和假光标重影。这个类还带一个守卫:hideselection 生效期间 DOM 选区若发生变化,selectionchange 监听器会延迟检查一次,编辑器不再持有选区或者 state 选区已经可见时把类摘掉,避免编辑器一直停留在隐藏状态。
第三层是真实 DOM 选区照样设置。visible 只影响可见性:selectionToDOM 照常调 docView.setSelection 把 DOM 选区放到缝隙位置,浏览器焦点和键盘输入才不中断。缝隙两侧都是不可编辑的块时,部分浏览器不接受这种 caret,view 里 temporarilyEditableNear 的补丁临时把相邻节点翻成 contentEditable,设完选区再翻回去(浏览器补丁见第 36 篇)。
三层合起来的效果:真实选区在缝隙位置但不可见,可见光标是 widget,输入焦点始终在编辑器里。所谓「光标落不进去的地方」,落到实现上就是状态层多一种选区类型,渲染层多一个装饰,再把原生行为各自藏好。
收尾
回到开头的场景,两个 horizontal_rule 之间现在可以落光标了:点击走 createSelectionBetween 或 handleClick,方向键走 arrow 加 findGapCursorFrom,落点由 valid 保证是两侧封闭且默认能放段落的位置,视觉由 widget 加 hideselection 伪装。valid 里 defaultType 必须是 textblock 的约定同时给 beforeinput 的 IME 补救留了后路:findWrapping 找的正是同一条内容表达式推出的包装链。
这套机制的边界也明确:gap cursor 只解决缝隙处落点的问题,落点之后输入内容仍要靠 schema 默认类型的填充或 IME hack 兜底。allowGapCursor 与 createGapCursor 两个 spec 字段是留给特殊节点的逃生门,自定义的隔离块想主动声明自己旁边允许或需要 gap cursor,直接写进 spec 即可,插件读取时优先于默认推导。
最后值得记一笔的是这个包的接入方式。整篇读下来,gapcursor 没有给核心打任何补丁:选区类型走 Selection 的公开继承点加 jsonID 注册,可见性走 visible 标记,选区读回走 createSelectionBetween prop,按键走 handleKeyDown,绘制走 decorations,IME 走 handleDOMEvents。这些扩展点分别来自 state 的选区体系和 view 的 props 管线(第 20、23、25 篇),插件只是把它们组合起来。一种新的光标形态能以纯插件形态落地,说明这套边界划分是经得住真实需求的。同思路的下一个包是 dropcursor,把落点指示用在拖拽场景,下篇拆。

