<p class='instruction'>Select text to highlight it and any matches. Select again to remove matches</p>
<div class="wrapper">
<h1 class="heading">Dummy Heading</h1>
<p class="para">Lorem ipsum, dolor sit <span class='emphasise'>amet <i>consectetur</i> adipisicing </span>elit. Similique itaque repellendus aperiam ullam impedit. Nesciunt dignissimos dicta esse <span class='emphasise'>nostrum</span> velit assumenda.</p>
<h1 class="heading">Dummy Heading</h1>
<p class="para">Lorem ipsum, dolor sit amet consectetur adipisicing elit. Similique itaque repellendus aperiam ullam impedit. Nesciunt dignissimos dicta esse nostrum velit assumenda.</p>
<ul>
<li>repellendus</li>
<li>consectetur</li>
<li>adipisicing</li>
</ul>
<h1 class="heading">Dummy Heading</h1>
<p class="para">Lorem ipsum, dolor sit amet <i>consectetur</i> adipisicing elit. Similique itaque repellendus aperiam ullam impedit. Nesciunt dignissimos dicta esse nostrum velit assumenda.</p>
</div>
@import url('https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500&display=swap');
body {
font-family: 'Poppins', sans-serif;
}
.instruction {
color: #eee;
background-color: #333;
font-weight: 300;
padding: .5rem .75rem;
width: fit-content;
}
i {
text-decoration: underline;
}
.emphasise {
font-weight: bold;
color: red;
}
button {
padding: .5rem .75rem;
cursor: pointer;
}
// see link to pen for expandSelection code
import { expandSelection, selectionHandler } from 'https://assets.codepen.io/3351103/expand-selection.js'
const highlightHandler = (function(win, doc){
const isNotNewLine = function (text) {
return !(/^[\n\r]+\s*/.test(text))
}
// target individual parents when modifying textContent
const getTextParentNodes = function(root) {
// Note: Built in filter is not performant
const iterator = document.createNodeIterator(root, NodeFilter.SHOW_TEXT, null)
const nodes = new Set() // only unique parentNodes
let currNode
// faster to filter here
while (currNode = iterator.nextNode()) {
if (isNotNewLine(currNode.textContent)) {
nodes.add(currNode.parentNode)
}
}
return nodes
}
const createSpan = function (strg) {
return `<span class="emphasise">${strg}</span>`
}
// replaces element with it's contents
const replaceWithChildren = function (elem) {
return elem.replaceWith(...elem.childNodes)
}
/**
* removeInnerSpans
* @PARAMS { ELEMENT } selection: DIV containing clone of selection nodes
* @RETURNS { ELEMENT } selection minus inner span elements
*/
const removeInnerSpans = function(parent) {
const innerSpans = parent.querySelectorAll('span.emphasise')
innerSpans.forEach(replaceWithChildren)
parent.normalize()
return parent
}
/**
* TODO: Wrap spans around text matches regardless of inline elements
* addSpans: adds spans to matching selected text
* @PARAMS { ELEMENT } root: element to replace HTML
* @PARAMS { ELEMENT } selection: DIV containing clone of selection nodes
*/
const addSpans = function (root, selection) {
const type = selection.dataset.type // 'innerHTML' or 'textContent'
const stringToMatch = selection[type]
const toMatchRx = new RegExp(`\\b${stringToMatch}\\b`, 'ig')
// avoids conflicts where selected text might match html attributes
if (type === 'textContent') {
const parents = getTextParentNodes(root)
return parents.forEach((parent) => {
const span = createSpan(stringToMatch)
parent.innerHTML = parent.innerHTML.replaceAll(toMatchRx, span)
})
}
// strip all inner spans from DIV and pass into createSpan
const span = createSpan(removeInnerSpans(selection).innerHTML)
root.innerHTML = root.innerHTML.replaceAll(toMatchRx, span)
}
/**
* removeSpans: removes spans from matching selected text
* @PARAMS { ELEMENT } root: element to search for SPANS from
* @PARAMS { ELEMENT } span: SPAN element containing selection nodes
*/
const removeSpans = function (root, span) {
const spans = root.querySelectorAll('span.emphasise')
const toMatch = span.innerHTML
spans.forEach((span) => {
if (span.innerHTML !== toMatch) return
span.replaceWith(...span.childNodes)
})
}
/**
* createContainer
* range.cloneContents() returns a fragment. To access the innerHTML
* the fragment needs to be attached to a parent element first
*/
const createContainer = function(child, textNode) {
const div = doc.createElement('div')
// specifiy type as addSpan will treat these differently
div.dataset.type = (textNode) ? 'textContent' : 'innerHTML'
div.appendChild(child)
return div
}
const isTextNode = (elem) => elem.nodeType === Node.TEXT_NODE
const getOuterSpan = (elem) => elem.closest('span.emphasise')
const toggleHighlights = function (wrapper, selection) {
const range = selection.getRangeAt(0)
const ancestor = range.commonAncestorContainer
const textNode = isTextNode(ancestor)
const outerSpan = getOuterSpan((textNode) ? ancestor.parentElement : ancestor)
// if removing span highlights
if (outerSpan !== null) {
return removeSpans(wrapper, outerSpan)
}
const selectionContainer = createContainer(range.cloneContents(), textNode)
addSpans(wrapper, selectionContainer)
}
const highlightHandler = function ({ currentTarget }) {
const selection = win.getSelection()
if (selection.toString().trim() !== '') {
toggleHighlights(currentTarget, expandSelection(selection))
}
}
return highlightHandler
}(window, window.document))
window.addEventListener('DOMContentLoaded', () => {
const wrapper = document.querySelector('.wrapper')
wrapper.addEventListener('pointerup', highlightHandler)
// On double clicking inside an inline element
// firefox selects the previous sibling to that element.
// Disabled for now.
wrapper.addEventListener('mousedown', (event) => {
if(event.detail > 1) event.preventDefault()
})
})
This Pen doesn't use any external CSS resources.