Skip to content

Commit

Permalink
feat: develop search ui
Browse files Browse the repository at this point in the history
  • Loading branch information
sanyuan0704 committed Oct 5, 2022
1 parent 0bb6210 commit 517002d
Show file tree
Hide file tree
Showing 3 changed files with 131 additions and 55 deletions.
10 changes: 10 additions & 0 deletions src/node/unocss.config.ts
Expand Up @@ -45,6 +45,16 @@ const options: VitePluginConfig = {
overflow: 'hidden',
'text-overflow': 'ellipsis'
}
],
[
'multi-line-ellipsis',
{
overflow: 'hidden',
'text-overflow': 'ellipsis',
display: '-webkit-box',
'webkit-line-clamp': '2',
'-webkit-box-orient': 'vertical'
}
]
],
theme: {
Expand Down
34 changes: 25 additions & 9 deletions src/theme-default/components/Search/index.tsx
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { ChangeEvent, useCallback, useEffect, useRef, useState } from 'react';
import { MatchResultItem, PageSearcher } from '../../logic/search';
import SearchSvg from './icons/search.svg';

Expand Down Expand Up @@ -35,9 +35,9 @@ function SuggestionContent(props: {
statementHighlightIndex + query.length
);
return (
<div font="normal" text="sm">
<div font="normal" text="sm gray-light" w="100%">
<span>{statementPrefix}</span>
<span bg="brand-light" p="y-0.4 x-0.8" rounded="md" text="text-1">
<span bg="brand-light" p="y-0.4 x-0.8" rounded="md" text="[#000]">
{query}
</span>
<span>{statementSuffix}</span>
Expand All @@ -51,7 +51,7 @@ function SuggestionContent(props: {
p="x-3 y-2"
hover="bg-[#f3f4f5]"
className="border-right-none"
transition="bg duration-300"
transition="bg duration-200"
>
<div font="medium" text="sm">
{renderHeaderMatch()}
Expand All @@ -63,15 +63,26 @@ function SuggestionContent(props: {

export function Search() {
const [suggestions, setSuggestions] = useState<MatchResultItem[]>([]);
const [query, setQuery] = useState('');
const [focused, setFocused] = useState(false);
const showSuggestions = focused && suggestions.length > 0;
const psRef = useRef<PageSearcher>();
useEffect(() => {
async function search() {
const ps = new PageSearcher();
await ps.init();
const matched = await ps.match('title');
setSuggestions(matched);
psRef.current = new PageSearcher();
await psRef.current.init();
}
search();
}, []);
const onQueryChanged = useCallback((e: ChangeEvent<HTMLInputElement>) => {
const newQuery = e.target.value;
setQuery(newQuery);
if (psRef.current) {
psRef.current.match(newQuery).then((matched) => {
setSuggestions(matched);
});
}
}, []);
return (
<div flex="" items-center="~" relative="" mr="4" font="semibold">
<SearchSvg w="5" h="5" fill="currentColor" />
Expand All @@ -88,16 +99,21 @@ export function Search() {
className="rounded-sm"
aria-label="Search"
autoComplete="off"
onChange={onQueryChanged}
onBlur={() => setTimeout(() => setFocused(false), 200)}
onFocus={() => setFocused(true)}
/>
{suggestions.length > 0 && (
{showSuggestions && (
<ul
display={showSuggestions ? 'block' : 'none'}
absolute=""
z="60"
pos="top-8"
border-1=""
p="2"
list="none"
bg="bg-default"
className="min-w-500px max-w-700px"
>
{suggestions.map((item) => (
<li key={item.title} rounded="sm" cursor="pointer" w="100%">
Expand Down
142 changes: 96 additions & 46 deletions src/theme-default/logic/search.ts
Expand Up @@ -2,6 +2,10 @@ import FlexSearch from 'flexsearch';
import type { Index as SearchIndex, CreateOptions } from 'flexsearch';
import { getAllPages } from 'island/client';
import { uniqBy } from 'lodash-es';
import { normalizeHref } from './index';

const THRESHOLD_CONTENT_LENGTH = 100;

interface PageDataForSearch {
title: string;
headers: string[];
Expand Down Expand Up @@ -34,6 +38,7 @@ const cjkRegex =
export class PageSearcher {
#index?: SearchIndex<PageDataForSearch[]>;
#cjkIndex?: SearchIndex<PageDataForSearch[]>;
#headerToIdMap: Record<string, string> = {};
async init(options: CreateOptions = {}) {
// Initial pages data and create index
const pages = await getAllPages();
Expand All @@ -44,6 +49,13 @@ export class PageSearcher {
path: page.routePath
}));

this.#headerToIdMap = pages.reduce((acc, page) => {
(page.toc || []).forEach((header) => {
acc[page.routePath + header.text] = header.id;
});
return acc;
}, {} as Record<string, string>);

const createOptions: CreateOptions = {
encode: 'simple',
tokenize: 'forward',
Expand Down Expand Up @@ -91,58 +103,96 @@ export class PageSearcher {
.filter(Boolean) as PageDataForSearch[];
const matchedResult: MatchResultItem[] = [];
flattenSearchResult.forEach((item) => {
const { headers } = item!;
// Header match
for (const header of headers) {
if (header.includes(query)) {
matchedResult.push({
type: 'header',
title: item.title,
header,
headerHighlightIndex: header.indexOf(query),
link: `${item.path}#${header}`
});
return;
}
const matchedHeader = this.#matchHeader(item, query, matchedResult);
// If we have matched header, we don't need to match content
// Because the header is already in the content
if (matchedHeader) {
return;
}
// Content match
const content = item.content;
const queryIndex = content.indexOf(query);
const headersIndex = headers.map((h) => content.indexOf(h));
const currentHeaderIndex = headersIndex.findIndex((hIndex, position) => {
if (position < headers.length - 1) {
const next = headersIndex[position + 1];
if (hIndex <= queryIndex && next >= queryIndex) {
return true;
}
} else {
return hIndex < queryIndex;
}
});
const currentHeader = headers[currentHeaderIndex] ?? item.title;

const statementStartIndex = content
.slice(0, queryIndex)
.lastIndexOf('\n');
const statementEndIndex = content.indexOf(
'\n',
queryIndex + query.length
);
const statement = content.slice(
statementStartIndex + 1,
statementEndIndex
);
matchedResult.push({
type: 'content',
title: item.title,
header: currentHeader,
statement,
statementHighlightIndex: statement.indexOf(query),
link: `${item.path}#${currentHeader}`
});
this.#matchContent(item, query, matchedResult);
});
const res = uniqBy(matchedResult, 'link');
console.log(res);
return res;
}

#matchHeader(
item: PageDataForSearch,
query: string,
matchedResult: MatchResultItem[]
): boolean {
const { headers } = item;
for (const header of headers) {
if (header.includes(query)) {
const headerAnchor = this.#headerToIdMap[item.path + header];
matchedResult.push({
type: 'header',
title: item.title,
header,
headerHighlightIndex: header.indexOf(query),
link: `${item.path}#${headerAnchor}`
});
return true;
}
}
return false;
}

#matchContent(
item: PageDataForSearch,
query: string,
matchedResult: MatchResultItem[]
) {
const { content, headers } = item;
const queryIndex = content.indexOf(query);
const headersIndex = headers.map((h) => content.indexOf(h));
const currentHeaderIndex = headersIndex.findIndex((hIndex, position) => {
if (position < headers.length - 1) {
const next = headersIndex[position + 1];
if (hIndex <= queryIndex && next >= queryIndex) {
return true;
}
} else {
return hIndex < queryIndex;
}
});
const currentHeader = headers[currentHeaderIndex] ?? item.title;

const statementStartIndex = content.slice(0, queryIndex).lastIndexOf('\n');
const statementEndIndex = content.indexOf('\n', queryIndex + query.length);
let statement = content.slice(statementStartIndex, statementEndIndex);
if (statement.length > THRESHOLD_CONTENT_LENGTH) {
statement = this.#normalizeStatement(statement, query);
}
matchedResult.push({
type: 'content',
title: item.title,
header: currentHeader,
statement,
statementHighlightIndex: statement.indexOf(query),
link: `${normalizeHref(item.path)}#${currentHeader}`
});
}

#normalizeStatement(statement: string, query: string) {
// If statement is too long, we will only show 120 characters
const queryIndex = statement.indexOf(query);
const maxPrefixOrSuffix = Math.floor(
(THRESHOLD_CONTENT_LENGTH - query.length) / 2
);
let prefix = statement.slice(0, queryIndex);
if (prefix.length > maxPrefixOrSuffix) {
prefix =
'...' + statement.slice(queryIndex - maxPrefixOrSuffix + 3, queryIndex);
}
let suffix = statement.slice(queryIndex + query.length);
if (suffix.length > maxPrefixOrSuffix) {
suffix =
statement.slice(queryIndex + query.length, maxPrefixOrSuffix - 3) +
'...';
}
return prefix + query + suffix;
}
}

0 comments on commit 517002d

Please sign in to comment.