51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

React 实现简易 Excel(选区篇)

介绍

最近在做表单低代码平台相关开发,其中有一个表格布局组件,需要实现 Excel 选区、合并、插入等功能,并往每个单元格放入组件,实现一个表格布局,其中 Excel 选区模式较难一点,花费了不少时间,在这里做一下记录,写文章也能继续理解一次。目前已经实现选区、合并、拆分功能,具体效果如下:

选区实现原理

  1. 第一步在鼠标事件 onmousedown 按下时,记录当前点击的单元格坐标信息,将此坐标当做开始坐标;定义一个数组,用于存放后续的所有坐标,暂叫坐标池;
  2. 在鼠标 onmousemove 事件移动时,记录当前移动过程停留单元格的坐标信息,存入坐标池,通过坐标池计算出开始坐标和结束坐标,再通过开始坐标和结束坐标推算出两点中间的所有坐标,得出一个区间坐标数组,将此时的区间数组转换为数组的下标,在 React 渲染时,通过下标比对,如果存在则为单元格加上一个高亮的选区 className ,这个过程需要实时计算;在这一步的计算过程中,需要注意以下两点:

    在拖动过程中,如果遇见已经合并过的单元格,需要将已合并过的单元格合并的坐标一起放入坐标池,计算得出开始坐标和结束坐标,中间会有一个递归查询坐标的过程,此时的坐标才是最准确的;
    onmousemove 每次给坐标池赋值时,需要先清除,否则会遗留已经失效的坐标,导致计算结果有误;

  3. 在鼠标事件 onmouseup 执行时,在执行一次第二步,并将元素的 onmousemoveonmouseup 事件清除。

核心计算选区代码

  1. 计算两个坐标的区间坐标:
/**
 * 获取坐标区间
 * @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;
}`

鼠标选区代码实现

  1. 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];`

  1. 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
)
);
}`

  1. onmouseup 执行时,记录最后一次坐标信息,并做更新:
currentTarget.onmouseup = (event: any) => {
  // 记录最后一次坐标信息
  setEndPosition(event);
`// 清除事件
currentTarget.onmousemove = null;
currentTarget.onmouseup = null;
};`

数据结构

  1. 单元格数据类型
/** 单元格类型 */
export interface TableCellType {
  rowSpan: number;
  colSpan: number;
  width: number;
  height: number;
  coordinate: string;
  /** 被合并的第一个坐标 */
  firstCoordinate?: string | null;
  /** 合并之后的坐标 */
  mergedCoordinate?: string | null;
}
  1. 表格渲染数据 Array<Array<TableCellType>>

结语

当前表格按一个公用组件封装使用,使用的是 React 函数式组件 ,选区部分感觉还可以优化,可能我的方案不是比较优秀,欢迎一起交流。未完待续...

参考资料

  1. 组件源码:https://github.com/D-xuanmo/mini-excel
  2. 计算区间坐标核心代码:https://github.com/D-xuanmo/mini-excel/blob/master/src/components/Table/utils/index.ts#L144-L206
  3. 在线预览、编辑(PC 端):https://codesandbox.io/s/mini-excel-4w29vd
  4. 选区计算规则,取矩形斜角两点:

在线演示

赞(1)
未经允许不得转载:工具盒子 » React 实现简易 Excel(选区篇)