| /** |
| * @license |
| * Copyright 2022 Google Inc. |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| |
| interface NonTrivialValueNode extends Node { |
| value: string; |
| } |
| |
| const TRIVIAL_VALUE_INPUT_TYPES = new Set(['checkbox', 'image', 'radio']); |
| |
| /** |
| * Determines if the node has a non-trivial value property. |
| * |
| * @internal |
| */ |
| const isNonTrivialValueNode = (node: Node): node is NonTrivialValueNode => { |
| if (node instanceof HTMLSelectElement) { |
| return true; |
| } |
| if (node instanceof HTMLTextAreaElement) { |
| return true; |
| } |
| if ( |
| node instanceof HTMLInputElement && |
| !TRIVIAL_VALUE_INPUT_TYPES.has(node.type) |
| ) { |
| return true; |
| } |
| return false; |
| }; |
| |
| const UNSUITABLE_NODE_NAMES = new Set(['SCRIPT', 'STYLE']); |
| |
| /** |
| * Determines whether a given node is suitable for text matching. |
| * |
| * @internal |
| */ |
| export const isSuitableNodeForTextMatching = (node: Node): boolean => { |
| return ( |
| !UNSUITABLE_NODE_NAMES.has(node.nodeName) && !document.head?.contains(node) |
| ); |
| }; |
| |
| /** |
| * @internal |
| */ |
| export interface TextContent { |
| // Contains the full text of the node. |
| full: string; |
| // Contains the text immediately beneath the node. |
| immediate: string[]; |
| } |
| |
| /** |
| * Maps {@link Node}s to their computed {@link TextContent}. |
| */ |
| const textContentCache = new WeakMap<Node, TextContent>(); |
| const eraseFromCache = (node: Node | null) => { |
| while (node) { |
| textContentCache.delete(node); |
| if (node instanceof ShadowRoot) { |
| node = node.host; |
| } else { |
| node = node.parentNode; |
| } |
| } |
| }; |
| |
| /** |
| * Erases the cache when the tree has mutated text. |
| */ |
| const observedNodes = new WeakSet<Node>(); |
| const textChangeObserver = new MutationObserver(mutations => { |
| for (const mutation of mutations) { |
| eraseFromCache(mutation.target); |
| } |
| }); |
| |
| /** |
| * Builds the text content of a node using some custom logic. |
| * |
| * @remarks |
| * The primary reason this function exists is due to {@link ShadowRoot}s not having |
| * text content. |
| * |
| * @internal |
| */ |
| export const createTextContent = (root: Node): TextContent => { |
| let value = textContentCache.get(root); |
| if (value) { |
| return value; |
| } |
| value = {full: '', immediate: []}; |
| if (!isSuitableNodeForTextMatching(root)) { |
| return value; |
| } |
| |
| let currentImmediate = ''; |
| if (isNonTrivialValueNode(root)) { |
| value.full = root.value; |
| value.immediate.push(root.value); |
| |
| root.addEventListener( |
| 'input', |
| event => { |
| eraseFromCache(event.target as HTMLInputElement); |
| }, |
| {once: true, capture: true}, |
| ); |
| } else { |
| for (let child = root.firstChild; child; child = child.nextSibling) { |
| if (child.nodeType === Node.TEXT_NODE) { |
| value.full += child.nodeValue ?? ''; |
| currentImmediate += child.nodeValue ?? ''; |
| continue; |
| } |
| if (currentImmediate) { |
| value.immediate.push(currentImmediate); |
| } |
| currentImmediate = ''; |
| if (child.nodeType === Node.ELEMENT_NODE) { |
| value.full += createTextContent(child).full; |
| } |
| } |
| if (currentImmediate) { |
| value.immediate.push(currentImmediate); |
| } |
| if (root instanceof Element && root.shadowRoot) { |
| value.full += createTextContent(root.shadowRoot).full; |
| } |
| |
| if (!observedNodes.has(root)) { |
| textChangeObserver.observe(root, { |
| childList: true, |
| characterData: true, |
| subtree: true, |
| }); |
| observedNodes.add(root); |
| } |
| } |
| textContentCache.set(root, value); |
| return value; |
| }; |