| // 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. |
| |
| import {assert, assertNotReached} from 'chrome://resources/js/assert.m.js'; |
| import {EventTracker} from 'chrome://resources/js/event_tracker.m.js'; |
| import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js'; |
| import {PromiseResolver} from 'chrome://resources/js/promise_resolver.m.js'; |
| import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js'; |
| |
| import {BrowserApi, ZoomBehavior} from './browser_api.js'; |
| import {FittingType, Point} from './constants.js'; |
| import {ContentController, MessageData, PluginController, PluginControllerEventType} from './controller.js'; |
| import {record, recordFitTo, UserAction} from './metrics.js'; |
| import {OpenPdfParams, OpenPdfParamsParser} from './open_pdf_params_parser.js'; |
| import {LoadState} from './pdf_scripting_api.js'; |
| import {DocumentDimensionsMessageData, MessageObject} from './pdf_viewer_utils.js'; |
| import {Viewport} from './viewport.js'; |
| import {ViewportScroller} from './viewport_scroller.js'; |
| import {ZoomManager} from './zoom_manager.js'; |
| |
| /** @return {number} Width of a scrollbar in pixels */ |
| function getScrollbarWidth() { |
| const 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); |
| const result = div.offsetWidth - div.clientWidth; |
| div.parentNode.removeChild(div); |
| return result; |
| } |
| |
| export class PDFViewerBaseElement extends PolymerElement { |
| static get is() { |
| return 'pdf-viewer-base'; |
| } |
| |
| static get template() { |
| return null; |
| } |
| |
| static get properties() { |
| return { |
| /** @protected */ |
| showErrorDialog: { |
| type: Boolean, |
| value: false, |
| }, |
| |
| /** @protected {Object|undefined} */ |
| strings: Object, |
| }; |
| } |
| |
| constructor() { |
| super(); |
| |
| /** @protected {?BrowserApi} */ |
| this.browserApi = null; |
| |
| /** @protected {?ContentController} */ |
| this.currentController = null; |
| |
| /** @protected {string} */ |
| this.originalUrl = ''; |
| |
| /** @protected {!EventTracker} */ |
| this.tracker = new EventTracker(); |
| |
| /** @protected {boolean} */ |
| this.isUserInitiatedEvent = true; |
| |
| /** @protected {?Point} */ |
| this.lastViewportPosition = null; |
| |
| /** @protected {?OpenPdfParamsParser} */ |
| this.paramsParser = null; |
| |
| /** @protected {?ViewportScroller} */ |
| this.viewportScroller = null; |
| |
| /** @protected {?DocumentDimensionsMessageData} */ |
| this.documentDimensions = null; |
| |
| /** @private {boolean} */ |
| this.overrideSendScriptingMessageForTest_ = false; |
| |
| /** @private {!LoadState} */ |
| this.loadState_ = LoadState.LOADING; |
| |
| /** @private {?Object} */ |
| this.parentWindow_ = null; |
| |
| /** @private {?string} */ |
| this.parentOrigin_ = null; |
| |
| /** @private {!Array} */ |
| this.delayedScriptingMessages_ = []; |
| |
| /** @private {?PromiseResolver} */ |
| this.loaded_ = null; |
| |
| /** @private {boolean} */ |
| this.initialLoadComplete_ = false; |
| |
| /** @private {?Viewport} */ |
| this.viewport_ = null; |
| |
| /** @private {?HTMLEmbedElement} */ |
| this.plugin_ = null; |
| |
| /** @private {?ZoomManager} */ |
| this.zoomManager_ = null; |
| } |
| |
| /** |
| * @return {!HTMLDivElement} |
| * @protected |
| */ |
| getContent() {} |
| |
| /** |
| * @return {!HTMLDivElement} |
| * @protected |
| */ |
| getSizer() {} |
| |
| /** |
| * @param {!FittingType} view |
| * @protected |
| */ |
| forceFit(view) {} |
| |
| /** |
| * @param {number} viewportZoom |
| * @protected |
| */ |
| afterZoom(viewportZoom) {} |
| |
| /** |
| * @param {string} query |
| * @return {?Element} |
| * @protected |
| */ |
| $$(query) { |
| return this.shadowRoot.querySelector(query); |
| } |
| |
| /** @return {number} */ |
| getBackgroundColor() { |
| return -1; |
| } |
| |
| /** |
| * @param {boolean} isPrintPreview Is the plugin for Print Preview. |
| * @return {!HTMLEmbedElement} The plugin |
| * @private |
| */ |
| createPlugin_(isPrintPreview) { |
| // 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. |
| const plugin = |
| /** @type {!HTMLEmbedElement} */ (document.createElement('embed')); |
| |
| // NOTE: The plugin's 'id' field must be set to 'plugin' since |
| // ChromePrintRenderFrameHelperDeleage::GetPdfElement() in |
| // chrome/renderer/printing/chrome_print_render_frame_helper_delegate.cc |
| // actually references it. |
| plugin.id = 'plugin'; |
| plugin.type = 'application/x-google-chrome-pdf'; |
| |
| plugin.setAttribute('src', this.originalUrl); |
| plugin.setAttribute( |
| 'stream-url', this.browserApi.getStreamInfo().streamUrl); |
| let headers = ''; |
| for (const header in this.browserApi.getStreamInfo().responseHeaders) { |
| headers += header + ': ' + |
| this.browserApi.getStreamInfo().responseHeaders[header] + '\n'; |
| } |
| plugin.setAttribute('headers', headers); |
| |
| plugin.setAttribute('background-color', this.getBackgroundColor()); |
| |
| const javascript = this.browserApi.getStreamInfo().javascript || 'block'; |
| plugin.setAttribute('javascript', javascript); |
| |
| if (this.browserApi.getStreamInfo().embedded) { |
| plugin.setAttribute( |
| 'top-level-url', this.browserApi.getStreamInfo().tabUrl); |
| } else { |
| plugin.toggleAttribute('full-frame', true); |
| } |
| |
| if (!isPrintPreview) { |
| plugin.toggleAttribute('pdf-viewer-update-enabled', true); |
| } |
| |
| return plugin; |
| } |
| |
| /** @param {!BrowserApi} browserApi */ |
| init(browserApi) { |
| this.browserApi = browserApi; |
| this.originalUrl = this.browserApi.getStreamInfo().originalUrl; |
| |
| record(UserAction.DOCUMENT_OPENED); |
| |
| // Parse open pdf parameters. |
| this.paramsParser = new OpenPdfParamsParser(destination => { |
| return PluginController.getInstance().getNamedDestination(destination); |
| }); |
| |
| // Determine the scrolling container. |
| const isPrintPreview = |
| document.documentElement.hasAttribute('is-print-preview'); |
| const scrollContainer = isPrintPreview ? |
| document.documentElement : |
| /** @type {!HTMLElement} */ (this.getSizer().offsetParent); |
| |
| // Create the viewport. |
| const defaultZoom = |
| this.browserApi.getZoomBehavior() === ZoomBehavior.MANAGE ? |
| this.browserApi.getDefaultZoom() : |
| 1.0; |
| |
| this.viewport_ = new Viewport( |
| scrollContainer, this.getSizer(), this.getContent(), |
| getScrollbarWidth(), defaultZoom); |
| this.viewport_.setViewportChangedCallback(() => this.viewportChanged_()); |
| this.viewport_.setBeforeZoomCallback( |
| () => this.currentController.beforeZoom()); |
| this.viewport_.setAfterZoomCallback(() => { |
| this.currentController.afterZoom(); |
| this.afterZoom(this.viewport_.getZoom()); |
| }); |
| this.viewport_.setUserInitiatedCallback( |
| userInitiated => this.setUserInitiated_(userInitiated)); |
| window.addEventListener('beforeunload', () => this.resetTrackers_()); |
| |
| // 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', message => { |
| this.handleScriptingMessage(/** @type {!MessageObject} */ (message)); |
| }, false); |
| |
| // Create the plugin. |
| this.plugin_ = this.createPlugin_(isPrintPreview); |
| this.getContent().appendChild(this.plugin_); |
| |
| const pluginController = PluginController.getInstance(); |
| pluginController.init( |
| this.plugin_, this.viewport_, () => this.isUserInitiatedEvent, |
| () => this.loaded); |
| pluginController.isActive = true; |
| this.currentController = pluginController; |
| |
| this.tracker.add( |
| pluginController.getEventTarget(), |
| PluginControllerEventType.PLUGIN_MESSAGE, |
| e => this.handlePluginMessage(e)); |
| |
| document.body.addEventListener('change-page-and-xy', e => { |
| const point = this.viewport_.convertPageToScreen(e.detail.page, e.detail); |
| this.viewport_.goToPageAndXY(e.detail.page, point.x, point.y); |
| }); |
| |
| // Set up the ZoomManager. |
| this.zoomManager_ = ZoomManager.create( |
| this.browserApi.getZoomBehavior(), () => this.viewport_.getZoom(), |
| zoom => this.browserApi.setZoom(zoom), |
| this.browserApi.getInitialZoom()); |
| this.viewport_.setZoomManager(assert(this.zoomManager_)); |
| this.browserApi.addZoomEventListener( |
| zoom => this.zoomManager_.onBrowserZoomChange(zoom)); |
| |
| this.viewportScroller = |
| new ViewportScroller(this.viewport_, this.plugin_, window); |
| |
| // Request translated strings. |
| chrome.resourcesPrivate.getStrings( |
| chrome.resourcesPrivate.Component.PDF, |
| strings => this.handleStrings(strings)); |
| } |
| |
| /** |
| * Update the loading progress of the document in response to a progress |
| * message being received from the content controller. |
| * @param {number} progress the progress as a percentage. |
| */ |
| updateProgress(progress) { |
| if (progress === -1) { |
| // Document load failed. |
| this.showErrorDialog = true; |
| this.getSizer().style.display = 'none'; |
| this.setLoadState(LoadState.FAILED); |
| this.sendDocumentLoadedMessage(); |
| } else if (progress === 100) { |
| // Document load complete. |
| if (this.lastViewportPosition) { |
| this.viewport_.position = this.lastViewportPosition; |
| } |
| this.paramsParser.getViewportFromUrlParams(this.originalUrl) |
| .then(this.handleURLParams_.bind(this)); |
| this.setLoadState(LoadState.SUCCESS); |
| this.sendDocumentLoadedMessage(); |
| while (this.delayedScriptingMessages_.length > 0) { |
| this.handleScriptingMessage(this.delayedScriptingMessages_.shift()); |
| } |
| } else { |
| this.setLoadState(LoadState.LOADING); |
| } |
| } |
| |
| /** @return {boolean} Whether the documentLoaded message can be sent. */ |
| readyToSendLoadMessage() { |
| return true; |
| } |
| |
| /** |
| * Sends a 'documentLoaded' message to the PDFScriptingAPI if the document has |
| * finished loading. |
| */ |
| sendDocumentLoadedMessage() { |
| if (this.loadState_ === LoadState.LOADING || |
| !this.readyToSendLoadMessage()) { |
| return; |
| } |
| this.sendScriptingMessage( |
| {type: 'documentLoaded', load_state: this.loadState_}); |
| } |
| |
| /** |
| * Called to update the UI before sending the viewport scripting message. |
| * Should be overridden by subclasses. |
| * @protected |
| */ |
| updateUIForViewportChange() {} |
| |
| /** |
| * A callback that's called after the viewport changes. |
| * @private |
| */ |
| viewportChanged_() { |
| if (!this.documentDimensions) { |
| return; |
| } |
| |
| this.updateUIForViewportChange(); |
| |
| const visiblePage = this.viewport_.getMostVisiblePage(); |
| const visiblePageDimensions = this.viewport_.getPageScreenRect(visiblePage); |
| const size = this.viewport_.size; |
| this.paramsParser.setViewportDimensions(size); |
| |
| this.sendScriptingMessage({ |
| type: 'viewport', |
| pageX: visiblePageDimensions.x, |
| pageY: visiblePageDimensions.y, |
| pageWidth: visiblePageDimensions.width, |
| viewportWidth: size.width, |
| viewportHeight: size.height |
| }); |
| } |
| |
| /** |
| * 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(message) { |
| if (this.parentWindow_ !== message.source) { |
| this.parentWindow_ = message.source; |
| this.parentOrigin_ = message.origin; |
| // Ensure that we notify the embedder if the document is loaded. |
| if (this.loadState_ !== LoadState.LOADING) { |
| this.sendDocumentLoadedMessage(); |
| } |
| } |
| } |
| |
| /** |
| * @param {!MessageObject} message The message to handle. |
| * @return {boolean} Whether the message was delayed and added to the queue. |
| */ |
| delayScriptingMessage(message) { |
| // Delay scripting messages from users of the scripting API until the |
| // document is loaded. This simplifies use of the APIs. |
| if (this.loadState_ !== LoadState.SUCCESS) { |
| this.delayedScriptingMessages_.push(message); |
| return true; |
| } |
| return false; |
| } |
| |
| /** |
| * @param {!CustomEvent<MessageData>} e |
| * @protected |
| */ |
| handlePluginMessage(e) {} |
| |
| /** |
| * Sets document dimensions from the current controller. |
| * @param {!DocumentDimensionsMessageData} documentDimensions |
| * @protected |
| */ |
| setDocumentDimensions(documentDimensions) { |
| this.documentDimensions = documentDimensions; |
| this.isUserInitiatedEvent = false; |
| this.viewport_.setDocumentDimensions(this.documentDimensions); |
| this.paramsParser.setViewportDimensions(this.viewport_.size); |
| this.isUserInitiatedEvent = true; |
| } |
| |
| /** |
| * @return {?Promise} Resolved when the load state reaches LOADED, |
| * rejects on FAILED. Returns null if no promise has been created, which |
| * is the case for initial load of the PDF. |
| */ |
| get loaded() { |
| return this.loaded_ ? this.loaded_.promise : null; |
| } |
| |
| /** @return {!Viewport} */ |
| get viewport() { |
| return assert(this.viewport_); |
| } |
| |
| /** |
| * Updates the load state and triggers completion of the `loaded` |
| * promise if necessary. |
| * @param {!LoadState} loadState |
| * @protected |
| */ |
| setLoadState(loadState) { |
| if (this.loadState_ === loadState) { |
| return; |
| } |
| assert( |
| loadState === LoadState.LOADING || |
| this.loadState_ === LoadState.LOADING); |
| this.loadState_ = loadState; |
| if (!this.initialLoadComplete_) { |
| this.initialLoadComplete_ = true; |
| return; |
| } |
| if (loadState === LoadState.SUCCESS) { |
| this.loaded_.resolve(); |
| } else if (loadState === LoadState.FAILED) { |
| this.loaded_.reject(); |
| } else { |
| this.loaded_ = new PromiseResolver(); |
| } |
| } |
| |
| /** |
| * Load a dictionary of translated strings into the UI. Used as a callback for |
| * chrome.resourcesPrivate. |
| * @param {?Object} strings Dictionary of translated strings |
| * @protected |
| */ |
| handleStrings(strings) { |
| if (!strings) { |
| return; |
| } |
| loadTimeData.data = strings; |
| |
| // Predefined zoom factors to be used when zooming in/out. These are in |
| // ascending order. |
| const presetZoomFactors = /** @type {!Array<number>} */ ( |
| JSON.parse(loadTimeData.getString('presetZoomFactors'))); |
| this.viewport_.setZoomFactorRange(presetZoomFactors); |
| |
| this.strings = strings; |
| } |
| |
| /** |
| * 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. |
| * @param {!OpenPdfParams} params The open params passed in the URL. |
| * @private |
| */ |
| handleURLParams_(params) { |
| if (params.zoom) { |
| this.viewport_.setZoom(params.zoom); |
| } |
| |
| if (params.position) { |
| this.viewport_.goToPageAndXY( |
| params.page ? params.page : 0, params.position.x, params.position.y); |
| } else if (params.page) { |
| this.viewport_.goToPage(params.page); |
| } |
| |
| if (params.view) { |
| this.isUserInitiatedEvent = false; |
| this.updateViewportFit(params.view); |
| this.forceFit(params.view); |
| if (params.viewPosition) { |
| const zoomedPositionShift = |
| params.viewPosition * this.viewport_.getZoom(); |
| const currentViewportPosition = this.viewport_.position; |
| if (params.view === FittingType.FIT_TO_WIDTH) { |
| currentViewportPosition.y += zoomedPositionShift; |
| } else if (params.view === FittingType.FIT_TO_HEIGHT) { |
| currentViewportPosition.x += zoomedPositionShift; |
| } |
| this.viewport_.position = currentViewportPosition; |
| } |
| this.isUserInitiatedEvent = true; |
| } |
| } |
| |
| /** |
| * A callback that sets |isUserInitiatedEvent| to |userInitiated|. |
| * @param {boolean} userInitiated The value to set |isUserInitiatedEvent| to. |
| * @private |
| */ |
| setUserInitiated_(userInitiated) { |
| assert(this.isUserInitiatedEvent !== userInitiated); |
| this.isUserInitiatedEvent = userInitiated; |
| } |
| |
| overrideSendScriptingMessageForTest() { |
| this.overrideSendScriptingMessageForTest_ = true; |
| } |
| |
| /** |
| * Send a scripting message outside the extension (typically to |
| * PDFScriptingAPI in a page containing the extension). |
| * @param {Object} message the message to send. |
| * @protected |
| */ |
| sendScriptingMessage(message) { |
| if (this.parentWindow_ && this.parentOrigin_) { |
| let targetOrigin; |
| // Only send data back to the embedder if it is from the same origin, |
| // unless we're sending it to ourselves (which could happen in the case |
| // of tests). We also allow 'documentLoaded' and 'passwordPrompted' |
| // messages through as they do not leak sensitive information. |
| if (this.parentOrigin_ === window.location.origin) { |
| targetOrigin = this.parentOrigin_; |
| } else if ( |
| message.type === 'documentLoaded' || |
| message.type === 'passwordPrompted') { |
| targetOrigin = '*'; |
| } else { |
| targetOrigin = this.originalUrl; |
| } |
| try { |
| this.parentWindow_.postMessage(message, targetOrigin); |
| } catch (ok) { |
| // TODO(crbug.com/1004425): targetOrigin probably was rejected, such as |
| // a "data:" URL. This shouldn't cause this method to throw, though. |
| } |
| } |
| } |
| |
| /** |
| * @param {!FittingType} fittingType |
| * @protected |
| */ |
| updateViewportFit(fittingType) { |
| if (fittingType === FittingType.FIT_TO_PAGE) { |
| this.viewport_.fitToPage(); |
| } else if (fittingType === FittingType.FIT_TO_WIDTH) { |
| this.viewport_.fitToWidth(); |
| } else if (fittingType === FittingType.FIT_TO_HEIGHT) { |
| this.viewport_.fitToHeight(); |
| } |
| } |
| |
| /** |
| * Request to change the viewport fitting type. |
| * @param {!CustomEvent<!FittingType>} e |
| * @protected |
| */ |
| onFitToChanged(e) { |
| this.updateViewportFit(e.detail); |
| recordFitTo(e.detail); |
| } |
| |
| /** @protected */ |
| onZoomIn() { |
| this.viewport_.zoomIn(); |
| record(UserAction.ZOOM_IN); |
| } |
| |
| /** |
| * @param {!CustomEvent<number>} e |
| * @protected |
| */ |
| onZoomChanged(e) { |
| this.viewport_.setZoom(e.detail / 100); |
| record(UserAction.ZOOM_CUSTOM); |
| } |
| |
| /** @protected */ |
| onZoomOut() { |
| this.viewport_.zoomOut(); |
| record(UserAction.ZOOM_OUT); |
| } |
| |
| /** |
| * Handles a selected text reply from the current controller. |
| * @param {!Object} message |
| * @protected |
| */ |
| handleSelectedTextReply(message) { |
| if (this.overrideSendScriptingMessageForTest_) { |
| this.overrideSendScriptingMessageForTest_ = false; |
| try { |
| this.sendScriptingMessage(message); |
| } finally { |
| this.parentWindow_.postMessage('flush', '*'); |
| } |
| return; |
| } |
| this.sendScriptingMessage(message); |
| } |
| |
| /** @protected */ |
| rotateClockwise() { |
| record(UserAction.ROTATE); |
| this.currentController.rotateClockwise(); |
| } |
| |
| /** @protected */ |
| rotateCounterclockwise() { |
| record(UserAction.ROTATE); |
| this.currentController.rotateCounterclockwise(); |
| } |
| |
| /** @private */ |
| resetTrackers_() { |
| this.viewport_.resetTracker(); |
| if (this.tracker) { |
| this.tracker.removeAll(); |
| } |
| } |
| } |
| |
| customElements.define(PDFViewerBaseElement.is, PDFViewerBaseElement); |