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