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