175
votes

Overview

I have the following HTML structure and I've attached the dragenter and dragleave events to the <div id="dropzone"> element.

<div id="dropzone">
    <div id="dropzone-content">
        <div id="drag-n-drop">
            <div class="text">this is some text</div>
            <div class="text">this is a container with text and images</div>
        </div>
    </div>
</div>

Problem

When I drag a file over the <div id="dropzone">, the dragenter event is fired as expected. However, when I move my mouse over a child element, such as <div id="drag-n-drop">, the dragenter event is fired for the <div id="drag-n-drop"> element and then the dragleave event is fired for the <div id="dropzone"> element.

If I hover over the <div id="dropzone"> element again, the dragenter event is again fired, which is cool, but then the dragleave event is fired for the child element just left, so the removeClass instruction is executed, which is not cool.

This behavior is problematic for 2 reasons:

  1. I'm only attaching dragenter & dragleave to the <div id="dropzone"> so I don't understand why the children elements have these events attached as well.

  2. I'm still dragging over the <div id="dropzone"> element while hovering over its children so I don't want dragleave to fire!

jsFiddle

Here's a jsFiddle to tinker with: http://jsfiddle.net/yYF3S/2/

Question

So... how can I make it such that when I'm dragging a file over the <div id="dropzone"> element, dragleave doesn't fire even if I'm dragging over any children elements... it should only fire when I leave the <div id="dropzone"> element... hovering/dragging around anywhere within the boundaries of the element should not trigger the dragleave event.

I need this to be cross-browser compatible, at least in the browsers that support HTML5 drag-n-drop, so this answer is not adequate.

It seems like Google and Dropbox have figured this out, but their source code is minified/complex so I haven't been able to figure this out from their implementation.

22
Prevent event propagation via e.stopPropagation();Blender
I think I already am... check out the updateHristo
Can you post a demo somewhere on jsfiddle.net people can tinker with it?Blender
@Blender... sure thing, give me a couple of minutes!Hristo
@Blender... I updated my question with a fiddleHristo

22 Answers

179
votes

If you don't need to bind events to the child elements, you can always use the pointer-events property.

.child-elements {
  pointer-events: none;
}
59
votes

I finally found a solution I'm happy with. I actually found several ways to do what I want but none were as successful as the current solution... in one solution, I experienced frequent flickering as a result of adding/removing a border to the #dropzone element... in another, the border was never removed if you hover away from the browser.

Anyway, my best hacky solution is this:

var dragging = 0;

attachEvent(window, 'dragenter', function(event) {

    dragging++;
    $(dropzone).addClass('drag-n-drop-hover');

    event.stopPropagation();
    event.preventDefault();
    return false;
});

attachEvent(window, 'dragover', function(event) {

    $(dropzone).addClass('drag-n-drop-hover');

    event.stopPropagation();
    event.preventDefault();
    return false;
});

attachEvent(window, 'dragleave', function(event) {

    dragging--;
    if (dragging === 0) {
        $(dropzone).removeClass('drag-n-drop-hover');
    }

    event.stopPropagation();
    event.preventDefault();
    return false;
});

This works pretty well but issues came up in Firefox because Firefox was double-invoking dragenter so my counter was off. But nevertheless, its not a very elegant solution.

Then I stumbled upon this question: How to detect the dragleave event in Firefox when dragging outside the window

So I took the answer and applied it to my situation:

$.fn.dndhover = function(options) {

    return this.each(function() {

        var self = $(this);
        var collection = $();

        self.on('dragenter', function(event) {
            if (collection.size() === 0) {
                self.trigger('dndHoverStart');
            }
            collection = collection.add(event.target);
        });

        self.on('dragleave', function(event) {
            /*
             * Firefox 3.6 fires the dragleave event on the previous element
             * before firing dragenter on the next one so we introduce a delay
             */
            setTimeout(function() {
                collection = collection.not(event.target);
                if (collection.size() === 0) {
                    self.trigger('dndHoverEnd');
                }
            }, 1);
        });
    });
};

$('#dropzone').dndhover().on({
    'dndHoverStart': function(event) {

        $('#dropzone').addClass('drag-n-drop-hover');

        event.stopPropagation();
        event.preventDefault();
        return false;
    },
    'dndHoverEnd': function(event) {

        $('#dropzone').removeClass('drag-n-drop-hover');

        event.stopPropagation();
        event.preventDefault();
        return false;
    }
});

This is clean and elegant and seems to be working in every browser I've tested so far (haven't tested IE yet).

41
votes

This is a little ugly but it works dammit!...

On your 'dragenter' handler store the event.target (in a variable inside your closure, or whatever), then in your 'dragleave' handler only fire your code if event.target === the one you stored.

If your 'dragenter' is firing when you don't want it to (i.e. when it's entering after leaving child elements), then the last time it fires before the mouse leaves the parent, it's on the parent, so the parent will always be the final 'dragenter' before the intended 'dragleave'.

(function () {

    var droppable = $('#droppable'),
        lastenter;

    droppable.on("dragenter", function (event) {
        lastenter = event.target;
        droppable.addClass("drag-over");            
    });

    droppable.on("dragleave", function (event) {
        if (lastenter === event.target) {
            droppable.removeClass("drag-over");
        }
    });

}());
35
votes

At first, I agreed with folks discarding the pointer-events: none approach. But then I asked myself:

Do you really need pointer-events to work on the child elements while dragging is in progress?

In my case, I have lots of stuff going on in the children, e.g. hover to show buttons for additional actions, inline-editing, etc... However, none of that is necessary or in fact even desired during a drag.

In my case, I use something like this to turn pointer events off selectively for all child nodes of the parent container:

  div.drag-target-parent-container.dragging-in-progress * {
    pointer-events: none;
  }

Use your favorite approach to add/remove the class dragging-in-progress in the dragEnter/dragLeave event handlers, as I did or do the same in dragStart, et. al.

12
votes

This seems to be a Chrome bug.

The only workaround that I could think of was to create a transparent overlay element to capture your events: http://jsfiddle.net/yYF3S/10/

JS:

$(document).ready(function() {
    var dropzone = $('#overlay');

    dropzone.on('dragenter', function(event) {
        $('#dropzone-highlight').addClass('dnd-hover');
    });

    dropzone.on('dragleave', function(event) {
        $('#dropzone-highlight').removeClass('dnd-hover');
    });

});​

HTML:

<div id="dropzone-highlight">
    <div id="overlay"></div>

    <div id="dropzone" class="zone">
        <div id="drag-n-drop">
            <div class="text1">this is some text</div>
            <div class="text2">this is a container with text and images</div>
        </div>
    </div>
</div>

<h2 draggable="true">Drag me</h2>
​
5
votes

The problem is, that your elements inside the dropzones are of course part of the dropzone and when you enter the children, you leave the parent. Solving this is not easy. You might try adding events to the children too adding your class again to the parent.

$("#dropzone,#dropzone *").on('dragenter', function(event) {

    // add a class to #dropzone

    event.stopPropagation(); // might not be necessary
    event.preventDefault();
    return false;
});

Your events will still fire multiple times but nobody will see.

//Edit: Use the dragmove event to permanently overwrite the dragleave event:

$("#dropzone,#dropzone *").on('dragenter dragover', function(event) {

    // add a class to #dropzone

    event.stopPropagation(); // might not be necessary
    event.preventDefault();
    return false;
});

Define the dragleave event only for the dropzone.

4
votes

As benr mentioned in this answer, you can prevent child nodes to fire on events, but if you need to bind some events, do this:

#dropzone.dragover *{
   pointer-events: none;
}

And add this one to your JS code:

$("#dropzone").on("dragover", function (event) {
   $("#dropzone").addClass("dragover");
});

$("#dropzone").on("dragleave", function (event) {
   $("#dropzone").removeClass("dragover");
});
3
votes

If you're using jQuery, check this out: https://github.com/dancork/jquery.event.dragout

It's truly awesome.

Special event created to handle true dragleave functionality.

HTML5 dragleave event works more like mouseout. This plugin was created to replicate the mouseleave style functionality whilst dragging.

Usage Example:

$('#myelement').on('dragout',function(event){ // YOUR CODE });

EDIT: actually, I don't think it's dependent on jQuery, you can probably just use the code even without it.

2
votes

@hristo I have a much more elegant solution. Check that if that is something you could use.

Your effort was not wasted after all. I managed to use yours at first, but had different problems in FF, Chrome. After spending so many hours I got that suggestion working exactly as intended.

Here's how the implementation is. I also took advantage of visual cues to correctly guide users about the drop zone.

$(document).on('dragstart dragenter dragover', function(event) {    
    // Only file drag-n-drops allowed, http://jsfiddle.net/guYWx/16/
    if ($.inArray('Files', event.originalEvent.dataTransfer.types) > -1) {
        // Needed to allow effectAllowed, dropEffect to take effect
        event.stopPropagation();
        // Needed to allow effectAllowed, dropEffect to take effect
        event.preventDefault();

        $('.dropzone').addClass('dropzone-hilight').show();     // Hilight the drop zone
        dropZoneVisible= true;

        // http://www.html5rocks.com/en/tutorials/dnd/basics/
        // http://api.jquery.com/category/events/event-object/
        event.originalEvent.dataTransfer.effectAllowed= 'none';
        event.originalEvent.dataTransfer.dropEffect= 'none';

         // .dropzone .message
        if($(event.target).hasClass('dropzone') || $(event.target).hasClass('message')) {
            event.originalEvent.dataTransfer.effectAllowed= 'copyMove';
            event.originalEvent.dataTransfer.dropEffect= 'move';
        } 
    }
}).on('drop dragleave dragend', function (event) {  
    dropZoneVisible= false;

    clearTimeout(dropZoneTimer);
    dropZoneTimer= setTimeout( function(){
        if( !dropZoneVisible ) {
            $('.dropzone').hide().removeClass('dropzone-hilight'); 
        }
    }, dropZoneHideDelay); // dropZoneHideDelay= 70, but anything above 50 is better
});
2
votes

My two cents: Hide a layer over your dropzone then show it when you dragenter, and target the dragleave on it.

Demo : https://jsfiddle.net/t6q4shat/

HTML

<div class="drop-zone">
  <h2 class="drop-here">Drop here</h2>
  <h2 class="drop-now">Drop now!</h2>
  <p>Or <a href="#">browse a file</a></p>
  <div class="drop-layer"></div>
</div>

CSS

.drop-zone{
  padding:50px;
  border:2px dashed #999;
  text-align:center;
  position:relative;
}
.drop-layer{
  display:none;
  position:absolute;
  top:0;
  left:0;
  bottom:0;
  right:0;
  z-index:5;
}
.drop-now{
  display:none;
}

JS

$('.drop-zone').on('dragenter', function(e){
    $('.drop-here').css('display','none');
    $('.drop-now').css('display','block');
    $(this).find('.drop-layer').css('display','block');
    return false;
});

$('.drop-layer').on('dragleave', function(e){
    $('.drop-here').css('display','block');
    $('.drop-now').css('display','none');
    $(this).css('display','none');
    return false;
});
2
votes

My version:

$(".dropzone").bind("dragover", function(e){
    console.log('dragover');
});

$(".dropzone").bind("dragleave", function(e) {
  var stopDrag = false;
  if (!e.relatedTarget) stopDrag = true;
  else {
    var parentDrop = $(e.relatedTarget).parents('.dropzone');
    if (e.relatedTarget != this && !parentDrop.length) stopDrag = true;
  }

  if (stopDrag) {
    console.log('dragleave');
  }
});

With this layout:

<div class="dropzone">
  <div class="inner-zone">Inner-zone</div>
</div>

I've made a dump of element classes for e.target, e.currentTarget, e.relatedTarget for both dragover and dragleave events.

It showed me that on leaving the parent block (.dropzone) e.relatedTarget is not a child of this block so I know I'm out of the dropzone.

2
votes

Sorry it's javascript not jquery, but for me it's the most logical way to solve that. The browser should call dropleave (of the previous element) before dropenter (ofr the new element, as something can't enter something else before having leaved the forst thing, I don't understand why they did that ! So you just need to delay de dropleave like that:

function mydropleave(e)
{
    e.preventDefault();
    e.stopPropagation();

    setTimeout(function(e){ //the things you want to do },1);
}

And the dropenter will happen after the dropleave, that's all !

1
votes

So for me the approach pointer-events: none; didn't work too well... So here is my alternative solution:

    #dropzone {
        position: relative;
    }

    #dropzone(.active)::after {
        position: absolute;
        top: 0;
        right: 0;
        bottom: 0;
        left: 0;
        content: '';
    }

This way it is not possible to dragleave the parent (on a child) or dragover a child element. hope this helps :)

*The '.active'-class I add when I dragenter or dragleave. But if you're working without that just leave the class away.

1
votes

I did not feel satisfied with any of the workarounds presented here, because I do not want to lose control over the children elements.

So I've used a different logic approach, translating it into a jQuery plugin, called jquery-draghandler. It does absolutely not manipulate the DOM, guaranteeing high performances. Its usage is simple:

$(document).ready(function() {

    $(selector).draghandler({
        onDragEnter: function() {
            // $(this).doSomething();
        },
        onDragLeave: function() {
            // $(this).doSomethingElse();
        }
    });

});

It deals flawlessly with the problem without compromising any DOM functionality.

Download, details and explanations on its Git repository.

1
votes

Super simple quick fix for this, not tested extensively but works in Chrome right now.

Excuse the Coffeescript.

  dragEndTimer = no

  document.addEventListener 'dragover', (event) ->
    clearTimeout dragEndTimer
    $('body').addClass 'dragging'
    event.preventDefault()
    return no

  document.addEventListener 'dragenter', (event) ->
    $('section').scrollTop(0)
    clearTimeout dragEndTimer
    $('body').addClass 'dragging'

  document.addEventListener 'dragleave', (event) ->
    dragEndTimer = setTimeout ->
      $('body').removeClass 'dragging'
    , 50

This fixes the Chrome flicker bug, or at least the permutation of it that was causing me issues.

1
votes

I'm actually liking what I see at https://github.com/lolmaus/jquery.dragbetter/ but wanted to share a possible alternative. My general strategy was to apply a background style to the dropzone (not its children) when dragentering it or any child of it (via bubbling). Then I remove the style when dragleaving the drop zone. Idea was when moving to a child, even if I remove the style from the dropzone when leaving it (the dragleave fires), I would simply reapply the style to the parent dropzone on dragentering any child. Problem is of course that when moving from the dropzone to a child of the dropzone, the dragenter gets fired on the child before the dragleave, so my styles got applied out of order. Solution for me was to use a timer to force the dragenter event back to the message queue, allowing me to process it AFTER the dragleave. I used a closure to access the event on the timer callback.

$('.dropzone').on('dragenter', function(event) {
  (function (event) {
    setTimeout(function () {
      $(event.target).closest('.dropzone').addClass('highlight');
    }, 0);
  }) (event.originalEvent); 
});

This seems to work in chrome, ie, firefox and works regardless of the number of children in the dropzone. I'm slightly uneasy about the timeout guaranteeing the resequencing of events, but it seems to work pretty well for my use case.

1
votes

This answer can be found here:

HTML5 dragleave fired when hovering a child element

var counter = 0;

$('#drop').bind({
    dragenter: function(ev) {
        ev.preventDefault(); // needed for IE
        counter++;
        $(this).addClass('red');
    },

    dragleave: function() {
        counter--;
        if (counter === 0) { 
            $(this).removeClass('red');
        }
    }
});
0
votes

Here, one of the simplest solution ●︿●

Take a look at this fiddle <-- try dragging some file inside the box

You can do something like this:

var dropZone= document.getElementById('box');
var dropMask = document.getElementById('drop-mask');


dropZone.addEventListener('dragover', drag_over, false);
dropMask.addEventListener('dragleave', drag_leave, false);
dropMask.addEventListener('drop', drag_drop, false);

By that you should already know whats happening here.
Just take a look at the fiddle, you know.

0
votes

I've had a similar problem and I fixed it this way:

The problem: The function drop(ev) is fired when the user drops an element in the "drop zone" (ul element), but, unfortunately, also when the element is dropped in one of its children ( li elements).

The fix:

function drop(ev) { 
ev.preventDefault(); 
data=ev.dataTransfer.getData('Text'); 
if(ev.target=="[object HTMLLIElement]")  
{ev.target.parentNode.appendChild(document.getElementById(data));}
else{ev.target.appendChild(document.getElementById(data));} 
} 
0
votes

I was trying to implement this myself for a file upload box where the box color would change when users dragged a file into the space.

I found a solution that is a nice mix of Javascript and CSS. Say you have a droppable zone with div with id #drop. Add this to your Javascript:

$('#drop').on('dragenter', function() {
    $(this).addClass('dragover');
    $(this).children().addClass('inactive');
});

$('#drop').on('dragleave', function() {
    $(this).removeClass('dragover');
    $(this).children().removeClass('inactive');
});

Then, add this to your CSS, to inactivate all children will class .inactive:

#drop *.inactive {
    pointer-events: none;
}

Thus, the children elements will be inactive for as long as the user is dragging the element over the box.

0
votes

I was trying to implement this myself, and I did not want Jquery or any plugin either.

I wanted to handle the file upload, following way deemed best for me:

Files Structure:

--- /uploads {uploads directory}

--- /js/slyupload.js {javascript file.}

--- index.php

--- upload.php

--- styles.css {just a little styling..}

HTML Code:

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=iso-8859-1" />
<title>my Dzone Upload...</title>
<link rel="stylesheet" href="styles.css" />
</head>

<body>
    <div id="uploads"></div>        
    <div class="dropzone" id="dropzone">Drop files here to upload</div>     
    <script type="text/javascript" src="js/slyupload.js"></script>      
</body>
</html>

Then This is the attached Javascript File:: 'js/slyupload.js'

<!-- begin snippet:  -->

<!-- language: lang-js -->

    // JavaScript Document

    //ondragover, ondragleave...


    (function(){        

        var dropzone = document.getElementById('dropzone');
        var intv;

        function displayUploads(data){
            //console.log(data, data.length);
            var uploads = document.getElementById('uploads'),anchor,x;

            for(x=0; x < data.length; x++){

                //alert(data[x].file);
                anchor = document.createElement('a');
                anchor.href = data[x].file;
                anchor.innerText = data[x].name;

                uploads.appendChild(anchor);
            }               
        }

        function upload(files){
            //console.log(files);
            var formData = new FormData(), 
                xhr      = new XMLHttpRequest(),    //for ajax calls...
                x;                                  //for the loop..

                for(x=0;x<files.length; x++){
                    formData.append('file[]', files[x]);

                    /*//do this for every file...
                    xhr = new XMLHttpRequest();

                    //open... and send individually..
                    xhr.open('post', 'upload.php');
                    xhr.send(formData);*/
                }

                xhr.onload = function(){
                    var data = JSON.parse(this.responseText);   //whatever comes from our php..
                    //console.log(data);
                    displayUploads(data);

                    //clear the interval when upload completes... 
                    clearInterval(intv);
                }                   

                xhr.onerror = function(){
                    console.log(xhr.status);
                }

                //use this to send all together.. and disable the xhr sending above...

                //open... and send individually..
                intv = setInterval(updateProgress, 50);
                xhr.open('post', 'upload.php');
                xhr.send(formData);

                //update progress... 
                 /* */                   
        }

        function updateProgress(){
            console.log('hello');
         }          

        dropzone.ondrop = function(e){
            e.preventDefault(); //prevent the default behaviour.. of displaying images when dropped...
            this.className = 'dropzone';
            //we can now call the uploading... 
            upload(e.dataTransfer.files); //the event has a data transfer object...
        }

        dropzone.ondragover = function(){
            //console.log('hello');
            this.className = 'dropzone dragover';
            return false;
        }

        dropzone.ondragleave = function(){
            this.className = 'dropzone';
            return false;
        }           
    }(window));

CSS:

body{
	font-family:Arial, Helvetica, sans-serif; 
	font-size:12px;
}

.dropzone{
	width:300px; 
	height:300px;
	border:2px dashed #ccc;
	color:#ccc;
	line-height:300px;
	text-align:center;
}

.dropzone.dragover{
	border-color:#000;
	color:#000;
}
-2
votes

Use the greedy : true to the child as a function of droppable. Then start only event clicked on the first layer.