My first approach to this problem was to naively apply the Range.surroundContents()
function to the range. Unfortunately, this doesn't work in all cases as MDN points out:
This method is nearly equivalent to newNode.appendChild(range.extractContents());
range.insertNode(newNode)
. After surrounding, the boundary points of the range include newNode.
An exception will be thrown, however, if the Range
splits a non-Text
node with only one of its boundary points. That is, unlike the alternative above, if there are partially selected nodes, they will not be cloned and instead the operation will fail.
const article = document.querySelector('article');
article.addEventListener('mouseup', () => {
/* Get and check that we have a selection. */
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
/* Wrap our selection in a new styled span. */
const highlight = Object.assign(document.createElement('span'), { classList: 'highlight' });
range.surroundContents(highlight);
selection.empty();
}, false);
.highlight {
color: black;
background-color: yellow;
}
<article>
<p>A selection in this paragraph will be highlighted correctly, but if the selection extends into the next paragraph...</p>
<p>...an InvalidStateError will be thrown because the selection contains only the closing </p> tag from the previous paragraph (and probably only the opening <p> tag from this paragraph). As above, a selection completely inside this paragraph will be highlighted correctly.</p>
</article>
To get around this limitation, I've taken the following approach:
- Find all text nodes completely contained by the range and highlight them. This does not include the first or last nodes in the range and may be zero nodes in total.
- For the first node in the range, highlight the portion of its text that is also contained in the selection. If the first node is also the last node, that means highlighting the text from the start of the selection to the end of the selection (which may not be the end of the text in the first node). If the first node is not also the last node, highlight to the end of the first node.
- If the first node wasn't also the last node, highlight the portion of the text in the last node that is also contained in the selection. This should always be from the start of the last node to the end of the selection (which may also be the end of the text in the last node).
The solution I'm proposing preserves other formatting and does not remove/replace nodes in the document, so all event listeners should still work.
There are still edge cases because only some elements are valid children for a <span>
. If your document is mostly text, this should work fine.
I'm standing on the shoulders of giants here, so I want to acknowledge their contributions first:
The following function comes from this answer by Tim Down. It returns the text nodes in a provided ancestor between a provided start and end node (non-inclusively). I've used it in my solution to get a list of nodes in the middle of the selection (the start and end nodes will be special cases as you'll see). To allow my contributions to my solution to be more readily apparent, I've minified the function in the snippet.
function getTextNodesBetween(rootNode, startNode, endNode) {
var pastStartNode = false, reachedEndNode = false, textNodes = [];
function getTextNodes(node) {
if (node == startNode) {
pastStartNode = true;
} else if (node == endNode) {
reachedEndNode = true;
} else if (node.nodeType == 3) {
if (pastStartNode && !reachedEndNode && !/^\s*$/.test(node.nodeValue)) {
textNodes.push(node);
}
} else {
for (var i = 0, len = node.childNodes.length; !reachedEndNode && i < len; ++i) {
getTextNodes(node.childNodes[i]);
}
}
}
getTextNodes(rootNode);
return textNodes;
}
The approach to highlighting text inside the start and end nodes comes from the following function from this answer by Sebastian Simon. In its presented form, it highlights all occurances of a provided phrase (the employee
parameter) in the current document. I have not minified this function because only parts appear in my solution.
function highlight(employee){
Array.from(document.querySelectorAll("body, body *:not(script):not(style):not(noscript)"))
.flatMap(({childNodes}) => [...childNodes])
.filter(({nodeType, textContent}) => nodeType === document.TEXT_NODE && textContent.includes(employee))
.forEach((textNode) => textNode.replaceWith(...textNode.textContent.split(employee).flatMap((part) => [
document.createTextNode(part),
Object.assign(document.createElement("mark"), {
textContent: employee
})
])
.slice(0, -1))); // The above flatMap creates a [text, employeeName, text, employeeName, text, employeeName]-pattern. We need to remove the last superfluous employeeName.
}
Here's a verbose snippet so you can hopefully see what's going on at each step. You'll see the following properties used:
The Range.startContainer
read-only property returns the Node
within which the Range starts.
The Range.endContainer
read-only property returns the Node
within which the Range ends.
The Range.commonAncestorContainer
read-only property returns the deepest — or furthest down the document tree — Node
that contains both boundary points of the Range. This means that if Range.startContainer
and Range.endContainer
both refer to the same node, this node is the common ancestor container.
/* This is the minified function from Tim Down. */
function getTextNodesBetween(e,n,s){var t=!1,o=!1,d=[];return function e(i){if(i==n)t=!0;else if(i==s)o=!0;else if(3==i.nodeType)!t||o||/^\s*$/.test(i.nodeValue)||d.push(i);else for(var l=0,f=i.childNodes.length;!o&&l<f;++l)e(i.childNodes[l])}(e),d}
const article = document.querySelector('article');
article.addEventListener('mouseup', () => {
/* Get and check that we have a selection. */
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
/* These declarations separated for clarity. */
const ancestor = range.commonAncestorContainer;
const start = range.startContainer;
const end = range.endContainer;
/* Step 1: nodes contained entirely by the selection. */
const nodes = getTextNodesBetween(ancestor, start, end);
nodes.forEach(n => {
if (n.nodeType !== document.TEXT_NODE) return;
const highlight = Object.assign(document.createElement('span'), { classList: 'highlight' });
n.parentNode.insertBefore(highlight, n);
highlight.appendChild(n);
});
/* Step 2: the first node in the selection. */
const startText = start.textContent;
const highlightInStart = startText.substring(range.startOffset, start === end ? range.endOffset : startText.length);
/* Here's part of the function from Sebastian Simon. */
start.replaceWith(...start.textContent.split(highlightInStart).flatMap((part) => [
document.createTextNode(part),
Object.assign(document.createElement("span"), {
textContent: highlightInStart,
classList: 'highlight'
})
]).slice(0, -1));
if (start === end) { selection.empty(); return; }
/* Step 3: the last node in the selection. */
const endText = end.textContent;
const highlightInEnd = endText.substring(0, range.endOffset);
/* Here's part of the function from Sebastian Simon. */
end.replaceWith(...end.textContent.split(highlightInEnd).flatMap((part) => [
document.createTextNode(part),
Object.assign(document.createElement("span"), {
textContent: highlightInEnd,
classList: 'highlight'
})
]).slice(0, -1));
selection.empty();
}, false);
.highlight {
color: black;
background-color: yellow;
}
<article style="padding-bottom: 40em;">
<h1>Vivamus aliquet fringilla tortor, at.</h1>
<h2>Duis id nunc vitae nulla accumsan cursus <em>eu vitae nibh</em>. Phasellus semper varius tellus.</h2>
<p>Lorem ipsum <sub>dolor sit amet, consectetur adipiscing elit</sub>. Mauris efficitur fringilla orci, vitae eleifend est bibendum sit amet. Ut fringilla, augue vitae egestas porta, diam dolor pharetra ex, nec bibendum erat risus a purus. Nulla dolor neque, maximus ut risus vitae, porttitor posuere nisl. Aliquam hendrerit sapien vitae nisl pharetra, nec rhoncus risus porttitor. <strong>Morbi venenatis malesuada quam</strong>, at finibus massa malesuada sit amet. Nam mauris enim, ullamcorper nec massa ac, commodo pellentesque magna. Etiam venenatis, quam porttitor maximus tempor, arcu dui efficitur libero, id accumsan sem sapien at nunc. Vivamus eget libero congue, <span style="color: blue">posuere ex eu, egestas enim. Curabitur tempor arcu sed velit tincidunt, ut porta ante varius. Morbi laoreet mauris quis elit accumsan, at vehicula tellus imperdiet.</span> Nulla gravida justo ac sem aliquam, ut elementum sem porta. Sed commodo, arcu quis viverra interdum, elit velit dignissim est, in fermentum massa risus at magna.</p>
<p>Nunc vestibulum sed lacus nec venenatis. Morbi fermentum dignissim nisi sed aliquam. Quisque fringilla velit id laoreet tempor. Donec et congue est, eget condimentum lacus. Maecenas mollis, risus eget commodo molestie, dui massa tristique nisi, at pellentesque enim arcu id dolor. Fusce a elementum nisi. Cras aliquet metus ut lorem tincidunt imperdiet. <span style="color: red">Maecenas vel scelerisque erat, at gravida ex.</span> Mauris mi arcu, mattis in sapien in, lobortis hendrerit diam. Nullam laoreet fringilla sapien ac molestie. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.</p>
<p>Aliquam erat volutpat. Pellentesque elementum massa sit amet ipsum lobortis, sed tincidunt eros dignissim. Donec dapibus tortor leo, vitae gravida ante sagittis ac. <i>Sed dapibus tincidunt ligula, in imperdiet tellus. Ut fermentum, risus sed porta interdum, nulla justo mattis metus, nec maximus lacus turpis laoreet nunc. Nunc varius odio a urna placerat posuere. Donec non enim in ligula placerat convallis at a ante. Nam purus sapien, viverra at imperdiet at, dictum at ante. Nullam sagittis, enim et vehicula tempus, nunc enim egestas erat, sed imperdiet dui turpis id lacus. Sed ante turpis, commodo eu sapien eu, iaculis consequat risus.</i> Aliquam a sodales neque. Sed dignissim finibus ex.</p>
<h2>Duis mollis massa eu egestas venenatis. Proin.</h2>
<p>Sed interdum nisl hendrerit leo commodo fermentum. Nam condimentum eleifend erat, sit amet congue ligula sollicitudin a. Nunc massa diam, efficitur vel mi non, feugiat sollicitudin neque. Aenean hendrerit, <s>diam ac ullamcorper mollis, risus</s> leo vehicula metus, eget hendrerit diam arcu vel tellus. <sup>Suspendisse eget</sup> velit molestie, tincidunt est lacinia, feugiat mi. Quisque nulla odio, auctor sit amet magna eget, pulvinar finibus risus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam ornare luctus ex et venenatis. Vivamus mollis ut magna sed tempor.</p>
<p>Suspendisse et sapien elementum, venenatis est ac, semper felis. Pellentesque euismod ultrices nulla, et malesuada risus molestie a. Aliquam bibendum lectus orci, nec aliquam est bibendum eget. Morbi molestie sapien vitae semper pharetra. Mauris fringilla libero eget risus sagittis tincidunt et eu augue. Donec ac quam tortor. Nunc molestie urna vitae interdum dapibus. Duis non eleifend ante.</p>
</article>
Here's a version that isn't so verbose. This is what I would go with as a mixture of legibility and brevity.
function getTextNodesBetween(e,n,s){var t=!1,o=!1,d=[];return function e(i){if(i==n)t=!0;else if(i==s)o=!0;else if(3==i.nodeType)!t||o||/^\s*$/.test(i.nodeValue)||d.push(i);else for(var l=0,f=i.childNodes.length;!o&&l<f;++l)e(i.childNodes[l])}(e),d}
const highlightWholeNode = (node) => {
if (node.nodeType !== document.TEXT_NODE) return;
const highlight = Object.assign(document.createElement('span'), { classList: 'highlight' });
node.parentNode.insertBefore(highlight, node);
highlight.appendChild(node);
};
const highlightInNode = (node, highlight) => {
if (node.nodeType !== document.TEXT_NODE) return;
node.replaceWith(...node.textContent.split(highlight).flatMap((part) => [
document.createTextNode(part),
Object.assign(document.createElement("span"), {
textContent: highlight,
classList: 'highlight'
})
]).slice(0, -1));
};
const article = document.querySelector('article');
article.addEventListener('mouseup', () => {
const selection = window.getSelection();
if (selection.rangeCount === 0) return;
const range = selection.getRangeAt(0);
const [ancestor, start, end] = [range.commonAncestorContainer, range.startContainer, range.endContainer];
/* Step 1: nodes contained entirely by the selection. */
getTextNodesBetween(ancestor, start, end).forEach(highlightWholeNode);
/* Step 2: the first node in the selection. */
highlightInNode(start, start.textContent.substring(range.startOffset, start === end ? range.endOffset : start.textContent.length));
if (start === end) { selection.empty(); return; }
/* Step 3: the last node in the selection. */
highlightInNode(end, end.textContent.substring(0, range.endOffset));
selection.empty();
}, false);
.highlight {
color: black;
background-color: yellow;
}
<article style="padding-bottom: 40em;">
<h1>Vivamus aliquet fringilla tortor, at.</h1>
<h2>Duis id nunc vitae nulla accumsan cursus <em>eu vitae nibh</em>. Phasellus semper varius tellus.</h2>
<p>Lorem ipsum <sub>dolor sit amet, consectetur adipiscing elit</sub>. Mauris efficitur fringilla orci, vitae eleifend est bibendum sit amet. Ut fringilla, augue vitae egestas porta, diam dolor pharetra ex, nec bibendum erat risus a purus. Nulla dolor neque, maximus ut risus vitae, porttitor posuere nisl. Aliquam hendrerit sapien vitae nisl pharetra, nec rhoncus risus porttitor. <strong>Morbi venenatis malesuada quam</strong>, at finibus massa malesuada sit amet. Nam mauris enim, ullamcorper nec massa ac, commodo pellentesque magna. Etiam venenatis, quam porttitor maximus tempor, arcu dui efficitur libero, id accumsan sem sapien at nunc. Vivamus eget libero congue, <span style="color: blue">posuere ex eu, egestas enim. Curabitur tempor arcu sed velit tincidunt, ut porta ante varius. Morbi laoreet mauris quis elit accumsan, at vehicula tellus imperdiet.</span> Nulla gravida justo ac sem aliquam, ut elementum sem porta. Sed commodo, arcu quis viverra interdum, elit velit dignissim est, in fermentum massa risus at magna.</p>
<p>Nunc vestibulum sed lacus nec venenatis. Morbi fermentum dignissim nisi sed aliquam. Quisque fringilla velit id laoreet tempor. Donec et congue est, eget condimentum lacus. Maecenas mollis, risus eget commodo molestie, dui massa tristique nisi, at pellentesque enim arcu id dolor. Fusce a elementum nisi. Cras aliquet metus ut lorem tincidunt imperdiet. <span style="color: red">Maecenas vel scelerisque erat, at gravida ex.</span> Mauris mi arcu, mattis in sapien in, lobortis hendrerit diam. Nullam laoreet fringilla sapien ac molestie. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas.</p>
<p>Aliquam erat volutpat. Pellentesque elementum massa sit amet ipsum lobortis, sed tincidunt eros dignissim. Donec dapibus tortor leo, vitae gravida ante sagittis ac. <i>Sed dapibus tincidunt ligula, in imperdiet tellus. Ut fermentum, risus sed porta interdum, nulla justo mattis metus, nec maximus lacus turpis laoreet nunc. Nunc varius odio a urna placerat posuere. Donec non enim in ligula placerat convallis at a ante. Nam purus sapien, viverra at imperdiet at, dictum at ante. Nullam sagittis, enim et vehicula tempus, nunc enim egestas erat, sed imperdiet dui turpis id lacus. Sed ante turpis, commodo eu sapien eu, iaculis consequat risus.</i> Aliquam a sodales neque. Sed dignissim finibus ex.</p>
<h2>Duis mollis massa eu egestas venenatis. Proin.</h2>
<p>Sed interdum nisl hendrerit leo commodo fermentum. Nam condimentum eleifend erat, sit amet congue ligula sollicitudin a. Nunc massa diam, efficitur vel mi non, feugiat sollicitudin neque. Aenean hendrerit, <s>diam ac ullamcorper mollis, risus</s> leo vehicula metus, eget hendrerit diam arcu vel tellus. <sup>Suspendisse eget</sup> velit molestie, tincidunt est lacinia, feugiat mi. Quisque nulla odio, auctor sit amet magna eget, pulvinar finibus risus. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nullam ornare luctus ex et venenatis. Vivamus mollis ut magna sed tempor.</p>
<p>Suspendisse et sapien elementum, venenatis est ac, semper felis. Pellentesque euismod ultrices nulla, et malesuada risus molestie a. Aliquam bibendum lectus orci, nec aliquam est bibendum eget. Morbi molestie sapien vitae semper pharetra. Mauris fringilla libero eget risus sagittis tincidunt et eu augue. Donec ac quam tortor. Nunc molestie urna vitae interdum dapibus. Duis non eleifend ante.</p>
</article>