blob: 882c9ce7009ce58ad763919ddb793b8aec9a6b4b [file] [log] [blame]
// Copyright 2018 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 'chrome://resources/cr_elements/hidden_style_css.m.js';
import 'chrome://resources/cr_elements/shared_vars_css.m.js';
import './print_preview_vars.css.js';
import '../strings.m.js';
import {I18nMixin} from 'chrome://resources/js/i18n_mixin.js';
import {hasKeyModifiers} from 'chrome://resources/js/util.m.js';
import {WebUIListenerMixin} from 'chrome://resources/js/web_ui_listener_mixin.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {DarkModeMixin} from '../dark_mode_mixin.js';
import {Coordinate2d} from '../data/coordinate2d.js';
import {Destination} from '../data/destination.js';
import {CustomMarginsOrientation, Margins, MarginsSetting, MarginsType} from '../data/margins.js';
import {MeasurementSystem} from '../data/measurement_system.js';
import {DuplexMode, MediaSizeValue, Ticket} from '../data/model.js';
import {ScalingType} from '../data/scaling.js';
import {Size} from '../data/size.js';
import {Error, State} from '../data/state.js';
import {NativeLayer, NativeLayerImpl} from '../native_layer.js';
import {areRangesEqual} from '../print_preview_utils.js';
import {MARGIN_KEY_MAP, PrintPreviewMarginControlContainerElement} from './margin_control_container.js';
import {PluginProxy, PluginProxyImpl} from './plugin_proxy.js';
import {getTemplate} from './preview_area.html.js';
import {SettingsMixin} from './settings_mixin.js';
export type PreviewTicket = Ticket&{
headerFooterEnabled: boolean,
pageRange: Array<{to: number, from: number}>,
pagesPerSheet: number,
isFirstRequest: boolean,
requestID: number,
};
export enum PreviewAreaState {
LOADING = 'loading',
DISPLAY_PREVIEW = 'display-preview',
OPEN_IN_PREVIEW_LOADING = 'open-in-preview-loading',
OPEN_IN_PREVIEW_LOADED = 'open-in-preview-loaded',
ERROR = 'error',
}
export interface PrintPreviewPreviewAreaElement {
$: {marginControlContainer: PrintPreviewMarginControlContainerElement};
}
const PrintPreviewPreviewAreaElementBase =
WebUIListenerMixin(I18nMixin(SettingsMixin(DarkModeMixin(PolymerElement))));
export class PrintPreviewPreviewAreaElement extends
PrintPreviewPreviewAreaElementBase {
static get is() {
return 'print-preview-preview-area';
}
static get template() {
return getTemplate();
}
static get properties() {
return {
destination: Object,
documentModifiable: Boolean,
error: {
type: Number,
notify: true,
},
margins: Object,
measurementSystem: Object,
pageSize: Object,
previewState: {
type: String,
notify: true,
value: PreviewAreaState.LOADING,
},
state: Number,
pluginLoadComplete_: {
type: Boolean,
value: false,
},
documentReady_: {
type: Boolean,
value: false,
},
previewLoaded_: {
type: Boolean,
notify: true,
computed: 'computePreviewLoaded_(documentReady_, pluginLoadComplete_)',
},
};
}
static get observers() {
return [
'onDarkModeChanged_(inDarkMode)',
'pluginOrDocumentStatusChanged_(pluginLoadComplete_, documentReady_)',
'onStateOrErrorChange_(state, error)',
];
}
destination: Destination;
documentModifiable: boolean;
error: Error;
margins: Margins;
measurementSystem: MeasurementSystem|null;
pageSize: Size;
previewState: PreviewAreaState;
state: State;
private pluginLoadComplete_: boolean;
private documentReady_: boolean;
private previewLoaded_: boolean;
private nativeLayer_: NativeLayer|null = null;
private lastTicket_: PreviewTicket|null = null;
private inFlightRequestId_: number = -1;
private pluginProxy_: PluginProxy = PluginProxyImpl.getInstance();
private keyEventCallback_: ((e: KeyboardEvent) => void)|null = null;
override connectedCallback() {
super.connectedCallback();
this.nativeLayer_ = NativeLayerImpl.getInstance();
this.addWebUIListener(
'page-preview-ready', this.onPagePreviewReady_.bind(this));
}
private computePreviewLoaded_(): boolean {
return this.documentReady_ && this.pluginLoadComplete_;
}
getLastTicketForTest(): PreviewTicket|null {
return this.lastTicket_;
}
previewLoaded(): boolean {
return this.previewLoaded_;
}
/**
* Called when the pointer moves onto the component. Shows the margin
* controls if custom margins are being used.
* @param event Contains element pointer moved from.
*/
private onPointerOver_(event: PointerEvent) {
const marginControlContainer = this.$.marginControlContainer;
let fromElement = event.relatedTarget as HTMLElement | null;
while (fromElement !== null) {
if (fromElement === marginControlContainer) {
return;
}
fromElement = fromElement.parentElement;
}
marginControlContainer.setInvisible(false);
}
/**
* Called when the pointer moves off of the component. Hides the margin
* controls if they are visible.
* @param event Contains element pointer moved to.
*/
private onPointerOut_(event: PointerEvent) {
const marginControlContainer = this.$.marginControlContainer;
let toElement = event.relatedTarget as HTMLElement | null;
while (toElement !== null) {
if (toElement === marginControlContainer) {
return;
}
toElement = toElement.parentElement;
}
marginControlContainer.setInvisible(true);
}
private pluginOrDocumentStatusChanged_() {
if (!this.pluginLoadComplete_ || !this.documentReady_ ||
this.previewState === PreviewAreaState.ERROR) {
return;
}
this.previewState =
this.previewState === PreviewAreaState.OPEN_IN_PREVIEW_LOADING ?
PreviewAreaState.OPEN_IN_PREVIEW_LOADED :
PreviewAreaState.DISPLAY_PREVIEW;
}
/**
* @return 'invisible' if overlay is invisible, '' otherwise.
*/
private getInvisible_(): string {
return this.isInDisplayPreviewState_() ? 'invisible' : '';
}
/**
* @return 'true' if overlay is aria-hidden, 'false' otherwise.
*/
private getAriaHidden_(): string {
return this.isInDisplayPreviewState_().toString();
}
/**
* @return Whether the preview area is in DISPLAY_PREVIEW state.
*/
private isInDisplayPreviewState_(): boolean {
return this.previewState === PreviewAreaState.DISPLAY_PREVIEW;
}
/**
* @return Whether the preview is currently loading.
*/
private isPreviewLoading_(): boolean {
return this.previewState === PreviewAreaState.LOADING;
}
/**
* @return 'jumping-dots' to enable animation, '' otherwise.
*/
private getJumpingDots_(): string {
return this.isPreviewLoading_() ? 'jumping-dots' : '';
}
/**
* @return The current preview area message to display.
*/
private currentMessage_(): string {
switch (this.previewState) {
case PreviewAreaState.LOADING:
return this.i18n('loading');
case PreviewAreaState.DISPLAY_PREVIEW:
return '';
// <if expr="is_macosx">
case PreviewAreaState.OPEN_IN_PREVIEW_LOADING:
case PreviewAreaState.OPEN_IN_PREVIEW_LOADED:
return this.i18n('openingPDFInPreview');
// </if>
case PreviewAreaState.ERROR:
// The preview area is responsible for displaying all errors except
// print failed.
return this.getErrorMessage_();
default:
return '';
}
}
/**
* @param forceUpdate Whether to force the preview area to update
* regardless of whether the print ticket has changed.
*/
startPreview(forceUpdate: boolean) {
if (!this.hasTicketChanged_() && !forceUpdate &&
this.previewState !== PreviewAreaState.ERROR) {
return;
}
this.previewState = PreviewAreaState.LOADING;
this.documentReady_ = false;
this.getPreview_().then(
previewUid => {
if (!this.documentModifiable) {
this.onPreviewStart_(previewUid, -1);
}
this.documentReady_ = true;
},
type => {
if (type === 'SETTINGS_INVALID') {
this.error = Error.INVALID_PRINTER;
this.previewState = PreviewAreaState.ERROR;
} else if (type !== 'CANCELLED') {
this.error = Error.PREVIEW_FAILED;
this.previewState = PreviewAreaState.ERROR;
}
});
}
// <if expr="is_macosx">
/** Set the preview state to display the "opening in preview" message. */
setOpeningPdfInPreview() {
this.previewState = this.previewState === PreviewAreaState.LOADING ?
PreviewAreaState.OPEN_IN_PREVIEW_LOADING :
PreviewAreaState.OPEN_IN_PREVIEW_LOADED;
}
// </if>
/**
* @param previewUid The unique identifier of the preview.
* @param index The index of the page to preview.
*/
private onPreviewStart_(previewUid: number, index: number) {
if (!this.pluginProxy_.pluginReady()) {
const plugin = this.pluginProxy_.createPlugin(previewUid, index);
this.pluginProxy_.setKeyEventCallback(this.keyEventCallback_!);
this.shadowRoot!.querySelector(
'.preview-area-plugin-wrapper')!.appendChild(plugin);
this.pluginProxy_.setLoadCompleteCallback(
this.onPluginLoadComplete_.bind(this));
this.pluginProxy_.setViewportChangedCallback(
this.onPreviewVisualStateChange_.bind(this));
}
this.pluginLoadComplete_ = false;
if (this.inDarkMode) {
this.pluginProxy_.darkModeChanged(true);
}
this.pluginProxy_.resetPrintPreviewMode(
previewUid, index, !this.getSettingValue('color'),
(this.getSettingValue('pages') as number[]), this.documentModifiable);
}
/**
* Called when the plugin loads the preview completely.
* @param success Whether the plugin load succeeded or not.
*/
private onPluginLoadComplete_(success: boolean) {
if (success) {
this.pluginLoadComplete_ = true;
} else {
this.error = Error.PREVIEW_FAILED;
this.previewState = PreviewAreaState.ERROR;
}
}
/**
* Called when the preview plugin's visual state has changed. This is a
* consequence of scrolling or zooming the plugin. Updates the custom
* margins component if shown.
* @param pageX The horizontal offset for the page corner in pixels.
* @param pageY The vertical offset for the page corner in pixels.
* @param pageWidth The page width in pixels.
* @param viewportWidth The viewport width in pixels.
* @param viewportHeight The viewport height in pixels.
*/
private onPreviewVisualStateChange_(
pageX: number, pageY: number, pageWidth: number, viewportWidth: number,
viewportHeight: number) {
// Ensure the PDF viewer isn't tabbable if the window is small enough that
// the zoom toolbar isn't displayed.
const tabindex = viewportWidth < 300 || viewportHeight < 200 ? '-1' : '0';
this.shadowRoot!.querySelector('.preview-area-plugin')!.setAttribute(
'tabindex', tabindex);
this.$.marginControlContainer.updateTranslationTransform(
new Coordinate2d(pageX, pageY));
this.$.marginControlContainer.updateScaleTransform(
pageWidth / this.pageSize.width);
this.$.marginControlContainer.updateClippingMask(
new Size(viewportWidth, viewportHeight));
// Align the margin control container with the preview content area.
// The offset may be caused by the scrollbar on the left in the preview
// area in right-to-left direction.
const previewDocument = this.shadowRoot!
.querySelector<HTMLIFrameElement>(
'.preview-area-plugin')!.contentDocument;
if (previewDocument && previewDocument.documentElement) {
this.$.marginControlContainer.style.left =
previewDocument.documentElement.offsetLeft + 'px';
}
}
/**
* Called when a page's preview has been generated.
* @param pageIndex The index of the page whose preview is ready.
* @param previewUid The unique ID of the print preview UI.
* @param previewResponseId The preview request ID that this page
* preview is a response to.
*/
private onPagePreviewReady_(
pageIndex: number, previewUid: number, previewResponseId: number) {
if (this.inFlightRequestId_ !== previewResponseId) {
return;
}
const pageNumber = pageIndex + 1;
let index = this.getSettingValue('pages').indexOf(pageNumber);
// When pagesPerSheet > 1, the backend will always return page indices 0 to
// N-1, where N is the total page count of the N-upped document.
const pagesPerSheet = (this.getSettingValue('pagesPerSheet') as number);
if (pagesPerSheet > 1) {
index = pageIndex;
}
if (index === 0) {
this.onPreviewStart_(previewUid, pageIndex);
}
if (index !== -1) {
this.pluginProxy_.loadPreviewPage(previewUid, pageIndex, index);
}
}
private onDarkModeChanged_() {
if (this.pluginProxy_.pluginReady()) {
this.pluginProxy_.darkModeChanged(this.inDarkMode);
}
if (this.previewState === PreviewAreaState.DISPLAY_PREVIEW) {
this.startPreview(true);
}
}
/**
* Processes a keyboard event that could possibly be used to change state of
* the preview plugin.
* @param e Keyboard event to process.
*/
handleDirectionalKeyEvent(e: KeyboardEvent) {
// Make sure the PDF plugin is there.
// We only care about: PageUp, PageDown, Left, Up, Right, Down.
// If the user is holding a modifier key, ignore.
if (!this.pluginProxy_.pluginReady() ||
!['PageUp', 'PageDown', 'ArrowLeft', 'ArrowRight', 'ArrowUp',
'ArrowDown']
.includes(e.key) ||
hasKeyModifiers(e)) {
return;
}
// Don't handle the key event for these elements.
const tagName = (e.composedPath()[0] as HTMLElement).tagName;
if (['INPUT', 'SELECT', 'EMBED'].includes(tagName)) {
return;
}
// For the most part, if any div of header was the last clicked element,
// then the active element is the body. Starting with the last clicked
// element, and work up the DOM tree to see if any element has a
// scrollbar. If there exists a scrollbar, do not handle the key event
// here.
const isEventHorizontal = ['ArrowLeft', 'ArrowRight'].includes(e.key);
for (let i = 0; i < e.composedPath().length; i++) {
const element = e.composedPath()[i] as HTMLElement;
if (element.scrollHeight > element.clientHeight && !isEventHorizontal ||
element.scrollWidth > element.clientWidth && isEventHorizontal) {
return;
}
}
// No scroll bar anywhere, or the active element is something else, like a
// button. Note: buttons have a bigger scrollHeight than clientHeight.
this.pluginProxy_.sendKeyEvent(e);
e.preventDefault();
}
/**
* Sends a message to the plugin to hide the toolbars after a delay.
*/
hideToolbar() {
if (!this.pluginProxy_.pluginReady()) {
return;
}
this.pluginProxy_.hideToolbar();
}
/**
* Set a callback that gets called when a key event is received that
* originates in the plugin.
* @param callback The callback to be called with a key event.
*/
setPluginKeyEventCallback(callback: (e: KeyboardEvent) => void) {
this.keyEventCallback_ = callback;
}
/**
* Called when dragging margins starts or stops.
*/
private onMarginDragChanged_(e: CustomEvent<boolean>) {
if (!this.pluginProxy_.pluginReady()) {
return;
}
// When hovering over the plugin (which may be in a separate iframe)
// pointer events will be sent to the frame. When dragging the margins,
// we don't want this to happen as it can cause the margin to stop
// being draggable.
this.pluginProxy_.setPointerEvents(!e.detail);
}
/**
* @param e Contains information about where the plugin should scroll to.
*/
private onTextFocusPosition_(e: CustomEvent<{x: number, y: number}>) {
// TODO(tkent): This is a workaround of a preview-area scrolling
// issue. Blink scrolls preview-area on focus, but we don't want it. We
// should adjust scroll position of PDF preview and positions of
// MarginContgrols here, or restructure the HTML so that the PDF review
// and MarginControls are on the single scrollable container.
// crbug.com/601341
this.scrollTop = 0;
this.scrollLeft = 0;
const position = e.detail;
if (position.x === 0 && position.y === 0) {
return;
}
this.pluginProxy_.scrollPosition(position.x, position.y);
}
/**
* @return Whether margin settings are valid for the print ticket.
*/
private marginsValid_(): boolean {
const type = this.getSettingValue('margins') as MarginsType;
if (!Object.values(MarginsType).includes(type)) {
// Unrecognized margins type.
return false;
}
if (type !== MarginsType.CUSTOM) {
return true;
}
const customMargins =
this.getSettingValue('customMargins') as MarginsSetting;
return customMargins.marginTop !== undefined &&
customMargins.marginLeft !== undefined &&
customMargins.marginBottom !== undefined &&
customMargins.marginRight !== undefined;
}
private hasTicketChanged_(): boolean {
if (!this.marginsValid_()) {
// Log so that we can try to debug how this occurs. See
// https://crbug.com/942211
console.warn('Requested preview with invalid margins');
return false;
}
if (!this.lastTicket_) {
return true;
}
const lastTicket = this.lastTicket_;
// Margins
const newMarginsType = this.getSettingValue('margins') as MarginsType;
if (newMarginsType !== lastTicket.marginsType &&
newMarginsType !== MarginsType.CUSTOM) {
return true;
}
if (newMarginsType === MarginsType.CUSTOM) {
const customMargins =
this.getSettingValue('customMargins') as MarginsSetting;
// Change in custom margins values.
if (!!lastTicket.marginsCustom &&
(lastTicket.marginsCustom.marginTop !== customMargins.marginTop ||
lastTicket.marginsCustom.marginLeft !== customMargins.marginLeft ||
lastTicket.marginsCustom.marginRight !== customMargins.marginRight ||
lastTicket.marginsCustom.marginBottom !==
customMargins.marginBottom)) {
return true;
}
// Changed to custom margins from a different margins type.
if (!this.margins) {
// Log so that we can try to debug how this occurs. See
// https://crbug.com/942211
console.warn('Requested preview with undefined document margins');
return false;
}
const customMarginsChanged =
Object.values(CustomMarginsOrientation).some(side => {
return this.margins.get(side) !==
customMargins[MARGIN_KEY_MAP.get(side)!];
});
if (customMarginsChanged) {
return true;
}
}
// Simple settings: ranges, layout, header/footer, pages per sheet, fit to
// page, css background, selection only, rasterize, scaling, dpi
if (!areRangesEqual(
(this.getSettingValue('ranges') as
Array<{to: number, from: number}>),
lastTicket.pageRange) ||
this.getSettingValue('layout') !== lastTicket.landscape ||
this.getColorForTicket_() !== lastTicket.color ||
this.getSettingValue('headerFooter') !==
lastTicket.headerFooterEnabled ||
this.getSettingValue('cssBackground') !==
lastTicket.shouldPrintBackgrounds ||
this.getSettingValue('selectionOnly') !==
lastTicket.shouldPrintSelectionOnly ||
this.getSettingValue('rasterize') !== lastTicket.rasterizePDF ||
this.isScalingChanged_(lastTicket)) {
return true;
}
// Pages per sheet. If margins are non-default, wait for the return to
// default margins to trigger a request.
if (this.getSettingValue('pagesPerSheet') !== lastTicket.pagesPerSheet &&
this.getSettingValue('margins') === MarginsType.DEFAULT) {
return true;
}
// Media size
const newValue = this.getSettingValue('mediaSize') as MediaSizeValue;
if (newValue.height_microns !== lastTicket.mediaSize.height_microns ||
newValue.width_microns !== lastTicket.mediaSize.width_microns ||
(this.destination.id !== lastTicket.deviceName &&
this.getSettingValue('margins') === MarginsType.MINIMUM)) {
return true;
}
// Destination
if (this.destination.type !== lastTicket.printerType) {
return true;
}
return false;
}
/** @return Native color model of the destination. */
private getColorForTicket_(): number {
return this.destination.getNativeColorModel(
this.getSettingValue('color') as boolean);
}
/** @return Scale factor for print ticket. */
private getScaleFactorForTicket_(): number {
return this.getSettingValue(this.getScalingSettingKey_()) ===
ScalingType.CUSTOM ?
parseInt(this.getSettingValue('scaling'), 10) :
100;
}
/** @return Appropriate key for the scaling type setting. */
private getScalingSettingKey_(): string {
return this.getSetting('scalingTypePdf').available ? 'scalingTypePdf' :
'scalingType';
}
/**
* @param lastTicket Last print ticket.
* @return Whether new scaling settings update the previewed
* document.
*/
private isScalingChanged_(lastTicket: PreviewTicket): boolean {
// Preview always updates if the scale factor is changed.
if (this.getScaleFactorForTicket_() !== lastTicket.scaleFactor) {
return true;
}
// If both scale factors and type match, no scaling change happened.
const scalingType = this.getSettingValue(this.getScalingSettingKey_());
if (scalingType === lastTicket.scalingType) {
return false;
}
// Scaling doesn't always change because of a scalingType change. Changing
// between custom scaling with a scale factor of 100 and default scaling
// makes no difference.
const defaultToCustom = scalingType === ScalingType.DEFAULT &&
lastTicket.scalingType === ScalingType.CUSTOM;
const customToDefault = scalingType === ScalingType.CUSTOM &&
lastTicket.scalingType === ScalingType.DEFAULT;
return !defaultToCustom && !customToDefault;
}
/**
* @param dpiField The field in dpi to retrieve.
* @return Field value.
*/
private getDpiForTicket_(dpiField: string): number {
const dpi = this.getSettingValue('dpi') as {[key: string]: number};
const value = (dpi && dpiField in dpi) ? dpi[dpiField] : 0;
return value;
}
/**
* Requests a preview from the native layer.
* @return Promise that resolves when the preview has been
* generated.
*/
private getPreview_(): Promise<number> {
this.inFlightRequestId_++;
const ticket: PreviewTicket = {
pageRange: this.getSettingValue('ranges'),
mediaSize: this.getSettingValue('mediaSize'),
landscape: this.getSettingValue('layout') as boolean,
color: this.getColorForTicket_(),
headerFooterEnabled: this.getSettingValue('headerFooter') as boolean,
marginsType: this.getSettingValue('margins') as MarginsType,
pagesPerSheet: this.getSettingValue('pagesPerSheet') as number,
isFirstRequest: this.inFlightRequestId_ === 0,
requestID: this.inFlightRequestId_,
previewModifiable: this.documentModifiable,
scaleFactor: this.getScaleFactorForTicket_(),
scalingType: this.getSettingValue(this.getScalingSettingKey_()),
shouldPrintBackgrounds: this.getSettingValue('cssBackground') as boolean,
shouldPrintSelectionOnly: this.getSettingValue('selectionOnly') as
boolean,
// NOTE: Even though the remaining fields don't directly relate to the
// preview, they still need to be included.
// e.g. printing::PrintSettingsFromJobSettings() still checks for them.
collate: true,
copies: 1,
deviceName: this.destination.id,
dpiHorizontal: this.getDpiForTicket_('horizontal_dpi'),
dpiVertical: this.getDpiForTicket_('vertical_dpi'),
duplex: this.getSettingValue('duplex') ? DuplexMode.LONG_EDGE :
DuplexMode.SIMPLEX,
printerType: this.destination.type,
rasterizePDF: this.getSettingValue('rasterize') as boolean,
};
if (this.getSettingValue('margins') === MarginsType.CUSTOM) {
ticket.marginsCustom = this.getSettingValue('customMargins');
}
this.lastTicket_ = ticket;
this.dispatchEvent(new CustomEvent(
'preview-start',
{bubbles: true, composed: true, detail: this.inFlightRequestId_}));
return this.nativeLayer_!.getPreview(JSON.stringify(ticket));
}
private onStateOrErrorChange_() {
if ((this.state === State.ERROR || this.state === State.FATAL_ERROR) &&
this.getErrorMessage_() !== '') {
this.previewState = PreviewAreaState.ERROR;
}
}
/** @return The error message to display in the preview area. */
private getErrorMessage_(): string {
switch (this.error) {
case Error.INVALID_PRINTER:
return this.i18nAdvanced('invalidPrinterSettings', {
substitutions: [],
tags: ['BR'],
});
// <if expr="is_chromeos">
case Error.NO_DESTINATIONS:
return this.i18n('noDestinationsMessage');
// </if>
case Error.PREVIEW_FAILED:
return this.i18n('previewFailed');
default:
return '';
}
}
}
declare global {
interface HTMLElementTagNameMap {
'print-preview-preview-area': PrintPreviewPreviewAreaElement;
}
}
customElements.define(
PrintPreviewPreviewAreaElement.is, PrintPreviewPreviewAreaElement);