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号