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

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

import type {PinchEventDetail} from './gesture_detector.js';
import {GestureDetector} from './gesture_detector.js';
import type {SwipeDirection} from './swipe_detector.js';
import {SwipeDetector} from './swipe_detector.js';

interface InProcessPdfPluginElement extends HTMLEmbedElement {
  postMessage(message: any): void;
}

const channel = new MessageChannel();

const sizer = document.querySelector<HTMLElement>('#sizer')!;
const plugin = document.querySelector<InProcessPdfPluginElement>('embed')!;

const srcUrl = new URL(plugin.src);
let parentOrigin = srcUrl.origin;
if (parentOrigin === 'chrome-untrusted://print') {
  // Within Print Preview, the source origin differs from the parent origin.
  parentOrigin = 'chrome://print';
}

// Plugin-to-parent message handlers. All messages are passed through, but some
// messages may affect this frame, too.
let isFormFieldFocused = false;
plugin.addEventListener('message', e => {
  const message = (e as MessageEvent).data;
  switch (message.type) {
    case 'formFocusChange':
      // TODO(crbug.com/40810904): Ideally, the plugin would just consume
      // interesting keyboard events first.
      isFormFieldFocused = (message as {focused: boolean}).focused;
      break;
  }

  channel.port1.postMessage(message);
});

// Parent-to-plugin message handlers. Most messages are passed through, but some
// messages (with handlers that `return` immediately) are meant only for this
// frame, not the plugin.
let isPresentationMode = false;
channel.port1.onmessage = e => {
  switch (e.data.type) {
    case 'loadArray':
      if (plugin.src.startsWith('blob:')) {
        URL.revokeObjectURL(plugin.src);
      }
      plugin.src = URL.createObjectURL(new Blob([e.data.dataToLoad]));
      plugin.setAttribute('has-edits', '');
      return;

    case 'setPresentationMode':
      isPresentationMode = e.data.enablePresentationMode;

      gestureDetector.setPresentationMode(isPresentationMode);
      swipeDetector.setPresentationMode(isPresentationMode);
      if (isPresentationMode) {
        document.documentElement.className = 'fullscreen';
      } else {
        document.documentElement.className = '';

        // Ensure that directional keys still work after exiting.
        plugin.focus();
      }
      break;

    case 'syncScrollToRemote':
      // TODO(crbug.com/40218278): Implement smooth scrolling correctly.
      window.scrollTo({
        left: e.data.x,
        top: e.data.y,
      });
      channel.port1.postMessage({
        type: 'ackScrollToRemote',
        x: window.scrollX,
        y: window.scrollY,
      });
      return;

    case 'updateSize':
      sizer.style.width = `${e.data.width}px`;
      sizer.style.height = `${e.data.height}px`;
      return;

    case 'viewport':
      // Snoop on "viewport" message to support real RTL scrolling in Print
      // Preview.
      // TODO(crbug.com/40737077): Support real RTL scrolling in the PDF viewer.
      if (parentOrigin === 'chrome://print' && e.data.layoutOptions) {
        switch (e.data.layoutOptions.direction) {
          case 1:
            document.dir = 'rtl';
            break;
          case 2:
            document.dir = 'ltr';
            break;
          default:
            document.dir = '';
            break;
        }
      }
      break;
  }

  plugin.postMessage(e.data);
};

// Entangle parent-child message channel.
window.parent.postMessage(
    {type: 'connect', token: srcUrl.href}, parentOrigin, [channel.port2]);

// Forward "scroll" events back to the parent frame's `Viewport`.
window.addEventListener('scroll', () => {
  channel.port1.postMessage({
    type: 'syncScrollFromRemote',
    x: window.scrollX,
    y: window.scrollY,
  });
});

/**
 * Relays gesture events to the parent frame.
 * @param e The gesture event.
 */
function relayGesture(e: Event): void {
  const gestureEvent = e as CustomEvent<PinchEventDetail>;
  channel.port1.postMessage({
    type: 'gesture',
    gesture: {
      type: gestureEvent.type,
      detail: gestureEvent.detail,
    },
  });
}

const gestureDetector = new GestureDetector(plugin);
for (const type of ['pinchstart', 'pinchupdate', 'pinchend', 'wheel']) {
  gestureDetector.getEventTarget().addEventListener(type, relayGesture);
}

/**
 * Relays swipe events to the parent frame.
 * @param e The swipe event.
 */
function relaySwipe(e: Event): void {
  const swipeEvent = e as CustomEvent<SwipeDirection>;
  channel.port1.postMessage({
    type: 'swipe',
    direction: swipeEvent.detail,
  });
}

const swipeDetector = new SwipeDetector(plugin);
swipeDetector.getEventTarget().addEventListener('swipe', relaySwipe);

document.addEventListener('keydown', e => {
  // Only forward potential shortcut keys.
  switch (e.key) {
    case ' ':
      // Preventing Space happens in the "keypress" event handler.
      break;
    case 'PageDown':
    case 'PageUp':
      // Prevent PageDown/PageUp when there are no modifier keys.
      if (!hasKeyModifiers(e)) {
        e.preventDefault();
        break;
      }
      return;

    case 'ArrowDown':
    case 'ArrowLeft':
    case 'ArrowRight':
    case 'ArrowUp':
      // Don't prevent arrow navigation in form fields, or if modified.
      if (!isFormFieldFocused && !hasKeyModifiers(e)) {
        e.preventDefault();
        break;
      }
      return;

    case 'Escape':
    case 'Tab':
      // Print Preview is interested in Escape and Tab.
      break;

    case '=':
    case '-':
    case '+':
      // Ignore zoom shortcuts in Presentation mode.
      if (isPresentationMode && hasCtrlModifier(e)) {
        e.preventDefault();
      }
      return;

    case 'a':
      // Take over Ctrl+A (but not other combinations like Ctrl-Shift-A).
      // Note that on macOS, "Ctrl" is Command.
      if (hasCtrlModifierOnly(e)) {
        e.preventDefault();
        break;
      }
      return;

    default:
      // Relay (but don't prevent) other shortcuts.
      if (hasCtrlModifier(e)) {
        break;
      }
      return;
  }

  channel.port1.postMessage({
    type: 'sendKeyEvent',
    keyEvent: {
      keyCode: e.keyCode,
      code: e.code,
      key: e.key,
      shiftKey: e.shiftKey,
      ctrlKey: e.ctrlKey,
      altKey: e.altKey,
      metaKey: e.metaKey,
    },
  });
});

// Suppress extra scroll by preventing the default "keypress" handler for Space.
// TODO(crbug.com/40208546): Ideally would prevent "keydown" instead, but this
// doesn't work when a plugin element has focus.
document.addEventListener('keypress', e => {
  switch (e.key) {
    case ' ':
      // Don't prevent Space in form fields.
      if (!isFormFieldFocused) {
        e.preventDefault();
      }
      break;
  }
});

// TODO(crbug.com/40792950): Load from pdf_viewer_utils.js instead.
function hasCtrlModifier(e: KeyboardEvent): boolean {
  let hasModifier = e.ctrlKey;
  // <if expr="is_macosx">
  hasModifier = e.metaKey;  // AKA Command.
  // </if>
  return hasModifier;
}

// TODO(crbug.com/40792950): Load from pdf_viewer_utils.js instead.
function hasCtrlModifierOnly(e: KeyboardEvent): boolean {
  let metaModifier = e.metaKey;
  // <if expr="is_macosx">
  metaModifier = e.ctrlKey;
  // </if>
  return hasCtrlModifier(e) && !e.shiftKey && !e.altKey && !metaModifier;
}

// TODO(crbug.com/40792950): Load from chrome://resources/js/util.js instead.
function hasKeyModifiers(e: KeyboardEvent): boolean {
  return !!(e.altKey || e.ctrlKey || e.metaKey || e.shiftKey);
}