chromium/chrome/browser/resources/pdf/elements/viewer_toolbar.ts

// 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 'chrome://resources/cr_elements/cr_icon_button/cr_icon_button.js';
import 'chrome://resources/cr_elements/icons_lit.html.js';
import 'chrome://resources/cr_elements/cr_progress/cr_progress.js';
import './icons.html.js';
import './viewer_download_controls.js';
import './viewer_page_selector.js';
import './shared_vars.css.js';
// <if expr="enable_ink">
import './viewer_annotations_bar.js';
import './viewer_annotations_mode_dialog.js';
// </if>

import type {CrActionMenuElement} from 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
import {AnchorAlignment} from 'chrome://resources/cr_elements/cr_action_menu/cr_action_menu.js';
// <if expr="enable_pdf_ink2">
import {assert} from 'chrome://resources/js/assert.js';
import {EventTracker} from 'chrome://resources/js/event_tracker.js';
// </if>
import {CrLitElement} from 'chrome://resources/lit/v3_0/lit.rollup.js';
import type {PropertyValues} from 'chrome://resources/lit/v3_0/lit.rollup.js';

import {FittingType, FormFieldFocusType} from '../constants.js';
// <if expr="enable_pdf_ink2">
import {PluginController, PluginControllerEventType} from '../controller.js';
// </if>
import {record, UserAction} from '../metrics.js';

import {getCss} from './viewer_toolbar.css.js';
import {getHtml} from './viewer_toolbar.html.js';

declare global {
  interface HTMLElementEventMap {
    'annotation-mode-toggled': CustomEvent<boolean>;
    'display-annotations-changed': CustomEvent<boolean>;
    'fit-to-changed': CustomEvent<FittingType>;
  }
}

export interface ViewerToolbarElement {
  $: {
    sidenavToggle: HTMLElement,
    menu: CrActionMenuElement,
    'present-button': HTMLButtonElement,
    'two-page-view-button': HTMLButtonElement,
  };
}

export class ViewerToolbarElement extends CrLitElement {
  static get is() {
    return 'viewer-toolbar';
  }

  static override get styles() {
    return getCss();
  }

  override render() {
    return getHtml.bind(this)();
  }

  static override get properties() {
    return {
      // <if expr="enable_ink or enable_pdf_ink2">
      annotationAvailable: {type: Boolean},
      annotationMode: {
        type: Boolean,
        reflect: true,
      },
      // </if>

      // <if expr="enable_pdf_ink2">
      canRedoAnnotation_: {type: Boolean},
      canUndoAnnotation_: {type: Boolean},
      // </if>

      docTitle: {type: String},
      docLength: {type: Number},
      embeddedViewer: {type: Boolean},
      hasEdits: {type: Boolean},
      hasEnteredAnnotationMode: {type: Boolean},
      // <if expr="enable_pdf_ink2">
      hasInk2Edits: {type: Boolean},
      // </if>
      formFieldFocus: {type: String},
      loadProgress: {type: Number},

      loading_: {
        type: Boolean,
        reflect: true,
      },

      pageNo: {type: Number},
      pdfAnnotationsEnabled: {type: Boolean},
      // <if expr="enable_pdf_ink2">
      pdfInk2Enabled: {type: Boolean},
      // </if>

      printingEnabled: {type: Boolean},
      rotated: {type: Boolean},
      viewportZoom: {type: Number},
      zoomBounds: {type: Object},
      sidenavCollapsed: {type: Boolean},
      twoUpViewEnabled: {type: Boolean},

      moreMenuOpen_: {
        type: Boolean,
        reflect: true,
      },

      fittingType_: {type: Number},
      viewportZoomPercent_: {type: Number},

      // <if expr="enable_ink">
      showAnnotationsModeDialog_: {type: Boolean},
      // </if> enable_ink
    };
  }

  docTitle: string = '';
  docLength: number = 0;
  embeddedViewer: boolean = false;
  hasEdits: boolean = false;
  hasEnteredAnnotationMode: boolean = false;
  // <if expr="enable_pdf_ink2">
  hasInk2Edits: boolean = false;
  // </if>
  formFieldFocus: FormFieldFocusType = FormFieldFocusType.NONE;
  loadProgress: number = 0;
  pageNo: number = 0;
  pdfAnnotationsEnabled: boolean = false;
  printingEnabled: boolean = false;
  rotated: boolean = false;
  viewportZoom: number = 0;
  zoomBounds: {min: number, max: number} = {min: 0, max: 0};
  sidenavCollapsed: boolean = false;
  twoUpViewEnabled: boolean = false;
  protected displayAnnotations_: boolean = true;
  private fittingType_: FittingType = FittingType.FIT_TO_PAGE;
  protected moreMenuOpen_: boolean = false;
  protected loading_: boolean = true;
  private viewportZoomPercent_: number = 0;

  // <if expr="enable_ink or enable_pdf_ink2">
  annotationAvailable: boolean = false;
  annotationMode: boolean = false;
  // </if>

  // <if expr="enable_ink">
  protected showAnnotationsModeDialog_: boolean = false;
  // </if>

  // <if expr="enable_pdf_ink2">
  pdfInk2Enabled: boolean = false;
  protected canRedoAnnotation_: boolean = false;
  protected canUndoAnnotation_: boolean = false;
  private currentStroke: number = 0;
  private mostRecentStroke: number = 0;
  private pluginController_: PluginController = PluginController.getInstance();
  private tracker_: EventTracker = new EventTracker();

  constructor() {
    super();

    this.tracker_.add(
        this.pluginController_.getEventTarget(),
        PluginControllerEventType.FINISH_INK_STROKE,
        this.handleFinishInkStroke_.bind(this));
  }
  // </if>

  override willUpdate(changedProperties: PropertyValues<this>) {
    super.willUpdate(changedProperties);

    if (changedProperties.has('loadProgress')) {
      this.loading_ = this.loadProgress < 100;
    }

    if (changedProperties.has('viewportZoom')) {
      this.viewportZoomPercent_ = Math.round(100 * this.viewportZoom);
    }

    // <if expr="enable_pdf_ink2">
    if (changedProperties.has('formFieldFocus')) {
      this.updateCanUndoRedo_();
    }
    // </if>
  }

  override updated(changedProperties: PropertyValues<this>) {
    super.updated(changedProperties);

    // viewportZoomPercent_ always updates with viewportZoom, see above.
    if (changedProperties.has('viewportZoom')) {
      this.getZoomInput_().value = `${this.viewportZoomPercent_}%`;
    }
  }

  protected onSidenavToggleClick_() {
    record(UserAction.TOGGLE_SIDENAV);
    this.dispatchEvent(new CustomEvent('sidenav-toggle-click'));
  }

  protected fitToButtonIcon_(): string {
    return this.fittingType_ === FittingType.FIT_TO_PAGE ? 'pdf:fit-to-height' :
                                                           'pdf:fit-to-width';
  }

  /** @return The appropriate tooltip for the current state. */
  protected getFitToButtonTooltip_(
      fitToPageTooltip: string, fitToWidthTooltip: string): string {
    return this.fittingType_ === FittingType.FIT_TO_PAGE ? fitToPageTooltip :
                                                           fitToWidthTooltip;
  }

  // <if expr="enable_ink">
  protected showInkAnnotationButton_(): boolean {
    // <if expr="enable_pdf_ink2">
    if (this.pdfInk2Enabled) {
      return false;
    }
    // </if> enable_pdf_ink2

    return this.pdfAnnotationsEnabled;
  }
  // </if> enable_ink

  // <if expr="enable_pdf_ink2">
  protected showInk2Buttons_(): boolean {
    return this.pdfInk2Enabled && this.pdfAnnotationsEnabled;
  }
  // </if>

  // <if expr="enable_ink or enable_pdf_ink2">
  protected showAnnotationsBar_(): boolean {
    return this.pdfAnnotationsEnabled && !this.loading_ && this.annotationMode;
  }
  // </if>

  protected onPrintClick_() {
    this.dispatchEvent(new CustomEvent('print'));
  }

  protected onRotateClick_() {
    this.dispatchEvent(new CustomEvent('rotate-left'));
  }

  protected toggleDisplayAnnotations_() {
    record(UserAction.TOGGLE_DISPLAY_ANNOTATIONS);
    this.displayAnnotations_ = !this.displayAnnotations_;
    this.dispatchEvent(new CustomEvent(
        'display-annotations-changed', {detail: this.displayAnnotations_}));
    this.$.menu.close();

    // <if expr="enable_ink">
    if (!this.displayAnnotations_ && this.annotationMode) {
      this.toggleAnnotation();
    }
    // </if>
  }

  protected onPresentClick_() {
    record(UserAction.PRESENT);
    this.$.menu.close();
    this.dispatchEvent(new CustomEvent('present-click'));
  }

  protected onPropertiesClick_() {
    record(UserAction.PROPERTIES);
    this.$.menu.close();
    this.dispatchEvent(new CustomEvent('properties-click'));
  }

  protected getAriaChecked_(checked: boolean): string {
    return checked ? 'true' : 'false';
  }

  protected getAriaExpanded_(): string {
    return this.sidenavCollapsed ? 'false' : 'true';
  }

  protected toggleTwoPageViewClick_() {
    const newTwoUpViewEnabled = !this.twoUpViewEnabled;
    this.dispatchEvent(
        new CustomEvent('two-up-view-changed', {detail: newTwoUpViewEnabled}));
    this.$.menu.close();
  }

  protected onZoomInClick_() {
    this.dispatchEvent(new CustomEvent('zoom-in'));
  }

  protected onZoomOutClick_() {
    this.dispatchEvent(new CustomEvent('zoom-out'));
  }

  forceFit(fittingType: FittingType) {
    // The fitting type is the new state. We want to set the button fitting type
    // to the opposite value.
    this.fittingType_ = fittingType === FittingType.FIT_TO_WIDTH ?
        FittingType.FIT_TO_PAGE :
        FittingType.FIT_TO_WIDTH;
  }

  fitToggle() {
    const newState = this.fittingType_ === FittingType.FIT_TO_PAGE ?
        FittingType.FIT_TO_WIDTH :
        FittingType.FIT_TO_PAGE;
    this.dispatchEvent(
        new CustomEvent('fit-to-changed', {detail: this.fittingType_}));
    this.fittingType_ = newState;
  }

  protected onFitToButtonClick_() {
    this.fitToggle();
  }

  private getZoomInput_(): HTMLInputElement {
    return this.shadowRoot!.querySelector('#zoom-controls input')!;
  }

  protected onZoomChange_() {
    const input = this.getZoomInput_();
    let value = Number.parseInt(input.value, 10);
    value = Math.max(Math.min(value, this.zoomBounds.max), this.zoomBounds.min);
    if (this.sendZoomChanged_(value)) {
      return;
    }

    const zoomString = `${this.viewportZoomPercent_}%`;
    input.value = zoomString;
  }

  /**
   * @param value The new zoom value
   * @return Whether the zoom-changed event was sent.
   */
  private sendZoomChanged_(value: number): boolean {
    if (Number.isNaN(value)) {
      return false;
    }

    // The viewport can have non-integer zoom values.
    if (Math.abs(this.viewportZoom * 100 - value) < 0.5) {
      return false;
    }

    this.dispatchEvent(new CustomEvent('zoom-changed', {detail: value}));
    return true;
  }

  protected onZoomInputPointerup_(e: Event) {
    (e.target as HTMLInputElement).select();
  }

  protected onMoreClick_() {
    const anchor = this.shadowRoot!.querySelector<HTMLElement>('#more')!;
    this.$.menu.showAt(anchor, {
      anchorAlignmentX: AnchorAlignment.CENTER,
      anchorAlignmentY: AnchorAlignment.AFTER_END,
      noOffset: true,
    });
  }

  protected onMoreOpenChanged_(e: CustomEvent<{value: boolean}>) {
    this.moreMenuOpen_ = e.detail.value;
  }

  protected isAtMinimumZoom_(): boolean {
    return this.zoomBounds !== undefined &&
        this.viewportZoomPercent_ === this.zoomBounds.min;
  }

  protected isAtMaximumZoom_(): boolean {
    return this.zoomBounds !== undefined &&
        this.viewportZoomPercent_ === this.zoomBounds.max;
  }

  // <if expr="enable_ink">
  protected onDialogClose_() {
    const confirmed =
        this.shadowRoot!.querySelector(
                            'viewer-annotations-mode-dialog')!.wasConfirmed();
    this.showAnnotationsModeDialog_ = false;
    if (confirmed) {
      this.dispatchEvent(new CustomEvent('annotation-mode-dialog-confirmed'));
      this.toggleAnnotation();
    }
  }
  // </if>

  // <if expr="enable_ink or enable_pdf_ink2">
  protected onAnnotationClick_() {
    // <if expr="enable_pdf_ink2">
    if (this.pdfInk2Enabled) {
      this.toggleAnnotation();
      return;
    }
    // </if> enable_pdf_ink2

    // <if expr="enable_ink">
    if (!this.rotated && !this.twoUpViewEnabled) {
      this.toggleAnnotation();
      return;
    }

    this.showAnnotationsModeDialog_ = true;
    // </if> enable_ink
  }

  toggleAnnotation() {
    const newAnnotationMode = !this.annotationMode;
    this.dispatchEvent(new CustomEvent(
        'annotation-mode-toggled', {detail: newAnnotationMode}));

    // <if expr="enable_pdf_ink2">
    // Don't toggle display annotations for Ink2.
    if (this.pdfInk2Enabled) {
      return;
    }
    // </if> enable_pdf_ink2

    if (newAnnotationMode && !this.displayAnnotations_) {
      this.toggleDisplayAnnotations_();
    }
  }
  // </if> enable_ink or enable_pdf_ink2

  // <if expr="enable_pdf_ink2">
  /**
   * Handles whether the undo and redo buttons should be enabled or disabled
   * when a new ink stroke is added to the page.
   */
  private handleFinishInkStroke_() {
    this.currentStroke++;
    this.mostRecentStroke = this.currentStroke;

    // When a new stroke is added, it can always be undone. Since it's the most
    // recent stroke, the redo action cannot be performed.
    this.canUndoAnnotation_ = true;
    this.canRedoAnnotation_ = false;
  }

  /**
   * Undo an annotation stroke, if possible.
   */
  undo() {
    if (!this.canUndoAnnotation_) {
      return;
    }

    assert(this.currentStroke > 0);
    assert(this.formFieldFocus !== FormFieldFocusType.TEXT);

    this.pluginController_.undo();
    this.currentStroke--;

    this.updateCanUndoRedo_();
    this.dispatchEvent(new CustomEvent(
        'strokes-updated',
        {detail: this.currentStroke, bubbles: true, composed: true}));
  }

  /**
   * Redo an annotation stroke, if possible.
   */
  redo() {
    if (!this.canRedoAnnotation_) {
      return;
    }

    assert(this.currentStroke < this.mostRecentStroke);
    assert(this.formFieldFocus !== FormFieldFocusType.TEXT);

    this.pluginController_.redo();
    this.currentStroke++;
    this.updateCanUndoRedo_();
    this.dispatchEvent(new CustomEvent(
        'strokes-updated',
        {detail: this.currentStroke, bubbles: true, composed: true}));
  }

  /**
   * Update whether the undo and redo buttons should be enabled or disabled.
   * Both buttons should be disabled when a text form field has focus. Undo and
   * redo should be disabled when there are no possible undo and redo actions
   * respectively.
   */
  private updateCanUndoRedo_() {
    const isTextFormFieldFocused =
        this.formFieldFocus === FormFieldFocusType.TEXT;
    this.canUndoAnnotation_ = !isTextFormFieldFocused && this.currentStroke > 0;
    this.canRedoAnnotation_ =
        !isTextFormFieldFocused && this.currentStroke < this.mostRecentStroke;
  }

  /**
   * Reset the stroke counts for testing. This allows tests to re-use the same
   * toolbar.
   */
  resetStrokesForTesting() {
    this.currentStroke = 0;
    this.mostRecentStroke = 0;
    this.updateCanUndoRedo_();
    this.dispatchEvent(new CustomEvent(
        'strokes-updated', {detail: 0, bubbles: true, composed: true}));
  }
  // </if>

  protected isFormFieldFocused_(): boolean {
    return this.formFieldFocus !== FormFieldFocusType.NONE;
  }

  /**
   * Updates the toolbar's presentation mode available flag depending on current
   * conditions.
   */
  protected presentationModeAvailable_(): boolean {
    // <if expr="enable_ink">
    return !this.annotationMode && !this.embeddedViewer;
    // </if>
    // <if expr="not enable_ink">
    return !this.embeddedViewer;
    // </if>
  }
}

declare global {
  interface HTMLElementTagNameMap {
    'viewer-toolbar': ViewerToolbarElement;
  }
}

customElements.define(ViewerToolbarElement.is, ViewerToolbarElement);