Files
meteor/packages/observe-sequence/observe_sequence.js
2015-03-10 15:34:59 -07:00

348 lines
12 KiB
JavaScript

var warn = function () {
if (ObserveSequence._suppressWarnings) {
ObserveSequence._suppressWarnings--;
} else {
if (typeof console !== 'undefined' && console.warn)
console.warn.apply(console, arguments);
ObserveSequence._loggedWarnings++;
}
};
var idStringify = LocalCollection._idStringify;
var idParse = LocalCollection._idParse;
ObserveSequence = {
_suppressWarnings: 0,
_loggedWarnings: 0,
// A mechanism similar to cursor.observe which receives a reactive
// function returning a sequence type and firing appropriate callbacks
// when the value changes.
//
// @param sequenceFunc {Function} a reactive function returning a
// sequence type. The currently supported sequence types are:
// 'null', arrays and cursors.
//
// @param callbacks {Object} similar to a specific subset of
// callbacks passed to `cursor.observe`
// (http://docs.meteor.com/#observe), with minor variations to
// support the fact that not all sequences contain objects with
// _id fields. Specifically:
//
// * addedAt(id, item, atIndex, beforeId)
// * changedAt(id, newItem, oldItem, atIndex)
// * removedAt(id, oldItem, atIndex)
// * movedTo(id, item, fromIndex, toIndex, beforeId)
//
// @returns {Object(stop: Function)} call 'stop' on the return value
// to stop observing this sequence function.
//
// We don't make any assumptions about our ability to compare sequence
// elements (ie, we don't assume EJSON.equals works; maybe there is extra
// state/random methods on the objects) so unlike cursor.observe, we may
// sometimes call changedAt() when nothing actually changed.
// XXX consider if we *can* make the stronger assumption and avoid
// no-op changedAt calls (in some cases?)
//
// XXX currently only supports the callbacks used by our
// implementation of {{#each}}, but this can be expanded.
//
// XXX #each doesn't use the indices (though we'll eventually need
// a way to get them when we support `@index`), but calling
// `cursor.observe` causes the index to be calculated on every
// callback using a linear scan (unless you turn it off by passing
// `_no_indices`). Any way to avoid calculating indices on a pure
// cursor observe like we used to?
observe: function (sequenceFunc, callbacks) {
var lastSeq = null;
var activeObserveHandle = null;
// 'lastSeqArray' contains the previous value of the sequence
// we're observing. It is an array of objects with '_id' and
// 'item' fields. 'item' is the element in the array, or the
// document in the cursor.
//
// '_id' is whichever of the following is relevant, unless it has
// already appeared -- in which case it's randomly generated.
//
// * if 'item' is an object:
// * an '_id' field, if present
// * otherwise, the index in the array
//
// * if 'item' is a number or string, use that value
//
// XXX this can be generalized by allowing {{#each}} to accept a
// general 'key' argument which could be a function, a dotted
// field name, or the special @index value.
var lastSeqArray = []; // elements are objects of form {_id, item}
var computation = Tracker.autorun(function () {
var seq = sequenceFunc();
Tracker.nonreactive(function () {
var seqArray; // same structure as `lastSeqArray` above.
if (activeObserveHandle) {
// If we were previously observing a cursor, replace lastSeqArray with
// more up-to-date information. Then stop the old observe.
lastSeqArray = _.map(lastSeq.fetch(), function (doc) {
return {_id: doc._id, item: doc};
});
activeObserveHandle.stop();
activeObserveHandle = null;
}
if (!seq) {
seqArray = seqChangedToEmpty(lastSeqArray, callbacks);
} else if (seq instanceof Array) {
seqArray = seqChangedToArray(lastSeqArray, seq, callbacks);
} else if (isStoreCursor(seq)) {
var result /* [seqArray, activeObserveHandle] */ =
seqChangedToCursor(lastSeqArray, seq, callbacks);
seqArray = result[0];
activeObserveHandle = result[1];
} else {
throw badSequenceError();
}
diffArray(lastSeqArray, seqArray, callbacks);
lastSeq = seq;
lastSeqArray = seqArray;
});
});
return {
stop: function () {
computation.stop();
if (activeObserveHandle)
activeObserveHandle.stop();
}
};
},
// Fetch the items of `seq` into an array, where `seq` is of one of the
// sequence types accepted by `observe`. If `seq` is a cursor, a
// dependency is established.
fetch: function (seq) {
if (!seq) {
return [];
} else if (seq instanceof Array) {
return seq;
} else if (isStoreCursor(seq)) {
return seq.fetch();
} else {
throw badSequenceError();
}
}
};
var badSequenceError = function () {
return new Error("{{#each}} currently only accepts " +
"arrays, cursors or falsey values.");
};
var isStoreCursor = function (cursor) {
return cursor && _.isObject(cursor) &&
_.isFunction(cursor.observe) && _.isFunction(cursor.fetch);
};
// Calculates the differences between `lastSeqArray` and
// `seqArray` and calls appropriate functions from `callbacks`.
// Reuses Minimongo's diff algorithm implementation.
var diffArray = function (lastSeqArray, seqArray, callbacks) {
var diffFn = Package.minimongo.LocalCollection._diffQueryOrderedChanges;
var oldIdObjects = [];
var newIdObjects = [];
var posOld = {}; // maps from idStringify'd ids
var posNew = {}; // ditto
var posCur = {};
var lengthCur = lastSeqArray.length;
_.each(seqArray, function (doc, i) {
newIdObjects.push({_id: doc._id});
posNew[idStringify(doc._id)] = i;
});
_.each(lastSeqArray, function (doc, i) {
oldIdObjects.push({_id: doc._id});
posOld[idStringify(doc._id)] = i;
posCur[idStringify(doc._id)] = i;
});
// Arrays can contain arbitrary objects. We don't diff the
// objects. Instead we always fire 'changedAt' callback on every
// object. The consumer of `observe-sequence` should deal with
// it appropriately.
diffFn(oldIdObjects, newIdObjects, {
addedBefore: function (id, doc, before) {
var position = before ? posCur[idStringify(before)] : lengthCur;
if (before) {
// If not adding at the end, we need to update indexes.
// XXX this can still be improved greatly!
_.each(posCur, function (pos, id) {
if (pos >= position)
posCur[id]++;
});
}
lengthCur++;
posCur[idStringify(id)] = position;
callbacks.addedAt(
id,
seqArray[posNew[idStringify(id)]].item,
position,
before);
},
movedBefore: function (id, before) {
if (id === before)
return;
var oldPosition = posCur[idStringify(id)];
var newPosition = before ? posCur[idStringify(before)] : lengthCur;
// Moving the item forward. The new element is losing one position as it
// was removed from the old position before being inserted at the new
// position.
// Ex.: 0 *1* 2 3 4
// 0 2 3 *1* 4
// The original issued callback is "1" before "4".
// The position of "1" is 1, the position of "4" is 4.
// The generated move is (1) -> (3)
if (newPosition > oldPosition) {
newPosition--;
}
// Fix up the positions of elements between the old and the new positions
// of the moved element.
//
// There are two cases:
// 1. The element is moved forward. Then all the positions in between
// are moved back.
// 2. The element is moved back. Then the positions in between *and* the
// element that is currently standing on the moved element's future
// position are moved forward.
_.each(posCur, function (elCurPosition, id) {
if (oldPosition < elCurPosition && elCurPosition < newPosition)
posCur[id]--;
else if (newPosition <= elCurPosition && elCurPosition < oldPosition)
posCur[id]++;
});
// Finally, update the position of the moved element.
posCur[idStringify(id)] = newPosition;
callbacks.movedTo(
id,
seqArray[posNew[idStringify(id)]].item,
oldPosition,
newPosition,
before);
},
removed: function (id) {
var prevPosition = posCur[idStringify(id)];
_.each(posCur, function (pos, id) {
if (pos >= prevPosition)
posCur[id]--;
});
delete posCur[idStringify(id)];
lengthCur--;
callbacks.removedAt(
id,
lastSeqArray[posOld[idStringify(id)]].item,
prevPosition);
}
});
_.each(posNew, function (pos, idString) {
var id = idParse(idString);
if (_.has(posOld, idString)) {
// specifically for primitive types, compare equality before
// firing the 'changedAt' callback. otherwise, always fire it
// because doing a deep EJSON comparison is not guaranteed to
// work (an array can contain arbitrary objects, and 'transform'
// can be used on cursors). also, deep diffing is not
// necessarily the most efficient (if only a specific subfield
// of the object is later accessed).
var newItem = seqArray[pos].item;
var oldItem = lastSeqArray[posOld[idString]].item;
if (typeof newItem === 'object' || newItem !== oldItem)
callbacks.changedAt(id, newItem, oldItem, pos);
}
});
};
seqChangedToEmpty = function (lastSeqArray, callbacks) {
return [];
};
seqChangedToArray = function (lastSeqArray, array, callbacks) {
var idsUsed = {};
var seqArray = _.map(array, function (item, index) {
var id;
if (typeof item === 'string') {
// ensure not empty, since other layers (eg DomRange) assume this as well
id = "-" + item;
} else if (typeof item === 'number' ||
typeof item === 'boolean' ||
item === undefined) {
id = item;
} else if (typeof item === 'object') {
id = (item && item._id) || index;
} else {
throw new Error("{{#each}} doesn't support arrays with " +
"elements of type " + typeof item);
}
var idString = idStringify(id);
if (idsUsed[idString]) {
if (typeof item === 'object' && '_id' in item)
warn("duplicate id " + id + " in", array);
id = Random.id();
} else {
idsUsed[idString] = true;
}
return { _id: id, item: item };
});
return seqArray;
};
seqChangedToCursor = function (lastSeqArray, cursor, callbacks) {
var initial = true; // are we observing initial data from cursor?
var seqArray = [];
var observeHandle = cursor.observe({
addedAt: function (document, atIndex, before) {
if (initial) {
// keep track of initial data so that we can diff once
// we exit `observe`.
if (before !== null)
throw new Error("Expected initial data from observe in order");
seqArray.push({ _id: document._id, item: document });
} else {
callbacks.addedAt(document._id, document, atIndex, before);
}
},
changedAt: function (newDocument, oldDocument, atIndex) {
callbacks.changedAt(newDocument._id, newDocument, oldDocument,
atIndex);
},
removedAt: function (oldDocument, atIndex) {
callbacks.removedAt(oldDocument._id, oldDocument, atIndex);
},
movedTo: function (document, fromIndex, toIndex, before) {
callbacks.movedTo(
document._id, document, fromIndex, toIndex, before);
}
});
initial = false;
return [seqArray, observeHandle];
};