| 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 |