gapcursor:光标落不进去的地方怎么办

3 分钟阅读
·

上一篇拆完 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 的实际表现:方向键直接跳过这段缝,鼠标点过去选中的是节点,用户没有办法在两个块之间插入新段落。上一篇讲列表时说「每个列表项都有一个可以直接打字的落点」,落点缺失正是这类位置的特征。

系列目录

日期 标题
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:列表节点与最复杂的一批命令
07-04 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 落在文档里只是状态,屏幕上那条闪烁的线完全是画出来的,分三层配合。

gap cursor 的文档位置与渲染层伪装

第一层是 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,把落点指示用在拖拽场景,下篇拆。


1196 字 · 44 段落
xi ming

Written by xi mingFollow onGitHub