Javascript  |  411行  |  12.75 KB

// Copyright (c) 2012 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

// require: list_selection_model.js
// require: list_selection_controller.js
// require: list.js

/**
 * @fileoverview This implements a grid control. Grid contains a bunch of
 * similar elements placed in multiple columns. It's pretty similar to the list,
 * except the multiple columns layout.
 */

cr.define('cr.ui', function() {
  /** @const */ var ListSelectionController = cr.ui.ListSelectionController;
  /** @const */ var List = cr.ui.List;
  /** @const */ var ListItem = cr.ui.ListItem;

  /**
   * Creates a new grid item element.
   * @param {*} dataItem The data item.
   * @constructor
   * @extends {cr.ui.ListItem}
   */
  function GridItem(dataItem) {
    var el = cr.doc.createElement('span');
    el.dataItem = dataItem;
    el.__proto__ = GridItem.prototype;
    return el;
  }

  GridItem.prototype = {
    __proto__: ListItem.prototype,

    /**
     * Called when an element is decorated as a grid item.
     */
    decorate: function() {
      ListItem.prototype.decorate.call(this, arguments);
      this.textContent = this.dataItem;
    }
  };

  /**
   * Creates a new grid element.
   * @param {Object=} opt_propertyBag Optional properties.
   * @constructor
   * @extends {cr.ui.List}
   */
  var Grid = cr.ui.define('grid');

  Grid.prototype = {
    __proto__: List.prototype,

    /**
     * The number of columns in the grid. Either set by the user, or lazy
     * calculated as the maximum number of items fitting in the grid width.
     * @type {number}
     * @private
     */
    columns_: 0,

    /**
     * Function used to create grid items.
     * @type {function(): !GridItem}
     * @override
     */
    itemConstructor_: GridItem,

    /**
     * Whether or not the rows on list have various heights.
     * Shows a warning at the setter because cr.ui.Grid does not support this.
     * @type {boolean}
     */
    get fixedHeight() {
      return true;
    },
    set fixedHeight(fixedHeight) {
      if (!fixedHeight)
        console.warn('cr.ui.Grid does not support fixedHeight = false');
    },

    /**
     * @return {number} The number of columns determined by width of the grid
     *     and width of the items.
     * @private
     */
    getColumnCount_: function() {
      // Size comes here with margin already collapsed.
      var size = this.getDefaultItemSize_();

      // We should uncollapse margin, since margin isn't collapsed for
      // inline-block elements according to css spec which are thumbnail items.

      var width = size.width + Math.min(size.marginLeft, size.marginRight);
      var height = size.width + Math.min(size.marginTop, size.marginBottom);

      if (!width || !height)
        return 0;

      var itemCount = this.dataModel ? this.dataModel.length : 0;
      if (!itemCount)
        return 0;

      var columns = Math.floor(this.clientWidthWithoutScrollbar_ / width);
      if (!columns)
        return 0;

      var rows = Math.ceil(itemCount / columns);
      if (rows * height <= this.clientHeight_)
        return columns;

      return Math.floor(this.clientWidthWithScrollbar_ / width);
    },

    /**
     * Measure and cache client width and height with and without scrollbar.
     * Must be updated when offsetWidth and/or offsetHeight changed.
     */
    updateMetrics_: function() {
      // Check changings that may affect number of columns.
      var offsetWidth = this.offsetWidth;
      var offsetHeight = this.offsetHeight;
      var overflowY = getComputedStyle(this).overflowY;

      if (this.lastOffsetWidth_ == offsetWidth &&
          this.lastOverflowY == overflowY) {
        this.lastOffsetHeight_ = offsetHeight;
        return;
      }

      this.lastOffsetWidth_ = offsetWidth;
      this.lastOffsetHeight_ = offsetHeight;
      this.lastOverflowY = overflowY;
      this.columns_ = 0;

      if (overflowY == 'auto' && offsetWidth > 0) {
        // Column number may depend on whether scrollbar is present or not.
        var originalClientWidth = this.clientWidth;
        // At first make sure there is no scrollbar and calculate clientWidth
        // (triggers reflow).
        this.style.overflowY = 'hidden';
        this.clientWidthWithoutScrollbar_ = this.clientWidth;
        this.clientHeight_ = this.clientHeight;
        if (this.clientWidth != originalClientWidth) {
          // If clientWidth changed then previously scrollbar was shown.
          this.clientWidthWithScrollbar_ = originalClientWidth;
        } else {
          // Show scrollbar and recalculate clientWidth (triggers reflow).
          this.style.overflowY = 'scroll';
          this.clientWidthWithScrollbar_ = this.clientWidth;
        }
        this.style.overflowY = '';
      } else {
        this.clientWidthWithoutScrollbar_ = this.clientWidthWithScrollbar_ =
            this.clientWidth;
        this.clientHeight_ = this.clientHeight;
      }
    },

    /**
     * The number of columns in the grid. If not set, determined automatically
     * as the maximum number of items fitting in the grid width.
     * @type {number}
     */
    get columns() {
      if (!this.columns_) {
        this.columns_ = this.getColumnCount_();
      }
      return this.columns_ || 1;
    },
    set columns(value) {
      if (value >= 0 && value != this.columns_) {
        this.columns_ = value;
        this.redraw();
      }
    },

    /**
     * @param {number} index The index of the item.
     * @return {number} The top position of the item inside the list, not taking
     *     into account lead item. May vary in the case of multiple columns.
     * @override
     */
    getItemTop: function(index) {
      return Math.floor(index / this.columns) * this.getDefaultItemHeight_();
    },

    /**
     * @param {number} index The index of the item.
     * @return {number} The row of the item. May vary in the case
     *     of multiple columns.
     * @override
     */
    getItemRow: function(index) {
      return Math.floor(index / this.columns);
    },

    /**
     * @param {number} row The row.
     * @return {number} The index of the first item in the row.
     * @override
     */
    getFirstItemInRow: function(row) {
      return row * this.columns;
    },

    /**
     * Creates the selection controller to use internally.
     * @param {cr.ui.ListSelectionModel} sm The underlying selection model.
     * @return {!cr.ui.ListSelectionController} The newly created selection
     *     controller.
     * @override
     */
    createSelectionController: function(sm) {
      return new GridSelectionController(sm, this);
    },

    /**
     * Calculates the number of items fitting in the given viewport.
     * @param {number} scrollTop The scroll top position.
     * @param {number} clientHeight The height of viewport.
     * @return {{first: number, length: number, last: number}} The index of
     *     first item in view port, The number of items, The item past the last.
     * @override
     */
    getItemsInViewPort: function(scrollTop, clientHeight) {
      var itemHeight = this.getDefaultItemHeight_();
      var firstIndex =
          this.autoExpands ? 0 : this.getIndexForListOffset_(scrollTop);
      var columns = this.columns;
      var count = this.autoExpands_ ? this.dataModel.length : Math.max(
          columns * (Math.ceil(clientHeight / itemHeight) + 1),
          this.countItemsInRange_(firstIndex, scrollTop + clientHeight));
      count = columns * Math.ceil(count / columns);
      count = Math.min(count, this.dataModel.length - firstIndex);
      return {
        first: firstIndex,
        length: count,
        last: firstIndex + count - 1
      };
    },

    /**
     * Merges list items. Calls the base class implementation and then
     * puts spacers on the right places.
     * @param {number} firstIndex The index of first item, inclusively.
     * @param {number} lastIndex The index of last item, exclusively.
     * @param {Object.<string, ListItem>} cachedItems Old items cache.
     * @param {Object.<string, ListItem>} newCachedItems New items cache.
     * @override
     */
    mergeItems: function(firstIndex, lastIndex, cachedItems, newCachedItems) {
      List.prototype.mergeItems.call(this,
          firstIndex, lastIndex, cachedItems, newCachedItems);

      var afterFiller = this.afterFiller_;
      var columns = this.columns;

      for (var item = this.beforeFiller_.nextSibling; item != afterFiller;) {
        var next = item.nextSibling;
        if (isSpacer(item)) {
          // Spacer found on a place it mustn't be.
          this.removeChild(item);
          item = next;
          continue;
        }
        var index = item.listIndex;
        var nextIndex = index + 1;

        // Invisible pinned item could be outside of the
        // [firstIndex, lastIndex). Ignore it.
        if (index >= firstIndex && nextIndex < lastIndex &&
            nextIndex % columns == 0) {
          if (isSpacer(next)) {
            // Leave the spacer on its place.
            item = next.nextSibling;
          } else {
            // Insert spacer.
            var spacer = this.ownerDocument.createElement('div');
            spacer.className = 'spacer';
            this.insertBefore(spacer, next);
            item = next;
          }
        } else
          item = next;
      }

      function isSpacer(child) {
        return child.classList.contains('spacer') &&
               child != afterFiller;  // Must not be removed.
      }
    },

    /**
     * Returns the height of after filler in the list.
     * @param {number} lastIndex The index of item past the last in viewport.
     * @return {number} The height of after filler.
     * @override
     */
    getAfterFillerHeight: function(lastIndex) {
      var columns = this.columns;
      var itemHeight = this.getDefaultItemHeight_();
      // We calculate the row of last item, and the row of last shown item.
      // The difference is the number of rows not shown.
      var afterRows = Math.floor((this.dataModel.length - 1) / columns) -
          Math.floor((lastIndex - 1) / columns);
      return afterRows * itemHeight;
    },

    /**
     * Returns true if the child is a list item.
     * @param {Node} child Child of the list.
     * @return {boolean} True if a list item.
     */
    isItem: function(child) {
      // Non-items are before-, afterFiller and spacers added in mergeItems.
      return child.nodeType == Node.ELEMENT_NODE &&
             !child.classList.contains('spacer');
    },

    redraw: function() {
      this.updateMetrics_();
      var itemCount = this.dataModel ? this.dataModel.length : 0;
      if (this.lastItemCount_ != itemCount) {
        this.lastItemCount_ = itemCount;
        // Force recalculation.
        this.columns_ = 0;
      }

      List.prototype.redraw.call(this);
    }
  };

  /**
   * Creates a selection controller that is to be used with grids.
   * @param {cr.ui.ListSelectionModel} selectionModel The selection model to
   *     interact with.
   * @param {cr.ui.Grid} grid The grid to interact with.
   * @constructor
   * @extends {!cr.ui.ListSelectionController}
   */
  function GridSelectionController(selectionModel, grid) {
    this.selectionModel_ = selectionModel;
    this.grid_ = grid;
  }

  GridSelectionController.prototype = {
    __proto__: ListSelectionController.prototype,

    /**
     * Returns the index below (y axis) the given element.
     * @param {number} index The index to get the index below.
     * @return {number} The index below or -1 if not found.
     * @override
     */
    getIndexBelow: function(index) {
      var last = this.getLastIndex();
      if (index == last) {
        return -1;
      }
      index += this.grid_.columns;
      return Math.min(index, last);
    },

    /**
     * Returns the index above (y axis) the given element.
     * @param {number} index The index to get the index above.
     * @return {number} The index below or -1 if not found.
     * @override
     */
    getIndexAbove: function(index) {
      if (index == 0) {
        return -1;
      }
      index -= this.grid_.columns;
      return Math.max(index, 0);
    },

    /**
     * Returns the index before (x axis) the given element.
     * @param {number} index The index to get the index before.
     * @return {number} The index before or -1 if not found.
     * @override
     */
    getIndexBefore: function(index) {
      return index - 1;
    },

    /**
     * Returns the index after (x axis) the given element.
     * @param {number} index The index to get the index after.
     * @return {number} The index after or -1 if not found.
     * @override
     */
    getIndexAfter: function(index) {
      if (index == this.getLastIndex()) {
        return -1;
      }
      return index + 1;
    }
  };

  return {
    Grid: Grid,
    GridItem: GridItem,
    GridSelectionController: GridSelectionController
  };
});