// Copyright 2022 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {CSS_CLASS_NAME_SELECT}
from '//ios/web/find_in_page/resources/find_in_page_constants.js';
/**
* Returns the width of the document.body. Sometimes though the body lies to
* try to make the page not break rails, so attempt to find those as well.
* An example: wikipedia pages for the ipad.
* @return {number} Width of the document body.
*/
function getBodyWidth_(): number {
let body = document.body;
let documentElement = document.documentElement;
return Math.max(
body.scrollWidth, documentElement.scrollWidth, body.offsetWidth,
documentElement.offsetWidth, body.clientWidth,
documentElement.clientWidth);
};
/**
* Returns the height of the document.body. Sometimes though the body lies to
* try to make the page not break rails, so attempt to find those as well.
* An example: wikipedia pages for the ipad.
* @return {number} Height of the document body.
*/
function getBodyHeight_(): number {
let body = document.body;
let documentElement = document.documentElement;
return Math.max(
body.scrollHeight, documentElement.scrollHeight, body.offsetHeight,
documentElement.offsetHeight, body.clientHeight,
documentElement.clientHeight);
};
/**
* Helper function that determines if an element is visible.
* @param {Element} elem Element to check.
* @return {boolean} Whether elem is visible or not.
*/
function isElementVisible_(elem: HTMLElement): boolean {
if (!elem) {
return false;
}
let top = 0;
let left = 0;
let bottom = Infinity;
let right = Infinity;
let originalElement = elem;
let nextOffsetParent = originalElement.offsetParent;
// We are currently handling all scrolling through the app, which means we can
// only scroll the window, not any scrollable containers in the DOM itself. So
// for now this function returns false if the element is scrolled outside the
// viewable area of its ancestors.
// TODO(crbug.com/40606656): handle scrolling within the DOM.
let bodyHeight = getBodyHeight_();
let bodyWidth = getBodyWidth_();
while (elem && elem.nodeName.toUpperCase() !== 'BODY') {
if (elem.style.display === 'none' || elem.style.visibility === 'hidden') {
return false;
}
// Check that there is a value set before converting to a Number, otherwise
// and empty string will convert to opacity zero and a visible item will be
// assumed hidden.
if (elem.style.opacity.length) {
const opacity = Number(elem.style.opacity);
if (!isNaN(opacity) && opacity === 0) {
return false;
}
}
if (elem.ownerDocument && elem.ownerDocument.defaultView) {
const computedStyle =
elem.ownerDocument.defaultView.getComputedStyle(elem, null);
if (computedStyle.display === 'none' ||
computedStyle.visibility === 'hidden') {
return false;
}
// Check that there is a value set before converting to a Number,
// otherwise and empty string will convert to opacity zero and a visible
// item will be assumed hidden.
if (computedStyle.opacity.length) {
const opacity = Number(computedStyle.opacity);
if (!isNaN(opacity) && opacity === 0) {
return false;
}
}
}
// For the original element and all ancestor offsetParents, trim down the
// visible area of the original element.
if (elem.isSameNode(originalElement) || elem.isSameNode(nextOffsetParent)) {
let visible = elem.getBoundingClientRect();
if (elem.style.overflow === 'hidden' &&
(visible.width === 0 || visible.height === 0))
return false;
top = Math.max(top, visible.top + window.pageYOffset);
bottom = Math.min(bottom, visible.bottom + window.pageYOffset);
left = Math.max(left, visible.left + window.pageXOffset);
right = Math.min(right, visible.right + window.pageXOffset);
// The element is not within the original viewport.
let notWithinViewport = top < 0 || left < 0;
// The element is flowing off the boundary of the page. Note this is
// not comparing to the size of the window, but the calculated offset
// size of the document body. This can happen if the element is within
// a scrollable container in the page.
let offPage = right > bodyWidth || bottom > bodyHeight;
if (notWithinViewport || offPage) {
return false;
}
nextOffsetParent = elem.offsetParent;
}
if (!(elem.parentNode instanceof HTMLElement)) {
break;
}
elem = elem.parentNode;
}
return true;
};
/**
* A Match represents a match result in the document. |this.nodes| stores all
* the <chrome_find> Nodes created for highlighting the matched text. If it
* contains only one Node, it means the match is found within one HTML TEXT
* Node, otherwise the match involves multiple HTML TEXT Nodes.
*/
class Match {
nodes: HTMLElement[] = [];
/**
* Returns if all <chrome_find> Nodes of this match are visible.
* @return {Boolean} If the Match is visible.
*/
visible(): boolean {
for (const node of this.nodes) {
if (!isElementVisible_(node))
return false;
}
return true;
}
/**
* Adds orange color highlight for "selected match result", over the yellow
* color highlight for "normal match result".
*/
addSelectHighlight(): void {
for (const node of this.nodes) {
node.classList.add(CSS_CLASS_NAME_SELECT);
}
}
/**
* Clears the orange color highlight.
*/
removeSelectHighlight(): void {
for (const node of this.nodes) {
node.classList.remove(CSS_CLASS_NAME_SELECT);
}
}
}
/**
* A part of a Match, within a Section. A Match may cover multiple sections_ in
* |allText_|, so it must be split into multiple PartialMatches and then
* dispatched into the Sections they belong. The range of a PartialMatch in
* |allText_| is [begin, end). Exactly one <chrome_find> will be created for
* each PartialMatch.
*/
class PartialMatch {
/**
* @param {number} matchId ID of the Match to which this PartialMatch belongs.
* @param {number} begin Beginning index of partial match text in |allText_|.
* @param {number} end Ending index of partial match text in |allText_|.
*/
constructor(
public matchId: number, public begin: number, public end: number) {}
}
/**
* A Replacement represents a DOM operation that swaps |oldNode| with |newNodes|
* under the parent of |oldNode| to highlight the match result inside |oldNode|.
* |newNodes| may contain plain TEXT Nodes for unhighlighted parts and
* <chrome_find> nodes for highlighted parts. This operation will be executed
* reversely when clearing current highlights for next FindInPage action.
*/
class Replacement {
/**
* @param {Node} HTMLElement The HTML Node containing search result.
* @param {Array<Node>} newNodes New HTML Nodes created for
* substitution of |oldNode|.
*/
constructor(
private readonly oldNode: HTMLElement,
private readonly newNodes: Node[]) {}
/**
* Executes the replacement to highlight search result.
*/
doSwap(): void {
let parentNode = this.oldNode.parentNode;
if (!parentNode)
return;
for (const newNode of this.newNodes) {
parentNode.insertBefore(newNode, this.oldNode);
}
parentNode.removeChild(this.oldNode);
}
/**
* Executes the replacement reversely to clear the highlight.
*/
undoSwap(): void {
const firstNewNode = this.newNodes[0];
if (!firstNewNode) {
return;
}
let parentNode = firstNewNode.parentNode;
if (!parentNode)
return;
parentNode.insertBefore(this.oldNode, firstNewNode);
for (const newNode of this.newNodes) {
parentNode.removeChild(newNode);
}
}
}
/**
* A Section contains the info of one TEXT node in the |allText_|. The node's
* textContent is [begin, end) of |allText_|.
*/
class Section {
/**
* @param {number} begin Beginning index of |node|.textContent in |allText_|.
* @param {number} end Ending index of |node|.textContent in |allText_|.
* @param {HTMLElement} node The TEXT Node of this section.
*/
constructor(
public begin: number, public end: number, public node: HTMLElement) {}
}
/**
* A timer that checks timeout for long tasks.
*/
class Timer {
private beginTime = Date.now();
/**
* @param {Number} timeoutMs Timeout in milliseconds.
*/
constructor(private timeoutMs: number) {}
/**
* @return {Boolean} Whether this timer has been reached.
*/
overtime(): boolean {
return Date.now() - this.beginTime > this.timeoutMs;
}
}
export {Match, PartialMatch, Replacement, Section, Timer}