diff --git a/lang/en/moodle.php b/lang/en/moodle.php index ee02e871581..6fbf005401e 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -1269,6 +1269,8 @@ $string['move'] = 'Move'; $string['movecoursemodule'] = 'Move resource'; $string['movecoursesection'] = 'Move section'; $string['movecontent'] = 'Move {$a}'; +$string['movecontentafter'] = 'After "{$a}"'; +$string['movecontenttothetop'] = 'To the top of the list'; $string['movecategorycontentto'] = 'Move into'; $string['movecategorysuccess'] = 'Successfully moved category \'{$a->moved}\' into category \'{$a->to}\''; $string['movecategoriessuccess'] = 'Successfully moved {$a->count} categories into category \'{$a->to}\''; diff --git a/lib/amd/build/sortable_list.min.js b/lib/amd/build/sortable_list.min.js new file mode 100644 index 00000000000..85971fb787f --- /dev/null +++ b/lib/amd/build/sortable_list.min.js @@ -0,0 +1 @@ +define(["jquery","core/log","core/autoscroll","core/str","core/modal_factory","core/modal_events","core/notification"],function(a,b,c,d,e,f,g){var h,i,j,k={listSelector:null,moveHandlerSelector:null,isHorizontal:!1,autoScroll:!0,elementNameCallback:function(a){return a.text()},destinationNameCallback:function(a,b){return b.length?D(b).then(function(a){return d.get_string("movecontentafter","moodle",a)}):d.get_string("movecontenttothetop","moodle")},moveDialogueTitleCallback:function(a){return D(a).then(function(a){return d.get_string("movecontent","moodle",a)})}},l={keyboardDragClass:"dragdrop-keyboard-drag",isDraggedClass:"sortable-list-is-dragged",currentPositionClass:"sortable-list-current-position",sourceListClass:"sortable-list-source",targetListClass:"sortable-list-target",overElementClass:"sortable-list-over-element"},m={},n=null,o=0,p=function(){var b=a(m.listSelector);b.children().removeClass(m.isDraggedClass).removeClass(m.currentPositionClass).removeClass(m.overElementClass),b.removeClass(m.targetListClass).removeClass(m.sourceListClass),h&&(h.remove(),h=a())},q=function(b){if(b.originalEvent&&b.originalEvent.touches&&void 0!==b.originalEvent.touches[0]){var c=b.originalEvent.touches[0];b.pageX=c.pageX,b.pageY=c.pageY}void 0===b.pageX?(b.pageX=j.pageX,b.pageY=j.pageY):j=b,void 0===b.clientX&&(b.clientX=Math.round(b.pageX-a(window).scrollLeft()),b.clientY=Math.round(b.pageY-a(window).scrollTop()))},r=function(b){if(m=b.data.params,null!==n){if("click"===n.type)return;y(n.sourceList,n.sourceNextElement),z()}if("mousedown"!==b.type||1===b.which){q(b);var d=a(b.currentTarget);if(null===m.moveHandlerSelector||a(b.target).closest(m.moveHandlerSelector,d).length){b.stopPropagation(),b.preventDefault(),o++,n={element:d,sourceNextElement:d.next(),sourceList:d.parent(),targetNextElement:d.next(),targetList:d.parent(),type:b.type,dropped:!1,startX:b.pageX,startY:b.pageY,startTime:(new Date).getTime()},a(m.listSelector).addClass(m.targetListClass);var e=d.offset();d.addClass(m.currentPositionClass),i={x:e.left-b.pageX,y:e.top-b.pageY},h=a();var f=o;setTimeout(function(){null!==n&&"click"!==n.type&&"keypress"!==n.type&&o===f&&s()},500),a("body").on("mousemove touchmove mouseup touchend",x),a("body").on("keypress",B),m.autoScroll&&c.start(function(){a("body").trigger("mousemove")}),A("dragstart")}}},s=function(){h=n.element.clone(),n.sourceList.append(h),h.removeAttr("id").removeClass(m.currentPositionClass).addClass(m.isDraggedClass).css({position:"fixed"}),h.offset({top:i.y+j.pageY,left:i.x+j.pageX})},t=function(b){if(!("keypress"===b.type&&13!==b.originalEvent.keyCode&&32!==b.originalEvent.keyCode||null!==n&&"click"===n.type)){b.preventDefault(),b.stopPropagation(),m=b.data.params;var c=a(b.currentTarget).closest(m.listSelector),d=c.children().filter(function(){return a.contains(this,b.currentTarget)});d.length&&(o++,n={element:d,sourceNextElement:d.next(),sourceList:c,targetNextElement:d.next(),targetList:c,dropped:!1,type:b.type,startTime:(new Date).getTime()},A("dragstart"),H())}},u=function(a,b,c){if(!c.length)return null;var d=c[0],e=0,f=d.getBoundingClientRect(),g=b-(f.top+window.scrollY),h=a-(f.left+window.scrollX);return h>=-e&&h<=f.width+e&&g>=-e&&g<=f.height+e?{x:h,y:g,xRatio:f.width?h/f.width:0,yRatio:f.height?g/f.height:0}:null},v=function(){return!h||!h.length||this!==h[0]},w=function(a){var b=m.isHorizontal;return b===!0||b===!1?b:b(a)},x=function(b){q(b),h.offset({top:-1e3,left:-1e3});var c=a(document.elementFromPoint(b.clientX,b.clientY)),d=c.closest("."+m.targetListClass+" > :not(."+m.isDraggedClass+")"),e=c.closest("."+m.targetListClass);if(a("."+m.overElementClass).removeClass(m.overElementClass),d.addClass(m.overElementClass),h.offset({top:i.y+b.pageY,left:i.x+b.pageX}),e.length&&!e.children().filter(v).length)y(e,a());else if(1===d.length&&!n.element.find(d[0]).length){var f=u(b.pageX,b.pageY,d);if(f){var g=d.parent(),j=w(g)?f.xRatio:f.yRatio,k=d.find("."+m.targetListClass),l=function(){return this!==n.element[0]},o=!k.children().filter(v).filter(l).length;k.length&&o&&j>.2&&j<.8?y(k,a()):j>.5?y(g,d.next().filter(v)):y(g,d)}}"mouseup"!==b.type&&"touchend"!==b.type||(n.endX=b.pageX,n.endY=b.pageY,n.endTime=(new Date).getTime(),n.dropped=!0,A("drop"),z())},y=function(a,b){var c=n.element;b.length&&b[0]===c[0]||a[0]===n.targetList[0]&&b.length===n.targetNextElement.length&&b[0]===n.targetNextElement[0]||(b.length?a[0].insertBefore(c[0],b[0]):h&&h.parent().length&&h.parent()[0]===a[0]?a[0].insertBefore(c[0],h[0]):a[0].appendChild(c[0]),n.targetList=a,n.targetNextElement=b,A("drag"))},z=function(){p(),m.autoScroll&&c.stop(),a("body").off("mousemove touchmove mouseup touchend",x),a("body").off("keypress",B),A("dragend"),n=null},A=function(a){n.element.trigger("sortablelist-"+a,n)},B=function(a){"keypress"===a.type&&27===a.originalEvent.keyCode&&(y(n.sourceList,n.sourceNextElement),z())},C=function(b){var c=b;return"object"==typeof b&&b.hasOwnProperty("then")||(c=a.Deferred(),c.resolve(b)),c},D=function(a){return C(m.elementNameCallback(a))},E=function(a,b){return C(m.destinationNameCallback(a,b))},F=function(a){return C(m.moveDialogueTitleCallback(a))},G=function(b){var c=[],d=a(m.listSelector),e=a("<ul/>").addClass(m.keyboardDragClass),f=function(c,d,f){if(!d.is(n.element)&&!f.is(n.element)){var h=a("<li/>").appendTo(e),i=a('<a href="#"/>').click(function(a){a.preventDefault(),a.stopPropagation(),y(c,d),n.endTime=(new Date).getTime(),n.dropped=!0,n.element.find(m.moveHandlerSelector).focus(),A("drop"),b.hide()}).appendTo(h);E(c,f).then(function(a){return i.text(a),a})["catch"](g.exception)}},h=function(){if(a.inArray(this,c)===-1){c.push(this);var b=a(this),e=b.children();e.each(function(){var c=a(this);f(b,c,c.prev()),c.find(d).each(h)}),f(b,a(),e.last())}};return d.each(h),e},H=function(){e.create({type:e.types.CANCEL,title:F(n.element)}).then(function(a){return a.getRoot().on(f.hidden,function(){a.destroy(),z()}),a.getBody().append(G(a)),a.setLarge(),a.show(),a})};return{init:function(c){return"undefined"==typeof c.listSelector?void b.error("Parameter listSelector must be specified"):(c=a.extend({},k,l,c),a(c.listSelector).on("mousedown touchstart","> *",{params:c},r),void(null!==c.moveHandlerSelector&&a(c.listSelector).on("click keypress",c.moveHandlerSelector,{params:c},t)))}}}); \ No newline at end of file diff --git a/lib/amd/src/sortable_list.js b/lib/amd/src/sortable_list.js new file mode 100644 index 00000000000..69e970889da --- /dev/null +++ b/lib/amd/src/sortable_list.js @@ -0,0 +1,692 @@ +// 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/>. + +/** + * A javascript module to handle list items drag and drop + * + * Example of usage: + * + * define(['jquery', 'core/sortable_list'], function($, sortableList) { + * sortableList.init({ + * listSelector: 'ul.my-awesome-list', // mandatory, CSS selector for the list (usually <ul> or <tbody>) + * moveHandlerSelector: '.draghandle' // CSS selector of the crossarrow handle. Make sure that this + * element can handle keypress and mouse click events for displaying accessible move popup. + * }); + * $('ul.my-awesome-list > *').on('sortablelist-drop', function(evt, info) { + * console.log(info); + * }); + * } + * + * More details: https://docs.moodle.org/dev/Sortable_list + * + * For the full list of possible parameters see var defaultParameters below. + * + * The following jQuery events are fired: + * - sortablelist-dragstart : when user started dragging a list element + * - sortablelist-drag : when user dragged a list element to a new position + * - sortablelist-drop : when user dropped a list element + * - sortablelist-dragend : when user finished dragging - either fired right after dropping or + * if "Esc" was pressed during dragging + * + * @module core/sortable_list + * @class sortable_list + * @package core + * @copyright 2018 Marina Glancy + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +define(['jquery', 'core/log', 'core/autoscroll', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/notification'], +function($, log, autoScroll, str, ModalFactory, ModalEvents, Notification) { + + /** + * Default parameters + * + * @property {String} listSelector CSS selector for sortable lists, must be specified during initialization. + * @property {String} moveHandlerSelector CSS selector for a drag handle. By default the whole item is a handle. + * Without drag handle sorting is not accessible! + * @property {Boolean|Function} isHorizontal Set to true if the list is horizontal + * (can also be a callback with list as an argument) + * @property {Boolean} autoScroll Engages autoscroll module for automatic vertical scrolling of the whole page + * @property {Function} elementNameCallback Should return a string or Promise. Used for move dialogue title and + * destination name + * @property {Function} destinationNameCallback Callback that returns a string or Promise with the label + * for the move destination + * @property {Function} moveDialogueTitleCallback Should return a string or Promise. Used to form move dialogue title + * + * @private + * @type {Object} + */ + var defaultParameters = { + listSelector: null, + moveHandlerSelector: null, + isHorizontal: false, + autoScroll: true, + elementNameCallback: function(element) { + return element.text(); + }, + destinationNameCallback: function(parentElement, afterElement) { + if (!afterElement.length) { + return str.get_string('movecontenttothetop', 'moodle'); + } else { + return getElementName(afterElement) + .then(function(name) { + return str.get_string('movecontentafter', 'moodle', name); + }); + } + }, + moveDialogueTitleCallback: function(element) { + return getElementName(element).then(function(name) { + return str.get_string('movecontent', 'moodle', name); + }); + } + }; + + /** + * Class names for different elements that may be changed during sorting + * + * @private + * @type {Object} + */ + var CSS = { + keyboardDragClass: 'dragdrop-keyboard-drag', /* Class of the list of destinations in the popup */ + isDraggedClass: 'sortable-list-is-dragged', /* Class added to the element that is dragged. */ + currentPositionClass: 'sortable-list-current-position', /* Class added to the current position of a dragged element. */ + sourceListClass: 'sortable-list-source', /* Class added to the list where dragging was started from. */ + targetListClass: 'sortable-list-target', /* Class added to all lists where item can be dropped. */ + overElementClass: 'sortable-list-over-element' /* Class added to the list element when the dragged element is above it. */ + }; + + /** + * Stores parameters of the currently dragged item + * + * @private + * @type {Object} + */ + var params = {}; + + /** + * Stores information about currently dragged item + * + * @private + * @type {Object} + */ + var info = null; + + /** + * Stores the proxy object + * + * @private + * @type {jQuery} + */ + var proxy; + + /** + * Stores initial position of the proxy + * + * @private + * @type {Object} + */ + var proxyDelta; + + /** + * Counter of drag events + * + * @private + * @type {Number} + */ + var dragCounter = 0; + + /** + * Resets the temporary classes assigned during dragging + * @private + */ + var resetDraggedClasses = function() { + var lists = $(params.listSelector); + lists.children() + .removeClass(params.isDraggedClass) + .removeClass(params.currentPositionClass) + .removeClass(params.overElementClass); + lists + .removeClass(params.targetListClass) + .removeClass(params.sourceListClass); + if (proxy) { + proxy.remove(); + proxy = $(); + } + }; + + /** + * {Event} stores the last event that had pageX and pageY defined + * @private + */ + var lastEvent; + + /** + * Calculates evt.pageX, evt.pageY, evt.clientX and evt.clientY + * + * For touch events pageX and pageY are taken from the first touch; + * For the emulated mousemove event they are taken from the last real event. + * + * @private + * @param {Event} evt + */ + var calculatePositionOnPage = function(evt) { + + if (evt.originalEvent && evt.originalEvent.touches && evt.originalEvent.touches[0] !== undefined) { + // This is a touchmove or touchstart event, get position from the first touch position. + var touch = evt.originalEvent.touches[0]; + evt.pageX = touch.pageX; + evt.pageY = touch.pageY; + } + + if (evt.pageX === undefined) { + // Information is not present in case of touchend or when event was emulated by autoScroll. + // Take the absolute mouse position from the last event. + evt.pageX = lastEvent.pageX; + evt.pageY = lastEvent.pageY; + } else { + lastEvent = evt; + } + + if (evt.clientX === undefined) { + // If not provided in event calculate relative mouse position. + evt.clientX = Math.round(evt.pageX - $(window).scrollLeft()); + evt.clientY = Math.round(evt.pageY - $(window).scrollTop()); + } + }; + + /** + * Handler from dragstart event + * + * @private + * @param {Event} evt + */ + var dragStartHandler = function(evt) { + params = evt.data.params; + if (info !== null) { + if (info.type === 'click') { + // Ignore double click. + return; + } + // Mouse down or touch while already dragging, cancel previous dragging. + moveElement(info.sourceList, info.sourceNextElement); + finishDragging(); + } + + if (evt.type === 'mousedown' && evt.which !== 1) { + // We only need left mouse click. + return; + } + + calculatePositionOnPage(evt); + var movedElement = $(evt.currentTarget); + + // Check that we grabbed the element by the handle. + if (params.moveHandlerSelector !== null) { + if (!$(evt.target).closest(params.moveHandlerSelector, movedElement).length) { + return; + } + } + + evt.stopPropagation(); + evt.preventDefault(); + + // Information about moved element with original location. + // This object is passed to event observers. + dragCounter++; + info = { + element: movedElement, + sourceNextElement: movedElement.next(), + sourceList: movedElement.parent(), + targetNextElement: movedElement.next(), + targetList: movedElement.parent(), + type: evt.type, + dropped: false, + startX: evt.pageX, + startY: evt.pageY, + startTime: new Date().getTime() + }; + + $(params.listSelector).addClass(params.targetListClass); + + var offset = movedElement.offset(); + movedElement.addClass(params.currentPositionClass); + proxyDelta = {x: offset.left - evt.pageX, y: offset.top - evt.pageY}; + proxy = $(); + var thisDragCounter = dragCounter; + setTimeout(function() { + if (info === null || info.type === 'click' || info.type === 'keypress' || dragCounter !== thisDragCounter) { + return; + } + + // Create a proxy - the copy of the dragged element that moves together with a mouse. + createProxy(); + }, 500); + + // Start drag. + $('body').on('mousemove touchmove mouseup touchend', dragHandler); + $('body').on('keypress', dragcancelHandler); + + // Start autoscrolling. Every time the page is scrolled emulate the mousemove event. + if (params.autoScroll) { + autoScroll.start(function () { + $('body').trigger('mousemove'); + }); + } + + executeCallback('dragstart'); + }; + + /** + * Creates a "proxy" object - a copy of the element that is being moved that always follows the mouse + * @private + */ + var createProxy = function() { + proxy = info.element.clone(); + info.sourceList.append(proxy); + proxy.removeAttr('id').removeClass(params.currentPositionClass) + .addClass(params.isDraggedClass).css({position: 'fixed'}); + proxy.offset({top: proxyDelta.y + lastEvent.pageY, left: proxyDelta.x + lastEvent.pageX}); + }; + + /** + * Handler for click event - when user clicks on the drag handler or presses Enter on keyboard + * + * @private + * @param {Event} evt + */ + var clickHandler = function(evt) { + if (evt.type === 'keypress' && evt.originalEvent.keyCode !== 13 && evt.originalEvent.keyCode !== 32) { + return; + } + if (info !== null && info.type === 'click') { + // Ignore double click. + return; + } + evt.preventDefault(); + evt.stopPropagation(); + params = evt.data.params; + + // Find the element that this draghandle belongs to. + var sourceList = $(evt.currentTarget).closest(params.listSelector), + movedElement = sourceList.children().filter(function() { + return $.contains(this, evt.currentTarget); + }); + if (!movedElement.length) { + return; + } + + // Store information about moved element with original location. + dragCounter++; + info = { + element: movedElement, + sourceNextElement: movedElement.next(), + sourceList: sourceList, + targetNextElement: movedElement.next(), + targetList: sourceList, + dropped: false, + type: evt.type, + startTime: new Date().getTime() + }; + + executeCallback('dragstart'); + displayMoveDialogue(); + }; + + /** + * Finds the position of the mouse inside the element - on the top, on the bottom, on the right or on the left\ + * + * Used to determine if the moved element should be moved after or before the current element + * + * @private + * @param {Number} pageX + * @param {Number} pageY + * @param {jQuery} element + * @returns {(Object|null)} + */ + var getPositionInNode = function(pageX, pageY, element) { + if (!element.length) { + return null; + } + var node = element[0], + offset = 0, + rect = node.getBoundingClientRect(), + y = pageY - (rect.top + window.scrollY), + x = pageX - (rect.left + window.scrollX); + if (x >= -offset && x <= rect.width + offset && y >= -offset && y <= rect.height + offset) { + return { + x: x, + y: y, + xRatio: rect.width ? (x / rect.width) : 0, + yRatio: rect.height ? (y / rect.height) : 0 + }; + } + return null; + }; + + /** + * Callback for filter that checks that current element is not proxy + * + * @private + * @return {boolean} + */ + var isNotProxy = function() { + return !proxy || !proxy.length || this !== proxy[0]; + }; + + /** + * Check if list is horizontal + * + * @param {jQuery} element + * @return {Boolean} + */ + var isListHorizontal = function(element) { + var isHorizontal = params.isHorizontal; + if (isHorizontal === true || isHorizontal === false) { + return isHorizontal; + } + return isHorizontal(element); + }; + + /** + * Handler for events mousemove touchmove mouseup touchend + * + * @private + * @param {Event} evt + */ + var dragHandler = function(evt) { + + calculatePositionOnPage(evt); + + // We can not use evt.target here because it will most likely be our proxy. + // Move the proxy out of the way so we can find the element at the current mouse position. + proxy.offset({top: -1000, left: -1000}); + // Find the element at the current mouse position. + var element = $(document.elementFromPoint(evt.clientX, evt.clientY)); + + // Find the list element and the list over the mouse position. + var current = element.closest('.' + params.targetListClass + ' > :not(.' + params.isDraggedClass + ')'), + currentList = element.closest('.' + params.targetListClass); + + // Add the specified class to the list element we are hovering. + $('.' + params.overElementClass).removeClass(params.overElementClass); + current.addClass(params.overElementClass); + + // Move proxy to the current position. + proxy.offset({top: proxyDelta.y + evt.pageY, left: proxyDelta.x + evt.pageX}); + + if (currentList.length && !currentList.children().filter(isNotProxy).length) { + // Mouse is over an empty list. + moveElement(currentList, $()); + } else if (current.length === 1 && !info.element.find(current[0]).length) { + // Mouse is over an element in a list - find whether we should move the current position + // above or below this element. + var coordinates = getPositionInNode(evt.pageX, evt.pageY, current); + if (coordinates) { + var parent = current.parent(), + ratio = isListHorizontal(parent) ? coordinates.xRatio : coordinates.yRatio, + subList = current.find('.' + params.targetListClass), + isNotCurrent = function() { + return this !== info.element[0]; + }, + subListEmpty = !subList.children().filter(isNotProxy).filter(isNotCurrent).length; + if (subList.length && subListEmpty && ratio > 0.2 && ratio < 0.8) { + // This is an element that is a parent of an empty list and we are around the middle of this element. + // Treat it as if we are over this empty list. + moveElement(subList, $()); + } else if (ratio > 0.5) { + // Insert after this element. + moveElement(parent, current.next().filter(isNotProxy)); + } else { + // Insert before this element. + moveElement(parent, current); + } + } + } + + if (evt.type === 'mouseup' || evt.type === 'touchend') { + // Drop the moved element. + info.endX = evt.pageX; + info.endY = evt.pageY; + info.endTime = new Date().getTime(); + info.dropped = true; + executeCallback('drop'); + finishDragging(); + } + }; + + /** + * Moves the current position of the dragged element + * + * @private + * @param {jQuery} parentElement + * @param {jQuery} beforeElement + */ + var moveElement = function(parentElement, beforeElement) { + var dragEl = info.element; + if (beforeElement.length && beforeElement[0] === dragEl[0]) { + // Insert before the current position of the dragged element - nothing to do. + return; + } + if (parentElement[0] === info.targetList[0] && + beforeElement.length === info.targetNextElement.length && + beforeElement[0] === info.targetNextElement[0]) { + // Insert in the same location as the current position - nothing to do. + return; + } + + if (beforeElement.length) { + // Move the dragged element before the specified element. + parentElement[0].insertBefore(dragEl[0], beforeElement[0]); + } else if (proxy && proxy.parent().length && proxy.parent()[0] === parentElement[0]) { + // We need to move to the end of the list but the last element in this list is a proxy. + // Always leave the proxy in the end of the list. + parentElement[0].insertBefore(dragEl[0], proxy[0]); + } else { + // Insert in the end of a list (when proxy is in another list). + parentElement[0].appendChild(dragEl[0]); + } + + // Save the current position of the dragged element in the list. + info.targetList = parentElement; + info.targetNextElement = beforeElement; + executeCallback('drag'); + }; + + /** + * Finish dragging (when dropped or cancelled). + * @private + */ + var finishDragging = function() { + resetDraggedClasses(); + if (params.autoScroll) { + autoScroll.stop(); + } + $('body').off('mousemove touchmove mouseup touchend', dragHandler); + $('body').off('keypress', dragcancelHandler); + executeCallback('dragend'); + info = null; + }; + + /** + * Executes callback specified in sortable list parameters + * + * @private + * @param {String} eventName + */ + var executeCallback = function(eventName) { + info.element.trigger('sortablelist-' + eventName, info); + }; + + /** + * Handler from keypress event (cancel dragging when Esc is pressed) + * + * @private + * @param {Event} evt + */ + var dragcancelHandler = function(evt) { + if (evt.type !== 'keypress' || evt.originalEvent.keyCode !== 27) { + // Only cancel dragging when Esc was pressed. + return; + } + // Dragging was cancelled. Return item to the original position. + moveElement(info.sourceList, info.sourceNextElement); + finishDragging(); + }; + + /** + * Helper method to convert a string to a promise + * + * @private + * @param {(String|Promise)} value + * @return {Promise} + */ + var convertToPromise = function(value) { + var p = value; + if (typeof value !== 'object' || !value.hasOwnProperty('then')) { + p = $.Deferred(); + p.resolve(value); + } + return p; + }; + + /** + * Returns the name of the current element to be used in the move dialogue + * + * @private + * @param {jQuery} element + * @return {Promise} + */ + var getElementName = function(element) { + return convertToPromise(params.elementNameCallback(element)); + }; + + /** + * Returns the label for the potential move destination, i.e. "After ElementX" or "To the top of the list" + * + * Note that we use "after" in the label for better UX + * + * @private + * @param {jQuery} parentElement + * @param {jQuery} afterElement + * @return {Promise} + */ + var getDestinationName = function(parentElement, afterElement) { + return convertToPromise(params.destinationNameCallback(parentElement, afterElement)); + }; + + /** + * Returns the title for the move dialogue ("Move elementY") + * + * @private + * @param {jQuery} element + * @return {Promise} + */ + var getMoveDialogueTitle = function(element) { + return convertToPromise(params.moveDialogueTitleCallback(element)); + }; + + /** + * Returns the list of possible move destinations with their onclick handlers + * + * @private + * @param {Modal} modal + * @return {jQuery} + */ + var getDestinationsList = function(modal) { + var addedLists = [], + targets = $(params.listSelector), + list = $('<ul/>').addClass(params.keyboardDragClass), + createLink = function(parentElement, beforeElement, afterElement) { + if (beforeElement.is(info.element) || afterElement.is(info.element)) { + return; + } + var li = $('<li/>').appendTo(list); + var a = $('<a href="#"/>') + .click(function(e) { + e.preventDefault(); + e.stopPropagation(); + moveElement(parentElement, beforeElement); + info.endTime = new Date().getTime(); + info.dropped = true; + info.element.find(params.moveHandlerSelector).focus(); + executeCallback('drop'); + modal.hide(); + }) + .appendTo(li); + getDestinationName(parentElement, afterElement) + .then(function(txt) { + a.text(txt); + return txt; + }).catch(Notification.exception); + }, + addList = function() { + if ($.inArray(this, addedLists) !== -1) { + return; + } + addedLists.push(this); + var list = $(this), + children = list.children(); + children.each(function() { + var element = $(this); + createLink(list, element, element.prev()); + // Add all nested lists. + element.find(targets).each(addList); + }); + createLink(list, $(), children.last()); + }; + targets.each(addList); + return list; + }; + + /** + * Displays the dialogue to move element. + * @private + */ + var displayMoveDialogue = function() { + ModalFactory.create({ + type: ModalFactory.types.CANCEL, + title: getMoveDialogueTitle(info.element) + }).then(function(modal) { + modal.getRoot().on(ModalEvents.hidden, function() { + // Always destroy when hidden, it is generated dynamically each time. + modal.destroy(); + finishDragging(); + }); + modal.getBody().append(getDestinationsList(modal)); + modal.setLarge(); + modal.show(); + return modal; + }); + }; + + return { + /** + * Initialise sortable list. + * + * @param {Object} params Parameters for the list. See defaultParameters above for examples. + */ + init: function(params) { + if (typeof params.listSelector === 'undefined') { + log.error('Parameter listSelector must be specified'); + return; + } + params = $.extend({}, defaultParameters, CSS, params); + $(params.listSelector).on('mousedown touchstart', '> *', {params: params}, dragStartHandler); + if (params.moveHandlerSelector !== null) { + $(params.listSelector).on('click keypress', params.moveHandlerSelector, {params: params}, clickHandler); + } + } + }; +});