MDL-63625 core: sortable_list iOS scrolling
[moodle.git] / lib / amd / src / sortable_list.js
blob3806563b8445e326fed3f196ed04a2600ad77470
1 // This file is part of Moodle - http://moodle.org/
2 //
3 // Moodle is free software: you can redistribute it and/or modify
4 // it under the terms of the GNU General Public License as published by
5 // the Free Software Foundation, either version 3 of the License, or
6 // (at your option) any later version.
7 //
8 // Moodle is distributed in the hope that it will be useful,
9 // but WITHOUT ANY WARRANTY; without even the implied warranty of
10 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11 // GNU General Public License for more details.
13 // You should have received a copy of the GNU General Public License
14 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
16 /**
17  * A javascript module to handle list items drag and drop
18  *
19  * Example of usage:
20  *
21  * Create a list (for example <ul> or <tbody>) where each draggable element has a drag handle.
22  * The best practice is to use the template core/drag_handle:
23  * $OUTPUT->render_from_template('core/drag_handle', ['movetitle' => get_string('movecontent', 'moodle', ELEMENTNAME)]);
24  *
25  * Attach this JS module to this list:
26  *
27  * define(['jquery', 'core/sortable_list'], function($, SortableList) {
28  *     var list = new SortableList('ul.my-awesome-list'); // source list (usually <ul> or <tbody>) - selector or element
29  *
30  *     // Listen to the events when element is dragged.
31  *     $('ul.my-awesome-list > *').on(SortableList.EVENTS.DROP, function(evt, info) {
32  *         console.log(info);
33  *     });
34  *
35  *     // Advanced usage. Overwrite methods getElementName, getDestinationName, moveDialogueTitle, for example:
36  *     list.getElementName = function(element) {
37  *         return $.Deferred().resolve(element.attr('data-name'));
38  *     }
39  * }
40  *
41  * More details: https://docs.moodle.org/dev/Sortable_list
42  *
43  * For the full list of possible parameters see var defaultParameters below.
44  *
45  * The following jQuery events are fired:
46  * - SortableList.EVENTS.DRAGSTART : when user started dragging a list element
47  * - SortableList.EVENTS.DRAG : when user dragged a list element to a new position
48  * - SortableList.EVENTS.DROP : when user dropped a list element
49  * - SortableList.EVENTS.DROPEND : when user finished dragging - either fired right after dropping or
50  *                          if "Esc" was pressed during dragging
51  *
52  * @module     core/sortable_list
53  * @class      sortable_list
54  * @package    core
55  * @copyright  2018 Marina Glancy
56  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
57  */
58 define(['jquery', 'core/log', 'core/autoscroll', 'core/str', 'core/modal_factory', 'core/modal_events', 'core/notification'],
59 function($, log, autoScroll, str, ModalFactory, ModalEvents, Notification) {
61     /**
62      * Default parameters
63      *
64      * @private
65      * @type {Object}
66      */
67     var defaultParameters = {
68         targetListSelector: null,
69         moveHandlerSelector: '[data-drag-type=move]',
70         isHorizontal: false,
71         autoScroll: true
72     };
74     /**
75      * Class names for different elements that may be changed during sorting
76      *
77      * @private
78      * @type {Object}
79      */
80     var CSS = {
81         keyboardDragClass: 'dragdrop-keyboard-drag',
82         isDraggedClass: 'sortable-list-is-dragged',
83         currentPositionClass: 'sortable-list-current-position',
84         sourceListClass: 'sortable-list-source',
85         targetListClass: 'sortable-list-target',
86         overElementClass: 'sortable-list-over-element'
87     };
89     /**
90      * Test the browser support for options objects on event listeners.
91      * @return {Boolean}
92      */
93     var eventListenerOptionsSupported = function() {
94         var passivesupported = false,
95             options,
96             testeventname = "testpassiveeventoptions";
98         // Options support testing example from:
99         // https://developer.mozilla.org/en-US/docs/Web/API/EventTarget/addEventListener
101         try {
102             options = Object.defineProperty({}, "passive", {
103                 get: function() {
104                     passivesupported = true;
105                 }
106             });
108             // We use an event name that is not likely to conflict with any real event.
109             document.addEventListener(testeventname, options, options);
110             // We remove the event listener as we have tested the options already.
111             document.removeEventListener(testeventname, options, options);
112         } catch (err) {
113             // It's already false.
114             passivesupported = false;
115         }
116         return passivesupported;
117     };
119     /**
120      * Allow to create non-passive touchstart listeners and prevent page scrolling when dragging
121      * From: https://stackoverflow.com/a/48098097
122      *
123      * @type {Object}
124      */
125     var registerNotPassiveListeners = function(eventname) {
126         return {
127             setup: function(x, ns, handle) {
128                 if (ns.includes('notPassive')) {
129                     this.addEventListener(eventname, handle, {passive: false});
130                     return true;
131                 } else {
132                     return false;
133                 }
134             }
135         };
136     };
138     if (eventListenerOptionsSupported) {
139         $.event.special.touchstart = registerNotPassiveListeners('touchstart');
140         $.event.special.touchmove = registerNotPassiveListeners('touchmove');
141         $.event.special.touchend = registerNotPassiveListeners('touchend');
142     }
144     /**
145      * Initialise sortable list.
146      *
147      * @param {(String|jQuery|Element)} root JQuery/DOM element representing sortable list (i.e. <ul>, <tbody>) or CSS selector
148      * @param {Object} config Parameters for the list. See defaultParameters above for examples.
149      * @property {(String|jQuery|Element)} config.targetListSelector target lists, by default same as root
150      * @property {String} config.moveHandlerSelector  CSS selector for a drag handle. By default '[data-drag-type=move]'
151      * @property {String} config.targetListSelector   CSS selector for target lists. By default the same as root
152      * @property {(Boolean|Function)} config.isHorizontal Set to true if the list is horizontal
153      *                                                (can also be a callback with list as an argument)
154      * @property {Boolean} config.autoScroll          Engages autoscroll module for automatic vertical scrolling of the
155      *                                                whole page, by default true
156      */
157     var SortableList = function(root, config) {
159         this.info = null;
160         this.proxy = null;
161         this.proxyDelta = null;
162         this.dragCounter = 0;
163         this.lastEvent = null;
165         this.config = $.extend({}, defaultParameters, config || {});
166         this.config.listSelector = root;
167         if (!this.config.targetListSelector) {
168             this.config.targetListSelector = root;
169         }
170         if (typeof this.config.listSelector === 'object') {
171             // The root is an element on the page. Register a listener for this element.
172             $(this.config.listSelector).on('mousedown touchstart.notPassive', $.proxy(this.dragStartHandler, this));
173         } else {
174             // The root is a CSS selector. Register a listener that picks up the element dynamically.
175             $('body').on('mousedown touchstart.notPassive', this.config.listSelector, $.proxy(this.dragStartHandler, this));
176         }
177         if (this.config.moveHandlerSelector !== null) {
178             $('body').on('click keypress', this.config.moveHandlerSelector, $.proxy(this.clickHandler, this));
179         }
181     };
183     /**
184      * Events fired by this entity
185      *
186      * @public
187      * @type {Object}
188      */
189     SortableList.EVENTS = {
190         DRAGSTART: 'sortablelist-dragstart',
191         DRAG: 'sortablelist-drag',
192         DROP: 'sortablelist-drop',
193         DRAGEND: 'sortablelist-dragend'
194     };
196     /**
197      * Resets the temporary classes assigned during dragging
198      * @private
199      */
200      SortableList.prototype.resetDraggedClasses = function() {
201         var classes = [
202             CSS.isDraggedClass,
203             CSS.currentPositionClass,
204             CSS.overElementClass,
205             CSS.targetListClass,
206         ];
207         for (var i in classes) {
208             $('.' + classes[i]).removeClass(classes[i]);
209         }
210         if (this.proxy) {
211             this.proxy.remove();
212             this.proxy = $();
213         }
214     };
216     /**
217      * Calculates evt.pageX, evt.pageY, evt.clientX and evt.clientY
218      *
219      * For touch events pageX and pageY are taken from the first touch;
220      * For the emulated mousemove event they are taken from the last real event.
221      *
222      * @private
223      * @param {Event} evt
224      */
225     SortableList.prototype.calculatePositionOnPage = function(evt) {
227         if (evt.originalEvent && evt.originalEvent.touches && evt.originalEvent.touches[0] !== undefined) {
228             // This is a touchmove or touchstart event, get position from the first touch position.
229             var touch = evt.originalEvent.touches[0];
230             evt.pageX = touch.pageX;
231             evt.pageY = touch.pageY;
232         }
234         if (evt.pageX === undefined) {
235             // Information is not present in case of touchend or when event was emulated by autoScroll.
236             // Take the absolute mouse position from the last event.
237             evt.pageX = this.lastEvent.pageX;
238             evt.pageY = this.lastEvent.pageY;
239         } else {
240             this.lastEvent = evt;
241         }
243         if (evt.clientX === undefined) {
244             // If not provided in event calculate relative mouse position.
245             evt.clientX = Math.round(evt.pageX - $(window).scrollLeft());
246             evt.clientY = Math.round(evt.pageY - $(window).scrollTop());
247         }
248     };
250     /**
251      * Handler from dragstart event
252      *
253      * @private
254      * @param {Event} evt
255      */
256     SortableList.prototype.dragStartHandler = function(evt) {
257         if (this.info !== null) {
258             if (this.info.type === 'click' || this.info.type === 'touchend') {
259                 // Ignore double click.
260                 return;
261             }
262             // Mouse down or touch while already dragging, cancel previous dragging.
263             this.moveElement(this.info.sourceList, this.info.sourceNextElement);
264             this.finishDragging();
265         }
267         if (evt.type === 'mousedown' && evt.which !== 1) {
268             // We only need left mouse click. If this is a mousedown event with right/middle click ignore it.
269             return;
270         }
272         this.calculatePositionOnPage(evt);
273         var movedElement = $(evt.target).closest($(evt.currentTarget).children());
274         if (!movedElement.length) {
275             // Can't find the element user wants to drag. They clicked on the list but outside of any element of the list.
276             return;
277         }
279         // Check that we grabbed the element by the handle.
280         if (this.config.moveHandlerSelector !== null) {
281             if (!$(evt.target).closest(this.config.moveHandlerSelector, movedElement).length) {
282                 return;
283             }
284         }
286         evt.stopPropagation();
287         evt.preventDefault();
289         // Information about moved element with original location.
290         // This object is passed to event observers.
291         this.dragCounter++;
292         this.info = {
293             element: movedElement,
294             sourceNextElement: movedElement.next(),
295             sourceList: movedElement.parent(),
296             targetNextElement: movedElement.next(),
297             targetList: movedElement.parent(),
298             type: evt.type,
299             dropped: false,
300             startX: evt.pageX,
301             startY: evt.pageY,
302             startTime: new Date().getTime()
303         };
305         $(this.config.targetListSelector).addClass(CSS.targetListClass);
307         var offset = movedElement.offset();
308         movedElement.addClass(CSS.currentPositionClass);
309         this.proxyDelta = {x: offset.left - evt.pageX, y: offset.top - evt.pageY};
310         this.proxy = $();
311         var thisDragCounter = this.dragCounter;
312         setTimeout($.proxy(function() {
313             // This mousedown event may in fact be a beginning of a 'click' event. Use timeout before showing the
314             // dragged object so we can catch click event. When timeout finishes make sure that click event
315             // has not happened during this half a second.
316             // Verify dragcounter to make sure the user did not manage to do two very fast drag actions one after another.
317             if (this.info === null || this.info.type === 'click' || this.info.type === 'keypress'
318                     || this.dragCounter !== thisDragCounter) {
319                 return;
320             }
322             // Create a proxy - the copy of the dragged element that moves together with a mouse.
323             this.createProxy();
324         }, this), 500);
326         // Start drag.
327         $(window).on('mousemove touchmove.notPassive mouseup touchend.notPassive', $.proxy(this.dragHandler, this));
328         $(window).on('keypress', $.proxy(this.dragcancelHandler, this));
330         // Start autoscrolling. Every time the page is scrolled emulate the mousemove event.
331         if (this.config.autoScroll) {
332             autoScroll.start(function() {
333                 $(window).trigger('mousemove');
334             });
335         }
337        this.executeCallback(SortableList.EVENTS.DRAGSTART);
338     };
340     /**
341      * Creates a "proxy" object - a copy of the element that is being moved that always follows the mouse
342      * @private
343      */
344     SortableList.prototype.createProxy = function() {
345         this.proxy = this.info.element.clone();
346         this.info.sourceList.append(this.proxy);
347         this.proxy.removeAttr('id').removeClass(CSS.currentPositionClass)
348             .addClass(CSS.isDraggedClass).css({position: 'fixed'});
349         this.proxy.offset({top: this.proxyDelta.y + this.lastEvent.pageY, left: this.proxyDelta.x + this.lastEvent.pageX});
350     };
352     /**
353      * Handler for click event - when user clicks on the drag handler or presses Enter on keyboard
354      *
355      * @private
356      * @param {Event} evt
357      */
358     SortableList.prototype.clickHandler = function(evt) {
359         if (evt.type === 'keypress' && evt.originalEvent.keyCode !== 13 && evt.originalEvent.keyCode !== 32) {
360             return;
361         }
362         if (this.info !== null) {
363             // Ignore double click.
364             return;
365         }
367         // Find the element that this draghandle belongs to.
368         var clickedElement = $(evt.target).closest(this.config.moveHandlerSelector),
369             sourceList = clickedElement.closest(this.config.listSelector),
370             movedElement = clickedElement.closest(sourceList.children());
371         if (!movedElement.length) {
372             return;
373         }
375         evt.preventDefault();
376         evt.stopPropagation();
378         // Store information about moved element with original location.
379         this.dragCounter++;
380         this.info = {
381             element: movedElement,
382             sourceNextElement: movedElement.next(),
383             sourceList: sourceList,
384             targetNextElement: movedElement.next(),
385             targetList: sourceList,
386             dropped: false,
387             type: evt.type,
388             startTime: new Date().getTime()
389         };
391         this.executeCallback(SortableList.EVENTS.DRAGSTART);
392         this.displayMoveDialogue(clickedElement);
393     };
395     /**
396      * Finds the position of the mouse inside the element - on the top, on the bottom, on the right or on the left\
397      *
398      * Used to determine if the moved element should be moved after or before the current element
399      *
400      * @private
401      * @param {Number} pageX
402      * @param {Number} pageY
403      * @param {jQuery} element
404      * @returns {(Object|null)}
405      */
406     SortableList.prototype.getPositionInNode = function(pageX, pageY, element) {
407         if (!element.length) {
408             return null;
409         }
410         var node = element[0],
411             offset = 0,
412             rect = node.getBoundingClientRect(),
413             y = pageY - (rect.top + window.scrollY),
414             x = pageX - (rect.left + window.scrollX);
415         if (x >= -offset && x <= rect.width + offset && y >= -offset && y <= rect.height + offset) {
416             return {
417                 x: x,
418                 y: y,
419                 xRatio: rect.width ? (x / rect.width) : 0,
420                 yRatio: rect.height ? (y / rect.height) : 0
421             };
422         }
423         return null;
424     };
426     /**
427      * Check if list is horizontal
428      *
429      * @param {jQuery} element
430      * @return {Boolean}
431      */
432     SortableList.prototype.isListHorizontal = function(element) {
433         var isHorizontal = this.config.isHorizontal;
434         if (isHorizontal === true || isHorizontal === false) {
435             return isHorizontal;
436         }
437         return isHorizontal(element);
438     };
440     /**
441      * Handler for events mousemove touchmove mouseup touchend
442      *
443      * @private
444      * @param {Event} evt
445      */
446     SortableList.prototype.dragHandler = function(evt) {
448         evt.preventDefault();
449         evt.stopPropagation();
451         this.calculatePositionOnPage(evt);
453         // We can not use evt.target here because it will most likely be our proxy.
454         // Move the proxy out of the way so we can find the element at the current mouse position.
455         this.proxy.offset({top: -1000, left: -1000});
456         // Find the element at the current mouse position.
457         var element = $(document.elementFromPoint(evt.clientX, evt.clientY));
459         // Find the list element and the list over the mouse position.
460         var mainElement = this.info.element[0],
461             isNotSelf = function() {
462                 return this !== mainElement;
463             },
464             current = element.closest('.' + CSS.targetListClass + ' > :not(.' + CSS.isDraggedClass + ')').filter(isNotSelf),
465             currentList = element.closest('.' + CSS.targetListClass),
466             proxy = this.proxy,
467             isNotProxy = function() {
468                 return !proxy || !proxy.length || this !== proxy[0];
469             };
471         // Add the specified class to the list element we are hovering.
472         $('.' + CSS.overElementClass).removeClass(CSS.overElementClass);
473         current.addClass(CSS.overElementClass);
475         // Move proxy to the current position.
476         this.proxy.offset({top: this.proxyDelta.y + evt.pageY, left: this.proxyDelta.x + evt.pageX});
478         if (currentList.length && !currentList.children().filter(isNotProxy).length) {
479             // Mouse is over an empty list.
480             this.moveElement(currentList, $());
481         } else if (current.length === 1 && !this.info.element.find(current[0]).length) {
482             // Mouse is over an element in a list - find whether we should move the current position
483             // above or below this element.
484             var coordinates = this.getPositionInNode(evt.pageX, evt.pageY, current);
485             if (coordinates) {
486                 var parent = current.parent(),
487                     ratio = this.isListHorizontal(parent) ? coordinates.xRatio : coordinates.yRatio,
488                     subList = current.find('.' + CSS.targetListClass),
489                     subListEmpty = !subList.children().filter(isNotProxy).filter(isNotSelf).length;
490                 if (subList.length && subListEmpty && ratio > 0.2 && ratio < 0.8) {
491                     // This is an element that is a parent of an empty list and we are around the middle of this element.
492                     // Treat it as if we are over this empty list.
493                    this.moveElement(subList, $());
494                 } else if (ratio > 0.5) {
495                     // Insert after this element.
496                    this.moveElement(parent, current.next().filter(isNotProxy));
497                 } else {
498                     // Insert before this element.
499                    this.moveElement(parent, current);
500                 }
501             }
502         }
504         if (evt.type === 'mouseup' || evt.type === 'touchend') {
505             // Drop the moved element.
506             this.info.endX = evt.pageX;
507             this.info.endY = evt.pageY;
508             this.info.endTime = new Date().getTime();
509             this.info.dropped = true;
510             this.info.positionChanged = this.hasPositionChanged(this.info);
511             var oldinfo = this.info;
512             this.executeCallback(SortableList.EVENTS.DROP);
513             this.finishDragging();
515             if (evt.type === 'touchend'
516                     && this.config.moveHandlerSelector !== null
517                     && (oldinfo.endTime - oldinfo.startTime < 500)
518                     && !oldinfo.positionChanged) {
519                 // The click event is not triggered on touch screens because we call preventDefault in touchstart handler.
520                 // If the touchend quickly followed touchstart without moving, consider it a "click".
521                 this.clickHandler(evt);
522             }
523         }
524     };
526     /**
527      * Checks if the position of the dragged element in the list has changed
528      *
529      * @private
530      * @param {Object} info
531      * @return {Boolean}
532      */
533     SortableList.prototype.hasPositionChanged = function(info) {
534         return info.sourceList[0] !== info.targetList[0] ||
535             info.sourceNextElement.length !== info.targetNextElement.length ||
536             (info.sourceNextElement.length && info.sourceNextElement[0] !== info.targetNextElement[0]);
537     };
539     /**
540      * Moves the current position of the dragged element
541      *
542      * @private
543      * @param {jQuery} parentElement
544      * @param {jQuery} beforeElement
545      */
546     SortableList.prototype.moveElement = function(parentElement, beforeElement) {
547         var dragEl = this.info.element;
548         if (beforeElement.length && beforeElement[0] === dragEl[0]) {
549             // Insert before the current position of the dragged element - nothing to do.
550             return;
551         }
552         if (parentElement[0] === this.info.targetList[0] &&
553                 beforeElement.length === this.info.targetNextElement.length &&
554                 beforeElement[0] === this.info.targetNextElement[0]) {
555             // Insert in the same location as the current position - nothing to do.
556             return;
557         }
559         if (beforeElement.length) {
560             // Move the dragged element before the specified element.
561             parentElement[0].insertBefore(dragEl[0], beforeElement[0]);
562         } else if (this.proxy && this.proxy.parent().length && this.proxy.parent()[0] === parentElement[0]) {
563             // We need to move to the end of the list but the last element in this list is a proxy.
564             // Always leave the proxy in the end of the list.
565             parentElement[0].insertBefore(dragEl[0], this.proxy[0]);
566         } else {
567             // Insert in the end of a list (when proxy is in another list).
568             parentElement[0].appendChild(dragEl[0]);
569         }
571         // Save the current position of the dragged element in the list.
572         this.info.targetList = parentElement;
573         this.info.targetNextElement = beforeElement;
574         this.executeCallback(SortableList.EVENTS.DRAG);
575     };
577     /**
578      * Finish dragging (when dropped or cancelled).
579      * @private
580      */
581     SortableList.prototype.finishDragging = function() {
582         this.resetDraggedClasses();
583         if (this.config.autoScroll) {
584             autoScroll.stop();
585         }
586         $(window).off('mousemove touchmove.notPassive mouseup touchend.notPassive', $.proxy(this.dragHandler, this));
587         $(window).off('keypress', $.proxy(this.dragcancelHandler, this));
588         this.executeCallback(SortableList.EVENTS.DRAGEND);
589         this.info = null;
590     };
592     /**
593      * Executes callback specified in sortable list parameters
594      *
595      * @private
596      * @param {String} eventName
597      */
598     SortableList.prototype.executeCallback = function(eventName) {
599         this.info.element.trigger(eventName, this.info);
600     };
602     /**
603      * Handler from keypress event (cancel dragging when Esc is pressed)
604      *
605      * @private
606      * @param {Event} evt
607      */
608     SortableList.prototype.dragcancelHandler = function(evt) {
609         if (evt.type !== 'keypress' || evt.originalEvent.keyCode !== 27) {
610             // Only cancel dragging when Esc was pressed.
611             return;
612         }
613         // Dragging was cancelled. Return item to the original position.
614         this.moveElement(this.info.sourceList, this.info.sourceNextElement);
615         this.finishDragging();
616     };
618     /**
619      * Returns the name of the current element to be used in the move dialogue
620      *
621      * @public
622      * @param {jQuery} element
623      * @return {Promise}
624      */
625     SortableList.prototype.getElementName = function(element) {
626         return $.Deferred().resolve(element.text());
627     };
629     /**
630      * Returns the label for the potential move destination, i.e. "After ElementX" or "To the top of the list"
631      *
632      * Note that we use "after" in the label for better UX
633      *
634      * @public
635      * @param {jQuery} parentElement
636      * @param {jQuery} afterElement
637      * @return {Promise}
638      */
639     SortableList.prototype.getDestinationName = function(parentElement, afterElement) {
640         if (!afterElement.length) {
641             return str.get_string('movecontenttothetop', 'moodle');
642         } else {
643             return this.getElementName(afterElement)
644                 .then(function(name) {
645                     return str.get_string('movecontentafter', 'moodle', name);
646                 });
647         }
648     };
650     /**
651      * Returns the title for the move dialogue ("Move elementY")
652      *
653      * @public
654      * @param {jQuery} element
655      * @param {jQuery} handler
656      * @return {Promise}
657      */
658     SortableList.prototype.getMoveDialogueTitle = function(element, handler) {
659         if (handler.attr('title')) {
660             return $.Deferred().resolve(handler.attr('title'));
661         }
662         return this.getElementName(element).then(function(name) {
663             return str.get_string('movecontent', 'moodle', name);
664         });
665     };
667     /**
668      * Returns the list of possible move destinations
669      *
670      * @private
671      * @return {Promise}
672      */
673     SortableList.prototype.getDestinationsList = function() {
674         var addedLists = [],
675             targets = $(this.config.targetListSelector),
676             destinations = $('<ul/>').addClass(CSS.keyboardDragClass),
677             result = $.when().then(function() {
678                 return destinations;
679             }),
680             createLink = $.proxy(function(parentElement, beforeElement, afterElement) {
681                 if (beforeElement.is(this.info.element) || afterElement.is(this.info.element)) {
682                     // Can not move before or after itself.
683                     return;
684                 }
685                 if ($.contains(this.info.element[0], parentElement[0])) {
686                     // Can not move to its own child.
687                     return;
688                 }
689                 result = result
690                 .then($.proxy(function() {
691                     return this.getDestinationName(parentElement, afterElement);
692                 }, this))
693                 .then(function(txt) {
694                     var li = $('<li/>').appendTo(destinations);
695                     var a = $('<a href="#"/>').attr('data-core_sortable_list-quickmove', 1).appendTo(li);
696                     a.data('parent-element', parentElement).data('before-element', beforeElement).text(txt);
697                     return destinations;
698                 });
699             }, this),
700             addList = function() {
701                 // Destination lists may be nested. We want to add all move destinations in the same
702                 // order they appear on the screen for the user.
703                 if ($.inArray(this, addedLists) !== -1) {
704                     return;
705                 }
706                 addedLists.push(this);
707                 var list = $(this),
708                     children = list.children();
709                 children.each(function() {
710                     var element = $(this);
711                     createLink(list, element, element.prev());
712                     // Add all nested lists.
713                     element.find(targets).each(addList);
714                 });
715                 createLink(list, $(), children.last());
716             };
717         targets.each(addList);
718         return result;
719     };
721     /**
722      * Displays the dialogue to move element.
723      * @param {jQuery} clickedElement element to return focus to after the modal is closed
724      * @private
725      */
726     SortableList.prototype.displayMoveDialogue = function(clickedElement) {
727         ModalFactory.create({
728             type: ModalFactory.types.CANCEL,
729             title: this.getMoveDialogueTitle(this.info.element, clickedElement),
730             body: this.getDestinationsList()
731         }).then($.proxy(function(modal) {
732             var quickMoveHandler = $.proxy(function(e) {
733                 e.preventDefault();
734                 e.stopPropagation();
735                 this.moveElement($(e.currentTarget).data('parent-element'), $(e.currentTarget).data('before-element'));
736                 this.info.endTime = new Date().getTime();
737                 this.info.positionChanged = this.hasPositionChanged(this.info);
738                 this.info.dropped = true;
739                 clickedElement.focus();
740                 this.executeCallback(SortableList.EVENTS.DROP);
741                 modal.hide();
742             }, this);
743             modal.getRoot().on('click', '[data-core_sortable_list-quickmove]', quickMoveHandler);
744             modal.getRoot().on(ModalEvents.hidden, $.proxy(function() {
745                 // Always destroy when hidden, it is generated dynamically each time.
746                 modal.getRoot().off('click', '[data-core_sortable_list-quickmove]', quickMoveHandler);
747                 modal.destroy();
748                 this.finishDragging();
749             }, this));
750             modal.setLarge();
751             modal.show();
752             return modal;
753         }, this)).catch(Notification.exception);
754     };
756     return SortableList;