rx/presenters

View on GitHub
views/mdc/assets/js/components/drag_n_drop.js

Summary

Maintainability
A
25 mins
Test Coverage
/*
    A drag data store (DragEvent.prototype.dataTransfer.items) can be in one of
    three modes:

    1. read/write, during a `dragstart` event: items can be read and added
    2. read-only, during a `drop` event: items can be read, but not added
    3. protected, during all other types of DragEvent: items cannot be read
       or added.

    (see https://html.spec.whatwg.org/multipage/dnd.html#concept-dnd-rw)

    Thus, attempting to mutate the store during events other than `dragstart`
    fails silently: no error is raised, but items are not added.


    To read items in protected mode, serialize data as a string and store it as
    a key instead of as a value. This makes it accessible via
    `dataTransfer.types`:

    // in a dragstart handler:
    event.dataTransfer.setData(JSON.stringify(foo), '');

    // in a later read-only or protected DragEvent handler:
    const json = event.dataTransfer.types[someIndex];
    const foo = JSON.stringify(json);


    There is no workaround for attempting to mutate a drag data store not in
    read/write mode.
 */

import {getRootNode} from './base-component';

export const EVENT_DROPPED = 'dropped';

const DRAG_DATA_MIME_TYPE = 'application/x.voom-drag-data+json';
const ELEMENT_ID_MIME_TYPE = 'text/x.voom-element-id';

function createDragStartHandler(root, element) {
    return function(event) {
        const dragParamData = event.target.dataset.drag_params;

        if (dragParamData) {
            event.dataTransfer.setData(DRAG_DATA_MIME_TYPE, dragParamData);

            const zone = JSON.parse(dragParamData).zone;

            if (zone) {
                event.dataTransfer.setData(zone, '');
            }

            event.dataTransfer.effectAllowed = 'move';
            event.target.classList.remove('v-dnd-draggable');
            event.target.classList.add('v-dnd-moving');

            event.dataTransfer.setData(ELEMENT_ID_MIME_TYPE, element.id);
        }
    };
}

function createDragOverHandler(root, element) {
    return function(event) {
        const dropZone = element.dataset.dropzone;

        if (dropZone == null || event.dataTransfer.types.includes(dropZone)) {
            if (event.preventDefault) {
                event.preventDefault();
            }
            element.classList.add('v-dnd-over');
        }
        else {
            element.classList.remove('v-dnd-over');
        }
    };
}

function createDragLeaveHandler(root, element) {
    return function(event) {
        element.classList.remove('v-dnd-over');
    };
}

function createDropHandler(root, element) {
    // When an element is upgraded to a Voom component after being replaced via
    // `replaces`, root may refer to the replaced element itself instead of the
    // element's root node.
    // Since a valid drop zone may exist anywhere on the page, it is not
    // guaranteed that root contains the element being dragged.
    // getRootNode will fetch `root`'s actual root node (document or shadow
    // DOM root).
    const trueRoot = getRootNode(root);

    return function(event) {
        event.stopPropagation();
        event.preventDefault();

        const id = event.dataTransfer.getData(ELEMENT_ID_MIME_TYPE);
        const draggedElement = id ? trueRoot.querySelector(`#${id}`) : null;
        let dragParams = {};

        if (draggedElement) {
            dragParams = JSON.parse(draggedElement.dataset.drag_params);
        }

        element.classList.remove('v-dnd-over', 'v-dnd-moving');
        element.classList.add('v-dnd-draggable');

        // Emit a "dropped" event for the element being dragged:
        // The drag_params of the dragged element are merged with the
        // drop_params of the drop zone.
        const dropZoneParams = JSON.parse(element.dataset.drop_params);
        const params = Object.assign({}, dragParams, dropZoneParams);
        const droppedEvent = new CustomEvent(EVENT_DROPPED, {detail: params});

        draggedElement.dispatchEvent(droppedEvent);

        return false;
    };
}

function createDragEndHandler(root, element) {
    return function(event) {
        element.classList.remove('v-dnd-over', 'v-dnd-moving');
        element.classList.add('v-dnd-draggable');
    };
}

const DRAGGABLE_SELECTOR = '[draggable=true]';
const DROP_ZONE_SELECTOR = '[data-dropzone]';

export function initDragAndDrop(root) {
    const draggables = Array.from(root.querySelectorAll(DRAGGABLE_SELECTOR));

    if (typeof root.matches === 'function' && root.matches(DRAGGABLE_SELECTOR)) {
        draggables.unshift(root);
    }

    for (const elem of draggables) {
        elem.addEventListener('dragstart', createDragStartHandler(root, elem));
        elem.addEventListener('dragend', createDragEndHandler(root, elem));
    }

    const dropZones = Array.from(root.querySelectorAll(DROP_ZONE_SELECTOR));

    if (typeof root.matches === 'function' && root.matches(DROP_ZONE_SELECTOR)) {
        dropZones.unshift(root);
    }

    for (const elem of dropZones) {
        elem.addEventListener('dragover', createDragOverHandler(root, elem));
        elem.addEventListener('drop', createDropHandler(root, elem));
        elem.addEventListener('dragleave', createDragLeaveHandler(root, elem));
    }
}

/**
 * hasDragDropData determines whether the provided event has previously-set
 * drag-n-drop data available.
 * @param {Event} event
 * @return {Boolean}
 */
export function hasDragDropData(event) {
    return event.type === 'drop' && event.dataTransfer
        || event.type === EVENT_DROPPED && event.detail;
}

/**
 * extractDragDropData attempts to extract a payload of drag-n-drop data from
 * the provided event previously set during  a `dragstart` event.
 * @param {Event} event
 * @return {Object}
 */
export function extractDragDropData(event) {
    if (event.type === 'drop' && event.dataTransfer) {
        return JSON.parse(event.dataTransfer.getData(DRAG_DATA_MIME_TYPE));
    }
    else if (event.type === EVENT_DROPPED) {
        return event.detail;
    }

    return {};
}