上篇把 CellSelection 看完,表格专题还剩命令层。prosemirror-tables 的编辑命令集中在 src/commands.ts 一个文件里,加列、删行、合并、拆分、导航,加起来二十来个导出。通读下来它们的结构高度一致:先用 TableMap 把选区翻译成一个矩形,在矩形上算坐标,再把坐标换回文档位置,用 setNodeMarkup、insert、delete 三类操作改文档。前两篇看的 TableMap 和 CellSelection 在这里全部被用上。参考代码是 prosemirror-tables 的 eb522f2。
系列目录
命令的统一外形
commands.ts 的导出分两层。底层是 addColumn、removeColumn、addRow、removeRow 这样的纯函数,签名是 (tr, rect, 行列号),只负责往一个已有的 transaction 上追加 step,不碰 state,也不 dispatch。上层是 addColumnBefore、deleteColumn、mergeCells 这样的标准命令,签名是 (state, dispatch) => boolean,和第 39 篇看的 commands 包同一个约定:dispatch 没传时只做可行性检查,返回 false 表示当前状态不可用,菜单和 keymap 都靠这个返回值决定是否拦截按键、置灰按钮。
上层命令的第一道检查几乎都是 isInTable(state)(src/util.ts):从选区 $head 沿深度往上找 tableRole 为 row 的祖先,找不到就返回 false。这道检查很浅,光标在表格任意深度都能通过,代价是 O(深度)。通过后命令才进入正题,而正题的第一步几乎都是 selectedRect。
selectedRect:所有命令共用的第一步
commands.ts 里的命令几乎都从 selectedRect 开始。它做三件事。
第一,selectionCell(state)(src/util.ts)拿到选区所在的格子位置。普通光标用 cellAround 沿深度往上找 tableRole 为 row 的祖先,再把位置解析到格子边界上;找不到时用 cellNear,先沿 nodeAfter 及其 firstChild 链向后找,再沿 nodeBefore 及其 lastChild 链向前找,直到撞上 tableRole 为 cell 或 header_cell 的节点;NodeSelection 选中一个格子时直接取 anchorCell 和 $headCell 中位置靠后的那个。三条路都找不到就抛 RangeError,不过调用方都先过了 isInTable,正常走不到。
第二,定位表格。pos.start(-1) 是表格内容的起始位置 tableStart。格子位置解析在 row 内部,相对深度 -1 正好落在 table 上。
第三,TableMap.get(table) 拿格网。CellSelection 用 map.rectBetween 算出 anchor 和 head 围成的矩形,普通选区用 map.findCell 找当前格子占的矩形。
返回值 TableRect 是 Rect(left/top/right/bottom)加上 tableStart、map、table 三个字段。后面每个命令拿到的都是这个包:行列坐标在格网上,位置换算靠 tableStart。findCell 和 rectBetween 工作在表格相对坐标系里,所以传参时先减掉 tableStart,用的时候再加回来。这个换算在文件里出现了几十次,是读这个文件最先要习惯的约定。
addColumn:一次插入,两种改法
addColumnBefore 和 addColumnAfter 本体只有几行:isInTable 检查,取 rect,然后调 addColumn(state.tr, rect, rect.left) 或 rect.right。真正的工作在 addColumn(src/commands.ts)里,它按行扫一遍格网,每行只有两种情况。
第一种:新列的格子落在一个 colspan 单元格内部。判定看 map 里相邻两项是否相等:map.map[index - 1] == map.map[index],同一个起点占了左右两格,说明这里被横向合并了。这时不插新格子,用 setNodeMarkup 把那个单元格的 colspan 加一,attrs 由 addColSpan(src/util.ts)算出:除了 colspan + n,还往 colwidth 数组的对应位置补 n 个 0,保持「colwidth 长度等于 colspan」的约定。处理完 row += cell.attrs.rowspan - 1,这个单元格纵向占的几行一起跳过。
第二种:普通格子,直接 insert 一个新单元格,位置用 map.positionAt(row, col, table) 算。positionAt 会跳过上一行 rowspan 垂下来的格子,返回「如果这里有一个格子,它该从哪开始」。新格子的类型由 refColumn 决定,这段值得展开。refColumn 初值是 -1(参考左边一列),col 为 0 时是 0(参考本列),新格子复制参考列同位置单元格的类型。然后做一次表头检测:参考列整列都是 header_cell 时(columnIsHeader 逐行查类型),refColumn 重新取值,插入点在表格最左或最右边缘就置 null,新格子一律用普通 cell;插入点在表格内部就取 0,改为复制本列(将被右移的那列)的类型。行为效果:在表头列块的内部插入,新列继承 header_cell;贴着表头块外缘或表格外缘插入,新列是普通 cell,避免一次加列把表头范围顺带扩大。
一个容易看漏的细节:addColumn 里所有写操作的位置都过了 tr.mapping.map。循环里 insert 和 setNodeMarkup 交替出现,每插入一个格子,后面的文档位置都变了,而 map 数组记录的是操作前的位置,必须映射过才能用。
addRow 是对称的,但有两个差异。一是行的插入位置好算:table_row 是 table 的直接子节点,把前 row 个 child 的 nodeSize 加起来就是插入点,不需要 positionAt。二是写操作不需要 mapping:被新行穿过的 rowspan 单元格只做 attrs.rowspan + 1,这些 setNodeMarkup 全部发生在 tr.insert 新行之前,此时文档还没动过,原始位置直接可用。同时 col += attrs.colspan - 1 跳过它横向占的列。最后新建一个 table_row,把这一行里需要新开的格子一次性 insert 进去。refRow 的逻辑和 refColumn 一样,用来继承表头行的单元格类型。
deleteColumn 与 deleteRow:删完要重取 TableMap
removeColumn 逐行处理:格子的左邻居或右邻居与它在 map 里同值,说明它在 colspan 中间,用 removeColSpan 减一(同时从 colwidth 里删掉对应项,数组里没有任何正值时整体置 null);否则 tr.delete 把整个格子删掉。row += attrs.rowspan 跳过纵向覆盖。
这里 mapping 的用法比 addColumn 更讲究。removeColumn 开头记下 mapStart = tr.mapping.maps.length,后面映射用 tr.mapping.slice(mapStart).map(…),只穿过本次调用自己加进去的 step。deleteColumn 要连删多列,每删一列格网就变了,下一列的位置换算必须把「这次调用产生的位移」和「之前删列产生的位移」分开,slice 出来的子 Mapping 干的就是这个。
deleteColumn 的循环配合这个设计:从右往左逐列删,每删完一列,用 tr.doc.nodeAt(rect.tableStart - 1) 重新拿 table 节点(rect.tableStart 为 0 时表就是整个文档,直接拿 tr.doc),再 TableMap.get 重算格网,下一轮用新 map。TableMap 缓存挂在节点对象上,文档变了节点对象也换了,重取是必需的;拿不到节点时抛 RangeError,属于防御分支。选区覆盖整表宽度时(rect.left == 0 且 rect.right == map.width)直接返回 false,删列不允许把表删空,删表有专门的 deleteTable。deleteRow 是同一套写法,只是换成按行循环。
removeRow 是另一个思路:先把整行 tr.delete 掉,再回头修受影响的单元格。从被删行上方延伸下来的 rowspan 单元格,rowspan 减一;从被删行开始、还要继续往下的单元格,复制一份 rowspan 减一的副本,insert 到下一行的对应位置(又是 map.positionAt)。seen 集合去重,避免一个 colspan 单元格被处理多次。
mergeCells:先验证矩形,再拼内容
mergeCells 的前置检查有两层。第一层要求选区是 CellSelection 且 anchor 和 head 不是同一个格子,单格没有合并一说。第二层是 cellsOverlapRectangle:沿矩形四条边扫,任何一条边上有单元格探出矩形外(边两侧的 map 值相等),返回 false。只有轮廓正好是矩形的选区才允许合并,跨着半个合并单元格的选区不行。
验证过后开始动手。按行优先顺序扫矩形内的格子,seen 表去重(合并单元格在 map 里占多格,只处理一次)。扫到的第一个格子保留为合并结果,记为 mergedCell;其余格子逐个处理:isEmpty 判断内容是不是只剩一个空段落,非空就把 cell.content append 进 content 片段(初值 Fragment.empty),然后 tr.delete 删掉整个格子,删除位置过了 tr.mapping.map。如果矩形里连一个格子都没扫到(理论上的防御分支),直接返回 true 收工。最后 setNodeMarkup 改写保留格子的 attrs:
tr.setNodeMarkup(mergedPos + rect.tableStart, null, {
...addColSpan(mergedCell.attrs, mergedCell.attrs.colspan,
rect.right - rect.left - mergedCell.attrs.colspan),
rowspan: rect.bottom - rect.top,
});colspan 补到矩形宽度,rowspan 设为矩形高度。被删格子的 colwidth 不带过来,addColSpan 在对应位置补 0,列宽信息在合并中丢掉,这是 attrs 级合并的自然结果。
content 不为空时,用 replaceWith 把收集到的内容塞进保留格子:保留格子本身是空的就整体替换,不空就追加在自己内容后面。这里的 start/end 用没映射的原始位置,和前面删除用映射位置看起来不一致,其实成立:按行优先顺序,被删的格子全在保留格子之后,删除不影响它前面的位置。最后把选区设为指向合并后格子的 CellSelection。
splitCell:span 重算加 colwidth 拆分
splitCell 是 splitCellWithType 的特化,后者接受一个 getCellType 回调决定新格子用 cell 还是 header_cell,回调拿到 {node, row, col} 三个字段,可以按格子在拆分区域里的位置返回不同类型;默认实现忽略行列,按原格子的 tableRole 取同类型,拆出来的格子和原格子同类。
可用条件比 mergeCells 宽:普通光标也行(cellWrapping 找所在格子),CellSelection 则要求 anchor 和 head 同格。colspan 和 rowspan 都是 1 的格子返回 false,没东西可拆。
拆分的做法:先把原格子的 attrs 复制一份,colspan、rowspan 都改回 1,按列预生成一份 attrs 数组。colwidth 在这里被拆开:原格子的 colwidth 是长度为 colspan 的数组,拆出来的第 i 列拿 [colwidth[i]],没设宽的列给 null。然后按行循环:第一行的新格子插在原格子后面(positionAt 加 cellNode.nodeSize),其余行插在 positionAt(row, rect.left, table) 算出的位置,每次插入的位置用 tr.mapping.map(pos, 1) 映射,assoc 传 1 让同一位置的连续插入互不覆盖。原格子最后用 setNodeMarkup 改成 attrs[0],自己充当拆分后左上角的格子。CellSelection 情况下选区重建为从 anchor 到最后一个插入格子的矩形。
mergeCells 和 splitCell 合起来看,span 信息的维护全在 attrs 上。TableMap 是只读的派生物,命令改的是文档里的 colspan/rowspan/colwidth,下一次 TableMap.get 基于新 attrs 重算格网。改 attrs 是唯一的事实来源,命令层不需要碰 map 数组本身。
setCellAttr:把「改 attrs」包装成命令
setCellAttr(name, value) 返回一个命令,干的就是 setNodeMarkup 改一个键,但包装了两层命令语义。一是 dry-run:dispatch 没传时,如果当前格子该属性已经是 value,返回 false。菜单系统拿这个返回值决定按钮置灰,已经是目标值的命令不可点。二是 CellSelection 感知:矩形选区下用 forEachCell 遍历选区格子,值已经等于 value 的跳过,其余逐个 setNodeMarkup;普通光标只改当前格。
colwidth 对 setCellAttr 来说只是一个普通的键,没有特殊路径。真正成体系写 colwidth 的是列宽拖拽,它走自己的 transaction,下一篇专门看。
goToNextCell:不用 TableMap 的导航
goToNextCell(direction) 是 Tab 键的处理函数,direction 为 1 向前,-1 向后。它完全不用 TableMap,findNextCell(src/commands.ts)直接走文档结构:
- 向前:当前行内还有右兄弟(cell.pos + nodeAfter.nodeSize,即下一个格子的起点;否则从下一行开始往下找第一个非空行,返回 rowStart + 1,加 1 是踏进 table_row 的开标签。
- 向后:先看 $cell.nodeBefore,有就减掉它的 nodeSize;否则往上逐行找,取遇到的第一个非空行的最后一个格子。
Tab 语义是「文档顺序的下一个格子」,文档顺序就是行优先的阅读顺序,用 ResolvedPos 的父子兄弟关系就能走完,格网在这里没有信息量。作为对照,方向键的上下左右移动(src/input.ts 的 arrow)用 src/util.ts 的 nextCell,那个才是 TableMap 的消费者:按格网方向找相邻格子,合并单元格的跨越被 map 数组自然吸收。同为移动,两种数据源,各管各的场景。
findNextCell 走到头(向前已是最后一格,向后已是第一格)返回 null,命令返回 false。这个 false 有用:keymap 里 Tab 还绑着其他处理,表格里走不通时按键可以继续往下一层处理器传,比如在列表里 Tab 是缩进。
找到目标格子后,选区设置也有讲究:TextSelection.between(cell))。moveCellForward 返回格子结束位置的 pos 之间找尽量大的文本选区,效果是把整格内容选中,Tab 过去直接打字就替换掉整格。最后 scrollIntoView 让视图跟上。
其余命令速览
deleteTable 不依赖格网,从 $anchor 沿深度找 tableRole 为 table 的节点,before/after 之间整个删掉。deleteCellSelection 把 CellSelection 里每个非空格子的内容替换成一份全新的空 cell 内容(tr.replace 配 Slice(baseContent, 0, 0),起止位置同样过 tr.mapping.map),保留格子和 attrs,只清内容。toggleHeaderRow、toggleHeaderColumn、toggleHeaderCell 共用一套切换逻辑:先在目标区域(toggleHeaderColumn 把选区覆盖的列扩成整列,toggleHeaderRow 把行扩成整行,toggleHeaderCell 就是选区本身)里把 header_cell 逐个 setNodeMarkup 回 cell;如果这批操作一个 step 都没产生(tr.steps.length 为 0),说明区域里本来没有表头,再反向把它们全部改成 header_cell。toggle 的实际语义是「区域里还有表头就去掉,没有就加上」,混合状态的区域会先被整体清成普通格。attrs 在类型切换中原样保留。文件尾部还有 moveTableRow 和 moveTableColumn 两个移动命令,核心逻辑抽在 src/utils/move-row 和 move-column 里,把移动拆成删除加插入,这里不展开。
收尾
commands.ts 读完,表格命令的面貌很统一:选区先翻译为 TableRect,在格网坐标系里想清楚动哪几行哪几列、哪些格子是合并的,再用 mapping 把操作前的位置换算成操作后的位置写回文档。TableMap 负责二维到一维的翻译,transaction 的 mapping 负责旧位置到新位置的翻译,命令本体只剩分情况的 attrs 维护。表格专题还剩最后一篇,columnresizing 看列宽拖拽怎么实时改 colwidth,以及 tableview 给表格套的那层 NodeView。

