vue+element使用自定义指令实现鼠标区域框选

发布于:2025-06-16 ⋅ 阅读:(15) ⋅ 点赞:(0)

main.js:

import directive from './directive' 
Vue.use(directive)

在这里插入图片描述
/directive/index.js:

import batchselect from './batchselect/batchselect'

const install = function (Vue) {
  Vue.directive('batchselect', batchselect)
}

if (window.Vue) {
  window['batchselect'] = batchselect
  Vue.use(install); // eslint-disable-line
}

export default install

/directive/batchselect/batchselect.js:

//框选时 实时选中、可带动滚动条
export default {
  inserted: (el, binding) => {
    // console.log(el)
    // console.log(binding)

    // 设置元素为相对定位,确保遮罩层定位正确
    el.style.position = 'relative';

    // 创建一个div作为area区域,用于显示框选效果
    const area = document.createElement('div');
    area.style = 'position: absolute; border: 1px solid #1362b4; background: rgba(139 191 249 / 50%); z-index: 10; visibility: hidden;';
    area.className = 'area';
    el.appendChild(area);

    // 保存相关数据的对象
    let state = {
      elPos: null,          // 元素在视口中的位置
      options: [],          // 可选元素列表
      optionsXYWH: [],      // 可选元素的位置和尺寸
      scrollOffset: { x: 0, y: 0 }, // 滚动偏移量
      startX: 0,            // 鼠标按下的X坐标
      startY: 0,            // 鼠标按下的Y坐标
      currentX: 0,          // 当前鼠标的X坐标
      currentY: 0,          // 当前鼠标的Y坐标
      hasMove: false,       // 是否有鼠标移动
      isSelecting: false,   // 是否正在框选
      scrollContainer: null, // 滚动容器
      scrollTimer: null,    // 滚动定时器
      scrollSpeed: 10,      // 滚动速度
      edgeThreshold: 50     // 边缘检测阈值
    };

    // 更新可选元素的位置信息
    const updateOptionsPosition = () => {
      // 获取元素在视口中的位置
      const { x, y } = el.getBoundingClientRect();
      state.elPos = { x, y };

      // 获取可选元素
      const optionClassName = binding.value.className;
      state.options = [].slice.call(el.querySelectorAll(optionClassName));

      // 更新可选元素的位置和尺寸
      state.optionsXYWH = state.options.map(v => {
        const obj = v.getBoundingClientRect();
        return {
          x: obj.x - state.elPos.x + state.scrollOffset.x,
          y: obj.y - state.elPos.y + state.scrollOffset.y,
          w: obj.width,
          h: obj.height
        };
      });
    };

    // 查找最近的滚动容器
    const findScrollContainer = (element) => {
      let parent = element.parentElement;
      console.log(parent)
      while (parent && parent !== document.body) {
        const overflow = window.getComputedStyle(parent).overflow;
        if (overflow === 'auto' || overflow === 'scroll') {
          return parent;
        }
        parent = parent.parentElement;
      }
      return null;
    };

    // 更新框选区域
    const updateSelectionArea = () => {
      if (!state.isSelecting) return;

      // 计算框选区域的尺寸和位置
      const width = Math.abs(state.currentX - state.startX);
      const height = Math.abs(state.currentY - state.startY);
      const left = Math.min(state.startX, state.currentX);
      const top = Math.min(state.startY, state.currentY);

      // 更新框选区域的样式
      area.style.left = `${left}px`;
      area.style.top = `${top}px`;
      area.style.width = `${width}px`;
      area.style.height = `${height}px`;

      // 显示框选区域
      area.style.visibility = 'visible';
    };

    // 检测并更新选中项
    const updateSelectedItems = () => {
      if (!state.isSelecting || !state.hasMove) return;

      // 获取框选区域的位置和尺寸
      const { left, top, width, height } = area.style;
      const areaTop = parseInt(top);
      const areaRight = parseInt(left) + parseInt(width);
      const areaBottom = parseInt(top) + parseInt(height);
      const areaLeft = parseInt(left);

      // 清空之前的选择
      binding.value.selectIdxs.length = 0;

      // 执行碰撞检测,找出被框选的元素
      state.optionsXYWH.forEach((v, i) => {
        const optionTop = v.y;
        const optionRight = v.x + v.w;
        const optionBottom = v.y + v.h;
        const optionLeft = v.x;

        // 判断是否相交
        if (!(areaTop > optionBottom || areaRight < optionLeft || areaBottom < optionTop || areaLeft > optionRight)) {
          // 记录被选中的元素索引
          binding.value.selectIdxs.push(i);
        }
      });
    };

    // 处理滚动
    const handleScroll = () => {
      if (!state.scrollContainer || !state.isSelecting) return;

      const rect = state.scrollContainer.getBoundingClientRect();
      const scrollHeight = state.scrollContainer.scrollHeight;
      const clientHeight = state.scrollContainer.clientHeight;

      // 计算鼠标位置与容器边缘的距离
      const distanceFromTop = state.currentY - state.startY + state.elPos.y - rect.top;
      const distanceFromBottom = rect.bottom - (state.currentY - state.startY + state.elPos.y);

      // 顶部边缘检测
      if (distanceFromTop < state.edgeThreshold && state.scrollContainer.scrollTop > 0) {
        state.scrollContainer.scrollTop -= state.scrollSpeed;
      }
      // 底部边缘检测
      else if (distanceFromBottom < state.edgeThreshold &&
        state.scrollContainer.scrollTop < scrollHeight - clientHeight) {
        state.scrollContainer.scrollTop += state.scrollSpeed;
      }

      // 重新计算滚动偏移量
      state.scrollOffset.y = state.scrollContainer.scrollTop;

      // 更新元素位置
      updateOptionsPosition();

      // 更新框选区域
      updateSelectionArea();

      // 更新选中项
      updateSelectedItems();
    };

    // 开始滚动定时器
    const startScrollTimer = () => {
      console.log('---开始滚动定时器---')
      if (state.scrollTimer) return;
      state.scrollTimer = setInterval(handleScroll, 50);
    };

    // 停止滚动定时器
    const stopScrollTimer = () => {
      console.log('---停止滚动定时器---')
      if (state.scrollTimer) {
        clearInterval(state.scrollTimer);
        state.scrollTimer = null;
      }
    };

    // 处理滚动事件
    const handleContainerScroll = () => {
      if (!state.isSelecting) return;

      // 更新滚动偏移量
      state.scrollOffset.y = state.scrollContainer.scrollTop;

      // 更新元素位置
      updateOptionsPosition();

      // 更新框选区域
      updateSelectionArea();

      // 更新选中项
      updateSelectedItems();
    };

    // 初始化滚动事件监听
    const initScrollListener = () => {
      // state.scrollContainer = findScrollContainer(el);
      state.scrollContainer = el.querySelector(binding.value.scrollelm);
      console.log(state.scrollContainer)//滚动容器

      if (state.scrollContainer) {
        state.scrollContainer.addEventListener('scroll', handleContainerScroll);

        // 保存清理函数
        state.cleanupScroll = () => {
          state.scrollContainer.removeEventListener('scroll', handleContainerScroll);
        };
      }
    };

    // 初始化框选功能
    const initSelect = () => {
      el.onmousedown = (e) => {
        e.preventDefault();

        // 更新元素位置信息
        updateOptionsPosition();

        // 获取鼠标按下时相对元素的坐标
        state.startX = e.clientX - state.elPos.x + state.scrollOffset.x;
        state.startY = e.clientY - state.elPos.y + state.scrollOffset.y;
        state.currentX = state.startX;
        state.currentY = state.startY;
        state.hasMove = false;
        state.isSelecting = true;

        // 显示框选区域
        area.style.visibility = 'visible';

        // 鼠标移动事件
        document.onmousemove = (e) => {
          e.preventDefault();
          state.hasMove = true;

          // 更新当前鼠标位置
          state.currentX = e.clientX - state.elPos.x + state.scrollOffset.x;
          state.currentY = e.clientY - state.elPos.y + state.scrollOffset.y;

          // 更新框选区域
          updateSelectionArea();

          // 更新选中项
          updateSelectedItems();

          // 开始滚动检测
          startScrollTimer();
        };

        // 鼠标抬起事件
        document.onmouseup = (e) => {
          document.onmousemove = document.onmouseup = null;
          state.isSelecting = false;

          // 停止滚动检测
          stopScrollTimer();

          // 如果有移动,处理选中项
          // if (state.hasMove) {
          //   // 滚动到选中的元素位置
          //   if (state.scrollContainer && binding.value.selectIdxs.length > 0) {
          //     const firstSelectedIndex = binding.value.selectIdxs[0];
          //     const selectedElement = state.options[firstSelectedIndex];

          //     if (selectedElement) {
          //       selectedElement.scrollIntoView({
          //         behavior: 'smooth',
          //         block: 'nearest'
          //       });
          //     }
          //   }
          // }

          // 重置框选区域
          area.style.visibility = 'hidden';
          area.style.left = '0';
          area.style.top = '0';
          area.style.width = '0';
          area.style.height = '0';
          return false;
        };
      };
    };

    // 初始化
    updateOptionsPosition();
    initScrollListener();
    initSelect();

    // 保存清理函数
    el.__batchselect_cleanup = () => {
      el.onmousedown = null;
      document.onmousemove = null;
      document.onmouseup = null;
      stopScrollTimer();
      if (state.cleanupScroll) {
        state.cleanupScroll();
      }
    };
  },

  unbind: (el) => {
    // 清理事件监听
    if (el.__batchselect_cleanup) {
      el.__batchselect_cleanup();
      delete el.__batchselect_cleanup;
    }
  }
}

demo:


<template>
  <div class="dragcontainer">

    <div class="cardbox" v-batchselect="{ className: '.tlpItem',selectIdxs,scrollelm:'.cardList'}">
      <el-checkbox :indeterminate="isIndeterminate" v-model="checkAll" @change="checkCardAllChange">全选</el-checkbox>
      <div class="cardList">
        <el-tooltip class="tlpItem" effect="dark" placement="bottom" v-for="(item,index) in tableData" :key="index">
          <div slot="content">123<br />456</div>
          <div class="cardItem">
            <el-checkbox class="checked" v-model="item.checked" @change="checkCardChange(item)"></el-checkbox>
            <p class="name">{{ index }}</p>
            <p class="date">{{ item.date }}</p>
          </div>
        </el-tooltip>
      </div>
    </div>

  </div>
</template>

<script>
export default {
  name: "drag",
  data() {
    return {
      isIndeterminate: false,
      checkAll: false,
      tableData: [
        {
          id: 0,
          name: '111',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 1,
          name: '222',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 2,
          name: '333',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 3,
          name: '444',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 4,
          name: '555',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 5,
          name: '666',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 6,
          name: '777',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 7,
          name: '888',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 8,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 9,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 10,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 11,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 12,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 13,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 14,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 15,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 16,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 17,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 18,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 19,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 20,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 21,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 22,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 23,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 24,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 25,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 26,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 27,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 28,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 29,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 30,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 31,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 32,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 33,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 34,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 35,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 36,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 37,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 38,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 39,
          name: '999',
          date: '2025-06-12',
          checked: false,
        }, {
          id: 40,
          name: '999',
          date: '2025-06-12',
          checked: false,
        },
      ],
      SelectRows: [],
      selectIdxs: [],
    };
  },
  watch: {
    // 监听自定义指令v-batchselect返回的selectIdxs
    selectIdxs(idxs) {
      // console.log(idxs)//[0,1,2,...]
      this.tableData.forEach((v, i) => {
        v.checked = idxs.indexOf(i) === -1 ? false : true;
      });
      this.$nextTick(() => {
        this.checkCardChange();
      });
    },
  },
  created() {
  },
  methods: {
    // 全选
    checkCardAllChange(val) {
      this.SelectRows = val ? this.tableData : [];
      this.isIndeterminate = false;
      for (var i = 0; i < this.tableData.length; i++) {
        this.tableData[i].checked = val
      }
    },
    // 单选
    checkCardChange() {
      this.$nextTick(() => {
        this.SelectRows = [];
        let checknum = 0;
        for (var i = 0; i < this.tableData.length; i++) {
          if (this.tableData[i].checked) {
            checknum++;
            this.SelectRows.push(this.tableData[i])
          }
        }
        this.checkAll = checknum === this.tableData.length;
        this.isIndeterminate = checknum > 0 && checknum < this.tableData.length;
      })
    },

  }
};
</script>

<style>
html,
body {
  overflow: hidden;
}
</style>
<style scoped>
.dragcontainer {
  width: 100%;
  height: 100%;
  padding: 20px;
  -webkit-user-select: none;
  -moz-user-select: none;
  -ms-user-select: none;
  user-select: none;
  overflow: hidden;
  overflow: auto;
}
.cardbox {
  width: 100%;
  height: 80%;
  overflow: hidden;
}
.cardList {
  display: flex;
  flex-wrap: wrap;
  height: 100%;
  border: 1px solid #f00;
  overflow: auto;
}
.cardList .cardItem {
  width: 150px;
  height: 150px;
  border: 1px solid #bedfff;
  margin: 10px;
  padding: 10px;
  text-align: center;
}
.cardList .cardItem:hover {
  background: #bedfff;
}
</style>

参考:vue鼠标批量框选复选框


网站公告

今日签到

点亮在社区的每一天
去签到