ng-sortable
Version:
Angular Library for Drag and Drop, supports Sortable and Draggable.
614 lines (534 loc) • 24 kB
JavaScript
/*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);
};
}
};
}]);
}());