import { useI18n, usePageData } from '@rspress/core/runtime'; import { IconClose, IconLoading, IconSearch, SvgWrapper, Tab, Tabs, useLinkNavigate, } from '@rspress/core/theme'; import { debounce } from '@rspress/shared/lodash-es'; import { useCallback, useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import * as userSearchHooks from 'virtual-search-hooks'; import { PageSearcher } from './logic/search'; import type { CustomMatchResult, DefaultMatchResult, DefaultMatchResultItem, MatchResult, PageSearcherConfig, } from './logic/types'; import { RenderType } from './logic/types'; import { NoSearchResult } from './NoSearchResult'; import './SearchPanel.scss'; import { SuggestItem } from './SuggestItem'; const KEY_CODE = { ARROW_UP: 'ArrowUp', ARROW_DOWN: 'ArrowDown', ENTER: 'Enter', SEARCH: 'KeyK', ESC: 'Escape', }; export interface SearchPanelProps { focused: boolean; setFocused: (focused: boolean) => void; } const useDebounce = any>(cb: T) => { const cbRef = useRef(cb); cbRef.current = cb; const debounced = useCallback( debounce( ((...args: Parameters): ReturnType => cbRef.current(...args)) as T, 150, ), [], ); return debounced; }; export function SearchPanel({ focused, setFocused }: SearchPanelProps) { const [query, setQuery] = useState(''); const [searchResult, setSearchResult] = useState([]); const searchInputRef = useRef(null); const [isSearching, setIsSearching] = useState(false); const [resultTabIndex, setResultTabIndex] = useState(0); const [currentSuggestionIndex, setCurrentSuggestionIndex] = useState(0); const pageSearcherRef = useRef(null); const pageSearcherConfigRef = useRef(null); const [initStatus, setInitStatus] = useState< 'initial' | 'initing' | 'inited' >('initial'); const searchResultRef = useRef(null); const searchResultTabRef = useRef(null); const mousePositionRef = useRef<{ pageX: number | null; pageY: number | null; }>({ pageX: null, pageY: null, }); // only scroll after keydown arrow up and arrow down. const [canScroll, setCanScroll] = useState(false); const scrollTo = (offsetTop: number, offsetHeight: number) => { const currentOffsetHeight = searchResultRef.current?.offsetHeight; const currentScrollTop = searchResultRef.current?.scrollTop; if ( canScroll && currentOffsetHeight !== undefined && currentScrollTop !== undefined ) { // Down // 50 = 20(modal margin) + 40(input height) - 10(item margin) // -10 = 50(following) - 50(tab title) - 10(item margin) const scrollDown = offsetTop + offsetHeight - currentOffsetHeight - (searchResult.length === 1 ? 50 : -10); if (scrollDown > currentScrollTop) { searchResultRef.current?.scrollTo({ top: scrollDown, }); } // Up // 70 = 20(modal margin) + 40(input height) + 10(item margin) // 10 = 70(following) - 50(tab title) - 10(item margin) const scrollUp = searchResult.length === 1 ? offsetTop - 70 : offsetTop - 10; if (scrollUp < currentScrollTop) { searchResultRef.current?.scrollTo({ top: scrollUp, }); } } }; const { siteData, page: { lang, version }, } = usePageData(); const t = useI18n(); const navigate = useLinkNavigate(); const { search, title: siteTitle } = siteData; const versionedSearch = typeof search !== 'boolean' && (search?.versioned ?? true); const DEFAULT_RESULT = [ { group: siteTitle, result: [], renderType: RenderType.Default }, ]; const currentSuggestions = (searchResult[resultTabIndex]?.result as DefaultMatchResultItem[]) ?? []; const currentRenderType = searchResult[resultTabIndex]?.renderType ?? RenderType.Default; if (search === false) { return null; } /** * Create page searcher instance. */ const createSearcher = () => { if (pageSearcherRef.current) { return pageSearcherRef.current; } const pageSearcherConfig = { currentLang: lang, currentVersion: version, }; const pageSearcher = new PageSearcher({ indexName: siteTitle, ...search, ...pageSearcherConfig, }); pageSearcherRef.current = pageSearcher; pageSearcherConfigRef.current = pageSearcherConfig; return pageSearcherRef.current; }; /** * Call `searcher.init` to initialize the search index */ async function initSearch() { if (initStatus !== 'initial') { return; } const searcher = createSearcher(); setInitStatus('initing'); await searcher.init(); setInitStatus('inited'); const query = searchInputRef.current?.value; if (query) { const matched = await searcher.match(query); setSearchResult(matched || DEFAULT_RESULT); setIsSearching(false); } } const clearSearchState = () => { setFocused(false); setResultTabIndex(0); setCurrentSuggestionIndex(0); }; useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { switch (e.code) { case KEY_CODE.SEARCH: if (e.ctrlKey || e.metaKey) { e.preventDefault(); setFocused(!focused); } break; case KEY_CODE.ARROW_DOWN: // prevent arrow down key event when IME is composing if (e.isComposing) { return; } if (focused) { e.preventDefault(); if ( currentSuggestions && currentRenderType === RenderType.Default ) { setCanScroll(true); setCurrentSuggestionIndex( (currentSuggestionIndex + 1) % currentSuggestions.length, ); } } break; case KEY_CODE.ARROW_UP: // prevent arrow up key event when IME is composing if (e.isComposing) { return; } if (focused) { e.preventDefault(); if (currentRenderType === RenderType.Default) { const currentSuggestionsLength = currentSuggestions.length; setCanScroll(true); setCurrentSuggestionIndex( (currentSuggestionIndex - 1 + currentSuggestionsLength) % currentSuggestionsLength, ); } } break; case KEY_CODE.ENTER: /** * prevent enter key event when IME is composing, it's more friendly for CJK users. * @see https://github.com/web-infra-dev/rspress/issues/1861 */ if (e.isComposing) { return; } if ( currentSuggestionIndex >= 0 && currentRenderType === RenderType.Default ) { // the ResultItem has been normalized to display const flatSuggestions = Array.from( normalizeSuggestions(currentSuggestions).values(), ).flat(); const suggestion = flatSuggestions[currentSuggestionIndex]; navigate(suggestion.link); clearSearchState(); } break; case KEY_CODE.ESC: clearSearchState(); break; default: break; } }; document.addEventListener('keydown', onKeyDown); return () => { document.removeEventListener('keydown', onKeyDown); }; }, [ setCurrentSuggestionIndex, setFocused, focused, resultTabIndex, currentSuggestions, currentSuggestionIndex, ]); useEffect(() => { if (focused) { setSearchResult(DEFAULT_RESULT); initSearch(); } else { setQuery(''); } }, [focused]); // Prefetch the search index when the page is idle useEffect(() => { if ('requestIdleCallback' in window && !pageSearcherRef.current) { window.requestIdleCallback(() => { const searcher = createSearcher(); searcher.fetchSearchIndex(); }); } }, []); // init pageSearcher again when lang or version changed useEffect(() => { const { currentLang, currentVersion } = pageSearcherConfigRef.current ?? {}; const isLangChanged = lang !== currentLang; const isVersionChanged = versionedSearch && version !== currentVersion; if (isLangChanged || isVersionChanged) { // reset status first setInitStatus('initial'); pageSearcherRef.current = null; const searcher = createSearcher(); searcher.fetchSearchIndex(); } }, [lang, version, versionedSearch]); const handleQueryChangedImpl = async (value: string) => { let newQuery = value; setQuery(newQuery); if (newQuery) { const searchResult: MatchResult = []; if ('beforeSearch' in userSearchHooks) { const key = 'beforeSearch' as const; const transformedQuery = await userSearchHooks[key](newQuery); if (transformedQuery) { newQuery = transformedQuery; } } const defaultSearchResult = await pageSearcherRef.current?.match(newQuery); if (defaultSearchResult) { searchResult.push(...defaultSearchResult); } if ('onSearch' in userSearchHooks) { const key = 'onSearch' as const; const customSearchResult = await userSearchHooks[key]( newQuery, searchResult as DefaultMatchResult[], ); if (customSearchResult) { searchResult.push( ...customSearchResult.map( item => ({ renderType: RenderType.Custom, ...item, }) as CustomMatchResult, ), ); } } if ('afterSearch' in userSearchHooks) { const key = 'afterSearch' as const; await userSearchHooks[key](newQuery, searchResult); } // only setSearchResult when query is current query value const currQuery = searchInputRef.current?.value; if (currQuery === newQuery) { // Reset current suggestion index to 0 when search query changes setCurrentSuggestionIndex(0); setSearchResult(searchResult || DEFAULT_RESULT); setIsSearching(false); } } }; const handleQueryChange = useDebounce(handleQueryChangedImpl); const normalizeSuggestions = ( suggestions: DefaultMatchResult['result'], ): Map => { return suggestions.reduce( (groups, item) => { const group = item.title; if (!groups.has(group)) { groups.set(group, []); } groups.get(group)!.push(item); return groups; }, new Map() as Map, ); }; const renderSearchResult = (result: MatchResult, isSearching: boolean) => { if (result.length === 1) { const currentSearchResult = result[0] .result as DefaultMatchResult['result']; if (currentSearchResult.length === 0 && !isSearching) { return ; } return (
{renderSearchResultItem(currentSearchResult, query, isSearching)}
); } const renderKey = 'render' as const; return ( { setResultTabIndex(index); setCurrentSuggestionIndex(0); }} keepDOM={false} ref={searchResultTabRef} > {result.map(item => ( {item.renderType === RenderType.Default && renderSearchResultItem(item.result, query, isSearching)} {item.renderType === RenderType.Custom && userSearchHooks[renderKey](item.result)} ))} ); }; const renderSearchResultItem = ( suggestionList: DefaultMatchResult['result'], query: string, isSearching: boolean, ) => { // if isSearching, show loading svg if (isSearching) { return (
); } // if no result, show the no result tip if (suggestionList.length === 0 && initStatus === 'inited') { return ; } const normalizedSuggestions = normalizeSuggestions(suggestionList); // accumulateIndex is used to calculate the index of the suggestion in the whole list. let accumulateIndex = -1; return (
    {Array.from(normalizedSuggestions.keys()).map(group => { const groupSuggestions = normalizedSuggestions.get(group) || []; return (
    • {groupSuggestions.map(suggestion => { accumulateIndex++; const suggestionIndex = accumulateIndex; return ( { if ( mousePositionRef.current.pageX === event.pageX && mousePositionRef.current.pageY === event.pageY ) { return; } setCanScroll(false); setCurrentSuggestionIndex(suggestionIndex); }} onMouseMove={event => { mousePositionRef.current = { pageX: event.pageX, pageY: event.pageY, }; }} closeSearch={() => { clearSearchState(); }} inCurrentDocIndex={resultTabIndex === 0} scrollTo={scrollTo} /> ); })}
  • ); })}
); }; return ( <> {focused && createPortal(
{ clearSearchState(); }} >
{ setFocused(true); e.stopPropagation(); }} >
handleQueryChange(e.target.value)} />

{ e.stopPropagation(); clearSearchState(); }} > {t('searchPanelCancelText')}

{query && initStatus === 'inited' ? (
{renderSearchResult(searchResult, isSearching)}
) : null}
, document.getElementById('__rspress_modal_container')!, )} ); }