原生js实现鼠标拖拽选中效果

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

实现效果

目前由于项目需要,想要一种效果,如上面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>

结语

由于第一个写,加上原本代码进行编辑时逻辑还是很清晰的,过几天写这篇文章的时候,就乱乱的,如果有什么不理解或者没懂的地方,可以评论区交流捏。

完整代码也放在上面,可以打断点进行调试调试,同时,如果有什么好的建议,也可以提出来,谢谢大家

相关推荐
醉城夜风~6 分钟前
CSS元素显示模式(display)
前端·css
AI大模型-小华1 小时前
Codex 三方充值快速入门指南
java·前端·数据库·chatgpt·ai编程·codex·chatgpt pro
做前端的娜娜子3 小时前
同一链接实现 PC Web 与移动 H5 自适应
前端·掘金·金石计划
小帅不太帅3 小时前
架构没变、规模没变,DeepSeek V4 Flash 正式版凭什么暴涨 47 分?
前端·aigc·deepseek
jarvisuni4 小时前
DeepSeekFlash前端依旧拉垮,而且变慢了很多!
前端·javascript·算法
卷福同学5 小时前
AI编程出海第二步:验证关键词能否做站
前端·人工智能·后端
赵庆明老师5 小时前
Vben精讲:21-详解web-antd:tsconfig.json
前端·json·vim
wc885 小时前
微软EDGE浏览器功能学习
前端·学习·edge
Csvn6 小时前
🎯 原生 `<dialog>` 元素:终于可以扔掉一半的自定义弹窗组件了?
前端