
各位客官好,这是我掘金的第一篇文章,想记录下自己写的一些好玩的小玩意,有些好的想法可以在评论中我们讨论讨论。
实现效果

目前由于项目需要,想要一种效果,如上面gif所示,支持鼠标滑动选择,也支持单独点击选中和取消选中,鼠标抬起(mouseup)会返回选中的元素下标数组。
思路逻辑
1. 需要计算外层元素的 offsetleft 和 offsetTop 高度,同时也需要计算内部每个 boxItem 在页面所处的位置(boxItem.offsetLeft - boxWrapPosition.offsetLeft ),形成一个map数据
javascript
const boxWrap = document.querySelector(".box");
const rowHeightConfig = {};
const boxWrapPosition = {
left: boxWrap.offsetLeft,
top: boxWrap.offsetTop,
width: boxWrap.offsetWidth,
height: boxWrap.offsetHeight,
};
// 计算每个boxItem的位置
function calcListItemPositionInfo() {
for (const itemNode of boxList) {
const itemTop = itemNode.offsetTop - boxWrapPosition.top;
const itemLeft = itemNode.offsetLeft - boxWrapPosition.left;
// 按照所处高度进行划分,方便定位元素
if (rowHeightConfig[itemTop]) {
rowHeightConfig[itemTop].push({
target: itemNode,
left: itemLeft,
width: itemNode.offsetWidth,
height: itemNode.offsetTop,
});
} else {
rowHeightConfig[itemTop] = [
{
target: itemNode,
left: itemLeft,
width: itemNode.offsetWidth,
height: itemNode.offsetTop,
},
];
}
}
}
最终生成如下图所示的数据结构,方便后续使用二分查找定位当前鼠标所在的元素位置

2. 进行事件绑定,绑定鼠标按下,移动,松开事件
javascript
// 当前有简单做了 pc 和 h5 的事件兼容
const isWindow = /window/i.test(navigator.userAgent);
function bindEvent() {
if (isWindow) {
boxWrap.addEventListener("mousedown", boxWrapMouseDown);
} else {
boxWrap.addEventListener("touchstart", boxWrapMouseDown, {
passive: false,
});
}
}
// 定义工具方法
// 依据位置判断当前点击的是那个元素
function locateBoxItem({ left, top }) {
const heightList = Reflect.ownKeys(rowHeightConfig);
// 依据当前鼠标的top值,进行计算 所处与 哪个高度区间,例如 [10, 30, 50] top为则返回1
const heightIndex = binarySearch(heightList, top);
const rowConfigList = rowHeightConfig[heightList[heightIndex]];
// 获取当前这一层的元素的left值,定位元素
const leftList = rowConfigList.map(
(item) => item.left + item.width / 2
);
const leftIndex = binarySearch(leftList, left, true);
return rowConfigList[leftIndex].target;
}
// 二分查找
function binarySearch(data, target, needDetermineDistance = false) {
let index = 0;
const len = data.length;
let start = 0,
last = len - 1;
if (Number.isNaN(target)) {
return start;
}
if (target >= data[last]) {
return last;
}
if (target <= data[start]) {
return start;
}
while (start <= last) {
const mid = Math.floor((start + last) / 2);
const midValue = data[mid];
if (index > len) {
console.log("溢出: ", data, target);
return;
}
if (midValue == target) {
return mid;
}
if (target > midValue && target < data[mid + 1]) {
// 需要定位大致位置,如果当前值靠近前面,就取前面的索引,靠近后面就取后面的索引
if (needDetermineDistance) {
const curRemainder = Math.abs(target - midValue);
const nextRemainder = Math.abs(target - data[mid + 1]);
return curRemainder <= nextRemainder ? mid : mid + 1;
}
return mid;
}
if (midValue > target) {
last = mid - 1;
} else if (midValue < target) {
start = mid + 1;
}
index++;
}
}
2-1 鼠标按下事件
首先获取当前鼠标放下的位置,并缓存起来,然后给window绑定事件
javascript
const keyDownConfig = {
downTarget: null,
moveTarget: null,
};
// 为了兼容h5 和 pc
const { clientX, clientY, target } = e?.touches?.[0] || e;
const realX = clientX - boxWrapPosition.left,
realY = clientY - boxWrapPosition.top;
// 由于在h5下通过点击元素的位置 closest 获取元素,有误差,所以在 h5 是通过计算的
let boxItemTarget = isWindow ? target.closest(".box-item") : null;
if (!boxItemTarget) {
// 计算当前位置是在哪个boxItem
boxItemTarget = locateBoxItem({
left: realX,
top: realY,
});
}
// 用于记录当前鼠标按下的元素,
keyDownConfig.downTarget = boxItemTarget;
if (isWindow) {
window.addEventListener("mousemove", boxWrapMousemove);
window.addEventListener("mouseup", boxWrapMouseUp);
} else {
window.addEventListener("touchmove", boxWrapMousemove, {
passive: false,
});
window.addEventListener("touchend", boxWrapMouseUp, {
passive: false,
});
}
2-2 鼠标move事件
鼠标移动的前部分逻辑与鼠标按下的前部分一致,以下只列出有差异部分,并进行备注
javascript
function boxWrapMousemove(e) {
...
// 如果 鼠标按下和移动到的元素是同一个
if (keyDownConfig.downTarget === boxItemTarget) {
if (
keyDownConfig.moveTarget &&
keyDownConfig.moveTarget !== boxItemTarget
) {
resetCurrent(
keyDownConfig.downTarget.dataset.index,
keyDownConfig.downTarget.dataset.index
);
}
return;
}
// 防止重复触发
if (
keyDownConfig.moveTarget?.dataset.index ===
boxItemTarget.dataset.index
) {
return;
}
keyDownConfig.moveTarget = boxItemTarget;
const startIndex = keyDownConfig.downTarget.dataset.index;
const endIndex = keyDownConfig.moveTarget.dataset.index;
// 通过开始下标和结束下标,进行选中
resetCurrent(startIndex, endIndex);
}
// 重置当前激活项
function resetCurrent(start, end) {
const _localSelect = [];
const min = Math.min(start, end);
const max = Math.max(start, end);
boxList.forEach((item, index) => {
const realIndex = index + 1;
if (realIndex >= min && realIndex <= max) {
// 对应上次,上次选择的这次还选择得取消
if (lastSelectList.includes(realIndex)) {
item.classList.remove("current");
} else {
item.classList.add("current");
_localSelect.push(realIndex);
}
} else {
if (currentSelectList.includes(realIndex)) {
item.classList.remove('current')
}
}
});
// 记录当前选择的数据 currentSelectList 即外部定义的一个对象,没有其他作用,只在这里赋值,鼠标抬起清空
currentSelectList = _localSelect;
}
2-3 鼠标抬起
javascript
function boxWrapMouseUp(e) {
const { clientX, clientY, target } = e?.changedTouches?.[0] || e;
const realX = clientX - boxWrapPosition.left,
realY = clientY - boxWrapPosition.top;
let boxItemTarget = isWindow ? target.closest(".box-item") : null;
if (!boxItemTarget) {
boxItemTarget = locateBoxItem({
left: realX,
top: realY,
});
}
// 如果点击和放手的元素相同,则说明要么是选中当前元素,要么是取消当前选择的选中(单击)
if (keyDownConfig.downTarget === boxItemTarget) {
if (boxItemTarget.classList.contains("current")) {
boxItemTarget.classList.remove("current");
} else {
boxItemTarget.classList.add("current");
}
}
// 记录上次选中的,用于处理后续滑动选择有交集的部分取消选中|选中
lastSelectList = [];
// 清空当前选择,否则会导致每次任意滑动都会清空之前的
currentSelectList = [];
const selectDomList = boxWrap.querySelectorAll(".current");
selectDomList.forEach((dom) => {
lastSelectList.push(dom.dataset.index * 1);
});
// 抛出最后的结果
console.log(lastSelectList)
keyDownConfig.downTarget = null;
keyDownConfig.moveTarget = null;
if (isWindow) {
window.removeEventListener("mousemove", boxWrapMousemove);
window.removeEventListener("mouseup", boxWrapMouseUp);
} else {
window.removeEventListener("touchmove", boxWrapMousemove);
window.removeEventListener("touchend", boxWrapMouseUp);
}
}
完整代码
javascript
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no"
/>
<title>Document</title>
<style>
* {
padding: 0;
margin: 0;
box-sizing: border-box;
}
body {
width: 100vw;
height: 100vh;
overflow: hidden;
}
.box {
/* margin: 100px auto; */
padding: 12px;
display: flex;
flex-wrap: wrap;
gap: 10px;
width: 100%;
border: 1px solid #ccc;
box-shadow: 0px 0px 3px #ccc;
border-radius: 4px;
box-sizing: border-box;
overflow: auto;
user-select: none;
}
.box-item {
--delay: 0s;
border-radius: 16px;
cursor: pointer;
padding: 7px 13px;
transition: 0.3s;
}
/* .box-item:where(.current, :hover) { */
.box-item.current {
background-color: #ebf5fd;
color: #0a82e5;
}
/* .box-item.start {
background-color: #ebf5fd;
color: #0a82e5;
} */
/* .box-item.end {
background-color: #ebf5fd;
color: #0a82e5;
} */
@media screen and (min-width: 800px) {
.box {
width: 400px;
margin: 100px auto;
}
}
</style>
</head>
<body>
<div class="box">
<div class="box-item" data-index="1">
<span>包1</span>
</div>
<div class="box-item" data-index="2">
<span>包2</span>
</div>
<div class="box-item" data-index="3">
<span>包3</span>
</div>
<div class="box-item" data-index="4">
<span>包4</span>
</div>
<div class="box-item" data-index="5">
<span>包5</span>
</div>
<div class="box-item" data-index="6">
<span>包6</span>
</div>
<div class="box-item" data-index="7">
<span>包7</span>
</div>
<div class="box-item" data-index="8">
<span>包8</span>
</div>
<div class="box-item" data-index="9">
<span>包9</span>
</div>
<div class="box-item" data-index="10">
<span>包10</span>
</div>
<div class="box-item" data-index="11">
<span>包11</span>
</div>
<div class="box-item" data-index="12">
<span>包12</span>
</div>
<div class="box-item" data-index="13">
<span>包13</span>
</div>
<div class="box-item" data-index="14">
<span>包14</span>
</div>
<div class="box-item" data-index="15">
<span>包15</span>
</div>
<div class="box-item" data-index="16">
<span>包16</span>
</div>
<div class="box-item" data-index="17">
<span>包17</span>
</div>
<div class="box-item" data-index="18">
<span>包18</span>
</div>
<div class="box-item" data-index="19">
<span>包19</span>
</div>
<div class="box-item" data-index="20">
<span>包120</span>
</div>
</div>
<script>
const isWindow = /window/i.test(navigator.userAgent);
const boxWrap = document.querySelector(".box");
const boxWrapPosition = {
left: boxWrap.offsetLeft,
top: boxWrap.offsetTop,
width: boxWrap.offsetWidth,
height: boxWrap.offsetHeight,
};
const boxList = boxWrap.querySelectorAll(".box-item");
const boxItemPositionMap = new WeakMap();
const rowHeightConfig = {};
const keyDownConfig = {
downTarget: null,
moveTarget: null,
};
let lastSelectList = [];
let currentSelectList = [];
function boxWrapMouseUp(e) {
const { clientX, clientY, target } = e?.changedTouches?.[0] || e;
const realX = clientX - boxWrapPosition.left,
realY = clientY - boxWrapPosition.top;
let boxItemTarget = isWindow ? target.closest(".box-item") : null;
if (!boxItemTarget) {
boxItemTarget = locateBoxItem({
left: realX,
top: realY,
});
}
// 如果点击和放手的元素相同,则说明要么是选中当前元素,要么是取消当前选择的选中
if (keyDownConfig.downTarget === boxItemTarget) {
if (boxItemTarget.classList.contains("current")) {
boxItemTarget.classList.remove("current");
} else {
boxItemTarget.classList.add("current");
}
}
// 记录上次选中的
lastSelectList = [];
currentSelectList = [];
const selectDomList = boxWrap.querySelectorAll(".current");
selectDomList.forEach((dom) => {
lastSelectList.push(dom.dataset.index * 1);
});
keyDownConfig.downTarget = null;
keyDownConfig.moveTarget = null;
if (isWindow) {
window.removeEventListener("mousemove", boxWrapMousemove);
window.removeEventListener("mouseup", boxWrapMouseUp);
} else {
window.removeEventListener("touchmove", boxWrapMousemove);
window.removeEventListener("touchend", boxWrapMouseUp);
}
}
function boxWrapMousemove(e) {
e.preventDefault();
e.stopPropagation();
const { clientX, clientY, target } = e?.touches?.[0] || e;
const realX = clientX - boxWrapPosition.left,
realY = clientY - boxWrapPosition.top;
let boxItemTarget = isWindow ? target.closest(".box-item") : null;
if (!boxItemTarget) {
boxItemTarget = locateBoxItem({
left: realX,
top: realY,
});
}
if (keyDownConfig.downTarget === boxItemTarget) {
if (
keyDownConfig.moveTarget &&
keyDownConfig.moveTarget !== boxItemTarget
) {
resetCurrent(
keyDownConfig.downTarget.dataset.index,
keyDownConfig.downTarget.dataset.index
);
console.log('xxx')
}
return;
}
if (
keyDownConfig.moveTarget?.dataset.index ===
boxItemTarget.dataset.index
) {
return;
}
keyDownConfig.moveTarget = boxItemTarget;
const startIndex = keyDownConfig.downTarget.dataset.index;
const endIndex = keyDownConfig.moveTarget.dataset.index;
resetCurrent(startIndex, endIndex);
}
function boxWrapMouseDown(e) {
const { clientX, clientY, target } = e?.touches?.[0] || e;
const realX = clientX - boxWrapPosition.left,
realY = clientY - boxWrapPosition.top;
let boxItemTarget = isWindow ? target.closest(".box-item") : null;
if (!boxItemTarget) {
boxItemTarget = locateBoxItem({
left: realX,
top: realY,
});
}
keyDownConfig.downTarget = boxItemTarget;
if (isWindow) {
window.addEventListener("mousemove", boxWrapMousemove);
window.addEventListener("mouseup", boxWrapMouseUp);
} else {
window.addEventListener("touchmove", boxWrapMousemove, {
passive: false,
});
window.addEventListener("touchend", boxWrapMouseUp, {
passive: false,
});
}
}
function bindEvent() {
if (isWindow) {
boxWrap.addEventListener("mousedown", boxWrapMouseDown);
} else {
boxWrap.addEventListener("touchstart", boxWrapMouseDown, {
passive: false,
});
}
}
// 重置当前激活项
function resetCurrent(start, end) {
const _localSelect = [];
const min = Math.min(start, end);
const max = Math.max(start, end);
boxList.forEach((item, index) => {
const realIndex = index + 1;
if (realIndex >= min && realIndex <= max) {
// 对应上次,上次选择的这次还选择得取消
if (lastSelectList.includes(realIndex)) {
item.classList.remove("current");
} else {
item.classList.add("current");
_localSelect.push(realIndex);
}
} else {
if (currentSelectList.includes(realIndex)) {
item.classList.remove('current')
}
}
});
currentSelectList = _localSelect;
}
// 依据位置判断当前点击的是那个元素
function locateBoxItem({ left, top }) {
const heightList = Reflect.ownKeys(rowHeightConfig);
const heightIndex = binarySearch(heightList, top);
const rowConfigList = rowHeightConfig[heightList[heightIndex]];
const leftList = rowConfigList.map(
(item) => item.left + item.width / 2
);
const leftIndex = binarySearch(leftList, left, true);
return rowConfigList[leftIndex].target;
}
// 计算每个boxItem的位置
function calcListItemPositionInfo() {
for (const itemNode of boxList) {
const itemTop = itemNode.offsetTop - boxWrapPosition.top;
const itemLeft = itemNode.offsetLeft - boxWrapPosition.left;
if (rowHeightConfig[itemTop]) {
rowHeightConfig[itemTop].push({
target: itemNode,
left: itemLeft,
width: itemNode.offsetWidth,
height: itemNode.offsetTop,
});
} else {
rowHeightConfig[itemTop] = [
{
target: itemNode,
left: itemLeft,
width: itemNode.offsetWidth,
height: itemNode.offsetTop,
},
];
}
boxItemPositionMap.set(itemNode, {
left: itemNode.offsetLeft - boxWrapPosition.left,
top: itemTop,
width: itemNode.offsetWidth,
height: itemNode.offsetTop,
});
}
}
// 二分查找
function binarySearch(data, target, needDetermineDistance = false) {
let index = 0;
const len = data.length;
let start = 0,
last = len - 1;
if (Number.isNaN(target)) {
return start;
}
if (target >= data[last]) {
return last;
}
if (target <= data[start]) {
return start;
}
while (start <= last) {
const mid = Math.floor((start + last) / 2);
const midValue = data[mid];
if (index > len) {
console.log("溢出: ", data, target);
return;
}
if (midValue == target) {
return mid;
}
if (target > midValue && target < data[mid + 1]) {
if (needDetermineDistance) {
const curRemainder = Math.abs(target - midValue);
const nextRemainder = Math.abs(target - data[mid + 1]);
return curRemainder <= nextRemainder ? mid : mid + 1;
}
return mid;
}
if (midValue > target) {
last = mid - 1;
} else if (midValue < target) {
start = mid + 1;
}
index++;
}
}
function init() {
calcListItemPositionInfo();
bindEvent();
console.log(rowHeightConfig)
}
init();
</script>
</body>
</html>
结语
由于第一个写,加上原本代码进行编辑时逻辑还是很清晰的,过几天写这篇文章的时候,就乱乱的,如果有什么不理解或者没懂的地方,可以评论区交流捏。
完整代码也放在上面,可以打断点进行调试调试,同时,如果有什么好的建议,也可以提出来,谢谢大家
