slatejs编辑器表格---选区
最近在 slate.js 的富文本编辑器中实现了 Table 的独立选区以及操作功能。由于表格存在单元格的合并操作,使得在选区计算和操作功能变得更加的复杂,所以对相关的实现进行了记录。当中,涉及到 slate.js 的内容不是很多,所以并不一定限制技术栈。在遇到类似功能需求时,希望能够为你提供出一种思路。整个功能内容相对还是比较多,所以将分为以下3个部分讲解:
- 独立选区的单元格和范围计算;
- 单元格操作;
- 行列操作。
本文是对表格自身独立的选区实现进行讲解。主要是对选区包含的单元格数据和选区范围大小计算的实现过程。效果如下:
前提条件
表格数据包括了:tableRow 表示行表格数据;tableCell 表示表格单元格。其中 tableCell 包括了数据内容和跨行(rowSpan)和跨列(colSpan)的数据(默认为 1)。
{
"type": "table",
"children": [
{
"type": "tableRow",
"children": [
{
"type": "tableCell",
"colSpan": 2,
"rowSpan": 1,
"children": [
...
]
},
{
"type": "tableCell",
"children": [
...
]
}
]
},
]
}
源表格
源表格指的是:表格的单元格数据是未跨行、跨列的相同表格对应的坐标或坐标范围(单元格存在合并情况时)。源表格是后续选区以及操作功能的基础,单元格存在跨行/跨列的合并,无法准确计算选区和操作的单元格范围,通过源表格能够实现相关范围计算。在后文中,都基于将左上角作为表格的起点进行坐标位置的描述。比如表格的首个单元格数据为{rowSpan: 3, colSpan:2}
那么,对应的表格中单元格为[0, 0]
,而源数据中单元格为[[0, 0], [2, 1]]
。
const originTable = [
...,
[
[
[
1,
0
],
[
1,
1
]
],
]
...
]
源表格可通过以下步骤计算可得:
- 计算源表格的总列数。表格的单元格同时存在跨行跨列情况,下一行单元格的 colSpan 之和不一定为源表格列数。在首行单元格没有其他单元格跨行影响,故可通过首行单元格计算可得源表格总列数。
const colNum = table.children[0].children.reduce(
(value: number, cell: TableCellElement) => {
const { colSpan = 1 } = cell
return colSpan + value
},
0,
)
- 计算表格中每行对应的源表格中行索引(行坐标)。
- 根据行索引计算当前行的 0~colNum 列中是否存在在当前源表格中;
- 若都存在则行索引加继续计算;若有不存在,则返回当前行索引,即为当前行的源表格索引。
table.children.forEach((row: TableRowElement) => {
const originRow: (number | number[])[][] = [] // 原始行数据
rowIndex = getRowOriginPosition(originTable, rowIndex, colNum)
let colOriginIndex = 0
...
})
/** 辅助方法 **/
// 计算行索引
function getRowOriginPosition(
originTable: (number | number[])[][][],
rowIndex: number,
colNum: number,
) {
let index = 0
while (true) {
// 初始源表格列索引
let colIndex = 0
// 根据行索引计算当前行的 0~colNum 列中是否存在在当前源表格中
while (colIndex < colNum) {
const originCell = [rowIndex + index, colIndex]
// 若都存在则行索引加继续计算;
// 若有不存在,则返回当前行索引,即为当前行的源表格索引
if (!isContainPath(originTable, originCell)) return originCell[0]
colIndex++
}
// 进行下一行计算
index++
}
}
// 判断坐标是否存在于源表格中
export function isContainPath(
originTable: (number | number[])[][][],
target: number[],
) {
const [x, y] = target
for (const row of originTable) {
for (const cell of row) {
if (Array.isArray(cell[0])) {
// 存在跨行/跨列单元格
const xRange = [cell[0][0], cell[1][0]]
const yRange = [cell[0][1], cell[1][1]]
if (
x >= xRange[0] &&
x <= xRange[1] &&
y >= yRange[0] &&
y <= yRange[1]
)
return true
} else if (cell[0] === x && cell[1] === y) {
// 不存在合并单元格直接判断
return true
}
}
}
return false
}
- 计算源表格单元格数据。
- 每行从第一列开始,判断是否已经存在于源表格中,直到不存在源表格时根据 rowSpan/colSpan 计算单元范围数据(单元存在跨行情况,当前坐标可能已经在其他存在了,所以需要判断一次);
- 计算源表格单元格数据
// 源表格行数据列索引
let colOriginIndex = 0
row.children.forEach((cell: TableCellElement) => {
const { rowSpan = 1, colSpan = 1 } = cell
while (true) {
// 每行从第一列开始,判断是否已经存在于源表格中,
// 直到不存在源表格时根据 rowSpan/colSpan 计算单元范围数据
const target = [rowIndex, colOriginIndex]
if (!isContainPath(originTable, target)) break
colOriginIndex++
}
// 计算源表格单元格数据
if (rowSpan === 1 && colSpan === 1) {
originRow.push([rowIndex, colOriginIndex])
} else {
// 合并的单元格数据
originRow.push([
[rowIndex, colOriginIndex],
[rowIndex + rowSpan - 1, colOriginIndex + colSpan - 1],
])
}
colOriginIndex += colSpan
})
选区范围计算
- 根据起止的单元格,计算出源表格的坐标范围数据。
- 根据起止单元格,获取对应源表格中对应单元格数据;
- 获取源表格中起止单元格的坐标范围;
// 根据起止单元格,获取对应源表格中对应单元格数据
const originStart = getOriginPath(originTable, startPath)
const originEnd = getOriginPath(originTable, endPath)
// 获取源表格中起止单元格的坐标范围
const newRange: number[][] = []
if (Array.isArray(originStart[0]) && Array.isArray(originStart[1])) {
newRange.push(originStart[0], originStart[1])
} else {
newRange.push(originStart as rangeType)
}
if (Array.isArray(originEnd[0]) && Array.isArray(originEnd[1])) {
newRange.push(originEnd[0], originEnd[1])
} else {
newRange.push(originEnd as rangeType)
}
const range = getRange(...(newRange as rangeType[]))
/** 辅助方法 **/
// 根据单元格坐标,计算出源表格单元格范围
function getOriginPath(originTable: (number | number[])[][][], real: number[]) {
return originTable[real[0]][real[1]]
}
// 计算最大范围
export function getRange(...args: rangeType[]): tableRange {
const xArr: number[] = []
const yArr: number[] = []
args.forEach((item) => {
xArr.push(item[0])
yArr.push(item[1])
})
return {
xRange: [Math.min(...xArr), Math.max(...xArr)],
yRange: [Math.min(...yArr), Math.max(...yArr)],
}
}
- 获取最终源表格中的坐标范围数据。单个坐标对应的源表格单元格坐标范围可能超出当前坐标范围,所以需要计算扩大范围。
以下图为例,起止单元格的坐标范围为:[0, 3],[2, 2]
。但是在[ [2,1], [2, 2] ]
和[ [0, 0], [1, 1] ]
时,会扩大选区范围,最终选择了整个表格。
- 根据坐标,计算每个坐标对应的源表格单元格;
- 并判断是否单元格范围是否在当前选区坐标范围之中;
- 若不在当前范围之中时,需要根据单元格数据扩大坐标范围数据再次进行计算。
function getOriginRange(
originTable: (number | number[])[][][],
xRange: rangeType,
yRange: rangeType,
) {
for (let x = xRange[0]; x <= xRange[1]; x++) {
for (let y = yRange[0]; y <= yRange[1]; y++) {
const path = [x, y]
// 根据坐标,计算每个坐标对应的源表格单元格
const rangePath = getRangeByOrigin(originTable, path)
if (rangePath !== path) {
// 返回范围数据
const range = getRange(
[xRange[0], yRange[0]],
[xRange[1], yRange[1]],
...(rangePath as rangeType[]),
)
// 并判断是否单元格范围是否在当前选区坐标范围之中
const isContain = isContainRange(range, { xRange, yRange })
if (!isContain) {
// 若不在当前范围之中时,需要根据单元格数据扩大坐标范围数据再次进行计算
return getOriginRange(originTable, range.xRange, range.yRange)
}
}
}
}
return {
xRange,
yRange,
}
}
/** 辅助方法 **/
// 根据坐标数据获取源表格中包含此坐标的单元格
function getRangeByOrigin(
originTable: (number | number[])[][][],
target: number[],
) {
const [x, y] = target
for (const row of originTable) {
for (const cell of row) {
if (Array.isArray(cell[0])) {
// 是否在范围内
const xRange = [cell[0][0], cell[1][0]]
const yRange = [cell[0][1], cell[1][1]]
if (
x >= xRange[0] &&
x <= xRange[1] &&
y >= yRange[0] &&
y <= yRange[1]
) {
return cell
}
} else if (cell[0] === x && cell[1] === y) {
return target
}
}
}
return []
}
- 通过源表格的坐标范围数据,计算表格对应单元格。遍历坐标范围内的所有坐标,获取相应的单元格并排除重复单元格。
function getRealRelativePaths(
originTable: (number | number[])[][][],
range: tableRange,
) {
const realPaths: Path[] = []
const { xRange, yRange } = range
// 遍历范围内每个行列,获取相应的单元格
for (let x = xRange[0]; x <= xRange[1]; x++) {
for (let y = yRange[0]; y <= yRange[1]; y++) {
const path = getRealPathByPath(originTable, [x, y])
if (path && !isIncludePath(realPaths, path)) {
realPaths.push(path)
}
}
}
return realPaths
}
/** 辅助方法 **/
// 根据源坐标,计算单元格
export function getRealPathByPath(
originTable: (number | number[])[][][],
path: Path,
) {
const [x, y] = path
for (const [rowKey, row] of originTable.entries()) {
for (const [cellKey, cell] of row.entries()) {
if (Array.isArray(cell[0])) {
// 是否在范围内
const xRange = [cell[0][0], cell[1][0]]
const yRange = [cell[0][1], cell[1][1]]
if (
x >= xRange[0] &&
x <= xRange[1] &&
y >= yRange[0] &&
y <= yRange[1]
) {
return [rowKey, cellKey]
}
} else if (cell[0] === x && cell[1] === y) {
return [rowKey, cellKey]
}
}
}
return [-1, -1]
}
- 计算选区大小。直接取选区数据的左上和右下单元格会因为存在合并情况而不准确,所以需要在源表格中获取对接单元格,从而进行选区计算。
- 将选区范围转换为源坐标,并获取相应的范围数据;
- 取左上角和右下角坐标,获取相应的单元格;
- 根据对应单元格 DOM 计算出选区大小。
function selectionBound(editor: IYTEditor, selectPath: Path[]) {
// 将选区范围转换为源坐标范围数据
const tablePath = Path.parent(Path.parent(selectPath[0]))
const [tableNode] = Editor.node(editor, tablePath)
const originTable = getOriginTable(tableNode as TableElement)
const originSelectPath: rangeType[] = []
selectPath.forEach((cellPath) => {
const relativePath = Path.relative(cellPath, tablePath)
const originRange = originTable[relativePath[0]][relativePath[1]]
if (Array.isArray(originRange[0])) {
originSelectPath.push(
originRange[0] as rangeType,
originRange[1] as rangeType,
)
} else {
originSelectPath.push(originRange as rangeType)
}
})
const { xRange, yRange } = getRange(...originSelectPath)
// 取左上角和右下角坐标,获取相应的单元格
const ltRelativePath = [xRange[0], yRange[0]]
const rbRelativePath = [xRange[1], yRange[1]]
const ltPath = getRealPathByPath(originTable, ltRelativePath)
const rbPath = getRealPathByPath(originTable, rbRelativePath)
// 根据对应单元格 DOM 计算出选区大小
const [startNode] = Editor.node(editor, [...tablePath, ...ltPath])
const [endNode] = Editor.node(editor, [...tablePath, ...rbPath])
const ltDom = ReactEditor.toDOMNode(editor, startNode)
const rbDom = ReactEditor.toDOMNode(editor, endNode)
const ltBound = ltDom.getBoundingClientRect()
const rbBound = rbDom.getBoundingClientRect()
return {
x: ltDom.offsetLeft,
y: ltDom.offsetTop,
left: ltBound.left,
top: ltBound.top,
right: rbBound.right,
bottom: rbBound.bottom,
}
}
总结
本文对表格选区的实现进行了讲解说明。主要是:
- 表格选区和操作的基础源表格数据计算,各种操作在源表格的支撑下能够更好的进行计算操作位置和范围;
- 选区范围计算时,要考虑表格存在合并的情况时扩大选区的当前范围,直到计算出真正的选区范围。
转载自:https://juejin.cn/post/7077766418841731108