怎么灵活的通过鼠标框选合并拆分单元格·

529 阅读5分钟

商品货架管理页面,要求可以通过拖拽鼠标框选栏位,把选中的栏位合并(不跨行),合并的栏位也可以拆分。 首先想到的是格栅布局,用gird来实现行列,等写出来发现进行合并时,操作dom非常的麻烦。

后来突然想到了table是一个天然的,能合并的单元格的元素;可以利用这个特性实现想要的需求

录屏2024-07-31-14.30.35.gif

1. 监听鼠标的拖拽操作

先写出基本的容器,鼠标可以拖拽的范围

<template>
    <div class="test" @mousedown="down" @mousemove="move" @mouseup="up">
        <div ref="movable.dom" class="move"></div>
    </div>
</template>

鼠标拖拽的时候需要显示出框选的范围,所以要初始化一个框选元素

/*
* 移动对象的属性
* */
let movable = reactive({
  dom: null,
  top: 0,
  left: 0,
  width: 0,
  height: 0
});
let point = {
  /*
  * 鼠标起点坐标
  * */
  start: {
    x: 0, y: 0
  },
  /*
  * 鼠标终点坐标
  * */
  end: {
    x: 0, y: 0
  },
  /*
  * 是否正在move
  * */
  moving: false,
  
}

下面就可以记录鼠标拖拽数据了

想确定鼠标选中的范围,要监听鼠标的三个状态,1、鼠标按下;2、鼠标移动;3、手指抬起

/*
* 鼠标按下
* 记录初始点击的位置
* */
const down = (e) => {
  point.start.x = e.x;
  point.start.y = e.y;

  movable.left = e.x + "px";
  movable.top = e.y + "px";

  console.log('标记移动');
  point.moving = true; // 标记移动开始
}
/*
* 鼠标移动
* */
let move = (e) => {
  if (!point.moving) return;

  const { x, y } = e; // 鼠标位置

  // 计算选择框的宽度和高度,同时确保选择框的左上角始终为起点
  movable.width = Math.abs(x - point.start.x) + "px";
  movable.height = Math.abs(y - point.start.y) + "px";
  point.end.x = x;
  point.end.y = y;

  // 如果当前鼠标位置小于起始位置,则调整left和top值
  if (x < point.start.x) {
    movable.left = x + "px";
  }
  if (y < point.start.y) {
    movable.top = y + "px";
  }
}
/*
* 鼠标抬起
* */
let up = (e) => {
  console.log(e);
  /*
  * 没有移动,直接终止
  * */
  if (!point.moving) return;

  movable.width = 0;
  movable.height = 0;
  point.end.x = e.x;
  point.end.y = e.y;
  point.moving = false;
}

2. 确定鼠标框选范围覆盖的元素

想确定覆盖的元素,先要知道框选的范围,完善一下point

let point = {
    ...
    ... 如此这般
    ...
  /*
  * 获取范围内四个角的坐标
  * */
  getPosition() {

    let angle = []; //角

    angle.push(this.start); //起点
    angle.push(this.end); //终点

    angle.push({
      x: this.end.x,
      y: this.start.y
    }); //对角

    angle.push({
      x: this.start.x,
      y: this.end.y
    }); //对角


    return angle;

  }
}

然后要知道每个单元格的坐标信息来判断是否被框选覆盖

<script setup>
const selected = ref([]); //被选元素的索引
const items = ref([]); //存储所有dom
const tableData = ref([]);
onMounted(() => {
  // tableData模拟数据,生成二维数组
  for (let i = 0; i < rowNum; i++) {
    let row = [];
    for (let j = 0; j < colNum; j++) {
      row.push({
        name: `我是测试${i + j + (i * (colNum - 1))}`,
        value: Math.random() * 100,
        id: i + j + (i * (colNum - 1)),
        colSpan: 1,
        isShowSplitBtn: false,
        isShowMergeBtn: false,
        selected: false
      });
    }
    tableData.value.push(row);
  }
})

const push = (dom, index) => {
  items.value[index] = {
    content: dom.textContent,
    dom: dom
  }; //记录dom
}

let point = {
    ...
    ...
    ...

  /*
  * 判断元素是否满足被选中的条件
  * */
  isSelected(rect) {
    let angle = point.getPosition(); // 获取四个角的坐标
    // 确定选择框的左上角和右下角
    let topLeft = {
      x: Math.min(angle[0].x, angle[1].x),
      y: Math.min(angle[0].y, angle[1].y)
    };
    let bottomRight = {
      x: Math.max(angle[0].x, angle[1].x),
      y: Math.max(angle[0].y, angle[1].y)
    };

    // console.log('point',angle);

    // 元素的边界
    let elementTopLeft = { x: rect.x, y: rect.y };
    let elementBottomRight = { x: rect.x + rect.width, y: rect.y + rect.height };
    // 检查元素是否与选择框相交
  if (
    elementTopLeft.x < bottomRight.x && // 元素左边在选择框右边之内
    elementBottomRight.x > topLeft.x && // 元素右边在选择框左边之外
    elementTopLeft.y < bottomRight.y && // 元素上边在选择框下边之内
    elementBottomRight.y > topLeft.y    // 元素下边在选择框上边之外
  ) {
    // console.log('符合条件',item.textContent);
    return true;
  }
  return false;
    
  }
}

const up = (e) => {
    ...
    ... 
    ...
 /*
  * 循环遍历元素是否被选中
  * */
  selected.value = []; //清空被选的索引
  items.value.forEach((item, index) => {
    if (point.isSelected(item.dom.getBoundingClientRect())) {
      console.log(item.dom.textContent, index);
      selected.value.push(index);
    }
  })
  selected.value.length <= 1 && (selected.value = []);
}
</script>
<template>
  <div class="test" @mousedown="down" @mousemove="move" @mouseup="up">
    <div ref="movable.dom" class="move"></div>
    <table border>
      <tr v-for="(item, index) in tableData">
        <template v-for="(el, i) in item">
          <td v-show="el.colSpan > 0" :colspan="el.colSpan" :ref="(dom) => push(dom, index + i + (index * (colNum - 1)))"
            @click="el.colSpan > 1 && !el.isShowMergeBtn && (el.isShowSplitBtn = true)"
            :class="{ selected: el.selected }" class="item" :style="{ width: el.colSpan * itemWidth + 'px' }">{{
              el.name
            }}
          </td>
        </template>
      </tr>
    </table>
  </div>
</template>

3. 合并、拆分单元格

添加合并、拆分按钮和逻辑

<script>
const up = (e) => {
    ...
    ...
    ...
    // 确定选中区域的范围
  let minCol = Infinity;
  let maxCol = -Infinity;
  let row = null;
  selected.value.forEach(index => {
    let curRow = Math.floor(index / colNum);
    let curCol = index % colNum;
    if (row === null) row = curRow; // 初始化行
    if (curRow !== row) return; // 如果不在同一行,跳过
    minCol = Math.min(minCol, curCol);
    maxCol = Math.max(maxCol, curCol);
  });

  let totalColSpan = 0;
  for (let col = minCol; col <= maxCol; col++) {
    totalColSpan += tableData.value[row][col].colSpan;
  }

  // 更新colSpan值
  for (let col = minCol; col <= maxCol; col++) {
    let currentCell = tableData.value[row][col];
    // 缓存被合并单元格原始colSpan

    originalColSpan.value.push(currentCell.colSpan);
    if (col === minCol) {
      // 将总colSpan赋给左侧最外层的单元格
      // 显示合并按钮
      if (currentCell.colSpan != totalColSpan && currentCell.colSpan < totalColSpan) currentCell.isShowMergeBtn = true;
      // 合并单元格
      currentCell.colSpan = totalColSpan;
    } else {
      // 其他单元格的colSpan设为0
      currentCell.colSpan = 0;
    }
  }
}

const originalColSpan = ref([]);

/**
 * 拆分单元格
 * @param rowIndex 行索引
 * @param colIndex 列索引
 */
const splitCell = (rowIndex, colIndex) => {
  // 恢复所有受rowIndex行colIndex列影响的单元格的 colSpan
  let affectedCells = [];
  let endIndex = tableData.value[rowIndex][colIndex].colSpan + colIndex;
  let startIndex = colIndex;
  affectedCells = tableData.value[rowIndex].slice(startIndex, endIndex);
  // console.log(affectedCells,originalColSpan.value);
  let isOrigin = originalColSpan.value.length > 0;
  affectedCells.forEach((cell, index) => {
    cell.colSpan = isOrigin ? originalColSpan.value[index] >= 0 ? originalColSpan.value[index] : 0 : 1;
    cell.isShowSplitBtn = false;
    cell.isShowMergeBtn = false;
  });
  // console.log(affectedCells);
  originalColSpan.value = [];
};
</script>
<template>
  <div class="test" @mousedown="down" @mousemove="move" @mouseup="up">
    <div ref="movable.dom" class="move"></div>
    <table border>
      <tr v-for="(item, index) in tableData">
        <template v-for="(el, i) in item">
          <td v-show="el.colSpan > 0" :colspan="el.colSpan" :ref="(dom) => push(dom, index + i + (index * (colNum - 1)))"
            @click="el.colSpan > 1 && !el.isShowMergeBtn && (el.isShowSplitBtn = true)"
            :class="{ selected: el.selected }" class="item" :style="{ width: el.colSpan * itemWidth + 'px' }">{{
              el.name
            }}
            <div v-if="el.isShowSplitBtn" class="btn-wrap">
              <button class="btn" @click.stop="splitCell(index, i)">拆分</button>
              <button class="btn" @click.stop="el.isShowSplitBtn = false; originalColSpan = [];setItemBg()">取消</button>
            </div>
            <div v-if="el.isShowMergeBtn" class="btn-wrap">
              <button class="btn"
                @click.stop.prevent="el.isShowMergeBtn = false; originalColSpan = []; setItemBg()">合并</button>
              <button class="btn" @click.stop="splitCell(index, i)">取消</button>
            </div>
          </td>
        </template>
      </tr>
    </table>
  </div>
</template>

这样基本上就通过table的特性实现了货架管理的交互。

还有一些细节需求完善,有优化建议或者什么不对的地方,欢迎评论区见~

附上完整代码

<script setup>

import { reactive, ref, onMounted } from "vue";

const selected = ref([]); //被选元素的索引
const items = ref([]); //存储所有dom
const tableData = ref([]);
const originalColSpan = ref([]);

const rowNum = 5;
const colNum = 6;
const itemWidth = 181;

onMounted(() => {
  // tableData模拟数据,生成二维数组
  for (let i = 0; i < rowNum; i++) {
    let row = [];
    for (let j = 0; j < colNum; j++) {
      row.push({
        name: `我是测试${i + j + (i * (colNum - 1))}`,
        value: Math.random() * 100,
        id: i + j + (i * (colNum - 1)),
        colSpan: 1,
        isShowSplitBtn: false,
        isShowMergeBtn: false,
        selected: false
      });
    }
    tableData.value.push(row);
  }
})
const push = (dom, index) => {
  items.value[index] = {
    content: dom.textContent,
    dom: dom
  }; //记录dom
}

/*
* 初始的原点
* */
let point = {
  /*
  * 鼠标起点坐标
  * */
  start: {
    x: 0, y: 0
  },
  /*
  * 鼠标终点坐标
  * */
  end: {
    x: 0, y: 0
  },
  /*
  * 是否正在move
  * */
  moving: false,
  /*
  * 获取范围内四个角的坐标
  * */
  getPosition() {

    let angle = []; //角

    angle.push(this.start); //起点
    angle.push(this.end); //终点

    angle.push({
      x: this.end.x,
      y: this.start.y
    }); //对角

    angle.push({
      x: this.start.x,
      y: this.end.y
    }); //对角


    return angle;

  },

  /*
  * 判断元素是否满足被选中的条件
  * */
  isSelected(rect) {
    let angle = point.getPosition(); // 获取四个角的坐标
    // 确定选择框的左上角和右下角
    let topLeft = {
      x: Math.min(angle[0].x, angle[1].x),
      y: Math.min(angle[0].y, angle[1].y)
    };
    let bottomRight = {
      x: Math.max(angle[0].x, angle[1].x),
      y: Math.max(angle[0].y, angle[1].y)
    };

    // console.log('point',angle);

    // 元素的边界
    let elementTopLeft = { x: rect.x, y: rect.y };
    let elementBottomRight = { x: rect.x + rect.width, y: rect.y + rect.height };
    // 检查元素是否与选择框相交
    if (point.start.x <= point.end.x) {
      // 鼠标起点在左边
      if (
        elementTopLeft.x < bottomRight.x && // 元素左边在选择框右边之内
        elementBottomRight.x > topLeft.x && // 元素右边在选择框左边之外
        elementTopLeft.y < bottomRight.y && // 元素上边在选择框下边之内
        elementBottomRight.y > topLeft.y    // 元素下边在选择框上边之外
      ) {
        // console.log('符合条件',item.textContent);
        return true;
      }
      return false;
    } else {
      if (
        elementTopLeft.x > topLeft.x &&
        elementTopLeft.y > topLeft.y &&
        elementBottomRight.x < bottomRight.x &&
        elementBottomRight.y < bottomRight.y
      ) {
        // console.log('符合条件',item.textContent);
        return true;
      }
      return false;
    }
  }
}

/*
* 鼠标按下
* 记录初始点击的位置
* */
const down = (e) => {
  point.start.x = e.x;
  point.start.y = e.y;

  movable.left = e.x + "px";
  movable.top = e.y + "px";

  console.log('标记移动');
  point.moving = true; // 标记移动开始
}

/*
* 鼠标移动
* */
const move = (e) => {
  if (!point.moving) return;

  const { x, y } = e; // 鼠标位置

  // 计算选择框的宽度和高度,同时确保选择框的左上角始终为起点
  movable.width = Math.abs(x - point.start.x) + "px";
  movable.height = Math.abs(y - point.start.y) + "px";
  point.end.x = x;
  point.end.y = y;

  // 如果当前鼠标位置小于起始位置,则调整left和top值
  if (x < point.start.x) {
    movable.left = x + "px";
  }
  if (y < point.start.y) {
    movable.top = y + "px";
  }
  setItemBg();

}

const setItemBg = () => {
  if (point.moving) {
    let row = null;
    // 找出选中的单元格
    items.value.forEach((item, index) => {
      if (point.isSelected(item.dom.getBoundingClientRect())) {
        if (row === null) row = Math.floor(index / colNum);
        if(row == Math.floor(index / colNum)) {
          // 改变背景颜色
          console.log(item.dom.textContent, index);
          tableData.value[Math.floor(index / colNum)][index % colNum].selected = true;
        }else {
          tableData.value[Math.floor(index / colNum)][index % colNum].selected = false;
        }
      } else {
        tableData.value[Math.floor(index / colNum)][index % colNum].selected = false;
      }
    })
  } else {
    items.value.forEach((item, index) => {
      // console.log(item);
      tableData.value[Math.floor(index / colNum)][index % colNum].selected = false;
    })
  }
}

/*
* 鼠标抬起
* */
const up = (e) => {
  console.log(e);
  /*
  * 没有移动,直接终止
  * */
  if (!point.moving) return;

  movable.width = 0;
  movable.height = 0;
  point.end.x = e.x;
  point.end.y = e.y;
  point.moving = false;
  // 如果鼠标在按钮上也直接返回
  if (e.target.className === "btn" || e.target.className === "btn-wrap") return;
  // 如果originalColSpan有值,说明有合并的单元格没有操作确认,需要恢复原样后合并新选中的单元格
  if (originalColSpan.value.length > 0) {
    // 在tabledata里寻找不是当前的isShowMergeBtn为true的单元格
    tableData.value.forEach((row, rowIndex) => {
      row.forEach((cell, colIndex) => {
        if (cell.isShowMergeBtn) {
          // console.log('拆分单元格', rowIndex, colIndex);
          splitCell(rowIndex, colIndex)
        }
      })
    })
    originalColSpan.value = [];
  }

  /*
  * 循环遍历元素是否被选中
  * */
  selected.value = []; //清空被选的索引
  items.value.forEach((item, index) => {
    if (point.isSelected(item.dom.getBoundingClientRect())) {
      console.log(item.dom.textContent, index);
      selected.value.push(index);
    }
  })
  selected.value.length <= 1 && (selected.value = []);

  // 确定选中区域的范围
  let minCol = Infinity;
  let maxCol = -Infinity;
  let row = null;
  selected.value.forEach(index => {
    let curRow = Math.floor(index / colNum);
    let curCol = index % colNum;
    if (row === null) row = curRow; // 初始化行
    if (curRow !== row) return; // 如果不在同一行,跳过
    minCol = Math.min(minCol, curCol);
    maxCol = Math.max(maxCol, curCol);
  });

  let totalColSpan = 0;
  for (let col = minCol; col <= maxCol; col++) {
    totalColSpan += tableData.value[row][col].colSpan;
  }

  // 更新colSpan值
  for (let col = minCol; col <= maxCol; col++) {
    let currentCell = tableData.value[row][col];
    // 缓存被合并单元格原始colSpan

    originalColSpan.value.push(currentCell.colSpan);
    if (col === minCol) {
      // 将总colSpan赋给左侧最外层的单元格
      // 显示合并按钮
      if (currentCell.colSpan != totalColSpan && currentCell.colSpan < totalColSpan) currentCell.isShowMergeBtn = true;
      // 合并单元格
      currentCell.colSpan = totalColSpan;
    } else {
      // 其他单元格的colSpan设为0
      currentCell.colSpan = 0;
    }
  }
  initPoint();
  setItemBg();
}

const initPoint = () => {
  movable.left = 0;
  movable.top = 0;
  movable.width = 0;
  movable.height = 0;
  point.start.x = 0;
  point.start.y = 0;
  point.end.x = 0;
  point.end.y = 0;
  point.moving = false;
}

/**
 * 拆分单元格
 * @param rowIndex 行索引
 * @param colIndex 列索引
 */
const splitCell = (rowIndex, colIndex) => {
  // 恢复所有受rowIndex行colIndex列影响的单元格的 colSpan
  let affectedCells = [];
  let endIndex = tableData.value[rowIndex][colIndex].colSpan + colIndex;
  let startIndex = colIndex;
  affectedCells = tableData.value[rowIndex].slice(startIndex, endIndex);
  // console.log(affectedCells,originalColSpan.value);
  let isOrigin = originalColSpan.value.length > 0;
  affectedCells.forEach((cell, index) => {
    cell.colSpan = isOrigin ? originalColSpan.value[index] >= 0 ? originalColSpan.value[index] : 0 : 1;
    cell.isShowSplitBtn = false;
    cell.isShowMergeBtn = false;
  });
  // console.log(affectedCells);
  originalColSpan.value = [];
  setItemBg();
};

/*
* 移动对象的属性
* */
const movable = reactive({
  dom: null,
  top: 0,
  left: 0,
  width: 0,
  height: 0
});


</script>

<template>
  <div class="test" @mousedown="down" @mousemove="move" @mouseup="up">
    <div ref="movable.dom" class="move"></div>
    <table border>
      <tr v-for="(item, index) in tableData">
        <template v-for="(el, i) in item">
          <td v-show="el.colSpan > 0" :colspan="el.colSpan" :ref="(dom) => push(dom, index + i + (index * (colNum - 1)))"
            @click="el.colSpan > 1 && !el.isShowMergeBtn && (el.isShowSplitBtn = true)"
            :class="{ selected: el.selected }" class="item" :style="{ width: el.colSpan * itemWidth + 'px' }">{{
              el.name
            }}
            <div v-if="el.isShowSplitBtn" class="btn-wrap">
              <button class="btn" @click.stop="splitCell(index, i)">拆分</button>
              <button class="btn" @click.stop="el.isShowSplitBtn = false; originalColSpan = [];setItemBg()">取消</button>
            </div>
            <div v-if="el.isShowMergeBtn" class="btn-wrap">
              <button class="btn"
                @click.stop.prevent="el.isShowMergeBtn = false; originalColSpan = []; setItemBg()">合并</button>
              <button class="btn" @click.stop="splitCell(index, i)">取消</button>
            </div>
          </td>
        </template>
      </tr>
    </table>
  </div>
</template>

<style scoped>
.test {
  width: 100%;
  height: 100%;
  text-align: center;
  padding: 20px;
}

table {
  margin: 0px auto;
}

.move {
  position: fixed;
  top: v-bind("movable.top");
  left: v-bind("movable.left");
  width: v-bind("movable.width");
  height: v-bind("movable.height");
  background: rgba(234, 23, 135, 0.3);
  z-index: 99;
}
.item {
  /* width: v-bind("itemWidth"); */
  padding: 50px;
  margin: 10px;
  position: relative;
  background: #0088ff;
  user-select: none;
}

.btn-wrap {
  width: 100%;
  height: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
  position: absolute;
  left: 0;
  top: 0;
  background-color: #fff;
}

.selected {
  background-color: black;
}

.button-container {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
}

tr {
  height: 146px;
}
</style>