slatejs 编辑器表格---合并单元格

2,588 阅读5分钟

最近在 slate.js 的富文本编辑器中实现了 Table 的独立选区以及操作功能。由于表格存在单元格的合并操作,使得在选区计算和操作功能变得更加的复杂,所以对相关的实现进行了记录。当中,涉及到 slate.js 的内容不是很多,所以并不一定限制技术栈。在遇到类似功能需求时,希望能够为你提供出一种思路。 image.png

整个功能内容相对还是比较多,所以将分为以下3个部分讲解:

  1. 独立选区的单元格和范围计算---slatejs 编辑器表格---选区
  2. 单元格操作;
  3. 行列操作。

本文是对表格的合并单元格实现进行讲解。主要是对选区单元格合并以及整行合并时,增加行占位等功能实现。 效果如下:eee.gif

单元格合并

合并单元格主要是对表格选区的单元格进行跨行/跨列数据和内容合并的过程。主要可分为以下步骤:

  1. 合并内容:合并单元格内容;
  2. 合并 span:计算合并后单元格的 colSpan 和 rowSpan;
  3. 合并单元格:移除选区单元格并插入合并后单元格;
  4. 缺行处理:处理整行合并问题。

合并内容

将表格选区中所有单元格的内容合并,并排除内容为空的单元格,避免显示不必要的数据展示。

function mergeChildren(editor: IYTEditor, cellPaths: Path[]) {
  const newChildren: Element[] = []
  cellPaths.forEach((cellPath) => {
    const [cellNode] = Editor.node(editor, cellPath)
    
    // 排除空单元格
    const isEmpty = isEmptyCell(editor, cellNode as TableCellElement)
    if (!isEmpty) newChildren.push(...(cellNode as TableCellElement).children)
  })

  //排除了空单元格,若所选单元格都为空需要手动填充数据
  return newChildren.length > 0
    ? newChildren
    : [
        {
          type: 'paragraph',
          children: [{ text: '' }],
        },
      ]
}

/** 辅助方法 **/

// 判断单元格是否为空
export function isEmptyCell(editor: IYTEditor, cellNode: TableCellElement) {
  if (cellNode.children.length > 1) return false
  const content = cellNode.children[0]
  if (content.type !== 'paragraph') return false
  return Editor.isEmpty(editor, content)
}

合并 span

计算合并后单元格的跨行/跨列(rowSpan/colSpan)数据。 由于表格选区单元格存在同列/同行的情况,不能简单的通过选区单元格跨行/跨列之和计算合并后单元格的数据。可以通过将单元格数据转换为源表格单元格数据,通过源表格中单元格的坐标范围数据获取正确的跨行/跨列数据。

export function getCellsSpan(
  editor: IYTEditor,
  table: TableElement,
  cellPaths: Path[],
) {
  // 获取源表格数据
  const originTable = getOriginTable(table)
  const tablePath = ReactEditor.findPath(editor, table)
  const ranges: rangeType[] = []

  cellPaths.forEach((cellPath) => {
    // 获取源表格单元格数据
    const cellRelative = Path.relative(cellPath, tablePath)
    const originRange = originTable[cellRelative[0]][cellRelative[1]]

    if (Array.isArray(originRange[0]) && Array.isArray(originRange[1])) {
      ranges.push(originRange[0] as rangeType, originRange[1] as rangeType)
    } else {
      ranges.push(originRange as rangeType)
    }
  })

  const { xRange, yRange } = getRange(...ranges)

  return {
    rowSpan: xRange[1] - xRange[0] + 1,
    colSpan: yRange[1] - yRange[0] + 1,
  }
}

合并单元格

在处理好合并单元格所需的属性(内容、跨行/跨列数据)之后,需要将选区单元格移除,若移除后单元格存在行中无单元格情况需要同时移除行。

function removeCellByPath(
  editor: IYTEditor,
  cellPaths: Path[],
  tablePath: Path,
) {
  Transforms.removeNodes(editor, {
    // 第一个单元格暂时先不移除,使得当前行不会存在移除的可能性
    at: tablePath,
    match: (_, path) =>
      !Path.equals(cellPaths[0], path) &&
      cellPaths.some((cellPath) => Path.equals(cellPath, path)),
  })
  // 移除空的行
  Transforms.removeNodes(editor, {
    at: tablePath,
    match: (node) =>
      Element.isElement(node) &&
      node.type === 'tableRow' &&
      !Element.matches((node as TableRowElement).children[0], {
        type: 'tableCell',
      }),
  })
  // 移除第一个单元格,后续不会判断是否为空,行会保留
  Transforms.removeNodes(editor, {
    match: (_, path) => Path.equals(cellPaths[0], path),
  })
}

在移除单元格后,将合并后的单元格插入选区第一个单元格的位置。

  Transforms.insertNodes(
    editor,
    {
      type: 'tableCell',
      colSpan: spans.colSpan,
      rowSpan: spans.rowSpan,
      children, // 合并后单元格内容
    },
    {
      at: cellPaths[0], // 第一个单元格位置
    },
  )
  // 焦点聚焦
  Transforms.select(editor, {
    anchor: Editor.end(editor, cellPaths[0]),
    focus: Editor.end(editor, cellPaths[0]),
  })

缺行处理

当某或某几行的所有单元格都被其他单元格合并时,在数据上此行会不存在。此时渲染到页面的数据会错乱,需要计算是否存在缺行情况并增加空行渲染。


function TableRow(props: RenderElementProps) {
  const { attributes, children, element } = props

  const editor = useSlate()

  const rowPath = ReactEditor.findPath(editor, element)
  // minRow 正常情况为1,代表当前行;> 1时,表示需要增加空行
  const minRow = getNextRowSpan(editor, rowPath)

  return (
    <>
      <tr {...attributes} className="yt-e-table-row">
        {children}
      </tr>
      {minRow > 1 &&
        Array.from({ length: minRow - 1 }).map((_, index) => (
          <tr key={index} />
        ))}
    </>
  )
}

计算当前行之后是否缺行,需要通过源表格数据计算下一行的单元格的行索引和当前单元格的行索引的差值,当不存在下一行时,则为当前行单元格的 rowSpan 数据。

export function getNextRowSpan(editor: IYTEditor, rowPath: Path) {
  const tablePath = Path.parent(rowPath)
  const [tableNode] = Editor.node(editor, tablePath)
  const [rowNode] = Editor.node(editor, rowPath)
  // 源表格数据
  const originTable = getOriginTable(tableNode as TableElement)
  // 源表格中当前行第一个单元格
  const rowIndex = Path.relative(rowPath, tablePath)
  const originRowRange = originTable[rowIndex[0]][0]
  // 源表格中单元格的行索引
  const originRowIndex = Array.isArray(originRowRange[0])
    ? originRowRange[0][0]
    : originRowRange[0]

  if (originTable[originRow[0] + 1]) {
    // 存在下一行数据时
    const originNextRowRange = originTable[originRow[0] + 1][0]
    const originNextRowIndex = Array.isArray(originNextRowRange[0])
      ? originNextRowRange[0][0]
      : originNextRowRange[0]

    return originNextRowIndex - originRowIndex
  }

  return (rowNode as TableRowElement).children[0].rowSpan || 1
}

总结

合并单元格的主要思路还是利用源表格数据来进行相关的计算数据。

  • 计算合并后的 span,需要使用源表格数据计算;
  • 移除单元格时,要保留第一个单元格所在行,不然无法将合并后单元格插入相应的位置;
  • 在整行数据被合并时,要对渲染数据进行补充空行。

展望

下一篇讲单元格的拆分功能,拆分功能在实现上要比合并单元格更加复杂。比如:

  • 当存在整行合并时,拆分需要插入新行数据,怎么计算新行数?
  • 当存在单元格跨多行同时又包含了整行被合并的行时,怎么计算新行插入的位置(不能直接在当前行之后插入,会影响下一行的数据的)?
  • 选中多个单元格拆分时,先拆分数据会影响后续单元格的坐标,怎么处理?

单元格拆分中,会对以上问题的解决方案一一说明,敬请期待......