Drag and drop is almost a goto way for various user interactions. More often than not, drag and drop functionality required is quite simple and easily solved by jQuery UI or other frameworks. Things start to get murky when you are working with an iframe (as always!) or need to mould the drag and drop to your needs.

Thankfully, HTML5 brought a native way to handle drag and drop events which should have made things simpler but that wasn’t the case. HTML5 Drag and Drop is still quite raw and full of gray areas. It doesn’t help that debugging drag and drop is quite painful most of the times.

Creating a Drag and Drop interface (like StackHive) required a lot of fiddling around with the HTML5 DnD API. After a lot searching around for articles and resources that would be more relevant for my use case, I came up short. So, after spending a lot of time on it I decided to roll it out as an open source plugin.

I recently worked on improving the drag and drop functionality inside StackHive to create a “Precision Drag and Drop” which enables -

DnD across frames Creates drop marker with precision Adds visual context to show where the new element will be added DnD is independent of child frame (so you can add any page inside the iframe as long as it is on the same domain!)

There are a zillion other resources which you can use to get started with HTML5 DnD API, so I’ll skip that part here and dive right into the good stuff! (Just Google HTML5 Drag and Drop if you are not familiar with that..)

HTML5 DnD Brief Recap Events - dragstart, dragenter, dragover, dragleave, drop, dragend Working with Data - event.DataTransfer.setData() event.DataTransfer.getData() So let’s get this party started!

Creating a basic cross frame HTML5 drag and drop Our goal here is to create a cross frame Drag and Drop Interface and keep the DnD API independent of the child frame. Here I am using jQuery as a selection library and rest everything is native Javascript. So, here we have 2 HTML pages - interface.html and clientpage.html Interface.html It contains a list of draggable elements and an iframe which includes the clientpage.html <div id="dragitemslist"> <h3>Draggable Item List</h3> <div> <ul id="dragitemslistcontainer"> <li draggable="true"><i class="fa fa-header"></i><p>Header</p></li> <li draggable="true"><i class="fa fa-bar-chart"></i><p>Chart</p></li> <li draggable="true"><i class="fa fa-envelope"></i><p>Contact</p></li> </ul> </div> </div> <div id="clientframe-container"> <iframe id="clientframe" src="./client.html" frameborder="0"></iframe> </div> It also contains a JS file which is orchestrating all the DnD Events. $(function() { var clientFrameWindow = $('#clientframe').get(0).contentWindow; $("#dragitemslistcontainer li").on('dragstart',function() { console.log("Drag Started"); }); $("#dragitemslistcontainer li").on('dragend',function() { console.log("Drag End"); }); $('#clientframe').load(function() { var total = 0; $(clientFrameWindow.document.body).find('*').on('dragenter',function(event) { event.preventDefault(); event.stopPropagation(); console.log('Drag Enter'); total +=1; }).on('dragover',function(event) { event.preventDefault(); event.stopPropagation(); console.log('Drag Over'); total +=1; }); $(clientFrameWindow.document).find('body,html').on('drop',function(event) { event.preventDefault(); event.stopPropagation(); console.log('Drop event'); total +=1; console.log("Total Events Fired = "+total); total = 0; }); }); }); Clientpage.html This is a target page for the elements to be dropped in. I am using a simple Bootstrap Template here. So this is a basic setup which allows dragging stuff from interface.html and dropping it inside clientpage.html Creating a marker to show current drop position Now that we have our basic setup, next step is to add drop marker for visual feedback. We can use the dragover event to find out the element which is currently hovered. Once we have that, we can figure out the exact position where the marker should be inserted To get started, let’s add the marker inside the element which gets the dragover event. $(clientFrameWindow.document.body).find('*').on('dragover',function(event) { event.preventDefault(); event.stopPropagation(); $(clientFrameWindow.document.body).find('.reserved-drop-marker').remove(); $(event.target).append("<p class='reserved-drop-marker'></p>"); console.log('Drag Over'); })

Too many events! Dragover event gets fired whenever the mouse moves which results in too many events which can be very costly and make the UI super slow. In order to prevent this from happening, we need to filter out the events and process only the a few of them instead. A simple way would be to create a stack and only process the latest one every 100ms or when the mouse enters another element (can track using dragenter event). var htmlBody = $(clientFrameWindow.document).find('body,html'); htmlBody.find('*').andSelf().on('dragenter',function(event) { event.stopPropagation(); currentElement = $(event.target); currentElementChangeFlag = true; //Change Flag to process DragOver Queue elementRectangle = event.target.getBoundingClientRect(); countdown = 1; }).on('dragover',function(event) { event.preventDefault(); event.stopPropagation(); //Process every 15th Event only to decrease the load if(countdown%15 != 0 && currentElementChangeFlag == false) { countdown = countdown+1; return; } event = event || window.event; var x = event.originalEvent.clientX; var y = event.originalEvent.clientY; countdown = countdown+1; currentElementChangeFlag = false; var mousePosition = {x:x,y:y}; DragDropFunctions.AddEntryToDragOverQueue(currentElement,elementRectangle,mousePosition) }) var DragDropFunctions = { dragoverqueue : [], AddEntryToDragOverQueue : function($element,elementRect,mousePos) { var newEvent = [$element,elementRect,mousePos]; this.dragoverqueue.push(newEvent); }, ProcessDragOverQueue : function($element,elementRect,mousePos) { var processing = this.dragoverqueue.pop(); this.dragoverqueue = []; if(processing && processing.length == 3) { var $el = processing[0]; var $elRect = processing[1]; var mousePos = processing[2]; this.OrchestrateDragDrop($el, $elRect, mousePos); } } }

Marker Precision (The Soul of Precision Drag and Drop) The marker needs to be more precise to facilitate a better drag and drop interface. We can utilize the coordinates of the mouse to figure out where the marker needs to be. To facilitate this, we can create multiple cases which would enable us to narrow down specific cases. For example, Decide whether to prepend or append the marker depending on the mouse position relative to the hovered element. If the hovered element has no child elements (like a Text Block), we can insert before or after the element instead of inserting inside the Text Block. If the hovered element has only 1 child element, we can safely place the marker before or after the child element. If the hovered element has more than one child element inside it, we can find the element which is nearest to the mouse position and then insert before or after that element - This is the key part which enables the marker to change with the mouse. When the mouse is hovered on the boundary of an element, find the closest valid parent element where the marker should be added. Since Drag and Drop is UI driven, we can always improve these cases and make it more or less specific depending on the use case. var DragDropFunctions = { dragoverqueue : [], GetMouseBearingsPercentage : function($element,elementRect,mousePos) { if(!elementRect) elementRect = $element.get(0).getBoundingClientRect(); var mousePosPercent_X = ((mousePos.x-elementRect.left)/(elementRect.right-elementRect.left))*100; var mousePosPercent_Y = ((mousePos.y-elementRect.top) /(elementRect.bottom-elementRect.top))*100; return {x:mousePosPercent_X,y:mousePosPercent_Y}; }, OrchestrateDragDrop : function($element, elementRect, mousePos) { //If no element is hovered or element hovered is the placeholder -> not valid -> return false; if(!$element || $element.length == 0 || !elementRect || !mousePos) return false; if($element.is('html')) $element = $element.find('body'); //Top and Bottom Area Percentage to trigger different case. [5% of top and bottom area gets reserved for this] var breakPointNumber = {x:5,y:5}; var mousePercents = this.GetMouseBearingsPercentage($element,elementRect,mousePos); if((mousePercents.x > breakPointNumber.x && mousePercents.x < 100-breakPointNumber.x) && (mousePercents.y > breakPointNumber.y && mousePercents.y < 100-breakPointNumber.y)) { //Case 1 - $tempelement = $element.clone(); $tempelement.find(".drop-marker").remove(); if($tempelement.html() == "" && !this.checkVoidElement($tempelement)) { if(mousePercents.y < 90) return this.PlaceInside($element); } else if($tempelement.children().length == 0) { //text element detected //console.log("Text Element"); this.DecideBeforeAfter($element,mousePercents); } else if($tempelement.children().length == 1) { //only 1 child element detected //console.log("1 Child Element"); this.DecideBeforeAfter($element.children(":not(.drop-marker,[data-dragcontext-marker])").first(),mousePercents); } else { var positionAndElement = this.findNearestElement($element,mousePos.x,mousePos.y); this.DecideBeforeAfter(positionAndElement.el,mousePercents,mousePos); //more than 1 child element present //console.log("More than 1 child detected"); } } else if((mousePercents.x <= breakPointNumber.x) || (mousePercents.y <= breakPointNumber.y)) { var validElement = null if(mousePercents.y <= mousePercents.x) validElement = this.FindValidParent($element,'top'); else validElement = this.FindValidParent($element,'left'); if(validElement.is("body,html")) validElement = $("#clientframe").contents().find("body").children(":not(.drop-marker,[data-dragcontext-marker])").first(); this.DecideBeforeAfter(validElement,mousePercents,mousePos); } else if((mousePercents.x >= 100-breakPointNumber.x) || (mousePercents.y >= 100-breakPointNumber.y)) { var validElement = null if(mousePercents.y >= mousePercents.x) validElement = this.FindValidParent($element,'bottom'); else validElement = this.FindValidParent($element,'right'); if(validElement.is("body,html")) validElement = $("#clientframe").contents().find("body").children(":not(.drop-marker,[data-dragcontext-marker])").last(); this.DecideBeforeAfter(validElement,mousePercents,mousePos); } }, DecideBeforeAfter : function($targetElement,mousePercents,mousePos) { if(mousePos) { mousePercents = this.GetMouseBearingsPercentage($targetElement,null,mousePos); } /*if(!mousePercents) { mousePercents = this.GetMouseBearingsPercentage($targetElement, $targetElement.get(0).getBoundingClientRect(), mousePos); } */ $orientation = ($targetElement.css('display') == "inline" || $targetElement.css('display') == "inline-block"); if($targetElement.is("br")) $orientation = false; if($orientation) { if(mousePercents.x < 50) { return this.PlaceBefore($targetElement); } else { return this.PlaceAfter($targetElement); } } else { if(mousePercents.y < 50) { return this.PlaceBefore($targetElement); } else { return this.PlaceAfter($targetElement); } } }, checkVoidElement : function($element) { var voidelements = ['i','area','base','br','col','command','embed','hr','img','input','keygen','link','meta','param','video','iframe','source','track','wbr']; var selector = voidelements.join(",") if($element.is(selector)) return true; else return false; }, calculateDistance : function(elementData, mouseX, mouseY) { return Math.sqrt(Math.pow(elementData.x - mouseX, 2) + Math.pow(elementData.y - mouseY, 2)); }, FindValidParent : function($element,direction) { switch(direction) { case "left": while(true) { var elementRect = $element.get(0).getBoundingClientRect(); var $tempElement = $element.parent(); var tempelementRect = $tempElement.get(0).getBoundingClientRect(); if($element.is("body")) return $element; if(Math.abs(tempelementRect.left - elementRect.left) == 0) $element = $element.parent(); else return $element; } break; case "right": while(true) { var elementRect = $element.get(0).getBoundingClientRect(); var $tempElement = $element.parent(); var tempelementRect = $tempElement.get(0).getBoundingClientRect(); if($element.is("body")) return $element; if(Math.abs(tempelementRect.right - elementRect.right) == 0) $element = $element.parent(); else return $element; } break; case "top": while(true) { var elementRect = $element.get(0).getBoundingClientRect(); var $tempElement = $element.parent(); var tempelementRect = $tempElement.get(0).getBoundingClientRect(); if($element.is("body")) return $element; if(Math.abs(tempelementRect.top - elementRect.top) == 0) $element = $element.parent(); else return $element; } break; case "bottom": while(true) { var elementRect = $element.get(0).getBoundingClientRect(); var $tempElement = $element.parent(); var tempelementRect = $tempElement.get(0).getBoundingClientRect(); if($element.is("body")) return $element; if(Math.abs(tempelementRect.bottom - elementRect.bottom) == 0) $element = $element.parent(); else return $element; } break; } }, addPlaceHolder : function($element,position,placeholder) { if(!placeholder) placeholder = this.getPlaceHolder(); this.removePlaceholder(); switch(position) { case "before": placeholder.find(".message").html($element.parent().data('sh-dnd-error')); $element.before(placeholder); console.log($element); console.log("BEFORE"); this.AddContainerContext($element,'sibling'); break; case "after": placeholder.find(".message").html($element.parent().data('sh-dnd-error')); $element.after(placeholder); console.log($element); console.log("AFTER"); this.AddContainerContext($element,'sibling'); break case "inside-prepend": placeholder.find(".message").html($element.data('sh-dnd-error')); $element.prepend(placeholder); this.AddContainerContext($element,'inside'); console.log($element); console.log("PREPEND"); break; case "inside-append": placeholder.find(".message").html($element.data('sh-dnd-error')); $element.append(placeholder); this.AddContainerContext($element,'inside'); console.log($element); console.log("APPEND"); break; } }, removePlaceholder : function() { $("#clientframe").contents().find(".drop-marker").remove(); }, getPlaceHolder : function() { return $("<li class='drop-marker'></li>"); }, PlaceInside : function($element) { var placeholder = this.getPlaceHolder(); placeholder.addClass('horizontal').css('width',$element.width()+"px"); this.addPlaceHolder($element,"inside-append",placeholder); }, PlaceBefore : function($element) { var placeholder = this.getPlaceHolder(); var inlinePlaceholder = ($element.css('display') == "inline" || $element.css('display') == "inline-block"); if($element.is("br")) { inlinePlaceholder = false; } else if($element.is("td,th")) { placeholder.addClass('horizontal').css('width',$element.width()+"px"); return this.addPlaceHolder($element,"inside-prepend",placeholder); } if(inlinePlaceholder) placeholder.addClass("vertical").css('height',$element.innerHeight()+"px"); else placeholder.addClass("horizontal").css('width',$element.parent().width()+"px"); this.addPlaceHolder($element,"before",placeholder); }, PlaceAfter : function($element) { var placeholder = this.getPlaceHolder(); var inlinePlaceholder = ($element.css('display') == "inline" || $element.css('display') == "inline-block"); if($element.is("br")) { inlinePlaceholder = false; } else if($element.is("td,th")) { placeholder.addClass('horizontal').css('width',$element.width()+"px"); return this.addPlaceHolder($element,"inside-append",placeholder); } if(inlinePlaceholder) placeholder.addClass("vertical").css('height',$element.innerHeight()+"px"); else placeholder.addClass("horizontal").css('width',$element.parent().width()+"px"); this.addPlaceHolder($element,"after",placeholder); }, findNearestElement : function($container,clientX,clientY) { var _this = this; var previousElData = null; var childElement = $container.children(":not(.drop-marker,[data-dragcontext-marker])"); if(childElement.length > 0) { childElement.each(function() { if($(this).is(".drop-marker")) return; var offset = $(this).get(0).getBoundingClientRect(); var distance = 0; var distance1,distance2 = null; var position = ''; var xPosition1 = offset.left; var xPosition2 = offset.right; var yPosition1 = offset.top; var yPosition2 = offset.bottom; var corner1 = null; var corner2 = null; //Parellel to Yaxis and intersecting with x axis if(clientY > yPosition1 && clientY < yPosition2 ) { if(clientX < xPosition1 && clientY < xPosition2) { corner1 = {x:xPosition1, y:clientY,'position':'before'}; } else { corner1 = {x:xPosition2, y:clientY,'position':'after'}; } } //Parellel to xAxis and intersecting with Y axis else if(clientX > xPosition1 && clientX < xPosition2) { if(clientY < yPosition1 && clientY < yPosition2) { corner1 = {x:clientX, y:yPosition1,'position':'before'}; } else { corner1 = {x:clientX, y:yPosition2,'position':'after'}; } } else { //runs if no element found! if(clientX < xPosition1 && clientX < xPosition2) { corner1 = {x:xPosition1, y:yPosition1,'position':'before'}; //left top corner2 = {x:xPosition1, y :yPosition2,'position':'after'}; //left bottom } else if(clientX > xPosition1 && clientX > xPosition2) { //console.log('I m on the right of the element'); corner1 = {x:xPosition2, y:yPosition1,'position':'before'}; //Right top corner2 = {x:xPosition2, y :yPosition2,'position':'after'}; //Right Bottom } else if(clientY < yPosition1 && clientY < yPosition2) { // console.log('I m on the top of the element'); corner1 = {x :xPosition1, y:yPosition1,'position':'before'}; //Top Left corner2 = {x :xPosition2, y:yPosition1,'position':'after'}; //Top Right } else if(clientY > yPosition1 && clientY > yPosition2) { // console.log('I m on the bottom of the element'); corner1 = {x :xPosition1, y:yPosition2,'position':'before'}; //Left bottom corner2 = {x :xPosition2, y:yPosition2,'position':'after'} //Right Bottom } } distance1 = _this.calculateDistance(corner1, clientX, clientY); if(corner2 !== null) distance2 = _this.calculateDistance(corner2, clientX, clientY); if(distance1 < distance2 || distance2 === null) { distance = distance1; position = corner1.position; } else { distance = distance2; position = corner2.position; } if(previousElData !== null) { if(previousElData.distance < distance) { return true; //continue statement } } previousElData = {'el':this,'distance':distance,'xPosition1':xPosition1,'xPosition2':xPosition2,'yPosition1':yPosition1,'yPosition2':yPosition2, 'position':position} }); if(previousElData !== null) { var position = previousElData.position; return {'el':$(previousElData.el),'position':position}; } else { return false; } } }, AddEntryToDragOverQueue : function($element,elementRect,mousePos) { var newEvent = [$element,elementRect,mousePos]; this.dragoverqueue.push(newEvent); }, ProcessDragOverQueue : function($element,elementRect,mousePos) { var processing = this.dragoverqueue.pop(); this.dragoverqueue = []; if(processing && processing.length == 3) { var $el = processing[0]; var $elRect = processing[1]; var mousePos = processing[2]; this.OrchestrateDragDrop($el, $elRect, mousePos); } } } Check out Drop Marker with Precision -

Managing Drop and Dragend Events So now that we have our marker precision figured out, all we need to do is handle the drop and dragend events to insert the dropped element at the marker position. You can use a lot of other approaches to link the dragged element and the HTML that the element should insert. I am using data-attr to do so. Once the element is dropped, all we need to do is replace the marker with the HTML that should be interested there. $(clientFrameWindow.document).find('body,html').on('drop',function(event) { event.preventDefault(); event.stopPropagation(); console.log('Drop event'); var e; if (event.isTrigger) e = triggerEvent.originalEvent; else var e = event.originalEvent; try { var textData = e.dataTransfer.getData('text'); var insertionPoint = $("#clientframe").contents().find(".drop-marker"); var checkDiv = $(textData); insertionPoint.after(checkDiv); insertionPoint.remove(); } catch(e) { console.log(e); } }); Dragend event can be utilized to clear up any remaining setup that we used to facilitate drag and drop. In this case, we can remove the marker from the page from the dragend event to keep things organized. $("#dragitemslistcontainer li").on('dragend',function() { console.log("Drag End"); clearInterval(dragoverqueue_processtimer); DragDropFunctions.removePlaceholder(); //clear drop marker }); Check out Drop Event in Action -

Adding Context Container For Visual Feedback So far, there is almost no way to figure out which HTML element is containing the marker. This is very important when working with nested HTML layouts. So, we need to create a visual marker to show the parent element. This is quite simple and independent of the actual drag and drop. We can create an absolute positioned element on the page with the exact height/width of the targeted element and place it directly on top of it. The key here is pointer-events:none which enables this context marker to not get in the way of drag and drop by not receiving any mouse events. GetContextMarker : function() { $contextMarker = $("<div data-dragcontext-marker><span data-dragcontext-marker-text></span></div>"); return $contextMarker; }, AddContainerContext : function($element,position) { $contextMarker = this.GetContextMarker(); this.ClearContainerContext(); if($element.is('html,body')) { position = 'inside'; $element = $("#clientframe").contents().find("body"); } switch(position) { case "inside": this.PositionContextMarker($contextMarker,$element); if($element.hasClass('stackhive-nodrop-zone')) $contextMarker.addClass('invalid'); var name = this.getElementName($element); $contextMarker.find('[data-dragcontext-marker-text]').html(name); if($("#clientframe").contents().find("body [data-sh-parent-marker]").length != 0) $("#clientframe").contents().find("body [data-sh-parent-marker]").first().before($contextMarker); else $("#clientframe").contents().find("body").append($contextMarker); break; case "sibling": this.PositionContextMarker($contextMarker,$element.parent()); if($element.parent().hasClass('stackhive-nodrop-zone')) $contextMarker.addClass('invalid'); var name = this.getElementName($element.parent()); $contextMarker.find('[data-dragcontext-marker-text]').html(name); $contextMarker.attr("data-dragcontext-marker",name.toLowerCase()); if($("#clientframe").contents().find("body [data-sh-parent-marker]").length != 0) $("#clientframe").contents().find("body [data-sh-parent-marker]").first().before($contextMarker); else $("#clientframe").contents().find("body").append($contextMarker); break; } }, PositionContextMarker : function($contextMarker,$element) { var rect = $element.get(0).getBoundingClientRect(); $contextMarker.css({ height: (rect.height + 4) +"px", width: (rect.width + 4) +"px", top: (rect.top+$($("#clientframe").get(0).contentWindow).scrollTop() - 2) +"px", left: (rect.left+$($("#clientframe").get(0).contentWindow).scrollLeft() - 2)+"px" }); if(rect.top+$("#clientframe").contents().find("body").scrollTop() < 24) $contextMarker.find("[data-dragcontext-marker-text]").css('top','0px'); }, ClearContainerContext : function() { $("#clientframe").contents().find('[data-dragcontext-marker]').remove(); }, getElementName : function($element) { return $element.prop('tagName'); } Container Context makes it a lot easier to identify the drop position -