chromium/ios/web/js_features/context_menu/resources/surrounding_text.ts

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

/**
 * @fileoverview A tool to fetch the surrounding text around a long
 * pressed character.
 */

// The number of characters to add after a long press going left or going right.
const NUMBER_OF_SURROUNDING_CHARS = 100;

// List of nodes whose contents should not be considered when extracting text.
const INVALID_TEXT_ELEMENTS = new Set([
  'APPLET',   'AREA',     'AUDIO',    'BUTTON', 'CANVAS',   'EMBED',
  'FRAME',    'FRAMESET', 'IFRAME',   'IMG',    'INPUT',    'KEYGEN',
  'LABEL',    'MAP',      'NOSCRIPT', 'OBJECT', 'OPTGROUP', 'OPTION',
  'PROGRESS', 'SCRIPT',   'SELECT',   'STYLE',  'TEXTAREA', 'VIDEO'
]);

/**
 * Contains `position`, as an index of the selected text and `text` the
 * surrounding text extended by `NUMBER_OF_SURROUNDING_CHARS` before and after
 * as much as possible.
 */
class SurroundingText {
  constructor(public position: number, public text: string) {}
}

// Mark: Private helper functions

/**
 * Returns whether the given element is valid.
 * An invalid element is one in `INVALID_TEXT_ELEMENTS` or if it is a
 * contenteditable element.
 */
function isValidElement(element: Element): boolean {
  if (element.getAttribute('contenteditable')) {
    return false;
  }
  return !INVALID_TEXT_ELEMENTS.has(element.nodeName);
}

/**
 * Returns the last node that is a descendant of `node` and not a descendant of
 * an invalid node (DFS order).
 */
function getLastValid(node: Node): Node {
  const childrenCount = node.childNodes.length;
  for (let i = childrenCount - 1; i >= 0; i--) {
    const child = node.childNodes[i];
    if (!child)
      continue;
    if (child instanceof Element && isValidElement(child)) {
      return getLastValid(child);
    }
    if (child.nodeType === child.TEXT_NODE) {
      return child;
    }
  }
  return node;
}

/**
 * Returns the previous valid text node.
 */
function getPrevNode(node: Node|null): Node|null {
  if (!node || node === document.body) {
    return null;
  }

  while (node != null) {
    if (node.previousSibling) {
      node = node.previousSibling;
      if (node.nodeType === node.TEXT_NODE) {
        return node;
      }
      if (node instanceof Element && isValidElement(node)) {
        return getLastValid(node);
      }
      continue;
    }
    node = node.parentNode;
    if (node instanceof Element && isValidElement(node)) {
      return node;
    }
  }
  return null;
}

/**
 * Returns the next valid node.
 */
function getNextNode(node: Node|null): Node|null {
  if (!node) {
    return null;
  }

  if (node.childNodes.length > 0) {
    node = node.childNodes[0]!;
    if (node.nodeType === node.TEXT_NODE ||
        (node instanceof Element && isValidElement(node))) {
      return node;
    }
    if (node.nodeType === node.ELEMENT_NODE) {
      return null;
    }
  }

  while (node != null) {
    if (!node.nextSibling) {
      node = node.parentNode;
      if (node === document.body) {
        return null;
      }
      continue;
    }
    node = node.nextSibling;
    if (node.nodeType === node.TEXT_NODE ||
        (node instanceof Element && isValidElement(node))) {
      return node;
    }
  }
  return null;
}

/**
 * Returns the next valid text node.
 */
function getNextTextNode(node: Node|null): Node|null {
  let n = getNextNode(node);
  while (n != null && n.nodeType != n.TEXT_NODE) {
    n = getNextNode(n);
  }
  return n;
}

/**
 * Returns the previous valid text node.
 */
function getPrevTextNode(node: Node|null): Node|null {
  var n = getPrevNode(node);
  while (n != null && n.nodeType != n.TEXT_NODE) {
    n = getPrevNode(n);
  }
  return n;
}

// Mark: Public API functions called from native code.

/**
 * Returns an object representing the position of the selected point in text
 * within the surrounding text, and the surrounding range.
 * @param range - the range where the user's selected point.
 */
function getSurroundingText(range: Range): SurroundingText {
  const node = range.startContainer;
  const textContent = node.textContent;
  if (!textContent) {
    return new SurroundingText(/*position=*/ 0, /*text=*/ '');
  }

  let leftText = textContent.substring(0, range.startOffset);
  let leftNode = getPrevTextNode(node);
  while (leftNode && leftText.length < NUMBER_OF_SURROUNDING_CHARS) {
    leftText = leftNode.textContent + ' ' + leftText;
    leftNode = getPrevTextNode(leftNode);
  }

  let rightText = textContent.substring(range.endOffset);
  let rightNode = getNextTextNode(node);
  while (rightNode && rightText.length < NUMBER_OF_SURROUNDING_CHARS) {
    rightText = rightText + ' ' + rightNode.textContent;
    rightNode = getNextTextNode(rightNode);
  }

  if (leftText.length > NUMBER_OF_SURROUNDING_CHARS) {
    leftText =
        leftText.substring(leftText.length - NUMBER_OF_SURROUNDING_CHARS);
  }
  if (rightText.length > NUMBER_OF_SURROUNDING_CHARS) {
    rightText = rightText.substring(0, NUMBER_OF_SURROUNDING_CHARS);
  }

  const middleText = textContent.substring(range.startOffset, range.endOffset);
  return new SurroundingText(
      leftText.length, leftText + middleText + rightText);
};

export {getSurroundingText, SurroundingText}