2022-09-19 10:49:13 +00:00
|
|
|
/**
|
|
|
|
* Copyright 2022 Google Inc. All rights reserved.
|
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
|
|
|
|
2022-09-15 11:12:13 +00:00
|
|
|
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.
|
2022-09-19 14:28:18 +00:00
|
|
|
*
|
|
|
|
* @internal
|
2022-09-15 11:12:13 +00:00
|
|
|
*/
|
|
|
|
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.
|
2022-09-15 16:48:55 +00:00
|
|
|
*
|
|
|
|
* @internal
|
2022-09-15 11:12:13 +00:00
|
|
|
*/
|
2022-09-15 16:48:55 +00:00
|
|
|
export const isSuitableNodeForTextMatching = (node: Node): boolean => {
|
2022-09-15 11:12:13 +00:00
|
|
|
return (
|
|
|
|
!UNSUITABLE_NODE_NAMES.has(node.nodeName) && !document.head?.contains(node)
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
|
|
|
/**
|
|
|
|
* @internal
|
|
|
|
*/
|
|
|
|
export type 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}.
|
|
|
|
*/
|
2022-09-15 16:48:55 +00:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
});
|
2022-09-15 11:12:13 +00:00
|
|
|
|
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
}
|
2022-09-15 16:48:55 +00:00
|
|
|
|
2022-09-15 11:12:13 +00:00
|
|
|
let currentImmediate = '';
|
|
|
|
if (isNonTrivialValueNode(root)) {
|
|
|
|
value.full = root.value;
|
|
|
|
value.immediate.push(root.value);
|
2022-09-15 16:48:55 +00:00
|
|
|
|
|
|
|
root.addEventListener(
|
|
|
|
'input',
|
|
|
|
event => {
|
|
|
|
eraseFromCache(event.target as HTMLInputElement);
|
|
|
|
},
|
|
|
|
{once: true, capture: true}
|
|
|
|
);
|
2022-09-15 11:12:13 +00:00
|
|
|
} 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;
|
|
|
|
}
|
2022-09-15 16:48:55 +00:00
|
|
|
|
|
|
|
if (!observedNodes.has(root)) {
|
|
|
|
textChangeObserver.observe(root, {
|
|
|
|
childList: true,
|
|
|
|
characterData: true,
|
|
|
|
});
|
|
|
|
observedNodes.add(root);
|
|
|
|
}
|
2022-09-15 11:12:13 +00:00
|
|
|
}
|
|
|
|
textContentCache.set(root, value);
|
|
|
|
return value;
|
|
|
|
};
|