CKEditor 4 表格列宽拖拽指示柱(Pillar)机制解析:从 trac10472 回归测试看 tableresize 插件的实现原理
CKEditor 4 表格列宽拖拽指示柱(Pillar)机制解析:从 trac10472 回归测试看 tableresize 插件的实现原理
CKEditor 4 的 Table Resize(tableresize)插件允许用户在编辑器中直接拖拽表格列边界调整列宽,拖拽时编辑器会显示一条"半透明的阴影柱"(shadowy pillar)作为列宽变化的视觉指示。本文以仓库中针对历史缺陷 trac10472(4.7.0 版本修复:无法在表头/表尾行上使用 Table Resize)的 Bender.js 手动回归测试用例 pillar.md 及其配套页面 pillar.html 为主线,深入讲解 pillar(指示柱)在源码中的构建、对齐与渲染机制,并给出可复现的测试步骤、自动测试佐证及后续相关缺陷的演进脉络。读完本文,你将理解 pillar 的高度为何必须与表格等高、它如何跨 <thead>/<tbody>/<tfoot> 对齐,以及如何用仓库内的手动测试与单元测试验证该行为。
一、测试用例背景:trac10472 到底在验证什么
pillar.md 是一份典型的 Bender.js manual test 文档,其头部元信息完整定义了测试的归属与运行环境:
@bender-ui: collapsed
@bender-tags: bug, trac10472, 4.7.0
@bender-ckeditor-plugins: wysiwygarea, toolbar, table, tableresize, basicstyles, undo
@bender-tags表明这是一个针对缺陷(bug)的回归测试,关联的工单为trac10472,修复随 4.7.0 版本发布;@bender-ckeditor-plugins声明了用例所需插件:wysiwygarea(经典编辑器承载区)、toolbar(工具栏)、table(表格)、tableresize(被测插件)、basicstyles(基础样式)与undo(撤销/重做,用于验证拖拽列宽后的历史记录)。
配套页面 pillar.html 构造了一个三列、宽度 500px、同时包含 <thead>、<tbody>、<tfoot> 三个区块的表格,并通过 CKEDITOR.replace( 'editor' ) 将其初始化为经典编辑器实例。测试操作与预期如下(原文完整继承):
按住表格第一行或最后一行的部分,拖动分隔单元格的垂直边框(内部边框)。
Expected(符合预期):通过这两处拖拽均可行,拖拽时出现的阴影 pillar 高度等于表格整体高度。
Unexpected(不符合预期):这两处无法拖拽,或者阴影 pillar 高度小于表格整体高度。
也就是说,该用例专门验证一个关键体验点:无论鼠标落在表头(thead)、表体(tbody)还是表尾(tfoot)的某一行的单元格边界上,拖拽指示柱都必须纵贯整个表格高度,而不能只覆盖当前那一行的高度。这正是 trac10472 "Unable to use Table Resize on table header and footer"(CHANGES.md 中 4.7.0 修复记录)所描述的历史缺陷——修复前,表头/表尾行上的列边界无法触发列宽拖拽,或指示柱高度计算不完整。
二、Pillar 是什么:从源码看指示柱的数据结构与生命周期
Pillar(柱)在 tableresize 插件中并不是一个可见的独立 DOM 元素,而是一个描述"某条垂直列边界在页面中的位置与尺寸"的数据对象,用于驱动鼠标命中检测与拖拽条(resizer)的渲染。
2.1 Pillar 对象的字段
在 plugin.js 的 buildTableColumnPillars 中,每个 pillar 被构造为如下结构(plugin.js):
pillar = {
table: table, // 所属表格的 CKEDITOR.dom.element
index: pillarIndex, // 列索引(累加 colSpan 得到)
x: pillarLeft, // 指示柱左边缘的页面横坐标
y: pillarPosition.y, // 指示柱顶部纵坐标(取当前 tr 的位置)
width: pillarWidth, // 指示柱宽度(最小 3px,保证可命中)
height: pillarHeight, // 指示柱高度(取当前 tr 的 offsetHeight)
rtl: rtl // 是否为从右到左(RTL)方向
};
其中 pillarLeft/pillarRight 的计算会综合当前单元格的右边界与下一单元格的左边界(表格最后一列则退化为表格自身右边界),pillarWidth = Math.max( pillarRight - pillarLeft, 3 ) 保证极窄时仍至少有 3px 的命中区域;IE 浏览器在 border-collapse: collapse 下还会做一次位置修正(对应缺陷 #2823)。RTL 方向则完全镜像左右语义。
2.2 Pillar 的高度来自哪一行
关键点在于 pillarHeight 与 pillarPosition:它们取自 pillar 所属那一行 <tr>(即鼠标当前悬停的行),见 setPillarDimensions(plugin.js):
function setPillarDimensions( nativeTableElement ) {
if ( nativeTableElement ) {
var tableElement = new CKEDITOR.dom.element( nativeTableElement );
return { height: tableElement.$.offsetHeight, position: tableElement.getDocumentPosition() };
}
}
这意味着:如果只按当前行的高度渲染指示柱,位于 thead 或 tfoot 的行就会产生"短柱子",这正是 trac10472 描述的 Unexpected 现象(pillar 短于表格高度)。修复的关键,是让相同列索引的多个 pillar 在渲染时被"对齐合并"。
2.3 alignedPillars:把各行的柱子对齐成一条完整纵线
buildTableColumnPillars 会遍历表格的全部行(包括 thead/tbody/tfoot 下的所有 <tr>,见 plugin.js),为每一行的每个单元格边界生成一个 pillar,然后通过 pillarIndexMap 按列索引分组:
pillarIndexMap[ pillarIndex ] = pillarIndexMap[ pillarIndex ] || [];
pillarIndexMap[ pillarIndex ].push( pillar );
pillar.alignedPillars = pillarIndexMap[ pillarIndex ];
同一列边界在表头、表体、表尾各自产生的 pillar 会被收进同一个 alignedPillars 数组。渲染拖拽条时(columnResizer.attachTo,plugin.js),取第一个对齐 pillar 的 y 作为顶部,取最后一个对齐 pillar 的底部作为底部:
firstAligned = pillar.alignedPillars[ 0 ];
lastAligned = pillar.alignedPillars[ pillar.alignedPillars.length - 1 ];
resizerY = firstAligned.y;
resizerHeight = lastAligned.height + lastAligned.y - firstAligned.y;
resizerHeight 因此覆盖了从第一个对齐 pillar 顶部到最后一个对齐 pillar 底部的全部纵向范围——在完整的 thead + tbody + tfoot 结构下,该范围恰好等于表格整体高度,与 pillar.md 中 Expected 的描述完全吻合。同时 attachTo 只接受当前悬停命中的那个 pillar,但把高度与位置扩展到整列对齐范围,从而让用户从表头行或表尾行的边界上都能发起拖拽。
三、复现步骤与判定标准:如何亲手跑一遍该手动测试
3.1 准备测试页面
该手动用例的页面 pillar.html 本身就是可直接运行的最小复现环境。其表格结构如下(已整理):
<div id="editor">
<table border="1" cellpadding="1" cellspacing="1" style="width:500px">
<thead>
<tr>
<th scope="row" style="width:227px">Header</th>
<th scope="col" style="width:90px">Header</th>
<th scope="col" style="width:165px">Header</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" style="width:227px">Header</th>
<td style="width:90px"> not header</td>
<td style="width:165px">not header</td>
</tr>
<tr>
<th scope="row" style="width:227px">Header</th>
<td style="width:90px">not header</td>
<td style="width:165px">not header</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row" style="width:227px">Header Footer</th>
<td style="width:90px">footer</td>
<td style="width:165px">footer</td>
</tr>
</tfoot>
</table>
</div>
<script>
CKEDITOR.replace( 'editor' );
</script>
三列分别占 227px、90px、165px,合计 500px;首列在表头、表体、表尾均为 scope="row" 的 <th>,其余为普通 <td>。这样的结构保证每一列边界在 thead、tbody、tfoot 三处都存在对齐的 pillar 可供合并。
3.2 操作步骤与判定
- 用
CKEDITOR.replace( 'editor' )初始化编辑器后,将鼠标移动到**表头行(thead 第一行)**某个单元格之间的垂直边框上; - 按住鼠标左键拖动;
- 将鼠标移动到**表尾行(tfoot 最后一行)**的对应边框上,重复拖拽;
- 观察拖拽时出现的深蓝色半透明指示柱(源码中 resizer 的
background-color:#004、cursor:col-resize,见 plugin.js)。
判定:
- Expected:表头行与表尾行的边框均可拖拽;指示柱纵贯整个表格(从 thead 顶部延伸到 tfoot 底部),高度等于表格整体高度;
- Unexpected:两处不可拖拽,或指示柱只覆盖鼠标所在那一行的高度(短柱子)。
满足 Unexpected 即意味着回归了 trac10472 的缺陷,说明 pillar 对齐逻辑出现退化。
四、指示柱背后的完整交互链路:从 mousemove 到 resize
pillar.md 验证的是交互链路中"命中与渲染"环节,而完整的列宽拖拽流程在 plugin.js 中可归纳为五步:
- mousemove 命中检测:插件在
contentDom阶段监听mousemove(plugin.js),仅当目标元素是<table>或其 thead/tbody/tfoot 后裔、且该表格位于编辑器可编辑区域内时才继续处理(防止误操作编辑器外部的表格,对应缺陷 #12825、#11603); - 构建并缓存 pillars:首次悬停时调用
buildTableColumnPillars,结果以自定义数据_cke_table_pillars缓存到表格节点上(plugin.js),鼠标移出或按下时通过clearPillarsCache失效重建;页面滚动时也会清空缓存并按防抖(200ms)重建(plugin.js,对应缺陷 #4889); - 命中 pillar 并挂接 resizer:
getPillarAtPosition用checkWithinDimensions判断鼠标坐标是否落在某个 pillar 的矩形内(plugin.js);命中则创建columnResizer并attachTo该 pillar,按 2.3 节的对齐逻辑设置指示条位置与高度; - mousedown 开始拖拽:
resizeStart用CKEDITOR.tools.buildTableMap建立表格映射,收集边界两侧的单元格并计算左右最小宽度作为拖拽边界(plugin.js),同时触发saveSnapshot记录撤销快照; - mousemove/mouseup 完成调整:
resizeColumn按currentShift增量同步改写左右两侧单元格宽度(最小值 1px,对应缺陷 #11626),位于表格边缘时同步调整表格整体宽度;全部单元格处理完后触发saveSnapshot收尾(plugin.js),保证一次拖拽对应一次可撤销的历史记录(对应缺陷 #13388)。
第 3 步正是 pillar.md 的验证对象——只有当 alignedPillars 正确合并了 thead 行与 tfoot 行产生的 pillar,attachTo 才能渲染出与表格等高的指示柱。
五、仓库中的佐证:自动测试与相邻手动用例
5.1 自动测试(Bender 单元测试)
tests/plugins/tableresize/tableresize.js 提供了与本主题互补的自动化覆盖:
init()与resize()辅助函数通过伪造mousemove/mousedown/mouseup事件驱动插件:第一次 mousemove 用于创建 pillars,第二次用于创建 resizer(tableresize.js);- 断言
div[data-cke-temp]指示条在编辑器内的数量为 1、编辑器销毁后被移除,验证 resizer 的生命周期与作用域隔离(test classic editor、test inline editor); test preventing creating pillars on tables out of editor验证位于其他表格内部的内联编辑器不会污染外层表格的 pillars;test undo/redo table resize验证一次拖拽对应一次可撤销快照;test resizing table with thead only、test resizing table with tfoot only验证只有表头或只有表尾的极端表格不会抛错(对应缺陷 #417);test pillars are resetted on scroll(inline 与 classic 两版)验证滚动后 pillars 缓存被清除重建(对应缺陷 #4889)。
5.2 相邻手动用例
tests/plugins/tableresize/manual/ 目录下的其他手动用例与本主题相互印证:
- headeronly.md 与 footeronly.md:验证仅含 thead 或仅含 tfoot 的表格在悬停与拖拽时控制台无报错、
column pillar可见; - mergedcells.md(缺陷 #1467,4.10.0):合并单元格中间的悬停不应触发 resize 光标,避免"从单元格中间就能拖拽表格";
- rowspan.md(缺陷 #3961,4.15.1):合并单元格(含 rowspan)两侧拖拽时垂直 pillar 应正确渲染,且点击靠近边框不应阻止单元格聚焦;
- scrollupdate.md(缺陷 #4889,4.20.0):横向滚动后指示条位置必须仍落在列边框上。
可见"pillar 的正确渲染"是 tableresize 一系列回归测试的共同锚点:既要求高度覆盖整表(trac10472),也要求在合并单元格、滚动、RTL、IE 等复杂场景下保持位置与命中区域正确。
六、小结
以 pillar.md 为代表的 Bender.js 手动用例,精确刻画了 tableresize 插件的一个核心质量属性:列宽拖拽指示柱(pillar)必须通过 alignedPillars 跨 thead/tbody/tfoot 对齐,使其高度始终等于表格整体高度。从源码看,这一保证由 plugin.js 中的 buildTableColumnPillars(按行构建、按列索引分组)与 columnResizer.attachTo(首尾对齐 pillar 求高度)共同实现;从测试看,它由手动用例 pillar.html 与自动用例 tableresize.js 双重守护。理解这条从"逐行 pillar"到"整表纵柱"的对齐链路,也就掌握了 tableresize 插件命中检测、指示条渲染与列宽调整的核心机制,并为排查"柱子太短""表头无法拖拽"一类回归问题提供了明确的定位路径。