前端实现单元格自由合并与拆分

13 阅读2分钟

需求

M*N矩阵中有大小相同的格子,实现单元格的行/列的合并与拆分,如下图:

image.png

实现方法

可以通过Antd Table表格行/列合并实现功能,但是对需求来说使用table有点重,探索用更轻量的方法实现。

调研发现,CSS Grid网格布局 引入了二维网格布局系统,不熟悉的同学可以先学习下Grid布局,二维网格布局系统对合并单元格来说有天然的优势。

组件入参

/*
参数             说明                类型
row             行数                Number
col             列数                Number
dataSource      外部数据源           Object[]
cellStyle       单个单元格样式        Object{}
cellRender      单元格渲染函数        Function(cellItem)	
onChange        单元格发生变化后触发   Function(allCells)	
*/
export default function CellMerger({
  row=3,
  col=3,
  cellStyle={
    width: "100px",
    height: "100px",
    gap: "2px",
  },
  cellRender,
  dataSource,
  onChange,
}) {
    ......
}

画矩阵

以3*3为例,首先需要生成9个网格

// 定义数组存储单元格信息
const [grids, setGrids] = useState([]);

// 初始化单元格数据。有外部传入直接使用,没有传入使用row、col自动生成
useEffect(() => {
    if (dataSource) {
      setGrids(dataSource)
    } else {
      generateGridData();
    }
}, []);

// 使用row、col自动生成
const generateGridData = () => {
    const data = [];
    for (let i = 1; i <= row; i++) {
      for (let j = 1; j <= col; j++) {
        data.push({
          id: generateUniqueId(),
          gridRowStart: i,
          gridRowEnd: i + 1,
          gridColumnStart: j,
          gridColumnEnd: j + 1,
        });
      }
    }
    onGridChange(data);
};

// 唯一ID
const generateUniqueId = () => {
    const randomNumber = Math.random().toString(36).substring(2, 5);
    const timestamp = Date.now().toString(36);
    return randomNumber + timestamp;
};

渲染矩阵

根据单元格数据循环渲染单元格

const renderGridItem = (item) => {
    return (
      <div
        key={item.id}
        className={styles.gridItem}
        style={{
          /*  单元格位置  */
          gridRowStart: item.gridRowStart,
          gridRowEnd: item.gridRowEnd,
          gridColumnStart: item.gridColumnStart,
          gridColumnEnd: item.gridColumnEnd,
        }}
      >
        <div className={styles.gridContainer}>
          <div className={styles.gridContent}>
            {/* 用户自定义渲染 */}
            {cellRender(item)}
          </div>
          {/* 渲染当前单元格是否显示四个方向合并按钮 */}
          {renderLinkButton(item)}
          {/* 渲染当前单元格是否显示拆分按钮 */}
          {renderUnlinkButton(item)}
        </div>
      </div>
    );
 };
  
 return (
    <div>
      <div
        className={styles.gridContainer}
        style={{
          /*  声明行的高度  */
          gridTemplateRows: `repeat(${row}, ${cellStyle.height}`,
          /*  声明列的宽度  */
          gridTemplateColumns: `repeat(${col}, ${cellStyle.width})`,
          /* 设置格子之间的间隔 */
          gap: cellStyle.gap || "2px",
        }}
      >
        {grids.map((item) => {
          return renderGridItem(item);
        })}
      </div>
    </div>
  );

判断单元格是否显示四个方向合并按钮

image.png

显然不是所有单元四个方向都是可合并的,那在什么情况下才显示合并按钮呢?

  1. 不在边的单元格;
  2. 横向合并要保证横向相邻单元格的高度一致;
  3. 竖向合并要保证竖向相邻单元格的宽度一致;
const renderLinkButton = (curGrid) => {
    let doms = [];
    let enableLeftBtn = false;
    for (let item of grids) {
      // 判断左边可不可以合并,元素必须和当当前元素高度一致并首尾相连
      if (
        item.gridRowStart === curGrid.gridRowStart &&
        item.gridRowEnd === curGrid.gridRowEnd &&
        item.gridColumnEnd === curGrid.gridColumnStart
      ) {
       enableLeftBtn = true;
      }

      // 边上按钮不显示
      if (curGrid.gridColumnStart <= 1) {
        enableLeftBtn = false;
      }

      // 同理其他三个方向
      .....
    }
    
    if (enableLeftBtn) {
      doms.push(
        <button
          key={`${curGrid.id}-left`}
          className={`${styles.leftButton} ${styles.hoverBtn}`}
          // 处理合并事件
          onClick={() => mergeCell(DIRECTION_TYPES.LEFT, curGrid)}
        >
          +
        </button>
      );
    }
}

/* Css部分 */
.gridItem:hover {
  .hoverBtn {
    display: block;
  }
}

.hoverBtn {
  display: none;
}

注意合并按钮只有hover时才显示。

处理合并事件

以左合并为例:

  1. 去除row相同 & col相同的数据(当前元素);
  2. 去除row相同 & 左边col的数据(被合并的元素),并记录该元素左边位置;
  3. 添加新合并后单元格:row保持不变,col从被合并元素左边开始到当前元素的右边;
const mergeCell = (direction, curGrid) => {
    let resGrid = [];
    // 向左合并: 去除row相同 & 左边col的数据,去除row相同 & col相同的数据
    if (direction === DIRECTION_TYPES.LEFT) {
      let start = null;
      for (let item of grids) {
        if ( // 去除当前元素 
          item.gridRowStart === curGrid.gridRowStart &&
          item.gridRowEnd === curGrid.gridRowEnd &&
          item.gridColumnEnd === curGrid.gridColumnEnd &&
          item.gridColumnStart === curGrid.gridColumnStart
        ) {
          continue;
        } else if ( // 去除row相同 & 左边col的数据,并记录起始点
          item.gridRowStart === curGrid.gridRowStart &&
          item.gridRowEnd === curGrid.gridRowEnd &&
          item.gridColumnEnd === curGrid.gridColumnStart
        ) {
          start = item.gridColumnStart;
          continue;
        } else {
          resGrid.push(item);
        }
      }
      // 添加合并后的新格子
      resGrid.push({
        id: generateUniqueId(),
        gridRowStart: curGrid.gridRowStart,
        gridRowEnd: curGrid.gridRowEnd,
        gridColumnStart: start,
        gridColumnEnd: curGrid.gridColumnEnd,
      });
    }
    // 其他三个方向同理
    onGridChange(resGrid);
};

// 单元格数据变化,需要触发回调
const onGridChange = (grids) => {
    setGrids(grids);
    onChange(grids);
};

判断是否显示拆分按钮

是否可以拆分只需要判断当前单元格的长宽是否大于1即可。

const renderUnlinkButton = (curGrid) => {
    if (
      curGrid.gridColumnEnd - curGrid.gridColumnStart > 1 ||
      curGrid.gridRowEnd - curGrid.gridRowStart > 1
    ) {
      return (
        <button
          className={`${styles.unlinkBtn} ${styles.hoverBtn}`}
          onClick={() => unlinkCell(curGrid)}
        >
          解除连接
        </button>
      );
    }
    return null;
};

处理拆分事件

  1. 去除row相同 & col相同的数据(当前元素);
  2. 遍历当前元素row和col,添加单元格
const unlinkCell = (curGrid) => {
    let resGrid = [];
    for (let item of grids) {
      if (
        item.gridRowStart === curGrid.gridRowStart &&
        item.gridRowEnd === curGrid.gridRowEnd &&
        item.gridColumnEnd === curGrid.gridColumnEnd &&
        item.gridColumnStart === curGrid.gridColumnStart
      ) {
        continue;
      } else {
        resGrid.push(item);
      }
    }

    for (let i = curGrid.gridRowStart; i < curGrid.gridRowEnd; i++) {
      for (let j = curGrid.gridColumnStart; j < curGrid.gridColumnEnd; j++) {
        resGrid.push({
          id: generateUniqueId(),
          gridRowStart: i,
          gridRowEnd: i + 1,
          gridColumnStart: j,
          gridColumnEnd: j + 1,
        });
      }
    }
    // 通知组件更新
    onGridChange(resGrid);
};

最终效果

image.png