github.com/olivere/camlistore@v0.0.0-20140121221811-1b7ac2da0199/server/camlistored/ui/blob_item_container_react.js (about)

     1  /*
     2  Copyright 2014 The Camlistore Authors
     3  
     4  Licensed under the Apache License, Version 2.0 (the "License");
     5  you may not use this file except in compliance with the License.
     6  You may obtain a copy of the License at
     7  
     8  	http://www.apache.org/licenses/LICENSE-2.0
     9  
    10  Unless required by applicable law or agreed to in writing, software
    11  distributed under the License is distributed on an "AS IS" BASIS,
    12  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    13  See the License for the specific language governing permissions and
    14  limitations under the License.
    15  */
    16  
    17  goog.provide('cam.BlobItemContainerReact');
    18  
    19  goog.require('goog.array');
    20  goog.require('goog.async.Throttle');
    21  goog.require('goog.dom');
    22  goog.require('goog.events.EventHandler');
    23  goog.require('goog.object');
    24  goog.require('goog.math.Coordinate');
    25  goog.require('goog.math.Size');
    26  goog.require('goog.style');
    27  
    28  goog.require('cam.BlobItemReact');
    29  goog.require('cam.BlobItemReactData');
    30  goog.require('cam.reactUtil');
    31  goog.require('cam.SearchSession');
    32  
    33  cam.BlobItemContainerReact = React.createClass({
    34  	displayName: 'BlobItemContainerReact',
    35  
    36  	// Margin between items in the layout.
    37  	BLOB_ITEM_MARGIN_: 7,
    38  
    39  	// If the last row uses at least this much of the available width before adjustments, we'll call it "close enough" and adjust things so that it fills the entire row. Less than this, and we'll leave the last row unaligned.
    40  	LAST_ROW_CLOSE_ENOUGH_TO_FULL_: 0.85,
    41  
    42  	// Distance from the bottom of the page at which we will trigger loading more data.
    43  	INFINITE_SCROLL_THRESHOLD_PX_: 100,
    44  
    45  	propTypes: {
    46  		detailURL: React.PropTypes.func.isRequired,  // string->string (blobref->complete detail URL)
    47  		history: cam.reactUtil.quacksLike({replaceState:React.PropTypes.func.isRequired}).isRequired,
    48  		onSelectionChange: React.PropTypes.func,
    49  		searchSession: cam.reactUtil.quacksLike({getCurrentResults:React.PropTypes.func.isRequired, addEventListener:React.PropTypes.func.isRequired, loadMoreResults:React.PropTypes.func.isRequired}),
    50  		selection: React.PropTypes.object.isRequired,
    51  		style: React.PropTypes.object,
    52  		thumbnailSize: React.PropTypes.number.isRequired,
    53  		thumbnailVersion: React.PropTypes.number.isRequired,
    54  	},
    55  
    56  	getDefaultProps: function() {
    57  		return {
    58  			style: {},
    59  		};
    60  	},
    61  
    62  	componentWillMount: function() {
    63  		this.eh_ = new goog.events.EventHandler(this);
    64  		this.lastCheckedIndex_ = -1;
    65  		this.scrollbarWidth_ = goog.style.getScrollbarWidth();
    66  		this.layoutHeight_ = 0;
    67  		this.childProps_ = null;
    68  		this.lastSize_ = new goog.math.Size(this.props.style.width, this.props.style.height);
    69  
    70  		// TODO(aa): This can be removed when https://code.google.com/p/chromium/issues/detail?id=50298 is fixed and deployed.
    71  		this.updateHistoryThrottle_ = new goog.async.Throttle(this.updateHistory_, 2000);
    72  
    73  		this.updateChildProps_();
    74  	},
    75  
    76  	componentDidMount: function() {
    77  		this.eh_.listen(this.props.searchSession, cam.SearchSession.SEARCH_SESSION_CHANGED, this.handleSearchSessionChanged_);
    78  		this.eh_.listen(this.getDOMNode(), 'scroll', this.handleScroll_);
    79  		if (this.props.history.state && this.props.history.state.scroll) {
    80  			this.getDOMNode().scrollTop = this.props.history.state.scroll;
    81  		}
    82  		this.fillVisibleAreaWithResults_();
    83  	},
    84  
    85  	componentWillReceiveProps: function(nextProps) {
    86  		if (nextProps.searchSession != this.props.searchSession) {
    87  			this.eh_.unlisten(this.props.searchSession, cam.SearchSession.SEARCH_SESSION_CHANGED, this.handleSearchSessionChanged_);
    88  			this.eh_.listen(nextProps.searchSession, cam.SearchSession.SEARCH_SESSION_CHANGED, this.handleSearchSessionChanged_);
    89  			nextProps.searchSession.loadMoreResults();
    90  		}
    91  
    92  		var nextSize = new goog.math.Size(nextProps.style.width, nextProps.style.height);
    93  		if (nextProps.searchSession != this.props.searchSession || !goog.math.Size.equals(this.lastSize_, nextSize)) {
    94  			this.lastSize_ = nextSize;
    95  			this.updateChildProps_();
    96  		}
    97  	},
    98  
    99  	componentWillUnmount: function() {
   100  		this.eh_.dispose();
   101  		this.updateHistoryThrottle_.dispose();
   102  	},
   103  
   104  	getInitialState: function() {
   105  		return {
   106  			scroll:0,
   107  		};
   108  	},
   109  
   110  	render: function() {
   111  		var children = [];
   112  		this.childProps_.forEach(function(props) {
   113  			if (this.isVisible_(props.position.y) || this.isVisible_(props.position.y + props.size.height)) {
   114  				children.push(cam.BlobItemReact(props));
   115  			}
   116  		}.bind(this));
   117  
   118  		children.push(React.DOM.div({
   119  			key: 'marker',
   120  			style: {
   121  				position: 'absolute',
   122  				top: this.layoutHeight_ - 1,
   123  				left: 0,
   124  				height: 1,
   125  				width: 1,
   126  			},
   127  		}));
   128  
   129  		// If we haven't filled the window with results, add some more.
   130  		this.fillVisibleAreaWithResults_();
   131  
   132  		return React.DOM.div({className:'cam-blobitemcontainer', style:this.props.style, onMouseDown:this.handleMouseDown_}, children);
   133  	},
   134  
   135  	updateChildProps_: function() {
   136  		this.childProps_ = [];
   137  
   138  		var results = this.props.searchSession.getCurrentResults();
   139  		var data = goog.array.map(results.blobs, function(blob) {
   140  			return new cam.BlobItemReactData(blob.blob, results.description.meta);
   141  		});
   142  
   143  		var currentTop = this.BLOB_ITEM_MARGIN_;
   144  		var currentWidth = this.BLOB_ITEM_MARGIN_;
   145  		var rowStart = 0;
   146  		var lastItem = results.blobs.length - 1;
   147  
   148  		for (var i = rowStart; i <= lastItem; i++) {
   149  			var item = data[i];
   150  			var availWidth = this.props.style.width - this.scrollbarWidth_;
   151  			var nextWidth = currentWidth + this.props.thumbnailSize * item.aspect + this.BLOB_ITEM_MARGIN_;
   152  			if (i != lastItem && nextWidth < availWidth) {
   153  				currentWidth = nextWidth;
   154  				continue;
   155  			}
   156  
   157  			// Decide how many items are going to be in this row. We choose the number that will result in the smallest adjustment to the image sizes having to be done.
   158  			var rowEnd, rowWidth;
   159  			if (i == lastItem) {
   160  				rowEnd = lastItem;
   161  				rowWidth = nextWidth;
   162  				if (nextWidth / availWidth < this.LAST_ROW_CLOSE_ENOUGH_TO_FULL_) {
   163  					availWidth = nextWidth;
   164  				}
   165  			} else if (availWidth - currentWidth <= nextWidth - availWidth) {
   166  				rowEnd = i - 1;
   167  				rowWidth = currentWidth;
   168  			} else {
   169  				rowEnd = i;
   170  				rowWidth = nextWidth;
   171  			}
   172  
   173  			currentTop += this.updateChildPropsRow_(data, rowStart, rowEnd, availWidth, rowWidth, currentTop) + this.BLOB_ITEM_MARGIN_;
   174  
   175  			currentWidth = this.BLOB_ITEM_MARGIN_;
   176  			rowStart = rowEnd + 1;
   177  			i = rowEnd;
   178  		}
   179  
   180  		this.layoutHeight_ = currentTop;
   181  	},
   182  
   183  	updateChildPropsRow_: function(data, startIndex, endIndex, availWidth, usedWidth, top) {
   184  		var currentLeft = 0;
   185  		var rowHeight = Number.POSITIVE_INFINITY;
   186  
   187  		var numItems = endIndex - startIndex + 1;
   188  		var availThumbWidth = availWidth - (this.BLOB_ITEM_MARGIN_ * (numItems + 1));
   189  		var usedThumbWidth = usedWidth - (this.BLOB_ITEM_MARGIN_ * (numItems + 1));
   190  
   191  		for (var i = startIndex; i <= endIndex; i++) {
   192  			// We figure out the amount to adjust each item in this slightly non-intuitive way so that the adjustment is split up as fairly as possible. Figuring out a ratio up front and applying it to all items uniformly can end up with a large amount left over because of rounding.
   193  			var item = data[i];
   194  			var numItemsLeft = (endIndex + 1) - i;
   195  			var delta = Math.round((availThumbWidth - usedThumbWidth) / numItemsLeft);
   196  			var originalWidth = this.props.thumbnailSize * item.aspect;
   197  			var width = originalWidth + delta;
   198  			var ratio = width / originalWidth;
   199  			var height = Math.round(this.props.thumbnailSize * ratio);
   200  
   201  			this.childProps_.push({
   202  				key: item.blobref,
   203  				blobref: item.blobref,
   204  				checked: Boolean(this.props.selection[item.blobref]),
   205  				href: this.props.detailURL(item).toString(),
   206  				data: item,
   207  				onCheckClick: this.handleCheckClick_,
   208  				position: new goog.math.Coordinate(currentLeft + this.BLOB_ITEM_MARGIN_, top),
   209  				size: new goog.math.Size(width, height),
   210  				thumbnailVersion: this.props.thumbnailVersion,
   211  			});
   212  
   213  			currentLeft += width + this.BLOB_ITEM_MARGIN_;
   214  			usedThumbWidth += delta;
   215  			rowHeight = Math.min(rowHeight, height);
   216  		}
   217  
   218  		for (var i = startIndex; i <= endIndex; i++) {
   219  			this.childProps_[i].size.height = rowHeight;
   220  		}
   221  
   222  		return rowHeight;
   223  	},
   224  
   225  	isVisible_: function(y) {
   226  		return y >= this.state.scroll && y < (this.state.scroll + this.props.style.height);
   227  	},
   228  
   229  	handleSearchSessionChanged_: function() {
   230  		this.updateChildProps_();
   231  		this.forceUpdate();
   232  	},
   233  
   234  	handleCheckClick_: function(blobref, e) {
   235  		var blobs = this.props.searchSession.getCurrentResults().blobs;
   236  		var index = goog.array.findIndex(blobs, function(b) { return b.blob == blobref });
   237  		var newSelection = cam.object.extend(this.props.selection, {});
   238  
   239  		if (e.shiftKey && this.lastCheckedIndex_ > -1) {
   240  			var low = Math.min(this.lastCheckedIndex_, index);
   241  			var high = Math.max(this.lastCheckedIndex_, index);
   242  			for (var i = low; i <= high; i++) {
   243  				newSelection[blobs[i].blob] = true;
   244  			}
   245  		} else {
   246  			if (newSelection[blobref]) {
   247  				delete newSelection[blobref];
   248  			} else {
   249  				newSelection[blobref] = true;
   250  			}
   251  		}
   252  
   253  		this.lastCheckedIndex_ = index;
   254  		this.forceUpdate();
   255  
   256  		if (this.props.onSelectionChange) {
   257  			this.props.onSelectionChange(newSelection);
   258  		}
   259  	},
   260  
   261  	handleMouseDown_: function(e) {
   262  		// Prevent the default selection behavior.
   263  		if (e.shiftKey) {
   264  			e.preventDefault();
   265  		}
   266  	},
   267  
   268  	handleScroll_: function() {
   269  		if (!this.isMounted()) {
   270  			return;
   271  		}
   272  
   273  		this.updateHistoryThrottle_.fire();
   274  		this.setState({scroll:this.getDOMNode().scrollTop});
   275  		this.fillVisibleAreaWithResults_();
   276  	},
   277  
   278  	// NOTE: This method causes the URL bar to throb for a split second (at least on Chrome), so it should not be called constantly.
   279  	updateHistory_: function() {
   280  		this.props.history.replaceState({scroll:this.getDOMNode().scrollTop});
   281  	},
   282  
   283  	fillVisibleAreaWithResults_: function() {
   284  		if (!this.isMounted()) {
   285  			return;
   286  		}
   287  
   288  		if ((this.layoutHeight_ - this.getDOMNode().scrollTop - this.props.style.height) > this.INFINITE_SCROLL_THRESHOLD_PX_) {
   289  			return;
   290  		}
   291  
   292  		this.props.searchSession.loadMoreResults();
   293  	},
   294  });