[email protected] | 3528d630 | 2014-02-19 08:13:07 | [diff] [blame^] | 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 | * Predefined zoom factors to be used when zooming in/out. These are in |
| 7 | * ascending order. |
| 8 | */ |
| 9 | var ZOOM_FACTORS = [0.25, 0.333, 0.5, 0.666, 0.75, 0.9, 1, |
| 10 | 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4, 5]; |
| 11 | |
| 12 | /** |
| 13 | * Returns the area of the intersection of two rectangles. |
| 14 | * @param {Object} rect1 the first rect |
| 15 | * @param {Object} rect2 the second rect |
| 16 | * @return {number} the area of the intersection of the rects |
| 17 | */ |
| 18 | function getIntersectionArea(rect1, rect2) { |
| 19 | var xOverlap = Math.max(0, |
| 20 | Math.min(rect1.x + rect1.width, rect2.x + rect2.width) - |
| 21 | Math.max(rect1.x, rect2.x)); |
| 22 | var yOverlap = Math.max(0, |
| 23 | Math.min(rect1.y + rect1.height, rect2.y + rect2.height) - |
| 24 | Math.max(rect1.y, rect2.y)); |
| 25 | return xOverlap * yOverlap; |
| 26 | } |
| 27 | |
| 28 | /** |
| 29 | * @return {number} width of a scrollbar in pixels |
| 30 | */ |
| 31 | function getScrollbarWidth() { |
| 32 | var parentDiv = document.createElement('div'); |
| 33 | parentDiv.style.visibility = 'hidden'; |
| 34 | var parentDivWidth = 500; |
| 35 | parentDiv.style.width = parentDivWidth + 'px'; |
| 36 | document.body.appendChild(parentDiv); |
| 37 | parentDiv.style.overflow = 'scroll'; |
| 38 | var childDiv = document.createElement('div'); |
| 39 | childDiv.style.width = '100%'; |
| 40 | parentDiv.appendChild(childDiv); |
| 41 | var childDivWidth = childDiv.offsetWidth; |
| 42 | parentDiv.parentNode.removeChild(parentDiv); |
| 43 | return parentDivWidth - childDivWidth; |
| 44 | } |
| 45 | |
| 46 | /** |
| 47 | * Create a new viewport. |
| 48 | * @param {Window} window the window |
| 49 | * @param {Object} sizer is the element which represents the size of the |
| 50 | * document in the viewport |
| 51 | * @param {Function} fitToPageEnabledFunction returns true if fit-to-page is |
| 52 | * enabled |
| 53 | * @param {Function} viewportChangedCallback is run when the viewport changes |
| 54 | */ |
| 55 | function Viewport(window, |
| 56 | sizer, |
| 57 | fitToPageEnabledFunction, |
| 58 | viewportChangedCallback) { |
| 59 | this.window_ = window; |
| 60 | this.sizer_ = sizer; |
| 61 | this.fitToPageEnabledFunction_ = fitToPageEnabledFunction; |
| 62 | this.viewportChangedCallback_ = viewportChangedCallback; |
| 63 | this.zoom_ = 1; |
| 64 | this.documentDimensions_ = {}; |
| 65 | this.pageDimensions_ = []; |
| 66 | this.scrollbarWidth_ = getScrollbarWidth(); |
| 67 | |
| 68 | window.addEventListener('scroll', this.updateViewport_.bind(this)); |
| 69 | } |
| 70 | |
| 71 | Viewport.prototype = { |
| 72 | /** |
| 73 | * @private |
| 74 | * Returns true if the document needs scrollbars at the given zoom level. |
| 75 | * @param {number} zoom compute whether scrollbars are needed at this zoom |
| 76 | * @return {Object} with 'x' and 'y' keys which map to bool values |
| 77 | * indicating if the horizontal and vertical scrollbars are needed |
| 78 | * respectively. |
| 79 | */ |
| 80 | documentNeedsScrollbars_: function(zoom) { |
| 81 | return { |
| 82 | x: this.documentDimensions_.width * zoom > this.window_.innerWidth, |
| 83 | y: this.documentDimensions_.height * zoom > this.window_.innerHeight |
| 84 | }; |
| 85 | }, |
| 86 | |
| 87 | /** |
| 88 | * Returns true if the document needs scrollbars at the current zoom level. |
| 89 | * @return {Object} with 'x' and 'y' keys which map to bool values |
| 90 | * indicating if the horizontal and vertical scrollbars are needed |
| 91 | * respectively. |
| 92 | */ |
| 93 | documentHasScrollbars: function() { |
| 94 | return this.documentNeedsScrollbars_(this.zoom_); |
| 95 | }, |
| 96 | |
| 97 | /** |
| 98 | * @private |
| 99 | * Helper function called when the zoomed document size changes. |
| 100 | */ |
| 101 | contentSizeChanged_: function() { |
| 102 | this.sizer_.style.width = |
| 103 | this.documentDimensions_.width * this.zoom_ + 'px'; |
| 104 | this.sizer_.style.height = |
| 105 | this.documentDimensions_.height * this.zoom_ + 'px'; |
| 106 | }, |
| 107 | |
| 108 | /** |
| 109 | * Sets the zoom of the viewport. |
| 110 | * @param {number} newZoom the zoom level to zoom to |
| 111 | */ |
| 112 | setZoom: function(newZoom) { |
| 113 | var oldZoom = this.zoom_; |
| 114 | this.zoom_ = newZoom; |
| 115 | // Record the scroll position (relative to the middle of the window). |
| 116 | var currentScrollPos = [ |
| 117 | (this.window_.scrollX + this.window_.innerWidth / 2) / oldZoom, |
| 118 | (this.window_.scrollY + this.window_.innerHeight / 2) / oldZoom |
| 119 | ]; |
| 120 | this.contentSizeChanged_(); |
| 121 | // Scroll to the scaled scroll position. |
| 122 | this.window_.scrollTo( |
| 123 | currentScrollPos[0] * newZoom - this.window_.innerWidth / 2, |
| 124 | currentScrollPos[1] * newZoom - this.window_.innerHeight / 2); |
| 125 | }, |
| 126 | |
| 127 | /** |
| 128 | * @private |
| 129 | * Called when the viewport should be updated. |
| 130 | */ |
| 131 | updateViewport_: function() { |
| 132 | // Shift the toolbar so that it doesn't move when the scrollbars display |
| 133 | var needsScrollbars = this.documentHasScrollbars(); |
| 134 | this.viewportChangedCallback_(this.zoom_, |
| 135 | this.window_.pageXOffset, |
| 136 | this.window_.pageYOffset, |
| 137 | this.scrollbarWidth_, |
| 138 | needsScrollbars); |
| 139 | }, |
| 140 | |
| 141 | /** |
| 142 | * @private |
| 143 | * Returns a rect representing the current viewport. |
| 144 | * @return {Object} a rect representing the current viewport. |
| 145 | */ |
| 146 | getCurrentViewportRect_: function() { |
| 147 | return { |
| 148 | x: this.window_.pageXOffset / this.zoom_, |
| 149 | y: this.window_.pageYOffset / this.zoom_, |
| 150 | width: this.window_.innerWidth / this.zoom_, |
| 151 | height: this.window_.innerHeight / this.zoom_, |
| 152 | }; |
| 153 | }, |
| 154 | |
| 155 | /** |
| 156 | * Returns the page with the most pixels in the current viewport. |
| 157 | * @return {int} the index of the most visible page. |
| 158 | */ |
| 159 | getMostVisiblePage: function() { |
| 160 | // TODO(raymes): Do a binary search here. |
| 161 | var mostVisiblePage = {'number': 0, 'area': 0}; |
| 162 | for (var i = 0; i < this.pageDimensions_.length; i++) { |
| 163 | var area = getIntersectionArea(this.pageDimensions_[i], |
| 164 | this.getCurrentViewportRect_()); |
| 165 | if (area > mostVisiblePage.area) { |
| 166 | mostVisiblePage.area = area; |
| 167 | mostVisiblePage.number = i; |
| 168 | } |
| 169 | } |
| 170 | return mostVisiblePage.number; |
| 171 | }, |
| 172 | |
| 173 | /** |
| 174 | * @private |
| 175 | * Compute the zoom level for fit-to-page or fit-to-width. |pageDimensions| is |
| 176 | * the dimensions for a given page and if |widthOnly| is true, it indicates |
| 177 | * that fit-to-page zoom should be computed rather than fit-to-page. |
| 178 | * @param {Object} pageDimensions the dimensions of a given page |
| 179 | * @param {boolean} widthOnly a bool indicating whether fit-to-page or |
| 180 | * fit-to-width should be computed. |
| 181 | * @return {number} the zoom to use |
| 182 | */ |
| 183 | computeFittingZoom_: function(pageDimensions, widthOnly) { |
| 184 | // First compute the zoom without scrollbars. |
| 185 | var zoomWidth = this.window_.innerWidth / pageDimensions.width; |
| 186 | var zoom; |
| 187 | if (widthOnly) { |
| 188 | zoom = zoomWidth; |
| 189 | } else { |
| 190 | var zoomHeight = this.window_.innerHeight / pageDimensions.height; |
| 191 | zoom = Math.min(zoomWidth, zoomHeight); |
| 192 | } |
| 193 | // Check if there needs to be any scrollbars. |
| 194 | var needsScrollbars = this.documentNeedsScrollbars_(zoom); |
| 195 | |
| 196 | // If the document fits, just return the zoom. |
| 197 | if (!needsScrollbars.x && !needsScrollbars.y) |
| 198 | return zoom; |
| 199 | |
| 200 | var zoomedDimensions = { |
| 201 | width: this.documentDimensions_.width * zoom, |
| 202 | height: this.documentDimensions_.height * zoom |
| 203 | }; |
| 204 | |
| 205 | // Check if adding a scrollbar will result in needing the other scrollbar. |
| 206 | var scrollbarWidth = this.scrollbarWidth_; |
| 207 | if (needsScrollbars.x && |
| 208 | zoomedDimensions.height > this.window_.innerHeight - scrollbarWidth) { |
| 209 | needsScrollbars.y = true; |
| 210 | } |
| 211 | if (needsScrollbars.y && |
| 212 | zoomedDimensions.width > this.window_.innerWidth - scrollbarWidth) { |
| 213 | needsScrollbars.x = true; |
| 214 | } |
| 215 | |
| 216 | // Compute available window space. |
| 217 | var windowWithScrollbars = { |
| 218 | width: this.window_.innerWidth, |
| 219 | height: this.window_.innerHeight |
| 220 | }; |
| 221 | if (needsScrollbars.x) |
| 222 | windowWithScrollbars.height -= scrollbarWidth; |
| 223 | if (needsScrollbars.y) |
| 224 | windowWithScrollbars.width -= scrollbarWidth; |
| 225 | |
| 226 | // Recompute the zoom. |
| 227 | zoomWidth = windowWithScrollbars.width / pageDimensions.width; |
| 228 | if (widthOnly) { |
| 229 | zoom = zoomWidth; |
| 230 | } else { |
| 231 | var zoomHeight = windowWithScrollbars.height / pageDimensions.height; |
| 232 | zoom = Math.min(zoomWidth, zoomHeight); |
| 233 | } |
| 234 | return zoom; |
| 235 | }, |
| 236 | |
| 237 | /** |
| 238 | * Zoom the viewport so that the page-width consumes the entire viewport. |
| 239 | */ |
| 240 | fitToWidth: function() { |
| 241 | var page = this.getMostVisiblePage(); |
| 242 | this.setZoom(this.computeFittingZoom_(this.pageDimensions_[page], true)); |
| 243 | this.window_.scrollTo(this.pageDimensions_[page].x * this.zoom_, |
| 244 | this.window_.scrollY); |
| 245 | this.updateViewport_(); |
| 246 | }, |
| 247 | |
| 248 | /** |
| 249 | * Zoom the viewport so that a page consumes the entire viewport. Also scrolls |
| 250 | * to the top of the most visible page. |
| 251 | */ |
| 252 | fitToPage: function() { |
| 253 | var page = this.getMostVisiblePage(); |
| 254 | this.setZoom(this.computeFittingZoom_(this.pageDimensions_[page], false)); |
| 255 | this.window_.scrollTo(this.pageDimensions_[page].x * this.zoom_, |
| 256 | this.pageDimensions_[page].y * this.zoom_); |
| 257 | this.updateViewport_(); |
| 258 | }, |
| 259 | |
| 260 | /** |
| 261 | * Zoom out to the next predefined zoom level. |
| 262 | */ |
| 263 | zoomOut: function() { |
| 264 | var nextZoom = ZOOM_FACTORS[0]; |
| 265 | for (var i = 0; i < ZOOM_FACTORS.length; i++) { |
| 266 | if (ZOOM_FACTORS[i] < this.zoom_) |
| 267 | nextZoom = ZOOM_FACTORS[i]; |
| 268 | } |
| 269 | this.setZoom(nextZoom); |
| 270 | this.updateViewport_(); |
| 271 | }, |
| 272 | |
| 273 | /** |
| 274 | * Zoom in to the next predefined zoom level. |
| 275 | */ |
| 276 | zoomIn: function() { |
| 277 | var nextZoom = ZOOM_FACTORS[ZOOM_FACTORS.length - 1]; |
| 278 | for (var i = ZOOM_FACTORS.length - 1; i >= 0; i--) { |
| 279 | if (ZOOM_FACTORS[i] > this.zoom_) |
| 280 | nextZoom = ZOOM_FACTORS[i]; |
| 281 | } |
| 282 | this.setZoom(nextZoom); |
| 283 | this.updateViewport_(); |
| 284 | }, |
| 285 | |
| 286 | /** |
| 287 | * Go to the given page index. |
| 288 | * @param {number} page the index of the page to go to |
| 289 | */ |
| 290 | goToPage: function(page) { |
| 291 | if (page < 0) |
| 292 | page = 0; |
| 293 | var dimensions = this.pageDimensions_[page]; |
| 294 | this.window_.scrollTo(dimensions.x * this.zoom_, dimensions.y * this.zoom_); |
| 295 | }, |
| 296 | |
| 297 | /** |
| 298 | * Set the dimensions of the document. |
| 299 | * @param {Object} documentDimensions the dimensions of the document |
| 300 | */ |
| 301 | setDocumentDimensions: function(documentDimensions) { |
| 302 | this.documentDimensions_ = documentDimensions; |
| 303 | this.pageDimensions_ = this.documentDimensions_.pageDimensions; |
| 304 | this.contentSizeChanged_(); |
| 305 | this.updateViewport_(); |
| 306 | } |
| 307 | }; |