chromium/chrome/browser/resources/pdf/pdf_viewer_print.ts

// Copyright 2013 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

import './elements/viewer_error_dialog.js';
import './elements/viewer_page_indicator.js';
import './elements/viewer_zoom_toolbar.js';

import {assert, assertNotReached} from 'chrome://resources/js/assert.js';
import {isRTL} from 'chrome://resources/js/util.js';

import type {BrowserApi} from './browser_api.js';
import type {ExtendedKeyEvent} from './constants.js';
import {FittingType} from './constants.js';
import type {MessageData, PrintPreviewParams} from './controller.js';
import {PluginController} from './controller.js';
import type {ViewerPageIndicatorElement} from './elements/viewer_page_indicator.js';
import type {ViewerZoomToolbarElement} from './elements/viewer_zoom_toolbar.js';
import {deserializeKeyEvent, LoadState, serializeKeyEvent} from './pdf_scripting_api.js';
import type {KeyEventData} from './pdf_viewer_base.js';
import {PdfViewerBaseElement} from './pdf_viewer_base.js';
import {getCss} from './pdf_viewer_print.css.js';
import {getHtml} from './pdf_viewer_print.html.js';
import type {DocumentDimensionsMessageData} from './pdf_viewer_utils.js';
import {hasCtrlModifierOnly, shouldIgnoreKeyEvents} from './pdf_viewer_utils.js';
import {ToolbarManager} from './toolbar_manager.js';

let pluginLoaderPolicy: TrustedTypePolicy|null = null;

export interface PdfViewerPrintElement {
  $: {
    content: HTMLElement,
    pageIndicator: ViewerPageIndicatorElement,
    sizer: HTMLElement,
    zoomToolbar: ViewerZoomToolbarElement,
  };
}

export class PdfViewerPrintElement extends PdfViewerBaseElement {
  static get is() {
    return 'pdf-viewer-print';
  }

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

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

  private isPrintPreviewLoadingFinished_: boolean = false;
  private inPrintPreviewMode_: boolean = false;
  private dark_: boolean = false;
  private pluginController_: PluginController|undefined = undefined;
  private toolbarManager_: ToolbarManager|null = null;

  override isNewUiEnabled() {
    return false;
  }

  getBackgroundColor() {
    return PRINT_PREVIEW_BACKGROUND_COLOR;
  }

  private getStreamUrl_(): TrustedScriptURL {
    if (pluginLoaderPolicy === null) {
      pluginLoaderPolicy =
          window.trustedTypes!.createPolicy('print-preview-plugin-loader', {
            createScriptURL: (_ignore: string) => {
              const url = new URL(this.browserApi!.getStreamInfo().streamUrl);

              // Checks based on data_request_filter.cc.
              assert(url.origin === 'chrome-untrusted://print');
              if (url.pathname.endsWith('test.pdf')) {
                return url.toString();
              }

              const paths = url.pathname.split('/');
              assert(paths.length === 4);
              assert(paths[3] === 'print.pdf');
              // Valid Print Preview UI ID
              assert(!Number.isNaN(parseInt(paths[1]!)));
              // Valid page index (can be negative for PDFs).
              assert(!Number.isNaN(parseInt(paths[2]!)));
              return url.toString();
            },
            createHTML: () => assertNotReached(),
            createScript: () => assertNotReached(),
          });
    }
    return pluginLoaderPolicy.createScriptURL('');
  }

  setPluginSrc(plugin: HTMLEmbedElement) {
    plugin.src = this.getStreamUrl_() as unknown as string;
  }

  init(browserApi: BrowserApi) {
    this.initInternal(
        browserApi, document.documentElement, this.$.sizer, this.$.content);

    this.pluginController_ = PluginController.getInstance();

    this.$.pageIndicator.setViewport(this.viewport);
    this.toolbarManager_ = new ToolbarManager(window, this.$.zoomToolbar);
  }

  handleKeyEvent(e: ExtendedKeyEvent) {
    if (shouldIgnoreKeyEvents() || e.defaultPrevented) {
      return;
    }

    this.toolbarManager_!.hideToolbarAfterTimeout();
    // Let the viewport handle directional key events.
    if (this.viewport.handleDirectionalKeyEvent(e, false)) {
      return;
    }

    switch (e.key) {
      case 'Tab':
        this.toolbarManager_!.showToolbarForKeyboardNavigation();
        return;
      case 'Escape':
        break;  // Ensure escape falls through to the print-preview handler.
      case 'a':
        if (hasCtrlModifierOnly(e)) {
          this.pluginController_!.selectAll();
          // Since we do selection ourselves.
          e.preventDefault();
        }
        return;
      case '\\':
        if (e.ctrlKey) {
          this.$.zoomToolbar.fitToggleFromHotKey();
        }
        return;
    }

    // Give print preview a chance to handle the key event.
    if (!e.fromScriptingAPI) {
      this.sendScriptingMessage(
          {type: 'sendKeyEvent', keyEvent: serializeKeyEvent(e)});
    } else {
      // Show toolbar as a fallback.
      if (!(e.shiftKey || e.ctrlKey || e.altKey)) {
        this.$.zoomToolbar.show();
      }
    }
  }

  private setBackgroundColorForPrintPreview_() {
    this.pluginController_!.setBackgroundColor(
        this.dark_ ? PRINT_PREVIEW_DARK_BACKGROUND_COLOR :
                     PRINT_PREVIEW_BACKGROUND_COLOR);
  }

  updateUiForViewportChange() {
    // Offset the toolbar position so that it doesn't move if scrollbars appear.
    const hasScrollbars = this.viewport.documentHasScrollbars();
    const scrollbarWidth = this.viewport.scrollbarWidth;
    const verticalScrollbarWidth = hasScrollbars.vertical ? scrollbarWidth : 0;
    const horizontalScrollbarWidth =
        hasScrollbars.horizontal ? scrollbarWidth : 0;

    // Shift the zoom toolbar to the left by half a scrollbar width. This
    // gives a compromise: if there is no scrollbar visible then the toolbar
    // will be half a scrollbar width further left than the spec but if there
    // is a scrollbar visible it will be half a scrollbar width further right
    // than the spec. In LTR layout, the zoom toolbar is on the left
    // left side, but the scrollbar is still on the right, so this is not
    // necessary.
    const zoomToolbar = this.$.zoomToolbar;
    if (isRTL()) {
      zoomToolbar.style.right =
          -verticalScrollbarWidth + (scrollbarWidth / 2) + 'px';
    }
    // Having a horizontal scrollbar is much rarer so we don't offset the
    // toolbar from the bottom any more than what the spec says. This means
    // that when there is a scrollbar visible, it will be a full scrollbar
    // width closer to the bottom of the screen than usual, but this is ok.
    zoomToolbar.style.bottom = -horizontalScrollbarWidth + 'px';

    // Update the page indicator.
    const visiblePage = this.viewport.getMostVisiblePage();
    const pageIndicator = this.$.pageIndicator;
    const lastIndex = pageIndicator.index;
    pageIndicator.index = visiblePage;
    if (this.documentDimensions!.pageDimensions.length > 1 &&
        hasScrollbars.vertical && lastIndex !== undefined) {
      pageIndicator.style.visibility = 'visible';
    } else {
      pageIndicator.style.visibility = 'hidden';
    }

    this.pluginController_!.viewportChanged();
  }

  override handleScriptingMessage(message: MessageEvent) {
    if (super.handleScriptingMessage(message)) {
      return true;
    }

    if (this.handlePrintPreviewScriptingMessage_(message)) {
      return true;
    }

    if (this.delayScriptingMessage(message)) {
      return true;
    }

    switch (message.data.type.toString()) {
      case 'getSelectedText':
        this.pluginController_!.getSelectedText().then(
            this.sendScriptingMessage.bind(this));
        break;
      case 'selectAll':
        this.pluginController_!.selectAll();
        break;
      default:
        return false;
    }
    return true;
  }

  /**
   * Handle scripting messages specific to print preview.
   * @param message the message to handle.
   * @return true if the message was handled, false otherwise.
   */
  private handlePrintPreviewScriptingMessage_(message: MessageEvent): boolean {
    const messageData = message.data;
    switch (messageData.type.toString()) {
      case 'loadPreviewPage':
        const loadData =
            messageData as MessageData & {url: string, index: number};
        this.pluginController_!.loadPreviewPage(loadData.url, loadData.index);
        return true;
      case 'resetPrintPreviewMode':
        const printPreviewData =
            messageData as (MessageData & PrintPreviewParams);
        this.setLoadState(LoadState.LOADING);
        if (!this.inPrintPreviewMode_) {
          this.inPrintPreviewMode_ = true;
          this.isUserInitiatedEvent = false;
          this.forceFit(FittingType.FIT_TO_PAGE);
          this.viewport.setFittingType(FittingType.FIT_TO_PAGE);
          this.isUserInitiatedEvent = true;
        }

        // Stash the scroll location so that it can be restored when the new
        // document is loaded.
        this.lastViewportPosition = this.viewport.position;
        this.$.pageIndicator.pageLabels = printPreviewData.pageNumbers;

        this.pluginController_!.resetPrintPreviewMode(printPreviewData);
        return true;
      case 'sendKeyEvent':
        const keyEvent =
            deserializeKeyEvent((message.data as KeyEventData).keyEvent);
        const extendedKeyEvent = keyEvent as ExtendedKeyEvent;
        extendedKeyEvent.fromScriptingAPI = true;
        this.handleKeyEvent(extendedKeyEvent);
        return true;
      case 'hideToolbar':
        this.toolbarManager_!.resetKeyboardNavigationAndHideToolbar();
        return true;
      case 'darkModeChanged':
        this.dark_ =
            (message.data as (MessageData & {darkMode: boolean})).darkMode;
        this.setBackgroundColorForPrintPreview_();
        return true;
      case 'scrollPosition':
        const position = this.viewport.position;
        const positionData =
            message.data as (MessageData & {x: number, y: number});
        position.y += positionData.y;
        position.x += positionData.x;
        this.viewport.setPosition(position);
        return true;
    }

    return false;
  }

  override setLoadState(loadState: LoadState) {
    super.setLoadState(loadState);
    if (loadState === LoadState.FAILED) {
      this.isPrintPreviewLoadingFinished_ = true;
    }
  }

  override handlePluginMessage(e: CustomEvent) {
    const data = e.detail;
    switch (data.type.toString()) {
      case 'documentDimensions':
        this.setDocumentDimensions(data as DocumentDimensionsMessageData);
        return;
      case 'documentFocusChanged':
        // TODO(crbug.com/40125884): Draw a focus rect around plugin.
        return;
      case 'loadProgress':
        this.updateProgress((data as {progress: number}).progress);
        return;
      case 'printPreviewLoaded':
        this.handlePrintPreviewLoaded_();
        return;
      case 'sendKeyEvent':
        const keyEvent = deserializeKeyEvent((data as KeyEventData).keyEvent) as
            ExtendedKeyEvent;
        keyEvent.fromPlugin = true;
        this.handleKeyEvent(keyEvent);
        return;
      case 'touchSelectionOccurred':
        this.sendScriptingMessage({
          type: 'touchSelectionOccurred',
        });
        return;
      case 'beep':
      case 'formFocusChange':
      case 'getPassword':
      case 'metadata':
      case 'navigate':
      case 'setIsEditing':
        // These messages are not relevant in Print Preview.
        return;
    }
    assertNotReached('Unknown message type received: ' + data.type);
  }

  /**
   * Handles a notification that print preview has loaded from the
   * current controller.
   */
  private handlePrintPreviewLoaded_() {
    this.isPrintPreviewLoadingFinished_ = true;
    this.sendDocumentLoadedMessage();
  }

  override readyToSendLoadMessage() {
    return this.isPrintPreviewLoadingFinished_;
  }

  forceFit(view: FittingType) {
    this.$.zoomToolbar.forceFit(view);
  }

  protected afterZoom(_viewportZoom: number) {}

  override handleStrings(strings: {[key: string]: string}) {
    super.handleStrings(strings);
    if (!strings) {
      return;
    }
    this.setBackgroundColorForPrintPreview_();
  }

  override updateProgress(progress: number) {
    super.updateProgress(progress);
    if (progress === 100) {
      this.toolbarManager_!.hideToolbarAfterTimeout();
    }
  }
}

/**
 * The background color used for print preview (--google-grey-300). Keep
 * in sync with `ChromePdfStreamDelegate::MapToOriginalUrl()`.
 */
const PRINT_PREVIEW_BACKGROUND_COLOR: number = 0xffdadce0;

/**
 * The background color used for print preview when dark mode is enabled
 * (--google-grey-700).
 */
const PRINT_PREVIEW_DARK_BACKGROUND_COLOR: number = 0xff5f6368;

declare global {
  interface HTMLElementTagNameMap {
    'pdf-viewer-print': PdfViewerPrintElement;
  }
}

customElements.define(PdfViewerPrintElement.is, PdfViewerPrintElement);