// 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 }; });