cherry-studio/src/renderer/src/components/ContentSearch.tsx
2025-09-15 06:51:25 +08:00

513 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { ToolbarButton } from '@renderer/pages/home/Inputbar/Inputbar'
import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
import { Tooltip } from 'antd'
import { debounce } from 'lodash'
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
import React, { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface Props {
children?: React.ReactNode
searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
/**
* 过滤`node``node`只会是`Node.TEXT_NODE`类型的文本节点
*
* 返回`true`表示该`node`会被搜索
*/
filter: NodeFilter
includeUser?: boolean
onIncludeUserChange?: (value: boolean) => void
/**
* 是否显示“包含用户问题”切换按钮(默认为 true
* 在富文本编辑器场景通常不需要该按钮。
*/
showUserToggle?: boolean
/**
* 搜索条定位方式
*/
positionMode?: 'fixed' | 'absolute' | 'sticky'
}
enum SearchCompletedState {
NotSearched,
Searched
}
export interface ContentSearchRef {
disable(): void
enable(initialText?: string): void
// 搜索下一个并定位
searchNext(): void
// 搜索上一个并定位
searchPrev(): void
// 搜索并定位
search(): void
// 搜索但不定位,或者说是更新
silentSearch(): void
focus(): void
}
const escapeRegExp = (string: string): string => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
}
const findRangesInTarget = (
target: HTMLElement,
filter: NodeFilter,
searchText: string,
isCaseSensitive: boolean,
isWholeWord: boolean
): Range[] => {
CSS.highlights.clear()
const ranges: Range[] = []
const escapedSearchText = escapeRegExp(searchText)
// 检查搜索文本是否仅包含拉丁字母
const hasOnlyLatinLetters = /^[a-zA-Z\s]+$/.test(searchText)
// 只有当搜索文本仅包含拉丁字母时才应用大小写敏感
const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi'
const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText
const searchRegex = new RegExp(regexPattern, regexFlags)
const treeWalker = document.createTreeWalker(target, NodeFilter.SHOW_TEXT, filter)
const allTextNodes: { node: Node; startOffset: number }[] = []
let fullText = ''
// 1. 拼接所有文本节点内容
while (treeWalker.nextNode()) {
allTextNodes.push({
node: treeWalker.currentNode,
startOffset: fullText.length
})
fullText += treeWalker.currentNode.nodeValue
}
// 2.在完整文本中查找匹配项
let match: RegExpExecArray | null = null
while ((match = searchRegex.exec(fullText))) {
const matchStart = match.index
const matchEnd = matchStart + match[0].length
// 3. 将匹配项的索引映射回DOM Range
let startNode: Node | null = null
let endNode: Node | null = null
let startOffset = 0
let endOffset = 0
// 找到起始节点和偏移
for (const nodeInfo of allTextNodes) {
if (
matchStart >= nodeInfo.startOffset &&
matchStart < nodeInfo.startOffset + (nodeInfo.node.nodeValue?.length ?? 0)
) {
startNode = nodeInfo.node
startOffset = matchStart - nodeInfo.startOffset
break
}
}
// 找到结束节点和偏移
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
}
}
// 如果起始和结束节点都找到了,则创建一个 Range
if (startNode && endNode) {
const range = new Range()
range.setStart(startNode, startOffset)
range.setEnd(endNode, endOffset)
ranges.push(range)
}
}
return ranges
}
// eslint-disable-next-line @eslint-react/no-forward-ref
export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
(
{ searchTarget, filter, includeUser = false, onIncludeUserChange, showUserToggle = true, positionMode = 'fixed' },
ref
) => {
const target: HTMLElement | null = (() => {
if (searchTarget instanceof HTMLElement) {
return searchTarget
} else {
return (searchTarget.current as HTMLElement) ?? null
}
})()
const containerRef = React.useRef<HTMLDivElement>(null)
const searchInputRef = React.useRef<HTMLInputElement>(null)
const [enableContentSearch, setEnableContentSearch] = useState(false)
const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched)
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
const [isWholeWord, setIsWholeWord] = useState(false)
const [allRanges, setAllRanges] = useState<Range[]>([])
const [currentIndex, setCurrentIndex] = useState(-1)
const prevSearchText = useRef('')
const { t } = useTranslation()
const resetSearch = useCallback(() => {
CSS.highlights.clear()
setAllRanges([])
setSearchCompleted(SearchCompletedState.NotSearched)
}, [])
const locateByIndex = useCallback(
(shouldScroll = true) => {
// 清理旧的高亮
CSS.highlights.clear()
if (allRanges.length > 0) {
// 1. 创建并注册所有匹配项的高亮
const allMatchesHighlight = new Highlight(...allRanges)
CSS.highlights.set('search-matches', allMatchesHighlight)
// 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) {
parentElement?.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'nearest'
})
}
}
}
},
[allRanges, currentIndex]
)
const search = useCallback(
(jump = false) => {
const searchText = searchInputRef.current?.value.trim() ?? null
setSearchCompleted(SearchCompletedState.Searched)
if (target && searchText !== null && searchText !== '') {
const ranges = findRangesInTarget(target, filter, searchText, isCaseSensitive, isWholeWord)
setAllRanges(ranges)
setCurrentIndex(jump && ranges.length > 0 ? 0 : -1)
}
},
[target, filter, isCaseSensitive, isWholeWord]
)
const implementation = useMemo(
() => ({
disable: () => {
setEnableContentSearch(false)
CSS.highlights.clear()
},
enable: (initialText?: string) => {
setEnableContentSearch(true)
if (searchInputRef.current) {
const inputEl = searchInputRef.current
if (initialText && initialText.trim().length > 0) {
inputEl.value = initialText
requestAnimationFrame(() => {
inputEl.focus()
inputEl.select()
search(false)
})
} else {
requestAnimationFrame(() => {
inputEl.focus()
inputEl.select()
})
}
}
},
searchNext: () => {
if (allRanges.length > 0) {
setCurrentIndex((prev) => (prev < allRanges.length - 1 ? prev + 1 : 0))
}
},
searchPrev: () => {
if (allRanges.length > 0) {
setCurrentIndex((prev) => (prev > 0 ? prev - 1 : allRanges.length - 1))
}
},
resetSearchState: () => {
setSearchCompleted(SearchCompletedState.NotSearched)
},
search: () => {
search(true)
locateByIndex(true)
},
silentSearch: () => {
search(false)
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
},
[searchHandler, resetSearch]
)
const keyDownHandler = useCallback(
(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 {
implementation.searchNext()
}
} else if (event.key === 'Escape') {
event.stopPropagation()
implementation.disable()
}
},
[implementation, resetSearch]
)
const searchInputFocus = useCallback(() => {
requestAnimationFrame(() => searchInputRef.current?.focus())
}, [])
const userOutlinedButtonOnClick = useCallback(() => {
onIncludeUserChange?.(!includeUser)
searchInputFocus()
}, [includeUser, onIncludeUserChange, searchInputFocus])
useImperativeHandle(ref, () => implementation, [implementation])
useEffect(() => {
locateByIndex()
}, [currentIndex, locateByIndex])
useEffect(() => {
if (enableContentSearch && searchInputRef.current?.value.trim()) {
search(true)
}
}, [isCaseSensitive, isWholeWord, enableContentSearch, search])
const prevButtonOnClick = () => {
implementation.searchPrev()
searchInputFocus()
}
const nextButtonOnClick = () => {
implementation.searchNext()
searchInputFocus()
}
const closeButtonOnClick = () => {
implementation.disable()
}
const caseSensitiveButtonOnClick = () => {
setIsCaseSensitive(!isCaseSensitive)
searchInputFocus()
}
const wholeWordButtonOnClick = () => {
setIsWholeWord(!isWholeWord)
searchInputFocus()
}
return (
<Container
ref={containerRef}
style={enableContentSearch ? {} : { display: 'none' }}
$overlayPosition={positionMode === 'absolute' ? 'absolute' : 'static'}>
<NarrowLayout style={{ width: '100%' }}>
<SearchBarContainer $position={positionMode}>
<InputWrapper>
<Input
ref={searchInputRef}
onInput={userInputHandler}
onKeyDown={keyDownHandler}
placeholder={t('chat.assistant.search.placeholder')}
style={{ lineHeight: '20px' }}
/>
<ToolBar>
{showUserToggle && (
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Tooltip>
)}
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
<CaseSensitive
size={18}
style={{ color: isCaseSensitive ? 'var(--color-link)' : 'var(--color-icon)' }}
/>
</ToolbarButton>
</Tooltip>
<Tooltip title={t('button.whole_word')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={wholeWordButtonOnClick}>
<WholeWord size={18} style={{ color: isWholeWord ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Tooltip>
</ToolBar>
</InputWrapper>
<Separator></Separator>
<SearchResults>
{searchCompleted !== SearchCompletedState.NotSearched && allRanges.length > 0 ? (
<>
<SearchResultCount>{currentIndex + 1}</SearchResultCount>
<SearchResultSeparator>/</SearchResultSeparator>
<SearchResultTotalCount>{allRanges.length}</SearchResultTotalCount>
</>
) : (
<SearchResultsPlaceholder>0/0</SearchResultsPlaceholder>
)}
</SearchResults>
<ToolBar>
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={allRanges.length === 0}>
<ChevronUp size={18} />
</ToolbarButton>
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={allRanges.length === 0}>
<ChevronDown size={18} />
</ToolbarButton>
<ToolbarButton type="text" onClick={closeButtonOnClick}>
<X size={18} />
</ToolbarButton>
</ToolBar>
</SearchBarContainer>
</NarrowLayout>
<Placeholder />
</Container>
)
}
)
ContentSearch.displayName = 'ContentSearch'
const Container = styled.div<{ $overlayPosition: 'static' | 'absolute' }>`
display: flex;
flex-direction: row;
position: ${({ $overlayPosition }) => $overlayPosition};
top: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')};
left: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')};
right: ${({ $overlayPosition }) => ($overlayPosition === 'absolute' ? '0' : 'auto')};
z-index: 999;
`
const SearchBarContainer = styled.div<{ $position: 'fixed' | 'absolute' | 'sticky' }>`
border: 1px solid var(--color-primary);
border-radius: 10px;
transition: all 0.2s ease;
position: ${({ $position }) => $position};
top: 15px;
left: 20px;
right: 20px;
margin-bottom: 5px;
padding: 5px 15px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-background);
flex: 1 1 auto; /* Take up input's previous space */
`
const Placeholder = styled.div`
width: 5px;
`
const InputWrapper = styled.div`
display: flex;
align-items: center;
flex: 1 1 auto; /* Take up input's previous space */
`
const Input = styled.input`
border: none;
color: var(--color-text);
background-color: transparent;
outline: none;
width: 100%;
padding: 0 5px; /* Adjust padding, wrapper will handle spacing */
flex: 1; /* Allow input to grow */
font-size: 14px;
font-family: Ubuntu;
`
const ToolBar = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: tpx;
`
const Separator = styled.div`
width: 1px;
height: 1.5em;
background-color: var(--color-border);
margin-left: 2px;
margin-right: 2px;
flex: 0 0 auto;
`
const SearchResults = styled.div`
display: flex;
justify-content: center;
width: 80px;
margin: 0 2px;
flex: 0 0 auto;
color: var(--color-text-1);
font-size: 14px;
font-family: Ubuntu;
`
const SearchResultsPlaceholder = styled.span`
color: var(--color-text-1);
opacity: 0.5;
`
const SearchResultCount = styled.span`
color: var(--color-text);
`
const SearchResultSeparator = styled.span`
color: var(--color-text);
margin: 0 4px;
`
const SearchResultTotalCount = styled.span`
color: var(--color-text);
`