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