mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-04 20:00:00 +08:00
♻️ refactor(ContentSearch): ContentSearch to use CSS highlights API (#7493)
This commit is contained in:
parent
8de6ae1772
commit
101d73fc10
@ -173,12 +173,12 @@ ul {
|
|||||||
color: var(--color-icon);
|
color: var(--color-icon);
|
||||||
}
|
}
|
||||||
|
|
||||||
span.highlight {
|
::highlight(search-matches) {
|
||||||
background-color: var(--color-background-highlight);
|
background-color: var(--color-background-highlight);
|
||||||
color: var(--color-highlight);
|
color: var(--color-highlight);
|
||||||
}
|
}
|
||||||
|
|
||||||
span.highlight.selected {
|
::highlight(current-match) {
|
||||||
background-color: var(--color-background-highlight-accent);
|
background-color: var(--color-background-highlight-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,13 +3,10 @@ import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
|
|||||||
import { Tooltip } from 'antd'
|
import { Tooltip } from 'antd'
|
||||||
import { debounce } from 'lodash'
|
import { debounce } from 'lodash'
|
||||||
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
|
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
|
||||||
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import styled from 'styled-components'
|
import styled from 'styled-components'
|
||||||
|
|
||||||
const HIGHLIGHT_CLASS = 'highlight'
|
|
||||||
const HIGHLIGHT_SELECT_CLASS = 'selected'
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
|
searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
|
||||||
@ -18,19 +15,14 @@ interface Props {
|
|||||||
*
|
*
|
||||||
* 返回`true`表示该`node`会被搜索
|
* 返回`true`表示该`node`会被搜索
|
||||||
*/
|
*/
|
||||||
filter: (node: Node) => boolean
|
filter: NodeFilter
|
||||||
includeUser?: boolean
|
includeUser?: boolean
|
||||||
onIncludeUserChange?: (value: boolean) => void
|
onIncludeUserChange?: (value: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
enum SearchCompletedState {
|
enum SearchCompletedState {
|
||||||
NotSearched,
|
NotSearched,
|
||||||
FirstSearched
|
Searched
|
||||||
}
|
|
||||||
|
|
||||||
enum SearchTargetIndex {
|
|
||||||
Next,
|
|
||||||
Prev
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ContentSearchRef {
|
export interface ContentSearchRef {
|
||||||
@ -47,60 +39,20 @@ export interface ContentSearchRef {
|
|||||||
focus(): void
|
focus(): void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MatchInfo {
|
|
||||||
index: number
|
|
||||||
length: number
|
|
||||||
text: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const escapeRegExp = (string: string): string => {
|
const escapeRegExp = (string: string): string => {
|
||||||
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
|
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
|
||||||
}
|
}
|
||||||
|
|
||||||
const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => {
|
const findRangesInTarget = (
|
||||||
if (!elementList || elementList.length === 0) {
|
target: HTMLElement,
|
||||||
return null
|
filter: NodeFilter,
|
||||||
}
|
|
||||||
let closestElementIndex: number | null = null
|
|
||||||
let minVerticalDistance = Infinity
|
|
||||||
const windowCenterY = window.innerHeight / 2
|
|
||||||
for (let i = 0; i < elementList.length; i++) {
|
|
||||||
const element = elementList[i]
|
|
||||||
if (!(element instanceof HTMLElement)) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const rect = element.getBoundingClientRect()
|
|
||||||
if (rect.bottom < 0 || rect.top > window.innerHeight) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
const elementCenterY = rect.top + rect.height / 2
|
|
||||||
const verticalDistance = Math.abs(elementCenterY - windowCenterY)
|
|
||||||
if (verticalDistance < minVerticalDistance) {
|
|
||||||
minVerticalDistance = verticalDistance
|
|
||||||
closestElementIndex = i
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return closestElementIndex
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlightText = (
|
|
||||||
textNode: Node,
|
|
||||||
searchText: string,
|
searchText: string,
|
||||||
highlightClass: string,
|
|
||||||
isCaseSensitive: boolean,
|
isCaseSensitive: boolean,
|
||||||
isWholeWord: boolean
|
isWholeWord: boolean
|
||||||
): HTMLSpanElement[] | null => {
|
): Range[] => {
|
||||||
const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement
|
CSS.highlights.clear()
|
||||||
if (textNodeParentNode) {
|
const ranges: Range[] = []
|
||||||
if (textNodeParentNode.classList.contains(highlightClass)) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const textContent = textNode.textContent
|
|
||||||
const escapedSearchText = escapeRegExp(searchText)
|
const escapedSearchText = escapeRegExp(searchText)
|
||||||
|
|
||||||
// 检查搜索文本是否仅包含拉丁字母
|
// 检查搜索文本是否仅包含拉丁字母
|
||||||
@ -109,89 +61,66 @@ const highlightText = (
|
|||||||
// 只有当搜索文本仅包含拉丁字母时才应用大小写敏感
|
// 只有当搜索文本仅包含拉丁字母时才应用大小写敏感
|
||||||
const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi'
|
const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi'
|
||||||
const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText
|
const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText
|
||||||
const regex = new RegExp(regexPattern, regexFlags)
|
const searchRegex = new RegExp(regexPattern, regexFlags)
|
||||||
|
const treeWalker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, filter)
|
||||||
|
const allTextNodes: { node: Node; startOffset: number }[] = []
|
||||||
|
let fullText = ''
|
||||||
|
|
||||||
let match
|
// 1. 拼接所有文本节点内容
|
||||||
const matches: MatchInfo[] = []
|
while (treeWalker.nextNode()) {
|
||||||
while ((match = regex.exec(textContent)) !== null) {
|
allTextNodes.push({
|
||||||
if (typeof match.index === 'number' && typeof match[0] === 'string') {
|
node: treeWalker.currentNode,
|
||||||
matches.push({ index: match.index, length: match[0].length, text: match[0] })
|
startOffset: fullText.length
|
||||||
} else {
|
})
|
||||||
console.error('Unexpected match format:', match)
|
fullText += treeWalker.currentNode.nodeValue
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matches.length === 0) {
|
// 2.在完整文本中查找匹配项
|
||||||
return null
|
let match: RegExpExecArray | null = null
|
||||||
}
|
while ((match = searchRegex.exec(fullText))) {
|
||||||
|
const matchStart = match.index
|
||||||
|
const matchEnd = matchStart + match[0].length
|
||||||
|
|
||||||
const parentNode = textNode.parentNode
|
// 3. 将匹配项的索引映射回DOM Range
|
||||||
if (!parentNode) {
|
let startNode: Node | null = null
|
||||||
return null
|
let endNode: Node | null = null
|
||||||
}
|
let startOffset = 0
|
||||||
|
let endOffset = 0
|
||||||
|
|
||||||
const fragment = document.createDocumentFragment()
|
// 找到起始节点和偏移
|
||||||
let currentIndex = 0
|
for (const nodeInfo of allTextNodes) {
|
||||||
const highlightTextSet = new Set<HTMLSpanElement>()
|
if (
|
||||||
|
matchStart >= nodeInfo.startOffset &&
|
||||||
matches.forEach(({ index, length, text }) => {
|
matchStart < nodeInfo.startOffset + (nodeInfo.node.nodeValue?.length ?? 0)
|
||||||
if (index > currentIndex) {
|
) {
|
||||||
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index)))
|
startNode = nodeInfo.node
|
||||||
}
|
startOffset = matchStart - nodeInfo.startOffset
|
||||||
const highlightSpan = document.createElement('span')
|
break
|
||||||
highlightSpan.className = highlightClass
|
|
||||||
highlightSpan.textContent = text // Use the matched text to preserve case if not case-sensitive
|
|
||||||
fragment.appendChild(highlightSpan)
|
|
||||||
highlightTextSet.add(highlightSpan)
|
|
||||||
currentIndex = index + length
|
|
||||||
})
|
|
||||||
|
|
||||||
if (currentIndex < textContent.length) {
|
|
||||||
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex)))
|
|
||||||
}
|
|
||||||
|
|
||||||
parentNode.replaceChild(fragment, textNode)
|
|
||||||
return [...highlightTextSet]
|
|
||||||
}
|
|
||||||
|
|
||||||
const mergeAdjacentTextNodes = (node: HTMLElement) => {
|
|
||||||
const children = Array.from(node.childNodes)
|
|
||||||
const groups: Array<Node | { text: string; nodes: Node[] }> = []
|
|
||||||
let currentTextGroup: { text: string; nodes: Node[] } | null = null
|
|
||||||
|
|
||||||
for (const child of children) {
|
|
||||||
if (child.nodeType === Node.TEXT_NODE) {
|
|
||||||
if (currentTextGroup === null) {
|
|
||||||
currentTextGroup = {
|
|
||||||
text: child.textContent ?? '',
|
|
||||||
nodes: [child]
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
currentTextGroup.text += child.textContent
|
|
||||||
currentTextGroup.nodes.push(child)
|
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
if (currentTextGroup !== null) {
|
|
||||||
groups.push(currentTextGroup!)
|
// 找到结束节点和偏移
|
||||||
currentTextGroup = null
|
for (const nodeInfo of allTextNodes) {
|
||||||
|
if (
|
||||||
|
matchEnd > nodeInfo.startOffset &&
|
||||||
|
matchEnd <= nodeInfo.startOffset + (nodeInfo.node.nodeValue?.length ?? 0)
|
||||||
|
) {
|
||||||
|
endNode = nodeInfo.node
|
||||||
|
endOffset = matchEnd - nodeInfo.startOffset
|
||||||
|
break
|
||||||
}
|
}
|
||||||
groups.push(child)
|
}
|
||||||
|
|
||||||
|
// 如果起始和结束节点都找到了,则创建一个 Range
|
||||||
|
if (startNode && endNode) {
|
||||||
|
const range = new Range()
|
||||||
|
range.setStart(startNode, startOffset)
|
||||||
|
range.setEnd(endNode, endOffset)
|
||||||
|
ranges.push(range)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentTextGroup !== null) {
|
return ranges
|
||||||
groups.push(currentTextGroup)
|
|
||||||
}
|
|
||||||
|
|
||||||
const newChildren = groups.map((group) => {
|
|
||||||
if (group instanceof Node) {
|
|
||||||
return group
|
|
||||||
} else {
|
|
||||||
return document.createTextNode(group.text)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
node.replaceChildren(...newChildren)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @eslint-react/no-forward-ref
|
// eslint-disable-next-line @eslint-react/no-forward-ref
|
||||||
@ -206,328 +135,178 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
|||||||
})()
|
})()
|
||||||
const containerRef = React.useRef<HTMLDivElement>(null)
|
const containerRef = React.useRef<HTMLDivElement>(null)
|
||||||
const searchInputRef = React.useRef<HTMLInputElement>(null)
|
const searchInputRef = React.useRef<HTMLInputElement>(null)
|
||||||
const [searchResultIndex, setSearchResultIndex] = useState(0)
|
|
||||||
const [totalCount, setTotalCount] = useState(0)
|
|
||||||
const [enableContentSearch, setEnableContentSearch] = useState(false)
|
const [enableContentSearch, setEnableContentSearch] = useState(false)
|
||||||
const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched)
|
const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched)
|
||||||
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
|
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
|
||||||
const [isWholeWord, setIsWholeWord] = useState(false)
|
const [isWholeWord, setIsWholeWord] = useState(false)
|
||||||
const [shouldScroll, setShouldScroll] = useState(false)
|
const [allRanges, setAllRanges] = useState<Range[]>([])
|
||||||
const highlightTextSet = useState(new Set<Node>())[0]
|
const [currentIndex, setCurrentIndex] = useState(0)
|
||||||
const prevSearchText = useRef('')
|
const prevSearchText = useRef('')
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
const locateByIndex = (index: number, shouldScroll = true) => {
|
const resetSearch = useCallback(() => {
|
||||||
if (target) {
|
CSS.highlights.clear()
|
||||||
const highlightTextNodes = [...highlightTextSet] as HTMLElement[]
|
setAllRanges([])
|
||||||
highlightTextNodes.sort((a, b) => {
|
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||||
const { top: aTop } = a.getBoundingClientRect()
|
}, [])
|
||||||
const { top: bTop } = b.getBoundingClientRect()
|
|
||||||
return aTop - bTop
|
const locateByIndex = useCallback(
|
||||||
})
|
(shouldScroll = true) => {
|
||||||
for (const node of highlightTextNodes) {
|
// 清理旧的高亮
|
||||||
node.classList.remove(HIGHLIGHT_SELECT_CLASS)
|
CSS.highlights.clear()
|
||||||
}
|
|
||||||
setSearchResultIndex(index)
|
if (allRanges.length > 0) {
|
||||||
if (highlightTextNodes.length > 0) {
|
// 1. 创建并注册所有匹配项的高亮
|
||||||
const highlightTextNode = highlightTextNodes[index] ?? null
|
const allMatchesHighlight = new Highlight(...allRanges)
|
||||||
if (highlightTextNode) {
|
CSS.highlights.set('search-matches', allMatchesHighlight)
|
||||||
highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS)
|
|
||||||
|
// 2. 如果有当前项,为其创建并注册一个特殊的高亮
|
||||||
|
if (currentIndex !== -1 && allRanges[currentIndex]) {
|
||||||
|
const currentMatchRange = allRanges[currentIndex]
|
||||||
|
const currentMatchHighlight = new Highlight(currentMatchRange)
|
||||||
|
CSS.highlights.set('current-match', currentMatchHighlight)
|
||||||
|
|
||||||
|
// 3. 将当前项滚动到视图中
|
||||||
|
// 获取第一个文本节点的父元素来进行滚动
|
||||||
|
const parentElement = currentMatchRange.startContainer.parentElement
|
||||||
if (shouldScroll) {
|
if (shouldScroll) {
|
||||||
highlightTextNode.scrollIntoView({
|
parentElement?.scrollIntoView({
|
||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
block: 'center'
|
block: 'center',
|
||||||
// inline: 'center' 水平方向居中可能会导致 content 页面整体偏右, 使得左半部的内容被遮挡. 因此先注释掉该代码
|
inline: 'nearest'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
[allRanges, currentIndex]
|
||||||
|
)
|
||||||
|
|
||||||
const restoreHighlight = () => {
|
const search = useCallback(() => {
|
||||||
const highlightTextParentNodeSet = new Set<HTMLElement>()
|
|
||||||
// Make a copy because the set might be modified during iteration indirectly
|
|
||||||
const nodesToRestore = [...highlightTextSet]
|
|
||||||
for (const highlightTextNode of nodesToRestore) {
|
|
||||||
if (highlightTextNode.textContent) {
|
|
||||||
const textNode = document.createTextNode(highlightTextNode.textContent)
|
|
||||||
const node = highlightTextNode as HTMLElement
|
|
||||||
if (node.parentNode) {
|
|
||||||
highlightTextParentNodeSet.add(node.parentNode as HTMLElement)
|
|
||||||
node.replaceWith(textNode) // This removes the node from the DOM
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
highlightTextSet.clear() // Clear the original set after processing
|
|
||||||
for (const parentNode of highlightTextParentNodeSet) {
|
|
||||||
mergeAdjacentTextNodes(parentNode)
|
|
||||||
}
|
|
||||||
// highlightTextSet.clear() // Already cleared
|
|
||||||
}
|
|
||||||
|
|
||||||
const search = (searchTargetIndex?: SearchTargetIndex): number | null => {
|
|
||||||
const searchText = searchInputRef.current?.value.trim() ?? null
|
const searchText = searchInputRef.current?.value.trim() ?? null
|
||||||
|
setSearchCompleted(SearchCompletedState.Searched)
|
||||||
if (target && searchText !== null && searchText !== '') {
|
if (target && searchText !== null && searchText !== '') {
|
||||||
restoreHighlight()
|
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
|
||||||
const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT)
|
setAllRanges(ranges)
|
||||||
let textNode: Node | null
|
setCurrentIndex(0)
|
||||||
const textNodeSet: Set<Node> = new Set()
|
|
||||||
while ((textNode = iter.nextNode())) {
|
|
||||||
if (filter(textNode)) {
|
|
||||||
textNodeSet.add(textNode)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const highlightTextSetTemp = new Set<HTMLSpanElement>()
|
|
||||||
for (const node of textNodeSet) {
|
|
||||||
const list = highlightText(node, searchText, HIGHLIGHT_CLASS, isCaseSensitive, isWholeWord)
|
|
||||||
if (list) {
|
|
||||||
list.forEach((node) => highlightTextSetTemp.add(node))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const highlightTextList = [...highlightTextSetTemp]
|
|
||||||
setTotalCount(highlightTextList.length)
|
|
||||||
highlightTextSetTemp.forEach((node) => highlightTextSet.add(node))
|
|
||||||
const changeIndex = () => {
|
|
||||||
let index: number
|
|
||||||
switch (searchTargetIndex) {
|
|
||||||
case SearchTargetIndex.Next:
|
|
||||||
{
|
|
||||||
index = (searchResultIndex + 1) % highlightTextList.length
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case SearchTargetIndex.Prev:
|
|
||||||
{
|
|
||||||
index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length
|
|
||||||
}
|
|
||||||
break
|
|
||||||
default: {
|
|
||||||
index = searchResultIndex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Math.max(index, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetIndex = (() => {
|
|
||||||
switch (searchCompleted) {
|
|
||||||
case SearchCompletedState.NotSearched: {
|
|
||||||
setSearchCompleted(SearchCompletedState.FirstSearched)
|
|
||||||
const index = findWindowVerticalCenterElementIndex(highlightTextList)
|
|
||||||
if (index !== null) {
|
|
||||||
setSearchResultIndex(index)
|
|
||||||
return index
|
|
||||||
} else {
|
|
||||||
setSearchResultIndex(0)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case SearchCompletedState.FirstSearched: {
|
|
||||||
return changeIndex()
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
|
|
||||||
if (targetIndex === null) {
|
|
||||||
return null
|
|
||||||
} else {
|
|
||||||
const totalCount = highlightTextSet.size
|
|
||||||
if (targetIndex >= totalCount) {
|
|
||||||
return totalCount - 1
|
|
||||||
} else {
|
|
||||||
return targetIndex
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}, [target, filter, isCaseSensitive, isWholeWord])
|
||||||
|
|
||||||
const _searchHandlerDebounce = debounce(() => {
|
const implementation = useMemo(
|
||||||
implementation.search()
|
() => ({
|
||||||
}, 300)
|
disable: () => {
|
||||||
const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce])
|
setEnableContentSearch(false)
|
||||||
const userInputHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
|
CSS.highlights.clear()
|
||||||
const value = event.target.value.trim()
|
},
|
||||||
if (value.length === 0) {
|
enable: (initialText?: string) => {
|
||||||
restoreHighlight()
|
setEnableContentSearch(true)
|
||||||
setTotalCount(0)
|
if (searchInputRef.current) {
|
||||||
setSearchResultIndex(0)
|
const inputEl = searchInputRef.current
|
||||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
if (initialText && initialText.trim().length > 0) {
|
||||||
} else {
|
inputEl.value = initialText
|
||||||
// 用户输入时允许滚动
|
requestAnimationFrame(() => {
|
||||||
setShouldScroll(true)
|
inputEl.focus()
|
||||||
searchHandler()
|
inputEl.select()
|
||||||
}
|
search()
|
||||||
prevSearchText.current = value
|
CSS.highlights.clear()
|
||||||
}
|
|
||||||
|
|
||||||
const keyDownHandler = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
|
||||||
const { code, key, shiftKey } = event
|
|
||||||
if (key === 'Process') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (code) {
|
|
||||||
case 'Enter':
|
|
||||||
{
|
|
||||||
if (shiftKey) {
|
|
||||||
implementation.searchPrev()
|
|
||||||
} else {
|
|
||||||
implementation.searchNext()
|
|
||||||
}
|
|
||||||
event.preventDefault()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
case 'Escape':
|
|
||||||
{
|
|
||||||
implementation.disable()
|
|
||||||
}
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const searchInputFocus = () => requestAnimationFrame(() => searchInputRef.current?.focus())
|
|
||||||
|
|
||||||
const userOutlinedButtonOnClick = () => {
|
|
||||||
if (onIncludeUserChange) {
|
|
||||||
onIncludeUserChange(!includeUser)
|
|
||||||
}
|
|
||||||
searchInputFocus()
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
const implementation = {
|
|
||||||
disable() {
|
|
||||||
setEnableContentSearch(false)
|
|
||||||
restoreHighlight()
|
|
||||||
setShouldScroll(false)
|
|
||||||
},
|
|
||||||
enable(initialText?: string) {
|
|
||||||
setEnableContentSearch(true)
|
|
||||||
setShouldScroll(false) // Default to false, search itself might set it to true
|
|
||||||
if (searchInputRef.current) {
|
|
||||||
const inputEl = searchInputRef.current
|
|
||||||
if (initialText && initialText.trim().length > 0) {
|
|
||||||
inputEl.value = initialText
|
|
||||||
// Trigger search after setting initial text
|
|
||||||
// Need to make sure search() uses the new value
|
|
||||||
// and also to focus and select
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
inputEl.focus()
|
|
||||||
inputEl.select()
|
|
||||||
setShouldScroll(true)
|
|
||||||
const targetIndex = search()
|
|
||||||
if (targetIndex !== null) {
|
|
||||||
locateByIndex(targetIndex, true) // Ensure scrolling
|
|
||||||
} else {
|
|
||||||
// If search returns null (e.g., empty input or no matches with initial text), clear state
|
|
||||||
restoreHighlight()
|
|
||||||
setTotalCount(0)
|
|
||||||
setSearchResultIndex(0)
|
|
||||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||||
}
|
})
|
||||||
})
|
} else {
|
||||||
} else {
|
requestAnimationFrame(() => {
|
||||||
requestAnimationFrame(() => {
|
inputEl.focus()
|
||||||
inputEl.focus()
|
inputEl.select()
|
||||||
inputEl.select()
|
})
|
||||||
})
|
|
||||||
// Only search if there's existing text and no new initialText
|
|
||||||
if (inputEl.value.trim()) {
|
|
||||||
const targetIndex = search()
|
|
||||||
if (targetIndex !== null) {
|
|
||||||
setSearchResultIndex(targetIndex)
|
|
||||||
// locateByIndex(targetIndex, false); // Don't scroll if just enabling with existing text
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
searchNext: () => {
|
||||||
searchNext() {
|
if (allRanges.length > 0) {
|
||||||
if (enableContentSearch) {
|
setCurrentIndex((prev) => (prev < allRanges.length - 1 ? prev + 1 : 0))
|
||||||
const targetIndex = search(SearchTargetIndex.Next)
|
|
||||||
if (targetIndex !== null) {
|
|
||||||
locateByIndex(targetIndex)
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
searchPrev: () => {
|
||||||
searchPrev() {
|
if (allRanges.length > 0) {
|
||||||
if (enableContentSearch) {
|
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : allRanges.length - 1))
|
||||||
const targetIndex = search(SearchTargetIndex.Prev)
|
|
||||||
if (targetIndex !== null) {
|
|
||||||
locateByIndex(targetIndex)
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
resetSearchState: () => {
|
||||||
resetSearchState() {
|
|
||||||
if (enableContentSearch) {
|
|
||||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
setSearchCompleted(SearchCompletedState.NotSearched)
|
||||||
// Maybe also reset index? Depends on desired behavior
|
},
|
||||||
// setSearchResultIndex(0);
|
search: () => {
|
||||||
|
search()
|
||||||
|
locateByIndex(true)
|
||||||
|
},
|
||||||
|
silentSearch: () => {
|
||||||
|
search()
|
||||||
|
locateByIndex(false)
|
||||||
|
},
|
||||||
|
focus: () => {
|
||||||
|
searchInputRef.current?.focus()
|
||||||
}
|
}
|
||||||
|
}),
|
||||||
|
[allRanges.length, locateByIndex, search]
|
||||||
|
)
|
||||||
|
|
||||||
|
const _searchHandlerDebounce = useMemo(() => debounce(implementation.search, 300), [implementation.search])
|
||||||
|
|
||||||
|
const searchHandler = useCallback(() => {
|
||||||
|
_searchHandlerDebounce()
|
||||||
|
}, [_searchHandlerDebounce])
|
||||||
|
|
||||||
|
const userInputHandler = useCallback(
|
||||||
|
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.target.value.trim()
|
||||||
|
if (value.length === 0) {
|
||||||
|
resetSearch()
|
||||||
|
} else {
|
||||||
|
searchHandler()
|
||||||
|
}
|
||||||
|
prevSearchText.current = value
|
||||||
},
|
},
|
||||||
search() {
|
[searchHandler, resetSearch]
|
||||||
if (enableContentSearch) {
|
)
|
||||||
const targetIndex = search()
|
|
||||||
if (targetIndex !== null) {
|
const keyDownHandler = useCallback(
|
||||||
locateByIndex(targetIndex, shouldScroll)
|
(event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault()
|
||||||
|
const value = (event.target as HTMLInputElement).value.trim()
|
||||||
|
if (value.length === 0) {
|
||||||
|
resetSearch()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (event.shiftKey) {
|
||||||
|
implementation.searchPrev()
|
||||||
} else {
|
} else {
|
||||||
// If search returns null (e.g., empty input), clear state
|
implementation.searchNext()
|
||||||
restoreHighlight()
|
|
||||||
setTotalCount(0)
|
|
||||||
setSearchResultIndex(0)
|
|
||||||
setSearchCompleted(SearchCompletedState.NotSearched)
|
|
||||||
}
|
}
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
implementation.disable()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
silentSearch() {
|
[implementation, resetSearch]
|
||||||
if (enableContentSearch) {
|
)
|
||||||
const targetIndex = search()
|
|
||||||
if (targetIndex !== null) {
|
|
||||||
// 只更新索引,不触发滚动
|
|
||||||
locateByIndex(targetIndex, false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
focus() {
|
|
||||||
searchInputFocus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({
|
const searchInputFocus = useCallback(() => {
|
||||||
disable() {
|
requestAnimationFrame(() => searchInputRef.current?.focus())
|
||||||
implementation.disable()
|
}, [])
|
||||||
},
|
|
||||||
enable(initialText?: string) {
|
const userOutlinedButtonOnClick = useCallback(() => {
|
||||||
implementation.enable(initialText)
|
onIncludeUserChange?.(!includeUser)
|
||||||
},
|
searchInputFocus()
|
||||||
searchNext() {
|
}, [includeUser, onIncludeUserChange, searchInputFocus])
|
||||||
implementation.searchNext()
|
|
||||||
},
|
useImperativeHandle(ref, () => implementation, [implementation])
|
||||||
searchPrev() {
|
|
||||||
implementation.searchPrev()
|
useEffect(() => {
|
||||||
},
|
locateByIndex()
|
||||||
search() {
|
}, [currentIndex, locateByIndex])
|
||||||
implementation.search()
|
|
||||||
},
|
|
||||||
silentSearch() {
|
|
||||||
implementation.silentSearch()
|
|
||||||
},
|
|
||||||
focus() {
|
|
||||||
implementation.focus()
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Re-run search when options change and search is active
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (enableContentSearch && searchInputRef.current?.value.trim()) {
|
if (enableContentSearch && searchInputRef.current?.value.trim()) {
|
||||||
implementation.search()
|
search()
|
||||||
}
|
}
|
||||||
}, [isCaseSensitive, isWholeWord, enableContentSearch, implementation]) // Add enableContentSearch dependency
|
}, [isCaseSensitive, isWholeWord, enableContentSearch, search])
|
||||||
|
|
||||||
const prevButtonOnClick = () => {
|
const prevButtonOnClick = () => {
|
||||||
implementation.searchPrev()
|
implementation.searchPrev()
|
||||||
@ -589,11 +368,11 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
|||||||
<Separator></Separator>
|
<Separator></Separator>
|
||||||
<SearchResults>
|
<SearchResults>
|
||||||
{searchCompleted !== SearchCompletedState.NotSearched ? (
|
{searchCompleted !== SearchCompletedState.NotSearched ? (
|
||||||
totalCount > 0 ? (
|
allRanges.length > 0 ? (
|
||||||
<>
|
<>
|
||||||
<SearchResultCount>{searchResultIndex + 1}</SearchResultCount>
|
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
|
||||||
<SearchResultSeparator>/</SearchResultSeparator>
|
<SearchResultSeparator>/</SearchResultSeparator>
|
||||||
<SearchResultTotalCount>{totalCount}</SearchResultTotalCount>
|
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<NoResults>{t('common.no_results')}</NoResults>
|
<NoResults>{t('common.no_results')}</NoResults>
|
||||||
@ -603,10 +382,10 @@ export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
|
|||||||
)}
|
)}
|
||||||
</SearchResults>
|
</SearchResults>
|
||||||
<ToolBar>
|
<ToolBar>
|
||||||
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={totalCount === 0}>
|
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={allRanges.length === 0}>
|
||||||
<ChevronUp size={18} />
|
<ChevronUp size={18} />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={totalCount === 0}>
|
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={allRanges.length === 0}>
|
||||||
<ChevronDown size={18} />
|
<ChevronDown size={18} />
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
<ToolbarButton type="text" onClick={closeButtonOnClick}>
|
<ToolbarButton type="text" onClick={closeButtonOnClick}>
|
||||||
|
|||||||
@ -55,28 +55,30 @@ const Chat: FC<Props> = (props) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const contentSearchFilter = (node: Node): boolean => {
|
const contentSearchFilter: NodeFilter = {
|
||||||
if (node.parentNode) {
|
acceptNode(node) {
|
||||||
let parentNode: HTMLElement | null = node.parentNode as HTMLElement
|
if (node.parentNode) {
|
||||||
while (parentNode?.parentNode) {
|
let parentNode: HTMLElement | null = node.parentNode as HTMLElement
|
||||||
if (parentNode.classList.contains('MessageFooter')) {
|
while (parentNode?.parentNode) {
|
||||||
return false
|
if (parentNode.classList.contains('MessageFooter')) {
|
||||||
}
|
return NodeFilter.FILTER_REJECT
|
||||||
|
}
|
||||||
|
|
||||||
if (filterIncludeUser) {
|
if (filterIncludeUser) {
|
||||||
if (parentNode?.classList.contains('message-content-container')) {
|
if (parentNode?.classList.contains('message-content-container')) {
|
||||||
return true
|
return NodeFilter.FILTER_ACCEPT
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (parentNode?.classList.contains('message-content-container-assistant')) {
|
if (parentNode?.classList.contains('message-content-container-assistant')) {
|
||||||
return true
|
return NodeFilter.FILTER_ACCEPT
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
parentNode = parentNode.parentNode as HTMLElement
|
||||||
}
|
}
|
||||||
parentNode = parentNode.parentNode as HTMLElement
|
return NodeFilter.FILTER_REJECT
|
||||||
|
} else {
|
||||||
|
return NodeFilter.FILTER_REJECT
|
||||||
}
|
}
|
||||||
return false
|
|
||||||
} else {
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user