gristlabs_grist-core/app/client/lib/koArray.js

458 lines
15 KiB
JavaScript
Raw Permalink Normal View History

/**
* Our version of knockout's ko.observableArray(), similar but more efficient. It
* supports fewer methods (mainly because we don't need other methods at the moment). Instead of
* emitting 'arrayChange' events, it emits 'spliceChange' events.
*/
var ko = require('knockout');
var Promise = require('bluebird');
var dispose = require('./dispose');
var gutil = require('app/common/gutil');
require('./koUtil'); // adds subscribeInit method to observables.
/**
* Event indicating that a koArray has been modified. This reflects changes to which objects are
* in the array, not the state of those objects. A `spliceChange` event is emitted after the array
* has been modified.
* @event spliceChange
* @property {Array} data - The underlying array, already modified.
* @property {Number} start - The start index at which items were inserted or deleted.
* @property {Number} added - The number of items inserted.
* @property {Array} deleted - The array of items that got deleted.
*/
/**
* Creates and returns a new koArray, either empty or with the given initial values.
* Unlike a ko.observableArray(), you access the values using array.all(), and set values using
* array.assign() (or better, by using push() and splice()).
*/
function koArray(optInitialValues) {
return KoArray.create(optInitialValues);
}
// The koArray function is the main export.
module.exports = exports = koArray;
exports.default = koArray;
/**
* Checks if an object is an instance of koArray.
*/
koArray.isKoArray = function(obj) {
return (obj && typeof obj.subscribe === 'function' && typeof obj.all === 'function');
};
exports.isKoArray = koArray.isKoArray;
/**
* Given an observable which evaluates to different arrays or koArrays, returns a single koArray
* observable which mirrors whichever array is the current value of the observable. If a callback
* is given, all elements are mapped through it. See also map().
* @param {ko.observable} koArrayObservable: observable whose value is a koArray or plain array.
* @param {Function} optCallback: If given, maps elements from original arrays.
* @param {Object} optCallbackTarget: If callback is given, this becomes the `this` value for it.
* @returns {koArray} a single koArray that mirrors the current value of koArrayObservable,
* optionally mapping them through optCallback.
*/
koArray.syncedKoArray = function(koArrayObservable, optCallback, optCallbackTarget) {
var ret = koArray();
optCallback = optCallback || identity;
ret.autoDispose(koArrayObservable.subscribeInit(function(currentArray) {
if (koArray.isKoArray(currentArray)) {
ret.syncMap(currentArray, optCallback, optCallbackTarget);
} else if (currentArray) {
ret.syncMapDisable();
ret.assign(currentArray.map(function(item, i) {
return optCallback.call(optCallbackTarget, item, i);
}));
}
}));
return ret;
};
exports.syncedKoArray = koArray.syncedKoArray;
function SyncedState(constructFunc, key) {
constructFunc(this, key);
}
dispose.makeDisposable(SyncedState);
/**
* Create and return a new Map that's kept in sync with koArrayObj. The keys are the array items
* themselves. The values are constructed using constructFunc(state, item), where state is a new
* Disposable object, allowing to associate other disposable state with the item. The returned Map
* should itself be disposed when no longer needed.
* @param {KoArray} koArrayObj: A KoArray object to watch.
* @param {Function} constructFunc(state, item): called for each item in the array, with a new
* disposable state object, on which all Disposable methods are available. The state object
* will be disposed when an item is removed or the returned map itself disposed.
* @param [Number] options.addDelay: (optional) If numeric, delay calls to add items
* by this many milliseconds (except initialization, which is always immediate).
* @return {Map} map object mapping array items to state objects, and with a dispose() method.
*/
koArray.syncedMap = function(koArrayObj, constructFunc, options) {
var map = new Map();
var sub = koArrayObj.subscribeForEach({
add: item => map.set(item, SyncedState.create(constructFunc, item)),
remove: item => gutil.popFromMap(map, item).dispose(),
addDelay: options && options.addDelay
});
map.dispose = () => {
sub.dispose();
map.forEach((stateObj, item) => stateObj.dispose());
};
return map;
};
/**
* The actual constructor for koArray. To create a new instance, simply use koArray() (without
* `new`). The constructor might be needed, however, to inherit from this class.
*/
function KoArray(initialValues) {
this._array = ko.observable(initialValues || []);
this._preparedSpliceEvent = null;
this._syncSubscription = null;
this._disposeElements = noop;
this.autoDispose(this._array.subscribe(this._emitPreparedEvent, this, 'spectate'));
this.autoDisposeCallback(function() {
this._disposeElements(this.peek());
});
}
exports.KoArray = KoArray;
dispose.makeDisposable(KoArray);
/**
* If called on a koArray, it will dispose of its contained items as they are removed or when the
* array is itself disposed.
* @returns {koArray} itself.
*/
KoArray.prototype.setAutoDisposeValues = function() {
this._disposeElements = this._doDisposeElements;
return this;
};
/**
* Returns the underlying array, creating a dependency when used from a computed observable.
* Note that you must not modify the returned array directly; you should use koArray methods.
*/
KoArray.prototype.all = function() {
return this._array();
};
/**
* Returns the underlying array without creating a dependency on it.
* Note that you must not modify the returned array directly; you should use koArray methods.
*/
KoArray.prototype.peek = function() {
return this._array.peek();
};
/**
* Returns the underlying observable whose value is a plain array.
*/
KoArray.prototype.getObservable = function() {
return this._array;
};
/**
* The `peekLength` property evaluates to the length of the underlying array. Using it does NOT
* create a dependency on the array. Use array.all().length to create a dependency.
*/
Object.defineProperty(KoArray.prototype, 'peekLength', {
configurable: false,
enumerable: false,
get: function() { return this._array.peek().length; },
});
/**
* A shorthand for the itemModel at a given index. Returns null if the index is invalid or out of
* range. Create a dependency on the array itself.
*/
KoArray.prototype.at = function(index) {
var arr = this._array();
return index >= 0 && index < arr.length ? arr[index] : null;
};
/**
* Assigns a new underlying array. This is analogous to observableArray(newValues).
*/
KoArray.prototype.assign = function(newValues) {
var oldArray = this.peek();
this._prepareSpliceEvent(0, newValues.length, oldArray);
this._array(newValues.slice());
this._disposeElements(oldArray);
};
/**
* Subscribe to events for this koArray. To be notified of splice details, subscribe to
* 'spliceChange', which will always follow the plain 'change' events.
*/
KoArray.prototype.subscribe = function(callback, callbackTarget, event) {
return this._array.subscribe(callback, callbackTarget, event);
};
/**
* @private
* Internal method to prepare a 'spliceChange' event.
*/
KoArray.prototype._prepareSpliceEvent = function(start, numAdded, deleted) {
this._preparedSpliceEvent = {
array: null,
start: start,
added: numAdded,
deleted: deleted
};
};
/**
* @private
* Internal method to emit and reset a prepared 'spliceChange' event, if there is one.
*/
KoArray.prototype._emitPreparedEvent = function() {
var event = this._preparedSpliceEvent;
if (event) {
event.array = this.peek();
this._preparedSpliceEvent = null;
this._array.notifySubscribers(event, 'spliceChange');
}
};
/**
* @private
* Internal method called before the underlying array is modified. This copies how knockout emits
* its default events internally.
*/
KoArray.prototype._preChange = function() {
this._array.valueWillMutate();
};
/**
* @private
* Internal method called before the underlying array is modified. This copies how knockout emits
* its default events internally.
*/
KoArray.prototype._postChange = function() {
this._array.valueHasMutated();
};
/**
* @private
* Internal method to call dispose() for each item in the passed-in array. It's only used when
* autoDisposeValues option is given to koArray.
*/
KoArray.prototype._doDisposeElements = function(elements) {
for (var i = 0; i < elements.length; i++) {
elements[i].dispose();
}
};
/**
* The standard array `push` method, which emits all expected events.
*/
KoArray.prototype.push = function() {
var array = this.peek();
var start = array.length;
this._preChange();
var ret = array.push.apply(array, arguments);
this._prepareSpliceEvent(start, arguments.length, []);
this._postChange();
return ret;
};
/**
* The standard array `unshift` method, which emits all expected events.
*/
KoArray.prototype.unshift = function() {
var array = this.peek();
this._preChange();
var ret = array.unshift.apply(array, arguments);
this._prepareSpliceEvent(0, arguments.length, []);
this._postChange();
return ret;
};
/**
* The standard array `splice` method, which emits all expected events.
*/
KoArray.prototype.splice = function(start, optDeleteCount) {
return this.arraySplice(start, optDeleteCount, Array.prototype.slice.call(arguments, 2));
};
KoArray.prototype.arraySplice = function(start, optDeleteCount, arrToInsert) {
var array = this.peek();
var len = array.length;
var startIndex = Math.min(len, Math.max(0, start < 0 ? len + start : start));
this._preChange();
var ret = (optDeleteCount === void 0 ? array.splice(start) :
array.splice(start, optDeleteCount));
gutil.arraySplice(array, startIndex, arrToInsert);
this._prepareSpliceEvent(startIndex, arrToInsert.length, ret);
this._postChange();
this._disposeElements(ret);
return ret;
};
/**
* The standard array `slice` method. Creates a dependency when used from a computed observable.
*/
KoArray.prototype.slice = function() {
var array = this.all();
return array.slice.apply(array, arguments);
};
/**
* Returns a new KoArray instance, subscribed to the current one to stay parallel to it. The new
* element are set to the result of calling `callback(orig, i)` on each original element. Note
* that the index argument is only correct as of the time the callback got called.
*/
KoArray.prototype.map = function(callback, optThis) {
var newArray = new KoArray();
newArray.syncMap(this, callback, optThis);
return newArray;
};
function noop() {}
function identity(x) { return x; }
/**
* Keep this array in sync with another koArray, optionally mapping all elements through the given
* callback. If callback is omitted, the current array will just mirror otherKoArray.
* See also map().
*
* The subscription is disposed when the koArray is disposed.
*/
KoArray.prototype.syncMap = function(otherKoArray, optCallback, optCallbackTarget) {
this.syncMapDisable();
optCallback = optCallback || identity;
this.assign(otherKoArray.peek().map(function(item, i) {
return optCallback.call(optCallbackTarget, item, i);
}));
this._syncSubscription = this.autoDispose(otherKoArray.subscribe(function(splice) {
var arr = splice.array;
var newValues = [];
for (var i = splice.start, n = 0; n < splice.added; i++, n++) {
newValues.push(optCallback.call(optCallbackTarget, arr[i], i));
}
this.arraySplice(splice.start, splice.deleted.length, newValues);
}, this, 'spliceChange'));
};
/**
* Disable previously created syncMap subscription, if any.
*/
KoArray.prototype.syncMapDisable = function() {
if (this._syncSubscription) {
this.disposeDiscard(this._syncSubscription);
this._syncSubscription = null;
}
};
/**
* Analog to forEach for regular arrays, but that stays in sync with array changes.
* @param {Function} options.add: func(item, index, koarray) is called for each item present,
* and whenever an item is added.
* @param {Function} options.remove: func(item, koarray) is called whenever an item is removed.
* @param [Object] options.context: (optional) `this` value to use in add/remove callbacks.
* @param [Number] options.addDelay: (optional) If numeric, delay calls to the add
* callback by this many milliseconds (except initialization calls which are always immediate).
*/
KoArray.prototype.subscribeForEach = function(options) {
var context = options.context;
var onAdd = options.add || noop;
var onRemove = options.remove || noop;
var shouldDelay = (typeof options.addDelay === 'number');
var subscription = this.subscribe(function(splice) {
var i, arr = splice.array;
for (i = 0; i < splice.deleted.length; i++) {
onRemove.call(context, splice.deleted[i], this);
}
var callAdd = () => {
var end = splice.start + splice.added;
for (i = splice.start; i < end; i++) {
onAdd.call(context, arr[i], i, this);
}
};
if (!shouldDelay) {
callAdd();
} else if (options.addDelay > 0) {
setTimeout(callAdd, options.addDelay);
} else {
// Promise library invokes the callback much sooner than setTimeout does, i.e. it's much
// closer to "nextTick", which is what we want here.
Promise.resolve(null).then(callAdd);
}
}, this, 'spliceChange');
this.peek().forEach(function(item, i) {
onAdd.call(context, item, i, this);
}, this);
return subscription;
};
/**
* Given a numeric index, returns an index that's valid for this array, clamping it if needed.
* If the array is empty, returns null. If the index given is null, treats it as 0.
*/
KoArray.prototype.clampIndex = function(index) {
var len = this.peekLength;
return len === 0 ? null : gutil.clamp(index || 0, 0, len - 1);
};
/**
* Returns a new observable representing an index into this array. It can be read and written, and
* its value is clamped to be a valid index. The index is only null if the array is empty.
*
* As the array changes, the index is adjusted to continue pointing to the same element. If the
* pointed element is deleted, the index is adjusted to after the deletion point.
*
* The returned observable has an additional .setLive(bool) method. While set to false, the
* observale will not be adjusted as the array changes, except to keep it valid.
*/
KoArray.prototype.makeLiveIndex = function(optInitialIndex) {
// The underlying observable index. Not exposed directly.
var index = ko.observable(this.clampIndex(optInitialIndex));
var isLive = true;
// Adjust the index when data is spliced before it.
this.subscribe(function(splice) {
var idx = index.peek();
if (!isLive) {
index(this.clampIndex(idx));
} else if (idx === null) {
index(this.clampIndex(0));
} else if (idx >= splice.start + splice.deleted.length) {
// Adjust the index if it was beyond the deleted region.
index(this.clampIndex(idx + splice.added - splice.deleted.length));
} else if (idx >= splice.start + splice.added) {
// Adjust the index if it was inside the deleted region (and not replaced).
index(this.clampIndex(splice.start + splice.added));
}
}, this, 'spliceChange');
// The returned value, which is a writable computable, constraining the value to the valid range
// (or null if the range is empty).
var ret = ko.pureComputed({
read: index,
write: function(val) { index(this.clampIndex(val)); },
owner: this
});
ret.setLive = (val => { isLive = val; });
return ret;
};