拖放(Drag’n’Drop)是一个很赞的界面解决方案。取某件东西并将其拖放是执行许多东西的一种简单明了的方式,从复制和移动文档(如在文件管理器中)到订购(将物品放入购物车)。

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

这些事件很有用,因为它们使我们能够轻松地解决简单的任务。例如,使我们能够处理将“外部”文件拖放到浏览器中的操作,因此我们可以在 OS 文件管理器中获取文件,并将其拖放到浏览器窗口中,从而使 JavaScript 可以访问其内容。

但是,原生的拖放事件也有其局限性。例如,我们无法将拖放限制在某个区域内。并且,我们无法将拖放变成“水平”或“垂直”拖放。还有其他一些使用该 API 无法完成的拖放任务。此外,移动设备基本都不支持此事件。

因此,在这里我们将看到,如何使用鼠标事件来实现拖放。

拖放算法

基础的拖放算法如下所示:

  1. mousedown 上 —— 根据需要准备要移动的元素(也许创建它的一个副本)。
  2. 然后在 mousemove 上,通过更改 left/topposition:absolute 来移动它。
  3. mouseup 上 —— 执行与完成的拖放相关的所有行为。

这些是基础。稍后我们可以扩展它,例如,当鼠标悬停在元素上方时,高亮显示 “droppable”(可用于放置到)的元素。

下面是拖放一个球的算法:

  1. ball.onmousedown = function(event) { // (1) 启动处理
  2. // (2) 准备移动:确保 absolute,并通过设置 z-index 以确保球在顶部
  3. ball.style.position = 'absolute';
  4. ball.style.zIndex = 1000;
  5. // 将其从当前父元素中直接移动到 body 中
  6. // 以使其定位是相对于 body 的
  7. document.body.append(ball);
  8. // ...并将绝对定位的球放在鼠标指针下方
  9. moveAt(event.pageX, event.pageY);
  10. // 现在球的中心在 (pageX, pageY) 坐标上
  11. function moveAt(pageX, pageY) {
  12. ball.style.left = pageX - ball.offsetWidth / 2 + 'px';
  13. ball.style.top = pageY - ball.offsetHeight / 2 + 'px';
  14. }
  15. function onMouseMove(event) {
  16. moveAt(event.pageX, event.pageY);
  17. }
  18. // (3) 在 mousemove 事件上移动球
  19. document.addEventListener('mousemove', onMouseMove);
  20. // (4) 放下球,并移除不需要的处理程序
  21. ball.onmouseup = function() {
  22. document.removeEventListener('mousemove', onMouseMove);
  23. ball.onmouseup = null;
  24. };
  25. };

如果我们运行这段代码,我们会发现一些奇怪的事情。在拖放的一开始,球“分叉”了:我们开始拖动它的“克隆”。

这是一个正在运行中的示例:

尝试拖放鼠标,你会看到这种奇怪的行为。

这是因为浏览器有自己的对图片和一些其他元素的拖放处理,会在我们拖放时自动运行,这与我们的拖放处理产生了冲突。

禁用它:

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

现在一切都正常了。

这是一个正在运行中的示例:

另一个重要的方面是 —— 我们在 document 上跟踪 mousemove,而不是在 ball 上。乍一看,鼠标似乎总是在球的上方,我们可以将 mousemove 放在球上。

但正如我们所记得的那样,mousemove 会经常被触发,但不会针对每个像素都如此。因此,在快速移动鼠标后,鼠标指针可能会从球上跳转至文档中间的某个位置(甚至跳转至窗口外)。

因此,我们应该监听 document 以捕获它。

修正定位

在上述示例中,球在移动时,球的中心始终位于鼠标指针下方:

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

不错,但这存在副作用。要启动拖放,我们可以在球上的任意位置 mousedown。但是,如果从球的边缘“抓住”球,那么球会突然“跳转”以使球的中心位于鼠标指针下方。

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

例如,我们按住球的边缘处开始拖动,那么在拖动时,鼠标指针应该保持在一开始所按住的边缘位置上。

鼠标拖放事件 - 图1

让我们更新一下我们的算法:

  1. 当访问者按下按钮(mousedown)时 —— 我们可以在变量 shiftX/shiftY 中记住鼠标指针到球左上角的距离。我们应该在拖动时保持这个距离。

    我们可以通过坐标相减来获取这个偏移:

    1. // onmousedown
    2. let shiftX = event.clientX - ball.getBoundingClientRect().left;
    3. let shiftY = event.clientY - ball.getBoundingClientRect().top;
  2. 然后,在拖动球时,我们将鼠标指针相对于球的这个偏移也考虑在内,像这样:

    1. // onmousemove
    2. // 球具有 position:absoute
    3. ball.style.left = event.pageX - shiftX + 'px';
    4. ball.style.top = event.pageY - shiftY + 'px';

能够更好地进行定位的最终代码:

  1. ball.onmousedown = function(event) {
  2. let shiftX = event.clientX - ball.getBoundingClientRect().left;
  3. let shiftY = event.clientY - ball.getBoundingClientRect().top;
  4. ball.style.position = 'absolute';
  5. ball.style.zIndex = 1000;
  6. document.body.append(ball);
  7. moveAt(event.pageX, event.pageY);
  8. // 移动现在位于坐标 (pageX, pageY) 上的球
  9. // 将初始的偏移考虑在内
  10. function moveAt(pageX, pageY) {
  11. ball.style.left = pageX - shiftX + 'px';
  12. ball.style.top = pageY - shiftY + 'px';
  13. }
  14. function onMouseMove(event) {
  15. moveAt(event.pageX, event.pageY);
  16. }
  17. // 在 mousemove 事件上移动球
  18. document.addEventListener('mousemove', onMouseMove);
  19. // 放下球,并移除不需要的处理程序
  20. ball.onmouseup = function() {
  21. document.removeEventListener('mousemove', onMouseMove);
  22. ball.onmouseup = null;
  23. };
  24. };
  25. ball.ondragstart = function() {
  26. return false;
  27. };

In action (inside <iframe>):

如果我们按住球的右下角来进行拖动,这种差异会尤其明显。在前面的示例中,球会在鼠标指针下“跳转”一下。现在,更新后的代码可以让我们从当前位置流畅地跟随鼠标。

潜在的放置目标

在前面的示例中,球可以被放置(drop)到“任何地方”。在实际中,我们通常是将一个元素放到另一个元素上。例如,将一个“文件”放置到一个“文件夹”或者其他地方。

抽象地讲,我们取一个 “draggable” 的元素,并将其放在 “droppable” 的元素上。

我们需要知道:

  • 在拖放结束时,所拖动的元素要放在哪里 —— 执行相应的行为
  • 并且,最好知道我们所拖动到的 “droppable” 的元素的位置,并高亮显示 “droppable” 的元素。

这个解决方案很有意思,只是有点麻烦,所以我们在这儿对此进行介绍。

第一个想法是什么?可能是将 onmouseover/mouseup 处理程序放在潜在的 “droppable” 的元素中?

但这行不通。

问题在于,当我们拖动时,可拖动元素一直是位于其他元素上的。而鼠标事件只发生在顶部元素上,而不是发生在那些下面的元素。

例如,下面有两个 <div> 元素,红色的在蓝色的上面(完全覆盖)。这里,在蓝色的 <div> 中没有办法来捕获事件,因为红色的 <div> 在它上面:

  1. <style>
  2. div {
  3. width: 50px;
  4. height: 50px;
  5. position: absolute;
  6. top: 0;
  7. }
  8. </style>
  9. <div style="background:blue" onmouseover="alert('never works')"></div>
  10. <div style="background:red" onmouseover="alert('over red!')"></div>

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

这就是一开始的那个想法,将处理程序放在潜在的 “droppable” 的元素,在实际操作中不起作用的原因。它们不会运行。

那么,该怎么办?

有一个叫做 document.elementFromPoint(clientX, clientY) 的方法。它会返回在给定的窗口相对坐标处的嵌套的最深的元素(如果给定的坐标在窗口外,则返回 null)。

我们可以在我们的任何鼠标事件处理程序中使用它,以检测鼠标指针下的潜在的 “droppable” 的元素,就像这样:

  1. // 在一个鼠标事件处理程序中
  2. ball.hidden = true; // (*) 隐藏我们拖动的元素
  3. let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
  4. // elemBelow 是球下方的元素,可能是 droppable 的元素
  5. ball.hidden = false;

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

我们可以使用该代码来检查我们正在“飞过”的元素是什么。并在放置(drop)时,对放置进行处理。

基于 onMouseMove 扩展的代码,用于查找 “droppable” 的元素:

  1. // 我们当前正在飞过的潜在的 droppable 的元素
  2. let currentDroppable = null;
  3. function onMouseMove(event) {
  4. moveAt(event.pageX, event.pageY);
  5. ball.hidden = true;
  6. let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
  7. ball.hidden = false;
  8. // mousemove 事件可能会在窗口外被触发(当球被拖出屏幕时)
  9. // 如果 clientX/clientY 在窗口外,那么 elementfromPoint 会返回 null
  10. if (!elemBelow) return;
  11. // 潜在的 droppable 的元素被使用 "droppable" 类进行标记(也可以是其他逻辑)
  12. let droppableBelow = elemBelow.closest('.droppable');
  13. if (currentDroppable != droppableBelow) {
  14. // 我们正在飞入或飞出...
  15. // 注意:它们两个的值都可能为 null
  16. // currentDroppable=null —— 如果我们在此事件之前,鼠标指针不是在一个 droppable 的元素上(例如空白处)
  17. // droppableBelow=null —— 如果现在,在当前事件中,我们的鼠标指针不是在一个 droppable 的元素上
  18. if (currentDroppable) {
  19. // 处理“飞出” droppable 的元素时的处理逻辑(移除高亮)
  20. leaveDroppable(currentDroppable);
  21. }
  22. currentDroppable = droppableBelow;
  23. if (currentDroppable) {
  24. // 处理“飞入” droppable 的元素时的逻辑
  25. enterDroppable(currentDroppable);
  26. }
  27. }
  28. }

在下面这个示例中,当球被拖到球门上时,球门会被高亮显示。

结果

style.css

index.html

  1. #gate {
  2. cursor: pointer;
  3. margin-bottom: 100px;
  4. width: 83px;
  5. height: 46px;
  6. }
  7. #ball {
  8. cursor: pointer;
  9. width: 40px;
  10. height: 40px;
  11. }
  1. <!doctype html>
  2. <html>
  3. <head>
  4. <meta charset="UTF-8">
  5. <link rel="stylesheet" href="style.css">
  6. </head>
  7. <body>
  8. <p>Drag the ball.</p>
  9. <img src="https://en.js.cx/clipart/soccer-gate.svg" id="gate" class="droppable">
  10. <img src="https://en.js.cx/clipart/ball.svg" id="ball">
  11. <script>
  12. let currentDroppable = null;
  13. ball.onmousedown = function(event) {
  14. let shiftX = event.clientX - ball.getBoundingClientRect().left;
  15. let shiftY = event.clientY - ball.getBoundingClientRect().top;
  16. ball.style.position = 'absolute';
  17. ball.style.zIndex = 1000;
  18. document.body.append(ball);
  19. moveAt(event.pageX, event.pageY);
  20. function moveAt(pageX, pageY) {
  21. ball.style.left = pageX - shiftX + 'px';
  22. ball.style.top = pageY - shiftY + 'px';
  23. }
  24. function onMouseMove(event) {
  25. moveAt(event.pageX, event.pageY);
  26. ball.hidden = true;
  27. let elemBelow = document.elementFromPoint(event.clientX, event.clientY);
  28. ball.hidden = false;
  29. if (!elemBelow) return;
  30. let droppableBelow = elemBelow.closest('.droppable');
  31. if (currentDroppable != droppableBelow) {
  32. if (currentDroppable) { // null when we were not over a droppable before this event
  33. leaveDroppable(currentDroppable);
  34. }
  35. currentDroppable = droppableBelow;
  36. if (currentDroppable) { // null if we're not coming over a droppable now
  37. // (maybe just left the droppable)
  38. enterDroppable(currentDroppable);
  39. }
  40. }
  41. }
  42. document.addEventListener('mousemove', onMouseMove);
  43. ball.onmouseup = function() {
  44. document.removeEventListener('mousemove', onMouseMove);
  45. ball.onmouseup = null;
  46. };
  47. };
  48. function enterDroppable(elem) {
  49. elem.style.background = 'pink';
  50. }
  51. function leaveDroppable(elem) {
  52. elem.style.background = '';
  53. }
  54. ball.ondragstart = function() {
  55. return false;
  56. };
  57. </script>
  58. </body>
  59. </html>

现在,我们在整个处理过程中,在当前变量 currentDroppable 中都存储了当前的“放置目标”,可以用它来进行高亮显示或者其他操作。

总结

我们考虑了一种基础的拖放算法。

关键部分:

  1. 事件流:ball.mousedowndocument.mousemoveball.mouseup(不要忘记取消原生 ondragstart)。
  2. 在拖动开始时 —— 记住鼠标指针相对于元素的初始偏移(shift):shiftX/shiftY,并在拖动过程中保持它不变。
  3. 使用 document.elementFromPoint 检测鼠标指针下的 “droppable” 的元素。

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

  • mouseup 上,我们可以智能地完成放置(drop):更改数据,移动元素。
  • 我们可以高亮我们正在“飞过”的元素。
  • 我们可以将拖动限制在特定的区域或者方向。
  • 我们可以对 mousedown/up 使用事件委托。一个大范围的用于检查 event.target 的事件处理程序可以管理数百个元素的拖放。
  • 等。

有一些在此基础上已经将体系结构构建好的框架:DragZoneDroppableDraggable 及其他 class。它们中的大多数做的都是与上述类似的事情,所以现在你应该很容易理解它们了。或者自己动手实现。正如你所看到的,其实挺简单的,有时候比基于第三方解决方案进行改写还容易。

任务

滑动条

重要程度: 5

创建一个滑动条(slider):

用鼠标拖动蓝色的滑块(thumb)并移动它。

重要的细节:

  • 当鼠标按钮被按下时,在滑动过程中,鼠标指针可能会移动到滑块的上方或下方。此时滑块仍会继续移动(方便用户)。
  • 如果鼠标非常快地向左边或者向右边移动,那么滑块应该恰好停在边缘。

打开一个任务沙箱。

解决方案

正如我们从 HTML/CSS 中所看到的,滑动条就是一个带有彩色北京的 <div>,其中包含一个滑块 —— 另一个具有 position:relative<div>

为了对滑块进行定位,我们使用 position:relative 来提供相对于其父元素的坐标,在这儿它比 position:absolute 更方便。

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

使用沙箱打开解决方案。

将超级英雄放置在足球场周围

重要程度: 5

这个任务可以帮助你检查你对拖放和 DOM 的一些方面的理解程度。

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

要求:

  • 使用事件委托来跟踪拖动的开始:一个在 document 上的用于 mousedown 的处理程序。
  • 如果元素被拖动到了窗口的顶端/末端 —— 页面会向上/向下滚动以允许进一步的拖动。
  • 没有水平滚动(这使本任务更简单,但添加水平滚动也很简单)。
  • 即使在快速移动鼠标后,可拖动元素或该元素的部分也绝不应该离开窗口。

这个示例太大了,不适合放在这里,所以在下面给出了示例链接。

在新窗口中演示

打开一个任务沙箱。

解决方案

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

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

更多细节请见代码注释。

使用沙箱打开解决方案。