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

实现类Ctrl+F的搜索高亮导航及重音匹配问题求助

解决WhatsApp Web风格聊天应用的搜索高亮问题

问题1:高亮数量无限递增导致页面崩溃

原因

当前在TextFormatter的渲染阶段(highlightText的map循环中)每次匹配都调用onMatchesCounterChange触发父组件状态更新,状态更新会导致Chat组件重新渲染,所有TextFormatter再次执行渲染逻辑,重复触发计数回调,形成无限渲染循环。此外,每次渲染都会重复计数,导致数值持续递增。

解决方案

不再在渲染时逐个触发计数,而是在TextFormatter中通过useEffect一次性计算当前文本的匹配数,再传递给父组件汇总总数:

修改TextFormatter.tsx

import { useEffect } from "react";
import "./textFormatter.css";

interface TextFormatterProps {
  text: string;
  searchQuery?: string | null;
  selectedResultIndex?: number | null;
  onMatchCountChange?: (count: number) => void; // 替换原回调
}

function TextFormatter(props: TextFormatterProps) {
  const boldRegex = /\*([^\s*].*?[^\s*])\*/g;
  const italicRegex = /\_([^\s_].*?[^\s_])\_/g;
  const strikeRegex = /\~([^\s~].*?[^\s~])\~/g;
  const monospaceRegex = /\```([^```].*?[^```])\```/gs;
  const formattingRegex = /(\*[^*]+\*|_[^_]+_|~[^~]+~|```[^```]+```)/g;

  // 计算当前文本的匹配数并通知父组件
  useEffect(() => {
    if (!props.searchQuery || props.searchQuery.trim() === "") {
      props.onMatchCountChange?.(0);
      return;
    }

    const accentsRegex = /[\u0300-\u036f]/gi;
    const formattedSearchQuery = props.searchQuery
      .normalize("NFD")
      .replace(accentsRegex, "")
      .toLowerCase();
    
    // 提取纯文本(移除格式标签)
    const plainText = props.text.replace(formattingRegex, "$1");
    const plainTextNoAccents = plainText.normalize("NFD").replace(accentsRegex, "").toLowerCase();
    
    // 统计匹配次数
    const matches = plainTextNoAccents.match(new RegExp(formattedSearchQuery, "gi"));
    const count = matches ? matches.length : 0;

    props.onMatchCountChange?.(count);
  }, [props.searchQuery, props.text]);

  const formatText = (text: string) => {
    let formattedText = text.split(formattingRegex);
    return formattedText;
  };

  const formattedText = formatText(props.text);

  // 修改highlightText,保留原文本重音
  const highlightText = (text: string) => {
    if (!props.searchQuery || props.searchQuery.trim() === "") {
      return text;
    }

    const accentsRegex = /[\u0300-\u036f]/gi;
    const formattedSearchQuery = props.searchQuery
      .normalize("NFD")
      .replace(accentsRegex, "")
      .toLowerCase();

    const textNoAccents = text.normalize("NFD").replace(accentsRegex, "").toLowerCase();
    const result = [];
    let currentIndex = 0;

    let matchIndex;
    while ((matchIndex = textNoAccents.indexOf(formattedSearchQuery, currentIndex)) !== -1) {
      // 添加匹配前的普通文本
      if (matchIndex > currentIndex) {
        result.push(text.slice(currentIndex, matchIndex));
      }
      // 添加带重音的原匹配文本并高亮
      const matchLength = formattedSearchQuery.length;
      const originalMatch = text.slice(matchIndex, matchIndex + matchLength);
      result.push(
        <span
          key={currentIndex + matchIndex}
          className={`highlight ${
            props.selectedResultIndex !== null && props.selectedResultIndex === result.length
              ? "selected-highlight"
              : ""
          }`}
        >
          {originalMatch}
        </span>
      );
      currentIndex = matchIndex + matchLength;
    }

    // 添加剩余文本
    if (currentIndex < text.length) {
      result.push(text.slice(currentIndex));
    }

    return result;
  };

  return (
    <div className="copyable-text">
      {formattedText.map((segment, index) => {
        if (boldRegex.test(segment)) {
          return (
            <span key={index} className="bold">
              {highlightText(segment.replace(boldRegex, "$1"))}
            </span>
          );
        } else if (italicRegex.test(segment)) {
          return (
            <span key={index} className="italic">
              {highlightText(segment.replace(italicRegex, "$1"))}
            </span>
          );
        } else if (strikeRegex.test(segment)) {
          return (
            <span key={index} className="strike">
              {highlightText(segment.replace(strikeRegex, "$1"))}
            </span>
          );
        } else if (monospaceRegex.test(segment)) {
          return (
            <span key={index} className="monospace">
              {highlightText(segment.replace(monospaceRegex, "$1"))}
            </span>
          );
        } else {
          return highlightText(segment);
        }
      })}
    </div>
  );
}

export default TextFormatter;

修改Chat.tsx

//...
function Chat (props: ChatProps) {
//...

const [search, setSearch] = useState("");
const [totalOfResults, setTotalOfResults] = useState<number>(0);
const [selectedResultIndex, setSelectedResultIndex] = useState<number | null>(null);
const [matchCounts, setMatchCounts] = useState<Record<string, number>>({}); // 存储每个消息的匹配数

// 汇总所有消息的匹配数
useEffect(() => {
  const total = Object.values(matchCounts).reduce((sum, count) => sum + count, 0);
  setTotalOfResults(total);
}, [matchCounts]);

const handleSearchBarChange = (
    event: React.ChangeEvent<HTMLInputElement>
  ) => {
    const searchQuery = event.target.value;
    setSearch(searchQuery);
    setMatchCounts({}); // 重置计数
    setSelectedResultIndex(null);
  };

// 接收单个消息的匹配数
const handleMatchCountChange = (messageId: string, count: number) => {
  setMatchCounts(prev => ({ ...prev, [messageId]: count }));
};

//...

return (
<div className="chat">
  <div className="chat-header">
    //...
    <input
      type="text"
      placeholder="Search..."
      value={search}
      onChange={handleSearchBarChange}
      className={`search-input ${isSearchBarVisible ? "active" : ""}`}
      ref={inputRef}
    />
  </div>

  //...
  <div className="chat-content" ref={chatContentRef}>
  //...
    {props.chatData.map((message) => (
  //...
       <div className="message-content">
         <TextFormatter
          text={message.content}
          searchQuery={search}
          onMatchCountChange={(count) => handleMatchCountChange(message.id, count)}
          selectedResultIndex={selectedResultIndex}
       />
       </div>
       <div className="message-bottom">
         <div className="message-timestamp">
           <span>{formatTimestamp(message.created_at)}</span>
         </div>
       </div>
    </div>
    ))}
 </div>
 //...

</div>
)

问题2:带重音的搜索内容丢失原文本重音

原因

原代码将原文本移除重音后再split,导致最终显示的是无重音的文本;若不移除重音,正则匹配会因字符编码(NFC/NFD)差异或大小写问题无法精准匹配目标内容。

解决方案

匹配时基于无重音的文本进行判断,但显示时保留原文本的带重音内容。通过indexOf遍历无重音文本找到匹配位置,再提取原文本对应片段进行高亮,既实现忽略重音的搜索,又保留原文本的正确显示。

上述修改后的highlightText函数已实现此逻辑,示例效果:

searchQuery = "Café";
sentence = "Hello, café! I'm testing accents.";
// 输出:Hello, <span class="highlight">café</span>! I'm testing accents.

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

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.07.10 04:37:03