React 实现简易 Excel(选区篇)

轩陌

分类: React 1261 10

介绍

最近在做表单低代码平台相关开发,其中有一个表格布局组件,需要实现 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. 选区计算规则,取矩形斜角两点:

  • 10人 Love
  • 1人 Haha
  • 1人 Wow
  • 0人 Sad
  • 0人 Angry
React、React 简易版 Excel

作者简介: 轩陌

打赏

生命的意义在于折腾,一直努力成长中,期待梦想实现的那刻。

共 10 条评论关于 “React 实现简易 Excel(选区篇)”

Loading...