实现框选TickBox

851 阅读5分钟

引言

不知道各位小伙伴在做前端时有没有碰到过实现框选效果的需求,框选效果看起来比较简单,但是实现起来还是有很多细节需要注意的,下面就让我们一起来看看这是如何实现的吧。

一、基础知识

在正式开始之前,有必要回顾一下基本的概念。

1. 获取元素的宽高

clientHeight = height + padding

offsetHeight = height + padding + border

scrollHeight = height + padding

2. 获取偏移量

clientTop:该元素对象的上边框宽度

offsetTop:该元素上外边框相对于 offsetParent 节点顶部边界的偏移像素值。(offsetParent元素是一个指向最近的(指包含层级上的最近)包含该元素的定位元素或者最近的元素。)

scrollTop:页面利用滚动条滚动到下侧时,隐藏在滚动条上侧的页面的宽度(滚动条在父元素身上,所以判断滚动的距离应该在父元素上判断)

3. 获取鼠标点击位置

clientY:以浏览器窗口左上顶角为原点,定位 y 轴坐标

offsetY:当事件被触发时鼠标指针相对于所触发的标签元素的上内边框的水平坐标

pageY: 以 document 对象(即文档窗口)左上顶角为原点,定位 y 轴坐标

screenY:计算机屏幕左上顶角为原点,定位 y 轴坐标

layerY:最近的绝对定位的父元素(如果没有,则为 document 对象)左上顶角为元素,定位 y 轴坐标

二、实现

1. 分析:

  实现框选checkbox的效果,主要就是实现在container区域点击鼠标左键键并移动的时候,绘制一个矩形框,松开时矩形框消失,同时在矩形框中的选择框被勾选

2. 流程:

  获取鼠标点击的位置,以x方向为例,限制只能在container的content区域框选,返回点击的位置以container左上角为原点的水平距离,注意要考虑边框以及滚动的距离

function getX(e) {
    var X;
    if (e.clientX <= container.offsetLeft + container.clientLeft - getScrollLeft()) {
        X = 0
    } else if (e.clientX >= container.offsetLeft + container.clientLeft + container.clientWidth - getScrollLeft()) {
        X = container.clientWidth
    } else {
        X = e.clientX - container.offsetLeft - container.clientLeft + getScrollLeft()
    }
    return X
}

  鼠标按下:绘制一个框选mask,绘制的位置以container的左上角为基准,因此需要计算鼠标点击的位置距离基准点的距离

function mouseDownEvent(e) {
    // 判断是否左键按下
    if (e.button !== 0) return
    cancelBubbelAndDefault(e)
    hasMouseDown = true
    startX = getX(e)
    startY = getY(e)
    var mask = document.createElement("div")
    mask.className = "showMask"
    mask.id = "mask"
    mask.style.left = startX + 'px';
    mask.style.top = startY + 'px';
    container.appendChild(mask)
}

  鼠标移动:鼠标移动时,根据当前鼠标所在位置(endXendY)和初始鼠标按下的位置(startXstartY)显示框选的mask,计算出mask的宽度高度以及mask的top和left值进行定位,定位时如果始终以初始位置设置top和left值,则只能向右下方拖动,因此需要比较end和start的值,选出较小的值作为top和left值。

function mouseMoveEvent(e) {
    // 不断改变样式,引起回流重绘,设置定时器使其每50ms触发一次
    setTimeout(() => {  
        cancelBubbelAndDefault(e)
        // 如果没有触发按下鼠标事件,也不触发此事件
        if (!hasMouseDown) return;
        var mask = document.getElementById("mask");
        // 获取鼠标当前所在的位置,修改mask的宽高以及距离
        var endX = getX(e), endY = getY(e);
        mask.style.left = Math.min(startX, endX) + 'px';
        mask.style.top = Math.min(startY, endY) + 'px';
        mask.style.width = Math.abs(endX - startX) + 'px';
        mask.style.height = Math.abs(endY - startY) + 'px';
        // 显示mask
        mask.style.display = "block";
    }, 50);

}

  鼠标弹起:隐藏mask,遍历每个勾选框,判断勾选框是否在mask中,判断方式是获取勾选框的边界以及mask的边界值并进行比较,如果在mask中就让其勾选

// 鼠标弹起:隐藏mask,并判断在mask中的勾选框进行勾选
function mouseUpEvent(e) {
    cancelBubbelAndDefault(e);
    if (!hasMouseDown) return;
    var mask = document.getElementById("mask");
    var checkbox = document.querySelectorAll('.tickBox');
    // 判断哪些勾选框在Mask中,遍历每个勾选框,判断其边界值
    for (var i = 0; i < checkbox.length; i++) {
        // 获取tickBox的和mask的上下左右边界值
        var tickLeft = checkbox[i].offsetLeft, tickRight = checkbox[i].offsetLeft + checkbox[i].offsetWidth
        var tickTop = checkbox[i].offsetTop, tickBottom = checkbox[i].offsetTop + checkbox[i].offsetHeight
        var maskLeft = mask.offsetLeft, maskRight = mask.offsetLeft + mask.offsetWidth
        var maskTop = mask.offsetTop, maskBottom = mask.offsetTop + mask.offsetHeight
        // 考虑小盒子不是全部在mask中的情况,只有局部在也要勾选
        if ((tickLeft < maskRight && tickRight > maskLeft) && (tickTop < maskBottom && tickBottom > maskTop)) {
            checkbox[i].checked = true
        }
    }
    mask.style.display = 'none';
    // 弹起之后,让值等于false,否则还会触发移动事件
    hasMouseDown = false;
}

三、问题及解决方案

1. 绘制mask框时,绘制范围会超出container盒子

  解决:设置overflow:hidden对其进行隐藏,但是当mask有边框时,就会出现部分边框看不见的情况,因此通过判断点击的位置将mask的绘制范围设置在盒子内部。

2. 鼠标左键弹起后,盒子没有隐藏并且继续随着鼠标的位置移动

  解决:鼠标弹起后会触发mouseup事件,此时会隐藏元素,但是随后移动鼠标又会触发mousemove事件,因此设置一个标记值hasMouseDown,只有当该值为true的时候,表明之前已经触发了mousedown事件,才会接着触发mousemove事件,在mouseup事件中将其值设置为false,这样鼠标弹起后再移动不会触发mouseMoveEvent

3. 当鼠标左键在container外的右下区域弹起时,遮罩mask不消失。

  解决:由于事件绑定在body上,而container外的右下区域不在body内部,不会触发mouseup事件,因此使用home盒子把body撑开。

4. 窗口缩小时,绘制盒子的位置和点击的位置出现了偏移

  解决:由于窗口缩小,会出现滚动条,导致clientX和clientY发生改变,因此在判断点击的位置时候出现位置偏差,需要在原有的top和left值上加上滚动的距离

5. 不断改变样式,引起回流重绘

  解决:设置定时器使其每50ms触发一次。

四、完整代码

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>框选checkbox</title>
    <style>
        * {
            margin: 0;
            padding: 0;
        }

        html,
        body {
            width: 100%;
            height: 100%;
        }

        #home {
            min-width: 800px;
            min-height: 900px;
        }

        #container {
            display: inline-block;
            position: relative;
            top: 200px;
            left: 300px;
            width: 420px;
            height: 180px;
            border: 1px solid black;
            overflow: hidden;
        }

        .tickBox {
            display: inline-block;
            width: 28px;
            height: 28px;
            margin-right: 10px;
            margin-top: 16px;
        }

        #container .tickBox:nth-child(6n) {
            margin-right: 160px;
        }

        #container .tickBox:nth-child(6n+1) {
            margin-left: 15px;
        }

        #container .tickBox:nth-child(-n+6) {
            margin-top: 30px;
        }

        .showMask {
            box-sizing: border-box;
            position: absolute;
            display: none;
            width: 0px;
            height: 0px;
            border: 2px dotted black;
            z-index: 100;
        }

        #clearAll {
            position: absolute;
            background-color: #fff;
            width: 65px;
            height: 25px;
            border: 1px solid black;
            top: 390px;
            left: 656px;
            padding: 3px;
        }
    </style>
</head>

<body>
    <div id="home">
        <div id="container">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
            <input type="checkbox" class="tickBox">
        </div>
        <button id="clearAll">一键清空</button>
    </div>
</body>
  
<script>
    var home = document.getElementById("home")
    var container = document.getElementById("container")
    var clearButton = document.getElementById("clearAll")
    var startX, startY
    // 记录是否触发鼠标按下事件
    var hasMouseDown = false
    // 取消冒泡和默认事件
    function cancelBubbelAndDefault(e) {
        if (e.stopPropagation) { e.stopPropagation(); }
        else { e.cancelBubble = true; }
        if (e.preventDefault) { e.preventDefault(); }
        else { e.returnValue = false; }
    }
    // 获取滚动距离
    function getScrollTop() {
        var scrolltop;
        if (document.documentElement) {
            scrolltop = document.documentElement.scrollTop;
        } else {
            scrolltop = document.body.scrollTop;
        }
        return scrolltop;
    }
    function getScrollLeft() {
        var scrollleft;
        if (document.documentElement) {
            scrollleft = document.documentElement.scrollLeft;
        } else {
            scrollleft = document.body.scrollLeft;
        }
        return scrollleft;
    }
    // 限制只能在container的content区域框选,返回点击的位置以container左上角为原点的水平和竖直方向的距离
    function getX(e) {
        var X;
        if (e.clientX <= container.offsetLeft + container.clientLeft - getScrollLeft()) {
            X = 0
        } else if (e.clientX >= container.offsetLeft + container.clientLeft + container.clientWidth - getScrollLeft()) {
            X = container.clientWidth
        } else {
            X = e.clientX - container.offsetLeft - container.clientLeft + getScrollLeft()
        }
        return X
    }
    function getY(e) {
        var Y
        if (e.clientY <= container.offsetTop + container.clientTop - getScrollTop()) {
            Y = 0
        } else if (e.clientY >= container.offsetTop + container.clientTop + container.clientHeight - getScrollTop()) {
            Y = container.clientHeight
        } else {
            Y = e.clientY - container.offsetTop - container.clientTop + getScrollTop()
        }
        return Y
    }
    // 点击清空所有勾选的按钮
    clearButton.addEventListener('click', function (e) {
        cancelBubbelAndDefault(e)
        var checkbox = document.querySelectorAll('.tickBox');
        for (var i = 0; i < checkbox.length; i++) {
            checkbox[i].checked = false
        }
    })
    // 鼠标按下:绘制一个框选mask,不显示
    function mouseDownEvent(e) {
        // 判断是否左键按下
        if (e.button !== 0) return
        cancelBubbelAndDefault(e)
        hasMouseDown = true
        startX = getX(e)
        startY = getY(e)
        var mask = document.createElement("div")
        mask.className = "showMask"
        mask.id = "mask"
        mask.style.left = startX + 'px';
        mask.style.top = startY + 'px';
        container.appendChild(mask)
    }
    // 鼠标移动:根据当前鼠标所在位置和初始鼠标按下的位置修改mask样式
    function mouseMoveEvent(e) {
        // 不断改变样式,引起回流重绘,设置定时器使其每50ms触发一次
        setTimeout(() => {  
            cancelBubbelAndDefault(e)
            // 如果没有触发按下鼠标事件,也不触发此事件
            if (!hasMouseDown) return;
            var mask = document.getElementById("mask");
            // 获取鼠标当前所在的位置,修改mask的宽高以及距离
            var endX = getX(e), endY = getY(e);
            mask.style.left = Math.min(startX, endX) + 'px';
            mask.style.top = Math.min(startY, endY) + 'px';
            mask.style.width = Math.abs(endX - startX) + 'px';
            mask.style.height = Math.abs(endY - startY) + 'px';
            // 显示mask
            mask.style.display = "block";
        }, 50);

    }
    // 鼠标弹起:隐藏mask,并判断在mask中的勾选框进行勾选
    function mouseUpEvent(e) {
        cancelBubbelAndDefault(e);
        if (!hasMouseDown) return;
        var mask = document.getElementById("mask");
        var checkbox = document.querySelectorAll('.tickBox');
        // 判断哪些勾选框在Mask中,遍历每个勾选框,判断其边界值
        for (var i = 0; i < checkbox.length; i++) {
            // 获取tickBox的和mask的上下左右边界值
            var tickLeft = checkbox[i].offsetLeft, tickRight = checkbox[i].offsetLeft + checkbox[i].offsetWidth
            var tickTop = checkbox[i].offsetTop, tickBottom = checkbox[i].offsetTop + checkbox[i].offsetHeight
            var maskLeft = mask.offsetLeft, maskRight = mask.offsetLeft + mask.offsetWidth
            var maskTop = mask.offsetTop, maskBottom = mask.offsetTop + mask.offsetHeight
            // 考虑小盒子不是全部在mask中的情况,只有局部在也要勾选
            if ((tickLeft < maskRight && tickRight > maskLeft) && (tickTop < maskBottom && tickBottom > maskTop)) {
                checkbox[i].checked = true
            }
        }
        mask.style.display = 'none';
        // 弹起之后,让值等于false,否则还会触发移动事件
        hasMouseDown = false;
    }
    document.body.addEventListener('mousedown', mouseDownEvent)
    document.body.addEventListener('mousemove', mouseMoveEvent)
    document.body.addEventListener('mouseup', mouseUpEvent)
</script>
</html>