// 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 './action_toolbar.js';
import './scanning_fonts.css.js';
import './scanning_shared.css.js';
import './strings.m.js';
import 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import 'chrome://resources/polymer/v3_0/paper-progress/paper-progress.js';
import {assert} from 'chrome://resources/ash/common/assert.js';
import {CrButtonElement} from 'chrome://resources/ash/common/cr_elements/cr_button/cr_button.js';
import {CrDialogElement} from 'chrome://resources/ash/common/cr_elements/cr_dialog/cr_dialog.js';
import {I18nMixin} from 'chrome://resources/ash/common/cr_elements/i18n_mixin.js';
import {DomRepeatEvent, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {ForceHiddenElementsVisibleObserverInterface, ForceHiddenElementsVisibleObserverReceiver} from './accessibility_features.mojom-webui.js';
import {getAccessibilityFeaturesInterface} from './mojo_interface_provider.js';
import {getTemplate} from './scan_preview.html.js';
import {AppState} from './scanning_app_types.js';
import {ScanningBrowserProxyImpl} from './scanning_browser_proxy.js';
const PROGRESS_TIMER_MS = 3000;
/**
* The bottom margin of each scanned image in pixels.
*/
const SCANNED_IMG_MARGIN_BOTTOM_PX = 12;
/**
* The bottom margin for the action toolbar from the bottom edge of the
* viewport.
*/
const ACTION_TOOLBAR_BOTTOM_MARGIN_PX = 40;
/**
* @fileoverview
* 'scan-preview' shows a preview of a scanned document.
*/
const ScanPreviewElementBase = I18nMixin(PolymerElement);
type DialogAction = 'rescan-page'|'remove-page';
export class ScanPreviewElement extends ScanPreviewElementBase implements
ForceHiddenElementsVisibleObserverInterface {
static get is() {
return 'scan-preview' as const;
}
static get template() {
return getTemplate();
}
static get properties() {
return {
appState: {
type: Number,
observer: ScanPreviewElement.prototype.appStateChanged,
},
/**
* The object URLs of the scanned images.
*/
objectUrls: {
type: Array,
observer: ScanPreviewElement.prototype.objectUrlsChanged,
},
pageNumber: {
type: Number,
observer: ScanPreviewElement.prototype.pageNumberChanged,
},
progressPercent: Number,
showHelpOrProgress: {
type: Boolean,
value: true,
},
showScannedImages: {
type: Boolean,
value: false,
},
showHelperText: {
type: Boolean,
value: true,
},
showScanProgress: {
type: Boolean,
value: false,
},
showCancelingProgress: {
type: Boolean,
value: false,
},
progressTextString: String,
previewAriaLabel: String,
progressTimer: {
type: Number,
value: null,
},
isMultiPageScan: {
type: Boolean,
observer: ScanPreviewElement.prototype.isMultiPageScanChanged,
},
/**
* The index of the page currently focused on.
*/
currentPageIndexInView: {
type: Number,
value: 0,
},
/**
* Set to true once the first scanned image from a scan is loaded. This is
* needed to prevent checking the dimensions of every scanned image. The
* assumption is that all scanned images share the same dimensions.
*/
scannedImagesLoaded: {
type: Boolean,
value: false,
},
showActionToolbar: Boolean,
dialogTitleText: String,
dialogConfirmationText: String,
dialogButtonText: String,
/**
* True when |appState| is MULTI_PAGE_SCANNING.
*/
multiPageScanning: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
showSingleImageFocus: {
type: Boolean,
reflectToAttribute: true,
},
/**
* True when the ChromeVox, Switch, or Screen Magnifier accessibility
* features are turned on that require the action toolbar to always be
* visible during multi-page scan sessions. Only used for CSS selector
* logic.
*/
forceActionToolbarVisible: {
type: Boolean,
value: false,
reflectToAttribute: true,
},
};
}
static get observers() {
return [
'setPreviewAriaLabel(showScannedImages, showCancelingProgress,' +
' showHelperText, objectUrls.length)',
'setScanProgressTimer(showScanProgress, progressPercent)',
];
}
appState: AppState;
objectUrls: string[];
private pageNumber: number;
private progressPercent: number;
private showHelpOrProgress: boolean;
private showScannedImages: boolean;
private showHelperText: boolean;
private showScanProgress: boolean;
private showCancelingProgress: boolean;
private progressTextString: string;
private previewAriaLabel: string;
private progressTimer: number|null;
private isMultiPageScan: boolean;
private currentPageIndexInView: number;
private scannedImagesLoaded: boolean;
private showActionToolbar: boolean;
private dialogTitleText: string;
private dialogConfirmationText: string;
private dialogButtonText: string;
private multiPageScanning: boolean;
private showSingleImageFocus: boolean;
private forceActionToolbarVisible: boolean;
private actionToolbarHeight: number;
private actionToolbarWidth: number;
private forceHiddenElementsVisibleObserverReceiver:
ForceHiddenElementsVisibleObserverReceiver;
private onDialogActionClick: EventListenerOrEventListenerObject;
private onWindowResized: EventListenerOrEventListenerObject;
private previewAreaResizeObserver: ResizeObserver;
// ScanningBrowserProxy is initialized when scanning_app.js is created.
private browserProxy = ScanningBrowserProxyImpl.getInstance();
constructor() {
super();
this.onWindowResized = () => this.setActionToolbarPosition();
this.previewAreaResizeObserver =
new ResizeObserver(() => this.updatePreviewElements());
}
override ready(): void {
super.ready();
this.style.setProperty(
'--scanned-image-margin-bottom', SCANNED_IMG_MARGIN_BOTTOM_PX + 'px');
// parseFloat() is used to convert the string returned by
// styleMap.get() into a number ("642px" --> 642).
const styleMap = (this as unknown as Element).computedStyleMap();
this.actionToolbarHeight =
parseFloat(styleMap.get('--action-toolbar-height')!.toString());
this.actionToolbarWidth =
parseFloat(styleMap.get('--action-toolbar-width')!.toString());
this.forceHiddenElementsVisibleObserverReceiver =
new ForceHiddenElementsVisibleObserverReceiver(this);
getAccessibilityFeaturesInterface()
.observeForceHiddenElementsVisible(
this.forceHiddenElementsVisibleObserverReceiver.$
.bindNewPipeAndPassRemote())
.then(
response => this.forceActionToolbarVisible = response.forceVisible);
}
override disconnectedCallback(): void {
super.disconnectedCallback();
if (this.isMultiPageScan) {
window.removeEventListener('resize', this.onWindowResized);
this.previewAreaResizeObserver.disconnect();
}
if (this.forceHiddenElementsVisibleObserverReceiver) {
this.forceHiddenElementsVisibleObserverReceiver.$.close();
}
}
/**
* Overrides ForceHiddenElementsVisibleObserverReceiver.
*/
onForceHiddenElementsVisibleChange(forceVisible: boolean): void {
this.forceActionToolbarVisible = forceVisible;
}
private appStateChanged(): void {
this.showScannedImages = this.appState === AppState.DONE ||
this.appState === AppState.MULTI_PAGE_NEXT_ACTION ||
this.appState === AppState.MULTI_PAGE_SCANNING;
this.showScanProgress = this.appState === AppState.SCANNING ||
this.appState === AppState.MULTI_PAGE_SCANNING;
this.showCancelingProgress = this.appState === AppState.CANCELING ||
this.appState === AppState.MULTI_PAGE_CANCELING;
this.showHelperText = !this.showScanProgress &&
!this.showCancelingProgress && !this.showScannedImages;
this.showHelpOrProgress = !this.showScannedImages ||
this.appState === AppState.MULTI_PAGE_SCANNING;
this.multiPageScanning = this.appState === AppState.MULTI_PAGE_SCANNING;
this.showSingleImageFocus =
this.appState === AppState.MULTI_PAGE_NEXT_ACTION;
this.showActionToolbar = this.appState === AppState.MULTI_PAGE_NEXT_ACTION;
// If no longer showing the scanned images, reset |scannedImagesLoaded_| so
// it can be used again for the next scan job.
if (this.showHelpOrProgress) {
this.scannedImagesLoaded = false;
}
}
private pageNumberChanged(): void {
this.progressTextString =
this.i18n('scanPreviewProgressText', this.pageNumber);
}
/**
* Sets the ARIA label used by the preview area based on the app state and the
* current page showing. In the initial state, use the scan preview
* instructions from the page as the label. When the scan completes, announce
* the total number of pages scanned.
*
*/
private setPreviewAriaLabel(): void {
if (this.showScannedImages) {
this.browserProxy
.getPluralString('scannedImagesAriaLabel', this.objectUrls.length)
.then((pluralString) => this.previewAriaLabel = pluralString);
return;
}
if (this.showCancelingProgress) {
this.previewAriaLabel = this.i18n('cancelingScanningText');
return;
}
if (this.showHelperText) {
this.previewAriaLabel = this.i18n('scanPreviewHelperText');
return;
}
}
/**
* When receiving progress updates from an ongoing scan job, only update the
* preview section aria label after a timer elapses to prevent successive
* progress updates from spamming ChromeVox.
*/
private setScanProgressTimer(): void {
// Only set the timer if scanning is still in progress.
if (!this.showScanProgress) {
return;
}
// Always announce when a page is completed. Bypass and clear any existing
// timer and immediately update the aria label.
if (this.progressPercent === 100) {
if (this.progressTimer) {
clearTimeout(this.progressTimer);
}
this.onScanProgressTimerComplete();
return;
}
// If a timer is already in progress, do not set another timer.
if (this.progressTimer) {
return;
}
this.progressTimer =
setTimeout(() => this.onScanProgressTimerComplete(), PROGRESS_TIMER_MS);
}
private onScanProgressTimerComplete(): void {
// Only update the aria label if scanning is still in progress.
if (!this.showScanProgress) {
return;
}
this.previewAriaLabel = this.i18n(
'scanningImagesAriaLabel', this.pageNumber, this.progressPercent);
this.progressTimer = null;
}
/**
* While scrolling, if the current page in view would change, update it and
* set the focus CSS variable accordingly.
*/
private onScannedImagesScroll(): void {
if (!this.isMultiPageScan ||
this.appState != AppState.MULTI_PAGE_NEXT_ACTION) {
return;
}
const scannedImagesDiv: HTMLDivElement =
this.shadowRoot!.querySelector<HTMLDivElement>('#scannedImages')!;
const scannedImages: HTMLCollection =
scannedImagesDiv.getElementsByClassName('scanned-image');
if (scannedImages.length === 0) {
return;
}
// If the current page in view stays the same, do nothing.
const pageIndexInView = this.getCurrentPageInView(scannedImages);
if (pageIndexInView === this.currentPageIndexInView) {
return;
}
this.setFocusedScannedImage(scannedImages, pageIndexInView);
}
/**
* Calculates the index of the current page in view based on the scroll
* position. This algorithm allows for every scanned image to be focusable
* via scrolling. It starts by waiting until the previous image is scrolled
* halfway outside the viewport before the page index changes, but then
* changes behavior once the end of the scroll area is reached and no more
* images can be scrolled up. In that case, the remaining scroll area is
* divided evenly between the final images in the viewport.
*/
private getCurrentPageInView(scannedImages: HTMLCollection): number {
assert(this.isMultiPageScan);
if (scannedImages.length === 1) {
return 0;
}
// Assumes the scanned images share the same dimensions.
const imageHeight = scannedImages[0].getBoundingClientRect().height +
SCANNED_IMG_MARGIN_BOTTOM_PX;
// The first step is to calculate the number of images that will be visible
// in the viewport when scrolled to the bottom. That is how to calculate the
// "crossover" point where the algorithm needs to change.
const numImagesVisibleAtEnd = Math.ceil(
this.shadowRoot!.querySelector<HTMLElement>(
'#previewDiv')!.offsetHeight /
imageHeight);
const numImagesBeforeCrossover =
scannedImages.length - numImagesVisibleAtEnd;
// Calculate the point where the last images in the scroll area are visible
// and the scrolling algorithm needs to change.
const crossoverBreakpoint = numImagesBeforeCrossover == 0 ?
Number.MIN_VALUE :
(scannedImages[numImagesBeforeCrossover] as HTMLElement).offsetTop -
(imageHeight / 2);
// Before the "crossover", update the page index based on when the previous
// image is scrolled halfway outside the viewport.
if (this.shadowRoot!.querySelector<HTMLElement>('#previewDiv')!.scrollTop <
crossoverBreakpoint) {
// Subtract half the image height so |scrollTop| = 0 when the first page
// is scrolled halfway outside the viewport. That way each page index will
// be the current scroll divided by the image height.
const scrollTop =
this.shadowRoot!.querySelector<HTMLElement>(
'#previewDiv')!.scrollTop -
(imageHeight / 2) -
/*imageFocusBorder=*/ 2;
if (scrollTop < 0) {
return 0;
}
return 1 + Math.floor(scrollTop / imageHeight);
}
// After the "crossover", the remaining amount of scroll left in the
// scrollbar is divided evenly to the remaining images. This allows every
// image to be scrolled to.
const maxScrollTop =
this.shadowRoot!.querySelector<HTMLElement>(
'#previewDiv')!.scrollHeight -
this.shadowRoot!.querySelector<HTMLElement>(
'#previewDiv')!.offsetHeight;
const scrollRemainingAfterCrossover =
Math.max(maxScrollTop - crossoverBreakpoint, 0);
const imageScrollProportion =
scrollRemainingAfterCrossover / numImagesVisibleAtEnd;
// Calculate the new page index.
const scrollTop =
this.shadowRoot!.querySelector<HTMLElement>('#previewDiv')!.scrollTop -
crossoverBreakpoint;
const index = Math.floor(scrollTop / imageScrollProportion);
return Math.min(numImagesBeforeCrossover + index, scannedImages.length - 1);
}
/**
* Sets the CSS class for the current scanned image in view so the blue border
* will show on the correct page when hovered.
*/
private setFocusedScannedImage(
scannedImages: HTMLCollection, pageIndexInView: number): void {
assert(this.isMultiPageScan);
this.removeFocusFromScannedImage(scannedImages);
assert(pageIndexInView >= 0 && pageIndexInView < scannedImages.length);
scannedImages[pageIndexInView].classList.add('focused-scanned-image');
this.currentPageIndexInView = pageIndexInView;
}
/**
* Removes the focus CSS class from the scanned image which already has it
* then resets |currentPageInView_|.
*/
private removeFocusFromScannedImage(scannedImages: HTMLCollection): void {
// This condition is only true when the user chooses to remove a page from
// the multi-page scan session. When a page gets removed, the focus is
// cleared and not immediately set again.
if (this.currentPageIndexInView < 0) {
return;
}
assert(
this.currentPageIndexInView >= 0 &&
this.currentPageIndexInView < scannedImages.length);
scannedImages[this.currentPageIndexInView].classList.remove(
'focused-scanned-image');
// Set to -1 because the focus has been removed from the current page and no
// other page has it.
this.currentPageIndexInView = -1;
}
/**
* Runs when a new scanned image is loaded.
*/
private onScannedImageLoaded(e: DomRepeatEvent<string>): void {
if (!this.isMultiPageScan) {
return;
}
const scannedImages =
this.shadowRoot!.querySelector<HTMLElement>('#scannedImages')!
.getElementsByClassName('scanned-image');
this.setFocusedScannedImage(
scannedImages, this.getCurrentPageInView(scannedImages));
this.updatePreviewElements();
// Scrolling to a page is only needed for the first scanned image load.
if (this.scannedImagesLoaded) {
return;
}
this.scannedImagesLoaded = true;
// |e.model| is populated by the dom-repeat element.
this.scrollToPage(e.model.index);
}
/**
* Set the focus to the clicked scanned image.
*/
private onScannedImageClick(e: DomRepeatEvent<string>): void {
if (!this.isMultiPageScan) {
return;
}
// |e.model| is populated by the dom-repeat element.
const scannedImages =
this.shadowRoot!.querySelector<HTMLDivElement>('#scannedImages')!
.getElementsByClassName('scanned-image');
this.setFocusedScannedImage(scannedImages, e.model.index);
}
/**
* Set the position of the action toolbar based on the size of the scanned
* images and the current size of the app window.
*/
private setActionToolbarPosition(): void {
assert(this.isMultiPageScan);
const scannedImage =
this.shadowRoot!.querySelector<HTMLImageElement>('.scanned-image')!;
if (!scannedImage) {
return;
}
const scannedImageRect = scannedImage.getBoundingClientRect();
// Set the toolbar position from the bottom edge of the viewport.
const topPosition =
this.shadowRoot!.querySelector<HTMLDivElement>(
'#previewDiv')!.offsetHeight -
ACTION_TOOLBAR_BOTTOM_MARGIN_PX - (this.actionToolbarHeight / 2);
this.style.setProperty('--action-toolbar-top', topPosition + 'px');
// Position the toolbar in the middle of the viewport.
const leftPosition = scannedImageRect.x + (scannedImageRect.width / 2) -
(this.actionToolbarWidth / 2);
this.style.setProperty('--action-toolbar-left', leftPosition + 'px');
}
/**
* Called when the "show-remove-page-dialog" event fires from the action
* toolbar button click.
*/
private onShowRemovePageDialog(e: CustomEvent<number>): void {
this.showRemoveOrRescanDialog(/* isRemovePageDialog */ true, e.detail);
}
/**
* Called when the "show-rescan-page-dialog" event fires from the action
* toolbar button click.
*/
private onShowRescanPageDialog(e: CustomEvent<number>): void {
this.showRemoveOrRescanDialog(/* isRemovePageDialog */ false, e.detail);
}
/**
* |isRemovePageDialog| determines whether to show the 'Remove Page' or
* 'Rescan Page' dialog.
*/
private showRemoveOrRescanDialog(
isRemovePageDialog: boolean, pageIndex: number): void {
// Configure the on-click action.
this.onDialogActionClick = () => {
this.fireDialogAction(
isRemovePageDialog ? 'remove-page' : 'rescan-page', pageIndex);
};
this.shadowRoot!.querySelector<CrButtonElement>('#actionButton')!
.addEventListener('click', this.onDialogActionClick, {once: true});
// Configure the dialog strings for the requested mode (Remove or Rescan).
this.dialogButtonText = this.i18n(
isRemovePageDialog ? 'removePageButtonLabel' : 'rescanPageButtonLabel');
this.dialogConfirmationText = this.i18n(
isRemovePageDialog ? 'removePageConfirmationText' :
'rescanPageConfirmationText');
this.browserProxy
.getPluralString(
isRemovePageDialog ? 'removePageDialogTitle' :
'rescanPageDialogTitle',
this.objectUrls.length === 1 ? 0 : pageIndex + 1)
.then((pluralString: string): void => {
// When removing a page while more than one page exists, leave the
// title empty and move the title text into the body.
const isRemoveFromMultiplePages =
isRemovePageDialog && this.objectUrls.length > 1;
this.dialogTitleText = isRemoveFromMultiplePages ? '' : pluralString;
if (isRemoveFromMultiplePages) {
this.dialogConfirmationText = pluralString;
}
// Once strings are loaded, open the dialog.
this.shadowRoot!.querySelector<CrDialogElement>(
'#scanPreviewDialog')!.showModal();
});
}
/**
* Filrs either the 'remove-page' or 'rescan-page' event.
*/
private fireDialogAction(event: DialogAction, pageIndex: number): void {
const scannedImages =
this.shadowRoot!.querySelector<HTMLDivElement>('#scannedImages')!
.getElementsByClassName('scanned-image');
this.removeFocusFromScannedImage(scannedImages);
assert(pageIndex >= 0);
this.dispatchEvent(new CustomEvent(
event, {bubbles: true, composed: true, detail: pageIndex}));
this.closeDialog();
}
private closeDialog(): void {
this.shadowRoot!.querySelector<CrDialogElement>(
'#scanPreviewDialog')!.close();
this.shadowRoot!.querySelector<CrButtonElement>('#actionButton')!
.removeEventListener('click', this.onDialogActionClick);
}
/**
* Scrolls the image specified by |pageIndex| into view.
*/
private scrollToPage(pageIndex: number): void {
assert(this.isMultiPageScan);
const scannedImages =
this.shadowRoot!.querySelector<HTMLDivElement>('#scannedImages')!
.getElementsByClassName('scanned-image');
if (scannedImages.length === 0) {
return;
}
assert(pageIndex >= 0 && pageIndex < scannedImages.length);
this.shadowRoot!.querySelector<HTMLElement>('#previewDiv')!.scrollTop =
(scannedImages[pageIndex] as HTMLElement).offsetTop -
/*imageFocusBorder=*/ 2;
}
private isMultiPageScanChanged(): void {
// Listen for window size changes during multi-page scan sessions so the
// position of the action toolbar can be updated.
if (this.isMultiPageScan) {
window.addEventListener('resize', this.onWindowResized);
// Observe changes to the preview area during multi-page scan sessions so
// the scan progress div height can be updated when images are
// added/removed.
this.previewAreaResizeObserver.observe(
(this.shadowRoot!.querySelector<HTMLDivElement>('#previewDiv')!));
} else {
window.removeEventListener('resize', this.onWindowResized);
this.previewAreaResizeObserver.disconnect();
}
}
/**
* Make the scan progress height match the preview area height.
*
*/
private setMultiPageScanProgressHeight(): void {
this.style.setProperty(
'--multi-page-scan-progress-height',
this.shadowRoot!.querySelector<HTMLDivElement>(
'#previewDiv')!.offsetHeight +
'px');
}
private objectUrlsChanged(): void {
if (!this.isMultiPageScan) {
return;
}
// Set to -1 when no pages exist after a scan is saved.
if (this.objectUrls.length === 0) {
this.currentPageIndexInView = -1;
}
}
/**
* Sets the size and positioning of elements that depend on the size of the
* scan preview area.
*
*/
private updatePreviewElements(): void {
this.setMultiPageScanProgressHeight();
this.setActionToolbarPosition();
}
/**
* Hide the action toolbar if it's page is not currently in view.
*/
private showActionToolbarByIndex(index: number): boolean {
return index === this.currentPageIndexInView && this.showActionToolbar;
}
/**
* Set |currentPageIndexInView_| to the page focused on (via ChromeVox).
*/
private onScannedImageInFocus(e: DomRepeatEvent<string>): void {
if (!this.isMultiPageScan) {
return;
}
// |e.model| is populated by the dom-repeat element.
const scannedImages =
this.shadowRoot!.querySelector<HTMLDivElement>('#scannedImages')!
.getElementsByClassName('scanned-image');
this.setFocusedScannedImage(scannedImages, e.model.index);
}
private getScannedImageAriaLabel(index: number): string {
return this.i18n(
'multiPageImageAriaLabel', index + 1, this.objectUrls.length);
}
setIsMultiPageScanForTesting(isMultiPageScan: boolean): void {
this.isMultiPageScan = isMultiPageScan;
}
setPageNumberForTesting(pageNumber: number): void {
this.pageNumber = pageNumber;
}
}
declare global {
interface HTMLElementEventMap {
'rescan-page': CustomEvent<number>;
'remove-page': CustomEvent<number>;
}
interface HTMLElementTagNameMap {
[ScanPreviewElement.is]: ScanPreviewElement;
}
}
customElements.define(ScanPreviewElement.is, ScanPreviewElement);