* @fileoverview Monitor and extract visible text on the page and pass it on to
* the annotations manager.
*/
import type {CountedIntersectionObserver} from '//ios/web/annotations/resources/text_dom_observer.js';
import type {ElementWithSymbolIndex, HTMLElementWithSymbolIndex, NodeWithSymbolIndex} from '//ios/web/annotations/resources/text_dom_utils.js';
import {isValidNode} from '//ios/web/annotations/resources/text_dom_utils.js';
import type {IdleTaskTracker} from '//ios/web/annotations/resources/text_tasks.js';
export const EXTRACTION_TIMEOUT_MS = 300;
export const visibleElement = Symbol('visibleElement');
export const visibleDescendantCount = Symbol('visibleDescendantCount');
export const observedNode = Symbol('observedNode');
export const observedTextNodeCount = Symbol('observedTextNodeCount');
export class InternalIntersectionObserver {
constructor(
_callback: IntersectionObserverCallback,
_options?: IntersectionObserverInit) {}
disconnect(): void {}
observe(_target: Element): void {}
unobserve(_target: Element): void {}
}
export class LiveIntersectionObserver implements InternalIntersectionObserver {
private observer: IntersectionObserver;
constructor(
callback: IntersectionObserverCallback,
options?: IntersectionObserverInit) {
this.observer = new IntersectionObserver(callback, options);
}
disconnect(): void {
this.observer.disconnect();
}
observe(target: Element): void {
this.observer.observe(target);
}
unobserve(target: Element): void {
this.observer.unobserve(target);
}
}
export interface TextNodeVisitor {
begin(): void;
visibleTextNode(textNode: Text): void;
invisibleNode(node: Node): void;
enterVisibleNode(node: Node): void;
leaveVisibleNode(node: Node): void;
end(): void;
}
export class TextIntersectionObserver implements CountedIntersectionObserver {
private intersectionOptions = {
root: null,
rootMargin: '100px',
threshold: 0,
};
private observer: InternalIntersectionObserver|null = null;
constructor(
public root: Element, public visitor: TextNodeVisitor,
private idleTaskTracker: IdleTaskTracker,
private observerClass:
typeof InternalIntersectionObserver = LiveIntersectionObserver,
private visitAfterDelayMs = EXTRACTION_TIMEOUT_MS) {}
private cleanup(): void {
const traverseVisible = (node: NodeWithSymbolIndex) => {
if (!isValidNode(node)) {
return;
}
if (node instanceof Element && node.shadowRoot &&
node.shadowRoot !== node as Node) {
traverseVisible(node.shadowRoot);
} else if (node.hasChildNodes()) {
for (const childNode of node.childNodes as
NodeListOf<NodeWithSymbolIndex>) {
if (childNode[visibleDescendantCount] || childNode[visibleElement]) {
traverseVisible(childNode);
}
}
if (node[visibleElement] && node instanceof Element) {
this.untagVisibleElement(node);
}
}
};
traverseVisible(this.root);
}
private visit(visitor: TextNodeVisitor): void {
const traverseVisible = (node: NodeWithSymbolIndex) => {
if (!isValidNode(node)) {
return;
}
if (node instanceof Element && node.shadowRoot &&
node.shadowRoot !== node as Node) {
traverseVisible(node.shadowRoot);
} else if (node.hasChildNodes()) {
const visible = node[visibleElement];
for (const childNode of node.childNodes as
NodeListOf<NodeWithSymbolIndex>) {
if (visible && childNode.nodeType === Node.TEXT_NODE) {
visitor.visibleTextNode(childNode as Text);
this.unobserve(childNode);
} else if (
childNode[visibleDescendantCount] || childNode[visibleElement]) {
visitor.enterVisibleNode(childNode);
traverseVisible(childNode);
visitor.leaveVisibleNode(childNode);
} else {
visitor.invisibleNode(childNode);
}
}
if (visible && node instanceof Element) {
this.untagVisibleElement(node);
}
}
};
visitor.begin();
traverseVisible(this.root);
visitor.end();
}
private textExtractionTask = () => {
this.visit(this.visitor);
};
private intersectionCallback: IntersectionObserverCallback = (entries) => {
let updateNeeded = false;
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.tagVisibleElement(entry.target);
updateNeeded = true;
} else {
this.untagVisibleElement(entry.target);
}
});
if (updateNeeded) {
this.idleTaskTracker.schedule(
this.textExtractionTask, this.visitAfterDelayMs);
}
};
private tagVisibleElement(element: Element): void {
let item: ElementWithSymbolIndex|null = element as ElementWithSymbolIndex;
let parent: ElementWithSymbolIndex|null;
if (item[visibleElement]) {
return;
}
item[visibleElement] = true;
while (item !== null && item !== this.root) {
if (item instanceof ShadowRoot) {
parent = item.host as ElementWithSymbolIndex;
} else {
parent = item.parentElement;
}
if (parent) {
parent[visibleDescendantCount] =
(parent[visibleDescendantCount] ?? 0) + 1;
}
item = parent;
}
}
private untagVisibleElement(element: Element): void {
let item: ElementWithSymbolIndex|null = element as ElementWithSymbolIndex;
let parent: ElementWithSymbolIndex|null;
if (!item[visibleElement]) {
return;
}
delete item[visibleElement];
while (item !== null && item !== this.root) {
if (item instanceof ShadowRoot) {
parent = item.host as ElementWithSymbolIndex;
} else {
parent = item.parentElement;
}
if (parent) {
if (parent[visibleDescendantCount] > 1) {
parent[visibleDescendantCount] = parent[visibleDescendantCount] - 1;
} else {
delete parent[visibleDescendantCount];
}
}
item = parent;
}
}
observe(node: NodeWithSymbolIndex): void {
if (node[observedNode]) {
return;
}
const element = node.parentElement as HTMLElementWithSymbolIndex;
if (!element || !isValidNode(element)) {
return;
}
node[observedNode] = true;
const count = element[observedTextNodeCount] ?? 0;
if (count === 0) {
this.observer?.observe(element);
}
element[observedTextNodeCount] = count + 1;
}
unobserve(node: NodeWithSymbolIndex): void {
if (!node[observedNode]) {
return;
}
delete node[observedNode];
const element = node.parentElement as HTMLElementWithSymbolIndex;
if (!element || !isValidNode(element)) {
return;
}
const count = element[observedTextNodeCount] ?? 0;
if (count === 1) {
this.observer?.unobserve(element);
delete element[observedTextNodeCount];
} else {
element[observedTextNodeCount] = count - 1;
}
}
start(): void {
this.observer = new this.observerClass(
this.intersectionCallback, this.intersectionOptions);
}
stop(): void {
this.cleanup();
this.observer?.disconnect();
this.observer = null;
}
}