How to decide whether a drag&drop operation points to somewhere outside the browser window?
Asked Answered
O

3

9

I'd like to handle dragend events differently depending on whether an element has been just dragged inside the browser window (or site resp.) or or outside, e.g. to an external file manager.

After I didn't find any attribute of the DragEvent instance indicating whether it's inside or outside the sites context I started to arithmetically figure out if the corresponding mouse event still takes place inside the geometry of the site.

Eventually I might succeed with that approach (currently not working yet) but it has one major disadvantage (leaving alone its ugliness): the drop target window might be on top of the browser, so the geometry is no real indicator at all..

so.. how do I find out if a dragend (or any other event I could use to store some state) is pointing outside of the browser window (or source site)?

Ottar answered 30/4, 2022 at 7:57 Comment(0)
S
4

I couldn't find any super straightforward ways to do this, but I could do it fairly concisely with a handful of listeners. If you're ok with having a variable to assist with the state then you can do something like this.

First, listen for the mouse leaving on a drag event. I've found the most reliable way to do this is to use a dragleave listener and then examine the event data to make sure it's really leaving the window. This event runs a ton though, so we need to filter out the ones we need.

dragleave runs every time any element's drop zone is left. To make sure that the drag event is just leaving the page, we can check the target to make sure leaving the html or body tag and going to null. This explains how to see the correct targets for the event.

Right before the dragend event, dragleave is ran as though it left the window. This is problematic because it makes every drop seem as though it were out of the window. It seems that this behavior isn't well defined in the specs and there is some variation between how Firefox and Chrome handle this.

For Chrome, we can make the code in dragleave run a cycle after the code in dragend by wrapping it in a timeout with 0 seconds.

This doesn't work well in Firefox though because the dragend event doesn't come as fast. Firefox does, however, set buttons to 0 so we know it's the end of an event.

Here is what the dragleave event listener might look like

window.addEventListener('dragleave', (e) => {
    window.setTimeout(() => {
        if ((e.target === document.documentElement || e.target === document.body) && e.relatedTarget == null && e.buttons != 0) {
            outside = true;
        }
    });
});

From here we just need to do something similar to see when the drag re-enters the viewport. This won't run before dragend like dragleave so it is simpler.

window.addEventListener('dragenter', (e) => {
    if ((e.target === document.documentElement || e.target === document.body) && e.relatedTarget == null) {
        outside = false;
    }
});

It's also a good idea to reset outside every time the drag event starts.

element.addEventListener('dragstart', (e) => {
    outside = false;
});

Now it's possible in the dragend event listener to see where the drop ended.

element.addEventListener('dragend', (e) => {
    console.log('Ended ' + (outside ? 'Outside' : 'Inside'));
});

Here is a snippet with everything put together (or fiddle)

Note #1: You'll need to drag the element out of the browser window, not just the demo window for it to appear as "outside".

Note #2: There has to be a better way for stopping the last dragleave event, but after a few hours of trying other things, this seemed the most consistent and reliable.

const element = document.querySelector('div');
var outside = false;

element.addEventListener('dragend', (e) => {
    console.log('Ended ' + (outside ? 'Outside' : 'Inside'));
});

element.addEventListener('dragstart', (e) => {
    outside = false;
});


window.addEventListener('dragleave', (e) => {
    window.setTimeout(() => {
        if ((e.target === document.documentElement || e.target === document.body) && e.relatedTarget == null && e.buttons != 0) {
            outside = true;
        }
    });
});

window.addEventListener('dragenter', (e) => {
    if ((e.target === document.documentElement || e.target === document.body) && e.relatedTarget == null) {
        outside = false;
    }
});
div[draggable] {
    width: fit-content;
    margin-bottom: 32px;
    padding: 16px 32px;
    background-color: black;
    color: white;
}
<div draggable="true">Drag Me</div>
Symmetrical answered 6/5, 2022 at 3:5 Comment(0)
A
2

This might help. You can click 'Run code snippet' to see how it works

Note: Increasing the offset would help detect the drag out sooner, but might affect precision (whether is has actually been dragged out or right on the edge)

/* events fired on the draggable target */
let offset = 2; // in case required
let width = window.innerWidth;
let height = window.innerHeight;
console.log('Starting screen width: ' + width);
console.log('Starting screen height: ' + height);
document.addEventListener("drag", function(event) {
  let posX = event.pageX;
  let posY = event.pageY;
  console.log('X:' + posX + ' Y:' + posY)
  let isExceedingWidth = posX >= (width - offset) || posX <= (0 + offset);
  let isExceedingHeight = posY >= (height - offset) || posY <= (0 + offset);
  if (isExceedingWidth || isExceedingHeight) {
    console.log('dragged out');
  } else {
    console.log('in');
  }
}, false);
#draggable {
  width: fit-content;
  padding: 1px;
  height: fit-content;
  text-align: center;
  background: black;
  color: white;
}
<div id="draggable" draggable="true">
  Drag Me
</div>
Acalia answered 2/5, 2022 at 8:30 Comment(7)
in principle this works but unfortunately those events won't get handled during an ongoing drag&drop operation. It feels like someone did hard work to avoid this being possible..Ottar
@Ottar I've updated the answer slightly. Maybe that helps?Acalia
Unfortunately not - while dragging an element mouseenter and mouseleave won't be called, at least for me :/Ottar
Even trying to move the element with the pointer while dragging to be able to catch the dragenter/dragleave events fails, since dragleave will be called before dragend. So it looks like I have to start a timer on dragleave which sets a flag. and I'll have to abort this timer in case I come back to the site.. omgOttar
@Ottar thank you for letting me know about the mouse events not working during a drag event. I have updated with another snippet that might help. Its still a bit wonky, but might just help avoid the timer approachAcalia
Checking the bounding boxes won't work neither because in case the window, you're dropping at overlaps with the browser you won't know if you're inside or not - it's like a curse..Ottar
Here's my current approch - I hope I'll find a better solution.. jsfiddle.net/x8vkyo7a/1Ottar
C
0

Here is a possibly controversial, but more programmatic alternative that worked well for me.

Create a function wrapper that takes a callback that will fire every time something is dragged into or out of the window:

export interface OutsideWindow {(outside: boolean): void}
export interface OutsideCleanup {(): {(): void}}
export const whenOutsideWindow = (onChangeCB: OutsideWindow): OutsideCleanup => {
    const windowDragLeaveHandler = () => onChangeCB(true);
    window.addEventListener('dragleave', windowDragLeaveHandler);

    const bodyDragLeaveHandler = (e: DragEvent) => e.stopPropagation();
    document.body.addEventListener('dragleave', bodyDragLeaveHandler);

    const windowDragEnterHandler = () => onChangeCB(false);
    window.addEventListener('dragenter', windowDragEnterHandler);

    return () => () => {
        window.removeEventListener('dragleave', windowDragLeaveHandler);
        document.body.removeEventListener('dragleave', bodyDragLeaveHandler);
        window.removeEventListener('dragenter', windowDragEnterHandler);
    }
}

A few things to note:

  • This function passes a boolean value into its callback. In this case, it will pass a value, true, when outside the browser window and false when inside it
  • It helps to know a little about bubbling and stopPrapogation(), but basically, we are making the window's child element, body, stop propagating the dragleave event, so it will never reach its parent, window
  • This only works in one direction, so there is no way to do a similar thing for the dragenter event, thus this will fire every time the draggable is dragged iinto a child element.
  • It is a good idea to cleanup any events you add to the DOM, so this function returns its cleanup function for you to call later.
  • The return value of this function is a function that returns a function. More on that below

That all looks a little messy, but it provides a relatively clean interface to use

let outside = false;
let cleanup = () => {};

// Probably inside onDragStart()
const foo = (out) => { outside = out; }
cleanup = whenOutsideWindow( outside => foo(outside) );

...

// Later in your code, when you want to cleanup...
// Probably inside onDragEnd()
cleanup()();

No, the multiple calls to cleanup is not a mistake. Remember that whenOutsideWindow returns a function that returns a function. This is because when a function is returned in javascript, it is run immediately. I have no idea why. Hopefully someone in the comments can shed some light on that.

But the important things is that if whenOutsideWindow returned a simple void function...

export interface OutsideCleanup {(): void}

...our dragstart and dragend events would be added and removed immediately. We get around this by calling the function that is returned from the function that is returned by whenOutsideWindow.

cleanup()();
Chinn answered 1/1, 2023 at 7:38 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.