feat(editor): add at member highlight (#12535)

Closes: BS-2896

<img width="468" alt="image" src="https://github.com/user-attachments/assets/2b84c484-29b8-4650-b74c-da7afd3a1e41" />

<!-- This is an auto-generated comment: release notes by coderabbit.ai -->

## Summary by CodeRabbit

- **New Features**
  - Enhanced member search results with highlighted text, making it easier to visually identify matched parts of member names during searches.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Saul-Mirone
2025-05-26 12:08:03 +00:00
parent 53a23dd4bf
commit 41f0a2d01a

View File

@@ -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<T>(data: T | Signal<T>): 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,
`<span style="color: ${cssVarV2('text/emphasis')}">`,
'</span>',
titleMatches ?? []
) ?? title
);
}
private memberGroup(
query: string,
close: () => void,
@@ -350,9 +380,10 @@ export class AtMenuConfigService extends Service {
? html`<img style=${avatarStyle} src="${avatar}" />`
: 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] : []),
];
});