+
) : 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]);
+}