1// Copyright 2013 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'use strict';
6
7/**
8 * @return {number} Width of a scrollbar in pixels
9 */
10function getScrollbarWidth() {
11  var div = document.createElement('div');
12  div.style.visibility = 'hidden';
13  div.style.overflow = 'scroll';
14  div.style.width = '50px';
15  div.style.height = '50px';
16  div.style.position = 'absolute';
17  document.body.appendChild(div);
18  var result = div.offsetWidth - div.clientWidth;
19  div.parentNode.removeChild(div);
20  return result;
21}
22
23/**
24 * The minimum number of pixels to offset the toolbar by from the bottom and
25 * right side of the screen.
26 */
27PDFViewer.MIN_TOOLBAR_OFFSET = 15;
28
29/**
30 * Creates a new PDFViewer. There should only be one of these objects per
31 * document.
32 * @param {Object} streamDetails The stream object which points to the data
33 *     contained in the PDF.
34 */
35function PDFViewer(streamDetails) {
36  this.streamDetails = streamDetails;
37  this.loaded = false;
38
39  // The sizer element is placed behind the plugin element to cause scrollbars
40  // to be displayed in the window. It is sized according to the document size
41  // of the pdf and zoom level.
42  this.sizer_ = $('sizer');
43  this.toolbar_ = $('toolbar');
44  this.pageIndicator_ = $('page-indicator');
45  this.progressBar_ = $('progress-bar');
46  this.passwordScreen_ = $('password-screen');
47  this.passwordScreen_.addEventListener('password-submitted',
48                                        this.onPasswordSubmitted_.bind(this));
49  this.errorScreen_ = $('error-screen');
50
51  // Create the viewport.
52  this.viewport_ = new Viewport(window,
53                                this.sizer_,
54                                this.viewportChanged_.bind(this),
55                                this.beforeZoom_.bind(this),
56                                this.afterZoom_.bind(this),
57                                getScrollbarWidth());
58
59  // Create the plugin object dynamically so we can set its src. The plugin
60  // element is sized to fill the entire window and is set to be fixed
61  // positioning, acting as a viewport. The plugin renders into this viewport
62  // according to the scroll position of the window.
63  this.plugin_ = document.createElement('object');
64  // NOTE: The plugin's 'id' field must be set to 'plugin' since
65  // chrome/renderer/printing/print_web_view_helper.cc actually references it.
66  this.plugin_.id = 'plugin';
67  this.plugin_.type = 'application/x-google-chrome-pdf';
68  this.plugin_.addEventListener('message', this.handlePluginMessage_.bind(this),
69                                false);
70
71  // Handle scripting messages from outside the extension that wish to interact
72  // with it. We also send a message indicating that extension has loaded and
73  // is ready to receive messages.
74  window.addEventListener('message', this.handleScriptingMessage_.bind(this),
75                          false);
76  this.sendScriptingMessage_({type: 'readyToReceive'});
77
78  this.plugin_.setAttribute('src', this.streamDetails.originalUrl);
79  this.plugin_.setAttribute('stream-url', this.streamDetails.streamUrl);
80  var headers = '';
81  for (var header in this.streamDetails.responseHeaders) {
82    headers += header + ': ' +
83        this.streamDetails.responseHeaders[header] + '\n';
84  }
85  this.plugin_.setAttribute('headers', headers);
86
87  if (window.top == window)
88    this.plugin_.setAttribute('full-frame', '');
89  document.body.appendChild(this.plugin_);
90
91  // TODO(raymes): Remove this spurious message once crbug.com/388606 is fixed.
92  // This is a hack to initialize pepper sync scripting and avoid re-entrancy.
93  this.plugin_.postMessage({
94    type: 'viewport',
95    zoom: 1,
96    xOffset: 0,
97    yOffset: 0
98  });
99
100  // Setup the button event listeners.
101  $('fit-to-width-button').addEventListener('click',
102      this.viewport_.fitToWidth.bind(this.viewport_));
103  $('fit-to-page-button').addEventListener('click',
104      this.viewport_.fitToPage.bind(this.viewport_));
105  $('zoom-in-button').addEventListener('click',
106      this.viewport_.zoomIn.bind(this.viewport_));
107  $('zoom-out-button').addEventListener('click',
108      this.viewport_.zoomOut.bind(this.viewport_));
109  $('save-button-link').href = this.streamDetails.originalUrl;
110  $('print-button').addEventListener('click', this.print_.bind(this));
111
112  // Setup the keyboard event listener.
113  document.onkeydown = this.handleKeyEvent_.bind(this);
114
115  // Set up the zoom API.
116  if (chrome.tabs) {
117    chrome.tabs.setZoomSettings({mode: 'manual', scope: 'per-tab'},
118                                this.afterZoom_.bind(this));
119    chrome.tabs.onZoomChange.addListener(function(zoomChangeInfo) {
120      // If the zoom level is close enough to the current zoom level, don't
121      // change it. This avoids us getting into an infinite loop of zoom changes
122      // due to floating point error.
123      var MIN_ZOOM_DELTA = 0.01;
124      var zoomDelta = Math.abs(this.viewport_.zoom -
125                               zoomChangeInfo.newZoomFactor);
126      // We should not change zoom level when we are responsible for initiating
127      // the zoom. onZoomChange() is called before setZoomComplete() callback
128      // when we initiate the zoom.
129      if ((zoomDelta > MIN_ZOOM_DELTA) && !this.setZoomInProgress_)
130        this.viewport_.setZoom(zoomChangeInfo.newZoomFactor);
131    }.bind(this));
132  }
133
134  // Parse open pdf parameters.
135  var paramsParser = new OpenPDFParamsParser(this.streamDetails.originalUrl);
136  this.urlParams_ = paramsParser.urlParams;
137}
138
139PDFViewer.prototype = {
140  /**
141   * @private
142   * Handle key events. These may come from the user directly or via the
143   * scripting API.
144   * @param {KeyboardEvent} e the event to handle.
145   */
146  handleKeyEvent_: function(e) {
147    var position = this.viewport_.position;
148    // Certain scroll events may be sent from outside of the extension.
149    var fromScriptingAPI = e.type == 'scriptingKeypress';
150
151    var pageUpHandler = function() {
152      // Go to the previous page if we are fit-to-page.
153      if (this.viewport_.fittingType == Viewport.FittingType.FIT_TO_PAGE) {
154        this.viewport_.goToPage(this.viewport_.getMostVisiblePage() - 1);
155        // Since we do the movement of the page.
156        e.preventDefault();
157      } else if (fromScriptingAPI) {
158        position.y -= this.viewport.size.height;
159        this.viewport.position = position;
160      }
161    }.bind(this);
162    var pageDownHandler = function() {
163      // Go to the next page if we are fit-to-page.
164      if (this.viewport_.fittingType == Viewport.FittingType.FIT_TO_PAGE) {
165        this.viewport_.goToPage(this.viewport_.getMostVisiblePage() + 1);
166        // Since we do the movement of the page.
167        e.preventDefault();
168      } else if (fromScriptingAPI) {
169        position.y += this.viewport.size.height;
170        this.viewport.position = position;
171      }
172    }.bind(this);
173
174    switch (e.keyCode) {
175      case 32:  // Space key.
176        if (e.shiftKey)
177          pageUpHandler();
178        else
179          pageDownHandler();
180        return;
181      case 33:  // Page up key.
182        pageUpHandler();
183        return;
184      case 34:  // Page down key.
185        pageDownHandler();
186        return;
187      case 37:  // Left arrow key.
188        // Go to the previous page if there are no horizontal scrollbars.
189        if (!this.viewport_.documentHasScrollbars().x) {
190          this.viewport_.goToPage(this.viewport_.getMostVisiblePage() - 1);
191          // Since we do the movement of the page.
192          e.preventDefault();
193        } else if (fromScriptingAPI) {
194          position.x -= Viewport.SCROLL_INCREMENT;
195          this.viewport.position = position;
196        }
197        return;
198      case 38:  // Up arrow key.
199        if (fromScriptingAPI) {
200          position.y -= Viewport.SCROLL_INCREMENT;
201          this.viewport.position = position;
202        }
203        return;
204      case 39:  // Right arrow key.
205        // Go to the next page if there are no horizontal scrollbars.
206        if (!this.viewport_.documentHasScrollbars().x) {
207          this.viewport_.goToPage(this.viewport_.getMostVisiblePage() + 1);
208          // Since we do the movement of the page.
209          e.preventDefault();
210        } else if (fromScriptingAPI) {
211          position.x += Viewport.SCROLL_INCREMENT;
212          this.viewport.position = position;
213        }
214        return;
215      case 40:  // Down arrow key.
216        if (fromScriptingAPI) {
217          position.y += Viewport.SCROLL_INCREMENT;
218          this.viewport.position = position;
219        }
220        return;
221      case 83:  // s key.
222        if (e.ctrlKey || e.metaKey) {
223          // Simulate a click on the button so that the <a download ...>
224          // attribute is used.
225          $('save-button-link').click();
226          // Since we do the saving of the page.
227          e.preventDefault();
228        }
229        return;
230      case 80:  // p key.
231        if (e.ctrlKey || e.metaKey) {
232          this.print_();
233          // Since we do the printing of the page.
234          e.preventDefault();
235        }
236        return;
237      case 219:  // left bracket.
238        if (e.ctrlKey) {
239          this.plugin_.postMessage({
240            type: 'rotateCounterclockwise',
241          });
242        }
243        return;
244      case 221:  // right bracket.
245        if (e.ctrlKey) {
246          this.plugin_.postMessage({
247            type: 'rotateClockwise',
248          });
249        }
250        return;
251    }
252  },
253
254  /**
255   * @private
256   * Notify the plugin to print.
257   */
258  print_: function() {
259    this.plugin_.postMessage({
260      type: 'print',
261    });
262  },
263
264  /**
265   * @private
266   * Handle open pdf parameters. This function updates the viewport as per
267   * the parameters mentioned in the url while opening pdf. The order is
268   * important as later actions can override the effects of previous actions.
269   */
270  handleURLParams_: function() {
271    if (this.urlParams_.page)
272      this.viewport_.goToPage(this.urlParams_.page);
273    if (this.urlParams_.position) {
274      // Make sure we don't cancel effect of page parameter.
275      this.viewport_.position = {
276        x: this.viewport_.position.x + this.urlParams_.position.x,
277        y: this.viewport_.position.y + this.urlParams_.position.y
278      };
279    }
280    if (this.urlParams_.zoom)
281      this.viewport_.setZoom(this.urlParams_.zoom);
282  },
283
284  /**
285   * @private
286   * Update the loading progress of the document in response to a progress
287   * message being received from the plugin.
288   * @param {number} progress the progress as a percentage.
289   */
290  updateProgress_: function(progress) {
291    this.progressBar_.progress = progress;
292    if (progress == -1) {
293      // Document load failed.
294      this.errorScreen_.style.visibility = 'visible';
295      this.sizer_.style.display = 'none';
296      this.toolbar_.style.visibility = 'hidden';
297      if (this.passwordScreen_.active) {
298        this.passwordScreen_.deny();
299        this.passwordScreen_.active = false;
300      }
301    } else if (progress == 100) {
302      // Document load complete.
303      if (this.lastViewportPosition_)
304        this.viewport_.position = this.lastViewportPosition_;
305      this.handleURLParams_();
306      this.loaded = true;
307      var loadEvent = new Event('pdfload');
308      window.dispatchEvent(loadEvent);
309      this.sendScriptingMessage_({
310        type: 'documentLoaded'
311      });
312    }
313  },
314
315  /**
316   * @private
317   * An event handler for handling password-submitted events. These are fired
318   * when an event is entered into the password screen.
319   * @param {Object} event a password-submitted event.
320   */
321  onPasswordSubmitted_: function(event) {
322    this.plugin_.postMessage({
323      type: 'getPasswordComplete',
324      password: event.detail.password
325    });
326  },
327
328  /**
329   * @private
330   * An event handler for handling message events received from the plugin.
331   * @param {MessageObject} message a message event.
332   */
333  handlePluginMessage_: function(message) {
334    switch (message.data.type.toString()) {
335      case 'documentDimensions':
336        this.documentDimensions_ = message.data;
337        this.viewport_.setDocumentDimensions(this.documentDimensions_);
338        this.toolbar_.style.visibility = 'visible';
339        // If we received the document dimensions, the password was good so we
340        // can dismiss the password screen.
341        if (this.passwordScreen_.active)
342          this.passwordScreen_.accept();
343
344        this.pageIndicator_.initialFadeIn();
345        this.toolbar_.initialFadeIn();
346        break;
347      case 'email':
348        var href = 'mailto:' + message.data.to + '?cc=' + message.data.cc +
349            '&bcc=' + message.data.bcc + '&subject=' + message.data.subject +
350            '&body=' + message.data.body;
351        var w = window.open(href, '_blank', 'width=1,height=1');
352        if (w)
353          w.close();
354        break;
355      case 'getAccessibilityJSONReply':
356        this.sendScriptingMessage_(message.data);
357        break;
358      case 'getPassword':
359        // If the password screen isn't up, put it up. Otherwise we're
360        // responding to an incorrect password so deny it.
361        if (!this.passwordScreen_.active)
362          this.passwordScreen_.active = true;
363        else
364          this.passwordScreen_.deny();
365        break;
366      case 'goToPage':
367        this.viewport_.goToPage(message.data.page);
368        break;
369      case 'loadProgress':
370        this.updateProgress_(message.data.progress);
371        break;
372      case 'navigate':
373        if (message.data.newTab)
374          window.open(message.data.url);
375        else
376          window.location.href = message.data.url;
377        break;
378      case 'setScrollPosition':
379        var position = this.viewport_.position;
380        if (message.data.x != undefined)
381          position.x = message.data.x;
382        if (message.data.y != undefined)
383          position.y = message.data.y;
384        this.viewport_.position = position;
385        break;
386      case 'setTranslatedStrings':
387        this.passwordScreen_.text = message.data.getPasswordString;
388        this.progressBar_.text = message.data.loadingString;
389        this.errorScreen_.text = message.data.loadFailedString;
390        break;
391      case 'cancelStreamUrl':
392        chrome.streamsPrivate.abort(this.streamDetails.streamUrl);
393        break;
394    }
395  },
396
397  /**
398   * @private
399   * A callback that's called before the zoom changes. Notify the plugin to stop
400   * reacting to scroll events while zoom is taking place to avoid flickering.
401   */
402  beforeZoom_: function() {
403    this.plugin_.postMessage({
404      type: 'stopScrolling'
405    });
406  },
407
408  /**
409   * @private
410   * A callback that's called after the zoom changes. Notify the plugin of the
411   * zoom change and to continue reacting to scroll events.
412   */
413  afterZoom_: function() {
414    var position = this.viewport_.position;
415    var zoom = this.viewport_.zoom;
416    if (chrome.tabs && !this.setZoomInProgress_) {
417      this.setZoomInProgress_ = true;
418      chrome.tabs.setZoom(zoom, this.setZoomComplete_.bind(this, zoom));
419    }
420    this.plugin_.postMessage({
421      type: 'viewport',
422      zoom: zoom,
423      xOffset: position.x,
424      yOffset: position.y
425    });
426  },
427
428  /**
429   * @private
430   * A callback that's called after chrome.tabs.setZoom is complete. This will
431   * call chrome.tabs.setZoom again if the zoom level has changed since it was
432   * last called.
433   * @param {number} lastZoom the zoom level that chrome.tabs.setZoom was called
434   *     with.
435   */
436  setZoomComplete_: function(lastZoom) {
437    var zoom = this.viewport_.zoom;
438    if (zoom != lastZoom)
439      chrome.tabs.setZoom(zoom, this.setZoomComplete_.bind(this, zoom));
440    else
441      this.setZoomInProgress_ = false;
442  },
443
444  /**
445   * @private
446   * A callback that's called after the viewport changes.
447   */
448  viewportChanged_: function() {
449    if (!this.documentDimensions_)
450      return;
451
452    // Update the buttons selected.
453    $('fit-to-page-button').classList.remove('polymer-selected');
454    $('fit-to-width-button').classList.remove('polymer-selected');
455    if (this.viewport_.fittingType == Viewport.FittingType.FIT_TO_PAGE) {
456      $('fit-to-page-button').classList.add('polymer-selected');
457    } else if (this.viewport_.fittingType ==
458               Viewport.FittingType.FIT_TO_WIDTH) {
459      $('fit-to-width-button').classList.add('polymer-selected');
460    }
461
462    var hasScrollbars = this.viewport_.documentHasScrollbars();
463    var scrollbarWidth = this.viewport_.scrollbarWidth;
464    // Offset the toolbar position so that it doesn't move if scrollbars appear.
465    var toolbarRight = Math.max(PDFViewer.MIN_TOOLBAR_OFFSET, scrollbarWidth);
466    var toolbarBottom = Math.max(PDFViewer.MIN_TOOLBAR_OFFSET, scrollbarWidth);
467    if (hasScrollbars.vertical)
468      toolbarRight -= scrollbarWidth;
469    if (hasScrollbars.horizontal)
470      toolbarBottom -= scrollbarWidth;
471    this.toolbar_.style.right = toolbarRight + 'px';
472    this.toolbar_.style.bottom = toolbarBottom + 'px';
473
474    // Update the page indicator.
475    var visiblePage = this.viewport_.getMostVisiblePage();
476    this.pageIndicator_.index = visiblePage;
477    if (this.documentDimensions_.pageDimensions.length > 1 &&
478        hasScrollbars.vertical) {
479      this.pageIndicator_.style.visibility = 'visible';
480    } else {
481      this.pageIndicator_.style.visibility = 'hidden';
482    }
483
484    var visiblePageDimensions = this.viewport_.getPageScreenRect(visiblePage);
485    var size = this.viewport_.size;
486    this.sendScriptingMessage_({
487      type: 'viewport',
488      pageX: visiblePageDimensions.x,
489      pageY: visiblePageDimensions.y,
490      pageWidth: visiblePageDimensions.width,
491      viewportWidth: size.width,
492      viewportHeight: size.height,
493    });
494  },
495
496  /**
497   * @private
498   * Handle a scripting message from outside the extension (typically sent by
499   * PDFScriptingAPI in a page containing the extension) to interact with the
500   * plugin.
501   * @param {MessageObject} message the message to handle.
502   */
503  handleScriptingMessage_: function(message) {
504    switch (message.data.type.toString()) {
505      case 'getAccessibilityJSON':
506      case 'loadPreviewPage':
507        this.plugin_.postMessage(message.data);
508        break;
509      case 'resetPrintPreviewMode':
510        if (!this.inPrintPreviewMode_) {
511          this.inPrintPreviewMode_ = true;
512          this.viewport_.fitToPage();
513        }
514
515        // Stash the scroll location so that it can be restored when the new
516        // document is loaded.
517        this.lastViewportPosition_ = this.viewport_.position;
518
519        // TODO(raymes): Disable these properly in the plugin.
520        var printButton = $('print-button');
521        if (printButton)
522          printButton.parentNode.removeChild(printButton);
523        var saveButton = $('save-button');
524        if (saveButton)
525          saveButton.parentNode.removeChild(saveButton);
526
527        this.pageIndicator_.pageLabels = message.data.pageNumbers;
528
529        this.plugin_.postMessage({
530          type: 'resetPrintPreviewMode',
531          url: message.data.url,
532          grayscale: message.data.grayscale,
533          // If the PDF isn't modifiable we send 0 as the page count so that no
534          // blank placeholder pages get appended to the PDF.
535          pageCount: (message.data.modifiable ?
536                      message.data.pageNumbers.length : 0)
537        });
538        break;
539      case 'sendKeyEvent':
540        var e = document.createEvent('Event');
541        e.initEvent('scriptingKeypress');
542        e.keyCode = message.data.keyCode;
543        this.handleKeyEvent_(e);
544        break;
545    }
546
547  },
548
549  /**
550   * @private
551   * Send a scripting message outside the extension (typically to
552   * PDFScriptingAPI in a page containing the extension).
553   * @param {Object} message the message to send.
554   */
555  sendScriptingMessage_: function(message) {
556    window.parent.postMessage(message, '*');
557  },
558
559  /**
560   * @type {Viewport} the viewport of the PDF viewer.
561   */
562  get viewport() {
563    return this.viewport_;
564  }
565};
566