// Copyright 2013 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. 'use strict'; /** * @return {number} Width of a scrollbar in pixels */ function getScrollbarWidth() { var div = document.createElement('div'); div.style.visibility = 'hidden'; div.style.overflow = 'scroll'; div.style.width = '50px'; div.style.height = '50px'; div.style.position = 'absolute'; document.body.appendChild(div); var result = div.offsetWidth - div.clientWidth; div.parentNode.removeChild(div); return result; } /** * Return the filename component of a URL. * @param {string} url The URL to get the filename from. * @return {string} The filename component. */ function getFilenameFromURL(url) { var components = url.split(/\/|\\/); return components[components.length - 1]; } /** * The minimum number of pixels to offset the toolbar by from the bottom and * right side of the screen. */ PDFViewer.MIN_TOOLBAR_OFFSET = 15; /** * Creates a new PDFViewer. There should only be one of these objects per * document. * @param {Object} streamDetails The stream object which points to the data * contained in the PDF. */ function PDFViewer(streamDetails) { this.streamDetails = streamDetails; this.loaded = false; // The sizer element is placed behind the plugin element to cause scrollbars // to be displayed in the window. It is sized according to the document size // of the pdf and zoom level. this.sizer_ = $('sizer'); this.toolbar_ = $('toolbar'); this.pageIndicator_ = $('page-indicator'); this.progressBar_ = $('progress-bar'); this.passwordScreen_ = $('password-screen'); this.passwordScreen_.addEventListener('password-submitted', this.onPasswordSubmitted_.bind(this)); this.errorScreen_ = $('error-screen'); // Create the viewport. this.viewport_ = new Viewport(window, this.sizer_, this.viewportChanged_.bind(this), this.beforeZoom_.bind(this), this.afterZoom_.bind(this), getScrollbarWidth()); var isPrintPreview = this.streamDetails.originalUrl.indexOf('chrome://print') == 0; // Create the plugin object dynamically so we can set its src. The plugin // element is sized to fill the entire window and is set to be fixed // positioning, acting as a viewport. The plugin renders into this viewport // according to the scroll position of the window. // // TODO(sammc): Remove special casing for print preview. This is currently // necessary because setting the src for an embed element triggers origin // checking and the PDF extension is not allowed to embed URLs with a scheme // of "chrome", which is used by print preview. this.plugin_ = document.createElement(isPrintPreview ? 'object' : 'embed'); // NOTE: The plugin's 'id' field must be set to 'plugin' since // chrome/renderer/printing/print_web_view_helper.cc actually references it. this.plugin_.id = 'plugin'; this.plugin_.type = 'application/x-google-chrome-pdf'; this.plugin_.addEventListener('message', this.handlePluginMessage_.bind(this), false); // Handle scripting messages from outside the extension that wish to interact // with it. We also send a message indicating that extension has loaded and // is ready to receive messages. window.addEventListener('message', this.handleScriptingMessage_.bind(this), false); this.sendScriptingMessage_({type: 'readyToReceive'}); document.title = getFilenameFromURL(this.streamDetails.originalUrl); this.plugin_.setAttribute('src', this.streamDetails.originalUrl); this.plugin_.setAttribute('stream-url', this.streamDetails.streamUrl); var headers = ''; for (var header in this.streamDetails.responseHeaders) { headers += header + ': ' + this.streamDetails.responseHeaders[header] + '\n'; } this.plugin_.setAttribute('headers', headers); if (!this.streamDetails.embedded) this.plugin_.setAttribute('full-frame', ''); document.body.appendChild(this.plugin_); // TODO(raymes): Remove this spurious message once crbug.com/388606 is fixed. // This is a hack to initialize pepper sync scripting and avoid re-entrancy. this.plugin_.postMessage({ type: 'viewport', zoom: 1, xOffset: 0, yOffset: 0 }); // Setup the button event listeners. $('fit-to-width-button').addEventListener('click', this.viewport_.fitToWidth.bind(this.viewport_)); $('fit-to-page-button').addEventListener('click', this.viewport_.fitToPage.bind(this.viewport_)); $('zoom-in-button').addEventListener('click', this.viewport_.zoomIn.bind(this.viewport_)); $('zoom-out-button').addEventListener('click', this.viewport_.zoomOut.bind(this.viewport_)); $('save-button').addEventListener('click', this.save_.bind(this)); $('print-button').addEventListener('click', this.print_.bind(this)); // Setup the keyboard event listener. document.onkeydown = this.handleKeyEvent_.bind(this); // Set up the zoom API. if (this.shouldManageZoom_()) { chrome.tabs.setZoomSettings(this.streamDetails.tabId, {mode: 'manual', scope: 'per-tab'}, this.afterZoom_.bind(this)); chrome.tabs.onZoomChange.addListener(function(zoomChangeInfo) { if (zoomChangeInfo.tabId != this.streamDetails.tabId) return; // If the zoom level is close enough to the current zoom level, don't // change it. This avoids us getting into an infinite loop of zoom changes // due to floating point error. var MIN_ZOOM_DELTA = 0.01; var zoomDelta = Math.abs(this.viewport_.zoom - zoomChangeInfo.newZoomFactor); // We should not change zoom level when we are responsible for initiating // the zoom. onZoomChange() is called before setZoomComplete() callback // when we initiate the zoom. if ((zoomDelta > MIN_ZOOM_DELTA) && !this.setZoomInProgress_) this.viewport_.setZoom(zoomChangeInfo.newZoomFactor); }.bind(this)); } // Parse open pdf parameters. var paramsParser = new OpenPDFParamsParser(this.streamDetails.originalUrl); this.urlParams_ = paramsParser.urlParams; } PDFViewer.prototype = { /** * @private * Handle key events. These may come from the user directly or via the * scripting API. * @param {KeyboardEvent} e the event to handle. */ handleKeyEvent_: function(e) { var position = this.viewport_.position; // Certain scroll events may be sent from outside of the extension. var fromScriptingAPI = e.type == 'scriptingKeypress'; var pageUpHandler = function() { // Go to the previous page if we are fit-to-page. if (this.viewport_.fittingType == Viewport.FittingType.FIT_TO_PAGE) { this.viewport_.goToPage(this.viewport_.getMostVisiblePage() - 1); // Since we do the movement of the page. e.preventDefault(); } else if (fromScriptingAPI) { position.y -= this.viewport.size.height; this.viewport.position = position; } }.bind(this); var pageDownHandler = function() { // Go to the next page if we are fit-to-page. if (this.viewport_.fittingType == Viewport.FittingType.FIT_TO_PAGE) { this.viewport_.goToPage(this.viewport_.getMostVisiblePage() + 1); // Since we do the movement of the page. e.preventDefault(); } else if (fromScriptingAPI) { position.y += this.viewport.size.height; this.viewport.position = position; } }.bind(this); switch (e.keyCode) { case 32: // Space key. if (e.shiftKey) pageUpHandler(); else pageDownHandler(); return; case 33: // Page up key. pageUpHandler(); return; case 34: // Page down key. pageDownHandler(); return; case 37: // Left arrow key. // Go to the previous page if there are no horizontal scrollbars. if (!this.viewport_.documentHasScrollbars().x) { this.viewport_.goToPage(this.viewport_.getMostVisiblePage() - 1); // Since we do the movement of the page. e.preventDefault(); } else if (fromScriptingAPI) { position.x -= Viewport.SCROLL_INCREMENT; this.viewport.position = position; } return; case 38: // Up arrow key. if (fromScriptingAPI) { position.y -= Viewport.SCROLL_INCREMENT; this.viewport.position = position; } return; case 39: // Right arrow key. // Go to the next page if there are no horizontal scrollbars. if (!this.viewport_.documentHasScrollbars().x) { this.viewport_.goToPage(this.viewport_.getMostVisiblePage() + 1); // Since we do the movement of the page. e.preventDefault(); } else if (fromScriptingAPI) { position.x += Viewport.SCROLL_INCREMENT; this.viewport.position = position; } return; case 40: // Down arrow key. if (fromScriptingAPI) { position.y += Viewport.SCROLL_INCREMENT; this.viewport.position = position; } return; case 65: // a key. if (e.ctrlKey || e.metaKey) { this.plugin_.postMessage({ type: 'selectAll', }); } return; case 80: // p key. if (e.ctrlKey || e.metaKey) { this.print_(); // Since we do the printing of the page. e.preventDefault(); } return; case 219: // left bracket. if (e.ctrlKey) { this.plugin_.postMessage({ type: 'rotateCounterclockwise', }); } return; case 221: // right bracket. if (e.ctrlKey) { this.plugin_.postMessage({ type: 'rotateClockwise', }); } return; } }, /** * @private * Notify the plugin to print. */ print_: function() { this.plugin_.postMessage({ type: 'print', }); }, /** * @private * Notify the plugin to save. */ save_: function() { this.plugin_.postMessage({ type: 'save', }); }, /** * @private * Handle open pdf parameters. This function updates the viewport as per * the parameters mentioned in the url while opening pdf. The order is * important as later actions can override the effects of previous actions. */ handleURLParams_: function() { if (this.urlParams_.page) this.viewport_.goToPage(this.urlParams_.page); if (this.urlParams_.position) { // Make sure we don't cancel effect of page parameter. this.viewport_.position = { x: this.viewport_.position.x + this.urlParams_.position.x, y: this.viewport_.position.y + this.urlParams_.position.y }; } if (this.urlParams_.zoom) this.viewport_.setZoom(this.urlParams_.zoom); }, /** * @private * Update the loading progress of the document in response to a progress * message being received from the plugin. * @param {number} progress the progress as a percentage. */ updateProgress_: function(progress) { this.progressBar_.progress = progress; if (progress == -1) { // Document load failed. this.errorScreen_.style.visibility = 'visible'; this.sizer_.style.display = 'none'; this.toolbar_.style.visibility = 'hidden'; if (this.passwordScreen_.active) { this.passwordScreen_.deny(); this.passwordScreen_.active = false; } } else if (progress == 100) { // Document load complete. if (this.lastViewportPosition_) this.viewport_.position = this.lastViewportPosition_; this.handleURLParams_(); this.loaded = true; var loadEvent = new Event('pdfload'); window.dispatchEvent(loadEvent); this.sendScriptingMessage_({ type: 'documentLoaded' }); } }, /** * @private * An event handler for handling password-submitted events. These are fired * when an event is entered into the password screen. * @param {Object} event a password-submitted event. */ onPasswordSubmitted_: function(event) { this.plugin_.postMessage({ type: 'getPasswordComplete', password: event.detail.password }); }, /** * @private * An event handler for handling message events received from the plugin. * @param {MessageObject} message a message event. */ handlePluginMessage_: function(message) { switch (message.data.type.toString()) { case 'documentDimensions': this.documentDimensions_ = message.data; this.viewport_.setDocumentDimensions(this.documentDimensions_); // If we received the document dimensions, the password was good so we // can dismiss the password screen. if (this.passwordScreen_.active) this.passwordScreen_.accept(); this.pageIndicator_.initialFadeIn(); this.toolbar_.initialFadeIn(); break; case 'email': var href = 'mailto:' + message.data.to + '?cc=' + message.data.cc + '&bcc=' + message.data.bcc + '&subject=' + message.data.subject + '&body=' + message.data.body; var w = window.open(href, '_blank', 'width=1,height=1'); if (w) w.close(); break; case 'getAccessibilityJSONReply': this.sendScriptingMessage_(message.data); break; case 'getPassword': // If the password screen isn't up, put it up. Otherwise we're // responding to an incorrect password so deny it. if (!this.passwordScreen_.active) this.passwordScreen_.active = true; else this.passwordScreen_.deny(); break; case 'goToPage': this.viewport_.goToPage(message.data.page); break; case 'loadProgress': this.updateProgress_(message.data.progress); break; case 'navigate': if (message.data.newTab) window.open(message.data.url); else window.location.href = message.data.url; break; case 'setScrollPosition': var position = this.viewport_.position; if (message.data.x != undefined) position.x = message.data.x; if (message.data.y != undefined) position.y = message.data.y; this.viewport_.position = position; break; case 'setTranslatedStrings': this.passwordScreen_.text = message.data.getPasswordString; this.progressBar_.text = message.data.loadingString; this.progressBar_.style.visibility = 'visible'; this.errorScreen_.text = message.data.loadFailedString; break; case 'cancelStreamUrl': chrome.streamsPrivate.abort(this.streamDetails.streamUrl); break; } }, /** * @private * A callback that's called before the zoom changes. Notify the plugin to stop * reacting to scroll events while zoom is taking place to avoid flickering. */ beforeZoom_: function() { this.plugin_.postMessage({ type: 'stopScrolling' }); }, /** * @private * A callback that's called after the zoom changes. Notify the plugin of the * zoom change and to continue reacting to scroll events. */ afterZoom_: function() { var position = this.viewport_.position; var zoom = this.viewport_.zoom; if (this.shouldManageZoom_() && !this.setZoomInProgress_) { this.setZoomInProgress_ = true; chrome.tabs.setZoom(this.streamDetails.tabId, zoom, this.setZoomComplete_.bind(this, zoom)); } this.plugin_.postMessage({ type: 'viewport', zoom: zoom, xOffset: position.x, yOffset: position.y }); }, /** * @private * A callback that's called after chrome.tabs.setZoom is complete. This will * call chrome.tabs.setZoom again if the zoom level has changed since it was * last called. * @param {number} lastZoom the zoom level that chrome.tabs.setZoom was called * with. */ setZoomComplete_: function(lastZoom) { var zoom = this.viewport_.zoom; if (zoom != lastZoom) { chrome.tabs.setZoom(this.streamDetails.tabId, zoom, this.setZoomComplete_.bind(this, zoom)); } else { this.setZoomInProgress_ = false; } }, /** * @private * A callback that's called after the viewport changes. */ viewportChanged_: function() { if (!this.documentDimensions_) return; // Update the buttons selected. $('fit-to-page-button').classList.remove('polymer-selected'); $('fit-to-width-button').classList.remove('polymer-selected'); if (this.viewport_.fittingType == Viewport.FittingType.FIT_TO_PAGE) { $('fit-to-page-button').classList.add('polymer-selected'); } else if (this.viewport_.fittingType == Viewport.FittingType.FIT_TO_WIDTH) { $('fit-to-width-button').classList.add('polymer-selected'); } var hasScrollbars = this.viewport_.documentHasScrollbars(); var scrollbarWidth = this.viewport_.scrollbarWidth; // Offset the toolbar position so that it doesn't move if scrollbars appear. var toolbarRight = Math.max(PDFViewer.MIN_TOOLBAR_OFFSET, scrollbarWidth); var toolbarBottom = Math.max(PDFViewer.MIN_TOOLBAR_OFFSET, scrollbarWidth); if (hasScrollbars.vertical) toolbarRight -= scrollbarWidth; if (hasScrollbars.horizontal) toolbarBottom -= scrollbarWidth; this.toolbar_.style.right = toolbarRight + 'px'; this.toolbar_.style.bottom = toolbarBottom + 'px'; // Hide the toolbar if it doesn't fit in the viewport. if (this.toolbar_.offsetLeft < 0 || this.toolbar_.offsetTop < 0) this.toolbar_.style.visibility = 'hidden'; else this.toolbar_.style.visibility = 'visible'; // Update the page indicator. var visiblePage = this.viewport_.getMostVisiblePage(); this.pageIndicator_.index = visiblePage; if (this.documentDimensions_.pageDimensions.length > 1 && hasScrollbars.vertical) { this.pageIndicator_.style.visibility = 'visible'; } else { this.pageIndicator_.style.visibility = 'hidden'; } var visiblePageDimensions = this.viewport_.getPageScreenRect(visiblePage); var size = this.viewport_.size; this.sendScriptingMessage_({ type: 'viewport', pageX: visiblePageDimensions.x, pageY: visiblePageDimensions.y, pageWidth: visiblePageDimensions.width, viewportWidth: size.width, viewportHeight: size.height, }); }, /** * @private * Handle a scripting message from outside the extension (typically sent by * PDFScriptingAPI in a page containing the extension) to interact with the * plugin. * @param {MessageObject} message the message to handle. */ handleScriptingMessage_: function(message) { switch (message.data.type.toString()) { case 'getAccessibilityJSON': case 'loadPreviewPage': this.plugin_.postMessage(message.data); break; case 'resetPrintPreviewMode': if (!this.inPrintPreviewMode_) { this.inPrintPreviewMode_ = true; this.viewport_.fitToPage(); } // Stash the scroll location so that it can be restored when the new // document is loaded. this.lastViewportPosition_ = this.viewport_.position; // TODO(raymes): Disable these properly in the plugin. var printButton = $('print-button'); if (printButton) printButton.parentNode.removeChild(printButton); var saveButton = $('save-button'); if (saveButton) saveButton.parentNode.removeChild(saveButton); this.pageIndicator_.pageLabels = message.data.pageNumbers; this.plugin_.postMessage({ type: 'resetPrintPreviewMode', url: message.data.url, grayscale: message.data.grayscale, // If the PDF isn't modifiable we send 0 as the page count so that no // blank placeholder pages get appended to the PDF. pageCount: (message.data.modifiable ? message.data.pageNumbers.length : 0) }); break; case 'sendKeyEvent': var e = document.createEvent('Event'); e.initEvent('scriptingKeypress'); e.keyCode = message.data.keyCode; this.handleKeyEvent_(e); break; } }, /** * @private * Send a scripting message outside the extension (typically to * PDFScriptingAPI in a page containing the extension). * @param {Object} message the message to send. */ sendScriptingMessage_: function(message) { window.parent.postMessage(message, '*'); }, /** * @private * Return whether this PDFViewer should manage zoom for its containing page. * @return {boolean} Whether this PDFViewer should manage zoom for its * containing page. */ shouldManageZoom_: function() { return !!(chrome.tabs && !this.streamDetails.embedded && this.streamDetails.tabId != -1); }, /** * @type {Viewport} the viewport of the PDF viewer. */ get viewport() { return this.viewport_; } };