blob: 882c0f37e45a6709e0f6759d165450bad56f13e5 [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.
cr.exportPath('print_preview');
/**
* @typedef {{
* width_microns: number,
* height_microns: number,
* }}
*/
print_preview.MediaSizeValue;
/** @enum {string} */
print_preview.PreviewAreaState = {
NO_PLUGIN: 'no-plugin',
LOADING: 'loading',
DISPLAY_PREVIEW: 'display-preview',
OPEN_IN_PREVIEW_LOADING: 'open-in-preview-loading',
OPEN_IN_PREVIEW_LOADED: 'open-in-preview-loaded',
ERROR: 'error',
};
Polymer({
is: 'print-preview-preview-area',
behaviors: [
WebUIListenerBehavior,
SettingsBehavior,
I18nBehavior,
print_preview.DarkModeBehavior,
],
properties: {
/** @type {print_preview.Destination} */
destination: Object,
documentModifiable: Boolean,
/** @type {!print_preview.Error} */
error: {
type: Number,
notify: true,
},
/** @type {print_preview.Margins} */
margins: Object,
/** @type {?print_preview.MeasurementSystem} */
measurementSystem: Object,
newPrintPreviewLayout: {
type: Boolean,
reflectToAttribute: true,
},
/** @type {!print_preview.Size} */
pageSize: Object,
/** @type {!print_preview.PreviewAreaState} */
previewState: {
type: String,
notify: true,
value: print_preview.PreviewAreaState.LOADING,
},
/** @type {!print_preview.State} */
state: Number,
/** @private {boolean} Whether the plugin is loaded */
pluginLoaded_: {
type: Boolean,
value: false,
},
/** @private {boolean} Whether the document is ready */
documentReady_: {
type: Boolean,
value: false,
},
/** @private {boolean} */
previewLoaded_: {
type: Boolean,
notify: true,
computed: 'computePreviewLoaded_(documentReady_, pluginLoaded_)',
},
},
listeners: {
'pointerover': 'onPointerOver_',
'pointerout': 'onPointerOut_',
},
observers: [
'onDarkModeChanged_(inDarkMode)',
'pluginOrDocumentStatusChanged_(pluginLoaded_, documentReady_)',
'onStateOrErrorChange_(state, error)',
],
/** @private {?print_preview.NativeLayer} */
nativeLayer_: null,
/** @private {?Object} */
lastTicket_: null,
/** @private {number} */
inFlightRequestId_: -1,
/** @private {?print_preview.PluginProxy} */
pluginProxy_: null,
/** @private {?function(!KeyboardEvent)} */
keyEventCallback_: null,
/** @override */
attached: function() {
this.nativeLayer_ = print_preview.NativeLayer.getInstance();
this.addWebUIListener(
'page-preview-ready', this.onPagePreviewReady_.bind(this));
if (!this.pluginProxy_.checkPluginCompatibility(assert(
this.$$('.preview-area-compatibility-object-out-of-process')))) {
this.error = print_preview.Error.NO_PLUGIN;
this.previewState = print_preview.PreviewAreaState.ERROR;
}
},
/** @override */
created: function() {
this.pluginProxy_ = print_preview.PluginProxy.getInstance();
},
/**
* @return {boolean} Whether the preview is loaded.
* @private
*/
computePreviewLoaded_: function() {
return this.documentReady_ && this.pluginLoaded_;
},
/** @return {boolean} Whether the preview is loaded. */
previewLoaded: function() {
return this.previewLoaded_;
},
/**
* Called when the pointer moves onto the component. Shows the margin
* controls if custom margins are being used.
* @param {!Event} event Contains element pointer moved from.
* @private
*/
onPointerOver_: function(event) {
const marginControlContainer = this.$.marginControlContainer;
let fromElement = event.fromElement;
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} event Contains element pointer moved to.
* @private
*/
onPointerOut_: function(event) {
const marginControlContainer = this.$.marginControlContainer;
let toElement = event.toElement;
while (toElement != null) {
if (toElement == marginControlContainer) {
return;
}
toElement = toElement.parentElement;
}
marginControlContainer.setInvisible(true);
},
/** @private */
pluginOrDocumentStatusChanged_: function() {
if (!this.pluginLoaded_ || !this.documentReady_ ||
this.previewState === print_preview.PreviewAreaState.ERROR) {
return;
}
this.previewState = this.previewState ==
print_preview.PreviewAreaState.OPEN_IN_PREVIEW_LOADING ?
print_preview.PreviewAreaState.OPEN_IN_PREVIEW_LOADED :
print_preview.PreviewAreaState.DISPLAY_PREVIEW;
},
/**
* @return {string} 'invisible' if overlay is invisible, '' otherwise.
* @private
*/
getInvisible_: function() {
return this.isInDisplayPreviewState_() ? 'invisible' : '';
},
/**
* @return {string} 'true' if overlay is aria-hidden, 'false' otherwise.
* @private
*/
getAriaHidden_: function() {
return this.isInDisplayPreviewState_().toString();
},
/**
* @return {boolean} Whether the preview area is in DISPLAY_PREVIEW state.
* @private
*/
isInDisplayPreviewState_: function() {
return this.previewState == print_preview.PreviewAreaState.DISPLAY_PREVIEW;
},
/**
* @return {boolean} Whether the preview is currently loading.
* @private
*/
isPreviewLoading_: function() {
return this.previewState == print_preview.PreviewAreaState.LOADING;
},
/**
* @return {string} 'jumping-dots' to enable animation, '' otherwise.
* @private
*/
getJumpingDots_: function() {
return this.isPreviewLoading_() ? 'jumping-dots' : '';
},
/**
* @return {boolean} Whether the "learn more" link to the cloud print help
* page should be shown.
* @private
*/
shouldShowLearnMoreLink_: function() {
return this.error === print_preview.Error.UNSUPPORTED_PRINTER;
},
/**
* @return {string} The current preview area message to display.
* @private
*/
currentMessage_: function() {
switch (this.previewState) {
case print_preview.PreviewAreaState.LOADING:
return this.i18n('loading');
case print_preview.PreviewAreaState.DISPLAY_PREVIEW:
return '';
// <if expr="is_macosx">
case print_preview.PreviewAreaState.OPEN_IN_PREVIEW_LOADING:
case print_preview.PreviewAreaState.OPEN_IN_PREVIEW_LOADED:
return this.i18n('openingPDFInPreview');
// </if>
case print_preview.PreviewAreaState.ERROR:
// The preview area is responsible for displaying all errors except
// print failed and cloud print error.
return this.getErrorMessage_();
default:
return '';
}
},
/**
* @param {boolean} forceUpdate Whether to force the preview area to update
* regardless of whether the print ticket has changed.
*/
startPreview: function(forceUpdate) {
if (!this.hasTicketChanged_() && !forceUpdate &&
this.previewState !== print_preview.PreviewAreaState.ERROR) {
return;
}
this.previewState = print_preview.PreviewAreaState.LOADING;
this.documentReady_ = false;
this.getPreview_().then(
previewUid => {
if (!this.documentModifiable) {
this.onPreviewStart_(previewUid, -1);
}
this.documentReady_ = true;
},
type => {
if (/** @type{string} */ (type) == 'SETTINGS_INVALID') {
this.error = print_preview.Error.INVALID_PRINTER;
this.previewState = print_preview.PreviewAreaState.ERROR;
} else if (/** @type{string} */ (type) != 'CANCELLED') {
this.error = print_preview.Error.PREVIEW_FAILED;
this.previewState = print_preview.PreviewAreaState.ERROR;
}
});
},
// <if expr="is_macosx">
/** Set the preview state to display the "opening in preview" message. */
setOpeningPdfInPreview: function() {
assert(cr.isMac);
this.previewState =
this.previewState == print_preview.PreviewAreaState.LOADING ?
print_preview.PreviewAreaState.OPEN_IN_PREVIEW_LOADING :
print_preview.PreviewAreaState.OPEN_IN_PREVIEW_LOADED;
},
// </if>
/**
* @param {number} previewUid The unique identifier of the preview.
* @param {number} index The index of the page to preview.
* @private
*/
onPreviewStart_: function(previewUid, index) {
if (!this.pluginProxy_.pluginReady()) {
const plugin = this.pluginProxy_.createPlugin(previewUid, index);
this.pluginProxy_.setKeyEventCallback(this.keyEventCallback_);
this.$$('.preview-area-plugin-wrapper')
.appendChild(
/** @type {Node} */ (plugin));
this.pluginProxy_.setLoadCallback(this.onPluginLoad_.bind(this));
this.pluginProxy_.setViewportChangedCallback(
this.onPreviewVisualStateChange_.bind(this));
}
this.pluginLoaded_ = false;
if (this.inDarkMode && this.newPrintPreviewLayout) {
this.pluginProxy_.darkModeChanged(true);
}
this.pluginProxy_.resetPrintPreviewMode(
previewUid, index, !this.getSettingValue('color'),
/** @type {!Array<number>} */ (this.getSettingValue('pages')),
this.documentModifiable);
},
/**
* Called when the plugin loads. This is a consequence of calling
* plugin.reload(). Certain plugin state can only be set after the plugin
* has loaded.
* @param {boolean} success Whether the plugin load succeeded or not.
* @private
*/
onPluginLoad_: function(success) {
if (success) {
this.pluginLoaded_ = true;
} else {
this.error = print_preview.Error.PREVIEW_FAILED;
this.previewState = print_preview.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 {number} pageX The horizontal offset for the page corner in pixels.
* @param {number} pageY The vertical offset for the page corner in pixels.
* @param {number} pageWidth The page width in pixels.
* @param {number} viewportWidth The viewport width in pixels.
* @param {number} viewportHeight The viewport height in pixels.
* @private
*/
onPreviewVisualStateChange_: function(
pageX, pageY, pageWidth, viewportWidth, viewportHeight) {
// 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.$$('.preview-area-plugin').setAttribute('tabindex', tabindex);
this.$.marginControlContainer.updateTranslationTransform(
new print_preview.Coordinate2d(pageX, pageY));
this.$.marginControlContainer.updateScaleTransform(
pageWidth / this.pageSize.width);
this.$.marginControlContainer.updateClippingMask(
new print_preview.Size(viewportWidth, viewportHeight));
},
/**
* Called when a page's preview has been generated.
* @param {number} pageIndex The index of the page whose preview is ready.
* @param {number} previewUid The unique ID of the print preview UI.
* @param {number} previewResponseId The preview request ID that this page
* preview is a response to.
* @private
*/
onPagePreviewReady_: function(pageIndex, previewUid, previewResponseId) {
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 =
/** @type {number} */ (this.getSettingValue('pagesPerSheet'));
if (pagesPerSheet > 1) {
index = pageIndex;
}
if (index == 0) {
this.onPreviewStart_(previewUid, pageIndex);
}
if (index != -1) {
this.pluginProxy_.loadPreviewPage(previewUid, pageIndex, index);
}
},
/** @private */
onDarkModeChanged_: function() {
if (!this.newPrintPreviewLayout) {
return;
}
if (this.pluginProxy_.pluginReady()) {
this.pluginProxy_.darkModeChanged(this.inDarkMode);
}
if (this.previewState === print_preview.PreviewAreaState.DISPLAY_PREVIEW) {
this.startPreview(true);
}
},
/**
* Processes a keyboard event that could possibly be used to change state of
* the preview plugin.
* @param {!KeyboardEvent} e Keyboard event to process.
*/
handleDirectionalKeyEvent: function(e) {
// 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.code) ||
hasKeyModifiers(e)) {
return;
}
// Don't handle the key event for these elements.
const tagName = e.composedPath()[0].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.code);
for (let i = 0; i < e.composedPath().length; i++) {
const element = e.composedPath()[i];
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.
*/
hideToolbars: function() {
if (!this.pluginProxy_.pluginReady()) {
return;
}
this.pluginProxy_.hideToolbars();
},
/**
* Set a callback that gets called when a key event is received that
* originates in the plugin.
* @param {function(KeyboardEvent)} callback The callback to be called with
* a key event.
*/
setPluginKeyEventCallback: function(callback) {
this.keyEventCallback_ = callback;
},
/**
* Called when dragging margins starts or stops.
*/
onMarginDragChanged_: function(e) {
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 {!CustomEvent<{x: number, y: number}>} e Contains information about
* where the plugin should scroll to.
* @private
*/
onTextFocusPosition_: function(e) {
// 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 {boolean} Whether margin settings are valid for the print ticket.
* @private
*/
marginsValid_: function() {
const type = this.getSettingValue('margins');
if (!Object.values(print_preview.MarginsType).includes(type)) {
// Unrecognized margins type.
return false;
}
if (type !== print_preview.MarginsType.CUSTOM) {
return true;
}
const customMargins = this.getSettingValue('customMargins');
return customMargins.marginTop !== undefined &&
customMargins.marginLeft !== undefined &&
customMargins.marginBottom !== undefined &&
customMargins.marginRight !== undefined;
},
/**
* @return {boolean}
* @private
*/
hasTicketChanged_: function() {
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');
if (newMarginsType !== lastTicket.marginsType &&
newMarginsType !== print_preview.MarginsType.CUSTOM) {
return true;
}
if (newMarginsType === print_preview.MarginsType.CUSTOM) {
const customMargins =
/** @type {!print_preview.MarginsSetting} */ (
this.getSettingValue('customMargins'));
// 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(print_preview.CustomMarginsOrientation).some(side => {
return this.margins.get(side) !==
customMargins[print_preview.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
const isScalingTypeFitToPage = this.getSettingValue('scalingTypePdf') ===
print_preview.ScalingType.FIT_TO_PAGE;
if (isScalingTypeFitToPage !== lastTicket.fitToPageEnabled ||
!areRangesEqual(
/** @type {!Array<{from: number, to: number}>} */
(this.getSettingValue('ranges')), 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.getScaleFactorForTicket_() !== lastTicket.scaleFactor) {
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') === print_preview.MarginsType.DEFAULT) {
return true;
}
// Media size
const newValue =
/** @type {!print_preview.MediaSizeValue} */ (
this.getSettingValue('mediaSize'));
if (newValue.height_microns != lastTicket.mediaSize.height_microns ||
newValue.width_microns != lastTicket.mediaSize.width_microns ||
(this.destination.id !== lastTicket.deviceName &&
this.getSettingValue('margins') ===
print_preview.MarginsType.MINIMUM)) {
return true;
}
// Destination
if (print_preview.getPrinterTypeForDestination(this.destination) !==
lastTicket.printerType) {
return true;
}
return false;
},
/** @return {number} Native color model of the destination. */
getColorForTicket_: function() {
return this.destination.getNativeColorModel(
/** @type {boolean} */ (this.getSettingValue('color')));
},
/** @return {number} Scale factor. */
getScaleFactorForTicket_: function() {
const scalingSettingKey = this.getSetting('scalingTypePdf').available ?
'scalingTypePdf' :
'scalingType';
return this.getSettingValue(scalingSettingKey) ===
print_preview.ScalingType.CUSTOM ?
parseInt(this.getSettingValue('scaling'), 10) :
100;
},
/**
* @param {string} dpiField The field in dpi to retrieve.
* @return {number} Field value.
*/
getDpiForTicket_: function(dpiField) {
const dpi =
/**
@type {{horizontal_dpi: (number | undefined),
vertical_dpi: (number | undefined),
vendor_id: (number | undefined)}}
*/
(this.getSettingValue('dpi'));
const value = (dpi && dpiField in dpi) ? dpi[dpiField] : 0;
return value;
},
/**
* Requests a preview from the native layer.
* @return {!Promise} Promise that resolves when the preview has been
* generated.
*/
getPreview_: function() {
this.inFlightRequestId_++;
const ticket = {
pageRange: this.getSettingValue('ranges'),
mediaSize: this.getSettingValue('mediaSize'),
landscape: this.getSettingValue('layout'),
color: this.getColorForTicket_(),
headerFooterEnabled: this.getSettingValue('headerFooter'),
marginsType: this.getSettingValue('margins'),
pagesPerSheet: this.getSettingValue('pagesPerSheet'),
isFirstRequest: this.inFlightRequestId_ == 0,
requestID: this.inFlightRequestId_,
previewModifiable: this.documentModifiable,
fitToPageEnabled: this.getSettingValue('scalingTypePdf') ===
print_preview.ScalingType.FIT_TO_PAGE,
scaleFactor: this.getScaleFactorForTicket_(),
shouldPrintBackgrounds: this.getSettingValue('cssBackground'),
shouldPrintSelectionOnly: this.getSettingValue('selectionOnly'),
// 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') ?
print_preview.DuplexMode.LONG_EDGE :
print_preview.DuplexMode.SIMPLEX,
printerType: print_preview.getPrinterTypeForDestination(this.destination),
rasterizePDF: this.getSettingValue('rasterize'),
};
// Set 'cloudPrintID' only if the this.destination is not local.
if (this.destination && !this.destination.isLocal) {
ticket.cloudPrintID = this.destination.id;
}
if (this.getSettingValue('margins') == print_preview.MarginsType.CUSTOM) {
ticket.marginsCustom = this.getSettingValue('customMargins');
}
this.lastTicket_ = ticket;
this.fire('preview-start', this.inFlightRequestId_);
return this.nativeLayer_.getPreview(JSON.stringify(ticket));
},
/** @private */
onStateOrErrorChange_: function() {
if ((this.state === print_preview.State.ERROR ||
this.state === print_preview.State.FATAL_ERROR) &&
this.getErrorMessage_() !== '') {
this.previewState = print_preview.PreviewAreaState.ERROR;
}
},
/** @return {string} The error message to display in the preview area. */
getErrorMessage_: function() {
switch (this.error) {
case print_preview.Error.INVALID_PRINTER:
return this.i18nAdvanced('invalidPrinterSettings', {
substitutions: [],
tags: ['BR'],
});
case print_preview.Error.UNSUPPORTED_PRINTER:
return this.i18nAdvanced('unsupportedCloudPrinter', {
substitutions: [],
tags: ['BR'],
});
// <if expr="chromeos">
case print_preview.Error.NO_DESTINATIONS:
return this.i18n('noDestinationsMessage');
// </if>
case print_preview.Error.NO_PLUGIN:
return this.i18n('noPlugin');
case print_preview.Error.PREVIEW_FAILED:
return this.i18n('previewFailed');
default:
return '';
}
},
});