diff --git a/packages/common/infra/src/framework/core/index.ts b/packages/common/infra/src/framework/core/index.ts index b5b1fca9d9c02..4655224335f39 100644 --- a/packages/common/infra/src/framework/core/index.ts +++ b/packages/common/infra/src/framework/core/index.ts @@ -6,5 +6,6 @@ export * from './error'; export { createEvent, OnEvent } from './event'; export { Framework } from './framework'; export { createIdentifier } from './identifier'; -export type { FrameworkProvider, ResolveOptions } from './provider'; +export type { ResolveOptions } from './provider'; +export { FrameworkProvider } from './provider'; export type { GeneralIdentifier } from './types'; diff --git a/packages/common/infra/src/framework/react/index.tsx b/packages/common/infra/src/framework/react/index.tsx index ff09c0dc96f27..ab032aca5a5a0 100644 --- a/packages/common/infra/src/framework/react/index.tsx +++ b/packages/common/infra/src/framework/react/index.tsx @@ -9,6 +9,12 @@ export const FrameworkStackContext = React.createContext([ Framework.EMPTY.provider(), ]); +export function useFramework(): FrameworkProvider { + const stack = useContext(FrameworkStackContext); + + return stack[stack.length - 1]; // never null, because the default value +} + export function useService( identifier: GeneralIdentifier ): T { diff --git a/packages/common/infra/src/modules/doc/entities/record.ts b/packages/common/infra/src/modules/doc/entities/record.ts index d7ce3b1394ebb..7cdc286a822c1 100644 --- a/packages/common/infra/src/modules/doc/entities/record.ts +++ b/packages/common/infra/src/modules/doc/entities/record.ts @@ -13,7 +13,6 @@ export type DocMode = 'edgeless' | 'page'; */ export class DocRecord extends Entity<{ id: string }> { id: string = this.props.id; - meta: Partial | null = null; constructor(private readonly docsStore: DocsStore) { super(); } diff --git a/packages/common/infra/src/modules/doc/services/docs.ts b/packages/common/infra/src/modules/doc/services/docs.ts index 3e1b38108af45..d2fcdb665fe06 100644 --- a/packages/common/infra/src/modules/doc/services/docs.ts +++ b/packages/common/infra/src/modules/doc/services/docs.ts @@ -1,6 +1,10 @@ +import { Unreachable } from '@affine/env/constant'; + import { Service } from '../../../framework'; +import { initEmptyPage } from '../../../initialization'; import { ObjectPool } from '../../../utils'; import type { Doc } from '../entities/doc'; +import type { DocMode } from '../entities/record'; import { DocRecordList } from '../entities/record-list'; import { DocScope } from '../scopes/doc'; import type { DocsStore } from '../stores/docs'; @@ -46,4 +50,22 @@ export class DocsService extends Service { return { doc: obj, release }; } + + createDoc( + options: { + mode?: DocMode; + title?: string; + } = {} + ) { + const doc = this.store.createBlockSuiteDoc(); + initEmptyPage(doc, options.title); + const docRecord = this.list.doc$(doc.id).value; + if (!docRecord) { + throw new Unreachable(); + } + if (options.mode) { + docRecord.setMode(options.mode); + } + return docRecord; + } } diff --git a/packages/common/infra/src/modules/doc/stores/docs.ts b/packages/common/infra/src/modules/doc/stores/docs.ts index 407e0a14817cc..1404be195fb03 100644 --- a/packages/common/infra/src/modules/doc/stores/docs.ts +++ b/packages/common/infra/src/modules/doc/stores/docs.ts @@ -18,6 +18,10 @@ export class DocsStore extends Store { return this.workspaceService.workspace.docCollection.getDoc(id); } + createBlockSuiteDoc() { + return this.workspaceService.workspace.docCollection.createDoc(); + } + watchDocIds() { return new Observable(subscriber => { const emit = () => { diff --git a/packages/frontend/core/package.json b/packages/frontend/core/package.json index e9b60124baf0d..ab3a267851eb0 100644 --- a/packages/frontend/core/package.json +++ b/packages/frontend/core/package.json @@ -55,6 +55,7 @@ "dayjs": "^1.11.10", "foxact": "^0.2.33", "fractional-indexing": "^3.2.0", + "fuse.js": "^7.0.0", "graphql": "^16.8.1", "history": "^5.3.0", "idb": "^8.0.0", diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx index f1d40544bb153..95f0c51ef6860 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/lit-adaper.tsx @@ -4,7 +4,6 @@ import { useLitPortalFactory, } from '@affine/component'; import { useJournalInfoHelper } from '@affine/core/hooks/use-journal'; -import { QuickSearchService } from '@affine/core/modules/cmdk'; import { PeekViewService } from '@affine/core/modules/peek-view'; import { WorkbenchService } from '@affine/core/modules/workbench'; import { @@ -15,7 +14,12 @@ import { PageEditor, } from '@blocksuite/presets'; import type { Doc } from '@blocksuite/store'; -import { type DocMode, useLiveData, useService } from '@toeverything/infra'; +import { + type DocMode, + useFramework, + useLiveData, + useService, +} from '@toeverything/infra'; import React, { forwardRef, Fragment, @@ -72,7 +76,7 @@ interface BlocksuiteEditorProps { const usePatchSpecs = (page: Doc, shared: boolean, mode: DocMode) => { const [reactToLit, portals] = useLitPortalFactory(); const peekViewService = useService(PeekViewService); - const quickSearchService = useService(QuickSearchService); + const framework = useFramework(); const referenceRenderer: ReferenceReactRenderer = useMemo(() => { return function customReference(reference) { const pageId = reference.delta.attributes?.reference?.pageId; @@ -96,7 +100,7 @@ const usePatchSpecs = (page: Doc, shared: boolean, mode: DocMode) => { patched = patchPeekViewService(patched, peekViewService); } if (!page.readonly) { - patched = patchQuickSearchService(patched, quickSearchService); + patched = patchQuickSearchService(patched, framework); } if (shared) { patched = patchForSharedPage(patched); @@ -104,9 +108,9 @@ const usePatchSpecs = (page: Doc, shared: boolean, mode: DocMode) => { return patched; }, [ confirmModal, + framework, page.readonly, peekViewService, - quickSearchService, reactToLit, referenceRenderer, shared, diff --git a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx index 01f4b97bddfac..5717e8a11f11e 100644 --- a/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx +++ b/packages/frontend/core/src/components/blocksuite/block-suite-editor/specs/custom/spec-patchers.tsx @@ -7,12 +7,16 @@ import { type ToastOptions, type useConfirmModal, } from '@affine/component'; -import type { - QuickSearchService, - SearchCallbackResult, -} from '@affine/core/modules/cmdk'; +import { DocsSearchService } from '@affine/core/modules/docs-search'; +import { resolveLinkToDoc } from '@affine/core/modules/navigation'; import type { PeekViewService } from '@affine/core/modules/peek-view'; import type { ActivePeekView } from '@affine/core/modules/peek-view/entities/peek-view'; +import { + DocsQuickSearchSession, + type QuickSearchItem, + QuickSearchService, + RecentDocsQuickSearchSession, +} from '@affine/core/modules/quicksearch'; import { DebugLogger } from '@affine/debug'; import type { BlockSpec, WidgetElement } from '@blocksuite/block-std'; import { @@ -25,6 +29,8 @@ import { ReferenceNodeConfig, type RootService, } from '@blocksuite/blocks'; +import { LinkIcon } from '@blocksuite/icons'; +import type { FrameworkProvider } from '@toeverything/infra'; import { html, LitElement, type TemplateResult } from 'lit'; import { customElement } from 'lit/decorators.js'; import { literal } from 'lit/static-html.js'; @@ -283,7 +289,7 @@ export function patchPeekViewService( export function patchQuickSearchService( specs: BlockSpec[], - service: QuickSearchService + framework: FrameworkProvider ) { const rootSpec = specs.find( spec => spec.schema.model.flavour === 'affine:page' @@ -298,39 +304,81 @@ export function patchQuickSearchService( pageService => { pageService.quickSearchService = { async searchDoc(options) { - let searchResult: SearchCallbackResult | null = null; + let searchResult: { docId: string } | { userInput: string } | null = + null; if (options.skipSelection) { const query = options.userInput; if (!query) { logger.error('No user input provided'); } else { - const searchedDoc = service.quickSearch - .getSearchedDocs(query) - .at(0); + const searchedDoc = ( + await framework.get(DocsSearchService).search(query) + ).at(0); if (searchedDoc) { searchResult = { - docId: searchedDoc.doc.id, - blockId: searchedDoc.blockId, - action: 'insert', - query, + docId: searchedDoc.docId, }; } } } else { - searchResult = await service.quickSearch.search(options.userInput); + searchResult = await new Promise(resolve => + framework.get(QuickSearchService).quickSearch.show( + [ + framework.get(RecentDocsQuickSearchSession), + framework.get(DocsQuickSearchSession), + (query: string) => { + if ( + (query.startsWith('http://') || + query.startsWith('https://')) && + resolveLinkToDoc(query) === null + ) { + return [ + { + id: 'link', + source: 'link', + icon: LinkIcon, + label: { + key: 'com.affine.cmdk.affine.insert-link', + }, + payload: { url: query }, + } as QuickSearchItem<'link', { url: string }>, + ]; + } + return []; + }, + ], + result => { + if (result === null) { + resolve(null); + return; + } + if ( + result.source === 'docs' || + result.source === 'recent-doc' + ) { + resolve({ + docId: result.payload.docId, + }); + } else if (result.source === 'link') { + resolve({ + userInput: result.payload.url, + }); + } + }, + { + defaultQuery: options.userInput, + label: { + key: 'com.affine.cmdk.insert-links', + }, + placeholder: { + key: 'com.affine.cmdk.docs.placeholder', + }, + } + ) + ); } - if (searchResult) { - if ('docId' in searchResult) { - return searchResult; - } else { - return { - userInput: searchResult.query, - action: 'insert', - }; - } - } - return null; + return searchResult; }, }; }, diff --git a/packages/frontend/core/src/components/page-list/operation-cell.tsx b/packages/frontend/core/src/components/page-list/operation-cell.tsx index 33d06e8ade1ed..f53d58f471ed3 100644 --- a/packages/frontend/core/src/components/page-list/operation-cell.tsx +++ b/packages/frontend/core/src/components/page-list/operation-cell.tsx @@ -82,7 +82,7 @@ export const PageOperationCell = ({ }, [page.id, page.title, setTrashModal]); const onOpenInSplitView = useCallback(() => { - workbench.openPage(page.id, { at: 'tail' }); + workbench.openDoc(page.id, { at: 'tail' }); }, [page.id, workbench]); const onToggleFavoritePage = useCallback(() => { diff --git a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx index a0dae1826d6ec..cd391cb0b6976 100644 --- a/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx +++ b/packages/frontend/core/src/components/pure/workspace-slider-bar/components/operation-menu-button.tsx @@ -69,7 +69,7 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => { }, [pageId, removeFromAllowList]); const handleOpenInSplitView = useCallback(() => { - workbench.openPage(pageId, { at: 'tail' }); + workbench.openDoc(pageId, { at: 'tail' }); }, [pageId, workbench]); return ( diff --git a/packages/frontend/core/src/hooks/use-register-workspace-commands.ts b/packages/frontend/core/src/hooks/use-register-workspace-commands.ts index f6787534a5c2c..d9e52bfb2340e 100644 --- a/packages/frontend/core/src/hooks/use-register-workspace-commands.ts +++ b/packages/frontend/core/src/hooks/use-register-workspace-commands.ts @@ -16,7 +16,7 @@ import { registerAffineUpdatesCommands, } from '../commands'; import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils'; -import { QuickSearchService } from '../modules/cmdk'; +import { CMDKQuickSearchService } from '../modules/quicksearch/services/cmdk'; import { useLanguageHelper } from './affine/use-language-helper'; import { useActiveBlocksuiteEditor } from './use-block-suite-editor'; import { useNavigateHelper } from './use-navigate-helper'; @@ -33,7 +33,7 @@ function hasLinkPopover(editor: AffineEditorContainer | null) { } function registerCMDKCommand( - qsService: QuickSearchService, + service: CMDKQuickSearchService, editor: AffineEditorContainer | null ) { return registerAffineCommand({ @@ -51,7 +51,7 @@ function registerCMDKCommand( if (hasLinkPopover(editor)) { return; } - qsService.quickSearch.toggle(); + service.toggle(); }, }); } @@ -65,15 +65,15 @@ export function useRegisterWorkspaceCommands() { const pageHelper = usePageHelper(currentWorkspace.docCollection); const navigationHelper = useNavigateHelper(); const [editor] = useActiveBlocksuiteEditor(); - const quickSearch = useService(QuickSearchService); + const cmdkQuickSearchService = useService(CMDKQuickSearchService); useEffect(() => { - const unsub = registerCMDKCommand(quickSearch, editor); + const unsub = registerCMDKCommand(cmdkQuickSearchService, editor); return () => { unsub(); }; - }, [editor, quickSearch]); + }, [cmdkQuickSearchService, editor]); // register AffineUpdatesCommands useEffect(() => { diff --git a/packages/frontend/core/src/layouts/workspace-layout.tsx b/packages/frontend/core/src/layouts/workspace-layout.tsx index 5c91181ecfffb..b5008919dd6c2 100644 --- a/packages/frontend/core/src/layouts/workspace-layout.tsx +++ b/packages/frontend/core/src/layouts/workspace-layout.tsx @@ -7,16 +7,10 @@ import { useSensor, useSensors, } from '@dnd-kit/core'; -import { - DocsService, - GlobalContextService, - useLiveData, - useService, - WorkspaceService, -} from '@toeverything/infra'; +import { useLiveData, useService, WorkspaceService } from '@toeverything/infra'; import { useAtomValue, useSetAtom } from 'jotai'; import type { PropsWithChildren, ReactNode } from 'react'; -import { lazy, useCallback, useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { Map as YMap } from 'yjs'; @@ -38,8 +32,9 @@ import { import { useRegisterFindInPageCommands } from '../hooks/affine/use-register-find-in-page-commands'; import { useNavigateHelper } from '../hooks/use-navigate-helper'; import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands'; -import { QuickSearchService } from '../modules/cmdk'; import { useRegisterNavigationCommands } from '../modules/navigation/view/use-register-navigation-commands'; +import { QuickSearchContainer } from '../modules/quicksearch'; +import { CMDKQuickSearchService } from '../modules/quicksearch/services/cmdk'; import { WorkbenchService } from '../modules/workbench'; import { AllWorkspaceModals, @@ -50,46 +45,6 @@ import { pathGenerator } from '../shared'; import { mixpanel } from '../utils'; import * as styles from './styles.css'; -const CMDKQuickSearchModal = lazy(() => - import('../modules/cmdk/views').then(module => ({ - default: module.CMDKQuickSearchModal, - })) -); - -export const QuickSearch = () => { - const quickSearch = useService(QuickSearchService).quickSearch; - const open = useLiveData(quickSearch.show$); - - const onToggleQuickSearch = useCallback( - (open: boolean) => { - if (open) { - // should never be here - quickSearch.show(); - } else { - quickSearch.hide(); - } - }, - [quickSearch] - ); - - const docRecordList = useService(DocsService).list; - const currentDocId = useLiveData( - useService(GlobalContextService).globalContext.docId.$ - ); - const currentPage = useLiveData( - currentDocId ? docRecordList.doc$(currentDocId) : null - ); - const pageMeta = useLiveData(currentPage?.meta$); - - return ( - - ); -}; - export const WorkspaceLayout = function WorkspaceLayout({ children, }: PropsWithChildren) { @@ -150,14 +105,14 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { return pageHelper.createPage(); }, [pageHelper]); - const quickSearch = useService(QuickSearchService).quickSearch; + const cmdkQuickSearchService = useService(CMDKQuickSearchService); const handleOpenQuickSearchModal = useCallback(() => { - quickSearch.show(); + cmdkQuickSearchService.toggle(); mixpanel.track('QuickSearchOpened', { segment: 'navigation panel', control: 'search button', }); - }, [quickSearch]); + }, [cmdkQuickSearchService]); const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); @@ -220,7 +175,7 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => { - + ); diff --git a/packages/frontend/core/src/modules/cmdk/entities/quick-search.ts b/packages/frontend/core/src/modules/cmdk/entities/quick-search.ts deleted file mode 100644 index 7c2c3d414dc48..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/entities/quick-search.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { - DocRecord, - DocsService, - WorkspaceService, -} from '@toeverything/infra'; -import { Entity, LiveData } from '@toeverything/infra'; - -import { resolveLinkToDoc } from '../../navigation'; - -type QuickSearchMode = 'commands' | 'docs'; - -export type SearchCallbackResult = - | { - docId: string; - blockId?: string; - } - | { - query: string; - action: 'insert'; - }; - -// todo: move command registry to entity as well -export class QuickSearch extends Entity { - constructor( - private readonly docsService: DocsService, - private readonly workspaceService: WorkspaceService - ) { - super(); - } - private readonly state$ = new LiveData<{ - mode: QuickSearchMode; - query: string; - callback?: (result: SearchCallbackResult | null) => void; - } | null>(null); - - readonly show$ = this.state$.map(s => !!s); - - show = ( - mode: QuickSearchMode | null = 'commands', - opts: { - callback?: (res: SearchCallbackResult | null) => void; - query?: string; - } = {} - ) => { - if (this.state$.value?.callback) { - this.state$.value.callback(null); - } - if (mode === null) { - this.state$.next(null); - } else { - this.state$.next({ - mode, - query: opts.query ?? '', - callback: opts.callback, - }); - } - }; - - mode$ = this.state$.map(s => s?.mode); - query$ = this.state$.map(s => s?.query || ''); - - setQuery = (query: string) => { - if (!this.state$.value) return; - this.state$.next({ - ...this.state$.value, - query, - }); - }; - - hide() { - return this.show(null); - } - - toggle() { - return this.show$.value ? this.hide() : this.show(); - } - - search(query?: string) { - const { promise, resolve } = - Promise.withResolvers(); - - this.show('docs', { - callback: resolve, - query, - }); - - return promise; - } - - setSearchCallbackResult(result: SearchCallbackResult) { - if (this.state$.value?.callback) { - this.state$.value.callback(result); - } - } - - getSearchedDocs(query: string) { - const searchResults = this.workspaceService.workspace.docCollection.search( - query - ) as unknown as Map< - string, - { - space: string; - content: string; - } - >; - // make sure we don't add the same page multiple times - const added = new Set(); - const docs = this.docsService.list.docs$.value; - const searchedDocs: { - doc: DocRecord; - blockId: string; - content?: string; - source: 'search' | 'link-ref'; - }[] = Array.from(searchResults.entries()) - .map(([blockId, { space, content }]) => { - const doc = docs.find(doc => doc.id === space && !added.has(doc.id)); - if (!doc) return null; - added.add(doc.id); - - return { - doc, - blockId, - content, - source: 'search' as const, - }; - }) - .filter((res): res is NonNullable => !!res); - - const maybeRefLink = resolveLinkToDoc(query); - - if (maybeRefLink) { - const doc = this.docsService.list.docs$.value.find( - doc => doc.id === maybeRefLink.docId - ); - if (doc) { - searchedDocs.push({ - doc, - blockId: maybeRefLink.blockId, - source: 'link-ref', - }); - } - } - - return searchedDocs; - } -} diff --git a/packages/frontend/core/src/modules/cmdk/index.ts b/packages/frontend/core/src/modules/cmdk/index.ts deleted file mode 100644 index a1df1e731732a..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/index.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { - DocsService, - type Framework, - WorkspaceLocalState, - WorkspaceScope, - WorkspaceService, -} from '@toeverything/infra'; - -import { QuickSearch } from './entities/quick-search'; -import { QuickSearchService } from './services/quick-search'; -import { RecentPagesService } from './services/recent-pages'; - -export * from './entities/quick-search'; -export { QuickSearchService, RecentPagesService }; - -export function configureQuickSearchModule(framework: Framework) { - framework - .scope(WorkspaceScope) - .service(QuickSearchService) - .service(RecentPagesService, [WorkspaceLocalState, DocsService]) - .entity(QuickSearch, [DocsService, WorkspaceService]); -} diff --git a/packages/frontend/core/src/modules/cmdk/views/__tests__/command.score.spec.ts b/packages/frontend/core/src/modules/cmdk/views/__tests__/command.score.spec.ts deleted file mode 100644 index effdcb302453d..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/views/__tests__/command.score.spec.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { commandScore } from '../command-score'; - -describe('commandScore', function () { - it('should match exact strings exactly', function () { - expect(commandScore('hello', 'hello')).to.equal(1); - }); - - it('should prefer case-sensitive matches', function () { - expect(commandScore('Hello', 'Hello')).to.be.greaterThan( - commandScore('Hello', 'hello') - ); - }); - - it('should mark down prefixes', function () { - expect(commandScore('hello', 'hello')).to.be.greaterThan( - commandScore('hello', 'he') - ); - }); - - it('should score all prefixes the same', function () { - expect(commandScore('help', 'he')).to.equal(commandScore('hello', 'he')); - }); - - it('should mark down word jumps', function () { - expect(commandScore('hello world', 'hello')).to.be.greaterThan( - commandScore('hello world', 'hewo') - ); - }); - - it('should score similar word jumps the same', function () { - expect(commandScore('hello world', 'hewo')).to.equal( - commandScore('hey world', 'hewo') - ); - }); - - it('should penalize long word jumps', function () { - expect(commandScore('hello world', 'hewo')).to.be.greaterThan( - commandScore('hello kind world', 'hewo') - ); - }); - - it('should match missing characters', function () { - expect(commandScore('hello', 'hl')).to.be.greaterThan(0); - }); - - it('should penalize more for more missing characters', function () { - expect(commandScore('hello', 'hllo')).to.be.greaterThan( - commandScore('hello', 'hlo') - ); - }); - - it('should penalize more for missing characters than case', function () { - expect(commandScore('go to Inbox', 'in')).to.be.greaterThan( - commandScore('go to Unversity/Societies/CUE/info@cue.org.uk', 'in') - ); - }); - - it('should match transpotisions', function () { - expect(commandScore('hello', 'hle')).to.be.greaterThan(0); - }); - - it('should not match with a trailing letter', function () { - expect(commandScore('ss', 'sss')).to.equal(0.1); - }); - - it('should match long jumps', function () { - expect(commandScore('go to @QuickFix', 'fix')).to.be.greaterThan(0); - expect(commandScore('go to Quick Fix', 'fix')).to.be.greaterThan( - commandScore('go to @QuickFix', 'fix') - ); - }); - - it('should work well with the presence of an m-dash', function () { - expect(commandScore('no go — Windows', 'windows')).to.be.greaterThan(0); - }); - - it('should be robust to duplicated letters', function () { - expect(commandScore('talent', 'tall')).to.be.equal(0.099); - }); - - it('should not allow letter insertion', function () { - expect(commandScore('talent', 'tadlent')).to.be.equal(0); - }); - - it('should match - with " " characters', function () { - expect(commandScore('Auto-Advance', 'Auto Advance')).to.be.equal(0.9999); - }); - - it('should score long strings quickly', function () { - expect( - commandScore( - 'go to this is a really long label that is really longthis is a really long label that is really longthis is a really long label that is really longthis is a really long label that is really long', - 'this is a' - ) - ).to.be.equal(0.891); - }); -}); diff --git a/packages/frontend/core/src/modules/cmdk/views/__tests__/filter-commands.spec.ts b/packages/frontend/core/src/modules/cmdk/views/__tests__/filter-commands.spec.ts deleted file mode 100644 index 87592dc7c644a..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/views/__tests__/filter-commands.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import { describe, expect, test } from 'vitest'; - -import { filterSortAndGroupCommands } from '../filter-commands'; -import type { CMDKCommand } from '../types'; - -const commands: CMDKCommand[] = ( - [ - { - id: 'affine:goto-all-pages', - category: 'affine:navigation', - label: { title: 'Go to All Pages' }, - }, - { - id: 'affine:goto-page-list', - category: 'affine:navigation', - label: { title: 'Go to Page List' }, - }, - { - id: 'affine:new-page', - category: 'affine:creation', - alwaysShow: true, - label: { title: 'New Page' }, - }, - { - id: 'affine:new-edgeless-page', - category: 'affine:creation', - alwaysShow: true, - label: { title: 'New Edgeless' }, - }, - { - id: 'affine:pages.foo', - category: 'affine:pages', - label: { title: 'New Page', subTitle: 'foo' }, - }, - { - id: 'affine:pages.bar', - category: 'affine:pages', - label: { title: 'New Page', subTitle: 'bar' }, - }, - ] as const -).map(c => { - return { - ...c, - run: () => {}, - }; -}); - -describe('filterSortAndGroupCommands', () => { - function defineTest( - name: string, - query: string, - expected: [string, string[]][] - ) { - test(name, () => { - // Call the function - const result = filterSortAndGroupCommands(commands, query); - const sortedIds = result.map(([category, commands]) => { - return [category, commands.map(command => command.id)]; - }); - - console.log(JSON.stringify(sortedIds)); - - // Assert the result - expect(sortedIds).toEqual(expected); - }); - } - - defineTest('without query', '', [ - ['affine:navigation', ['affine:goto-all-pages', 'affine:goto-page-list']], - ['affine:creation', ['affine:new-page', 'affine:new-edgeless-page']], - ['affine:pages', ['affine:pages.foo', 'affine:pages.bar']], - ]); - - defineTest('with query = a', 'a', [ - [ - 'affine:results', - [ - 'affine:goto-all-pages', - 'affine:pages.foo', - 'affine:pages.bar', - 'affine:new-page', - 'affine:new-edgeless-page', - 'affine:goto-page-list', - ], - ], - ]); - - defineTest('with query = nepa', 'nepa', [ - [ - 'affine:results', - [ - 'affine:pages.foo', - 'affine:pages.bar', - 'affine:new-page', - 'affine:new-edgeless-page', - ], - ], - ]); - - defineTest('with query = new', 'new', [ - [ - 'affine:results', - [ - 'affine:pages.foo', - 'affine:pages.bar', - 'affine:new-page', - 'affine:new-edgeless-page', - ], - ], - ]); - - defineTest('with query = foo', 'foo', [ - [ - 'affine:results', - ['affine:pages.foo', 'affine:new-page', 'affine:new-edgeless-page'], - ], - ]); -}); diff --git a/packages/frontend/core/src/modules/cmdk/views/__tests__/use-highlight.spec.ts b/packages/frontend/core/src/modules/cmdk/views/__tests__/use-highlight.spec.ts deleted file mode 100644 index f2dca1b6e9529..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/views/__tests__/use-highlight.spec.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, test } from 'vitest'; - -import { highlightTextFragments } from '../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/modules/cmdk/views/command-score.ts b/packages/frontend/core/src/modules/cmdk/views/command-score.ts deleted file mode 100644 index 44c84f36b8486..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/views/command-score.ts +++ /dev/null @@ -1,195 +0,0 @@ -// The scores are arranged so that a continuous match of characters will -// result in a total score of 1. -// -// The best case, this character is a match, and either this is the start -// of the string, or the previous character was also a match. -const SCORE_CONTINUE_MATCH = 1, - // A new match at the start of a word scores better than a new match - // elsewhere as it's more likely that the user will type the starts - // of fragments. - // NOTE: We score word jumps between spaces slightly higher than slashes, brackets - // hyphens, etc. - SCORE_SPACE_WORD_JUMP = 0.9, - SCORE_NON_SPACE_WORD_JUMP = 0.8, - // Any other match isn't ideal, but we include it for completeness. - SCORE_CHARACTER_JUMP = 0.17, - // If the user transposed two letters, it should be significantly penalized. - // - // i.e. "ouch" is more likely than "curtain" when "uc" is typed. - SCORE_TRANSPOSITION = 0.1, - // The goodness of a match should decay slightly with each missing - // character. - // - // i.e. "bad" is more likely than "bard" when "bd" is typed. - // - // This will not change the order of suggestions based on SCORE_* until - // 100 characters are inserted between matches. - PENALTY_SKIPPED = 0.999, - // The goodness of an exact-case match should be higher than a - // case-insensitive match by a small amount. - // - // i.e. "HTML" is more likely than "haml" when "HM" is typed. - // - // This will not change the order of suggestions based on SCORE_* until - // 1000 characters are inserted between matches. - PENALTY_CASE_MISMATCH = 0.9999, - // If the word has more characters than the user typed, it should - // be penalised slightly. - // - // i.e. "html" is more likely than "html5" if I type "html". - // - // However, it may well be the case that there's a sensible secondary - // ordering (like alphabetical) that it makes sense to rely on when - // there are many prefix matches, so we don't make the penalty increase - // with the number of tokens. - PENALTY_NOT_COMPLETE = 0.99; - -const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/, - COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g, - IS_SPACE_REGEXP = /[\s-]/, - COUNT_SPACE_REGEXP = /[\s-]/g; - -const MAX_RECUR = 1500; - -function commandScoreInner( - string: string, - abbreviation: string, - lowerString: string, - lowerAbbreviation: string, - stringIndex: number, - abbreviationIndex: number, - memoizedResults: Record, - recur: number = 0 -) { - recur += 1; - if (abbreviationIndex === abbreviation.length) { - if (stringIndex === string.length) { - return SCORE_CONTINUE_MATCH; - } - return PENALTY_NOT_COMPLETE; - } - - const memoizeKey = `${stringIndex},${abbreviationIndex}`; - if (memoizedResults[memoizeKey] !== undefined) { - return memoizedResults[memoizeKey]; - } - - const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex); - let index = lowerString.indexOf(abbreviationChar, stringIndex); - let highScore = 0; - - let score, transposedScore, wordBreaks, spaceBreaks; - - while (index >= 0) { - score = commandScoreInner( - string, - abbreviation, - lowerString, - lowerAbbreviation, - index + 1, - abbreviationIndex + 1, - memoizedResults, - recur - ); - if (score > highScore) { - if (index === stringIndex) { - score *= SCORE_CONTINUE_MATCH; - } else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) { - score *= SCORE_NON_SPACE_WORD_JUMP; - wordBreaks = string - .slice(stringIndex, index - 1) - .match(COUNT_GAPS_REGEXP); - if (wordBreaks && stringIndex > 0) { - score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length); - } - } else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) { - score *= SCORE_SPACE_WORD_JUMP; - spaceBreaks = string - .slice(stringIndex, index - 1) - .match(COUNT_SPACE_REGEXP); - if (spaceBreaks && stringIndex > 0) { - score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length); - } - } else { - score *= SCORE_CHARACTER_JUMP; - if (stringIndex > 0) { - score *= Math.pow(PENALTY_SKIPPED, index - stringIndex); - } - } - - if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) { - score *= PENALTY_CASE_MISMATCH; - } - } - - if ( - (score < SCORE_TRANSPOSITION && - lowerString.charAt(index - 1) === - lowerAbbreviation.charAt(abbreviationIndex + 1)) || - (lowerAbbreviation.charAt(abbreviationIndex + 1) === - lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428 - lowerString.charAt(index - 1) !== - lowerAbbreviation.charAt(abbreviationIndex)) - ) { - transposedScore = commandScoreInner( - string, - abbreviation, - lowerString, - lowerAbbreviation, - index + 1, - abbreviationIndex + 2, - memoizedResults, - recur - ); - - if (transposedScore * SCORE_TRANSPOSITION > score) { - score = transposedScore * SCORE_TRANSPOSITION; - } - } - - if (score > highScore) { - highScore = score; - } - - index = lowerString.indexOf(abbreviationChar, index + 1); - - if (recur > MAX_RECUR || score > 0.85) { - break; - } - } - - memoizedResults[memoizeKey] = highScore; - return highScore; -} - -function formatInput(string: string) { - // convert all valid space characters to space so they match each other - return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' '); -} - -export function commandScore( - string: string, - abbreviation: string, - aliases?: string[] -): number { - /* NOTE: - * in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase() - * was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster. - */ - string = - aliases && aliases.length > 0 - ? `${string + ' ' + aliases.join(' ')}` - : string; - const memoizedResults = {}; - const result = commandScoreInner( - string, - abbreviation, - formatInput(string), - formatInput(abbreviation), - 0, - 0, - memoizedResults - ); - - return result; -} diff --git a/packages/frontend/core/src/modules/cmdk/views/data-hooks.tsx b/packages/frontend/core/src/modules/cmdk/views/data-hooks.tsx deleted file mode 100644 index b617b46d8d90d..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/views/data-hooks.tsx +++ /dev/null @@ -1,492 +0,0 @@ -import { - type AffineCommand, - AffineCommandRegistry, - type CommandCategory, - PreconditionStrategy, -} from '@affine/core/commands'; -import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta'; -import { useGetDocCollectionPageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title'; -import { useJournalHelper } from '@affine/core/hooks/use-journal'; -import { - QuickSearchService, - RecentPagesService, - type SearchCallbackResult, -} from '@affine/core/modules/cmdk'; -import { CollectionService } from '@affine/core/modules/collection'; -import { WorkspaceSubPath } from '@affine/core/shared'; -import { mixpanel } from '@affine/core/utils'; -import type { Collection } from '@affine/env/filter'; -import { Trans } from '@affine/i18n'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { - EdgelessIcon, - LinkIcon, - PageIcon, - TodayIcon, - ViewLayersIcon, -} from '@blocksuite/icons'; -import type { DocRecord, Workspace } from '@toeverything/infra'; -import { - GlobalContextService, - useLiveData, - useService, - WorkspaceService, -} from '@toeverything/infra'; -import { atom } from 'jotai'; -import { useCallback, useEffect, useMemo, useState } from 'react'; - -import { usePageHelper } from '../../../components/blocksuite/block-suite-page-list/utils'; -import { useNavigateHelper } from '../../../hooks/use-navigate-helper'; -import { filterSortAndGroupCommands } from './filter-commands'; -import * as hlStyles from './highlight.css'; -import type { CMDKCommand, CommandContext } from './types'; - -export const cmdkValueAtom = atom(''); - -function filterCommandByContext( - command: AffineCommand, - context: CommandContext -) { - if (command.preconditionStrategy === PreconditionStrategy.Always) { - return true; - } - if (command.preconditionStrategy === PreconditionStrategy.InEdgeless) { - return context.docMode === 'edgeless'; - } - if (command.preconditionStrategy === PreconditionStrategy.InPaper) { - return context.docMode === 'page'; - } - if (command.preconditionStrategy === PreconditionStrategy.InPaperOrEdgeless) { - return !!context.docMode; - } - if (command.preconditionStrategy === PreconditionStrategy.Never) { - return false; - } - if (typeof command.preconditionStrategy === 'function') { - return command.preconditionStrategy(); - } - return true; -} - -function getAllCommand(context: CommandContext) { - const commands = AffineCommandRegistry.getAll(); - return commands.filter(command => { - return filterCommandByContext(command, context); - }); -} - -const docToCommand = ( - category: CommandCategory, - doc: DocRecord, - run: () => void, - getPageTitle: ReturnType, - isPageJournal: (pageId: string) => boolean, - t: ReturnType, - subTitle?: string -): CMDKCommand => { - const docMode = doc.mode$.value; - - const title = getPageTitle(doc.id) || t['Untitled'](); - const commandLabel = { - title: title, - subTitle: subTitle, - }; - - const id = category + '.' + doc.id; - - const icon = isPageJournal(doc.id) ? ( - - ) : docMode === 'edgeless' ? ( - - ) : ( - - ); - - return { - id, - label: commandLabel, - category: category, - originalValue: title, - run: run, - icon: icon, - timestamp: doc.meta?.updatedDate, - }; -}; - -function useSearchedDocCommands( - onSelect: (opts: { docId: string; blockId?: string }) => void -) { - const quickSearch = useService(QuickSearchService).quickSearch; - const recentPages = useService(RecentPagesService); - const query = useLiveData(quickSearch.query$); - const workspace = useService(WorkspaceService).workspace; - const getPageTitle = useGetDocCollectionPageTitle(workspace.docCollection); - const { isPageJournal } = useJournalHelper(workspace.docCollection); - const t = useAFFiNEI18N(); - - const [searchTime, setSearchTime] = useState(0); - - // HACK: blocksuite indexer is async, - // so we need to re-search after it has been updated - useEffect(() => { - let timer: NodeJS.Timeout | null = null; - const dosearch = () => { - setSearchTime(Date.now()); - timer = setTimeout(dosearch, 500); - }; - timer = setTimeout(dosearch, 500); - return () => { - if (timer) clearTimeout(timer); - }; - }, []); - - return useMemo(() => { - searchTime; // hack to make the searchTime as a dependency - - if (query.trim().length === 0) { - return recentPages.getRecentDocs().map(doc => { - return docToCommand( - 'affine:recent', - doc, - () => onSelect({ docId: doc.id }), - getPageTitle, - isPageJournal, - t - ); - }); - } else { - return quickSearch - .getSearchedDocs(query) - .map(({ blockId, content, doc, source }) => { - const category = 'affine:pages'; - - const command = docToCommand( - category, - doc, - () => - onSelect({ - docId: doc.id, - blockId, - }), - getPageTitle, - isPageJournal, - t, - content - ); - - if (source === 'link-ref') { - command.alwaysShow = true; - command.originalValue = query; - } - - return command; - }); - } - }, [ - searchTime, - query, - recentPages, - getPageTitle, - isPageJournal, - t, - onSelect, - quickSearch, - ]); -} - -export const usePageCommands = () => { - const quickSearch = useService(QuickSearchService).quickSearch; - const workspace = useService(WorkspaceService).workspace; - const pageHelper = usePageHelper(workspace.docCollection); - const pageMetaHelper = useDocMetaHelper(workspace.docCollection); - const query = useLiveData(quickSearch.query$); - const navigationHelper = useNavigateHelper(); - const journalHelper = useJournalHelper(workspace.docCollection); - const t = useAFFiNEI18N(); - - const onSelectPage = useCallback( - (opts: { docId: string; blockId?: string }) => { - if (!workspace) { - console.error('current workspace not found'); - return; - } - - if (opts.blockId) { - navigationHelper.jumpToPageBlock( - workspace.id, - opts.docId, - opts.blockId - ); - } else { - navigationHelper.jumpToPage(workspace.id, opts.docId); - } - }, - [navigationHelper, workspace] - ); - - const searchedDocsCommands = useSearchedDocCommands(onSelectPage); - - return useMemo(() => { - const results: CMDKCommand[] = [...searchedDocsCommands]; - - // check if the pages have exact match. if not, we should show the "create page" command - if ( - results.every(command => command.originalValue !== query) && - query.trim() - ) { - results.push({ - id: 'affine:pages:append-to-journal', - label: t['com.affine.journal.cmdk.append-to-today'](), - alwaysShow: true, - category: 'affine:creation', - run: async () => { - const appendRes = await journalHelper.appendContentToToday(query); - if (!appendRes) return; - const { page, blockId } = appendRes; - navigationHelper.jumpToPageBlock( - page.collection.id, - page.id, - blockId - ); - mixpanel.track('AppendToJournal', { - control: 'cmdk', - }); - }, - icon: , - }); - - results.push({ - id: 'affine:pages:create-page', - label: ( - , - }} - /> - ), - alwaysShow: true, - category: 'affine:creation', - run: async () => { - const page = pageHelper.createPage(); - page.load(); - pageMetaHelper.setDocTitle(page.id, query); - mixpanel.track('DocCreated', { - control: 'cmdk', - type: 'doc', - }); - }, - icon: , - }); - - results.push({ - id: 'affine:pages:create-edgeless', - label: ( - , - }} - /> - ), - alwaysShow: true, - category: 'affine:creation', - run: async () => { - const page = pageHelper.createEdgeless(); - page.load(); - pageMetaHelper.setDocTitle(page.id, query); - mixpanel.track('DocCreated', { - control: 'cmdk', - type: 'whiteboard', - }); - }, - icon: , - }); - } - return results; - }, [ - searchedDocsCommands, - t, - query, - journalHelper, - navigationHelper, - pageHelper, - pageMetaHelper, - ]); -}; - -// todo: refactor to reduce duplication with usePageCommands -export const useSearchCallbackCommands = () => { - const quickSearch = useService(QuickSearchService).quickSearch; - const workspace = useService(WorkspaceService).workspace; - const pageHelper = usePageHelper(workspace.docCollection); - const pageMetaHelper = useDocMetaHelper(workspace.docCollection); - const query = useLiveData(quickSearch.query$); - - const onSelectPage = useCallback( - (searchResult: SearchCallbackResult) => { - if (!workspace) { - console.error('current workspace not found'); - return; - } - quickSearch.setSearchCallbackResult(searchResult); - }, - [quickSearch, workspace] - ); - - const searchedDocsCommands = useSearchedDocCommands(onSelectPage); - - return useMemo(() => { - const results: CMDKCommand[] = [...searchedDocsCommands]; - - // check if the pages have exact match. if not, we should show the "create page" command - if ( - results.every(command => command.originalValue !== query) && - query.trim() - ) { - if (query.startsWith('http://') || query.startsWith('https://')) { - results.push({ - id: 'affine:pages:create-page', - label: , - alwaysShow: true, - category: 'affine:creation', - run: async () => { - onSelectPage({ - query, - action: 'insert', - }); - }, - icon: , - }); - } else { - results.push({ - id: 'affine:pages:create-page', - label: ( - , - }} - /> - ), - alwaysShow: true, - category: 'affine:creation', - run: async () => { - const page = pageHelper.createPage('page', false); - page.load(); - pageMetaHelper.setDocTitle(page.id, query); - mixpanel.track('DocCreated', { - control: 'cmdk', - type: 'doc', - }); - onSelectPage({ docId: page.id }); - }, - icon: , - }); - } - } - return results; - }, [searchedDocsCommands, query, pageHelper, pageMetaHelper, onSelectPage]); -}; - -export const collectionToCommand = ( - collection: Collection, - navigationHelper: ReturnType, - selectCollection: (id: string) => void, - t: ReturnType, - workspace: Workspace -): CMDKCommand => { - const label = collection.name || t['Untitled'](); - const category = 'affine:collections'; - return { - id: collection.id, - label: label, - category: category, - run: () => { - navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL); - selectCollection(collection.id); - }, - icon: , - }; -}; - -export const useCollectionsCommands = () => { - // todo: considering collections for searching pages - const collectionService = useService(CollectionService); - const collections = useLiveData(collectionService.collections$); - const quickSearch = useService(QuickSearchService).quickSearch; - const query = useLiveData(quickSearch.query$); - const navigationHelper = useNavigateHelper(); - const t = useAFFiNEI18N(); - const workspace = useService(WorkspaceService).workspace; - const selectCollection = useCallback( - (id: string) => { - navigationHelper.jumpToCollection(workspace.id, id); - }, - [navigationHelper, workspace.id] - ); - return useMemo(() => { - let results: CMDKCommand[] = []; - if (query.trim() === '') { - return results; - } else { - results = collections.map(collection => { - const command = collectionToCommand( - collection, - navigationHelper, - selectCollection, - t, - workspace - ); - return command; - }); - return results; - } - }, [query, collections, navigationHelper, selectCollection, t, workspace]); -}; - -export const useCMDKCommandGroups = () => { - const pageCommands = usePageCommands(); - const collectionCommands = useCollectionsCommands(); - - const currentDocMode = - useLiveData(useService(GlobalContextService).globalContext.docMode.$) ?? - undefined; - const affineCommands = useMemo(() => { - return getAllCommand({ - docMode: currentDocMode, - }); - }, [currentDocMode]); - const quickSearch = useService(QuickSearchService).quickSearch; - const query = useLiveData(quickSearch.query$).trim(); - - return useMemo(() => { - const commands = [ - ...collectionCommands, - ...pageCommands, - ...affineCommands, - ]; - return filterSortAndGroupCommands(commands, query); - }, [affineCommands, collectionCommands, pageCommands, query]); -}; - -export const useSearchCallbackCommandGroups = () => { - const searchCallbackCommands = useSearchCallbackCommands(); - - const quickSearch = useService(QuickSearchService).quickSearch; - const query = useLiveData(quickSearch.query$).trim(); - - return useMemo(() => { - const commands = [...searchCallbackCommands]; - return filterSortAndGroupCommands(commands, query); - }, [searchCallbackCommands, query]); -}; diff --git a/packages/frontend/core/src/modules/cmdk/views/filter-commands.ts b/packages/frontend/core/src/modules/cmdk/views/filter-commands.ts deleted file mode 100644 index e575ba921c3b4..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/views/filter-commands.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { CommandCategory } from '@affine/core/commands'; -import { groupBy } from 'lodash-es'; - -import { commandScore } from './command-score'; -import type { CMDKCommand } from './types'; -import { highlightTextFragments } from './use-highlight'; - -export function filterSortAndGroupCommands( - commands: CMDKCommand[], - query: string -): [CommandCategory, CMDKCommand[]][] { - const scoredCommands = commands - .map(command => { - // attach value = id to each command - return { - ...command, - value: command.id.toLowerCase(), // required by cmdk library - score: getCommandScore(command, query), - }; - }) - .filter(c => c.score > 0); - - const sorted = scoredCommands.sort((a, b) => { - return b.score - a.score; - }); - - if (query) { - const onlyCreation = sorted.every( - command => command.category === 'affine:creation' - ); - if (onlyCreation) { - return [['affine:creation', sorted]]; - } else { - return [['affine:results', sorted]]; - } - } else { - const groups = groupBy(sorted, command => command.category); - return Object.entries(groups) as [CommandCategory, CMDKCommand[]][]; - } -} - -const highlightScore = (text: string, search: string) => { - if (text.trim().length === 0) { - return 0; - } - const fragments = highlightTextFragments(text, search); - const highlightedFragment = fragments.filter(fragment => fragment.highlight); - // check the longest highlighted fragment - const longestFragment = Math.max( - 0, - ...highlightedFragment.map(fragment => fragment.text.length) - ); - return longestFragment / search.length; -}; - -const getCategoryWeight = (command: CommandCategory) => { - switch (command) { - case 'affine:recent': - return 1; - case 'affine:pages': - case 'affine:edgeless': - case 'affine:collections': - return 0.8; - case 'affine:creation': - return 0.2; - default: - return 0.5; - } -}; - -const subTitleWeight = 0.8; - -export const getCommandScore = (command: CMDKCommand, search: string) => { - if (search.trim() === '') { - return 1; - } - - const label = command.label; - - const title = - label && typeof label === 'object' && 'title' in label - ? label.title - : typeof label === 'string' - ? label - : ''; - - const subTitle = - label && typeof label === 'object' && 'title' in label - ? label.subTitle ?? '' - : typeof label === 'string' - ? label - : ''; - - const catWeight = getCategoryWeight(command.category); - - const zeroComScore = Math.max( - commandScore(title, search), - commandScore(subTitle, search) * subTitleWeight - ); - - // if both title and subtitle has matched, we will use the higher score - const hlScore = Math.max( - highlightScore(title, search), - highlightScore(subTitle, search) * subTitleWeight - ); - - const score = Math.max( - zeroComScore * hlScore * catWeight, - command.alwaysShow ? 0.1 : 0 - ); - return score; -}; diff --git a/packages/frontend/core/src/modules/cmdk/views/highlight.tsx b/packages/frontend/core/src/modules/cmdk/views/highlight.tsx deleted file mode 100644 index 4387f44061815..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/views/highlight.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import { memo, type ReactNode } from 'react'; - -import * as styles from './highlight.css'; -import { useHighlight } from './use-highlight'; - -type SearchResultLabel = { - title: string; - subTitle?: string; -}; - -type HighlightProps = { - text: string; - highlight: string; -}; - -type HighlightLabelProps = { - label: SearchResultLabel | ReactNode; - highlight: string; -}; - -export const Highlight = memo(function Highlight({ - text = '', - highlight = '', -}: HighlightProps) { - const highlights = useHighlight(text, highlight); - - return ( -
- {highlights.map((part, i) => ( - - {part.text} - - ))} -
- ); -}); - -export const HighlightLabel = memo(function HighlightLabel({ - label, - highlight, -}: HighlightLabelProps) { - if (label && typeof label === 'object' && 'title' in label) { - return ( -
-
- -
- {label.subTitle ? ( -
- -
- ) : null} -
- ); - } - - return
{label}
; -}); diff --git a/packages/frontend/core/src/modules/cmdk/views/index.tsx b/packages/frontend/core/src/modules/cmdk/views/index.tsx deleted file mode 100644 index fc2b5fe89a9f5..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/views/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export * from './main'; -export * from './modal'; diff --git a/packages/frontend/core/src/modules/cmdk/views/main.tsx b/packages/frontend/core/src/modules/cmdk/views/main.tsx deleted file mode 100644 index 6250cce249d7d..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/views/main.tsx +++ /dev/null @@ -1,355 +0,0 @@ -import { Loading } from '@affine/component/ui/loading'; -import type { CommandCategory } from '@affine/core/commands'; -import { formatDate } from '@affine/core/components/page-list'; -import { useDocEngineStatus } from '@affine/core/hooks/affine/use-doc-engine-status'; -import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; -import { QuickSearchService } from '@affine/core/modules/cmdk'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import type { DocMeta } from '@blocksuite/store'; -import { useLiveData, useService } from '@toeverything/infra'; -import clsx from 'clsx'; -import { Command } from 'cmdk'; -import { useDebouncedValue } from 'foxact/use-debounced-value'; -import { useAtom } from 'jotai'; -import { - type ReactNode, - Suspense, - useLayoutEffect, - useMemo, - useRef, - useState, -} from 'react'; - -import { - cmdkValueAtom, - useCMDKCommandGroups, - useSearchCallbackCommandGroups, -} from './data-hooks'; -import { HighlightLabel } from './highlight'; -import * as styles from './main.css'; -import type { CMDKModalProps } from './modal'; -import { CMDKModal } from './modal'; -import { NotFoundGroup } from './not-found'; -import type { CMDKCommand } from './types'; - -type NoParametersKeys = { - [K in keyof T]: T[K] extends () => any ? K : never; -}[keyof T]; - -type i18nKey = NoParametersKeys>; - -const categoryToI18nKey: Record = { - 'affine:recent': 'com.affine.cmdk.affine.category.affine.recent', - 'affine:navigation': 'com.affine.cmdk.affine.category.affine.navigation', - 'affine:creation': 'com.affine.cmdk.affine.category.affine.creation', - 'affine:general': 'com.affine.cmdk.affine.category.affine.general', - 'affine:layout': 'com.affine.cmdk.affine.category.affine.layout', - 'affine:pages': 'com.affine.cmdk.affine.category.affine.pages', - 'affine:edgeless': 'com.affine.cmdk.affine.category.affine.edgeless', - 'affine:collections': 'com.affine.cmdk.affine.category.affine.collections', - 'affine:settings': 'com.affine.cmdk.affine.category.affine.settings', - 'affine:updates': 'com.affine.cmdk.affine.category.affine.updates', - 'affine:help': 'com.affine.cmdk.affine.category.affine.help', - 'editor:edgeless': 'com.affine.cmdk.affine.category.editor.edgeless', - 'editor:insert-object': - 'com.affine.cmdk.affine.category.editor.insert-object', - 'editor:page': 'com.affine.cmdk.affine.category.editor.page', - 'affine:results': 'com.affine.cmdk.affine.category.results', -}; - -const QuickSearchGroup = ({ - category, - commands, - onOpenChange, -}: { - category: CommandCategory; - commands: CMDKCommand[]; - onOpenChange?: (open: boolean) => void; -}) => { - const t = useAFFiNEI18N(); - const i18nKey = categoryToI18nKey[category]; - const quickSearch = useService(QuickSearchService).quickSearch; - const query = useLiveData(quickSearch.query$); - - const onCommendSelect = useAsyncCallback( - async (command: CMDKCommand) => { - try { - await command.run(); - } finally { - onOpenChange?.(false); - } - }, - [onOpenChange] - ); - - return ( - - {commands.map(command => { - const label = - typeof command.label === 'string' - ? { - title: command.label, - } - : command.label; - return ( - onCommendSelect(command)} - value={command.value} - data-is-danger={ - command.id === 'editor:page-move-to-trash' || - command.id === 'editor:edgeless-move-to-trash' - } - > -
{command.icon}
-
- -
- {command.timestamp ? ( -
- {formatDate(new Date(command.timestamp))} -
- ) : null} - {command.keyBinding ? ( - - ) : null} -
- ); - })} -
- ); -}; - -const QuickSearchCommands = ({ - onOpenChange, - groups, -}: { - onOpenChange?: (open: boolean) => void; - groups: ReturnType; -}) => { - return ( - <> - {groups.map(([category, commands]) => { - return ( - - ); - })} - - ); -}; - -export const CMDKContainer = ({ - className, - onQueryChange, - query, - children, - inputLabel, - open, - ...rest -}: React.PropsWithChildren<{ - open: boolean; - className?: string; - query: string; - inputLabel?: ReactNode; - groups: ReturnType; - onQueryChange: (query: string) => void; -}>) => { - const t = useAFFiNEI18N(); - const [value, setValue] = useAtom(cmdkValueAtom); - const [opening, setOpening] = useState(open); - const { syncing, progress } = useDocEngineStatus(); - const showLoading = useDebouncedValue(syncing, 500); - const quickSearch = useService(QuickSearchService).quickSearch; - const mode = useLiveData(quickSearch.mode$); - - const inputRef = useRef(null); - - // fix list height animation on opening - useLayoutEffect(() => { - if (open) { - setOpening(true); - const timeout = setTimeout(() => { - setOpening(false); - inputRef.current?.focus(); - }, 150); - return () => { - clearTimeout(timeout); - }; - } else { - setOpening(false); - } - return; - }, [open]); - - return ( - - {/* todo: add page context here */} - {inputLabel ? ( -
- {inputLabel} -
- ) : null} -
- {showLoading ? ( - - ) : null} - -
- - - {children} - - {mode === 'commands' ? : null} -
- ); -}; - -const CMDKQuickSearchModalInner = ({ - pageMeta, - open, - ...props -}: CMDKModalProps & { pageMeta?: Partial }) => { - const quickSearch = useService(QuickSearchService).quickSearch; - const query = useLiveData(quickSearch.query$); - const groups = useCMDKCommandGroups(); - const t = useAFFiNEI18N(); - return ( - - - - ); -}; - -const CMDKQuickSearchCallbackModalInner = ({ - open, - ...props -}: CMDKModalProps & { pageMeta?: Partial }) => { - const quickSearch = useService(QuickSearchService).quickSearch; - const query = useLiveData(quickSearch.query$); - const groups = useSearchCallbackCommandGroups(); - const t = useAFFiNEI18N(); - return ( - - - - ); -}; - -export const CMDKQuickSearchModal = ({ - pageMeta, - open, - ...props -}: CMDKModalProps & { pageMeta?: Partial }) => { - const quickSearch = useService(QuickSearchService).quickSearch; - const mode = useLiveData(quickSearch.mode$); - const InnerComp = - mode === 'commands' - ? CMDKQuickSearchModalInner - : CMDKQuickSearchCallbackModalInner; - - return ( - - }> - - - - ); -}; - -const CMDKKeyBinding = ({ keyBinding }: { keyBinding: string }) => { - const isMacOS = environment.isBrowser && environment.isMacOs; - const fragments = useMemo(() => { - return keyBinding.split('+').map(fragment => { - if (fragment === '$mod') { - return isMacOS ? '⌘' : 'Ctrl'; - } - if (fragment === 'ArrowUp') { - return '↑'; - } - if (fragment === 'ArrowDown') { - return '↓'; - } - if (fragment === 'ArrowLeft') { - return '←'; - } - if (fragment === 'ArrowRight') { - return '→'; - } - return fragment; - }); - }, [isMacOS, keyBinding]); - - return ( -
- {fragments.map((fragment, index) => { - return ( -
- {fragment} -
- ); - })} -
- ); -}; diff --git a/packages/frontend/core/src/modules/cmdk/views/not-found.css.ts b/packages/frontend/core/src/modules/cmdk/views/not-found.css.ts deleted file mode 100644 index 07f48c12cb325..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/views/not-found.css.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { cssVar } from '@toeverything/theme'; -import { style } from '@vanilla-extract/css'; -export const notFoundContainer = style({ - display: 'flex', - flexDirection: 'column', - padding: '0 8px', - marginBottom: 8, -}); -export const notFoundItem = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'flex-start', - padding: '0 12px', - gap: 16, -}); -export const notFoundIcon = style({ - display: 'flex', - alignItems: 'center', - fontSize: 20, - color: cssVar('iconSecondary'), - padding: '12px 0', -}); -export const notFoundTitle = style({ - fontSize: cssVar('fontXs'), - color: cssVar('textSecondaryColor'), - fontWeight: '600', - lineHeight: '20px', - whiteSpace: 'nowrap', - wordBreak: 'break-word', - textOverflow: 'ellipsis', - overflow: 'hidden', - padding: '8px', -}); -export const notFoundText = style({ - fontSize: cssVar('fontSm'), - color: cssVar('textPrimaryColor'), - lineHeight: '22px', - fontWeight: '400', -}); diff --git a/packages/frontend/core/src/modules/cmdk/views/not-found.tsx b/packages/frontend/core/src/modules/cmdk/views/not-found.tsx deleted file mode 100644 index c65ea16a3db8c..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/views/not-found.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { QuickSearchService } from '@affine/core/modules/cmdk'; -import { useAFFiNEI18N } from '@affine/i18n/hooks'; -import { SearchIcon } from '@blocksuite/icons'; -import { useLiveData, useService } from '@toeverything/infra'; -import { useCommandState } from 'cmdk'; - -import * as styles from './not-found.css'; - -export const NotFoundGroup = () => { - const quickSearch = useService(QuickSearchService).quickSearch; - const query = useLiveData(quickSearch.query$); - // hack: we know that the filtered count is 3 when there is no result (create page & edgeless & append to journal, for mode === 'cmdk') - const renderNoResult = useCommandState(state => state.filtered.count === 3); - - const t = useAFFiNEI18N(); - - if (!renderNoResult) { - return null; - } - return ( -
-
{`Search for "${query}"`}
-
-
- -
-
- {t['com.affine.cmdk.no-results']()} -
-
-
- ); -}; diff --git a/packages/frontend/core/src/modules/cmdk/views/types.ts b/packages/frontend/core/src/modules/cmdk/views/types.ts deleted file mode 100644 index 789f6576614f4..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/views/types.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { CommandCategory } from '@affine/core/commands'; -import type { DocMode } from '@toeverything/infra'; -import type { ReactNode } from 'react'; - -export interface CommandContext { - docMode: DocMode | undefined; -} - -// similar to AffineCommand, but for rendering into the UI -// it unifies all possible commands into a single type so that -// we can use a single render function to render all different commands -export interface CMDKCommand { - id: string; - label: - | ReactNode - | string - | { - title: string; - subTitle?: string; - }; - icon?: React.ReactNode; - category: CommandCategory; - keyBinding?: string | { binding: string }; - timestamp?: number; - alwaysShow?: boolean; - value?: string; // this is used for item filtering - originalValue?: string; // some values may be transformed, this is the original value - run: (e?: Event) => void | Promise; -} diff --git a/packages/frontend/core/src/modules/cmdk/views/use-highlight.ts b/packages/frontend/core/src/modules/cmdk/views/use-highlight.ts deleted file mode 100644 index e9808a7b17e29..0000000000000 --- a/packages/frontend/core/src/modules/cmdk/views/use-highlight.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { useMemo } from 'react'; - -function* highlightTextFragmentsGenerator(text: string, query: string) { - const cleanedText = text.replace(/\r?\n|\r|\t/g, ''); - const lowerCaseText = cleanedText.toLowerCase(); - query = query.toLowerCase(); - let startIndex = lowerCaseText.indexOf(query); - - if (startIndex !== -1) { - if (startIndex > 0) { - yield { text: cleanedText.substring(0, startIndex), highlight: false }; - } - - yield { - text: cleanedText.substring(startIndex, startIndex + query.length), - highlight: true, - }; - - if (startIndex + query.length < cleanedText.length) { - yield { - text: cleanedText.substring(startIndex + query.length), - highlight: false, - }; - } - } else { - startIndex = 0; - for (const char of query) { - const pos = cleanedText.toLowerCase().indexOf(char, startIndex); - if (pos !== -1) { - if (pos > startIndex) { - yield { - text: cleanedText.substring(startIndex, pos), - highlight: false, - }; - } - yield { text: cleanedText.substring(pos, pos + 1), highlight: true }; - startIndex = pos + 1; - } - } - if (startIndex < cleanedText.length) { - yield { text: cleanedText.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]); -} diff --git a/packages/frontend/core/src/modules/i18n/index.ts b/packages/frontend/core/src/modules/i18n/index.ts new file mode 100644 index 0000000000000..809e3687de77d --- /dev/null +++ b/packages/frontend/core/src/modules/i18n/index.ts @@ -0,0 +1,11 @@ +import type { Framework } from '@toeverything/infra'; + +import { I18nService } from './services/i18n'; + +export { I18nService } from './services/i18n'; +export type { I18nKeys, I18nString } from './types/i18n'; +export { isI18nString } from './utils/is-i18n-string'; + +export function configureI18nModule(framework: Framework) { + framework.service(I18nService); +} diff --git a/packages/frontend/core/src/modules/i18n/services/i18n.ts b/packages/frontend/core/src/modules/i18n/services/i18n.ts new file mode 100644 index 0000000000000..422febf51894e --- /dev/null +++ b/packages/frontend/core/src/modules/i18n/services/i18n.ts @@ -0,0 +1,33 @@ +import { getI18n } from '@affine/i18n'; +import { Service } from '@toeverything/infra'; + +import type { I18nFuncs, I18nString } from '../types/i18n'; + +class RawI18nService extends Service { + constructor() { + super(); + + // eslint-disable-next-line no-constructor-return + return new Proxy(this, { + get(self, key) { + const i18n = getI18n(); + if (typeof key === 'string' && i18n.exists(key)) { + return i18n.t.bind(i18n, key as string); + } else { + return (self as any)[key as string] as any; + } + }, + }); + } + + t(i18nStr: I18nString) { + const i18n = getI18n(); + if (typeof i18nStr === 'object') { + return i18n.t(i18nStr.key, 'options' in i18nStr ? i18nStr.options : {}); + } + return i18nStr; + } +} + +export const I18nService = RawI18nService as new () => RawI18nService & + I18nFuncs; diff --git a/packages/frontend/core/src/modules/i18n/types/i18n.ts b/packages/frontend/core/src/modules/i18n/types/i18n.ts new file mode 100644 index 0000000000000..0e0e3555b92f8 --- /dev/null +++ b/packages/frontend/core/src/modules/i18n/types/i18n.ts @@ -0,0 +1,21 @@ +import type { useAFFiNEI18N } from '@affine/i18n/hooks'; + +export type I18nFuncs = ReturnType; + +export type I18nInfos = { + [K in keyof I18nFuncs]: I18nFuncs[K] extends (...a: infer Opt) => any + ? Opt[0] + : never; +}; + +export type I18nKeys = keyof I18nInfos; + +export type I18nString = + | { + [K in I18nKeys]: { + key: K; + } & (I18nInfos[K] extends undefined + ? unknown + : { options: I18nInfos[K] }); + }[I18nKeys] + | string; diff --git a/packages/frontend/core/src/modules/i18n/utils/is-i18n-string.ts b/packages/frontend/core/src/modules/i18n/utils/is-i18n-string.ts new file mode 100644 index 0000000000000..e399aa4d901bc --- /dev/null +++ b/packages/frontend/core/src/modules/i18n/utils/is-i18n-string.ts @@ -0,0 +1,7 @@ +import type { I18nString } from '../types/i18n'; + +export const isI18nString = (value: any): value is I18nString => { + return ( + typeof value === 'string' || (typeof value === 'object' && 'key' in value) + ); +}; diff --git a/packages/frontend/core/src/modules/index.ts b/packages/frontend/core/src/modules/index.ts index 79f25736ffab3..4acf5ccbed7eb 100644 --- a/packages/frontend/core/src/modules/index.ts +++ b/packages/frontend/core/src/modules/index.ts @@ -2,13 +2,15 @@ import { configureQuotaModule } from '@affine/core/modules/quota'; import { configureInfraModules, type Framework } from '@toeverything/infra'; import { configureCloudModule } from './cloud'; -import { configureQuickSearchModule } from './cmdk'; import { configureCollectionModule } from './collection'; +import { configureDocsSearchModule } from './docs-search'; import { configureFindInPageModule } from './find-in-page'; +import { configureI18nModule } from './i18n'; import { configureNavigationModule } from './navigation'; import { configurePeekViewModule } from './peek-view'; import { configurePermissionsModule } from './permissions'; import { configureWorkspacePropertiesModule } from './properties'; +import { configureQuickSearchModule } from './quicksearch'; import { configureRightSidebarModule } from './right-sidebar'; import { configureShareDocsModule } from './share-doc'; import { configureStorageImpls } from './storage'; @@ -32,6 +34,8 @@ export function configureCommonModules(framework: Framework) { configureFindInPageModule(framework); configurePeekViewModule(framework); configureQuickSearchModule(framework); + configureDocsSearchModule(framework); + configureI18nModule(framework); } export function configureImpls(framework: Framework) { diff --git a/packages/frontend/core/src/modules/peek-view/view/doc-peek-controls.tsx b/packages/frontend/core/src/modules/peek-view/view/doc-peek-controls.tsx index 5d989132bb653..2112179938e97 100644 --- a/packages/frontend/core/src/modules/peek-view/view/doc-peek-controls.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/doc-peek-controls.tsx @@ -94,7 +94,7 @@ export const DocPeekViewControls = ({ // todo: for frame blocks, we should mimic "view in edgeless" button behavior blockId ? jumpToPageBlock(workspace.id, docId, blockId) - : workbench.openPage(docId); + : workbench.openDoc(docId); if (mode) { doc?.setMode(mode); } @@ -106,7 +106,7 @@ export const DocPeekViewControls = ({ nameKey: 'split-view', name: t['com.affine.peek-view-controls.open-doc-in-split-view'](), onClick: () => { - workbench.openPage(docId, { at: 'beside' }); + workbench.openDoc(docId, { at: 'beside' }); peekView.close(); }, }, diff --git a/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx index 6450299c355d4..ba734b76f9d6b 100644 --- a/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/doc-peek-view.tsx @@ -47,7 +47,7 @@ const DocPreview = forwardRef< useEffect(() => { const disposable = AIProvider.slots.requestContinueInChat.on(() => { if (doc) { - workbench.openPage(doc.id); + workbench.openDoc(doc.id); peekView.close(); // chat panel open is already handled in } diff --git a/packages/frontend/core/src/modules/quicksearch/entities/quick-search.ts b/packages/frontend/core/src/modules/quicksearch/entities/quick-search.ts new file mode 100644 index 0000000000000..19752c473d363 --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/entities/quick-search.ts @@ -0,0 +1,113 @@ +import { Entity, LiveData } from '@toeverything/infra'; +import { mean } from 'lodash-es'; + +import type { + QuickSearchSession, + QuickSearchSource, + QuickSearchSourceItemType, +} from '../providers/quick-search-provider'; +import type { QuickSearchItem } from '../types/item'; +import type { QuickSearchOptions } from '../types/options'; + +export class QuickSearch extends Entity { + constructor() { + super(); + } + private readonly state$ = new LiveData<{ + query: string; + sessions: QuickSearchSession[]; + options: QuickSearchOptions; + callback: (result: QuickSearchItem | null) => void; + } | null>(null); + + readonly items$ = this.state$ + .map(s => s?.sessions.map(session => session.items$) ?? []) + .flat() + .map(items => items.flat()); + + readonly show$ = this.state$.map(s => !!s); + + readonly options$ = this.state$.map(s => s?.options); + + readonly isLoading$ = this.state$ + .map( + s => + s?.sessions.map(session => session.isLoading$ ?? new LiveData(false)) ?? + [] + ) + .flat() + .map(items => items.reduce((acc, item) => acc || item, false)); + + readonly loadingProgress$ = this.state$ + .map( + s => + s?.sessions.map( + session => + (session.loadingProgress$ ?? new LiveData(null)) as LiveData< + number | null + > + ) ?? [] + ) + .flat() + .map(items => mean(items.filter((v): v is number => v === null))); + + show = ( + sources: Sources, + cb: (result: QuickSearchSourceItemType | null) => void, + options: QuickSearchOptions = {} + ) => { + if (this.state$.value) { + this.hide(); + } + + const sessions = sources.map((source: QuickSearchSource) => { + if (typeof source === 'function') { + const items$ = new LiveData[]>([]); + return { + items$, + query: (query: string) => { + items$.next(source(query)); + }, + } as QuickSearchSession; + } else { + return source as QuickSearchSession; + } + }); + sessions.forEach(session => { + session.query?.(options.defaultQuery || ''); + }); + this.state$.next({ + query: options.defaultQuery ?? '', + options, + sessions: sessions, + callback: cb as any, + }); + }; + + query$ = this.state$.map(s => s?.query || ''); + + setQuery = (query: string) => { + if (!this.state$.value) return; + this.state$.next({ + ...this.state$.value, + query, + }); + this.state$.value.sessions.forEach(session => session.query?.(query)); + }; + + hide() { + if (this.state$.value) { + this.state$.value.sessions.forEach(session => session.dispose?.()); + this.state$.value.callback?.(null); + } + + this.state$.next(null); + } + + submit(result: QuickSearchItem | null) { + if (this.state$.value?.callback) { + this.state$.value.callback(result); + } + this.state$.next(null); + } +} diff --git a/packages/frontend/core/src/modules/quicksearch/impls/collections.ts b/packages/frontend/core/src/modules/quicksearch/impls/collections.ts new file mode 100644 index 0000000000000..905faaf73bf4c --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/impls/collections.ts @@ -0,0 +1,77 @@ +import { ViewLayersIcon } from '@blocksuite/icons'; +import { Entity, LiveData } from '@toeverything/infra'; +import Fuse from 'fuse.js'; + +import type { CollectionService } from '../../collection'; +import type { QuickSearchSession } from '../providers/quick-search-provider'; +import type { QuickSearchGroup } from '../types/group'; +import type { QuickSearchItem } from '../types/item'; +import { highlighter } from '../utils/highlighter'; + +const group = { + id: 'collections', + label: { + key: 'com.affine.cmdk.affine.category.affine.collections', + }, + score: 10, +} as QuickSearchGroup; + +export class CollectionsQuickSearchSession + extends Entity + implements QuickSearchSession<'collections', { collectionId: string }> +{ + constructor(private readonly collectionService: CollectionService) { + super(); + } + + query$ = new LiveData(''); + + items$: LiveData[]> = + LiveData.computed(get => { + const query = get(this.query$); + + const collections = get(this.collectionService.collections$); + + const fuse = new Fuse(collections, { + keys: ['name'], + includeMatches: true, + includeScore: true, + }); + + const result = fuse.search(query); + + return result.map< + QuickSearchItem<'collections', { collectionId: string }> + >(({ item, matches, score = 1 }) => { + const nomalizedRange = ([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 === 'name') + .flatMap(match => match.indices.map(nomalizedRange)); + + return { + id: 'collection:' + item.id, + source: 'collections', + label: { + title: (highlighter(item.name, '', '', titleMatches ?? []) ?? + item.name) || { + key: 'Untitled', + }, + }, + group, + score: + 1 - + score /* in fuse, the smaller the score, the better the match, so we need to reverse it */, + icon: ViewLayersIcon, + payload: { collectionId: item.id }, + }; + }); + }); + + query(query: string) { + this.query$.next(query); + } +} diff --git a/packages/frontend/core/src/modules/quicksearch/impls/commands.ts b/packages/frontend/core/src/modules/quicksearch/impls/commands.ts new file mode 100644 index 0000000000000..4e756ea2df5e9 --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/impls/commands.ts @@ -0,0 +1,209 @@ +import { + type AffineCommand, + AffineCommandRegistry, + type CommandCategory, + PreconditionStrategy, +} from '@affine/core/commands'; +import type { DocMode, GlobalContextService } from '@toeverything/infra'; +import { Entity, LiveData } from '@toeverything/infra'; +import Fuse from 'fuse.js'; + +import type { QuickSearchSession } from '../providers/quick-search-provider'; +import type { QuickSearchGroup } from '../types/group'; +import type { QuickSearchItem } from '../types/item'; +import { highlighter } from '../utils/highlighter'; + +const categories = { + 'affine:recent': { + id: 'command:affine:recent', + label: { key: 'com.affine.cmdk.affine.category.affine.recent' }, + score: 10, + }, + 'affine:navigation': { + id: 'command:affine:navigation', + label: { + key: 'com.affine.cmdk.affine.category.affine.navigation', + }, + score: 10, + }, + 'affine:creation': { + id: 'command:affine:creation', + label: { key: 'com.affine.cmdk.affine.category.affine.creation' }, + score: 10, + }, + 'affine:general': { + id: 'command:affine:general', + label: { key: 'com.affine.cmdk.affine.category.affine.general' }, + score: 10, + }, + 'affine:layout': { + id: 'command:affine:layout', + label: { key: 'com.affine.cmdk.affine.category.affine.layout' }, + score: 10, + }, + 'affine:pages': { + id: 'command:affine:pages', + label: { key: 'com.affine.cmdk.affine.category.affine.pages' }, + score: 10, + }, + 'affine:edgeless': { + id: 'command:affine:edgeless', + label: { key: 'com.affine.cmdk.affine.category.affine.edgeless' }, + score: 10, + }, + 'affine:collections': { + id: 'command:affine:collections', + label: { + key: 'com.affine.cmdk.affine.category.affine.collections', + }, + score: 10, + }, + 'affine:settings': { + id: 'command:affine:settings', + label: { key: 'com.affine.cmdk.affine.category.affine.settings' }, + score: 10, + }, + 'affine:updates': { + id: 'command:affine:updates', + label: { key: 'com.affine.cmdk.affine.category.affine.updates' }, + score: 10, + }, + 'affine:help': { + id: 'command:affine:help', + label: { key: 'com.affine.cmdk.affine.category.affine.help' }, + score: 10, + }, + 'editor:edgeless': { + id: 'command:editor:edgeless', + label: { key: 'com.affine.cmdk.affine.category.editor.edgeless' }, + score: 10, + }, + 'editor:insert-object': { + id: 'command:editor:insert-object', + label: { key: 'com.affine.cmdk.affine.category.editor.insert-object' }, + score: 10, + }, + 'editor:page': { + id: 'command:editor:page', + label: { key: 'com.affine.cmdk.affine.category.editor.page' }, + score: 10, + }, + 'affine:results': { + id: 'command:affine:results', + label: { key: 'com.affine.cmdk.affine.category.results' }, + score: 10, + }, +} satisfies Required<{ + [key in CommandCategory]: QuickSearchGroup & { id: `command:${key}` }; +}>; + +function filterCommandByContext( + command: AffineCommand, + context: { + docMode: DocMode | undefined; + } +) { + if (command.preconditionStrategy === PreconditionStrategy.Always) { + return true; + } + if (command.preconditionStrategy === PreconditionStrategy.InEdgeless) { + return context.docMode === 'edgeless'; + } + if (command.preconditionStrategy === PreconditionStrategy.InPaper) { + return context.docMode === 'page'; + } + if (command.preconditionStrategy === PreconditionStrategy.InPaperOrEdgeless) { + return !!context.docMode; + } + if (command.preconditionStrategy === PreconditionStrategy.Never) { + return false; + } + if (typeof command.preconditionStrategy === 'function') { + return command.preconditionStrategy(); + } + return true; +} + +function getAllCommand(context: { docMode: DocMode | undefined }) { + const commands = AffineCommandRegistry.getAll(); + return commands.filter(command => { + return filterCommandByContext(command, context); + }); +} + +export class CommandsQuickSearchSession + extends Entity + implements QuickSearchSession<'commands', AffineCommand> +{ + constructor(private readonly contextService: GlobalContextService) { + super(); + } + + query$ = new LiveData(''); + + items$ = LiveData.computed(get => { + const query = get(this.query$); + const docMode = + get(this.contextService.globalContext.docMode.$) ?? undefined; + const commands = getAllCommand({ docMode }); + + const fuse = new Fuse(commands, { + keys: [{ name: 'label.title', weight: 2 }, 'label.subTitle'], + includeMatches: true, + includeScore: true, + }); + + const result = query + ? fuse.search(query) + : commands.map(item => ({ item, matches: [], score: 0 })); + + return result.map>( + ({ item, matches, score = 1 }) => { + const nomalizedRange = ([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 === 'label.title') + .flatMap(match => match.indices.map(nomalizedRange)); + const subTitleMatches = matches + ?.filter(match => match.key === 'label.subTitle') + .flatMap(match => match.indices.map(nomalizedRange)); + + return { + id: 'command:' + item.id, + source: 'commands', + label: { + title: + highlighter( + item.label.title, + '', + '', + titleMatches ?? [] + ) ?? item.label.title, + subTitle: item.label.subTitle + ? highlighter( + item.label.subTitle, + '', + '', + subTitleMatches ?? [] + ) ?? item.label.subTitle + : undefined, + }, + group: categories[item.category], + score: + 1 - + score /* in fuse, the smaller the score, the better the match, so we need to reverse it */, + icon: item.icon, + keyBinding: item.keyBinding?.binding, + payload: item, + }; + } + ); + }); + + query(query: string) { + this.query$.next(query); + } +} diff --git a/packages/frontend/core/src/modules/quicksearch/impls/creation.ts b/packages/frontend/core/src/modules/quicksearch/impls/creation.ts new file mode 100644 index 0000000000000..11b2be74c5c25 --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/impls/creation.ts @@ -0,0 +1,54 @@ +import { EdgelessIcon, PageIcon } from '@blocksuite/icons'; +import { type DocMode, Entity, LiveData } from '@toeverything/infra'; + +import type { QuickSearchSession } from '../providers/quick-search-provider'; +import type { QuickSearchGroup } from '../types/group'; +import type { QuickSearchItem } from '../types/item'; + +const group = { + id: 'creation', + label: { key: 'com.affine.quicksearch.group.creation' }, + score: 0, +} as QuickSearchGroup; + +export class CreationQuickSearchSession + extends Entity + implements QuickSearchSession<'creation', { title: string; mode: DocMode }> +{ + query$ = new LiveData(''); + + items$ = LiveData.computed(get => { + const query = get(this.query$); + + if (!query.trim()) { + return []; + } + + return [ + { + id: 'creation:create-page', + label: { + key: 'com.affine.cmdk.affine.create-new-page-as', + options: { keyWord: query }, + }, + group, + icon: PageIcon, + payload: { mode: 'edgeless', title: query }, + }, + { + id: 'creation:create-edgeless', + label: { + key: 'com.affine.cmdk.affine.create-new-edgeless-as', + options: { keyWord: query }, + }, + group, + icon: EdgelessIcon, + payload: { mode: 'edgeless', title: query }, + }, + ] as QuickSearchItem<'creation', { title: string; mode: DocMode }>[]; + }); + + query(query: string) { + this.query$.next(query); + } +} diff --git a/packages/frontend/core/src/modules/quicksearch/impls/docs.ts b/packages/frontend/core/src/modules/quicksearch/impls/docs.ts new file mode 100644 index 0000000000000..3694804c2a9e5 --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/impls/docs.ts @@ -0,0 +1,158 @@ +import { EdgelessIcon, PageIcon, TodayIcon } from '@blocksuite/icons'; +import type { DocsService, WorkspaceService } from '@toeverything/infra'; +import { + effect, + Entity, + fromPromise, + LiveData, + onComplete, + onStart, +} from '@toeverything/infra'; +import { truncate } from 'lodash-es'; +import { EMPTY, mergeMap, switchMap } from 'rxjs'; + +import type { DocsSearchService } from '../../docs-search'; +import { resolveLinkToDoc } from '../../navigation'; +import type { WorkspacePropertiesAdapter } from '../../properties'; +import type { QuickSearchSession } from '../providers/quick-search-provider'; +import type { QuickSearchItem } from '../types/item'; + +interface DocsPayload { + docId: string; + title?: string; + blockId?: string | undefined; + blockContent?: string | undefined; +} + +export class DocsQuickSearchSession + extends Entity + implements QuickSearchSession<'docs', DocsPayload> +{ + constructor( + private readonly workspaceService: WorkspaceService, + private readonly docSearchService: DocsSearchService, + private readonly docsService: DocsService, + private readonly propertiesAdapter: WorkspacePropertiesAdapter + ) { + super(); + } + + query$ = new LiveData(''); + + isLoading$ = new LiveData(false); + + loadingProgress$ = new LiveData(0); + + items$ = new LiveData[]>([]); + + query = effect( + switchMap((query: string) => { + return fromPromise(async () => { + if (!query) { + return []; + } + + const maybeLink = resolveLinkToDoc(query); + + let result: QuickSearchItem<'docs', DocsPayload>[] = []; + if ( + maybeLink && + maybeLink.workspaceId === this.workspaceService.workspace.id + ) { + const docRecord = this.docsService.list.doc$(maybeLink.docId).value; + if (docRecord) { + const docMode = docRecord?.mode$.value; + const icon = this.propertiesAdapter.getJournalPageDateString( + maybeLink.docId + ) /* is journal */ + ? TodayIcon + : docMode === 'edgeless' + ? EdgelessIcon + : PageIcon; + + result = [ + { + id: 'doc:' + maybeLink.docId, + source: 'docs', + group: { + id: 'docs', + label: { + key: 'com.affine.quicksearch.group.searchfor', + options: { query: truncate(query) }, + }, + score: 5, + }, + label: { + title: docRecord.title$.value || { key: 'Untitled' }, + }, + score: 100, + icon, + timestamp: docRecord.meta$.value.updatedDate, + payload: { + docId: maybeLink.docId, + }, + }, + ]; + } + } + + const docs = await this.docSearchService.search(query); + + result.push( + ...docs.map(doc => { + const docRecord = this.docsService.list.doc$(doc.docId).value; + const docMode = docRecord?.mode$.value; + const updatedTime = docRecord?.meta$.value.updatedDate; + + const icon = this.propertiesAdapter.getJournalPageDateString( + doc.docId + ) /* is journal */ + ? TodayIcon + : docMode === 'edgeless' + ? EdgelessIcon + : PageIcon; + + return { + id: 'doc:' + doc.docId, + source: 'docs', + group: { + id: 'docs', + label: { + key: 'com.affine.quicksearch.group.searchfor', + options: { query: truncate(query) }, + }, + score: 5, + }, + label: { + title: doc.title || { key: 'Untitled' }, + subTitle: doc.blockContent, + }, + score: doc.score, + icon, + timestamp: updatedTime, + payload: doc, + } as QuickSearchItem<'docs', DocsPayload>; + }) + ); + + return result; + }).pipe( + mergeMap((items: QuickSearchItem<'docs', DocsPayload>[]) => { + this.items$.next(items); + return EMPTY; + }), + onStart(() => { + this.items$.next([]); + // loading + }), + onComplete(() => {}) + ); + }) + ); + + // TODO: load more + + setQuery(query: string) { + this.query$.next(query); + } +} diff --git a/packages/frontend/core/src/modules/quicksearch/impls/recent-docs.ts b/packages/frontend/core/src/modules/quicksearch/impls/recent-docs.ts new file mode 100644 index 0000000000000..a4c50e4af81bb --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/impls/recent-docs.ts @@ -0,0 +1,70 @@ +import { EdgelessIcon, PageIcon, TodayIcon } from '@blocksuite/icons'; +import { Entity, LiveData } from '@toeverything/infra'; + +import type { WorkspacePropertiesAdapter } from '../../properties'; +import type { QuickSearchSession } from '../providers/quick-search-provider'; +import type { RecentDocsService } from '../services/recent-pages'; +import type { QuickSearchGroup } from '../types/group'; +import type { QuickSearchItem } from '../types/item'; + +const group = { + id: 'recent-docs', + label: { + key: 'com.affine.cmdk.affine.category.affine.recent', + }, + score: 15, +} as QuickSearchGroup; + +export class RecentDocsQuickSearchSession + extends Entity + implements QuickSearchSession<'recent-doc', { docId: string }> +{ + constructor( + private readonly recentDocsService: RecentDocsService, + private readonly propertiesAdapter: WorkspacePropertiesAdapter + ) { + super(); + } + + query$ = new LiveData(''); + + items$: LiveData[]> = + LiveData.computed(get => { + const query = get(this.query$); + + if (query) { + return []; /* recent docs only for empty query */ + } + + const docRecords = this.recentDocsService.getRecentDocs(); + + return docRecords.map>( + docRecord => { + const icon = this.propertiesAdapter.getJournalPageDateString( + docRecord.id + ) /* is journal */ + ? TodayIcon + : docRecord.mode$.value === 'edgeless' + ? EdgelessIcon + : PageIcon; + + return { + id: 'recent-doc:' + docRecord.id, + source: 'recent-doc', + group: group, + label: { + title: docRecord.meta$.value.title || { key: 'Untitled' }, + }, + score: 0, + icon, + timestamp: docRecord.meta$.value.updatedDate, + payload: { docId: docRecord.id }, + }; + } + ); + }); + + query(query: string) { + this.query$.next(query); + } +} diff --git a/packages/frontend/core/src/modules/quicksearch/index.ts b/packages/frontend/core/src/modules/quicksearch/index.ts new file mode 100644 index 0000000000000..25db6bec26ea4 --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/index.ts @@ -0,0 +1,58 @@ +import { + DocsService, + type Framework, + GlobalContextService, + WorkspaceLocalState, + WorkspaceScope, + WorkspaceService, +} from '@toeverything/infra'; + +import { CollectionService } from '../collection'; +import { DocsSearchService } from '../docs-search'; +import { WorkspacePropertiesAdapter } from '../properties'; +import { WorkbenchService } from '../workbench'; +import { QuickSearch } from './entities/quick-search'; +import { CollectionsQuickSearchSession } from './impls/collections'; +import { CommandsQuickSearchSession } from './impls/commands'; +import { CreationQuickSearchSession } from './impls/creation'; +import { DocsQuickSearchSession } from './impls/docs'; +import { RecentDocsQuickSearchSession } from './impls/recent-docs'; +import { CMDKQuickSearchService } from './services/cmdk'; +import { QuickSearchService } from './services/quick-search'; +import { RecentDocsService } from './services/recent-pages'; + +export { QuickSearch } from './entities/quick-search'; +export { QuickSearchService, RecentDocsService }; +export { CollectionsQuickSearchSession } from './impls/collections'; +export { CommandsQuickSearchSession } from './impls/commands'; +export { CreationQuickSearchSession } from './impls/creation'; +export { DocsQuickSearchSession } from './impls/docs'; +export { RecentDocsQuickSearchSession } from './impls/recent-docs'; +export type { QuickSearchItem } from './types/item'; +export { QuickSearchContainer } from './views/container'; + +export function configureQuickSearchModule(framework: Framework) { + framework + .scope(WorkspaceScope) + .service(QuickSearchService) + .service(CMDKQuickSearchService, [ + QuickSearchService, + WorkbenchService, + DocsService, + ]) + .service(RecentDocsService, [WorkspaceLocalState, DocsService]) + .entity(QuickSearch) + .entity(CommandsQuickSearchSession, [GlobalContextService]) + .entity(DocsQuickSearchSession, [ + WorkspaceService, + DocsSearchService, + DocsService, + WorkspacePropertiesAdapter, + ]) + .entity(CreationQuickSearchSession) + .entity(CollectionsQuickSearchSession, [CollectionService]) + .entity(RecentDocsQuickSearchSession, [ + RecentDocsService, + WorkspacePropertiesAdapter, + ]); +} diff --git a/packages/frontend/core/src/modules/quicksearch/providers/quick-search-provider.ts b/packages/frontend/core/src/modules/quicksearch/providers/quick-search-provider.ts new file mode 100644 index 0000000000000..d17a477b6903b --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/providers/quick-search-provider.ts @@ -0,0 +1,28 @@ +import { type LiveData } from '@toeverything/infra'; + +import type { QuickSearchItem } from '../types/item'; + +export type QuickSearchFunction = ( + query: string +) => QuickSearchItem[]; + +export interface QuickSearchSession { + items$: LiveData[]>; + isError$?: LiveData; + isLoading$?: LiveData; + loadingProgress$?: LiveData; + hasMore$?: LiveData; + + query?: (query: string) => void; + loadMore?: () => void; + dispose?: () => void; +} + +export type QuickSearchSource = + | QuickSearchFunction + | QuickSearchSession; + +export type QuickSearchSourceItemType = + Source extends QuickSearchSource + ? QuickSearchItem + : never; diff --git a/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts b/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts new file mode 100644 index 0000000000000..548ed5875fc75 --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts @@ -0,0 +1,82 @@ +import type { DocsService } from '@toeverything/infra'; +import { Service } from '@toeverything/infra'; + +import type { WorkbenchService } from '../../workbench'; +import { CollectionsQuickSearchSession } from '../impls/collections'; +import { CommandsQuickSearchSession } from '../impls/commands'; +import { CreationQuickSearchSession } from '../impls/creation'; +import { DocsQuickSearchSession } from '../impls/docs'; +import { RecentDocsQuickSearchSession } from '../impls/recent-docs'; +import type { QuickSearchService } from './quick-search'; + +export class CMDKQuickSearchService extends Service { + constructor( + private readonly quickSearchService: QuickSearchService, + private readonly workbenchService: WorkbenchService, + private readonly docsService: DocsService + ) { + super(); + } + + toggle() { + if (this.quickSearchService.quickSearch.show$.value) { + this.quickSearchService.quickSearch.hide(); + } else { + this.quickSearchService.quickSearch.show( + [ + this.framework.createEntity(RecentDocsQuickSearchSession), + this.framework.createEntity(CollectionsQuickSearchSession), + this.framework.createEntity(CommandsQuickSearchSession), + this.framework.createEntity(CreationQuickSearchSession), + this.framework.createEntity(DocsQuickSearchSession), + ], + result => { + if (!result) { + return; + } + if (result.source === 'commands') { + result.payload.run()?.catch(err => { + console.error(err); + }); + } else if ( + result.source === 'recent-doc' || + result.source === 'docs' + ) { + const doc: { + docId: string; + blockId?: string; + } = result.payload; + + this.workbenchService.workbench.openDoc({ + docId: doc.docId, + blockId: doc.blockId, + }); + } else if (result.source === 'collections') { + this.workbenchService.workbench.openCollection( + result.payload.collectionId + ); + } else if (result.source === 'creation') { + if (result.id === 'creation:create-page') { + const newDoc = this.docsService.createDoc({ + mode: 'page', + title: result.payload.title, + }); + this.workbenchService.workbench.openDoc(newDoc.id); + } else if (result.id === 'creation:create-edgeless') { + const newDoc = this.docsService.createDoc({ + mode: 'edgeless', + title: result.payload.title, + }); + this.workbenchService.workbench.openDoc(newDoc.id); + } + } + }, + { + placeholder: { + key: 'com.affine.cmdk.docs.placeholder', + }, + } + ); + } + } +} diff --git a/packages/frontend/core/src/modules/cmdk/services/quick-search.ts b/packages/frontend/core/src/modules/quicksearch/services/quick-search.ts similarity index 100% rename from packages/frontend/core/src/modules/cmdk/services/quick-search.ts rename to packages/frontend/core/src/modules/quicksearch/services/quick-search.ts diff --git a/packages/frontend/core/src/modules/cmdk/services/recent-pages.ts b/packages/frontend/core/src/modules/quicksearch/services/recent-pages.ts similarity index 95% rename from packages/frontend/core/src/modules/cmdk/services/recent-pages.ts rename to packages/frontend/core/src/modules/quicksearch/services/recent-pages.ts index 27bd9c5362720..1712e3a36bc1b 100644 --- a/packages/frontend/core/src/modules/cmdk/services/recent-pages.ts +++ b/packages/frontend/core/src/modules/quicksearch/services/recent-pages.ts @@ -10,7 +10,7 @@ const RECENT_PAGES_KEY = 'recent-pages'; const EMPTY_ARRAY: string[] = []; -export class RecentPagesService extends Service { +export class RecentDocsService extends Service { constructor( private readonly localState: WorkspaceLocalState, private readonly docsService: DocsService diff --git a/packages/frontend/core/src/modules/quicksearch/types/group.ts b/packages/frontend/core/src/modules/quicksearch/types/group.ts new file mode 100644 index 0000000000000..982a728f2ecb7 --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/types/group.ts @@ -0,0 +1,7 @@ +import type { I18nString } from '../../i18n'; + +export interface QuickSearchGroup { + id: string; + label: I18nString; + score?: number; +} diff --git a/packages/frontend/core/src/modules/quicksearch/types/item.ts b/packages/frontend/core/src/modules/quicksearch/types/item.ts new file mode 100644 index 0000000000000..12d82d75056f0 --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/types/item.ts @@ -0,0 +1,20 @@ +import type { I18nString } from '../../i18n'; +import type { QuickSearchGroup } from './group'; + +export type QuickSearchItem = { + id: string; + source: S; + label: + | I18nString + | { + title: I18nString; + subTitle?: I18nString; + }; + score: number; + icon?: React.ReactNode | React.ComponentType; + group?: QuickSearchGroup; + disabled?: boolean; + keyBinding?: string; + timestamp?: number; + payload?: P; +} & (P extends NonNullable ? { payload: P } : unknown); diff --git a/packages/frontend/core/src/modules/quicksearch/types/options.ts b/packages/frontend/core/src/modules/quicksearch/types/options.ts new file mode 100644 index 0000000000000..da0227172ca7a --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/types/options.ts @@ -0,0 +1,7 @@ +import type { I18nString } from '../../i18n'; + +export interface QuickSearchOptions { + label?: I18nString; + placeholder?: I18nString; + defaultQuery?: string; +} diff --git a/packages/frontend/core/src/modules/quicksearch/utils/highlighter.ts b/packages/frontend/core/src/modules/quicksearch/utils/highlighter.ts new file mode 100644 index 0000000000000..5e31df3ebefeb --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/utils/highlighter.ts @@ -0,0 +1,80 @@ +export function highlighter( + originText: string, + before: string, + after: string, + matches: [number, number][], + { + maxLength = 50, + maxPrefix = 20, + }: { maxLength?: number; maxPrefix?: number } = {} +) { + if (!originText) { + return; + } + const merged = mergeRanges(matches); + + if (merged.length === 0) { + return null; + } + + const firstMatch = merged[0][0]; + const start = Math.max( + 0, + Math.min(firstMatch - maxPrefix, originText.length - maxLength) + ); + const end = Math.min(start + maxLength, originText.length); + const text = originText.substring(start, end); + + let result = ''; + + let pointer = 0; + for (const match of merged) { + const matchStart = match[0] - start; + const matchEnd = match[1] - start; + if (matchStart >= text.length) { + break; + } + result += text.substring(pointer, matchStart); + pointer = matchStart; + const highlighted = text.substring(matchStart, matchEnd); + + if (highlighted.length === 0) { + continue; + } + + result += `${before}${highlighted}${after}`; + pointer = matchEnd; + } + result += text.substring(pointer); + + if (start > 0) { + result = `...${result}`; + } + + if (end < originText.length) { + result = `${result}...`; + } + + return result; +} + +function mergeRanges(intervals: [number, number][]) { + if (intervals.length === 0) return []; + + intervals.sort((a, b) => a[0] - b[0]); + + const merged = [intervals[0]]; + + for (let i = 1; i < intervals.length; i++) { + const last = merged[merged.length - 1]; + const current = intervals[i]; + + if (current[0] <= last[1]) { + last[1] = Math.max(last[1], current[1]); + } else { + merged.push(current); + } + } + + return merged; +} diff --git a/packages/frontend/core/src/modules/cmdk/views/main.css.ts b/packages/frontend/core/src/modules/quicksearch/views/cmdk.css.ts similarity index 93% rename from packages/frontend/core/src/modules/cmdk/views/main.css.ts rename to packages/frontend/core/src/modules/quicksearch/views/cmdk.css.ts index eec19d0a2e804..54b3b6b9a0c6b 100644 --- a/packages/frontend/core/src/modules/cmdk/views/main.css.ts +++ b/packages/frontend/core/src/modules/quicksearch/views/cmdk.css.ts @@ -1,61 +1,8 @@ import { cssVar } from '@toeverything/theme'; import { globalStyle, style } from '@vanilla-extract/css'; -export const root = style({}); -export const commandsContainer = style({ - height: 'calc(100% - 65px)', - padding: '8px 6px 18px 6px', -}); -export const searchInputContainer = style({ - height: 66, - padding: '18px 16px', - marginBottom: '8px', - width: '100%', - display: 'flex', - alignItems: 'center', - gap: 12, - borderBottom: `1px solid ${cssVar('borderColor')}`, - flexShrink: 0, -}); -export const hasInputLabel = style([ - searchInputContainer, - { - paddingTop: '12px', - paddingBottom: '18px', - }, -]); +export const root = style({}); -export const searchInput = style({ - color: cssVar('textPrimaryColor'), - fontSize: cssVar('fontH5'), - width: '100%', - '::placeholder': { - color: cssVar('textSecondaryColor'), - }, -}); -export const pageTitleWrapper = style({ - display: 'flex', - alignItems: 'center', - padding: '18px 16px 0', - width: '100%', -}); -export const pageTitle = style({ - padding: '2px 6px', - borderRadius: 4, - fontSize: cssVar('fontXs'), - lineHeight: '20px', - color: cssVar('textSecondaryColor'), - overflow: 'hidden', - textOverflow: 'ellipsis', - whiteSpace: 'nowrap', - maxWidth: '100%', - backgroundColor: cssVar('backgroundSecondaryColor'), -}); -export const panelContainer = style({ - height: '100%', - display: 'flex', - flexDirection: 'column', -}); export const itemIcon = style({ fontSize: 20, marginRight: 16, @@ -64,6 +11,7 @@ export const itemIcon = style({ alignItems: 'center', color: cssVar('iconSecondary'), }); + export const itemLabel = style({ fontSize: 14, lineHeight: '1.5', @@ -73,30 +21,7 @@ export const itemLabel = style({ textOverflow: 'ellipsis', whiteSpace: 'nowrap', }); -export const timestamp = style({ - display: 'flex', - fontSize: cssVar('fontXs'), - color: cssVar('textSecondaryColor'), - minWidth: 120, - flexDirection: 'row-reverse', -}); -export const keybinding = style({ - display: 'flex', - fontSize: cssVar('fontXs'), - columnGap: 2, -}); -export const keybindingFragment = style({ - display: 'flex', - alignItems: 'center', - justifyContent: 'center', - padding: '0 4px', - borderRadius: 4, - color: cssVar('textSecondaryColor'), - backgroundColor: cssVar('backgroundTertiaryColor'), - minWidth: 24, - height: 20, - textTransform: 'uppercase', -}); + globalStyle(`${root} [cmdk-root]`, { height: '100%', }); @@ -170,10 +95,100 @@ globalStyle( color: cssVar('errorColor'), } ); -export const resultGroupHeader = style({ - padding: '8px', + +export const panelContainer = style({ + height: '100%', + display: 'flex', + flexDirection: 'column', +}); + +export const pageTitleWrapper = style({ + display: 'flex', + alignItems: 'center', + padding: '18px 16px 0', + width: '100%', +}); + +export const pageTitle = style({ + padding: '2px 6px', + borderRadius: 4, + fontSize: cssVar('fontXs'), + lineHeight: '20px', color: cssVar('textSecondaryColor'), + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + maxWidth: '100%', + backgroundColor: cssVar('backgroundSecondaryColor'), +}); + +export const searchInputContainer = style({ + height: 66, + padding: '18px 16px', + marginBottom: '8px', + width: '100%', + display: 'flex', + alignItems: 'center', + gap: 12, + borderBottom: `1px solid ${cssVar('borderColor')}`, + flexShrink: 0, +}); + +export const hasInputLabel = style([ + searchInputContainer, + { + paddingTop: '12px', + paddingBottom: '18px', + }, +]); + +export const searchInput = style({ + color: cssVar('textPrimaryColor'), + fontSize: cssVar('fontH5'), + width: '100%', + '::placeholder': { + color: cssVar('textSecondaryColor'), + }, +}); + +export const timestamp = style({ + display: 'flex', fontSize: cssVar('fontXs'), - fontWeight: 600, - lineHeight: '1.67', + color: cssVar('textSecondaryColor'), + minWidth: 120, + flexDirection: 'row-reverse', +}); + +export const keybinding = style({ + display: 'flex', + fontSize: cssVar('fontXs'), + columnGap: 2, +}); + +export const keybindingFragment = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + padding: '0 4px', + borderRadius: 4, + color: cssVar('textSecondaryColor'), + backgroundColor: cssVar('backgroundTertiaryColor'), + minWidth: 24, + height: 20, + textTransform: 'uppercase', +}); + +export const itemTitle = style({ + fontSize: cssVar('fontBase'), + lineHeight: '24px', + fontWeight: 400, + textAlign: 'justify', + overflow: 'hidden', + textOverflow: 'ellipsis', +}); +export const itemSubtitle = style({ + fontSize: cssVar('fontXs'), + lineHeight: '20px', + fontWeight: 400, + textAlign: 'justify', }); diff --git a/packages/frontend/core/src/modules/quicksearch/views/cmdk.tsx b/packages/frontend/core/src/modules/quicksearch/views/cmdk.tsx new file mode 100644 index 0000000000000..6ce9105a528f4 --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/views/cmdk.tsx @@ -0,0 +1,219 @@ +import { Loading } from '@affine/component/ui/loading'; +import { formatDate } from '@affine/core/components/page-list'; +import { useServices } from '@toeverything/infra'; +import clsx from 'clsx'; +import { Command } from 'cmdk'; +import { + type ReactNode, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from 'react'; + +import { I18nService, isI18nString } from '../../i18n'; +import type { QuickSearchGroup } from '../types/group'; +import type { QuickSearchItem } from '../types/item'; +import * as styles from './cmdk.css'; +import { HighlightText } from './highlight-text'; + +export const CMDK = ({ + className, + query, + groups = [], + inputLabel, + placeholder, + loading, + loadingProgress, + onQueryChange, + onSubmit, +}: React.PropsWithChildren<{ + className?: string; + query: string; + inputLabel?: ReactNode; + placeholder?: string; + loading?: boolean; + loadingProgress?: number; + groups?: { group?: QuickSearchGroup; items: QuickSearchItem[] }[]; + onSubmit?: (item: QuickSearchItem) => void; + onQueryChange?: (query: string) => void; +}>) => { + const [opening, setOpening] = useState(false); + + const inputRef = useRef(null); + + // fix list height animation on opening + useLayoutEffect(() => { + setOpening(true); + const timeout = setTimeout(() => { + setOpening(false); + inputRef.current?.focus(); + }, 150); + return () => { + clearTimeout(timeout); + }; + }, []); + + const listRef = useRef(null); + + useEffect(() => { + requestAnimationFrame(() => { + if (listRef.current) listRef.current.scrollTop = 0; + }); + }, [query]); + + return ( + + {inputLabel ? ( +
+ {inputLabel} +
+ ) : null} +
+ + {loading ? ( + + ) : null} +
+ + + {groups.map(({ group, items }) => { + return ( + + ); + })} + +
+ ); +}; + +export const CMDKGroup = ({ + group: { group, items }, + onSubmit, + query, +}: { + group: { group?: QuickSearchGroup; items: QuickSearchItem[] }; + onSubmit?: (item: QuickSearchItem) => void; + query: string; +}) => { + const { i18nService } = useServices({ I18nService }); + + return ( + + {items.map(item => { + const title = !isI18nString(item.label) + ? i18nService.t(item.label.title) + : i18nService.t(item.label); + const subTitle = !isI18nString(item.label) + ? item.label.subTitle && i18nService.t(item.label.subTitle) + : null; + return ( + onSubmit?.(item)} + value={item.id} + disabled={item.disabled} + data-is-danger={ + item.id === 'editor:page-move-to-trash' || + item.id === 'editor:edgeless-move-to-trash' + } + > +
+ {item.icon && + (typeof item.icon === 'function' ? : item.icon)} +
+
+
+ +
+ {subTitle && ( +
+ +
+ )} +
+ {item.timestamp ? ( +
+ {formatDate(new Date(item.timestamp))} +
+ ) : null} + {item.keyBinding ? ( + + ) : null} +
+ ); + })} +
+ ); +}; + +const CMDKKeyBinding = ({ keyBinding }: { keyBinding: string }) => { + const isMacOS = environment.isBrowser && environment.isMacOs; + const fragments = useMemo(() => { + return keyBinding.split('+').map(fragment => { + if (fragment === '$mod') { + return isMacOS ? '⌘' : 'Ctrl'; + } + if (fragment === 'ArrowUp') { + return '↑'; + } + if (fragment === 'ArrowDown') { + return '↓'; + } + if (fragment === 'ArrowLeft') { + return '←'; + } + if (fragment === 'ArrowRight') { + return '→'; + } + return fragment; + }); + }, [isMacOS, keyBinding]); + + return ( +
+ {fragments.map((fragment, index) => { + return ( +
+ {fragment} +
+ ); + })} +
+ ); +}; diff --git a/packages/frontend/core/src/modules/quicksearch/views/container.tsx b/packages/frontend/core/src/modules/quicksearch/views/container.tsx new file mode 100644 index 0000000000000..dc81b52107835 --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/views/container.tsx @@ -0,0 +1,91 @@ +import { useLiveData, useServices } from '@toeverything/infra'; +import { useCallback, useMemo } from 'react'; + +import { I18nService } from '../../i18n'; +import { QuickSearchService } from '../services/quick-search'; +import type { QuickSearchGroup } from '../types/group'; +import type { QuickSearchItem } from '../types/item'; +import { CMDK } from './cmdk'; +import { QuickSearchModal } from './modal'; + +export const QuickSearchContainer = () => { + const { quickSearchService, i18nService } = useServices({ + QuickSearchService, + I18nService, + }); + const quickSearch = quickSearchService.quickSearch; + const open = useLiveData(quickSearch.show$); + const query = useLiveData(quickSearch.query$); + const loading = useLiveData(quickSearch.isLoading$); + const loadingProgress = useLiveData(quickSearch.loadingProgress$); + const items = useLiveData(quickSearch.items$); + const options = useLiveData(quickSearch.options$); + + const onToggleQuickSearch = useCallback( + (open: boolean) => { + if (open) { + // should never be here + } else { + quickSearch.hide(); + } + }, + [quickSearch] + ); + + const groups = useMemo(() => { + const groups: { group?: QuickSearchGroup; items: QuickSearchItem[] }[] = []; + + for (const item of items) { + const group = item.group; + const existingGroup = groups.find(g => g.group?.id === group?.id); + if (existingGroup) { + existingGroup.items.push(item); + } else { + groups.push({ group, items: [item] }); + } + } + + for (const { items } of groups) { + items.sort((a, b) => b.score - a.score); + } + + groups.sort((a, b) => { + const group = (b.group?.score ?? 0) - (a.group?.score ?? 0); + if (group !== 0) { + return group; + } + return b.items[0].score - a.items[0].score; + }); + + return groups; + }, [items]); + + const handleChangeQuery = useCallback( + (query: string) => { + quickSearch.setQuery(query); + }, + [quickSearch] + ); + + const handleSubmit = useCallback( + (item: QuickSearchItem) => { + quickSearch.submit(item); + }, + [quickSearch] + ); + + return ( + + + + ); +}; diff --git a/packages/frontend/core/src/modules/cmdk/views/highlight.css.ts b/packages/frontend/core/src/modules/quicksearch/views/highlight-text.css.ts similarity index 52% rename from packages/frontend/core/src/modules/cmdk/views/highlight.css.ts rename to packages/frontend/core/src/modules/quicksearch/views/highlight-text.css.ts index 5d084367c637e..3cba1f298d914 100644 --- a/packages/frontend/core/src/modules/cmdk/views/highlight.css.ts +++ b/packages/frontend/core/src/modules/quicksearch/views/highlight-text.css.ts @@ -1,9 +1,6 @@ import { cssVar } from '@toeverything/theme'; import { style } from '@vanilla-extract/css'; -export const highlightContainer = style({ - display: 'flex', - flexWrap: 'nowrap', -}); + export const highlightText = style({ whiteSpace: 'pre', overflow: 'hidden', @@ -19,17 +16,3 @@ export const highlightKeyword = style({ flexShrink: 0, maxWidth: '360px', }); -export const labelTitle = style({ - fontSize: cssVar('fontBase'), - lineHeight: '24px', - fontWeight: 400, - textAlign: 'justify', - overflow: 'hidden', - textOverflow: 'ellipsis', -}); -export const labelContent = style({ - fontSize: cssVar('fontXs'), - lineHeight: '20px', - fontWeight: 400, - textAlign: 'justify', -}); diff --git a/packages/frontend/core/src/modules/quicksearch/views/highlight-text.tsx b/packages/frontend/core/src/modules/quicksearch/views/highlight-text.tsx new file mode 100644 index 0000000000000..24ec737753876 --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/views/highlight-text.tsx @@ -0,0 +1,44 @@ +import { Fragment, useMemo } from 'react'; + +import * as styles from './highlight-text.css'; + +type HighlightProps = { + text: string; + start: string; + end: string; +}; + +export const HighlightText = ({ text = '', end, start }: HighlightProps) => { + const parts = useMemo( + () => + text.split(start).flatMap(part => { + if (part.includes(end)) { + const [highlighted, ...ending] = part.split(end); + + return [ + { + h: highlighted, + }, + ending.join(), + ]; + } else { + return part; + } + }), + [end, start, text] + ); + + return ( + + {parts.map((part, i) => + typeof part === 'string' ? ( + {part} + ) : ( + + {part.h} + + ) + )} + + ); +}; diff --git a/packages/frontend/core/src/modules/cmdk/views/modal.css.ts b/packages/frontend/core/src/modules/quicksearch/views/modal.css.ts similarity index 100% rename from packages/frontend/core/src/modules/cmdk/views/modal.css.ts rename to packages/frontend/core/src/modules/quicksearch/views/modal.css.ts diff --git a/packages/frontend/core/src/modules/cmdk/views/modal.tsx b/packages/frontend/core/src/modules/quicksearch/views/modal.tsx similarity index 85% rename from packages/frontend/core/src/modules/cmdk/views/modal.tsx rename to packages/frontend/core/src/modules/quicksearch/views/modal.tsx index cd033b05d042a..fba3fea3fcfaf 100644 --- a/packages/frontend/core/src/modules/cmdk/views/modal.tsx +++ b/packages/frontend/core/src/modules/quicksearch/views/modal.tsx @@ -5,21 +5,21 @@ import { useTransition } from 'react-transition-state'; import * as styles from './modal.css'; -// a CMDK modal that can be used to display a CMDK command +// a QuickSearch modal that can be used to display a QuickSearch command // it has a smooth animation and can be closed by clicking outside of the modal -export interface CMDKModalProps { +export interface QuickSearchModalProps { open: boolean; onOpenChange?: (open: boolean) => void; } const animationTimeout = 120; -export const CMDKModal = ({ +export const QuickSearchModal = ({ onOpenChange, open, children, -}: React.PropsWithChildren) => { +}: React.PropsWithChildren) => { const [{ status }, toggle] = useTransition({ timeout: animationTimeout, }); diff --git a/packages/frontend/core/src/modules/workbench/entities/workbench.ts b/packages/frontend/core/src/modules/workbench/entities/workbench.ts index e51a2a3176664..9799e19d3aeaa 100644 --- a/packages/frontend/core/src/modules/workbench/entities/workbench.ts +++ b/packages/frontend/core/src/modules/workbench/entities/workbench.ts @@ -71,8 +71,13 @@ export class Workbench extends Entity { } } - openPage(pageId: string, options?: WorkbenchOpenOptions) { - this.open(`/${pageId}`, options); + openDoc( + id: string | { docId: string; blockId?: string }, + options?: WorkbenchOpenOptions + ) { + const docId = typeof id === 'string' ? id : id.docId; + const blockId = typeof id === 'string' ? undefined : id.blockId; + this.open(blockId ? `/${docId}#${blockId}` : `/${docId}`, options); } openCollections(options?: WorkbenchOpenOptions) { diff --git a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx index 47935e34c61bd..066386dbde908 100644 --- a/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx +++ b/packages/frontend/core/src/pages/workspace/detail-page/detail-page.tsx @@ -2,7 +2,7 @@ import { Scrollable } from '@affine/component'; import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton'; import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding'; import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper'; -import { RecentPagesService } from '@affine/core/modules/cmdk'; +import { RecentDocsService } from '@affine/core/modules/quicksearch'; import type { PageRootService } from '@blocksuite/blocks'; import { BookmarkBlockService, @@ -368,7 +368,7 @@ export const Component = () => { performanceRenderLogger.info('DetailPage'); const params = useParams(); - const recentPages = useService(RecentPagesService); + const recentPages = useService(RecentDocsService); useEffect(() => { if (params.pageId) { diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index c2a7383ee740f..b32af9678a025 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -536,6 +536,8 @@ "com.affine.cloudTempDisable.title": "AFFiNE Cloud is upgrading now.", "com.affine.cmdk.affine.category.affine.collections": "Collections", "com.affine.cmdk.affine.category.affine.creation": "Create", + "com.affine.quicksearch.group.creation": "New", + "com.affine.quicksearch.group.searchfor": "Search for \"{{query}}\"", "com.affine.cmdk.affine.category.affine.edgeless": "Edgeless", "com.affine.cmdk.affine.category.affine.general": "General", "com.affine.cmdk.affine.category.affine.help": "Help", @@ -553,9 +555,9 @@ "com.affine.cmdk.affine.color-mode.to": "Change Colour Mode to", "com.affine.cmdk.affine.color-scheme.to": "Change Colour Scheme to", "com.affine.cmdk.affine.contact-us": "Contact Us", - "com.affine.cmdk.affine.create-new-edgeless-as": "New \"<1>{{keyWord}}\" Edgeless", - "com.affine.cmdk.affine.create-new-page-as": "New \"<1>{{keyWord}}\" Page", - "com.affine.cmdk.affine.create-new-doc-and-insert": "Create \"<1>{{keyWord}}\" Doc and insert", + "com.affine.cmdk.affine.create-new-edgeless-as": "New \"{{keyWord}}\" Edgeless", + "com.affine.cmdk.affine.create-new-page-as": "New \"{{keyWord}}\" Page", + "com.affine.cmdk.affine.create-new-doc-and-insert": "Create \"{{keyWord}}\" Doc and insert", "com.affine.cmdk.affine.insert-link": "Insert this link to the current doc", "com.affine.cmdk.affine.display-language.to": "Change Display Language to", "com.affine.cmdk.affine.editor.add-to-favourites": "Add to Favourites", diff --git a/yarn.lock b/yarn.lock index ef113c95cb0a6..7fe7ddec4dd3a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -371,6 +371,7 @@ __metadata: fake-indexeddb: "npm:^6.0.0" foxact: "npm:^0.2.33" fractional-indexing: "npm:^3.2.0" + fuse.js: "npm:^7.0.0" graphql: "npm:^16.8.1" history: "npm:^5.3.0" idb: "npm:^8.0.0"