/**
* @module montage/core/range-controller
*/
var Montage = require("./core").Montage;
var GenericCollection = require("collections/generic-collection");
var observableArrayProperties = require("collections/listen/array-changes").observableArrayProperties;
var deprecate = require("core/deprecate");
// The content controller is responsible for determining which content from a
// source collection are visible, their order of appearance, and whether they
// are selected. Multiple repetitions may share a single content controller
// and thus their selection state.
// The controller manages a series of visible iterations. Each iteration has a
// corresponding "object" and whether that iteration is "selected". The
// controller uses a bidirectional binding to ensure that the controller's
// "selections" collection and the "selected" property of each iteration are in
// sync.
// The controller can determine which content to display and the order in which
// to render them in a variety of ways. You can use a "selector" to
// filter and sort the content. The controller binds the content of
// "organizedContent" depending on which strategy you use.
// The content of "organizedContent" is then reflected with corresponding
// incremental changes to "iterations". The "iterations" array will always
// have an "iteration" corresponding to the "object" in "organizedContent" at
// the same position.
/**
* @const {Array}
*/
var EMPTY_ARRAY = Object.freeze([]);
/**
* A `_RangeSelection` is a special kind of `Array` that knows about a `RangeController`
* and maintains invariants about itself relative to the properties of the
* `RangeController`. A `_RangeSelection` should only be modified using the `splice`
* method. Changes by directly using other `Array` methods can break the invariants.
*
* @class _RangeSelection
* @private
*/
var _RangeSelection = function(content, rangeController) {
var self = content;
//Moved to RangeSelection.prototype for optimization
//self.makeObservable();
self.__proto__ = _RangeSelection.prototype;
self.rangeController = rangeController;
self.contentEquals = content && content.contentEquals || Object.is;
//Moved to _RangeSelection.prototype for optimization
// Object.defineProperty(self, "clone", {
// value: function(){
// return this.slice();
// }
// });
// Object.defineProperty(self, "swap", {
// configurable: false,
// value: _RangeSelection.prototype.swap
// });
// Object.defineProperty(self, "push", {
// configurable: false,
// value: _RangeSelection.prototype.push
// });
return self;
};
_RangeSelection.prototype = Object.create(Array.prototype, observableArrayProperties);
Object.defineProperty(_RangeSelection.prototype, "clone", {
value: function(){
return this.slice();
}
});
_RangeSelection.prototype.oldSwap = observableArrayProperties.swap.value;
Object.defineProperty(_RangeSelection.prototype, "swap", {
configurable: false,
value: function(start, howMany, itemsToAdd) {
return this.swap_or_push(start, howMany, itemsToAdd);
}
});
_RangeSelection.prototype.oldPush = observableArrayProperties.push.value;
Object.defineProperty(_RangeSelection.prototype, "push", {
configurable: false,
value: function() {
var i = -1,
l = arguments.length,
x = Array(l);
while (++i < l) {
x[i] = arguments[i];
}
this.swap_or_push(this.length, 0, x);
}
});
/**
* A custom version of swap to ensure that changes obey the RangeController
* invariants:
* - if rC.allowsMultipleSelection is false, only allow one item in set.
* - if rC.avoidsEmtySelection is true, require at least one item in set.
* - only add items that are present in rC.content
* - enforce uniqueness of items according to the contentEquals of the content
*
* @function swap
* @param {number} start
* @param {number} howMany
* @param {Object} itemsToAdd
*
*/
Object.defineProperty(_RangeSelection.prototype, "swap_or_push", {
configurable: false,
value: function(start, howMany, itemsToAdd) {
var content = this.rangeController.content;
this.contentEquals = content && content.contentEquals || Object.is;
start = start >= 0 ? start : this.length + start;
var plus;
var oldLength = this.length;
var minusLength = Math.min(howMany, oldLength - start);
if(itemsToAdd) {
itemsToAdd.contentEquals = this.contentEquals;
plus = itemsToAdd.filter(function(item, index){
// do not add items to the selection if they aren't in content
if (content && !content.has(item)) {
return false;
}
// if the same item appears twice in the add list, only add it once
if (itemsToAdd.findLast(item) > index) {
return false;
}
// if the item is already in the selection, don't add it
// unless it's in the part that we're about to delete.
var indexInSelection = this.find(item);
return indexInSelection < 0 ||
(indexInSelection >= start && indexInSelection < start + minusLength);
}, this);
}
else {
plus = EMPTY_ARRAY;
}
var minus;
if (minusLength === 0) {
// minus will be empty
minus = EMPTY_ARRAY;
} else {
minus = Array.prototype.slice.call(this, start, start + minusLength);
}
var diff = plus.length - minus.length;
var newLength = Math.max(this.length + diff, start + plus.length);
if (!this.rangeController.allowsMultipleSelection && newLength > 1) {
// use the last-supplied item as the sole element of the set
var last = plus.length ? plus[plus.length-1] : this.one();
if(oldLength === 0) {
this.oldPush(last);
return EMPTY_ARRAY;
}
else {
return this.oldSwap(0, oldLength, [last]);
}
} else if (this.rangeController.avoidsEmptySelection && newLength === 0) {
// use the first item in the selection, unless it is no longer in the content
if (content.has(this[0])) {
if((this.length-1) === 0) {
return EMPTY_ARRAY;
}
else {
return this.oldSwap(1, this.length-1);
}
} else {
if(this.length === 0) {
this.oldPush(content.one());
return EMPTY_ARRAY;
}
else {
return this.oldSwap(0, this.length, [content.one()]);
}
}
} else {
return this.oldSwap(start, howMany, plus);
}
}
});
/**
* A `RangeController` is responsible for managing "ranged content", typically
* an array, but any collection that implements ranged content change dispatch,
* `(plus, minus, index)`, would suffice. The controller manages selection and
* governs the filtering and ordering of the content. `RangeController` is not
* affiliated with a number range input.
*
* A `RangeController` receives a `content` collection, manages what portition
* of that content is visible and the order of its appearance
* (`organizedContent`), and projects changes to the the organized content into
* an array of iteration controllers (`iterations`, containing instances of
* `Iteration`).
*
* The `RangeController` provides a variety of knobs for how to project the
* content into the organized content, all of which are optional, and the
* default behavior is to preserve the content and its order.
* You can use the bindings path expression language (from FRB) to determine
* the `sortPath` and `filterPath`.
* There is a `reversed` flag to invert the order of appearance.
*
* The `RangeController` is also responsible for managing which content is
* selected and provides a variety of knobs for that purpose.
*
* @class RangeController
* @classdesc Manages the selection and visible portion of given content,
* typically an Array for for a [Repetition]{@link Repetition}.
* @extends Montage
*/
var RangeController = exports.RangeController = Montage.specialize( /** @lends RangeController.prototype # */ {
/**
* @constructs RangeController
*/
constructor: {
value: function RangeController(content) {
this.content = null;
this._selection = new _RangeSelection([], this);
this.sortPath = null;
this.filterPath = null;
this.reversed = false;
this.selectAddedContent = false;
this.deselectInvisibleContent = false;
this.clearSelectionOnOrderChange = false;
this.avoidsEmptySelection = false;
// The following establishes a pipeline for projecting the
// underlying content into organizedContent.
// The filterPath, sortedPath and reversed are all optional stages
// in that pipeline and used if non-null and in that order.
// The _filteredContent and _sortedContent are intermediate variables
// from which organizedContent is generated.
this.organizedContent = [];
// dispatches handleOrganizedContentRangeChange
this.organizedContent.addRangeChangeListener(this, "organizedContent");
this.defineBinding("_filteredContent", {
"<-": "$filterPath.defined() ? content.filter{path($filterPath)} : content"
});
this.defineBinding("_sortedContent", {
"<-": "$sortPath.defined() ? _filteredContent.sorted{path($sortPath)} : _filteredContent"
});
this.defineBinding("organizedContent.rangeContent()", {
"<-": "$reversed ?? 0 ? _sortedContent.reversed() : _sortedContent"
});
this.addRangeAtPathChangeListener("content", this, "handleContentRangeChange");
this.addPathChangeListener("sortPath", this, "handleOrderChange");
this.addPathChangeListener("reversed", this, "handleOrderChange");
this.addOwnPropertyChangeListener("allowsMultipleSelection", this);
this.iterations = [];
if (content) {
this.initWithContent(content);
}
}
},
/**
* Initializes a range controller with a backing collection.
*
* @function
* @param {Array|SortedSet} content - Any collection that produces range change events
* @returns this
*/
initWithContent: {
value: function (content) {
this.content = content;
return this;
}
},
// Organizing Content
// ------------------
/**
* An FRB expression that determines how to sort the content, like "name"
* to sort by name.
* If the `sortPath` is null, the content is not sorted.
*
* @property {string} value
*/
sortPath: {value: null},
/**
* Whether to reverse the order of the sorted content.
*
* @property {boolean} value
*/
reversed: {value: null},
/**
* An FRB expression that determines how to filter content like
* "name.startsWith('A')" to see only names starting with 'A'.
* If the `filterPath` is null, all content is accepted.
*
* @property {string} value
*/
filterPath: {value: null},
// Managing Selection
// ------------------
/**
* Whether to select new content automatically.
* @property {boolean}
* @default false
*
* @todo make this work
*/
selectAddedContent: {value: false},
/**
* Whether to automatically deselect content that disappears from the
* `organizedContent`.
*
* @default false
* @property {boolean}
*/
deselectInvisibleContent: {value: false},
/**
* Whether to automatically clear the selection whenever the
* `sortPath`, `filterPath`, or `reversed`
* knobs change.
*
* @default false
* @property {boolean}
*/
clearSelectionOnOrderChange: {value: false},
/**
* Whether to automatically reselect a value if it is the last value
* removed from the selection.
*
* @default false
* @property {boolean}
*/
avoidsEmptySelection: {value: false},
/**
* Whether to automatically deselect all previously selected content when a
* new selection is made.
*
* @default false
* @property {boolean}
*/
allowsMultipleSelection: {value: false},
/**
* @deprecated
*/
multiSelect: {
set: function (multiSelect) {
deprecate.deprecationWarning("multiSelect", "allowsMultipleSelection");
this.allowsMultipleSelection = !!multiSelect;
},
get: function () {
deprecate.deprecationWarning("multiSelect", "allowsMultipleSelection");
return this.allowsMultipleSelection;
}
},
// Properties managed by the controller
// ------------------------------------
/**
* An array incrementally projected from `content` through sort,
* reversed and filter.
*
* @property {Array.<Object>}
*/
organizedContent: {value: null},
/**
* An array of iterations corresponding to each of the values in
* `organizedContent`, providing an interface for getting or
* setting whether each is selected.
*
* @property {Array.<Iteration>}
*/
iterations: {value: null},
_selection: {value: null},
/**
* A subset of the `content` that are selected.
* The user may safely reassign this property and all iterations will react
* to the change.
* The selection may be `null`.
* The selection may be any collection that supports range change events
* like `Array` or `SortedSet`.
*
* Deprecation warning: setting the `selection` will not replace it with the provided.
* collection. Instead, it will empty the selection and then shallow-copy the
* contents of the argument into the existing selection array. This is done to
* maintain the complicated invariants about what the selection can be.
*
* @property {?Array|Set|SortedSet}
*/
selection: {
get: function () {
return this._selection;
},
set: function (collection) {
var args = [0, this._selection.length];
if (collection && collection.toArray) {
args = args.concat(collection.toArray());
}
this._selection.splice.apply(this._selection, args);
}
},
/**
* A managed interface for adding values to the selection, accounting for
* `allowsMultipleSelection`.
* You can however directly manipulate the selection, but that will update
* the selection asynchronously because the controller cannot change the
* selection while handling a selection change event.
*
* @function
* @param value
*/
select: {
value: function (value) {
if (!this.allowsMultipleSelection && this.selection.length >= 1) {
this.selection.clear();
}
this.selection.add(value);
}
},
/**
* A managed interface for removing values from the selection, accounting
* for `avoidsEmptySelection`.
* You can however directly manipulate the selection, but that will update
* the selection asynchronously because the controller cannot change the
* selection while handling a selection change event.
*
* @function
* @param value
*/
deselect: {
value: function (value) {
if (!this.avoidsEmptySelection || this.selection.length > 1) {
this.selection["delete"](value);
}
}
},
/**
* A managed interface for clearing the selection, accounting for
* `avoidsEmptySelection`.
* You can however directly manipulate the selection, but that will update
* the selection asynchronously because the controller cannot change the
* selection while handling a selection change event.
*
* @function
*/
clearSelection: {
value: function () {
if (!this.avoidsEmptySelection || this.selection.length > 1) {
this.selection.clear();
}
}
},
/**
* Proxies adding content to the underlying collection, accounting for
* `selectAddedContent`.
*
* @function
* @param value
* @returns {boolean} whether the value was added
*/
add: {
value: function (value) {
var result;
if (!this.content) {
this.content = [];
}
result = this.content.add(value);
if (result) {
this.handleAdd(value);
}
return result;
}
},
/**
* Proxies pushing content to the underlying collection, accounting for
* `selectAddedContent`.
*
* @function
* @param ...values
* @returns {boolean} whether the value was added
*/
push: {
value: function () {
var result = this.content.push.apply(this.content, arguments);
for (var index = 0; index < arguments.length; index++) {
this.handleAdd(arguments[index]);
}
return result;
}
},
/**
* Proxies popping content from the underlying collection.
*
* @function
* @returns the popped value
*/
pop: {
value: function () {
return this.content.pop();
}
},
/**
* Proxies shifting content from the underlying collection.
*
* @function
* @returns the shifted value
*/
shift: {
value: function () {
return this.content.shift();
}
},
/**
* Proxies unshifting content to the underlying collection, accounting for
* `selectAddedContent`.
*
* @function
* @param ...values
* @returns {boolean} whether the value was added
*/
unshift: {
value: function () {
var result = this.content.unshift.apply(this.content, arguments);
for (var index = 0; index < arguments.length; index++) {
this.handleAdd(arguments[index]);
}
return result;
}
},
/**
* Proxies splicing values into the underlying collection.
* Accounts for * `selectAddedContent`.
*
* @function
* @returns the resulting content
*/
splice: {
value: function () {
var result = this.content.splice.apply(this.content, arguments);
for (var index = 2; index < arguments.length; index++) {
this.handleAdd(arguments[index]);
}
return result;
}
},
/**
* Proxies swapping values in the underlying collection.
* Accounts for * `selectAddedContent`
*
* @function
* @param {number} index the position at which to remove values
* @param {number} minusLength the number of values to remove
* @param {Array} plus the values to add
* @returns {Array} `minus`, the removed values from the content
*/
swap: {
value: function (index, length, values) {
var result = this.content.swap.apply(this.content, arguments);
if (values) {
// TODO WTF index vs index
for (index = 2; index < values.length; index++) {
this.handleAdd(values[index]);
}
}
return result;
}
},
/**
* Proxies deleting content from the underlying collection.
*
* @function
* @param value
* @returns {boolean} whether the value was found and deleted successfully
*/
"delete": {
value: function (value) {
return this.content["delete"](value);
}
},
/**
* Does the value exist in the content?
*
* @function
* @param {object} value the value to test for
* @returns {boolean}
*/
has: {
value: function (value) {
if (this.content) {
return this.content.has(value);
} else {
return false;
}
}
},
/**
* Proxies adding each value into the underlying collection.
*
* @function
* @param {...object} values
*/
addEach: {
value: GenericCollection.prototype.addEach
},
/**
* Proxies deleting each value out from the underlying collection.
* @function
* @param {...object} values
*/
deleteEach: {
value: GenericCollection.prototype.deleteEach
},
/**
* Proxies clearing the underlying content collection.
* @function
*/
clear: {
value: function () {
this.content.clear();
}
},
/**
* Creates content and adds it to the controller and its backing
* collection.
* Uses `add` and `contentConstructor`.
* @function
* @returns the value constructed and added
*/
addContent: {
value: function () {
var content = new this.contentConstructor();
this.add(content);
return content;
}
},
/**
* @private
*/
_contentConstructor: {
value: null
},
/**
* Creates a content value for this range controller.
* If the backing
* collection has an intrinsic type, uses its `contentConstructor`.
* Otherwise, creates and returns simple, empty objects.
*
* This property can be set to an alternate content constructor, which will
* take precedence over either of the above defaults.
*
* @type {function}
*/
contentConstructor: {
get: function () {
if (this._contentConstructor) {
return this._contentConstructor;
} else if (this.content && this.content.contentConstructor) {
return this.content.contentConstructor;
} else {
return Object;
}
},
set: function (contentConstructor) {
this._contentConstructor = contentConstructor;
}
},
/**
* Dispatched by range changes to the controller's content, arranged in
* constructor.
*
* Reacts to content changes to ensure that content that no
* longer exists is removed from the selection, regardless of whether it is
* from the user or any other entity modifying the backing collection.
* @private
*/
handleContentRangeChange: {
value: function (plus, minus, index) {
if (this.selection.length > 0) {
var equals = this.content && this.content.contentEquals || Object.is;
// remove all values from the selection that were removed (but
// not added back)
minus.deleteEach(plus, equals);
if (this.selection.length) {
this.selection.deleteEach(minus);
// ensure selection always has content
if (this.selection.length === 0 && this.content && this.content.length &&
this.avoidsEmptySelection && !this.allowsMultipleSelection) {
// selection can't contain previous content value as content already changed
this.selection.add(this.content[this.content.length - 1]);
}
}
}
}
},
/**
* Watches changes to the private reflection of the public selection,
* enforcing the `allowsMultipleSelection` and `avoidsEmptySelection` invariants.
*
* @private
* @todo this doesn't seem to be used anywhere
*/
handleSelectionRangeChange : {
value: function (plus, minus, index) {
if (this.selection) {
if (this.content) {
var notInContent = [];
for (var i=0;i<plus.length;i++) {
if (!this.content.has(plus[i])) {
notInContent.push(plus[i]);
}
}
this._selection.deleteEach(notInContent);
if (!this.allowsMultipleSelection && this._selection.length > 1) {
var last = this._selection.pop();
this._selection.clear();
this._selection.add(last);
}
if (this.avoidsEmptySelection && this._selection.length === 0) {
this._selection.add(minus[0]);
}
} else {
this._selection.clear();
}
}
}
},
/**
* Dispatched by a range-at-path change listener arranged in constructor.
* Synchronizes the `iterations` with changes to `organizedContent`.
* Also manages the `deselectInvisibleContent` invariant.
* @private
*/
handleOrganizedContentRangeChange: {
value: function (plus, minus, index) {
if (this.deselectInvisibleContent && this.selection) {
var diff = minus.clone(1);
diff.deleteEach(plus);
this.selection.deleteEach(minus);
}
}
},
/**
* Dispatched by changes to sortPath, filterPath, and reversed to maintain
* the `clearSelectionOnOrderChange` invariant.
* @private
*/
handleOrderChange: {
value: function () {
if (this.clearSelectionOnOrderChange && this.selection) {
this.selection.clear();
}
}
},
/**
* Dispatched manually by all of the managed methods for adding values to
* the underlying content, like `add` and `push`, to support `allowsMultipleSelection`.
* @private
*/
handleAdd: {
value: function (value) {
if (this.selectAddedContent && this.selection) {
if (!this.allowsMultipleSelection && this.selection.length) {
this.selection.swap(0, this.selection.length, [value]);
} else {
this.selection.add(value);
}
}
}
},
/**
* Enforces the `allowsMultipleSelection` invariant when that property becomes true.
* @private
*/
handleAllowsMultipleSelectionChange: {
value: function () {
if (this.selection) {
var length = this.selection.length;
if (!this.allowsMultipleSelection && length > 1) {
var last = this._selection.pop();
this._selection.clear();
this._selection.add(last);
}
}
}
}
}, /** @lends RangeController. */ {
objectDescriptorModuleId:require("./core")._objectDescriptorModuleIdDescriptor,
objectDescriptor:require("./core")._objectDescriptorDescriptor,
blueprintModuleId:require("./core")._blueprintModuleIdDescriptor,
blueprint:require("./core")._blueprintDescriptor
});
// TODO @kriskowal scrollIndex, scrollDelegate -> scrollDelegate.scrollBy(offset)
// TODO allowsMultipleSelectionWithModifiers to support ctrl/command/shift selection such
// that individual values and ranges of values.
// TODO @kriskowal decouple such that content controllers can be chained using
// adapter pattern