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