blob: 28fd350748edd693c11c455adc0c8f17d06fd182 [file] [log] [blame]
// Copyright 2013 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
// clang-format off
// <if expr="enable_pdf_ink2">
import 'chrome://resources/cr_elements/cr_page_selector/cr_page_selector.js';
// </if>
import 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import './elements/viewer_error_dialog.js';
import './elements/viewer_password_dialog.js';
// <if expr="enable_pdf_ink2">
import './elements/ink_text_box.js';
import './elements/viewer_bottom_toolbar.js';
import './elements/viewer_side_panel.js';
import './elements/viewer_text_bottom_toolbar.js';
import './elements/viewer_text_side_panel.js';
// </if>
import './elements/viewer_pdf_sidenav.js';
import './elements/viewer_properties_dialog.js';
// <if expr="enable_pdf_save_to_drive">
import './elements/viewer_save_to_drive_bubble.js';
// </if> enable_pdf_save_to_drive
import './elements/viewer_toolbar.js';
import {PdfHelpBubbleProxyImpl} from 'chrome://resources/cr_components/help_bubble/pdf_help_bubble_proxy.js';
import type {CrToastElement} from 'chrome://resources/cr_elements/cr_toast/cr_toast.js';
import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import type {LoadTimeDataRaw} from 'chrome://resources/js/load_time_data.js';
import {listenOnce} from 'chrome://resources/js/util.js';
import type {PropertyValues} from 'chrome://resources/lit/v3_0/lit.rollup.js';
// <if expr="enable_pdf_ink2 or enable_pdf_save_to_drive">
import {BeforeUnloadProxyImpl} from './before_unload_proxy.js';
// </if>
import type {Bookmark} from './bookmark_type.js';
import type {BrowserApi} from './browser_api.js';
import type {Attachment, DocumentMetadata, ExtendedKeyEvent, Point} from './constants.js';
// <if expr="enable_pdf_ink2">
import {AnnotationMode} from './constants.js';
// </if>
import {FittingType, FormFieldFocusType} from './constants.js';
// <if expr="enable_pdf_save_to_drive">
import {SaveToDriveBubbleRequestType, SaveToDriveState} from './constants.js';
// </if> enable_pdf_save_to_drive
import type {MessageData} from './controller.js';
import {PluginController} from './controller.js';
// <if expr="enable_pdf_ink2">
import {PluginControllerEventType} from './controller.js';
// </if>
// <if expr="enable_pdf_ink2">
import {TextBoxState} from './elements/ink_text_box.js';
// </if>
import type {ChangePageAndXyDetail, ChangePageDetail, NavigateDetail} from './elements/viewer_bookmark.js';
import {ChangePageOrigin} from './elements/viewer_bookmark.js';
import type {ViewerErrorDialogElement} from './elements/viewer_error_dialog.js';
import type {ViewerPasswordDialogElement} from './elements/viewer_password_dialog.js';
// <if expr="enable_pdf_save_to_drive">
import type {ViewerSaveToDriveBubbleElement} from './elements/viewer_save_to_drive_bubble.js';
// </if> enable_pdf_save_to_drive
// <if expr="enable_pdf_ink2">
import type {Ink2ThumbnailData} from './elements/viewer_thumbnail_bar.js';
//</if>
import type {ViewerToolbarElement} from './elements/viewer_toolbar.js';
// <if expr="enable_pdf_ink2">
import {Ink2Manager} from './ink2_manager.js';
//</if>
import {LocalStorageProxyImpl} from './local_storage_proxy.js';
import {convertDocumentDimensionsMessage, convertFormFocusChangeMessage, convertLoadProgressMessage} from './message_converter.js';
import {record, recordEnumeration, UserAction} from './metrics.js';
import {NavigatorDelegateImpl, PdfNavigatorImpl, WindowOpenDisposition} from './navigator.js';
import type {PdfNavigator} from './navigator.js';
import {deserializeKeyEvent, LoadState} from './pdf_scripting_api.js';
import {getCss} from './pdf_viewer.css.js';
import {getHtml} from './pdf_viewer.html.js';
import type {KeyEventData} from './pdf_viewer_base.js';
import {PdfViewerBaseElement} from './pdf_viewer_base.js';
import {PdfViewerPrivateProxyImpl} from './pdf_viewer_private_proxy.js';
import type {DocumentDimensionsMessageData} from './pdf_viewer_utils.js';
// <if expr="enable_pdf_save_to_drive">
import {getSaveToDriveManageStorageUrl, getSaveToDriveOpenInDriveUrl} from './pdf_viewer_utils.js';
// </if> enable_pdf_save_to_drive
import {hasCtrlModifier, hasCtrlModifierOnly, shouldIgnoreKeyEvents, verifyPdfHeader} from './pdf_viewer_utils.js';
// <if expr="enable_pdf_save_to_drive">
import {recordSaveToDriveBubbleActionMetrics, recordSaveToDriveBubbleRetryMetrics, recordSaveToDriveMetrics, recordShowSaveToDriveBubbleMetrics} from './save_to_drive_metrics.js';
// </if> enable_pdf_save_to_drive
// clang-format on
// <if expr="enable_pdf_save_to_drive">
const SaveToDriveErrorType = chrome.pdfViewerPrivate.SaveToDriveErrorType;
const SaveToDriveStatus = chrome.pdfViewerPrivate.SaveToDriveStatus;
type SaveToDriveProgress = chrome.pdfViewerPrivate.SaveToDriveProgress;
type SaveToDriveStatus = chrome.pdfViewerPrivate.SaveToDriveStatus;
// </if> enable_pdf_save_to_drive
const SaveRequestType = chrome.pdfViewerPrivate.SaveRequestType;
type SaveRequestType = chrome.pdfViewerPrivate.SaveRequestType;
/**
* Keep in sync with the values for enum PDFPostMessageDataType in
* tools/metrics/histograms/metadata/pdf/enums.xml.
* These values are persisted to logs. Entries should not be renumbered, removed
* or reused.
*/
enum PostMessageDataType {
GET_SELECTED_TEXT = 0,
PRINT = 1,
SELECT_ALL = 2,
}
interface EmailMessageData {
type: string;
to: string;
cc: string;
bcc: string;
subject: string;
body: string;
}
interface NavigateMessageData {
type: string;
url: string;
disposition: WindowOpenDisposition;
}
interface 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()));
}
// Unlike hasCtrlModifierOnly(), this always checks `e.ctrlKey` and not
// `e.metaKey`. Whereas hasCtrlModifierOnly() will flip the two modifiers on
// macOS.
function hasFixedCtrlModifierOnly(e: KeyboardEvent): boolean {
return e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey;
}
const LOCAL_STORAGE_SIDENAV_COLLAPSED_KEY: string = 'sidenavCollapsed';
/**
* The background color used for the regular viewer.
*/
// LINT.IfChange(PdfBackgroundColor)
const BACKGROUND_COLOR: number = 0xff282828;
// clang-format off
// LINT.ThenChange(//chrome/browser/resources/pdf/pdf_embedder.css:PdfBackgroundColor, //components/pdf/common/pdf_util.cc:PdfBackgroundColor)
// clang-format on
// <if expr="enable_pdf_ink2 or enable_pdf_save_to_drive">
function isEditedSaveRequestType(requestType: SaveRequestType): boolean {
return requestType === SaveRequestType.ANNOTATION ||
requestType === SaveRequestType.EDITED;
}
// </if>
// <if expr="enable_pdf_save_to_drive">
function convertNoErrorStatusToSaveToDriveState(status: SaveToDriveStatus):
SaveToDriveState {
switch (status) {
case SaveToDriveStatus.INITIATED:
case SaveToDriveStatus.FETCH_OAUTH:
case SaveToDriveStatus.FETCH_PARENT_FOLDER:
case SaveToDriveStatus.UPLOAD_STARTED:
case SaveToDriveStatus.UPLOAD_IN_PROGRESS:
return SaveToDriveState.UPLOADING;
case SaveToDriveStatus.UPLOAD_COMPLETED:
return SaveToDriveState.SUCCESS;
default:
return SaveToDriveState.UNINITIALIZED;
}
}
function convertSaveToDriveProgressToSaveToDriveState(
progress: SaveToDriveProgress): SaveToDriveState {
switch (progress.errorType) {
case SaveToDriveErrorType.NO_ERROR:
return convertNoErrorStatusToSaveToDriveState(progress.status);
case SaveToDriveErrorType.UNKNOWN_ERROR:
return SaveToDriveState.UNKNOWN_ERROR;
case SaveToDriveErrorType.QUOTA_EXCEEDED:
return SaveToDriveState.STORAGE_FULL_ERROR;
case SaveToDriveErrorType.OFFLINE:
return SaveToDriveState.CONNECTION_ERROR;
case SaveToDriveErrorType.OAUTH_ERROR:
return SaveToDriveState.SESSION_TIMEOUT_ERROR;
case SaveToDriveErrorType.ACCOUNT_CHOOSER_CANCELED:
return SaveToDriveState.UNINITIALIZED;
case SaveToDriveErrorType.PARENT_FOLDER_SELECTION_FAILED:
return SaveToDriveState.UNKNOWN_ERROR;
default:
assertNotReached();
}
}
function saveToDriveStateIsFinalState(state: SaveToDriveState): boolean {
switch (state) {
case SaveToDriveState.SUCCESS:
case SaveToDriveState.CONNECTION_ERROR:
case SaveToDriveState.STORAGE_FULL_ERROR:
case SaveToDriveState.SESSION_TIMEOUT_ERROR:
case SaveToDriveState.UNKNOWN_ERROR:
return true;
default:
return false;
}
}
// </if> enable_pdf_save_to_drive
export interface PdfViewerElement {
$: {
content: HTMLElement,
scroller: HTMLElement,
sizer: HTMLElement,
toolbar: ViewerToolbarElement,
searchifyProgress: CrToastElement,
};
}
export class PdfViewerElement extends PdfViewerBaseElement {
static get is() {
return 'pdf-viewer';
}
static override get styles() {
return getCss();
}
override render() {
return getHtml.bind(this)();
}
static override get properties() {
return {
// from PdfViewerBaseElement
showErrorDialog: {type: Boolean},
strings: {type: Object},
// <if expr="enable_pdf_ink2">
annotationMode_: {type: String},
// </if>
attachments_: {type: Array},
bookmarks_: {type: Array},
canSerializeDocument_: {type: Boolean},
clockwiseRotations_: {type: Number},
/** The number of pages in the PDF document. */
docLength_: {type: Number},
documentHasFocus_: {type: Boolean},
documentMetadata_: {type: Object},
embedded_: {type: Boolean},
fileName_: {type: String},
hadPassword_: {type: Boolean},
hasEdits_: {type: Boolean},
// <if expr="enable_pdf_ink2">
hasCommittedInk2Edits_: {type: Boolean},
// </if>
formFieldFocus_: {type: String},
/** The current loading progress of the PDF document (0 - 100). */
loadProgress_: {type: Number},
/** The number of the page being viewed (1-based). */
pageNo_: {type: Number},
// <if expr="enable_pdf_ink2">
pdfInk2Enabled_: {type: Boolean},
// </if>
// <if expr="enable_pdf_save_to_drive">
pdfSaveToDriveEnabled_: {type: Boolean},
saveToDriveProgress_: {type: Object},
saveToDriveState_: {type: String},
// </if>
showPasswordDialog_: {type: Boolean},
showPropertiesDialog_: {type: Boolean},
sidenavCollapsed_: {type: Boolean},
// <if expr="enable_pdf_ink2">
textboxState_: {type: Number},
// </if>
title_: {type: String},
twoUpViewEnabled_: {type: Boolean},
// <if expr="enable_pdf_ink2">
useSidePanelForInk_: {type: Boolean},
// </if>
viewportZoom_: {type: Number},
zoomBounds_: {type: Object},
};
}
beepCount: number = 0;
// <if expr="enable_pdf_ink2">
protected accessor annotationMode_: AnnotationMode = AnnotationMode.OFF;
// </if>
protected accessor attachments_: Attachment[] = [];
protected accessor bookmarks_: Bookmark[] = [];
private accessor canSerializeDocument_: boolean = false;
private caretBrowsingEnabled_: boolean = false;
protected accessor clockwiseRotations_: number = 0;
protected accessor docLength_: number = 0;
protected accessor documentHasFocus_: boolean = false;
protected accessor documentMetadata_: DocumentMetadata = {
author: '',
canSerializeDocument: false,
creationDate: '',
creator: '',
fileSize: '',
keywords: '',
linearized: false,
modDate: '',
pageSize: '',
producer: '',
subject: '',
title: '',
version: '',
};
protected accessor embedded_: boolean = false;
protected accessor fileName_: string = '';
private accessor hadPassword_: boolean = false;
protected accessor hasEdits_: boolean = false;
// <if expr="enable_pdf_ink2">
protected accessor hasCommittedInk2Edits_: boolean = false;
// `hasSavedEdits_` is true if the PDF has been saved with edits. Additional
// changes or saves of the document will not update this property.
private hasSavedEdits_: boolean = false;
// </if>
// <if expr="enable_pdf_ink2 or enable_pdf_save_to_drive">
// `hasUnsavedEdits_` is set whenever the user makes edits to the PDF that
// have not been saved. This is used to determine whether to enable the
// beforeunload dialog when the user navigates away with unsaved changes.
private hasUnsavedEdits_: boolean = false;
// </if>
protected accessor formFieldFocus_: FormFieldFocusType =
FormFieldFocusType.NONE;
protected accessor loadProgress_: number = 0;
private navigator_: PdfNavigator|null = null;
protected accessor pageNo_: number = 0;
private pdfGetSaveDataInBlocks_: boolean = false;
// <if expr="enable_pdf_ink2">
protected accessor pdfInk2Enabled_: boolean = false;
// </if>
// <if expr="enable_pdf_save_to_drive">
protected accessor pdfSaveToDriveEnabled_: boolean = false;
protected accessor saveToDriveProgress_: SaveToDriveProgress = {
status: SaveToDriveStatus.NOT_STARTED,
errorType: SaveToDriveErrorType.NO_ERROR,
};
protected accessor saveToDriveState_: SaveToDriveState =
SaveToDriveState.UNINITIALIZED;
private saveToDriveRequestType_: SaveRequestType = SaveRequestType.ORIGINAL;
// </if>
private pdfSearchifySaveEnabled_: boolean = false;
private pdfUseShowSaveFilePicker_: boolean = false;
private pluginController_: PluginController = PluginController.getInstance();
// <if expr="enable_pdf_ink2">
private restoreAnnotationMode_: AnnotationMode = AnnotationMode.OFF;
// </if>
// <if expr="enable_pdf_ink2 or enable_pdf_save_to_drive">
private showBeforeUnloadDialog_: boolean = false;
// </if>
protected accessor showPasswordDialog_: boolean = false;
protected accessor showPropertiesDialog_: boolean = false;
protected accessor sidenavCollapsed_: boolean;
// <if expr="enable_pdf_ink2">
protected accessor textboxState_: TextBoxState = TextBoxState.INACTIVE;
// </if>
protected accessor title_: string = '';
protected toolbarEnabled_: boolean = false;
protected accessor twoUpViewEnabled_: boolean = false;
// <if expr="enable_pdf_ink2">
private accessor useSidePanelForInk_: boolean = false;
// </if>
protected accessor viewportZoom_: number = 1;
protected accessor zoomBounds_: ZoomBounds = {min: 0, max: 0};
private hasSearchifyText_: boolean = false;
constructor() {
PdfHelpBubbleProxyImpl.createConnectedInstance();
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));
}
// <if expr="enable_pdf_ink2">
override willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
const changedPrivateProperties =
changedProperties as Map<PropertyKey, unknown>;
if (changedPrivateProperties.has('pdfInk2Enabled_') &&
this.pdfInk2Enabled_) {
// Set the viewport when PdfInk2 is enabled, if this happens after init().
Ink2Manager.getInstance().setViewport(this.viewport);
}
}
// </if>
override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (changedProperties.has('showErrorDialog') && this.showErrorDialog) {
this.onErrorDialog_();
}
// <if expr="enable_pdf_save_to_drive">
const changedPrivateProperties =
changedProperties as Map<PropertyKey, unknown>;
if (changedPrivateProperties.has('saveToDriveState_')) {
this.onSaveToDriveStateChanged_(
changedPrivateProperties.get('saveToDriveState_') as
SaveToDriveState);
}
// </if>
}
// <if expr="enable_pdf_ink2 or enable_pdf_save_to_drive">
override connectedCallback() {
super.connectedCallback();
this.tracker.add(window, 'beforeunload', this.onBeforeUnload_.bind(this));
// <if expr="enable_pdf_ink2">
const mediaQuery = window.matchMedia('(min-width: 960px)');
this.useSidePanelForInk_ = mediaQuery.matches;
this.tracker.add(mediaQuery, 'change', () => {
this.useSidePanelForInk_ = mediaQuery.matches;
// If we are in DRAW or TEXT annotation mode, record opening the
// UI that's opened by making the window narrower/wider.
if (this.annotationMode_ !== AnnotationMode.OFF) {
record(
this.useSidePanelForInk_ ? UserAction.OPEN_INK2_SIDE_PANEL :
UserAction.OPEN_INK2_BOTTOM_TOOLBAR);
}
});
// </if> enable_pdf_ink2
}
override disconnectedCallback() {
this.tracker.removeAll();
super.disconnectedCallback();
}
// </if> enable_pdf_ink2 or enable_pdf_save_to_drive
getBackgroundColor(): number {
return BACKGROUND_COLOR;
}
setPluginSrc(plugin: HTMLEmbedElement) {
plugin.src = this.browserApi!.getStreamInfo().streamUrl;
}
init(browserApi: BrowserApi) {
this.initInternal(
browserApi, this.$.scroller, this.$.sizer, this.$.content);
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;
}
const showSidenav = this.paramsParser.shouldShowSidenav(
this.originalUrl, this.sidenavCollapsed_);
this.sidenavCollapsed_ = !showSidenav;
this.navigator_ = new PdfNavigatorImpl(
this.originalUrl, this.viewport, this.paramsParser,
new NavigatorDelegateImpl(browserApi));
// Listen for save commands from the browser.
if (this.pdfOopifEnabled) {
chrome.pdfViewerPrivate.onSave.addListener(this.onSave_.bind(this));
} else {
chrome.mimeHandlerPrivate.onSave.addListener(this.onSave_.bind(this));
}
// Listen for hash updates from the browser.
chrome.pdfViewerPrivate.onShouldUpdateViewport.addListener(
this.handleMaybeUpdateViewport_.bind(this));
// <if expr="enable_pdf_save_to_drive">
PdfViewerPrivateProxyImpl.getInstance().onSaveToDriveProgress.addListener(
this.handleSaveToDriveProgress_.bind(this));
// </if>
this.embedded_ = this.browserApi!.getStreamInfo().embedded;
if (this.pdfOopifEnabled && !this.embedded_) {
// Give the full page PDF viewer focus so it can handle keyboard events
// immediately.
window.focus();
}
}
handleKeyEvent(e: KeyboardEvent) {
if (shouldIgnoreKeyEvents() || e.defaultPrevented) {
return;
}
// Let the viewport handle directional key events.
if (this.viewport.handleDirectionalKeyEvent(
e, this.formFieldFocus_ !== FormFieldFocusType.NONE,
this.caretBrowsingEnabled_)) {
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':
// Take over Ctrl+A (but not other combinations like Ctrl-Shift-A).
// Note that on macOS, "Ctrl" is Command.
if (hasCtrlModifierOnly(e)) {
this.pluginController_.selectAll();
// Since we do selection ourselves.
e.preventDefault();
}
return;
// <if expr="enable_pdf_ink2">
case 'Enter':
if ((e as ExtendedKeyEvent).fromPlugin &&
this.isInTextAnnotationMode_()) {
this.maybeCreateTextAnnotation_();
}
// </if>
}
// Handle toolbar related key events.
this.handleToolbarKeyEvent_(e);
}
/**
* Helper for handleKeyEvent dealing with events that control toolbars.
*/
private handleToolbarKeyEvent_(e: KeyboardEvent) {
// TODO: Add handling for additional relevant hotkeys for the new unified
// toolbar.
switch (e.key) {
case '[':
// Do not use hasCtrlModifierOnly() here, since Command + [ is already
// taken by the "go back to the previous webpage" action.
if (hasFixedCtrlModifierOnly(e)) {
this.rotateCounterclockwise();
}
return;
case '\\':
// Do not use hasCtrlModifierOnly() here, to match '[' and ']'.
if (hasFixedCtrlModifierOnly(e)) {
this.$.toolbar.fitToggle();
}
return;
case ']':
// Do not use hasCtrlModifierOnly() here, since Command + ] is already
// taken by the "go forward to the next webpage" action.
if (hasFixedCtrlModifierOnly(e)) {
this.rotateClockwise();
}
return;
// <if expr="enable_pdf_ink2">
case 'z':
// <if expr="is_macosx">
if (e.metaKey && !e.ctrlKey && !e.altKey) {
if (e.shiftKey) {
this.$.toolbar.redo();
} else {
this.$.toolbar.undo();
}
}
// </if> is_macosx
// <if expr="not is_macosx">
if (hasCtrlModifierOnly(e)) {
this.$.toolbar.undo();
}
// </if> not is_macosx
return;
// <if expr="not is_macosx">
case 'y':
if (hasCtrlModifierOnly(e)) {
this.$.toolbar.redo();
}
return;
// </if> not is_macosx
// </if> enable_pdf_ink2
}
}
// <if expr="enable_pdf_ink2">
private maybeCreateTextAnnotation_(location?: Point) {
const created =
Ink2Manager.getInstance().initializeTextAnnotation(location);
if (!created && this.textboxState_ !== TextBoxState.INACTIVE) {
const textbox = this.shadowRoot.querySelector('ink-text-box');
assert(textbox);
textbox.commitTextAnnotation();
}
}
private recordEnterExitAnnotationModeMetrics_(
newAnnotationMode: AnnotationMode) {
// Record exit metrics if annotation mode is being changed from one of
// the ink annotation modes.
switch (this.annotationMode_) {
case AnnotationMode.DRAW:
record(UserAction.EXIT_INK2_ANNOTATION_MODE);
break;
case AnnotationMode.TEXT:
record(UserAction.EXIT_INK2_TEXT_ANNOTATION_MODE);
break;
case AnnotationMode.OFF:
break;
default:
assertNotReached();
}
// Record enter metrics if annotation mode is being changed to one of
// the ink annotation modes.
switch (newAnnotationMode) {
case AnnotationMode.DRAW:
record(UserAction.ENTER_INK2_ANNOTATION_MODE);
break;
case AnnotationMode.TEXT:
record(UserAction.ENTER_INK2_TEXT_ANNOTATION_MODE);
break;
case AnnotationMode.OFF:
break;
default:
assertNotReached();
}
}
// </if>
// <if expr="enable_pdf_ink2">
// Handles the annotation mode being updated from the toolbar buttons.
protected async onAnnotationModeUpdated_(e: CustomEvent<AnnotationMode>) {
assert(this.pdfInk2Enabled_);
const newAnnotationMode = e.detail;
if (newAnnotationMode === this.annotationMode_) {
return;
}
if (this.annotationMode_ === AnnotationMode.OFF) {
record(
this.useSidePanelForInk_ ? UserAction.OPEN_INK2_SIDE_PANEL :
UserAction.OPEN_INK2_BOTTOM_TOOLBAR);
}
if (this.restoreAnnotationMode_ === AnnotationMode.OFF) {
this.recordEnterExitAnnotationModeMetrics_(newAnnotationMode);
}
this.pluginController_.setAnnotationMode(newAnnotationMode);
if (newAnnotationMode === AnnotationMode.DRAW &&
!Ink2Manager.getInstance().isInitializationStarted()) {
await Ink2Manager.getInstance().initializeBrush();
}
if (newAnnotationMode === AnnotationMode.TEXT &&
!Ink2Manager.getInstance().isTextInitializationComplete()) {
await Ink2Manager.getInstance().initializeTextAnnotations();
}
this.annotationMode_ = newAnnotationMode;
}
// </if> enable_pdf_ink2
protected onDisplayAnnotationsChanged_(e: CustomEvent<boolean>) {
assert(this.currentController);
this.currentController.setDisplayAnnotations(e.detail);
}
private async enterPresentationMode_(): Promise<void> {
// <if expr="enable_pdf_ink2">
// Exit annotation mode if it was enabled.
if (this.pdfInk2Enabled_ && this.annotationMode_ !== AnnotationMode.OFF) {
this.restoreAnnotationMode_ = this.annotationMode_;
this.$.toolbar.setAnnotationMode(AnnotationMode.OFF);
}
assert(this.annotationMode_ === AnnotationMode.OFF);
// </if>
const scroller = this.$.scroller;
this.viewport.saveZoomState();
await Promise.all([
eventToPromise('fullscreenchange', scroller),
scroller.requestFullscreen(),
]);
this.forceFit(FittingType.FIT_TO_HEIGHT);
// Switch viewport's wheel behavior.
this.viewport.setPresentationMode(true);
// Set presentation mode, which restricts the content to read only
// (e.g. disable forms and links).
this.pluginController_.setPresentationMode(true);
// Nothing else to do here. The viewport will be updated as a result
// of a 'resize' event callback.
}
private exitPresentationMode_(): void {
// Revert back to the normal state when exiting Presentation mode.
assert(document.fullscreenElement === null);
this.viewport.setPresentationMode(false);
this.pluginController_.setPresentationMode(false);
// Ensure that directional keys still work after exiting.
this.shadowRoot.querySelector('embed')!.focus();
// Set zoom back to original zoom before presentation mode.
this.viewport.restoreZoomState();
// <if expr="enable_pdf_ink2">
// Enter annotation mode again if it was enabled before entering
// Presentation mode.
if (this.restoreAnnotationMode_ !== AnnotationMode.OFF) {
this.$.toolbar.setAnnotationMode(this.restoreAnnotationMode_);
assert(this.annotationMode_ !== AnnotationMode.OFF);
this.restoreAnnotationMode_ = AnnotationMode.OFF;
}
// </if>
}
protected async onPresentClick_() {
await this.enterPresentationMode_();
// When fullscreen changes, it means that the user exited Presentation
// mode.
await eventToPromise('fullscreenchange', this.$.scroller);
this.exitPresentationMode_();
}
protected onPropertiesClick_() {
assert(!this.showPropertiesDialog_);
this.showPropertiesDialog_ = true;
}
protected 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.
*/
protected 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: ChangePageOrigin, page: number, message: Point) {
this.viewport.goToPageAndXy(page, message.x, message.y);
if (origin === ChangePageOrigin.BOOKMARK) {
record(UserAction.FOLLOW_BOOKMARK);
}
}
/** @return The bookmarks. Used for testing. */
get bookmarks(): Bookmark[] {
return this.bookmarks_;
}
/** @return The title. Used for testing. */
get pdfTitle(): string {
return this.title_;
}
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);
// Text fragment directives should be handled after the document is set to
// finished loading.
if (progress === 100) {
this.maybeRenderTextDirectiveHighlights_(this.originalUrl);
}
}
protected 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();
}
}
protected 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.
*/
protected 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();
// <if expr="enable_pdf_ink2">
if (this.pdfInk2Enabled_) {
const hasScrollbars = this.viewport.documentHasScrollbars();
const scrollbarWidthStyle = `${this.viewport.scrollbarWidth}px`;
this.style.setProperty(
'--vertical-scrollbar-width',
hasScrollbars.vertical ? scrollbarWidthStyle : '0px');
this.style.setProperty(
'--horizontal-scrollbar-width',
hasScrollbars.horizontal ? scrollbarWidthStyle : '0px');
Ink2Manager.getInstance().viewportChanged();
}
// </if>
}
override handleStrings(strings: LoadTimeDataRaw) {
super.handleStrings(strings);
this.pdfGetSaveDataInBlocks_ =
loadTimeData.getBoolean('pdfGetSaveDataInBlocks');
// <if expr="enable_pdf_ink2">
this.pdfInk2Enabled_ = loadTimeData.getBoolean('pdfInk2Enabled');
// </if>
// <if expr="enable_pdf_save_to_drive">
this.pdfSaveToDriveEnabled_ = loadTimeData.getBoolean('pdfSaveToDrive');
// </if>
this.pdfSearchifySaveEnabled_ =
loadTimeData.getBoolean('pdfSearchifySaveEnabled');
this.pdfUseShowSaveFilePicker_ =
loadTimeData.getBoolean('pdfUseShowSaveFilePicker');
const presetZoomFactors = this.viewport.presetZoomFactors;
assert(presetZoomFactors.length > 0);
this.zoomBounds_.min = Math.round(presetZoomFactors[0]! * 100);
this.zoomBounds_.max =
Math.round(presetZoomFactors[presetZoomFactors.length - 1]! * 100);
// <if expr="enable_pdf_ink2">
if (this.pdfInk2Enabled_) {
this.updateComplete.then(() => {
this.registerHelpBubble(
'PdfHelpBubbleHandlerFactory::kPdfInkSignaturesDrawElementId',
this.$.toolbar.shadowRoot.querySelector<HTMLElement>('#annotate')!);
});
}
// </if>
}
override handleScriptingMessage(message: MessageEvent<any>) {
if (super.handleScriptingMessage(message)) {
return true;
}
if (this.delayScriptingMessage(message)) {
return true;
}
let messageType;
switch (message.data.type.toString()) {
case 'getSelectedText':
messageType = PostMessageDataType.GET_SELECTED_TEXT;
this.pluginController_.getSelectedText().then(
this.handleSelectedTextReply.bind(this));
break;
case 'print':
messageType = PostMessageDataType.PRINT;
this.pluginController_.print();
break;
case 'selectAll':
messageType = PostMessageDataType.SELECT_ALL;
this.pluginController_.selectAll();
break;
default:
return false;
}
recordEnumeration(
'PDF.PostMessageDataType', messageType,
Object.keys(PostMessageDataType).length);
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(convertDocumentDimensionsMessage(data));
return;
case 'documentFocusChanged':
const hasFocusData = data as unknown as {hasFocus: boolean};
this.documentHasFocus_ = hasFocusData.hasFocus;
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 'executedEditCommand':
const editCommandData = data as unknown as {editCommand: string};
const editCommand = editCommandData.editCommand;
switch (editCommand) {
case 'Cut':
record(UserAction.CUT);
return;
case 'Copy':
record(UserAction.COPY);
if (this.hasSearchifyText_) {
record(UserAction.COPY_SEARCHIFIED);
}
return;
case 'Paste':
record(UserAction.PASTE);
return;
}
assertNotReached(
'Unknown executedEditCommand data received: ' + editCommand);
// <if expr="enable_pdf_ink2">
case 'finishInkStroke':
const modifiedData = data as unknown as {modified: boolean};
this.handleFinishInkStroke_(modifiedData.modified);
return;
// </if>
case 'formFocusChange':
const focusedData = convertFormFocusChangeMessage(data);
this.formFieldFocus_ = focusedData.focused;
return;
case 'getPassword':
this.handlePasswordRequest_();
return;
case 'loadProgress':
const progressData = convertLoadProgressMessage(data);
this.updateProgress(progressData.progress);
return;
case 'metadata':
const metadataData =
data as unknown as {metadataData: DocumentMetadata};
this.setDocumentMetadata_(metadataData.metadataData);
return;
case 'navigate':
const navigateData = data as unknown as NavigateMessageData;
this.handleNavigate_(navigateData.url, navigateData.disposition);
return;
case 'rendererPreferencesUpdated':
const caretBrowsingEnabledData =
data as unknown as {caretBrowsingEnabled: boolean};
this.caretBrowsingEnabled_ =
caretBrowsingEnabledData.caretBrowsingEnabled;
return;
case 'sendKeyEvent':
const keyEventData = data as unknown as KeyEventData;
const keyEvent =
deserializeKeyEvent(keyEventData.keyEvent) as ExtendedKeyEvent;
keyEvent.fromPlugin = true;
this.handleKeyEvent(keyEvent);
return;
case 'setIsEditing':
// Editing mode can only be entered once, and cannot be exited.
this.hasEdits_ = true;
return;
case 'setHasSearchifyText':
this.hasSearchifyText_ = true;
return;
case 'showSearchifyInProgress':
if ((data as unknown as {
show: boolean,
}).show) {
this.$.searchifyProgress.show();
} else {
this.$.searchifyProgress.hide();
}
return;
// <if expr="enable_pdf_ink2">
case 'startInkStroke':
this.handleStartInkStroke_();
return;
// </if>
case 'startedFindInPage':
record(UserAction.FIND_IN_PAGE);
if (this.hasSearchifyText_) {
record(UserAction.FIND_IN_PAGE_SEARCHIFIED);
}
return;
case 'touchSelectionOccurred':
this.sendScriptingMessage({
type: 'touchSelectionOccurred',
});
return;
// <if expr="enable_pdf_ink2">
case 'updateInk2Thumbnail':
const thumbnailData = data as unknown as Ink2ThumbnailData;
this.pluginController_.getEventTarget().dispatchEvent(
new CustomEvent<Ink2ThumbnailData>(
PluginControllerEventType.UPDATE_INK_THUMBNAIL,
{detail: thumbnailData}));
return;
case 'sendClickEvent':
// Ignore click events outside of text annotation mode.
if (this.annotationMode_ !== AnnotationMode.TEXT) {
return;
}
const location = data as unknown as Point;
// Clicks on a scrollbar should allow the plugin to take focus.
if (this.viewport.isPointOnScrollbar(location)) {
const textbox = this.shadowRoot.querySelector('ink-text-box');
assert(textbox);
textbox.blur();
} else {
this.maybeCreateTextAnnotation_(data as unknown as Point);
}
return;
// </if>
}
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);
}
// <if expr="enable_pdf_save_to_drive">
private handleSaveToDriveProgress_(
streamUrl: string, progress: SaveToDriveProgress) {
if (streamUrl !== this.browserApi!.getStreamInfo().streamUrl) {
return;
}
this.saveToDriveProgress_ = progress;
this.saveToDriveState_ =
convertSaveToDriveProgressToSaveToDriveState(progress);
}
// </if>
/** Handles updating viewport params based on the `newUrl` provided. */
private handleMaybeUpdateViewport_(newUrl: string) {
assert(this.paramsParser);
this.paramsParser.getViewportFromUrlParams(newUrl).then(
params => this.handleUrlParams(params));
this.maybeRenderTextDirectiveHighlights_(newUrl);
}
// <if expr="enable_pdf_ink2">
/** Handles the start of a new ink stroke in annotation mode. */
private handleStartInkStroke_() {
this.pluginController_.getEventTarget().dispatchEvent(
new CustomEvent(PluginControllerEventType.START_INK_STROKE));
}
/** Handles a new ink stroke in annotation mode. */
private handleFinishInkStroke_(modified: boolean) {
if (modified) {
this.hasCommittedInk2Edits_ = true;
this.hasUnsavedEdits_ = true;
this.setShowBeforeUnloadDialog_(true);
}
this.pluginController_.getEventTarget().dispatchEvent(new CustomEvent(
PluginControllerEventType.FINISH_INK_STROKE, {detail: modified}));
}
// </if>
/**
* Returns whether the PDF has entered editing mode or has committed ink2
* edits.
*/
private hasCommittedEdits_(): boolean {
let hasEdits = this.hasEdits_;
// <if expr="enable_pdf_ink2">
hasEdits ||= this.hasCommittedInk2Edits_;
// </if>
return hasEdits;
}
/** 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_;
// Tab title is updated only when document.title is called in a
// top-level document (`main_frame` of `WebContents`). For OOPIF PDF viewer,
// the current document is the child of a top-level document, hence using a
// private API to set the tab title.
// NOTE: Title should only be set for full-page PDFs.
if (this.pdfOopifEnabled && !this.embedded_) {
PdfViewerPrivateProxyImpl.getInstance().setPdfDocumentTitle(this.title_);
} else {
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.
*/
protected async onSaveAttachment_(e: CustomEvent<number>) {
const index = e.detail;
assert(this.attachments_[index] !== undefined);
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_web_plugin.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;
if (this.pdfUseShowSaveFilePicker_) {
try {
const fileHandle = await window.showSaveFilePicker({
suggestedName: fileName,
});
const writable = await fileHandle.createWritable();
await writable.write(blob);
await writable.close();
} catch (error: any) {
if (error.name !== 'AbortError') {
console.error('window.showSaveFilePicker failed: ' + error);
}
}
} else {
const writer = await this.selectFileAndGetWriter_(fileName);
if (writer !== null) {
writer.write(blob);
}
}
}
/**
* 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 onSave_(streamUrl: string) {
if (streamUrl !== this.browserApi!.getStreamInfo().streamUrl) {
return;
}
let shouldSaveWithAnnotation = false;
// <if expr="enable_pdf_ink2">
if (this.pdfInk2Enabled_) {
shouldSaveWithAnnotation = this.hasCommittedInk2Edits_ ||
this.textboxState_ === TextBoxState.EDITED;
}
// </if>
let saveMode;
if (shouldSaveWithAnnotation) {
saveMode = SaveRequestType.ANNOTATION;
} else if (this.hasEdits_) {
saveMode = SaveRequestType.EDITED;
} else if (this.hasSearchifyText_ && this.pdfSearchifySaveEnabled_) {
saveMode = SaveRequestType.SEARCHIFIED;
} else {
saveMode = SaveRequestType.ORIGINAL;
}
this.save_(saveMode);
}
protected onToolbarSave_(e: CustomEvent<SaveRequestType>) {
this.save_(e.detail);
}
// <if expr="enable_pdf_save_to_drive">
getStreamUrlForTesting(): string {
return this.browserApi!.getStreamInfo().streamUrl;
}
setOnSaveToDriveProgressListenerForTesting() {
PdfViewerPrivateProxyImpl.getInstance().onSaveToDriveProgress.addListener(
this.handleSaveToDriveProgress_.bind(this));
}
setPdfNavigatorForTesting(navigator: PdfNavigator) {
this.navigator_ = navigator;
}
// Calculates the save to Drive progress in percentage. Returns 0 if the PDF
// is not uploading to Drive.
protected getSaveToDriveProgress_(): number {
if (!this.isSaveToDriveUploading_()) {
return 0;
}
const fileSizeBytes = this.saveToDriveProgress_.fileSizeBytes ?? 0;
if (fileSizeBytes === 0) {
return 0;
}
const uploadedBytes = this.saveToDriveProgress_.uploadedBytes ?? 0;
return Math.round((uploadedBytes / fileSizeBytes) * 100);
}
protected isSaveToDriveUploading_(): boolean {
return this.saveToDriveState_ === SaveToDriveState.UPLOADING;
}
protected onSaveToDrive_(e: CustomEvent<SaveRequestType>) {
if (this.saveToDriveState_ === SaveToDriveState.UNINITIALIZED) {
PdfViewerPrivateProxyImpl.getInstance().saveToDrive(e.detail);
this.saveToDriveRequestType_ = e.detail;
let pdfInk2Enabled = false;
// <if expr="enable_pdf_ink2">
pdfInk2Enabled = this.pdfInk2Enabled_;
// </if>
recordSaveToDriveMetrics(
e.detail, this.hasCommittedEdits_(), pdfInk2Enabled);
return;
}
this.getSaveToDriveBubble_().showAt(
this.$.toolbar.getSaveToDriveBubbleAnchor());
recordShowSaveToDriveBubbleMetrics(this.saveToDriveState_);
}
protected onSaveToDriveBubbleAction_(
e: CustomEvent<SaveToDriveBubbleRequestType>) {
recordSaveToDriveBubbleActionMetrics(e.detail);
switch (e.detail) {
case SaveToDriveBubbleRequestType.CANCEL_UPLOAD:
PdfViewerPrivateProxyImpl.getInstance().saveToDrive(
/*saveRequestType=undefined*/);
this.saveToDriveState_ = SaveToDriveState.UNINITIALIZED;
break;
case SaveToDriveBubbleRequestType.MANAGE_STORAGE:
assert(this.saveToDriveProgress_.accountEmail);
this.handleNavigate_(
getSaveToDriveManageStorageUrl(
this.saveToDriveProgress_.accountEmail,
this.saveToDriveProgress_.accountIsManaged ?? false),
WindowOpenDisposition.NEW_FOREGROUND_TAB);
this.saveToDriveState_ = SaveToDriveState.UNINITIALIZED;
break;
case SaveToDriveBubbleRequestType.OPEN_IN_DRIVE:
assert(this.saveToDriveProgress_.accountEmail);
assert(this.saveToDriveProgress_.driveItemId);
this.handleNavigate_(
getSaveToDriveOpenInDriveUrl(
this.saveToDriveProgress_.accountEmail,
this.saveToDriveProgress_.driveItemId),
WindowOpenDisposition.NEW_FOREGROUND_TAB);
this.saveToDriveState_ = SaveToDriveState.UNINITIALIZED;
break;
case SaveToDriveBubbleRequestType.RETRY:
PdfViewerPrivateProxyImpl.getInstance().saveToDrive(
this.saveToDriveRequestType_);
recordSaveToDriveBubbleRetryMetrics(
this.saveToDriveRequestType_, this.hasCommittedEdits_());
break;
case SaveToDriveBubbleRequestType.DIALOG_CLOSED:
if (saveToDriveStateIsFinalState(this.saveToDriveState_)) {
this.saveToDriveState_ = SaveToDriveState.UNINITIALIZED;
}
break;
default:
console.warn(
'Saving to Drive bubble action is not implemented yet.', e.detail);
break;
}
}
private getSaveToDriveBubble_(): ViewerSaveToDriveBubbleElement {
const bubble =
this.shadowRoot.querySelector<ViewerSaveToDriveBubbleElement>(
'viewer-save-to-drive-bubble');
assert(bubble);
return bubble;
}
private onSaveToDriveStateChanged_(oldState: SaveToDriveState) {
const newState = this.saveToDriveState_;
if (saveToDriveStateIsFinalState(newState)) {
if (newState === SaveToDriveState.SUCCESS) {
this.onSaveSuccessful_(this.saveToDriveRequestType_);
} else if (oldState === SaveToDriveState.UPLOADING) {
// TODO(crbug.com/450600664): Fix an edge case where beforeunload dialog
// is still blocking if an EDITED upload is cancelled after a successful
// EDITED disk save.
// <if expr="enable_pdf_ink2">
this.onSaveFailedOrCancelled_(this.saveToDriveRequestType_);
// </if>
}
this.getSaveToDriveBubble_().showAt(
this.$.toolbar.getSaveToDriveBubbleAnchor(),
/*autoDismiss=*/ true);
return;
}
if (newState === SaveToDriveState.UPLOADING) {
// Block unloading the window if upload is in progress.
this.setShowBeforeUnloadDialog_(true);
if (isEditedSaveRequestType(this.saveToDriveRequestType_)) {
this.hasUnsavedEdits_ = false;
}
return;
}
assert(
newState === SaveToDriveState.UNINITIALIZED,
`Unexpected state: ${newState}`);
if (oldState !== SaveToDriveState.UPLOADING) {
this.setShowBeforeUnloadDialog_(this.hasUnsavedEdits_);
}
}
// </if> enable_pdf_save_to_drive
protected onChangePage_(e: CustomEvent<ChangePageDetail>) {
this.viewport.goToPage(e.detail.page);
if (e.detail.origin === ChangePageOrigin.BOOKMARK) {
record(UserAction.FOLLOW_BOOKMARK);
} else if (e.detail.origin === ChangePageOrigin.PAGE_SELECTOR) {
record(UserAction.PAGE_SELECTOR_NAVIGATE);
} else if (e.detail.origin === ChangePageOrigin.THUMBNAIL) {
record(UserAction.THUMBNAIL_NAVIGATE);
}
}
protected onChangePageAndXy_(e: CustomEvent<ChangePageAndXyDetail>) {
const point = this.viewport.convertPageToScreen(e.detail.page, e.detail);
this.goToPageAndXy_(e.detail.origin, e.detail.page, point);
}
protected onNavigate_(e: CustomEvent<NavigateDetail>) {
const disposition = e.detail.newtab ?
WindowOpenDisposition.NEW_BACKGROUND_TAB :
WindowOpenDisposition.CURRENT_TAB;
this.navigator_!.navigate(e.detail.uri, disposition);
}
protected 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');
}
// <if expr="enable_pdf_ink2">
protected onStrokesUpdated_(e: CustomEvent<number>) {
this.hasCommittedInk2Edits_ = e.detail > 0;
this.hasUnsavedEdits_ = this.hasCommittedInk2Edits_;
// If the user already saved, always show the beforeunload dialog if the
// strokes have updated. If the user hasn't saved, only show the
// beforeunload dialog if there's edits.
this.setShowBeforeUnloadDialog_(
this.hasSavedEdits_ || this.shouldShowBeforeUnloadDialog_());
}
// </if>
/**
* Sends a message to the PDF plugin to highlight the provided text
* directives if any.
*/
private maybeRenderTextDirectiveHighlights_(url: string) {
assert(this.paramsParser);
const textDirectives = this.paramsParser.getTextFragments(url);
if (textDirectives.length > 0) {
this.pluginController_.highlightTextFragments(textDirectives);
}
}
/**
* Shows save file picker and returns a writable.
* @param: suggestedName The default value for the filename.
* @returns A Writable if successful, otherwise throws an exception.
*/
private async selectFileAndGetWritable_(suggestedName: string) {
assert(this.pdfUseShowSaveFilePicker_);
const fileHandle = await window.showSaveFilePicker({
suggestedName: suggestedName,
types: [{
description: 'PDF Files',
accept: {'application/pdf': ['.pdf']},
}],
});
return fileHandle.createWritable();
}
/**
* Shows deprecated save file picker and returns a FileWriter if successful.
* @param suggestedName The default value for the filename.
* @returns A FileWriter if successful, otherwise returns null.
*/
private async selectFileAndGetWriter_(suggestedName: string):
Promise<FileWriter|null> {
assert(!this.pdfUseShowSaveFilePicker_);
return new Promise(resolve => {
chrome.fileSystem.chooseEntry(
{
type: 'saveFile',
accepts: [{description: '*.pdf', extensions: ['pdf']}],
suggestedName: suggestedName,
},
(entry?: FileSystemFileEntry) => {
if (chrome.runtime.lastError) {
if (chrome.runtime.lastError.message !== 'User cancelled') {
console.error(
'chrome.fileSystem.chooseEntry failed: ' +
chrome.runtime.lastError.message);
}
resolve(null);
}
assert(entry);
entry.createWriter(writer => {
resolve(writer);
});
});
});
}
/**
* Writes a blob to a FileWriter, waiting until writing is completed. Throws
* an exception on error.
* @param writer: The FileWriter into which data is written.
* @param blob: The Blob of data to write.
*/
private writeToWriter_(writer: FileWriter, blob: Blob): Promise<void> {
return new Promise((resolve, reject) => {
writer.onwriteend = () => resolve();
writer.onerror = () => reject(writer.error);
writer.write(blob);
});
}
/**
* 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.
assert(this.currentController);
// <if expr="enable_pdf_ink2">
// If there is an open textbox, call commitTextAnnotation(). This will fire
// a message to the plugin with the annotation, if it has been edited.
if (this.textboxState_ !== TextBoxState.INACTIVE) {
const textbox = this.shadowRoot.querySelector('ink-text-box');
assert(textbox);
textbox.commitTextAnnotation();
}
// `this.hasUnsavedEdits_` will be set back to true if save is disrupted for
// SaveRequestType.ANNOTATION or SaveRequestType.EDITED.
if (isEditedSaveRequestType(requestType)) {
this.hasUnsavedEdits_ = false;
}
// </if>
if (this.pdfGetSaveDataInBlocks_) {
this.saveInBlocks_(requestType);
return;
}
const result = await this.currentController.save(requestType);
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';
}
// <if expr="enable_pdf_ink2">
if (result.bypassSaveFileForTesting) {
// Only set by the mock plugin.
this.onSaveSuccessful_(requestType);
return;
}
// </if>
// Create blob before callback to avoid race condition.
const blob = new Blob([result.dataToSave], {type: 'application/pdf'});
if (!this.pdfUseShowSaveFilePicker_) {
const writer = await this.selectFileAndGetWriter_(fileName);
if (writer === null) {
// <if expr="enable_pdf_ink2">
this.onSaveFailedOrCancelled_(requestType);
// </if>
return;
}
writer.write(blob);
// <if expr="enable_pdf_ink2">
this.onSaveSuccessful_(requestType);
// </if>
return;
}
try {
const writable = await this.selectFileAndGetWritable_(fileName);
await writable.write(blob);
await writable.close();
// <if expr="enable_pdf_ink2">
this.onSaveSuccessful_(requestType);
// </if>
} catch (error: any) {
if (error.name !== 'AbortError') {
console.error('window.showSaveFilePicker failed: ' + error);
}
// <if expr="enable_pdf_ink2">
this.onSaveFailedOrCancelled_(requestType);
// </if>
}
}
/**
* Saves the current PDF document to disk in blocks.
*
* This function does not perform pre/post steps of saving and should be
* called by `save_`.
*/
private async saveInBlocks_(requestType: SaveRequestType) {
// TODO(crbug.com/382610226): Update for `SaveRequestType.SEARCHIFIED` to
// allow users to select saving original PDF or text extracted one.
// To do so, the save type should be asked first, and then content would be
// fetched based on the selected type.
assert(this.pluginController_.isActive);
// Request type is only passed for testing purposes.
const nameResult =
await this.pluginController_.getSuggestedFileName(requestType);
// Make sure file extension is .pdf, avoids dangerous extensions.
let fileName = nameResult.fileName;
if (!fileName.toLowerCase().endsWith('.pdf')) {
fileName = fileName + '.pdf';
}
// <if expr="enable_pdf_ink2">
if (nameResult.bypassSaveFileForTesting) {
// Only set by the mock plugin.
this.onSaveSuccessful_(requestType);
return;
}
// </if>
try {
let writable: FileSystemWritableFileStream|null;
let writer: FileWriter|null;
if (this.pdfUseShowSaveFilePicker_) {
writable = await this.selectFileAndGetWritable_(fileName);
writer = null;
} else {
writer = await this.selectFileAndGetWriter_(fileName);
if (writer === null) {
// <if expr="enable_pdf_ink2">
this.onSaveFailedOrCancelled_(requestType);
// </if>
return;
}
writable = null;
}
// Total file size is updated after the first results are received.
let totalFileSize = 0;
let offset = 0;
do {
// Get save data from plugin in maximum 16 MB blocks.
// LINT.IfChange(MaxSaveBufferSize)
const MAX_SAVE_BUFFER_SIZE = 16 * 1000 * 1000;
// LINT.ThenChange(//pdf/pdf_view_web_plugin.cc:MaxSaveBufferSize)
// `blockSize` will be 0 on the first call, since the total file size
// is not known yet.
const blockSize =
Math.min(totalFileSize - offset, MAX_SAVE_BUFFER_SIZE);
const result = await this.pluginController_.getSaveDataBlock(
requestType, offset, blockSize);
if (offset === 0) {
// Update `totalFileSize` after the first block of data is received.
totalFileSize = result.totalFileSize;
if (totalFileSize === 0) {
// File could not be saved.
throw new Error('File size is zero.');
}
verifyPdfHeader(result.dataToSave);
assert(result.dataToSave.byteLength !== 0);
} else {
assert(result.dataToSave.byteLength === blockSize);
}
offset += result.dataToSave.byteLength;
if (writable !== null) {
await writable.write(result.dataToSave);
} else {
assert(writer !== null);
const blob = new Blob([result.dataToSave], {type: 'application/pdf'});
await this.writeToWriter_(writer, blob);
}
} while (offset < totalFileSize);
if (writable !== null) {
await writable.close();
}
// <if expr="enable_pdf_ink2">
this.onSaveSuccessful_(requestType);
// </if>
} catch (error: any) {
this.pluginController_.releaseSaveInBlockBuffers();
if (error.name !== 'AbortError') {
console.error('window.showSaveFilePicker failed: ' + error);
}
// <if expr="enable_pdf_ink2">
this.onSaveFailedOrCancelled_(requestType);
// </if>
}
}
// <if expr="enable_pdf_ink2 or enable_pdf_save_to_drive">
/**
* Performs required tasks after a successful save.
*/
private onSaveSuccessful_(requestType: SaveRequestType) {
this.setShowBeforeUnloadDialog_(this.shouldShowBeforeUnloadDialog_());
// <if expr="enable_pdf_ink2">
this.hasSavedEdits_ =
this.hasSavedEdits_ || requestType === SaveRequestType.EDITED;
// </if> enable_pdf_ink2
}
/**
* Returns whether the beforeunload dialog should be shown.
*/
private shouldShowBeforeUnloadDialog_(): boolean {
let showBeforeUnloadDialog = this.hasUnsavedEdits_;
// <if expr="enable_pdf_save_to_drive">
// If Save to Drive is uploading, block closing the window.
showBeforeUnloadDialog =
showBeforeUnloadDialog || this.isSaveToDriveUploading_();
// </if> enable_pdf_save_to_drive
return showBeforeUnloadDialog;
}
// </if> enable_pdf_ink2 or enable_pdf_save_to_drive
/**
* Records metrics for saving PDFs.
*/
private recordSaveMetrics_(requestType: SaveRequestType) {
record(UserAction.SAVE);
switch (requestType) {
case SaveRequestType.ANNOTATION:
record(UserAction.SAVE_WITH_ANNOTATION);
// <if expr="enable_pdf_ink2">
if (this.pdfInk2Enabled_) {
record(UserAction.SAVE_WITH_INK2_ANNOTATION);
}
// </if>
break;
case SaveRequestType.ORIGINAL:
record(
this.hasCommittedEdits_() ? UserAction.SAVE_ORIGINAL :
UserAction.SAVE_ORIGINAL_ONLY);
break;
case SaveRequestType.EDITED:
record(UserAction.SAVE_EDITED);
break;
case SaveRequestType.SEARCHIFIED:
// TODO(crbug.com/382610226): Update metric after the code is updated to
// give users the option to save searchified or original PDF, and add
// test.
record(UserAction.SAVE_SEARCHIFIED);
break;
}
}
protected onPrint_() {
record(UserAction.PRINT);
assert(this.currentController);
this.currentController.print();
}
/**
* Updates the toolbar's annotation available flag depending on current
* conditions.
* @return Whether annotations are available.
*/
protected annotationAvailable_(): boolean {
return this.canSerializeDocument_ && !this.hadPassword_;
}
/** @return Whether the PDF contents are rotated. */
protected isRotated_(): boolean {
return this.clockwiseRotations_ !== 0;
}
// <if expr="enable_pdf_ink2">
protected isTextboxActive_(): boolean {
return this.textboxState_ !== TextBoxState.INACTIVE;
}
protected isInTextAnnotationMode_(): boolean {
return this.annotationMode_ === AnnotationMode.TEXT;
}
/**
* @return Whether the Ink bottom toolbar should be shown. It should never be
* shown if the Ink side panel is shown.
*/
protected shouldShowInkBottomToolbar_(): boolean {
return this.inInk2AnnotationMode_() && !this.useSidePanelForInk_;
}
/**
* @return Whether the Ink side panel should be shown. It should never be
* shown if the Ink bottom toolbar is shown. It should be shown if the
* window width is at least a certain width.
*/
protected shouldShowInkSidePanel_(): boolean {
return this.inInk2AnnotationMode_() && this.useSidePanelForInk_;
}
protected hasInk2AnnotationEdits_(): boolean {
return this.textboxState_ === TextBoxState.EDITED ||
this.hasCommittedInk2Edits_;
}
/**
* Performs required tasks after a failed or cancelled save.
*/
private onSaveFailedOrCancelled_(requestType: SaveRequestType) {
// Restore the original value of `hasUnsavedEdits_` and block closing the
// window if there are unsaved edits.
if (isEditedSaveRequestType(requestType)) {
this.hasUnsavedEdits_ = true;
}
this.setShowBeforeUnloadDialog_(this.shouldShowBeforeUnloadDialog_());
}
protected onTextBoxStateChanged_(e: CustomEvent<TextBoxState>) {
this.textboxState_ = e.detail;
if (e.detail === TextBoxState.EDITED) {
this.setShowBeforeUnloadDialog_(true);
}
}
/**
* @returns Whether the PDF viewer has Ink2 enabled and is in annotation mode.
*/
private inInk2AnnotationMode_(): boolean {
return this.pdfInk2Enabled_ && this.annotationMode_ !== AnnotationMode.OFF;
}
// </if>
// <if expr="enable_pdf_ink2 or enable_pdf_save_to_drive">
/**
* Handles the `BeforeUnloadEvent` event.
* @param event The `BeforeUnloadEvent` object representing the event.
*/
private onBeforeUnload_(event: BeforeUnloadEvent) {
// When a user tries to leave PDF with unsaved changes or when Save to Drive
// is in progress, show the 'Leave site' dialog. OOPIF PDF only, since
// MimeHandler handles the beforeunload event instead.
if (this.pdfOopifEnabled && this.showBeforeUnloadDialog_) {
BeforeUnloadProxyImpl.getInstance().preventDefault(event);
}
}
/**
* Sets whether to show the beforeunload dialog when navigating away from pdf.
* @param showDialog A boolean indicating whether to show the beforeunload
* dialog.
*/
private setShowBeforeUnloadDialog_(showDialog: boolean) {
if (this.showBeforeUnloadDialog_ === showDialog) {
return;
}
this.showBeforeUnloadDialog_ = showDialog;
if (!this.pdfOopifEnabled) {
chrome.mimeHandlerPrivate.setShowBeforeUnloadDialog(showDialog);
}
}
// </if>
}
declare global {
interface HTMLElementTagNameMap {
'pdf-viewer': PdfViewerElement;
}
interface Window {
showSaveFilePicker(opts: unknown): Promise<FileSystemFileHandle>;
}
}
customElements.define(PdfViewerElement.is, PdfViewerElement);