2022 年 7 月 27 日

使用鼠标事件进行拖放

拖放是一种出色的界面解决方案。拿起某物并拖放它是一种清晰而简单的方法,可以完成许多事情,从复制和移动文档(如在文件管理器中)到排序(将物品放入购物车)。

在现代 HTML 标准中,有一个关于拖放的部分,其中包含特殊事件,例如 dragstartdragend 等。

这些事件允许我们支持特殊类型的拖放,例如处理从操作系统文件管理器拖动文件并将其放入浏览器窗口。然后 JavaScript 可以访问此类文件的内容。

但本机拖放事件也有局限性。例如,我们不能阻止从特定区域拖动。此外,我们不能只让拖动“水平”或“垂直”。还有许多其他使用它们无法完成的拖放任务。此外,移动设备对此类事件的支持非常弱。

因此,我们将在此处了解如何使用鼠标事件实现拖放。

拖放算法

基本的拖放算法如下所示

  1. mousedown 上 - 准备元素以进行移动(如果需要)(可能创建其克隆、向其添加类或其他操作)。
  2. 然后在 mousemove 上通过使用 position:absolute 更改 left/top 来移动它。
  3. mouseup 上 - 执行与完成拖放相关的所有操作。

这些是基础知识。稍后,我们将了解如何添加其他功能,例如在拖动元素时突出显示当前底层元素。

以下是拖动球的实现

ball.onmousedown = function(event) {
  // (1) prepare to moving: make absolute and on top by z-index
  ball.style.position = 'absolute';
  ball.style.zIndex = 1000;

  // move it out of any current parents directly into body
  // to make it positioned relative to the body
  document.body.append(ball);

  // centers the ball at (pageX, pageY) coordinates
  function moveAt(pageX, pageY) {
    ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
    ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
  }

  // move our absolutely positioned ball under the pointer
  moveAt(event.pageX, event.pageY);

  function onMouseMove(event) {
    moveAt(event.pageX, event.pageY);
  }

  // (2) move the ball on mousemove
  document.addEventListener('mousemove', onMouseMove);

  // (3) drop the ball, remove unneeded handlers
  ball.onmouseup = function() {
    document.removeEventListener('mousemove', onMouseMove);
    ball.onmouseup = null;
  };

};

如果我们运行代码,我们可能会注意到一些奇怪的事情。在拖放开始时,球“分叉”:我们开始拖动其“克隆”。

以下是一个实际示例

尝试使用鼠标拖放,您将看到这种行为。

这是因为浏览器对图像和某些其他元素具有自己的拖放支持。它会自动运行并与我们的支持冲突。

要禁用它

ball.ondragstart = function() {
  return false;
};

现在一切都将正常。

实际操作

另一个重要方面 - 我们在 document 上跟踪 mousemove,而不是在 ball 上。从第一眼看,鼠标似乎始终位于球上,我们可以在其上放置 mousemove

但正如我们所记得的,mousemove 经常触发,但不是针对每个像素。因此,在快速移动后,指针可以从球跳到文档的中间位置(甚至在窗口外部)。

因此,我们应该在 document 上进行侦听以捕获它。

正确的定位

在上面的示例中,球始终移动,以便其中心位于指针下方

ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
ball.style.top = pageY - ball.offsetHeight / 2 + 'px';

还不错,但有一个副作用。要启动拖放,我们可以在球上的任何位置 mousedown。但如果从其边缘“拿”它,那么球会突然“跳动”以居中在鼠标指针下方。

如果我们保持元素相对于指针的初始偏移,那就更好了。

例如,如果我们从球的边缘开始拖动,那么指针在拖动时应保持在边缘上方。

让我们更新我们的算法

  1. 当访问者按下按钮(mousedown)时 - 记下指针到球的左上角的距离,记在变量 shiftX/shiftY 中。我们在拖动时将保持该距离。

    要获得这些偏移,我们可以减去坐标

    // onmousedown
    let shiftX = event.clientX - ball.getBoundingClientRect().left;
    let shiftY = event.clientY - ball.getBoundingClientRect().top;
  2. 然后,在拖动时,我们将球定位在相对于指针的相同偏移上,如下所示

    // onmousemove
    // ball has position:absolute
    ball.style.left = event.pageX - shiftX + 'px';
    ball.style.top = event.pageY - shiftY + 'px';

具有更好定位的最终代码

ball.onmousedown = function(event) {

  let shiftX = event.clientX - ball.getBoundingClientRect().left;
  let shiftY = event.clientY - ball.getBoundingClientRect().top;

  ball.style.position = 'absolute';
  ball.style.zIndex = 1000;
  document.body.append(ball);

  moveAt(event.pageX, event.pageY);

  // moves the ball at (pageX, pageY) coordinates
  // taking initial shifts into account
  function moveAt(pageX, pageY) {
    ball.style.left = pageX - shiftX + 'px';
    ball.style.top = pageY - shiftY + 'px';
  }

  function onMouseMove(event) {
    moveAt(event.pageX, event.pageY);
  }

  // move the ball on mousemove
  document.addEventListener('mousemove', onMouseMove);

  // drop the ball, remove unneeded handlers
  ball.onmouseup = function() {
    document.removeEventListener('mousemove', onMouseMove);
    ball.onmouseup = null;
  };

};

ball.ondragstart = function() {
  return false;
};

在动作中(在 <iframe> 内)

如果我们通过球的右下角拖动球,这种差异会特别明显。在之前的示例中,球在指针下方“跳动”。现在,它会从当前位置流畅地跟随指针。

潜在的放置目标(可放置目标)

在之前的示例中,球可以放置在“任何地方”以保持静止。在实际生活中,我们通常会取一个元素并将其放置在另一个元素上。例如,将“文件”放置在“文件夹”或其他位置。

抽象地说,我们取一个“可拖动”元素并将其放置在“可放置”元素上。

我们需要知道

  • 元素在拖放结束时放置在何处 - 以执行相应的操作,
  • 并且最好知道我们正在拖动的可放置目标,以突出显示它。

解决方案有点意思,也有一点棘手,所以我们在这里介绍一下。

第一个想法是什么?可能是对潜在的可放置目标设置 mouseover/mouseup 处理程序?

但这不起作用。

问题在于,在拖动时,可拖动元素始终位于其他元素之上。并且鼠标事件只发生在最顶层的元素上,而不是在其下方的元素上。

例如,下面有两个 <div> 元素,红色的元素位于蓝色的元素之上(完全覆盖)。无法在蓝色的元素上捕获事件,因为红色的元素位于其上方

<style>
  div {
    width: 50px;
    height: 50px;
    position: absolute;
    top: 0;
  }
</style>
<div style="background:blue" onmouseover="alert('never works')"></div>
<div style="background:red" onmouseover="alert('over red!')"></div>

可拖动元素也是如此。球始终位于其他元素之上,因此事件发生在球上。无论我们在较低元素上设置什么处理程序,它们都不会起作用。

这就是为什么在潜在的可放置目标上放置处理程序的最初想法在实践中不起作用。它们不会运行。

那么,该怎么办?

有一个名为 document.elementFromPoint(clientX, clientY) 的方法。它返回给定窗口相对坐标上最嵌套的元素(如果给定的坐标超出窗口,则返回 null)。如果在同一坐标上有多个重叠元素,则返回最顶层的元素。

我们可以在任何鼠标事件处理程序中使用它来检测指针下方的潜在可放置目标,如下所示

// in a mouse event handler
ball.hidden = true; // (*) hide the element that we drag

let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
// elemBelow is the element below the ball, may be droppable

ball.hidden = false;

请注意:我们需要在调用 (*) 之前隐藏球。否则,我们通常会在这些坐标上有一个球,因为它是指针下的最顶层元素:elemBelow=ball。因此,我们隐藏它并立即再次显示它。

我们可以使用该代码随时检查我们“飞过”的元素。并在发生拖放时处理拖放。

扩展的 onMouseMove 代码以查找“可拖放”元素

// potential droppable that we're flying over right now
let currentDroppable = null;

function onMouseMove(event) {
  moveAt(event.pageX, event.pageY);

  ball.hidden = true;
  let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
  ball.hidden = false;

  // mousemove events may trigger out of the window (when the ball is dragged off-screen)
  // if clientX/clientY are out of the window, then elementFromPoint returns null
  if (!elemBelow) return;

  // potential droppables are labeled with the class "droppable" (can be other logic)
  let droppableBelow = elemBelow.closest('.droppable');

  if (currentDroppable != droppableBelow) {
    // we're flying in or out...
    // note: both values can be null
    //   currentDroppable=null if we were not over a droppable before this event (e.g over an empty space)
    //   droppableBelow=null if we're not over a droppable now, during this event

    if (currentDroppable) {
      // the logic to process "flying out" of the droppable (remove highlight)
      leaveDroppable(currentDroppable);
    }
    currentDroppable = droppableBelow;
    if (currentDroppable) {
      // the logic to process "flying in" of the droppable
      enterDroppable(currentDroppable);
    }
  }
}

在下面的示例中,当球被拖到足球球门上方时,球门将高亮显示。

结果
style.css
index.html
#gate {
  cursor: pointer;
  margin-bottom: 100px;
  width: 83px;
  height: 46px;
}

#ball {
  cursor: pointer;
  width: 40px;
  height: 40px;
}
<!doctype html>
<html>

<head>
  <meta charset="UTF-8">
  <link rel="stylesheet" href="style.css">
</head>

<body>

  <p>Drag the ball.</p>

  <img src="https://en.js.cx/clipart/soccer-gate.svg" id="gate" class="droppable">

  <img src="https://en.js.cx/clipart/ball.svg" id="ball">

  <script>
    let currentDroppable = null;

    ball.onmousedown = function(event) {

      let shiftX = event.clientX - ball.getBoundingClientRect().left;
      let shiftY = event.clientY - ball.getBoundingClientRect().top;

      ball.style.position = 'absolute';
      ball.style.zIndex = 1000;
      document.body.append(ball);

      moveAt(event.pageX, event.pageY);

      function moveAt(pageX, pageY) {
        ball.style.left = pageX - shiftX + 'px';
        ball.style.top = pageY - shiftY + 'px';
      }

      function onMouseMove(event) {
        moveAt(event.pageX, event.pageY);

        ball.hidden = true;
        let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
        ball.hidden = false;

        if (!elemBelow) return;

        let droppableBelow = elemBelow.closest('.droppable');
        if (currentDroppable != droppableBelow) {
          if (currentDroppable) { // null when we were not over a droppable before this event
            leaveDroppable(currentDroppable);
          }
          currentDroppable = droppableBelow;
          if (currentDroppable) { // null if we're not coming over a droppable now
            // (maybe just left the droppable)
            enterDroppable(currentDroppable);
          }
        }
      }

      document.addEventListener('mousemove', onMouseMove);

      ball.onmouseup = function() {
        document.removeEventListener('mousemove', onMouseMove);
        ball.onmouseup = null;
      };

    };

    function enterDroppable(elem) {
      elem.style.background = 'pink';
    }

    function leaveDroppable(elem) {
      elem.style.background = '';
    }

    ball.ondragstart = function() {
      return false;
    };
  </script>


</body>
</html>

现在,我们可以在整个过程中将当前“拖放目标”(我们飞过的目标)保存在变量 currentDroppable 中,并可以使用它来高亮显示或执行任何其他操作。

摘要

我们考虑了一个基本的拖放算法。

关键组件

  1. 事件流:ball.mousedowndocument.mousemoveball.mouseup(别忘了取消本机 ondragstart)。
  2. 在拖动开始时,记住指针相对于元素的初始偏移:shiftX/shiftY,并在拖动过程中保持该偏移。
  3. 使用 document.elementFromPoint 检测指针下的可拖放元素。

我们可以在此基础上做很多事情。

  • mouseup 时,我们可以从概念上完成拖放:更改数据,移动元素。
  • 我们可以高亮显示我们飞过的元素。
  • 我们可以通过某个区域或方向限制拖动。
  • 我们可以对 mousedown/up 使用事件委托。检查 event.target 的大面积事件处理程序可以管理数百个元素的拖放。
  • 等等。

有一些框架在此基础上构建了架构:DragZoneDroppableDraggable 和其他类。它们中的大多数都执行与上面描述类似的操作,因此现在应该很容易理解它们。或者自己动手,正如你所看到的,这很容易做到,有时比改编第三方解决方案更容易。

任务

重要性:5

创建滑块

用鼠标拖动蓝色滑块并移动它。

重要细节

  • 当按下鼠标按钮时,在拖动过程中,鼠标可能会移到滑块上方或下方。滑块仍然可以工作(方便用户)。
  • 如果鼠标向左或向右移动得非常快,则滑块应正好停在边缘处。

打开任务沙盒。

从 HTML/CSS 中可以看到,滑块是一个带有彩色背景的 <div>,其中包含一个滑块——另一个具有 position:relative<div>

为了定位运行器,我们使用 position:relative,以提供相对于其父元素的坐标,此处比 position:absolute 更方便。

然后,我们通过宽度限制实现仅水平方向的拖放。

在沙盒中打开解决方案。

重要性:5

此任务可以帮助你检查对拖放和 DOM 的几个方面的理解。

使所有具有 draggable 类的元素可拖动。就像本章中的球一样。

要求

  • 使用事件委托来跟踪拖动开始:对 mousedowndocument 上使用单个事件处理程序。
  • 如果元素被拖动到窗口顶部/底部边缘,页面将向上/向下滚动以允许进一步拖动。
  • 没有水平滚动(这使任务变得简单一些,添加它很容易)。
  • 可拖动元素或其部分永远不会离开窗口,即使在快速移动鼠标之后也是如此。

演示太大,无法在此处显示,因此这里有链接。

在新窗口中演示

打开任务沙盒。

为了拖动元素,我们可以使用 position:fixed,它使坐标更容易管理。最后,我们应该将其切换回 position:absolute 以将元素放置到文档中。

当坐标位于窗口顶部/底部时,我们使用 window.scrollTo 来滚动它。

有关详细信息,请参阅代码中的注释。

在沙盒中打开解决方案。

教程地图

评论

在评论之前阅读此内容…
  • 如果你有改进建议,请 提交 GitHub 问题 或提交拉取请求,而不是评论。
  • 如果你无法理解文章中的某些内容,请详细说明。
  • 要插入几行代码,请使用 <code> 标签,对于多行,请将其包装在 <pre> 标签中,对于 10 行以上,请使用沙盒 (plnkrjsbincodepen…)