// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as utils from '//third_party/text-fragments-polyfill/src/src/text-fragment-utils.js';
import {gCrWeb} from '//ios/web/public/js_messaging/resources/gcrweb.js';
import {sendWebKitMessage} from '//ios/web/public/js_messaging/resources/utils.js';
/**
* @fileoverview Interface used for Chrome/WebView to call into the
* text-fragments-polyfill lib, which handles finding text fragments provided
* by the navigation layer, highlighting them, and scrolling them into view.
*/
declare interface TextFragment {
textStart: string,
textEnd?: string,
prefix?: string,
suffix?: string
}
declare interface MarkStyle {
backgroundColor: string,
color: string
}
// Stores an array of <mark> html elements.
let marks: Element[] | null;
let cachedFragments: TextFragment[];
/**
* Attempts to identify and highlight the given text fragments and
* optionally, scroll them into view, and apply default colors.
*/
function handleTextFragments(fragments:TextFragment[], scroll: boolean,
backgroundColor: string, foregroundColor: string): void {
// If `marks` already exists, it's because we've already highlighted
// fragments on this page. This might happen if the user got here by
// navigating back. Stop now to avoid creating nested <mark> elements.
if (marks?.length)
return;
let markDefaultStyle: MarkStyle | null = null;
if (backgroundColor && foregroundColor) {
markDefaultStyle =
{backgroundColor: `#${backgroundColor}`,
color: `#${foregroundColor}`};
}
if (document.readyState === "complete" ||
document.readyState === "interactive") {
doHandleTextFragments(fragments, scroll, markDefaultStyle);
return;
}
document.addEventListener('DOMContentLoaded', () => {
doHandleTextFragments(fragments, scroll, markDefaultStyle);
});
};
function removeHighlights(new_url: string): void {
if (marks) {
utils.removeMarks(marks);
marks = null;
}
document.removeEventListener("click", handleClick,
/*useCapture=*/true);
if (new_url) {
try {
history.replaceState(
history.state, // Don't overwrite any existing state object
"", // Title param is required but unused
new_url);
} catch (err) {
// history.replaceState throws an exception if the origin of the new URL
// is different from the current one. This shouldn't happen, but if it
// does, we don't want the exception to bubble up and cause
// side-effects.
}
}
};
/**
* Does the actual work for handleTextFragments.
*/
function doHandleTextFragments(fragments: TextFragment[],
scroll: boolean, markStyle: MarkStyle | null): void {
marks = [];
let successCount = 0;
if (markStyle) utils.setDefaultTextFragmentsStyle(markStyle);
for (const fragment of fragments) {
// Process the fragments, then filter out any that evaluate to false.
const foundRanges: Range[] = utils.processTextFragmentDirective(fragment)
.filter((mark) => { return !!mark });
if (Array.isArray(foundRanges)) {
// If length < 1, then nothing was found. If length > 1, the spec says
// to take the first instance.
if (foundRanges.length >= 1) {
++successCount;
let newMarks: Element[] = utils.markRange(foundRanges[0] as Range);
if (Array.isArray(newMarks)) {
marks.push(...newMarks);
}
}
}
}
if (scroll && marks.length > 0) {
cachedFragments = fragments;
utils.scrollElementIntoView(marks[0] as Element);
}
// Send events back to the browser when the user taps a mark, and when the
// user taps the page anywhere. We have to send both because one is consumed
// when kIOSSharedHighlightingV2 is enabled, and the other when it's
// disabled, and this JS file doesn't know about flag states.
// Use capture to make sure the event listener is executed immediately and
// cannot be prevented by the event target (during bubble phase).
document.addEventListener("click", handleClick, /*useCapture=*/true);
for (let mark of marks) {
mark.addEventListener("click", handleClickWithSender.bind(mark), true);
}
sendWebKitMessage('textFragments', {
command: 'textFragments.processingComplete',
result: {
successCount: successCount,
fragmentsCount: fragments.length
}
});
};
function handleClick(): void {
sendWebKitMessage('textFragments', {
command: 'textFragments.onClick'
});
};
function handleClickWithSender(event: Event): void {
if (!(event.currentTarget instanceof HTMLElement)) {
return;
}
const mark = event.currentTarget as HTMLElement;
// Traverse upwards from the mark element to see if it's a child of an <a>.
// If so, discard the event to prevent showing a menu while navigation is
// in progress.
let node = mark.parentNode;
while (node != null) {
if (node instanceof HTMLElement && node.tagName == 'A') {
return;
}
node = node.parentNode;
}
sendWebKitMessage('textFragments', {
command: 'textFragments.onClickWithSender',
rect: rectFromElement(mark),
text: `"${mark.innerText}"`,
fragments: cachedFragments
});
};
function rectFromElement(elt: Element) {
const domRect = elt.getClientRects()[0];
return {
x: domRect?.x,
y: domRect?.y,
width: domRect?.width,
height: domRect?.height
};
};
gCrWeb.textFragments = { handleTextFragments, removeHighlights }