| // 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 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'; |
| |
| // Delay before starting text extraction. |
| export const EXTRACTION_TIMEOUT_MS = 300; |
| |
| // Using Symbol as property key ensure the data doesn't show up in |
| // property key lists. |
| |
| // Tagged on an `Element` that is is visible according to IntersectionObserver. |
| export const visibleElement = Symbol('visibleElement'); |
| |
| // Tagged on parent chain of every element with `visibleElement`. It maintains |
| // a count of how many descendants are visible and is used to avoid going |
| // down uselessly a branch of the DOM that is 100% not visible when extracting |
| // text. |
| export const visibleDescendantCount = Symbol('visibleDescendantCount'); |
| |
| // Tagged on an text `Node` that contribute to their parent element |
| // `observedTextNodeCount`. |
| export const observedNode = Symbol('observedNode'); |
| |
| // Tagged on an `Element` that is is visible according to IntersectionObserver. |
| // The attached value contains the number of text nodes child that have |
| // requested observation. |
| export const observedTextNodeCount = Symbol('observedTextNodeCount'); |
| |
| // Interface to used parts of `IntersectionObserver`. Can be mocked easily. |
| export class InternalIntersectionObserver { |
| constructor( |
| _callback: IntersectionObserverCallback, |
| _options?: IntersectionObserverInit) {} |
| disconnect(): void {} |
| observe(_target: Element): void {} |
| unobserve(_target: Element): void {} |
| } |
| |
| // Real time `IntersectionObserverInterface` based on `IntersectionObserver`. |
| 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); |
| } |
| } |
| |
| // Interface for objects wanting to visit the visible part of the DOM. |
| export interface TextNodeVisitor { |
| // Called when starting the visit. |
| begin(): void; |
| // Called for visible text `textNode` with `textContent` not null. |
| visibleTextNode(textNode: Text): void; |
| // Called for invisible `node` between `visibleTextNode`s. |
| invisibleNode(node: Node): void; |
| // Called before entering `node` subtree. |
| enterVisibleNode(node: Node): void; |
| // Called after leaving `node` subtree. |
| leaveVisibleNode(node: Node): void; |
| // Called when ending the visit. |
| end(): void; |
| } |
| |
| export class TextIntersectionObserver implements CountedIntersectionObserver { |
| private intersectionOptions = { |
| // Monitor viewport. |
| root: null, |
| // Make intersect window bigger to prepare potential incoming (scrolling) |
| // intents. |
| rootMargin: '100px', |
| // Catch any node partially in (extended) viewport. |
| threshold: 0, |
| }; |
| |
| // IntersectionObserver can only observe `Element` objects, not `Node`s |
| // like text nodes. To cope with that, we observe the text nodes parent, and |
| // when they are called visible, we take for granted all their text nodes are |
| // too (which is probably not true all the time, but there's no fast and |
| // obvious solution). |
| 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) {} |
| |
| // Cleanup visibility tags. |
| 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); |
| } |
| |
| // Extracts visible text that hasn't been processed. |
| // Releases intersection observation, so this will not trigger again and be |
| // extracted only once. Unless node's text is mutated and the domObserver |
| // re-adds it to the intersection observer. |
| private visit(visitor: TextNodeVisitor): void { |
| // DFS traversal to locate visible elements, in rendering order. |
| 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(); |
| } |
| |
| // Singleton function for text extraction. Needs to be a singleton for the |
| // `idleTaskTracker` to replace an already scheduled extraction. |
| private textExtractionTask = () => { |
| this.visit(this.visitor); |
| }; |
| |
| // `IntersectionObserver` used to tag visibility of elements. |
| 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); |
| } |
| }; |
| |
| // Tags given `element` with `visibleElement` symbol and updates the parent |
| // chain of `visibleDescendantCount` tags. |
| 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; |
| } |
| } |
| |
| // Untags given `element` `visibleElement` symbol and updates the parent chain |
| // of `visibleDescendantCount` tags. |
| private untagVisibleElement(element: Element): void { |
| let item: ElementWithSymbolIndex|null = element as ElementWithSymbolIndex; |
| let parent: ElementWithSymbolIndex|null; |
| if (!item[visibleElement]) { |
| // It happens... |
| 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; |
| } |
| } |
| |
| // Mark: CountedIntersectionObserver |
| |
| observe(node: NodeWithSymbolIndex): void { |
| // Already observed and counted. |
| 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 { |
| // Not observed and counted. |
| 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; |
| } |
| } |
| |
| // Mark: Public API |
| |
| // Starts the intersection observer. |
| start(): void { |
| this.observer = new this.observerClass( |
| this.intersectionCallback, this.intersectionOptions); |
| } |
| |
| // Stops the intersection observer. |
| stop(): void { |
| this.cleanup(); |
| this.observer?.disconnect(); |
| this.observer = null; |
| } |
| } |