Precise Drag and Drop within a contenteditable Precise Drag and Drop within a contenteditable javascript javascript

Precise Drag and Drop within a contenteditable


Dragon Drop

I've done a ridiculous amount of fiddling. So, so much jsFiddling.

This is not a robust, or complete solution; I may never quite come up with one. If anyone has any better solutions, I'm all ears -- I didn't want to have to do it this way, but it's the only way I've been able to uncover so far. The following jsFiddle, and the information I am about to vomit up, worked for me in this particular instance with my particular versions of Firefox and Chrome on my particular WAMP setup and computer. Don't come crying to me when it doesn't work on your website. This drag-and-drop crap is clearly every man for himself.

jsFiddle: Chase Moskal's Dragon Drop

So, I was boring my girlfriend's brains out, and she thought I kept saying "dragon drop" when really, I was just saying "drag-and-drop". It stuck, so that's what I call my little JavaScript buddy I've created for handling these drag-and-drop situations.

Turns out -- it's a bit of a nightmare. The HTML5 Drag-and-Drop API even at first glance, is horrible. Then, you almost warm up to it, as you start to understand and accept the way it's supposed to work.. Then you realize what a terrifying nightmare it actually is, as you learn how Firefox and Chrome go about this specification in their own special way, and seem to completely ignore all of your needs. You find yourself asking questions like: "Wait, what element is even being dragged right now? How to do I get that information? How do I cancel this drag operation? How can I stop this particular browser's unique default handling of this situation?"... The answers to your questions: "You're on your own, LOSER! Keep hacking things in, until something works!".

So, here's how I accomplished Precise Drag and Drop of Arbitrary HTML Elements within, around, and between multiple contenteditable's. (note: I'm not going fully in-depth with every detail, you'll have to look at the jsFiddle for that -- I'm just rambling off seemingly relevant details that I remember from the experience, as I have limited time)

My Solution

  • First, I applied CSS to the draggables (fancybox) -- we needed user-select:none; user-drag:element; on the fancy box, and then specifically user-drag:none; on the image within the fancy box (and any other elements, why not?). Unfortunately, this was not quite enough for Firefox, which required attribute draggable="false" to be explicitly set on the image to prevent it from being draggable.
  • Next, I applied attributes draggable="true" and dropzone="copy" onto the contenteditables.

To the draggables (fancyboxes), I bind a handler for dragstart. We set the dataTransfer to copy a blank string of HTML ' ' -- because we need to trick it into thinking we are going to drag HTML, but we are cancelling out any default behavior. Sometimes default behavior slips in somehow, and it results in a duplicate (as we do the insertion ourselves), so now the worst glitch is a ' ' (space) being inserted when a drag fails. We couldn't rely on the default behavior, as it would fail to often, so I found this to be the most versatile solution.

DD.$draggables.off('dragstart').on('dragstart',function(event){    var e=event.originalEvent;    $(e.target).removeAttr('dragged');    var dt=e.dataTransfer,        content=e.target.outerHTML;    var is_draggable = DD.$draggables.is(e.target);    if (is_draggable) {        dt.effectAllowed = 'copy';        dt.setData('text/plain',' ');        DD.dropLoad=content;        $(e.target).attr('dragged','dragged');    }});

To the dropzones, I bind a handler for dragleave and drop. The dragleave handler exists only for Firefox, as in Firefox, the drag-drop would work (Chrome denies you by default) when you tried to drag it outside the contenteditable, so it performs a quick check against the Firefox-only relatedTarget. Huff.

Chrome and Firefox have different ways of acquiring the Range object, so effort had to be put in to do it differently for each browser in the drop event. Chrome builds a range based on mouse-coordinates (yup that's right), but Firefox provides it in the event data. document.execCommand('insertHTML',false,blah) turns out to be how we handle the drop. OH, I forgot to mention -- we can't use dataTransfer.getData() on Chrome to get our dragstart set HTML -- it appears to be some kind of weird bug in the specification. Firefox calls the spec out on it's bullcrap and gives us the data anyways -- but Chrome doesn't, so we bend over backwards and to set the content to a global, and go through hell to kill all the default behavior...

DD.$dropzones.off('dragleave').on('dragleave',function(event){    var e=event.originalEvent;    var dt=e.dataTransfer;    var relatedTarget_is_dropzone = DD.$dropzones.is(e.relatedTarget);    var relatedTarget_within_dropzone = DD.$dropzones.has(e.relatedTarget).length>0;    var acceptable = relatedTarget_is_dropzone||relatedTarget_within_dropzone;    if (!acceptable) {        dt.dropEffect='none';        dt.effectAllowed='null';    }});DD.$dropzones.off('drop').on('drop',function(event){    var e=event.originalEvent;    if (!DD.dropLoad) return false;    var range=null;    if (document.caretRangeFromPoint) { // Chrome        range=document.caretRangeFromPoint(e.clientX,e.clientY);    }    else if (e.rangeParent) { // Firefox        range=document.createRange(); range.setStart(e.rangeParent,e.rangeOffset);    }    var sel = window.getSelection();    sel.removeAllRanges(); sel.addRange(range);    $(sel.anchorNode).closest(DD.$dropzones.selector).get(0).focus(); // essential    document.execCommand('insertHTML',false,'<param name="dragonDropMarker" />'+DD.dropLoad);    sel.removeAllRanges();    // verification with dragonDropMarker    var $DDM=$('param[name="dragonDropMarker"]');    var insertSuccess = $DDM.length>0;    if (insertSuccess) {        $(DD.$draggables.selector).filter('[dragged]').remove();        $DDM.remove();    }    DD.dropLoad=null;    DD.bindDraggables();    e.preventDefault();});

Okay, I'm sick of this. I've wrote all I want to about this. I'm calling it a day, and might update this if I think of anything important.

Thanks everybody. //Chase.


Since I wanted to see this in a native JS solution I worked a bit to remove all jQuery dependencies. Hopefully it can help someone.

First the markup

    <div class="native_receiver" style="border: 2px solid red;padding: 5px;" contenteditable="true" >      WAITING  FOR STUFF    </div>    <div class="drawer" style="border: 2px solid #AAE46A; padding: 10px">      <span class="native_drag" data-type="dateselector" contenteditable="false" draggable="true" style="border: 2px solid rgba(0,0,0,0.2);padding:4px; margin:2px">        Block 1      </span>      <span class="native_drag" data-type="dateselector" contenteditable="false" draggable="true" style="border: 2px solid rgba(0,0,0,0.2);padding:4px; margin:2px">        Second Blk      </span>    </div>

Then some helpers

    function addClass( elem, className ){        var classNames = elem.className.split( " " )        if( classNames.indexOf( className ) === -1 ){            classNames.push( className )        }        elem.className = classNames.join( " " )    }    function selectElem( selector ){        return document.querySelector( selector )    }    function selectAllElems( selector ){        return document.querySelectorAll( selector )    }    function removeElem( elem ){         return elem ? elem.parentNode.removeChild( elem ) : false    }

Then the actual methods

    function nativeBindDraggable( elems = false ){        elems = elems || selectAllElems( '.native_drag' );        if( !elems ){            // No element exists, abort            return false;        }else if( elems.outerHTML ){            // if only a single element, put in array            elems = [ elems ];        }        // else it is html-collection already (as good as array)        for( let i = 0 ; i < elems.length ; i++ ){            // For every elem in list, attach or re-attach event handling            elems[i].dataset.transferreference = `transit_${ new Date().getTime() }`;            elems[i].ondragstart = function(e){                if (!e.target.id){                    e.target.id = (new Date()).getTime();                }                window.inTransferMarkup = e.target.outerHTML;                window.transferreference = elems[i].dataset.transferreference;                addClass( e.target, 'dragged');            };        };    }    function nativeBindWriteRegion( elems = false ){        elems = elems || selectAllElems( '.native_receiver' );        if( !elems ){            // No element exists, abort            return false;        }else if( elems.outerHTML ){            // if only a single element, put in array            elems = [ elems ];        }        // else it is html-collection        for( let i = 0 ; i < elems.length ; i++ ){            elems[i].ondragover = function(e){                e.preventDefault();                return false;            };            elems[i].ondrop = function(e){                receiveBlock(e);            };        }    }    function receiveBlock(e){        e.preventDefault();        let content = window.inTransferMarkup;        window.inTransferMarkup = "";        let range = null;        if (document.caretRangeFromPoint) { // Chrome            range = document.caretRangeFromPoint(e.clientX, e.clientY);        }else if (e.rangeParent) { // Firefox            range = document.createRange();            range.setStart(e.rangeParent, e.rangeOffset);        }        let sel = window.getSelection();        sel.removeAllRanges();         sel.addRange( range );        e.target.focus();        document.execCommand('insertHTML',false, content);        sel.removeAllRanges();        // reset draggable on all blocks, esp the recently created        nativeBindDraggable(          document.querySelector(            `[data-transferreference='${window.transferreference}']`          )        );        removeElem( selectElem( '.dragged' ) );        return false;    }

And lastly instantiate

nativeBindDraggable();nativeBindWriteRegion();

Below is the functioning snippet

function addClass( elem, className ){            var classNames = elem.className.split( " " )            if( classNames.indexOf( className ) === -1 ){                classNames.push( className )            }            elem.className = classNames.join( " " )        }        function selectElem( selector ){            return document.querySelector( selector )        }        function selectAllElems( selector ){            return document.querySelectorAll( selector )        }        function removeElem( elem ){             return elem ? elem.parentNode.removeChild( elem ) : false        }                  	function nativeBindDraggable( elems = false ){    		elems = elems || selectAllElems( '.native_drag' );    		if( !elems ){    			// No element exists, abort    			return false;    		}else if( elems.outerHTML ){    			// if only a single element, put in array    			elems = [ elems ];    		}    		// else it is html-collection already (as good as array)                		for( let i = 0 ; i < elems.length ; i++ ){    			// For every elem in list, attach or re-attach event handling    			elems[i].dataset.transferreference = `transit_${ new Date().getTime() }`;    			elems[i].ondragstart = function(e){    				if (!e.target.id){    					e.target.id = (new Date()).getTime();    				}    				window.inTransferMarkup = e.target.outerHTML;    				window.transferreference = elems[i].dataset.transferreference;    				addClass( e.target, 'dragged');    			};    		};    	}            	function nativeBindWriteRegion( elems = false ){    		elems = elems || selectAllElems( '.native_receiver' );    		if( !elems ){    			// No element exists, abort    			return false;    		}else if( elems.outerHTML ){    			// if only a single element, put in array    			elems = [ elems ];    		}    		// else it is html-collection    		    		for( let i = 0 ; i < elems.length ; i++ ){    			elems[i].ondragover = function(e){    				e.preventDefault();    				return false;    			};    			elems[i].ondrop = function(e){    				receiveBlock(e);    			};    		}    	}                function receiveBlock(e){    		e.preventDefault();    		let content = window.inTransferMarkup;    		    		window.inTransferMarkup = "";    		    		let range = null;    		if (document.caretRangeFromPoint) { // Chrome    			range = document.caretRangeFromPoint(e.clientX, e.clientY);    		}else if (e.rangeParent) { // Firefox    			range = document.createRange();    			range.setStart(e.rangeParent, e.rangeOffset);    		}    		let sel = window.getSelection();    		sel.removeAllRanges();     		sel.addRange( range );    		e.target.focus();    		    		document.execCommand('insertHTML',false, content);    		sel.removeAllRanges();    		            // reset draggable on all blocks, esp the recently created    		nativeBindDraggable(              document.querySelector(                `[data-transferreference='${window.transferreference}']`              )            );    		removeElem( selectElem( '.dragged' ) );    		return false;    	}    nativeBindDraggable();    nativeBindWriteRegion();
        <div class="native_receiver" style="border: 2px solid red;padding: 5px;" contenteditable="true" >          WAITING  FOR STUFF        </div>        <div class="drawer" style="border: 2px solid #AAE46A; padding: 10px">          <span class="native_drag" data-type="dateselector" contenteditable="false" draggable="true" style="border: 2px solid rgba(0,0,0,0.2);padding:4px; margin:2px">            Block 1          </span>          <span class="native_drag" data-type="dateselector" contenteditable="false" draggable="true" style="border: 2px solid rgba(0,0,0,0.2);padding:4px; margin:2px">            Second Blk          </span>        </div>