blob: aebe2d938f4dd134821a44013b337df39cc87cb0 [file] [log] [blame]
/**
* SPDX-FileCopyrightText: 2016-2020 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;
}