@danielkalen/simplybind
Version:
Magically simple, framework-less one-way/two-way data binding for frontend/backend in ~5kb.
426 lines (355 loc) • 11.4 kB
JavaScript
function isIndex(s) {
return +s === s >>> 0;
}
function toNumber(s) {
return +s;
}
function newSplice(index, removed, addedCount) {
return {
index: index,
removed: removed,
addedCount: addedCount
};
}
const EDIT_LEAVE = 0;
const EDIT_UPDATE = 1;
const EDIT_ADD = 2;
const EDIT_DELETE = 3;
function ArraySplice() {}
ArraySplice.prototype = {
// Note: This function is *based* on the computation of the Levenshtein
// "edit" distance. The one change is that "updates" are treated as two
// edits - not one. With Array splices, an update is really a delete
// followed by an add. By retaining this, we optimize for "keeping" the
// maximum array items in the original array. For example:
//
// 'xxxx123' -> '123yyyy'
//
// With 1-edit updates, the shortest path would be just to update all seven
// characters. With 2-edit updates, we delete 4, leave 3, and add 4. This
// leaves the substring '123' intact.
calcEditDistances: function(current, currentStart, currentEnd, old, oldStart, oldEnd) {
// "Deletion" columns
let rowCount = oldEnd - oldStart + 1;
let columnCount = currentEnd - currentStart + 1;
let distances = new Array(rowCount);
let north;
let west;
// "Addition" rows. Initialize null column.
for (let i = 0; i < rowCount; ++i) {
distances[i] = new Array(columnCount);
distances[i][0] = i;
}
// Initialize null row
for (let j = 0; j < columnCount; ++j) {
distances[0][j] = j;
}
for (let i = 1; i < rowCount; ++i) {
for (let j = 1; j < columnCount; ++j) {
if (this.equals(current[currentStart + j - 1], old[oldStart + i - 1])) {
distances[i][j] = distances[i - 1][j - 1];
} else {
north = distances[i - 1][j] + 1;
west = distances[i][j - 1] + 1;
distances[i][j] = north < west ? north : west;
}
}
}
return distances;
},
// This starts at the final weight, and walks "backward" by finding
// the minimum previous weight recursively until the origin of the weight
// matrix.
spliceOperationsFromEditDistances: function(distances) {
let i = distances.length - 1;
let j = distances[0].length - 1;
let current = distances[i][j];
let edits = [];
while (i > 0 || j > 0) {
if (i === 0) {
edits.push(EDIT_ADD);
j--;
continue;
}
if (j === 0) {
edits.push(EDIT_DELETE);
i--;
continue;
}
let northWest = distances[i - 1][j - 1];
let west = distances[i - 1][j];
let north = distances[i][j - 1];
let min;
if (west < north) {
min = west < northWest ? west : northWest;
} else {
min = north < northWest ? north : northWest;
}
if (min === northWest) {
if (northWest === current) {
edits.push(EDIT_LEAVE);
} else {
edits.push(EDIT_UPDATE);
current = northWest;
}
i--;
j--;
} else if (min === west) {
edits.push(EDIT_DELETE);
i--;
current = west;
} else {
edits.push(EDIT_ADD);
j--;
current = north;
}
}
edits.reverse();
return edits;
},
/**
* Splice Projection functions:
*
* A splice map is a representation of how a previous array of items
* was transformed into a new array of items. Conceptually it is a list of
* tuples of
*
* <index, removed, addedCount>
*
* which are kept in ascending index order of. The tuple represents that at
* the |index|, |removed| sequence of items were removed, and counting forward
* from |index|, |addedCount| items were added.
*/
/**
* Lacking individual splice mutation information, the minimal set of
* splices can be synthesized given the previous state and final state of an
* array. The basic approach is to calculate the edit distance matrix and
* choose the shortest path through it.
*
* Complexity: O(l * p)
* l: The length of the current array
* p: The length of the old array
*/
calcSplices: function(current, currentStart, currentEnd, old, oldStart, oldEnd) {
let prefixCount = 0;
let suffixCount = 0;
let minLength = Math.min(currentEnd - currentStart, oldEnd - oldStart);
if (currentStart === 0 && oldStart === 0) {
prefixCount = this.sharedPrefix(current, old, minLength);
}
if (currentEnd === current.length && oldEnd === old.length) {
suffixCount = this.sharedSuffix(current, old, minLength - prefixCount);
}
currentStart += prefixCount;
oldStart += prefixCount;
currentEnd -= suffixCount;
oldEnd -= suffixCount;
if ((currentEnd - currentStart) === 0 && (oldEnd - oldStart) === 0) {
return [];
}
if (currentStart === currentEnd) {
let splice = newSplice(currentStart, [], 0);
while (oldStart < oldEnd) {
splice.removed.push(old[oldStart++]);
}
return [ splice ];
} else if (oldStart === oldEnd) {
return [ newSplice(currentStart, [], currentEnd - currentStart) ];
}
let ops = this.spliceOperationsFromEditDistances(
this.calcEditDistances(current, currentStart, currentEnd,
old, oldStart, oldEnd));
let splice = undefined;
let splices = [];
let index = currentStart;
let oldIndex = oldStart;
for (let i = 0; i < ops.length; ++i) {
switch (ops[i]) {
case EDIT_LEAVE:
if (splice) {
splices.push(splice);
splice = undefined;
}
index++;
oldIndex++;
break;
case EDIT_UPDATE:
if (!splice) {
splice = newSplice(index, [], 0);
}
splice.addedCount++;
index++;
splice.removed.push(old[oldIndex]);
oldIndex++;
break;
case EDIT_ADD:
if (!splice) {
splice = newSplice(index, [], 0);
}
splice.addedCount++;
index++;
break;
case EDIT_DELETE:
if (!splice) {
splice = newSplice(index, [], 0);
}
splice.removed.push(old[oldIndex]);
oldIndex++;
break;
// no default
}
}
if (splice) {
splices.push(splice);
}
return splices;
},
sharedPrefix: function(current, old, searchLength) {
for (let i = 0; i < searchLength; ++i) {
if (!this.equals(current[i], old[i])) {
return i;
}
}
return searchLength;
},
sharedSuffix: function(current, old, searchLength) {
let index1 = current.length;
let index2 = old.length;
let count = 0;
while (count < searchLength && this.equals(current[--index1], old[--index2])) {
count++;
}
return count;
},
calculateSplices: function(current, previous) {
return this.calcSplices(current, 0, current.length, previous, 0,
previous.length);
},
equals: function(currentValue, previousValue) {
return currentValue === previousValue;
}
};
let arraySplice = new ArraySplice();
export function calcSplices(current, currentStart, currentEnd, old, oldStart, oldEnd) {
return arraySplice.calcSplices(current, currentStart, currentEnd, old, oldStart, oldEnd);
}
function intersect(start1, end1, start2, end2) {
// Disjoint
if (end1 < start2 || end2 < start1) {
return -1;
}
// Adjacent
if (end1 === start2 || end2 === start1) {
return 0;
}
// Non-zero intersect, span1 first
if (start1 < start2) {
if (end1 < end2) {
return end1 - start2; // Overlap
}
return end2 - start2; // Contained
}
// Non-zero intersect, span2 first
if (end2 < end1) {
return end2 - start1; // Overlap
}
return end1 - start1; // Contained
}
export function mergeSplice(splices, index, removed, addedCount) {
let splice = newSplice(index, removed, addedCount);
let inserted = false;
let insertionOffset = 0;
for (let i = 0; i < splices.length; i++) {
let current = splices[i];
current.index += insertionOffset;
if (inserted) {
continue;
}
let intersectCount = intersect(splice.index,
splice.index + splice.removed.length,
current.index,
current.index + current.addedCount);
if (intersectCount >= 0) {
// Merge the two splices
splices.splice(i, 1);
i--;
insertionOffset -= current.addedCount - current.removed.length;
splice.addedCount += current.addedCount - intersectCount;
let deleteCount = splice.removed.length +
current.removed.length - intersectCount;
if (!splice.addedCount && !deleteCount) {
// merged splice is a noop. discard.
inserted = true;
} else {
let currentRemoved = current.removed;
if (splice.index < current.index) {
// some prefix of splice.removed is prepended to current.removed.
let prepend = splice.removed.slice(0, current.index - splice.index);
Array.prototype.push.apply(prepend, currentRemoved);
currentRemoved = prepend;
}
if (splice.index + splice.removed.length > current.index + current.addedCount) {
// some suffix of splice.removed is appended to current.removed.
let append = splice.removed.slice(current.index + current.addedCount - splice.index);
Array.prototype.push.apply(currentRemoved, append);
}
splice.removed = currentRemoved;
if (current.index < splice.index) {
splice.index = current.index;
}
}
} else if (splice.index < current.index) {
// Insert splice here.
inserted = true;
splices.splice(i, 0, splice);
i++;
let offset = splice.addedCount - splice.removed.length;
current.index += offset;
insertionOffset += offset;
}
}
if (!inserted) {
splices.push(splice);
}
}
function createInitialSplices(array, changeRecords) {
let splices = [];
for (let i = 0; i < changeRecords.length; i++) {
let record = changeRecords[i];
switch (record.type) {
case 'splice':
mergeSplice(splices, record.index, record.removed.slice(), record.addedCount);
break;
case 'add':
case 'update':
case 'delete':
if (!isIndex(record.name)) {
continue;
}
let index = toNumber(record.name);
if (index < 0) {
continue;
}
mergeSplice(splices, index, [record.oldValue], record.type === 'delete' ? 0 : 1);
break;
default:
console.error('Unexpected record type: ' + JSON.stringify(record)); // eslint-disable-line no-console
break;
}
}
return splices;
}
export function projectArraySplices(array, changeRecords) {
let splices = [];
createInitialSplices(array, changeRecords).forEach(function(splice) {
if (splice.addedCount === 1 && splice.removed.length === 1) {
if (splice.removed[0] !== array[splice.index]) {
splices.push(splice);
}
return;
}
splices = splices.concat(calcSplices(array, splice.index, splice.index + splice.addedCount,
splice.removed, 0, splice.removed.length));
});
return splices;
}