blob: 4c990dd3a47ea331adb51d47636047ed4a85aa3a [file] [log] [blame]
// 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';
/**
* @typedef {{
* source: Object,
* origin: string,
* data: !MessageData,
* }}
*/
let MessageObject;
/**
* @typedef {{
* type: string,
* height: number,
* width: number,
* layoutOptions: (!LayoutOptions|undefined),
* pageDimensions: Array
* }}
*/
let DocumentDimensionsMessageData;
/**
* @typedef {{
* type: string,
* url: string,
* disposition: !PdfNavigator.WindowOpenDisposition,
* }}
*/
let NavigateMessageData;
/**
* @typedef {{
* type: string,
* page: number,
* x: number,
* y: number,
* zoom: number
* }}
*/
let DestinationMessageData;
/**
* @typedef {{
* type: string,
* title: string,
* bookmarks: !Array<!Bookmark>,
* canSerializeDocument: boolean,
* }}
*/
let MetadataMessageData;
/**
* @typedef {{
* hasUnsavedChanges: (boolean|undefined),
* fileName: string,
* dataToSave: !ArrayBuffer
* }}
*/
let RequiredSaveResult;
/** @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;
}
/**
* Return the filename component of a URL, percent decoded if possible.
* @param {string} url The URL to get the filename from.
* @return {string} The filename component.
*/
function getFilenameFromURL(url) {
// Ignore the query and fragment.
const mainUrl = url.split(/#|\?/)[0];
const components = mainUrl.split(/\/|\\/);
const filename = components[components.length - 1];
try {
return decodeURIComponent(filename);
} catch (e) {
if (e instanceof URIError) {
return filename;
}
throw e;
}
}
/**
* Whether keydown events should currently be ignored. Events are ignored when
* an editable element has focus, to allow for proper editing controls.
* @param {Element} activeElement The currently selected DOM node.
* @return {boolean} True if keydown events should be ignored.
*/
function shouldIgnoreKeyEvents(activeElement) {
while (activeElement.shadowRoot != null &&
activeElement.shadowRoot.activeElement != null) {
activeElement = activeElement.shadowRoot.activeElement;
}
return (
activeElement.isContentEditable ||
(activeElement.tagName == 'INPUT' && activeElement.type != 'radio') ||
activeElement.tagName == 'TEXTAREA');
}
/**
* Creates a new PDFViewer. There should only be one of these objects per
* document.
*/
class PDFViewer {
/**
* @param {!BrowserApi} browserApi An object providing an API to the browser.
*/
constructor(browserApi) {
/** @private {!BrowserApi} */
this.browserApi_ = browserApi;
/** @private {string} */
this.originalUrl_ = this.browserApi_.getStreamInfo().originalUrl;
/** @private {string} */
this.javascript_ = this.browserApi_.getStreamInfo().javascript || 'block';
/** @private {!LoadState} */
this.loadState_ = LoadState.LOADING;
/** @private {?Object} */
this.parentWindow_ = null;
/** @private {?string} */
this.parentOrigin_ = null;
/** @private {boolean} */
this.isFormFieldFocused_ = false;
/** @private {number} */
this.beepCount_ = 0;
/** @private {!Array} */
this.delayedScriptingMessages_ = [];
/** @private {!PromiseResolver} */
this.loaded_;
/** @private {boolean} */
this.initialLoadComplete_ = false;
/** @private {boolean} */
this.isPrintPreview_ = location.origin === 'chrome://print';
/** @private {boolean} */
this.isPrintPreviewLoadingFinished_ = false;
/** @private {boolean} */
this.isUserInitiatedEvent_ = true;
/** @private {boolean} */
this.hasEnteredAnnotationMode_ = false;
/** @private {boolean} */
this.hadPassword_ = false;
/** @private {boolean} */
this.canSerializeDocument_ = false;
/** @private {!EventTracker} */
this.tracker_ = new EventTracker();
PDFMetrics.record(PDFMetrics.UserAction.DOCUMENT_OPENED);
// Parse open pdf parameters.
/** @private {!OpenPdfParamsParser} */
this.paramsParser_ = new OpenPdfParamsParser(
destination => this.pluginController_.getNamedDestination(destination));
const toolbarEnabled =
this.paramsParser_.getUiUrlParams(this.originalUrl_).toolbar &&
!this.isPrintPreview_;
// 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_ = /** @type {!HTMLDivElement} */ ($('sizer'));
/** @private {?ViewerPageIndicatorElement} */
this.pageIndicator_ = this.isPrintPreview_ ?
/** @type {!ViewerPageIndicatorElement} */ ($('page-indicator')) :
null;
/** @private {?ViewerPasswordScreenElement} */
this.passwordScreen_ =
/** @type {!ViewerPasswordScreenElement} */ ($('password-screen'));
this.passwordScreen_.addEventListener('password-submitted', e => {
this.onPasswordSubmitted_(
/** @type {!CustomEvent<{password: string}>} */ (e));
});
/** @private {?ViewerErrorScreenElement} */
this.errorScreen_ =
/** @type {!ViewerErrorScreenElement} */ ($('error-screen'));
// Can only reload if we are in a normal tab.
if (chrome.tabs && this.browserApi_.getStreamInfo().tabId != -1) {
this.errorScreen_.reloadFn = () => {
chrome.tabs.reload(this.browserApi_.getStreamInfo().tabId);
};
}
// Create the viewport.
const shortWindow =
window.innerHeight < PDFViewer.TOOLBAR_WINDOW_MIN_HEIGHT;
const topToolbarHeight =
(toolbarEnabled) ? PDFViewer.MATERIAL_TOOLBAR_HEIGHT : 0;
const defaultZoom =
this.browserApi_.getZoomBehavior() == BrowserApi.ZoomBehavior.MANAGE ?
this.browserApi_.getDefaultZoom() :
1.0;
/** @private {!Viewport} */
this.viewport_ = new Viewport(
window, this.sizer_, getScrollbarWidth(), defaultZoom,
topToolbarHeight);
this.viewport_.setViewportChangedCallback(() => this.viewportChanged_());
this.viewport_.setBeforeZoomCallback(
() => this.currentController_.beforeZoom());
this.viewport_.setAfterZoomCallback(
() => this.currentController_.afterZoom());
this.viewport_.setUserInitiatedCallback(
userInitiated => this.setUserInitiated_(userInitiated));
window.addEventListener('beforeunload', () => this.resetTrackers_());
// 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.
/** @private {!HTMLEmbedElement} */
this.plugin_ =
/** @type {!HTMLEmbedElement} */ (document.createElement('embed'));
// NOTE: The plugin's 'id' field must be set to 'plugin' since
// chrome/renderer/printing/print_render_frame_helper.cc actually
// references it.
this.plugin_.id = 'plugin';
this.plugin_.type = 'application/x-google-chrome-pdf';
// 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);
this.plugin_.setAttribute('src', this.originalUrl_);
this.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';
}
this.plugin_.setAttribute('headers', headers);
this.plugin_.setAttribute('background-color', PDFViewer.BACKGROUND_COLOR);
this.plugin_.setAttribute('top-toolbar-height', topToolbarHeight);
this.plugin_.setAttribute('javascript', this.javascript_);
if (this.browserApi_.getStreamInfo().embedded) {
this.plugin_.setAttribute(
'top-level-url', this.browserApi_.getStreamInfo().tabUrl);
} else {
this.plugin_.setAttribute('full-frame', '');
}
$('content').appendChild(this.plugin_);
/** @private {!PluginController} */
this.pluginController_ = new PluginController(
this.plugin_, this.viewport_, () => this.isUserInitiatedEvent_,
() => this.loaded);
this.tracker_.add(
this.pluginController_.getEventTarget(), 'plugin-message',
e => this.handlePluginMessage_(e));
/** @private {!InkController} */
this.inkController_ = new InkController(this.viewport_);
this.tracker_.add(
this.inkController_.getEventTarget(), 'stroke-added',
() => chrome.mimeHandlerPrivate.setShowBeforeUnloadDialog(true));
this.tracker_.add(
this.inkController_.getEventTarget(), 'set-annotation-undo-state',
e => this.setAnnotationUndoState_(e));
/** @private {!ContentController} */
this.currentController_ = this.pluginController_;
// Setup the button event listeners.
/** @private {!ViewerZoomToolbarElement} */
this.zoomToolbar_ =
/** @type {!ViewerZoomToolbarElement} */ ($('zoom-toolbar'));
this.zoomToolbar_.setIsPrintPreview(this.isPrintPreview_);
this.zoomToolbar_.addEventListener(
'fit-to-changed',
e => this.fitToChanged_(
/** @type {!CustomEvent<FitToChangedEvent>}} */ (e)));
this.zoomToolbar_.addEventListener(
'zoom-in', () => this.viewport_.zoomIn());
this.zoomToolbar_.addEventListener(
'zoom-out', () => this.viewport_.zoomOut());
/** @private {!GestureDetector} */
this.gestureDetector_ = new GestureDetector(assert($('content')));
this.gestureDetector_.addEventListener(
'pinchstart', e => this.onPinchStart_(e));
this.sentPinchEvent_ = false;
this.gestureDetector_.addEventListener(
'pinchupdate', e => this.onPinchUpdate_(e));
this.gestureDetector_.addEventListener(
'pinchend', e => this.onPinchEnd_(e));
/** @private {?ViewerPdfToolbarElement} */
this.toolbar_ = null;
if (toolbarEnabled) {
this.toolbar_ = /** @type {!ViewerPdfToolbarElement} */ ($('toolbar'));
this.toolbar_.hidden = false;
this.toolbar_.addEventListener('save', () => this.save_());
this.toolbar_.addEventListener('print', () => this.print_());
this.toolbar_.addEventListener(
'undo', () => this.currentController_.undo());
this.toolbar_.addEventListener(
'redo', () => this.currentController_.redo());
this.toolbar_.addEventListener(
'rotate-right', () => this.rotateClockwise_());
this.toolbar_.addEventListener('annotation-mode-toggled', e => {
this.annotationModeToggled_(
/** @type {!CustomEvent<{value: boolean}>} */ (e));
});
this.toolbar_.addEventListener(
'annotation-tool-changed',
e => this.inkController_.setAnnotationTool(e.detail.value));
this.toolbar_.docTitle = getFilenameFromURL(this.originalUrl_);
}
document.body.addEventListener('change-page', e => {
this.viewport_.goToPage(e.detail.page);
if (e.detail.origin == 'bookmark') {
PDFMetrics.record(PDFMetrics.UserAction.FOLLOW_BOOKMARK);
} else if (e.detail.origin == 'pageselector') {
PDFMetrics.record(PDFMetrics.UserAction.PAGE_SELECTOR_NAVIGATE);
}
});
document.body.addEventListener('change-zoom', e => {
this.viewport_.setZoom(e.detail.zoom);
});
document.body.addEventListener('change-page-and-xy', e => {
const point = this.viewport_.convertPageToScreen(e.detail.page, e.detail);
this.goToPageAndXY_(e.detail.origin, e.detail.page, point);
});
document.body.addEventListener('navigate', e => {
const disposition = e.detail.newtab ?
PdfNavigator.WindowOpenDisposition.NEW_BACKGROUND_TAB :
PdfNavigator.WindowOpenDisposition.CURRENT_TAB;
this.navigator_.navigate(e.detail.uri, disposition);
});
document.body.addEventListener('dropdown-opened', e => {
if (e.detail == 'bookmarks') {
PDFMetrics.record(PDFMetrics.UserAction.OPEN_BOOKMARKS_PANEL);
}
});
/** @private {!ToolbarManager} */
this.toolbarManager_ =
new ToolbarManager(window, this.toolbar_, this.zoomToolbar_);
// Set up the ZoomManager.
/** @private {!ZoomManager} */
this.zoomManager_ = ZoomManager.create(
this.browserApi_.getZoomBehavior(), () => this.viewport_.getZoom(),
zoom => this.browserApi_.setZoom(zoom),
this.browserApi_.getInitialZoom());
this.viewport_.setZoomManager(this.zoomManager_);
this.browserApi_.addZoomEventListener(
zoom => this.zoomManager_.onBrowserZoomChange(zoom));
// Setup the keyboard event listener.
document.addEventListener(
'keydown',
e => this.handleKeyEvent_(/** @type {!KeyboardEvent} */ (e)));
document.addEventListener('mousemove', e => this.handleMouseEvent_(e));
document.addEventListener('mouseout', e => this.handleMouseEvent_(e));
document.addEventListener(
'contextmenu', e => this.handleContextMenuEvent_(e));
const tabId = this.browserApi_.getStreamInfo().tabId;
/** @private {!PdfNavigator} */
this.navigator_ = new PdfNavigator(
this.originalUrl_, this.viewport_, this.paramsParser_,
new NavigatorDelegate(tabId));
/** @private {!ViewportScroller} */
this.viewportScroller_ =
new ViewportScroller(this.viewport_, this.plugin_, window);
/** @private {!Array<!Bookmark>} */
this.bookmarks_;
/** @private {!Point} */
this.lastViewportPosition_;
/** @private {boolean} */
this.reverseZoomToolbar_;
/** @private {boolean} */
this.inPrintPreviewMode_;
/** @private {boolean} */
this.dark_;
/** @private {!DocumentDimensionsMessageData} */
this.documentDimensions_;
// Request translated strings.
chrome.resourcesPrivate.getStrings(
chrome.resourcesPrivate.Component.PDF,
strings => this.handleStrings_(strings));
// Listen for save commands from the browser.
if (chrome.mimeHandlerPrivate && chrome.mimeHandlerPrivate.onSave) {
chrome.mimeHandlerPrivate.onSave.addListener(url => this.onSave_(url));
}
}
/**
* Handle key events. These may come from the user directly or via the
* scripting API.
* @param {!KeyboardEvent} e the event to handle.
* @private
*/
handleKeyEvent_(e) {
const position = this.viewport_.position;
// Certain scroll events may be sent from outside of the extension.
const fromScriptingAPI = e.fromScriptingAPI;
if (shouldIgnoreKeyEvents(document.activeElement) || e.defaultPrevented) {
return;
}
this.toolbarManager_.hideToolbarsAfterTimeout();
const pageUpHandler = () => {
// Go to the previous page if we are fit-to-page or fit-to-height.
if (this.viewport_.isPagedMode()) {
this.viewport_.goToPreviousPage();
// Since we do the movement of the page.
e.preventDefault();
} else if (fromScriptingAPI) {
position.y -= this.viewport_.size.height;
this.viewport_.position = position;
}
};
const pageDownHandler = () => {
// Go to the next page if we are fit-to-page or fit-to-height.
if (this.viewport_.isPagedMode()) {
this.viewport_.goToNextPage();
// Since we do the movement of the page.
e.preventDefault();
} else if (fromScriptingAPI) {
position.y += this.viewport_.size.height;
this.viewport_.position = position;
}
};
switch (e.keyCode) {
case 9: // Tab key.
this.toolbarManager_.showToolbarsForKeyboardNavigation();
return;
case 27: // Escape key.
if (!this.isPrintPreview_) {
this.toolbarManager_.hideSingleToolbarLayer();
return;
}
break; // Ensure escape falls through to the print-preview handler.
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.
if (!hasKeyModifiers(e)) {
// Go to the previous page if there are no horizontal scrollbars and
// no form field is focused.
if (!(this.viewport_.documentHasScrollbars().horizontal ||
this.isFormFieldFocused_)) {
this.viewport_.goToPreviousPage();
// 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.
if (!hasKeyModifiers(e)) {
// Go to the next page if there are no horizontal scrollbars and no
// form field is focused.
if (!(this.viewport_.documentHasScrollbars().horizontal ||
this.isFormFieldFocused_)) {
this.viewport_.goToNextPage();
// 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.pluginController_.selectAll();
// Since we do selection ourselves.
e.preventDefault();
}
return;
case 71: // 'g' key.
if (this.toolbar_ && (e.ctrlKey || e.metaKey) && e.altKey) {
this.toolbarManager_.showToolbars();
this.toolbar_.selectPageNumber();
}
return;
case 219: // Left bracket key.
if (e.ctrlKey) {
this.rotateCounterclockwise_();
}
return;
case 220: // Backslash key.
if (e.ctrlKey) {
this.zoomToolbar_.fitToggleFromHotKey();
}
return;
case 221: // Right bracket key.
if (e.ctrlKey) {
this.rotateClockwise_();
}
return;
}
// Give print preview a chance to handle the key event.
if (!fromScriptingAPI && this.isPrintPreview_) {
this.sendScriptingMessage_(
{type: 'sendKeyEvent', keyEvent: SerializeKeyEvent(e)});
} else {
// Show toolbars as a fallback.
if (!(e.shiftKey || e.ctrlKey || e.altKey)) {
this.toolbarManager_.showToolbars();
}
}
}
handleMouseEvent_(e) {
if (e.type == 'mousemove') {
this.toolbarManager_.handleMouseMove(e);
} else if (e.type == 'mouseout') {
this.toolbarManager_.hideToolbarsForMouseOut();
}
}
/**
* @param {!Event} e The context menu event
* @private
*/
handleContextMenuEvent_(e) {
// Stop Chrome from popping up the context menu on long press. We need to
// make sure the start event did not have 2 touches because we don't want
// to block two finger tap opening the context menu. We check for
// firesTouchEvents in order to not block the context menu on right click.
const capabilities =
/** @type {{ sourceCapabilities: Object }} */ (e).sourceCapabilities;
if (capabilities.firesTouchEvents &&
!this.gestureDetector_.wasTwoFingerTouch()) {
e.preventDefault();
}
}
/**
* Handles the annotation mode being toggled on or off.
* @param {!CustomEvent<{value: boolean}>} e
* @private
*/
async annotationModeToggled_(e) {
const annotationMode = e.detail.value;
if (annotationMode) {
// Enter annotation mode.
assert(this.currentController_ == this.pluginController_);
// TODO(dstockwell): set plugin read-only, begin transition
this.updateProgress_(0);
// TODO(dstockwell): handle save failure
const saveResult = await this.pluginController_.save(true);
// Data always exists when save is called with requireResult = true.
const result = /** @type {!RequiredSaveResult} */ (saveResult);
if (result.hasUnsavedChanges) {
assert(!loadTimeData.getBoolean('pdfFormSaveEnabled'));
try {
await $('form-warning').show();
} catch (e) {
// The user aborted entering annotation mode. Revert to the plugin.
this.toolbar_.annotationMode = false;
this.updateProgress_(100);
return;
}
}
PDFMetrics.record(PDFMetrics.UserAction.ENTER_ANNOTATION_MODE);
this.hasEnteredAnnotationMode_ = true;
// TODO(dstockwell): feed real progress data from the Ink component
this.updateProgress_(50);
await this.inkController_.load(result.fileName, result.dataToSave);
this.inkController_.setAnnotationTool(
assert(this.toolbar_.annotationTool));
this.currentController_ = this.inkController_;
this.pluginController_.unload();
this.updateProgress_(100);
} else {
// Exit annotation mode.
PDFMetrics.record(PDFMetrics.UserAction.EXIT_ANNOTATION_MODE);
assert(this.currentController_ == this.inkController_);
// TODO(dstockwell): set ink read-only, begin transition
this.updateProgress_(0);
// This runs separately to allow other consumers of `loaded` to queue
// up after this task.
this.loaded.then(() => {
this.currentController_ = this.pluginController_;
this.inkController_.unload();
});
// TODO(dstockwell): handle save failure
const saveResult = await this.inkController_.save(true);
// Data always exists when save is called with requireResult = true.
const result = /** @type {!RequiredSaveResult} */ (saveResult);
await this.pluginController_.load(result.fileName, result.dataToSave);
// Ensure the plugin gets the initial viewport.
this.pluginController_.afterZoom();
}
}
/**
* Exits annotation mode if active.
* @return {Promise<void>}
*/
async exitAnnotationMode_() {
if (!this.toolbar_.annotationMode) {
return;
}
this.toolbar_.toggleAnnotation();
await this.loaded;
}
/**
* Request to change the viewport fitting type.
* @param {!CustomEvent<FitToChangedEvent>} e
* @private
*/
fitToChanged_(e) {
if (e.detail.fittingType == FittingType.FIT_TO_PAGE) {
this.viewport_.fitToPage();
this.toolbarManager_.forceHideTopToolbar();
} else if (e.detail.fittingType == FittingType.FIT_TO_WIDTH) {
this.viewport_.fitToWidth();
} else if (e.detail.fittingType == FittingType.FIT_TO_HEIGHT) {
this.viewport_.fitToHeight();
this.toolbarManager_.forceHideTopToolbar();
}
if (e.detail.userInitiated) {
PDFMetrics.recordFitTo(e.detail.fittingType);
}
}
/**
* Sends a 'documentLoaded' message to the PDFScriptingAPI if the document has
* finished loading.
* @private
*/
sendDocumentLoadedMessage_() {
if (this.loadState_ == LoadState.LOADING) {
return;
}
if (this.isPrintPreview_ && !this.isPrintPreviewLoadingFinished_) {
return;
}
this.sendScriptingMessage_(
{type: 'documentLoaded', load_state: this.loadState_});
}
/**
* 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 {Object} 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.zoomToolbar_.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;
}
}
/**
* Moves the viewport to a point in a page. Called back after a
* 'transformPagePointReply' is returned from the plugin.
* @param {string} origin Identifier for the caller for logging purposes.
* @param {number} page The index of the page to go to. zero-based.
* @param {Point} message Message received from the plugin containing the
* x and y to navigate to in screen coordinates.
* @private
*/
goToPageAndXY_(origin, page, message) {
this.viewport_.goToPageAndXY(page, message.x, message.y);
if (origin == 'bookmark') {
PDFMetrics.record(PDFMetrics.UserAction.FOLLOW_BOOKMARK);
}
}
/**
* @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} The viewport. Used for testing. */
get viewport() {
return this.viewport_;
}
/** @return {!Array<!Bookmark>} The bookmarks. Used for testing. */
get bookmarks() {
return this.bookmarks_;
}
/**
* Updates the load state and triggers completion of the `loaded`
* promise if necessary.
* @param {!LoadState} loadState
* @private
*/
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();
}
}
/**
* 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.
* @private
*/
updateProgress_(progress) {
if (this.toolbar_) {
this.toolbar_.loadProgress = progress;
}
if (progress == -1) {
// Document load failed.
this.errorScreen_.show();
this.sizer_.style.display = 'none';
if (this.passwordScreen_.active) {
this.passwordScreen_.deny();
this.passwordScreen_.close();
}
this.setLoadState_(LoadState.FAILED);
this.isPrintPreviewLoadingFinished_ = true;
this.sendDocumentLoadedMessage_();
} else if (progress == 100) {
// Document load complete.
if (this.lastViewportPosition_) {
this.viewport_.position = this.lastViewportPosition_;
}
this.paramsParser_.getViewportFromUrlParams(
this.originalUrl_, params => this.handleURLParams_(params));
this.setLoadState_(LoadState.SUCCESS);
this.sendDocumentLoadedMessage_();
while (this.delayedScriptingMessages_.length > 0) {
this.handleScriptingMessage(this.delayedScriptingMessages_.shift());
}
this.toolbarManager_.hideToolbarsAfterTimeout();
} else {
this.setLoadState_(LoadState.LOADING);
}
}
/** @private */
sendBackgroundColorForPrintPreview_() {
this.pluginController_.backgroundColorChanged(
this.dark_ ? PDFViewer.PRINT_PREVIEW_DARK_BACKGROUND_COLOR :
PDFViewer.PRINT_PREVIEW_BACKGROUND_COLOR);
}
/**
* Load a dictionary of translated strings into the UI. Used as a callback for
* chrome.resourcesPrivate.
* @param {Object} strings Dictionary of translated strings
* @private
*/
handleStrings_(strings) {
const stringsDictionary =
/** @type {{ textdirection: string, language: string }} */ (strings);
document.documentElement.dir = stringsDictionary.textdirection;
document.documentElement.lang = stringsDictionary.language;
loadTimeData.data = strings;
const isNewPrintPreview = this.isPrintPreview_ &&
loadTimeData.getBoolean('newPrintPreviewLayoutEnabled');
if (isNewPrintPreview) {
this.sendBackgroundColorForPrintPreview_();
this.toolbarManager_.reverseSideToolbar();
}
this.reverseZoomToolbar_ = isNewPrintPreview;
this.zoomToolbar_.newPrintPreview = isNewPrintPreview;
$('toolbar').strings = strings;
$('toolbar').pdfAnnotationsEnabled =
loadTimeData.getBoolean('pdfAnnotationsEnabled');
$('toolbar').printingEnabled = loadTimeData.getBoolean('printingEnabled');
$('zoom-toolbar').setStrings(strings);
$('password-screen').strings = strings;
$('error-screen').strings = strings;
if ($('form-warning')) {
$('form-warning').strings = strings;
}
}
/**
* An event handler for handling password-submitted events. These are fired
* when an event is entered into the password screen.
* @param {!CustomEvent<{password: string}>} event a password-submitted event.
* @private
*/
onPasswordSubmitted_(event) {
this.pluginController_.getPasswordComplete(event.detail.password);
}
/**
* 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;
}
/**
* A callback that's called when an update to a pinch zoom is detected.
* @param {!Object} e the pinch event.
* @private
*/
onPinchUpdate_(e) {
// Throttle number of pinch events to one per frame.
if (!this.sentPinchEvent_) {
this.sentPinchEvent_ = true;
window.requestAnimationFrame(() => {
this.sentPinchEvent_ = false;
this.viewport_.pinchZoom(e);
});
}
}
/**
* A callback that's called when the end of a pinch zoom is detected.
* @param {!Object} e the pinch event.
* @private
*/
onPinchEnd_(e) {
// Using rAF for pinch end prevents pinch updates scheduled by rAF getting
// sent after the pinch end.
window.requestAnimationFrame(() => {
this.viewport_.pinchZoomEnd(e);
});
}
/**
* A callback that's called when the start of a pinch zoom is detected.
* @param {!Object} e the pinch event.
* @private
*/
onPinchStart_(e) {
// We also use rAF for pinch start, so that if there is a pinch end event
// scheduled by rAF, this pinch start will be sent after.
window.requestAnimationFrame(() => {
this.viewport_.pinchZoomStart(e);
});
}
/**
* A callback that's called after the viewport changes.
* @private
*/
viewportChanged_() {
if (!this.documentDimensions_) {
return;
}
// Offset the toolbar position so that it doesn't move if scrollbars appear.
const hasScrollbars = this.viewport_.documentHasScrollbars();
const scrollbarWidth = this.viewport_.scrollbarWidth;
const verticalScrollbarWidth = hasScrollbars.vertical ? scrollbarWidth : 0;
const horizontalScrollbarWidth =
hasScrollbars.horizontal ? scrollbarWidth : 0;
// Shift the zoom toolbar to the left by half a scrollbar width. This
// gives a compromise: if there is no scrollbar visible then the toolbar
// will be half a scrollbar width further left than the spec but if there
// is a scrollbar visible it will be half a scrollbar width further right
// than the spec. In RTL layout normally, and in LTR layout in Print Preview
// when the NewPrintPreview flag is enabled, the zoom toolbar is on the left
// left side, but the scrollbar is still on the right, so this is not
// necessary.
if (isRTL() === this.reverseZoomToolbar_) {
this.zoomToolbar_.style.right =
-verticalScrollbarWidth + (scrollbarWidth / 2) + 'px';
}
// Having a horizontal scrollbar is much rarer so we don't offset the
// toolbar from the bottom any more than what the spec says. This means
// that when there is a scrollbar visible, it will be a full scrollbar
// width closer to the bottom of the screen than usual, but this is ok.
this.zoomToolbar_.style.bottom = -horizontalScrollbarWidth + 'px';
// Update the page indicator.
const visiblePage = this.viewport_.getMostVisiblePage();
if (this.toolbar_) {
this.toolbar_.pageNo = visiblePage + 1;
}
// TODO(raymes): Give pageIndicator_ the same API as toolbar_.
if (this.pageIndicator_) {
this.pageIndicator_.index = visiblePage;
if (this.documentDimensions_.pageDimensions.length > 1 &&
hasScrollbars.vertical) {
this.pageIndicator_.style.visibility = 'visible';
} else {
this.pageIndicator_.style.visibility = 'hidden';
}
}
this.currentController_.viewportChanged();
const visiblePageDimensions = this.viewport_.getPageScreenRect(visiblePage);
const size = this.viewport_.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_();
}
}
if (this.handlePrintPreviewScriptingMessage_(message)) {
return;
}
// 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;
}
switch (message.data.type.toString()) {
case 'getSelectedText':
this.pluginController_.getSelectedText();
break;
case 'print':
this.pluginController_.print();
break;
case 'selectAll':
this.pluginController_.selectAll();
break;
}
}
/**
* Handle scripting messages specific to print preview.
* @param {!MessageObject} message the message to handle.
* @return {boolean} true if the message was handled, false otherwise.
* @private
*/
handlePrintPreviewScriptingMessage_(message) {
if (!this.isPrintPreview_) {
return false;
}
let messageData = message.data;
switch (messageData.type.toString()) {
case 'loadPreviewPage':
messageData =
/** @type {{ url: string, index: number }} */ (messageData);
this.pluginController_.loadPreviewPage(
messageData.url, messageData.index);
return true;
case 'resetPrintPreviewMode':
messageData = /** @type {!PrintPreviewParams} */ (messageData);
this.setLoadState_(LoadState.LOADING);
if (!this.inPrintPreviewMode_) {
this.inPrintPreviewMode_ = true;
this.isUserInitiatedEvent_ = false;
this.zoomToolbar_.forceFit(FittingType.FIT_TO_PAGE);
this.isUserInitiatedEvent_ = true;
}
// 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.
const printButton = $('print-button');
if (printButton) {
printButton.parentNode.removeChild(printButton);
}
const saveButton = $('save-button');
if (saveButton) {
saveButton.parentNode.removeChild(saveButton);
}
this.pageIndicator_.pageLabels = messageData.pageNumbers;
this.pluginController_.resetPrintPreviewMode(messageData);
return true;
case 'sendKeyEvent':
this.handleKeyEvent_(/** @type {!KeyboardEvent} */ (DeserializeKeyEvent(
/** @type {{ keyEvent: Object }} */ (message.data).keyEvent)));
return true;
case 'hideToolbars':
this.toolbarManager_.resetKeyboardNavigationAndHideToolbars();
return true;
case 'darkModeChanged':
this.dark_ = /** @type {{darkMode: boolean}} */ (message.data).darkMode;
if (this.isPrintPreview_) {
this.sendBackgroundColorForPrintPreview_();
}
return true;
case 'scrollPosition':
const position = this.viewport_.position;
messageData = /** @type {{ x: number, y: number }} */ (message.data);
position.y += messageData.y;
position.x += messageData.x;
this.viewport_.position = position;
return true;
}
return false;
}
/**
* Send a scripting message outside the extension (typically to
* PDFScriptingAPI in a page containing the extension).
* @param {Object} message the message to send.
* @private
*/
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 messages through as this won't
// leak important information.
if (this.parentOrigin_ == window.location.origin) {
targetOrigin = this.parentOrigin_;
} else if (message.type == 'documentLoaded') {
targetOrigin = '*';
} else {
targetOrigin = this.originalUrl_;
}
this.parentWindow_.postMessage(message, targetOrigin);
}
}
/**
* @param {!CustomEvent<MessageData>} e
* @private
*/
handlePluginMessage_(e) {
const data = e.detail;
switch (data.type.toString()) {
case 'beep':
this.handleBeep_();
return;
case 'documentDimensions':
this.setDocumentDimensions_(
/** @type {!DocumentDimensionsMessageData} */ (data));
return;
case 'getPassword':
this.handlePasswordRequest_();
return;
case 'getSelectedTextReply':
this.handleSelectedTextReply_(
/** @type {{ selectedText: string }} */ (data).selectedText);
return;
case 'loadProgress':
this.updateProgress_(
/** @type {{ progress: number }} */ (data).progress);
return;
case 'navigate':
const navigateData = /** @type {!NavigateMessageData} */ (data);
this.handleNavigate_(navigateData.url, navigateData.disposition);
return;
case 'navigateToDestination':
const destinationData = /** @type {!DestinationMessageData} */ (data);
this.handleNavigateToDestination_(
destinationData.page, destinationData.x, destinationData.y,
destinationData.zoom);
return;
case 'printPreviewLoaded':
this.handlePrintPreviewLoaded_();
return;
case 'metadata':
const metadata = /** @type {!MetadataMessageData} */ (data);
this.setDocumentMetadata_(
metadata.title, metadata.bookmarks, metadata.canSerializeDocument);
return;
case 'setIsSelecting':
this.setIsSelecting_(
/** @type {{ isSelecting: boolean }} */ (data).isSelecting);
return;
case 'getNamedDestinationReply':
this.paramsParser_.onNamedDestinationReceived(
/** @type {{ pageNumber: number }} */ (data).pageNumber);
return;
case 'formFocusChange':
this.isFormFieldFocused_ =
/** @type {{ focused: boolean }} */ (data).focused;
return;
}
assertNotReached('Unknown message type received: ' + data.type);
}
/**
* Sets document dimensions from the current controller.
* @param {!DocumentDimensionsMessageData} documentDimensions
* @private
*/
setDocumentDimensions_(documentDimensions) {
this.documentDimensions_ = documentDimensions;
this.isUserInitiatedEvent_ = false;
this.viewport_.setDocumentDimensions(this.documentDimensions_);
this.isUserInitiatedEvent_ = true;
// If we received the document dimensions, the password was good so we
// can dismiss the password screen.
if (this.passwordScreen_.active) {
this.passwordScreen_.close();
}
if (this.toolbar_) {
this.toolbar_.docLength = this.documentDimensions_.pageDimensions.length;
}
}
/**
* Handles a beep request from the current controller.
* @private
*/
handleBeep_() {
// Beeps are annoying, so just track count for now.
this.beepCount_ += 1;
}
/**
* Handles a password request from the current controller.
* @private
*/
handlePasswordRequest_() {
// 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.hadPassword_ = true;
this.updateAnnotationAvailable_();
this.passwordScreen_.show();
} else {
this.passwordScreen_.deny();
}
}
/**
* Handles a selected text reply from the current controller.
* @param {string} selectedText
* @private
*/
handleSelectedTextReply_(selectedText) {
this.sendScriptingMessage_({
type: 'getSelectedTextReply',
selectedText: selectedText,
});
}
/**
* Handles a navigation request from the current controller.
* @param {string} url
* @param {!PdfNavigator.WindowOpenDisposition} disposition
* @private
*/
handleNavigate_(url, disposition) {
// If in print preview, always open a new tab.
if (this.isPrintPreview_) {
this.navigator_.navigate(
url, PdfNavigator.WindowOpenDisposition.NEW_BACKGROUND_TAB);
} else {
this.navigator_.navigate(url, disposition);
}
}
/**
* Handles an internal navigation request to a destination from the current
* controller.
*
* @param {number} page
* @param {number} x
* @param {number} y
* @param {number} zoom
* @private
*/
handleNavigateToDestination_(page, x, y, zoom) {
if (zoom) {
this.viewport_.setZoom(zoom);
}
if (x || y) {
this.viewport_.goToPageAndXY(page, x ? x : 0, y ? y : 0);
} else {
this.viewport_.goToPage(page);
}
}
/**
* Handles a notification that print preview has loaded from the
* current controller.
* @private
*/
handlePrintPreviewLoaded_() {
this.isPrintPreviewLoadingFinished_ = true;
this.sendDocumentLoadedMessage_();
}
/**
* Sets document metadata from the current controller.
* @param {string} title
* @param {!Array<!Bookmark>} bookmarks
* @param {boolean} canSerializeDocument
* @private
*/
setDocumentMetadata_(title, bookmarks, canSerializeDocument) {
if (title) {
document.title = title;
} else {
document.title = getFilenameFromURL(this.originalUrl_);
}
this.bookmarks_ = bookmarks;
if (this.toolbar_) {
this.toolbar_.docTitle = document.title;
this.toolbar_.bookmarks = this.bookmarks_;
}
this.canSerializeDocument_ = canSerializeDocument;
this.updateAnnotationAvailable_();
}
/**
* Sets the is selecting flag from the current controller.
* @param {boolean} isSelecting
* @private
*/
setIsSelecting_(isSelecting) {
this.viewportScroller_.setEnableScrolling(isSelecting);
}
/**
* An event handler for when the browser tells the PDF Viewer to perform a
* save.
* @param {string} streamUrl unique identifier for a PDF Viewer instance.
* @private
*/
async onSave_(streamUrl) {
if (streamUrl != this.browserApi_.getStreamInfo().streamUrl) {
return;
}
this.save_();
}
/**
* Saves the current PDF document to disk.
* @private
*/
async save_() {
PDFMetrics.record(PDFMetrics.UserAction.SAVE);
if (this.hasEnteredAnnotationMode_) {
PDFMetrics.record(PDFMetrics.UserAction.SAVE_WITH_ANNOTATION);
}
// If we have entered annotation mode we must require the local
// contents to ensure annotations are saved. Otherwise we would
// save the cached or remote copy without annotatios.
const requireResult = this.hasEnteredAnnotationMode_;
// TODO(dstockwell): Report an error to user if this fails.
const result = await this.currentController_.save(requireResult);
if (result == null) {
// The content controller handled the save internally.
return;
}
// Make sure file extension is .pdf, avoids dangerous extensions.
let fileName = result.fileName;
if (!fileName.toLowerCase().endsWith('.pdf')) {
fileName = fileName + '.pdf';
}
chrome.fileSystem.chooseEntry(
{type: 'saveFile', suggestedName: fileName}, entry => {
if (chrome.runtime.lastError) {
if (chrome.runtime.lastError.message != 'User cancelled') {
console.log(
'chrome.fileSystem.chooseEntry failed: ' +
chrome.runtime.lastError.message);
}
return;
}
entry.createWriter(writer => {
writer.write(
new Blob([result.dataToSave], {type: 'application/pdf'}));
// Unblock closing the window now that the user has saved
// successfully.
chrome.mimeHandlerPrivate.setShowBeforeUnloadDialog(false);
});
});
// Saving in Annotation mode is destructive: crbug.com/919364
this.exitAnnotationMode_();
}
/** @private */
async print_() {
PDFMetrics.record(PDFMetrics.UserAction.PRINT);
await this.exitAnnotationMode_();
this.currentController_.print();
}
/**
* Updates the toolbar's annotation available flag depending on current
* conditions.
* @private
*/
updateAnnotationAvailable_() {
if (!this.toolbar_) {
return;
}
let annotationAvailable = true;
if (this.viewport_.getClockwiseRotations() != 0) {
annotationAvailable = false;
}
if (this.hadPassword_) {
annotationAvailable = false;
}
if (!this.canSerializeDocument_) {
annotationAvailable = false;
}
this.toolbar_.annotationAvailable = annotationAvailable;
}
/** @private */
rotateClockwise_() {
PDFMetrics.record(PDFMetrics.UserAction.ROTATE);
this.viewport_.rotateClockwise(1);
this.currentController_.rotateClockwise();
this.updateAnnotationAvailable_();
}
/** @private */
rotateCounterclockwise_() {
PDFMetrics.record(PDFMetrics.UserAction.ROTATE);
this.viewport_.rotateClockwise(3);
this.currentController_.rotateCounterclockwise();
this.updateAnnotationAvailable_();
}
/**
* @param {!CustomEvent<{canUndo: boolean, canRedo: boolean}>} e
* @private
*/
setAnnotationUndoState_(e) {
this.toolbar_.canUndoAnnotation = e.detail.canUndo;
this.toolbar_.canRedoAnnotation = e.detail.canRedo;
}
/** @private */
resetTrackers_() {
this.viewport_.resetTracker();
if (this.tracker_) {
this.tracker_.removeAll();
}
}
}
/**
* The height of the toolbar along the top of the page. The document will be
* shifted down by this much in the viewport.
*/
PDFViewer.MATERIAL_TOOLBAR_HEIGHT = 56;
/**
* Minimum height for the material toolbar to show (px). Should match the media
* query in index-material.css. If the window is smaller than this at load,
* leave no space for the toolbar.
*/
PDFViewer.TOOLBAR_WINDOW_MIN_HEIGHT = 250;
/**
* The background color used for print preview (--google-grey-refresh-300).
*/
PDFViewer.PRINT_PREVIEW_BACKGROUND_COLOR = '0xFFDADCE0';
/**
* The background color used for print preview when dark mode is enabled
* (--google-grey-refresh-700).
*/
PDFViewer.PRINT_PREVIEW_DARK_BACKGROUND_COLOR = '0xFF5F6368';
/**
* The background color used for the regular viewer.
*/
PDFViewer.BACKGROUND_COLOR = '0xFF525659';