React 实现简易 Excel(选区篇)
分类: React 4075 11
介绍
最近在做表单低代码平台相关开发,其中有一个表格布局组件,需要实现 Excel 选区、合并、插入等功能,并往每个单元格放入组件,实现一个表格布局,其中 Excel 选区模式较难一点,花费了不少时间,在这里做一下记录,写文章也能继续理解一次。目前已经实现选区、合并、拆分功能,具体效果如下:
选区实现原理
- 第一步在鼠标事件
onmousedown
按下时,记录当前点击的单元格坐标信息,将此坐标当做开始坐标;定义一个数组,用于存放后续的所有坐标,暂叫坐标池; - 在鼠标
onmousemove
事件移动时,记录当前移动过程停留单元格的坐标信息,存入坐标池,通过坐标池计算出开始坐标和结束坐标,再通过开始坐标和结束坐标推算出两点中间的所有坐标,得出一个区间坐标数组,将此时的区间数组转换为数组的下标,在React
渲染时,通过下标比对,如果存在则为单元格加上一个高亮的选区className
,这个过程需要实时计算;在这一步的计算过程中,需要注意以下两点:
在拖动过程中,如果遇见已经合并过的单元格,需要将已合并过的单元格合并的坐标一起放入坐标池,计算得出开始坐标和结束坐标,中间会有一个递归查询坐标的过程,此时的坐标才是最准确的;
在onmousemove
每次给坐标池赋值时,需要先清除,否则会遗留已经失效的坐标,导致计算结果有误; - 在鼠标事件
onmouseup
执行时,在执行一次第二步,并将元素的onmousemove
、onmouseup
事件清除。
核心计算选区代码
- 计算两个坐标的区间坐标:
/**
* 获取坐标区间
* @param cells 所有单元格
* @param firstCoordinate 第一个坐标
* @param lastCoordinate 最后一个坐标
* @param column 总列数
*/
export function getCoordinateRange(
cells: TableCellType[],
firstCoordinate: string,
lastCoordinate: string,
column: number
): string[] {
if (!firstCoordinate || !lastCoordinate) return [];
const [firstX, firstY] = firstCoordinate.split('_');
const [lastX, lastY] = lastCoordinate.split('_');
function generateCoordinates(
minx: number,
maxX: number,
minY: number,
maxY: number
) {
const result: string[] = [];
for (let x = minx; x <= maxX; x++) {
for (let y = minY; y <= maxY; y++) {
const coordinate = `${x}_${y}`;
const currentCell = cells[coordinateToIndex(coordinate, column)];
result.push(coordinate);
if (currentCell?.firstCoordinate)
result.push(currentCell.firstCoordinate);
if (currentCell?.mergedCoordinate)
result.push(currentCell.mergedCoordinate);
}
}
return Array.from(new Set(result));
}
function findMaxAndMinCoordinate(coordinates: string[]) {
const _rows = coordinates.map((item) => +item.split('_')[0]);
const _columns = coordinates.map((item) => +item.split('_')[1]);
const minx = Math.min(..._rows);
const maxX = Math.max(..._rows);
const minY = Math.min(..._columns);
const maxY = Math.max(..._columns);
return { minx, maxX, minY, maxY };
}
const minX = Math.min(+firstX, +lastX);
const maxX = Math.max(+firstX, +lastX);
const minY = Math.min(+firstY, +lastY);
const maxY = Math.max(+firstY, +lastY);
// 第一次查找所有坐标
const coordinates = generateCoordinates(
Math.min(+firstX, +lastX),
Math.max(+firstX, +lastX),
Math.min(+firstY, +lastY),
Math.max(+firstY, +lastY)
);
const {
minx: newMinX,
maxX: newMaxX,
minY: newMinY,
maxY: newMaxY,
} = findMaxAndMinCoordinate(coordinates);
// 如果两次查找的结果不相等,说明还有合并的单元格未找到,则继续查找
if (
minX !== newMinX ||
maxX !== newMaxX ||
minY !== newMinY ||
maxY !== newMaxY
) {
return getCoordinateRange(
cells,
`${newMinX}_${newMinY}`,
`${newMaxX}_${newMaxY}`,
column
);
}
return coordinates;
}
鼠标选区代码实现
onmousedown
执行时,需要定义以下变量:
// 记录当前触发 onmousedown 的元素
const currentTarget = event.currentTarget as HTMLElement;
// 当前点击的单元格
// event.target 在 td 元素存在子级的情况,获取的不一定是 td,所以需要递归查询父级为 td 的元素
const cell = getElementParent(event.target as HTMLElement, 'td');
// 开始坐标
const startCoordinate = cell?.dataset.coordinate!;
// 如果当前单元格已经被合并过,则还需记录合并后的坐标
const startMergedCoordinate = cell?.dataset.mergedCoordinate ?? '';
// 记录所有的坐标
let allCoordinates: string[] = [startCoordinate, startMergedCoordinate];
onmousemove
执行时,需要完成上边第二步,执行以下代码:
function setEndPosition(event: React.MouseEvent) {
allCoordinates = [startCoordinate, startMergedCoordinate];
// 当前鼠标停留的单元格
const currentCell = getElementParent(event.target as HTMLElement, 'td');
// 当前鼠标停留单元格坐标
const currentCellCoordinate = currentCell?.dataset.coordinate ?? '';
allCoordinates.push(currentCellCoordinate);
// 如果当前单元格被合并过,则需要一起被记录
const currentMergedCoordinate = currentCell?.dataset.mergedCoordinate;
if (currentMergedCoordinate) allCoordinates.push(currentMergedCoordinate);
// 通过坐标池计算得出真实的开始坐标、结束坐标
// 鼠标点击时,记录的开始坐标如果存在反向选区时,坐标就会不准确
const { startCoordinate: _startCoordinate, endCoordinate } =
buildSelectionCoordinates(flatRows, allCoordinates, columnNumber);
// 将已经计算后的坐标区间做记录
setSelectedCoordinates(
getCoordinateRange(flatRows, _startCoordinate, endCoordinate, columnNumber)
);
// 计算后的坐标区间下标
setSelectedIndexList(
coordinatesToIndexList(
flatRows,
_startCoordinate,
endCoordinate,
columnNumber
)
);
}
onmouseup
执行时,记录最后一次坐标信息,并做更新:
currentTarget.onmouseup = (event: any) => {
// 记录最后一次坐标信息
setEndPosition(event);
// 清除事件
currentTarget.onmousemove = null;
currentTarget.onmouseup = null;
};
数据结构
- 单元格数据类型
/** 单元格类型 */
export interface TableCellType {
rowSpan: number;
colSpan: number;
width: number;
height: number;
coordinate: string;
/** 被合并的第一个坐标 */
firstCoordinate?: string | null;
/** 合并之后的坐标 */
mergedCoordinate?: string | null;
}
- 表格渲染数据
Array<Array<TableCellType>>
结语
当前表格按一个公用组件封装使用,使用的是 React 函数式组件
,选区部分感觉还可以优化,可能我的方案不是比较优秀,欢迎一起交流。未完待续...
参考资料
- 组件源码:https://github.com/D-xuanmo/mini-excel
- 计算区间坐标核心代码:https://github.com/D-xuanmo/mini-excel/blob/master/src/components/Table/utils/index.ts#L144-L206
- 在线预览、编辑(PC 端):https://codesandbox.io/s/mini-excel-4w29vd
- 选区计算规则,取矩形斜角两点:
共 11 条评论关于 “React 实现简易 Excel(选区篇)”