blob: 27b97157e8f5cae17a70f8313e4045f30c893355 [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.
import './elements/viewer-error-dialog.js';
// <if expr="enable_ink">
import './elements/viewer-ink-host.js';
// </if>
import './elements/viewer-password-dialog.js';
import './elements/viewer-pdf-sidenav.js';
import './elements/viewer-properties-dialog.js';
import './elements/viewer-toolbar.js';
import './elements/shared-vars.js';
import './pdf_viewer_shared_style.js';
import 'chrome://resources/cr_elements/hidden_style_css.m.js';
import 'chrome://resources/cr_elements/shared_vars_css.m.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert_ts.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {listenOnce} from 'chrome://resources/js/util.m.js';
import {Bookmark} from './bookmark_type.js';
import {BrowserApi} from './browser_api.js';
import {Attachment, DocumentMetadata, ExtendedKeyEvent, FittingType, Point, SaveRequestType} from './constants.js';
import {MessageData, PluginController} from './controller.js';
// <if expr="enable_ink">
import {ContentController} from './controller.js';
// </if>
import {ChangePageAndXyDetail, ChangePageDetail, NavigateDetail} from './elements/viewer-bookmark.js';
import {ViewerErrorDialogElement} from './elements/viewer-error-dialog.js';
import {ViewerPasswordDialogElement} from './elements/viewer-password-dialog.js';
import {ViewerPdfSidenavElement} from './elements/viewer-pdf-sidenav.js';
import {ViewerToolbarElement} from './elements/viewer-toolbar.js';
// <if expr="enable_ink">
import {InkController, InkControllerEventType} from './ink_controller.js';
//</if>
import {LocalStorageProxyImpl} from './local_storage_proxy.js';
import {record, UserAction} from './metrics.js';
import {NavigatorDelegateImpl, PdfNavigator, WindowOpenDisposition} from './navigator.js';
import {deserializeKeyEvent, LoadState} from './pdf_scripting_api.js';
import {getTemplate} from './pdf_viewer.html.js';
import {KeyEventData, PDFViewerBaseElement} from './pdf_viewer_base.js';
import {DestinationMessageData, DocumentDimensionsMessageData, hasCtrlModifier, shouldIgnoreKeyEvents} from './pdf_viewer_utils.js';
type EmailMessageData = {
type: string,
to: string,
cc: string,
bcc: string,
subject: string,
body: string,
};
type NavigateMessageData = {
type: string,
url: string,
disposition: WindowOpenDisposition,
};
type ZoomBounds = {
min: number,
max: number,
};
/**
* Return the filename component of a URL, percent decoded if possible.
* Exported for tests.
*/
export function getFilenameFromURL(url: string): string {
// 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;
}
}
function eventToPromise(event: string, target: HTMLElement): Promise<void> {
return new Promise(
resolve => listenOnce(target, event, (_e: Event) => resolve()));
}
const LOCAL_STORAGE_SIDENAV_COLLAPSED_KEY: string = 'sidenavCollapsed';
/**
* The background color used for the regular viewer. Its decimal value in string
* format should match `kPdfViewerBackgroundColor` in
* components/pdf/browser/plugin_response_writer.cc.
*/
const BACKGROUND_COLOR: number = 0xff525659;
export interface PDFViewerElement {
$: {
content: HTMLElement,
scroller: HTMLElement,
sizer: HTMLElement,
toolbar: ViewerToolbarElement,
};
}
export class PDFViewerElement extends PDFViewerBaseElement {
static get is() {
return 'pdf-viewer';
}
static get template() {
return getTemplate();
}
static override get properties() {
return {
annotationAvailable_: {
type: Boolean,
computed: 'computeAnnotationAvailable_(' +
'hadPassword_, clockwiseRotations_, canSerializeDocument_,' +
'twoUpViewEnabled_)',
},
annotationMode_: {
type: Boolean,
value: false,
},
attachments_: {
type: Array,
value: () => [],
},
bookmarks_: {
type: Array,
value: () => [],
},
canSerializeDocument_: {
type: Boolean,
value: false,
},
clockwiseRotations_: {
type: Number,
value: 0,
},
/** The number of pages in the PDF document. */
docLength_: Number,
documentHasFocus_: {
type: Boolean,
value: false,
},
documentMetadata_: {
type: Object,
value: () => {},
},
fileName_: String,
hadPassword_: {
type: Boolean,
value: false,
},
hasEdits_: {
type: Boolean,
value: false,
},
hasEnteredAnnotationMode_: {
type: Boolean,
value: false,
},
isFormFieldFocused_: {
type: Boolean,
value: false,
},
/** The current loading progress of the PDF document (0 - 100). */
loadProgress_: Number,
/** The number of the page being viewed (1-based). */
pageNo_: Number,
pdfAnnotationsEnabled_: {
type: Boolean,
value: false,
},
printingEnabled_: {
type: Boolean,
value: false,
},
showPasswordDialog_: {
type: Boolean,
value: false,
},
showPropertiesDialog_: {
type: Boolean,
value: false,
},
sidenavCollapsed_: {
type: Boolean,
value: false,
},
title_: String,
twoUpViewEnabled_: {
type: Boolean,
value: false,
},
viewportZoom_: {
type: Number,
value: 1,
},
zoomBounds_: {
type: Object,
value: () => ({min: 0, max: 0}),
},
};
}
beepCount: number = 0;
private annotationAvailable_: boolean;
private annotationMode_: boolean;
private attachments_: Attachment[];
private bookmarks_: Bookmark[];
private canSerializeDocument_: boolean;
private clockwiseRotations_: number;
private docLength_: number;
private documentHasFocus_: boolean;
private documentMetadata_: DocumentMetadata;
private fileName_: string;
private hadPassword_: boolean;
private hasEdits_: boolean;
private hasEnteredAnnotationMode_: boolean;
private isFormFieldFocused_: boolean;
private loadProgress_: number;
private navigator_: PdfNavigator|null = null;
private pageNo_: number;
private pdfAnnotationsEnabled_: boolean;
private pluginController_: PluginController|null = null;
private printingEnabled_: boolean;
private showPasswordDialog_: boolean;
private showPropertiesDialog_: boolean;
private sidenavCollapsed_: boolean;
/**
* The state to which to restore `sidenavCollapsed_` after exiting annotation
* mode.
*/
private sidenavRestoreState_: boolean = false;
private title_: string;
private toolbarEnabled_: boolean = false;
private twoUpViewEnabled_: boolean;
private viewportZoom_: number;
private zoomBounds_: ZoomBounds;
// <if expr="enable_ink">
private inkController_: InkController|null = null;
// </if>
constructor() {
super();
// TODO(dpapad): Add tests after crbug.com/1111459 is fixed.
this.sidenavCollapsed_ = Boolean(Number.parseInt(
LocalStorageProxyImpl.getInstance().getItem(
LOCAL_STORAGE_SIDENAV_COLLAPSED_KEY)!,
10));
}
getBackgroundColor(): number {
return BACKGROUND_COLOR;
}
init(browserApi: BrowserApi) {
this.initInternal(
browserApi, this.$.scroller, this.$.sizer, this.$.content);
this.pluginController_ = PluginController.getInstance();
// <if expr="enable_ink">
this.inkController_ = InkController.getInstance();
this.inkController_.init(this.viewport);
this.tracker.add(
this.inkController_.getEventTarget(),
InkControllerEventType.HAS_UNSAVED_CHANGES,
() => chrome.mimeHandlerPrivate.setShowBeforeUnloadDialog(true));
// </if>
this.fileName_ = getFilenameFromURL(this.originalUrl);
this.title_ = this.fileName_;
assert(this.paramsParser);
this.toolbarEnabled_ =
this.paramsParser.shouldShowToolbar(this.originalUrl);
if (this.toolbarEnabled_) {
this.$.toolbar.hidden = false;
}
this.navigator_ = new PdfNavigator(
this.originalUrl, this.viewport, this.paramsParser,
new NavigatorDelegateImpl(browserApi));
// Listen for save commands from the browser.
if (chrome.mimeHandlerPrivate && chrome.mimeHandlerPrivate.onSave) {
chrome.mimeHandlerPrivate.onSave.addListener(this.onSave_.bind(this));
}
}
handleKeyEvent(e: KeyboardEvent) {
if (shouldIgnoreKeyEvents() || e.defaultPrevented) {
return;
}
// Let the viewport handle directional key events.
if (this.viewport.handleDirectionalKeyEvent(e, this.isFormFieldFocused_)) {
return;
}
if (document.fullscreenElement !== null) {
// Disable zoom shortcuts in Presentation mode.
// Handle '+' and '-' buttons (both in the numpad and elsewhere).
if (hasCtrlModifier(e) &&
(e.key === '=' || e.key === '-' || e.key === '+')) {
e.preventDefault();
}
// Disable further key handling when in Presentation mode.
return;
}
switch (e.key) {
case 'a':
if (hasCtrlModifier(e)) {
this.pluginController_!.selectAll();
// Since we do selection ourselves.
e.preventDefault();
}
return;
case '[':
// Do not use hasCtrlModifier() here, since Command + [ is already
// taken by the "go back to the previous webpage" action.
if (e.ctrlKey) {
this.rotateCounterclockwise();
}
return;
case ']':
// Do not use hasCtrlModifier() here, since Command + ] is already
// taken by the "go forward to the next webpage" action.
if (e.ctrlKey) {
this.rotateClockwise();
}
return;
}
// Handle toolbar related key events.
this.handleToolbarKeyEvent_(e);
}
/**
* Helper for handleKeyEvent dealing with events that control toolbars.
*/
private handleToolbarKeyEvent_(e: KeyboardEvent) {
// TODO(thestig): Should this use hasCtrlModifier() or stay as is?
if (e.key === '\\' && e.ctrlKey) {
this.$.toolbar.fitToggle();
}
// TODO: Add handling for additional relevant hotkeys for the new unified
// toolbar.
}
// <if expr="enable_ink">
private onResetView_() {
if (this.twoUpViewEnabled_) {
assert(this.currentController);
this.currentController.setTwoUpView(false);
}
const rotations = this.viewport.getClockwiseRotations();
switch (rotations) {
case 0:
break;
case 1:
this.rotateCounterclockwise();
break;
case 2:
this.rotateCounterclockwise();
this.rotateCounterclockwise();
break;
case 3:
this.rotateClockwise();
break;
default:
assertNotReached('Invalid rotations count: ' + rotations);
}
}
/** @return Resolves when the sidenav animation is complete. */
private waitForSidenavTransition_(): Promise<void> {
return eventToPromise(
'transitionend',
this.shadowRoot!.querySelector<ViewerPdfSidenavElement>(
'#sidenav-container')!);
}
/**
* @return Resolves when the sidenav is restored to `sidenavRestoreState_`,
* after having been closed for annotation mode.
*/
private restoreSidenav_(): Promise<void> {
this.sidenavCollapsed_ = this.sidenavRestoreState_;
return this.sidenavCollapsed_ ? Promise.resolve() :
this.waitForSidenavTransition_();
}
/** Handles the annotation mode being toggled on or off. */
private async onAnnotationModeToggled_(e: CustomEvent<boolean>) {
const annotationMode = e.detail;
if (annotationMode) {
// Enter annotation mode.
assert(this.pluginController_!.isActive);
assert(!this.inkController_!.isActive);
// TODO(dstockwell): set plugin read-only, begin transition
this.updateProgress(0);
this.sidenavRestoreState_ = this.sidenavCollapsed_;
this.sidenavCollapsed_ = true;
if (!this.sidenavRestoreState_) {
// Wait for the animation before proceeding.
await this.waitForSidenavTransition_();
}
// TODO(dstockwell): handle save failure
const result =
await this.pluginController_!.save(SaveRequestType.ANNOTATION);
// Data always exists when save is called with requestType = ANNOTATION.
record(UserAction.ENTER_ANNOTATION_MODE);
this.annotationMode_ = true;
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.currentController = this.inkController_!;
this.pluginController_!.unload();
this.updateProgress(100);
} else {
// Exit annotation mode.
record(UserAction.EXIT_ANNOTATION_MODE);
assert(!this.pluginController_!.isActive);
assert(this.inkController_!.isActive);
assert(this.currentController === this.inkController_!);
// TODO(dstockwell): set ink read-only, begin transition
this.updateProgress(0);
this.annotationMode_ = false;
// This runs separately to allow other consumers of `loaded` to queue
// up after this task.
this.loaded!.then(() => {
this.inkController_!.unload();
});
// TODO(dstockwell): handle save failure
const result =
await this.inkController_!.save(SaveRequestType.ANNOTATION);
// Data always exists when save is called with requestType = ANNOTATION.
await this.restoreSidenav_();
this.currentController = this.pluginController_!;
await this.pluginController_!.load(result.fileName, result.dataToSave);
}
}
/** Exits annotation mode if active. */
async exitAnnotationMode_(): Promise<void> {
if (!this.$.toolbar.annotationMode) {
return;
}
this.$.toolbar.toggleAnnotation();
this.annotationMode_ = false;
await this.restoreSidenav_();
await this.loaded;
}
// </if>
private onDisplayAnnotationsChanged_(e: CustomEvent<boolean>) {
assert(this.currentController);
this.currentController.setDisplayAnnotations(e.detail);
}
private onPresentClick_() {
const scroller = this.$.scroller;
Promise
.all([
eventToPromise('fullscreenchange', scroller),
scroller.requestFullscreen(),
])
.then(() => {
this.forceFit(FittingType.FIT_TO_HEIGHT);
// Switch viewport's wheel behavior.
this.viewport.setPresentationMode(true);
// Restrict the content to read only (e.g. disable forms and links).
this.pluginController_!.setReadOnly(true);
// Revert back to the normal state when exiting Presentation mode.
eventToPromise('fullscreenchange', scroller).then(() => {
assert(document.fullscreenElement === null);
this.viewport.setPresentationMode(false);
this.pluginController_!.setReadOnly(false);
// Ensure that directional keys still work after exiting.
this.shadowRoot!.querySelector('embed')!.focus();
});
// Nothing else to do here. The viewport will be updated as a result
// of a 'resize' event callback.
});
}
private onPropertiesClick_() {
assert(!this.showPropertiesDialog_);
this.showPropertiesDialog_ = true;
}
private onPropertiesDialogClose_() {
assert(this.showPropertiesDialog_);
this.showPropertiesDialog_ = false;
}
/**
* Changes two up view mode for the controller. Controller will trigger
* layout update later, which will update the viewport accordingly.
*/
private onTwoUpViewChanged_(e: CustomEvent<boolean>) {
const twoUpViewEnabled = e.detail;
assert(this.currentController);
this.currentController.setTwoUpView(twoUpViewEnabled);
record(
twoUpViewEnabled ? UserAction.TWO_UP_VIEW_ENABLE :
UserAction.TWO_UP_VIEW_DISABLE);
}
/**
* Moves the viewport to a point in a page. Called back after a
* 'transformPagePointReply' is returned from the plugin.
* @param origin Identifier for the caller for logging purposes.
* @param page The index of the page to go to. zero-based.
* @param message Message received from the plugin containing the x and y to
* navigate to in screen coordinates.
*/
private goToPageAndXY_(origin: string, page: number, message: Point) {
this.viewport.goToPageAndXY(page, message.x, message.y);
if (origin === 'bookmark') {
record(UserAction.FOLLOW_BOOKMARK);
}
}
/** @return The bookmarks. Used for testing. */
get bookmarks(): Bookmark[] {
return this.bookmarks_;
}
override setLoadState(loadState: LoadState) {
super.setLoadState(loadState);
if (loadState === LoadState.FAILED) {
this.closePasswordDialog_();
}
}
override updateProgress(progress: number) {
if (this.toolbarEnabled_) {
this.loadProgress_ = progress;
}
super.updateProgress(progress);
}
private onErrorDialog_() {
// The error screen can only reload from a normal tab.
if (!chrome.tabs || this.browserApi!.getStreamInfo().tabId === -1) {
return;
}
const errorDialog =
this.shadowRoot!.querySelector<ViewerErrorDialogElement>(
'#error-dialog')!;
errorDialog.reloadFn = () => {
chrome.tabs.reload(this.browserApi!.getStreamInfo().tabId);
};
}
private closePasswordDialog_() {
const passwordDialog =
this.shadowRoot!.querySelector<ViewerPasswordDialogElement>(
'#password-dialog')!;
if (passwordDialog) {
passwordDialog.close();
}
}
private onPasswordDialogClose_() {
this.showPasswordDialog_ = false;
}
/**
* An event handler for handling password-submitted events. These are fired
* when an event is entered into the password dialog.
* @param event A password-submitted event.
*/
private onPasswordSubmitted_(event: CustomEvent<{password: string}>) {
this.pluginController_!.getPasswordComplete(event.detail.password);
}
updateUIForViewportChange() {
// Update toolbar elements.
this.clockwiseRotations_ = this.viewport.getClockwiseRotations();
this.pageNo_ = this.viewport.getMostVisiblePage() + 1;
this.twoUpViewEnabled_ = this.viewport.twoUpViewEnabled();
assert(this.currentController);
this.currentController.viewportChanged();
}
override handleStrings(strings: {[key: string]: string}) {
super.handleStrings(strings);
this.pdfAnnotationsEnabled_ =
loadTimeData.getBoolean('pdfAnnotationsEnabled');
this.printingEnabled_ = loadTimeData.getBoolean('printingEnabled');
const presetZoomFactors = this.viewport.presetZoomFactors;
this.zoomBounds_.min = Math.round(presetZoomFactors[0] * 100);
this.zoomBounds_.max =
Math.round(presetZoomFactors[presetZoomFactors.length - 1] * 100);
}
override handleScriptingMessage(message: MessageEvent<any>) {
if (super.handleScriptingMessage(message)) {
return true;
}
if (this.delayScriptingMessage(message)) {
return true;
}
switch (message.data.type.toString()) {
case 'getSelectedText':
this.pluginController_!.getSelectedText().then(
this.handleSelectedTextReply.bind(this));
break;
case 'print':
this.pluginController_!.print();
break;
case 'selectAll':
this.pluginController_!.selectAll();
break;
default:
return false;
}
return true;
}
handlePluginMessage(e: CustomEvent<MessageData>) {
const data = e.detail;
switch (data.type.toString()) {
case 'attachments':
const attachmentsData =
data as unknown as {attachmentsData: Attachment[]};
this.setAttachments_(attachmentsData.attachmentsData);
return;
case 'beep':
this.handleBeep_();
return;
case 'bookmarks':
const bookmarksData = data as unknown as {bookmarksData: Bookmark[]};
this.setBookmarks_(bookmarksData.bookmarksData);
return;
case 'documentDimensions':
this.setDocumentDimensions(
data as unknown as DocumentDimensionsMessageData);
return;
case 'email':
const emailData = data as unknown as EmailMessageData;
const href = 'mailto:' + emailData.to + '?cc=' + emailData.cc +
'&bcc=' + emailData.bcc + '&subject=' + emailData.subject +
'&body=' + emailData.body;
this.handleNavigate_(href, WindowOpenDisposition.CURRENT_TAB);
return;
case 'getPassword':
this.handlePasswordRequest_();
return;
case 'loadProgress':
const progressData = data as unknown as {progress: number};
this.updateProgress(progressData.progress);
return;
case 'navigate':
const navigateData = data as unknown as NavigateMessageData;
this.handleNavigate_(navigateData.url, navigateData.disposition);
return;
case 'navigateToDestination':
const destinationData = data as unknown as DestinationMessageData;
this.viewport.handleNavigateToDestination(
destinationData.page, destinationData.x, destinationData.y,
destinationData.zoom);
return;
case 'metadata':
const metadataData =
data as unknown as {metadataData: DocumentMetadata};
this.setDocumentMetadata_(metadataData.metadataData);
return;
case 'setIsEditing':
// Editing mode can only be entered once, and cannot be exited.
this.hasEdits_ = true;
return;
case 'setIsSelecting':
const selectingData = data as unknown as {isSelecting: boolean};
this.viewportScroller!.setEnableScrolling(selectingData.isSelecting);
return;
case 'setSmoothScrolling':
this.viewport.setSmoothScrolling(
(data as unknown as {smoothScrolling: boolean}).smoothScrolling);
return;
case 'formFocusChange':
const focusedData = data as unknown as {focused: boolean};
this.isFormFieldFocused_ = focusedData.focused;
return;
case 'touchSelectionOccurred':
this.sendScriptingMessage({
type: 'touchSelectionOccurred',
});
return;
case 'documentFocusChanged':
const hasFocusData = data as unknown as {hasFocus: boolean};
this.documentHasFocus_ = hasFocusData.hasFocus;
return;
case 'sendKeyEvent':
const keyEventData = data as unknown as KeyEventData;
const keyEvent =
deserializeKeyEvent(keyEventData.keyEvent) as ExtendedKeyEvent;
keyEvent.fromPlugin = true;
this.handleKeyEvent(keyEvent);
return;
}
assertNotReached('Unknown message type received: ' + data.type);
}
forceFit(view: FittingType): void {
this.$.toolbar.forceFit(view);
}
afterZoom(viewportZoom: number): void {
this.viewportZoom_ = viewportZoom;
}
override setDocumentDimensions(documentDimensions:
DocumentDimensionsMessageData): void {
super.setDocumentDimensions(documentDimensions);
// If the document dimensions are received, the password was correct and the
// password dialog can be dismissed.
this.closePasswordDialog_();
if (this.toolbarEnabled_) {
this.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_() {
// Show the password dialog if it is not already shown. Otherwise, respond
// to an incorrect password.
if (!this.showPasswordDialog_) {
this.showPasswordDialog_ = true;
this.sendScriptingMessage({type: 'passwordPrompted'});
} else {
const passwordDialog =
this.shadowRoot!.querySelector<ViewerPasswordDialogElement>(
'#password-dialog')!;
assert(passwordDialog);
passwordDialog.deny();
}
}
/** Handles a navigation request from the current controller. */
private handleNavigate_(url: string, disposition: WindowOpenDisposition):
void {
this.navigator_!.navigate(url, disposition);
}
/** Sets the document attachment data. */
private setAttachments_(attachments: Attachment[]) {
this.attachments_ = attachments;
}
/** Sets the document bookmarks data. */
private setBookmarks_(bookmarks: Bookmark[]) {
this.bookmarks_ = bookmarks;
}
/** Sets document metadata from the current controller. */
private setDocumentMetadata_(metadata: DocumentMetadata) {
this.documentMetadata_ = metadata;
this.title_ = this.documentMetadata_.title || this.fileName_;
document.title = this.title_;
this.canSerializeDocument_ = this.documentMetadata_.canSerializeDocument;
}
/**
* An event handler for when the browser tells the PDF Viewer to perform a
* save on the attachment at a certain index. Callers of this function must
* be responsible for checking whether the attachment size is valid for
* downloading.
* @param e The event which contains the index of attachment to be downloaded.
*/
private async onSaveAttachment_(e: CustomEvent<number>) {
const index = e.detail;
const size = this.attachments_[index].size;
assert(size !== -1);
let dataArray: ArrayBuffer[] = [];
// If the attachment size is 0, skip requesting the backend to fetch the
// attachment data.
if (size !== 0) {
assert(this.currentController);
const result = await this.currentController.saveAttachment(index);
// Cap the PDF attachment size at 100 MB. This cap should be kept in sync
// with and is also enforced in pdf/pdf_view_plugin_base.h.
const MAX_FILE_SIZE = 100 * 1000 * 1000;
const bufView = new Uint8Array(result.dataToSave);
assert(
bufView.length <= MAX_FILE_SIZE,
`File too large to be saved: ${bufView.length} bytes.`);
assert(
bufView.length === size,
`Received attachment size does not match its expected value: ${
size} bytes.`);
dataArray = [result.dataToSave];
}
const blob = new Blob(dataArray);
const fileName = this.attachments_[index].name;
chrome.fileSystem.chooseEntry(
{type: 'saveFile', suggestedName: fileName},
(entry?: FileSystemFileEntry) => {
if (chrome.runtime.lastError) {
if (chrome.runtime.lastError.message !== 'User cancelled') {
console.error(
'chrome.fileSystem.chooseEntry failed: ' +
chrome.runtime.lastError.message);
}
return;
}
entry!.createWriter((writer: FileWriter) => {
writer.write(blob);
// Unblock closing the window now that the user has saved
// successfully.
chrome.mimeHandlerPrivate.setShowBeforeUnloadDialog(false);
});
});
}
/**
* An event handler for when the browser tells the PDF Viewer to perform a
* save.
* @param streamUrl Unique identifier for a PDF Viewer instance.
*/
private async onSave_(streamUrl: string) {
if (streamUrl !== this.browserApi!.getStreamInfo().streamUrl) {
return;
}
let saveMode;
if (this.hasEnteredAnnotationMode_) {
saveMode = SaveRequestType.ANNOTATION;
} else if (this.hasEdits_) {
saveMode = SaveRequestType.EDITED;
} else {
saveMode = SaveRequestType.ORIGINAL;
}
this.save_(saveMode);
}
private onToolbarSave_(e: CustomEvent<SaveRequestType>) {
this.save_(e.detail);
}
private onChangePage_(e: CustomEvent<ChangePageDetail>) {
this.viewport.goToPage(e.detail.page);
if (e.detail.origin === 'bookmark') {
record(UserAction.FOLLOW_BOOKMARK);
} else if (e.detail.origin === 'pageSelector') {
record(UserAction.PAGE_SELECTOR_NAVIGATE);
} else if (e.detail.origin === 'thumbnail') {
record(UserAction.THUMBNAIL_NAVIGATE);
}
}
private onChangePageAndXy_(e: CustomEvent<ChangePageAndXyDetail>) {
const point = this.viewport.convertPageToScreen(e.detail.page, e.detail);
this.goToPageAndXY_(e.detail.origin, e.detail.page, point);
}
private onNavigate_(e: CustomEvent<NavigateDetail>) {
const disposition = e.detail.newtab ?
WindowOpenDisposition.NEW_BACKGROUND_TAB :
WindowOpenDisposition.CURRENT_TAB;
this.navigator_!.navigate(e.detail.uri, disposition);
}
private onSidenavToggleClick_() {
this.sidenavCollapsed_ = !this.sidenavCollapsed_;
// Workaround for crbug.com/1119944, so that the PDF plugin resizes only
// once when the sidenav is opened/closed.
const container = this.shadowRoot!.querySelector('#sidenav-container')!;
if (!this.sidenavCollapsed_) {
container.classList.add('floating');
container.addEventListener('transitionend', () => {
container.classList.remove('floating');
}, {once: true});
}
LocalStorageProxyImpl.getInstance().setItem(
LOCAL_STORAGE_SIDENAV_COLLAPSED_KEY,
(this.sidenavCollapsed_ ? 1 : 0).toString());
}
/**
* Saves the current PDF document to disk.
*/
private async save_(requestType: SaveRequestType) {
this.recordSaveMetrics_(requestType);
// If we have entered annotation mode we must require the local
// contents to ensure annotations are saved, unless the user specifically
// requested the original document. Otherwise we would save the cached
// remote copy without annotations.
//
// Always send requests of type ORIGINAL to the plugin controller, not the
// ink controller. The ink controller always saves the edited document.
// TODO(dstockwell): Report an error to user if this fails.
let result: {fileName: string, dataToSave: ArrayBuffer}|null = null;
assert(this.currentController);
if (requestType !== SaveRequestType.ORIGINAL || !this.annotationMode_) {
result = await this.currentController.save(requestType);
} else {
// <if expr="enable_ink">
// Request type original in annotation mode --> need to exit annotation
// mode before saving. See https://crbug.com/919364.
await this.exitAnnotationMode_();
assert(!this.annotationMode_);
result = await this.currentController.save(SaveRequestType.ORIGINAL);
// </if>
}
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';
}
// Create blob before callback to avoid race condition.
const blob = new Blob([result.dataToSave], {type: 'application/pdf'});
chrome.fileSystem.chooseEntry(
{
type: 'saveFile',
accepts: [{description: '*.pdf', extensions: ['pdf']}],
suggestedName: fileName
},
(entry?: FileSystemFileEntry) => {
if (chrome.runtime.lastError) {
if (chrome.runtime.lastError.message !== 'User cancelled') {
console.error(
'chrome.fileSystem.chooseEntry failed: ' +
chrome.runtime.lastError.message);
}
return;
}
entry!.createWriter((writer: FileWriter) => {
writer.write(blob);
// Unblock closing the window now that the user has saved
// successfully.
chrome.mimeHandlerPrivate.setShowBeforeUnloadDialog(false);
});
});
// <if expr="enable_ink">
// Saving in Annotation mode is destructive: crbug.com/919364
this.exitAnnotationMode_();
// </if>
}
/**
* Records metrics for saving PDFs.
*/
private recordSaveMetrics_(requestType: SaveRequestType) {
record(UserAction.SAVE);
switch (requestType) {
case SaveRequestType.ANNOTATION:
record(UserAction.SAVE_WITH_ANNOTATION);
break;
case SaveRequestType.ORIGINAL:
record(
this.hasEdits_ ? UserAction.SAVE_ORIGINAL :
UserAction.SAVE_ORIGINAL_ONLY);
break;
case SaveRequestType.EDITED:
record(UserAction.SAVE_EDITED);
break;
}
}
private async onPrint_() {
record(UserAction.PRINT);
// <if expr="enable_ink">
await this.exitAnnotationMode_();
// </if>
assert(this.currentController);
this.currentController.print();
}
/**
* Updates the toolbar's annotation available flag depending on current
* conditions.
* @return Whether annotations are available.
*/
private computeAnnotationAvailable_(): boolean {
return this.canSerializeDocument_ && !this.hadPassword_;
}
/** @return Whether the PDF contents are rotated. */
private isRotated_(): boolean {
return this.clockwiseRotations_ !== 0;
}
// <if expr="enable_ink">
getCurrentControllerForTesting(): ContentController|null {
return this.currentController;
}
// </if>
}
declare global {
interface HTMLElementTagNameMap {
'pdf-viewer': PDFViewerElement;
}
}
customElements.define(PDFViewerElement.is, PDFViewerElement);