Home | History | Annotate | Download | only in pdf
      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