mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-18 06:47:02 +08:00
feat(core): support fuzzy highlighting (#4765)
This commit is contained in:
@@ -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 <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>
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
50
packages/frontend/core/src/hooks/affine/use-highlight.ts
Normal file
50
packages/frontend/core/src/hooks/affine/use-highlight.ts
Normal 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]);
|
||||
}
|
||||
Reference in New Issue
Block a user