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.
8 * @return {number} Width of a scrollbar in pixels
10 function 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);
24 * Return the filename component of a URL.
25 * @param {string} url The URL to get the filename from.
26 * @return {string} The filename component.
28 function getFilenameFromURL(url) {
29 var components = url.split(/\/|\\/);
30 return components[components.length - 1];
34 * Called when navigation happens in the current tab.
35 * @param {string} url The url to be opened in the current tab.
37 function onNavigateInCurrentTab(url) {
38 window.location.href = url;
42 * Called when navigation happens in the new tab.
43 * @param {string} url The url to be opened in the new tab.
45 function onNavigateInNewTab(url) {
46 // Prefer the tabs API because it guarantees we can just open a new tab.
47 // window.open doesn't have this guarantee.
49 chrome.tabs.create({ url: url});
55 * The minimum number of pixels to offset the toolbar by from the bottom and
56 * right side of the screen.
58 PDFViewer.MIN_TOOLBAR_OFFSET = 15;
61 * Creates a new PDFViewer. There should only be one of these objects per
64 * @param {Object} streamDetails The stream object which points to the data
65 * contained in the PDF.
67 function PDFViewer(streamDetails) {
68 this.streamDetails_ = streamDetails;
70 this.parentWindow_ = null;
72 this.delayedScriptingMessages_ = [];
74 this.isPrintPreview_ =
75 this.streamDetails_.originalUrl.indexOf('chrome://print') == 0;
76 this.isMaterial_ = location.pathname.substring(1) === 'index-material.html';
78 // The sizer element is placed behind the plugin element to cause scrollbars
79 // to be displayed in the window. It is sized according to the document size
80 // of the pdf and zoom level.
81 this.sizer_ = $('sizer');
82 this.toolbar_ = $('toolbar');
83 this.pageIndicator_ = $('page-indicator');
84 this.progressBar_ = $('progress-bar');
85 this.passwordScreen_ = $('password-screen');
86 this.passwordScreen_.addEventListener('password-submitted',
87 this.onPasswordSubmitted_.bind(this));
88 this.errorScreen_ = $('error-screen');
90 // Create the viewport.
91 this.viewport_ = new Viewport(window,
93 this.viewportChanged_.bind(this),
94 this.beforeZoom_.bind(this),
95 this.afterZoom_.bind(this),
98 // Create the plugin object dynamically so we can set its src. The plugin
99 // element is sized to fill the entire window and is set to be fixed
100 // positioning, acting as a viewport. The plugin renders into this viewport
101 // according to the scroll position of the window.
102 this.plugin_ = document.createElement('embed');
103 // NOTE: The plugin's 'id' field must be set to 'plugin' since
104 // chrome/renderer/printing/print_web_view_helper.cc actually references it.
105 this.plugin_.id = 'plugin';
106 this.plugin_.type = 'application/x-google-chrome-pdf';
107 this.plugin_.addEventListener('message', this.handlePluginMessage_.bind(this),
110 // Handle scripting messages from outside the extension that wish to interact
111 // with it. We also send a message indicating that extension has loaded and
112 // is ready to receive messages.
113 window.addEventListener('message', this.handleScriptingMessage.bind(this),
116 document.title = getFilenameFromURL(this.streamDetails_.originalUrl);
117 this.plugin_.setAttribute('src', this.streamDetails_.originalUrl);
118 this.plugin_.setAttribute('stream-url', this.streamDetails_.streamUrl);
120 for (var header in this.streamDetails_.responseHeaders) {
121 headers += header + ': ' +
122 this.streamDetails_.responseHeaders[header] + '\n';
124 this.plugin_.setAttribute('headers', headers);
126 if (this.isMaterial_)
127 this.plugin_.setAttribute('is-material', '');
129 if (!this.streamDetails_.embedded)
130 this.plugin_.setAttribute('full-frame', '');
131 document.body.appendChild(this.plugin_);
133 // Setup the button event listeners.
134 if (!this.isMaterial_) {
135 $('fit-to-width-button').addEventListener('click',
136 this.viewport_.fitToWidth.bind(this.viewport_));
137 $('fit-to-page-button').addEventListener('click',
138 this.viewport_.fitToPage.bind(this.viewport_));
139 $('zoom-in-button').addEventListener('click',
140 this.viewport_.zoomIn.bind(this.viewport_));
141 $('zoom-out-button').addEventListener('click',
142 this.viewport_.zoomOut.bind(this.viewport_));
143 $('save-button').addEventListener('click', this.save_.bind(this));
144 $('print-button').addEventListener('click', this.print_.bind(this));
147 if (this.isMaterial_) {
148 this.bookmarksPane_ = $('bookmarks-pane');
150 this.zoomSelector_ = $('zoom-selector');
151 this.zoomSelector_.zoomMin = Viewport.ZOOM_FACTOR_RANGE.min * 100;
152 this.zoomSelector_.zoomMax = Viewport.ZOOM_FACTOR_RANGE.max * 100;
153 this.zoomSelector_.addEventListener('zoom', function(e) {
154 this.viewport_.setZoom(e.detail.zoom);
156 this.zoomSelector_.addEventListener('fit-to-width',
157 this.viewport_.fitToWidth.bind(this.viewport_));
158 this.zoomSelector_.addEventListener('fit-to-page',
159 this.viewport_.fitToPage.bind(this.viewport_));
161 this.materialToolbar_ = $('material-toolbar');
162 this.materialToolbar_.docTitle = document.title;
163 this.materialToolbar_.addEventListener('save', this.save_.bind(this));
164 this.materialToolbar_.addEventListener('print', this.print_.bind(this));
165 this.materialToolbar_.addEventListener('rotate-right',
166 this.rotateClockwise_.bind(this));
167 this.materialToolbar_.addEventListener('toggle-bookmarks', function() {
168 this.bookmarksPane_.buttonToggle();
171 document.body.addEventListener('change-page', function(e) {
172 this.viewport_.goToPage(e.detail.page);
175 this.uiManager_ = new UiManager(window, this.materialToolbar_,
176 [this.bookmarksPane_]);
179 // Setup the keyboard event listener.
180 document.onkeydown = this.handleKeyEvent_.bind(this);
182 // Set up the zoom API.
183 if (this.shouldManageZoom_()) {
184 chrome.tabs.setZoomSettings(this.streamDetails_.tabId,
185 {mode: 'manual', scope: 'per-tab'},
186 this.afterZoom_.bind(this));
187 chrome.tabs.onZoomChange.addListener(function(zoomChangeInfo) {
188 if (zoomChangeInfo.tabId != this.streamDetails_.tabId)
190 // If the zoom level is close enough to the current zoom level, don't
191 // change it. This avoids us getting into an infinite loop of zoom changes
192 // due to floating point error.
193 var MIN_ZOOM_DELTA = 0.01;
194 var zoomDelta = Math.abs(this.viewport_.zoom -
195 zoomChangeInfo.newZoomFactor);
196 // We should not change zoom level when we are responsible for initiating
197 // the zoom. onZoomChange() is called before setZoomComplete() callback
198 // when we initiate the zoom.
199 if ((zoomDelta > MIN_ZOOM_DELTA) && !this.setZoomInProgress_)
200 this.viewport_.setZoom(zoomChangeInfo.newZoomFactor);
204 // Parse open pdf parameters.
206 new OpenPDFParamsParser(this.getNamedDestination_.bind(this));
207 this.navigator_ = new Navigator(this.streamDetails_.originalUrl,
208 this.viewport_, this.paramsParser_,
209 onNavigateInCurrentTab, onNavigateInNewTab);
210 this.viewportScroller_ =
211 new ViewportScroller(this.viewport_, this.plugin_, window);
214 PDFViewer.prototype = {
217 * Handle key events. These may come from the user directly or via the
219 * @param {KeyboardEvent} e the event to handle.
221 handleKeyEvent_: function(e) {
222 var position = this.viewport_.position;
223 // Certain scroll events may be sent from outside of the extension.
224 var fromScriptingAPI = e.fromScriptingAPI;
226 var pageUpHandler = function() {
227 // Go to the previous page if we are fit-to-page.
228 if (this.viewport_.fittingType == Viewport.FittingType.FIT_TO_PAGE) {
229 this.viewport_.goToPage(this.viewport_.getMostVisiblePage() - 1);
230 // Since we do the movement of the page.
232 } else if (fromScriptingAPI) {
233 position.y -= this.viewport.size.height;
234 this.viewport.position = position;
237 var pageDownHandler = function() {
238 // Go to the next page if we are fit-to-page.
239 if (this.viewport_.fittingType == Viewport.FittingType.FIT_TO_PAGE) {
240 this.viewport_.goToPage(this.viewport_.getMostVisiblePage() + 1);
241 // Since we do the movement of the page.
243 } else if (fromScriptingAPI) {
244 position.y += this.viewport.size.height;
245 this.viewport.position = position;
250 case 32: // Space key.
256 case 33: // Page up key.
259 case 34: // Page down key.
262 case 37: // Left arrow key.
263 if (!(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) {
264 // Go to the previous page if there are no horizontal scrollbars.
265 if (!this.viewport_.documentHasScrollbars().horizontal) {
266 this.viewport_.goToPage(this.viewport_.getMostVisiblePage() - 1);
267 // Since we do the movement of the page.
269 } else if (fromScriptingAPI) {
270 position.x -= Viewport.SCROLL_INCREMENT;
271 this.viewport.position = position;
275 case 38: // Up arrow key.
276 if (fromScriptingAPI) {
277 position.y -= Viewport.SCROLL_INCREMENT;
278 this.viewport.position = position;
281 case 39: // Right arrow key.
282 if (!(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey)) {
283 // Go to the next page if there are no horizontal scrollbars.
284 if (!this.viewport_.documentHasScrollbars().horizontal) {
285 this.viewport_.goToPage(this.viewport_.getMostVisiblePage() + 1);
286 // Since we do the movement of the page.
288 } else if (fromScriptingAPI) {
289 position.x += Viewport.SCROLL_INCREMENT;
290 this.viewport.position = position;
294 case 40: // Down arrow key.
295 if (fromScriptingAPI) {
296 position.y += Viewport.SCROLL_INCREMENT;
297 this.viewport.position = position;
301 if (e.ctrlKey || e.metaKey) {
302 this.plugin_.postMessage({
305 // Since we do selection ourselves.
310 if (this.isMaterial_ && (e.ctrlKey || e.metaKey)) {
311 this.materialToolbar_.selectPageNumber();
312 // To prevent the default "find text" behaviour in Chrome.
317 if (e.ctrlKey || e.metaKey) {
319 // Since we do the printing of the page.
323 case 219: // left bracket.
325 this.rotateCounterClockwise_();
327 case 221: // right bracket.
329 this.rotateClockwise_();
333 // Give print preview a chance to handle the key event.
334 if (!fromScriptingAPI && this.isPrintPreview_) {
335 this.sendScriptingMessage_({
336 type: 'sendKeyEvent',
337 keyEvent: SerializeKeyEvent(e)
344 * Rotate the plugin clockwise.
346 rotateClockwise_: function() {
347 this.plugin_.postMessage({
348 type: 'rotateClockwise'
354 * Rotate the plugin counter-clockwise.
356 rotateCounterClockwise_: function() {
357 this.plugin_.postMessage({
358 type: 'rotateCounterclockwise'
364 * Notify the plugin to print.
367 this.plugin_.postMessage({
374 * Notify the plugin to save.
377 this.plugin_.postMessage({
383 * Fetches the page number corresponding to the given named destination from
385 * @param {string} name The namedDestination to fetch page number from plugin.
387 getNamedDestination_: function(name) {
388 this.plugin_.postMessage({
389 type: 'getNamedDestination',
390 namedDestination: name
396 * Handle open pdf parameters. This function updates the viewport as per
397 * the parameters mentioned in the url while opening pdf. The order is
398 * important as later actions can override the effects of previous actions.
399 * @param {Object} viewportPosition The initial position of the viewport to be
402 handleURLParams_: function(viewportPosition) {
403 if (viewportPosition.page != undefined)
404 this.viewport_.goToPage(viewportPosition.page);
405 if (viewportPosition.position) {
406 // Make sure we don't cancel effect of page parameter.
407 this.viewport_.position = {
408 x: this.viewport_.position.x + viewportPosition.position.x,
409 y: this.viewport_.position.y + viewportPosition.position.y
412 if (viewportPosition.zoom)
413 this.viewport_.setZoom(viewportPosition.zoom);
418 * Update the loading progress of the document in response to a progress
419 * message being received from the plugin.
420 * @param {number} progress the progress as a percentage.
422 updateProgress_: function(progress) {
423 if (this.isMaterial_)
424 this.materialToolbar_.loadProgress = progress;
426 this.progressBar_.progress = progress;
428 if (progress == -1) {
429 // Document load failed.
430 this.errorScreen_.style.visibility = 'visible';
431 this.sizer_.style.display = 'none';
432 if (!this.isMaterial_)
433 this.toolbar_.style.visibility = 'hidden';
434 if (this.passwordScreen_.active) {
435 this.passwordScreen_.deny();
436 this.passwordScreen_.active = false;
438 } else if (progress == 100) {
439 // Document load complete.
440 if (this.lastViewportPosition_)
441 this.viewport_.position = this.lastViewportPosition_;
442 this.paramsParser_.getViewportFromUrlParams(
443 this.streamDetails_.originalUrl, this.handleURLParams_.bind(this));
445 this.sendScriptingMessage_({
446 type: 'documentLoaded'
448 while (this.delayedScriptingMessages_.length > 0)
449 this.handleScriptingMessage(this.delayedScriptingMessages_.shift());
451 if (this.isMaterial_)
452 this.uiManager_.hideUiAfterTimeout();
458 * An event handler for handling password-submitted events. These are fired
459 * when an event is entered into the password screen.
460 * @param {Object} event a password-submitted event.
462 onPasswordSubmitted_: function(event) {
463 this.plugin_.postMessage({
464 type: 'getPasswordComplete',
465 password: event.detail.password
471 * An event handler for handling message events received from the plugin.
472 * @param {MessageObject} message a message event.
474 handlePluginMessage_: function(message) {
475 switch (message.data.type.toString()) {
476 case 'documentDimensions':
477 this.documentDimensions_ = message.data;
478 this.viewport_.setDocumentDimensions(this.documentDimensions_);
479 // If we received the document dimensions, the password was good so we
480 // can dismiss the password screen.
481 if (this.passwordScreen_.active)
482 this.passwordScreen_.accept();
484 if (this.isMaterial_) {
485 this.materialToolbar_.docLength =
486 this.documentDimensions_.pageDimensions.length;
488 this.pageIndicator_.initialFadeIn();
489 this.toolbar_.initialFadeIn();
494 var href = 'mailto:' + message.data.to + '?cc=' + message.data.cc +
495 '&bcc=' + message.data.bcc + '&subject=' + message.data.subject +
496 '&body=' + message.data.body;
497 window.location.href = href;
499 case 'getAccessibilityJSONReply':
500 this.sendScriptingMessage_(message.data);
503 // If the password screen isn't up, put it up. Otherwise we're
504 // responding to an incorrect password so deny it.
505 if (!this.passwordScreen_.active)
506 this.passwordScreen_.active = true;
508 this.passwordScreen_.deny();
510 case 'getSelectedTextReply':
511 this.sendScriptingMessage_(message.data);
514 this.viewport_.goToPage(message.data.page);
517 this.updateProgress_(message.data.progress);
520 // If in print preview, always open a new tab.
521 if (this.isPrintPreview_)
522 this.navigator_.navigate(message.data.url, true);
524 this.navigator_.navigate(message.data.url, message.data.newTab);
526 case 'setScrollPosition':
527 var position = this.viewport_.position;
528 if (message.data.x !== undefined)
529 position.x = message.data.x;
530 if (message.data.y !== undefined)
531 position.y = message.data.y;
532 this.viewport_.position = position;
534 case 'setTranslatedStrings':
535 this.passwordScreen_.text = message.data.getPasswordString;
536 if (!this.isMaterial_) {
537 this.progressBar_.text = message.data.loadingString;
538 if (!this.isPrintPreview_)
539 this.progressBar_.style.visibility = 'visible';
541 this.errorScreen_.text = message.data.loadFailedString;
543 case 'cancelStreamUrl':
544 chrome.mimeHandlerPrivate.abortStream();
547 this.bookmarks_ = message.data.bookmarks;
548 if (this.isMaterial_ && this.bookmarks_.length !== 0) {
549 $('bookmarks-container').bookmarks = this.bookmarks;
550 this.materialToolbar_.hasBookmarks = true;
553 case 'setIsSelecting':
554 this.viewportScroller_.setEnableScrolling(message.data.isSelecting);
556 case 'getNamedDestinationReply':
557 this.paramsParser_.onNamedDestinationReceived(
558 message.data.pageNumber);
565 * A callback that's called before the zoom changes. Notify the plugin to stop
566 * reacting to scroll events while zoom is taking place to avoid flickering.
568 beforeZoom_: function() {
569 this.plugin_.postMessage({
570 type: 'stopScrolling'
576 * A callback that's called after the zoom changes. Notify the plugin of the
577 * zoom change and to continue reacting to scroll events.
579 afterZoom_: function() {
580 var position = this.viewport_.position;
581 var zoom = this.viewport_.zoom;
582 if (this.isMaterial_)
583 this.zoomSelector_.zoomValue = 100 * zoom;
584 if (this.shouldManageZoom_() && !this.setZoomInProgress_) {
585 this.setZoomInProgress_ = true;
586 chrome.tabs.setZoom(this.streamDetails_.tabId, zoom,
587 this.setZoomComplete_.bind(this, zoom));
589 this.plugin_.postMessage({
599 * A callback that's called after chrome.tabs.setZoom is complete. This will
600 * call chrome.tabs.setZoom again if the zoom level has changed since it was
602 * @param {number} lastZoom the zoom level that chrome.tabs.setZoom was called
605 setZoomComplete_: function(lastZoom) {
606 var zoom = this.viewport_.zoom;
607 if (zoom !== lastZoom) {
608 chrome.tabs.setZoom(this.streamDetails_.tabId, zoom,
609 this.setZoomComplete_.bind(this, zoom));
611 this.setZoomInProgress_ = false;
617 * A callback that's called after the viewport changes.
619 viewportChanged_: function() {
620 if (!this.documentDimensions_)
623 // Update the buttons selected.
624 if (!this.isMaterial_) {
625 $('fit-to-page-button').classList.remove('polymer-selected');
626 $('fit-to-width-button').classList.remove('polymer-selected');
627 if (this.viewport_.fittingType == Viewport.FittingType.FIT_TO_PAGE) {
628 $('fit-to-page-button').classList.add('polymer-selected');
629 } else if (this.viewport_.fittingType ==
630 Viewport.FittingType.FIT_TO_WIDTH) {
631 $('fit-to-width-button').classList.add('polymer-selected');
635 // Offset the toolbar position so that it doesn't move if scrollbars appear.
636 var hasScrollbars = this.viewport_.documentHasScrollbars();
637 var scrollbarWidth = this.viewport_.scrollbarWidth;
638 var verticalScrollbarWidth = hasScrollbars.vertical ? scrollbarWidth : 0;
639 var horizontalScrollbarWidth =
640 hasScrollbars.horizontal ? scrollbarWidth : 0;
641 var toolbarRight = Math.max(PDFViewer.MIN_TOOLBAR_OFFSET, scrollbarWidth);
642 var toolbarBottom = Math.max(PDFViewer.MIN_TOOLBAR_OFFSET, scrollbarWidth);
643 toolbarRight -= verticalScrollbarWidth;
644 toolbarBottom -= horizontalScrollbarWidth;
645 if (!this.isMaterial_) {
646 this.toolbar_.style.right = toolbarRight + 'px';
647 this.toolbar_.style.bottom = toolbarBottom + 'px';
648 // Hide the toolbar if it doesn't fit in the viewport.
649 if (this.toolbar_.offsetLeft < 0 || this.toolbar_.offsetTop < 0)
650 this.toolbar_.style.visibility = 'hidden';
652 this.toolbar_.style.visibility = 'visible';
655 // Update the page indicator.
656 var visiblePage = this.viewport_.getMostVisiblePage();
657 if (this.isMaterial_) {
658 this.materialToolbar_.pageIndex = visiblePage;
660 this.pageIndicator_.index = visiblePage;
661 if (this.documentDimensions_.pageDimensions.length > 1 &&
662 hasScrollbars.vertical) {
663 this.pageIndicator_.style.visibility = 'visible';
665 this.pageIndicator_.style.visibility = 'hidden';
669 var visiblePageDimensions = this.viewport_.getPageScreenRect(visiblePage);
670 var size = this.viewport_.size;
671 this.sendScriptingMessage_({
673 pageX: visiblePageDimensions.x,
674 pageY: visiblePageDimensions.y,
675 pageWidth: visiblePageDimensions.width,
676 viewportWidth: size.width,
677 viewportHeight: size.height
682 * Handle a scripting message from outside the extension (typically sent by
683 * PDFScriptingAPI in a page containing the extension) to interact with the
685 * @param {MessageObject} message the message to handle.
687 handleScriptingMessage: function(message) {
688 if (this.parentWindow_ != message.source) {
689 this.parentWindow_ = message.source;
690 // Ensure that we notify the embedder if the document is loaded.
692 this.sendScriptingMessage_({
693 type: 'documentLoaded'
698 if (this.handlePrintPreviewScriptingMessage_(message))
701 // Delay scripting messages from users of the scripting API until the
702 // document is loaded. This simplifies use of the APIs.
704 this.delayedScriptingMessages_.push(message);
708 switch (message.data.type.toString()) {
709 case 'getAccessibilityJSON':
710 case 'getSelectedText':
713 this.plugin_.postMessage(message.data);
720 * Handle scripting messages specific to print preview.
721 * @param {MessageObject} message the message to handle.
722 * @return {boolean} true if the message was handled, false otherwise.
724 handlePrintPreviewScriptingMessage_: function(message) {
725 if (!this.isPrintPreview_)
728 switch (message.data.type.toString()) {
729 case 'loadPreviewPage':
730 this.plugin_.postMessage(message.data);
732 case 'resetPrintPreviewMode':
733 this.loaded_ = false;
734 if (!this.inPrintPreviewMode_) {
735 this.inPrintPreviewMode_ = true;
736 this.viewport_.fitToPage();
739 // Stash the scroll location so that it can be restored when the new
740 // document is loaded.
741 this.lastViewportPosition_ = this.viewport_.position;
743 // TODO(raymes): Disable these properly in the plugin.
744 var printButton = $('print-button');
746 printButton.parentNode.removeChild(printButton);
747 var saveButton = $('save-button');
749 saveButton.parentNode.removeChild(saveButton);
751 if (!this.isMaterial_)
752 this.pageIndicator_.pageLabels = message.data.pageNumbers;
754 this.plugin_.postMessage({
755 type: 'resetPrintPreviewMode',
756 url: message.data.url,
757 grayscale: message.data.grayscale,
758 // If the PDF isn't modifiable we send 0 as the page count so that no
759 // blank placeholder pages get appended to the PDF.
760 pageCount: (message.data.modifiable ?
761 message.data.pageNumbers.length : 0)
765 this.handleKeyEvent_(DeserializeKeyEvent(message.data.keyEvent));
774 * Send a scripting message outside the extension (typically to
775 * PDFScriptingAPI in a page containing the extension).
776 * @param {Object} message the message to send.
778 sendScriptingMessage_: function(message) {
779 if (this.parentWindow_)
780 this.parentWindow_.postMessage(message, '*');
785 * Return whether this PDFViewer should manage zoom for its containing page.
786 * @return {boolean} Whether this PDFViewer should manage zoom for its
789 shouldManageZoom_: function() {
790 return !!(chrome.tabs && !this.streamDetails_.embedded &&
791 this.streamDetails_.tabId != -1);
795 * @type {Viewport} the viewport of the PDF viewer.
798 return this.viewport_;
802 * Each bookmark is an Object containing a:
805 * - array of children (themselves bookmarks)
806 * @type {Array} the top-level bookmarks of the PDF.
809 return this.bookmarks_;