diff --git a/packages/frontend/core/src/modules/at-menu-config/services/index.ts b/packages/frontend/core/src/modules/at-menu-config/services/index.ts index b7472c53ba..ec971e20f1 100644 --- a/packages/frontend/core/src/modules/at-menu-config/services/index.ts +++ b/packages/frontend/core/src/modules/at-menu-config/services/index.ts @@ -31,8 +31,11 @@ import { import { computed, Signal } from '@preact/signals-core'; import { Service } from '@toeverything/infra'; import { cssVarV2 } from '@toeverything/theme/v2'; +import type { FuseResultMatch } from 'fuse.js'; +import Fuse from 'fuse.js'; import { html } from 'lit'; import { styleMap } from 'lit/directives/style-map.js'; +import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { createAbsolutePositionFromRelativePosition, createRelativePositionFromTypeIndex, @@ -46,6 +49,7 @@ import { type JournalService, suggestJournalDate } from '../../journal'; import { NotificationService } from '../../notification'; import type { GuardService, MemberSearchService } from '../../permissions'; import type { DocGrantedUsersService } from '../../permissions/services/doc-granted-users'; +import { highlighter } from '../../quicksearch/utils/highlighter'; import type { SearchMenuService } from '../../search-menu/services'; function resolveSignal(data: T | Signal): T { @@ -327,6 +331,32 @@ export class AtMenuConfigService extends Service { return result; } + private highlightFuseTitle( + matches: readonly FuseResultMatch[] | undefined, + title: string, + key: string + ): string { + if (!matches) { + return title; + } + const normalizedRange = ([start, end]: [number, number]) => + [ + start, + end + 1 /* in fuse, the `end` is different from the `substring` */, + ] as [number, number]; + const titleMatches = matches + ?.filter(match => match.key === key) + .flatMap(match => match.indices.map(normalizedRange)); + return ( + highlighter( + title, + ``, + '', + titleMatches ?? [] + ) ?? title + ); + } + private memberGroup( query: string, close: () => void, @@ -350,9 +380,10 @@ export class AtMenuConfigService extends Service { ? html`` : UserIcon(); + let displayName = name ?? 'Unknown'; return { key: id, - name: name ?? 'Unknown', + name: html`${unsafeHTML(displayName)}`, icon, action: () => { const root = inlineEditor.rootElement; @@ -567,15 +598,34 @@ export class AtMenuConfigService extends Service { ]; } + // Create a single Fuse instance for all members + const fuse = new Fuse(members, { + keys: ['name'], + includeMatches: true, + includeScore: true, + ignoreLocation: true, + threshold: 0.0, + }); + const searchResults = fuse.search(query); + return [ - ...members.map(member => - getMenuItem( - member.id, - member.name, - member.avatarUrl, - member.id !== currentUser?.id - ) - ), + ...searchResults.map(result => { + const member = result.item; + const displayName = this.highlightFuseTitle( + result.matches, + member.name ?? 'Unknown', + 'name' + ); + return { + ...getMenuItem( + member.id, + member.name, + member.avatarUrl, + member.id !== currentUser?.id + ), + name: html`${unsafeHTML(displayName)}`, + }; + }), ...(canUserManage ? [inviteItem] : []), ]; });