You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

ReactMarkdown结合SpeechSynthesis API实现朗读单词高亮问题求助

问题:Markdown文本朗读高亮丢失原有HTML标签

问题背景

在React组件中借助SpeechSynthesis API实现Markdown文本朗读时的单词高亮效果:

  • 用react-markdown渲染Markdown文本
  • 用speak-tts包与SpeechSynthesis API交互
  • 当前实现:将ReactMarkdown包裹在id="article-content"的div中,遍历子节点定位朗读位置,通过替换innerHTML添加高亮span
  • 核心问题:替换innerHTML会丢失子元素原有的HTML标签(比如Markdown渲染出的<strong>、<em>等)

解决方案

核心思路:不直接替换元素的innerHTML,而是遍历DOM中的文本节点,精准定位到需要高亮的字符位置,拆分文本节点并插入高亮span,保留原有DOM结构。

修改后的完整代码

Article组件

import ReactMarkdown from "react-markdown";
import Speech from "speak-tts";
import { FunctionComponent } from "react";
import { useSpeech } from "./useSpeech";

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

useSpeech Hook

import { useState, useEffect } from "react";
import Speech from "speak-tts";
import { useRouter } from "next/router";

export const useSpeech = (text?: string) => {
  const [isPlaying, setIsPlaying] = useState(false);
  const [speech, setSpeech] = useState<Speech | null>(null);
  const router = useRouter();

  useEffect(() => {
    const initSpeech = async () => {
      const speechInstance = new Speech();
      try {
        await speechInstance.init({ rate: 3, pitch: 1.4 });
        setSpeech(speechInstance);
      } catch (err) {
        console.error("Speech init failed:", err);
      }
    };

    initSpeech();

    const cancelSpeech = () => speech?.cancel();
    router.events.on("routeChangeStart", cancelSpeech);
    window.addEventListener("beforeunload", cancelSpeech);

    return () => {
      router.events.off("routeChangeStart", cancelSpeech);
      window.removeEventListener("beforeunload", cancelSpeech);
      speech?.cancel();
    };
  }, [router]);

  // 清除所有高亮
  const removeAllHighlights = () => {
    const highlights = document.querySelectorAll('.speech-highlight');
    highlights.forEach(highlight => {
      const parent = highlight.parentNode;
      if (parent) {
        // 将span内的文本节点移回父元素,再删除span
        while (highlight.firstChild) {
          parent.insertBefore(highlight.firstChild, highlight);
        }
        parent.removeChild(highlight);
        // 合并相邻的文本节点
        parent.normalize();
      }
    });
  };

  // 递归遍历所有文本节点,累计字符数,找到目标位置并插入高亮span
  const findAndHighlight = (node: Node, targetIndex: number, targetLength: number): { found: boolean, charCount: number } => {
    let charCount = 0;

    if (node.nodeType === Node.TEXT_NODE && node.textContent) {
      const textLength = node.textContent.length;
      if (charCount + textLength < targetIndex) {
        return { found: false, charCount: charCount + textLength };
      }

      // 计算当前文本节点内的起始和结束位置
      const startInNode = targetIndex - charCount;
      const endInNode = startInNode + targetLength;

      // 拆分文本节点:前半部分、高亮部分、后半部分
      const before = node.textContent.slice(0, startInNode);
      const highlightText = node.textContent.slice(startInNode, endInNode);
      const after = node.textContent.slice(endInNode);

      const parent = node.parentNode;
      if (parent) {
        // 删除原文本节点
        parent.removeChild(node);

        // 插入前半部分文本(如果有)
        if (before) parent.insertBefore(document.createTextNode(before), null);

        // 插入高亮span
        const highlightSpan = document.createElement('span');
        highlightSpan.className = 'speech-highlight';
        highlightSpan.style.cssText = `text-decoration: underline; text-underline-position: under; text-decoration-color: #fb923c`;
        highlightSpan.textContent = highlightText;
        parent.insertBefore(highlightSpan, null);

        // 插入后半部分文本(如果有)
        if (after) parent.insertBefore(document.createTextNode(after), null);
      }

      return { found: true, charCount: charCount + textLength };
    }

    // 遍历子节点
    for (let child of Array.from(node.childNodes)) {
      const result = findAndHighlight(child, targetIndex, targetLength);
      charCount += result.charCount;
      if (result.found) {
        return { found: true, charCount };
      }
    }

    return { found: false, charCount };
  };

  const play = () => {
    if (!speech) return;
    if (speech.paused()) {
      speech.resume();
      setIsPlaying(true);
      return;
    }
    if (!("speechSynthesis" in window)) {
      alert("浏览器不支持文本转语音功能");
      return;
    }
    if (!text) return;

    // 预处理文本(保持原逻辑)
    const processedText = text.replaceAll(".", ",").replace(/\n/g, " ");
    let currentCharIndex = 0;

    speech.speak({
      text: processedText,
      listeners: {
        onstart: () => {
          removeAllHighlights();
          setIsPlaying(true);
        },
        onended: () => {
          removeAllHighlights();
          setIsPlaying(false);
        },
        onboundary: (event: SpeechSynthesisEvent) => {
          if (event.name !== 'word') return; // 只处理单词边界
          removeAllHighlights();
          currentCharIndex = event.charIndex;
          const contentNode = document.getElementById("article-content");
          if (contentNode) {
            findAndHighlight(contentNode, currentCharIndex, event.charLength);
          }
        },
      },
    });
  };

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

  return { play, pause, isPlaying };
};

关键改进点

  • 避免innerHTML替换:通过遍历文本节点拆分插入span,完全保留原有DOM结构(比如Markdown渲染的粗体、斜体标签)
  • 精准字符定位:递归遍历所有文本节点,累计字符数,解决跨节点的索引计算问题
  • 改进高亮清除:将高亮span替换回文本节点并合并,避免DOM残留
  • 优化事件监听:使用async/await初始化Speech,添加错误处理,避免内存泄漏

内容的提问来源于stack exchange,提问作者anonymoususer8893

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.05 03:50:23