-
Notifications
You must be signed in to change notification settings - Fork 5
/
Copy pathmap.js
352 lines (305 loc) · 16 KB
/
map.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
(function (root, factory) {
if (typeof exports === 'object') {
module.exports = factory(require('knockout'));
} else if (typeof define === 'function' && define.amd) {
define(['knockout'], factory);
} else {
factory(root.ko);
}
}(this, function (ko) {
ko.transformations = ko.transformations || {
fn: {}
};
function StateItem(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.disposeFuncFromMostRecentMapping = null;
this.mappedValueComputed = ko.computed(this.mappingEvaluator, this);
this.mappedValueComputed.subscribe(this.onMappingResultChanged, this);
this.previousMappedValue = this.mappedValueComputed.peek();
}
StateItem.prototype.dispose = function () {
this.mappedValueComputed.dispose();
this.disposeResultFromMostRecentEvaluation();
};
StateItem.prototype.disposeResultFromMostRecentEvaluation = function () {
if (this.disposeFuncFromMostRecentMapping) {
this.disposeFuncFromMostRecentMapping();
this.disposeFuncFromMostRecentMapping = null;
}
if (this.mappingOptions.disposeItem) {
var mappedItem = this.mappedValueComputed();
this.mappingOptions.disposeItem(mappedItem);
}
};
StateItem.prototype.mappingEvaluator = function () {
if (this.isIncluded !== null) { // i.e., not first run
// This is a replace-in-place, so call any dispose callbacks
// we have for the earlier value
this.disposeResultFromMostRecentEvaluation();
}
var mappedValue;
if (this.mappingOptions.mapping) {
mappedValue = this.mappingOptions.mapping(this.inputItem, this.outputArrayIndex);
} else if (this.mappingOptions.mappingWithDisposeCallback) {
var mappedValueWithDisposeCallback = this.mappingOptions.mappingWithDisposeCallback(this.inputItem, this.outputArrayIndex);
if (!('mappedValue' in mappedValueWithDisposeCallback)) {
throw new Error('Return value from mappingWithDisposeCallback should have a \'mappedItem\' property.');
}
mappedValue = mappedValueWithDisposeCallback.mappedValue;
this.disposeFuncFromMostRecentMapping = mappedValueWithDisposeCallback.dispose;
} else {
throw new Error('No mapping callback given.');
}
if (this.isIncluded === null) { // first run
this.isIncluded = mappedValue !== this.mappingOptions.exclusionMarker;
}
return mappedValue;
};
StateItem.prototype.updateInclusion = function () {
var outputArrayIndex = this.outputArrayIndex.peek();
var outputArray = this.outputArray;
for (var iterationIndex = this.stateArrayIndex; iterationIndex < this.arrayOfState.length; iterationIndex += 1) {
var stateItem = this.arrayOfState[iterationIndex];
stateItem.setOutputArrayIndexSilently(outputArrayIndex);
var newValue = stateItem.mappingEvaluator();
var newInclusionState = newValue !== this.mappingOptions.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 (newInclusionState) {
outputArray[outputArrayIndex] = newValue;
outputArrayIndex += 1;
}
stateItem.previousMappedValue = newValue;
stateItem.isIncluded = newInclusionState;
}
if (outputArrayIndex < outputArray.length) {
outputArray.length = outputArrayIndex;
}
};
StateItem.prototype.onMappingResultChanged = function (newValue) {
if (newValue !== this.previousMappedValue) {
if (!this.suppressNotification) {
this.outputObservableArray.valueWillMutate();
}
var newInclusionState = newValue !== this.mappingOptions.exclusionMarker;
if (this.isIncluded !== newInclusionState) {
this.updateInclusion();
} else {
if (newInclusionState) {
this.outputArray[this.outputArrayIndex.peek()] = newValue;
}
this.previousMappedValue = newValue;
}
if (!this.suppressNotification) {
this.outputObservableArray.valueHasMutated();
}
}
};
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(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(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 += 1) {
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(inputObservableArray, arrayOfState, outputArray, outputObservableArray, mappingOptions) {
return inputObservableArray.subscribe(function (diff) {
if (!diff.length) {
return;
}
if (arrayOfState.length === 0) {
// Only add items
var newOutputItems = [];
ko.utils.arrayForEach(diff, function (diffEntry, i) {
var inputItem = diffEntry.value;
var stateItem = new StateItem(inputItem, i, newOutputItems.length, mappingOptions, arrayOfState, outputObservableArray);
var mappedValue = stateItem.mappedValueComputed.peek();
arrayOfState.push(stateItem);
if (stateItem.isIncluded) {
newOutputItems.push(mappedValue);
}
});
outputObservableArray.push.apply(outputObservableArray, newOutputItems);
return;
}
outputObservableArray.valueWillMutate();
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 += 1) {
// 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(diffEntry, movedStateItems, stateArrayIndex, outputArrayIndex, mappingOptions, arrayOfState, outputObservableArray, outputArray);
if (stateItem.isIncluded) {
outputArrayIndex += 1;
}
editOffset += 1;
break;
case 'deleted':
// Just erase from the output, and update indexes
deleteOutputItem(diffEntry, arrayOfState, stateArrayIndex, outputArrayIndex, outputArray);
editOffset -= 1;
stateArrayIndex -= 1; // 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 += 1;
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');
}
ko.observableArray.fn.map = ko.transformations.fn.map = function map(mappingOptions) {
var that = this,
arrayOfState = [],
outputArray = [],
outputObservableArray = ko.observableArray(outputArray),
originalInputArrayContents = that.peek();
// Shorthand syntax - just pass a function instead of an options object
if (typeof mappingOptions === 'function') {
mappingOptions = { mapping: mappingOptions };
}
if (!mappingOptions.exclusionMarker) {
mappingOptions.exclusionMarker = {};
}
// Validate the options
if (mappingOptions.mappingWithDisposeCallback) {
if (mappingOptions.mapping || mappingOptions.disposeItem) {
throw new Error('\'mappingWithDisposeCallback\' cannot be used in conjunction with \'mapping\' or \'disposeItem\'.');
}
} else if (!mappingOptions.mapping) {
throw new Error('Specify either \'mapping\' or \'mappingWithDisposeCallback\'.');
}
// Initial state: map each of the inputs
for (var i = 0; i < originalInputArrayContents.length; i += 1) {
var inputItem = originalInputArrayContents[i],
stateItem = new StateItem(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(that, arrayOfState, outputArray, outputObservableArray, mappingOptions);
var outputComputed = ko.computed(outputObservableArray);
if ('throttle' in mappingOptions) {
outputComputed = outputComputed.extend({ throttle: mappingOptions.throttle });
}
// 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 = outputComputed.extend({ trackArrayChanges: true }),
originalDispose = returnValue.dispose;
returnValue.dispose = function () {
inputArraySubscription.dispose();
ko.utils.arrayForEach(arrayOfState, function (stateItem) {
stateItem.dispose();
});
originalDispose.call(this, arguments);
};
// Make transformations chainable
ko.utils.extend(returnValue, ko.transformations.fn);
return returnValue;
};
return ko.transformations.fn.map;
}));