| /** |
| * SPDX-FileCopyrightText: 2016-2021 The Apache Software Foundation |
| * SPDX-License-Identifier: Apache-2.0 |
| * @license |
| * Licensed to the Apache Software Foundation (ASF) under one |
| * or more contributor license agreements. See the NOTICE file |
| * distributed with this work for additional information |
| * regarding copyright ownership. The ASF licenses this file |
| * to you 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. |
| */ |
| |
| import { ownerDocument } from './owner-document'; |
| import { toRange } from './to-range'; |
| |
| /** |
| * Wrap each text node in a given Node or Range with a `<mark>` or other |
| * element. |
| * |
| * If a Range is given that starts and/or ends within a Text node, that node |
| * will be split in order to only wrap the contained part in the mark element. |
| * |
| * The highlight can be removed again by calling the function that cleans up the |
| * wrapper elements. Note that this might not perfectly restore the DOM to its |
| * previous state: text nodes that were split are not merged again. One could |
| * consider running `range.commonAncestorContainer.normalize()` afterwards to |
| * join all adjacent text nodes. |
| * |
| * @param target - The Node/Range containing the text. If it is a Range, note |
| * that as highlighting modifies the DOM, the Range may be unusable afterwards. |
| * @param tagName - The element used to wrap text nodes. Defaults to `'mark'`. |
| * @param attributes - An object defining any attributes to be set on the |
| * wrapper elements, e.g. its `class`. |
| * @returns A function that removes the created highlight. |
| * |
| * @public |
| */ |
| export function highlightText( |
| target: Node | Range, |
| tagName = 'mark', |
| attributes: Record<string, string> = {}, |
| ): () => void { |
| // First put all nodes in an array (splits start and end nodes if needed) |
| const nodes = textNodesInRange(toRange(target)); |
| |
| // Highlight each node |
| const highlightElements: HTMLElement[] = []; |
| for (const node of nodes) { |
| const highlightElement = wrapNodeInHighlight(node, tagName, attributes); |
| highlightElements.push(highlightElement); |
| } |
| |
| // Return a function that cleans up the highlightElements. |
| function removeHighlights() { |
| // Remove each of the created highlightElements. |
| for (const highlightElement of highlightElements) { |
| removeHighlight(highlightElement); |
| } |
| } |
| return removeHighlights; |
| } |
| |
| // Return an array of the text nodes in the range. Split the start and end nodes if required. |
| function textNodesInRange(range: Range): Text[] { |
| // If the start or end node is a text node and only partly in the range, split it. |
| if (isTextNode(range.startContainer) && range.startOffset > 0) { |
| const endOffset = range.endOffset; // (this may get lost when the splitting the node) |
| const createdNode = range.startContainer.splitText(range.startOffset); |
| if (range.endContainer === range.startContainer) { |
| // If the end was in the same container, it will now be in the newly created node. |
| range.setEnd(createdNode, endOffset - range.startOffset); |
| } |
| range.setStart(createdNode, 0); |
| } |
| if ( |
| isTextNode(range.endContainer) && |
| range.endOffset < range.endContainer.length |
| ) { |
| range.endContainer.splitText(range.endOffset); |
| } |
| |
| // Collect the text nodes. |
| const walker = ownerDocument(range).createTreeWalker( |
| range.commonAncestorContainer, |
| NodeFilter.SHOW_TEXT, |
| { |
| acceptNode: (node) => |
| range.intersectsNode(node) |
| ? NodeFilter.FILTER_ACCEPT |
| : NodeFilter.FILTER_REJECT, |
| }, |
| ); |
| walker.currentNode = range.startContainer; |
| |
| // // Optimise by skipping nodes that are explicitly outside the range. |
| // const NodeTypesWithCharacterOffset = [ |
| // Node.TEXT_NODE, |
| // Node.PROCESSING_INSTRUCTION_NODE, |
| // Node.COMMENT_NODE, |
| // ]; |
| // if (!NodeTypesWithCharacterOffset.includes(range.startContainer.nodeType)) { |
| // if (range.startOffset < range.startContainer.childNodes.length) { |
| // walker.currentNode = range.startContainer.childNodes[range.startOffset]; |
| // } else { |
| // walker.nextSibling(); // TODO verify this is correct. |
| // } |
| // } |
| |
| const nodes: Text[] = []; |
| if (isTextNode(walker.currentNode)) nodes.push(walker.currentNode); |
| while (walker.nextNode() && range.comparePoint(walker.currentNode, 0) !== 1) |
| nodes.push(walker.currentNode as Text); |
| return nodes; |
| } |
| |
| // Replace [node] with <tagName ...attributes>[node]</tagName> |
| function wrapNodeInHighlight( |
| node: ChildNode, |
| tagName: string, |
| attributes: Record<string, string>, |
| ): HTMLElement { |
| const document = node.ownerDocument as Document; |
| const highlightElement = document.createElement(tagName); |
| Object.keys(attributes).forEach((key) => { |
| highlightElement.setAttribute(key, attributes[key]); |
| }); |
| const tempRange = document.createRange(); |
| tempRange.selectNode(node); |
| tempRange.surroundContents(highlightElement); |
| return highlightElement; |
| } |
| |
| // Remove a highlight element created with wrapNodeInHighlight. |
| function removeHighlight(highlightElement: HTMLElement) { |
| // If it has somehow been removed already, there is nothing to be done. |
| if (!highlightElement.parentNode) return; |
| if (highlightElement.childNodes.length === 1) { |
| highlightElement.replaceWith(highlightElement.firstChild as Node); |
| } else { |
| // If the highlight somehow contains multiple nodes now, move them all. |
| while (highlightElement.firstChild) { |
| highlightElement.parentNode.insertBefore( |
| highlightElement.firstChild, |
| highlightElement, |
| ); |
| } |
| highlightElement.remove(); |
| } |
| } |
| |
| function isTextNode(node: Node): node is Text { |
| return node.nodeType === Node.TEXT_NODE; |
| } |