react implements the printer effect of chatGPT, is compatible with rich text, and comes with a git address

1. Method 1: Use the plug-in typed.js

typed.js website address, click me to open it

1.1. The core code is as follows:

//TypeWriteEffect/index.tsx component
import React, {<!-- --> useEffect, useRef } from 'react';
import Typed from 'typed.js';
import {<!-- --> PropsType } from './index.d';
const TypeWriteEffect: React.FC<PropsType> = ({<!-- --> text = '', callback, seed = 20 }) => {<!-- -->
  const el = useRef(null);
  useEffect(() => {<!-- -->
    const typed = new Typed(el.current, {<!-- -->
      strings: [text],
      typeSpeed: seed,
      showCursor: true,
      onComplete(self) {<!-- -->
        callback?.();
        self.cursor.style.display = 'none'; // Hide cursor
      },
    });
    return () => {<!-- -->
      typed.destroy();
    };
  }, []);
  return (
    <div>
      <span ref={<!-- -->el}></span>
    </div>
  );
};
export default TypeWriteEffect;
// index.d.ts
export type PropsType = {<!-- -->
  text: string; //Text content
  seed?: number; //Speed
  callback?: () => void; //Callback function after printing is completed
};

1.2, use

/*
 * @Description:
 * @Author: muge
 * @LastEditors: muge
 */
import TypeWriteEffect from '@/components/TypeWriteEffect';
import React from 'react';

const Index = () => {<!-- -->
  const richText =
    '<code>2112.1</code>This is the response text of <span class="typing-text" style="color: red">Intelligent Question and Answer Assistant--</span>----very long of. <div style="color: pink; font-size: 20px">Genshin Impact* is launched! </div>---King* starts! <img src="//i2.wp.com/nimg.ws.126.net/?url=http://dingyue.ws.126.net/2022/0830/74168ba1j00rhf6m5002cd000u000jfp.jpg & amp;thumbnail=660x2147483647 & amp;quality=80 & amp;type=jpg" style="height: 150px"/>';
  return <TypeWriteEffect text={<!-- -->richText} />;
};
export default Index;

1.3, the effect is as shown in the figure

2. Method 2: Custom implementation

2.1, ideas

My idea is to cut the string into two arrays, one is the tag array of <>, and the other is the array intercepted by characters and tags. The effect is as follows:


Then traverse chucksList to generate a new array, as shown below:

Then iterate through this array and use the timer to insert into the dom

2.2, core code

2.2.1, writeEffect.ts

// utils/writeEffect/index.ts
import type {<!-- --> TypingEffectType } from './index.d';
import initData from './lib/tool';
import {<!-- --> createBlinkSpan } from './lib/createBlinkSpan';
import {<!-- --> textConversionArr } from './lib/textConversionArr';
import {<!-- --> getCursorClassName } from './lib/getCursorClassName';
import {<!-- --> removeCursor } from './lib/removeCursor';
/**
 * @description: Cursor printing effect
 * @param {HTMLElement} dom
 * @param {TypingEffectType} parameter
 * @author: muge
 */
export const typingEffect = (dom: HTMLElement, parameter: TypingEffectType) => {<!-- -->
  const {<!-- --> text, callback, cursorConfig = {<!-- -->}, seed = initData.seed } = parameter;
  const {<!-- -->
    cursor = false,
    dieTime = initData.dieTime,
    blinkSeed = initData.blinkSeed,
  } = cursorConfig as any;
  if (!dom || !text) return;
  const textArrs: string[] = textConversionArr(text);
  dom.innerHTML = ''; //Clear the content each time
  let blinkInterval: any = null; //Cursor timer
  //Add cursor effect
  cursor & amp; & amp; createBlinkSpan(dom, blinkInterval, blinkSeed);
  let startIndex = 0;
  const element = document.createElement('span'); //Text storage label
  const start = () => {<!-- -->
    startIndex + + ;
    if (startIndex >= textArrs.length) {<!-- -->
      cursor & amp; & amp; removeCursor(dom, blinkInterval, dieTime);
      callback?.();
      return;
    }
    if (cursor) {<!-- -->
      element.innerHTML = textArrs[startIndex];
      dom.insertBefore(element, getCursorClassName());
    } else {<!-- -->
      dom.innerHTML = textArrs[startIndex];
    }
    setTimeout(() => start(), seed);
  };
  start();
};

//index.d.ts
type cursorConfigType = {<!-- -->
  cursor?: boolean; //Whether to display the cursor
  seed?: number; //Default cursor speed =>default 250ms
  dieTime?: number; //The cursor disappears after typing =>Default 200ms
  blinkSeed?: number; //cursor blinking speed
};
export type TypingEffectType = {<!-- -->
  text: string; //text
  seed?: number; //Default typing speed, default 250ms
  callback?: () => void; //The callback function when the typewriter ends
  cursorConfig?: cursorConfigType; //Cursor configuration item
};

2.2.2, createBlinkSpan

import initData from './tool';

export const createBlinkSpan = (
  dom: HTMLElement,
  intervalName: NodeJS.Timer,
  blinkSeed: number,
) => {<!-- -->
  const {<!-- --> cursorClassName } = initData;
  const blinkName = document.createElement('span');
  blinkName.className = cursorClassName;
  blinkName.innerHTML = '|';
  dom.appendChild(blinkName);
  //Set the blinking interval, for example, switch the cursor state every 500 milliseconds
  intervalName = setInterval(() => {<!-- -->
    blinkName.style.display = blinkName.style.display === 'none' ? 'inline' : 'none';
  }, blinkSeed);
};

2.2.3, textConversionArr

// Label cutting
const labelCut = (str: string) => {<!-- -->
  const arrs = str.match(/<[^>] + >(?!\/>)/g);
  if (!arrs) return [];
  return arrs.filter((item) => !/<[^>] + \/>$/.test(item));
};
// Separate strings by <></> => array
const splitStringToChunks = (str: string): string[] => {<!-- -->
  const chunks: string[] = [];
  let currentChunk = '';
  let insideTag = false;
  for (let i = 0; i < str.length; i + + ) {<!-- -->
    const char = str[i];
    if (char === '<') {<!-- -->
      insideTag = true;
      currentChunk + = char;
    } else if (char === '>') {<!-- -->
      insideTag = false;
      currentChunk + = char;
    } else {<!-- -->
      currentChunk + = char;
    }
    if (!insideTag || i === str.length - 1) {<!-- -->
      chunks.push(currentChunk);
      currentChunk = '';
    }
  }
  return chunks;
};
/**
 * @description: text conversion array
 * @param {string} str
 * @author: muge
 */
export const textConversionArr = (str: string): string[] => {<!-- -->
  const labelCutList = labelCut(str);
  const chucksList = splitStringToChunks(str);
  let startIndex: number = 0;
  const result: string[] = [];
  let lastStr = ''; // concatenated string
  const isCloseTagReg = /<\/[^>]*>/; //Whether it is a closed tag </img>=>true <>=>false <div/>=>false
  while (startIndex < chucksList?.length) {<!-- -->
    let currentIndex = startIndex;
     + + startIndex;
    const currentStr = chucksList[currentIndex];
    const index = labelCutList.indexOf(currentStr);
    if (index === -1) {<!-- -->
      lastStr + = currentStr;
      result.push(lastStr);
      continue;
    }
    // start tag
    if (!/<\/[^>] + >/.test(currentStr)) {<!-- -->
      // Determine whether it is a self-closing tag, such as <img><br>This kind of irregular writing method
      const nextCloseTag: string | undefined = labelCutList[index + 1];
      if (!nextCloseTag || !isCloseTagReg.test(nextCloseTag)) {<!-- -->
        lastStr + = currentStr;
        result.push(lastStr);
        continue;
      }
      // Find the index of the first closed tag
      const findArrs = chucksList.slice(currentIndex);
      const endTagIndex = findArrs.findIndex((item) => item === nextCloseTag);
      let curStr: string = '';
      for (let i = 1; i < endTagIndex; i + + ) {<!-- -->
        curStr + = findArrs[i];
        const res = labelCutList[index] + curStr + nextCloseTag;
        result.push(lastStr + res);
        if (endTagIndex - 1 === i) {<!-- -->
          lastStr + = res;
        }
      }
      startIndex = currentIndex + endTagIndex; //Reset subscript
      continue;
    }
  }
  return result;
};

2.2.4, getCursorClassName

import initData from './tool';
/**
 * @description: //Get cursor dom
 * @author: muge
 */
export const getCursorClassName = () => {<!-- -->
  return document.querySelector(`.${<!-- -->initData.cursorClassName}`) as HTMLElement;
};

2.2.5, removeCursor

import initData from './tool';
/**
 * @description: //Remove cursor label
 * @param {HTMLElement} dom //cursor label dom
 * @param {string} intervalName //Timer name
 * @param {number} cursorAway //cursor disappearance time
 * @author: muge
 */
export const removeCursor = (dom: HTMLElement, intervalName: NodeJS.Timer, cursorAway: number) => {<!-- -->
  setTimeout(() => {<!-- -->
    clearInterval(intervalName);
    dom.removeChild(document.querySelector(`.${<!-- -->initData.cursorClassName}`) as HTMLElement);
  }, cursorAway);
};

2.2.6, initData

type initDataType = {<!-- -->
  cursorClassName: string;
  seed: number;
  blinkSeed: number;
  dieTime: number;
};
const initData: initDataType = {<!-- -->
  cursorClassName: 'blink-class',
  seed: 100,
  dieTime: 500,
  blinkSeed: 350,
};
export default initData;

2.3, use

import {<!-- --> typingEffect } from '@/utils/writeEffect';
import React, {<!-- --> useEffect, useRef } from 'react';

const Index = () => {<!-- -->
  const el = useRef<HTMLElement | any>(null);

  const richText =
    'Genshin Impact · Start! <img src="//i2.wp.com/nimg.ws.126.net/?url=http://dingyue.ws.126.net/2022/0830/74168ba1j00rhf6m5002cd000u000jfp.jpg & amp;thumbnail=660x2147483647 & amp;quality=80 & amp;type=jpg" style="height: 150px"/><br/><br><div>Glory of Kings · Start! </div>';
  useEffect(() => {<!-- -->
    typingEffect(el.current, {<!-- -->
      text: richText,
      callback: () => {<!-- -->
        console.log('Callback function executed after the printer ends!');
      },
      cursorConfig: {<!-- -->
        cursor: true,
      },
    });
  }, []);
  return <div ref={<!-- -->el}></div>;
};

export default Index;

2.4, Effect

git project address, click me to open