github.com/olivere/camlistore@v0.0.0-20140121221811-1b7ac2da0199/server/camlistored/ui/detail.js (about) 1 /* 2 Copyright 2013 Google Inc. 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.DetailView'); 18 19 goog.require('goog.array'); 20 goog.require('goog.events.EventHandler'); 21 goog.require('goog.math.Size'); 22 goog.require('goog.object'); 23 goog.require('goog.string'); 24 25 goog.require('cam.AnimationLoop'); 26 goog.require('cam.BlobItemReactData'); 27 goog.require('cam.imageUtil'); 28 goog.require('cam.Navigator'); 29 goog.require('cam.reactUtil'); 30 goog.require('cam.SearchSession'); 31 goog.require('cam.SpritedAnimation'); 32 33 cam.DetailView = React.createClass({ 34 displayName: 'DetailView', 35 36 IMG_MARGIN: 20, 37 PIGGY_WIDTH: 88, 38 PIGGY_HEIGHT: 62, 39 40 propTypes: { 41 blobref: React.PropTypes.string.isRequired, 42 getDetailURL: React.PropTypes.func.isRequired, 43 history: cam.reactUtil.quacksLike({go:React.PropTypes.func.isRequired}).isRequired, 44 height: React.PropTypes.number.isRequired, 45 keyEventTarget: React.PropTypes.object.isRequired, // An event target we will addEventListener() on to receive key events. 46 navigator: React.PropTypes.instanceOf(cam.Navigator).isRequired, 47 oldURL: React.PropTypes.instanceOf(goog.Uri).isRequired, 48 searchSession: React.PropTypes.instanceOf(cam.SearchSession).isRequired, 49 searchURL: React.PropTypes.instanceOf(goog.Uri).isRequired, 50 width: React.PropTypes.number.isRequired, 51 }, 52 53 getInitialState: function() { 54 this.imgSize_ = null; 55 this.lastImageHeight_ = 0; 56 this.pendingNavigation_ = 0; 57 this.navCount_ = 1; 58 this.eh_ = new goog.events.EventHandler(this); 59 60 return { 61 imgHasLoaded: false, 62 backwardPiggy: false, 63 }; 64 }, 65 66 componentWillReceiveProps: function(nextProps) { 67 if (this.props.blobref != nextProps.blobref) { 68 this.blobItemData_ = null; 69 this.imgSize_ = null; 70 this.lastImageHeight_ = 0; 71 this.setState({imgHasLoaded: false}); 72 } 73 }, 74 75 componentDidMount: function(root) { 76 this.eh_.listen(this.props.searchSession, cam.SearchSession.SEARCH_SESSION_CHANGED, this.searchUpdated_); 77 this.eh_.listen(this.props.keyEventTarget, 'keyup', this.handleKeyUp_); 78 this.searchUpdated_(); 79 }, 80 81 componentDidUpdate: function(prevProps, prevState) { 82 var img = this.getImageRef_(); 83 if (img) { 84 // This function gets called multiple times, but the DOM de-dupes listeners for us. Thanks DOM. 85 img.getDOMNode().addEventListener('load', this.onImgLoad_); 86 img.getDOMNode().addEventListener('error', function() { 87 console.error('Could not load image: %s', img.props.src); 88 }) 89 } 90 }, 91 92 render: function() { 93 this.blobItemData_ = this.getBlobItemData_(); 94 this.imgSize_ = this.getImgSize_(); 95 return ( 96 React.DOM.div({className:'detail-view', style: this.getStyle_()}, 97 this.getImg_(), 98 this.getPiggy_(), 99 React.DOM.div({className:'detail-view-sidebar', key:'sidebar', style: this.getSidebarStyle_()}, 100 React.DOM.a({key:'search-link', href:this.props.searchURL.toString(), onClick:this.handleEscape_}, 'Back to search'), 101 ' - ', 102 React.DOM.a({key:'old-link', href:this.props.oldURL.toString()}, 'Old and busted'), 103 React.DOM.pre({key:'sidebar-pre'}, JSON.stringify(this.getPermanodeMeta_(), null, 2))))); 104 }, 105 106 componentWillUnmount: function() { 107 this.eh_.dispose(); 108 }, 109 110 handleKeyUp_: function(e) { 111 if (e.keyCode == goog.events.KeyCodes.LEFT) { 112 this.navigate_(-1); 113 } else if (e.keyCode == goog.events.KeyCodes.RIGHT) { 114 this.navigate_(1); 115 } else if (e.keyCode == goog.events.KeyCodes.ESC) { 116 this.handleEscape_(e); 117 } 118 }, 119 120 navigate_: function(offset) { 121 this.pendingNavigation_ = offset; 122 ++this.navCount_; 123 this.setState({backwardPiggy: offset < 0}); 124 this.handlePendingNavigation_(); 125 }, 126 127 handleEscape_: function(e) { 128 e.preventDefault(); 129 e.stopPropagation(); 130 history.go(-this.navCount_); 131 }, 132 133 handlePendingNavigation_: function() { 134 if (!this.pendingNavigation_) { 135 return; 136 } 137 138 var results = this.props.searchSession.getCurrentResults(); 139 var index = goog.array.findIndex(results.blobs, function(elm) { 140 return elm.blob == this.props.blobref; 141 }.bind(this)); 142 143 if (index == -1) { 144 this.props.searchSession.loadMoreResults(); 145 return; 146 } 147 148 index += this.pendingNavigation_; 149 if (index < 0) { 150 this.pendingNavigation_ = 0; 151 console.log('Cannot navigate past beginning of search result.'); 152 return; 153 } 154 155 if (index >= results.blobs.length) { 156 if (this.props.searchSession.isComplete()) { 157 this.pendingNavigation_ = 0; 158 console.log('Cannot navigate past end of search result.'); 159 } else { 160 this.props.searchSession.loadMoreResults(); 161 } 162 return; 163 } 164 165 this.props.navigator.navigate(this.props.getDetailURL(results.blobs[index].blob)); 166 }, 167 168 onImgLoad_: function() { 169 this.setState({imgHasLoaded:true}); 170 }, 171 172 searchUpdated_: function() { 173 this.handlePendingNavigation_(); 174 175 this.blobItemData_ = this.getBlobItemData_(); 176 if (this.blobItemData_) { 177 this.forceUpdate(); 178 return; 179 } 180 181 if (this.props.searchSession.isComplete()) { 182 // TODO(aa): 404 UI. 183 var error = goog.string.subs('Could not find blobref %s in search session.', this.props.blobref); 184 alert(error); 185 throw new Error(error); 186 } 187 188 // TODO(aa): This can be inefficient in the case of a fresh page load if we have to load lots of pages to find the blobref. 189 // Our search protocol needs to be updated to handle the case of paging ahead to a particular item. 190 this.props.searchSession.loadMoreResults(); 191 }, 192 193 getImg_: function() { 194 var transition = React.addons.TransitionGroup({transitionName: 'detail-img'}, []); 195 if (this.imgSize_) { 196 transition.props.children.push( 197 React.DOM.img({ 198 className: React.addons.classSet({ 199 'detail-view-img': true, 200 'detail-view-img-loaded': this.state.imgHasLoaded 201 }), 202 // We want each image to have its own node in the DOM so that during the crossfade, we don't see the image jump to the next image's size. 203 key: this.getImageId_(), 204 ref: this.getImageId_(), 205 src: this.getSrc_(), 206 style: this.getCenteredProps_(this.imgSize_.width, this.imgSize_.height) 207 }) 208 ); 209 } 210 return transition; 211 }, 212 213 getPiggy_: function() { 214 var transition = React.addons.TransitionGroup({transitionName: 'detail-piggy'}, []); 215 if (!this.state.imgHasLoaded) { 216 transition.props.children.push( 217 cam.SpritedAnimation({ 218 src: 'glitch/npc_piggy__x1_walk_png_1354829432.png', 219 className: React.addons.classSet({ 220 'detail-view-piggy': true, 221 'detail-view-piggy-backward': this.state.backwardPiggy 222 }), 223 spriteWidth: this.PIGGY_WIDTH, 224 spriteHeight: this.PIGGY_HEIGHT, 225 sheetWidth: 8, 226 sheetHeight: 3, 227 interval: 30, 228 style: this.getCenteredProps_(this.PIGGY_WIDTH, this.PIGGY_HEIGHT) 229 })); 230 } 231 return transition; 232 }, 233 234 getCenteredProps_: function(w, h) { 235 var avail = new goog.math.Size(this.props.width - this.getSidebarWidth_(), this.props.height); 236 return { 237 top: (avail.height - h) / 2, 238 left: (avail.width - w) / 2, 239 width: w, 240 height: h 241 } 242 }, 243 244 getSrc_: function() { 245 this.lastImageHeight_ = Math.min(this.blobItemData_.im.height, cam.imageUtil.getSizeToRequest(this.imgSize_.height, this.lastImageHeight_)); 246 var uri = new goog.Uri(this.blobItemData_.m.thumbnailSrc); 247 uri.setParameterValue('mh', this.lastImageHeight_); 248 return uri.toString(); 249 }, 250 251 getImgSize_: function() { 252 if (!this.blobItemData_) { 253 return null; 254 } 255 var rawSize = new goog.math.Size(this.blobItemData_.im.width, this.blobItemData_.im.height); 256 var available = new goog.math.Size( 257 this.props.width - this.getSidebarWidth_() - this.IMG_MARGIN * 2, 258 this.props.height - this.IMG_MARGIN * 2); 259 if (rawSize.height <= available.height && rawSize.width <= available.width) { 260 return rawSize; 261 } 262 return rawSize.scaleToFit(available); 263 }, 264 265 getStyle_: function() { 266 return { 267 width: this.props.width, 268 height: this.props.height 269 } 270 }, 271 272 getSidebarStyle_: function() { 273 return { 274 width: this.getSidebarWidth_() 275 } 276 }, 277 278 getSidebarWidth_: function() { 279 return Math.max(this.props.width * 0.2, 300); 280 }, 281 282 getPermanodeMeta_: function() { 283 if (!this.blobItemData_) { 284 return null; 285 } 286 return this.blobItemData_.m; 287 }, 288 289 getBlobItemData_: function() { 290 var metabag = this.props.searchSession.getCurrentResults().description.meta; 291 if (!metabag[this.props.blobref]) { 292 return null; 293 } 294 return new cam.BlobItemReactData(this.props.blobref, metabag); 295 }, 296 297 getImageRef_: function() { 298 return this.refs[this.getImageId_()]; 299 }, 300 301 getImageId_: function() { 302 return 'img' + this.props.blobref; 303 } 304 });