drag_and_drop.js 19 KB
Newer Older
1 2 3 4 5 6 7 8
define(["jquery", "jquery.ui", "underscore", "gettext", "js/views/feedback_notification", "draggabilly",
    "js/utils/module"],
    function ($, ui, _, gettext, NotificationView, Draggabilly, ModuleUtils) {

    var contentDragger = {
            droppableClasses: 'drop-target drop-target-prepend drop-target-before drop-target-after',
            validDropClass: "valid-drop",
            expandOnDropClass: "expand-on-drop",
9
            collapsedClass: "is-collapsed",
10 11 12 13 14 15 16 17

            /*
             * Determine information about where to drop the currently dragged
             * element. Returns the element to attach to and the method of
             * attachment ('before', 'after', or 'prepend').
             */
            findDestination: function (ele, yChange) {
                var eleY = ele.offset().top;
18
                var eleYEnd = eleY + ele.outerHeight();
19 20 21 22 23 24 25 26 27 28 29 30 31
                var containers = $(ele.data('droppable-class'));

                for (var i = 0; i < containers.length; i++) {
                    var container = $(containers[i]);
                    // Exclude the 'new unit' buttons, and make sure we don't
                    // prepend an element to itself
                    var siblings = container.children().filter(function () {
                        return $(this).data('locator') !== undefined && !$(this).is(ele);
                    });
                    // If the container is collapsed, check to see if the
                    // element is on top of its parent list -- don't check the
                    // position of the container
                    var parentList = container.parents(ele.data('parent-location-selector')).first();
32
                    if (parentList.hasClass(this.collapsedClass)) {
33 34 35 36 37 38 39
                        var parentListTop =  parentList.offset().top;
                        // To make it easier to drop subsections into collapsed sections (which have
                        // a lot of visual padding around them), allow a fudge factor around the
                        // parent element.
                        var collapseFudge = 10;
                        if (Math.abs(eleY - parentListTop) < collapseFudge ||
                            (eleY > parentListTop &&
40
                             eleYEnd - collapseFudge <= parentListTop + parentList.outerHeight())
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68
                            ) {
                            return {
                                ele: container,
                                attachMethod: 'prepend',
                                parentList: parentList
                            };
                        }
                    }
                    // Otherwise, do check the container
                    else {
                        // If the list is empty, we should prepend to it,
                        // unless both elements are at the same location --
                        // this prevents the user from being unable to expand
                        // a section
                        var containerY = container.offset().top;
                        if (siblings.length === 0 &&
                            containerY !== eleY &&
                            Math.abs(eleY - containerY) < 50) {
                            return {
                                ele: container,
                                attachMethod: 'prepend'
                            };
                        }
                        // Otherwise the list is populated, and we should attach before/after a sibling
                        else {
                            for (var j = 0; j < siblings.length; j++) {
                                var $sibling = $(siblings[j]);
                                var siblingY = $sibling.offset().top;
69
                                var siblingHeight = $sibling.outerHeight();
70 71 72 73
                                var siblingYEnd = siblingY + siblingHeight;

                                // Facilitate dropping into the beginning or end of a list
                                // (coming from opposite direction) via a "fudge factor". Math.min is for Jasmine test.
74
                                var fudge = Math.min(Math.ceil(siblingHeight / 2), 35);
75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161

                                // Dragging to top or bottom of a list with only one element is tricky
                                // because the element being dragged may be the same size as the sibling.
                                if (siblings.length === 1) {
                                    // Element being dragged is within the drop target. Use the direction
                                    // of the drag (yChange) to determine before or after.
                                    if (eleY + fudge >= siblingY && eleYEnd - fudge <= siblingYEnd) {
                                        return {
                                            ele: $sibling,
                                            attachMethod: yChange > 0 ? 'after' : 'before'
                                        };
                                    }
                                    // Element being dragged is before the drop target.
                                    else if (Math.abs(eleYEnd - siblingY) <= fudge) {
                                        return {
                                            ele: $sibling,
                                            attachMethod: 'before'
                                        };
                                    }
                                    // Element being dragged is after the drop target.
                                    else if (Math.abs(eleY - siblingYEnd) <= fudge) {
                                        return {
                                            ele: $sibling,
                                            attachMethod: 'after'
                                        };
                                    }
                                }
                                else {
                                    // Dragging up into end of list.
                                    if (j === siblings.length - 1 && yChange < 0 && Math.abs(eleY - siblingYEnd) <= fudge) {
                                        return {
                                                ele: $sibling,
                                                attachMethod: 'after'
                                            };
                                    }
                                    // Dragging up or down into beginning of list.
                                    else if (j === 0 && Math.abs(eleY - siblingY) <= fudge) {
                                        return {
                                            ele: $sibling,
                                            attachMethod: 'before'
                                        };
                                    }
                                    // Dragging down into end of list. Special handling required because
                                    // the element being dragged may be taller then the element being dragged over
                                    // (if eleY can never be >= siblingY, general case at the end does not work).
                                    else if (j === siblings.length - 1 && yChange > 0 &&
                                        Math.abs(eleYEnd - siblingYEnd) <= fudge) {
                                        return {
                                            ele: $sibling,
                                            attachMethod: 'after'
                                        };
                                    }
                                    else if (eleY >= siblingY && eleY <= siblingYEnd) {
                                        return {
                                            ele: $sibling,
                                            attachMethod: eleY - siblingY <= siblingHeight / 2 ? 'before' : 'after'
                                        };
                                    }
                                }
                            }
                        }
                    }
                }
                // Failed drag
                return {
                    ele: null,
                    attachMethod: ''
                };
            },

            // Information about the current drag.
            dragState: {},

            onDragStart: function (draggie, event, pointer) {
                var ele = $(draggie.element);
                this.dragState = {
                    // Which element will be dropped into/onto on success
                    dropDestination: null,
                    // How we attach to the destination: 'before', 'after', 'prepend'
                    attachMethod: '',
                    // If dragging to an empty section, the parent section
                    parentList: null,
                    // The y location of the last dragMove event (to determine direction).
                    lastY: 0,
                    // The direction the drag is moving in (negative means up, positive down).
                    dragDirection: 0
                };
162 163
                if (!ele.hasClass(this.collapsedClass)) {
                    ele.addClass(this.collapsedClass);
164 165 166 167
                    ele.find('.expand-collapse').first().addClass('expand').removeClass('collapse');
                    // onDragStart gets called again after the collapse, so we can't just store a variable in the dragState.
                    ele.addClass(this.expandOnDropClass);
                }
168 169 170 171

                // We should remove this class name before start dragging to
                // avoid performance issues.
                ele.removeClass('was-dragging');
172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258
            },

            onDragMove: function (draggie, event, pointer) {
                // Handle scrolling of the browser.
                var scrollAmount = 0;
                var dragBuffer = 10;
                if (window.innerHeight - dragBuffer < pointer.clientY) {
                    scrollAmount = dragBuffer;
                }
                else if (dragBuffer > pointer.clientY) {
                    scrollAmount = -(dragBuffer);
                }
                if (scrollAmount !== 0) {
                    window.scrollBy(0, scrollAmount);
                    return;
                }

                var yChange = draggie.dragPoint.y - this.dragState.lastY;
                if (yChange !== 0) {
                    this.dragState.direction = yChange;
                }
                this.dragState.lastY = draggie.dragPoint.y;

                var ele = $(draggie.element);
                var destinationInfo = this.findDestination(ele, this.dragState.direction);
                var destinationEle = destinationInfo.ele;
                this.dragState.parentList = destinationInfo.parentList;

                // Clear out the old destination
                if (this.dragState.dropDestination) {
                    this.dragState.dropDestination.removeClass(this.droppableClasses);
                }
                // Mark the new destination
                if (destinationEle && this.pointerInBounds(pointer, ele)) {
                    ele.addClass(this.validDropClass);
                    destinationEle.addClass('drop-target drop-target-' + destinationInfo.attachMethod);
                    this.dragState.attachMethod = destinationInfo.attachMethod;
                    this.dragState.dropDestination = destinationEle;
                }
                else {
                    ele.removeClass(this.validDropClass);
                    this.dragState.attachMethod = '';
                    this.dragState.dropDestination = null;
                }
            },

            onDragEnd: function (draggie, event, pointer) {
                var ele = $(draggie.element);
                var destination = this.dragState.dropDestination;

                // Clear dragging state in preparation for the next event.
                if (destination) {
                    destination.removeClass(this.droppableClasses);
                }
                ele.removeClass(this.validDropClass);

                // If the drag succeeded, rearrange the DOM and send the result.
                if (destination && this.pointerInBounds(pointer, ele)) {
                    // Make sure we don't drop into a collapsed element
                    if (this.dragState.parentList) {
                        this.expandElement(this.dragState.parentList);
                    }
                    var method = this.dragState.attachMethod;
                    destination[method](ele);
                    this.handleReorder(ele);
                }
                // If the drag failed, send it back
                else {
                    $('.was-dragging').removeClass('was-dragging');
                    ele.addClass('was-dragging');
                }

                if (ele.hasClass(this.expandOnDropClass)) {
                    this.expandElement(ele);
                    ele.removeClass(this.expandOnDropClass);
                }

                // Everything in its right place
                ele.css({
                    top: 'auto',
                    left: 'auto'
                });

                this.dragState = {};
            },

            pointerInBounds: function (pointer, ele) {
259
                return pointer.clientX >= ele.offset().left && pointer.clientX < ele.offset().left + ele.outerWidth();
260 261 262
            },

            expandElement: function (ele) {
263 264 265 266
                // Verify all children of the element are rendered.
                var ensureChildrenRendered = ele.data('ensureChildrenRendered');
                if (_.isFunction(ensureChildrenRendered)) { ensureChildrenRendered(); }
                // Update classes.
267
                ele.removeClass(this.collapsedClass);
268 269 270 271 272 273
                ele.find('.expand-collapse').first().removeClass('expand').addClass('collapse');
            },

            /*
             * Find all parent-child changes and save them.
             */
274 275 276 277 278 279
            handleReorder: function (element) {
                var parentSelector = element.data('parent-location-selector'),
                    childrenSelector = element.data('child-selector'),
                    newParentEle = element.parents(parentSelector).first(),
                    newParentLocator = newParentEle.data('locator'),
                    oldParentLocator = element.data('parent'),
280 281 282 283
                    oldParentEle, saving, refreshParent;

                refreshParent = function (element) {
                    var refresh = element.data('refresh');
284 285 286 287 288
                    // If drop was into a collapsed parent, the parent will have been
                    // expanded. Views using this class may need to track the
                    // collapse/expand state, so send it with the refresh callback.
                    var collapsed = element.hasClass(this.collapsedClass);
                    if (_.isFunction(refresh)) { refresh(collapsed); }
289 290

                };
291
                saving = new NotificationView.Mini({
Bertrand Marron committed
292
                    title: gettext('Saving')
293 294
                });
                saving.show();
295
                element.addClass('was-dropped');
296 297
                // Timeout interval has to match what is in the CSS.
                setTimeout(function () {
298
                    element.removeClass('was-dropped');
299 300 301
                }, 1000);
                this.saveItem(newParentEle, childrenSelector, function () {
                    saving.hide();
302 303

                    // Refresh new parent.
304
                    refreshParent(newParentEle);
305 306 307 308 309 310 311 312 313

                    // Refresh old parent.
                    if (newParentLocator !== oldParentLocator) {
                        oldParentEle = $(parentSelector).filter(function () {
                            return $(this).data('locator') === oldParentLocator;
                        });
                        refreshParent(oldParentEle);
                        element.data('parent', newParentLocator);
                    }
314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342
                });
            },

            /*
             * Actually save the update to the server. Takes the element
             * representing the parent item to save, a CSS selector to find
             * its children, and a success callback.
             */
            saveItem: function (ele, childrenSelector, success) {
                // Find all current child IDs.
                var children = _.map(
                    ele.find(childrenSelector),
                    function (child) {
                        return $(child).data('locator');
                    }
                );
                $.ajax({
                    url: ModuleUtils.getUpdateUrl(ele.data('locator')),
                    type: 'PUT',
                    dataType: 'json',
                    contentType: 'application/json',
                    data: JSON.stringify({
                        children: children
                    }),
                    success: success
                });
            },

            /*
343 344 345 346 347 348 349 350 351 352
             * Make DOM element with class `type` draggable using `handleClass`, able to be dropped
             * into `droppableClass`, and with parent type `parentLocationSelector`.
             * @param {DOM element, jQuery element} element
             * @param {Object} options The list of options. Possible options:
             *   `type` - class name of the element.
             *   `handleClass` - specifies on what element the drag interaction starts.
             *   `droppableClass` - specifies on what elements draggable element can be dropped.
             *   `parentLocationSelector` - class name of a parent element with data-locator.
             *   `refresh` - method that will be called after dragging to refresh
             *      views of the target and source xblocks.
353
             */
354 355 356 357 358 359 360
            makeDraggable: function (element, options) {
                var draggable;
                options = _.defaults({
                    type: null,
                    handleClass: null,
                    droppableClass: null,
                    parentLocationSelector: null,
361 362
                    refresh: null,
                    ensureChildrenRendered: null
363 364 365 366 367 368 369
                }, options);

                if ($(element).data('droppable-class') !== options.droppableClass) {
                    $(element).data({
                      'droppable-class': options.droppableClass,
                      'parent-location-selector': options.parentLocationSelector,
                      'child-selector': options.type,
370 371
                      'refresh': options.refresh,
                      'ensureChildrenRendered': options.ensureChildrenRendered
372 373 374 375 376 377 378 379 380 381
                    });

                    draggable = new Draggabilly(element, {
                        handle: options.handleClass,
                        containment: '.wrapper-dnd'
                    });
                    draggable.on('dragStart', _.bind(contentDragger.onDragStart, contentDragger));
                    draggable.on('dragMove', _.bind(contentDragger.onDragMove, contentDragger));
                    draggable.on('dragEnd', _.bind(contentDragger.onDragEnd, contentDragger));
                }
382 383 384 385 386
            }
        };

        return contentDragger;
    });