1// Copyright (c) 2012 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5cr.define('cr.ui', function() {
6  /** @const */ var EventTarget = cr.EventTarget;
7
8  /**
9   * Creates a new selection model that is to be used with lists.
10   *
11   * @param {number=} opt_length The number items in the selection.
12   *
13   * @constructor
14   * @extends {cr.EventTarget}
15   */
16  function ListSelectionModel(opt_length) {
17    this.length_ = opt_length || 0;
18    // Even though selectedIndexes_ is really a map we use an array here to get
19    // iteration in the order of the indexes.
20    this.selectedIndexes_ = [];
21
22    // True if any item could be lead or anchor. False if only selected ones.
23    this.independentLeadItem_ = !cr.isMac && !cr.isChromeOS;
24  }
25
26  ListSelectionModel.prototype = {
27    __proto__: EventTarget.prototype,
28
29    /**
30     * The number of items in the model.
31     * @type {number}
32     */
33    get length() {
34      return this.length_;
35    },
36
37    /**
38     * The selected indexes.
39     * Setter also changes lead and anchor indexes if value list is nonempty.
40     * @type {!Array}
41     */
42    get selectedIndexes() {
43      return Object.keys(this.selectedIndexes_).map(Number);
44    },
45    set selectedIndexes(selectedIndexes) {
46      this.beginChange();
47      var unselected = {};
48      for (var index in this.selectedIndexes_) {
49        unselected[index] = true;
50      }
51
52      for (var i = 0; i < selectedIndexes.length; i++) {
53        var index = selectedIndexes[i];
54        if (index in this.selectedIndexes_) {
55          delete unselected[index];
56        } else {
57          this.selectedIndexes_[index] = true;
58          // Mark the index as changed. If previously marked, then unmark,
59          // since it just got reverted to the original state.
60          if (index in this.changedIndexes_)
61            delete this.changedIndexes_[index];
62          else
63            this.changedIndexes_[index] = true;
64        }
65      }
66
67      for (var index in unselected) {
68        delete this.selectedIndexes_[index];
69        // Mark the index as changed. If previously marked, then unmark,
70        // since it just got reverted to the original state.
71        if (index in this.changedIndexes_)
72          delete this.changedIndexes_[index];
73        else
74          this.changedIndexes_[index] = false;
75      }
76
77      if (selectedIndexes.length) {
78        this.leadIndex = this.anchorIndex = selectedIndexes[0];
79      } else {
80        this.leadIndex = this.anchorIndex = -1;
81      }
82      this.endChange();
83    },
84
85    /**
86     * Convenience getter which returns the first selected index.
87     * Setter also changes lead and anchor indexes if value is nonnegative.
88     * @type {number}
89     */
90    get selectedIndex() {
91      for (var i in this.selectedIndexes_) {
92        return Number(i);
93      }
94      return -1;
95    },
96    set selectedIndex(selectedIndex) {
97      this.selectedIndexes = selectedIndex != -1 ? [selectedIndex] : [];
98    },
99
100    /**
101     * Returns the nearest selected index or -1 if no item selected.
102     * @param {number} index The origin index.
103     * @return {number}
104     * @private
105     */
106    getNearestSelectedIndex_: function(index) {
107      if (index == -1)
108        return -1;
109
110      var result = Infinity;
111      for (var i in this.selectedIndexes_) {
112        if (Math.abs(i - index) < Math.abs(result - index))
113          result = i;
114      }
115      return result < this.length ? Number(result) : -1;
116    },
117
118    /**
119     * Selects a range of indexes, starting with {@code start} and ends with
120     * {@code end}.
121     * @param {number} start The first index to select.
122     * @param {number} end The last index to select.
123     */
124    selectRange: function(start, end) {
125      // Swap if starts comes after end.
126      if (start > end) {
127        var tmp = start;
128        start = end;
129        end = tmp;
130      }
131
132      this.beginChange();
133
134      for (var index = start; index != end; index++) {
135        this.setIndexSelected(index, true);
136      }
137      this.setIndexSelected(end, true);
138
139      this.endChange();
140    },
141
142    /**
143     * Selects all indexes.
144     */
145    selectAll: function() {
146      this.selectRange(0, this.length - 1);
147    },
148
149    /**
150     * Clears the selection
151     */
152    clear: function() {
153      this.beginChange();
154      this.length_ = 0;
155      this.anchorIndex = this.leadIndex = -1;
156      this.unselectAll();
157      this.endChange();
158    },
159
160    /**
161     * Unselects all selected items.
162     */
163    unselectAll: function() {
164      this.beginChange();
165      for (var i in this.selectedIndexes_) {
166        this.setIndexSelected(+i, false);
167      }
168      this.endChange();
169    },
170
171    /**
172     * Sets the selected state for an index.
173     * @param {number} index The index to set the selected state for.
174     * @param {boolean} b Whether to select the index or not.
175     */
176    setIndexSelected: function(index, b) {
177      var oldSelected = index in this.selectedIndexes_;
178      if (oldSelected == b)
179        return;
180
181      if (b)
182        this.selectedIndexes_[index] = true;
183      else
184        delete this.selectedIndexes_[index];
185
186      this.beginChange();
187
188      this.changedIndexes_[index] = b;
189
190      // End change dispatches an event which in turn may update the view.
191      this.endChange();
192    },
193
194    /**
195     * Whether a given index is selected or not.
196     * @param {number} index The index to check.
197     * @return {boolean} Whether an index is selected.
198     */
199    getIndexSelected: function(index) {
200      return index in this.selectedIndexes_;
201    },
202
203    /**
204     * This is used to begin batching changes. Call {@code endChange} when you
205     * are done making changes.
206     */
207    beginChange: function() {
208      if (!this.changeCount_) {
209        this.changeCount_ = 0;
210        this.changedIndexes_ = {};
211        this.oldLeadIndex_ = this.leadIndex_;
212        this.oldAnchorIndex_ = this.anchorIndex_;
213      }
214      this.changeCount_++;
215    },
216
217    /**
218     * Call this after changes are done and it will dispatch a change event if
219     * any changes were actually done.
220     */
221    endChange: function() {
222      this.changeCount_--;
223      if (!this.changeCount_) {
224        // Calls delayed |dispatchPropertyChange|s, only when |leadIndex| or
225        // |anchorIndex| has been actually changed in the batch.
226        this.leadIndex_ = this.adjustIndex_(this.leadIndex_);
227        if (this.leadIndex_ != this.oldLeadIndex_) {
228          cr.dispatchPropertyChange(this, 'leadIndex',
229                                    this.leadIndex_, this.oldLeadIndex_);
230        }
231        this.oldLeadIndex_ = null;
232
233        this.anchorIndex_ = this.adjustIndex_(this.anchorIndex_);
234        if (this.anchorIndex_ != this.oldAnchorIndex_) {
235          cr.dispatchPropertyChange(this, 'anchorIndex',
236                                    this.anchorIndex_, this.oldAnchorIndex_);
237        }
238        this.oldAnchorIndex_ = null;
239
240        var indexes = Object.keys(this.changedIndexes_);
241        if (indexes.length) {
242          var e = new Event('change');
243          e.changes = indexes.map(function(index) {
244            return {
245              index: Number(index),
246              selected: this.changedIndexes_[index]
247            };
248          }, this);
249          this.dispatchEvent(e);
250        }
251        this.changedIndexes_ = {};
252      }
253    },
254
255    leadIndex_: -1,
256    oldLeadIndex_: null,
257
258    /**
259     * The leadIndex is used with multiple selection and it is the index that
260     * the user is moving using the arrow keys.
261     * @type {number}
262     */
263    get leadIndex() {
264      return this.leadIndex_;
265    },
266    set leadIndex(leadIndex) {
267      var oldValue = this.leadIndex_;
268      var newValue = this.adjustIndex_(leadIndex);
269      this.leadIndex_ = newValue;
270      // Delays the call of dispatchPropertyChange if batch is running.
271      if (!this.changeCount_ && newValue != oldValue)
272        cr.dispatchPropertyChange(this, 'leadIndex', newValue, oldValue);
273    },
274
275    anchorIndex_: -1,
276    oldAnchorIndex_: null,
277
278    /**
279     * The anchorIndex is used with multiple selection.
280     * @type {number}
281     */
282    get anchorIndex() {
283      return this.anchorIndex_;
284    },
285    set anchorIndex(anchorIndex) {
286      var oldValue = this.anchorIndex_;
287      var newValue = this.adjustIndex_(anchorIndex);
288      this.anchorIndex_ = newValue;
289      // Delays the call of dispatchPropertyChange if batch is running.
290      if (!this.changeCount_ && newValue != oldValue)
291        cr.dispatchPropertyChange(this, 'anchorIndex', newValue, oldValue);
292    },
293
294    /**
295     * Helper method that adjustes a value before assiging it to leadIndex or
296     * anchorIndex.
297     * @param {number} index New value for leadIndex or anchorIndex.
298     * @return {number} Corrected value.
299     */
300    adjustIndex_: function(index) {
301      index = Math.max(-1, Math.min(this.length_ - 1, index));
302      // On Mac and ChromeOS lead and anchor items are forced to be among
303      // selected items. This rule is not enforces until end of batch update.
304      if (!this.changeCount_ && !this.independentLeadItem_ &&
305          !this.getIndexSelected(index)) {
306        var index2 = this.getNearestSelectedIndex_(index);
307        index = index2;
308      }
309      return index;
310    },
311
312    /**
313     * Whether the selection model supports multiple selected items.
314     * @type {boolean}
315     */
316    get multiple() {
317      return true;
318    },
319
320    /**
321     * Adjusts the selection after reordering of items in the table.
322     * @param {!Array.<number>} permutation The reordering permutation.
323     */
324    adjustToReordering: function(permutation) {
325      this.beginChange();
326      var oldLeadIndex = this.leadIndex;
327      var oldAnchorIndex = this.anchorIndex;
328      var oldSelectedItemsCount = this.selectedIndexes.length;
329
330      this.selectedIndexes = this.selectedIndexes.map(function(oldIndex) {
331        return permutation[oldIndex];
332      }).filter(function(index) {
333        return index != -1;
334      });
335
336      // Will be adjusted in endChange.
337      if (oldLeadIndex != -1)
338        this.leadIndex = permutation[oldLeadIndex];
339      if (oldAnchorIndex != -1)
340        this.anchorIndex = permutation[oldAnchorIndex];
341
342      if (oldSelectedItemsCount && !this.selectedIndexes.length &&
343          this.length_ && oldLeadIndex != -1) {
344        // All selected items are deleted. We move selection to next item of
345        // last selected item.
346        this.selectedIndexes = [Math.min(oldLeadIndex, this.length_ - 1)];
347      }
348
349      this.endChange();
350    },
351
352    /**
353     * Adjusts selection model length.
354     * @param {number} length New selection model length.
355     */
356    adjustLength: function(length) {
357      this.length_ = length;
358    }
359  };
360
361  return {
362    ListSelectionModel: ListSelectionModel
363  };
364});
365