OLD | NEW |
| (Empty) |
1 // Copyright (c) 2012 The Chromium Authors. All rights reserved. | |
2 // Use of this source code is governed by a BSD-style license that can be | |
3 // found in the LICENSE file. | |
4 | |
5 'use strict'; | |
6 | |
7 /** | |
8 * The overlay displaying the image. | |
9 * | |
10 * @param {HTMLElement} container The container element. | |
11 * @param {Viewport} viewport The viewport. | |
12 * @param {MetadataCache} metadataCache The metadataCache. | |
13 * @constructor | |
14 */ | |
15 function ImageView(container, viewport, metadataCache) { | |
16 this.container_ = container; | |
17 this.viewport_ = viewport; | |
18 this.document_ = container.ownerDocument; | |
19 this.contentGeneration_ = 0; | |
20 this.displayedContentGeneration_ = 0; | |
21 this.displayedViewportGeneration_ = 0; | |
22 | |
23 this.imageLoader_ = new ImageUtil.ImageLoader(this.document_, metadataCache); | |
24 // We have a separate image loader for prefetch which does not get cancelled | |
25 // when the selection changes. | |
26 this.prefetchLoader_ = new ImageUtil.ImageLoader( | |
27 this.document_, metadataCache); | |
28 | |
29 // The content cache is used for prefetching the next image when going | |
30 // through the images sequentially. The real life photos can be large | |
31 // (18Mpix = 72Mb pixel array) so we want only the minimum amount of caching. | |
32 this.contentCache_ = new ImageView.Cache(2); | |
33 | |
34 // We reuse previously generated screen-scale images so that going back to | |
35 // a recently loaded image looks instant even if the image is not in | |
36 // the content cache any more. Screen-scale images are small (~1Mpix) | |
37 // so we can afford to cache more of them. | |
38 this.screenCache_ = new ImageView.Cache(5); | |
39 this.contentCallbacks_ = []; | |
40 | |
41 /** | |
42 * The element displaying the current content. | |
43 * | |
44 * @type {HTMLCanvasElement|HTMLVideoElement} | |
45 * @private | |
46 */ | |
47 this.screenImage_ = null; | |
48 | |
49 this.localImageTransformFetcher_ = function(url, callback) { | |
50 metadataCache.get(url, 'fetchedMedia', function(fetchedMedia) { | |
51 callback(fetchedMedia.imageTransform); | |
52 }); | |
53 }; | |
54 } | |
55 | |
56 /** | |
57 * Duration of transition between modes in ms. | |
58 */ | |
59 ImageView.MODE_TRANSITION_DURATION = 350; | |
60 | |
61 /** | |
62 * If the user flips though images faster than this interval we do not apply | |
63 * the slide-in/slide-out transition. | |
64 */ | |
65 ImageView.FAST_SCROLL_INTERVAL = 300; | |
66 | |
67 /** | |
68 * Image load type: full resolution image loaded from cache. | |
69 */ | |
70 ImageView.LOAD_TYPE_CACHED_FULL = 0; | |
71 | |
72 /** | |
73 * Image load type: screen resolution preview loaded from cache. | |
74 */ | |
75 ImageView.LOAD_TYPE_CACHED_SCREEN = 1; | |
76 | |
77 /** | |
78 * Image load type: image read from file. | |
79 */ | |
80 ImageView.LOAD_TYPE_IMAGE_FILE = 2; | |
81 | |
82 /** | |
83 * Image load type: video loaded. | |
84 */ | |
85 ImageView.LOAD_TYPE_VIDEO_FILE = 3; | |
86 | |
87 /** | |
88 * Image load type: error occurred. | |
89 */ | |
90 ImageView.LOAD_TYPE_ERROR = 4; | |
91 | |
92 /** | |
93 * Image load type: the file contents is not available offline. | |
94 */ | |
95 ImageView.LOAD_TYPE_OFFLINE = 5; | |
96 | |
97 /** | |
98 * The total number of load types. | |
99 */ | |
100 ImageView.LOAD_TYPE_TOTAL = 6; | |
101 | |
102 ImageView.prototype = {__proto__: ImageBuffer.Overlay.prototype}; | |
103 | |
104 /** | |
105 * Draw below overlays with the default zIndex. | |
106 * @return {number} Z-index. | |
107 */ | |
108 ImageView.prototype.getZIndex = function() { return -1 }; | |
109 | |
110 /** | |
111 * Draw the image on screen. | |
112 */ | |
113 ImageView.prototype.draw = function() { | |
114 if (!this.contentCanvas_) // Do nothing if the image content is not set. | |
115 return; | |
116 | |
117 var forceRepaint = false; | |
118 | |
119 if (this.displayedViewportGeneration_ != | |
120 this.viewport_.getCacheGeneration()) { | |
121 this.displayedViewportGeneration_ = this.viewport_.getCacheGeneration(); | |
122 | |
123 this.setupDeviceBuffer(this.screenImage_); | |
124 | |
125 forceRepaint = true; | |
126 } | |
127 | |
128 if (forceRepaint || | |
129 this.displayedContentGeneration_ != this.contentGeneration_) { | |
130 this.displayedContentGeneration_ = this.contentGeneration_; | |
131 | |
132 ImageUtil.trace.resetTimer('paint'); | |
133 this.paintDeviceRect(this.viewport_.getDeviceClipped(), | |
134 this.contentCanvas_, this.viewport_.getImageClipped()); | |
135 ImageUtil.trace.reportTimer('paint'); | |
136 } | |
137 }; | |
138 | |
139 /** | |
140 * @param {number} x X pointer position. | |
141 * @param {number} y Y pointer position. | |
142 * @param {boolean} mouseDown True if mouse is down. | |
143 * @return {string} CSS cursor style. | |
144 */ | |
145 ImageView.prototype.getCursorStyle = function(x, y, mouseDown) { | |
146 // Indicate that the image is draggable. | |
147 if (this.viewport_.isClipped() && | |
148 this.viewport_.getScreenClipped().inside(x, y)) | |
149 return 'move'; | |
150 | |
151 return null; | |
152 }; | |
153 | |
154 /** | |
155 * @param {number} x X pointer position. | |
156 * @param {number} y Y pointer position. | |
157 * @return {function} The closure to call on drag. | |
158 */ | |
159 ImageView.prototype.getDragHandler = function(x, y) { | |
160 var cursor = this.getCursorStyle(x, y); | |
161 if (cursor == 'move') { | |
162 // Return the handler that drags the entire image. | |
163 return this.viewport_.createOffsetSetter(x, y); | |
164 } | |
165 | |
166 return null; | |
167 }; | |
168 | |
169 /** | |
170 * @return {number} The cache generation. | |
171 */ | |
172 ImageView.prototype.getCacheGeneration = function() { | |
173 return this.contentGeneration_; | |
174 }; | |
175 | |
176 /** | |
177 * Invalidate the caches to force redrawing the screen canvas. | |
178 */ | |
179 ImageView.prototype.invalidateCaches = function() { | |
180 this.contentGeneration_++; | |
181 }; | |
182 | |
183 /** | |
184 * @return {HTMLCanvasElement} The content canvas element. | |
185 */ | |
186 ImageView.prototype.getCanvas = function() { return this.contentCanvas_ }; | |
187 | |
188 /** | |
189 * @return {boolean} True if the a valid image is currently loaded. | |
190 */ | |
191 ImageView.prototype.hasValidImage = function() { | |
192 return !this.preview_ && this.contentCanvas_ && this.contentCanvas_.width; | |
193 }; | |
194 | |
195 /** | |
196 * @return {HTMLVideoElement} The video element. | |
197 */ | |
198 ImageView.prototype.getVideo = function() { return this.videoElement_ }; | |
199 | |
200 /** | |
201 * @return {HTMLCanvasElement} The cached thumbnail image. | |
202 */ | |
203 ImageView.prototype.getThumbnail = function() { return this.thumbnailCanvas_ }; | |
204 | |
205 /** | |
206 * @return {number} The content revision number. | |
207 */ | |
208 ImageView.prototype.getContentRevision = function() { | |
209 return this.contentRevision_; | |
210 }; | |
211 | |
212 /** | |
213 * Copy an image fragment from a full resolution canvas to a device resolution | |
214 * canvas. | |
215 * | |
216 * @param {Rect} deviceRect Rectangle in the device coordinates. | |
217 * @param {HTMLCanvasElement} canvas Full resolution canvas. | |
218 * @param {Rect} imageRect Rectangle in the full resolution canvas. | |
219 */ | |
220 ImageView.prototype.paintDeviceRect = function(deviceRect, canvas, imageRect) { | |
221 // Map screen canvas (0,0) to (deviceBounds.left, deviceBounds.top) | |
222 var deviceBounds = this.viewport_.getDeviceClipped(); | |
223 deviceRect = deviceRect.shift(-deviceBounds.left, -deviceBounds.top); | |
224 | |
225 // The source canvas may have different physical size than the image size | |
226 // set at the viewport. Adjust imageRect accordingly. | |
227 var bounds = this.viewport_.getImageBounds(); | |
228 var scaleX = canvas.width / bounds.width; | |
229 var scaleY = canvas.height / bounds.height; | |
230 imageRect = new Rect(imageRect.left * scaleX, imageRect.top * scaleY, | |
231 imageRect.width * scaleX, imageRect.height * scaleY); | |
232 Rect.drawImage( | |
233 this.screenImage_.getContext('2d'), canvas, deviceRect, imageRect); | |
234 }; | |
235 | |
236 /** | |
237 * Create an overlay canvas with properties similar to the screen canvas. | |
238 * Useful for showing quick feedback when editing. | |
239 * | |
240 * @return {HTMLCanvasElement} Overlay canvas. | |
241 */ | |
242 ImageView.prototype.createOverlayCanvas = function() { | |
243 var canvas = this.document_.createElement('canvas'); | |
244 canvas.className = 'image'; | |
245 this.container_.appendChild(canvas); | |
246 return canvas; | |
247 }; | |
248 | |
249 /** | |
250 * Sets up the canvas as a buffer in the device resolution. | |
251 * | |
252 * @param {HTMLCanvasElement} canvas The buffer canvas. | |
253 */ | |
254 ImageView.prototype.setupDeviceBuffer = function(canvas) { | |
255 var deviceRect = this.viewport_.getDeviceClipped(); | |
256 | |
257 // Set the canvas position and size in device pixels. | |
258 if (canvas.width != deviceRect.width) | |
259 canvas.width = deviceRect.width; | |
260 | |
261 if (canvas.height != deviceRect.height) | |
262 canvas.height = deviceRect.height; | |
263 | |
264 canvas.style.left = deviceRect.left + 'px'; | |
265 canvas.style.top = deviceRect.top + 'px'; | |
266 | |
267 // Scale the canvas down to screen pixels. | |
268 this.setTransform(canvas); | |
269 }; | |
270 | |
271 /** | |
272 * @return {ImageData} A new ImageData object with a copy of the content. | |
273 */ | |
274 ImageView.prototype.copyScreenImageData = function() { | |
275 return this.screenImage_.getContext('2d').getImageData( | |
276 0, 0, this.screenImage_.width, this.screenImage_.height); | |
277 }; | |
278 | |
279 /** | |
280 * @return {boolean} True if the image is currently being loaded. | |
281 */ | |
282 ImageView.prototype.isLoading = function() { | |
283 return this.imageLoader_.isBusy(); | |
284 }; | |
285 | |
286 /** | |
287 * Cancel the current image loading operation. The callbacks will be ignored. | |
288 */ | |
289 ImageView.prototype.cancelLoad = function() { | |
290 this.imageLoader_.cancel(); | |
291 }; | |
292 | |
293 /** | |
294 * Load and display a new image. | |
295 * | |
296 * Loads the thumbnail first, then replaces it with the main image. | |
297 * Takes into account the image orientation encoded in the metadata. | |
298 * | |
299 * @param {string} url Image url. | |
300 * @param {Object} metadata Metadata. | |
301 * @param {Object} effect Transition effect object. | |
302 * @param {function(number} displayCallback Called when the image is displayed | |
303 * (possibly as a prevew). | |
304 * @param {function(number} loadCallback Called when the image is fully loaded. | |
305 * The parameter is the load type. | |
306 */ | |
307 ImageView.prototype.load = function(url, metadata, effect, | |
308 displayCallback, loadCallback) { | |
309 if (effect) { | |
310 // Skip effects when reloading repeatedly very quickly. | |
311 var time = Date.now(); | |
312 if (this.lastLoadTime_ && | |
313 (time - this.lastLoadTime_) < ImageView.FAST_SCROLL_INTERVAL) { | |
314 effect = null; | |
315 } | |
316 this.lastLoadTime_ = time; | |
317 } | |
318 | |
319 metadata = metadata || {}; | |
320 | |
321 ImageUtil.metrics.startInterval(ImageUtil.getMetricName('DisplayTime')); | |
322 | |
323 var self = this; | |
324 | |
325 this.contentID_ = url; | |
326 this.contentRevision_ = -1; | |
327 | |
328 var loadingVideo = FileType.getMediaType(url) == 'video'; | |
329 if (loadingVideo) { | |
330 var video = this.document_.createElement('video'); | |
331 var videoPreview = !!(metadata.thumbnail && metadata.thumbnail.url); | |
332 if (videoPreview) { | |
333 var thumbnailLoader = new ThumbnailLoader( | |
334 metadata.thumbnail.url, | |
335 ThumbnailLoader.LoaderType.CANVAS, | |
336 metadata); | |
337 thumbnailLoader.loadDetachedImage(function(success) { | |
338 if (success) { | |
339 var canvas = thumbnailLoader.getImage(); | |
340 video.setAttribute('poster', canvas.toDataURL('image/jpeg')); | |
341 this.replace(video, effect); // Show the poster immediately. | |
342 if (displayCallback) displayCallback(); | |
343 } | |
344 }.bind(this)); | |
345 } | |
346 | |
347 var onVideoLoad = function(error) { | |
348 video.removeEventListener('loadedmetadata', onVideoLoadSuccess); | |
349 video.removeEventListener('error', onVideoLoadError); | |
350 displayMainImage(ImageView.LOAD_TYPE_VIDEO_FILE, videoPreview, video, | |
351 error); | |
352 }; | |
353 var onVideoLoadError = onVideoLoad.bind(this, 'VIDEO_ERROR'); | |
354 var onVideoLoadSuccess = onVideoLoad.bind(this, null); | |
355 | |
356 video.addEventListener('loadedmetadata', onVideoLoadSuccess); | |
357 video.addEventListener('error', onVideoLoadError); | |
358 | |
359 video.src = url; | |
360 video.load(); | |
361 return; | |
362 } | |
363 | |
364 // Cache has to be evicted in advance, so the returned cached image is not | |
365 // evicted later by the prefetched image. | |
366 this.contentCache_.evictLRU(); | |
367 | |
368 var cached = this.contentCache_.getItem(this.contentID_); | |
369 if (cached) { | |
370 displayMainImage(ImageView.LOAD_TYPE_CACHED_FULL, | |
371 false /* no preview */, cached); | |
372 } else { | |
373 var cachedScreen = this.screenCache_.getItem(this.contentID_); | |
374 var imageWidth = metadata.media && metadata.media.width || | |
375 metadata.drive && metadata.drive.imageWidth; | |
376 var imageHeight = metadata.media && metadata.media.height || | |
377 metadata.drive && metadata.drive.imageHeight; | |
378 if (cachedScreen) { | |
379 // We have a cached screen-scale canvas, use it instead of a thumbnail. | |
380 displayThumbnail(ImageView.LOAD_TYPE_CACHED_SCREEN, cachedScreen); | |
381 // As far as the user can tell the image is loaded. We still need to load | |
382 // the full res image to make editing possible, but we can report now. | |
383 ImageUtil.metrics.recordInterval(ImageUtil.getMetricName('DisplayTime')); | |
384 } else if ((!effect || (effect.constructor.name == 'Slide')) && | |
385 metadata.thumbnail && metadata.thumbnail.url && | |
386 !(imageWidth && imageHeight && | |
387 ImageUtil.ImageLoader.isTooLarge(imageWidth, imageHeight))) { | |
388 // Only show thumbnails if there is no effect or the effect is Slide. | |
389 // Also no thumbnail if the image is too large to be loaded. | |
390 var thumbnailLoader = new ThumbnailLoader( | |
391 metadata.thumbnail.url, | |
392 ThumbnailLoader.LoaderType.CANVAS, | |
393 metadata); | |
394 thumbnailLoader.loadDetachedImage(function(success) { | |
395 displayThumbnail(ImageView.LOAD_TYPE_IMAGE_FILE, | |
396 success ? thumbnailLoader.getImage() : null); | |
397 }); | |
398 } else { | |
399 loadMainImage(ImageView.LOAD_TYPE_IMAGE_FILE, url, | |
400 false /* no preview*/, 0 /* delay */); | |
401 } | |
402 } | |
403 | |
404 function displayThumbnail(loadType, canvas) { | |
405 if (canvas) { | |
406 self.replace( | |
407 canvas, | |
408 effect, | |
409 metadata.media.width || metadata.drive.imageWidth, | |
410 metadata.media.height || metadata.drive.imageHeight, | |
411 true /* preview */); | |
412 if (displayCallback) displayCallback(); | |
413 } | |
414 loadMainImage(loadType, url, !!canvas, | |
415 (effect && canvas) ? effect.getSafeInterval() : 0); | |
416 } | |
417 | |
418 function loadMainImage(loadType, contentURL, previewShown, delay) { | |
419 if (self.prefetchLoader_.isLoading(contentURL)) { | |
420 // The image we need is already being prefetched. Initiating another load | |
421 // would be a waste. Hijack the load instead by overriding the callback. | |
422 self.prefetchLoader_.setCallback( | |
423 displayMainImage.bind(null, loadType, previewShown)); | |
424 | |
425 // Swap the loaders so that the self.isLoading works correctly. | |
426 var temp = self.prefetchLoader_; | |
427 self.prefetchLoader_ = self.imageLoader_; | |
428 self.imageLoader_ = temp; | |
429 return; | |
430 } | |
431 self.prefetchLoader_.cancel(); // The prefetch was doing something useless. | |
432 | |
433 self.imageLoader_.load( | |
434 contentURL, | |
435 self.localImageTransformFetcher_, | |
436 displayMainImage.bind(null, loadType, previewShown), | |
437 delay); | |
438 } | |
439 | |
440 function displayMainImage(loadType, previewShown, content, opt_error) { | |
441 if (opt_error) | |
442 loadType = ImageView.LOAD_TYPE_ERROR; | |
443 | |
444 // If we already displayed the preview we should not replace the content if: | |
445 // 1. The full content failed to load. | |
446 // or | |
447 // 2. We are loading a video (because the full video is displayed in the | |
448 // same HTML element as the preview). | |
449 var animationDuration = 0; | |
450 if (!(previewShown && | |
451 (loadType == ImageView.LOAD_TYPE_ERROR || | |
452 loadType == ImageView.LOAD_TYPE_VIDEO_FILE))) { | |
453 var replaceEffect = previewShown ? null : effect; | |
454 animationDuration = replaceEffect ? replaceEffect.getSafeInterval() : 0; | |
455 self.replace(content, replaceEffect); | |
456 if (!previewShown && displayCallback) displayCallback(); | |
457 } | |
458 | |
459 if (loadType != ImageView.LOAD_TYPE_ERROR && | |
460 loadType != ImageView.LOAD_TYPE_CACHED_SCREEN) { | |
461 ImageUtil.metrics.recordInterval(ImageUtil.getMetricName('DisplayTime')); | |
462 } | |
463 ImageUtil.metrics.recordEnum(ImageUtil.getMetricName('LoadMode'), | |
464 loadType, ImageView.LOAD_TYPE_TOTAL); | |
465 | |
466 if (loadType == ImageView.LOAD_TYPE_ERROR && | |
467 !navigator.onLine && metadata.streaming) { | |
468 // |streaming| is set only when the file is not locally cached. | |
469 loadType = ImageView.LOAD_TYPE_OFFLINE; | |
470 } | |
471 if (loadCallback) loadCallback(loadType, animationDuration, opt_error); | |
472 } | |
473 }; | |
474 | |
475 /** | |
476 * Prefetch an image. | |
477 * | |
478 * @param {string} url The image url. | |
479 * @param {number} delay Image load delay in ms. | |
480 */ | |
481 ImageView.prototype.prefetch = function(url, delay) { | |
482 var self = this; | |
483 function prefetchDone(canvas) { | |
484 if (canvas.width) | |
485 self.contentCache_.putItem(url, canvas); | |
486 } | |
487 | |
488 var cached = this.contentCache_.getItem(url); | |
489 if (cached) { | |
490 prefetchDone(cached); | |
491 } else if (FileType.getMediaType(url) == 'image') { | |
492 // Evict the LRU item before we allocate the new canvas to avoid unneeded | |
493 // strain on memory. | |
494 this.contentCache_.evictLRU(); | |
495 | |
496 this.prefetchLoader_.load( | |
497 url, | |
498 this.localImageTransformFetcher_, | |
499 prefetchDone, | |
500 delay); | |
501 } | |
502 }; | |
503 | |
504 /** | |
505 * Rename the current image. | |
506 * | |
507 * @param {string} newUrl The new image url. | |
508 */ | |
509 ImageView.prototype.changeUrl = function(newUrl) { | |
510 this.contentCache_.renameItem(this.contentID_, newUrl); | |
511 this.screenCache_.renameItem(this.contentID_, newUrl); | |
512 this.contentID_ = newUrl; | |
513 }; | |
514 | |
515 /** | |
516 * Unload content. | |
517 * | |
518 * @param {Rect} zoomToRect Target rectangle for zoom-out-effect. | |
519 */ | |
520 ImageView.prototype.unload = function(zoomToRect) { | |
521 if (this.unloadTimer_) { | |
522 clearTimeout(this.unloadTimer_); | |
523 this.unloadTimer_ = null; | |
524 } | |
525 if (zoomToRect && this.screenImage_) { | |
526 var effect = this.createZoomEffect(zoomToRect); | |
527 this.setTransform(this.screenImage_, effect); | |
528 this.screenImage_.setAttribute('fade', true); | |
529 this.unloadTimer_ = setTimeout(function() { | |
530 this.unloadTimer_ = null; | |
531 this.unload(null /* force unload */); | |
532 }.bind(this), | |
533 effect.getSafeInterval()); | |
534 return; | |
535 } | |
536 this.container_.textContent = ''; | |
537 this.contentCanvas_ = null; | |
538 this.screenImage_ = null; | |
539 this.videoElement_ = null; | |
540 }; | |
541 | |
542 /** | |
543 * | |
544 * @param {HTMLCanvasElement|HTMLVideoElement} content The image element. | |
545 * @param {number=} opt_width Image width. | |
546 * @param {number=} opt_height Image height. | |
547 * @param {boolean=} opt_preview True if the image is a preview (not full res). | |
548 * @private | |
549 */ | |
550 ImageView.prototype.replaceContent_ = function( | |
551 content, opt_width, opt_height, opt_preview) { | |
552 | |
553 if (this.contentCanvas_ && this.contentCanvas_.parentNode == this.container_) | |
554 this.container_.removeChild(this.contentCanvas_); | |
555 | |
556 if (content.constructor.name == 'HTMLVideoElement') { | |
557 this.contentCanvas_ = null; | |
558 this.videoElement_ = content; | |
559 this.screenImage_ = content; | |
560 this.screenImage_.className = 'image'; | |
561 this.container_.appendChild(this.screenImage_); | |
562 this.videoElement_.play(); | |
563 return; | |
564 } | |
565 | |
566 this.screenImage_ = this.document_.createElement('canvas'); | |
567 this.screenImage_.className = 'image'; | |
568 | |
569 this.videoElement_ = null; | |
570 this.contentCanvas_ = content; | |
571 this.invalidateCaches(); | |
572 this.viewport_.setImageSize( | |
573 opt_width || this.contentCanvas_.width, | |
574 opt_height || this.contentCanvas_.height); | |
575 this.viewport_.fitImage(); | |
576 this.viewport_.update(); | |
577 this.draw(); | |
578 | |
579 this.container_.appendChild(this.screenImage_); | |
580 | |
581 this.preview_ = opt_preview; | |
582 // If this is not a thumbnail, cache the content and the screen-scale image. | |
583 if (this.hasValidImage()) { | |
584 // Insert the full resolution canvas into DOM so that it can be printed. | |
585 this.container_.appendChild(this.contentCanvas_); | |
586 this.contentCanvas_.classList.add('fullres'); | |
587 | |
588 this.contentCache_.putItem(this.contentID_, this.contentCanvas_, true); | |
589 this.screenCache_.putItem(this.contentID_, this.screenImage_); | |
590 | |
591 // TODO(kaznacheev): It is better to pass screenImage_ as it is usually | |
592 // much smaller than contentCanvas_ and still contains the entire image. | |
593 // Once we implement zoom/pan we should pass contentCanvas_ instead. | |
594 this.updateThumbnail_(this.screenImage_); | |
595 | |
596 this.contentRevision_++; | |
597 for (var i = 0; i != this.contentCallbacks_.length; i++) { | |
598 try { | |
599 this.contentCallbacks_[i](); | |
600 } catch (e) { | |
601 console.error(e); | |
602 } | |
603 } | |
604 } | |
605 }; | |
606 | |
607 /** | |
608 * Add a listener for content changes. | |
609 * @param {function} callback Callback. | |
610 */ | |
611 ImageView.prototype.addContentCallback = function(callback) { | |
612 this.contentCallbacks_.push(callback); | |
613 }; | |
614 | |
615 /** | |
616 * Update the cached thumbnail image. | |
617 * | |
618 * @param {HTMLCanvasElement} canvas The source canvas. | |
619 * @private | |
620 */ | |
621 ImageView.prototype.updateThumbnail_ = function(canvas) { | |
622 ImageUtil.trace.resetTimer('thumb'); | |
623 var pixelCount = 10000; | |
624 var downScale = | |
625 Math.max(1, Math.sqrt(canvas.width * canvas.height / pixelCount)); | |
626 | |
627 this.thumbnailCanvas_ = canvas.ownerDocument.createElement('canvas'); | |
628 this.thumbnailCanvas_.width = Math.round(canvas.width / downScale); | |
629 this.thumbnailCanvas_.height = Math.round(canvas.height / downScale); | |
630 Rect.drawImage(this.thumbnailCanvas_.getContext('2d'), canvas); | |
631 ImageUtil.trace.reportTimer('thumb'); | |
632 }; | |
633 | |
634 /** | |
635 * Replace the displayed image, possibly with slide-in animation. | |
636 * | |
637 * @param {HTMLCanvasElement|HTMLVideoElement} content The image element. | |
638 * @param {Object=} opt_effect Transition effect object. | |
639 * @param {number=} opt_width Image width. | |
640 * @param {number=} opt_height Image height. | |
641 * @param {boolean=} opt_preview True if the image is a preview (not full res). | |
642 */ | |
643 ImageView.prototype.replace = function( | |
644 content, opt_effect, opt_width, opt_height, opt_preview) { | |
645 var oldScreenImage = this.screenImage_; | |
646 | |
647 this.replaceContent_(content, opt_width, opt_height, opt_preview); | |
648 if (!opt_effect) { | |
649 if (oldScreenImage) | |
650 oldScreenImage.parentNode.removeChild(oldScreenImage); | |
651 return; | |
652 } | |
653 | |
654 var newScreenImage = this.screenImage_; | |
655 | |
656 if (oldScreenImage) | |
657 ImageUtil.setAttribute(newScreenImage, 'fade', true); | |
658 this.setTransform(newScreenImage, opt_effect, 0 /* instant */); | |
659 | |
660 setTimeout(function() { | |
661 this.setTransform(newScreenImage, null, | |
662 opt_effect && opt_effect.getDuration()); | |
663 if (oldScreenImage) { | |
664 ImageUtil.setAttribute(newScreenImage, 'fade', false); | |
665 ImageUtil.setAttribute(oldScreenImage, 'fade', true); | |
666 console.assert(opt_effect.getReverse, 'Cannot revert an effect.'); | |
667 var reverse = opt_effect.getReverse(); | |
668 this.setTransform(oldScreenImage, reverse); | |
669 setTimeout(function() { | |
670 if (oldScreenImage.parentNode) | |
671 oldScreenImage.parentNode.removeChild(oldScreenImage); | |
672 }, reverse.getSafeInterval()); | |
673 } | |
674 }.bind(this), 0); | |
675 }; | |
676 | |
677 /** | |
678 * @param {HTMLCanvasElement|HTMLVideoElement} element The element to transform. | |
679 * @param {ImageView.Effect=} opt_effect The effect to apply. | |
680 * @param {number=} opt_duration Transition duration. | |
681 */ | |
682 ImageView.prototype.setTransform = function(element, opt_effect, opt_duration) { | |
683 if (!opt_effect) | |
684 opt_effect = new ImageView.Effect.None(); | |
685 if (typeof opt_duration != 'number') | |
686 opt_duration = opt_effect.getDuration(); | |
687 element.style.webkitTransitionDuration = opt_duration + 'ms'; | |
688 element.style.webkitTransitionTimingFunction = opt_effect.getTiming(); | |
689 element.style.webkitTransform = opt_effect.transform(element, this.viewport_); | |
690 }; | |
691 | |
692 /** | |
693 * @param {Rect} screenRect Target rectangle in screen coordinates. | |
694 * @return {ImageView.Effect.Zoom} Zoom effect object. | |
695 */ | |
696 ImageView.prototype.createZoomEffect = function(screenRect) { | |
697 return new ImageView.Effect.Zoom( | |
698 this.viewport_.screenToDeviceRect(screenRect), | |
699 null /* use viewport */, | |
700 ImageView.MODE_TRANSITION_DURATION); | |
701 }; | |
702 | |
703 /** | |
704 * Visualize crop or rotate operation. Hide the old image instantly, animate | |
705 * the new image to visualize the operation. | |
706 * | |
707 * @param {HTMLCanvasElement} canvas New content canvas. | |
708 * @param {Rect} imageCropRect The crop rectangle in image coordinates. | |
709 * Null for rotation operations. | |
710 * @param {number} rotate90 Rotation angle in 90 degree increments. | |
711 * @return {number} Animation duration. | |
712 */ | |
713 ImageView.prototype.replaceAndAnimate = function( | |
714 canvas, imageCropRect, rotate90) { | |
715 var oldScale = this.viewport_.getScale(); | |
716 var deviceCropRect = imageCropRect && this.viewport_.screenToDeviceRect( | |
717 this.viewport_.imageToScreenRect(imageCropRect)); | |
718 | |
719 var oldScreenImage = this.screenImage_; | |
720 this.replaceContent_(canvas); | |
721 var newScreenImage = this.screenImage_; | |
722 | |
723 // Display the new canvas, initially transformed. | |
724 var deviceFullRect = this.viewport_.getDeviceClipped(); | |
725 | |
726 var effect = rotate90 ? | |
727 new ImageView.Effect.Rotate( | |
728 oldScale / this.viewport_.getScale(), -rotate90) : | |
729 new ImageView.Effect.Zoom(deviceCropRect, deviceFullRect); | |
730 | |
731 this.setTransform(newScreenImage, effect, 0 /* instant */); | |
732 | |
733 oldScreenImage.parentNode.appendChild(newScreenImage); | |
734 oldScreenImage.parentNode.removeChild(oldScreenImage); | |
735 | |
736 // Let the layout fire, then animate back to non-transformed state. | |
737 setTimeout( | |
738 this.setTransform.bind( | |
739 this, newScreenImage, null, effect.getDuration()), | |
740 0); | |
741 | |
742 return effect.getSafeInterval(); | |
743 }; | |
744 | |
745 /** | |
746 * Visualize "undo crop". Shrink the current image to the given crop rectangle | |
747 * while fading in the new image. | |
748 * | |
749 * @param {HTMLCanvasElement} canvas New content canvas. | |
750 * @param {Rect} imageCropRect The crop rectangle in image coordinates. | |
751 * @return {number} Animation duration. | |
752 */ | |
753 ImageView.prototype.animateAndReplace = function(canvas, imageCropRect) { | |
754 var deviceFullRect = this.viewport_.getDeviceClipped(); | |
755 var oldScale = this.viewport_.getScale(); | |
756 | |
757 var oldScreenImage = this.screenImage_; | |
758 this.replaceContent_(canvas); | |
759 var newScreenImage = this.screenImage_; | |
760 | |
761 var deviceCropRect = this.viewport_.screenToDeviceRect( | |
762 this.viewport_.imageToScreenRect(imageCropRect)); | |
763 | |
764 var setFade = ImageUtil.setAttribute.bind(null, newScreenImage, 'fade'); | |
765 setFade(true); | |
766 oldScreenImage.parentNode.insertBefore(newScreenImage, oldScreenImage); | |
767 | |
768 var effect = new ImageView.Effect.Zoom(deviceCropRect, deviceFullRect); | |
769 // Animate to the transformed state. | |
770 this.setTransform(oldScreenImage, effect); | |
771 | |
772 setTimeout(setFade.bind(null, false), 0); | |
773 | |
774 setTimeout(function() { | |
775 if (oldScreenImage.parentNode) | |
776 oldScreenImage.parentNode.removeChild(oldScreenImage); | |
777 }, effect.getSafeInterval()); | |
778 | |
779 return effect.getSafeInterval(); | |
780 }; | |
781 | |
782 | |
783 /** | |
784 * Generic cache with a limited capacity and LRU eviction. | |
785 * | |
786 * @param {number} capacity Maximum number of cached item. | |
787 * @constructor | |
788 */ | |
789 ImageView.Cache = function(capacity) { | |
790 this.capacity_ = capacity; | |
791 this.map_ = {}; | |
792 this.order_ = []; | |
793 }; | |
794 | |
795 /** | |
796 * Fetch the item from the cache. | |
797 * | |
798 * @param {string} id The item ID. | |
799 * @return {Object} The cached item. | |
800 */ | |
801 ImageView.Cache.prototype.getItem = function(id) { return this.map_[id] }; | |
802 | |
803 /** | |
804 * Put the item into the cache. | |
805 * @param {string} id The item ID. | |
806 * @param {Object} item The item object. | |
807 * @param {boolean=} opt_keepLRU True if the LRU order should not be modified. | |
808 */ | |
809 ImageView.Cache.prototype.putItem = function(id, item, opt_keepLRU) { | |
810 var pos = this.order_.indexOf(id); | |
811 | |
812 if ((pos >= 0) != (id in this.map_)) | |
813 throw new Error('Inconsistent cache state'); | |
814 | |
815 if (id in this.map_) { | |
816 if (!opt_keepLRU) { | |
817 // Move to the end (most recently used). | |
818 this.order_.splice(pos, 1); | |
819 this.order_.push(id); | |
820 } | |
821 } else { | |
822 this.evictLRU(); | |
823 this.order_.push(id); | |
824 } | |
825 | |
826 if ((pos >= 0) && (item != this.map_[id])) | |
827 this.deleteItem_(this.map_[id]); | |
828 this.map_[id] = item; | |
829 | |
830 if (this.order_.length > this.capacity_) | |
831 throw new Error('Exceeded cache capacity'); | |
832 }; | |
833 | |
834 /** | |
835 * Evict the least recently used items. | |
836 */ | |
837 ImageView.Cache.prototype.evictLRU = function() { | |
838 if (this.order_.length == this.capacity_) { | |
839 var id = this.order_.shift(); | |
840 this.deleteItem_(this.map_[id]); | |
841 delete this.map_[id]; | |
842 } | |
843 }; | |
844 | |
845 /** | |
846 * Change the id of an entry. | |
847 * @param {string} oldId The old ID. | |
848 * @param {string} newId The new ID. | |
849 */ | |
850 ImageView.Cache.prototype.renameItem = function(oldId, newId) { | |
851 if (oldId == newId) | |
852 return; // No need to rename. | |
853 | |
854 var pos = this.order_.indexOf(oldId); | |
855 if (pos < 0) | |
856 return; // Not cached. | |
857 | |
858 this.order_[pos] = newId; | |
859 this.map_[newId] = this.map_[oldId]; | |
860 delete this.map_[oldId]; | |
861 }; | |
862 | |
863 /** | |
864 * Disposes an object. | |
865 * | |
866 * @param {Object} item The item object. | |
867 * @private | |
868 */ | |
869 ImageView.Cache.prototype.deleteItem_ = function(item) { | |
870 // Trick to reduce memory usage without waiting for gc. | |
871 if (item instanceof HTMLCanvasElement) { | |
872 // If the canvas is being used somewhere else (eg. displayed on the screen), | |
873 // it will be cleared. | |
874 item.width = 0; | |
875 item.height = 0; | |
876 } | |
877 }; | |
878 | |
879 /* Transition effects */ | |
880 | |
881 /** | |
882 * Base class for effects. | |
883 * | |
884 * @param {number} duration Duration in ms. | |
885 * @param {string=} opt_timing CSS transition timing function name. | |
886 * @constructor | |
887 */ | |
888 ImageView.Effect = function(duration, opt_timing) { | |
889 this.duration_ = duration; | |
890 this.timing_ = opt_timing || 'linear'; | |
891 }; | |
892 | |
893 /** | |
894 * | |
895 */ | |
896 ImageView.Effect.DEFAULT_DURATION = 180; | |
897 | |
898 /** | |
899 * | |
900 */ | |
901 ImageView.Effect.MARGIN = 100; | |
902 | |
903 /** | |
904 * @return {number} Effect duration in ms. | |
905 */ | |
906 ImageView.Effect.prototype.getDuration = function() { return this.duration_ }; | |
907 | |
908 /** | |
909 * @return {number} Delay in ms since the beginning of the animation after which | |
910 * it is safe to perform CPU-heavy operations without disrupting the animation. | |
911 */ | |
912 ImageView.Effect.prototype.getSafeInterval = function() { | |
913 return this.getDuration() + ImageView.Effect.MARGIN; | |
914 }; | |
915 | |
916 /** | |
917 * @return {string} CSS transition timing function name. | |
918 */ | |
919 ImageView.Effect.prototype.getTiming = function() { return this.timing_ }; | |
920 | |
921 /** | |
922 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. | |
923 * @return {number} Preferred pixel ration to use with this element. | |
924 * @private | |
925 */ | |
926 ImageView.Effect.getPixelRatio_ = function(element) { | |
927 if (element.constructor.name == 'HTMLCanvasElement') | |
928 return Viewport.getDevicePixelRatio(); | |
929 else | |
930 return 1; | |
931 }; | |
932 | |
933 /** | |
934 * Default effect. It is not a no-op as it needs to adjust a canvas scale | |
935 * for devicePixelRatio. | |
936 * | |
937 * @constructor | |
938 */ | |
939 ImageView.Effect.None = function() { | |
940 ImageView.Effect.call(this, 0); | |
941 }; | |
942 | |
943 /** | |
944 * Inherits from ImageView.Effect. | |
945 */ | |
946 ImageView.Effect.None.prototype = { __proto__: ImageView.Effect.prototype }; | |
947 | |
948 /** | |
949 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. | |
950 * @return {string} Transform string. | |
951 */ | |
952 ImageView.Effect.None.prototype.transform = function(element) { | |
953 var ratio = ImageView.Effect.getPixelRatio_(element); | |
954 return 'scale(' + (1 / ratio) + ')'; | |
955 }; | |
956 | |
957 /** | |
958 * Slide effect. | |
959 * | |
960 * @param {number} direction -1 for left, 1 for right. | |
961 * @param {boolean=} opt_slow True if slow (as in slideshow). | |
962 * @constructor | |
963 */ | |
964 ImageView.Effect.Slide = function Slide(direction, opt_slow) { | |
965 ImageView.Effect.call(this, | |
966 opt_slow ? 800 : ImageView.Effect.DEFAULT_DURATION, 'ease-in-out'); | |
967 this.direction_ = direction; | |
968 this.slow_ = opt_slow; | |
969 this.shift_ = opt_slow ? 100 : 40; | |
970 if (this.direction_ < 0) this.shift_ = -this.shift_; | |
971 }; | |
972 | |
973 /** | |
974 * Inherits from ImageView.Effect. | |
975 */ | |
976 ImageView.Effect.Slide.prototype = { __proto__: ImageView.Effect.prototype }; | |
977 | |
978 /** | |
979 * @return {ImageView.Effect.Slide} Reverse Slide effect. | |
980 */ | |
981 ImageView.Effect.Slide.prototype.getReverse = function() { | |
982 return new ImageView.Effect.Slide(-this.direction_, this.slow_); | |
983 }; | |
984 | |
985 /** | |
986 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. | |
987 * @return {string} Transform string. | |
988 */ | |
989 ImageView.Effect.Slide.prototype.transform = function(element) { | |
990 var ratio = ImageView.Effect.getPixelRatio_(element); | |
991 return 'scale(' + (1 / ratio) + ') translate(' + this.shift_ + 'px, 0px)'; | |
992 }; | |
993 | |
994 /** | |
995 * Zoom effect. | |
996 * | |
997 * Animates the original rectangle to the target rectangle. Both parameters | |
998 * should be given in device coordinates (accounting for devicePixelRatio). | |
999 * | |
1000 * @param {Rect} deviceTargetRect Target rectangle. | |
1001 * @param {Rect=} opt_deviceOriginalRect Original rectangle. If omitted, | |
1002 * the full viewport will be used at the time of |transform| call. | |
1003 * @param {number=} opt_duration Duration in ms. | |
1004 * @constructor | |
1005 */ | |
1006 ImageView.Effect.Zoom = function( | |
1007 deviceTargetRect, opt_deviceOriginalRect, opt_duration) { | |
1008 ImageView.Effect.call(this, | |
1009 opt_duration || ImageView.Effect.DEFAULT_DURATION); | |
1010 this.target_ = deviceTargetRect; | |
1011 this.original_ = opt_deviceOriginalRect; | |
1012 }; | |
1013 | |
1014 /** | |
1015 * Inherits from ImageView.Effect. | |
1016 */ | |
1017 ImageView.Effect.Zoom.prototype = { __proto__: ImageView.Effect.prototype }; | |
1018 | |
1019 /** | |
1020 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. | |
1021 * @param {Viewport} viewport Viewport. | |
1022 * @return {string} Transform string. | |
1023 */ | |
1024 ImageView.Effect.Zoom.prototype.transform = function(element, viewport) { | |
1025 if (!this.original_) | |
1026 this.original_ = viewport.getDeviceClipped(); | |
1027 | |
1028 var ratio = ImageView.Effect.getPixelRatio_(element); | |
1029 | |
1030 var dx = (this.target_.left + this.target_.width / 2) - | |
1031 (this.original_.left + this.original_.width / 2); | |
1032 var dy = (this.target_.top + this.target_.height / 2) - | |
1033 (this.original_.top + this.original_.height / 2); | |
1034 | |
1035 var scaleX = this.target_.width / this.original_.width; | |
1036 var scaleY = this.target_.height / this.original_.height; | |
1037 | |
1038 return 'translate(' + (dx / ratio) + 'px,' + (dy / ratio) + 'px) ' + | |
1039 'scaleX(' + (scaleX / ratio) + ') scaleY(' + (scaleY / ratio) + ')'; | |
1040 }; | |
1041 | |
1042 /** | |
1043 * Rotate effect. | |
1044 * | |
1045 * @param {number} scale Scale. | |
1046 * @param {number} rotate90 Rotation in 90 degrees increments. | |
1047 * @constructor | |
1048 */ | |
1049 ImageView.Effect.Rotate = function(scale, rotate90) { | |
1050 ImageView.Effect.call(this, ImageView.Effect.DEFAULT_DURATION); | |
1051 this.scale_ = scale; | |
1052 this.rotate90_ = rotate90; | |
1053 }; | |
1054 | |
1055 /** | |
1056 * Inherits from ImageView.Effect. | |
1057 */ | |
1058 ImageView.Effect.Rotate.prototype = { __proto__: ImageView.Effect.prototype }; | |
1059 | |
1060 /** | |
1061 * @param {HTMLCanvasElement|HTMLVideoElement} element Element. | |
1062 * @return {string} Transform string. | |
1063 */ | |
1064 ImageView.Effect.Rotate.prototype.transform = function(element) { | |
1065 var ratio = ImageView.Effect.getPixelRatio_(element); | |
1066 return 'rotate(' + (this.rotate90_ * 90) + 'deg) ' + | |
1067 'scale(' + (this.scale_ / ratio) + ')'; | |
1068 }; | |
OLD | NEW |