chromium/ios/web/js_features/context_menu/resources/all_frames_context_menu.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.

/**
 * @fileoverview APIs used by CRWContextMenuController.
 */

import {getSurroundingText} from '//ios/web/js_features/context_menu/resources/surrounding_text.js';
import {gCrWeb} from '//ios/web/public/js_messaging/resources/gcrweb.js';
import {sendWebKitMessage} from '//ios/web/public/js_messaging/resources/utils.js'

// The minimum opacity for an element to be considered as opaque. Elements
// with a higher opacity will prevent selection of images underneath.
const OPACITY_THRESHOLD = 0.9;

// The maximum opacity for an element to be considered as transparent.
// Images with a lower opacity will not be selectable.
const TRANSPARENCY_THRESHOLD = 0.1;

// The maximum depth to search for elements at any point.
const MAX_SEARCH_DEPTH = 20;

/**
 * Response from `findElementAtPoint` describing an image element.
 */
interface FindElementImgResult {
  // The request id passed to this JavaScript from the native application.
  // It is always included, but marked as optional because it is set after
  // creation.
  requestId?: string;
  // Lowercase tag of the element found ('img').
  tagName: string;
  // Referrer policy to use for navigations away from the current page.
  referrerPolicy: string;
  // URL source of the image.
  src: string;
  // URL of a link.
  href?: string;
  // Title of the image.
  title?: string;
  // The alternative text given with the image.
  alt?: string;
}

/**
 * Response from `findElementAtPoint` describing a link element.
 */
interface FindElementLinkResult {
  // The request id passed to this JavaScript from the native application.
  // It is always included, but marked as optional because it is set after
  // creation.
  requestId?: string;
  // Lowercase tag of the element found ('a')
  tagName: string;
  // Referrer policy to use for navigations away from the current page.
  referrerPolicy: string;
  // URL of a link if any.
  href: string;
  // The inner text of the link.
  innerText?: string;
}

/**
 * Response from `findElementAtPoint` describing any other element.
 */
interface FindElementTextResult {
  // The request id passed to this JavaScript from the native application.
  // It is always included, but marked as optional because it is set after
  // creation.
  requestId?: string;
  // Lowercase tag of the element found ('a')
  tagName: string;
  // The inner text of the element.
  innerText?: string;
  // This is defined for any text element and is an index to the tapped
  // character in `innerText`.
  textOffset?: number;
  // `surroundingText` and `surroundingTextOffset` before and after `innerText`.
  // Note that `innerText` is contained in `surroundingText`.
  surroundingText?: string;
  surroundingTextOffset?: number;
}

/**
 * Response from `findElementAtPoint` for failure to find any element.
 */
interface FindElementFailResult {
  requestId?: string;
}

type FindElementResult = FindElementImgResult|FindElementLinkResult|
    FindElementTextResult|FindElementFailResult;

/**
 * Represents local `x` and `y` coordinates in `window` space.
 */
class WindowCoordinates {
  public readonly viewPortX: number;
  public readonly viewPortY: number;

  constructor(public readonly x: number, public readonly y: number) {
    this.viewPortX = x - window.pageXOffset;
    this.viewPortY = y - window.pageYOffset;
  }
}

class Coordinates {
  constructor(public x: number, public y: number) {}
}

/**
 * Returns an object representing the details of a given link element.
 * @param element - the element whose details will be returned.
 */
function getResponseForLinkElement(element: HTMLAnchorElement|
                                   SVGAElement): FindElementLinkResult {
  return {
    tagName: 'a',
    href: getElementHref(element),
    referrerPolicy: getReferrerPolicy(element),
    innerText: element instanceof HTMLAnchorElement ? element.innerText :
                                                      undefined,
  };
}

/**
 * Returns an object representing the details of a given image element.
 * @param element - the element whose details will be returned.
 * @param src - the source of the image or image-like element.
 */
function getResponseForImageElement(
    element: HTMLElement, src: string): FindElementImgResult {
  const result: FindElementImgResult = {
    tagName: 'img',
    src: src,
    referrerPolicy: getReferrerPolicy(),
  };
  // Copy the title, if any.
  if (element.title) {
    result.title = element.title;
  }
  // Copy the alt text attribute, if any.
  if (element instanceof HTMLImageElement && element.alt) {
    result.alt = element.alt;
  }
  // Check if the image is also a link.
  let parent: Node|null = element.parentNode;
  while (parent) {
    if ((parent instanceof HTMLAnchorElement ||
         parent instanceof SVGAElement) &&
        getElementHref(parent)) {
      const href = getElementHref(parent);
      // This regex identifies strings like void(0),
      // void(0)  ;void(0);, ;;;;
      // which result in a NOP when executed as JavaScript.
      const regex = RegExp('^javascript:(?:(?:void\\(0\\)|;)\\s*)+$');
      if (href.match(regex)) {
        parent = parent.parentNode;
        continue;
      }
      result.href = href;
      result.referrerPolicy = getReferrerPolicy(parent);
      break;
    }
    parent = parent.parentNode;
  }
  return result;
}

/**
 * Returns an object representing the details of a given text element.
 * @param element - the element whose details will be returned.
 * @param x - horizontal center of the selected point in page coordinates.
 * @param y - Vertical center of the selected point in page coordinates.
 */
function getResponseForTextElement(
    element: Element, x: number, y: number): FindElementTextResult {
  const result: FindElementTextResult = {
    tagName: element.tagName,
  };

  // caretRangeFromPoint is custom WebKit method.
  if (document.caretRangeFromPoint) {
    const range = document.caretRangeFromPoint(x, y);
    if (range && range.startContainer) {
      const textNode = range.startContainer;
      if (textNode.nodeType === Node.TEXT_NODE) {
        result.textOffset = range.startOffset;
        result.innerText = textNode.nodeValue ?? '';
        const textAndStartPos = getSurroundingText(range);
        result.surroundingText = textAndStartPos.text;
        result.surroundingTextOffset = textAndStartPos.position;
      }
    }
  }
  return result;
}

/**
 * Finds the url of the image or link under the selected point. Sends a
 * `FindElementResult` with details the found element (or an empty object
 * if nothing is found) back to the application by posting a
 * 'FindElementResult' message.
 * @param requestId - an identifier which will be returned in the result
 *                 dictionary of this request.
 * @param x - horizontal center of the selected point in page
 *                 coordinates.
 * @param y - vertical center of the selected point in page
 *                 coordinates.
 */
function findElementAtPointInPageCoordinates(
    requestId: string, x: number, y: number) {
  const hitCoordinates = spiralCoordinates(x, y);
  const processedElements = new Set<Element>();
  for (let coordinates of hitCoordinates) {
    const coordinateDetails =
        new WindowCoordinates(coordinates.x, coordinates.y);
    const useViewPortCoordinates = elementFromPointIsUsingViewPortCoordinates();
    const coordinateX = useViewPortCoordinates ? coordinateDetails.viewPortX :
                                                 coordinateDetails.x;
    const coordinateY = useViewPortCoordinates ? coordinateDetails.viewPortY :
                                                 coordinateDetails.y;
    const elementWasFound = findElementAtPoint(
        requestId, window.document, processedElements, coordinateX, coordinateY,
        x, y);

    // Exit early if an element was found.
    if (elementWasFound) {
      return;
    }
  }

  // If no element was found, send an empty response.
  sendFindElementAtPointResponse(requestId, /*response=*/ {});
}

/**
 * Finds the topmost valid element at the given point. returns whether or not an
 * element was matched as a target of the touch.
 * @param requestId - an identifier which will be returned in the result
 *                 dictionary of this request.
 * @param root - the Document or ShadowRoot object to search within.
 * @param processedElements - a set to store processed elements in.
 * @param pointX - the X coordinate of the target location.
 * @param pointY - the Y coordinate of the target location.
 * @param centerX - the X coordinate of the center of the target.
 * @param centerY - the Y coordinate of the center of the target.
 */
function findElementAtPoint(
    requestId: string, root: Document|ShadowRoot,
    processedElements: Set<Element>, pointX: number, pointY: number,
    centerX: number, centerY: number): boolean {
  // Make chrome_annotation temporary available for `elementsFromPoint`.
  const annotations = document.querySelectorAll('chrome_annotation');
  for (let annotation of annotations) {
    if (annotation instanceof HTMLElement)
      annotation.style.pointerEvents = 'all';
  }
  const elements = root.elementsFromPoint(pointX, pointY);
  for (let annotation of annotations) {
    if (annotation instanceof HTMLElement)
      annotation.style.pointerEvents = 'none';
  }
  let foundLinkElement: HTMLAnchorElement|SVGAElement|null = null;
  let foundTextElement: Element|null = null;
  let foundImageElement: HTMLElement|null = null;
  for (let elementIndex = 0;
       elementIndex < elements.length && elementIndex < MAX_SEARCH_DEPTH;
       elementIndex++) {
    const element: Element = elements[elementIndex]!;

    // Element.closest will find link elements that are parents of the current
    // element. It also works for SVGAElements, links within svg tags. However,
    // we must still iterate through the elements at this position to find
    // images. This ensures that it will never miss the link, even if this loop
    //  terminates due to hitting an opaque element.
    if (!foundLinkElement) {
      const closestLink = element.closest('a');
      if (closestLink && closestLink.href &&
          getComputedWebkitTouchCallout(closestLink) !== 'none') {
        foundLinkElement = closestLink;
      }
    }

    if (!processedElements.has(element)) {
      processedElements.add(element);
      if (element.shadowRoot) {
        // The element's shadowRoot can be the same as `root` if the point is
        // not on any child element. Break the recursion and return no found
        // element.
        if (element.shadowRoot === root) {
          return false;
        }

        // If an element is found in the shadow DOM, return true, otherwise
        // keep iterating.
        if (findElementAtPoint(
                requestId, element.shadowRoot, processedElements, pointX,
                pointY, centerX, centerY)) {
          return true;
        }
      }

      if (processElementForFindElementAtPoint(
              requestId, centerX, centerY, element as HTMLElement)) {
        return true;
      }
    }

    if (getComputedWebkitTouchCallout(element) !== 'none') {
      // Remember topmost text element, while going up the tree looking for
      // links.
      if (foundTextElement === null && element.tagName !== 'HTML' &&
          element.tagName !== 'IMG' && element.tagName !== 'svg' &&
          isTextElement(element)) {
        foundTextElement = element;
      }

      // Remember topmost opaque image, while going up the tree looking for
      // links. If there's already a topmost text, no need to remember this
      // image.
      if (foundImageElement === null && foundTextElement === null &&
          getImageSource(element) && !isTransparentElement(element)) {
        foundImageElement = element as HTMLElement;
      }
    }

    // Opaque elements should block taps on images that are behind them.
    if (isOpaqueElement(element)) {
      break;
    }
  }

  if (foundImageElement) {
    // imageSrc cannot be null, as it would've stopped `foundImageElement` from
    // being set.
    const imageSrc = getImageSource(foundImageElement);
    sendFindElementAtPointResponse(
        requestId, getResponseForImageElement(foundImageElement, imageSrc!));
    return true;
  }

  if (foundLinkElement) {
    // If no link was processed in the prior loop, but a link was found
    // using element.closest, then return that link. This can occur if the
    // link was a child of an <svg> element. This can also occur if the link
    // element is too deep in the ancestor tree.
    sendFindElementAtPointResponse(
        requestId, getResponseForLinkElement(foundLinkElement));
    return true;
  }

  if (foundTextElement) {
    sendFindElementAtPointResponse(
        requestId,
        getResponseForTextElement(
            foundTextElement, centerX - window.pageXOffset,
            centerY - window.pageYOffset));
    return true;
  }

  return false;
}

/**
 * Processes the element for a find element at point response and return true
 * if `element` was matched as the target of the touch. Only links and input
 * elements will stop the process right away. Frames will make it continue
 * inside the frame.
 * @param requestId - an identifier which will be returned in the result
 *                 dictionary of this request.
 * @param centerX - the X coordinate of the center of the target.
 * @param centerY - the Y coordinate of the center of the target.
 * @param element - element in the page.
 */
function processElementForFindElementAtPoint(
    requestId: string, centerX: number, centerY: number,
    element: HTMLElement|null): boolean {
  if (!element || !element.tagName) {
    return false;
  }

  const tagName = element.tagName.toLowerCase();

  // if element is a frame, tell it to respond to this element request
  if (tagName === 'iframe' || tagName === 'frame') {
    const payload = {
      type: 'org.chromium.contextMenuMessage',
      requestId: requestId,
      x: centerX - element.offsetLeft,
      y: centerY - element.offsetTop,
    };
    // The message will not be sent if `targetOrigin` is null or "about:blank",
    // so use * which allows the message to be delievered to the contentWindow
    // regardless of the origin.
    if (element instanceof HTMLIFrameElement) {
      let targetOrigin = '*';
      const iframeSrc = element.src;
      if (iframeSrc && !iframeSrc.startsWith('about:')) {
        targetOrigin = iframeSrc;
      }
      if (element.contentWindow) {
        element.contentWindow.postMessage(payload, targetOrigin);
      }
    }
    return true;
  }

  if (tagName === 'input' || tagName === 'textarea' || tagName === 'select' ||
      tagName === 'option') {
    // If the element is a known input element, stop the spiral search and
    // return empty results.
    sendFindElementAtPointResponse(requestId, /*response=*/ {});
    return true;
  }

  if (getComputedWebkitTouchCallout(element) !== 'none') {
    if (tagName === 'a' &&
        (element instanceof HTMLAnchorElement ||
         element instanceof SVGAElement) &&
        element.href) {
      sendFindElementAtPointResponse(
          requestId, getResponseForLinkElement(element));
      return true;
    }
  }

  return false;
}

/**
 * Returns true if given node has at least one non empty child text node.
 */
function isTextElement(node: Node) {
  if (!node.hasChildNodes())
    return false;
  for (let subnode of node.childNodes) {
    if (subnode.nodeType === Node.TEXT_NODE && subnode.textContent !== '')
      return true;
  }
  return false;
}

/**
 * Inserts |requestId| into |response| and sends the result as the payload of a
 * 'FindElementResult' message back to the native application.
 * @param requestId - an identifier which will be returned in the result
 *                 dictionary of this request.
 * @param response - the 'FindElementResult' message payload.
 */
function sendFindElementAtPointResponse(
    requestId: string, response: FindElementResult): void {
  response.requestId = requestId;
  sendWebKitMessage('FindElementResultHandler', response);
}

/**
 * Returns whether or not view port coordinates should be used for the given
 * window (if the window has been scrolled down or to the right).
 */
function elementFromPointIsUsingViewPortCoordinates(): boolean {
  if (window.pageYOffset > 0) {  // Page scrolled down.
    return (
        window.document.elementFromPoint(
            0, window.pageYOffset + window.innerHeight - 1) === null);
  }
  if (window.pageXOffset > 0) {  // Page scrolled to the right.
    return (
        window.document.elementFromPoint(
            window.pageXOffset + window.innerWidth - 1, 0) === null);
  }
  return false;  // No scrolling, don't care.
}

/**
 * Returns spiral coordinates around `x` and `y`.
 */
function spiralCoordinates(x: number, y: number): Coordinates[] {
  const maxAngle = Math.PI * 2.0 * 2.0;
  const pointCount = 10;
  const angleStep = maxAngle / pointCount;
  const touchMargin = 15;
  const speed = touchMargin / maxAngle;

  const coordinates: Coordinates[] = [];
  for (let index = 0; index < pointCount; index++) {
    const angle = angleStep * index;
    const radius = angle * speed;

    coordinates.push(new Coordinates(
        x + Math.round(Math.cos(angle) * radius),
        y + Math.round(Math.sin(angle) * radius)));
  }

  return coordinates;
}

/**
 * Returns webkitTouchCallout styles or 'none'.
 */
function getComputedWebkitTouchCallout(element: Element): string {
  // webkitTouchCallout isn't an 'official' property, so it must be retrieved
  // by name. Casting `CSSStyleDecoration` as dictionary to avoid error TS7015
  // (Element implicitly has an 'any' type because index expression is not of
  // type 'number').
  const styles = window.getComputedStyle(element, null) as {[key: string]: any};
  return styles['webkitTouchCallout'];
}

/**
 * Gets the referrer policy to use for navigations away from the current page.
 * If a link element is passed, and it includes a rel=noreferrer tag, that
 * will override the page setting.
 * @param linkElement - optional link triggering the navigation.
 */
function getReferrerPolicy(linkElement?: Element): string {
  if (linkElement) {
    const rel = linkElement.getAttribute('rel');
    if (rel && rel.toLowerCase() === 'noreferrer') {
      return 'never';
    }
  }

  // Search for referrer meta tag.  WKWebView only supports a subset of values
  // for referrer meta tags.  If it parses a referrer meta tag with an
  // unsupported value, it will default to 'never'.
  const metaTags = document.getElementsByTagName('meta');
  for (const metaTag of metaTags) {
    if (metaTag.name.toLowerCase() === 'referrer') {
      const referrerPolicy = metaTag.content.toLowerCase();
      if (referrerPolicy === 'default' || referrerPolicy === 'always' ||
          referrerPolicy === 'no-referrer' || referrerPolicy === 'origin' ||
          referrerPolicy === 'no-referrer-when-downgrade' ||
          referrerPolicy === 'unsafe-url') {
        return referrerPolicy;
      } else {
        return 'never';
      }
    }
  }
  return 'default';
}

/**
 * Returns the href of the given element. Handles standard <a> links as well as
 * xlink:href links as used within <svg> tags.
 * @param element - the link triggering the navigation.
 */
function getElementHref(element: HTMLAnchorElement|SVGAElement): string {
  const href = element.href;
  if (href instanceof SVGAnimatedString) {
    return href.animVal;
  }
  return href;
}


/**
 * Checks if the element is effectively transparent and should be skipped when
 * checking for image and link elements.
 * @param element - element in the page.
 */
function isTransparentElement(element: Element): boolean {
  const style = window.getComputedStyle(element);
  return style.getPropertyValue('display') === 'none' ||
      style.getPropertyValue('visibility') !== 'visible' ||
      Number(style.getPropertyValue('opacity')) <= TRANSPARENCY_THRESHOLD;
}

/**
 * Checks if the element blocks the user from viewing elements underneath it.
 * @param element - the element to check for opacity.
 */
function isOpaqueElement(element: Element): boolean {
  const style = window.getComputedStyle(element);
  const isOpaque = style.getPropertyValue('display') !== 'none' &&
      style.getPropertyValue('visibility') === 'visible' &&
      Number(style.getPropertyValue('opacity')) >= OPACITY_THRESHOLD;

  // We consider an element opaque if it has a background color with an alpha
  // value of 1. To check this, we check to see if only rgb values are returned
  // or all rgba values are (the alpha value is only returned if it is not 1).
  const hasOpaqueBackgroundColor =
      style.getPropertyValue('background-color').split(', ').length === 3;
  const imageSource = getImageSource(element);
  const hasBackground = !!imageSource || hasOpaqueBackgroundColor;

  return isOpaque && hasBackground;
}

/**
 * Returns the image source if the element is an <img> or an element with a
 * background image. Returns null if no image source was found.
 * @param element - the element from which to get the image source.
 */
function getImageSource(element: Element): string|null {
  if (element.tagName && element.tagName.toLowerCase() === 'img') {
    if (element instanceof HTMLImageElement) {
      return element.currentSrc || element.src;
    }
  }
  const backgroundImageString =
      window.getComputedStyle(element).backgroundImage;
  if (backgroundImageString === '' || backgroundImageString === 'none') {
    return null;
  }
  return extractUrlFromBackgroundImageString(backgroundImageString);
}

/**
 * Returns a url if it is the first background image in the string. Otherwise,
 * returns null.
 * @param backgroundImageString - string of the CSS background image
 *     property of an element.
 */
function extractUrlFromBackgroundImageString(backgroundImageString: string):
    string|null {
  // backgroundImageString can contain more than one background image, some of
  // which may be urls or gradients.
  // Example: 'url("image1"), linear-gradient(#ffffff, #000000), url("image2")'
  const regex = /^url\(['|"]?(.+?)['|"]?\)/;
  const url = regex.exec(backgroundImageString);
  return url && url[1] ? url[1] : null;
}

/**
 * Processes context menu messages received by the window.
 */
window.addEventListener('message', function(message) {
  const payload = message.data;
  if (!payload || typeof payload !== 'object') {
    return;
  }
  if (payload.hasOwnProperty('type') &&
      payload.type === 'org.chromium.contextMenuMessage') {
    findElementAtPointInPageCoordinates(
        payload.requestId, payload.x + window.pageXOffset,
        payload.y + window.pageYOffset);
  }
});

// Call contextMenuAllFrames on gCrWeb directly to prevent code duplication
// that using export/import would create.
gCrWeb.contextMenuAllFrames = {
  findElementAtPointInPageCoordinates,
  // For testing only:
  getSurroundingText,
};