Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(core): support fuzzy highlighting #4765

Merged
merged 5 commits into from
Oct 31, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
52 changes: 18 additions & 34 deletions packages/frontend/core/src/components/pure/cmdk/highlight.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 <span>{text}</span>;
}
const parts = text.split(regex);
const highlights = useHighlight(cleanedText, highlight.toLowerCase());

return (
<div className={highlightContainer}>
{parts.map((part, i) => {
if (regex.test(part)) {
return (
<span key={i} className={highlightKeyword}>
{part}
</span>
);
} else {
return (
<span key={i} className={highlightText}>
{part}
</span>
);
}
})}
<div className={styles.highlightContainer}>
{highlights.map((part, i) => (
<span
key={i}
className={
part.highlight ? styles.highlightKeyword : styles.highlightText
}
>
{part.text}
</span>
))}
</div>
);
});
Expand All @@ -65,11 +49,11 @@ export const HighlightLabel = memo(function HighlightLabel({
}: HighlightLabelProps) {
return (
<div>
<div className={labelTitle}>
<div className={styles.labelTitle}>
<Highlight text={label.title} highlight={highlight} />
</div>
{label.subTitle ? (
<div className={labelContent}>
<div className={styles.labelContent}>
<Highlight text={label.subTitle} highlight={highlight} />
</div>
) : null}
Expand Down
33 changes: 33 additions & 0 deletions packages/frontend/core/src/hooks/__tests__/use-highlight.spec.ts
Original file line number Diff line number Diff line change
@@ -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 },
JimmFly marked this conversation as resolved.
Show resolved Hide resolved
{ 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');
JimmFly marked this conversation as resolved.
Show resolved Hide resolved
expect(highlights).toStrictEqual([
{ text: 'H', highlight: true },
{ text: 'ello ', highlight: false },
{ text: 'W', highlight: true },
{ text: 'orld', highlight: false },
]);
});
});
50 changes: 50 additions & 0 deletions packages/frontend/core/src/hooks/affine/use-highlight.ts
Original file line number Diff line number Diff line change
@@ -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]);
JimmFly marked this conversation as resolved.
Show resolved Hide resolved
}