// Copyright 2020 The Chromium Authors
// 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.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
import {FocusOutlineManager} from 'chrome://resources/js/focus_outline_manager.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.js';
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import {PluginController, PluginControllerEventType} from '../controller.js';
import type {ViewerThumbnailElement} from './viewer_thumbnail.js';
import {getCss} from './viewer_thumbnail_bar.css.js';
import {getHtml} from './viewer_thumbnail_bar.html.js';
export interface ViewerThumbnailBarElement {
$: {
thumbnails: HTMLElement,
};
}
export class ViewerThumbnailBarElement extends CrLitElement {
static get is() {
return 'viewer-thumbnail-bar';
}
static override get styles() {
return getCss();
}
override render() {
return getHtml.bind(this)();
}
static override get properties() {
return {
activePage: {type: Number},
clockwiseRotations: {type: Number},
docLength: {type: Number},
isPluginActive_: {type: Boolean},
};
}
activePage: number = 0;
clockwiseRotations: number = 0;
docLength: number = 0;
protected isPluginActive_: boolean = false;
private intersectionObserver_: IntersectionObserver|null = null;
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);
}
override firstUpdated() {
this.addEventListener('focus', this.onFocus_);
this.addEventListener('keydown', this.onKeydown_);
const thumbnailsDiv = this.$.thumbnails;
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;
}
// Convert to zero-based page index.
this.pluginController_.requestThumbnail(thumbnail.pageNumber - 1)
.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);
}
override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (changedProperties.has('activePage')) {
if (this.shadowRoot!.activeElement) {
// Changes the focus to the thumbnail of the new active page if the
// focus was already on a thumbnail.
this.getThumbnailForPage(this.activePage)!.focusAndScroll();
}
}
if (changedProperties.has('docLength')) {
assert(this.intersectionObserver_);
// If doc length changes, we render new thumbnails.
this.shadowRoot!.querySelectorAll('viewer-thumbnail')
.forEach(thumbnail => this.intersectionObserver_!.observe(thumbnail));
}
}
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. */
protected computePageNumbers_(): number[] {
return Array.from({length: this.docLength}, (_, i) => i + 1);
}
protected getAriaLabel_(pageNumber: number): string {
return loadTimeData.getStringF('thumbnailPageAriaLabel', pageNumber);
}
/** @return Whether the page is the current page. */
protected isActivePage_(page: number): boolean {
return this.activePage === page;
}
/** 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) {
switch (e.key) {
case '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.
const lastThumbnail =
this.shadowRoot!.querySelector<ViewerThumbnailElement>(
'viewer-thumbnail:last-of-type');
assert(lastThumbnail);
lastThumbnail.focus({preventScroll: true});
break;
case 'ArrowRight':
case 'ArrowDown':
// Prevent default arrow scroll behavior.
e.preventDefault();
this.clickThumbnailForPage(this.activePage + 1);
break;
case 'ArrowLeft':
case 'ArrowUp':
// Prevent default arrow scroll behavior.
e.preventDefault();
this.clickThumbnailForPage(this.activePage - 1);
break;
}
}
}
declare global {
interface HTMLElementTagNameMap {
'viewer-thumbnail-bar': ViewerThumbnailBarElement;
}
}
customElements.define(ViewerThumbnailBarElement.is, ViewerThumbnailBarElement);