columnresizing:列宽拖拽的实现

3 分钟阅读
·

上一篇把表格的编辑命令看完了:addColumn、mergeCells 这批命令都在改文档结构,靠的是 TableMap 的格网坐标。这篇是表格专题的最后一篇,看一个结构上不动、只改 attr 的交互:拖动列边缘调列宽。实现分在两个文件里:columnresizing.ts 是插件本体,负责鼠标事件、拖拽状态和手柄装饰;tableview.ts 是表格的 NodeView,负责把文档里的 colwidth attr 同步成 colgroup 里的 col 元素。参考代码是 prosemirror-tables 的 eb522f2;顺带引用的 prosemirror-state、prosemirror-view 分别是 ffad5d9、ca4c78e。

系列目录

日期 标题
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
10-10 表格专题(中):CellSelection,矩形的选区
10-17 表格专题(下):addColumn/mergeCells 等编辑命令
10-24 columnresizing:列宽拖拽的实现(本篇)

插件和 NodeView 各管一半

列宽这件事有两个面:交互(找到边缘、跟着鼠标动、松手写回)和呈现(列宽在 DOM 上怎么表达)。columnResizing() 返回的插件管第一个面,TableView 管第二个面。

插件这边,columnResizing(options) 接受五个配置:handleWidth 判定「鼠标在列边缘」的像素阈值,默认 5;cellMinWidth 列宽下限,默认 25;defaultCellMinWidth 没有显式宽度的列参与表格总宽计算时按多少估值,默认 100;lastColumnResizable 最后一列给不给拖,默认 true;View 渲染表格用的 NodeView 类,默认 TableView,传 null 则不注册自定义 NodeView。

插件状态是 ResizeState 类(src/columnresizing.ts),两个字段:activeHandle 记录当前激活手柄所在的单元格位置,-1 表示没有;dragging 记录拖拽起点 {startX, startWidth},false 表示没在拖。状态的更新全部走 meta:updateHandle 和 mousedown 的处理都是 dispatch 一个带 columnResizingPluginKey meta 的 transaction,ResizeState.apply 里读 meta 的 setHandle、setDragging 字段生成新状态。两个字段的更新互不影响,只有一个例外:setHandle 生效时 dragging 会被一并重置成 false,换手柄必然打断拖拽。纯 UI 状态也过一遍 transaction,这是 prosemirror-state 插件的常规做法(第 22 篇),状态变化可以靠现有的插件机制观察和调试。

文档变化时手柄位置要跟着走。ResizeState.apply 在 activeHandle > -1 且 tr.docChanged 时,用 tr.mapping.map(activeHandle, -1) 映射旧位置,再用 util.ts 的 pointsAtCell 校验映射结果是否仍指在某个单元格前面,校验不过就置 -1。合并单元格、删列之后手柄位置可能失效,这里直接放弃,不猜新位置。

TableView(src/tableview.ts)这边把表格包成三层 DOM:最外 div.tableWrapper 是 NodeView 的 dom,里面一个 table,table 先挂 colgroup 再挂 tbody,tbody 是 contentDOM,行和单元格的内容交给 ProseMirror 的 ViewDesc 照常渲染。列宽不写在 td 上,全部集中在 colgroup 的 col 元素上,同步逻辑收敛在 updateColumnsOnResize 一个函数里,下面专门看。

拖拽中的表格:手柄装饰、colgroup 与文档 colwidth 的对应关系

手柄是 widget 装饰

拖拽手柄平时不存在于 DOM 里。它是一个 widget 装饰,只在 activeHandle > -1 时生成:插件的 props.decorations 读插件状态,激活时调 handleDecorations(state, activeHandle)。props.decorations 在每次 state 更新后都会重跑,activeHandle 一变装饰集就重建,手柄的出现、移动、消失全靠这条声明式路径,插件自己从不直接增删手柄 DOM。

handleDecorations 先算出手柄对应的列:map.colCount($cell.pos - start) + colspan - 1,即手柄所在格覆盖的最后一列。然后逐行扫这一列,给满足条件的格生成装饰。条件有两半:右边是别的格或表尾(col == map.width - 1 || map.map[index] != map.map[index + 1]),上边是表顶或别的格(row == 0 || map.map[index] != map.map[index - map.width])。两半合起来排除被合并格遮住的内部位置,保证手柄只出现在这一列可见的右边缘上,rowspan 跨下来的格不会重复生成。

widget 的位置是 start + cellPos + nodeSize - 1,即格的末尾减一,渲染出来是贴着格右边缘的一个 div.column-resize-handle,绝对定位、光标形状这些交给样式表。dragging 期间还会给这一列的格各加一个 node 装饰,class 是 column-resize-dragging,样式表可以借此给拖拽中的列整列上色。

另外插件的 attributes prop 在 activeHandle > -1 时给编辑器根节点加 class resize-cursor,配合样式表让光标在表格任意位置都保持 col-resize 形状,拖出单元格边缘时光标不会跳变。

mousemove:找到要激活的手柄

激活哪个手柄由 handleMouseMove 决定。先看两个开关:view.editable 为假直接返回;正在拖拽时也不更新手柄,避免拖动中手柄跳到别的列。

判定分两步。第一步用 domCellAround 从 event.target 沿 parentNode 向上找 TD 或 TH,碰到带 ProseMirror class 的根节点就返回 null。null 意味着鼠标在编辑器里但不在任何单元格上,cell 保持 -1,已激活的手柄会被清掉。拿到鼠标下的单元格 DOM 后用 getBoundingClientRect 算鼠标距左、右边缘的距离,进入 handleWidth 范围内才继续。第二步 edgeCell 把屏幕坐标换成文档位置:posAtCoords 查询的横坐标在 clientX 基础上按方向加减一个 handleWidth 的偏移。源码注释解释了原因:鼠标贴着折叠边框移动时 posAtCoords 返回的位置不稳定,往格内偏一点再查。查到的位置经 cellAround(src/util.ts)归到单元格,右边缘直接用这个格的位置;左边缘换成左边相邻格的位置,如果当前格已经是行首(index % map.width == 0)就返回 -1,表格最左沿不给手柄。

lastColumnResizable 为 false 时还有一道检查:算出手柄对应的是最后一列(col == map.width - 1)就直接返回,activeHandle 保持不动。

算出的位置和当前 activeHandle 不同才 updateHandle,鼠标离开边缘时算出的是 -1,同样靠这次比较触发清除。手柄位置存在插件状态里,文档没变、只是鼠标移动时也走了一次空 transaction,这是这个插件里 transaction 最频繁的用法,每一步都不产生 step,开销就是一次状态分发。

除 mousemove 外还有一个 mouseleave 监听:鼠标离开编辑器、当前有激活手柄且没在拖拽时,直接 updateHandle(-1) 清掉。没有这道清理,手柄会停在最后一次激活的位置,光标已经不在表格上时它还在。

mousedown 之后:拖拽期间只改 DOM

handleMouseDown 在 activeHandle 有效且没在拖拽时接管事件。它做三件事:先用 currentColWidth 算出这一列当前的像素宽作为 startWidth;再 dispatch setDragging: {startX, startWidth} 把插件状态推进拖拽中;最后在窗口对象上(view.dom.ownerDocument.defaultView,覆盖编辑器放在 iframe 里的情况)挂 mousemove 和 mouseup 监听,preventDefault 之后返回 true。监听挂到 window 而不是编辑器 DOM 上,是因为拖拽中鼠标经常移出编辑器,挂在编辑器上就收不到事件了。返回 true 会让事件停在当前插件,后面的插件收不到这个 mousedown,这一点和拼装顺序有关,最后一节说。

挂监听之前还有一步容易漏掉的调用:displayColumnWidth 用刚算出的 startWidth 立刻跑一遍。此时宽度没变,画面不动,作用是借助拖拽的 override 通道把 colgroup 刷新到和文档一致的状态,接下来的 move 事件直接在这个基线上改。

currentColWidth 的取值有个顺序:attrs.colwidth 数组末位有值直接用,手柄格覆盖的最后一列正是拖拽目标列,末位就是目标列的宽;没有就从 DOM 量,view.domAtPos(cellPos) 的 node.childNodes[offset] 即单元格元素,offsetWidth 减掉已知 colwidth 的部分,剩余平摊给没定宽的列。第一次拖一个从没设过宽的表格时,起始宽度来自布局结果。

拖拽中的 mousemove 只做一件事:draggedWidth 算出 startWidth + (clientX - startX),用 cellMinWidth 兜底下限,然后调 displayColumnWidth。注意这里不 dispatch 任何修改文档的 transaction。displayColumnWidth 从单元格位置向上找到 TABLE 元素后直接调 updateColumnsOnResize,借它的 overrideCol、overrideValue 参数把目标列的 col 宽度改成拖拽值,只动 DOM。colgroup 以 dom.firstChild 取得,TableView 构造时先挂 colgroup 再挂 tbody 的顺序正是在这里被消费的。文档不动,ViewDesc 就不重渲染,一次 mousemove 的成本是一次 colgroup 属性更新加浏览器重排。event.which 为 0 说明按键已在窗口外松开,直接走 finish 兜底。

mouseup 进 finish:摘掉两个监听,updateColumnWidth 把最终宽度写回文档,再 dispatch setDragging: null 退出拖拽态。

写回:一次 transaction 更新整列

updateColumnWidth 是唯一直接改文档的地方。它先算出手柄格覆盖的最后一列 col,然后逐行遍历这一列:mapIndex = row * map.width + col,如果这一行该位置和上一行是同一个格(rowspan 的延续)就跳过,一个合并格只写一次。对每个要写的格,先定位 colwidth 数组下标:colspan 为 1 就是 0,否则用 col - map.colCount(pos),即目标列在格内的偏移。已有值和新宽度相等就跳过;没有 colwidth 就用 zeroes(colspan) 补一个全 0 数组再写下标,其余下标留 0,而 0 在 updateColumnsOnResize 里按「没宽」处理,不影响其他列的呈现。写法是 tr.setNodeMarkup(start + pos, null, {…attrs, colwidth}),只改 attrs,不动结构。

整列所有格在同一个 transaction 里写完,tr.docChanged 才 dispatch。一次拖拽对应一步可撤销的修改,undo 一次整列回到原宽。

对比一下两条路径:拖拽中 displayColumnWidth 只改 DOM,文档里的 colwidth 保持原值;松手时 updateColumnWidth 一次写回。中间态不进文档是刻意的:每次 mousemove 都 dispatch 会把 undo 栈填满中间宽度,协作场景还会把这些瞬时状态广播给远端。DOM 预览把中间态全部留在本地,只有最终值成为一步历史。中间如果文档因为别的原因变了,比如协作远端进来 step,TableView.update 会用文档里的旧值重算 colgroup,把拖拽中的临时宽度冲掉,前面对 ResizeState 做映射和校验的兜底就是给这种情况准备的。预览走 DOM、提交走文档,和第 33 篇 Decoration 不改文档做视觉标注是同一思路。

updateColumnsOnResize:colwidth 到 col 的同步

tableview.ts 的 updateColumnsOnResize 是三处共用的同步函数:TableView 构造时、TableView.update 时、拖拽中 displayColumnWidth 都调它。入参里的 overrideCol、overrideValue 是拖拽专用通道,指定某一列不用文档值而用传入值。TableView.update 本身只有一层类型检查:node.type 变了就返回 false,让 ProseMirror 销毁旧 NodeView 重建;类型没变就更新 node 引用并重跑同步,colwidth 变化、增删列都走这条路。

函数遍历表格首行,把每个格的 colspan 展开成逐列循环:这一列的宽度取 override(正好是被覆盖列时)或 colwidth[j],有值写 px 字符串,没有给空串。col 元素按需在 colgroup 末尾新建,已有的就地更新 style.width,遍历完多余的删掉,增删列之后 colgroup 的长度也总是对的。

总宽度同时累加:有宽的列按实际值,没宽的列按 defaultCellMinWidth。所有列都有宽时 fixedWidth 为真,table.style.width 固定成总宽、minWidth 清空;只要有没定宽的列就反过来,width 交给浏览器自动布局,minWidth 设成总宽保底。这解释了 defaultCellMinWidth 的实际角色:它是没定宽的列参与总宽计算时的估值,CSS 层面的单元格最小宽度要靠样式表自己设。TableView 构造时把这个值写成 table 上的 CSS 变量 —default-cell-min-width,样式表用它给单元格设 min-width,DOM 表现就能和这里的估值对齐。

拖拽期间这些 DOM 修改会触发 MutationObserver。TableView.ignoreMutation 把 target 是 table 或 colgroup 内部的 attributes 变化全部忽略,DOMObserver 不会把它们读回文档。没有这层忽略,拖拽预览就会和读回机制打架。

配置项与拼装顺序

最后看拼装。columnResizing 注册 NodeView 的方式有点特别:插件 props 里挂了一个空对象 nodeViews: {},真正的赋值发生在插件 state 的 init 里,往这个对象上写 nodeViews[tableName] = (node, view) => new View(node, defaultCellMinWidth, view)。这能成立是因为 EditorState.create 先于 EditorView 构造,插件 state init 跑完时 view 还没开始读 nodeViews,而空对象是挂在插件 spec 上的引用,init 里填的内容之后能被 view 看到。代价是这个 NodeView 在首次建 state 时就定死了,reconfigure 动态增删插件换不掉它。

插件之间的顺序有一条明确约束,写在 index.ts 的 tableEditing 文档注释里:tableEditing 对表格内的鼠标和方向键处理得比较宽泛,建议放在插件数组靠后的位置,让 gapcursor、columnResizing 这类处理更具体的插件先拿到事件。原因在事件分发机制上:handleDOMEvents 按插件顺序依次尝试,前一个返回 true 就不再传给后面的(第 29 篇)。columnResizing 的 mousedown 在激活手柄上按下时返回 true,如果 tableEditing 排在前面,这个按下会先被它当成单元格选择的起点处理,拖拽就起不来。所以表格家族的推荐顺序是 columnResizing 在前、tableEditing 殿后,其他插件放中间。

表格专题到这里收尾。回头看这四篇:TableMap 把合并单元格摊平成格网,CellSelection 和编辑命令建在格网坐标上,columnresizing 同样靠 map.colCount 和 map.map 定位列。表格相关的所有交互最终都落在同一张格网上,这是 prosemirror-tables 里最值得带走的设计。下一篇进入工程话题,看 example-setup 怎么把这一堆插件装配成一个完整编辑器。


1175 字 · 40 段落
xi ming

Written by xi mingFollow onGitHub