原生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>

结语

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

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

相关推荐
黄尚圈圈18 分钟前
Vue 中引入 ECharts 的详细步骤与示例
前端·vue.js·echarts
浮华似水1 小时前
简洁之道 - React Hook Form
前端
正小安3 小时前
如何在微信小程序中实现分包加载和预下载
前端·微信小程序·小程序
_.Switch5 小时前
Python Web 应用中的 API 网关集成与优化
开发语言·前端·后端·python·架构·log4j
一路向前的月光5 小时前
Vue2中的监听和计算属性的区别
前端·javascript·vue.js
长路 ㅤ   5 小时前
vite学习教程06、vite.config.js配置
前端·vite配置·端口设置·本地开发
长路 ㅤ   5 小时前
vue-live2d看板娘集成方案设计使用教程
前端·javascript·vue.js·live2d
Fan_web5 小时前
jQuery——事件委托
开发语言·前端·javascript·css·jquery
安冬的码畜日常5 小时前
【CSS in Depth 2 精译_044】第七章 响应式设计概述
前端·css·css3·html5·响应式设计·响应式
莹雨潇潇6 小时前
Docker 快速入门(Ubuntu版)
java·前端·docker·容器