上一篇看了 EditorState 的四元组,selection 字段当时一句话带过,这篇把它打开。参考代码是 prosemirror-state 的 ffad5d9,主文件 src/selection.ts,整个文件不到 500 行,装下了选区基类、三种内置选区、书签机制和一组位置查找函数。标题里的第四种选区 GapCursor 不在这个包里,它由 prosemirror-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 体系:四种选区与选区书签(本篇) |
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 的头端无法落在图片之后,因为那里没有 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 位置时先用 findFrom 带 textOnly = true 双向找,找不到就退回 Selection.near;anchor 不在 inline 位置时,如果两点原本相同直接把 anchor 收成 head,否则反方向优先找,找到之后还要检查方向有没有翻转,翻转了(比如原本 anchor 在 head 前,修正后跑到 head 后)同样收成 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 兜底。节点选区比文本选区脆弱,节点本身可能消失,所以它的书签两级都有退路。
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 更新的实际通道。

