angular-sortable-view 是一款很好用的angularJs可拖拽列表排序插件,使用也非常简单,其内部封装了几个指令,直接注入模块,调用指令即可实现功能。
使用说明:
sv-root
根据名字也能大概猜出功能,主要是定义了拖拽、排序等方法,有点像工具类函数,必须添加在要拖拽的父类上,否则拖动无法实现排序。
sv-part
定义要排序的数据结构和实现元素排序,内容设置为循环的数组即可。
sv-handle
拖动事件句柄。
sv-element
拖动元素,设置在 sv-root
内的那个元素,那个元素就可拖动。
调用实例:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>title</title>
<script src="./js/libs/angular.min.js"></script>
<script src="./js/libs/angular-sortable-view.js"></script>
</head>
<style>
ul{
width: 40%;
margin: 100px auto 0;
}
li {
display: flex;
border: 1px solid #bfbfbf;
border-radius: 5px;
height: 40px;
line-height: 40px;
margin-bottom: 20px;
}
li>span{
flex: 1 1 33.33%;
text-align: center;
}
</style>
<body ng-app="myApp" ng-controller="myController">
<ul sv-root sv-part="feature">
<li sv-handle ng-repeat="item in feature track by $index" sv-element>
<span ng-bind="item.name"></span>
<span ng-bind="item.age"></span>
<span ng-bind="item.weight"></span>
</li>
</ul>
<ul>
<li ng-repeat="item in feature track by $index">
<span ng-bind="item.name"></span>
<span ng-bind="item.age"></span>
<span ng-bind="item.weight"></span>
</li>
</ul>
</body>
<script>
angular.module("myApp",['angular-sortable-view']).controller("myController",["$scope", function ($scope) {
$scope.feature = [
{name: "张三", age: 23, weight: "50KG"},
{name: "李四", age: 36, weight: "65KG"},
{name: "赵武", age: 8, weight: "33KG"},
{name: "孙柳", age: 44, weight: "77KG"}
]
}])
</script>
</html>
angular-sortable-view.js
(function(window, angular){
'use strict';
/* jshint eqnull:true */
/* jshint -W041 */
/* jshint -W030 */
var module = angular.module('angular-sortable-view', []);
module.directive('svRoot', [function(){
function shouldBeAfter(elem, pointer, isGrid){
return isGrid ? elem.x - pointer.x < 0 : elem.y - pointer.y < 0;
}
function getSortableElements(key){
return ROOTS_MAP[key];
}
function removeSortableElements(key){
delete ROOTS_MAP[key];
}
var sortingInProgress;
var ROOTS_MAP = Object.create(null);
// window.ROOTS_MAP = ROOTS_MAP; // for debug purposes
return {
restrict: 'A',
controller: ['$scope', '$attrs', '$interpolate', '$parse', function($scope, $attrs, $interpolate, $parse){
var mapKey = $interpolate($attrs.svRoot)($scope) || $scope.$id;
if(!ROOTS_MAP[mapKey]) ROOTS_MAP[mapKey] = [];
var that = this;
var candidates; // set of possible destinations
var $placeholder;// placeholder element
var options; // sortable options
var $helper; // helper element - the one thats being dragged around with the mouse pointer
var $original; // original element
var $target; // last best candidate
var isGrid = false;
var onSort = $parse($attrs.svOnSort);
// ----- hack due to https://github.com/angular/angular.js/issues/8044
$attrs.svOnStart = $attrs.$$element[0].attributes['sv-on-start'];
$attrs.svOnStart = $attrs.svOnStart && $attrs.svOnStart.value;
$attrs.svOnStop = $attrs.$$element[0].attributes['sv-on-stop'];
$attrs.svOnStop = $attrs.svOnStop && $attrs.svOnStop.value;
// -------------------------------------------------------------------
var onStart = $parse($attrs.svOnStart);
var onStop = $parse($attrs.svOnStop);
this.sortingInProgress = function(){
return sortingInProgress;
};
if($attrs.svGrid){ // sv-grid determined explicite
isGrid = $attrs.svGrid === "true" ? true : $attrs.svGrid === "false" ? false : null;
if(isGrid === null)
throw 'Invalid value of sv-grid attribute';
}
else{
// check if at least one of the lists have a grid like layout
$scope.$watchCollection(function(){
return getSortableElements(mapKey);
}, function(collection){
isGrid = false;
var array = collection.filter(function(item){
return !item.container;
}).map(function(item){
return {
part: item.getPart().id,
y: item.element[0].getBoundingClientRect().top
};
});
var dict = Object.create(null);
array.forEach(function(item){
if(dict[item.part])
dict[item.part].push(item.y);
else
dict[item.part] = [item.y];
});
Object.keys(dict).forEach(function(key){
dict[key].sort();
dict[key].forEach(function(item, index){
if(index < dict[key].length - 1){
if(item > 0 && item === dict[key][index + 1]){
isGrid = true;
}
}
});
});
});
}
this.$moveUpdate = function(opts, mouse, svElement, svOriginal, svPlaceholder, originatingPart, originatingIndex){
var svRect = svElement[0].getBoundingClientRect();
if(opts.tolerance === 'element')
mouse = {
x: ~~(svRect.left + svRect.width/2),
y: ~~(svRect.top + svRect.height/2)
};
sortingInProgress = true;
candidates = [];
if(!$placeholder){
if(svPlaceholder){ // custom placeholder
$placeholder = svPlaceholder.clone();
$placeholder.removeClass('ng-hide');
}
else{ // default placeholder
$placeholder = svOriginal.clone();
$placeholder.addClass('sv-visibility-hidden');
$placeholder.addClass('sv-placeholder');
$placeholder.css({
'height': svRect.height + 'px',
'width': svRect.width + 'px'
});
}
svOriginal.after($placeholder);
svOriginal.addClass('ng-hide');
// cache options, helper and original element reference
$original = svOriginal;
options = opts;
$helper = svElement;
onStart($scope, {
$helper: {element: $helper},
$part: originatingPart.model(originatingPart.scope),
$index: originatingIndex,
$item: originatingPart.model(originatingPart.scope)[originatingIndex]
});
$scope.$root && $scope.$root.$$phase || $scope.$apply();
}
// ----- move the element
$helper[0].reposition({
x: mouse.x + document.body.scrollLeft - mouse.offset.x*svRect.width,
y: mouse.y + document.body.scrollTop - mouse.offset.y*svRect.height
});
// ----- manage candidates
getSortableElements(mapKey).forEach(function(se, index){
if(opts.containment != null){
// TODO: optimize this since it could be calculated only once when the moving begins
if(
!elementMatchesSelector(se.element, opts.containment) &&
!elementMatchesSelector(se.element, opts.containment + ' *')
) return; // element is not within allowed containment
}
var rect = se.element[0].getBoundingClientRect();
var center = {
x: ~~(rect.left + rect.width/2),
y: ~~(rect.top + rect.height/2)
};
if(!se.container && // not the container element
(se.element[0].scrollHeight || se.element[0].scrollWidth)){ // element is visible
candidates.push({
element: se.element,
q: (center.x - mouse.x)*(center.x - mouse.x) + (center.y - mouse.y)*(center.y - mouse.y),
view: se.getPart(),
targetIndex: se.getIndex(),
after: shouldBeAfter(center, mouse, isGrid)
});
}
if(se.container && !se.element[0].querySelector('[sv-element]:not(.sv-placeholder):not(.sv-source)')){ // empty container
candidates.push({
element: se.element,
q: (center.x - mouse.x)*(center.x - mouse.x) + (center.y - mouse.y)*(center.y - mouse.y),
view: se.getPart(),
targetIndex: 0,
container: true
});
}
});
var pRect = $placeholder[0].getBoundingClientRect();
var pCenter = {
x: ~~(pRect.left + pRect.width/2),
y: ~~(pRect.top + pRect.height/2)
};
candidates.push({
q: (pCenter.x - mouse.x)*(pCenter.x - mouse.x) + (pCenter.y - mouse.y)*(pCenter.y - mouse.y),
element: $placeholder,
placeholder: true
});
candidates.sort(function(a, b){
return a.q - b.q;
});
candidates.forEach(function(cand, index){
if(index === 0 && !cand.placeholder && !cand.container){
$target = cand;
cand.element.addClass('sv-candidate');
if(cand.after)
cand.element.after($placeholder);
else
insertElementBefore(cand.element, $placeholder);
}
else if(index === 0 && cand.container){
$target = cand;
cand.element.append($placeholder);
}
else
cand.element.removeClass('sv-candidate');
});
};
this.$drop = function(originatingPart, index, options){
if(!$placeholder) return;
if(options.revert){
var placeholderRect = $placeholder[0].getBoundingClientRect();
var helperRect = $helper[0].getBoundingClientRect();
var distance = Math.sqrt(
Math.pow(helperRect.top - placeholderRect.top, 2) +
Math.pow(helperRect.left - placeholderRect.left, 2)
);
var duration = +options.revert*distance/200; // constant speed: duration depends on distance
duration = Math.min(duration, +options.revert); // however it's not longer that options.revert
['-webkit-', '-moz-', '-ms-', '-o-', ''].forEach(function(prefix){
if(typeof $helper[0].style[prefix + 'transition'] !== "undefined")
$helper[0].style[prefix + 'transition'] = 'all ' + duration + 'ms ease';
});
setTimeout(afterRevert, duration);
$helper.css({
'top': placeholderRect.top + document.body.scrollTop + 'px',
'left': placeholderRect.left + document.body.scrollLeft + 'px'
});
}
else
afterRevert();
function afterRevert(){
sortingInProgress = false;
$placeholder.remove();
$helper.remove();
$original.removeClass('ng-hide');
candidates = void 0;
$placeholder = void 0;
options = void 0;
$helper = void 0;
$original = void 0;
// sv-on-stop callback
onStop($scope, {
$part: originatingPart.model(originatingPart.scope),
$index: index,
$item: originatingPart.model(originatingPart.scope)[index]
});
if($target){
$target.element.removeClass('sv-candidate');
var spliced = originatingPart.model(originatingPart.scope).splice(index, 1);
var targetIndex = $target.targetIndex;
if($target.view === originatingPart && $target.targetIndex > index)
targetIndex--;
if($target.after)
targetIndex++;
$target.view.model($target.view.scope).splice(targetIndex, 0, spliced[0]);
// sv-on-sort callback
if($target.view !== originatingPart || index !== targetIndex)
onSort($scope, {
$partTo: $target.view.model($target.view.scope),
$partFrom: originatingPart.model(originatingPart.scope),
$item: spliced[0],
$indexTo: targetIndex,
$indexFrom: index
});
}
$target = void 0;
$scope.$root && $scope.$root.$$phase || $scope.$apply();
}
};
this.addToSortableElements = function(se){
getSortableElements(mapKey).push(se);
};
this.removeFromSortableElements = function(se){
var elems = getSortableElements(mapKey);
var index = elems.indexOf(se);
if(index > -1){
elems.splice(index, 1);
if(elems.length === 0)
removeSortableElements(mapKey);
}
};
}]
};
}]);
module.directive('svPart', ['$parse', function($parse){
return {
restrict: 'A',
require: '^svRoot',
controller: ['$scope', function($scope){
$scope.$ctrl = this;
this.getPart = function(){
return $scope.part;
};
this.$drop = function(index, options){
$scope.$sortableRoot.$drop($scope.part, index, options);
};
}],
scope: true,
link: function($scope, $element, $attrs, $sortable){
if(!$attrs.svPart) throw new Error('no model provided');
var model = $parse($attrs.svPart);
if(!model.assign) throw new Error('model not assignable');
$scope.part = {
id: $scope.$id,
element: $element,
model: model,
scope: $scope
};
$scope.$sortableRoot = $sortable;
var sortablePart = {
element: $element,
getPart: $scope.$ctrl.getPart,
container: true
};
$sortable.addToSortableElements(sortablePart);
$scope.$on('$destroy', function(){
$sortable.removeFromSortableElements(sortablePart);
});
}
};
}]);
module.directive('svElement', ['$parse', function($parse){
return {
restrict: 'A',
require: ['^svPart', '^svRoot'],
controller: ['$scope', function($scope){
$scope.$ctrl = this;
}],
link: function($scope, $element, $attrs, $controllers){
var sortableElement = {
element: $element,
getPart: $controllers[0].getPart,
getIndex: function(){
return $scope.$index;
}
};
$controllers[1].addToSortableElements(sortableElement);
$scope.$on('$destroy', function(){
$controllers[1].removeFromSortableElements(sortableElement);
});
var handle = $element;
handle.on('mousedown touchstart', onMousedown);
$scope.$watch('$ctrl.handle', function(customHandle){
if(customHandle){
handle.off('mousedown touchstart', onMousedown);
handle = customHandle;
handle.on('mousedown touchstart', onMousedown);
}
});
var helper;
$scope.$watch('$ctrl.helper', function(customHelper){
if(customHelper){
helper = customHelper;
}
});
var placeholder;
$scope.$watch('$ctrl.placeholder', function(customPlaceholder){
if(customPlaceholder){
placeholder = customPlaceholder;
}
});
var body = angular.element(document.body);
var html = angular.element(document.documentElement);
var moveExecuted;
function onMousedown(e){
touchFix(e);
if($controllers[1].sortingInProgress()) return;
if(e.button != 0 && e.type === 'mousedown') return;
moveExecuted = false;
var opts = $parse($attrs.svElement)($scope);
opts = angular.extend({}, {
tolerance: 'pointer',
revert: 200,
containment: 'html'
}, opts);
if(opts.containment){
var containmentRect = closestElement.call($element, opts.containment)[0].getBoundingClientRect();
}
var target = $element;
var clientRect = $element[0].getBoundingClientRect();
var clone;
if(!helper) helper = $controllers[0].helper;
if(!placeholder) placeholder = $controllers[0].placeholder;
if(helper){
clone = helper.clone();
clone.removeClass('ng-hide');
clone.css({
'left': clientRect.left + document.body.scrollLeft + 'px',
'top': clientRect.top + document.body.scrollTop + 'px'
});
target.addClass('sv-visibility-hidden');
}
else{
clone = target.clone();
clone.addClass('sv-helper').css({
'left': clientRect.left + document.body.scrollLeft + 'px',
'top': clientRect.top + document.body.scrollTop + 'px',
'width': clientRect.width + 'px'
});
}
clone[0].reposition = function(coords){
var targetLeft = coords.x;
var targetTop = coords.y;
var helperRect = clone[0].getBoundingClientRect();
var body = document.body;
if(containmentRect){
if(targetTop < containmentRect.top + body.scrollTop) // top boundary
targetTop = containmentRect.top + body.scrollTop;
if(targetTop + helperRect.height > containmentRect.top + body.scrollTop + containmentRect.height) // bottom boundary
targetTop = containmentRect.top + body.scrollTop + containmentRect.height - helperRect.height;
if(targetLeft < containmentRect.left + body.scrollLeft) // left boundary
targetLeft = containmentRect.left + body.scrollLeft;
if(targetLeft + helperRect.width > containmentRect.left + body.scrollLeft + containmentRect.width) // right boundary
targetLeft = containmentRect.left + body.scrollLeft + containmentRect.width - helperRect.width;
}
this.style.left = targetLeft - body.scrollLeft + 'px';
this.style.top = targetTop - body.scrollTop + 'px';
};
var pointerOffset = {
x: (e.clientX - clientRect.left)/clientRect.width,
y: (e.clientY - clientRect.top)/clientRect.height
};
html.addClass('sv-sorting-in-progress');
html.on('mousemove touchmove', onMousemove).on('mouseup touchend touchcancel', function mouseup(e){
html.off('mousemove touchmove', onMousemove);
html.off('mouseup touchend', mouseup);
html.removeClass('sv-sorting-in-progress');
if(moveExecuted)
$controllers[0].$drop($scope.$index, opts);
else
$element.removeClass('sv-visibility-hidden');
});
// onMousemove(e);
function onMousemove(e){
touchFix(e);
if(!moveExecuted){
$element.parent().prepend(clone);
moveExecuted = true;
}
$controllers[1].$moveUpdate(opts, {
x: e.clientX,
y: e.clientY,
offset: pointerOffset
}, clone, $element, placeholder, $controllers[0].getPart(), $scope.$index);
}
}
}
};
}]);
module.directive('svHandle', function(){
return {
require: '?^svElement',
link: function($scope, $element, $attrs, $ctrl){
if($ctrl)
$ctrl.handle = $element.add($ctrl.handle); // support multiple handles
}
};
});
module.directive('svHelper', function(){
return {
require: ['?^svPart', '?^svElement'],
link: function($scope, $element, $attrs, $ctrl){
$element.addClass('sv-helper').addClass('ng-hide');
if($ctrl[1])
$ctrl[1].helper = $element;
else if($ctrl[0])
$ctrl[0].helper = $element;
}
};
});
module.directive('svPlaceholder', function(){
return {
require: ['?^svPart', '?^svElement'],
link: function($scope, $element, $attrs, $ctrl){
$element.addClass('sv-placeholder').addClass('ng-hide');
if($ctrl[1])
$ctrl[1].placeholder = $element;
else if($ctrl[0])
$ctrl[0].placeholder = $element;
}
};
});
angular.element(document.head).append([
'<style>' +
'.sv-helper{' +
'position: fixed !important;' +
'z-index: 99999;' +
'margin: 0 !important;' +
'}' +
'.sv-candidate{' +
'}' +
'.sv-placeholder{' +
// 'opacity: 0;' +
'}' +
'.sv-sorting-in-progress{' +
'-webkit-user-select: none;' +
'-moz-user-select: none;' +
'-ms-user-select: none;' +
'user-select: none;' +
'}' +
'.sv-visibility-hidden{' +
'visibility: hidden !important;' +
'opacity: 0 !important;' +
'}' +
'</style>'
].join(''));
function touchFix(e){
if(!('clientX' in e) && !('clientY' in e)) {
var touches = e.touches || e.originalEvent.touches;
if(touches && touches.length) {
e.clientX = touches[0].clientX;
e.clientY = touches[0].clientY;
}
e.preventDefault();
}
}
function getPreviousSibling(element){
element = element[0];
if(element.previousElementSibling)
return angular.element(element.previousElementSibling);
else{
var sib = element.previousSibling;
while(sib != null && sib.nodeType != 1)
sib = sib.previousSibling;
return angular.element(sib);
}
}
function insertElementBefore(element, newElement){
var prevSibl = getPreviousSibling(element);
if(prevSibl.length > 0){
prevSibl.after(newElement);
}
else{
element.parent().prepend(newElement);
}
}
var dde = document.documentElement,
matchingFunction = dde.matches ? 'matches' :
dde.matchesSelector ? 'matchesSelector' :
dde.webkitMatches ? 'webkitMatches' :
dde.webkitMatchesSelector ? 'webkitMatchesSelector' :
dde.msMatches ? 'msMatches' :
dde.msMatchesSelector ? 'msMatchesSelector' :
dde.mozMatches ? 'mozMatches' :
dde.mozMatchesSelector ? 'mozMatchesSelector' : null;
if(matchingFunction == null)
throw 'This browser doesn\'t support the HTMLElement.matches method';
function elementMatchesSelector(element, selector){
if(element instanceof angular.element) element = element[0];
if(matchingFunction !== null)
return element[matchingFunction](selector);
}
var closestElement = angular.element.prototype.closest || function (selector){
var el = this[0].parentNode;
while(el !== document.documentElement && !el[matchingFunction](selector))
el = el.parentNode;
if(el[matchingFunction](selector))
return angular.element(el);
else
return angular.element();
};
/*
Simple implementation of jQuery's .add method
*/
if(typeof angular.element.prototype.add !== 'function'){
angular.element.prototype.add = function(elem){
var i, res = angular.element();
elem = angular.element(elem);
for(i=0;i<this.length;i++){
res.push(this[i]);
}
for(i=0;i<elem.length;i++){
res.push(elem[i]);
}
return res;
};
}
})(window, window.angular);