最近在 slate.js 的富文本编辑器中实现了 Table 的独立选区以及操作功能。由于表格存在单元格的合并操作,使得在选区计算和操作功能变得更加的复杂,所以对相关的实现进行了记录。当中,涉及到 slate.js 的内容不是很多,所以并不一定限制技术栈。在遇到类似功能需求时,希望能够为你提供出一种思路。 整个功能内容相对还是比较多,所以将分为以下3个部分讲解:
- 独立选区的单元格和范围计算;
- 单元格操作;
- 行列操作。
本文是对表格的单元格拆分实现进行讲解。根据单元格的rowSpan/colSpan
拆分成rowSpan/colSpan
为 1 的单元格,会对拆分过程中是否需要插入新行和插入位置计算以及多个单元格拆分等问题梳理。 效果如下:
单元格拆分
拆分单元格主要是对表格单元格或选区的单元格跨行/跨列数据拆分的过程。主要可分为以下步骤:
- 拆分计算:计算是否需要插入新行,以及插入位置计算(会存在多个位置需要插入);
- 单元格填充:需要拆分单元格修改相应属性,并插入拆分后的其他单元格;
- 多个单元格拆分。
拆分计算
在合并单元格一文中提及,合并单元格时可能会存在整行单元格合并的情况,那么在拆分单元格就需要插入新行来处理缺行问题。
在拆分计算中,需要计算缺失的行数以及新行插入的位置,而且也可能存在多个位置需要插入行。可以通过以下步骤理解计算过程:
- 通过源表格数据,获取单元格在源表格中的坐标范围;
- 在源表格数据中,从当前单元格下一行开始循环,获取自增和源表格数据中行索引数据;
- 当前行的源表格行索引数据与自增数据之差,即为插入的行数;插入位置(相对于拆分单元格的位置)为插入当前索引与已插入行之和;
- 根据计算出的结果插入行。
// 获取插入行的行数和位置信息
function getInsertRowInfo(
editor: IYTEditor,
tablePath: Path,
cell: NodeEntry<Node>,
) {
// 获取源表格数据
const [tableNode] = Editor.node(editor, tablePath)
const originTable = getOriginTable(tableNode as TableElement)
// 获取源表格中当前单元格所在行的开始行索引
const [cellNode, cellPath] = cell
const { rowSpan = 1 } = cellNode as TableCellElement
const relativePath = Path.relative(cellPath, tablePath)
const originPath = originTable[relativePath[0]][relativePath[1]]
const currentOriginRow: number = originPath[0][0]
// 单元格源表格中范围数据
const range: number = originPath[1][0] - originPath[0][0]
const positions: [number, number][] = []
let insertRow = 0
for (let i = 1; i <= range; i++) {
const rowIndex = relativePath[0] + i
// 自增表格行索引(插入新行会影响索引,需要加上已插入行数)
const originRowIndex = currentOriginRow + i + insertRow
if (!originTable[rowIndex]) {
// 下一行为表格中最后一行时,插入数据计算
positions.push([rowSpan - insertRow - i, i + insertRow])
return positions
}
const newOrigin = originTable[rowIndex][0]
// 实际源表格中行索引
const currentRowIndex: number = Array.isArray(newOrigin[0])
? newOrigin[0][0]
: newOrigin[0]
if (currentRowIndex !== originRowIndex) {
// 不相等代表需要插入行数据
// 插入位置加上 inserRow 是因为前面数据插入会影响后面单元格位置
const position: [number, number] = [
currentRowIndex - originRowIndex,
i + insertRow,
]
insertRow += currentRowIndex - originRowIndex
positions.push(position)
}
}
return positions
}
// 插入行
if (rowSpan > 1) {
// 存在跨行单元格才会处理插入行问题
const insertRowArr = getInsertRowInfo(editor, tablePath, cellNode)
if (insertRowArr.length > 0) {
// 当前行索引
const [rowIndex] = Path.relative(currentRow, tablePath)
insertRowArr.forEach(([insertRow, position]) => {
// 根据需要插入行数,生成相应数据
const rowNodes = Array.from({ length: insertRow }).map(() =>
getRowNode([]),
)
Transforms.insertNodes(editor, rowNodes, {
// 计算插入位置,position 在计算时就考虑了前面数据插入的影响
at: [...tablePath, rowIndex + position],
})
})
}
}
复制代码
单元格填充
在将单元格拆分对行数据的影响处理之后,需要修改当前单元格的属性,并插入rowSpan * colSpan -1
个单元格到表格中。可以分为以下步骤:
- 修改当前单元格属性;
- 当前单元格所在行插入
colSpan - 1
个单元格,位置在当前单元格之后; - 其他行插入
colSpan
单元格,位置需要计算。
//修改当前单元格属性
Transforms.setNodes(
editor,
{
colSpan: 1,
rowSpan: 1,
},
{
at: cellNode[1],
},
)
// sourceOriginCol 为当前单元格源表格中起始位置列坐标
dealCell(editor, cellNode, sourceOriginCol)
// 插入单元格
function dealCell(
editor: IYTEditor,
cellNode: NodeEntry<Node>,
sourceOriginCol: number,
) {
let currentRow = Path.parent(cellNode[1])
const { colSpan = 1, rowSpan = 1 } = cellNode[0] as TableCellElement
Array.from({ length: rowSpan }).forEach((_, rowIndex) => {
if (rowIndex === 0 && colSpan > 1) {
// 当前单元格同行插入
const nodes = Array.from({ length: colSpan - 1 }).map(() =>
getEmptyCellNode(),
)
Transforms.insertNodes(editor, nodes, {
at: Path.next(cellNode[1]),
})
}
if (rowIndex !== 0) {
// 存在跨行数是拆分
currentRow = Path.next(currentRow)
const nodes = Array.from({ length: colSpan }).map(() =>
getEmptyCellNode(),
)
// 处理下一行拆分后单元起点
const path = getNextInsertRowPosition(editor, currentRow, sourceOriginCol)
Transforms.insertNodes(editor, nodes, {
at: path,
})
}
})
}
复制代码
下一行数据插入位置分为两种情况:当插入的列位置为 0 或者 行为空行时,则直接插入位置为行的开始位置;否则,通过源表格中当前行中前面的列数据获取对应的表格单元格是否在同行(不同行表示被其他行合并了),若不同行则将列再往前移动,直到行的开始位置。
function getNextInsertRowPosition(
editor: IYTEditor,
nextRow: Path,
sourceOriginCol: number,
) {
const [rowNode, rowPath] = Editor.node(editor, nextRow)
const tablePath = Path.parent(rowPath)
if (
Editor.isEmpty(editor, rowNode as TableRowElement) ||
sourceOriginCol === 0
) {
// 手动插入的行,直接插入第一个位置 || 在行首的拆分
return [...nextRow, 0]
}
// 需要通过源表格获取插入的位置
let i = 1
const [tableNode] = Editor.node(editor, tablePath)
const originTable = getOriginTable(tableNode as TableElement)
const relativeRowPath = Path.relative(nextRow, tablePath)
const originCell = originTable[relativeRowPath[0]][0]
const originRow = Array.isArray(originCell[0])
? originCell[0][0]
: originCell[0]
while (true) {
// 需要理解一下这个过程---
const sourceCellOriginPath = [originRow, sourceOriginCol - i]
const realPath = getRealPathByPath(originTable, sourceCellOriginPath)
if (realPath[0] === relativeRowPath[0]) {
// 排除不在当前行情况,避免单元格path超出表格
return [...nextRow, realPath[1] + 1]
}
if (sourceOriginCol === i) {
// 最后未找到
return [...nextRow, 0]
}
i++
}
}
复制代码
多单元格拆分
在通过表格选区拆分单元格时,不能通过选区顺序依次进行拆分。因为前置的拆分后,后续单元格的位置就不正确了,如果要计算非常麻烦。所以可以通过倒序的方式拆分,不会影响其他单元格的位置(代码很简单,就是需要想到通过倒序方式拆分)。
function splitCells(editor: IYTEditor, cellPaths: Path[]) {
const newCell: Path[] = getCellBySelectOrFocus(editor, cellPaths)
if (!newCell[0]) return
// 倒序拆分,避免拆分后续单元格找不到对应的位置
;[...newCell].reverse().forEach((cell) => {
const node = Editor.node(editor, cell)
splitCell(editor, node)
})
// 焦点聚焦
Transforms.select(editor, {
anchor: Editor.end(editor, newCell[0]),
focus: Editor.end(editor, newCell[0]),
})
}
复制代码
总结
单元格由于能够自由进行合并,造成了很多的可能性。在拆分时,要处理好以下问题:
- 需要插入行时,插入行的位置以及行数,而且可能存在多个插入;
- 插入单元格时,对于每行单元格位置的计算;
- 多单元格拆分时,需要转换思路,通过倒序方式拆分。
无论是单元格合并还是拆分,源表格数据都是比较重要的数据源,要好好利用,并进行相应转换。
展望
下一篇讲表格的插入行列功能。插入行列也不是单纯的在表格位置相应插入新行就完事,也需要处理一些特殊情况,比如:
- 插入列时,需要在每一行增加列。由于存在合并,每一行列的位置怎么计算?
- 插入行列遇到贯穿的合并单元格怎么处理?
在表格插入中,会对以上问题的解决方案一一说明,敬请期待......