chromium/chrome/browser/resources/pdf/elements/viewer_ink_host.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 {PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';

import type {AnnotationTool} from '../annotation_tool.js';
import type {InkApi} from '../ink/ink_api.js';
import {record, UserAction} from '../metrics.js';
import type {Viewport} from '../viewport.js';
import {PAGE_SHADOW} from '../viewport.js';

import {getTemplate} from './viewer_ink_host.html.js';

enum State {
  LOADING = 'loading',
  ACTIVE = 'active',
  IDLE = 'idle'
}

const BACKGROUND_COLOR: string = '#525659';

declare global {
  interface HTMLElementEventMap {
    'undo-state-changed': CustomEvent<drawings.UndoState>;
  }
}

/**
 * Hosts the Ink component which is responsible for both PDF rendering and
 * annotation when in annotation mode.
 */
export class ViewerInkHostElement extends PolymerElement {
  static get is() {
    return 'viewer-ink-host';
  }

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

  private tool_: AnnotationTool|null = null;
  viewport: Viewport|null = null;
  private activePointer_: PointerEvent|null = null;

  /**
   * Used to conditionally allow a 'touchstart' event to cause
   * a gesture. If we receive a 'touchstart' with this timestamp
   * we will skip calling `preventDefault()`.
   */
  private allowTouchStartTimeStamp_: number|null = null;
  private buffer_: ArrayBuffer|null = null;
  private fileName_: string|null = null;
  private ink_: InkApi|null = null;
  private lastZoom_: number|null = null;
  private penMode_: boolean = false;

  /**
   * Whether we should suppress pointer events due to a gesture;
   * eg. pinch-zoom.
   */
  private pointerGesture_: boolean = false;
  private state_: State = State.IDLE;

  override ready() {
    super.ready();
    this.addEventListener('pointerdown', this.onPointerDown_.bind(this));
    this.addEventListener('pointerup', this.onPointerUpOrCancel_.bind(this));
    this.addEventListener('pointermove', this.onPointerMove_.bind(this));
    this.addEventListener(
        'pointercancel', this.onPointerUpOrCancel_.bind(this));
    this.addEventListener('pointerleave', this.onPointerLeave_.bind(this));
    this.addEventListener('touchstart', this.onTouchStart.bind(this));
  }

  /** Turns off pen mode if it is active. */
  resetPenMode() {
    this.penMode_ = false;
  }

  setAnnotationTool(tool: AnnotationTool) {
    this.tool_ = tool;
    if (this.state_ === State.ACTIVE) {
      this.ink_!.setAnnotationTool(tool);
    }
  }

  private isActivePointer_(e: PointerEvent) {
    return this.activePointer_ && this.activePointer_.pointerId === e.pointerId;
  }

  /** Dispatches a pointer event to Ink */
  private dispatchPointerEvent_(e: PointerEvent) {
    this.ink_!.dispatchPointerEvent(e);
  }

  onTouchStart(e: TouchEvent) {
    if (e.timeStamp !== this.allowTouchStartTimeStamp_) {
      e.preventDefault();
    }
    this.allowTouchStartTimeStamp_ = null;
  }

  private onPointerDown_(e: PointerEvent) {
    if (e.pointerType === 'mouse' && e.buttons !== 1 || this.pointerGesture_) {
      return;
    }

    if (e.pointerType === 'pen') {
      this.penMode_ = true;
    }

    if (this.activePointer_) {
      if (this.activePointer_.pointerType === 'touch' &&
          e.pointerType === 'touch') {
        // A multi-touch gesture has started with the active pointer. Cancel
        // the active pointer and suppress further events until it is released.
        this.pointerGesture_ = true;
        this.ink_!.dispatchPointerEvent(new PointerEvent('pointercancel', {
          pointerId: this.activePointer_.pointerId,
          pointerType: this.activePointer_.pointerType,
        }));
      }
      return;
    }

    if (!this.viewport!.isPointInsidePage({x: e.clientX, y: e.clientY}) &&
        (e.pointerType === 'touch' || e.pointerType === 'pen')) {
      // If a touch or pen is outside the page, we allow pan gestures to start.
      this.allowTouchStartTimeStamp_ = e.timeStamp;
      return;
    }

    if (e.pointerType === 'touch' && this.penMode_) {
      // If we see a touch after having seen a pen, we allow touches to start
      // pan gestures anywhere and suppress all touches from drawing.
      this.allowTouchStartTimeStamp_ = e.timeStamp;
      return;
    }

    this.activePointer_ = e;
    this.dispatchPointerEvent_(e);
  }

  private onPointerLeave_(e: PointerEvent) {
    if (e.pointerType !== 'mouse' || !this.isActivePointer_(e)) {
      return;
    }
    this.onPointerUpOrCancel_(new PointerEvent('pointerup', e));
  }

  private onPointerUpOrCancel_(e: PointerEvent) {
    if (!this.isActivePointer_(e)) {
      return;
    }
    this.activePointer_ = null;
    if (!this.pointerGesture_) {
      this.dispatchPointerEvent_(e);
      // If the stroke was not cancelled (type === pointercanel),
      // notify about mutation and record metrics.
      if (e.type === 'pointerup') {
        this.dispatchEvent(new CustomEvent('stroke-added'));
        if (e.pointerType === 'mouse') {
          record(UserAction.ANNOTATE_STROKE_DEVICE_MOUSE);
        } else if (e.pointerType === 'pen') {
          record(UserAction.ANNOTATE_STROKE_DEVICE_PEN);
        } else if (e.pointerType === 'touch') {
          record(UserAction.ANNOTATE_STROKE_DEVICE_TOUCH);
        }
        if (this.tool_!.tool === 'eraser') {
          record(UserAction.ANNOTATE_STROKE_TOOL_ERASER);
        } else if (this.tool_!.tool === 'pen') {
          record(UserAction.ANNOTATE_STROKE_TOOL_PEN);
        } else if (this.tool_!.tool === 'highlighter') {
          record(UserAction.ANNOTATE_STROKE_TOOL_HIGHLIGHTER);
        }
      }
    }
    this.pointerGesture_ = false;
  }

  private onPointerMove_(e: PointerEvent) {
    if (!this.isActivePointer_(e) || this.pointerGesture_) {
      return;
    }

    let events = e.getCoalescedEvents();
    if (events.length === 0) {
      events = [e];
    }
    for (const event of events) {
      this.dispatchPointerEvent_(event);
    }
  }

  /**
   * Begins annotation mode with the document represented by `data`.
   * When the return value resolves the Ink component will be ready
   * to render immediately.
   *
   * @param fileName The name of the PDF file.
   * @param data The contents of the PDF document.
   */
  async load(fileName: string, data: ArrayBuffer): Promise<void> {
    this.fileName_ = fileName;
    this.state_ = State.LOADING;

    const frame = this.shadowRoot!.querySelector<HTMLIFrameElement>('#frame')!;
    frame.src = 'ink/index.html';
    await new Promise(resolve => frame.onload = resolve);
    this.ink_ = await frame.contentWindow!.initInk();
    this.ink_!.addUndoStateListener(
        e => this.dispatchEvent(
            new CustomEvent('undo-state-changed', {detail: e})));
    await this.ink_!.setPdf(data);
    this.state_ = State.ACTIVE;
    this.viewportChanged();
    // Wait for the next task to avoid a race where Ink drops the background
    // color.
    await new Promise(resolve => setTimeout(resolve));
    this.ink_!.setOutOfBoundsColor(BACKGROUND_COLOR);
    const spacing = PAGE_SHADOW.top + PAGE_SHADOW.bottom;
    this.ink_!.setPageSpacing(spacing);
    this.style.visibility = 'visible';
  }

  viewportChanged() {
    if (this.state_ !== State.ACTIVE) {
      return;
    }
    const viewport = this.viewport!;
    const pos = viewport.position;
    const size = viewport.size;
    const zoom = viewport.getZoom();
    const documentWidth = viewport.getDocumentDimensions().width * zoom;
    // Adjust for page shadows.
    const y = pos.y - PAGE_SHADOW.top * zoom;
    let x = pos.x - PAGE_SHADOW.left * zoom;
    // Center the document if the width is smaller than the viewport.
    if (documentWidth < size.width) {
      x += (documentWidth - size.width) / 2;
    }
    // Invert the Y-axis and convert Pixels to Points.
    const pixelsToPoints = 72 / 96;
    const scale = pixelsToPoints / zoom;
    const camera = {
      top: (-y) * scale,
      left: (x) * scale,
      right: (x + size.width) * scale,
      bottom: (-y - size.height) * scale,
    };
    // Ink doesn't scale the shadow, so we must update it each time the zoom
    // changes.
    if (this.lastZoom_ !== zoom) {
      this.lastZoom_ = zoom;
      this.updateShadow_(zoom);
    }
    this.ink_!.setCamera(camera);
  }

  /** Undo the last edit action. */
  undo() {
    this.ink_!.undo();
  }

  /** Redo the last undone edit action. */
  redo() {
    this.ink_!.redo();
  }

  /**
   * @return The serialized PDF document including any annotations that were
   *     made.
   */
  async saveDocument(): Promise<{fileName: string, dataToSave: ArrayBuffer}> {
    if (this.state_ === State.ACTIVE) {
      const pdf = await this.ink_!.getPdfDestructive();
      this.buffer_ = await pdf.buffer;
      this.state_ = State.IDLE;
    }
    return {
      fileName: this.fileName_!,
      dataToSave: this.buffer_!,
    };
  }

  private updateShadow_(zoom: number) {
    const boxWidth = (50 * zoom) | 0;
    const shadowWidth = (8 * zoom) | 0;
    const width = boxWidth + shadowWidth * 2 + 2;
    const boxOffset = (width - boxWidth) / 2;

    const canvas = document.createElement('canvas');
    canvas.width = width;
    canvas.height = width;

    const ctx = canvas.getContext('2d')!;
    ctx.fillStyle = 'black';
    ctx.shadowColor = 'black';
    ctx.shadowBlur = shadowWidth;
    ctx.fillRect(boxOffset, boxOffset, boxWidth, boxWidth);
    ctx.shadowBlur = 0;

    // 9-piece markers
    for (let i = 0; i < 4; i++) {
      ctx.fillStyle = 'white';
      ctx.fillRect(0, 0, width, 1);
      ctx.fillStyle = 'black';
      ctx.fillRect(shadowWidth + 1, 0, boxWidth, 1);
      ctx.rotate(0.5 * Math.PI);
      ctx.translate(0, -width);
    }

    this.ink_!.setBorderImage(canvas.toDataURL());
  }

  getInkApiForTesting(): InkApi {
    return this.ink_!;
  }

  getPenModeForTesting(): boolean {
    return this.penMode_;
  }
}

declare global {
  interface Window {
    initInk(): Promise<InkApi>;
  }

  interface HTMLElementTagNameMap {
    'viewer-ink-host': ViewerInkHostElement;
  }
}

customElements.define(ViewerInkHostElement.is, ViewerInkHostElement);