Created
April 1, 2014 20:16
-
-
Save SteveSanderson/9922236 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/*! 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