/**
* @typedef {Object} NodeAndOffset
* @property {Node} node - The DOM node that makes up a portion of a highlight.
* @property {number} offset - Offset within the node for a portion of a highlight.
* @property {number} normalisedOffset - Offset within the node's original text that excludes
* characters that are normalised away.
* @property {number} length - Length of the portion highlighted in the node.
* @property {string} normalisedText - Node's text content stripped of carriage returns
* and white spaces that follow carriage returns.
*
* @typedef {Object} NodesAndOffsetsResult
* @property {NodeAndOffset[]} nodesAndOffsets
* @property {string} allText
*
* @typedef {Object} RangeLite
* @property {HTMLElement} startContainer
* @property {number} startOffset
* @property {HTMLElement} endContainer
* @property {number} endOffset
*/
import dom, { NODE_TYPE } from "./dom";
import { DATA_ATTR, START_OFFSET_ATTR, LENGTH_ATTR, IGNORE_TAGS } from "../config";
import { arrayToLower } from "./arrays";
/**
* Takes range object as parameter and refines it boundaries
* @param range
* @returns {object} refined boundaries and initial state of highlighting algorithm.
*/
export function refineRangeBoundaries(range) {
let startContainer = range.startContainer,
endContainer = range.endContainer,
ancestor = range.commonAncestorContainer,
goDeeper = true;
if (range.endOffset === 0) {
while (!endContainer.previousSibling && endContainer.parentNode !== ancestor) {
endContainer = endContainer.parentNode;
}
endContainer = endContainer.previousSibling;
} else if (endContainer.nodeType === NODE_TYPE.TEXT_NODE) {
if (range.endOffset < endContainer.nodeValue.length) {
endContainer.splitText(range.endOffset);
}
} else if (range.endOffset > 0) {
endContainer = endContainer.childNodes.item(range.endOffset - 1);
}
if (startContainer.nodeType === NODE_TYPE.TEXT_NODE) {
if (range.startOffset === startContainer.nodeValue.length) {
goDeeper = false;
} else if (range.startOffset > 0) {
startContainer = startContainer.splitText(range.startOffset);
if (endContainer === startContainer.previousSibling) {
endContainer = startContainer;
}
}
} else if (range.startOffset < startContainer.childNodes.length) {
startContainer = startContainer.childNodes.item(range.startOffset);
} else {
startContainer = startContainer.nextSibling;
}
return {
startContainer: startContainer,
endContainer: endContainer,
goDeeper: goDeeper,
};
}
/**
* Sorts array of DOM elements by its depth in DOM tree.
* @param {HTMLElement[]} arr - array to sort.
* @param {boolean} descending - order of sort.
*/
export function sortByDepth(arr, descending) {
arr.sort(function(a, b) {
return dom(descending ? b : a).parents().length - dom(descending ? a : b).parents().length;
});
}
/**
* Returns true if elements a i b have the same color.
* @param {Node} a
* @param {Node} b
* @returns {boolean}
*/
export function haveSameColor(a, b) {
return dom(a).color() === dom(b).color();
}
/**
* Creates wrapper for highlights.
* TextHighlighter instance calls this method each time it needs to create highlights and pass options retrieved
* in constructor.
* @param {object} options - the same object as in TextHighlighter constructor.
* @param {Document} doc - the document to create the wrapper element with.
* @returns {HTMLElement}
*/
export function createWrapper(options, doc = document) {
let span = doc.createElement("span");
span.style.backgroundColor = options.color;
span.className = options.highlightedClass;
return span;
}
export function findTextNodeAtLocation(element, locationInChildNodes) {
let textNodeElement = element;
let i = 0;
while (textNodeElement && textNodeElement.nodeType !== NODE_TYPE.TEXT_NODE) {
if (locationInChildNodes === "start") {
if (textNodeElement.childNodes.length > 0) {
textNodeElement = textNodeElement.childNodes[0];
} else {
textNodeElement = textNodeElement.nextSibling;
}
} else if (locationInChildNodes === "end") {
if (textNodeElement.childNodes.length > 0) {
let lastIndex = textNodeElement.childNodes.length - 1;
textNodeElement = textNodeElement.childNodes[lastIndex];
} else {
textNodeElement = textNodeElement.previousSibling;
}
} else {
textNodeElement = null;
}
i++;
}
return textNodeElement;
}
function textContentExcludingTags(node, excludeNodeNames) {
return dom(node).textContentExcludingTags(arrayToLower(excludeNodeNames));
}
/**
* Deals with normalising text for when carriage returns and white space
* that directly follows should be ignored.
*
* @param {string} text
*/
function normaliseText(text) {
return text.replace(/((\r\n|\n\r|\n|\r)\s*)/g, "");
}
/**
* Deals with normalising text for when carriage returns and white space
* that directly follows or when white space and white space that directly
* follows should be ignored.
*
* @param {string} text
*
* @returns {string}
*/
function normaliseTextWithLeadingSpaces(text) {
return text.replace(/(((\r\n|\n\r|\n|\r)\s*)|(^\s+))/g, "");
}
/**
* Checks whether previous siblings end with a carriage return followed by
* zero or more whitespaces and selects the function that should be used to
* normalise the text.
*
* @param {Node} node
* @param {Node} parentNode
* @param {string} text
*
* @returns {string}
*/
function normaliseBasedOnPrevSibling(node, parentNode, text) {
const prevNode = dom(node).previousClosestSibling(parentNode);
if (prevNode) {
const matchRegex = /((\r\n|\n\r|\n|\r)\s*)$/;
const matches = matchRegex.test(prevNode.textContent);
if (matches) {
return normaliseTextWithLeadingSpaces(text);
}
}
return normaliseText(text);
}
/**
*
* @param {number} offsetWithinNode
* @param {string} text
*
* @returns {number}
*/
function normaliseOffset(offsetWithinNode, text) {
const matchResults = text.match(/^((\r\n|\n\r|\n|\r)\s*)/g);
if (!matchResults) {
return offsetWithinNode;
}
return offsetWithinNode + matchResults[0].length;
}
/**
* Determine where to inject a highlight based on it's offset.
* A highlight can span multiple nodes, so in here we accumulate
* all those nodes with offset and length of the content in the node
* included in the highlight.
*
* The normalisedOffset returned for each node when excludeWithSpaceAndReturns
* is set to true represents the normalised offset in the original text and NOT
* the normalised text.
*
* @param {*} highlight
* @param {*} parentNode
* @param {*} excludeNodeNames
* @param {boolean} excludeWhiteSpaceAndReturns
*
* @return {NodesAndOffsetsResult}
*/
export function findNodesAndOffsets(
highlight,
parentNode,
excludeNodeNames = IGNORE_TAGS,
excludeWhiteSpaceAndReturns = false,
) {
const nodesAndOffsets = [];
let currentNode = parentNode;
let currentOffset = 0;
const highlightEndOffset = highlight.offset + highlight.length;
let allText = "";
while (currentNode && currentOffset < highlightEndOffset) {
// Ensure we ignore node types that the caller has specified should be excluded.
if (!excludeNodeNames.includes(currentNode.nodeName)) {
const textContent = textContentExcludingTags(currentNode, excludeNodeNames);
const reducedTextContent = excludeWhiteSpaceAndReturns
? normaliseBasedOnPrevSibling(currentNode, parentNode, textContent)
: "";
if (currentNode == parentNode) {
allText = excludeWhiteSpaceAndReturns ? reducedTextContent : textContent;
}
const textLength = textContent.length;
const normalisedTextLength = normaliseBasedOnPrevSibling(currentNode, parentNode, textContent)
.length;
const endOfCurrentNodeOffset = currentOffset + textLength;
const normalisedEOCNodeOffset = excludeWhiteSpaceAndReturns
? currentOffset + normalisedTextLength
: endOfCurrentNodeOffset;
if (normalisedEOCNodeOffset > highlight.offset) {
const isTerminalNode = currentNode.childNodes.length === 0;
if (isTerminalNode) {
if (currentNode.nodeType === NODE_TYPE.TEXT_NODE) {
const offsetWithinNode =
highlight.offset > currentOffset ? highlight.offset - currentOffset : 0;
// Only exclude text normalised away at the start of the entire highlight.
const normalisedOffset =
excludeWhiteSpaceAndReturns && nodesAndOffsets.length === 0
? normaliseOffset(offsetWithinNode, textContent)
: offsetWithinNode;
const normalisedOffsetDiff = Math.abs(normalisedOffset - offsetWithinNode);
// Remove further carriage returns and white spaces that directly follow
// from the length in the node
// that may be in the middle or at the end of the node.
const textFromNormalisedOffset = textContent.substr(normalisedOffset);
const charactersToIgnoreInside = excludeWhiteSpaceAndReturns
? textFromNormalisedOffset.length -
normaliseBasedOnPrevSibling(currentNode, parentNode, textFromNormalisedOffset)
.length
: 0;
const nextNodeOffset =
endOfCurrentNodeOffset - normalisedOffsetDiff - charactersToIgnoreInside;
const lengthInHighlight =
highlightEndOffset >= nextNodeOffset
? textLength - offsetWithinNode
: // While counting the actual amount of text in the DOM node, we need to retain
// any characters that are ignored when determining nodes from a highlight offset and length
// to know exactly where to inject the highlight's spans without modifying the original text.
highlightEndOffset - currentOffset - offsetWithinNode + charactersToIgnoreInside;
// Only exclude text normalised away from the node at the start of the
// entire highlight.
const normalisedLengthInHighlight =
excludeWhiteSpaceAndReturns && nodesAndOffsets.length === 0
? lengthInHighlight - normalisedOffsetDiff
: lengthInHighlight;
if (normalisedLengthInHighlight > 0) {
nodesAndOffsets.push({
node: currentNode,
offset: normalisedOffset,
length: normalisedLengthInHighlight,
normalisedText: excludeWhiteSpaceAndReturns
? normaliseBasedOnPrevSibling(currentNode, parentNode, currentNode.textContent)
: currentNode.textContent,
});
}
currentOffset = nextNodeOffset;
}
// It doesn't matter if it is a text node or not at this point,
// we still need to get the next sibling of the node or it's ancestors.
currentNode = dom(currentNode).nextClosestSibling(parentNode);
} else {
currentNode = currentNode.childNodes[0];
}
} else {
currentOffset = normalisedEOCNodeOffset;
if (currentNode !== parentNode) {
currentNode = currentNode.nextSibling;
} else {
currentNode = null;
}
}
} else {
currentNode = dom(currentNode).nextClosestSibling(parentNode);
}
}
return { nodesAndOffsets, allText };
}
export function getElementOffset(
childElement,
rootElement,
excludeNodeNames = IGNORE_TAGS,
excludeWhiteSpaceAndReturns = false,
startOffset = 0,
isStartOfRange = false,
) {
let offset = 0;
let childNodes;
let currentElement = childElement;
do {
// Ensure specified node types are not counted in the offset.
if (!excludeNodeNames.includes(currentElement.nodeName)) {
childNodes = currentElement.parentNode.childNodes;
const childElementIndex = dom(currentElement.parentNode).getChildIndex(currentElement);
const offsetInCurrentParent = getTextOffsetBefore(
childNodes,
childElementIndex,
excludeNodeNames,
excludeWhiteSpaceAndReturns,
);
offset += offsetInCurrentParent;
}
currentElement = currentElement.parentNode;
} while (currentElement !== rootElement || !currentElement);
return excludeWhiteSpaceAndReturns && isStartOfRange ? offset : offset + startOffset;
}
function getTextOffsetBefore(
childNodes,
cutIndex,
excludeNodeNames,
excludeWhiteSpaceAndReturns = false,
) {
let textOffset = 0;
for (let i = 0; i < cutIndex; i++) {
const currentNode = childNodes[i];
// Strip out all nodes from the child node that we should be excluding.
//
// Use textContent and not innerText to account for invisible characters such as carriage returns as well,
// plus innerText forces a reflow of the layout and as we access text content of nodes
// a lot in the highlighting process, we don't want to take the performance hit.
// https://developer.mozilla.org/en-US/docs/Web/API/Node/textContent
const text = dom(currentNode).textContentExcludingTags(arrayToLower(excludeNodeNames));
if (!excludeNodeNames.includes(currentNode.nodeName) && text && text.length > 0) {
textOffset += excludeWhiteSpaceAndReturns ? normaliseText(text).length : text.length;
}
}
return textOffset;
}
export function findFirstNonSharedParent(elements) {
let childElement = elements.childElement;
let otherElement = elements.otherElement;
let parents = dom(childElement).parentsWithoutDocument();
let i = 0;
let firstNonSharedParent = null;
let allParentsAreShared = false;
while (!firstNonSharedParent && !allParentsAreShared && i < parents.length) {
const currentParent = parents[i];
if (currentParent.contains(otherElement)) {
if (i > 0) {
firstNonSharedParent = parents[i - 1];
} else {
allParentsAreShared = true;
}
}
i++;
}
return firstNonSharedParent;
}
function gatherSiblingsUpToEndNode(startNodeOrContainer, endNode) {
const gatheredSiblings = [];
let foundEndNodeSibling = false;
let currentNode = startNodeOrContainer.nextSibling;
while (currentNode && !foundEndNodeSibling) {
if (currentNode === endNode || currentNode.contains(endNode)) {
foundEndNodeSibling = true;
} else {
gatheredSiblings.push(currentNode);
currentNode = currentNode.nextSibling;
}
}
return { gatheredSiblings, foundEndNodeSibling };
}
/**
* Gets all the nodes in between the provided start and end.
*
* @param {HTMLElement} startNode
* @param {HTMLElement} endNode
* @returns {HTMLElement[]} Nodes that live in between the two.
*/
export function nodesInBetween(startNode, endNode) {
if (startNode === endNode) {
return [];
}
// First attempt the easiest solution, hoping endNode will be at the same level
// as the start node or contained in an element at the same level.
const {
foundEndNodeSibling: foundEndNodeSiblingOnSameLevel,
gatheredSiblings,
} = gatherSiblingsUpToEndNode(startNode, endNode);
if (foundEndNodeSiblingOnSameLevel) {
return gatheredSiblings;
}
// Now go for the route that goes to the highest parent of the start node in the tree
// that is not the parent of the end node.
const startNodeParent = findFirstNonSharedParent({
childElement: startNode,
otherElement: endNode,
});
if (startNodeParent) {
const {
foundEndNodeSibling: foundEndNodeSiblingFromParentLevel,
gatheredSiblings: gatheredSiblingsFromParent,
} = gatherSiblingsUpToEndNode(startNodeParent, endNode);
if (foundEndNodeSiblingFromParentLevel) {
return gatheredSiblingsFromParent;
}
}
return [];
}
/**
* Groups given highlights by timestamp.
* @param {Array} highlights
* @param {string} timestampAttr
* @returns {Array} Grouped highlights.
*/
export function groupHighlights(highlights, timestampAttr) {
let order = [],
chunks = {},
grouped = [];
highlights.forEach(function(hl) {
let timestamp = hl.getAttribute(timestampAttr);
if (typeof chunks[timestamp] === "undefined") {
chunks[timestamp] = [];
order.push(timestamp);
}
chunks[timestamp].push(hl);
});
order.forEach(function(timestamp) {
let group = chunks[timestamp];
grouped.push({
chunks: group,
timestamp: timestamp,
toString: function() {
return group
.map(function(h) {
return h.textContent;
})
.join("");
},
});
});
return grouped;
}
export function retrieveHighlights(params) {
params = {
andSelf: true,
grouped: false,
...params,
};
let nodeList = params.container.querySelectorAll("[" + params.dataAttr + "]"),
highlights = Array.prototype.slice.call(nodeList);
if (params.andSelf === true && params.container.hasAttribute(params.dataAttr)) {
highlights.push(params.container);
}
if (params.grouped) {
highlights = groupHighlights(highlights, params.timestampAttr);
}
return highlights;
}
export function isElementHighlight(el, dataAttr) {
return el && el.nodeType === NODE_TYPE.ELEMENT_NODE && el.hasAttribute(dataAttr);
}
export function addNodesToHighlightAfterElement({
element,
elementAncestor,
highlightWrapper,
highlightedClass,
}) {
if (elementAncestor) {
if (elementAncestor.classList.contains(highlightedClass)) {
// Ensure we only take the children from a parent that is a highlight.
elementAncestor.childNodes.forEach((childNode) => {
// if (dom(childNode).isAfter(element)) {
// }
elementAncestor.appendChild(childNode);
});
} else {
highlightWrapper.appendChild(elementAncestor);
}
} else {
highlightWrapper.appendChild(element);
}
}
/**
* Collects the human-readable highlighted text for all nodes in the selected range.
*
* @param {Range} range
*
* @return {string} The human-readable highlighted text for the given range.
*/
export function getHighlightedTextForRange(range, excludeTags = IGNORE_TAGS) {
// Strip out all carriage returns and excess html layout space.
return dom(range.cloneContents())
.textContentExcludingTags(arrayToLower(excludeTags))
.replace(/\s{2,}/g, " ")
.replace("\r\n", "")
.replace("\r", "")
.replace("\n", "");
}
/**
* Collects the human-readable highlighted text for all nodes from the start text offset
* relative to the root element.
*
* @param {{ rootElement: HTMLElement, startOffset: number, length: number}} params
* The root-relative parameters for extracting highlighted text.
* @param {Document} doc The document to create new elements in as a part of the process of
* extracting highlighted text.
*
* @return {string} The human-readable highlighted text for the given root element, offset and length.
*/
export function getHighlightedTextRelativeToRoot(
{
rootElement,
startOffset,
length,
excludeTags = IGNORE_TAGS,
excludeWhiteSpaceAndReturns = false,
},
doc = document,
) {
const textContent = dom(rootElement).textContentExcludingTags(arrayToLower(excludeTags));
const finalTextContent = excludeWhiteSpaceAndReturns ? normaliseText(textContent) : textContent;
const highlightedRawText = finalTextContent.substring(
startOffset,
Number.parseInt(startOffset) + Number.parseInt(length),
);
const textNode = doc.createTextNode(highlightedRawText);
const tempContainer = doc.createElement("div");
tempContainer.appendChild(textNode);
// Extract the human-readable text only.
return tempContainer.innerText;
}
export function createDescriptors({
rootElement,
range,
wrapper,
excludeNodeNames = IGNORE_TAGS,
dataAttr = DATA_ATTR,
excludeWhiteSpaceAndReturns = false,
}) {
const wrapperClone = wrapper.cloneNode(true);
const normalisedStartOffset = excludeWhiteSpaceAndReturns
? normaliseOffset(range.startOffset, range.startContainer.textContent)
: range.startOffset;
const startOffset = getElementOffset(
range.startContainer,
rootElement,
excludeNodeNames,
excludeWhiteSpaceAndReturns,
normalisedStartOffset,
true,
);
const normalisedEndOffset = excludeWhiteSpaceAndReturns
? normaliseOffset(range.endOffset, range.endContainer.textContent)
: range.endOffset;
const endOffset =
range.startContainer === range.endContainer
? startOffset + (normalisedEndOffset - normalisedStartOffset)
: getElementOffset(
range.endContainer,
rootElement,
excludeNodeNames,
excludeWhiteSpaceAndReturns,
normalisedEndOffset,
false,
);
const length = endOffset - startOffset;
wrapperClone.setAttribute(dataAttr, true);
wrapperClone.setAttribute(START_OFFSET_ATTR, startOffset);
wrapperClone.setAttribute(LENGTH_ATTR, length);
wrapperClone.innerHTML = "";
const wrapperHTML = wrapperClone.outerHTML;
const descriptor = [
wrapperHTML,
// retrieve all the text content between the start and end offsets.
getHighlightedTextForRange(range, excludeNodeNames),
startOffset,
length,
];
return [descriptor];
}
/**
* Returns the namespaces for the provided element given a list of namespaces
* @param {HTMLElement} element
* @param {Array<string>} namespaces
*
* @param {string}
*/
function getHighlighterNamespace(element, namespaces) {
return namespaces.find((namespace) => !!element.getAttribute(namespace));
}
/**
* Collects all the higher priority highlight nodes
* when trying to focus or deserialise a portion of a highlight
* into a given DOM node.
*
* @param {HTMLElement} rootElement The parent element.
* @param {HTMLElement} node The current node.
* @param {Record<string, number>} priorities The priorities for multiple highlighters.
* @param {string} namespaceDataAttribute The namespace data attribute for highlights for a provided text highlighter instance.
*
* @return {HTMLElement[]}
*/
export function findHigherPriorityHighlights(parentNode, node, priorities, namespaceDataAttribute) {
const ancestors = dom(node).parentsUpTo(parentNode);
const namespacePriority = priorities[namespaceDataAttribute];
const higherPriorityHighlights = [];
ancestors.forEach((element) => {
const namespace = getHighlighterNamespace(element, Object.keys(priorities));
if (namespace && priorities[namespace] > namespacePriority) {
higherPriorityHighlights.push({ element, namespacePriority: priorities[namespace] });
}
});
higherPriorityHighlights.sort((a, b) => {
return b.namespacePriority - a.namespacePriority;
});
return higherPriorityHighlights.map(({ element }) => element);
}
/**
* Determines whether the highlight with the provided unique id is the closest parent
* to the provided node of all the potential highlight wrappers.
*
* In the case an id is not provided it will simply check if any highlight for the given
* dataAttr namespace is the closest parent.
*
* @param {HTMLElement} node The element we need to get parent information for.
* @param {HTMLElement} rootElement The root element of the context to stop at.
* @param {string} dataAttr The namespace data attribute for highlights for a provided text highlighter instance.
* @param {string} id The unique id of the collection of elements representing a highlight.
*
* @return {boolean}
*/
function isClosestHighlightParent(node, rootElement, dataAttr = DATA_ATTR, id = null) {
let isClosest = true;
let currentNode = node.parentNode;
let nodeHighlightParentCount = 0;
while (currentNode && currentNode !== rootElement && isClosest) {
if (isElementHighlight(currentNode, dataAttr)) {
const isDifferentHighlight = id && !currentNode.classList.contains(id);
nodeHighlightParentCount += 1;
if (isDifferentHighlight) {
// The case there is a closer parent than the highlight for the provided id.
isClosest = false;
} else {
currentNode = currentNode.parentNode;
}
} else {
currentNode = currentNode.parentNode;
}
}
// In the case the provided node doesn't have any highlight wrapper
// parents in the tree, then the provided highlight is not considered
// the closest parent as there aren't any.
return nodeHighlightParentCount === 0 ? false : isClosest;
}
/**
* Focuses a set of highlight elements for a given id by ensuring if it has descendants that are highlights
* it is moved inside of the innermost highlight.
*
* The innermost highlight's styles will be applied and will be visible to the user
* and given the "focus".
*
* To focus the red highlight the following:
*
* -- <red-highlight>
* ---- <blue-highlight>
* ------ <green-highlight>
* ---------- Highlighted text
*
* becomes:
*
* -- <blue-highlight>
* ---- <green-highlight>
* ------ <red-highlight>
* -------- Highlighted text
*
* and
*
* -- <red-highlight>
* ---- Some text only highlighted in red
* ---- <blue-highlight>
* ------ Text in blue and red
* ------ <green-highlight>
* ---------- Rest of the highlight in red, green and blue
*
* becomes
*
* -- <red-highlight>
* ---- Some text only highlighted in red
* -- <blue-highlight>
* ---- <red-highlight-copy-1>
* ------ Text in blue and red
* ---- <green-highlight>
* ------ <red-highlight-copy-2>
* -------- Rest of the highlight in red, green and blue
*
* @typedef NodeInfo
* @type {object}
* @property {HTMLElement} nodeInfo.node The html element (This will in most cases be a text node)
* @property {number} nodeInfo.offset The offset within the node to be highlighted
* @property {number} nodeInfo.length The length within the node that should be highlighted.
*
* @param {string} id The unique identifier of a highlight represented by one or more nodes in the DOM.
* @param {NodeInfo[]} nodeInfoList The highlight portion node information that should be focused.
* @param {HTMLElement} highlightWrapper The highlight wrapper representing the highlight to be focused.
*
* @param {HTMLElement} rootElement The root context element to normalise elements within.
* @param {string} highlightedClass The class used to identify highlights.
* @param {boolean} normalizeElements Whether or not elements should be normalised.
* @param {Record<string, number>} priorities Provides priorities for multiple highlighters operating in the same root node.
* @param {string} dataAttr The namespace data attribute for highlights for a provided text highlighter instance.
*/
export function focusHighlightNodes(
id,
nodeInfoList,
highlightWrapper,
rootElement,
highlightedClass,
normalizeElements,
priorities,
dataAttr = DATA_ATTR,
) {
nodeInfoList.forEach((nodeInfo) => {
const node = nodeInfo.node;
// Only wrap the node if the closest highlight parent isn't one with the given id
// and if the highlighter takes priority over highlights from other highlighter instances.
const higherPriorityHighlights = findHigherPriorityHighlights(
rootElement,
node,
priorities,
dataAttr,
);
const isClosest = isClosestHighlightParent(node, rootElement, dataAttr, id);
if (higherPriorityHighlights.length === 0 && !isClosest) {
// Ensure any ancestors that aren't direct parents that represent the same highlight wrapper are removed.
const ancestors = dom(node).parentsUpTo(rootElement);
ancestors.forEach((ancestor) => {
if (isElementHighlight(ancestor, dataAttr) && ancestor.classList.contains(id)) {
// Ensure a copy of the ancestor is wrapped back around any
// other children that do not contain the current node.
ancestor.childNodes.forEach((ancestorChild) => {
if (!ancestorChild.contains(node)) {
const wrapper = highlightWrapper.cloneNode(true);
dom(ancestorChild).wrap(wrapper);
}
});
dom(ancestor).unwrap();
}
});
// Now wrap the node or the part of the node the highlight covers directly with the wrapper.
let nodeToBeWrapped = node;
if (nodeInfo.offset > 0) {
nodeToBeWrapped = node.splitText(nodeInfo.offset);
}
if (nodeInfo.length < nodeToBeWrapped.textContent.length) {
nodeToBeWrapped.splitText(nodeInfo.length);
}
dom(nodeToBeWrapped).wrap(highlightWrapper.cloneNode(true));
}
});
if (normalizeElements) {
// Ensure we normalise all nodes in the root container to merge sibling elements
// of the same highlight together that get copied for the purpose of focusing.
dom(rootElement).normalizeElements(highlightedClass, dataAttr);
}
}
/**
* Validation for descriptors to ensure they are of the correct format to be used
* by the Independencia highlighter.
*
* @param {array} descriptors The descriptors to be validated.
* @return {boolean} - if the descriptors are valid or not.
*/
export function validateIndependenciaDescriptors(descriptors) {
if (descriptors && descriptors.length === 4) {
return true;
}
return false;
}
/**
* Extracts a sub-range from a given window selection range
* that only includes the given root element and its descendants.
*
* @param {RangeLite} range The current text selection range for the window.
* @param {HTMLElement} rootElement The root element to extract a sub-range for.
*
* @returns {Range | null} The sub-range or null if rootElement is not in the text selection.
*/
export function extractRangeRelativeToRootElement(range, rootElement) {
// It's really important that we extract sub-ranges without manipulating
// the window selection as there are situations where multiple highlighters
// will be extracting sub-ranges from the current selection at the same time.
// This is why we won't be using Range.extractContents.
const hasStartContainer = dom(rootElement).contains(range.startContainer);
const hasEndContainer = dom(rootElement).contains(range.endContainer);
if (!hasStartContainer && !hasEndContainer) {
return null;
}
if (hasStartContainer && !hasEndContainer) {
const endContainer = getLastDescendantTerminalNode(rootElement);
const subRange = new rootElement.ownerDocument.defaultView.Range();
subRange.setStart(range.startContainer, range.startOffset);
subRange.setEnd(endContainer, endContainer.textContent.length - 1);
return subRange;
}
if (!hasStartContainer && hasEndContainer) {
const startContainer = getFirstDescendantTerminalNode(rootElement);
const subRange = new rootElement.ownerDocument.defaultView.Range();
subRange.setStart(startContainer, 0);
subRange.setEnd(range.endContainer, range.endOffset);
return subRange;
}
return range;
}
/**
* Finds the first descendant node without any children
* of it's own starting with the given root node.
*
* @param {Node} rootNode The root node from which to find the first descendant terminal node for.
*
* @returns {Node} the first descendant terminal node.
*/
function getFirstDescendantTerminalNode(rootNode) {
let currentNode = rootNode;
while (currentNode.childNodes.length > 0) {
currentNode = currentNode.childNodes[0];
}
return currentNode;
}
/**
* Finds the last descendant node without any children
* of it's own starting with the given root node.
*
* @param {Node} rootNode The root node from which to find the last descendant terminal node for.
*
* @returns {Node} the last descendant terminal node.
*/
function getLastDescendantTerminalNode(rootNode) {
let currentNode = rootNode;
while (currentNode.childNodes.length > 0) {
currentNode = currentNode.childNodes[currentNode.childNodes.length - 1];
}
return currentNode;
}