从跨页表格到 1.0:canvas-editor 用 1556 天补上最后一块拼图

2026-08-01 25 预计阅读时间: 1 分钟
来源: oschina.net AI 摘要 Original link

Disclaimer: This article is an AI-assisted summary. Read it together with the original source when precision matters. The summary may omit context, version differences, or edge cases and is not official documentation.

预计阅读时间:10 分钟

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 的片段。如果实现只修改这个片段的本地宽度,第二页会立即变化,但第一页和第三页仍保留旧宽度。更严重的是,下一次重新分页可能又用逻辑表中的旧数据覆盖当前结果。

因此,跨页表格的核心约束应当是:分页片段只保存与页面相关的布局结果,列宽必须由逻辑表统一持有。

这也解释了为什么一个看似局部的缺陷会长期存在。修复它通常不只是补一行赋值,而是要重新确认数据模型、布局过程和交互状态之间的所有权。

列宽同步是一条完整的更新链路

一次可靠的列宽拖拽,至少会经过以下步骤:

  1. 根据鼠标坐标和页面偏移,定位被拖动的表格及列边界。
  2. 将拖动距离转换为文档坐标,处理缩放比例和设备像素比。
  3. 更新逻辑表中的列宽,而不是直接修改当前分页片段。
  4. 使该表全部分页片段的布局缓存失效。
  5. 重新计算单元格文本换行、行高和分页位置。
  6. 重绘所有受影响页面,并把修改写入撤销栈。

这里最容易被低估的是第 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();
  });
}

其中 invalidateTableFragmentsrelayoutFromTablerepaintVisiblePages 是需要按项目架构实现的接口。关键不是函数名称,而是更新顺序:先改共享模型,再使缓存失效,最后排版和绘制。

修复功能之外,还要守住编辑器状态

跨页列宽同步会触碰多个容易回归的区域,测试不能只验证“其他页面看起来也变宽了”。至少应覆盖这些场景:

  • 向左拖动时,列宽不能小于可用的最小值。
  • 固定表格总宽时,一列增加的宽度应由相邻列承担,而不是让整表无限扩张。
  • 单元格包含长文本、合并单元格或空内容时,重新排版结果应稳定。
  • 调整列宽导致行跨页后,不能丢行、重复行或打乱单元格内容。
  • 执行撤销和重做时,所有分页片段应恢复到同一个列宽版本。
  • 文档处于缩放状态时,拖动距离必须正确换算为文档坐标。
  • 只重绘可见页面时,滚动到未显示页面后仍应得到最新布局。

如果编辑器支持协同编辑,还要进一步明确列宽变更的操作语义。同步“第 2 列的新宽度”通常比同步“第二页某个片段的边界位置”更稳定,因为前者指向文档模型,后者依赖瞬时分页结果。

1.0 的意义在于边界开始稳定

从第一行代码到 1.0.0,1723 天和 1460 次提交体现的不只是开发周期,也说明富文本编辑器的成熟来自大量边界条件的收敛。关闭一个存在 1556 天、经历 39 条讨论的 issue,并不意味着表格排版从此没有问题;它意味着项目终于为这类跨页交互建立了可以兑现的行为约束。

评估或采用 1.0 版本时,可以重点检查三件事:文档模型是否拥有唯一的数据源,分页布局是否可以可靠失效并重算,复杂编辑操作是否能够撤销、重做和测试。对 Canvas 编辑器而言,真正的版本里程碑不是功能列表变长,而是用户在拖动一条列边界时,不再需要理解页面背后的实现细节。


相关推荐