10

The Javascript onmouseup event is not triggered if the mouse button is released outside the element on which onmousedown has been triggered.

This causes a drag&drop bug in JQuery UI: A JQuery draggable element does not stop dragging when mouse button is released outside of its container (because the element will stop moving when reaching it's parent boundaries). Steps to reproduce:

  • Go to http://jqueryui.com/draggable/.
  • Drag the draggable downward until the mouse has left the surrounding container
  • Release mouse button (no mouse button is pressed at this point)
  • Move mouse back into container
  • And the draggable is still being dragged. I would expect the dragging to have stopped as soon as I released the mouse button - no matter where it is released.

I see that behavior in latest Chrome and IE.

Is there any work-around?

I know that we could stop dragging the container on mouseout or mouseleave, but I would like to keep dragging, even if I am outside the parent container, much like in google maps (no matter, where you release the mouse, it always stops dragging the map).

Domi
  • 22,151
  • 15
  • 92
  • 122

4 Answers4

5

You can have your mousedown element "capture" the pointer. Then it would always receive the mouseup event. In React this could look like this:

const onPointerDownDiv1 = (event: React.PointerEvent) => {
  (event.target as HTMLDivElement).setPointerCapture(event.pointerId);
  // Do something ...
};

const onPointerUpDiv1 = (event: React.PointerEvent) => {
  (event.target as HTMLDivElement).releasePointerCapture(event.pointerId);
  // Do something ...
};
<div
  ref={div1}
  id="div1"
  className="absolute top-[200px] left-[390px] h-8 w-8 bg-red-300"
  onPointerDown={onPointerDownDiv1}
  onPointerUp={onPointerUpDiv1}
/>

And here is an implementation using "plain vanilla" html + javascript:

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <div
      id="div1"
      style="
        position: absolute;
        left: 50px;
        top: 50px;
        width: 20px;
        height: 20px;
        background-color: red;
      "
    ></div>
  </body>
  <script>
    let isDragging = false;
    let offsetX = 0;
    let offsetY = 0;
    let divElement = document.getElementById("div1");

    divElement.addEventListener("pointerdown", onPointerDown);
    divElement.addEventListener("pointermove", onPointerMove);
    divElement.addEventListener("pointerup", onPointerUp);

    function onPointerDown(event) {
      divElement.setPointerCapture(event.pointerId);
      offsetX = event.clientX - divElement.offsetLeft;
      offsetY = event.clientY - divElement.offsetTop;
      isDragging = true;
    }

    function onPointerMove(event) {
      if (isDragging) {
        divElement.style.left = (event.clientX - offsetX).toString() + "px";
        divElement.style.top = (event.clientY - offsetY).toString() + "px";
      }
    }

    function onPointerUp(event) {
      divElement.releasePointerCapture(event.pointerId);
      isDragging = false;
    }
  </script>
</html>
bassman21
  • 320
  • 2
  • 11
  • Looks interesting. Sadly, this Q is not about `react`. Also, your code does not address dragging of elements. Can you remove the `react` dependency and illustrate how this can be used to stop dragging an object? – Domi Mar 28 '22 at 08:11
  • 1
    I have added a "plain vanilla" html + javascript version of my solution in my previous answer. You should be able to copy and paste it into an html-file and run it directly in any browser of you choice. You should be able to drag html element "div1" around with propper start and stop of the dragging action. – bassman21 Mar 28 '22 at 09:48
  • 1
    JSFiddle possible? Or any runnable snippet? – Domi Mar 28 '22 at 09:50
  • 1
    Here is a JSFiddle demonstrating the solution: https://jsfiddle.net/bassman21/crk7p4jb. The runnable "snippet" is also in my previous answer. I will add another another answer to make it clearer. – bassman21 Mar 28 '22 at 10:00
  • Thanks! I updated my own answer to point to yours. I would recommend adding some linkage. Either way, good catch! The [setPointerCapture API](https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture) did not exist back then. [Chrome 55](https://developer.chrome.com/blog/new-in-chrome-55/) was one of the earliest to support it and that's from 2016, while this Q was from 2014. – Domi Apr 02 '22 at 07:12
  • Works great in React. – kiranvj Jul 09 '22 at 14:59
4

I found this to be the best solution: Attach the mouseup event handler to document instead. Then it will always cancel, even if you release the mouse button outside the browser. Of course, this is not a pretty solution, but apparently, this is the only way to get dragging to work correctly.

Try the solution below:

  • You will see that "Drag END" will always happen, no matter where you release the cursor.
  • Also, in order to prevent text selection while dragging, I added an unselectable class.

let dragging = false;
const dragEl = document.querySelector('div');
const logEl = document.querySelector('pre');

dragEl.addEventListener('mousedown touchstart', (evt) => {
  dragging = true;
  dragEl.classList.add('unselectable');
  logEl.textContent += 'drag START\n';
});
document.addEventListener('mouseup touchend', (evt) => {
  if (dragging) {
    event.preventDefault();
    dragEl.classList.remove('unselectable');
    dragging = false;
    logEl.textContent += 'drag END\n';
  }
});
div {
  background: red;
}

.unselectable {
  -webkit-user-select: none; /* Safari */        
  -moz-user-select: none; /* Firefox */
  -ms-user-select: none; /* IE10+/Edge */
  user-select: none; /* Standard */
}
<div>drag me</div>
<hr>
LOG:
<p><pre></pre></p>

Update

These days, the setPointerCapture API provides a cleaner solution, as explained in this answer.

Domi
  • 22,151
  • 15
  • 92
  • 122
  • 1
    Thanks, I was just looking for how to keep track of the cursor outside the document (or if there was a way) and saw this. I didn't notice that Google maps did it, even though I've noticed some other sites don't, and it's annoying when they don't. Anyway, thanks for posting your own answer, it helped me out! – Hamza Kubba Apr 22 '14 at 05:33
  • This does just move the problem up and you get the same issue if you release the mouse button outside of the document (or window). This can be overcome with the solution in my answer. – Mog0 Dec 03 '14 at 16:31
  • @Mog0 Not sure what you are basing your claims on? Have you even tried it? – Domi Sep 18 '21 at 06:14
  • I did try it. However, pay attention to the dates - my answer was nearly 7.5 years ago and the modern browsers are very different now. Note my solution below had issues with compatibility between IE8 and Firefox! – Mog0 Sep 18 '21 at 18:04
2

The best way to handle this is to check the current button status in the mousemove event handler. If the button is no longer down, stop dragging.

If you put the mouseup event on the document then you just move the problem up, the original problem will still show itself if you release the button outside of the document (or even outside of the browser window).

For example with jQuery

$div.on("mousedown", function (evt) {
        dragging = true;
  }).on("mouseup", function (evt) {
        dragging = false;
  }).on("mousemove", function (evt) {
        if (dragging && evt.button == 0) {
            //Catch case where button released outside of div
            dragging = false;
        }
        if (dragging) {
          //handle dragging here
        }
  });

You could skip the mouseup event handler because it will be caught by the mousemove but I think it's more readable with it still there.

Mog0
  • 1,689
  • 1
  • 16
  • 40
  • 1
    I like this solution better, except I found that the property (on Firefox at least) was mouse.buttons, not mouse.button. mouse.button was always 0 for me – Erin Drummond Feb 25 '15 at 03:29
  • 1
    @ErinDrummond Well spotted. evt.button == 0 means no button on IE <= 8 but left button on Firefox and newer IE. evt.buttons however is only available on IE9+ and Firefox and unsupported on Chrome, Safari and Opera. – Mog0 Feb 26 '15 at 12:02
  • This solution still only sets `dragging` to `false` when the mouse is on the element, not outside of it. Also, contrary to your claims, the `document` event handler is different from all other event handlers, because it is **NOT** restricted to the `document` or even the browser window, and will **ALWAYS** trigger immediately, no matter where you stop dragging. – Domi Sep 18 '21 at 05:55
  • @Domi - This is a very old discussion and down voting an answer after 6 years of browser development would seem to me to be very unfair. The answer was based on the situation at the time when using IE7/8 and equivalents so I would not be at all surprised if modern browsers behave differently as the specs have changed significantly in that time. – Mog0 Sep 18 '21 at 18:10
  • This part did not change. I downvoted because it never solved the problem stated in the question. – Domi Sep 19 '21 at 06:29
0

If you create an "index.html" file with the following code in it (you can just copy and paste it entirely) it will show the working solution using just plain vanilla html + javascript.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
  </head>
  <body>
    <div
      id="div1"
      style="
        position: absolute;
        left: 50px;
        top: 50px;
        width: 20px;
        height: 20px;
        background-color: red;
      "
    ></div>
  </body>
  <script>
    let isDragging = false;
    let offsetX = 0;
    let offsetY = 0;
    let divElement = document.getElementById("div1");

    divElement.addEventListener("pointerdown", onPointerDown);
    divElement.addEventListener("pointermove", onPointerMove);
    divElement.addEventListener("pointerup", onPointerUp);

    function onPointerDown(event) {
      divElement.setPointerCapture(event.pointerId);
      offsetX = event.clientX - divElement.offsetLeft;
      offsetY = event.clientY - divElement.offsetTop;
      isDragging = true;
    }

    function onPointerMove(event) {
      if (isDragging) {
        let newPosLeft = event.clientX - offsetX;
        if (newPosLeft < 30) {
          newPosLeft = 30;
        }
        let newPosTop = event.clientY - offsetY;
        if (newPosTop < 30) {
          newPosTop = 30;
        }
        divElement.style.left = newPosLeft.toString() + "px";
        divElement.style.top = newPosTop.toString() + "px";
      }
    }

    function onPointerUp(event) {
      divElement.releasePointerCapture(event.pointerId);
      isDragging = false;
    }
  </script>
</html>
bassman21
  • 320
  • 2
  • 11
  • I have added on purpose the limitations regarding the absolute position of the dragged element. That allows you to see that the mouseup event is captured even if the mouse pointer is not over it. – bassman21 Mar 28 '22 at 10:14