question/type/ordering/amd/src/drag_reorder.js

// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

/*
 * To make a set of things draggable, create a new instance of this object passing the
 * necessary config, as explained in the comment on the constructor.
 *
 * @package   qtype_ordering\drag_reorder
 * @copyright 2018 The Open University
 * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
 */

'use strict';

import $ from 'jquery';
import drag from 'core/dragdrop';
import Templates from 'core/templates';
import Notification from 'core/notification';
import {getString} from 'core/str';
import {prefetchString} from 'core/prefetch';

export default class DragReorder {

    // Class variables handling state.
    config = {reorderStart: undefined, reorderEnd: undefined}; // Config object with some basic definitions.
    dragStart = null; // Information about when and where the drag started.
    originalOrder = null; // Array of ids that's used to compare the state after the drag event finishes.

    // DOM Nodes and jQuery representations.
    orderList = null; // Order list (HTMLElement).
    itemDragging = null; // Item being moved by dragging (jQuery object).
    proxy = null; // Drag proxy (jQuery object).

    /**
     * Constructor.
     *
     * To make a list draggable, create a new instance of this object, passing the necessary config.
     * For example:
     * {
     *      // Selector for the list (or lists) to be reordered.
     *      list: 'ul.my-list',
     *
     *      // Selector, relative to the list selector, for the items that can be moved.
     *      item: '> li',
     *
     *      // While the proxy is being dragged, this class is added to the item being moved.
     *      // You can probably use "osep-itemmoving" here.
     *      itemMovingClass: "osep-itemmoving",
     *
     *      // This is a callback which, when called with the DOM node for an item,
     *      // returns the string that uniquely identifies each item.
     *      // Therefore, the result of the drag action will be represented by the array
     *      // obtained by calling this method on each item in the list in order.
     *      idGetter: function(item) { return node.id; },
     *
     *      // Function that will be called when a re-order starts (optional, can be not set).
     *      // Useful if you need to save information about the initial state.
     *      // This function should have two parameters. The first will be a
     *      // jQuery object for the list that was reordered, the second will
     *      // be the jQuery object for the item moved - which will not yet have been moved.
     *      // Note, it is quite possible for reorderStart to be called with no
     *      // subsequent call to reorderDone.
     *      reorderStart: function($list, $item) { ... }
     *
     *      // Function that will be called when a drag has finished, and the list
     *      // has been reordered. This function should have three parameters. The first will be
     *      // a jQuery object for the list that was reordered, the second will be the jQuery
     *      // object for the item moved, and the third will be the new order, which is
     *      // an array of ids obtained by calling idGetter on each item in the list in order.
     *      // This callback will only be called in the new order is actually different from the old order.
     *      reorderDone: function($list, $item, newOrder) { ... }
     *
     *      // Function that is always called when a re-order ends (optional, can be not set)
     *      // whether the order has changed. Useful if you need to undo changes made
     *      // in reorderStart, since reorderDone is only called if the new order is different
     *      // from the original order.
     *      reorderEnd: function($list, $item) { ... }
     *  }
     *
     * There is a subtlety, If you have items in your list that do not have a drag handle,
     * they are considered to be placeholders in otherwise empty containers.
     *
     * @param {Object} config As above.
     */
    constructor(config) {
        // Bring in the config to our state.
        this.config = config;

        // Get the list we'll be working with this time.
        this.orderList = document.querySelector(this.config.list);

        this.startListeners();
    }

    /**
     * Start the listeners for the list.
     */
    startListeners() {
        /**
         * Handle mousedown or touchstart events on the list.
         *
         * @param {Event} e The event.
         */
        const pointerHandle = e => {
            if (e.target.closest(this.config.item) && !e.target.closest(this.config.actionButton)) {
                this.itemDragging = $(e.target.closest(this.config.item));
                const details = drag.prepare(e);
                if (details.start) {
                    this.startDrag(e, details);
                }
            }
        };
        // Set up the list listeners for moving list items around.
        this.orderList.addEventListener('mousedown', pointerHandle);
        this.orderList.addEventListener('touchstart', pointerHandle);
        this.orderList.addEventListener('click', this.itemMovedByClick.bind(this));
    }

    /**
     * Start dragging.
     *
     * @param {Event} e The event which is either mousedown or touchstart.
     * @param {Object} details Object with start (boolean flag) and x, y (only if flag true) values
     */
    startDrag(e, details) {
        this.dragStart = {
            time: new Date().getTime(),
            x: details.x,
            y: details.y
        };

        if (typeof this.config.reorderStart !== 'undefined') {
            this.config.reorderStart(this.itemDragging.closest(this.config.list), this.itemDragging);
        }

        this.originalOrder = this.getCurrentOrder();

        Templates.renderForPromise('qtype_ordering/proxyhtml', {
            itemHtml: this.itemDragging.html(),
            itemClassName: this.itemDragging.attr('class'),
            listClassName: this.orderList.classList.toString(),
            proxyStyles: [
                `width: ${this.itemDragging.outerWidth()}px;`,
                `height: ${this.itemDragging.outerHeight()}px;`,
            ].join(' '),
        }).then(({html, js}) => {
            this.proxy = $(Templates.appendNodeContents(document.body, html, js)[0]);
            this.proxy.css(this.itemDragging.offset());

            this.itemDragging.addClass(this.config.itemMovingClass);

            this.updateProxy();
            // Start drag.
            drag.start(e, this.proxy, this.dragMove.bind(this), this.dragEnd.bind(this));
        }).catch(Notification.exception);
    }

    /**
     * Move the proxy to the current mouse position.
     */
    dragMove() {
        let closestItem = null;
        let closestDistance = null;
        this.orderList.querySelectorAll(this.config.item).forEach(element => {
            const distance = this.distanceBetweenElements(element);
            if (closestItem === null || distance < closestDistance) {
                closestItem = $(element);
                closestDistance = distance;
            }
        });

        if (closestItem[0] === this.itemDragging[0]) {
            return;
        }

        // Set offset depending on if item is being dragged downwards/upwards.
        const offsetValue = this.midY(this.proxy) < this.midY(closestItem) ? 20 : -20;
        if (this.midY(this.proxy) + offsetValue < this.midY(closestItem)) {
            this.itemDragging.insertBefore(closestItem);
        } else {
            this.itemDragging.insertAfter(closestItem);
        }
        this.updateProxy();
    }

    /**
     * Update proxy's position.
     */
    updateProxy() {
        const items = [...this.orderList.querySelectorAll(this.config.item)];
        for (let i = 0; i < items.length; ++i) {
            if (this.itemDragging[0] === items[i]) {
                this.proxy.find('li').attr('value', i + 1);
                break;
            }
        }
    }

    /**
     * End dragging.
     */
    dragEnd() {
        if (typeof this.config.reorderEnd !== 'undefined') {
            this.config.reorderEnd(this.itemDragging.closest(this.config.list), this.itemDragging);
        }

        if (!this.arrayEquals(this.originalOrder, this.getCurrentOrder())) {
            // Order has changed, call the callback.
            this.config.reorderDone(this.itemDragging.closest(this.config.list), this.itemDragging, this.getCurrentOrder());

            getString('moved', 'qtype_ordering', {
                item: this.itemDragging.find('[data-itemcontent]').text().trim(),
                position: this.itemDragging.index() + 1,
                total: this.orderList.querySelectorAll(this.config.item).length
            }).then((str) => {
                this.config.announcementRegion.innerHTML = str;
            });
        }

        // Clean up after the drag is finished.
        this.proxy.remove();
        this.proxy = null;
        this.itemDragging.removeClass(this.config.itemMovingClass);
        this.itemDragging = null;
        this.dragStart = null;
    }

    /**
     * Handles the movement of an item by click.
     *
     * @param {MouseEvent} e The pointer event.
     */
    itemMovedByClick(e) {
        const actionButton = e.target.closest(this.config.actionButton);
        if (actionButton) {
            this.itemDragging = $(e.target.closest(this.config.item));

            // Store the current state of the list.
            this.originalOrder = this.getCurrentOrder();

            switch (actionButton.dataset.action) {
                case 'move-backward':
                    e.preventDefault();
                    e.stopPropagation();
                    if (this.itemDragging.prev().length) {
                        this.itemDragging.prev().insertAfter(this.itemDragging);
                    }
                    break;
                case 'move-forward':
                    e.preventDefault();
                    e.stopPropagation();
                    if (this.itemDragging.next().length) {
                        this.itemDragging.next().insertBefore(this.itemDragging);
                    }
                    break;
            }

            // After we have potentially moved the item, we need to check if the order has changed.
            if (!this.arrayEquals(this.originalOrder, this.getCurrentOrder())) {
                // Order has changed, call the callback.
                this.config.reorderDone(this.itemDragging.closest(this.config.list), this.itemDragging, this.getCurrentOrder());

                // When moving an item to the first or last position, the button that was clicked will be hidden.
                // In this case, we need to focus the other button.
                if (!this.itemDragging.prev().length) {
                    // Focus the 'next' action button.
                    this.itemDragging.find('[data-action="move-forward"]').focus();
                } else if (!this.itemDragging.next().length) {
                    // Focus the 'previous' action button.
                    this.itemDragging.find('[data-action="move-backward"]').focus();
                }

                getString('moved', 'qtype_ordering', {
                    item: this.itemDragging.find('[data-itemcontent]').text().trim(),
                    position: this.itemDragging.index() + 1,
                    total: this.orderList.querySelectorAll(this.config.item).length
                }).then((str) => {
                    this.config.announcementRegion.innerHTML = str;
                });
            }
        }
    }

    /**
     * Get the x-position of the middle of the DOM node represented by the given jQuery object.
     *
     * @param {jQuery} node jQuery wrapping a DOM node.
     * @returns {number} Number the x-coordinate of the middle (left plus half outerWidth).
     */
    midX(node) {
        return node.offset().left + node.outerWidth() / 2;
    }

    /**
     * Get the y-position of the middle of the DOM node represented by the given jQuery object.
     *
     * @param {jQuery} node jQuery wrapped DOM node.
     * @returns {number} Number the y-coordinate of the middle (top plus half outerHeight).
     */
    midY(node) {
        return node.offset().top + node.outerHeight() / 2;
    }

    /**
     * Calculate the distance between the centres of two elements.
     *
     * @param {HTMLLIElement} element DOM node of a list item.
     * @return {number} number the distance in pixels.
     */
    distanceBetweenElements(element) {
        const [e1, e2] = [$(element), $(this.proxy)];
        const [dx, dy] = [this.midX(e1) - this.midX(e2), this.midY(e1) - this.midY(e2)];
        return Math.sqrt(dx * dx + dy * dy);
    }

    /**
     * Get the current order of the list containing itemDragging.
     *
     * @returns {Array} Array of strings, the id of each element in order.
     */
    getCurrentOrder() {
        return this.itemDragging.closest(this.config.list).find(this.config.item).map(
            (index, item) => {
                return this.config.idGetter(item);
            }).get();
    }

    /**
     * Compare two arrays which contain primitive types to see if they are equal.
     * @param {Array} a1 first array.
     * @param {Array} a2 second array.
     * @return {Boolean} boolean true if they both contain the same elements in the same order, else false.
     */
    arrayEquals(a1, a2) {
        return a1.length === a2.length &&
            a1.every((v, i) => {
                return v === a2[i];
            });
    }

    /**
     * Initialise one ordering question.
     *
     * @param {String} sortableid id of ul for this question.
     * @param {String} responseid id of hidden field for this question.
     */
    static init(sortableid, responseid) {
        new DragReorder({
            actionButton: '[data-action]',
            announcementRegion: document.querySelector(`#${sortableid}-announcement`),
            list: 'ul#' + sortableid,
            item: 'li.sortableitem',
            itemMovingClass: "current-drop",
            idGetter: item => {
                return item.id;
            },
            reorderDone: (list, item, newOrder) => {
                $('input#' + responseid)[0].value = newOrder.join(',');
            }
        });

        prefetchString('qtype_ordering', 'moved');
    }
}