chromium/chrome/browser/resources/print_preview/ui/margin_control.ts

// Copyright 2018 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_shared_vars.css.js';
import 'chrome://resources/cr_elements/cr_input/cr_input_style.css.js';
import '../strings.m.js';

import {I18nMixin} from 'chrome://resources/cr_elements/i18n_mixin.js';
import {WebUiListenerMixin} from 'chrome://resources/cr_elements/web_ui_listener_mixin.js';
import {assert} from 'chrome://resources/js/assert.js';
import {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import type {Coordinate2d} from '../data/coordinate2d.js';
import {CustomMarginsOrientation} from '../data/margins.js';
import type {MeasurementSystem} from '../data/measurement_system.js';
import type {Size} from '../data/size.js';
import {observerDepsDefined} from '../print_preview_utils.js';

import {InputMixin} from './input_mixin.js';
import {getTemplate} from './margin_control.html.js';

/**
 * Radius of the margin control in pixels. Padding of control + 1 for border.
 */
const RADIUS_PX: number = 9;

export interface PrintPreviewMarginControlElement {
  $: {
    input: HTMLInputElement,
    lineContainer: HTMLDivElement,
    line: HTMLDivElement,
  };
}

const PrintPreviewMarginControlElementBase =
    I18nMixin(WebUiListenerMixin(InputMixin(PolymerElement)));

export class PrintPreviewMarginControlElement extends
    PrintPreviewMarginControlElementBase {
  static get is() {
    return 'print-preview-margin-control';
  }

  static get template() {
    return getTemplate();
  }

  static get properties() {
    return {
      disabled: {
        type: Boolean,
        reflectToAttribute: true,
        observer: 'onDisabledChange_',
      },

      side: {
        type: String,
        reflectToAttribute: true,
      },

      invalid: {
        type: Boolean,
        reflectToAttribute: true,
      },

      invisible: {
        type: Boolean,
        reflectToAttribute: true,
        observer: 'onClipSizeChange_',
      },

      measurementSystem: Object,

      focused_: {
        type: Boolean,
        reflectToAttribute: true,
        value: false,
      },

      positionInPts_: {
        type: Number,
        notify: true,
        value: 0,
      },

      scaleTransform: {
        type: Number,
        notify: true,
      },

      translateTransform: {
        type: Object,
        notify: true,
      },

      pageSize: {
        type: Object,
        notify: true,
      },

      clipSize: {
        type: Object,
        notify: true,
        observer: 'onClipSizeChange_',
      },
    };
  }

  disabled: boolean;
  side: CustomMarginsOrientation;
  invalid: boolean;
  invisible: boolean;
  measurementSystem: MeasurementSystem|null;
  scaleTransform: number;
  translateTransform: Coordinate2d;
  pageSize: Size;
  clipSize: Size|null;

  private focused_: boolean;
  private positionInPts_: number;

  static get observers() {
    return [
      'updatePosition_(positionInPts_, scaleTransform, translateTransform, ' +
          'pageSize, side)',
    ];
  }

  override ready() {
    super.ready();

    this.addEventListener('input-change', e => this.onInputChange_(e));
  }

  /** @return The input element for InputBehavior. */
  override getInput(): HTMLInputElement {
    return this.$.input;
  }

  /**
   * @param valueInPts New value of the margin control's textbox in pts.
   */
  setTextboxValue(valueInPts: number) {
    const textbox = this.$.input;
    const pts = textbox.value ? this.parseValueToPts_(textbox.value) : null;
    if (pts !== null && valueInPts === Math.round(pts)) {
      // If the textbox's value represents the same value in pts as the new one,
      // don't reset. This allows the "undo" command to work as expected, see
      // https://crbug.com/452844.
      return;
    }

    textbox.value = this.serializeValueFromPts_(valueInPts);
    this.resetString();
  }

  /** @return The current position of the margin control. */
  getPositionInPts(): number {
    return this.positionInPts_;
  }

  /** @param position The new position for the margin control. */
  setPositionInPts(position: number) {
    this.positionInPts_ = position;
  }

  /**
   * @return 'true' or 'false', indicating whether the input should be
   *     aria-hidden.
   */
  private getAriaHidden_(): string {
    return this.invisible.toString();
  }

  /**
   * Converts a value in pixels to points.
   * @param pixels Pixel value to convert.
   * @return Given value expressed in points.
   */
  convertPixelsToPts(pixels: number): number {
    let pts;
    const Orientation = CustomMarginsOrientation;
    if (this.side === Orientation.TOP) {
      pts = pixels - this.translateTransform.y + RADIUS_PX;
      pts /= this.scaleTransform;
    } else if (this.side === Orientation.RIGHT) {
      pts = pixels - this.translateTransform.x + RADIUS_PX;
      pts /= this.scaleTransform;
      pts = this.pageSize.width - pts;
    } else if (this.side === Orientation.BOTTOM) {
      pts = pixels - this.translateTransform.y + RADIUS_PX;
      pts /= this.scaleTransform;
      pts = this.pageSize.height - pts;
    } else {
      assert(this.side === Orientation.LEFT);
      pts = pixels - this.translateTransform.x + RADIUS_PX;
      pts /= this.scaleTransform;
    }
    return pts;
  }

  /**
   * @param event A pointerdown event triggered by this element.
   * @return Whether the margin should start being dragged.
   */
  shouldDrag(event: PointerEvent): boolean {
    return !this.disabled && event.button === 0 &&
        (event.composedPath()[0] === this.$.lineContainer ||
         event.composedPath()[0] === this.$.line);
  }

  private onDisabledChange_() {
    if (this.disabled) {
      this.focused_ = false;
    }
  }

  /**
   * @param value Value to parse to points. E.g. '3.40' or '200'.
   * @return Value in points represented by the input value.
   */
  private parseValueToPts_(value: string): number|null {
    value = value.trim();
    if (value.length === 0) {
      return null;
    }
    assert(this.measurementSystem);
    const decimal = this.measurementSystem!.decimalDelimiter;
    const thousands = this.measurementSystem!.thousandsDelimiter;
    const whole = `(?:0|[1-9]\\d*|[1-9]\\d{0,2}(?:[${thousands}]\\d{3})*)`;
    const fractional = `(?:[${decimal}]\\d+)`;
    const wholeDecimal = `(?:${whole}[${decimal}])`;
    const validationRegex = new RegExp(
        `^-?(?:${whole}${fractional}?|${fractional}|${wholeDecimal})$`);
    if (validationRegex.test(value)) {
      // Removing thousands delimiters and replacing the decimal delimiter with
      // the dot symbol in order to use parseFloat() properly.
      value = value.replace(new RegExp(`\\${thousands}`, 'g'), '')
                  .replace(decimal, '.');
      return this.measurementSystem!.convertToPoints(parseFloat(value));
    }
    return null;
  }

  /**
   * @param value Value in points to serialize.
   * @return String representation of the value in the system's local units.
   */
  private serializeValueFromPts_(value: number): string {
    assert(this.measurementSystem);
    value = this.measurementSystem!.convertFromPoints(value);
    value = this.measurementSystem!.roundValue(value);
    // Convert the dot symbol to the decimal delimiter for the locale.
    return value.toString().replace(
        '.', this.measurementSystem!.decimalDelimiter);
  }

  private fire_(eventName: string, detail?: any) {
    this.dispatchEvent(
        new CustomEvent(eventName, {bubbles: true, composed: true, detail}));
  }

  /**
   * @param e Contains the new value of the input.
   */
  private onInputChange_(e: CustomEvent<string>) {
    if (e.detail === '') {
      return;
    }

    const value = this.parseValueToPts_(e.detail);
    if (value === null) {
      this.invalid = true;
      return;
    }

    this.fire_('text-change', value);
  }

  private onBlur_() {
    this.focused_ = false;
    this.resetAndUpdate();
    this.fire_('text-blur', this.invalid || !this.$.input.value);
  }

  private onFocus_() {
    this.focused_ = true;
    this.fire_('text-focus');
  }

  private updatePosition_() {
    if (!observerDepsDefined(Array.from(arguments))) {
      return;
    }

    const Orientation = CustomMarginsOrientation;
    let x = this.translateTransform.x;
    let y = this.translateTransform.y;
    let width: number|null = null;
    let height: number|null = null;
    if (this.side === Orientation.TOP) {
      y = this.scaleTransform * this.positionInPts_ +
          this.translateTransform.y - RADIUS_PX;
      width = this.scaleTransform * this.pageSize.width;
    } else if (this.side === Orientation.RIGHT) {
      x = this.scaleTransform * (this.pageSize.width - this.positionInPts_) +
          this.translateTransform.x - RADIUS_PX;
      height = this.scaleTransform * this.pageSize.height;
    } else if (this.side === Orientation.BOTTOM) {
      y = this.scaleTransform * (this.pageSize.height - this.positionInPts_) +
          this.translateTransform.y - RADIUS_PX;
      width = this.scaleTransform * this.pageSize.width;
    } else {
      x = this.scaleTransform * this.positionInPts_ +
          this.translateTransform.x - RADIUS_PX;
      height = this.scaleTransform * this.pageSize.height;
    }
    window.requestAnimationFrame(() => {
      this.style.left = Math.round(x) + 'px';
      this.style.top = Math.round(y) + 'px';
      if (width !== null) {
        this.style.width = Math.round(width) + 'px';
      }
      if (height !== null) {
        this.style.height = Math.round(height) + 'px';
      }
    });
    this.onClipSizeChange_();
  }

  private onClipSizeChange_() {
    if (!this.clipSize) {
      return;
    }
    window.requestAnimationFrame(() => {
      const offsetLeft = this.offsetLeft;
      const offsetTop = this.offsetTop;
      this.style.clip = 'rect(' + (-offsetTop) + 'px, ' +
          (this.clipSize!.width - offsetLeft) + 'px, ' +
          (this.clipSize!.height - offsetTop) + 'px, ' + (-offsetLeft) + 'px)';
    });
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'print-preview-margin-control': PrintPreviewMarginControlElement;
  }
}

customElements.define(
    PrintPreviewMarginControlElement.is, PrintPreviewMarginControlElement);