Skip to content

Instantly share code, notes, and snippets.

@SteveSanderson
Created April 1, 2014 20:16
Show Gist options
  • Save SteveSanderson/9922236 to your computer and use it in GitHub Desktop.
Save SteveSanderson/9922236 to your computer and use it in GitHub Desktop.
/*! Knockout projections plugin - version 1.1.0-pre
------------------------------------------------------------------------------
Copyright (c) Microsoft Corporation
All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
THIS CODE IS PROVIDED *AS IS* BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION ANY IMPLIED WARRANTIES OR CONDITIONS OF TITLE, FITNESS FOR A PARTICULAR PURPOSE, MERCHANTABLITY OR NON-INFRINGEMENT.
See the Apache Version 2.0 License for specific language governing permissions and limitations under the License.
------------------------------------------------------------------------------
*/
(function(global, undefined) {
'use strict';
var exclusionMarker = {};
function StateItem(ko, inputItem, initialStateArrayIndex, initialOutputArrayIndex, mappingOptions, arrayOfState, outputObservableArray) {
// Capture state for later use
this.inputItem = inputItem;
this.stateArrayIndex = initialStateArrayIndex;
this.mappingOptions = mappingOptions;
this.arrayOfState = arrayOfState;
this.outputObservableArray = outputObservableArray;
this.outputArray = this.outputObservableArray.peek();
this.isIncluded = null; // Means 'not yet determined'
this.suppressNotification = false; // TODO: Instead of this technique, consider raising a sparse diff with a "mutated" entry when a single item changes, and not having any other change logic inside StateItem
// Set up observables
this.outputArrayIndex = ko.observable(initialOutputArrayIndex); // When excluded, it's the position the item would go if it became included
this.mappedValueComputed = ko.computed(this.mappingEvaluator, this);
this.mappedValueComputed.subscribe(this.onMappingResultChanged, this);
this.previousMappedValue = this.mappedValueComputed.peek();
}
StateItem.prototype.dispose = function() {
var mappedItem = this.mappedValueComputed();
this.mappedValueComputed.dispose();
if (this.mappingOptions.disposeItem) {
this.mappingOptions.disposeItem(mappedItem);
}
};
StateItem.prototype.mappingEvaluator = function() {
var mappedValue = this.mappingOptions.mapping(this.inputItem, this.outputArrayIndex),
newInclusionState = mappedValue !== exclusionMarker;
// Inclusion state changes can *only* happen as a result of changing an individual item.
// Structural changes to the array can't cause this (because they don't cause any remapping;
// they only map newly added items which have no earlier inclusion state to change).
if (this.isIncluded !== newInclusionState) {
if (this.isIncluded !== null) { // i.e., not first run
this.moveSubsequentItemsBecauseInclusionStateChanged(newInclusionState);
}
this.isIncluded = newInclusionState;
}
return mappedValue;
};
StateItem.prototype.onMappingResultChanged = function(newValue) {
if (newValue !== this.previousMappedValue) {
if (this.isIncluded) {
this.outputArray.splice(this.outputArrayIndex.peek(), 1, newValue);
}
if (!this.suppressNotification) {
this.outputObservableArray.valueHasMutated();
}
this.previousMappedValue = newValue;
}
};
StateItem.prototype.moveSubsequentItemsBecauseInclusionStateChanged = function(newInclusionState) {
var outputArrayIndex = this.outputArrayIndex.peek(),
iterationIndex,
stateItem;
if (newInclusionState) {
// Shift all subsequent items along by one space, and increment their indexes.
// Note that changing their indexes might cause remapping, but won't affect their
// inclusion status (by definition, inclusion status must not be affected by index,
// otherwise you get undefined results) so there's no risk of a chain reaction.
this.outputArray.splice(outputArrayIndex, 0, null);
for (iterationIndex = this.stateArrayIndex + 1; iterationIndex < this.arrayOfState.length; iterationIndex++) {
stateItem = this.arrayOfState[iterationIndex];
stateItem.setOutputArrayIndexSilently(stateItem.outputArrayIndex.peek() + 1);
}
} else {
// Shift all subsequent items back by one space, and decrement their indexes
this.outputArray.splice(outputArrayIndex, 1);
for (iterationIndex = this.stateArrayIndex + 1; iterationIndex < this.arrayOfState.length; iterationIndex++) {
stateItem = this.arrayOfState[iterationIndex];
stateItem.setOutputArrayIndexSilently(stateItem.outputArrayIndex.peek() - 1);
}
}
};
StateItem.prototype.setOutputArrayIndexSilently = function(newIndex) {
// We only want to raise one output array notification per input array change,
// so during processing, we suppress notifications
this.suppressNotification = true;
this.outputArrayIndex(newIndex);
this.suppressNotification = false;
};
function getDiffEntryPostOperationIndex(diffEntry, editOffset) {
// The diff algorithm's "index" value refers to the output array for additions,
// but the "input" array for deletions. Get the output array position.
if (!diffEntry) { return null; }
switch (diffEntry.status) {
case 'added':
return diffEntry.index;
case 'deleted':
return diffEntry.index + editOffset;
default:
throw new Error('Unknown diff status: ' + diffEntry.status);
}
}
function insertOutputItem(ko, diffEntry, movedStateItems, stateArrayIndex, outputArrayIndex, mappingOptions, arrayOfState, outputObservableArray, outputArray) {
// Retain the existing mapped value if this is a move, otherwise perform mapping
var isMoved = typeof diffEntry.moved === 'number',
stateItem = isMoved ?
movedStateItems[diffEntry.moved] :
new StateItem(ko, diffEntry.value, stateArrayIndex, outputArrayIndex, mappingOptions, arrayOfState, outputObservableArray);
arrayOfState.splice(stateArrayIndex, 0, stateItem);
if (stateItem.isIncluded) {
outputArray.splice(outputArrayIndex, 0, stateItem.mappedValueComputed.peek());
}
// Update indexes
if (isMoved) {
// We don't change the index until *after* updating this item's position in outputObservableArray,
// because changing the index may trigger re-mapping, which in turn would cause the new
// value to be written to the 'index' position in the output array
stateItem.stateArrayIndex = stateArrayIndex;
stateItem.setOutputArrayIndexSilently(outputArrayIndex);
}
return stateItem;
}
function deleteOutputItem(diffEntry, arrayOfState, stateArrayIndex, outputArrayIndex, outputArray) {
var stateItem = arrayOfState.splice(stateArrayIndex, 1)[0];
if (stateItem.isIncluded) {
outputArray.splice(outputArrayIndex, 1);
}
if (typeof diffEntry.moved !== 'number') {
// Be careful to dispose only if this item really was deleted and not moved
stateItem.dispose();
}
}
function updateRetainedOutputItem(stateItem, stateArrayIndex, outputArrayIndex) {
// Just have to update its indexes
stateItem.stateArrayIndex = stateArrayIndex;
stateItem.setOutputArrayIndexSilently(outputArrayIndex);
// Return the new value for outputArrayIndex
return outputArrayIndex + (stateItem.isIncluded ? 1 : 0);
}
function makeLookupOfMovedStateItems(diff, arrayOfState) {
// Before we mutate arrayOfComputedMappedValues at all, grab a reference to each moved item
var movedStateItems = {};
for (var diffIndex = 0; diffIndex < diff.length; diffIndex++) {
var diffEntry = diff[diffIndex];
if (diffEntry.status === 'added' && (typeof diffEntry.moved === 'number')) {
movedStateItems[diffEntry.moved] = arrayOfState[diffEntry.moved];
}
}
return movedStateItems;
}
function getFirstModifiedOutputIndex(firstDiffEntry, arrayOfState, outputArray) {
// Work out where the first edit will affect the output array
// Then we can update outputArrayIndex incrementally while walking the diff list
if (!outputArray.length || !arrayOfState[firstDiffEntry.index]) {
// The first edit is beyond the end of the output or state array, so we must
// just be appending items.
return outputArray.length;
} else {
// The first edit corresponds to an existing state array item, so grab
// the first output array index from it.
return arrayOfState[firstDiffEntry.index].outputArrayIndex.peek();
}
}
function respondToArrayStructuralChanges(ko, inputObservableArray, arrayOfState, outputArray, outputObservableArray, mappingOptions) {
return inputObservableArray.subscribe(function(diff) {
if (!diff.length) {
return;
}
var movedStateItems = makeLookupOfMovedStateItems(diff, arrayOfState),
diffIndex = 0,
diffEntry = diff[0],
editOffset = 0, // A running total of (num(items added) - num(items deleted)) not accounting for filtering
outputArrayIndex = diffEntry && getFirstModifiedOutputIndex(diffEntry, arrayOfState, outputArray);
// Now iterate over the state array, at each stage checking whether the current item
// is the next one to have been edited. We can skip all the state array items whose
// indexes are less than the first edit index (i.e., diff[0].index).
for (var stateArrayIndex = diffEntry.index; diffEntry || (stateArrayIndex < arrayOfState.length); stateArrayIndex++) {
// Does the current diffEntry correspond to this position in the state array?
if (getDiffEntryPostOperationIndex(diffEntry, editOffset) === stateArrayIndex) {
// Yes - insert or delete the corresponding state and output items
switch (diffEntry.status) {
case 'added':
// Add to output, and update indexes
var stateItem = insertOutputItem(ko, diffEntry, movedStateItems, stateArrayIndex, outputArrayIndex, mappingOptions, arrayOfState, outputObservableArray, outputArray);
if (stateItem.isIncluded) {
outputArrayIndex++;
}
editOffset++;
break;
case 'deleted':
// Just erase from the output, and update indexes
deleteOutputItem(diffEntry, arrayOfState, stateArrayIndex, outputArrayIndex, outputArray);
editOffset--;
stateArrayIndex--; // To compensate for the "for" loop incrementing it
break;
default:
throw new Error('Unknown diff status: ' + diffEntry.status);
}
// We're done with this diff entry. Move on to the next one.
diffIndex++;
diffEntry = diff[diffIndex];
} else if (stateArrayIndex < arrayOfState.length) {
// No - the current item was retained. Just update its index.
outputArrayIndex = updateRetainedOutputItem(arrayOfState[stateArrayIndex], stateArrayIndex, outputArrayIndex);
}
}
outputObservableArray.valueHasMutated();
}, null, 'arrayChange');
}
// Mapping
function observableArrayMap(ko, mappingOptions) {
var inputObservableArray = this,
arrayOfState = [],
outputArray = [],
outputObservableArray = ko.observableArray(outputArray),
originalInputArrayContents = inputObservableArray.peek();
// Shorthand syntax - just pass a function instead of an options object
if (typeof mappingOptions === 'function') {
mappingOptions = { mapping: mappingOptions };
}
// Initial state: map each of the inputs
for (var i = 0; i < originalInputArrayContents.length; i++) {
var inputItem = originalInputArrayContents[i],
stateItem = new StateItem(ko, inputItem, i, outputArray.length, mappingOptions, arrayOfState, outputObservableArray),
mappedValue = stateItem.mappedValueComputed.peek();
arrayOfState.push(stateItem);
if (stateItem.isIncluded) {
outputArray.push(mappedValue);
}
}
// If the input array changes structurally (items added or removed), update the outputs
var inputArraySubscription = respondToArrayStructuralChanges(ko, inputObservableArray, arrayOfState, outputArray, outputObservableArray, mappingOptions);
// Return value is a readonly computed which can track its own changes to permit chaining.
// When disposed, it cleans up everything it created.
var returnValue = ko.computed(outputObservableArray).extend({ trackArrayChanges: true }),
originalDispose = returnValue.dispose;
returnValue.dispose = function() {
inputArraySubscription.dispose();
ko.utils.arrayForEach(arrayOfState, function(stateItem) {
stateItem.dispose();
});
originalDispose.call(this, arguments);
};
// Make projections chainable
addProjectionFunctions(ko, returnValue);
return returnValue;
}
// Filtering
function observableArrayFilter(ko, predicate) {
return observableArrayMap.call(this, ko, function(item) {
return predicate(item) ? item : exclusionMarker;
});
}
// Attaching projection functions
// ------------------------------
//
// Builds a collection of projection functions that can quickly be attached to any object.
// The functions are predefined to retain 'this' and prefix the arguments list with the
// relevant 'ko' instance.
var projectionFunctionsCacheName = '_ko.projections.cache';
function attachProjectionFunctionsCache(ko) {
// Wraps callback so that, when invoked, its arguments list is prefixed by 'ko' and 'this'
function makeCaller(ko, callback) {
return function() {
return callback.apply(this, [ko].concat(Array.prototype.slice.call(arguments, 0)));
};
}
ko[projectionFunctionsCacheName] = {
map: makeCaller(ko, observableArrayMap),
filter: makeCaller(ko, observableArrayFilter)
};
}
function addProjectionFunctions(ko, target) {
ko.utils.extend(target, ko[projectionFunctionsCacheName]);
return target; // Enable chaining
}
// Module initialisation
// ---------------------
//
// When this script is first evaluated, it works out what kind of module loading scenario
// it is in (Node.js or a browser `<script>` tag), and then attaches itself to whichever
// instance of Knockout.js it can find.
function attachToKo(ko) {
ko.projections = {
_exclusionMarker: exclusionMarker
};
attachProjectionFunctionsCache(ko);
addProjectionFunctions(ko, ko.observableArray.fn); // Make all observable arrays projectable
}
// Determines which module loading scenario we're in, grabs dependencies, and attaches to KO
function prepareExports() {
if (typeof module !== 'undefined') {
// Node.js case - load KO synchronously
var ko = require('knockout');
attachToKo(ko);
module.exports = ko;
} else if ('ko' in global) {
// Non-module case - attach to the global instance
attachToKo(global.ko);
}
}
prepareExports();
})(this);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment