Highlight words within a ReactMarkdown component as they are spoken using SpeechSynthesis API

279 Views Asked by At

I am attempting to highlight words as they are spoken using SpeechSynthesis API in my React component. The text being read is in markdown format. This markdown is being rendered on the page using the "react-markdown" package. It might also be important to note that I am using a package "speak-tts" to interact with the SpeechSynthesis API.

To do this, I have wrapped the ReactMarkdown component in a div with id "article-content" so I can access all the child nodes. I loop over the child nodes until I find the child node which contains the index for the end of the current word being spoken. Then I am appending a span tag with my desired styles from the beginning of the child's textContent until the end position of the current word being spoken.

The problem with this, is that the child element loses any HTML tags within it (e.g. ).

Below is the code I have so far:

Component:

import ReactMarkdown from "react-markdown";
import Speech from "speak-tts";

export const Article: FunctionComponent<{
  markdown: string;
}> = ({ markdown }) => {
    const { play, pause, isPlaying } = useSpeech(article.post?.content.markdown);
    return (
    <div>
      <button onClick={isPlaying ? play : pause}>
         Play
      </button>
      <div id="article-content">
        <ReactMarkdown>
          {markdown}
        </ReactMarkdown>
      </div>
    );
}

useSpeech hook:

const useSpeech = (text?: string) => {
  const [isPlaying, setIsPlaying] = useState(false);
  const [speech, setSpeech] = useState<Speech>();
  const router = useRouter();
  useEffect(() => {
    const speech = new Speech();
    speech.init({
      rate: 3,
      pitch: 1.4,
    });
    setSpeech(speech);
    router.events.on("routeChangeStart", () => speech.cancel());
    window.addEventListener("beforeunload", () => speech.cancel());

    // Cleanup function
    return () => {
      router.events.off("routeChangeStart", () => speech.cancel());
      window.removeEventListener("beforeunload", () => speech.cancel());
    };
  }, []);
  const removeHighlightedText = () => {
    const highlightedText = document.querySelector('span[style*="underline"]');
    if (highlightedText) {
      highlightedText.style.textDecoration = "none";
    }
  };
  let totalChars = 0;
  const play = () => {
    if (speech.paused()) {
      speech.resume();
      setIsPlaying(true);
      return;
    }
    if (!("speechSynthesis" in window)) {
      alert("Your browser does not support text-to-speech.");
      return;
    }
    if (speech && text) {
      let charIndex = 0;
      speech.speak({
        // Replace all periods with commas so that the charIndex is not reset at the end of the sentence.
        text: text
          .replaceAll(".", ",")
          .replace(/\n/g, " "),
        listeners: {
          onstart: () => {
            removeHighlightedText();
            setIsPlaying(true);
          },
          onended: () => {
            removeHighlightedText();
            setIsPlaying(false);
          },
          onboundary: (event: SpeechSynthesisEvent) => {
            charIndex = event.charIndex;
            const children =
              document.getElementById("article-content")?.childNodes;
            if (!children) return;
            for (let i = 0; i < children.length; i++) {
              const child = children[i];
              if (child && child.textContent) {
                // Get the total characters played so far
                totalChars += child.textContent.length;
                if (totalChars >= charIndex) {
                  // Highlight the current word
                  const start =
                    charIndex - (totalChars - child.textContent.length);
                  // After the first child, the highlight index is off by 1, not sure why
                  const end = start + event.charLength - (i > 0 ? 1 : 0);
                  child.innerHTML =
                    "<span style='text-decoration: underline; text-underline-position: under; text-decoration-color: #fb923c'>" +
                    child.textContent.slice(0, end) +
                    "</span>" +
                    child.textContent.slice(end);
                  break;
                }
              }
            }
            totalChars = 0;
          },
        },
      });
    }
  };

  const pause = () => {
    setIsPlaying(false);
    speech.pause();
  };

  return {
    play,
    pause,
    isPlaying,
  };
};
0

There are 0 best solutions below