0

PDF Viewer: Migrate to TypeScript, part 2.

Based on dhoss@'s https://chromium-review.googlesource.com/c/chromium/src/+/2983068

Bug: 1260303
Change-Id: Ibc3d7c1887b9287bf0c4ec5611f8ae1d011472b6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/3500673
Reviewed-by: K. Moon <kmoon@chromium.org>
Reviewed-by: Rebekah Potter <rbpotter@chromium.org>
Commit-Queue: Demetrios Papadopoulos <dpapad@chromium.org>
Cr-Commit-Position: refs/heads/main@{#977013}
This commit is contained in:
dpapad
2022-03-03 08:09:45 +00:00
committed by Chromium LUCI CQ
parent 8285b14907
commit ff8a7e4ecf
17 changed files with 454 additions and 512 deletions

@ -8,12 +8,20 @@ import 'chrome://resources/cr_elements/shared_vars_css.m.js';
import 'chrome://resources/polymer/v3_0/paper-styles/color.js';
import './shared-css.js';
import {CrIconButtonElement} from 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.m.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {Bookmark} from '../bookmark_type.js';
/** Amount that each level of bookmarks is indented by (px). */
const BOOKMARK_INDENT = 20;
const BOOKMARK_INDENT: number = 20;
export interface ViewerBookmarkElement {
$: {
item: HTMLElement,
expand: CrIconButtonElement,
};
}
export class ViewerBookmarkElement extends PolymerElement {
static get is() {
@ -26,7 +34,6 @@ export class ViewerBookmarkElement extends PolymerElement {
static get properties() {
return {
/** @type {Bookmark} */
bookmark: {
type: Object,
observer: 'bookmarkChanged_',
@ -37,10 +44,8 @@ export class ViewerBookmarkElement extends PolymerElement {
observer: 'depthChanged_',
},
/** @private */
childDepth_: Number,
/** @private */
childrenShown_: {
type: Boolean,
reflectToAttribute: true,
@ -49,51 +54,45 @@ export class ViewerBookmarkElement extends PolymerElement {
};
}
/** @override */
bookmark: Bookmark;
depth: number;
private childDepth_: number;
private childrenShown_: boolean;
ready() {
super.ready();
this.$.item.addEventListener('keydown', e => {
const keyboardEvent = /** @type {!KeyboardEvent} */ (e);
if (keyboardEvent.key === 'Enter') {
this.onEnter_(keyboardEvent);
} else if (keyboardEvent.key === ' ') {
this.onSpace_(keyboardEvent);
if (e.key === 'Enter') {
this.onEnter_(e);
} else if (e.key === ' ') {
this.onSpace_(e);
}
});
}
/**
* @param {string} eventName
* @param {*=} detail
* @private
*/
fire_(eventName, detail) {
private fire_(eventName: string, detail?: any) {
this.dispatchEvent(
new CustomEvent(eventName, {bubbles: true, composed: true, detail}));
}
/** @private */
bookmarkChanged_() {
private bookmarkChanged_() {
this.$.expand.style.visibility =
this.bookmark.children.length > 0 ? 'visible' : 'hidden';
}
/** @private */
depthChanged_() {
private depthChanged_() {
this.childDepth_ = this.depth + 1;
this.$.item.style.paddingInlineStart =
(this.depth * BOOKMARK_INDENT) + 'px';
}
/** @private */
onClick_() {
private onClick_() {
if (this.bookmark.page != null) {
if (this.bookmark.zoom != null) {
this.fire_('change-zoom', {zoom: this.bookmark.zoom});
}
if (this.bookmark.x != null &&
this.bookmark.y != null) {
if (this.bookmark.x != null && this.bookmark.y != null) {
this.fire_('change-page-and-xy', {
page: this.bookmark.page,
x: this.bookmark.x,
@ -109,11 +108,7 @@ export class ViewerBookmarkElement extends PolymerElement {
}
}
/**
* @param {!KeyboardEvent} e
* @private
*/
onEnter_(e) {
private onEnter_(e: KeyboardEvent) {
// Don't allow events which have propagated up from the expand button to
// trigger a click.
if (e.target !== this.$.expand) {
@ -121,11 +116,7 @@ export class ViewerBookmarkElement extends PolymerElement {
}
}
/**
* @param {!KeyboardEvent} e
* @private
*/
onSpace_(e) {
private onSpace_(e: KeyboardEvent) {
// cr-icon-button stops propagation of space events, so there's no need
// to check the event source here.
this.onClick_();
@ -133,22 +124,20 @@ export class ViewerBookmarkElement extends PolymerElement {
e.preventDefault();
}
/**
* @param {!Event} e
* @private
*/
toggleChildren_(e) {
private toggleChildren_(e: Event) {
this.childrenShown_ = !this.childrenShown_;
e.stopPropagation(); // Prevent the above onClick_ handler from firing.
}
/**
* @return {string}
* @private
*/
getAriaExpanded_() {
private getAriaExpanded_(): string {
return this.childrenShown_ ? 'true' : 'false';
}
}
declare global {
interface HTMLElementTagNameMap {
'viewer-bookmark': ViewerBookmarkElement;
}
}
customElements.define(ViewerBookmarkElement.is, ViewerBookmarkElement);

@ -20,10 +20,17 @@ export class ViewerDocumentOutlineElement extends PolymerElement {
static get properties() {
return {
/** @type {!Array<!Bookmark>} */
bookmarks: Array,
};
}
bookmarks: Bookmark[];
}
declare global {
interface HTMLElementTagNameMap {
'viewer-document-outline': ViewerDocumentOutlineElement;
}
}
customElements.define(

@ -2,17 +2,26 @@
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.m.js';
import 'chrome://resources/cr_elements/icons.m.js';
import './icons.js';
import './shared-css.js';
import {AnchorAlignment, CrActionMenuElement} from 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import {CrIconButtonElement} from 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.m.js';
import {PromiseResolver} from 'chrome://resources/js/promise_resolver.m.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {SaveRequestType} from '../constants.js';
export interface ViewerDownloadControlsElement {
$: {
download: CrIconButtonElement,
menu: CrActionMenuElement,
};
}
export class ViewerDownloadControlsElement extends PolymerElement {
static get is() {
return 'viewer-download-controls';
@ -39,7 +48,6 @@ export class ViewerDownloadControlsElement extends PolymerElement {
'hasEnteredAnnotationMode)',
},
/** @private */
menuOpen_: {
type: Boolean,
reflectToAttribute: true,
@ -48,73 +56,38 @@ export class ViewerDownloadControlsElement extends PolymerElement {
};
}
constructor() {
super();
hasEdits: boolean;
hasEnteredAnnotationMode: boolean;
isFormFieldFocused: boolean;
private downloadHasPopup_: string;
private menuOpen_: boolean;
private waitForFormFocusChange_: PromiseResolver<boolean>|null = null;
// Polymer properties
/** @private {string} */
this.downloadHasPopup_;
/** @type {boolean} */
this.hasEdits;
/** @type {boolean} */
this.hasEnteredAnnotationMode;
/** @type {boolean} */
this.isFormFieldFocused;
// Non-Polymer properties
/** @private {?PromiseResolver<boolean>} */
this.waitForFormFocusChange_ = null;
}
/** @return {boolean} */
isMenuOpen() {
isMenuOpen(): boolean {
return this.menuOpen_;
}
closeMenu() {
this.getDownloadMenu_().close();
this.$.menu.close();
}
/**
* @param {!CustomEvent<!{value: boolean}>} e
* @private
*/
onOpenChanged_(e) {
private onOpenChanged_(e: CustomEvent<{value: boolean}>) {
this.menuOpen_ = e.detail.value;
}
/**
* @return {boolean}
* @private
*/
hasEditsToSave_() {
private hasEditsToSave_(): boolean {
return this.hasEnteredAnnotationMode || this.hasEdits;
}
/**
* @return {string} The value for the aria-haspopup attribute for the download
* button.
* @private
* @return The value for the aria-haspopup attribute for the download button.
*/
computeDownloadHasPopup_() {
private computeDownloadHasPopup_(): string {
return this.hasEditsToSave_() ? 'menu' : 'false';
}
/**
* @return {!CrActionMenuElement}
* @private
*/
getDownloadMenu_() {
return /** @type {!CrActionMenuElement} */ (
this.shadowRoot.querySelector('#menu'));
}
/** @private */
showDownloadMenu_() {
this.getDownloadMenu_().showAt(this.$.download, {
private showDownloadMenu_() {
this.$.menu.showAt(this.$.download, {
anchorAlignmentX: AnchorAlignment.CENTER,
});
// For tests
@ -122,8 +95,7 @@ export class ViewerDownloadControlsElement extends PolymerElement {
'download-menu-shown-for-testing', {bubbles: true, composed: true}));
}
/** @private */
onDownloadClick_() {
private onDownloadClick_() {
this.waitForEdits_().then(hasEdits => {
if (hasEdits) {
this.showDownloadMenu_();
@ -134,11 +106,10 @@ export class ViewerDownloadControlsElement extends PolymerElement {
}
/**
* @return {!Promise<boolean>} Promise that resolves with true if the PDF has
* edits and/or annotations, and false otherwise.
* @private
* @return Promise that resolves with true if the PDF has edits and/or
* annotations, and false otherwise.
*/
waitForEdits_() {
private waitForEdits_(): Promise<boolean> {
if (this.hasEditsToSave_()) {
return Promise.resolve(true);
}
@ -149,8 +120,7 @@ export class ViewerDownloadControlsElement extends PolymerElement {
return this.waitForFormFocusChange_.promise;
}
/** @private */
onFormFieldFocusedChanged_() {
private onFormFieldFocusedChanged_() {
if (!this.waitForFormFocusChange_) {
return;
}
@ -159,28 +129,29 @@ export class ViewerDownloadControlsElement extends PolymerElement {
this.waitForFormFocusChange_ = null;
}
/**
* @param {!SaveRequestType} type
* @private
*/
dispatchSaveEvent_(type) {
private dispatchSaveEvent_(type: SaveRequestType) {
this.dispatchEvent(
new CustomEvent('save', {detail: type, bubbles: true, composed: true}));
}
/** @private */
onDownloadOriginalClick_() {
private onDownloadOriginalClick_() {
this.dispatchSaveEvent_(SaveRequestType.ORIGINAL);
this.getDownloadMenu_().close();
this.$.menu.close();
}
/** @private */
onDownloadEditedClick_() {
private onDownloadEditedClick_() {
this.dispatchSaveEvent_(
this.hasEnteredAnnotationMode ? SaveRequestType.ANNOTATION :
SaveRequestType.EDITED);
this.getDownloadMenu_().close();
this.$.menu.close();
}
}
declare global {
interface HTMLElementTagNameMap {
'viewer-download-controls': ViewerDownloadControlsElement;
}
}
customElements.define(
ViewerDownloadControlsElement.is, ViewerDownloadControlsElement);

@ -13,7 +13,7 @@
--page-selector-spacing: 4px;
}
#pageselector::selection {
#pageSelector::selection {
background-color: var(--viewer-text-input-selection-color);
}
@ -41,9 +41,9 @@
}
</style>
<div id="content">
<input part="input" type="text" id="pageselector" value="[[pageNo]]"
<input part="input" type="text" id="pageSelector" value="[[pageNo]]"
on-pointerup="select" on-input="onInput_" on-change="pageNoCommitted"
aria-label="$i18n{labelPageNumber}">
<span id="divider">/</span>
<span id="pagelength">[[docLength]]</span>
</div>
</div>

@ -6,6 +6,12 @@ import './shared-vars.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
export interface ViewerPageSelectorElement {
$: {
pageSelector: HTMLInputElement,
};
}
export class ViewerPageSelectorElement extends PolymerElement {
static get is() {
return 'viewer-page-selector';
@ -17,9 +23,7 @@ export class ViewerPageSelectorElement extends PolymerElement {
static get properties() {
return {
/**
* The number of pages the document contains.
*/
/** The number of pages the document contains. */
docLength: {type: Number, value: 1, observer: 'docLengthChanged_'},
/**
@ -35,48 +39,46 @@ export class ViewerPageSelectorElement extends PolymerElement {
};
}
/** @return {!HTMLInputElement} */
get pageSelector() {
return /** @type {!HTMLInputElement} */ (this.$.pageselector);
}
docLength: number;
pageNo: number;
pageNoCommitted() {
const page = parseInt(this.pageSelector.value, 10);
const page = parseInt(this.$.pageSelector.value, 10);
if (!isNaN(page) && page <= this.docLength && page > 0) {
this.dispatchEvent(new CustomEvent('change-page', {
detail: {page: page - 1, origin: 'pageselector'},
detail: {page: page - 1, origin: 'pageSelector'},
composed: true,
}));
} else {
this.pageSelector.value = this.pageNo.toString();
this.$.pageSelector.value = this.pageNo.toString();
}
this.pageSelector.blur();
this.$.pageSelector.blur();
}
/** @private */
docLengthChanged_() {
private docLengthChanged_() {
const numDigits = this.docLength.toString().length;
this.style.setProperty('--page-length-digits', `${numDigits}`);
}
select() {
this.pageSelector.select();
this.$.pageSelector.select();
}
/**
* @return {boolean} True if the selector input field is currently focused.
*/
isActive() {
return this.shadowRoot.activeElement === this.pageSelector;
/** @return True if the selector input field is currently focused. */
isActive(): boolean {
return this.shadowRoot!.activeElement === this.$.pageSelector;
}
/**
* Immediately remove any non-digit characters.
* @private
*/
onInput_() {
this.pageSelector.value = this.pageSelector.value.replace(/[^\d]/, '');
/** Immediately remove any non-digit characters. */
private onInput_() {
this.$.pageSelector.value = this.$.pageSelector.value.replace(/[^\d]/, '');
}
}
declare global {
interface HTMLElementTagNameMap {
'viewer-page-selector': ViewerPageSelectorElement;
}
}

@ -8,8 +8,19 @@ import 'chrome://resources/cr_elements/cr_input/cr_input.m.js';
import 'chrome://resources/cr_elements/shared_style_css.m.js';
import 'chrome://resources/cr_elements/shared_vars_css.m.js';
import {CrButtonElement} from 'chrome://resources/cr_elements/cr_button/cr_button.m.js';
import {CrDialogElement} from 'chrome://resources/cr_elements/cr_dialog/cr_dialog.m.js';
import {CrInputElement} from 'chrome://resources/cr_elements/cr_input/cr_input.m.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
export interface ViewerPasswordDialogElement {
$: {
dialog: CrDialogElement,
password: CrInputElement,
submit: CrButtonElement,
};
}
export class ViewerPasswordDialogElement extends PolymerElement {
static get is() {
return 'viewer-password-dialog';
@ -25,12 +36,14 @@ export class ViewerPasswordDialogElement extends PolymerElement {
};
}
invalid: boolean;
close() {
this.$.dialog.close();
}
deny() {
const password = /** @type {!CrInputElement} */ (this.$.password);
const password = this.$.password;
password.disabled = false;
this.$.submit.disabled = false;
this.invalid = true;
@ -40,7 +53,7 @@ export class ViewerPasswordDialogElement extends PolymerElement {
}
submit() {
const password = /** @type {!CrInputElement} */ (this.$.password);
const password = this.$.password;
if (password.value.length === 0) {
return;
}
@ -52,5 +65,11 @@ export class ViewerPasswordDialogElement extends PolymerElement {
}
}
declare global {
interface HTMLElementTagNameMap {
'viewer-password-dialog': ViewerPasswordDialogElement;
}
}
customElements.define(
ViewerPasswordDialogElement.is, ViewerPasswordDialogElement);

@ -29,7 +29,6 @@ export class ViewerPdfSidenavElement extends PolymerElement {
return {
activePage: Number,
/** @type {!Array<!Bookmark>} */
bookmarks: {
type: Array,
value: () => [],
@ -39,7 +38,6 @@ export class ViewerPdfSidenavElement extends PolymerElement {
docLength: Number,
/** @private */
thumbnailView_: {
type: Boolean,
value: true,
@ -47,49 +45,43 @@ export class ViewerPdfSidenavElement extends PolymerElement {
};
}
/** @private */
onThumbnailClick_() {
activePage: number;
bookmarks: Bookmark[];
clockwiseRotations: number;
docLength: number;
private thumbnailView_: boolean;
private onThumbnailClick_() {
record(UserAction.SELECT_SIDENAV_THUMBNAILS);
this.thumbnailView_ = true;
}
/** @private */
onOutlineClick_() {
private onOutlineClick_() {
record(UserAction.SELECT_SIDENAV_OUTLINE);
this.thumbnailView_ = false;
}
/**
* @return {string}
* @private
*/
outlineButtonClass_() {
private outlineButtonClass_(): string {
return this.thumbnailView_ ? '' : 'selected';
}
/**
* @return {string}
* @private
*/
thumbnailButtonClass_() {
private thumbnailButtonClass_(): string {
return this.thumbnailView_ ? 'selected' : '';
}
/**
* @return {string}
* @private
*/
getAriaSelectedThumbnails_() {
private getAriaSelectedThumbnails_(): string {
return this.thumbnailView_ ? 'true' : 'false';
}
/**
* @return {string}
* @private
*/
getAriaSelectedOutline_() {
private getAriaSelectedOutline_(): string {
return this.thumbnailView_ ? 'false' : 'true';
}
}
declare global {
interface HTMLElementTagNameMap {
'viewer-pdf-sidenav': ViewerPdfSidenavElement;
}
}
customElements.define(ViewerPdfSidenavElement.is, ViewerPdfSidenavElement);

@ -41,7 +41,7 @@
white-space: normal;
}
</style>
<cr-dialog show-on-attach>
<cr-dialog id="dialog" show-on-attach>
<div slot="title">$i18n{propertiesDialogTitle}</div>
<div slot="body">
<table>

@ -6,10 +6,17 @@ import 'chrome://resources/cr_elements/cr_dialog/cr_dialog.m.js';
import 'chrome://resources/cr_elements/shared_style_css.m.js';
import 'chrome://resources/cr_elements/shared_vars_css.m.js';
import {CrDialogElement} from 'chrome://resources/cr_elements/cr_dialog/cr_dialog.m.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {DocumentMetadata} from '../constants.js';
export interface ViewerPropertiesDialogElement {
$: {
dialog: CrDialogElement,
};
}
export class ViewerPropertiesDialogElement extends PolymerElement {
static get is() {
return 'viewer-properties-dialog';
@ -21,47 +28,33 @@ export class ViewerPropertiesDialogElement extends PolymerElement {
static get properties() {
return {
/** @type {!DocumentMetadata} */
documentMetadata: Object,
fileName: String,
pageCount: Number,
};
}
/**
* @return {!CrDialogElement}
* @private
*/
getDialog_() {
return /** @type {!CrDialogElement} */ (
this.shadowRoot.querySelector('cr-dialog'));
}
documentMetadata: DocumentMetadata;
fileName: string;
pageCount: number;
/**
* @param {string} yesLabel
* @param {string} noLabel
* @param {boolean} linearized
* @return {string}
* @private
*/
getFastWebViewValue_(yesLabel, noLabel, linearized) {
private getFastWebViewValue_(
yesLabel: string, noLabel: string, linearized: boolean): string {
return linearized ? yesLabel : noLabel;
}
/**
* @param {string} value
* @return {string}
* @private
*/
getOrPlaceholder_(value) {
private getOrPlaceholder_(value: string): string {
return value || '-';
}
/** @private */
onClickClose_() {
this.getDialog_().close();
private onClickClose_() {
this.$.dialog.close();
}
}
declare global {
interface HTMLElementTagNameMap {
'viewer-properties-dialog': ViewerPropertiesDialogElement;
}
}

@ -1,243 +0,0 @@
// Copyright 2020 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 './viewer-thumbnail.js';
import {assert} from 'chrome://resources/js/assert.m.js';
import {FocusOutlineManager} from 'chrome://resources/js/cr/ui/focus_outline_manager.m.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.m.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {PluginController, PluginControllerEventType} from '../controller.js';
import {ViewerThumbnailElement} from './viewer-thumbnail.js';
export class ViewerThumbnailBarElement extends PolymerElement {
static get is() {
return 'viewer-thumbnail-bar';
}
static get template() {
return html`{__html_template__}`;
}
static get properties() {
return {
activePage: {
type: Number,
observer: 'activePageChanged_',
},
clockwiseRotations: Number,
docLength: Number,
isPluginActive_: Boolean,
/** @private {Array<number>} */
pageNumbers_: {
type: Array,
computed: 'computePageNumbers_(docLength)',
},
};
}
constructor() {
super();
// TODO(dhoss): Remove `this.inTest` when implemented a mock plugin
// controller.
/** @type {boolean} */
this.inTest = false;
/** @private {!PluginController} */
this.pluginController_ = PluginController.getInstance();
/** @private {boolean} */
this.isPluginActive_ = this.pluginController_.isActive;
/** @private {!EventTracker} */
this.tracker_ = new EventTracker();
// Listen to whether the plugin is active. Thumbnails should be hidden
// when the plugin is inactive.
this.tracker_.add(
this.pluginController_.getEventTarget(),
PluginControllerEventType.IS_ACTIVE_CHANGED,
e => this.isPluginActive_ = e.detail);
}
ready() {
super.ready();
this.addEventListener('focus', this.onFocus_);
this.addEventListener('keydown', this.onKeydown_);
const thumbnailsDiv = this.shadowRoot.querySelector('#thumbnails');
assert(thumbnailsDiv);
/** @private {!IntersectionObserver} */
this.intersectionObserver_ = new IntersectionObserver(entries => {
entries.forEach(entry => {
const thumbnail = /** @type {!ViewerThumbnailElement} */ (entry.target);
if (!entry.isIntersecting) {
thumbnail.clearImage();
return;
}
if (thumbnail.isPainted()) {
return;
}
thumbnail.setPainted();
if (!this.isPluginActive_ || this.inTest) {
return;
}
this.pluginController_.requestThumbnail(thumbnail.pageNumber)
.then(response => {
const array = new Uint8ClampedArray(response.imageData);
const imageData = new ImageData(array, response.width);
thumbnail.image = imageData;
});
});
}, {
root: thumbnailsDiv,
// The root margin is set to 100% on the bottom to prepare thumbnails that
// are one standard scroll finger swipe away.
// The root margin is set to 500% on the top to discard thumbnails that
// far from view, but to avoid regenerating thumbnails that are close.
rootMargin: '500% 0% 100%',
});
FocusOutlineManager.forDocument(document);
}
/**
* Changes the focus to the thumbnail of the new active page if the focus was
* already on a thumbnail.
* @private
*/
activePageChanged_() {
if (this.shadowRoot.activeElement) {
this.getThumbnailForPage(this.activePage).focusAndScroll();
}
}
/**
* @param {number} pageNumber
* @private
*/
clickThumbnailForPage(pageNumber) {
if (pageNumber < 1 || pageNumber > this.docLength) {
return;
}
this.getThumbnailForPage(pageNumber).getClickTarget().click();
}
/**
* @param {number} pageNumber
* @return {?ViewerThumbnailElement}
*/
getThumbnailForPage(pageNumber) {
return /** @type {ViewerThumbnailElement} */ (this.shadowRoot.querySelector(
`viewer-thumbnail:nth-child(${pageNumber})`));
}
/**
* @return {!Array<number>} The array of page numbers.
* @private
*/
computePageNumbers_() {
return Array.from({length: this.docLength}, (_, i) => i + 1);
}
/**
* @param {number} pageNumber
* @return {string}
* @private
*/
getAriaLabel_(pageNumber) {
return loadTimeData.getStringF('thumbnailPageAriaLabel', pageNumber);
}
/**
* @param {number} page
* @return {boolean} Whether the page is the current page.
* @private
*/
isActivePage_(page) {
return this.activePage === page;
}
/** @private */
onDomChange_() {
this.shadowRoot.querySelectorAll('viewer-thumbnail').forEach(thumbnail => {
this.intersectionObserver_.observe(thumbnail);
});
}
/**
* Forwards focus to a thumbnail when tabbing.
* @private
*/
onFocus_() {
// Ignore focus triggered by mouse to allow the focus to go straight to the
// thumbnail being clicked.
const focusOutlineManager = FocusOutlineManager.forDocument(document);
if (!focusOutlineManager.visible) {
return;
}
// Change focus to the thumbnail of the active page.
const activeThumbnail =
this.shadowRoot.querySelector('viewer-thumbnail[is-active]');
if (activeThumbnail) {
activeThumbnail.focus();
return;
}
// Otherwise change to the first thumbnail, if there is one.
const firstThumbnail = this.shadowRoot.querySelector('viewer-thumbnail');
if (!firstThumbnail) {
return;
}
firstThumbnail.focus();
}
/**
* @param {!Event} e
* @private
*/
onKeydown_(e) {
const keyboardEvent = /** @type {!KeyboardEvent} */ (e);
if (keyboardEvent.key === 'Tab') {
// On shift+tab, first redirect focus from the thumbnails to:
// 1) Avoid focusing on the thumbnail bar.
// 2) Focus to the element before the thumbnail bar from any thumbnail.
if (e.shiftKey) {
this.focus();
return;
}
// On tab, first redirect focus to the last thumbnail to focus to the
// element after the thumbnail bar from any thumbnail.
this.shadowRoot.querySelector('viewer-thumbnail:last-of-type').focus({
preventScroll: true
});
} else if (keyboardEvent.key === 'ArrowRight') {
// Prevent default arrow scroll behavior.
keyboardEvent.preventDefault();
this.clickThumbnailForPage(this.activePage + 1);
} else if (keyboardEvent.key === 'ArrowLeft') {
// Prevent default arrow scroll behavior.
keyboardEvent.preventDefault();
this.clickThumbnailForPage(this.activePage - 1);
}
}
}
customElements.define(ViewerThumbnailBarElement.is, ViewerThumbnailBarElement);

@ -0,0 +1,220 @@
// Copyright 2020 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 './viewer-thumbnail.js';
import {assert} from 'chrome://resources/js/assert.m.js';
import {FocusOutlineManager} from 'chrome://resources/js/cr/ui/focus_outline_manager.m.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.m.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {PluginController, PluginControllerEventType} from '../controller.js';
import {ViewerThumbnailElement} from './viewer-thumbnail.js';
export class ViewerThumbnailBarElement extends PolymerElement {
static get is() {
return 'viewer-thumbnail-bar';
}
static get template() {
return html`{__html_template__}`;
}
static get properties() {
return {
activePage: {
type: Number,
observer: 'activePageChanged_',
},
clockwiseRotations: Number,
docLength: Number,
isPluginActive_: Boolean,
pageNumbers_: {
type: Array,
computed: 'computePageNumbers_(docLength)',
},
};
}
activePage: number;
clockwiseRotations: number;
docLength: number;
private isPluginActive_: boolean;
private pageNumbers_: number[];
private intersectionObserver_: IntersectionObserver;
private pluginController_: PluginController = PluginController.getInstance();
private tracker_: EventTracker = new EventTracker();
// TODO(dhoss): Remove `this.inTest` when implemented a mock plugin
// controller.
inTest: boolean = false;
constructor() {
super();
this.isPluginActive_ = this.pluginController_.isActive;
// Listen to whether the plugin is active. Thumbnails should be hidden
// when the plugin is inactive.
this.tracker_.add(
this.pluginController_.getEventTarget(),
PluginControllerEventType.IS_ACTIVE_CHANGED,
(e: CustomEvent<boolean>) => this.isPluginActive_ = e.detail);
}
ready() {
super.ready();
this.addEventListener('focus', this.onFocus_);
this.addEventListener('keydown', this.onKeydown_);
const thumbnailsDiv = this.shadowRoot!.querySelector('#thumbnails');
assert(thumbnailsDiv);
// TODO(crbug.com/1260303): Change `any` to `IntersectionObserverEntry`.
this.intersectionObserver_ =
new IntersectionObserver((entries: IntersectionObserverEntry[]) => {
entries.forEach(entry => {
const thumbnail = entry.target as ViewerThumbnailElement;
if (!entry.isIntersecting) {
thumbnail.clearImage();
return;
}
if (thumbnail.isPainted()) {
return;
}
thumbnail.setPainted();
if (!this.isPluginActive_ || this.inTest) {
return;
}
this.pluginController_.requestThumbnail(thumbnail.pageNumber)
.then(response => {
const array = new Uint8ClampedArray(response.imageData);
const imageData = new ImageData(array, response.width);
thumbnail.image = imageData;
});
});
}, {
root: thumbnailsDiv,
// The root margin is set to 100% on the bottom to prepare thumbnails
// that are one standard scroll finger swipe away. The root margin is
// set to 500% on the top to discard thumbnails that are far from
// view, but to avoid regenerating thumbnails that are close.
rootMargin: '500% 0% 100%',
});
FocusOutlineManager.forDocument(document);
}
/**
* Changes the focus to the thumbnail of the new active page if the focus was
* already on a thumbnail.
*/
private activePageChanged_() {
if (this.shadowRoot!.activeElement) {
this.getThumbnailForPage(this.activePage)!.focusAndScroll();
}
}
private clickThumbnailForPage(pageNumber: number) {
const thumbnail = this.getThumbnailForPage(pageNumber);
if (!thumbnail) {
return;
}
thumbnail.getClickTarget().click();
}
getThumbnailForPage(pageNumber: number): ViewerThumbnailElement|null {
return this.shadowRoot!.querySelector(
`viewer-thumbnail:nth-child(${pageNumber})`);
}
/** @return The array of page numbers. */
private computePageNumbers_(): number[] {
return Array.from({length: this.docLength}, (_, i) => i + 1);
}
private getAriaLabel_(pageNumber: number): string {
return loadTimeData.getStringF('thumbnailPageAriaLabel', pageNumber);
}
/** @return Whether the page is the current page. */
private isActivePage_(page: number): boolean {
return this.activePage === page;
}
private onDomChange_() {
this.shadowRoot!.querySelectorAll('viewer-thumbnail').forEach(thumbnail => {
this.intersectionObserver_.observe(thumbnail);
});
}
/** Forwards focus to a thumbnail when tabbing. */
private onFocus_() {
// Ignore focus triggered by mouse to allow the focus to go straight to the
// thumbnail being clicked.
const focusOutlineManager = FocusOutlineManager.forDocument(document);
if (!focusOutlineManager.visible) {
return;
}
// Change focus to the thumbnail of the active page.
const activeThumbnail =
this.shadowRoot!.querySelector<ViewerThumbnailElement>(
'viewer-thumbnail[is-active]');
if (activeThumbnail) {
activeThumbnail.focus();
return;
}
// Otherwise change to the first thumbnail, if there is one.
const firstThumbnail = this.shadowRoot!.querySelector('viewer-thumbnail');
if (!firstThumbnail) {
return;
}
firstThumbnail.focus();
}
private onKeydown_(e: KeyboardEvent) {
if (e.key === 'Tab') {
// On shift+tab, first redirect focus from the thumbnails to:
// 1) Avoid focusing on the thumbnail bar.
// 2) Focus to the element before the thumbnail bar from any thumbnail.
if (e.shiftKey) {
this.focus();
return;
}
// On tab, first redirect focus to the last thumbnail to focus to the
// element after the thumbnail bar from any thumbnail.
this.shadowRoot!
.querySelector<ViewerThumbnailElement>(
'viewer-thumbnail:last-of-type')!.focus({preventScroll: true});
} else if (e.key === 'ArrowRight') {
// Prevent default arrow scroll behavior.
e.preventDefault();
this.clickThumbnailForPage(this.activePage + 1);
} else if (e.key === 'ArrowLeft') {
// Prevent default arrow scroll behavior.
e.preventDefault();
this.clickThumbnailForPage(this.activePage - 1);
}
}
}
declare global {
interface HTMLElementTagNameMap {
'viewer-thumbnail-bar': ViewerThumbnailBarElement;
}
}
customElements.define(ViewerThumbnailBarElement.is, ViewerThumbnailBarElement);

@ -10,13 +10,17 @@ import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/poly
// The maximum widths of thumbnails for each layout (px).
// These constants should be kept in sync with `kMaxWidthPortraitPx` and
// `kMaxWidthLandscapePx` in pdf/thumbnail.cc.
/** @type {number} */
const PORTRAIT_WIDTH = 108;
/** @type {number} */
const LANDSCAPE_WIDTH = 140;
const PORTRAIT_WIDTH: number = 108;
/** @type {string} */
export const PAINTED_ATTRIBUTE = 'painted';
const LANDSCAPE_WIDTH: number = 140;
export const PAINTED_ATTRIBUTE: string = 'painted';
export interface ViewerThumbnailElement {
$: {
thumbnail: HTMLElement,
};
}
export class ViewerThumbnailElement extends PolymerElement {
static get is() {
@ -45,14 +49,17 @@ export class ViewerThumbnailElement extends PolymerElement {
};
}
clockwiseRotations: number;
isActive: boolean;
pageNumber: number;
constructor() {
super();
this.addEventListener('keydown', this.onKeydown_);
}
/** @param {!ImageData} imageData */
set image(imageData) {
set image(imageData: ImageData) {
let canvas = this.getCanvas_();
if (!canvas) {
canvas = document.createElement('canvas');
@ -61,7 +68,7 @@ export class ViewerThumbnailElement extends PolymerElement {
// has restricted access rights.
canvas.oncontextmenu = e => e.preventDefault();
this.shadowRoot.querySelector('#thumbnail').appendChild(canvas);
this.$.thumbnail.appendChild(canvas);
}
canvas.width = imageData.width;
@ -69,7 +76,7 @@ export class ViewerThumbnailElement extends PolymerElement {
this.styleCanvas_();
const ctx = canvas.getContext('2d');
const ctx = canvas.getContext('2d')!;
ctx.putImageData(imageData, 0, 0);
}
@ -87,26 +94,18 @@ export class ViewerThumbnailElement extends PolymerElement {
this.removeAttribute(PAINTED_ATTRIBUTE);
}
/** @return {!HTMLElement} */
getClickTarget() {
return /** @type {!HTMLElement} */ (
this.shadowRoot.querySelector('#thumbnail'));
getClickTarget(): HTMLElement {
return this.$.thumbnail;
}
/** @private */
clockwiseRotationsChanged_() {
private clockwiseRotationsChanged_() {
if (this.getCanvas_()) {
this.styleCanvas_();
}
}
/**
* @return {?HTMLCanvasElement}
* @private
*/
getCanvas_() {
return /** @type {?HTMLCanvasElement} */ (
this.shadowRoot.querySelector('canvas'));
private getCanvas_(): HTMLCanvasElement|null {
return this.shadowRoot!.querySelector('canvas');
}
/**
@ -114,12 +113,10 @@ export class ViewerThumbnailElement extends PolymerElement {
* dimensions of the image data, and the screen resolution. The plugin
* scales the thumbnail image data by the device to pixel ratio, so that
* scaling must be taken into account on the UI.
* @param {boolean} rotated
* @return {!{width: number, height: number}}
* @private
*/
getThumbnailCssSize_(rotated) {
const canvas = this.getCanvas_();
private getThumbnailCssSize_(rotated: boolean):
{width: number, height: number} {
const canvas = this.getCanvas_()!;
const isPortrait = canvas.width < canvas.height !== rotated;
const orientedWidth = rotated ? canvas.height : canvas.width;
const orientedHeight = rotated ? canvas.width : canvas.height;
@ -129,9 +126,9 @@ export class ViewerThumbnailElement extends PolymerElement {
// thumbnail.
const cssWidth = Math.min(
isPortrait ? PORTRAIT_WIDTH : LANDSCAPE_WIDTH,
parseInt(orientedWidth / window.devicePixelRatio, 10));
Math.trunc(orientedWidth / window.devicePixelRatio));
const scale = cssWidth / orientedWidth;
const cssHeight = parseInt(orientedHeight * scale, 10);
const cssHeight = Math.trunc(orientedHeight * scale);
return {width: cssWidth, height: cssHeight};
}
@ -146,8 +143,7 @@ export class ViewerThumbnailElement extends PolymerElement {
this.focus({preventScroll: true});
}
/** @return {boolean} */
isPainted() {
isPainted(): boolean {
return this.hasAttribute(PAINTED_ATTRIBUTE);
}
@ -155,31 +151,27 @@ export class ViewerThumbnailElement extends PolymerElement {
this.toggleAttribute(PAINTED_ATTRIBUTE, true);
}
/** @private */
isActiveChanged_() {
private isActiveChanged_() {
if (this.isActive) {
this.scrollIntoView({block: 'nearest'});
}
}
/** @private */
focusThumbnailNext_() {
private focusThumbnailNext_() {
if (this.nextElementSibling &&
this.nextElementSibling.matches('viewer-thumbnail')) {
this.nextElementSibling.focusAndScroll();
(this.nextElementSibling as ViewerThumbnailElement).focusAndScroll();
}
}
/** @private */
focusThumbnailPrev_() {
private focusThumbnailPrev_() {
if (this.previousElementSibling &&
this.previousElementSibling.matches('viewer-thumbnail')) {
this.previousElementSibling.focusAndScroll();
(this.previousElementSibling as ViewerThumbnailElement).focusAndScroll();
}
}
/** @private */
onClick_() {
private onClick_() {
this.dispatchEvent(new CustomEvent('change-page', {
detail: {page: this.pageNumber - 1, origin: 'thumbnail'},
bubbles: true,
@ -187,27 +179,22 @@ export class ViewerThumbnailElement extends PolymerElement {
}));
}
/**
* @param {!Event} e
* @private
*/
onKeydown_(e) {
const keyboardEvent = /** @type {!KeyboardEvent} */ (e);
switch (keyboardEvent.key) {
private onKeydown_(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowDown':
// Prevent default arrow scroll behavior.
keyboardEvent.preventDefault();
e.preventDefault();
this.focusThumbnailNext_();
break;
case 'ArrowUp':
// Prevent default arrow scroll behavior.
keyboardEvent.preventDefault();
// e default arrow scroll behavior.
e.preventDefault();
this.focusThumbnailPrev_();
break;
case 'Enter':
case ' ':
// Prevent default space scroll behavior.
keyboardEvent.preventDefault();
e.preventDefault();
this.onClick_();
break;
}
@ -216,13 +203,12 @@ export class ViewerThumbnailElement extends PolymerElement {
/**
* Sets the canvas CSS size to maintain the resolution of the thumbnail at any
* rotation.
* @private
*/
styleCanvas_() {
private styleCanvas_() {
assert(this.clockwiseRotations >= 0 && this.clockwiseRotations < 4);
const canvas = this.getCanvas_();
const div = this.shadowRoot.querySelector('#thumbnail');
const canvas = this.getCanvas_()!;
const div = this.shadowRoot!.querySelector<HTMLElement>('#thumbnail')!;
const degreesRotated = this.clockwiseRotations * 90;
canvas.style.transform = `rotate(${degreesRotated}deg)`;
@ -241,4 +227,10 @@ export class ViewerThumbnailElement extends PolymerElement {
}
}
declare global {
interface HTMLElementTagNameMap {
'viewer-thumbnail': ViewerThumbnailElement;
}
}
customElements.define(ViewerThumbnailElement.is, ViewerThumbnailElement);

@ -26,15 +26,15 @@ if (enable_ink) {
# Files that need to be passed to html_to_js() that are used only in PDF Viewer.
pdf_webcomponents_files = [
"elements/shared-css.js",
"elements/viewer-bookmark.js",
"elements/viewer-document-outline.js",
"elements/viewer-download-controls.js",
"elements/viewer-page-selector.js",
"elements/viewer-password-dialog.js",
"elements/viewer-pdf-sidenav.js",
"elements/viewer-properties-dialog.js",
"elements/viewer-thumbnail-bar.js",
"elements/viewer-thumbnail.js",
"elements/viewer-bookmark.ts",
"elements/viewer-document-outline.ts",
"elements/viewer-download-controls.ts",
"elements/viewer-page-selector.ts",
"elements/viewer-password-dialog.ts",
"elements/viewer-pdf-sidenav.ts",
"elements/viewer-properties-dialog.ts",
"elements/viewer-thumbnail-bar.ts",
"elements/viewer-thumbnail.ts",
"elements/viewer-toolbar.js",
"pdf_viewer.js",
]

@ -1026,7 +1026,7 @@ export class PDFViewerElement extends PDFViewerBaseElement {
this.viewport.goToPage(e.detail.page);
if (e.detail.origin === 'bookmark') {
record(UserAction.FOLLOW_BOOKMARK);
} else if (e.detail.origin === 'pageselector') {
} else if (e.detail.origin === 'pageSelector') {
record(UserAction.PAGE_SELECTOR_NAVIGATE);
} else if (e.detail.origin === 'thumbnail') {
record(UserAction.THUMBNAIL_NAVIGATE);

@ -44,7 +44,7 @@ const tests = [
// Test case where an <input> field is focused.
toolbar.shadowRoot.querySelector('viewer-page-selector')
.pageSelector.focus();
.$.pageSelector.focus();
chrome.test.assertTrue(shouldIgnoreKeyEvents());
// Test case where another field is focused.

@ -23,7 +23,7 @@ const tests = [
selector.docLength = 1234;
document.body.appendChild(selector);
const input = selector.pageSelector;
const input = selector.$.pageSelector;
// Simulate entering text into `input` and pressing enter.
function changeInput(newValue) {
input.value = newValue;

@ -38,7 +38,7 @@ constexpr int kMaxThumbnailPixels = 255 * 1024 / kImageColorChannels;
// Maximum CSS dimensions are set to match UX specifications.
// These constants should be kept in sync with `PORTRAIT_WIDTH` and
// `LANDSCAPE_WIDTH` in
// chrome/browser/resources/pdf/elements/viewer-thumbnail.js.
// chrome/browser/resources/pdf/elements/viewer-thumbnail.ts.
constexpr int kMaxWidthPortraitPx = 108;
constexpr int kMaxWidthLandscapePx = 140;