<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()
  })
})
Run Pen

External CSS

This Pen doesn't use any external CSS resources.

External JavaScript

  1. https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.21/lodash.min.js