import React, { useMemo, useState, useEffect } from 'react'
import { AutoComplete, Input } from 'antd'
import {
SearchOutlined,
NumberOutlined,
ProfileOutlined,
ClearOutlined,
} from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { useAllPagesOutlines } from 'vite-plugin-react-pages/client'
import { useThemeCtx } from '../..'
import s from './search.module.less'
import type { PageMeta } from '../../analyzeStaticData'
const recentSearchesKey = '__VITE_PAGES_RECENT_SEARCHES'
const getPagePosition = (page: PageMetaExtended) => {
return [page.groupKey, page.subGroupKey, page.pageTitle]
.filter((s) => s !== '/')
.join(' > ')
}
const hasInRecentSearches = (
page: PageMetaExtended,
recentSearches: SearchResultItem[]
) => {
return recentSearches
.map((item) => item.page.pagePath)
.includes(page.pagePath)
}
const renderSearchResultItem = (
type: 'title' | 'heading',
matchedString: string,
pagePosition: string
) => {
if (type === 'title') {
return (
Title: {matchedString}
{pagePosition}
)
}
if (type === 'heading') {
return (
Heading: {matchedString}
{pagePosition}
)
}
throw new Error('unexpected SearchResultItem: type ' + type)
}
const calcRecentSearchesOptions = (
recentSearches: SearchResultItem[],
clearAllHistory: () => void
) => {
const len = recentSearches.length
const label = (
{len ? 'Recent' : 'No recent searches'}
{len ? : null}
)
const options = recentSearches.map((item) => {
const { type, page, matechedString } = item
const value = [
type,
page.pagePath,
type === 'heading' ? item.headingId : '',
matechedString,
].join(' - ')
const rendered = (() => {
const pagePosition = getPagePosition(page)
return renderSearchResultItem(type, matechedString, pagePosition)
})()
return { value, label: rendered, result: item }
})
return [{ label, options }]
}
interface Props {}
// TODO: use https://github.com/nextapps-de/flexsearch to do full text search in browser
// flexsearch options to support both en and zh:
// https://github.com/nextapps-de/flexsearch/issues/202#issuecomment-1092409502
// load outline data on demand
const Search: React.FC> = (props) => {
const { staticData, resolvedLocale, pageGroups } = useThemeCtx()
const [popupOpen, setPopupOpen] = useState(false)
const [keywords, setKeywords] = useState('')
const [recentSearches, setRecentSearches] = useState([])
const navigate = useNavigate()
const allPagesOutlines = useAllPagesOutlines(2000)?.allPagesOutlines
const clearAllHistory = () => {
setRecentSearches([])
localStorage.setItem(recentSearchesKey, JSON.stringify([]))
}
const recentSearchesOptions = useMemo(
() => calcRecentSearchesOptions(recentSearches, clearAllHistory),
[recentSearches]
)
const preparedPages = useMemo(() => {
const res = [] as PageMetaExtended[]
Object.entries(pageGroups).forEach(([groupKey, group]) => {
Object.entries(group).forEach(([subGroupKey, pages]) => {
pages.forEach((page) => {
// pages with path params should not be showed in search result
if (page.pagePath.includes('/:')) return
// pages with different locale should not be showed in search result
if (resolvedLocale.localeKey !== page.pageLocaleKey) return
const outlines = (() => {
const pageOutlines = allPagesOutlines?.[page.pagePath]
if (!pageOutlines) return
const outlinesMerged: any[] = []
Object.entries(pageOutlines).forEach(([key, { outline }]: any) => {
outlinesMerged.push(...outline)
})
if (!outlinesMerged.length) return
return outlinesMerged
})()
res.push({ ...page, outlines })
})
})
})
return res
}, [allPagesOutlines, staticData, resolvedLocale.localeKey])
const options = useMemo(() => {
const filteredData = filter(preparedPages, keywords)
return filteredData.map((item) => {
const { type, page, matechedString } = item
const rendered = (() => {
const pagePosition = getPagePosition(page)
return renderSearchResultItem(type, matechedString, pagePosition)
})()
return {
value: [
type,
page.pagePath,
type === 'heading' ? item.headingId : '',
matechedString,
].join(' - '),
label: rendered,
result: item,
}
})
}, [preparedPages, keywords])
useEffect(() => {
const value = localStorage.getItem(recentSearchesKey)
if (value) {
setRecentSearches(JSON.parse(value))
}
}, [])
return (
)
}
export default Search
function filter(
pages: PageMetaExtended[],
keywords: string
): SearchResultItem[] {
keywords = keywords?.trim()
if (!keywords) return []
const results: SearchResultItem[] = []
pages.forEach((page) => {
if (page.pagePath === '/404') return
if (containString(page.pageTitle, keywords)) {
results.push({
type: 'title',
page,
matechedString: page.pageTitle,
})
}
page.outlines?.forEach(({ id, text, depth }, index) => {
if (text === page.pageTitle && index === 0) {
// ignore the first outline heading if it is same with the page title
// we already have a result item with `{type: 'title', matechedString: page.pageTitle}`
return
}
if (containString(text, keywords)) {
results.push({
type: 'heading',
page,
matechedString: text,
headingId: id,
})
}
})
})
return results
}
/**
* string match ignoring case
*/
function containString(whole: string, part: string) {
return whole.toLowerCase().indexOf(part.toLowerCase()) !== -1
}
interface OutlineItem {
text: string
id: string
depth: number
}
type PageMetaExtended = PageMeta & { outlines?: OutlineItem[] }
type SearchResultItem =
| {
type: 'title'
page: PageMetaExtended
matechedString: string
}
| {
type: 'heading'
page: PageMetaExtended
matechedString: string
headingId: string
}