feat(core): support fuzzy highlighting (#4765)

This commit is contained in:
JimmFly
2023-10-31 11:02:53 +08:00
committed by GitHub
parent 17afe218fe
commit a015dc42bb
3 changed files with 101 additions and 34 deletions

View File

@@ -1,13 +1,7 @@
import { escapeRegExp } from 'lodash-es';
import { memo } from 'react'; import { memo } from 'react';
import { import { useHighlight } from '../../../hooks/affine/use-highlight';
highlightContainer, import * as styles from './highlight.css';
highlightKeyword,
highlightText,
labelContent,
labelTitle,
} from './highlight.css';
type SearchResultLabel = { type SearchResultLabel = {
title: string; title: string;
@@ -28,33 +22,23 @@ export const Highlight = memo(function Highlight({
text = '', text = '',
highlight = '', highlight = '',
}: HighlightProps) { }: HighlightProps) {
//Regex is used to ignore case // Use regular expression to replace all line breaks and carriage returns in the text
const regex = highlight.trim() const cleanedText = text.replace(/\r?\n|\r/g, '');
? new RegExp(`(${escapeRegExp(highlight)})`, 'ig')
: null;
if (!regex) { const highlights = useHighlight(cleanedText, highlight.toLowerCase());
return <span>{text}</span>;
}
const parts = text.split(regex);
return ( return (
<div className={highlightContainer}> <div className={styles.highlightContainer}>
{parts.map((part, i) => { {highlights.map((part, i) => (
if (regex.test(part)) { <span
return ( key={i}
<span key={i} className={highlightKeyword}> className={
{part} part.highlight ? styles.highlightKeyword : styles.highlightText
</span> }
); >
} else { {part.text}
return ( </span>
<span key={i} className={highlightText}> ))}
{part}
</span>
);
}
})}
</div> </div>
); );
}); });
@@ -65,11 +49,11 @@ export const HighlightLabel = memo(function HighlightLabel({
}: HighlightLabelProps) { }: HighlightLabelProps) {
return ( return (
<div> <div>
<div className={labelTitle}> <div className={styles.labelTitle}>
<Highlight text={label.title} highlight={highlight} /> <Highlight text={label.title} highlight={highlight} />
</div> </div>
{label.subTitle ? ( {label.subTitle ? (
<div className={labelContent}> <div className={styles.labelContent}>
<Highlight text={label.subTitle} highlight={highlight} /> <Highlight text={label.subTitle} highlight={highlight} />
</div> </div>
) : null} ) : null}

View File

@@ -0,0 +1,33 @@
import { describe, expect, test } from 'vitest';
import { highlightTextFragments } from '../affine/use-highlight';
describe('highlightTextFragments', () => {
test('should correctly highlight full matches', () => {
const highlights = highlightTextFragments('This is a test', 'is');
expect(highlights).toStrictEqual([
{ text: 'Th', highlight: false },
{ text: 'is', highlight: true },
{ text: ' is a test', highlight: false },
]);
});
test('highlight with space', () => {
const result = highlightTextFragments('Hello World', 'lo w');
expect(result).toEqual([
{ text: 'Hel', highlight: false },
{ text: 'lo W', highlight: true },
{ text: 'orld', highlight: false },
]);
});
test('should correctly perform partial matching', () => {
const highlights = highlightTextFragments('Hello World', 'hw');
expect(highlights).toStrictEqual([
{ text: 'H', highlight: true },
{ text: 'ello ', highlight: false },
{ text: 'W', highlight: true },
{ text: 'orld', highlight: false },
]);
});
});

View File

@@ -0,0 +1,50 @@
import { useMemo } from 'react';
function* highlightTextFragmentsGenerator(text: string, query: string) {
const lowerCaseText = text.toLowerCase();
let startIndex = lowerCaseText.indexOf(query);
if (startIndex !== -1) {
if (startIndex > 0) {
yield { text: text.substring(0, startIndex), highlight: false };
}
yield {
text: text.substring(startIndex, startIndex + query.length),
highlight: true,
};
if (startIndex + query.length < text.length) {
yield {
text: text.substring(startIndex + query.length),
highlight: false,
};
}
} else {
startIndex = 0;
for (const char of query) {
const pos = text.toLowerCase().indexOf(char, startIndex);
if (pos !== -1) {
if (pos > startIndex) {
yield {
text: text.substring(startIndex, pos),
highlight: false,
};
}
yield { text: text.substring(pos, pos + 1), highlight: true };
startIndex = pos + 1;
}
}
if (startIndex < text.length) {
yield { text: text.substring(startIndex), highlight: false };
}
}
}
export function highlightTextFragments(text: string, query: string) {
return Array.from(highlightTextFragmentsGenerator(text, query));
}
export function useHighlight(text: string, query: string) {
return useMemo(() => highlightTextFragments(text, query), [text, query]);
}