diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-config.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-config.ts index 509c5fe03..584c80882 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-config.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/chat-config.ts @@ -1,4 +1,5 @@ import type { + SearchCollectionMenuAction, SearchDocMenuAction, SearchTagMenuAction, } from '@affine/core/modules/search-menu/services'; @@ -43,4 +44,9 @@ export interface SearchMenuConfig { action: SearchTagMenuAction, abortSignal: AbortSignal ) => LinkedMenuGroup; + getCollectionMenuGroup: ( + query: string, + action: SearchCollectionMenuAction, + abortSignal: AbortSignal + ) => LinkedMenuGroup; } diff --git a/packages/frontend/core/src/blocksuite/ai/chat-panel/components/add-popover.ts b/packages/frontend/core/src/blocksuite/ai/chat-panel/components/add-popover.ts index 42b4f5743..a20a87329 100644 --- a/packages/frontend/core/src/blocksuite/ai/chat-panel/components/add-popover.ts +++ b/packages/frontend/core/src/blocksuite/ai/chat-panel/components/add-popover.ts @@ -1,5 +1,8 @@ import { toast } from '@affine/component'; -import type { TagMeta } from '@affine/core/components/page-list'; +import type { + CollectionMeta, + TagMeta, +} from '@affine/core/components/page-list'; import { ShadowlessElement } from '@blocksuite/affine/block-std'; import { SignalWatcher, WithDisposable } from '@blocksuite/affine/global/lit'; import { scrollbarStyle } from '@blocksuite/affine/shared/styles'; @@ -314,6 +317,13 @@ export class ChatPanelAddPopover extends SignalWatcher( this.abortController.signal ); break; + case AddPopoverMode.Collections: + this._searchGroup = this.searchMenuConfig.getCollectionMenuGroup( + this._query, + this._addCollectionChip, + this.abortController.signal + ); + break; default: this._searchGroup = this.searchMenuConfig.getDocMenuGroup( this._query, @@ -335,6 +345,10 @@ export class ChatPanelAddPopover extends SignalWatcher( this.abortController.abort(); }; + private readonly _addCollectionChip = (_collection: CollectionMeta) => { + this.abortController.abort(); + }; + private readonly _handleKeyDown = (event: KeyboardEvent) => { if (event.isComposing) return; diff --git a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx index 03fea398c..48516441d 100644 --- a/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx +++ b/packages/frontend/core/src/desktop/pages/workspace/detail-page/tabs/chat.tsx @@ -94,6 +94,13 @@ export const EditorChatPanel = forwardRef(function EditorChatPanel( getTagMenuGroup: (query, action, abortSignal) => { return searchMenuService.getTagMenuGroup(query, action, abortSignal); }, + getCollectionMenuGroup: (query, action, abortSignal) => { + return searchMenuService.getCollectionMenuGroup( + query, + action, + abortSignal + ); + }, }; const previewSpecBuilder = enableFootnoteConfigExtension( SpecProvider._.getSpec('preview:page') diff --git a/packages/frontend/core/src/modules/search-menu/index.ts b/packages/frontend/core/src/modules/search-menu/index.ts index 0f2b86544..10a4a877a 100644 --- a/packages/frontend/core/src/modules/search-menu/index.ts +++ b/packages/frontend/core/src/modules/search-menu/index.ts @@ -1,5 +1,6 @@ import { type Framework } from '@toeverything/infra'; +import { CollectionService } from '../collection'; import { DocDisplayMetaService } from '../doc-display-meta'; import { DocsSearchService } from '../docs-search'; import { RecentDocsService } from '../quicksearch'; @@ -16,5 +17,6 @@ export function configSearchMenuModule(framework: Framework) { RecentDocsService, DocsSearchService, TagService, + CollectionService, ]); } diff --git a/packages/frontend/core/src/modules/search-menu/services/index.ts b/packages/frontend/core/src/modules/search-menu/services/index.ts index 07f9cf245..76d104d7c 100644 --- a/packages/frontend/core/src/modules/search-menu/services/index.ts +++ b/packages/frontend/core/src/modules/search-menu/services/index.ts @@ -1,4 +1,7 @@ -import type { TagMeta } from '@affine/core/components/page-list'; +import type { + CollectionMeta, + TagMeta, +} from '@affine/core/components/page-list'; import { fuzzyMatch } from '@affine/core/utils/fuzzy-match'; import { I18n } from '@affine/i18n'; import type { @@ -7,14 +10,16 @@ import type { } from '@blocksuite/affine/blocks/root'; import { createSignalFromObservable } from '@blocksuite/affine/shared/utils'; import type { DocMeta } from '@blocksuite/affine/store'; +import { CollectionsIcon } from '@blocksuite/icons/lit'; import { computed } from '@preact/signals-core'; import { Service } from '@toeverything/infra'; import { cssVarV2 } from '@toeverything/theme/v2'; -import Fuse from 'fuse.js'; +import Fuse, { type FuseResultMatch } from 'fuse.js'; import { html } from 'lit'; import { unsafeHTML } from 'lit/directives/unsafe-html.js'; import { map, takeWhile } from 'rxjs'; +import type { CollectionService } from '../../collection'; import type { DocDisplayMetaService } from '../../doc-display-meta'; import type { DocsSearchService } from '../../docs-search'; import { type RecentDocsService } from '../../quicksearch'; @@ -32,13 +37,18 @@ export type SearchDocMenuAction = (meta: DocMeta) => Promise | void; export type SearchTagMenuAction = (tagId: TagMeta) => Promise | void; +export type SearchCollectionMenuAction = ( + collection: CollectionMeta +) => Promise | void; + export class SearchMenuService extends Service { constructor( private readonly workspaceService: WorkspaceService, private readonly docDisplayMetaService: DocDisplayMetaService, private readonly recentDocsService: RecentDocsService, private readonly docsSearch: DocsSearchService, - private readonly tagService: TagService + private readonly tagService: TagService, + private readonly collectionService: CollectionService ) { super(); } @@ -218,13 +228,38 @@ export class SearchMenuService extends Service { }; } + 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 + ); + } + getTagMenuGroup( query: string, action: SearchTagMenuAction, _abortSignal: AbortSignal ): LinkedMenuGroup { const tags: TagMeta[] = this.tagService.tagList.tagMetas$.value; - if (query.trim().length === 0) { return { name: I18n.t('com.affine.editor.at-menu.tags', { @@ -241,7 +276,6 @@ export class SearchMenuService extends Service { ignoreLocation: true, threshold: 0.0, }); - const result = fuse.search(query); return { @@ -249,27 +283,12 @@ export class SearchMenuService extends Service { query, }), items: result.map(item => { - const normalizedRange = ([start, end]: [number, number]) => - [ - start, - end + 1 /* in fuse, the `end` is different from the `substring` */, - ] as [number, number]; - const titleMatches = item.matches - ?.filter(match => match.key === 'title') - .flatMap(match => match.indices.map(normalizedRange)); - const hTitle = highlighter( + const title = this.highlightFuseTitle( + item.matches, item.item.title, - ``, - '', - titleMatches ?? [] - ); - return this.toTagMenuItem( - { - ...item.item, - title: hTitle ?? item.item.title, - }, - action + 'title' ); + return this.toTagMenuItem({ ...item.item, title }, action); }), }; } @@ -294,4 +313,65 @@ export class SearchMenuService extends Service { }, }; } + + getCollectionMenuGroup( + query: string, + action: SearchCollectionMenuAction, + _abortSignal: AbortSignal + ): LinkedMenuGroup { + const collections = this.collectionService.collections$.value; + if (query.trim().length === 0) { + return { + name: I18n.t('com.affine.editor.at-menu.collections', { + query, + }), + items: collections.map(collection => + this.toCollectionMenuItem( + { + ...collection, + title: collection.name, + }, + action + ) + ), + }; + } + + const fuse = new Fuse(collections, { + keys: ['name'], + includeMatches: true, + includeScore: true, + ignoreLocation: true, + threshold: 0.0, + }); + const result = fuse.search(query); + + return { + name: I18n.t('com.affine.editor.at-menu.link-to-doc', { + query, + }), + items: result.map(item => { + const title = this.highlightFuseTitle( + item.matches, + item.item.name, + 'name' + ); + return this.toCollectionMenuItem({ ...item.item, title }, action); + }), + }; + } + + private toCollectionMenuItem( + collection: CollectionMeta, + action: SearchCollectionMenuAction + ): LinkedMenuItem { + return { + key: collection.id, + name: html`${unsafeHTML(collection.title)}`, + icon: CollectionsIcon(), + action: async () => { + await action(collection); + }, + }; + } } diff --git a/packages/frontend/i18n/src/i18n.gen.ts b/packages/frontend/i18n/src/i18n.gen.ts index 3cc3c40dc..1e4cae42c 100644 --- a/packages/frontend/i18n/src/i18n.gen.ts +++ b/packages/frontend/i18n/src/i18n.gen.ts @@ -6833,6 +6833,10 @@ export function useAFFiNEI18N(): { * `Tags` */ ["com.affine.editor.at-menu.tags"](): string; + /** + * `Collections` + */ + ["com.affine.editor.at-menu.collections"](): string; /** * `Loading...` */ diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 49d37afb3..aa661bdda 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -1698,6 +1698,7 @@ "com.affine.editor.at-menu.link-to-doc": "Search for \"{{query}}\"", "com.affine.editor.at-menu.recent-docs": "Recent", "com.affine.editor.at-menu.tags": "Tags", + "com.affine.editor.at-menu.collections": "Collections", "com.affine.editor.at-menu.loading": "Loading...", "com.affine.editor.at-menu.new-doc": "New", "com.affine.editor.at-menu.create-page": "New \"{{name}}\" page",