1 // Copyright 2014 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 /** 6 * Returns the area of the intersection of two rectangles. 7 * @param {Object} rect1 the first rect 8 * @param {Object} rect2 the second rect 9 * @return {number} the area of the intersection of the rects 10 */ 11 function getIntersectionArea(rect1, rect2) { 12 var xOverlap = Math.max(0, 13 Math.min(rect1.x + rect1.width, rect2.x + rect2.width) - 14 Math.max(rect1.x, rect2.x)); 15 var yOverlap = Math.max(0, 16 Math.min(rect1.y + rect1.height, rect2.y + rect2.height) - 17 Math.max(rect1.y, rect2.y)); 18 return xOverlap * yOverlap; 19 } 20 21 /** 22 * Create a new viewport. 23 * @param {Window} window the window 24 * @param {Object} sizer is the element which represents the size of the 25 * document in the viewport 26 * @param {Function} viewportChangedCallback is run when the viewport changes 27 * @param {Function} beforeZoomCallback is run before a change in zoom 28 * @param {Function} afterZoomCallback is run after a change in zoom 29 * @param {number} scrollbarWidth the width of scrollbars on the page 30 */ 31 function Viewport(window, 32 sizer, 33 viewportChangedCallback, 34 beforeZoomCallback, 35 afterZoomCallback, 36 scrollbarWidth) { 37 this.window_ = window; 38 this.sizer_ = sizer; 39 this.viewportChangedCallback_ = viewportChangedCallback; 40 this.beforeZoomCallback_ = beforeZoomCallback; 41 this.afterZoomCallback_ = afterZoomCallback; 42 this.allowedToChangeZoom_ = false; 43 this.zoom_ = 1; 44 this.documentDimensions_ = null; 45 this.pageDimensions_ = []; 46 this.scrollbarWidth_ = scrollbarWidth; 47 this.fittingType_ = Viewport.FittingType.NONE; 48 49 window.addEventListener('scroll', this.updateViewport_.bind(this)); 50 window.addEventListener('resize', this.resize_.bind(this)); 51 } 52 53 /** 54 * Enumeration of page fitting types. 55 * @enum {string} 56 */ 57 Viewport.FittingType = { 58 NONE: 'none', 59 FIT_TO_PAGE: 'fit-to-page', 60 FIT_TO_WIDTH: 'fit-to-width' 61 }; 62 63 /** 64 * The increment to scroll a page by in pixels when up/down/left/right arrow 65 * keys are pressed. Usually we just let the browser handle scrolling on the 66 * window when these keys are pressed but in certain cases we need to simulate 67 * these events. 68 */ 69 Viewport.SCROLL_INCREMENT = 40; 70 71 /** 72 * Predefined zoom factors to be used when zooming in/out. These are in 73 * ascending order. This should match the list in 74 * chrome/browser/chrome_page_zoom_constants.cc. 75 */ 76 Viewport.ZOOM_FACTORS = [0.25, 0.333, 0.5, 0.666, 0.75, 0.9, 1, 77 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5]; 78 79 /** 80 * The minimum and maximum range to be used to clip zoom factor. 81 */ 82 Viewport.ZOOM_FACTOR_RANGE = { 83 min: Viewport.ZOOM_FACTORS[0], 84 max: Viewport.ZOOM_FACTORS[Viewport.ZOOM_FACTORS.length - 1] 85 }; 86 87 /** 88 * The width of the page shadow around pages in pixels. 89 */ 90 Viewport.PAGE_SHADOW = {top: 3, bottom: 7, left: 5, right: 5}; 91 92 Viewport.prototype = { 93 /** 94 * @private 95 * Returns true if the document needs scrollbars at the given zoom level. 96 * @param {number} zoom compute whether scrollbars are needed at this zoom 97 * @return {Object} with 'horizontal' and 'vertical' keys which map to bool 98 * values indicating if the horizontal and vertical scrollbars are needed 99 * respectively. 100 */ 101 documentNeedsScrollbars_: function(zoom) { 102 if (!this.documentDimensions_) { 103 return { 104 horizontal: false, 105 vertical: false 106 }; 107 } 108 var documentWidth = this.documentDimensions_.width * zoom; 109 var documentHeight = this.documentDimensions_.height * zoom; 110 return { 111 horizontal: documentWidth > this.window_.innerWidth, 112 vertical: documentHeight > this.window_.innerHeight 113 }; 114 }, 115 116 /** 117 * Returns true if the document needs scrollbars at the current zoom level. 118 * @return {Object} with 'x' and 'y' keys which map to bool values 119 * indicating if the horizontal and vertical scrollbars are needed 120 * respectively. 121 */ 122 documentHasScrollbars: function() { 123 return this.documentNeedsScrollbars_(this.zoom_); 124 }, 125 126 /** 127 * @private 128 * Helper function called when the zoomed document size changes. 129 */ 130 contentSizeChanged_: function() { 131 if (this.documentDimensions_) { 132 this.sizer_.style.width = 133 this.documentDimensions_.width * this.zoom_ + 'px'; 134 this.sizer_.style.height = 135 this.documentDimensions_.height * this.zoom_ + 'px'; 136 } 137 }, 138 139 /** 140 * @private 141 * Called when the viewport should be updated. 142 */ 143 updateViewport_: function() { 144 this.viewportChangedCallback_(); 145 }, 146 147 /** 148 * @private 149 * Called when the viewport size changes. 150 */ 151 resize_: function() { 152 if (this.fittingType_ == Viewport.FittingType.FIT_TO_PAGE) 153 this.fitToPage(); 154 else if (this.fittingType_ == Viewport.FittingType.FIT_TO_WIDTH) 155 this.fitToWidth(); 156 else 157 this.updateViewport_(); 158 }, 159 160 /** 161 * @type {Object} the scroll position of the viewport. 162 */ 163 get position() { 164 return { 165 x: this.window_.pageXOffset, 166 y: this.window_.pageYOffset 167 }; 168 }, 169 170 /** 171 * Scroll the viewport to the specified position. 172 * @type {Object} position the position to scroll to. 173 */ 174 set position(position) { 175 this.window_.scrollTo(position.x, position.y); 176 }, 177 178 /** 179 * @type {Object} the size of the viewport excluding scrollbars. 180 */ 181 get size() { 182 var needsScrollbars = this.documentNeedsScrollbars_(this.zoom_); 183 var scrollbarWidth = needsScrollbars.vertical ? this.scrollbarWidth_ : 0; 184 var scrollbarHeight = needsScrollbars.horizontal ? this.scrollbarWidth_ : 0; 185 return { 186 width: this.window_.innerWidth - scrollbarWidth, 187 height: this.window_.innerHeight - scrollbarHeight 188 }; 189 }, 190 191 /** 192 * @type {number} the zoom level of the viewport. 193 */ 194 get zoom() { 195 return this.zoom_; 196 }, 197 198 /** 199 * @private 200 * Used to wrap a function that might perform zooming on the viewport. This is 201 * required so that we can notify the plugin that zooming is in progress 202 * so that while zooming is taking place it can stop reacting to scroll events 203 * from the viewport. This is to avoid flickering. 204 */ 205 mightZoom_: function(f) { 206 this.beforeZoomCallback_(); 207 this.allowedToChangeZoom_ = true; 208 f(); 209 this.allowedToChangeZoom_ = false; 210 this.afterZoomCallback_(); 211 }, 212 213 /** 214 * @private 215 * Sets the zoom of the viewport. 216 * @param {number} newZoom the zoom level to zoom to. 217 */ 218 setZoomInternal_: function(newZoom) { 219 if (!this.allowedToChangeZoom_) { 220 throw 'Called Viewport.setZoomInternal_ without calling ' + 221 'Viewport.mightZoom_.'; 222 } 223 var oldZoom = this.zoom_; 224 this.zoom_ = newZoom; 225 // Record the scroll position (relative to the middle of the window). 226 var currentScrollPos = [ 227 (this.window_.pageXOffset + this.window_.innerWidth / 2) / oldZoom, 228 (this.window_.pageYOffset + this.window_.innerHeight / 2) / oldZoom 229 ]; 230 this.contentSizeChanged_(); 231 // Scroll to the scaled scroll position. 232 this.window_.scrollTo( 233 currentScrollPos[0] * newZoom - this.window_.innerWidth / 2, 234 currentScrollPos[1] * newZoom - this.window_.innerHeight / 2); 235 }, 236 237 /** 238 * Sets the zoom to the given zoom level. 239 * @param {number} newZoom the zoom level to zoom to. 240 */ 241 setZoom: function(newZoom) { 242 newZoom = Math.max(Viewport.ZOOM_FACTOR_RANGE.min, 243 Math.min(newZoom, Viewport.ZOOM_FACTOR_RANGE.max)); 244 this.mightZoom_(function() { 245 this.setZoomInternal_(newZoom); 246 this.updateViewport_(); 247 }.bind(this)); 248 }, 249 250 /** 251 * @type {number} the width of scrollbars in the viewport in pixels. 252 */ 253 get scrollbarWidth() { 254 return this.scrollbarWidth_; 255 }, 256 257 /** 258 * @type {Viewport.FittingType} the fitting type the viewport is currently in. 259 */ 260 get fittingType() { 261 return this.fittingType_; 262 }, 263 264 /** 265 * @private 266 * @param {integer} y the y-coordinate to get the page at. 267 * @return {integer} the index of a page overlapping the given y-coordinate. 268 */ 269 getPageAtY_: function(y) { 270 var min = 0; 271 var max = this.pageDimensions_.length - 1; 272 while (max >= min) { 273 var page = Math.floor(min + ((max - min) / 2)); 274 // There might be a gap between the pages, in which case use the bottom 275 // of the previous page as the top for finding the page. 276 var top = 0; 277 if (page > 0) { 278 top = this.pageDimensions_[page - 1].y + 279 this.pageDimensions_[page - 1].height; 280 } 281 var bottom = this.pageDimensions_[page].y + 282 this.pageDimensions_[page].height; 283 284 if (top <= y && bottom > y) 285 return page; 286 else if (top > y) 287 max = page - 1; 288 else 289 min = page + 1; 290 } 291 return 0; 292 }, 293 294 /** 295 * Returns the page with the most pixels in the current viewport. 296 * @return {int} the index of the most visible page. 297 */ 298 getMostVisiblePage: function() { 299 var firstVisiblePage = this.getPageAtY_(this.position.y / this.zoom_); 300 var mostVisiblePage = {number: 0, area: 0}; 301 var viewportRect = { 302 x: this.position.x / this.zoom_, 303 y: this.position.y / this.zoom_, 304 width: this.size.width / this.zoom_, 305 height: this.size.height / this.zoom_ 306 }; 307 for (var i = firstVisiblePage; i < this.pageDimensions_.length; i++) { 308 var area = getIntersectionArea(this.pageDimensions_[i], 309 viewportRect); 310 // If we hit a page with 0 area overlap, we must have gone past the 311 // pages visible in the viewport so we can break. 312 if (area == 0) 313 break; 314 if (area > mostVisiblePage.area) { 315 mostVisiblePage.area = area; 316 mostVisiblePage.number = i; 317 } 318 } 319 return mostVisiblePage.number; 320 }, 321 322 /** 323 * @private 324 * Compute the zoom level for fit-to-page or fit-to-width. |pageDimensions| is 325 * the dimensions for a given page and if |widthOnly| is true, it indicates 326 * that fit-to-page zoom should be computed rather than fit-to-page. 327 * @param {Object} pageDimensions the dimensions of a given page 328 * @param {boolean} widthOnly a bool indicating whether fit-to-page or 329 * fit-to-width should be computed. 330 * @return {number} the zoom to use 331 */ 332 computeFittingZoom_: function(pageDimensions, widthOnly) { 333 // First compute the zoom without scrollbars. 334 var zoomWidth = this.window_.innerWidth / pageDimensions.width; 335 var zoom; 336 if (widthOnly) { 337 zoom = zoomWidth; 338 } else { 339 var zoomHeight = this.window_.innerHeight / pageDimensions.height; 340 zoom = Math.min(zoomWidth, zoomHeight); 341 } 342 // Check if there needs to be any scrollbars. 343 var needsScrollbars = this.documentNeedsScrollbars_(zoom); 344 345 // If the document fits, just return the zoom. 346 if (!needsScrollbars.horizontal && !needsScrollbars.vertical) 347 return zoom; 348 349 var zoomedDimensions = { 350 width: this.documentDimensions_.width * zoom, 351 height: this.documentDimensions_.height * zoom 352 }; 353 354 // Check if adding a scrollbar will result in needing the other scrollbar. 355 var scrollbarWidth = this.scrollbarWidth_; 356 if (needsScrollbars.horizontal && 357 zoomedDimensions.height > this.window_.innerHeight - scrollbarWidth) { 358 needsScrollbars.vertical = true; 359 } 360 if (needsScrollbars.vertical && 361 zoomedDimensions.width > this.window_.innerWidth - scrollbarWidth) { 362 needsScrollbars.horizontal = true; 363 } 364 365 // Compute available window space. 366 var windowWithScrollbars = { 367 width: this.window_.innerWidth, 368 height: this.window_.innerHeight 369 }; 370 if (needsScrollbars.horizontal) 371 windowWithScrollbars.height -= scrollbarWidth; 372 if (needsScrollbars.vertical) 373 windowWithScrollbars.width -= scrollbarWidth; 374 375 // Recompute the zoom. 376 zoomWidth = windowWithScrollbars.width / pageDimensions.width; 377 if (widthOnly) { 378 zoom = zoomWidth; 379 } else { 380 var zoomHeight = windowWithScrollbars.height / pageDimensions.height; 381 zoom = Math.min(zoomWidth, zoomHeight); 382 } 383 return zoom; 384 }, 385 386 /** 387 * Zoom the viewport so that the page-width consumes the entire viewport. 388 */ 389 fitToWidth: function() { 390 this.mightZoom_(function() { 391 this.fittingType_ = Viewport.FittingType.FIT_TO_WIDTH; 392 if (!this.documentDimensions_) 393 return; 394 // Track the last y-position to stay at the same position after zooming. 395 var oldY = this.window_.pageYOffset / this.zoom_; 396 // When computing fit-to-width, the maximum width of a page in the 397 // document is used, which is equal to the size of the document width. 398 this.setZoomInternal_(this.computeFittingZoom_(this.documentDimensions_, 399 true)); 400 var page = this.getMostVisiblePage(); 401 this.window_.scrollTo(0, oldY * this.zoom_); 402 this.updateViewport_(); 403 }.bind(this)); 404 }, 405 406 /** 407 * Zoom the viewport so that a page consumes the entire viewport. Also scrolls 408 * to the top of the most visible page. 409 */ 410 fitToPage: function() { 411 this.mightZoom_(function() { 412 this.fittingType_ = Viewport.FittingType.FIT_TO_PAGE; 413 if (!this.documentDimensions_) 414 return; 415 var page = this.getMostVisiblePage(); 416 this.setZoomInternal_(this.computeFittingZoom_( 417 this.pageDimensions_[page], false)); 418 // Center the document in the page by scrolling by the amount of empty 419 // space to the left of the document. 420 var xOffset = 421 (this.documentDimensions_.width - this.pageDimensions_[page].width) * 422 this.zoom_ / 2; 423 this.window_.scrollTo(xOffset, 424 this.pageDimensions_[page].y * this.zoom_); 425 this.updateViewport_(); 426 }.bind(this)); 427 }, 428 429 /** 430 * Zoom out to the next predefined zoom level. 431 */ 432 zoomOut: function() { 433 this.mightZoom_(function() { 434 this.fittingType_ = Viewport.FittingType.NONE; 435 var nextZoom = Viewport.ZOOM_FACTORS[0]; 436 for (var i = 0; i < Viewport.ZOOM_FACTORS.length; i++) { 437 if (Viewport.ZOOM_FACTORS[i] < this.zoom_) 438 nextZoom = Viewport.ZOOM_FACTORS[i]; 439 } 440 this.setZoomInternal_(nextZoom); 441 this.updateViewport_(); 442 }.bind(this)); 443 }, 444 445 /** 446 * Zoom in to the next predefined zoom level. 447 */ 448 zoomIn: function() { 449 this.mightZoom_(function() { 450 this.fittingType_ = Viewport.FittingType.NONE; 451 var nextZoom = Viewport.ZOOM_FACTORS[Viewport.ZOOM_FACTORS.length - 1]; 452 for (var i = Viewport.ZOOM_FACTORS.length - 1; i >= 0; i--) { 453 if (Viewport.ZOOM_FACTORS[i] > this.zoom_) 454 nextZoom = Viewport.ZOOM_FACTORS[i]; 455 } 456 this.setZoomInternal_(nextZoom); 457 this.updateViewport_(); 458 }.bind(this)); 459 }, 460 461 /** 462 * Go to the given page index. 463 * @param {number} page the index of the page to go to. zero-based. 464 */ 465 goToPage: function(page) { 466 this.mightZoom_(function() { 467 if (this.pageDimensions_.length == 0) 468 return; 469 if (page < 0) 470 page = 0; 471 if (page >= this.pageDimensions_.length) 472 page = this.pageDimensions_.length - 1; 473 var dimensions = this.pageDimensions_[page]; 474 this.window_.scrollTo(dimensions.x * this.zoom_, 475 dimensions.y * this.zoom_); 476 this.updateViewport_(); 477 }.bind(this)); 478 }, 479 480 /** 481 * Set the dimensions of the document. 482 * @param {Object} documentDimensions the dimensions of the document 483 */ 484 setDocumentDimensions: function(documentDimensions) { 485 this.mightZoom_(function() { 486 var initialDimensions = !this.documentDimensions_; 487 this.documentDimensions_ = documentDimensions; 488 this.pageDimensions_ = this.documentDimensions_.pageDimensions; 489 if (initialDimensions) { 490 this.setZoomInternal_(this.computeFittingZoom_(this.documentDimensions_, 491 true)); 492 if (this.zoom_ > 1) 493 this.setZoomInternal_(1); 494 this.window_.scrollTo(0, 0); 495 } 496 this.contentSizeChanged_(); 497 this.resize_(); 498 }.bind(this)); 499 }, 500 501 /** 502 * Get the coordinates of the page contents (excluding the page shadow) 503 * relative to the screen. 504 * @param {number} page the index of the page to get the rect for. 505 * @return {Object} a rect representing the page in screen coordinates. 506 */ 507 getPageScreenRect: function(page) { 508 if (!this.documentDimensions_) { 509 return { 510 x: 0, 511 y: 0, 512 width: 0, 513 height: 0 514 }; 515 } 516 if (page >= this.pageDimensions_.length) 517 page = this.pageDimensions_.length - 1; 518 519 var pageDimensions = this.pageDimensions_[page]; 520 521 // Compute the page dimensions minus the shadows. 522 var insetDimensions = { 523 x: pageDimensions.x + Viewport.PAGE_SHADOW.left, 524 y: pageDimensions.y + Viewport.PAGE_SHADOW.top, 525 width: pageDimensions.width - Viewport.PAGE_SHADOW.left - 526 Viewport.PAGE_SHADOW.right, 527 height: pageDimensions.height - Viewport.PAGE_SHADOW.top - 528 Viewport.PAGE_SHADOW.bottom 529 }; 530 531 // Compute the x-coordinate of the page within the document. 532 // TODO(raymes): This should really be set when the PDF plugin passes the 533 // page coordinates, but it isn't yet. 534 var x = (this.documentDimensions_.width - pageDimensions.width) / 2 + 535 Viewport.PAGE_SHADOW.left; 536 // Compute the space on the left of the document if the document fits 537 // completely in the screen. 538 var spaceOnLeft = (this.size.width - 539 this.documentDimensions_.width * this.zoom_) / 2; 540 spaceOnLeft = Math.max(spaceOnLeft, 0); 541 542 return { 543 x: x * this.zoom_ + spaceOnLeft - this.window_.pageXOffset, 544 y: insetDimensions.y * this.zoom_ - this.window_.pageYOffset, 545 width: insetDimensions.width * this.zoom_, 546 height: insetDimensions.height * this.zoom_ 547 }; 548 } 549 }; 550