| // 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); |