Platejs【表格单元格合并拆分 】原理剖析
Plate 表格单元格合并拆分原理
核心概念
单元格数据结构
// 表格单元格的基本结构
interface TTableCellElement {
id: string;
type: 'td' | 'th';
colSpan?: number; // 列跨度,默认为 1
rowSpan?: number; // 行跨度,默认为 1
children: TText[];
background?: string;
// ... 其他属性
}
HTML 渲染结果
// 在 table-cell-element.tsx 中
const attributes = {
...props.attributes,
colSpan: api.table.getColSpan(element), // 获取列跨度
rowSpan: api.table.getRowSpan(element), // 获取行跨度
}
// 最终渲染为 HTML
<td colSpan={2} rowSpan={1}>合并后的内容</td>
合并操作:tf.table.merge()
合并条件判断
// 来自 useTableMergeState.ts
const canMerge =
!readOnly && // 不是只读模式
someTable && // 光标在表格内
selectionExpanded && // 有选区(选中多个单元格)
selectedCellEntries.length > 1 && // 选中了多个单元格
isTableRectangular(selectedTable); // 选中区域是矩形
合并原理
选区检测
// 获取选中的单元格
const selectedCellEntries = getTableGridAbove(editor, {
format: 'cell',
});
// 检查是否为矩形选区
// 例如:选中 2x2 的单元格区域
// ┌─────┬─────┐
// │ A1 │ A2 │ ← 选中
// ├─────┼─────┤
// │ B1 │ B2 │ ← 选中
// └─────┴─────┘
合并算法
// 伪代码:合并过程
function mergeTableCells(editor) {
// 1. 获取选中的单元格区域
const selectedCells = getSelectedCells();
// 2. 计算合并后的跨度
const mergedColSpan = calculateColSpan(selectedCells);
const mergedRowSpan = calculateRowSpan(selectedCells);
// 3. 合并内容
const mergedContent = combineContent(selectedCells);
// 4. 更新第一个单元格
const firstCell = selectedCells[0];
updateCell(firstCell, {
colSpan: mergedColSpan,
rowSpan: mergedRowSpan,
children: mergedContent
});
// 5. 删除其他单元格
removeOtherCells(selectedCells.slice(1));
// 6. 更新表格结构
normalizeTable(editor);
}
数据结构变化
// 合并前:4个独立单元格
const tableData = {
type: 'table',
children: [
{
type: 'tr',
children: [
{ type: 'td', id: 'A1', children: [{ text: 'A1' }] },
{ type: 'td', id: 'A2', children: [{ text: 'A2' }] }
]
},
{
type: 'tr',
children: [
{ type: 'td', id: 'B1', children: [{ text: 'B1' }] },
{ type: 'td', id: 'B2', children: [{ text: 'B2' }] }
]
}
]
}
// 合并后:删除单元格 + 设置跨度
const mergedTableData = {
type: 'table',
children: [
{
type: 'tr',
children: [
{
type: 'td',
id: 'A1',
colSpan: 2, // 🔑 跨2列
rowSpan: 2, // 🔑 跨2行
children: [{ text: 'A1 A2 B1 B2' }]
}
// A2 被删除!
]
},
{
type: 'tr',
children: [
// B1, B2 都被删除!
]
}
]
}
渲染机制详解
HTML 表格原生渲染机制:colSpan 和 rowSpan 是 HTML 标准属性:
参考demo:
demo代码:
<body>
<div class="section">
<h2>合并前</h2>
<table class="table-before">
<tr>
<td>A1</td>
<td>A2</td>
</tr>
<tr>
<td>B1</td>
<td>B2</td>
</tr>
</table>
</div>
<div class="section">
<h2>合并后</h2>
<table class="table-after">
<tr>
<td colspan="2" rowspan="2">A1 A2 B1 B2</td>
<!-- 第一行的第二个单元格被 colspan 占据,所以不需要写 -->
</tr>
<tr>
<!-- 第二行的两个单元格都被 rowspan 占据,所以不需要写任何 td -->
</tr>
</table>
</div>
<div class="section">
<h2>其他合并示例</h2>
<table class="table-after">
<tr>
<td colspan="2">A1</td>
</tr>
<tr>
<td>B1</td>
<td>B2</td>
</tr>
</table>
<br />
<table class="table-after">
<tr>
<td rowspan="2">A1</td>
<td>A2</td>
</tr>
<tr>
<td>B2</td>
</tr>
</table>
</div>
</body>
浏览器的表格渲染引擎会自动计算跨度单元格的占位,不需要手动处理布局
浏览器渲染引擎大致的计算过程:
-
扫描所有 和 元素
-
为每个单元格分配逻辑坐标 (row, col)
-
检查 colSpan 和 rowSpan 属性
-
标记被跨度占据的位置为"已占用"
-
后续单元格自动跳过"已占用"位置
-
计算最终的视觉布局和尺寸
CSS 样式的配合
宽度计算:
// 来自 useTableCellSize
const { minHeight, width } = useTableCellSize({ element });
// 对于合并单元格,width 会自动计算为多个列的总宽度
// 例如:colSpan=2 的单元格宽度 = 列1宽度 + 列2宽度 + 边框
高度计算:
// 来自 useTableCellElement.ts 第59行
minHeight: rowSizeOverrides.get?.(endingRowIndex) ?? minHeight,
// 对于合并单元格,高度会自动计算为多个行的总高度
// 例如:rowSpan=2 的单元格高度 = 行1高度 + 行2高度 + 边框
Plate 中的具体使用
// 在 table-cell-element.tsx 第113-119行
const attributes = {
...props.attributes,
colSpan: api.table.getColSpan(element), // 获取列跨度
rowSpan: api.table.getRowSpan(element), // 获取行跨度
}
// 清理可能的冲突属性
if ('colspan' in attributes) delete attributes.colspan;
if ('rowspan' in attributes) delete attributes.rowspan;
// 第121-143行:渲染为 HTML
return (
<PlateElement
{...props}
as={isHeader ? 'th' : 'td'}
attributes={attributes} // 包含 colSpan 和 rowSpan
>
拆分操作:tf.table.split()
拆分条件判断
// 来自 useTableMergeState.ts
const canSplit =
collapsed && // 光标未选中(单点光标)
selectedCellEntries.length === 1 && // 只选中一个单元格
(api.table.getColSpan(cell) > 1 || // 列跨度 > 1
api.table.getRowSpan(cell) > 1); // 或行跨度 > 1
拆分原理
知道合并就差不多知道拆分怎么搞了,这里简单带过
拆分算法
// 伪代码:拆分过程
function splitTableCell(editor) {
// 1. 获取当前合并单元格
const mergedCell = getCurrentCell();
const { colSpan, rowSpan } = mergedCell;
// 2. 计算需要创建的单元格数量
const cellsToCreate = colSpan * rowSpan - 1;
// 3. 重置原单元格跨度
updateCell(mergedCell, {
colSpan: 1,
rowSpan: 1
});
// 4. 创建新的单元格填充空位
for (let row = 0; row < rowSpan; row++) {
for (let col = 0; col < colSpan; col++) {
if (row === 0 && col === 0) continue; // 跳过原单元格
const newCell = createEmptyCell();
insertCellAt(row, col, newCell);
}
}
// 5. 更新表格结构
normalizeTable(editor);
}
实际效果
// 拆分前:1个合并单元格
<tr>
<td colSpan="2" rowSpan="2">合并内容</td>
</tr>
<tr>
<!-- 空行,由上面的 rowSpan 覆盖 -->
</tr>
// 拆分后:4个独立单元格
<tr>
<td>合并内容</td> <!-- 原内容保留在第一个单元格 -->
<td></td> <!-- 新创建的空单元格 -->
</tr>
<tr>
<td></td> <!-- 新创建的空单元格 -->
<td></td> <!-- 新创建的空单元格 -->
</tr>
在目前工程中 UI层 中的应用
工具栏按钮
// 来自 table-element.tsx
{canMerge && (
<ToolbarButton
onClick={() => tf.table.merge()} // 直接调用合并
tooltip="合并单元格"
>
<CombineIcon />
</ToolbarButton>
)}
{canSplit && (
<ToolbarButton
onClick={() => tf.table.split()} // 直接调用拆分
tooltip="拆分单元格"
>
<SquareSplitHorizontalIcon />
</ToolbarButton>
)}
右键菜单
// 来自 table-element.tsx
<TableContextMenu
canMerge={canMerge}
canSplit={canSplit}
onMerge={() => tf.table.merge()} // 合并回调
onSplit={() => tf.table.split()} // 拆分回调
/>
大致总结
数据层面
-
colSpan/rowSpan:控制单元格跨度
-
内容合并:将多个单元格内容合并到一个单元格
-
结构规范化:确保表格结构的一致性
渲染层面
-
HTML 属性:使用标准的
colSpan和rowSpan属性 -
占位处理:被合并的单元格在 DOM 中被移除
-
样式适配:确保合并后的单元格样式正确
交互层面
-
选区检测:判断用户选择是否符合合并条件
-
状态管理:实时更新合并/拆分按钮的可用状态
-
用户反馈:提供清晰的视觉反馈和操作提示
核心原理
-
数据层:Plate 在 Slate 数据中删除被合并的单元格,给保留的单元格设置
colSpan和rowSpan -
渲染层:React 将跨度属性传递给 HTML
<td>元素 -
浏览器层:HTML 表格渲染引擎自动计算跨度单元格的位置和尺寸
-
样式层:CSS 配合 HTML 属性完成最终的视觉呈现

浙公网安备 33010602011771号