Search code examples
javascripthtmljquerycssanimation

Javascript live typing animation that handles HTML tags


I'm building an HTML/CSS/JavaScript + jQuery web-application. I have a page that calls an API, which returns a string with HTML code. Here's an example:

// Data returned by server

data = {
  text: "<b>Here is some example text<b> followed by line breaks<br><br><br><br><br",
};

I need to implement live typing animation to display the data. I've got that working (see code below), but when there's an HTML tag such as <b> or <br>, the < characters briefly flash on the screen before the element is properly displayed. Is there some kind of decoding function I need to run before calling the function to display the text?

// The text we want to animate
let text =
  "Here is some example text followed by a line break<br><br>and another line break<br><br><b>as well as some bold text.<b>";

// The current position of the animation
let index = 0;

// Function to trigger the live typing animation
function typingAnimation(id) {
  setTimeout(() => {
    index++;
    updateText(id);
    if (index < text.length) {
      typingAnimation(id);
    }
  }, 50);
}

// Function to update the text 
function updateText(id) {
  // Get the element with our id
  const typing = document.getElementById(id);
  // Split our text into lines based on newline characters or <br> tags
  const lines = text.substring(0, index).split(/\n|<br>/);

  // Check if our element exists before updating it
  if (typing == null) {
    return;
  }

  //------> EDIT:: Attach click listener for text div so the user can click to skip animation
  $("#skipAnimationBtn").on("click", () => {
    index = text.length;

  });

  // Update the element with our new lines
  typing.innerHTML = lines
    .map((line, index) => {
      // Check if this is the last line in our text
      const isLastLine = index === lines.length - 1;

      // Add a line break if this isn't the last line of text
      const lineBreak = isLastLine ? "" : "<br>";

      // Return our line of text with or without a line break
      return `${line}${lineBreak}`;
    })
    .join("");
}

typingAnimation("typing-animation");
#parent {
  display: block;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
<div id="parent">
  <button id="skipAnimationBtn">Click to Skip Animation</button>
  <div id="typing-animation"></div>
</div>


Solution

  • The break tags are easy, just replace those with a new line tag /n.

    It gets complicated with the bold tags. The only way I see to do this is to replace() the bold tags within the initial string, defining a newly reformatted text variable. A function within a replace method to get a start and end range of the bold characters and save them in an array of objects.

    Then iterate over the reformatted text variable and use a conditional defining a boolean variable with .some() to check the range start and end, when we have a match, wrap those characters in a span tag that has a class which can be formatted in CSS to add the bold formatting.

    Then in the updateText() method we swap out formattedText accordingly using the current index, apply the bold formatting and then assign the innerHTML.

    I have further comments in the snippet to help explain the logic in more detail.

    EDIT: Added logic => tagOffset and adjustedOffset to track the exact amount of characters after the bold tags were removed within the replace method. The indexing was getting off because it was still referring to the indexes of the bold tag characters within the string.

    let text = "Ferrars all spirits his imagine effects amongst neither.<br><br>Sure last upon he same as knew next. <b>End friendship sufficient assistance</b> can prosperous met. As game he show it park do. Was has <b>unknown few certain </b>ten promise. No finished my <b>an likewise cheerful packages we are.</b>For assurance concluded son <br><br>son something depending discourse see led collected. <b>Packages oh no denoting my advanced humoured.</b> Pressed be so thought natural.<br>";
    
    const skip = document.querySelector('#skipAnimation');
    
    // replace <br> tags with newline characters
    text = text.replace(/<br>/g, '\n');
    
    // define an array to hold any bold strings
    let boldTextRange = [];
    
    // we need to track positions of the shift of characters when we remvoe the break tags
    let replacedText = ''; // initialize a variable to track the character offset
    
    // initialize a variable to keep a closer track on the 
    // positions of the characters before removing bold tags
    let tagOffset = 0; 
    
    // strip <b> tags from the string and define a start and end 
    // range object and push that into the boldTextRange array
    replacedText = text.replace(/<b>(.*?)<\/b>/g, (match, newText, offset) => {
      const adjustedOffset = offset - tagOffset; // FIX
      boldTextRange.push({ 
        start: adjustedOffset, 
        end: adjustedOffset + newText.length 
      });
      tagOffset += match.length - newText.length; // track how many characters were removed exactly
      return newText; // return unformatted text
    });
    
    // get the length of the stripped and formatted text
    let totalLength = replacedText.length;
    
    // define the index to track where in the typing animation
    let index = 0;
    
    // added a skip animation, since index is defined outside update  
    // methods scope and changed inside the scope, you can that
    // simply place logic right after indexes initial definition
    function skipAnimation(){
      return index = text.length;
    }
    skip.addEventListener('click', skipAnimation);
    
    
    // function for the typing animation
    function typingAnimation(id) {
      setTimeout(() => {
        index++;
        updateText(id);
        if (index < totalLength) {
          typingAnimation(id);
        }
      }, 50);
    }
    
    // helper function to apply bold formatting 
    function applyBoldFormatting(replacedText) {
      // define an empty variable to hold the formatted text
      let formattedText = '';
      // set a boolean to track wrapping in span
      let boldBool = false;
    
      // iterate over the text and apply bold formatting
      for (let i = 0; i < replacedText.length; i++) {
    
        // define a boolean to track the range start and end for bold formatted text
        const isBold = boldTextRange.some(range => i >= range.start && i < range.end);
        
        // conditional to check if character is bold and set 
        // formatted text span tags open and closing tags
        if (isBold && !boldBool) {
          formattedText += `<span class="bold">`;
          boldBool = true; // set boolean to track range
        } else if (!isBold && boldBool) {
          formattedText += `</span>`; // close out the span tag
          boldBool = false; // reset boolean to false 
        }
    
        // add the next character to the string
        formattedText += replacedText[i];
      }
    
      // return the formatted string
      return formattedText;
    }
    
    // function to update the text in the element
    function updateText(id) {
      // get the element to display the typing animation
      const typing = document.getElementById(id);
      if (!typing) return;
    
      // geet the text up to the current index
      let currentText = replacedText.substring(0, index);
    
      // pass in the currentText to apply bold formatting
      let formattedText = applyBoldFormatting(currentText);
    
      // replace newline characters with <br> for proper line breaks
      formattedText = formattedText.replace(/\n/g, '<br>');
    
      // display the formatted text animation
      typing.innerHTML = formattedText;
    }
    
    // start the typing animation
    typingAnimation("typing-animation");
    .bold {
      font-weight: bold;
    }
    <div>
      <button id="skipAnimation">Skip Animation</button>
      <div id="typing-animation"></div>
    </div>