• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 */
11function 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 */
31function 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 */
57Viewport.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 */
69Viewport.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 */
76Viewport.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 */
82Viewport.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 */
90Viewport.PAGE_SHADOW = {top: 3, bottom: 7, left: 5, right: 5};
91
92Viewport.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