<!DOCTYPE html>
<html>
<head>
<script data-require="jquery@*" data-semver="2.0.3" src="http://code.jquery.com/jquery-2.0.3.min.js"></script>
<link data-require="jqueryui@*" data-semver="1.10.0" rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/jqueryui/1.10.0/css/smoothness/jquery-ui-1.10.0.custom.min.css" />
<script data-require="jqueryui@*" data-semver="1.10.0" src="//cdnjs.cloudflare.com/ajax/libs/jqueryui/1.10.0/jquery-ui.js"></script>
<script data-require="knockout@*" data-semver="3.0.0" src="//cdnjs.cloudflare.com/ajax/libs/knockout/3.0.0/knockout-min.js"></script>
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/font-awesome/4.1.0/css/font-awesome.min.css" />
<script src="knockout-sortable.js"></script>
</head>
<body>
<h2>Multi-select sortable with Knockout.js</h2>
<p><code>Click</code> to select individual items</p>
<p><code>Ctrl + Click</code> to select multiple items</p>
<p><code>Shift + Click</code> to select multiple items</p>
<div class="list-manager">
<div class="loading-overlay" data-bind="visible: actingOnThings">
<span class="fa fa-spin fa-spinner"></span> Doing a thing...
</div>
<div class="list">
<h2>Trash, And Similar Things</h2>
<ul class="list list-container" id="userContainer_1" data-bind="sortable: {
data: userList1,
beforeMove: beforeMove,
afterMove: afterMove,
options: multiSortableOptions,
connectToClass: 'list-container'
}">
<li class="item" data-bind="attr: { id: 'sp_'+Id }, text: Name, click: $root.selectProcedure.bind($data, $parent.userList1()), css: { selected: Selected }"></li>
</ul>
<button class="selectAll" data-bind="click: selectAllItemsIn.bind(this, userList1)">Select All</button>
<button class"moveSelected" data-bind="click: moveAllFunction(userList1, userList2)">Move Selected</button>
</div>
<div class="list">
<h2>Actual Things, Like For Real</h2>
<ul class="list list-container" id="userContainer_2" data-bind="sortable: {
data: userList2,
beforeMove: beforeMove,
afterMove: afterMove,
options: multiSortableOptions,
connectToClass: 'list-container'
}">
<li class="item" data-bind="attr: { id: 'sp_'+Id }, text: Name, click: $root.selectProcedure.bind($data, $parent.userList2()), css: { selected: Selected }"></li>
</ul>
<button class"moveSelected" data-bind="click: moveAllFunction(userList2, userList1)">Move Selected</button>
<button class="selectAll" data-bind="click: selectAllItemsIn.bind(this, userList2)">Select All</button>
</div>
</div>
<script src="script.js"></script>
</body>
</html>
var ViewModel = function() {
var self = this;
self.userList1 = ko.observableArray([]);
self.userList2 = ko.observableArray([]);
self.actingOnThings = ko.observable(false);
self.canSort = ko.observable(true);
for (var i = 0; i < 5000; i++) {
self.userList2.push({
Name: 'SP' + i,
Id: i,
Selected: ko.observable(false)
})
}
self.multiSortableOptions = {
revert: 100,
tolerance: "pointer",
distance: 15,
stop: function() {
if(self.$selected) {
self.$selected.fadeIn(100);
}
},
helper: function(event, $item) {
// probably a better way to pass these around than in id attributes, but it works
var dbId = $item.parent().attr('id').split('_')[1],
itemId = $item.attr('id').split('_')[1],
db = myViewModel['userList' + dbId];
// If you grab an unhighlighted item to drag, then deselect (unhighlight) everything else
if (!$item.hasClass('selected')) {
ko.utils.arrayForEach(db(), function(item) {
//needs to be like this for string coercion
item.Selected(item.Id == itemId);
});
}
// Create a helper object with all currently selected items
var $selected = $item.parent().find('.selected');
var $helper;
if ($selected.size() > 1) {
$helper = $('<li class="item selected">You have ' + $selected.size() + ' items selected.</li>');
$selected.fadeOut(100);
$selected.removeClass('selected');
} else {
$helper = $selected;
}
self.$selected = $selected;
return $helper;
}
};
var moveTheseTo = function(items, from, to, atPosition) {
self.actingOnThings(true);
var copyFunction = function() {
var newArgs = [atPosition, 0].concat(items);
ko.utils.arrayForEach(to(), function(item) {
item.Selected(false);
});
ko.utils.arrayForEach(items, function(item) {
from.remove(item);
});
to.splice.apply(to, newArgs);
self.actingOnThings(false);
}
if(items.length > 300) {
setTimeout(copyFunction, 100);
} else {
copyFunction();
}
};
self.selectAllItemsIn = function(list) {
ko.utils.arrayForEach(list(), function(item) {
item.Selected(true);
});
};
self.moveAllFunction = function(from, to) {
return function() {
var items = ko.utils.arrayFilter(from(), function(item) {
return item.Selected();
});
moveTheseTo(items, from, to, to().length);
};
};
self.beforeMove = function(args, event, ui) {
if(
args.sourceParent === args.targetParent
&& args.targetPosition === args.sourcePosition
) {
self.$selected.fadeIn(100);
return;
}
event.cancelDrop = true;
};
self.afterMove = function(args, event, ui) {
var items = ko.utils.arrayFilter(args.sourceParent(), function(item) {
return item.Selected();
});
moveTheseTo(items, args.sourceParent, args.targetParent, args.targetIndex);
args.item.Selected(true);
};
self.selectProcedure = function(array, $data, event) {
if (!event.ctrlKey && !event.metaKey && !event.shiftKey) {
$data.Selected(true);
ko.utils.arrayForEach(array, function(item) {
if (item !== $data) {
item.Selected(false);
}
});
} else if (event.shiftKey && !event.ctrlKey && self._lastSelectedIndex > -1) {
var myIndex = array.indexOf($data);
if (myIndex > self._lastSelectedIndex) {
for (var i = self._lastSelectedIndex; i <= myIndex; i++) {
array[i].Selected(true);
}
} else if (myIndex < self._lastSelectedIndex) {
for (var i = myIndex; i <= self._lastSelectedIndex; i++) {
array[i].Selected(true);
}
}
} else if (event.ctrlKey && !event.shiftKey) {
$data.Selected(!$data.Selected());
}
self._lastSelectedIndex = array.indexOf($data);
};
};
myViewModel = new ViewModel();
ko.applyBindings(myViewModel);
.item {
cursor: pointer;
padding-left: 10px;
}
.item.selected {
background-color:#1e91fc;
color: #fff;
cursor: move;
}
div.list {
display: inline-block;
vertical-align: top;
width: 300px;
max-width: 350px;
}
div.list ul.list {
max-height: 400px;
height: 400px;
overflow-y: scroll;
}
.list {
padding: 0;
background-color: #fafafa;
border: 1px solid #CCC;
list-style-type: none;
margin: 0;
}
.list-manager {
position: relative;
display: table-cell;
}
.loading-overlay {
position: absolute;
line-height: 400px;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color:#fafafa;
filter:alpha(opacity=90);
opacity:0.9;
-moz-opacity:0.9;
z-index:100;
text-align:center;
vertical-align:middle;
}
;(function(factory) {
if (typeof define === "function" && define.amd) {
// AMD anonymous module
define(["knockout", "jquery", "jquery.ui.sortable"], factory);
} else {
// No module loader (plain <script> tag) - put directly in global namespace
factory(window.ko, jQuery);
}
})(function(ko, $) {
var ITEMKEY = "ko_sortItem",
INDEXKEY = "ko_sourceIndex",
LISTKEY = "ko_sortList",
PARENTKEY = "ko_parentList",
DRAGKEY = "ko_dragItem",
unwrap = ko.utils.unwrapObservable,
dataGet = ko.utils.domData.get,
dataSet = ko.utils.domData.set;
//internal afterRender that adds meta-data to children
var addMetaDataAfterRender = function(elements, data) {
ko.utils.arrayForEach(elements, function(element) {
if (element.nodeType === 1) {
dataSet(element, ITEMKEY, data);
dataSet(element, PARENTKEY, dataGet(element.parentNode, LISTKEY));
}
});
};
//prepare the proper options for the template binding
var prepareTemplateOptions = function(valueAccessor, dataName) {
var result = {},
options = unwrap(valueAccessor()) || {},
actualAfterRender;
//build our options to pass to the template engine
if (options.data) {
result[dataName] = options.data;
result.name = options.template;
} else {
result[dataName] = valueAccessor();
}
ko.utils.arrayForEach(["afterAdd", "afterRender", "as", "beforeRemove", "includeDestroyed", "templateEngine", "templateOptions"], function (option) {
result[option] = options[option] || ko.bindingHandlers.sortable[option];
});
//use an afterRender function to add meta-data
if (dataName === "foreach") {
if (result.afterRender) {
//wrap the existing function, if it was passed
actualAfterRender = result.afterRender;
result.afterRender = function(element, data) {
addMetaDataAfterRender.call(data, element, data);
actualAfterRender.call(data, element, data);
};
} else {
result.afterRender = addMetaDataAfterRender;
}
}
//return options to pass to the template binding
return result;
};
var updateIndexFromDestroyedItems = function(index, items) {
var unwrapped = unwrap(items);
if (unwrapped) {
for (var i = 0; i < index; i++) {
//add one for every destroyed item we find before the targetIndex in the target array
if (unwrapped[i] && unwrap(unwrapped[i]._destroy)) {
index++;
}
}
}
return index;
};
//connect items with observableArrays
ko.bindingHandlers.sortable = {
init: function(element, valueAccessor, allBindingsAccessor, data, context) {
var $element = $(element),
value = unwrap(valueAccessor()) || {},
templateOptions = prepareTemplateOptions(valueAccessor, "foreach"),
sortable = {},
startActual, updateActual;
//remove leading/trailing non-elements from anonymous templates
$element.contents().each(function() {
if (this && this.nodeType !== 1) {
element.removeChild(this);
}
});
//build a new object that has the global options with overrides from the binding
$.extend(true, sortable, ko.bindingHandlers.sortable);
if (value.options && sortable.options) {
ko.utils.extend(sortable.options, value.options);
delete value.options;
}
ko.utils.extend(sortable, value);
//if allowDrop is an observable or a function, then execute it in a computed observable
if (sortable.connectClass && (ko.isObservable(sortable.allowDrop) || typeof sortable.allowDrop == "function")) {
ko.computed({
read: function() {
var value = unwrap(sortable.allowDrop),
shouldAdd = typeof value == "function" ? value.call(this, templateOptions.foreach) : value;
ko.utils.toggleDomNodeCssClass(element, sortable.connectClass, shouldAdd);
},
disposeWhenNodeIsRemoved: element
}, this);
} else {
ko.utils.toggleDomNodeCssClass(element, sortable.connectClass, sortable.allowDrop);
}
//wrap the template binding
ko.bindingHandlers.template.init(element, function() { return templateOptions; }, allBindingsAccessor, data, context);
//keep a reference to start/update functions that might have been passed in
startActual = sortable.options.start;
updateActual = sortable.options.update;
//initialize sortable binding after template binding has rendered in update function
var createTimeout = setTimeout(function() {
var dragItem;
$element.sortable(ko.utils.extend(sortable.options, {
start: function(event, ui) {
//track original index
var el = ui.item[0];
dataSet(el, INDEXKEY, ko.utils.arrayIndexOf(ui.item.parent().children(), el));
//make sure that fields have a chance to update model
ui.item.find("input:focus").change();
if (startActual) {
startActual.apply(this, arguments);
}
},
receive: function(event, ui) {
dragItem = dataGet(ui.item[0], DRAGKEY);
if (dragItem) {
//copy the model item, if a clone option is provided
if (dragItem.clone) {
dragItem = dragItem.clone();
}
//configure a handler to potentially manipulate item before drop
if (sortable.dragged) {
dragItem = sortable.dragged.call(this, dragItem, event, ui) || dragItem;
}
}
},
update: function(event, ui) {
var sourceParent, targetParent, sourceIndex, targetIndex, arg,
el = ui.item[0],
parentEl = ui.item.parent()[0],
item = dataGet(el, ITEMKEY) || dragItem;
dragItem = null;
//make sure that moves only run once, as update fires on multiple containers
if (item && (this === parentEl || $.contains(this, parentEl))) {
//identify parents
sourceParent = dataGet(el, PARENTKEY);
sourceIndex = dataGet(el, INDEXKEY);
targetParent = dataGet(el.parentNode, LISTKEY);
targetIndex = ko.utils.arrayIndexOf(ui.item.parent().children(), el);
//take destroyed items into consideration
if (!templateOptions.includeDestroyed) {
sourceIndex = updateIndexFromDestroyedItems(sourceIndex, sourceParent);
targetIndex = updateIndexFromDestroyedItems(targetIndex, targetParent);
}
if (sortable.beforeMove || sortable.afterMove) {
arg = {
item: item,
sourceParent: sourceParent,
sourceParentNode: sourceParent && ui.sender || el.parentNode,
sourceIndex: sourceIndex,
targetParent: targetParent,
targetIndex: targetIndex,
cancelDrop: false
};
}
if (sortable.beforeMove) {
sortable.beforeMove.call(this, arg, event, ui);
if (arg.cancelDrop) {
//call cancel on the correct list
if (arg.sourceParent) {
$(arg.sourceParent === arg.targetParent ? this : ui.sender).sortable('cancel');
}
//for a draggable item just remove the element
else {
$(el).remove();
}
return;
}
}
if (targetIndex >= 0) {
if (sourceParent) {
sourceParent.splice(sourceIndex, 1);
//if using deferred updates plugin, force updates
if (ko.processAllDeferredBindingUpdates) {
ko.processAllDeferredBindingUpdates();
}
}
targetParent.splice(targetIndex, 0, item);
}
//rendering is handled by manipulating the observableArray; ignore dropped element
dataSet(el, ITEMKEY, null);
ui.item.remove();
//if using deferred updates plugin, force updates
if (ko.processAllDeferredBindingUpdates) {
ko.processAllDeferredBindingUpdates();
}
//allow binding to accept a function to execute after moving the item
if (sortable.afterMove) {
sortable.afterMove.call(this, arg, event, ui);
}
}
if (updateActual) {
updateActual.apply(this, arguments);
}
},
connectWith: sortable.connectClass ? "." + sortable.connectClass : false
}));
//handle enabling/disabling sorting
if (sortable.isEnabled !== undefined) {
ko.computed({
read: function() {
$element.sortable(unwrap(sortable.isEnabled) ? "enable" : "disable");
},
disposeWhenNodeIsRemoved: element
});
}
}, 0);
//handle disposal
ko.utils.domNodeDisposal.addDisposeCallback(element, function() {
//only call destroy if sortable has been created
if ($element.data("ui-sortable") || $element.data("sortable")) {
$element.sortable("destroy");
}
//do not create the sortable if the element has been removed from DOM
clearTimeout(createTimeout);
});
return { 'controlsDescendantBindings': true };
},
update: function(element, valueAccessor, allBindingsAccessor, data, context) {
var templateOptions = prepareTemplateOptions(valueAccessor, "foreach");
//attach meta-data
dataSet(element, LISTKEY, templateOptions.foreach);
//call template binding's update with correct options
ko.bindingHandlers.template.update(element, function() { return templateOptions; }, allBindingsAccessor, data, context);
},
connectClass: 'ko_container',
allowDrop: true,
afterMove: null,
beforeMove: null,
options: {}
};
//create a draggable that is appropriate for dropping into a sortable
ko.bindingHandlers.draggable = {
init: function(element, valueAccessor, allBindingsAccessor, data, context) {
var value = unwrap(valueAccessor()) || {},
options = value.options || {},
draggableOptions = ko.utils.extend({}, ko.bindingHandlers.draggable.options),
templateOptions = prepareTemplateOptions(valueAccessor, "data"),
connectClass = value.connectClass || ko.bindingHandlers.draggable.connectClass,
isEnabled = value.isEnabled !== undefined ? value.isEnabled : ko.bindingHandlers.draggable.isEnabled;
value = value.data || value;
//set meta-data
dataSet(element, DRAGKEY, value);
//override global options with override options passed in
ko.utils.extend(draggableOptions, options);
//setup connection to a sortable
draggableOptions.connectToSortable = connectClass ? "." + connectClass : false;
//initialize draggable
$(element).draggable(draggableOptions);
//handle enabling/disabling sorting
if (isEnabled !== undefined) {
ko.computed({
read: function() {
$(element).draggable(unwrap(isEnabled) ? "enable" : "disable");
},
disposeWhenNodeIsRemoved: element
});
}
return ko.bindingHandlers.template.init(element, function() { return templateOptions; }, allBindingsAccessor, data, context);
},
update: function(element, valueAccessor, allBindingsAccessor, data, context) {
var templateOptions = prepareTemplateOptions(valueAccessor, "data");
return ko.bindingHandlers.template.update(element, function() { return templateOptions; }, allBindingsAccessor, data, context);
},
connectClass: ko.bindingHandlers.sortable.connectClass,
options: {
helper: "clone"
}
};
});