Search code examples
javascripthtmljquerycssmouseevent

Javascript highlight text following cursor forwards and backwards


I have a task in which users need to highlight portions of text with their cursor. However, the way I am handling mouse move events right now is only allowing users to highlight text in a single direction--i.e., if they change their mouse direction mid-move event, the text highlighting does not follow their cursor.

This post is relevant in request but handles text highlighting by adding/toggling classes, whereas I am handling highlighting by changing the background color of span elements. This post is also very relevant but I'm not sure how to implement.

I am hoping to figure out how to allow removing highlighting when mouse changes direction. Is this possible?

// Initialize data structures and variables
        var wordDictionary = {};         // Dictionary to store words
        var selectedWords = {};          // Currently selected words
        var isMouseDown = false;         // Flag for mouse state
        var currentColor = '';           // Current highlight color
        var usedColors = new Set();      // Set of used colors
        // Available highlight colors
        var availableColors = ["yellow", "red", "blue", "green", "orange"]; 
        var highlights = {};             // Store highlighted words for each event
        var eventCounter = 0;            // Counter for events

        var text = "Pangolins, sometimes known as scaly anteaters, are mammals of the order Pholidota. \
      The one extant family, the Manidae, has three genera: Manis, Phataginus, and Smutsia. \
       Manis comprises four species found in Asia, while Phataginus and Smutsia include two species each, all found in sub-Saharan Africa. \
        These species range in size from 30 to 100 cm (12 to 39 in). \
        A number of extinct pangolin species are also known. \
        In September 2023, nine species were reported.<br><br> \
        Pangolins have large, protective keratin scales, similar in material to fingernails and toenails, covering their skin; \
        they are the only known mammals with this feature. \
        They live in hollow trees or burrows, depending on the species. \
        Pangolins are nocturnal, and their diet consists of mainly ants and termites, which they capture using their long tongues. \
        They tend to be solitary animals, meeting only to mate and produce a litter of one to three offspring, which they raise for about two years.";

        var textParagraph = document.getElementById("textParagraph");
        textParagraph.innerHTML = text;

        // Execute when the window is loaded
        window.onload = function () {
            var contentDiv = document.getElementById('content');
            let ptag = contentDiv.querySelector('p');
            var text = ptag.innerHTML.trim();
            var words = text.split(/\s+|(?=<br><br>)/);

            // Populate wordDictionary with words and create span elements for each word
            for (var i = 0; i < words.length; i++) {
                wordDictionary[i] = words[i];
            }

            for (var i = 0; i < words.length; i++) {
                var wordElement = document.createElement('span');
                wordElement.textContent = words[i] + ' ';
                wordElement.dataset.index = i;
                wordElement.addEventListener('mousedown', handleMouseDown);
                if (words[i] == '<br><br>') {
                    wordElement.textContent = ' ';
                    wordElement.classList.add('line-break')
                }
                contentDiv.appendChild(wordElement);
            }

            // Add mouseup event listener for handling mouse up events
            document.addEventListener('mouseup', handleMouseUp);
        };

        // Function to get a random highlight color
        function getRandomColor() {
            if (availableColors.length === 0) {
                availableColors = [...usedColors];
                usedColors.clear();
            }

            var randomIndex = Math.floor(Math.random() * availableColors.length);
            var color = availableColors.splice(randomIndex, 1)[0];
            usedColors.add(color);
            return color;
        }

        // Function to handle mouse down event on words
        function handleMouseDown(event) {
            isMouseDown = true;
            var index = event.target.dataset.index;
            var word = event.target.textContent.trim();

            if (!isHighlighted(index)) {
                selectedWords = {};
                selectedWords.startIndex = index;
                selectedWords.endIndex = index;

                selectedWords[index] = word;
                // console.log(selectedWords)
                currentColor = getRandomColor();
                event.target.style.backgroundColor = currentColor;

                event.preventDefault();

                document.addEventListener('mousemove', handleMouseMove);
            }
        }

        // Function to handle mouse up event
        function handleMouseUp(event) {
            if (isMouseDown) {
                document.removeEventListener('mousemove', handleMouseMove);
                var highlightedWords = {};

                for (var index in selectedWords) {
                    if (index !== 'startIndex' && index !== 'endIndex') {
                        highlightedWords[index] = selectedWords[index];
                    }
                }

                eventCounter++;
                highlights[eventCounter] = highlightedWords;
                // console.log(highlights);
            }

            isMouseDown = false;
        }

        // Function to handle mouse move event (word selection)
        function handleMouseMove(event) {
            if (isMouseDown) {
                var currentIndex = event.target.dataset.index;
                var startIndex = selectedWords.startIndex;
                var endIndex = selectedWords.endIndex;
                var contentDiv = document.getElementById('content');

                var newStartIndex = Math.min(startIndex, currentIndex);
                var newEndIndex = Math.max(endIndex, currentIndex);

                clearPreviousSelection();

                for (var i = newStartIndex; i <= newEndIndex; i++) {
                    selectedWords[i] = wordDictionary[i];
                }

                for (var i = newStartIndex + 1; i <= newEndIndex + 1; i++) {
                    contentDiv.children[i].style.backgroundColor = currentColor;
                }

                selectedWords.startIndex = newStartIndex;
                selectedWords.endIndex = newEndIndex;
            }
        }

        // Function to clear previously selected words
        function clearPreviousSelection() {
            var contentDiv = document.getElementById('content');

            for (var i in selectedWords) {
                if (i !== 'startIndex' && i !== 'endIndex') {
                    contentDiv.children[i].style.backgroundColor = '';
                    delete selectedWords[i];
                }
            }
        }

        // Function to check if a word is already highlighted
        function isHighlighted(index) {
            for (var eventKey in highlights) {
                var highlightedWords = highlights[eventKey];

                for (var wordIndex in highlightedWords) {
                    if (wordIndex === index) {
                        return true;
                    }
                }
            }

            return false;
        }

        // Function to clear all selections and reset
        function clearSelections() {
            var contentDiv = document.getElementById('content');
            var wordElements = contentDiv.getElementsByTagName('span');

            for (var i = 0; i < wordElements.length; i++) {
                wordElements[i].style.backgroundColor = '';
            }

            highlights = {};
            eventCounter = 0;
        }

        // Function to undo the last selection
        function undoSelection() {
            if (eventCounter > 0) {
                var lastHighlight = highlights[eventCounter];

                for (var index in lastHighlight) {
                    var wordIndex = parseInt(index);
                    var contentDiv = document.getElementById('content');

                    if (!isNaN(wordIndex)) {
                        contentDiv.children[wordIndex + 1].style.backgroundColor = '';
                    }
                }

                delete highlights[eventCounter];
                eventCounter--;
            }
        }

        // Add event listeners to the clear and undo buttons
        document.getElementById("removeHighlight").addEventListener("click", clearSelections);
        document.getElementById("undoHighlight").addEventListener("click", undoSelection);
        #buttons {
            margin-top: 30px;
        }

        .line-break {
            display: block;
            margin: 15px;
        }

        #trial_display {
            display: block;
            padding: 50px;
        }

        #title{
            text-align: center;
            font-size: 20px;
        }

        #content {
            display: block;
            border: 2px solid gray;
            padding: 50px;
        }
    <div id="trial_display">
    <div id="content">
        <p id="textParagraph" style="display: none"></p> 
      </div>

    <div id="buttons">
        <button id="removeHighlight">Clear</button>
        <button id="undoHighlight">Undo</button>
    </div>
</div>


Solution

  • It seems that what you want, is already achieved natively with browser's selection API.

    So, you could handle it all via selection, by getting elements from it and then changing their background color via their data-index, as well as changing the default selection highlight background color, so that they are in congruence.

    Try this (code key points):

    1. extract elements from the selection
    sel.getRangeAt(i).cloneContents()
    
    1. loop each element, get index and then change its background
    document.querySelector(`[data-index="${index}"]`).style.backgroundColor = currentColor;
    
    1. when each selection starts, remove previous selection rule, and add new with new color
    sheet.insertRule(`#content span::selection { background-color: ${currentColor}; }`, sheet.cssRules.length);
    

    EDIT

    I've also changed undo and clear features so that now I just store highlighted indexes in an array, and then on undo just pop last element, and loop spans and remove their background. If you need to store the words, you can also get them from the elements via index.

    Steps with complete newly added code:

    1. extract elements from the selection

       function getSelectionElements(e) {
      
           const sel = window.getSelection();
      
           if (sel.rangeCount) {
      
               // container to store all seleccted elements
               const container = document.createElement('div');
      
               for (let i = 0, len = sel.rangeCount; i < len; ++i) {
                   // append elements
                   // if only single text node, then append element from event.target
                   if (sel.getRangeAt(i).cloneContents().childNodes.length > 1) {
                       container.appendChild(sel.getRangeAt(i).cloneContents());
                   } else {
                       container.appendChild(e.target.cloneNode());
                   }
               }
      
               return container;
           }
      
       }
      
    1. loop each element, get index and then change its background

       // get elements from selection
       // loop and add background color to each
       document.addEventListener('mouseup', (e) => {
      
           const selectedElements = getSelectionElements(e);
      
           if (selectedElements)
      
               selectedElements.childNodes.forEach(el => {
      
               let index = el.dataset.index;
               let word = el.textContent.trim();
      
      
               if (!isHighlighted(index)) {
                   selectedWords = {};
                   selectedWords.startIndex = index;
                   selectedWords.endIndex = index;
      
                   selectedWords[index] = word;
      
                   document.querySelector(`[data-index="${index}"]`).style.backgroundColor = currentColor;
      
               }
           });
      
      
       });
      
    1. when each selection starts, remove previous selection rule, and add new with new color

       // remove previous selection style
       // add new highlight color
       document.addEventListener('mousedown', (e) => {
      
           const sheet = document.styleSheets[0];
      
           const rules = sheet.cssRules;
      
           for (let i = 0; i < rules.length; i++) {
               const rule = rules[i];
      
               if (rule.selectorText.includes('::selection')) sheet.deleteRule(i);
           }
      
           currentColor = getRandomColor();
      
      
           sheet.insertRule(`#content span::selection { background-color: ${currentColor}; }`, sheet.cssRules.length);
      
       });
      

    EDIT

    • undo (code key points)
    // remove background from last element, if any
    if(previousHiglight.length > 0) {
    
      previousHiglight.pop().forEach(word=>{
    
        contentDiv.querySelector(`[data-index="${word}"]`).style.backgroundColor = '';
      });
    }
    
    • clear (code key points)
    contentDiv.querySelectorAll('span').forEach(word=>{
      word.style.backgroundColor = '';
    });
    
    previousHiglight.length = 0;
    

    // Execute when the window is loaded
    window.onload = function() {
    
      // Initialize data structures and variables
      var wordDictionary = {}; // Dictionary to store words
      var selectedWords = {}; // Currently selected words
      var isMouseDown = false; // Flag for mouse state
      var currentColor = ''; // Current highlight color
      var usedColors = new Set(); // Set of used colors
      // Available highlight colors
      var availableColors = ["yellow", "red", "blue", "green", "orange"];
      var highlights = {}; // Store highlighted words for each event
      var eventCounter = 0; // Counter for events
    
      var text = "Pangolins, sometimes known as scaly anteaters, are mammals of the order Pholidota. \
          The one extant family, the Manidae, has three genera: Manis, Phataginus, and Smutsia. \
           Manis comprises four species found in Asia, while Phataginus and Smutsia include two species each, all found in sub-Saharan Africa. \
            These species range in size from 30 to 100 cm (12 to 39 in). \
            A number of extinct pangolin species are also known. \
            In September 2023, nine species were reported.<br><br> \
            Pangolins have large, protective keratin scales, similar in material to fingernails and toenails, covering their skin; \
            they are the only known mammals with this feature. \
            They live in hollow trees or burrows, depending on the species. \
            Pangolins are nocturnal, and their diet consists of mainly ants and termites, which they capture using their long tongues. \
            They tend to be solitary animals, meeting only to mate and produce a litter of one to three offspring, which they raise for about two years.";
    
      var textParagraph = document.getElementById("textParagraph");
      textParagraph.innerHTML = text;
    
    
      var contentDiv = document.getElementById('content');
      let ptag = contentDiv.querySelector('p');
      var text = ptag.innerHTML.trim();
      var words = text.split(/\s+|(?=<br><br>)/);
    
      // Populate wordDictionary with words and create span elements for each word
      for (var i = 0; i < words.length; i++) {
        wordDictionary[i] = words[i];
      }
    
      for (var i = 0; i < words.length; i++) {
        var wordElement = document.createElement('span');
        wordElement.textContent = words[i] + ' ';
        wordElement.dataset.index = i;
        //wordElement.addEventListener('mousedown', handleMouseDown);
        if (words[i] == '<br><br>') {
          wordElement.textContent = ' ';
          wordElement.classList.add('line-break')
        }
        contentDiv.appendChild(wordElement);
      }
    
      // Add mouseup event listener for handling mouse up events
      //document.addEventListener('mouseup', handleMouseUp);
    
    
      // Function to get a random highlight color
      function getRandomColor() {
        if (availableColors.length === 0) {
          availableColors = [...usedColors];
          usedColors.clear();
        }
    
        var randomIndex = Math.floor(Math.random() * availableColors.length);
        var color = availableColors.splice(randomIndex, 1)[0];
        usedColors.add(color);
        return color;
      }
    
      // Function to handle mouse down event on words
      function handleMouseDown(event) {
    
        isMouseDown = true;
        var index = event.target.dataset.index;
        var word = event.target.textContent.trim();
    
        if (!isHighlighted(index)) {
          selectedWords = {};
          selectedWords.startIndex = index;
          selectedWords.endIndex = index;
    
          selectedWords[index] = word;
          // console.log(selectedWords)
          currentColor = getRandomColor();
          event.target.style.backgroundColor = currentColor;
    
          event.preventDefault();
    
          //document.addEventListener('mousemove', handleMouseMove);
        }
      }
    
      // Function to handle mouse up event
      function handleMouseUp(event) {
        if (isMouseDown) {
          document.removeEventListener('mousemove', handleMouseMove);
          var highlightedWords = {};
    
          for (var index in selectedWords) {
            if (index !== 'startIndex' && index !== 'endIndex') {
              highlightedWords[index] = selectedWords[index];
            }
          }
    
          eventCounter++;
          highlights[eventCounter] = highlightedWords;
          // console.log(highlights);
        }
    
        isMouseDown = false;
      }
    
      // Function to handle mouse move event (word selection)
      function handleMouseMove(event) {
        if (isMouseDown) {
          var currentIndex = event.target.dataset.index;
          var startIndex = selectedWords.startIndex;
          var endIndex = selectedWords.endIndex;
          var contentDiv = document.getElementById('content');
    
          var newStartIndex = Math.min(startIndex, currentIndex);
          var newEndIndex = Math.max(endIndex, currentIndex);
    
          clearPreviousSelection();
    
          for (var i = newStartIndex; i <= newEndIndex; i++) {
            selectedWords[i] = wordDictionary[i];
          }
    
          for (var i = newStartIndex + 1; i <= newEndIndex + 1; i++) {
            contentDiv.children[i].style.backgroundColor = currentColor;
          }
    
          selectedWords.startIndex = newStartIndex;
          selectedWords.endIndex = newEndIndex;
        }
      }
    
      // Function to clear previously selected words
      function clearPreviousSelection() {
        var contentDiv = document.getElementById('content');
    
        for (var i in selectedWords) {
          if (i !== 'startIndex' && i !== 'endIndex') {
            contentDiv.children[i].style.backgroundColor = '';
            delete selectedWords[i];
          }
        }
      }
    
      // Function to check if a word is already highlighted
      function isHighlighted(index) {
        for (var eventKey in highlights) {
          var highlightedWords = highlights[eventKey];
    
          for (var wordIndex in highlightedWords) {
            if (wordIndex === index) {
              return true;
            }
          }
        }
    
        return false;
      }
    
      // Function to clear all selections and reset
      function clearSelections() {
        var contentDiv = document.getElementById('content');
        var wordElements = contentDiv.getElementsByTagName('span');
    
        for (var i = 0; i < wordElements.length; i++) {
          wordElements[i].style.backgroundColor = '';
        }
    
        highlights = {};
        eventCounter = 0;
      }
    
      // Function to undo the last selection
      function undoSelection() {
        if (eventCounter > 0) {
          var lastHighlight = highlights[eventCounter];
    
          for (var index in lastHighlight) {
            var wordIndex = parseInt(index);
            var contentDiv = document.getElementById('content');
    
            if (!isNaN(wordIndex)) {
              contentDiv.children[wordIndex + 1].style.backgroundColor = '';
            }
          }
    
          delete highlights[eventCounter];
          eventCounter--;
        }
      }
    
      // Add event listeners to the clear and undo buttons
      document.getElementById("removeHighlight").addEventListener("click", clearSelectionsNew);
      document.getElementById("undoHighlight").addEventListener("click", undoSelectionNew);
    
    
      // remove selection highlight
      function clearHighlight() {
        const sel = window.getSelection();
        if (sel.rangeCount > 0) {
          sel.removeAllRanges();
        }
      }
    
      // remove background from all elements
      function clearSelectionsNew() {
    
        clearHighlight();
    
        contentDiv.querySelectorAll('span').forEach(word => {
          word.style.backgroundColor = '';
        });
    
        previousHiglight.length = 0;
    
      }
    
      // previous indexes store
      const previousHiglight = [];
    
      function undoSelectionNew() {
    
        clearHighlight();
    
        // remove background from last element, if any
        if (previousHiglight.length > 0) {
    
          previousHiglight.pop().forEach(word => {
    
            contentDiv.querySelector(`[data-index="${word}"]`).style.backgroundColor = '';
          });
        }
    
      }
    
    
      function getSelectionElements(e) {
    
        const sel = window.getSelection();
    
        if (sel.rangeCount) {
    
          // container to store all seleccted elements
          const container = document.createElement('div');
    
          for (let i = 0, len = sel.rangeCount; i < len; ++i) {
            // append elements
            // if only single text node, then append element from event.target
            if (sel.getRangeAt(i).cloneContents().childNodes.length > 1) {
              container.appendChild(sel.getRangeAt(i).cloneContents());
            } else {
              container.appendChild(e.target.cloneNode());
            }
          }
    
          return container;
        }
    
    
      }
    
      // remove previous selection style
      // add new highlight color
      contentDiv.addEventListener('mousedown', (e) => {
    
        const sheet = document.styleSheets[0];
    
        const rules = sheet.cssRules;
    
        for (let i = 0; i < rules.length; i++) {
          const rule = rules[i];
    
          if (rule.selectorText.includes('::selection')) sheet.deleteRule(i);
        }
    
        currentColor = getRandomColor();
    
    
        sheet.insertRule(`#content span::selection { background-color: ${currentColor}; }`, sheet.cssRules.length);
    
      });
    
    
      // get elements from selection
      // loop and add background color to each
      contentDiv.addEventListener('mouseup', (e) => {
    
        const selectedElements = getSelectionElements(e);
    
        if (selectedElements) {
    
          const indexes = [];
    
          selectedElements.childNodes.forEach(el => {
    
            let index = el.dataset.index;
            let word = el.textContent.trim();
    
    
            if (!isHighlighted(index)) {
              selectedWords = {};
              selectedWords.startIndex = index;
              selectedWords.endIndex = index;
    
              selectedWords[index] = word;
    
              contentDiv.querySelector(`[data-index="${index}"]`).style.backgroundColor = currentColor;
    
              indexes.push(index);
    
            }
          });
    
          previousHiglight.push(indexes);
        }
    
    
      });
    
    
    };
    #buttons {
      margin-top: 30px;
    }
    
    .line-break {
      display: block;
      margin: 15px;
    }
    
    #trial_display {
      display: block;
      padding: 50px;
    }
    
    #title {
      text-align: center;
      font-size: 20px;
    }
    
    #content {
      display: block;
      border: 2px solid gray;
      padding: 50px;
    }
    <div id="trial_display">
      <div id="content">
        <p id="textParagraph" style="display:none"></p>
      </div>
      <div id="buttons">
        <button id="removeHighlight">Clear</button>
        <button id="undoHighlight">Undo</button>
      </div>
    </div>