UNPKG

ng-sortable

Version:

Angular Library for Drag and Drop, supports Sortable and Draggable.

614 lines (534 loc) 24 kB
/*jshint indent: 2 */ /*global angular: false */ (function () { 'use strict'; var mainModule = angular.module('as.sortable'); /** * Controller for sortableItemHandle * * @param $scope - item handle scope. */ mainModule.controller('as.sortable.sortableItemHandleController', ['$scope', function ($scope) { this.scope = $scope; $scope.itemScope = null; $scope.type = 'handle'; }]); //Check if a node is parent to another node function isParent(possibleParent, elem) { if(!elem || elem.nodeName === 'HTML') { return false; } if(elem.parentNode === possibleParent) { return true; } return isParent(possibleParent, elem.parentNode); } /** * Directive for sortable item handle. */ mainModule.directive('asSortableItemHandle', ['sortableConfig', '$helper', '$window', '$document', '$timeout', function (sortableConfig, $helper, $window, $document, $timeout) { return { require: '^asSortableItem', scope: true, restrict: 'A', controller: 'as.sortable.sortableItemHandleController', link: function (scope, element, attrs, itemController) { var dragElement, //drag item element. placeHolder, //place holder class element. placeElement,//hidden place element. itemPosition, //drag item element position. dragItemInfo, //drag item data. containment,//the drag container. containerPositioning, // absolute or relative positioning. dragListen,// drag listen event. scrollableContainer, //the scrollable container dragStart,// drag start event. dragMove,//drag move event. dragEnd,//drag end event. dragCancel,//drag cancel event. isDraggable,//is element draggable. placeHolderIndex,//placeholder index in items elements. bindDrag,//bind drag events. unbindDrag,//unbind drag events. bindEvents,//bind the drag events. unBindEvents,//unbind the drag events. hasTouch,// has touch support. isIOS,// is iOS device. longTouchStart, // long touch start event longTouchCancel, // cancel long touch longTouchTimer, // timer promise for the long touch on iOS devices dragHandled, //drag handled. createPlaceholder,//create place holder. isPlaceHolderPresent,//is placeholder present. isDisabled = false, // drag enabled escapeListen, // escape listen event isLongTouch = false; //long touch disabled. hasTouch = 'ontouchstart' in $window; isIOS = /iPad|iPhone|iPod/.test($window.navigator.userAgent) && !$window.MSStream; if (sortableConfig.handleClass) { element.addClass(sortableConfig.handleClass); } scope.itemScope = itemController.scope; element.data('_scope', scope); // #144, work with angular debugInfoEnabled(false) scope.$watchGroup(['sortableScope.isDisabled', 'sortableScope.options.longTouch'], function (newValues) { if (isDisabled !== newValues[0]) { isDisabled = newValues[0]; if (isDisabled) { unbindDrag(); } else { bindDrag(); } } else if (isLongTouch !== newValues[1]) { isLongTouch = newValues[1]; unbindDrag(); bindDrag(); } else { bindDrag(); } }); scope.$on('$destroy', function () { angular.element($document[0].body).unbind('keydown', escapeListen); }); createPlaceholder = function (itemScope) { if (typeof scope.sortableScope.options.placeholder === 'function') { return angular.element(scope.sortableScope.options.placeholder(itemScope)); } else if (typeof scope.sortableScope.options.placeholder === 'string') { return angular.element(scope.sortableScope.options.placeholder); } else { return angular.element($document[0].createElement(itemScope.element.prop('tagName'))); } }; /** * Listens for a 10px movement before * dragStart is called to allow for * a click event on the element. * * @param event - the event object. */ dragListen = function (event) { var unbindMoveListen = function () { angular.element($document).unbind('mousemove', moveListen); angular.element($document).unbind('touchmove', moveListen); element.unbind('mouseup', unbindMoveListen); element.unbind('touchend', unbindMoveListen); element.unbind('touchcancel', unbindMoveListen); }; var startPosition; var moveListen = function (e) { e.preventDefault(); var eventObj = $helper.eventObj(e); if (!startPosition) { startPosition = { clientX: eventObj.clientX, clientY: eventObj.clientY }; } if (Math.abs(eventObj.clientX - startPosition.clientX) + Math.abs(eventObj.clientY - startPosition.clientY) > 10) { unbindMoveListen(); dragStart(event); } }; angular.element($document).bind('mousemove', moveListen); angular.element($document).bind('touchmove', moveListen); element.bind('mouseup', unbindMoveListen); element.bind('touchend', unbindMoveListen); element.bind('touchcancel', unbindMoveListen); event.stopPropagation(); }; /** * Triggered when drag event starts. * * @param event the event object. */ dragStart = function (event) { var eventObj, tagName; if (!hasTouch && (event.button === 2 || event.which === 3)) { // disable right click return; } if (hasTouch && $helper.isTouchInvalid(event)) { return; } if (dragHandled || !isDraggable(event)) { // event has already fired in other scope. return; } // Set the flag to prevent other items from inheriting the drag event dragHandled = true; event.preventDefault(); eventObj = $helper.eventObj(event); scope.sortableScope = scope.sortableScope || scope.itemScope.sortableScope; //isolate directive scope issue. scope.callbacks = scope.callbacks || scope.itemScope.callbacks; //isolate directive scope issue. if (scope.itemScope.sortableScope.options.clone || (scope.itemScope.sortableScope.options.ctrlClone && event.ctrlKey)) { // Clone option is true // or Ctrl clone option is true & the ctrl key was pressed when the user innitiated drag scope.itemScope.sortableScope.cloning = true; } else { scope.itemScope.sortableScope.cloning = false; } // (optional) Scrollable container as reference for top & left offset calculations, defaults to Document scrollableContainer = angular.element($document[0].querySelector(scope.sortableScope.options.scrollableContainer)).length > 0 ? $document[0].querySelector(scope.sortableScope.options.scrollableContainer) : $document[0].documentElement; containment = (scope.sortableScope.options.containment)? $helper.findAncestor(element, scope.sortableScope.options.containment):angular.element($document[0].body); //capture mouse move on containment. containment.css('cursor', 'move'); containment.css('cursor', '-webkit-grabbing'); containment.css('cursor', '-moz-grabbing'); containment.addClass('as-sortable-un-selectable'); // container positioning containerPositioning = scope.sortableScope.options.containerPositioning || 'absolute'; dragItemInfo = $helper.dragItem(scope); tagName = scope.itemScope.element.prop('tagName'); dragElement = angular.element($document[0].createElement(scope.sortableScope.element.prop('tagName'))) .addClass(scope.sortableScope.element.attr('class')).addClass(sortableConfig.dragClass); dragElement.css('width', $helper.width(scope.itemScope.element) + 'px'); dragElement.css('height', $helper.height(scope.itemScope.element) + 'px'); placeHolder = createPlaceholder(scope.itemScope) .addClass(sortableConfig.placeHolderClass).addClass(scope.sortableScope.options.additionalPlaceholderClass); placeHolder.css('width', $helper.width(scope.itemScope.element) + 'px'); placeHolder.css('height', $helper.height(scope.itemScope.element) + 'px'); placeElement = angular.element($document[0].createElement(tagName)); if (sortableConfig.hiddenClass) { placeElement.addClass(sortableConfig.hiddenClass); } itemPosition = $helper.positionStarted(eventObj, scope.itemScope.element, scrollableContainer); // fill the immediate vacuum. if (!scope.itemScope.sortableScope.options.clone) { scope.itemScope.element.after(placeHolder); } if (scope.itemScope.sortableScope.cloning) { // clone option is enabled or triggered, so clone the element. dragElement.append(scope.itemScope.element.clone()); } else { // add hidden placeholder element in original position. scope.itemScope.element.after(placeElement); // not cloning, so use the original element. dragElement.append(scope.itemScope.element); } containment.append(dragElement); $helper.movePosition(eventObj, dragElement, itemPosition, containment, containerPositioning, scrollableContainer); scope.sortableScope.$apply(function () { scope.callbacks.dragStart(dragItemInfo.eventArgs()); }); bindEvents(); }; /** * Allow Drag if it is a proper item-handle element. * * @param event - the event object. * @return boolean - true if element is draggable. */ isDraggable = function (event) { var elementClicked, sourceScope, isDraggable; elementClicked = angular.element(event.target); // look for the handle on the current scope or parent scopes sourceScope = fetchScope(elementClicked); isDraggable = (sourceScope && sourceScope.type === 'handle'); //If a 'no-drag' element inside item-handle if any. while (isDraggable && elementClicked[0] !== element[0]) { if ($helper.noDrag(elementClicked)) { isDraggable = false; } elementClicked = elementClicked.parent(); } return isDraggable; }; /** * Inserts the placeHolder in to the targetScope. * * @param targetElement the target element * @param targetScope the target scope */ function insertBefore(targetElement, targetScope) { // Ensure the placeholder is visible in the target (unless it's a table row) if (placeHolder.css('display') !== 'table-row') { placeHolder.css('display', 'block'); } if (!targetScope.sortableScope.options.clone) { targetElement[0].parentNode.insertBefore(placeHolder[0], targetElement[0]); dragItemInfo.moveTo(targetScope.sortableScope, targetScope.index()); } } /** * Inserts the placeHolder next to the targetScope. * * @param targetElement the target element * @param targetScope the target scope */ function insertAfter(targetElement, targetScope) { // Ensure the placeholder is visible in the target (unless it's a table row) if (placeHolder.css('display') !== 'table-row') { placeHolder.css('display', 'block'); } if (!targetScope.sortableScope.options.clone) { targetElement.after(placeHolder); dragItemInfo.moveTo(targetScope.sortableScope, targetScope.index() + 1); } } /** * Triggered when drag is moving. * * @param event - the event object. */ dragMove = function (event) { var eventObj, targetX, targetY, targetScope, targetElement; if (hasTouch && $helper.isTouchInvalid(event)) { return; } // Ignore event if not handled if (!dragHandled) { return; } if (dragElement) { event.preventDefault(); eventObj = $helper.eventObj(event); // checking if dragMove callback exists, to prevent application // rerenderings on each mouse move event if (scope.callbacks.dragMove !== angular.noop) { scope.sortableScope.$apply(function () { scope.callbacks.dragMove(itemPosition, containment, eventObj); }); } targetX = eventObj.pageX - $document[0].documentElement.scrollLeft; targetY = eventObj.pageY - ($window.pageYOffset || $document[0].documentElement.scrollTop); //IE fixes: hide show element, call element from point twice to return pick correct element. dragElement.addClass(sortableConfig.hiddenClass); targetElement = angular.element($document[0].elementFromPoint(targetX, targetY)); dragElement.removeClass(sortableConfig.hiddenClass); $helper.movePosition(eventObj, dragElement, itemPosition, containment, containerPositioning, scrollableContainer); //Set Class as dragging starts dragElement.addClass(sortableConfig.dragging); targetScope = fetchScope(targetElement); if (!targetScope || !targetScope.type) { return; } if (targetScope.type === 'handle') { targetScope = targetScope.itemScope; } if (targetScope.type !== 'item' && targetScope.type !== 'sortable') { return; } if (targetScope.type === 'item' && targetScope.accept(scope, targetScope.sortableScope, targetScope)) { // decide where to insert placeholder based on target element and current placeholder if is present targetElement = targetScope.element; // Fix #241 Drag and drop have trembling with blocks of different size var targetElementOffset = $helper.offset(targetElement, scrollableContainer); if (!dragItemInfo.canMove(itemPosition, targetElement, targetElementOffset)) { return; } var placeholderIndex = placeHolderIndex(targetScope.sortableScope.element); if (placeholderIndex < 0) { insertBefore(targetElement, targetScope); } else { if (placeholderIndex <= targetScope.index()) { insertAfter(targetElement, targetScope); } else { insertBefore(targetElement, targetScope); } } } if (targetScope.type === 'sortable') {//sortable scope. if (targetScope.accept(scope, targetScope) && !isParent(targetScope.element[0], targetElement[0])) { //moving over sortable bucket. not over item. if (!isPlaceHolderPresent(targetElement) && !targetScope.options.clone) { targetElement[0].appendChild(placeHolder[0]); dragItemInfo.moveTo(targetScope, targetScope.modelValue.length); } } } } }; /** * Fetch scope from element or parents * @param {object} element Source element * @return {object} Scope, or null if not found */ function fetchScope(element) { var scope; while (!scope && element.length) { scope = element.data('_scope'); if (!scope) { element = element.parent(); } } return scope; } /** * Get position of place holder among item elements in itemScope. * @param targetElement the target element to check with. * @returns {*} -1 if placeholder is not present, index if yes. */ placeHolderIndex = function (targetElement) { var itemElements, i; // targetElement is placeHolder itself, return index 0 if (targetElement.hasClass(sortableConfig.placeHolderClass)){ return 0; } // find index in target children itemElements = targetElement.children(); for (i = 0; i < itemElements.length; i += 1) { //TODO may not be accurate when elements contain other siblings than item elements //solve by adding 1 to model index of previous item element if (angular.element(itemElements[i]).hasClass(sortableConfig.placeHolderClass)) { return i; } } return -1; }; /** * Check there is no place holder placed by itemScope. * @param targetElement the target element to check with. * @returns {*} true if place holder present. */ isPlaceHolderPresent = function (targetElement) { return placeHolderIndex(targetElement) >= 0; }; /** * Rollback the drag data changes. */ function rollbackDragChanges() { if (!scope.itemScope.sortableScope.cloning) { placeElement.replaceWith(scope.itemScope.element); } placeHolder.remove(); dragElement.remove(); dragElement = null; dragHandled = false; containment.css('cursor', ''); containment.removeClass('as-sortable-un-selectable'); } /** * triggered while drag ends. * * @param event - the event object. */ dragEnd = function (event) { // Ignore event if not handled if (!dragHandled) { return; } event.preventDefault(); if (dragElement) { //rollback all the changes. rollbackDragChanges(); // update model data dragItemInfo.apply(); scope.sortableScope.$apply(function () { if (dragItemInfo.isSameParent()) { if (dragItemInfo.isOrderChanged()) { scope.callbacks.orderChanged(dragItemInfo.eventArgs()); } } else { scope.callbacks.itemMoved(dragItemInfo.eventArgs()); } }); scope.sortableScope.$apply(function () { scope.callbacks.dragEnd(dragItemInfo.eventArgs()); }); dragItemInfo = null; } unBindEvents(); }; /** * triggered while drag is cancelled. * * @param event - the event object. */ dragCancel = function (event) { // Ignore event if not handled if (!dragHandled) { return; } event.preventDefault(); if (dragElement) { //rollback all the changes. rollbackDragChanges(); scope.sortableScope.$apply(function () { scope.callbacks.dragCancel(dragItemInfo.eventArgs()); }); dragItemInfo = null; } unBindEvents(); }; /** * Binds the drag start events. */ bindDrag = function () { if (hasTouch) { if (isLongTouch) { if (isIOS) { element.bind('touchstart', longTouchStart); element.bind('touchend', longTouchCancel); element.bind('touchmove', longTouchCancel); } else { element.bind('contextmenu', dragListen); } } else { element.bind('touchstart', dragListen); } } element.bind('mousedown', dragListen); }; /** * Unbinds the drag start events. */ unbindDrag = function () { element.unbind('touchstart', longTouchStart); element.unbind('touchend', longTouchCancel); element.unbind('touchmove', longTouchCancel); element.unbind('contextmenu', dragListen); element.unbind('touchstart', dragListen); element.unbind('mousedown', dragListen); }; /** * starts a timer to detect long touch on iOS devices. If touch held for more than 500ms, * it would be considered as long touch. * * @param event - the event object. */ longTouchStart = function(event) { longTouchTimer = $timeout(function() { dragListen(event); }, 500); }; /** * cancel the long touch and its timer. */ longTouchCancel = function() { $timeout.cancel(longTouchTimer); }; //bind drag start events. //put in a watcher since this method is now depending on the longtouch option from sortable.sortOptions //bindDrag(); //Cancel drag on escape press. escapeListen = function (event) { if (event.keyCode === 27) { dragCancel(event); } }; angular.element($document[0].body).bind('keydown', escapeListen); /** * Binds the events based on the actions. */ bindEvents = function () { angular.element($document).bind('touchmove', dragMove); angular.element($document).bind('touchend', dragEnd); angular.element($document).bind('touchcancel', dragCancel); angular.element($document).bind('mousemove', dragMove); angular.element($document).bind('mouseup', dragEnd); }; /** * Un binds the events for drag support. */ unBindEvents = function () { angular.element($document).unbind('touchend', dragEnd); angular.element($document).unbind('touchcancel', dragCancel); angular.element($document).unbind('touchmove', dragMove); angular.element($document).unbind('mouseup', dragEnd); angular.element($document).unbind('mousemove', dragMove); }; } }; }]); }());