UNPKG

@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
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; }