商品货架管理页面,要求可以通过拖拽鼠标框选栏位,把选中的栏位合并(不跨行),合并的栏位也可以拆分。 首先想到的是格栅布局,用gird来实现行列,等写出来发现进行合并时,操作dom非常的麻烦。
后来突然想到了table是一个天然的,能合并的单元格的元素;可以利用这个特性实现想要的需求
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>