表格专题(上):表格 schema 与 TableMap

3 分钟阅读
·

上一篇看完 search,高级扩展阶段收尾。从这篇开始用四篇看 prosemirror-tables。表格在富文本编辑器里属于最容易出问题的功能:合并单元格让「第几行第几列」不再有简单答案,选区是矩形而不是区间,删一列会牵动一批单元格的 attrs。这个包的应对思路是把表格的二维结构单独算成一份扁平格网,所有命令先在格网上算坐标,再翻译回文档位置。顺带交代一句:仓库里有过一个 prosemirror-schema-table,那是只定义表格 schema 的历史包,已被 prosemirror-tables 取代,不用再读。参考代码是 prosemirror-tables 的 eb522f2。这篇看地基:src/schema.ts 的节点定义,src/tablemap.ts 的格网结构,加上 src/util.ts 和 src/utils/query.ts 里的定位函数。

系列目录

日期 标题
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:光标落不进去的地方怎么办
07-11 dropcursor:拖拽时的插入位置指示
07-18 menu:菜单栏组件体系
08-01 collab(上):协作编辑的 rebase 原理
08-08 collab(下):receiveTransaction 与整个收发循环
08-15 changeset:变更集的计算与展示
09-05 markdown:文档与 Markdown 的双向转换
09-19 search:查找替换插件
10-03 表格专题(上):表格 schema 与 TableMap(本篇)

schema:tableNodes 工厂

schema.ts 不直接给出四个节点的定义,导出的是一个工厂函数 tableNodes(options),因为单元格的内容表达式和附加 attr 得由调用方决定。options 有三个字段:tableGroup 指定 table 节点进哪个 group(通常传 “block”,让它能出现在 doc 顶层);cellContent 是单元格的内容表达式(比如 “block+”);cellAttributes 允许给单元格附加自定义 attr,后面细说。

返回的四个 NodeSpec 各有一个 tableRole 字段挂在 spec 上:table、row、cell、header_cell。这个字段不是节点 attr,同类型的所有节点共享,含义是「这个类型在表格结构里扮演什么角色」。包内代码一律通过 tableRole 识别节点身份,不硬编码类型名,所以调用方把 table_cell 改名成别的也能跑。

table 的 spec:content 是 “table_row+“,isolating 为 true,parseDOM 匹配 table 标签,toDOM 返回 [‘table’, [‘tbody’, 0]],输出时多包一层 tbody,和 HTML 表格的习惯结构对齐。解析方向不用为 tbody 写规则,DOMParser 遇到没有匹配规则的元素会默认下沉到子节点,tbody 里的 tr 自然被 table_row 接住。isolating 的效果在解析和切片时体现:外部内容不能跨过表格边界和内部内容拼接。

table_row 的 content 是 “(table_cell | table_header)*“。注意是星号不是加号,schema 层面允许空行存在。空行在格网计算时会记为 missing problem,由修复逻辑兜底,spec 这里选择先放行。这是表格 schema 的一个整体取向:内容表达式只能约束子节点的类型序列,约束不了「各行 colspan 之和相等」「rowspan 不越界」这类跨节点的一致性,这类约束全部下放给 TableMap 诊断加修复器,schema 保持宽松。

table_cell 和 table_header 形状相同,差在 tableRole 和 DOM 标签(td 与 th)。attrs 三个:colspan、rowspan 默认 1,validate 用内置的 “number”;colwidth 默认 null,validate 走自定义的 validateColwidth,要求 null 或数字数组。colwidth 的语义是「这个单元格覆盖的每一列各自的像素宽度」,所以数组长度应当等于 colspan,两者不一致时视为数据有问题。isolating 同样为 true,外部内容不会跨过单元格边界和内部内容拼接。

DOM 双向转换由 getCellAttrs 和 setCellAttrs 一对函数负责。getCellAttrs 从 td/th 上读 colspan、rowspan 属性,缺省按 1;读 data-colwidth 时先用 /^\d+(,\d+)*$/ 校验格式再拆成数字数组,并且只在 widths.length == colspan 时才保留,否则置 null。setCellAttrs 是逆操作:colspan、rowspan 等于 1 时不写属性(和默认值对齐,HTML 更干净),colwidth 存在时 join 成逗号串写进 data-colwidth。宽度不借用 HTML 原生属性,而是放在自定义的 data-colwidth 里,读写都由这对函数自己实现,数值可以精确还原。getCellAttrs 的入参类型是 HTMLElement | string,字符串形态出现在规则按 style 匹配的场合,这时没有属性可读,直接返回空对象走默认值。

cellAttributes 选项给调用方挂自定义 attr 的通道,比如给单元格加背景色。每个附加 attr 提供 default、validate,可选 getFromDOM 和 setDOMAttr 两个钩子,tableNodes 把它们合并进两种单元格的 attrs,读写 DOM 时在上面的 getCellAttrs/setCellAttrs 里顺带处理。schema 层不用为新增 attr 改任何代码。

最后是 tableNodeTypes(schema):遍历 schema.nodes,按 spec.tableRole 把四个 NodeType 收进一张表,结果缓存在 schema.cached 上。命令层拿单元格类型时走这个函数,比按名字查 schema.nodes 更稳。

TableMap:把二维表格拍平成数组

文档是树,表格在树里是「行容器套单元格」的结构。没有合并单元格时,行号乘列数加列号就能定位;有了 colspan 和 rowspan,一个单元格占多个格子,它在兄弟节点中的下标和格网坐标之间失去对应关系。这类问题在编辑命令里密集出现:在某列左侧插入一列,要知道哪些单元格被劈开、哪些要加 colspan;合并选区,要知道矩形里有哪些单元格、各自的跨度怎么收。TableMap(tablemap.ts)就是为这些问题存在的:把表格预先算成一份 width × height 的格网,之后所有几何问题都在格网上回答。

TableMap 的字段:width、height 是格网尺寸;map 是长度 width * height 的数组,按下标 row * width + col 存放「覆盖这个格子的单元格的起始位置」;problems 是诊断数组,没有问题时为 null。注意 map 里存的位置是表格相对坐标:表格内容起点记为 0,每个 table_row 的开闭 token 各占 1 个位置,和文档位置的换算留给调用方。

表格格网与 TableMap 的 map 数组

上图的例子:A 的 rowspan 为 2,占 (0,0) 和 (1,0);F 的 colspan 为 2,占 (2,0) 和 (2,1)。假设每个单元格 nodeSize 为 6,算出来的 map 是 [1, 7, 13, 1, 21, 27, 35, 35, 41]。同一个值出现多次,就是合并单元格占的那几格。查「(1,0) 是哪个单元格」直接读 map[3] 得 1,再 nodeAt(1) 拿到 A,不用遍历。

map 由 computeMap(table) 算出。computeMap 开头先校验入参的 tableRole 是不是 “table”,不是就直接抛 RangeError;height 直接取表格的 childCount,宽度由 findWidth 确定:每行的宽度是本行单元格 colspan 之和,加上前面行里 rowspan 覆盖到本行的那些单元格的 colspan,各行取最大值。这个函数里有个小优化,只有见过 rowspan 大于 1 的单元格之后才启动回看,普通表格每行只扫本行。扫描过程维护一个 mapPos 指针,始终指向 map 里下一个空格:rowspan 从上面压下来的格子已被占,指针先跳过它们,再写入当前单元格。写入就是往 colspan × rowspan 的矩形区域里填当前 pos。pos 的推进遵循位置约定:进一行加 1,过一个单元格加它的 nodeSize,出一行再加 1。

扫描中发现的不合法结构不抛错,记进 problems,共五种。overlong_rowspan:rowspan 超出表格最后一行,记超出的行数。collision:要写入的格子已被占,说明两个单元格抢同一格,记冲突的列数。missing:一行扫完还有空洞,比如空行或 colspan 之和小于 width,记缺的格数。zero_sized:width 或 height 为 0。colwidth mismatch:同一列在不同行声明了不同的宽度。overlong_rowspan、collision、missing 三种在扫描中产生;zero_sized 在行扫描结束后统一检查;colwidth mismatch 由最后的独立一步补查。往 map 里填值的同时,computeMap 用一个按列双槽的数组统计宽度声明:col * 2 存宽度值,col * 2 + 1 存该值被声明的次数,首次声明直接写入,后续行同值就加一,不同值且原计数为 1 就换成新值重新计一。扫完后,某列有宽度声明但计数小于 height,说明该列各行的宽度声明不一致,findBadColWidths 逐单元格核对,把宽度与最终采用值不符的单元格记成 problem,附带修正后的 colwidth 数组。注意这里用的是 unshift,colwidth mismatch 排在 problems 数组头部。problems 的消费方是表格修复器(fixtables.ts),它在 transaction 之后检查表格,按这些诊断把文档修回合法形状。扫描策略和修复策略分开,让 computeMap 可以对任何输入给出结果,不用假设文档一定合法。

TableMap.get(table) 是唯一的取数入口,带缓存。首选 WeakMap,以表格节点对象为键;文档不可变,没被改到的子树节点身份不变,缓存直接命中,表格没动的 transaction 不用重算格网。WeakMap 不可用的环境里退化成 10 个槽位的定长数组轮换。缓存能按节点身份成立,前提是 map 里存表格相对坐标,和表格在文档里的绝对位置无关,节点被移动后缓存依然有效。

TableMap 上的查询方法

格网建好之后,tablemap.ts 在类上提供一组几何查询,全部是 O(width × height) 以内的线性扫描,靠缓存摊薄成本。

findCell(pos) 回答「这个单元格占哪块矩形」:扫 map 找第一个等于 pos 的下标,换算出 left 和 top,然后向右看同行后续格子是否还是同一个 pos,是就扩 right;向下同理扩 bottom。SVG 里画了 findCell(35) 的过程,得到 Rect {left: 0, top: 2, right: 2, bottom: 3}。Rect 的 right 和 bottom 是开边界,F 占两列,right 是 2 而非 1,这样 right - left 正好等于 colspan,bottom - top 等于 rowspan,后续做区间运算不用反复加一减一。findCell 从第一个命中下标出发,恰好是单元格左上角的格子,向右向下扩展才成立。找不到时抛 RangeError,调用方有责任只传单元格起始位置。colCount(pos) 只取第一个命中下标的列号,回答「这个单元格最左在哪列」,删除列时用它判断单元格是否被波及。

nextCell(pos, axis, dir) 做方向导航:先 findCell 拿到矩形,横向移动时越过矩形左缘或右缘取相邻格子的 map 值,纵向同理,已经在边缘就返回 null。Tab 键在单元格间跳转、方向键进出表格,底层都是它。rectBetween(a, b) 把两个单元格的矩形取并集,给矩形选区定界。cellsInRect(rect) 返回矩形内所有单元格的起始位置,两处细节:用 seen 表去重,合并单元格只占一个名额;只收「左上角落在矩形内」的单元格,从矩形左缘或上缘延伸进来的半截单元格会被跳过,判定方式就是检查 map[index - 1] 或 map[index - width] 是否等于当前 pos。positionAt(row, col, table) 是反方向的换算:给格网坐标求文档位置。它逐行累加 rowStart,到目标行后从下标 col + row * width 开始读 map,跳过值小于 rowStart 的格子(那些是上面行 rowspan 压下来的,起始位置不在本行),落到行尾还没找到就返回 rowEnd - 1,也就是行结束前的位置,插入行的命令把新单元格放在这里。

定位函数:从文档位置到表格结构

TableMap 的输入是表格节点,日常使用里先得从选区位置找到表格和单元格。这批函数分两处:src/util.ts 和 src/utils/query.ts。

findTable($pos)(utils/query.ts)内部走 findParentNode,从当前深度向上找第一个 tableRole 为 “table” 的祖先,返回 FindNodeResult:node 是表格节点,pos 是表格前的位置,start 是表格内容起点,depth 是所在深度。start 正是把 TableMap 相对坐标换算成文档坐标的偏移量。同文件还有 findCellPos(doc, pos)(resolve 之后先试 cellAround 再试 cellNear)和 findCellRange(selection, anchorHit, headHit):后者在传入的就是 CellSelection 时直接取它的 anchorCell 和 headCell,否则用 hit 点(缺省回退到 selection 的 anchor 和 head)各解析出一个单元格,最后用 inSameTable 确认两端在同一张表里,跨表时返回 null。

util.ts 里的 cellAround(pos.depth - 1 向下遍历到 1,找到第一个 tableRole 为 “row” 的祖先就返回 pos) 处理位置不在单元格内的情况:先沿 nodeAfter 一路 firstChild 下沉找 cell 角色,找不到再沿 nodeBefore 一路 lastChild 反向找。isInTable(state) 判断选区 head 的任一祖先是不是 row。pointsAtCell(cellA, cellA.pos 落在 cellB.end(-1) 之间;深度相同时这个检查等价于两端挂在同一个表格节点下。

util.ts 里还有一组把 TableMap 包装成位置语义的函数:findCell(pos)、nextCell(pos.node(-1) 取表格节点,用 pos.start(-1) 把文档位置换成表格相对坐标喂给 TableMap,nextCell 拿到结果后再 resolve 回文档位置。坐标换算集中在这几行,命令层完全不用碰相对坐标。

这个文件尾部还有三个 attrs 层面的小工具,增删列、合并拆分单元格时会用到。removeColSpan(attrs, pos, n) 把 colspan 减 n,同时从 colwidth 数组里裁掉对应下标的段,裁完没有正值就把 colwidth 置回 null。addColSpan(attrs, pos, n) 反向操作,colwidth 在对应位置补 0。columnIsHeader(map, table, col) 扫一整列,所有格子都是 header_cell 类型才返回 true,「切换整列表头」这类命令靠它判断当前状态。三个函数都不碰文档,只算数据,文档修改由命令层用 Transform 完成。

到这里,表格的两块地基看完了:schema 层用 tableRole 和三个 attr 描述结构,TableMap 把结构拍平成可查的格网,定位函数负责文档位置和格网之间的往返。下一篇看建立在这上面的 CellSelection:矩形选区怎么用 anchorCell 和 headCell 表示,行选、列选怎么特化,以及它怎么借助 cellsInRect 算出选区覆盖的单元格集合。


1248 字 · 31 段落
xi ming

Written by xi mingFollow onGitHub