Source: core/range-controller.js

/**
 * @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