| /** |
| * @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. |
| */ |
| |
| // Wrap each text node in a given DOM Range with a <mark> or other element. |
| // Breaks start and/or end node if needed. |
| // Returns a function that cleans up the created highlight (not a perfect undo: split text nodes are |
| // not merged again; if desired, you could run range.commonAncestorContainer.normalize() afterwards). |
| // |
| // Parameters: |
| // - range: a DOM Range object. Note that as highlighting modifies the DOM, the range may be |
| // unusable afterwards |
| // - tagName: the element used to wrap text nodes. Defaults to 'mark'. |
| // - attributes: an Object defining any attributes to be set on the wrapper elements. |
| export function highlightRange( |
| range: 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(range); |
| |
| // 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 document = |
| range.commonAncestorContainer.ownerDocument ?? |
| (range.commonAncestorContainer as Document); |
| const walker = document.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; |
| } |