Drag and drop example - HTML5 - html

I'm trying to learn the drag and drop example from the WWWC (here) and I can get the list items to be removed from the original list when dragged away, but not appear in the new list. Any ideas why not? I have tried on Safari 5.1.1, Chrome 15, and Firefox 7.0.1.
<head>
<title>Drag 'N Drop</title>
</head>
<p>What fruits do you like?</p>
<ol ondragstart="dragStartHandler(event)" ondragend="dragEndHandler(event)">
<li draggable="true" data-value="fruit-apple">Apples</li>
<li draggable="true" data-value="fruit-orange">Oranges</li>
<li draggable="true" data-value="fruit-pear">Pears</li>
</ol>
<script>
var internalDNDType = 'text/plain'; // set this to something specific to your site
function dragStartHandler(event) {
if (event.target instanceof HTMLLIElement) {
// use the element's data-value="" attribute as the value to be moving:
event.dataTransfer.setData(internalDNDType, event.target.dataset.value);
event.dataTransfer.effectAllowed = 'move'; // only allow moves
} else {
event.preventDefault(); // don't allow selection to be dragged
}
}
function dragEndHandler(event) {
// remove the dragged element
event.target.parentNode.removeChild(event.target);
}
</script>
<p>Drop your favorite fruits below:</p>
<div dropzone="move s:text/plain" ondrop="dropHandler(event)">
<ol dropzone="move s:text/plain" ondrop="dropHandler(event)">
<!-- don't forget to change the "text/x-example" type to something
specific to your site -->
<li>Bananas</li>
</ol>
</br>
</br>
</br>
</div>
<script>
var internalDNDType = 'text/plain'; // set this to something specific to your site
function dropHandler(event) {
var li = document.createElement('li');
var data = event.dataTransfer.getData(internalDNDType);
if (data == 'fruit-apple') {
li.textContent = 'Apples';
} else if (data == 'fruit-orange') {
li.textContent = 'Oranges';
} else if (data == 'fruit-pear') {
li.textContent = 'Pears';
} else {
li.textContent = 'Unknown Fruit';
}
event.target.appendChild(li);
}
</script>

There are a couple of issues. First, your dropzone needs to cancel the event on drag over:
<ol dropzone="move s:text/plain" ondrop="dropHandler(event)" ondragover="dragOverHandler(event)">
function dragOverHandler(event) {
event.preventDefault();
return false;
}
Second, your dropHandler handler function is going to get fired several times because the drop target, most of the time, is going to be an li element rather than the ol (and possibly also the div, but I ignored that element). So either add code to only handle the event at the ol, or cancel the event in dropHandler with stopPropagation.
Finally, the default action (at least in Firefox, didn't check other browsers) when an item is dropped is to try and navigate to the URL represented by the text/plain value, so you should add some event.preventDefault() in likely places. Here's an updated dropHandler function:
function dropHandler(event) {
var li = document.createElement('li');
var data = event.dataTransfer.getData(internalDNDType);
if (data == 'fruit-apple') {
li.textContent = 'Apples';
} else if (data == 'fruit-orange') {
li.textContent = 'Oranges';
} else if (data == 'fruit-pear') {
li.textContent = 'Pears';
} else {
li.textContent = 'Unknown Fruit';
}
event.target.appendChild(li);
event.stopPropagation();
event.preventDefault();
return false;
}
Here's my updated version.

The short answer would be that you also need to add a dragover listener to the drop area in order to allow the drop action to happen.
function handleDragOver(e) {
if (e.preventDefault) {
e.preventDefault(); // Necessary. Allows us to drop.
}
e.dataTransfer.dropEffect = 'move';
return false;
}
You have a detailed explanation in this step by step tutorial (although the elements in that tutorial act as both draggable elements and drop areas)
However, Chris, as much as I am glad you are experimenting with the native HTML5 drag and drop feature please accept my humble opinion when I say that your code has way too many serious mistakes both at markup level and Javascript level. i.e. missing tags, closing tags that don't exist, wrong attribute namespaces, repeated listeners in chained elements, repeated variables inside the same scope, etc. I'd suggest to go through several code reviews first.

Related

prototype - Trigger event when an element is removed from the DOM

I'm trying to figure out how to execute some js code when an element is removed from the page:
Something in prototype like:
$('custom-div').observe('remove', function(event) {
// Handle the event
});
Does anything like this exist?
In modern browsers, you can use a MutationObserver. Here's code that will call a callback when a DOM element is removed from it's current location:
function watchRemove(el, fn) {
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
var item;
if (mutation.type === "childList" && mutation.removedNodes) {
for (var i = 0; i < mutation.removedNodes.length; i++) {
item = mutation.removedNodes[i];
if (item === el) {
// clear the observer
observer.disconnect();
fn.call(item, item);
break;
}
}
}
});
});
observer.observe(el.parentNode, {childList: true});
return observer;
}
And, a working demo: http://jsfiddle.net/jfriend00/naft3qeb/
This watches the parent for changes to its direct children and calls the callback if the specific DOM element passed in is removed.
The observer will remove itself when the DOM element is removed or watchRemove() returns the observer instance which you can call .disconnect() on at any time to stop the watching.
Here's a jQuery plug-in that uses this functionality:
jQuery.fn.watchRemove = function(fn, observers) {
return this.each(function() {
var o = watchRemove(this, fn);
if (observers) {
observers.push(o);
}
});
}
In this case, it accepts an optional array object as an argument that will be filled with all the observer objects (only necessary to pass this if you want to be able to stop the watching yourself on any given item).

How do you stop a function from being executed and only execute on click in mootools

I'm new to Mootools and I have found that I have to use the click element but I'm not 100% sure where I am meant to put it in the below code:
function setInStockOption (labels, e) {
active = false;
labels.some (function (item,index) {
if(item.hasClass ('selected')) {
if(item.hasClass ('unavailable')) {
item.removeClass('selected');
item.addClass ('unselected');
active = true;
} else {
return true;
}
}
if(active) {
if (!item.hasClass ('unavailable')) {
e.target = this;
item.fireEvent ('click', e);
active = false;
return true;
}
}
});
}
window.addEvent('load', function(e) {
var labels = $$('div.option-radios label.radio');
setInStockOption(labels, e);
});
I basically need to add the class selected on click instead. At the moment this script is adding the selected class to the first child of Radio in the html and then when you click on others it'll add the class selected. I basically want all the classes to be unselected when the page loads
.
Any ideas?
You'll want something like this:
window.addEvent('domready', function(e) {
$$('div.option-radios label.radio').each(function(label, i) {
label.addEvent('click', function(event) {
event.target.toggleClass('selected');
});
});
});
Note that this uses the Array.each method instead of Array.some. The latter doesn't do what you expect. It then registers a click event on every label which simply toggles the selected class on the event target.
Then you can add other initialization code to the each loop and more logic to the click event handler.
I also used the domready event which is usually preferred over load since it fires earlier.
Here's a fiddle to play around with.

javascript new function call with MooTools

I have found a MooTools version of Nivoo Slider that (in theory) will work with our MooTools dropdown menu. However, the menu is using MooTools 1.2.x, and Nivoo is using 1.3.2 or 1.4.0. Every time I try and use both the menu and the slider, the menu stops working.
Are the versions of the MooTools framework not backward compatible?
Also, are these plugins compatible or is one overriding the other?
I don't know enough about JS to correct my errors or rewrite the function call. Is there a good beginner's tutorial for this?
window.addEvent('domready', function () {
var menu = new UvumiDropdown('dropdown-demo');
// initialize Nivoo-Slider
new NivooSlider($('slider'), {
directionNavHide: true,
effect: 'wipeDown',
interval: 1000
});
});
In trying to convert without compatibility, here are instructions that I was not sure how to implement.
Instruction
:: Line of 1.2 code
$clear => use the native clearTimeout when using fn.delay, use clearInterval when using fn.periodical.
:: $clear(a.retrieve('closeDelay'))
myFn.create => Use the according functions like .pass, .bind, .delay, .periodical
:: this.createSubmenu(this.menu)
myFn.bind(this, [arg1, arg2, arg3]) => myFn.bind(this, arg1, arg2, arg3) OR myFn.pass([arg1, arg2, arg3], this)
:: this.domReady.bind(this)
$$ now only accepts a single selector, an array or arguments of elements
:: $$(b,b.getChildren('li')
These instructions are with compatibility. I'm trying both.
myElement.get('tween', options); // WRONG
myElement.set('tween', options).get('tween'); // YES, INDEED.
:: this.menu.get('tag')!='ul'
:: this.menu.getElement('ul')
OK I tested the UvumiDropdown latest build with mootools 1.4.x and it worked fine as long as I included a Mootools more build that includes Fx.Elements
Hope this helps
<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/mootools/1.4.1/mootools-yui-compressed.js"> </script>
// MooTools: the javascript framework.
// Load this file's selection again by visiting: http://mootools.net/more/c8813373963b6a3e9a4d4bcfe9290081
// Or build this file again with packager using: packager build More/Fx.Elements
/*
---
script: More.js
name: More
description: MooTools More
license: MIT-style license
authors:
- Guillermo Rauch
- Thomas Aylott
- Scott Kyle
- Arian Stolwijk
- Tim Wienk
- Christoph Pojer
- Aaron Newton
- Jacob Thornton
requires:
- Core/MooTools
provides: [MooTools.More]
...
*/
MooTools.More = {
'version': '1.4.0.1',
'build': 'a4244edf2aa97ac8a196fc96082dd35af1abab87'
};
/*
---
script: Fx.Elements.js
name: Fx.Elements
description: Effect to change any number of CSS properties of any number of Elements.
license: MIT-style license
authors:
- Valerio Proietti
requires:
- Core/Fx.CSS
- /MooTools.More
provides: [Fx.Elements]
...
*/
Fx.Elements = new Class({
Extends: Fx.CSS,
initialize: function(elements, options){
this.elements = this.subject = $$(elements);
this.parent(options);
},
compute: function(from, to, delta){
var now = {};
for (var i in from){
var iFrom = from[i], iTo = to[i], iNow = now[i] = {};
for (var p in iFrom) iNow[p] = this.parent(iFrom[p], iTo[p], delta);
}
return now;
},
set: function(now){
for (var i in now){
if (!this.elements[i]) continue;
var iNow = now[i];
for (var p in iNow) this.render(this.elements[i], p, iNow[p], this.options.unit);
}
return this;
},
start: function(obj){
if (!this.check(obj)) return this;
var from = {}, to = {};
for (var i in obj){
if (!this.elements[i]) continue;
var iProps = obj[i], iFrom = from[i] = {}, iTo = to[i] = {};
for (var p in iProps){
var parsed = this.prepare(this.elements[i], p, iProps[p]);
iFrom[p] = parsed.from;
iTo[p] = parsed.to;
}
}
return this.parent(from, to);
}
});
/*
UvumiTools Dropdown Menu v1.1.2 http://uvumi.com/tools/dropdown.html
Copyright (c) 2009 Uvumi LLC
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation
files (the "Software"), to deal in the Software without
restriction, including without limitation the rights to use,
copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following
conditions:
The above copyright notice and this permission notice shall be
included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
*/
var UvumiDropdown = new Class({
Implements:Options,
options:{
clickToOpen:false, //if set to true, must click to open submenues
openDelay:150, //if hover mode, duration the mouse must stay on target before submenu is opened. if exits before delay expires, timer is cleared
closeDelay:500, //delay before the submenu close when mouse exits. If mouse enter the submenu again before timer expires, it's cleared
duration:250, //duration in millisecond of opening/closing effect
link:'cancel',
transition:Fx.Transitions.linear, //effect's transitions. See http://docs.mootools.net/Fx/Fx.Transitions for more details
mode:'horizontal' //if set to horizontal, the top level menu will be displayed horizontally. If set to vertical, it will be displayed vertically. If it does not match any of those two words, 'horizontal' will be used.
},
initialize: function(menu,options){
this.menu = menu;
this.setOptions(options);
if(this.options.mode != 'horizontal' && this.options.mode != 'vertical'){
this.options.mode = 'horizontal';
}
//some versions of Safari and Chrome run domready before DOM is actually ready, causing wrong positioning. If you still have some display issues in those browser try to increase the delay value a bit. I tried to keep it as low as possible, but sometimes it can take a bit longer than expected
if(Browser.Engine.webkit){
window.addEvent('domready',this.domReady.delay(200,this));
}else{
window.addEvent('domready',this.domReady.bind(this));
}
},
domReady:function(){
this.menu = $(this.menu);
if(!$defined(this.menu)){
return false;
}
//if passed element is not a UL, tries to find one in the children elements
if(this.menu.get('tag')!='ul'){
this.menu = this.menu.getElement('ul');
if(!$defined(this.menu)){
return false;
}
}
//handles pages written form right to left.
if(this.menu.getStyle('direction') == 'rtl' || $(document.body).getStyle('direction') == 'rtl'){
this.rtl = true;
if(Browser.Engine.trident && $(document.body).getStyle('direction') == 'rtl'){
this.menu.getParent().setStyle('direction','ltr');
this.menu.setStyle('direction','rtl');
}
}
//start setup
this.menu.setStyles({
visibility:'hidden',
display:'block',
overflow:'hidden',
height:0,
marginLeft:(Browser.Engine.trident?1:-1)
});
//we call the createSubmenu function on the main UL, which is a recursive function
this.createSubmenu(this.menu);
//the LIs must be floated to be displayed horisotally
if(this.options.mode=='horizontal'){
this.menu.getChildren('li').setStyles({
'float':(this.rtl?'right':'left'),
display:'block',
top:0
});
//We create an extar LI which role will be to clear the floats of the others
var clear = new Element('li',{
html:" ",
styles:{
clear:(this.rtl?'right':'left'),
display:(Browser.Engine.trident?'inline':'block'), //took me forever to find that fix
position:'relative',
top:0,
height:0,
width:0,
fontSize:0,
lineHeight:0,
margin:0,
padding:0
}
}).inject(this.menu);
}else{
this.menu.getChildren('li').setStyles({
display:'block',
top:0
});
}
this.menu.setStyles({
height:'auto',
overflow:'visible',
visibility:'visible'
});
//hack for IE, again
this.menu.getElements('a').setStyle('display',(Browser.Engine.trident?'inline-block':'block'));
},
createSubmenu:function(ul){
//we collect all the LI of the ul
var LIs = ul.getChildren('li');
var offset = 0;
//loop through the LIs
LIs.each(function(li){
li.setStyles({
position:'relative',
display:'block',
top:-offset,
zIndex:1
});
offset += li.getSize().y;
var innerUl = li.getFirst('ul');
//if the current LI contains a UL
if($defined(innerUl)){
ul.getElements('ul').setStyle('display','none');
//if the current UL is the main one, that means we are still in the top row, and the submenu must drop down
if(ul == this.menu && this.options.mode == 'horizontal'){
li.addClass('submenu-down');
var x = 0;
var y = li.getSize().y;
this.options.link='cancel';
li.store('animation',new Fx.Elements($$(innerUl,innerUl.getChildren('li')).setStyle('opacity',0),this.options));
//if the current UL is not the main one, the sub menu must pop from the side
}else{
li.addClass('submenu-left');
var x = li.getSize().x-(this.rtl&&!Browser.Engine.trident?2:1)*li.getStyle('border-left-width').toInt();
var y = -li.getStyle('border-bottom-width').toInt();
this.options.link='chain';
li.store('animation',new Fx.Elements($$(innerUl,innerUl.getChildren('li')).setStyle('opacity',0),this.options));
offset=li.getSize().y+li.getPosition(this.menu).y;
}
innerUl.setStyles({
position:'absolute',
top:y,
opacity:0
});
ul.getElements('ul').setStyle('display','block');
if(this.rtl){
innerUl.setStyles({
right:x,
marginRight:-x
});
}else{
innerUl.setStyles({
left:x,
marginLeft:-x
});
}
//we call the createsubmenu function again, on the new UL
this.createSubmenu(innerUl);
//apply events to make the submenu appears when hovering the LI
if(this.options.clickToOpen){
li.addEvent('mouseenter',function(){
$clear(li.retrieve('closeDelay'));
}.bind(this)
);
li.getFirst('a').addEvent('click',function(e){
e.stop();
$clear(li.retrieve('closeDelay'));
this.showChildList(li);
}.bind(this));
}else{
li.addEvent('mouseenter',function(){
$clear(li.retrieve('closeDelay'));
li.store('openDelay',this.showChildList.delay(this.options.openDelay,this,li));
}.bind(this));
}
li.addEvent('mouseleave', function(){
$clear(li.retrieve('openDelay'));
li.store('closeDelay',this.hideChildList.delay(this.options.closeDelay,this,li));
}.bind(this));
}
},this);
},
//display submenu
showChildList:function(li){
var ul = li.getFirst('ul');
var LIs = $$(ul.getChildren('li'));
var animation = li.retrieve('animation');
//if the parent menu is not the main menu, the submenu must pop from the side
if(li.getParent('ul')!=this.menu || this.options.mode == 'vertical'){
animation.cancel();
var anim ={
0:{
opacity:1
},
1:{
opacity:1
}
};
if(this.rtl){
anim[0]['marginRight'] = 0;
}else{
anim[0]['marginLeft'] = 0;
}
animation.start(anim);
var animobject={};
//if the parent menu us the main menu, the submenu must drop down
}else{
var animobject = {0:{opacity:1}};
}
LIs.each(function(innerli,i){
animobject[i+1]={
top:0,
opacity:1
};
});
li.setStyle('z-index',99);
animation.start(animobject);
},
//hide the menu
hideChildList:function(li){
var animation = li.retrieve('animation');
var ul = li.getFirst('ul');
var LIs = $$(ul.getChildren('li'));
var offset = 0;
var animobject={};
LIs.each(function(innerli,i){
animobject[i+1]={
top:-offset,
opacity:0
};
offset += innerli.getSize().y;
});
li.setStyle('z-index',1);
//if the parent menu is not the main menu, the submenu must fold up, and go to the left
if(li.getParent('ul')!=this.menu || this.options.mode == 'vertical'){
animobject[1]=null;
animation.cancel();
animation.start(animobject);
var anim = {
0:{
opacity:0
},
1:{
opacity:0
}
};
if(this.rtl){
anim[0]['marginRight'] = -ul.getSize().x;
}else{
anim[0]['marginLeft'] = -ul.getSize().x;
}
animation.start(anim);
//if the parent menu is the main menu, the submenu must just fold up
}else{
animobject[0]={opacity:0};
animation.start(animobject);
}
}
});
MooTools follows SemVer (Semantic Versioning), meaning that a minor version number (x.Y.z) bump is not guaranteed to be backward-compatible (and is usually not).
However, new versions come with a compatibility layer. Just tick the box on the MooTools Core builder if you really can't upgrade your code. You should though avoid to do such a thing, it is bad for performance and potentially forward-compatibility.
As for a tutorial, the best way to learn how to upgrade code from one version to the other is to read the changelog of the 1.3 to learn about the differences with 1.2, and from the 1.3 to the 1.4 if you want to upgrade to the latest version. From this knowledge, rewrite all calls that make use of outdated APIs.
It looks like a daunting task at first, but it usually goes very quickly (actually, in this precise case, it is most often about rewriting Hash references and .each calls). It might be hard if you're learning JS, but it will definitely be a very rewarding experience in JS, and especially in MooTools, as you'll learn about what makes a code ”Mooish” :)

How do I replace Function create from MooTools 1.2 to 1.3?

Hi there i have this code snippet I need to get working with MooTools 1.3 :
this.fn = function (e, cal) {
var e = new Event(e);
var el = e.target;
var stop = false;
while (el != document.body && el.nodeType == 1) {
if (el == this.calendar) { stop = true; }
this.calendars.each(function (kal) {
if (kal.button == el || kal.els.contains(el)) { stop = true; }
});
if (stop) {
e.stop();
return false;
}
else { el = el.parentNode; }
}
this.toggle(cal);
}.create({
'arguments': cal,
'bind': this,
'event': true
}); <-- THIS CREATE METHOD DOES NOT WORK
Can someone help me whit this ?
After create function was deprecated, you need to "manually" recreate the usage.
In this case, you are creating a function that will be an event listener and binding it later in the code (this is Aeron Glemann's calendar).
So what you need to do, is put this function in addEvent you find directly below it, like this.
document.addEvent('mousedown', function(e, cal) {
[...]
}.bind(this));
Also, there's a removeEvent call at the begining of the function you're currently editing (the toggle function) that will no longer work since this function no longer has a name, replace it with removing all events on mousedown, worked for me.
document.removeEvents('mousedown');
as i said on the mootools user mailing list, i don't know about the "perfect" way, but in the meantime you can always (if you don't want to use the 1.2 compat version)
inspire yourself from the implementation of the function for 1.2 compat :
https://github.com/mootools/mootools-core/blob/025adc07dc7e9851f30b3911961d43d525d83847/Source/Types/Function.js#L74
I have to admit the doc for 1.3 only mention that this method is deprecated.

How do I detect a HTML5 drag event entering and leaving the window, like Gmail does?

I'd like to be able to highlight the drop area as soon as the cursor carrying a file enters the browser window, exactly the way Gmail does it. But I can't make it work, and I feel like I'm just missing something really obvious.
I keep trying to do something like this:
this.body = $('body').get(0)
this.body.addEventListener("dragenter", this.dragenter, true)
this.body.addEventListener("dragleave", this.dragleave, true)`
But that fires the events whenever the cursor moves over and out of elements other than BODY, which makes sense, but absolutely doesn't work. I could place an element on top of everything, covering the entire window and detect on that, but that'd be a horrible way to go about it.
What am I missing?
I solved it with a timeout (not squeaky-clean, but works):
var dropTarget = $('.dropTarget'),
html = $('html'),
showDrag = false,
timeout = -1;
html.bind('dragenter', function () {
dropTarget.addClass('dragging');
showDrag = true;
});
html.bind('dragover', function(){
showDrag = true;
});
html.bind('dragleave', function (e) {
showDrag = false;
clearTimeout( timeout );
timeout = setTimeout( function(){
if( !showDrag ){ dropTarget.removeClass('dragging'); }
}, 200 );
});
My example uses jQuery, but it's not necessary. Here's a summary of what's going on:
Set a flag (showDrag) to true on dragenter and dragover of the html (or body) element.
On dragleave set the flag to false. Then set a brief timeout to check if the flag is still false.
Ideally, keep track of the timeout and clear it before setting the next one.
This way, each dragleave event gives the DOM enough time for a new dragover event to reset the flag. The real, final dragleave that we care about will see that the flag is still false.
Modified version from Rehmat (thx)
I liked this idea and instead of writing a new answer, I am updating it here itself. It can be made more precise by checking window dimensions.
var body = document.querySelector("body");
body.ondragleave = (e) => {
if (
e.clientX >= 0 && e.clientX <= body.clientWidth
&& e.clientY >= 0 && e.clientY <= body.clientHeight
) {} else {
// do something here
}
}
Old Version
Don't know it this works for all cases but in my case it worked very well
$('body').bind("dragleave", function(e) {
if (!e.originalEvent.clientX && !e.originalEvent.clientY) {
//outside body / window
}
});
Adding the events to document seemed to work? Tested with Chrome, Firefox, IE 10.
The first element that gets the event is <html>, which should be ok I think.
var dragCount = 0,
dropzone = document.getElementById('dropzone');
function dragenterDragleave(e) {
e.preventDefault();
dragCount += (e.type === "dragenter" ? 1 : -1);
if (dragCount === 1) {
dropzone.classList.add('drag-highlight');
} else if (dragCount === 0) {
dropzone.classList.remove('drag-highlight');
}
};
document.addEventListener("dragenter", dragenterDragleave);
document.addEventListener("dragleave", dragenterDragleave);
Here's another solution. I wrote it in React, but I'll explain it at the end if you want to rebuild it in plain JS. It's similar to other answers here, but perhaps slightly more refined.
import React from 'react';
import styled from '#emotion/styled';
import BodyEnd from "./BodyEnd";
const DropTarget = styled.div`
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
pointer-events: none;
background-color:rgba(0,0,0,.5);
`;
function addEventListener<K extends keyof DocumentEventMap>(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions) {
document.addEventListener(type, listener, options);
return () => document.removeEventListener(type, listener, options);
}
function setImmediate(callback: (...args: any[]) => void, ...args: any[]) {
let cancelled = false;
Promise.resolve().then(() => cancelled || callback(...args));
return () => {
cancelled = true;
};
}
function noop(){}
function handleDragOver(ev: DragEvent) {
ev.preventDefault();
ev.dataTransfer!.dropEffect = 'copy';
}
export default class FileDrop extends React.Component {
private listeners: Array<() => void> = [];
state = {
dragging: false,
}
componentDidMount(): void {
let count = 0;
let cancelImmediate = noop;
this.listeners = [
addEventListener('dragover',handleDragOver),
addEventListener('dragenter',ev => {
ev.preventDefault();
if(count === 0) {
this.setState({dragging: true})
}
++count;
}),
addEventListener('dragleave',ev => {
ev.preventDefault();
cancelImmediate = setImmediate(() => {
--count;
if(count === 0) {
this.setState({dragging: false})
}
})
}),
addEventListener('drop',ev => {
ev.preventDefault();
cancelImmediate();
if(count > 0) {
count = 0;
this.setState({dragging: false})
}
}),
]
}
componentWillUnmount(): void {
this.listeners.forEach(f => f());
}
render() {
return this.state.dragging ? <BodyEnd><DropTarget/></BodyEnd> : null;
}
}
So, as others have observed, the dragleave event fires before the next dragenter fires, which means our counter will momentarily hit 0 as we drag files (or whatever) around the page. To prevent that, I've used setImmediate to push the event to the bottom of JavaScript's event queue.
setImmediate isn't well supported, so I wrote my own version which I like better anyway. I haven't seen anyone else implement it quite like this. I use Promise.resolve().then to move the callback to the next tick. This is faster than setImmediate(..., 0) and simpler than many of the other hacks I've seen.
Then the other "trick" I do is to clear/cancel the leave event callback when you drop a file just in case we had a callback pending -- this will prevent the counter from going into the negatives and messing everything up.
That's it. Seems to work very well in my initial testing. No delays, no flashing of my drop target.
Can get the file count too with ev.dataTransfer.items.length
#tyler's answer is the best! I have upvoted it. After spending so many hours I got that suggestion working exactly as intended.
$(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
});
Your third argument to addEventListener is true, which makes the listener run during capture phase (see http://www.w3.org/TR/DOM-Level-3-Events/#event-flow for a visualization). This means that it will capture the events intended for its descendants - and for the body that means all elements on the page. In your handlers, you'll have to check if the element they're triggered for is the body itself. I'll give you my very dirty way of doing it. If anyone knows a simpler way that actually compares elements, I'd love to see it.
this.dragenter = function() {
if ($('body').not(this).length != 0) return;
... functional code ...
}
This finds the body and removes this from the set of elements found. If the set isn't empty, this wasn't the body, so we don't like this and return. If this is body, the set will be empty and the code executes.
You can try with a simple if (this == $('body').get(0)), but that will probably fail miserably.
I was having trouble with this myself and came up with a usable solution, though I'm not crazy about having to use an overlay.
Add ondragover, ondragleave and ondrop to window
Add ondragenter, ondragleave and ondrop to an overlay and a target element
If drop occurs on the window or overlay, it is ignored, whereas the target handles the drop as desired. The reason we need an overlay is because ondragleave triggers every time an element is hovered, so the overlay prevents that from happening, while the drop zone is given a higher z-index so that the files can be dropped. I am using some code snippets found in other drag and drop related questions, so I cannot take full credit. Here's the full HTML:
<!DOCTYPE html>
<html>
<head>
<title>Drag and Drop Test</title>
<meta http-equiv="X-UA-Compatible" content="chrome=1" />
<style>
#overlay {
display: none;
left: 0;
position: absolute;
top: 0;
z-index: 100;
}
#drop-zone {
background-color: #e0e9f1;
display: none;
font-size: 2em;
padding: 10px 0;
position: relative;
text-align: center;
z-index: 150;
}
#drop-zone.hover {
background-color: #b1c9dd;
}
output {
bottom: 10px;
left: 10px;
position: absolute;
}
</style>
<script>
var windowInitialized = false;
var overlayInitialized = false;
var dropZoneInitialized = false;
function handleFileSelect(e) {
e.preventDefault();
var files = e.dataTransfer.files;
var output = [];
for (var i = 0; i < files.length; i++) {
output.push('<li>',
'<strong>', escape(files[i].name), '</strong> (', files[i].type || 'n/a', ') - ',
files[i].size, ' bytes, last modified: ',
files[i].lastModifiedDate ? files[i].lastModifiedDate.toLocaleDateString() : 'n/a',
'</li>');
}
document.getElementById('list').innerHTML = '<ul>' + output.join('') + '</ul>';
}
window.onload = function () {
var overlay = document.getElementById('overlay');
var dropZone = document.getElementById('drop-zone');
dropZone.ondragenter = function () {
dropZoneInitialized = true;
dropZone.className = 'hover';
};
dropZone.ondragleave = function () {
dropZoneInitialized = false;
dropZone.className = '';
};
dropZone.ondrop = function (e) {
handleFileSelect(e);
dropZoneInitialized = false;
dropZone.className = '';
};
overlay.style.width = (window.innerWidth || document.body.clientWidth) + 'px';
overlay.style.height = (window.innerHeight || document.body.clientHeight) + 'px';
overlay.ondragenter = function () {
if (overlayInitialized) {
return;
}
overlayInitialized = true;
};
overlay.ondragleave = function () {
if (!dropZoneInitialized) {
dropZone.style.display = 'none';
}
overlayInitialized = false;
};
overlay.ondrop = function (e) {
e.preventDefault();
dropZone.style.display = 'none';
};
window.ondragover = function (e) {
e.preventDefault();
if (windowInitialized) {
return;
}
windowInitialized = true;
overlay.style.display = 'block';
dropZone.style.display = 'block';
};
window.ondragleave = function () {
if (!overlayInitialized && !dropZoneInitialized) {
windowInitialized = false;
overlay.style.display = 'none';
dropZone.style.display = 'none';
}
};
window.ondrop = function (e) {
e.preventDefault();
windowInitialized = false;
overlayInitialized = false;
dropZoneInitialized = false;
overlay.style.display = 'none';
dropZone.style.display = 'none';
};
};
</script>
</head>
<body>
<div id="overlay"></div>
<div id="drop-zone">Drop files here</div>
<output id="list"><output>
</body>
</html>
I see a lot of overengineered solutions out there. You should be able to achieve this by simply listening to dragenter and dragleave as your gut seemingly told you.
The tricky part is that when dragleave fires, it seems to have its toElement and fromElement inverted from what makes sense in everyday life (which kind of makes sense in logical terms since it's the inverted action of dragenter).
Bottom-line when you move the cursor from the listening element to outside that element, toElement will have the listening element and fromElement will have the outer non-listening element. In our case, fromElement will be null when we drag outside the browser.
Solution
window.addEventListener("dragleave", function(e){
if (!e.fromElement){
console.log("Dragging back to OS")
}
})
window.addEventListener("dragenter", function(e){
console.log("Dragging to browser")
})
The ondragenter is fired quite often. You can avoid using a helper variable like draggedFile. If you don't care how often your on ondragenter function is being called, you can remove that helper variable.
Solution:
let draggedFile = false;
window.ondragenter = (e) => {
if(!draggedFile) {
draggedFile = true;
console.log("dragenter");
}
}
window.ondragleave = (e) => {
if (!e.fromElement && draggedFile) {
draggedFile = false;
console.log("dragleave");
}
}
Have you noticed that there is a delay before the dropzone disappears in Gmail? My guess is that they have it disappear on a timer (~500ms) that gets reset by dragover or some such event.
The core of the problem you described is that dragleave is triggered even when you drag into a child element. I'm trying to find a way to detect this, but I don't have an elegantly clean solution yet.
really sorry to post something that is angular & underscore specific, however the way i solved the problem (HTML5 spec, works on chrome) should be easy to observe.
.directive('documentDragAndDropTrigger', function(){
return{
controller: function($scope, $document){
$scope.drag_and_drop = {};
function set_document_drag_state(state){
$scope.$apply(function(){
if(state){
$document.context.body.classList.add("drag-over");
$scope.drag_and_drop.external_dragging = true;
}
else{
$document.context.body.classList.remove("drag-over");
$scope.drag_and_drop.external_dragging = false;
}
});
}
var drag_enters = [];
function reset_drag(){
drag_enters = [];
set_document_drag_state(false);
}
function drag_enters_push(event){
var element = event.target;
drag_enters.push(element);
set_document_drag_state(true);
}
function drag_leaves_push(event){
var element = event.target;
var position_in_drag_enter = _.find(drag_enters, _.partial(_.isEqual, element));
if(!_.isUndefined(position_in_drag_enter)){
drag_enters.splice(position_in_drag_enter,1);
}
if(_.isEmpty(drag_enters)){
set_document_drag_state(false);
}
}
$document.bind("dragenter",function(event){
console.log("enter", "doc","drag", event);
drag_enters_push(event);
});
$document.bind("dragleave",function(event){
console.log("leave", "doc", "drag", event);
drag_leaves_push(event);
console.log(drag_enters.length);
});
$document.bind("drop",function(event){
reset_drag();
console.log("drop","doc", "drag",event);
});
}
};
})
I use a list to represent the elements that have triggered a drag enter event. when a drag leave event happens i find the element in the drag enter list that matches, remove it from the list, and if the resulting list is empty i know that i have dragged outside of the document/window.
I need to reset the list containing dragged over elements after a drop event occurs, or the next time I start dragging something the list will be populated with elements from the last drag and drop action.
I have only tested this on chrome so far. I made this because Firefox and chrome have different API implementations of HTML5 DND. (drag and drop).
really hope this helps some people.
When the file enters and leaves child elements it fires additional dragenter and dragleave so you need to count up and down.
var count = 0
document.addEventListener("dragenter", function() {
if (count === 0) {
setActive()
}
count++
})
document.addEventListener("dragleave", function() {
count--
if (count === 0) {
setInactive()
}
})
document.addEventListener("drop", function() {
if (count > 0) {
setInactive()
}
count = 0
})
I found out from looking at the spec that if the evt.dataTransfer.dropEffect on dragEnd match none then it's a cancelation.
I did already use that event to handle copying without affecting the clipboard. so this was good for me.
When I hit Esc then the drop effect was equal to none
window.ondragend = evt => {
if (evt.dataTransfer.dropEffect === 'none') abort
if (evt.dataTransfer.dropEffect === 'copy') copy // user holds alt on mac
if (evt.dataTransfer.dropEffect === 'move') move
}
on "dropend" event you can check the value of the document.focus() was the magic trick in my case.