TextContent.ts (3472B)
1 /** 2 * @license 3 * Copyright 2022 Google Inc. 4 * SPDX-License-Identifier: Apache-2.0 5 */ 6 7 interface NonTrivialValueNode extends Node { 8 value: string; 9 } 10 11 const TRIVIAL_VALUE_INPUT_TYPES = new Set(['checkbox', 'image', 'radio']); 12 13 /** 14 * Determines if the node has a non-trivial value property. 15 * 16 * @internal 17 */ 18 const isNonTrivialValueNode = (node: Node): node is NonTrivialValueNode => { 19 if (node instanceof HTMLSelectElement) { 20 return true; 21 } 22 if (node instanceof HTMLTextAreaElement) { 23 return true; 24 } 25 if ( 26 node instanceof HTMLInputElement && 27 !TRIVIAL_VALUE_INPUT_TYPES.has(node.type) 28 ) { 29 return true; 30 } 31 return false; 32 }; 33 34 const UNSUITABLE_NODE_NAMES = new Set(['SCRIPT', 'STYLE']); 35 36 /** 37 * Determines whether a given node is suitable for text matching. 38 * 39 * @internal 40 */ 41 export const isSuitableNodeForTextMatching = (node: Node): boolean => { 42 return ( 43 !UNSUITABLE_NODE_NAMES.has(node.nodeName) && !document.head?.contains(node) 44 ); 45 }; 46 47 /** 48 * @internal 49 */ 50 export interface TextContent { 51 // Contains the full text of the node. 52 full: string; 53 // Contains the text immediately beneath the node. 54 immediate: string[]; 55 } 56 57 /** 58 * Maps {@link Node}s to their computed {@link TextContent}. 59 */ 60 const textContentCache = new WeakMap<Node, TextContent>(); 61 const eraseFromCache = (node: Node | null) => { 62 while (node) { 63 textContentCache.delete(node); 64 if (node instanceof ShadowRoot) { 65 node = node.host; 66 } else { 67 node = node.parentNode; 68 } 69 } 70 }; 71 72 /** 73 * Erases the cache when the tree has mutated text. 74 */ 75 const observedNodes = new WeakSet<Node>(); 76 const textChangeObserver = new MutationObserver(mutations => { 77 for (const mutation of mutations) { 78 eraseFromCache(mutation.target); 79 } 80 }); 81 82 /** 83 * Builds the text content of a node using some custom logic. 84 * 85 * @remarks 86 * The primary reason this function exists is due to {@link ShadowRoot}s not having 87 * text content. 88 * 89 * @internal 90 */ 91 export const createTextContent = (root: Node): TextContent => { 92 let value = textContentCache.get(root); 93 if (value) { 94 return value; 95 } 96 value = {full: '', immediate: []}; 97 if (!isSuitableNodeForTextMatching(root)) { 98 return value; 99 } 100 101 let currentImmediate = ''; 102 if (isNonTrivialValueNode(root)) { 103 value.full = root.value; 104 value.immediate.push(root.value); 105 106 root.addEventListener( 107 'input', 108 event => { 109 eraseFromCache(event.target as HTMLInputElement); 110 }, 111 {once: true, capture: true}, 112 ); 113 } else { 114 for (let child = root.firstChild; child; child = child.nextSibling) { 115 if (child.nodeType === Node.TEXT_NODE) { 116 value.full += child.nodeValue ?? ''; 117 currentImmediate += child.nodeValue ?? ''; 118 continue; 119 } 120 if (currentImmediate) { 121 value.immediate.push(currentImmediate); 122 } 123 currentImmediate = ''; 124 if (child.nodeType === Node.ELEMENT_NODE) { 125 value.full += createTextContent(child).full; 126 } 127 } 128 if (currentImmediate) { 129 value.immediate.push(currentImmediate); 130 } 131 if (root instanceof Element && root.shadowRoot) { 132 value.full += createTextContent(root.shadowRoot).full; 133 } 134 135 if (!observedNodes.has(root)) { 136 textChangeObserver.observe(root, { 137 childList: true, 138 characterData: true, 139 subtree: true, 140 }); 141 observedNodes.add(root); 142 } 143 } 144 textContentCache.set(root, value); 145 return value; 146 };