上一篇介绍了 EditorState 的四元组,本文展开其中的 selection 字段。参考代码是 prosemirror-state 的 ffad5d9,主文件为 src/selection.ts,整个文件不到 500 行,包含选区基类、三种内置选区、书签机制和一组位置查找函数。标题中的第四种选区 GapCursor 不在这个包中;prosemirror-gapcursor 通过注册机制提供它,本文最后说明。
系列目录
Selection 基类:anchor、head 与 ranges
Selection 是抽象基类,构造时收两个 ResolvedPos:$anchor 是选区修改时不动的一端,$head 是移动的一端。shift+方向键扩选时,锚点留在原地,头端跟着走,这两个字段对应的就是这个交互模型。anchor 和 head 两个 getter 返回未解析的数字位置,做相等判断和序列化时用数字就够。
基类还维护一个 ranges 数组。不传时默认构造一个区间,取 $anchor 和 $head 的 min、max 各一个。from、to、$from、$to 四个访问器都从 ranges[0] 上取,也就是说主区间永远是第一个 range。empty 检查所有区间的起止是否相同,任何一个区间非空整个选区就算非空。当前三种内置选区都只有单个 range。ranges 采用数组是为了支持表格等多区间选区,后续表格专题会涉及。
基类上有三个抽象方法:eq、map、toJSON。getBookmark 不是抽象的,基类给了默认实现,放到书签一节细说。另外还有一个 visible 标记,挂在原型上,默认 true,表示这类选区激活时浏览器里的选中范围要不要对用户可见。NodeSelection 把它改成 false,节点选区有自己的高亮渲染方式,不依赖浏览器原生选区。
三种内置选区的语义
TextSelection 是常规选区,两个端点都必须指向有 inline 内容的节点。构造函数中的 checkTextSelection 会检查这一点,端点位置错误时用 console.warn 警告一次(模块级变量记录已警告状态,避免重复输出)。这个检查仅用于构造路径,属于开发期辅助。TextSelection 有一个常用访问器 $cursor:anchor 和 head 相等时返回 $head,否则返回 null。该模型没有单独的光标类型;空 TextSelection 表示光标,$cursor 用于判断当前是否为光标点。
NodeSelection 指向单个节点。构造时取 $pos.nodeAfter 作为被选节点,把 $end 解析到 $pos.pos + node.nodeSize,然后以 $pos 为 anchor、$end 为 head 调基类构造。所以 NodeSelection 里 anchor 恒等于 from,head 恒等于 to,锚和头的区分对它没有意义,它只是「包住一个节点的区间」。isSelectable 静态方法规定什么样的节点能被选:不是文本节点,且节点类型的 spec 没有写 selectable: false。它的 content() 返回以该节点为唯一内容、openStart 和 openEnd 都为 0 的 Slice,复制一个被选节点时拿到的就是它。
AllSelection 表示全选,构造时 anchor 解析到 0,head 解析到 doc.content.size。文档首尾存在叶 block 节点时(例如文档以图片结尾),TextSelection 的 head 无法落在图片之后,因为该位置没有 inline 容器,因此无法用 TextSelection 表达全选。AllSelection 直接覆盖整个文档范围。
三者的 eq 语义各不相同,值得摆在一起看。TextSelection.eq 比较对方的 anchor 和 head 两个数字;NodeSelection.eq 只比 anchor,因为 head 由节点大小推导出来,anchor 相同 head 必然相同;AllSelection.eq 只看对方是不是 AllSelection 的实例,连位置都不比,全选没有部分相等的概念。eq 的调用方拿它判断两次 state 之间选区有没有真的变过,没变就跳过一次 DOM 选区同步或一次插件回调。
$cursor 和 inline 位置的边界处理
文档经过映射之后,原来合法的选区端点可能不再合法。比如选区头端原本在一个段落里,映射后那个段落被删了,头端落到一个 block 节点边界上。TextSelection.map 处理这种情况的方式是:先映射 head,如果新位置的父节点没有 inline 内容,整个选区退回 Selection.near($head) 找最近的有效位置;head 合法时再映射 anchor,anchor 的父节点没有 inline 内容就把 anchor 收成 head。
Selection.near 依赖一组查找函数。findFrom($pos, dir, textOnly) 先看 $pos.parent 本身有没有 inline 内容,有就直接在这个位置建 TextSelection;没有就调 findSelectionIn 在当前父节点的子节点里按 dir 方向扫;还没找到就沿深度逐层向外,在每一层的 before 或 after 位置继续扫。findSelectionIn 是递归扫描:节点有 inline 内容就返回 TextSelection;子节点不是 atom 就递归进去;子节点是 atom 且 isSelectable(且 textOnly 为假)就返回包它的 NodeSelection。Selection.near 先按 bias 方向找一遍,找不到换反方向,两个方向都找不到时返回 AllSelection,因此任何文档都能得到一个选区。atStart 和 atEnd 是同一套扫描从文档头或尾开始。
静态方法 TextSelection.between($anchor, $head, bias) 接收任意两个位置,并返回合法的文本选区。其修正顺序如下:两点不同时,bias 按两者先后关系重算(anchor 在 head 后取 1,在前取 -1),传入的 bias 只在两点重合时生效;head 不在 inline 位置时,先用带 textOnly = true 的 findFrom 双向查找,找不到则调用 Selection.near;anchor 不在 inline 位置时,两点原本相同则将 anchor 设为 head,否则优先反方向查找。查找后还会检查方向是否翻转;例如原本 anchor 在 head 前,修正后位于 head 后时,也会将 anchor 设为 head。该检查避免 between 返回方向与用户操作相反的选区。
文件中的 FIXME 注释指出:扫描选区的代码目前不感知文本方向,双向文本(如阿拉伯文混排)场景下「最近位置」的语义可能不正确。
map 的消费方:transaction 的惰性映射
map 方法最主要的调用方在 src/transaction.ts。Transaction 内部存两个私有字段:curSelection 是当前选区,curSelectionFor 记录这个选区对多少个 step 有效。外部读 tr.selection 时走 getter:如果 curSelectionFor 落后于已累积的 step 数,就把选区 map 过 this.mapping.slice(this.curSelectionFor) 这段尚未应用的映射,然后更新计数。也就是说选区不会每加一个 step 就重算一次,只在被读的时候一次性补齐,多次读取之间没有新 step 时直接返回缓存。EditorState 一侧,src/state.ts 把 selection 定义成内置 StateField,init 取 config.selection || Selection.atStart(instance.doc),apply 直接返回 tr.selection,所以一次 transaction 应用完,新 state 的选区就是这里惰性映射出来的结果。
setSelection 写入时有个校验:选区的 $from.doc 必须是 transaction 当前的 doc,拿旧文档上的选区直接 set 会抛错。这个校验挡掉的是一类常见错误:先 dispatch 了一个 transaction,又拿旧 state 的 selection 构造新 transaction。
选区书签:不带文档保存一个选区
SelectionBookmark 是个接口,只有两个方法:map(mapping) 把书签映射过一组变更,resolve(doc) 在给定文档上把书签恢复成真实选区。历史插件需要用到它:undo 栈要记录每个历史点的选区,但保存 Selection 实例会同时持有当时的文档(因为其中包含 ResolvedPos)。书签只保存数字,不包含文档,并将映射和恢复拆为两个步骤。
三种内置选区各有对应的书签实现。
TextBookmark 存 anchor 和 head 两个数字。map 时用 mapping 各映射一次;resolve 时不直接 new TextSelection,改走 TextSelection.between,这样映射把端点打到非 inline 位置时能按前面的修正逻辑处理,恢复不出来就退回最近位置。
NodeBookmark 只存 anchor 一个数字,它的 map 多一步检查:用 mapping.mapResult 获取 deleted 标志。被选节点在变更中删除时,书签降级为 TextBookmark(pos, pos),即光标书签。resolve 时先检查 $pos.nodeAfter 存在且仍然 isSelectable,满足时返回 NodeSelection,否则调用 Selection.near 查找选区。节点本身可能在变更中消失,因此 NodeBookmark 在 map 和 resolve 两个阶段都提供降级处理。
AllBookmark 是个单例对象,map 原样返回自己,resolve 时拿传入文档 new 一个 AllSelection。全选跟具体内容无关,不需要记任何位置。
基类的 getBookmark 有个默认实现:把当前选区转成 TextSelection.between(this.$anchor, this.$head) 再取它的书签。自定义选区类如果不覆盖这个方法,历史恢复时选区类型会丢,退化成文本选区。三种内置选区都覆盖了自己的 getBookmark。
书签的实际消费者在 prosemirror-history(参考代码 445409b)。它的 undo 栈由 Item 组成,每个 Item 可选地持有 step 和选区书签,文件里的注释解释了为什么只存书签:恢复选区之前不必提供文档,这在压缩合并历史事件时更灵活。每次有新的编辑事件入栈时,history 调 state.selection.getBookmark() 记下当时的选区;undo 弹出 Item 时,先把书签 map 过中间累积的映射,再在新文档上 resolve,光标回到那次编辑发生前的位置。书签先通过 map 跟随变更,再通过 resolve 在目标文档中恢复选区。
replace:替换选区之后光标落在哪
Selection.replace(tr, content) 把选区替换成一个 slice(不传就是删除),修改追加到传入的 transaction 上。实现里先记 mapFrom = tr.steps.length,然后遍历 ranges:每个 range 用 tr.mapping.slice(mapFrom) 把位置映射过本次操作已追加的 step,第一个 range 用 tr.replaceRange 换上 content,其余 range 换空 slice(多区间选区替换时,内容只插一份,其余区间删掉)。
替换完要把选区放到插入内容的末尾,这件事由 selectionToInsertionEnd(tr, startLen, bias) 完成。它先看 transaction 最后一个 step,只有 ReplaceStep 或 ReplaceAroundStep 才继续(别的 step 类型谈不上「插入末尾」);然后取最后一个 StepMap,forEach 时用 end == null 的判断只记下第一个新区间的 newTo 作为插入末尾(常规插入只有一个新区间);最后用 Selection.near(tr.doc.resolve(end), bias) 算出选区,tr.setSelection 写回 transaction。
bias 的计算在 replace 开头:沿 content 的 openEnd 一路取 lastChild 钻到最深层,如果结尾落在 inline 节点上(或者结尾父节点是 textblock),bias 取 -1,让 Selection.near 先往文内找,光标停在插入文字的后面;否则取 1,向前找。这个区分对应一个实际体验:输入一段文字后光标应该在文字末尾,插入一个 block 节点后光标应该在节点之后。
TextSelection.replace 在基类之上加了一段:content 为空(即纯删除)时,取 this.$from.marksAcross(this.$to) 算出删除范围两侧仍然成立的 mark 集合,非空调 tr.ensureMarks(marks) 写进 storedMarks。效果是删掉一段加粗文字后,光标处的 storedMarks 仍带着加粗,继续输入还是粗的。marksAcross 是 ResolvedPos 上的方法(第 7 篇的范围),ensureMarks 是 Transaction 上的方法(下一篇细讲),这里只看它们在选区替换里的配合。replaceWith(tr, node) 是同一模式的变体,用 replaceRangeWith 插节点、用 deleteRange 清其余区间。
第四种选区与注册机制
state 包内置三种选区,第四种从注册机制进来。文件开头的 classesById 是用 Object.create(null) 建的对象注册表,Selection.jsonID(id, selectionClass) 把自定义选区类按 ID 字符串登记进去,重复注册直接抛错,同时在类的 prototype 上记下 jsonID。Selection.fromJSON(doc, json) 按 json.type 查表找到类,再调那个类的静态 fromJSON。三种内置选区在文件里各自注册了 “text”、“node”、“all”,toJSON 分别输出 {type: "text", anchor, head}、{type: "node", anchor}、{type: "all"}。
prosemirror-gapcursor(参考代码 72657d0)里的 GapCursor 就是这个机制的现成例子:class GapCursor extends Selection,原型上 visible = false,文件里跑一句 Selection.jsonID("gapcursor", GapCursor)。它处理的是两个 block 节点之间的空档,那种位置没有任何文本容器,TextSelection 落不进去,GapCursor 伪装一个光标在那里。机制细节留给扩展篇,选区体系允许扩展。新选区类型实现 eq、map、toJSON、fromJSON 并注册 ID 后,就能作为 EditorState 的 selection 字段值参与序列化和历史恢复;若不覆盖 getBookmark,历史恢复时选区类型会变为文本选区。
本文说明了选区的静态结构:基类的 anchor/head/ranges 模型,三种内置选区的语义和 eq 规则,书签的保存与恢复,以及 replace 后的选区位置计算。下一篇介绍 Transaction,它在 Transform 上增加 selection、storedMarks、meta 等状态语义,并承接用户输入到 state 更新的流程。
