blob: c4928c9aa1e5a47cd5092d22d424e56177488c28 [file] [log] [blame]
/**
* @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;
}