CKEditor 4 表格列宽拖拽指示柱(Pillar)机制解析:从 trac10472 回归测试看 tableresize 插件的实现原理

原创2026-10-16 10:12:17864 阅读
文章标签:前端UI组件

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">&nbsp;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 操作步骤与判定

  1. 用 CKEDITOR.replace( 'editor' ) 初始化编辑器后,将鼠标移动到**表头行(thead 第一行)**某个单元格之间的垂直边框上;
  2. 按住鼠标左键拖动;
  3. 将鼠标移动到**表尾行(tfoot 最后一行)**的对应边框上,重复拖拽;
  4. 观察拖拽时出现的深蓝色半透明指示柱(源码中 resizer 的 background-color:#004、cursor:col-resize,见 plugin.js)。

判定:

  • Expected:表头行与表尾行的边框均可拖拽;指示柱纵贯整个表格(从 thead 顶部延伸到 tfoot 底部),高度等于表格整体高度;
  • Unexpected:两处不可拖拽,或指示柱只覆盖鼠标所在那一行的高度(短柱子)。

满足 Unexpected 即意味着回归了 trac10472 的缺陷,说明 pillar 对齐逻辑出现退化。

四、指示柱背后的完整交互链路:从 mousemove 到 resize

pillar.md 验证的是交互链路中"命中与渲染"环节,而完整的列宽拖拽流程在 plugin.js 中可归纳为五步:

  1. mousemove 命中检测:插件在 contentDom 阶段监听 mousemove(plugin.js),仅当目标元素是 <table> 或其 thead/tbody/tfoot 后裔、且该表格位于编辑器可编辑区域内时才继续处理(防止误操作编辑器外部的表格,对应缺陷 #12825、#11603);
  2. 构建并缓存 pillars:首次悬停时调用 buildTableColumnPillars,结果以自定义数据 _cke_table_pillars 缓存到表格节点上(plugin.js),鼠标移出或按下时通过 clearPillarsCache 失效重建;页面滚动时也会清空缓存并按防抖(200ms)重建(plugin.js,对应缺陷 #4889);
  3. 命中 pillar 并挂接 resizer:getPillarAtPosition 用 checkWithinDimensions 判断鼠标坐标是否落在某个 pillar 的矩形内(plugin.js);命中则创建 columnResizer 并 attachTo 该 pillar,按 2.3 节的对齐逻辑设置指示条位置与高度;
  4. mousedown 开始拖拽:resizeStart 用 CKEDITOR.tools.buildTableMap 建立表格映射,收集边界两侧的单元格并计算左右最小宽度作为拖拽边界(plugin.js),同时触发 saveSnapshot 记录撤销快照;
  5. 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 插件命中检测、指示条渲染与列宽调整的核心机制,并为排查"柱子太短""表头无法拖拽"一类回归问题提供了明确的定位路径。

登录后查看全文
ckeditor4