1723 天前,Canvas Editor 写下第一行代码;1460 次提交之后,项目正式发布 1.0.0。促成这个版本的最后一块拼图,是一个存在了 1556 天的 issue:跨页表格修改列宽时,各页无法同步。
这不是一个简单的拖拽交互问题。表格一旦跨页,屏幕上看到的多个片段,在数据层仍然属于同一张表。列宽修改、分页重排、命中检测和撤销重做必须围绕同一个逻辑模型协作。这个持续 39 条讨论的问题,恰好揭示了 Canvas 富文本编辑器中最难处理的一类工程边界。
跨页之后,一张表变成了多个渲染片段
普通网页表格由浏览器负责布局。开发者修改一列的宽度,浏览器会重新计算整张表。但 Canvas 编辑器没有现成的文档流:文字测量、单元格尺寸、行高、分页位置和绘制顺序都需要编辑器自行维护。
假设一张表有 80 行,其中前 20 行位于第一页,接下来的行分布在后续页面。渲染层可能会生成多个分页片段:
LogicalTable(table-42)
├── TableFragment(page-1, rows 0..19)
├── TableFragment(page-2, rows 20..43)
└── TableFragment(page-3, rows 44..79)
用户拖动第二页上的列边界时,事件首先命中的是 page-2 的片段。如果实现只修改这个片段的本地宽度,第二页会立即变化,但第一页和第三页仍保留旧宽度。更严重的是,下一次重新分页可能又用逻辑表中的旧数据覆盖当前结果。
因此,跨页表格的核心约束应当是:分页片段只保存与页面相关的布局结果,列宽必须由逻辑表统一持有。
这也解释了为什么一个看似局部的缺陷会长期存在。修复它通常不只是补一行赋值,而是要重新确认数据模型、布局过程和交互状态之间的所有权。
列宽同步是一条完整的更新链路
一次可靠的列宽拖拽,至少会经过以下步骤:
- 根据鼠标坐标和页面偏移,定位被拖动的表格及列边界。
- 将拖动距离转换为文档坐标,处理缩放比例和设备像素比。
- 更新逻辑表中的列宽,而不是直接修改当前分页片段。
- 使该表全部分页片段的布局缓存失效。
- 重新计算单元格文本换行、行高和分页位置。
- 重绘所有受影响页面,并把修改写入撤销栈。
这里最容易被低估的是第 5 步。列宽变化会改变单元格中的换行位置,换行会改变行高,行高又可能把后续行推到下一页。因此,一次水平拖动可能造成纵向分页变化,甚至增加或减少表格占用的页数。
合理的实现不应把“同步”理解为将一个像素值复制到其他页面,而应把它看成一次由共享模型触发的增量排版。
可以这样实践:让分页片段共享同一套列定义
下面是一个可直接用 Node.js 运行的最小示例。它不依赖具体编辑器 API,重点演示逻辑表、分页片段和列宽更新之间的关系。可以把这套数据流改造成实际项目中的 store、文档节点或布局对象。
将代码保存为 table-layout.js,然后运行 node table-layout.js:
const assert = require("node:assert/strict");
class LogicalTable {
constructor(id, columnWidths, rows) {
this.id = id;
this.columnWidths = [...columnWidths];
this.rows = rows;
this.layoutVersion = 0;
}
resizeColumn(columnIndex, delta, minWidth = 48) {
const current = this.columnWidths[columnIndex];
if (current === undefined) {
throw new RangeError(`Unknown column: ${columnIndex}`);
}
this.columnWidths[columnIndex] = Math.max(minWidth, current + delta);
this.layoutVersion += 1;
}
}
class TableFragment {
constructor(table, page, startRow, endRow) {
this.table = table;
this.page = page;
this.startRow = startRow;
this.endRow = endRow;
this.cachedVersion = -1;
this.cachedLayout = null;
}
layout() {
if (this.cachedVersion !== this.table.layoutVersion) {
this.cachedLayout = {
page: this.page,
rows: [this.startRow, this.endRow],
// 复制的是本轮布局快照,数据源仍然是逻辑表。
columnWidths: [...this.table.columnWidths]
};
this.cachedVersion = this.table.layoutVersion;
}
return this.cachedLayout;
}
}
const rows = Array.from({ length: 60 }, (_, index) => [`Row ${index}`, "Content"]);
const table = new LogicalTable("table-42", [120, 240], rows);
const fragments = [
new TableFragment(table, 1, 0, 19),
new TableFragment(table, 2, 20, 39),
new TableFragment(table, 3, 40, 59)
];
// 用户在第二页拖动第一列,但操作目标仍是逻辑表。
table.resizeColumn(0, 36);
const layouts = fragments.map((fragment) => fragment.layout());
assert.deepEqual(
layouts.map((layout) => layout.columnWidths),
[[156, 240], [156, 240], [156, 240]]
);
console.log(layouts);
这个示例保留了三个重要性质:
columnWidths只有一个权威数据源。- 每个分页片段可以缓存自己的布局结果,但必须记录对应的模型版本。
- 无论拖拽发生在哪一页,更新入口都是
LogicalTable.resizeColumn()。
接入真实 Canvas 编辑器时,还需要在 resizeColumn() 之后调度排版与重绘。为了避免拖拽过程中反复执行完整分页,可以在指针移动时按动画帧合并更新,在 pointerup 时提交最终文档操作:
let pendingDelta = 0;
let frameId = null;
function onColumnDrag(delta) {
pendingDelta += delta;
if (frameId !== null) return;
frameId = requestAnimationFrame(() => {
table.resizeColumn(activeColumn, pendingDelta);
pendingDelta = 0;
frameId = null;
invalidateTableFragments(table.id);
relayoutFromTable(table.id);
repaintVisiblePages();
});
}
其中 invalidateTableFragments、relayoutFromTable 和 repaintVisiblePages 是需要按项目架构实现的接口。关键不是函数名称,而是更新顺序:先改共享模型,再使缓存失效,最后排版和绘制。
修复功能之外,还要守住编辑器状态
跨页列宽同步会触碰多个容易回归的区域,测试不能只验证“其他页面看起来也变宽了”。至少应覆盖这些场景:
- 向左拖动时,列宽不能小于可用的最小值。
- 固定表格总宽时,一列增加的宽度应由相邻列承担,而不是让整表无限扩张。
- 单元格包含长文本、合并单元格或空内容时,重新排版结果应稳定。
- 调整列宽导致行跨页后,不能丢行、重复行或打乱单元格内容。
- 执行撤销和重做时,所有分页片段应恢复到同一个列宽版本。
- 文档处于缩放状态时,拖动距离必须正确换算为文档坐标。
- 只重绘可见页面时,滚动到未显示页面后仍应得到最新布局。
如果编辑器支持协同编辑,还要进一步明确列宽变更的操作语义。同步“第 2 列的新宽度”通常比同步“第二页某个片段的边界位置”更稳定,因为前者指向文档模型,后者依赖瞬时分页结果。
1.0 的意义在于边界开始稳定
从第一行代码到 1.0.0,1723 天和 1460 次提交体现的不只是开发周期,也说明富文本编辑器的成熟来自大量边界条件的收敛。关闭一个存在 1556 天、经历 39 条讨论的 issue,并不意味着表格排版从此没有问题;它意味着项目终于为这类跨页交互建立了可以兑现的行为约束。
评估或采用 1.0 版本时,可以重点检查三件事:文档模型是否拥有唯一的数据源,分页布局是否可以可靠失效并重算,复杂编辑操作是否能够撤销、重做和测试。对 Canvas 编辑器而言,真正的版本里程碑不是功能列表变长,而是用户在拖动一条列边界时,不再需要理解页面背后的实现细节。