CKEditor 4 Table Resize 插件对「仅有表头/表尾」表格的列宽拖拽支持与 417 回归验证
CKEditor 4 Table Resize 插件对「仅有表头/表尾」表格的列宽拖拽支持与 #417 回归验证
导读
本文围绕 CKEditor 4 仓库中 Table Resize(表格列宽拖拽)插件的 Bender 手动回归用例 tests/plugins/tableresize/manual/headeronly.md,讲解插件在「表格只有 <thead> 表头行(或只有 <tfoot> 表尾行)」这种特殊结构下的行为边界,回溯 bug #417 的修复背景,并下沉到 plugins/tableresize/plugin.js 源码剖析其「列支柱(pillar)」定位机制、事件绑定与宽度换算逻辑。读完你将掌握:如何用 Bender 手动用例复现该场景、如何读懂插件的核心源码路径、以及配套的自动化测试如何验证该修复。
一、场景背景:bug #417 与 Table Resize 插件
Table Resize 是 CKEditor 4 的表格增强插件,功能是当鼠标悬停在表格列边界时出现横向拖拽光标,允许用户直接拖动改变列宽,而无需打开单元格属性对话框。它在 CHANGES.md 中拥有较长的修复历史,其中与本用例直接相关的一条记录是(4.7.1 版本):
#417: Fixed: The Table Resize plugin throws an error when used with a table with only header or footer rows.
即:当文档中的表格只有 <thead>(表头)行,或只有 <tfoot>(表尾)行、没有任何 <tbody> 行时,旧版本插件会在鼠标悬停表格时抛出 JavaScript 异常,导致无法拖拽调整列宽。headeronly.md 正是这一修复的回归测试用例,同时配套的 footeronly.md 覆盖 tfoot 场景。
值得注意,这一族问题还有更早的记录——trac #10472("Unable to use Table Resize on table header and footer",修复于 4.7.0):在包含表头/表尾行的表格上,按住首行或末行的列边界拖拽时,拖拽指示条(pillar)高度不正确、甚至无法拖动。可见「表头/表尾行」一直是该插件边界处理的关注点。
二、手动测试用例逐行解读
2.1 Bender 元数据声明
headeronly.md 的前三行是 Bender 测试框架的指令标签:
@bender-ui: collapsed
@bender-tags: bug, 4.7.1, 417
@bender-ckeditor-plugins: wysiwygarea, toolbar, table, tableresize
@bender-ui: collapsed:手动测试页面(headeronly.html)默认以折叠状态展示,需要用户主动展开查看编辑器。@bender-tags: bug, 4.7.1, 417:声明该用例属于 bug 回归类,关联 4.7.1 版本与 issue 编号 417。@bender-ckeditor-plugins: wysiwygarea, toolbar, table, tableresize:手动测试页会加载的插件集合。这里既包括编辑器基础(wysiwygarea、toolbar)、表格插件table,也明确加载了被测对象tableresize。注意tableresize依赖tabletools(见 plugins/tableresize/plugin.js 中的requires: 'tabletools'),该依赖由 Bender 自动解析加载。
2.2 测试步骤与验收标准
用例正文只有三个步骤,但每步都对应明确的「期望 / 非期望」判定:
1. Focus editor.
2. Move mouse over the table.
* **Expected**: No error is thrown in the browser console.
3. Try to resize one of the table columns.
* **Expected**: The cursor changes to horizontal resize cursor when hovering over table borders.
It is possible to resize table. The `column pillar` is visible while resizing table.
**Unexpected**: An error in the browser console occurs. Not possible to resize table.
翻译并拆解其验收要点:
| 步骤 | 操作 | 期望(Expected) | 非期望(Unexpected) |
|---|---|---|---|
| 1 | 聚焦编辑器 | — | — |
| 2 | 鼠标移到表格上 | 浏览器控制台无任何报错 | 控制台出现异常 |
| 3 | 拖拽某一列的边界 | 悬停列边界时光标变为横向调整光标(col-resize);可正常调整列宽;调整过程中可见半透明的「column pillar」列指示条 |
无法拖拽、列宽不变 |
三个断言分别对应插件运行链路的三道关卡:不抛异常(悬停阶段的事件处理)、光标反馈正确(命中列边界时的视觉反馈)、拖拽可完成且指示条可见(resizer 的吸附与移动)。
三、复现页面结构:headeronly.html
手动测试的载体页面是 tests/plugins/tableresize/manual/headeronly.html,其编辑内容刻意构造了「只有表头、没有表体」的极端表格:
<div id="editor">
<table border="1" style="width:500px">
<thead>
<tr>
<td>1</td>
<td>2</td>
<td>3</td>
</tr>
</thead>
</table>
<p>Foo Bar...</p>
</div>
<script>
CKEDITOR.replace( 'editor' );
</script>
关键点:该 <table> 内只有 <thead>,没有 <tbody> 行。历史上正是这种结构会让插件在遍历行以构建 pillar 时访问到不存在的行/单元格而抛错。表格宽度固定为 500px、三列各含一个 <td>,便于验证三列的分别拖拽。
四、源码级原理:插件如何工作,为何会在空表体上出错
4.1 悬停监听与「列支柱(pillar)」构建
插件在编辑器 contentDom 时挂载 mousemove 监听(plugins/tableresize/plugin.js)。流程如下:
- 从事件目标向上查找表格:仅当目标是
table本身或thead/tbody/tfoot内元素时继续处理。 - 校验表格位于编辑器内部,避免误伤编辑器外层页面中的表格(对应修复记录 trac #12825、#11603)。
- 若该表格尚无 pillar 缓存(自定义数据
_cke_table_pillars),调用buildTableColumnPillars( table )为每个列边界计算「列支柱」,并缓存结果;同时监听mouseout/mousedown使缓存失效(clearPillarsCache)。 - 用
getPillarAtPosition判断当前鼠标坐标是否落在某个 pillar 矩形内,命中则把半透明 resizer 吸附到该 pillar(attachTo)。
buildTableColumnPillars 是核心(plugins/tableresize/plugin.js):它把 table.$.rows 与 CKEDITOR.tools.buildTableMap( table ) 生成的「表格单元格映射」用 CKEDITOR.tools.array.zip 配对,逐行遍历单元格,依据当前单元格的 offsetWidth、边框宽度以及相邻单元格/整表位置,计算每个列边界 pillar 的 x/y/width/height。这里遍历的是 table.$.rows(<tr> 的集合),无论这些行位于 thead、tbody 还是 tfoot 中都会被枚举——这正是修复 #417 后行为正确的根本原因:插件不再假设表格一定存在 tbody 行。
4.2 buildTableMap:兼容合并单元格的表格模型
CKEDITOR.tools.buildTableMap 定义于 plugins/tabletools/plugin.js,把表格的行×列结构展开成二维数组 aMap,并处理 colSpan/rowSpan 展开(同一合并单元格会占据多个行列槽位)。Table Resize 在构建 pillars(#3961 修复,解决合并单元格导致编辑受阻的问题)以及拖拽开始阶段确定左右两侧受影响单元格时都会使用该映射,保证合并单元格场景下也能正确计算边界。
4.3 拖拽过程与边界保护
命中 pillar 后,columnResizer 对象负责完整拖拽生命周期(plugins/tableresize/plugin.js):
attachTo:把resizer(一个position:absolute;cursor:col-resize的透明div,见 plugin.js)定位到 pillar 位置,并将 body 光标设为col-resize;多个对齐的 pillar(合并单元格跨行)会合并成一条贯穿整表高度的指示条(对应pillar.md用例)。resizeStart:用buildTableMap找出 pillar 左右两侧的单元格集合,计算拖拽左右边界leftShiftBoundary/rightShiftBoundary,防止把列拖到负宽度。move:随鼠标更新 resizer 的left,记录currentShift,并把位移钳制在边界内。resizeEnd:对两侧单元格执行setStyle('width', …);单元格最终宽度不小于 1px(对应修复 trac #11626),处于最外侧时同步调整整个表格宽度;所有单元格异步调整完成后触发saveSnapshot以配合撤销/重做(对应修复 trac #13388)。
4.4 为何要监听 scroll
4.20.0 起插件额外监听 scroll 事件,用 200ms 防抖清除所有表格的 pillar 缓存(plugins/tableresize/plugin.js),修复 #4889「横向滚动后拖拽光标位置错位」的问题。对应手动用例见 tests/plugins/tableresize/manual/scrollupdate.md。
五、自动化测试:headeronly / footeronly 场景的代码级验证
除手动用例外,仓库还提供 Bender 自动化测试 tests/plugins/tableresize/tableresize.js,其中直接对应 #417 的两个用例:
// #417
'test resizing table with thead only': function() {
var editor = this.editors.classic2,
editable = editor.editable();
editor.setData( CKEDITOR.document.findOne( '#headeronly' ).getOuterHtml(), {
callback: function() {
resume( function() {
editor.document.fire( 'mousemove', new CKEDITOR.dom.event( {
target: editable.findOne( 'table' ).$
} ) );
assert.pass();
} );
}
} );
wait();
},
// #417
'test resizing table with tfoot only': function() { /* …同上,取 #footeronly …*/ }
其断言方式为「不抛异常即通过」:向表格目标派发一次 mousemove,若构建 pillars 的代码路径正常完成、没有抛出异常,测试即 pass。测试页 tests/plugins/tableresize/tableresize.html 中定义了 #headeronly 与 #footeronly 两个表格,分别只有 <thead> 与 <tfoot> 行,结构与本手动用例完全一致。
同一测试文件还覆盖了其余回归点:编辑器外表格不被误伤(intable 场景)、撤销/重做表格缩放(#13388)、空表格不抛错(#14762,对应 manual/empty.md)、滚动后 pillar 缓存重置(#4889)等。测试同时运行于 classic 与 inline 编辑器,并在结束时断言临时 div[data-cke-temp] resizer 已被清理(对应 trac #11179 的 destroy 清理)。
六、同场景回归用例矩阵:Table Resize 的边界情况全览
headeronly.md 只是 tests/plugins/tableresize/manual/ 目录下十余个手动用例之一,它们共同构成该插件的边界回归矩阵:
| 用例 | 关联 issue | 验证要点 |
|---|---|---|
| headeronly.md / footeronly.md | #417 | 仅表头 / 仅表尾表格可正常悬停与拖拽 |
| pillar.md | trac #10472 | 首末行拖拽时指示条应贯穿整表高度 |
| mergedcells.md | #1467 | 合并单元格中部悬停不应误触 resize 光标 |
| rowspan.md | #3961 | 跨行单元格(rowspan)拖拽时 pillar 渲染正确、可正常聚焦 |
| empty.md | trac #14762 | 空表格悬停不抛 Unable to get property 'cells' 异常 |
| iebordercollapse.md | #2823 | IE 下 border-collapse 表格最右列可调整 |
| scrollupdate.md | #4889 | 横向滚动后光标位置不漂移 |
结合 CHANGES.md 中 tableresize 的修复记录,还可以看到更多历史边界:绝对定位容器内不可用(#909)、最大化 + divarea 组合(#1959)、IE8 滚动条上报错(trac #11823)、设置非法列宽(trac #11626)等。这些记录说明,Table Resize 看似简单,实则围绕表格结构多样性与浏览器兼容性做了大量防御性处理。
七、如何在本仓库中运行这些用例
本仓库使用 Bender 测试框架(参见根目录 bender.js、bender.ci.js 与 package.json 中的 benderjs 系列依赖)。手动用例的运行方式为:
- 在仓库根目录启动 Bender 服务并打开
tests页面,通过@bender-ui界面导航到plugins/tableresize/manual/headeronly.html; - 按用例步骤在真实浏览器(如 Chrome/Edge)中操作,观察控制台与光标行为;
- 自动化用例
tableresize.js则随 Bender 测试套件一并执行,用于持续集成回归。
运行环境注意事项:插件源码中仍保留针对旧版 IE 的分支(如 needsIEHacks 针对 IE6/7 的特殊处理,plugin.js),这些分支在现代浏览器中不会生效,但阅读源码时有助于理解 data-cke-temp resizer 的创建与清理逻辑为何如此编写。
八、小结
headeronly.md 是一个短小但信息量完整的手动回归用例:它以「仅有表头的表格」为靶场景,验证 Table Resize 插件在 bug #417 修复后能够(1)悬停不抛异常、(2)正确显示横向调整光标、(3)通过可见的 column pillar 完成列宽拖拽。配合 plugins/tableresize/plugin.js 的 pillar 构建与拖拽实现、plugins/tabletools/plugin.js 的 buildTableMap 表格模型,以及 tests/plugins/tableresize/tableresize.js 的自动化断言,读者可以完整还原这一修复的前因后果,并将其方法论迁移到对该插件其他边界用例(合并单元格、跨行、空表格、IE 兼容、滚动定位)的理解上。