blob: d53f01c007699579819fec07f06e44d523fc489f [file] [log] [blame]
// Copyright 2017 The Chromium Authors
// 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/cr_input/cr_input.js';
import 'chrome://resources/cr_elements/cr_collapse/cr_collapse.js';
import './print_preview_shared.css.js';
import './settings_section.js';
import '/strings.m.js';
import type {CrInputElement} from 'chrome://resources/cr_elements/cr_input/cr_input.js';
import {WebUiListenerMixinLit} from 'chrome://resources/cr_elements/web_ui_listener_mixin_lit.js';
import {assert} from 'chrome://resources/js/assert.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 type {Range} from '../print_preview_utils.js';
import {areRangesEqual} from '../print_preview_utils.js';
import {InputMixin} from './input_mixin.js';
import {getCss} from './pages_settings.css.js';
import {getHtml} from './pages_settings.html.js';
import {SelectMixin} from './select_mixin.js';
import {SettingsMixin} from './settings_mixin.js';
enum PagesInputErrorState {
NO_ERROR = 0,
INVALID_SYNTAX = 1,
OUT_OF_BOUNDS = 2,
EMPTY = 3,
}
export enum PagesValue {
ALL = 0,
ODDS = 1,
EVENS = 2,
CUSTOM = 3,
}
/**
* Used in place of Number.parseInt(), to ensure values like '1 2' or '1a2' are
* not allowed.
* @param value The value to convert to a number.
* @return The value converted to a number, or NaN if it cannot be converted.
*/
function parseIntStrict(value: string): number {
if (/^\d+$/.test(value.trim())) {
return Number(value);
}
return NaN;
}
export interface PrintPreviewPagesSettingsElement {
$: {
pageSettingsCustomInput: CrInputElement,
};
}
const PrintPreviewPagesSettingsElementBase =
WebUiListenerMixinLit(InputMixin(SettingsMixin(SelectMixin(CrLitElement))));
export class PrintPreviewPagesSettingsElement extends
PrintPreviewPagesSettingsElementBase {
static get is() {
return 'print-preview-pages-settings';
}
static override get styles() {
return getCss();
}
override render() {
return getHtml.bind(this)();
}
static override get properties() {
return {
disabled: {type: Boolean},
pageCount: {type: Number},
controlsDisabled_: {type: Boolean},
errorState_: {
type: Number,
reflect: true,
},
hasError_: {type: Boolean},
inputString_: {type: String},
pagesToPrint_: {type: Array},
rangesToPrint_: {type: Array},
selection_: {type: Number},
};
}
accessor disabled: boolean = false;
accessor pageCount: number = 0;
protected accessor controlsDisabled_: boolean = false;
private accessor errorState_: PagesInputErrorState =
PagesInputErrorState.NO_ERROR;
protected accessor hasError_: boolean = false;
private accessor inputString_: string = '';
private accessor pagesToPrint_: number[] = [];
private accessor rangesToPrint_: Range[] = [];
private accessor selection_: PagesValue = PagesValue.ALL;
/**
* True if the user's last valid input should be restored to the custom
* input field. Cleared when the input is set automatically, or the user
* manually clears the field.
*/
private restoreLastInput_: boolean = true;
/**
* Memorizes the user's last non-custom pages setting. Used when
* `PagesValue.ODDS` and `PagesValue.EVEN` become invalid due to a changed
* page count.
*/
private restorationValue_: PagesValue = PagesValue.ALL;
override firstUpdated() {
this.addEventListener('input-change', e => this.onInputChange_(e));
}
/**
* Initialize |selectedValue| in connectedCallback() since this doesn't
* observe settings.pages, because settings.pages is not sticky.
*/
override connectedCallback() {
super.connectedCallback();
this.selectedValue = PagesValue.ALL.toString();
this.addSettingObserver('pages', () => this.onRangeChange_());
this.addSettingObserver('pagesPerSheet.value', () => this.onRangeChange_());
}
override willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
const changedPrivateProperties =
changedProperties as Map<PropertyKey, unknown>;
if (changedProperties.has('disabled') ||
changedPrivateProperties.has('hasError_')) {
this.controlsDisabled_ = this.computeControlsDisabled_();
}
if (changedPrivateProperties.has('inputString_')) {
this.updatePagesToPrint_();
}
if (changedPrivateProperties.has('pagesToPrint_')) {
this.rangesToPrint_ = this.computeRangesToPrint_();
}
if (changedPrivateProperties.has('errorState_') ||
changedPrivateProperties.has('rangesToPrint_')) {
this.onRangeChange_();
}
}
override updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
const changedPrivateProperties =
changedProperties as Map<PropertyKey, unknown>;
if (changedProperties.has('pageCount')) {
this.onPageCountChange_(
this.pageCount, changedProperties.get('pageCount'));
}
if (changedPrivateProperties.has('selection_')) {
this.onSelectionChange_();
}
}
/** The cr-input field element for InputMixin. */
override getInput() {
return this.$.pageSettingsCustomInput;
}
private setSelectedValue_(value: PagesValue) {
this.selectedValue = value.toString();
this.shadowRoot.querySelector('select')!.dispatchEvent(
new CustomEvent('change', {bubbles: true, composed: true}));
}
private onInputChange_(e: CustomEvent<string>) {
if (this.inputString_ !== e.detail) {
this.restoreLastInput_ = true;
}
this.inputString_ = e.detail;
}
override onProcessSelectChange(value: string) {
this.selection_ = parseInt(value, 10);
}
protected onCollapseChanged_() {
if (this.selection_ === PagesValue.CUSTOM) {
this.$.pageSettingsCustomInput.inputElement.focus();
}
}
/**
* @return Whether the controls should be disabled.
*/
private computeControlsDisabled_(): boolean {
// Disable the input if other settings are responsible for the error state.
return !this.hasError_ && this.disabled;
}
/**
* Updates pages to print and error state based on the validity and
* current value of the input.
*/
private updatePagesToPrint_() {
if (this.selection_ !== PagesValue.CUSTOM) {
this.errorState_ = PagesInputErrorState.NO_ERROR;
if (!this.pageCount) {
this.pagesToPrint_ = [];
return;
}
const first = this.selection_ === PagesValue.EVENS ? 2 : 1;
const step = this.selection_ === PagesValue.ALL ? 1 : 2;
assert(first === 1 || this.pageCount !== 1);
const length = Math.floor(1 + (this.pageCount - first) / step);
this.pagesToPrint_ = Array.from({length}, (_, i) => step * i + first);
return;
} else if (this.inputString_ === '') {
this.errorState_ = PagesInputErrorState.EMPTY;
return;
}
const pages = [];
const added: {[page: number]: boolean} = {};
const ranges = this.inputString_.split(/,|\u3001/);
const maxPage = this.pageCount;
for (const range of ranges) {
if (range === '') {
this.errorState_ = PagesInputErrorState.INVALID_SYNTAX;
this.onRangeChange_();
return;
}
const limits = range.split('-');
if (limits.length > 2) {
this.errorState_ = PagesInputErrorState.INVALID_SYNTAX;
this.onRangeChange_();
return;
}
let min = parseIntStrict(limits[0]!);
if ((limits[0]!.length > 0 && Number.isNaN(min)) || min < 1) {
this.errorState_ = PagesInputErrorState.INVALID_SYNTAX;
this.onRangeChange_();
return;
}
if (limits.length === 1) {
if (min > maxPage) {
this.errorState_ = PagesInputErrorState.OUT_OF_BOUNDS;
this.onRangeChange_();
return;
}
if (!added.hasOwnProperty(min)) {
pages.push(min);
added[min] = true;
}
continue;
}
let max = parseIntStrict(limits[1]!);
if (Number.isNaN(max) && limits[1]!.length > 0) {
this.errorState_ = PagesInputErrorState.INVALID_SYNTAX;
this.onRangeChange_();
return;
}
if (Number.isNaN(min)) {
min = 1;
}
if (Number.isNaN(max)) {
max = maxPage;
}
if (min > max) {
this.errorState_ = PagesInputErrorState.INVALID_SYNTAX;
this.onRangeChange_();
return;
}
if (max > maxPage) {
this.errorState_ = PagesInputErrorState.OUT_OF_BOUNDS;
this.onRangeChange_();
return;
}
for (let i = min; i <= max; i++) {
if (!added.hasOwnProperty(i)) {
pages.push(i);
added[i] = true;
}
}
}
// Page numbers should be sorted to match the order of the pages in the
// rendered PDF.
pages.sort((left, right) => left - right);
this.errorState_ = PagesInputErrorState.NO_ERROR;
this.pagesToPrint_ = pages;
}
private computeRangesToPrint_(): Range[] {
if (!this.pagesToPrint_ || this.pagesToPrint_.length === 0 ||
this.pagesToPrint_[0] === -1 ||
this.pagesToPrint_.length === this.pageCount) {
return [];
}
let from = this.pagesToPrint_[0]!;
let to = this.pagesToPrint_[0]!;
const ranges: Range[] = [];
for (const page of this.pagesToPrint_.slice(1)) {
if (page === to + 1) {
to = page;
continue;
}
ranges.push({from: from, to: to});
from = page;
to = page;
}
ranges.push({from: from, to: to});
return ranges;
}
/**
* @return The final page numbers, reflecting N-up setting.
* Page numbers are 1 indexed, since these numbers are displayed to the
* user.
*/
private getNupPages_(): number[] {
const pagesPerSheet = this.getSettingValue('pagesPerSheet') as number;
if (pagesPerSheet <= 1 || this.pagesToPrint_.length === 0) {
return this.pagesToPrint_;
}
const numPages = Math.ceil(this.pagesToPrint_.length / pagesPerSheet);
const nupPages = new Array(numPages);
for (let i = 0; i < nupPages.length; i++) {
nupPages[i] = i + 1;
}
return nupPages;
}
/**
* Updates the model with pages and validity, and adds error styling if
* needed.
*/
private onRangeChange_() {
if (this.errorState_ === PagesInputErrorState.EMPTY) {
this.setSettingValid('pages', true);
this.hasError_ = false;
return;
}
if (this.errorState_ !== PagesInputErrorState.NO_ERROR) {
this.hasError_ = true;
this.setSettingValid('pages', false);
return;
}
const nupPages = this.getNupPages_();
const rangesChanged = !areRangesEqual(
this.rangesToPrint_, this.getSettingValue('ranges') as Range[]);
if (rangesChanged ||
nupPages.length !== this.getSettingValue('pages').length) {
this.setSetting('pages', nupPages);
}
if (rangesChanged) {
this.setSetting('ranges', this.rangesToPrint_);
}
this.setSettingValid('pages', true);
this.hasError_ = false;
}
protected onSelectBlur_(event: FocusEvent) {
if (this.selection_ !== PagesValue.CUSTOM ||
event.relatedTarget === this.$.pageSettingsCustomInput) {
return;
}
this.onCustomInputBlur_();
}
protected async onCustomInputBlur_() {
this.resetAndUpdate();
await this.shadowRoot.querySelector('cr-input')!.updateComplete;
if (this.errorState_ === PagesInputErrorState.EMPTY) {
// Update with all pages.
this.shadowRoot.querySelector('cr-input')!.value =
this.getAllPagesString_();
this.inputString_ = this.getAllPagesString_();
this.resetString();
this.restoreLastInput_ = false;
}
this.dispatchEvent(new CustomEvent(
'custom-input-blurred-for-test', {bubbles: true, composed: true}));
}
/**
* @return Message to show as hint.
*/
protected getHintMessage_(): string {
if (this.errorState_ === PagesInputErrorState.NO_ERROR ||
this.errorState_ === PagesInputErrorState.EMPTY) {
return '';
}
let formattedMessage = '';
if (this.errorState_ === PagesInputErrorState.INVALID_SYNTAX) {
formattedMessage = loadTimeData.getStringF(
'pageRangeSyntaxInstruction',
loadTimeData.getString('examplePageRangeText'));
} else {
formattedMessage = loadTimeData.getStringF(
'pageRangeLimitInstructionWithValue', this.pageCount);
}
return formattedMessage.replace(/<\/b>|<b>/g, '');
}
/**
* @return Whether the document being printed has only one page.
*/
protected isSinglePage_(): boolean {
return this.pageCount === 1;
}
/**
* @return Whether to disable the custom input.
*/
protected inputDisabled_(): boolean {
return this.selection_ !== PagesValue.CUSTOM || this.controlsDisabled_;
}
/**
* @return Whether to display the custom input.
*/
protected shouldShowInput_(): boolean {
return this.selection_ === PagesValue.CUSTOM;
}
/**
* @return A string representing the full page range.
*/
private getAllPagesString_(): string {
if (this.pageCount === 0) {
return '';
}
return this.pageCount === 1 ? '1' : `1-${this.pageCount}`;
}
private onSelectionChange_() {
const customSelected = this.selection_ === PagesValue.CUSTOM;
if ((customSelected && !this.restoreLastInput_) ||
this.errorState_ !== PagesInputErrorState.NO_ERROR) {
this.restoreLastInput_ = true;
this.inputString_ = '';
this.shadowRoot.querySelector('cr-input')!.value = '';
this.resetString();
}
this.updatePagesToPrint_();
}
private onPageCountChange_(current: number, previous: number|undefined) {
// Remember non-custom page settings when the page count changes to 1, so
// they can be re-applied if the page count exceeds 1 again.
if (this.selection_ !== PagesValue.CUSTOM) {
if (current === 1) {
this.restorationValue_ = this.selection_;
this.setSelectedValue_(PagesValue.ALL);
} else if (previous === 1) {
assert(this.restorationValue_ !== PagesValue.CUSTOM);
this.setSelectedValue_(this.restorationValue_);
}
}
// Reset the custom input to the new "all pages" value if it is equal to the
// full page range and was either set automatically, or would become invalid
// due to the page count change.
const resetCustom = this.selection_ === PagesValue.CUSTOM &&
!!this.pagesToPrint_ && this.pagesToPrint_.length === previous &&
(current < previous || !this.restoreLastInput_);
if (resetCustom) {
this.shadowRoot.querySelector('cr-input')!.value =
this.getAllPagesString_();
this.inputString_ = this.getAllPagesString_();
this.resetString();
} else {
this.updatePagesToPrint_();
}
}
}
export type PagesSettingsElement = PrintPreviewPagesSettingsElement;
declare global {
interface HTMLElementTagNameMap {
'print-preview-pages-settings': PrintPreviewPagesSettingsElement;
}
}
customElements.define(
PrintPreviewPagesSettingsElement.is, PrintPreviewPagesSettingsElement);