From a015dc42bb23997637d563121b4add8985814699 Mon Sep 17 00:00:00 2001 From: JimmFly Date: Tue, 31 Oct 2023 11:02:53 +0800 Subject: [PATCH] feat(core): support fuzzy highlighting (#4765) --- .../src/components/pure/cmdk/highlight.tsx | 52 +++++++------------ .../src/hooks/__tests__/use-highlight.spec.ts | 33 ++++++++++++ .../core/src/hooks/affine/use-highlight.ts | 50 ++++++++++++++++++ 3 files changed, 101 insertions(+), 34 deletions(-) create mode 100644 packages/frontend/core/src/hooks/__tests__/use-highlight.spec.ts create mode 100644 packages/frontend/core/src/hooks/affine/use-highlight.ts diff --git a/packages/frontend/core/src/components/pure/cmdk/highlight.tsx b/packages/frontend/core/src/components/pure/cmdk/highlight.tsx index 4e32d215bb..359f77972d 100644 --- a/packages/frontend/core/src/components/pure/cmdk/highlight.tsx +++ b/packages/frontend/core/src/components/pure/cmdk/highlight.tsx @@ -1,13 +1,7 @@ -import { escapeRegExp } from 'lodash-es'; import { memo } from 'react'; -import { - highlightContainer, - highlightKeyword, - highlightText, - labelContent, - labelTitle, -} from './highlight.css'; +import { useHighlight } from '../../../hooks/affine/use-highlight'; +import * as styles from './highlight.css'; type SearchResultLabel = { title: string; @@ -28,33 +22,23 @@ export const Highlight = memo(function Highlight({ text = '', highlight = '', }: HighlightProps) { - //Regex is used to ignore case - const regex = highlight.trim() - ? new RegExp(`(${escapeRegExp(highlight)})`, 'ig') - : null; + // Use regular expression to replace all line breaks and carriage returns in the text + const cleanedText = text.replace(/\r?\n|\r/g, ''); - if (!regex) { - return {text}; - } - const parts = text.split(regex); + const highlights = useHighlight(cleanedText, highlight.toLowerCase()); return ( -
- {parts.map((part, i) => { - if (regex.test(part)) { - return ( - - {part} - - ); - } else { - return ( - - {part} - - ); - } - })} +
+ {highlights.map((part, i) => ( + + {part.text} + + ))}
); }); @@ -65,11 +49,11 @@ export const HighlightLabel = memo(function HighlightLabel({ }: HighlightLabelProps) { return (
-
+
{label.subTitle ? ( -
+
) : null} diff --git a/packages/frontend/core/src/hooks/__tests__/use-highlight.spec.ts b/packages/frontend/core/src/hooks/__tests__/use-highlight.spec.ts new file mode 100644 index 0000000000..16ef24584c --- /dev/null +++ b/packages/frontend/core/src/hooks/__tests__/use-highlight.spec.ts @@ -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 }, + ]); + }); +}); diff --git a/packages/frontend/core/src/hooks/affine/use-highlight.ts b/packages/frontend/core/src/hooks/affine/use-highlight.ts new file mode 100644 index 0000000000..d807065681 --- /dev/null +++ b/packages/frontend/core/src/hooks/affine/use-highlight.ts @@ -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]); +}