React中实现关键词检索高亮以及定位+音频当前时间点播放文本内容同步高亮及编辑

271 阅读4分钟
import { useEffect, useRef, useState } from 'react'

function CombinedHighlighter() {
  const AudioLightedRefs  = useRef(null)
  const [currentSyncTime, setCurrentSyncTime] = useState(0)
  const [currentEditingId, setCurrentEditingId] = useState(null) // 记录当前编辑的句子 ID
  const [inputKeyword, setInputKeyword] = useState('')
  const [keyword, setKeyword] = useState('')
  const [highlightCount, setHighlightCount] = useState(0)
  const [currentIndex, setCurrentIndex] = useState(0)
  const highlightedRefs = useRef([])

  // 初始化文本内容与时间的映射
  const initialTextSegments = [
    {
      begin: 1360,
      end: 5000,
      text: '多测几个,到时候我们测试的时候到时候我们测试的时候可能也测一下这种就是到时候我们测试的时候可能也测一下这种就是到时候我们测试的时候可能也测一下这种就是到时候我们测试的时候可能也测一下这种就是到时候我们测试的时候可能也测一下这种就是到时候我们测试的时候可能也测一下这种就是可能也测一下这种就是',
      uuid: '794b6a811c41449496efd4adc1691008'
    },
    {
      begin: 5020,
      end: 7100,
      text: '碎的片段,比如说好几个人碎的片段,比如说好几个人碎的片段,比如说好几个人碎的片段,比如说好几个人碎的片段,比如说好几个人碎的片段,比如说好几个人碎的片段,比如说好几个人碎的片段,比如说好几个人,',
      uuid: '759be4d4071e412e9dc4cb283015fdd8'
    },
    {
      begin: 7100,
      end: 12410,
      text: '同时在讨论每个人只有几个字的情况下,可能会出现什么效果,其实我的效果还是比较担心的同时在讨论每个人只有几个字的情况下同时在讨论每个人只有几个字的情况下同时在讨论每个人只有几个字的情况下同时在讨论每个人只有几个字的情况下。',
      uuid: 'c8a6db458b7a4ef8a44e3ef7aa1b6cff'
    },
    {
      begin: 13610,
      end: 17580,
      text: '好,那看这个其前端跟内容规整那看这个其前端跟内容规整这块还有没有什么问题?那看这个其前端跟内容规整这块还有没有什么问题?那看这个其前端跟内容规整这块还有没有什么问题?那看这个其前端跟内容规整这块还有没有什么问题?这块还有没有什么问题?',
      uuid: '5273e88ca9f843e59be461b41a6a7b35'
    },
    {
      begin: 20150,
      end: 21470,
      text: '没有的话我们这块。',
      uuid: '71422483c25d42f2aed0224ec6888e8d'
    }
  ]

  const [textSegments, setTextSegments] = useState(initialTextSegments)
  const [isEdit, setIsEdit] = useState(false)

  // 更新音频播放时间
  useEffect(() => {
    const audioElement = AudioLightedRefs.current
    const handleTimeUpdate = () => {
      setCurrentSyncTime(audioElement.currentTime * 1000)
    }

    audioElement.addEventListener('timeupdate', handleTimeUpdate)
    return () => {
      audioElement.removeEventListener('timeupdate', handleTimeUpdate)
    }
  }, [])

  // 高亮显示
  useEffect(() => {
    if (keyword && highlightedRefs.current.length > 0) {
      const currentElement = highlightedRefs.current[currentIndex];
      const previousElement = highlightedRefs.current[currentIndex - 1];
      const nextElement = highlightedRefs.current[currentIndex + 1];
  
      // 移除前一个元素的高亮类
      if (previousElement) {
        previousElement.style.backgroundColor = 'yellow' 
      }
  
      // 为当前元素添加高亮类
      if (currentElement) {
        currentElement.style.backgroundColor = 'orange' 
        currentElement.scrollIntoView({ behavior: 'smooth', block: 'center' });
      }

       // 移除后一个元素的高亮类
      if (nextElement) {
        nextElement.style.backgroundColor = 'yellow' 
      }
    }
  }, [keyword, currentIndex]);

  const handleInputChange = (e) => {
    setInputKeyword(e.target.value)
  }

  const handleHighlightClick = () => {
    if (inputKeyword === keyword) return

    setKeyword(inputKeyword)
    highlightedRefs.current = [] // 清空高亮引用
    setCurrentIndex(0) // 重置为第一个匹配项

    // 重新计算高亮数量
    const count = textSegments.reduce((acc, item) => {
      const regex = new RegExp(`(${inputKeyword})`, 'gi')
      return acc + (item.text.match(regex)?.length || 0)
    }, 0)

    setHighlightCount(count)
  }

  const handleNext = () => {
    if (highlightCount > 0) {
      setCurrentIndex((prevIndex) => (prevIndex + 1) % highlightCount)
    }
  }

  const handlePrevious = () => {
    if (highlightCount > 0) {
      setCurrentIndex(
        (prevIndex) => (prevIndex - 1 + highlightCount) % highlightCount
      )
    }
  }

  const getHighlightedText = () => {
    console.log(11111);
    return textSegments.map((segment) => {
      const isActive =
        currentSyncTime >= segment.begin && currentSyncTime < segment.end
      const isCurrentEditing = currentEditingId === segment.uuid

      // 使用正则表达式分割文本并包裹匹配的关键词
      const parts = segment.text.split(new RegExp(`(${keyword})`, 'gi'))

      return (
        <span
          key={segment.uuid}
          contentEditable={isEdit} // 当处于编辑模式时启用编辑功能
          suppressContentEditableWarning={true} // 禁止警告
          onFocus={() => setCurrentEditingId(segment.uuid)} // 聚焦时设置当前编辑句子
          onBlur={(e) => handleTextChange(segment.uuid, e.target.textContent)} // 失去焦点时保存修改内容
          style={{
            backgroundColor: isActive && !isEdit ? '#654cff' : 'transparent',
            outline: 'none',
            padding: '2px', // 添加一些内边距使其更易点击
            cursor: isEdit ? 'text' : 'default', // 编辑模式下显示文本光标
            borderBottom: isCurrentEditing ? '1px solid black' : 'none' // 在编辑模式下添加下划线
          }}
        >
          {parts.map((part, index) =>
            part.toLowerCase() === keyword.toLowerCase() ? (
              <span
                key={index}
                ref={(el) => el && highlightedRefs.current.push(el)} // 将关键词的引用添加到 highlightedRefs 中
                style={{
                  backgroundColor:
                    currentIndex === highlightedRefs.current.length
                      ? 'orange'
                      : 'yellow'
                }}
              >
                {part}
              </span>
            ) : (
              part
            )
          )}
        </span>
      )
    })
  }

  const handleTextChange = (uuid, newContent) => {
    setTextSegments((prevSegments) =>
      prevSegments.map((segment) =>
        segment.uuid === uuid ? { ...segment, content: newContent } : segment
      )
    )
  }

  const editClick = () => {
    if (!isEdit) {
      // 进入编辑模式时,清空所有高亮
      clearHightLighter()
    } else {
      setCurrentEditingId(null) // 退出编辑模式时清除当前编辑 ID
    }

    setIsEdit(!isEdit)
  }

  const clearHightLighter = () => {
    highlightedRefs.current.forEach((item) => {
      console.log('🚀 ~ highlightedRefs.current.forEach ~ item:', item)
      if (item) {
        item.style.backgroundColor = 'transparent'
      }
    })
    setInputKeyword('')
    setKeyword('')
    setHighlightCount(0)
    setCurrentIndex(0)
    highlightedRefs.current = [] // 清空高亮引用
  }
  
    // 防抖函数,用于限制滚动频率
const debounce = (func, delay) => {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => {
      func(...args);
    }, delay);
  };
};
  // 使用防抖函数来包装 scrollIntoView 方法,限制其调用频率
const debounceScrollIntoView = debounce((element) => {
  element.scrollIntoView({
    behavior: 'smooth',
    block: 'center',
    inline: 'nearest',
  });
}, 300); // 300ms 防抖延迟时间,可根据需要调整

const lastScrolledElement = useRef(null);
const BOTTOM_OFFSET = 400; // 可根据需要调整偏移量
useEffect(() => {
    const activeElement = AudioLightedRefs.current[0];

    // 如果当前高亮元素存在,并且与上一次滚动的元素不同,则触发滚动
    if (activeElement && lastScrolledElement.current !== activeElement) {
      const rect = activeElement.getBoundingClientRect();
      const isInViewport =
        rect.top >= 0 &&
        rect.left >= 0 &&
        rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) - BOTTOM_OFFSET &&
        rect.right <= (window.innerWidth || document.documentElement.clientWidth);

      if (!isInViewport) {
        // 使用防抖函数来限制滚动频率
        debounceScrollIntoView(activeElement);
        lastScrolledElement.current = activeElement; // 更新上一次滚动的元素
      }
    }
  }, [currentSyncTime]);

  return (
    <div>
      <audio ref={AudioLightedRefs} controls>
        <source
          src="https://www.xzmp3.com/down/941e80129a35.mp3"
          type="audio/mpeg"
        />
        Your browser does not support the audio element.
      </audio>
      <button onClick={editClick}>{isEdit ? 'Save' : 'Edit'}</button>
      <div style={{ marginTop: '20px' }}>
        <input
          type="text"
          placeholder="Enter keyword"
          value={inputKeyword}
          onChange={handleInputChange}
          style={{ marginBottom: '10px', padding: '10px', width: '300px' }}
        />
        <button onClick={handleHighlightClick} style={{ marginLeft: '10px' }}>
          Highlight
        </button>
        <div style={{ marginTop: '20px' }}>
          <button onClick={handlePrevious} disabled={highlightCount === 0}>
            Previous
          </button>
          <button
            onClick={handleNext}
            disabled={highlightCount === 0}
            style={{ marginLeft: '10px' }}
          >
            Next
          </button>
          <span style={{ marginLeft: '10px' }}>
            {highlightCount > 0
              ? `${currentIndex + 1}/${highlightCount}`
              : '0/0'}
          </span>
        </div>
      </div>
      <div
        style={{
          maxHeight: '400px',
          overflowY: 'auto',
          border: '1px solid #ccc',
          padding: '10px',
          marginTop: '10px'
        }}
      >
        {getHighlightedText()}
      </div>
    </div>
  )
}

export default CombinedHighlighter