上一篇看了表格的 schema 和 TableMap:合并单元格被摊平成一张格网,findCell、rectBetween、cellsInRect 这些矩形运算都建在格网坐标上。这篇看消费这些运算最多的文件 cellselection.ts,回答一个问题:表格里「选了一片格子」这件事怎么表示、怎么画出来、文档变了怎么跟随。参考代码是 prosemirror-tables 的 eb522f2;顺带引用的 prosemirror-state、prosemirror-view 分别是 ffad5d9、ca4c78e。
系列目录
两个位置确定一个矩形
第 20 篇看过 state 包的 Selection 体系:TextSelection 用 anchor 和 head 两个扁平位置表达一条线段,NodeSelection 用 from 和 to 框住一个节点。表格选区是二维的,用户拖出来的是一个矩形,两个点不够描述全部边界,但两个点可以确定矩形:给对角线上的两个格,矩形由 TableMap 算出来。
CellSelection 的两个字段 headCell 都是 ResolvedPos,约定指在单元格前面,也就是 parent 是 table_row、nodeAfter 是那个格。锚点格是按下鼠标的那格,拖动时不动;头格跟着鼠标走。构造函数做的事:
- 取 anchorCell.start(-1) 得到表格内容起点 tableStart,把两个位置换算成相对表格起点的偏移。
- map.rectBetween(anchor 偏移, head 偏移) 求出覆盖两格的最小矩形。合并格在这里自然展开,一个 colspan=2 的格在格网上占两个位置,矩形按格网坐标算,不按文档节点算。
- map.cellsInRect(rect) 拿出矩形内所有格的偏移,过滤掉头格后再把头格 unshift 到最前面,让头格的 range 成为 ranges[0]。
- 每个格生成一个 SelectionRange:从 tableStart + pos + 1 到再加上 cell.content.size,即覆盖这个格的全部内容。cellselection.ts 里拿不到格时会抛 RangeError,因为 TableMap 和文档节点不同步属于数据损坏。
拿图里的例子过一遍:锚点格 E 在第 1 行第 1 列,头格 J 在第 2 行第 2 列,rectBetween 求出的矩形是第 1 到 2 行、第 1 到 2 列,cellsInRect 返回 E、F、I、J 四个偏移,过滤掉头格 J 再 unshift 回来,最终 ranges 的顺序是 J、E、F、I。注意 cellsInRect 的收取规则:只收顶角落在矩形内的格,一个从矩形左边或上边伸进来的合并格会被跳过(实现上是比较前一列或上一行的格网位置是否还是同一个偏移)。rectBetween 算出来的矩形按锚点格、头格的完整边界对齐,构造选区时这条规则不会丢格;它是给后面 commands 传任意矩形时兜底的。
最后 super(ranges[0].to, ranges) 交给基类。基类的 to 读的是 ranges[0],所以 CellSelection 的 from/to 落在头格的内容范围上。这个安排有用意:浏览器需要一个一维的 DOM 选区,把头格内容给它,光标落在用户最后操作的那格里,键盘输入接着头格走。
toJSON 只存 anchor 和 head 两个数字,类型标记 ‘cell’ 通过 Selection.jsonID(‘cell’, CellSelection) 注册,序列化选区(比如协作或本地持久化)能按这个 id 找回类。eq 比的是两个格的 pos,矩形相同即相等,不关心 ranges 的生成过程。
行选与列选
「选中整行」「选中整列」没有单独的类,就是矩形顶满了表格的某一边。两个判定方法:
- isColSelection():选区从表格顶一直到底。用 $anchorCell.index(-1) 拿到格所在行的下标(index(-1) 是行在表格里的序号),较小的行下标必须是 0;再用 nodeAfter.attrs.rowspan 算出两格各自的底边,较大的底边必须等于表格的行数 node(-1).childCount。
- isRowSelection():选区从最左到最右。用 map.colCount 拿两格的左边列号,较小的必须是 0;各自的右边由左边加 colspan 算出,较大的必须等于 map.width。
配套的两个静态方法 rowSelection 和 colSelection 做反向的事:给任意对角,返回能罩住它们的最小整行或整列选区。做法是 findCell 拿两个格的矩形,哪条边没贴到表格边缘,就把对应位置重新 resolve 到边缘上的格。以 colSelection 为例,锚点格的 top 大于 0 时,把锚点改到 map.map[anchorRect.left],也就是同列第一行的格;头格的 bottom 小于 map.height 时,把头格改到最后一行同列的格。两个方向的调整都做完,再用新的两个位置构造 CellSelection,矩形自然顶满整列。
这两个方法有两处实际消费。一处是 normalizeSelection 把 NodeSelection(row) 改写成整行选区,下面讲。另一处在 map 里:文档被改过之后,原来的锚点格、头格位置还能 resolve 到格上,但表格形状可能变了,原来的整行选区不一定还顶满,这时检测 isRowSelection() 为真就用 rowSelection 重建一遍,把「整行」的语义保住。
map:文档变了选区怎么跟
Selection.map 的职责是把选区映射到修改后的文档上。CellSelection.map 的步骤:mapping.map 映射两个格位置,doc.resolve 重新解析。这里位置选在格前面有一个附带的好处:格内文字的增删改映射后仍然停在格前面,选区对格内容的大部分编辑天然稳定,只有格本身的增删才会让位置失效。接下来过两道校验:pointsAtCell 确认两个位置仍然指在格前面(parent 的 tableRole 是 row 且 nodeAfter 存在),inSameTable 确认两格还在同一张表里。两道都过,再看表格节点身份有没有变(this.$anchorCell.node(-1) 与映射后的 node(-1) 引用比较),变了且原来是行选或列选就按上一节的方式重建,否则直接 new CellSelection。
校验不过时降级成 TextSelection.between(headCell):格被删了、表格没了,选区退回成普通文本选区,落在两个位置之间。这是整个降级链的最后一环,保证任何修改之后选区总是合法。
书签走的是同一套校验。getBookmark 返回 CellBookmark,只存 anchor 和 head 两个扁平数字;resolve 时重新 resolve 两个位置,检查 parent 的 tableRole 是 row、index 没越界、inSameTable,全过才重建 CellSelection,否则 Selection.near($headCell, 1) 在头格附近找一个位置。undo 恢复选区就是这条路径,所以 undo 一次删表操作后光标落在表格原位置附近,不会报错。
content 与 replace:矩形切片
CellSelection.content() 返回一个 Slice,内容是一串 table_row 节点,只含选中的矩形部分。遍历矩形的每一行,行内按格网扫描,seen 表去重(合并格在 map 数组里出现多次,只收一次)。麻烦在被矩形边缘切开的合并格:
- 横向被切:用 removeColSpan 修 attrs。左切从第 0 列减,右切从 colspan - extraRight 减,colwidth 数组同步 splice 掉对应项,剩下的项没有正宽度就置 null。左边被切掉的格用 type.createAndFill(attrs) 重建,原内容丢弃,生成一个全新的合法格;只是右边被切的格用 type.create(attrs, cell.content),原内容保留。
- 纵向被切:重算 rowspan 为矩形内实际覆盖的行数。上边被切的格同样 createAndFill 重建,只切下边的保留内容。
左切、上切丢内容的逻辑可以这样理解:矩形从中间剖开一个合并格,拿到的碎片在语义上是「新格」,它的内容归属已经在矩形外了,给一个空格比给一个内容残缺的格安全。最后如果同时 isColSelection 且 isRowSelection(整表全选),fragment 直接放整个 table 节点。Slice 的 openStart 和 openEnd 都是 1:切片在 row 这一层是打开的,粘贴时可以把这串 row 嵌进另一张表。复制粘贴对这块的消费留到后面讲 copypaste.ts 时再展开。
replace(tr, content) 逐 range 替换:记下当前步数 mapFrom,每个 range 用 tr.mapping.slice(mapFrom) 映射到最新坐标,第一个 range 塞 content,其余塞 Slice.empty。替换完用 Selection.findFrom 从映射后的 this.to(头格内容末尾)向前找一个新选区放上去。replaceWith 是 replace 包一层切片。这套逐 range 替换的骨架和基类 Selection.replace 一样,只是基类在第一个 range 后会算插入末尾位置,这里简化成 findFrom 向前搜。
画出来:装饰加隐藏原生高亮
浏览器原生选区是一维的 DOM Range,表达「一片格子」没有好办法:跨 td 拖选各浏览器行为不一致,选中的高亮也画不成矩形。prosemirror-tables 的处理分两层。
第一层,CellSelection.prototype.visible = false。prosemirror-view 的 selectionToDOM 仍然会把 DOM 选区同步到头格的内容范围(前面说的 ranges[0]),但发现选区 visible 为假时给编辑器 DOM 挂上 ProseMirror-hideselection 类,浏览器原生高亮被 CSS 压掉。NodeSelection 也是 visible = false,同一套机制。
第二层,视觉交给 drawCellSelection。当前选区是 CellSelection 时,forEachCell 遍历每个选中格,生成 Decoration.node(pos, pos + node.nodeSize, { class: ‘selectedCell’ }),打包成 DecorationSet 返回;其他选区返回 null。tableEditing 插件把它挂在 decorations prop 上,view 渲染时给每个选中 td 的 DOM 加 selectedCell 类,配色由样式表决定。这套就是第 33 篇 Decoration 体系里 node 装饰的标准用法:文档不动,只改 DOM 的 class,文档一变 DecorationSet map 一下继续跟。
forEachCell 本身也是对外的枚举接口:deleteCellSelection 删除选中内容、copypaste 复制选中格,都靠它拿到 (node, pos) 序列。
normalizeSelection:把落进表格的选区掰正
每个 transaction 之后,tableEditing 插件的 appendTransaction 先跑 fixTables 修表格形状,再把结果交给 normalizeSelection 修选区。两个函数用同一个 transaction 串联:fixTables 返回的可能是 undefined(表格没毛病),normalizeSelection 就从 state 上读选区和文档;真需要改写时,传入的 tr 为空调用 state.tr 现建一个,保证整个修正最多追加一个 transaction。它处理五类形态,核心思路是表格区域内不允许存在语义含糊的选区:
- NodeSelection 落在 cell 或 header_cell 上:改写成 CellSelection.create(doc, sel.from),单格选区。
- NodeSelection 落在 row 上:resolve sel.from + 1 拿到行内第一格,rowSelection 扩成整行。
- NodeSelection 落在 table 上:allowTableNodeSelection 为假(默认)时改写成从第一格到最后一格的 CellSelection,即整表全选;为真则保留 NodeSelection,让整张表当一个原子节点用。
- TextSelection 跨在格边界上:isCellBoundarySelection 检测 from 和 to 只差几个 token,中间只包着 row、table 的开闭标签,没有任何实际内容。开头的廉价排除用了一个固定的差距上限 6,这个数字可以数出来:同一行里从一格内容末尾到相邻格内容开头,隔着段落闭、cell 闭、cell 开、段落开 4 个 token;跨行时再加 row 闭、row 开,正好 6 个。差距更大说明中间夹着真实内容,直接放行。通过廉价排除之后,实现是从两端各自向上爬深度,分别找「后面还有内容」和「前面还有内容」的层,两边爬到的位置相同且该层节点 tableRole 匹配 row 或 table,就折叠成 sel.from 处的光标。
- TextSelection 横跨两个格:isTextSelectionAcrossCells 从两端向上找 tableRole 为 cell 或 header_cell 的祖先,祖先不同且 from.start() 到 $from.end()。浏览器把 DOM 选区拖过格边界时读回来的正是这种形态,收成单格文本选区后,后续操作(比如输入替换)不会误伤两格。
跨表格的边界
CellSelection 有一个硬约束:两个格必须在同一张表里。构造时不查这个(注释里写明是调用方的责任),map、CellBookmark.resolve、拖拽交互各自用 inSameTable 兜底。inSameTable 的判定就两条:两位置深度相同,且一个位置落在另一个位置的 node(-1)(表格)内容范围内。
拖拽场景在 input.ts 的 handleMouseDown:mousedown 落在 td 里就注册 mousemove 监听,move 里 cellUnderMouse 求鼠标下的格,调 setCellSelection(anchor, $head) 或鼠标不在任何格上时,分两种:拖拽刚开始(插件状态为空)就把头格收回锚点格,选区缩成单格;拖拽已经开始就直接 return,选区停在拖出表格前的最后一帧。效果是拖过表格边缘不会把选区拉进另一张表,也不会退化成文本选区,松开鼠标前的视觉始终是合法的矩形。
键盘路径在同一份文件里,走的是同一套边界。handleKeyDown 注册的 Shift 加方向键交给 shiftArrow:当前选区不是 CellSelection 时,先调 atEndOfCell 确认光标已经顶到格在按键方向上的边缘。atEndOfCell 从 head.before(d)。确认后把这个位置包成单格 CellSelection,然后用 nextCell 在格网上沿方向找下一格,找到就 new CellSelection(head) 扩展矩形,找不到(即头格已经在表格边缘)返回 false,按键交还给其他处理器。nextCell 只在头格所属表格的 TableMap 上找,嵌套表格里它看到的只有内层这张表,所以键盘扩展同样跨不出表格边界。
拖拽进行中,tableEditingKey 的插件状态存着锚点格的位置:拖拽中间可能有 transaction 进来(比如其他插件的 appendTransaction),apply 里用 mapping.mapResult 让锚点跟着文档走,被删就置空。createSelectionBetween 在拖拽期间直接返回当前选区,浏览器自己想建的 DOM 选区被压住。mouseup 或 dragstart 触发 stop:摘掉监听,往 transaction 里写 meta -1 结束拖拽态。
这一篇看完了 cellselection.ts 的全部内容:选区子类、书签、装饰函数、归一化函数,四块都立在 TableMap 的矩形运算上。下一篇看 commands.ts,addColumn、mergeCells 这批编辑命令怎么以 rect 定位为骨架改写表格结构。

