A Cross-platform Drag and Drop library for Blazor - Part 1

There is a long standing request from the Blazor development community for Microsoft to provide support for drag and drop functionality. In an article from Visual Studio Magazine (3/30/2022), author David Ramel details past efforts made towards adding this feature, but sadly, Blazor development manager Daniel Roth; "After investigating various approaches we've decided that a general purpose drag & drop feature isn't something we can easily add to the Blazor framework."

Like a few other developers, I experimented with the HTML5 Drag and Drop API, but its primary shortcoming is that it is not fully supported by some browsers, in particular mobile browsers. Roth's recommendation is to leverage JavaScript interoperability and rely on use of a JavaScript library for this functionality.

The Ramel article cites a 2021 presentation by Blazor creator Steve Sanderson which demonstrates use of the draggable.js library to create a calendar appointment application. I tried my hand at integrating draggable.js, as well as a few other well-known JavaScript libraries like lmdd.js (Lean and Mean Drag and Drop) and dragula.js (the name is rather clever) but found all of them lacking, at least for the use cases I have in mind.

The biggest problem with all of the libraries is that they mutate the Document Object Model (DOM), which according to the Blazor JavaScript interop documentation is a cardinal sin. It makes sense that you don't want to muck around under the covers with the DOM given how Blazor follows a different paradigm that says, "don't worry about the DOM, we'll manage that in our framework". Indeed, I quickly discovered that every time you call StateHasChanged() or initiated an automatic rerendering of the UI by changing parameter values, or use of an EventCallBack, any changes made by these third party libraries disappeared, or altered the drag and drop behavior in unexpected ways.

After numerous failed experiments using other peoples' libraries, I decided to create my own simple drag and drop JavaScript library that does not mutate the DOM. By using specific JavaScript events (pointerdown, pointermove and pointerup), my library displays the movement of objects on the screen, and uses a JSInvokable method to send data on what changes were made back to a .NET object. That object in turn, sends the data to one or more Blazor components which handle any changes to the UI.

I'll explain this process by walking through an example and showing the associated code. You can find all the code presented here, and a working sample in my GitHub repository. I noticed that a number of drag and drop demos are a form of draggable ToDo list. I borrowed the design of my sample from a demo created for lmdd.js. In the figure shown below, there are three colored containers labeled Tasks, High Priority and Done. I've predefined some tasks and placed some in each of the first two containers. The application is designed so that a user can drag a given task by it's handle (the menu hamburger icon on the left side of each task) to either of the other containers. In a later post, I'll demonstrate how to add some logic to limit where a task can be placed, but for now, any task can be dragged to any container.

Demo available at: https://rlebowitz.github.io/Finaltouch.DragDrop/

The two most important items in my solution are the JavaScript module, DragDropInterop, and the the C# class, DragDropInterop.cs which uses Javascript Interoperability to call specific methods and pass data to the module, and receives data sent back from the module at the end of a drop event. The post (Part I) will focus on the JavaScript module, my next blog post will discuss its C# counterpart.


function DragDropInterop() {
    let options, draggingElement, rect, sourceContainerId, sourceItemId, raf;
    let x, y, deltaX, deltaY;
    x = y = deltaX = deltaY = 0;

    /**
        Default options which can be overridden by passing in a DropDropOptions object 
     */
    const defaults = {
        componentClass: 'dd-component',
        containerClass: 'dd-container',
        itemClass: 'dd-item',
        handleClass: 'handle',
        sort: true
    };
    
    /**
     * Method used to merge DragDropOptions (C#) values with the predefined default option values.
     * Values passed in override the defaults automatically.
     * @param {any} settings
     */
    const assignOptions = function (settings) {
        var target = {};
        Object.keys(defaults).forEach(function (key) {
            target[key] = (Object.prototype.hasOwnProperty.call(settings, key) ? settings[key] : defaults[key]);
        });
        options = target;
    };

    /**
     * Public exported method used to initialize the various options associated with this module and 
     * store the DotNet object used to call methods labeled with JSInvokable.
     * @param {any} helper - the specified DotNet object
     * @param {any} jsonOptions - the serialized DragDropOptions object
     */
    this.initialize = function (helper, jsonOptions) {
        dragDropHelper = helper;
        // assign the options
        assignOptions(JSON.parse(jsonOptions));
    };
    
    ...
}

The initialize method is early in the lifecycle of any Blazor page or component that will use drag and drop. It accepts a JSON serialized object specifying CSS class values used to identify container, task item and handle elements in the DOM. There is also an option to determine whether to sort objects or not. In the current application, sorting is useful so I've set the property value to true. I added the ability to disable this functionality since there are likely other applications where sorting isn't used.


    /**
     * Method used to add event listeners to all of the draggable items/elements.
     */
    this.addListeners = function () {
        let items = Array.from(document.querySelectorAll(`.${options.itemClass}`));
        items.forEach(item => {
            item.addEventListener('pointerdown', pointerDown, { passive: true });
            if (!options.handleClass) {
                // change the item's cursor to the move cursor
                item.classList.add('moveCursor');
            }
            else {
                let handle = item.querySelector(`.${options.handleClass}`);
                if (!!handle) {
                    handle.classList.add('moveCursor');
                }
            }
        });
    };
    /**
     * Method used to remove the event listeners from all draggable items/elements.
     */
    const removeItemListeners = function () {
        let items = Array.from(document.querySelectorAll(`.${options.itemClass}`));
        items.forEach(item => {
            item.removeEventListener('pointerdown', pointerDown, { passive: true });
        });
    };

The next two methods are (obviously) used to add and remove a pointerdown event to each task item element in the DOM. The addListeners method is exported by the module; the removeItemListeners method is a private method called internally. Note that I've added a feature to change the cursor that appears when the cursor is over an item or its handle. I used handles in my app but you could have the cursor modified for the entire item by specifying the handleClass option as an empty string. You'll note that at this point in time, only event listeners for the pointerdown event are applied. This is deliberate; we don't want to listen for the other events just yet. I've used the various pointer events for convenience sake. These events allow the application to accept input from a mouse, touchscreen or other input device. In some third party libraries I've seen developers specify many different kinds of listeners to handle the different types of input; the pointer events seemed more convenient since they require less coding.


    /**
     * Private method called when the left button of a mouse is held down, or a touch screen is touched on
     * a draggable item/element.
     * @param {any} event - a 'pointerdown' event.
     */
    const pointerDown = function (event) {
        if (options.handleClass && event.target.classList.contains(options.handleClass)) {
            // the handle is presumably within the draggable item, so locate the item itself
            draggingElement = event.target.closest(`.${options.itemClass}`);
        }
        else {
            if (event.target.classList.contains(options.itemClass)) {
                draggingElement = event.target;
            }
            else {
                draggingElement = event.target.closest(`.${options.itemClass}`);
            }
        }
        if (!!draggingElement) {
            draggingElement.classList.add('dragging');
            x = event.clientX;
            y = event.clientY;
            rect = draggingElement.getBoundingClientRect();
            var container = draggingElement.closest(`.${options.containerClass}`);
            if (container) {
                sourceContainerId = container.dataset.containerId;
                sourceItemId = draggingElement.dataset.itemId;
            }
            document.addEventListener('pointermove', pointerMove, { passive: true });
            document.addEventListener('pointerup', pointerUp, { passive: true });
        }
    }

The pointerdown event handler is where we begin to track dragging movement. The initial code just identifies the actual element being dragged based on whether you are using a handle or not. After that we begin to store the input device's initial coordinates and those of the bouding rectangle of the element we're dragging. Next, we identify the container (task list) that the task element we're dragging is located in initially, as well as the unique identifier of the task item. Both these pieces of data are important to have when we send data back to the C# object that uses this module. In my component implementations I automatically assign random container and item identifiers using data attributes. These values are used in a couple of ways as will be shown later. Finally, I add the pointermove and pointerup event listeners to the document itself. In my application the ToDo component is the only component that will use drag and drop. In other applications, you may want to place your code in a top level div element and apply the listeners to that element instead. I found that if I went outside the boundary of my ToDo component, seen as a black border, the drag and drop functionality would stutter. The technical term I've seen used to describe this behavior is janky.


    /**
     * Private method called when the mouse or pointer device is moved.  
     * This method is called very frequently.  The request animation frame API is 
     * leveraged to smooth the appearance of dragging the item/element.
     * @param {any} event - a 'pointermove' event.
     */
    const pointerMove = function (event) {
        if (!raf) {
            deltaX = event.clientX - x;
            deltaY = event.clientY - y;
            raf = requestAnimationFrame(pointerMoveRAF);
        }
    };
    /**
     * Private method called by the request animation frame that animates the item/element's movement.
     * Once the element is finished rendering the frame variable is released to allow the next rendering.
     */
    const pointerMoveRAF = function () {
        draggingElement.style.transform = `translate3d(${deltaX}px, ${deltaY}px, 0px)`;
        raf = null;
    };

The two methods listed above are where the animation magic used to make the task element appear to move across the screen occurs. The pointerMove event handler method leverages the Request Animation Frame API which is designed to render the UI at a much higher frame rate. The important thing to remember is that the movement is simulated, there is no actual physical change to the DOM taking place.


    /**
     * Private method called when the mouse button or pointer device is released (dragging has completed).
     * @param {any} event - 'pointerup' event
     */
    const pointerUp = function (event) {
        // clean up
        document.removeEventListener('pointermove', pointerMove);
        document.removeEventListener('pointerup', pointerUp);
        // if the animation frame rendering was in process when the button or pointer device was release,
        // this will cancel the scheduled rendering.
        if (raf) {
            cancelAnimationFrame(raf);
            raf = null;
        };
        draggingElement.style.left = `${rect.left + deltaX}px`;
        draggingElement.style.top = `${rect.top + deltaY}px`;
        draggingElement.style.transform = 'translate3d(0px,0px,0px)';
        if (deltaX == 0 && deltaY == 0) {
            // item wasn't moved, so ignore the event
            return;
        }
        deltaX = deltaY = 0;
        // locate the container on which the item/element was dropped (if any)
        let element = document.elementFromPoint(event.clientX, event.clientY);he 
        let container = element.closest(`.${options.containerClass}`);
        if (container) {
            let afterElement;
            if (options.sort) { 
                afterElement = getDragAfterElement(container, event.clientY);
            }
            let targetItemId = !!afterElement ? afterElement.dataset.itemId : '';
            // create result object
            let result = new DragDropResult(sourceItemId, sourceContainerId, targetItemId, container.dataset.containerId);
            // remove the listeners to avoid possible memory leaks
            removeItemListeners();
            // pass back the DragDropResult to the C# object
            dragDropHelper.invokeMethodAsync('OnPointerUp', result);
        }
    }

The final event handler has several key functions. The first half of the code used to clean up a few things; remove the pointermove and pointerup event listeners, cancel the animation, and reset a couple of the module variables. The second half is going to gather data on which container and (if applicable) where the task item was dropped within the container. Note that if you don't drop the task item on a container, the task will simply remain where it was when you began dragging it. At the very end of this method we perform some cleanup by removing all the pointerdown event listeners, and finally we call the method OnPointerUp in the C# object that uses this module and passes it four pieces of data; the unique identifiers of the initial (source) container, the task item we dragged, the target container where we dropped the task item, and, if one exists, the task item upon which the dragged item was dropped.

There is one last vital bit of information that I need to add related to the pointermove event. There is another event called pointercancel which will have a deleterious effect on drag and drop functionality when you use a touch device like touch screens on mobile devices. If you want all the technical details, I suggest you check out this article. In a nutshell, we have to provide a small fix to the CSS that we apply to our tasks / items. Just add a touch-action: none to the item CSS and this problem is solved. By the way, you can't apply touch-action programmatically; once you start a gesture, applying element.style.touchAction = 'none' will have no effect! Just stick to defining it in a CSS class and you're all set.


    /**
     * Private method used to determine where the dragged item/element should be placed if sorting is enabled.
     * @param {any} container - the container on which the item/element was dropped.
     * @param {any} y - The y coordinate of the mouse or pointer device when the item was dropped.
     * @returns The element after which the dragged item/element should be inserted.  If the return value is null
     * the dragged item should be appended to end of the container's items.
     */
    const getDragAfterElement = function (container, y) {
        const draggableElements = [...container.querySelectorAll(`.${options.itemClass}:not(.dragging)`)];

        return draggableElements.reduce((closest, child) => {
            const box = child.getBoundingClientRect();
            const offset = y - box.top - box.height / 2;
            if (offset < 0 && offset > closest.offset) {
                return { offset: offset, element: child }
            } else {
                return closest
            }
        }, { offset: Number.NEGATIVE_INFINITY }).element
    }

I wish I could take credit for this last little bit of code, it's a very clever way to figure out where in an existing task list you have dropped the task item. It essentially figures out which existing task item in a list is closest to where you're dropping the dragged task item, and whether the dragged item is above or below the center of the existing task's x-axis. I found this snippet here. I only call this method if the sort option is set to true. Otherwise, we assume that the dropped item is appended to the end of the task list, and not necessarily somewhere within the list.

I'll detail the C# class that leverages all the JavaScript code in Part II.

No comments:

Post a Comment