mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-08-01 17:39:55 +08:00
refactor(core): new quick search service (#7214)
This commit is contained in:
@@ -6,5 +6,6 @@ export * from './error';
|
|||||||
export { createEvent, OnEvent } from './event';
|
export { createEvent, OnEvent } from './event';
|
||||||
export { Framework } from './framework';
|
export { Framework } from './framework';
|
||||||
export { createIdentifier } from './identifier';
|
export { createIdentifier } from './identifier';
|
||||||
export type { FrameworkProvider, ResolveOptions } from './provider';
|
export type { ResolveOptions } from './provider';
|
||||||
|
export { FrameworkProvider } from './provider';
|
||||||
export type { GeneralIdentifier } from './types';
|
export type { GeneralIdentifier } from './types';
|
||||||
|
|||||||
@@ -9,6 +9,12 @@ export const FrameworkStackContext = React.createContext<FrameworkProvider[]>([
|
|||||||
Framework.EMPTY.provider(),
|
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<T extends Service>(
|
export function useService<T extends Service>(
|
||||||
identifier: GeneralIdentifier<T>
|
identifier: GeneralIdentifier<T>
|
||||||
): T {
|
): T {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export type DocMode = 'edgeless' | 'page';
|
|||||||
*/
|
*/
|
||||||
export class DocRecord extends Entity<{ id: string }> {
|
export class DocRecord extends Entity<{ id: string }> {
|
||||||
id: string = this.props.id;
|
id: string = this.props.id;
|
||||||
meta: Partial<DocMeta> | null = null;
|
|
||||||
constructor(private readonly docsStore: DocsStore) {
|
constructor(private readonly docsStore: DocsStore) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import { Unreachable } from '@affine/env/constant';
|
||||||
|
|
||||||
import { Service } from '../../../framework';
|
import { Service } from '../../../framework';
|
||||||
|
import { initEmptyPage } from '../../../initialization';
|
||||||
import { ObjectPool } from '../../../utils';
|
import { ObjectPool } from '../../../utils';
|
||||||
import type { Doc } from '../entities/doc';
|
import type { Doc } from '../entities/doc';
|
||||||
|
import type { DocMode } from '../entities/record';
|
||||||
import { DocRecordList } from '../entities/record-list';
|
import { DocRecordList } from '../entities/record-list';
|
||||||
import { DocScope } from '../scopes/doc';
|
import { DocScope } from '../scopes/doc';
|
||||||
import type { DocsStore } from '../stores/docs';
|
import type { DocsStore } from '../stores/docs';
|
||||||
@@ -46,4 +50,22 @@ export class DocsService extends Service {
|
|||||||
|
|
||||||
return { doc: obj, release };
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export class DocsStore extends Store {
|
|||||||
return this.workspaceService.workspace.docCollection.getDoc(id);
|
return this.workspaceService.workspace.docCollection.getDoc(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createBlockSuiteDoc() {
|
||||||
|
return this.workspaceService.workspace.docCollection.createDoc();
|
||||||
|
}
|
||||||
|
|
||||||
watchDocIds() {
|
watchDocIds() {
|
||||||
return new Observable<string[]>(subscriber => {
|
return new Observable<string[]>(subscriber => {
|
||||||
const emit = () => {
|
const emit = () => {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"foxact": "^0.2.33",
|
"foxact": "^0.2.33",
|
||||||
"fractional-indexing": "^3.2.0",
|
"fractional-indexing": "^3.2.0",
|
||||||
|
"fuse.js": "^7.0.0",
|
||||||
"graphql": "^16.8.1",
|
"graphql": "^16.8.1",
|
||||||
"history": "^5.3.0",
|
"history": "^5.3.0",
|
||||||
"idb": "^8.0.0",
|
"idb": "^8.0.0",
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
useLitPortalFactory,
|
useLitPortalFactory,
|
||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
|
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
|
||||||
import { QuickSearchService } from '@affine/core/modules/cmdk';
|
|
||||||
import { PeekViewService } from '@affine/core/modules/peek-view';
|
import { PeekViewService } from '@affine/core/modules/peek-view';
|
||||||
import { WorkbenchService } from '@affine/core/modules/workbench';
|
import { WorkbenchService } from '@affine/core/modules/workbench';
|
||||||
import {
|
import {
|
||||||
@@ -19,6 +18,7 @@ import {
|
|||||||
type DocMode,
|
type DocMode,
|
||||||
DocService,
|
DocService,
|
||||||
DocsService,
|
DocsService,
|
||||||
|
useFramework,
|
||||||
useLiveData,
|
useLiveData,
|
||||||
useService,
|
useService,
|
||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
@@ -79,9 +79,9 @@ interface BlocksuiteEditorProps {
|
|||||||
const usePatchSpecs = (page: Doc, shared: boolean, mode: DocMode) => {
|
const usePatchSpecs = (page: Doc, shared: boolean, mode: DocMode) => {
|
||||||
const [reactToLit, portals] = useLitPortalFactory();
|
const [reactToLit, portals] = useLitPortalFactory();
|
||||||
const peekViewService = useService(PeekViewService);
|
const peekViewService = useService(PeekViewService);
|
||||||
const quickSearchService = useService(QuickSearchService);
|
|
||||||
const docService = useService(DocService);
|
const docService = useService(DocService);
|
||||||
const docsService = useService(DocsService);
|
const docsService = useService(DocsService);
|
||||||
|
const framework = useFramework();
|
||||||
const referenceRenderer: ReferenceReactRenderer = useMemo(() => {
|
const referenceRenderer: ReferenceReactRenderer = useMemo(() => {
|
||||||
return function customReference(reference) {
|
return function customReference(reference) {
|
||||||
const pageId = reference.delta.attributes?.reference?.pageId;
|
const pageId = reference.delta.attributes?.reference?.pageId;
|
||||||
@@ -105,7 +105,7 @@ const usePatchSpecs = (page: Doc, shared: boolean, mode: DocMode) => {
|
|||||||
patched = patchPeekViewService(patched, peekViewService);
|
patched = patchPeekViewService(patched, peekViewService);
|
||||||
}
|
}
|
||||||
if (!page.readonly) {
|
if (!page.readonly) {
|
||||||
patched = patchQuickSearchService(patched, quickSearchService);
|
patched = patchQuickSearchService(patched, framework);
|
||||||
}
|
}
|
||||||
if (shared) {
|
if (shared) {
|
||||||
patched = patchForSharedPage(patched);
|
patched = patchForSharedPage(patched);
|
||||||
@@ -116,9 +116,9 @@ const usePatchSpecs = (page: Doc, shared: boolean, mode: DocMode) => {
|
|||||||
confirmModal,
|
confirmModal,
|
||||||
docService,
|
docService,
|
||||||
docsService,
|
docsService,
|
||||||
|
framework,
|
||||||
page.readonly,
|
page.readonly,
|
||||||
peekViewService,
|
peekViewService,
|
||||||
quickSearchService,
|
|
||||||
reactToLit,
|
reactToLit,
|
||||||
referenceRenderer,
|
referenceRenderer,
|
||||||
shared,
|
shared,
|
||||||
|
|||||||
+91
-26
@@ -7,12 +7,17 @@ import {
|
|||||||
toReactNode,
|
toReactNode,
|
||||||
type useConfirmModal,
|
type useConfirmModal,
|
||||||
} from '@affine/component';
|
} from '@affine/component';
|
||||||
import type {
|
import { DocsSearchService } from '@affine/core/modules/docs-search';
|
||||||
QuickSearchService,
|
import { resolveLinkToDoc } from '@affine/core/modules/navigation';
|
||||||
SearchCallbackResult,
|
|
||||||
} from '@affine/core/modules/cmdk';
|
|
||||||
import type { PeekViewService } from '@affine/core/modules/peek-view';
|
import type { PeekViewService } from '@affine/core/modules/peek-view';
|
||||||
import type { ActivePeekView } from '@affine/core/modules/peek-view/entities/peek-view';
|
import type { ActivePeekView } from '@affine/core/modules/peek-view/entities/peek-view';
|
||||||
|
import {
|
||||||
|
CreationQuickSearchSession,
|
||||||
|
DocsQuickSearchSession,
|
||||||
|
type QuickSearchItem,
|
||||||
|
QuickSearchService,
|
||||||
|
RecentDocsQuickSearchSession,
|
||||||
|
} from '@affine/core/modules/quicksearch';
|
||||||
import { mixpanel } from '@affine/core/utils';
|
import { mixpanel } from '@affine/core/utils';
|
||||||
import { DebugLogger } from '@affine/debug';
|
import { DebugLogger } from '@affine/debug';
|
||||||
import type { BlockSpec, WidgetElement } from '@blocksuite/block-std';
|
import type { BlockSpec, WidgetElement } from '@blocksuite/block-std';
|
||||||
@@ -23,7 +28,13 @@ import {
|
|||||||
type ParagraphBlockService,
|
type ParagraphBlockService,
|
||||||
type RootService,
|
type RootService,
|
||||||
} from '@blocksuite/blocks';
|
} from '@blocksuite/blocks';
|
||||||
import type { DocMode, DocService, DocsService } from '@toeverything/infra';
|
import { LinkIcon } from '@blocksuite/icons/rc';
|
||||||
|
import type {
|
||||||
|
DocMode,
|
||||||
|
DocService,
|
||||||
|
DocsService,
|
||||||
|
FrameworkProvider,
|
||||||
|
} from '@toeverything/infra';
|
||||||
import { type TemplateResult } from 'lit';
|
import { type TemplateResult } from 'lit';
|
||||||
import { customElement } from 'lit/decorators.js';
|
import { customElement } from 'lit/decorators.js';
|
||||||
import { literal } from 'lit/static-html.js';
|
import { literal } from 'lit/static-html.js';
|
||||||
@@ -302,7 +313,7 @@ export function patchDocModeService(
|
|||||||
|
|
||||||
export function patchQuickSearchService(
|
export function patchQuickSearchService(
|
||||||
specs: BlockSpec[],
|
specs: BlockSpec[],
|
||||||
service: QuickSearchService
|
framework: FrameworkProvider
|
||||||
) {
|
) {
|
||||||
const rootSpec = specs.find(
|
const rootSpec = specs.find(
|
||||||
spec => spec.schema.model.flavour === 'affine:page'
|
spec => spec.schema.model.flavour === 'affine:page'
|
||||||
@@ -317,39 +328,93 @@ export function patchQuickSearchService(
|
|||||||
pageService => {
|
pageService => {
|
||||||
pageService.quickSearchService = {
|
pageService.quickSearchService = {
|
||||||
async searchDoc(options) {
|
async searchDoc(options) {
|
||||||
let searchResult: SearchCallbackResult | null = null;
|
let searchResult:
|
||||||
|
| { docId: string; isNewDoc?: boolean }
|
||||||
|
| { userInput: string }
|
||||||
|
| null = null;
|
||||||
if (options.skipSelection) {
|
if (options.skipSelection) {
|
||||||
const query = options.userInput;
|
const query = options.userInput;
|
||||||
if (!query) {
|
if (!query) {
|
||||||
logger.error('No user input provided');
|
logger.error('No user input provided');
|
||||||
} else {
|
} else {
|
||||||
const searchedDoc = service.quickSearch
|
const searchedDoc = (
|
||||||
.getSearchedDocs(query)
|
await framework.get(DocsSearchService).search(query)
|
||||||
.at(0);
|
).at(0);
|
||||||
if (searchedDoc) {
|
if (searchedDoc) {
|
||||||
searchResult = {
|
searchResult = {
|
||||||
docId: searchedDoc.doc.id,
|
docId: searchedDoc.docId,
|
||||||
blockId: searchedDoc.blockId,
|
|
||||||
action: 'insert',
|
|
||||||
query,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
searchResult = await service.quickSearch.search(options.userInput);
|
searchResult = await new Promise(resolve =>
|
||||||
|
framework.get(QuickSearchService).quickSearch.show(
|
||||||
|
[
|
||||||
|
framework.get(RecentDocsQuickSearchSession),
|
||||||
|
framework.get(DocsQuickSearchSession),
|
||||||
|
framework.get(CreationQuickSearchSession),
|
||||||
|
(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,
|
||||||
|
});
|
||||||
|
} else if (
|
||||||
|
result.source === 'creation' &&
|
||||||
|
result.id === 'creation:create-page'
|
||||||
|
) {
|
||||||
|
throw new Error('Not implemented');
|
||||||
|
// resolve({
|
||||||
|
// docId: 'new-doc',
|
||||||
|
// isNewDoc: true,
|
||||||
|
// });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
defaultQuery: options.userInput,
|
||||||
|
label: {
|
||||||
|
key: 'com.affine.cmdk.insert-links',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
key: 'com.affine.cmdk.docs.placeholder',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (searchResult) {
|
return searchResult;
|
||||||
if ('docId' in searchResult) {
|
|
||||||
return searchResult;
|
|
||||||
} else {
|
|
||||||
return {
|
|
||||||
userInput: searchResult.query,
|
|
||||||
action: 'insert',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ export const PageOperationCell = ({
|
|||||||
}, [page.id, page.title, setTrashModal]);
|
}, [page.id, page.title, setTrashModal]);
|
||||||
|
|
||||||
const onOpenInSplitView = useCallback(() => {
|
const onOpenInSplitView = useCallback(() => {
|
||||||
workbench.openPage(page.id, { at: 'tail' });
|
workbench.openDoc(page.id, { at: 'tail' });
|
||||||
}, [page.id, workbench]);
|
}, [page.id, workbench]);
|
||||||
|
|
||||||
const onToggleFavoritePage = useCallback(() => {
|
const onToggleFavoritePage = useCallback(() => {
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@ export const OperationMenuButton = ({ ...props }: OperationMenuButtonProps) => {
|
|||||||
}, [pageId, removeFromAllowList]);
|
}, [pageId, removeFromAllowList]);
|
||||||
|
|
||||||
const handleOpenInSplitView = useCallback(() => {
|
const handleOpenInSplitView = useCallback(() => {
|
||||||
workbench.openPage(pageId, { at: 'tail' });
|
workbench.openDoc(pageId, { at: 'tail' });
|
||||||
}, [pageId, workbench]);
|
}, [pageId, workbench]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
registerAffineUpdatesCommands,
|
registerAffineUpdatesCommands,
|
||||||
} from '../commands';
|
} from '../commands';
|
||||||
import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
|
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 { useLanguageHelper } from './affine/use-language-helper';
|
||||||
import { useActiveBlocksuiteEditor } from './use-block-suite-editor';
|
import { useActiveBlocksuiteEditor } from './use-block-suite-editor';
|
||||||
import { useNavigateHelper } from './use-navigate-helper';
|
import { useNavigateHelper } from './use-navigate-helper';
|
||||||
@@ -33,7 +33,7 @@ function hasLinkPopover(editor: AffineEditorContainer | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function registerCMDKCommand(
|
function registerCMDKCommand(
|
||||||
qsService: QuickSearchService,
|
service: CMDKQuickSearchService,
|
||||||
editor: AffineEditorContainer | null
|
editor: AffineEditorContainer | null
|
||||||
) {
|
) {
|
||||||
return registerAffineCommand({
|
return registerAffineCommand({
|
||||||
@@ -52,7 +52,7 @@ function registerCMDKCommand(
|
|||||||
if (hasLinkPopover(editor)) {
|
if (hasLinkPopover(editor)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
qsService.quickSearch.toggle();
|
service.toggle();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -66,15 +66,15 @@ export function useRegisterWorkspaceCommands() {
|
|||||||
const pageHelper = usePageHelper(currentWorkspace.docCollection);
|
const pageHelper = usePageHelper(currentWorkspace.docCollection);
|
||||||
const navigationHelper = useNavigateHelper();
|
const navigationHelper = useNavigateHelper();
|
||||||
const [editor] = useActiveBlocksuiteEditor();
|
const [editor] = useActiveBlocksuiteEditor();
|
||||||
const quickSearch = useService(QuickSearchService);
|
const cmdkQuickSearchService = useService(CMDKQuickSearchService);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = registerCMDKCommand(quickSearch, editor);
|
const unsub = registerCMDKCommand(cmdkQuickSearchService, editor);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
unsub();
|
unsub();
|
||||||
};
|
};
|
||||||
}, [editor, quickSearch]);
|
}, [cmdkQuickSearchService, editor]);
|
||||||
|
|
||||||
// register AffineUpdatesCommands
|
// register AffineUpdatesCommands
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ import {
|
|||||||
DocsService,
|
DocsService,
|
||||||
effect,
|
effect,
|
||||||
fromPromise,
|
fromPromise,
|
||||||
GlobalContextService,
|
|
||||||
onStart,
|
onStart,
|
||||||
throwIfAborted,
|
throwIfAborted,
|
||||||
useLiveData,
|
useLiveData,
|
||||||
@@ -59,9 +58,9 @@ import {
|
|||||||
import { useRegisterFindInPageCommands } from '../hooks/affine/use-register-find-in-page-commands';
|
import { useRegisterFindInPageCommands } from '../hooks/affine/use-register-find-in-page-commands';
|
||||||
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
import { useNavigateHelper } from '../hooks/use-navigate-helper';
|
||||||
import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands';
|
import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands';
|
||||||
import { QuickSearchService } from '../modules/cmdk';
|
|
||||||
import { CMDKQuickSearchModal } from '../modules/cmdk/views';
|
|
||||||
import { useRegisterNavigationCommands } from '../modules/navigation/view/use-register-navigation-commands';
|
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 { WorkbenchService } from '../modules/workbench';
|
||||||
import {
|
import {
|
||||||
AllWorkspaceModals,
|
AllWorkspaceModals,
|
||||||
@@ -72,40 +71,6 @@ import { pathGenerator } from '../shared';
|
|||||||
import { mixpanel } from '../utils';
|
import { mixpanel } from '../utils';
|
||||||
import * as styles from './styles.css';
|
import * as styles from './styles.css';
|
||||||
|
|
||||||
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 (
|
|
||||||
<CMDKQuickSearchModal
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onToggleQuickSearch}
|
|
||||||
pageMeta={pageMeta}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WorkspaceLayout = function WorkspaceLayout({
|
export const WorkspaceLayout = function WorkspaceLayout({
|
||||||
children,
|
children,
|
||||||
}: PropsWithChildren) {
|
}: PropsWithChildren) {
|
||||||
@@ -159,7 +124,7 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
|
|||||||
timeout(10000 /* 10s */),
|
timeout(10000 /* 10s */),
|
||||||
mergeMap(({ mode, doc }) => {
|
mergeMap(({ mode, doc }) => {
|
||||||
docsList.setMode(doc.id, mode as DocMode);
|
docsList.setMode(doc.id, mode as DocMode);
|
||||||
workbench.openPage(doc.id);
|
workbench.openDoc(doc.id);
|
||||||
return EMPTY;
|
return EMPTY;
|
||||||
}),
|
}),
|
||||||
onStart(() => {
|
onStart(() => {
|
||||||
@@ -226,14 +191,14 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
|
|||||||
return pageHelper.createPage();
|
return pageHelper.createPage();
|
||||||
}, [pageHelper]);
|
}, [pageHelper]);
|
||||||
|
|
||||||
const quickSearch = useService(QuickSearchService).quickSearch;
|
const cmdkQuickSearchService = useService(CMDKQuickSearchService);
|
||||||
const handleOpenQuickSearchModal = useCallback(() => {
|
const handleOpenQuickSearchModal = useCallback(() => {
|
||||||
quickSearch.show();
|
cmdkQuickSearchService.toggle();
|
||||||
mixpanel.track('QuickSearchOpened', {
|
mixpanel.track('QuickSearchOpened', {
|
||||||
segment: 'navigation panel',
|
segment: 'navigation panel',
|
||||||
control: 'search button',
|
control: 'search button',
|
||||||
});
|
});
|
||||||
}, [quickSearch]);
|
}, [cmdkQuickSearchService]);
|
||||||
|
|
||||||
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
|
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);
|
||||||
|
|
||||||
@@ -297,7 +262,7 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
|
|||||||
</AppContainer>
|
</AppContainer>
|
||||||
<GlobalDragOverlay />
|
<GlobalDragOverlay />
|
||||||
</DndContext>
|
</DndContext>
|
||||||
<QuickSearch />
|
<QuickSearchContainer />
|
||||||
<SyncAwareness />
|
<SyncAwareness />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,147 +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;
|
|
||||||
isNewDoc?: boolean;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
query: string;
|
|
||||||
action: 'insert';
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO(@Peng): 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<SearchCallbackResult | null>();
|
|
||||||
|
|
||||||
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<string>();
|
|
||||||
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<typeof res> => !!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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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'],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
@@ -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 },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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<string, number>,
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,487 +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, useI18n } from '@affine/i18n';
|
|
||||||
import {
|
|
||||||
EdgelessIcon,
|
|
||||||
LinkIcon,
|
|
||||||
PageIcon,
|
|
||||||
TodayIcon,
|
|
||||||
ViewLayersIcon,
|
|
||||||
} from '@blocksuite/icons/rc';
|
|
||||||
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<typeof useGetDocCollectionPageTitle>,
|
|
||||||
isPageJournal: (pageId: string) => boolean,
|
|
||||||
t: ReturnType<typeof useI18n>,
|
|
||||||
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) ? (
|
|
||||||
<TodayIcon />
|
|
||||||
) : docMode === 'edgeless' ? (
|
|
||||||
<EdgelessIcon />
|
|
||||||
) : (
|
|
||||||
<PageIcon />
|
|
||||||
);
|
|
||||||
|
|
||||||
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 = useI18n();
|
|
||||||
|
|
||||||
const [searchTime, setSearchTime] = useState<number>(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 = useI18n();
|
|
||||||
|
|
||||||
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: <TodayIcon />,
|
|
||||||
});
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
id: 'affine:pages:create-page',
|
|
||||||
label: (
|
|
||||||
<Trans
|
|
||||||
i18nKey="com.affine.cmdk.affine.create-new-page-as"
|
|
||||||
values={{
|
|
||||||
keyWord: query,
|
|
||||||
}}
|
|
||||||
components={{
|
|
||||||
1: <span className={hlStyles.highlightKeyword} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
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: <PageIcon />,
|
|
||||||
});
|
|
||||||
|
|
||||||
results.push({
|
|
||||||
id: 'affine:pages:create-edgeless',
|
|
||||||
label: (
|
|
||||||
<Trans
|
|
||||||
i18nKey="com.affine.cmdk.affine.create-new-edgeless-as"
|
|
||||||
values={{
|
|
||||||
keyWord: query,
|
|
||||||
}}
|
|
||||||
components={{
|
|
||||||
1: <span className={hlStyles.highlightKeyword} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
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: <EdgelessIcon />,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}, [
|
|
||||||
searchedDocsCommands,
|
|
||||||
t,
|
|
||||||
query,
|
|
||||||
journalHelper,
|
|
||||||
navigationHelper,
|
|
||||||
pageHelper,
|
|
||||||
pageMetaHelper,
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO(@Peng): 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: <Trans i18nKey="com.affine.cmdk.affine.insert-link" />,
|
|
||||||
alwaysShow: true,
|
|
||||||
category: 'affine:creation',
|
|
||||||
run: async () => {
|
|
||||||
onSelectPage({
|
|
||||||
query,
|
|
||||||
action: 'insert',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
icon: <LinkIcon />,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
results.push({
|
|
||||||
id: 'affine:pages:create-page',
|
|
||||||
label: (
|
|
||||||
<Trans
|
|
||||||
i18nKey="com.affine.cmdk.affine.create-new-doc-and-insert"
|
|
||||||
values={{
|
|
||||||
keyWord: query,
|
|
||||||
}}
|
|
||||||
components={{
|
|
||||||
1: <span className={hlStyles.highlightKeyword} />,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
alwaysShow: true,
|
|
||||||
category: 'affine:creation',
|
|
||||||
run: async () => {
|
|
||||||
const page = pageHelper.createPage('page', false);
|
|
||||||
page.load();
|
|
||||||
pageMetaHelper.setDocTitle(page.id, query);
|
|
||||||
onSelectPage({ docId: page.id, isNewDoc: true });
|
|
||||||
},
|
|
||||||
icon: <PageIcon />,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
}, [searchedDocsCommands, query, pageHelper, pageMetaHelper, onSelectPage]);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const collectionToCommand = (
|
|
||||||
collection: Collection,
|
|
||||||
navigationHelper: ReturnType<typeof useNavigateHelper>,
|
|
||||||
selectCollection: (id: string) => void,
|
|
||||||
t: ReturnType<typeof useI18n>,
|
|
||||||
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: <ViewLayersIcon />,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCollectionsCommands = () => {
|
|
||||||
// TODO(@eyhn): 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 = useI18n();
|
|
||||||
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]);
|
|
||||||
};
|
|
||||||
@@ -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;
|
|
||||||
};
|
|
||||||
@@ -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 (
|
|
||||||
<div className={styles.highlightContainer}>
|
|
||||||
{highlights.map((part, i) => (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className={
|
|
||||||
part.highlight ? styles.highlightKeyword : styles.highlightText
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{part.text}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export const HighlightLabel = memo(function HighlightLabel({
|
|
||||||
label,
|
|
||||||
highlight,
|
|
||||||
}: HighlightLabelProps) {
|
|
||||||
if (label && typeof label === 'object' && 'title' in label) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className={styles.labelTitle}>
|
|
||||||
<Highlight text={label.title} highlight={highlight} />
|
|
||||||
</div>
|
|
||||||
{label.subTitle ? (
|
|
||||||
<div className={styles.labelContent}>
|
|
||||||
<Highlight text={label.subTitle} highlight={highlight} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return <div className={styles.labelTitle}>{label}</div>;
|
|
||||||
});
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export * from './main';
|
|
||||||
export * from './modal';
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
import { Loading } from '@affine/component/ui/loading';
|
|
||||||
import type { CommandCategory } from '@affine/core/commands';
|
|
||||||
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 { type I18nKeys, i18nTime } from '@affine/i18n';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
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';
|
|
||||||
|
|
||||||
const categoryToI18nKey = {
|
|
||||||
'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',
|
|
||||||
} satisfies Record<CommandCategory, I18nKeys>;
|
|
||||||
|
|
||||||
const QuickSearchGroup = ({
|
|
||||||
category,
|
|
||||||
commands,
|
|
||||||
onOpenChange,
|
|
||||||
}: {
|
|
||||||
category: CommandCategory;
|
|
||||||
commands: CMDKCommand[];
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
}) => {
|
|
||||||
const t = useI18n();
|
|
||||||
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 (
|
|
||||||
<Command.Group key={category} heading={t[i18nKey]()}>
|
|
||||||
{commands.map(command => {
|
|
||||||
const label =
|
|
||||||
typeof command.label === 'string'
|
|
||||||
? {
|
|
||||||
title: command.label,
|
|
||||||
}
|
|
||||||
: command.label;
|
|
||||||
return (
|
|
||||||
<Command.Item
|
|
||||||
key={command.id}
|
|
||||||
onSelect={() => onCommendSelect(command)}
|
|
||||||
value={command.value}
|
|
||||||
data-is-danger={
|
|
||||||
command.id === 'editor:page-move-to-trash' ||
|
|
||||||
command.id === 'editor:edgeless-move-to-trash'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className={styles.itemIcon}>{command.icon}</div>
|
|
||||||
<div
|
|
||||||
data-testid="cmdk-label"
|
|
||||||
className={styles.itemLabel}
|
|
||||||
data-value={command.value}
|
|
||||||
>
|
|
||||||
<HighlightLabel highlight={query} label={label} />
|
|
||||||
</div>
|
|
||||||
{command.timestamp ? (
|
|
||||||
<div className={styles.timestamp}>
|
|
||||||
{i18nTime(command.timestamp, { relative: true })}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{command.keyBinding ? (
|
|
||||||
<CMDKKeyBinding
|
|
||||||
keyBinding={
|
|
||||||
typeof command.keyBinding === 'string'
|
|
||||||
? command.keyBinding
|
|
||||||
: command.keyBinding.binding
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</Command.Item>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Command.Group>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const QuickSearchCommands = ({
|
|
||||||
onOpenChange,
|
|
||||||
groups,
|
|
||||||
}: {
|
|
||||||
onOpenChange?: (open: boolean) => void;
|
|
||||||
groups: ReturnType<typeof useCMDKCommandGroups>;
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{groups.map(([category, commands]) => {
|
|
||||||
return (
|
|
||||||
<QuickSearchGroup
|
|
||||||
key={category}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
category={category}
|
|
||||||
commands={commands}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CMDKContainer = ({
|
|
||||||
className,
|
|
||||||
onQueryChange,
|
|
||||||
query,
|
|
||||||
children,
|
|
||||||
inputLabel,
|
|
||||||
open,
|
|
||||||
...rest
|
|
||||||
}: React.PropsWithChildren<{
|
|
||||||
open: boolean;
|
|
||||||
className?: string;
|
|
||||||
query: string;
|
|
||||||
inputLabel?: ReactNode;
|
|
||||||
groups: ReturnType<typeof useCMDKCommandGroups>;
|
|
||||||
onQueryChange: (query: string) => void;
|
|
||||||
}>) => {
|
|
||||||
const t = useI18n();
|
|
||||||
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<HTMLInputElement>(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 (
|
|
||||||
<Command
|
|
||||||
{...rest}
|
|
||||||
data-testid="cmdk-quick-search"
|
|
||||||
shouldFilter={false}
|
|
||||||
className={clsx(className, styles.panelContainer)}
|
|
||||||
value={value}
|
|
||||||
onValueChange={setValue}
|
|
||||||
loop
|
|
||||||
>
|
|
||||||
{/* TODO(@Peng): add page context here */}
|
|
||||||
{inputLabel ? (
|
|
||||||
<div className={styles.pageTitleWrapper}>
|
|
||||||
<span className={styles.pageTitle}>{inputLabel}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div
|
|
||||||
className={clsx(className, styles.searchInputContainer, {
|
|
||||||
[styles.hasInputLabel]: inputLabel,
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
{showLoading ? (
|
|
||||||
<Loading
|
|
||||||
size={24}
|
|
||||||
progress={progress ? Math.max(progress, 0.2) : undefined}
|
|
||||||
speed={progress ? 0 : undefined}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
<Command.Input
|
|
||||||
placeholder={t[
|
|
||||||
mode === 'commands'
|
|
||||||
? 'com.affine.cmdk.placeholder'
|
|
||||||
: 'com.affine.cmdk.docs.placeholder'
|
|
||||||
]()}
|
|
||||||
ref={inputRef}
|
|
||||||
{...rest}
|
|
||||||
value={query}
|
|
||||||
onValueChange={onQueryChange}
|
|
||||||
className={clsx(className, styles.searchInput)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Command.List data-opening={opening ? true : undefined}>
|
|
||||||
{children}
|
|
||||||
</Command.List>
|
|
||||||
{mode === 'commands' ? <NotFoundGroup /> : null}
|
|
||||||
</Command>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CMDKQuickSearchModalInner = ({
|
|
||||||
pageMeta,
|
|
||||||
open,
|
|
||||||
...props
|
|
||||||
}: CMDKModalProps & { pageMeta?: Partial<DocMeta> }) => {
|
|
||||||
const quickSearch = useService(QuickSearchService).quickSearch;
|
|
||||||
const query = useLiveData(quickSearch.query$);
|
|
||||||
const groups = useCMDKCommandGroups();
|
|
||||||
const t = useI18n();
|
|
||||||
return (
|
|
||||||
<CMDKContainer
|
|
||||||
className={styles.root}
|
|
||||||
query={query}
|
|
||||||
groups={groups}
|
|
||||||
onQueryChange={quickSearch.setQuery}
|
|
||||||
inputLabel={
|
|
||||||
pageMeta ? (pageMeta.title ? pageMeta.title : t['Untitled']()) : null
|
|
||||||
}
|
|
||||||
open={open}
|
|
||||||
>
|
|
||||||
<QuickSearchCommands groups={groups} onOpenChange={props.onOpenChange} />
|
|
||||||
</CMDKContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const CMDKQuickSearchCallbackModalInner = ({
|
|
||||||
open,
|
|
||||||
...props
|
|
||||||
}: CMDKModalProps & { pageMeta?: Partial<DocMeta> }) => {
|
|
||||||
const quickSearch = useService(QuickSearchService).quickSearch;
|
|
||||||
const query = useLiveData(quickSearch.query$);
|
|
||||||
const groups = useSearchCallbackCommandGroups();
|
|
||||||
const t = useI18n();
|
|
||||||
return (
|
|
||||||
<CMDKContainer
|
|
||||||
className={styles.root}
|
|
||||||
query={query}
|
|
||||||
groups={groups}
|
|
||||||
onQueryChange={quickSearch.setQuery}
|
|
||||||
inputLabel={t['com.affine.cmdk.insert-links']()}
|
|
||||||
open={open}
|
|
||||||
>
|
|
||||||
<QuickSearchCommands groups={groups} onOpenChange={props.onOpenChange} />
|
|
||||||
</CMDKContainer>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const CMDKQuickSearchModal = ({
|
|
||||||
pageMeta,
|
|
||||||
open,
|
|
||||||
...props
|
|
||||||
}: CMDKModalProps & { pageMeta?: Partial<DocMeta> }) => {
|
|
||||||
const quickSearch = useService(QuickSearchService).quickSearch;
|
|
||||||
const mode = useLiveData(quickSearch.mode$);
|
|
||||||
const InnerComp =
|
|
||||||
mode === 'commands'
|
|
||||||
? CMDKQuickSearchModalInner
|
|
||||||
: CMDKQuickSearchCallbackModalInner;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CMDKModal open={open} {...props}>
|
|
||||||
<Suspense fallback={<Command.Loading />}>
|
|
||||||
<InnerComp
|
|
||||||
pageMeta={pageMeta}
|
|
||||||
open={open}
|
|
||||||
onOpenChange={props.onOpenChange}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</CMDKModal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 (
|
|
||||||
<div className={styles.keybinding}>
|
|
||||||
{fragments.map((fragment, index) => {
|
|
||||||
return (
|
|
||||||
<div key={index} className={styles.keybindingFragment}>
|
|
||||||
{fragment}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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',
|
|
||||||
});
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import { QuickSearchService } from '@affine/core/modules/cmdk';
|
|
||||||
import { useI18n } from '@affine/i18n';
|
|
||||||
import { SearchIcon } from '@blocksuite/icons/rc';
|
|
||||||
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 = useI18n();
|
|
||||||
|
|
||||||
if (!renderNoResult) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className={styles.notFoundContainer}>
|
|
||||||
<div
|
|
||||||
className={styles.notFoundTitle}
|
|
||||||
data-testid="cmdk-search-not-found"
|
|
||||||
>{`Search for "${query}"`}</div>
|
|
||||||
<div className={styles.notFoundItem}>
|
|
||||||
<div className={styles.notFoundIcon}>
|
|
||||||
<SearchIcon />
|
|
||||||
</div>
|
|
||||||
<div className={styles.notFoundText}>
|
|
||||||
{t['com.affine.cmdk.no-results']()}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -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<void>;
|
|
||||||
}
|
|
||||||
@@ -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]);
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
|
fromPromise,
|
||||||
OnEvent,
|
OnEvent,
|
||||||
Service,
|
Service,
|
||||||
WorkspaceEngineBeforeStart,
|
WorkspaceEngineBeforeStart,
|
||||||
} from '@toeverything/infra';
|
} from '@toeverything/infra';
|
||||||
|
import { type Observable, switchMap } from 'rxjs';
|
||||||
|
|
||||||
import { DocsIndexer } from '../entities/docs-indexer';
|
import { DocsIndexer } from '../entities/docs-indexer';
|
||||||
|
|
||||||
@OnEvent(WorkspaceEngineBeforeStart, s => s.handleWorkspaceEngineBeforeStart)
|
@OnEvent(WorkspaceEngineBeforeStart, s => s.handleWorkspaceEngineBeforeStart)
|
||||||
export class DocsSearchService extends Service {
|
export class DocsSearchService extends Service {
|
||||||
private readonly indexer = this.framework.createEntity(DocsIndexer);
|
readonly indexer = this.framework.createEntity(DocsIndexer);
|
||||||
|
|
||||||
handleWorkspaceEngineBeforeStart() {
|
handleWorkspaceEngineBeforeStart() {
|
||||||
this.indexer.setupListener();
|
this.indexer.setupListener();
|
||||||
@@ -115,6 +117,114 @@ export class DocsSearchService extends Service {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
search$(query: string): Observable<
|
||||||
|
{
|
||||||
|
docId: string;
|
||||||
|
title: string;
|
||||||
|
score: number;
|
||||||
|
blockId?: string;
|
||||||
|
blockContent?: string;
|
||||||
|
}[]
|
||||||
|
> {
|
||||||
|
return this.indexer.blockIndex
|
||||||
|
.aggregate$(
|
||||||
|
{
|
||||||
|
type: 'boolean',
|
||||||
|
occur: 'must',
|
||||||
|
queries: [
|
||||||
|
{
|
||||||
|
type: 'match',
|
||||||
|
field: 'content',
|
||||||
|
match: query,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'boolean',
|
||||||
|
occur: 'should',
|
||||||
|
queries: [
|
||||||
|
{
|
||||||
|
type: 'all',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'boost',
|
||||||
|
boost: 100,
|
||||||
|
query: {
|
||||||
|
type: 'match',
|
||||||
|
field: 'flavour',
|
||||||
|
match: 'affine:page',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'docId',
|
||||||
|
{
|
||||||
|
pagination: {
|
||||||
|
limit: 50,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
hits: {
|
||||||
|
pagination: {
|
||||||
|
limit: 2,
|
||||||
|
skip: 0,
|
||||||
|
},
|
||||||
|
fields: ['blockId', 'flavour'],
|
||||||
|
highlights: [
|
||||||
|
{
|
||||||
|
field: 'content',
|
||||||
|
before: '<b>',
|
||||||
|
end: '</b>',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
switchMap(({ buckets }) => {
|
||||||
|
return fromPromise(async () => {
|
||||||
|
const docData = await this.indexer.docIndex.getAll(
|
||||||
|
buckets.map(bucket => bucket.key)
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
for (const bucket of buckets) {
|
||||||
|
const firstMatchFlavour = bucket.hits.nodes[0]?.fields.flavour;
|
||||||
|
if (firstMatchFlavour === 'affine:page') {
|
||||||
|
// is title match
|
||||||
|
const blockContent =
|
||||||
|
bucket.hits.nodes[1]?.highlights.content[0]; // try to get block content
|
||||||
|
result.push({
|
||||||
|
docId: bucket.key,
|
||||||
|
title: bucket.hits.nodes[0].highlights.content[0],
|
||||||
|
score: bucket.score,
|
||||||
|
blockContent,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const title =
|
||||||
|
docData.find(doc => doc.id === bucket.key)?.get('title') ??
|
||||||
|
'';
|
||||||
|
const matchedBlockId = bucket.hits.nodes[0]?.fields.blockId;
|
||||||
|
// is block match
|
||||||
|
result.push({
|
||||||
|
docId: bucket.key,
|
||||||
|
title: typeof title === 'string' ? title : title[0],
|
||||||
|
blockId:
|
||||||
|
typeof matchedBlockId === 'string'
|
||||||
|
? matchedBlockId
|
||||||
|
: matchedBlockId[0],
|
||||||
|
score: bucket.score,
|
||||||
|
blockContent: bucket.hits.nodes[0]?.highlights.content[0],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async getDocTitle(docId: string) {
|
async getDocTitle(docId: string) {
|
||||||
const doc = await this.indexer.docIndex.get(docId);
|
const doc = await this.indexer.docIndex.get(docId);
|
||||||
const title = doc?.get('title');
|
const title = doc?.get('title');
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { configureQuotaModule } from '@affine/core/modules/quota';
|
|||||||
import { configureInfraModules, type Framework } from '@toeverything/infra';
|
import { configureInfraModules, type Framework } from '@toeverything/infra';
|
||||||
|
|
||||||
import { configureCloudModule } from './cloud';
|
import { configureCloudModule } from './cloud';
|
||||||
import { configureQuickSearchModule } from './cmdk';
|
|
||||||
import { configureCollectionModule } from './collection';
|
import { configureCollectionModule } from './collection';
|
||||||
|
import { configureDocsSearchModule } from './docs-search';
|
||||||
import { configureFindInPageModule } from './find-in-page';
|
import { configureFindInPageModule } from './find-in-page';
|
||||||
import { configureNavigationModule } from './navigation';
|
import { configureNavigationModule } from './navigation';
|
||||||
import { configurePeekViewModule } from './peek-view';
|
import { configurePeekViewModule } from './peek-view';
|
||||||
import { configurePermissionsModule } from './permissions';
|
import { configurePermissionsModule } from './permissions';
|
||||||
import { configureWorkspacePropertiesModule } from './properties';
|
import { configureWorkspacePropertiesModule } from './properties';
|
||||||
|
import { configureQuickSearchModule } from './quicksearch';
|
||||||
import { configureRightSidebarModule } from './right-sidebar';
|
import { configureRightSidebarModule } from './right-sidebar';
|
||||||
import { configureShareDocsModule } from './share-doc';
|
import { configureShareDocsModule } from './share-doc';
|
||||||
import { configureStorageImpls } from './storage';
|
import { configureStorageImpls } from './storage';
|
||||||
@@ -32,6 +33,7 @@ export function configureCommonModules(framework: Framework) {
|
|||||||
configureFindInPageModule(framework);
|
configureFindInPageModule(framework);
|
||||||
configurePeekViewModule(framework);
|
configurePeekViewModule(framework);
|
||||||
configureQuickSearchModule(framework);
|
configureQuickSearchModule(framework);
|
||||||
|
configureDocsSearchModule(framework);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function configureImpls(framework: Framework) {
|
export function configureImpls(framework: Framework) {
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export class PeekViewEntity extends Entity {
|
|||||||
// if there is an active peek view and it is a doc peek view, we will navigate it first
|
// if there is an active peek view and it is a doc peek view, we will navigate it first
|
||||||
if (active?.info.type === 'doc' && this.show$.value) {
|
if (active?.info.type === 'doc' && this.show$.value) {
|
||||||
// TODO(@pengx17): scroll to the viewing position?
|
// TODO(@pengx17): scroll to the viewing position?
|
||||||
this.workbenchService.workbench.openPage(active.info.docId);
|
this.workbenchService.workbench.openDoc(active.info.docId);
|
||||||
}
|
}
|
||||||
|
|
||||||
this._active$.next({ target, info: resolvedInfo });
|
this._active$.next({ target, info: resolvedInfo });
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function DocPeekPreview({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const disposable = AIProvider.slots.requestOpenWithChat.on(() => {
|
const disposable = AIProvider.slots.requestOpenWithChat.on(() => {
|
||||||
if (doc) {
|
if (doc) {
|
||||||
workbench.openPage(doc.id);
|
workbench.openDoc(doc.id);
|
||||||
peekView.close();
|
peekView.close();
|
||||||
// chat panel open is already handled in <DetailPageImpl />
|
// chat panel open is already handled in <DetailPageImpl />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ export const DocPeekViewControls = ({
|
|||||||
// TODO(@Peng): for frame blocks, we should mimic "view in edgeless" button behavior
|
// TODO(@Peng): for frame blocks, we should mimic "view in edgeless" button behavior
|
||||||
blockId
|
blockId
|
||||||
? jumpToPageBlock(workspace.id, docId, blockId)
|
? jumpToPageBlock(workspace.id, docId, blockId)
|
||||||
: workbench.openPage(docId);
|
: workbench.openDoc(docId);
|
||||||
if (mode) {
|
if (mode) {
|
||||||
doc?.setMode(mode);
|
doc?.setMode(mode);
|
||||||
}
|
}
|
||||||
@@ -131,7 +131,7 @@ export const DocPeekViewControls = ({
|
|||||||
nameKey: 'split-view',
|
nameKey: 'split-view',
|
||||||
name: t['com.affine.peek-view-controls.open-doc-in-split-view'](),
|
name: t['com.affine.peek-view-controls.open-doc-in-split-view'](),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
workbench.openPage(docId, { at: 'beside' });
|
workbench.openDoc(docId, { at: 'beside' });
|
||||||
peekView.close();
|
peekView.close();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<any, any>[];
|
||||||
|
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 = <const Sources extends any[]>(
|
||||||
|
sources: Sources,
|
||||||
|
cb: (result: QuickSearchSourceItemType<Sources[number]> | null) => void,
|
||||||
|
options: QuickSearchOptions = {}
|
||||||
|
) => {
|
||||||
|
if (this.state$.value) {
|
||||||
|
this.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
const sessions = sources.map((source: QuickSearchSource<any, any>) => {
|
||||||
|
if (typeof source === 'function') {
|
||||||
|
const items$ = new LiveData<QuickSearchItem<any, any>[]>([]);
|
||||||
|
return {
|
||||||
|
items$,
|
||||||
|
query: (query: string) => {
|
||||||
|
items$.next(source(query));
|
||||||
|
},
|
||||||
|
} as QuickSearchSession<any, any>;
|
||||||
|
} else {
|
||||||
|
return source as QuickSearchSession<any, any>;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { ViewLayersIcon } from '@blocksuite/icons/rc';
|
||||||
|
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<QuickSearchItem<'collections', { collectionId: string }>[]> =
|
||||||
|
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, '<b>', '</b>', 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
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,
|
||||||
|
threshold: 0.4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = query
|
||||||
|
? fuse.search(query)
|
||||||
|
: commands.map(item => ({ item, matches: [], score: 0 }));
|
||||||
|
|
||||||
|
return result.map<QuickSearchItem<'commands', AffineCommand>>(
|
||||||
|
({ 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,
|
||||||
|
'<b>',
|
||||||
|
'</b>',
|
||||||
|
titleMatches ?? []
|
||||||
|
) ?? item.label.title,
|
||||||
|
subTitle: item.label.subTitle
|
||||||
|
? highlighter(
|
||||||
|
item.label.subTitle,
|
||||||
|
'<b>',
|
||||||
|
'</b>',
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
||||||
|
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',
|
||||||
|
source: 'creation',
|
||||||
|
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',
|
||||||
|
source: 'creation',
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { EdgelessIcon, PageIcon, TodayIcon } from '@blocksuite/icons/rc';
|
||||||
|
import type { DocsService } from '@toeverything/infra';
|
||||||
|
import {
|
||||||
|
effect,
|
||||||
|
Entity,
|
||||||
|
LiveData,
|
||||||
|
onComplete,
|
||||||
|
onStart,
|
||||||
|
} from '@toeverything/infra';
|
||||||
|
import { truncate } from 'lodash-es';
|
||||||
|
import { EMPTY, map, mergeMap, of, 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 docsSearchService: DocsSearchService,
|
||||||
|
private readonly docsService: DocsService,
|
||||||
|
private readonly propertiesAdapter: WorkspacePropertiesAdapter
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly isIndexerLoading$ =
|
||||||
|
this.docsSearchService.indexer.status$.map(({ remaining }) => {
|
||||||
|
return remaining === undefined || remaining > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly isQueryLoading$ = new LiveData(false);
|
||||||
|
|
||||||
|
isLoading$ = LiveData.computed(get => {
|
||||||
|
return get(this.isIndexerLoading$) || get(this.isQueryLoading$);
|
||||||
|
});
|
||||||
|
|
||||||
|
query$ = new LiveData('');
|
||||||
|
|
||||||
|
items$ = new LiveData<QuickSearchItem<'docs', DocsPayload>[]>([]);
|
||||||
|
|
||||||
|
query = effect(
|
||||||
|
switchMap((query: string) => {
|
||||||
|
let out;
|
||||||
|
if (!query) {
|
||||||
|
out = of([] as QuickSearchItem<'docs', DocsPayload>[]);
|
||||||
|
} else {
|
||||||
|
const maybeLink = resolveLinkToDoc(query);
|
||||||
|
const docRecord = maybeLink
|
||||||
|
? this.docsService.list.doc$(maybeLink.docId).value
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (docRecord) {
|
||||||
|
const docMode = docRecord?.mode$.value;
|
||||||
|
const icon = this.propertiesAdapter.getJournalPageDateString(
|
||||||
|
docRecord.id
|
||||||
|
) /* is journal */
|
||||||
|
? TodayIcon
|
||||||
|
: docMode === 'edgeless'
|
||||||
|
? EdgelessIcon
|
||||||
|
: PageIcon;
|
||||||
|
|
||||||
|
out = of([
|
||||||
|
{
|
||||||
|
id: 'doc:' + docRecord.id,
|
||||||
|
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: docRecord.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
] as QuickSearchItem<'docs', DocsPayload>[]);
|
||||||
|
} else {
|
||||||
|
out = this.docsSearchService.search$(query).pipe(
|
||||||
|
map(docs =>
|
||||||
|
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 out.pipe(
|
||||||
|
mergeMap((items: QuickSearchItem<'docs', DocsPayload>[]) => {
|
||||||
|
this.items$.next(items);
|
||||||
|
this.isQueryLoading$.next(false);
|
||||||
|
return EMPTY;
|
||||||
|
}),
|
||||||
|
onStart(() => {
|
||||||
|
this.items$.next([]);
|
||||||
|
this.isQueryLoading$.next(true);
|
||||||
|
}),
|
||||||
|
onComplete(() => {})
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO(@EYHN): load more
|
||||||
|
|
||||||
|
setQuery(query: string) {
|
||||||
|
this.query$.next(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { EdgelessIcon, PageIcon, TodayIcon } from '@blocksuite/icons/rc';
|
||||||
|
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<QuickSearchItem<'recent-doc', { docId: string }>[]> =
|
||||||
|
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<QuickSearchItem<'recent-doc', { docId: string }>>(
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
DocsService,
|
||||||
|
type Framework,
|
||||||
|
GlobalContextService,
|
||||||
|
WorkspaceLocalState,
|
||||||
|
WorkspaceScope,
|
||||||
|
} 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, [
|
||||||
|
DocsSearchService,
|
||||||
|
DocsService,
|
||||||
|
WorkspacePropertiesAdapter,
|
||||||
|
])
|
||||||
|
.entity(CreationQuickSearchSession)
|
||||||
|
.entity(CollectionsQuickSearchSession, [CollectionService])
|
||||||
|
.entity(RecentDocsQuickSearchSession, [
|
||||||
|
RecentDocsService,
|
||||||
|
WorkspacePropertiesAdapter,
|
||||||
|
]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { type LiveData } from '@toeverything/infra';
|
||||||
|
|
||||||
|
import type { QuickSearchItem } from '../types/item';
|
||||||
|
|
||||||
|
export type QuickSearchFunction<S, P> = (
|
||||||
|
query: string
|
||||||
|
) => QuickSearchItem<S, P>[];
|
||||||
|
|
||||||
|
export interface QuickSearchSession<S, P> {
|
||||||
|
items$: LiveData<QuickSearchItem<S, P>[]>;
|
||||||
|
isError$?: LiveData<boolean>;
|
||||||
|
isLoading$?: LiveData<boolean>;
|
||||||
|
loadingProgress$?: LiveData<number>;
|
||||||
|
hasMore$?: LiveData<boolean>;
|
||||||
|
|
||||||
|
query?: (query: string) => void;
|
||||||
|
loadMore?: () => void;
|
||||||
|
dispose?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type QuickSearchSource<S, P> =
|
||||||
|
| QuickSearchFunction<S, P>
|
||||||
|
| QuickSearchSession<S, P>;
|
||||||
|
|
||||||
|
export type QuickSearchSourceItemType<Source> =
|
||||||
|
Source extends QuickSearchSource<infer S, infer P>
|
||||||
|
? QuickSearchItem<S, P>
|
||||||
|
: never;
|
||||||
@@ -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',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -10,7 +10,7 @@ const RECENT_PAGES_KEY = 'recent-pages';
|
|||||||
|
|
||||||
const EMPTY_ARRAY: string[] = [];
|
const EMPTY_ARRAY: string[] = [];
|
||||||
|
|
||||||
export class RecentPagesService extends Service {
|
export class RecentDocsService extends Service {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly localState: WorkspaceLocalState,
|
private readonly localState: WorkspaceLocalState,
|
||||||
private readonly docsService: DocsService
|
private readonly docsService: DocsService
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { I18nString } from '@affine/i18n';
|
||||||
|
|
||||||
|
export interface QuickSearchGroup {
|
||||||
|
id: string;
|
||||||
|
label: I18nString;
|
||||||
|
score?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import type { I18nString } from '@affine/i18n';
|
||||||
|
|
||||||
|
import type { QuickSearchGroup } from './group';
|
||||||
|
|
||||||
|
export type QuickSearchItem<S = any, P = any> = {
|
||||||
|
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<unknown> ? { payload: P } : unknown);
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { I18nString } from '@affine/i18n';
|
||||||
|
|
||||||
|
export interface QuickSearchOptions {
|
||||||
|
label?: I18nString;
|
||||||
|
placeholder?: I18nString;
|
||||||
|
defaultQuery?: string;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+99
-84
@@ -1,61 +1,8 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { globalStyle, style } from '@vanilla-extract/css';
|
import { globalStyle, style } from '@vanilla-extract/css';
|
||||||
|
|
||||||
export const root = style({});
|
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 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({
|
export const itemIcon = style({
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
marginRight: 16,
|
marginRight: 16,
|
||||||
@@ -64,6 +11,7 @@ export const itemIcon = style({
|
|||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
color: cssVar('iconSecondary'),
|
color: cssVar('iconSecondary'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const itemLabel = style({
|
export const itemLabel = style({
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
lineHeight: '1.5',
|
lineHeight: '1.5',
|
||||||
@@ -73,30 +21,7 @@ export const itemLabel = style({
|
|||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
whiteSpace: 'nowrap',
|
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]`, {
|
globalStyle(`${root} [cmdk-root]`, {
|
||||||
height: '100%',
|
height: '100%',
|
||||||
});
|
});
|
||||||
@@ -170,10 +95,100 @@ globalStyle(
|
|||||||
color: cssVar('errorColor'),
|
color: cssVar('errorColor'),
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
export const resultGroupHeader = style({
|
|
||||||
padding: '8px',
|
export const panelContainer = style({
|
||||||
color: cssVar('textSecondaryColor'),
|
height: '100%',
|
||||||
fontSize: cssVar('fontXs'),
|
display: 'flex',
|
||||||
fontWeight: 600,
|
flexDirection: 'column',
|
||||||
lineHeight: '1.67',
|
});
|
||||||
|
|
||||||
|
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'),
|
||||||
|
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',
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,319 @@
|
|||||||
|
import { Loading } from '@affine/component/ui/loading';
|
||||||
|
import { i18nTime, isI18nString, useI18n } from '@affine/i18n';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
import { Command } from 'cmdk';
|
||||||
|
import {
|
||||||
|
type ReactNode,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useMemo,
|
||||||
|
useReducer,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
|
||||||
|
import type { QuickSearchGroup } from '../types/group';
|
||||||
|
import type { QuickSearchItem } from '../types/item';
|
||||||
|
import * as styles from './cmdk.css';
|
||||||
|
import { HighlightText } from './highlight-text';
|
||||||
|
|
||||||
|
type Groups = { group?: QuickSearchGroup; items: QuickSearchItem[] }[];
|
||||||
|
|
||||||
|
export const CMDK = ({
|
||||||
|
className,
|
||||||
|
query,
|
||||||
|
groups: newGroups = [],
|
||||||
|
inputLabel,
|
||||||
|
placeholder,
|
||||||
|
loading: newLoading = false,
|
||||||
|
loadingProgress,
|
||||||
|
onQueryChange,
|
||||||
|
onSubmit,
|
||||||
|
}: React.PropsWithChildren<{
|
||||||
|
className?: string;
|
||||||
|
query: string;
|
||||||
|
inputLabel?: ReactNode;
|
||||||
|
placeholder?: string;
|
||||||
|
loading?: boolean;
|
||||||
|
loadingProgress?: number;
|
||||||
|
groups?: Groups;
|
||||||
|
onSubmit?: (item: QuickSearchItem) => void;
|
||||||
|
onQueryChange?: (query: string) => void;
|
||||||
|
}>) => {
|
||||||
|
const [opening, setOpening] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
const [{ groups, selectedValue }, dispatch] = useReducer(
|
||||||
|
(
|
||||||
|
state: {
|
||||||
|
groups: Groups;
|
||||||
|
selectedValue: string;
|
||||||
|
},
|
||||||
|
action:
|
||||||
|
| { type: 'select'; payload: string }
|
||||||
|
| { type: 'reset-select' }
|
||||||
|
| { type: 'update-groups'; payload: Groups }
|
||||||
|
) => {
|
||||||
|
// control the currently selected item so that when the item list changes, the selected item remains controllable
|
||||||
|
if (action.type === 'select') {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selectedValue: action.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action.type === 'reset-select') {
|
||||||
|
// reset selected item to the first item
|
||||||
|
const firstItem = state.groups.at(0)?.items.at(0)?.id;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
selectedValue: firstItem ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (action.type === 'update-groups') {
|
||||||
|
const prevGroups = state.groups;
|
||||||
|
const prevSelectedValue = state.selectedValue;
|
||||||
|
|
||||||
|
const prevFirstItem = prevGroups.at(0)?.items.at(0)?.id;
|
||||||
|
const newFirstItem = action.payload.at(0)?.items.at(0)?.id;
|
||||||
|
const isSelectingFirstItem = prevSelectedValue === prevFirstItem;
|
||||||
|
// if previous selected item is the first item, select the new first item
|
||||||
|
if (isSelectingFirstItem) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
groups: action.payload,
|
||||||
|
selectedValue: newFirstItem ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedExists = state.groups.some(({ items }) =>
|
||||||
|
items.some(item => item.id === prevSelectedValue)
|
||||||
|
);
|
||||||
|
// if previous selected item exists in the new list, keep it
|
||||||
|
if (selectedExists) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
groups: action.payload,
|
||||||
|
selectedValue: prevSelectedValue,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// if previous selected item does not exist in the new list, select the new first item
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
groups: action.payload,
|
||||||
|
selectedExists: newFirstItem ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
},
|
||||||
|
{ groups: [], selectedValue: '' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// fix list height animation on opening
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setOpening(true);
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
setOpening(false);
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 150);
|
||||||
|
return () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleValueChange = useCallback(
|
||||||
|
(query: string) => {
|
||||||
|
onQueryChange?.(query);
|
||||||
|
dispatch({
|
||||||
|
type: 'reset-select',
|
||||||
|
});
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (listRef.current) listRef.current.scrollTop = 0;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[onQueryChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
dispatch({
|
||||||
|
type: 'select',
|
||||||
|
payload: value,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// on group change
|
||||||
|
dispatch({
|
||||||
|
type: 'update-groups',
|
||||||
|
payload: newGroups,
|
||||||
|
});
|
||||||
|
}, [newGroups]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// debounce loading state
|
||||||
|
const timeout = setTimeout(() => setLoading(newLoading), 1000);
|
||||||
|
return () => clearTimeout(timeout);
|
||||||
|
}, [newLoading]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Command
|
||||||
|
data-testid="cmdk-quick-search"
|
||||||
|
shouldFilter={false}
|
||||||
|
className={clsx(className, styles.root, styles.panelContainer)}
|
||||||
|
value={selectedValue}
|
||||||
|
onValueChange={handleSelectChange}
|
||||||
|
loop
|
||||||
|
>
|
||||||
|
{inputLabel ? (
|
||||||
|
<div className={styles.pageTitleWrapper}>
|
||||||
|
<span className={styles.pageTitle}>{inputLabel}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className={clsx(className, styles.searchInputContainer, {
|
||||||
|
[styles.hasInputLabel]: inputLabel,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Command.Input
|
||||||
|
placeholder={placeholder}
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onValueChange={handleValueChange}
|
||||||
|
className={clsx(className, styles.searchInput)}
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<Loading
|
||||||
|
size={24}
|
||||||
|
progress={
|
||||||
|
loadingProgress ? Math.max(loadingProgress, 0.2) : undefined
|
||||||
|
}
|
||||||
|
speed={loadingProgress ? 0 : undefined}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Command.List ref={listRef} data-opening={opening ? true : undefined}>
|
||||||
|
{groups.map(({ group, items }) => {
|
||||||
|
return (
|
||||||
|
<CMDKGroup
|
||||||
|
key={group?.id ?? ''}
|
||||||
|
onSubmit={onSubmit}
|
||||||
|
query={query}
|
||||||
|
group={{ group, items }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Command.List>
|
||||||
|
</Command>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CMDKGroup = ({
|
||||||
|
group: { group, items },
|
||||||
|
onSubmit,
|
||||||
|
query,
|
||||||
|
}: {
|
||||||
|
group: { group?: QuickSearchGroup; items: QuickSearchItem[] };
|
||||||
|
onSubmit?: (item: QuickSearchItem) => void;
|
||||||
|
query: string;
|
||||||
|
}) => {
|
||||||
|
const i18n = useI18n();
|
||||||
|
return (
|
||||||
|
<Command.Group
|
||||||
|
key={query + ':' + (group?.id ?? '')}
|
||||||
|
heading={group && i18n.t(group.label)}
|
||||||
|
style={{ overflowAnchor: 'none' }}
|
||||||
|
>
|
||||||
|
{items.map(item => {
|
||||||
|
const title = !isI18nString(item.label)
|
||||||
|
? i18n.t(item.label.title)
|
||||||
|
: i18n.t(item.label);
|
||||||
|
const subTitle = !isI18nString(item.label)
|
||||||
|
? item.label.subTitle && i18n.t(item.label.subTitle)
|
||||||
|
: null;
|
||||||
|
return (
|
||||||
|
<Command.Item
|
||||||
|
key={item.id}
|
||||||
|
onSelect={() => 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'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.itemIcon}>
|
||||||
|
{item.icon &&
|
||||||
|
(typeof item.icon === 'function' ? <item.icon /> : item.icon)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="cmdk-label"
|
||||||
|
className={styles.itemLabel}
|
||||||
|
data-value={item.id}
|
||||||
|
>
|
||||||
|
<div className={styles.itemTitle}>
|
||||||
|
<HighlightText text={title} start="<b>" end="</b>" />
|
||||||
|
</div>
|
||||||
|
{subTitle && (
|
||||||
|
<div className={styles.itemSubtitle}>
|
||||||
|
<HighlightText text={subTitle} start="<b>" end="</b>" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{item.timestamp ? (
|
||||||
|
<div className={styles.timestamp}>
|
||||||
|
{i18nTime(new Date(item.timestamp))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{item.keyBinding ? (
|
||||||
|
<CMDKKeyBinding keyBinding={item.keyBinding} />
|
||||||
|
) : null}
|
||||||
|
</Command.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Command.Group>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={styles.keybinding}>
|
||||||
|
{fragments.map((fragment, index) => {
|
||||||
|
return (
|
||||||
|
<div key={index} className={styles.keybindingFragment}>
|
||||||
|
{fragment}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { useI18n } from '@affine/i18n';
|
||||||
|
import { useLiveData, useServices } from '@toeverything/infra';
|
||||||
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
|
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 } = useServices({
|
||||||
|
QuickSearchService,
|
||||||
|
});
|
||||||
|
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 i18n = useI18n();
|
||||||
|
|
||||||
|
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 ?? 0) - (a.score ?? 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
groups.sort((a, b) => {
|
||||||
|
const group = (b.group?.score ?? 0) - (a.group?.score ?? 0);
|
||||||
|
if (group !== 0) {
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
return (b.items[0].score ?? 0) - (a.items[0].score ?? 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
return groups;
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
const handleChangeQuery = useCallback(
|
||||||
|
(query: string) => {
|
||||||
|
quickSearch.setQuery(query);
|
||||||
|
},
|
||||||
|
[quickSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback(
|
||||||
|
(item: QuickSearchItem) => {
|
||||||
|
quickSearch.submit(item);
|
||||||
|
},
|
||||||
|
[quickSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QuickSearchModal open={open} onOpenChange={onToggleQuickSearch}>
|
||||||
|
<CMDK
|
||||||
|
query={query}
|
||||||
|
groups={groups}
|
||||||
|
loading={loading}
|
||||||
|
loadingProgress={loadingProgress}
|
||||||
|
onQueryChange={handleChangeQuery}
|
||||||
|
onSubmit={handleSubmit}
|
||||||
|
inputLabel={options?.label && i18n.t(options.label)}
|
||||||
|
placeholder={options?.placeholder && i18n.t(options.placeholder)}
|
||||||
|
/>
|
||||||
|
</QuickSearchModal>
|
||||||
|
);
|
||||||
|
};
|
||||||
+1
-18
@@ -1,9 +1,6 @@
|
|||||||
import { cssVar } from '@toeverything/theme';
|
import { cssVar } from '@toeverything/theme';
|
||||||
import { style } from '@vanilla-extract/css';
|
import { style } from '@vanilla-extract/css';
|
||||||
export const highlightContainer = style({
|
|
||||||
display: 'flex',
|
|
||||||
flexWrap: 'nowrap',
|
|
||||||
});
|
|
||||||
export const highlightText = style({
|
export const highlightText = style({
|
||||||
whiteSpace: 'pre',
|
whiteSpace: 'pre',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
@@ -19,17 +16,3 @@ export const highlightKeyword = style({
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
maxWidth: '360px',
|
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',
|
|
||||||
});
|
|
||||||
@@ -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 (
|
||||||
|
<span className={styles.highlightText}>
|
||||||
|
{parts.map((part, i) =>
|
||||||
|
typeof part === 'string' ? (
|
||||||
|
<Fragment key={i}>{part}</Fragment>
|
||||||
|
) : (
|
||||||
|
<span key={i} className={styles.highlightKeyword}>
|
||||||
|
{part.h}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
+4
-4
@@ -5,21 +5,21 @@ import { useTransition } from 'react-transition-state';
|
|||||||
|
|
||||||
import * as styles from './modal.css';
|
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
|
// it has a smooth animation and can be closed by clicking outside of the modal
|
||||||
|
|
||||||
export interface CMDKModalProps {
|
export interface QuickSearchModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const animationTimeout = 120;
|
const animationTimeout = 120;
|
||||||
|
|
||||||
export const CMDKModal = ({
|
export const QuickSearchModal = ({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
open,
|
open,
|
||||||
children,
|
children,
|
||||||
}: React.PropsWithChildren<CMDKModalProps>) => {
|
}: React.PropsWithChildren<QuickSearchModalProps>) => {
|
||||||
const [{ status }, toggle] = useTransition({
|
const [{ status }, toggle] = useTransition({
|
||||||
timeout: animationTimeout,
|
timeout: animationTimeout,
|
||||||
});
|
});
|
||||||
@@ -71,8 +71,13 @@ export class Workbench extends Entity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
openPage(pageId: string, options?: WorkbenchOpenOptions) {
|
openDoc(
|
||||||
this.open(`/${pageId}`, options);
|
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) {
|
openCollections(options?: WorkbenchOpenOptions) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { AIProvider } from '@affine/core/blocksuite/presets/ai';
|
|||||||
import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
|
import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
|
||||||
import { AIIsland } from '@affine/core/components/pure/ai-island';
|
import { AIIsland } from '@affine/core/components/pure/ai-island';
|
||||||
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
|
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 type { PageRootService } from '@blocksuite/blocks';
|
||||||
import {
|
import {
|
||||||
BookmarkBlockService,
|
BookmarkBlockService,
|
||||||
@@ -358,7 +358,7 @@ export const Component = () => {
|
|||||||
performanceRenderLogger.debug('DetailPage');
|
performanceRenderLogger.debug('DetailPage');
|
||||||
|
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const recentPages = useService(RecentPagesService);
|
const recentPages = useService(RecentDocsService);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (params.pageId) {
|
if (params.pageId) {
|
||||||
|
|||||||
@@ -542,6 +542,8 @@
|
|||||||
"com.affine.cloudTempDisable.title": "AFFiNE Cloud is upgrading now.",
|
"com.affine.cloudTempDisable.title": "AFFiNE Cloud is upgrading now.",
|
||||||
"com.affine.cmdk.affine.category.affine.collections": "Collections",
|
"com.affine.cmdk.affine.category.affine.collections": "Collections",
|
||||||
"com.affine.cmdk.affine.category.affine.creation": "Create",
|
"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.edgeless": "Edgeless",
|
||||||
"com.affine.cmdk.affine.category.affine.general": "General",
|
"com.affine.cmdk.affine.category.affine.general": "General",
|
||||||
"com.affine.cmdk.affine.category.affine.help": "Help",
|
"com.affine.cmdk.affine.category.affine.help": "Help",
|
||||||
@@ -559,9 +561,9 @@
|
|||||||
"com.affine.cmdk.affine.color-mode.to": "Change Colour Mode to",
|
"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.color-scheme.to": "Change Colour Scheme to",
|
||||||
"com.affine.cmdk.affine.contact-us": "Contact Us",
|
"com.affine.cmdk.affine.contact-us": "Contact Us",
|
||||||
"com.affine.cmdk.affine.create-new-doc-and-insert": "Create \"<1>{{keyWord}}</1>\" Doc and insert",
|
"com.affine.cmdk.affine.create-new-edgeless-as": "New \"{{keyWord}}\" Edgeless",
|
||||||
"com.affine.cmdk.affine.create-new-edgeless-as": "New \"<1>{{keyWord}}</1>\" Edgeless",
|
"com.affine.cmdk.affine.create-new-page-as": "New \"{{keyWord}}\" Page",
|
||||||
"com.affine.cmdk.affine.create-new-page-as": "New \"<1>{{keyWord}}</1>\" Page",
|
"com.affine.cmdk.affine.create-new-doc-and-insert": "Create \"{{keyWord}}\" Doc and insert",
|
||||||
"com.affine.cmdk.affine.display-language.to": "Change Display Language to",
|
"com.affine.cmdk.affine.display-language.to": "Change Display Language to",
|
||||||
"com.affine.cmdk.affine.editor.add-to-favourites": "Add to Favourites",
|
"com.affine.cmdk.affine.editor.add-to-favourites": "Add to Favourites",
|
||||||
"com.affine.cmdk.affine.editor.edgeless.presentation-start": "Start Presentation",
|
"com.affine.cmdk.affine.editor.edgeless.presentation-start": "Start Presentation",
|
||||||
|
|||||||
@@ -550,7 +550,7 @@
|
|||||||
"com.affine.cmdk.affine.color-scheme.to": "Changer le thème de couleur pour",
|
"com.affine.cmdk.affine.color-scheme.to": "Changer le thème de couleur pour",
|
||||||
"com.affine.cmdk.affine.contact-us": "Nous contacter",
|
"com.affine.cmdk.affine.contact-us": "Nous contacter",
|
||||||
"com.affine.cmdk.affine.create-new-edgeless-as": "Créer une nouvelle page sans bord sous :",
|
"com.affine.cmdk.affine.create-new-edgeless-as": "Créer une nouvelle page sans bord sous :",
|
||||||
"com.affine.cmdk.affine.create-new-page-as": "Nouveau document \"<1>{{keyWord}}</1>\" ",
|
"com.affine.cmdk.affine.create-new-page-as": "Nouveau document \"{{keyWord}}\" ",
|
||||||
"com.affine.cmdk.affine.display-language.to": "Changer la langue d'affichage pour",
|
"com.affine.cmdk.affine.display-language.to": "Changer la langue d'affichage pour",
|
||||||
"com.affine.cmdk.affine.editor.add-to-favourites": "Ajouter aux Favoris",
|
"com.affine.cmdk.affine.editor.add-to-favourites": "Ajouter aux Favoris",
|
||||||
"com.affine.cmdk.affine.editor.edgeless.presentation-start": "Commencer la Présentation",
|
"com.affine.cmdk.affine.editor.edgeless.presentation-start": "Commencer la Présentation",
|
||||||
|
|||||||
@@ -518,8 +518,8 @@
|
|||||||
"com.affine.cmdk.affine.color-mode.to": "색상 모드를 다음과 같이 변경",
|
"com.affine.cmdk.affine.color-mode.to": "색상 모드를 다음과 같이 변경",
|
||||||
"com.affine.cmdk.affine.color-scheme.to": "색 구성표를 다음과 같이 변경",
|
"com.affine.cmdk.affine.color-scheme.to": "색 구성표를 다음과 같이 변경",
|
||||||
"com.affine.cmdk.affine.contact-us": "Contact Us",
|
"com.affine.cmdk.affine.contact-us": "Contact Us",
|
||||||
"com.affine.cmdk.affine.create-new-edgeless-as": "새 \"<1>{{keyWord}}</1>\" Edgeless",
|
"com.affine.cmdk.affine.create-new-edgeless-as": "새 \"{{keyWord}}\" Edgeless",
|
||||||
"com.affine.cmdk.affine.create-new-page-as": "새 \"<1>{{keyWord}}</1>\" 페이지",
|
"com.affine.cmdk.affine.create-new-page-as": "새 \"{{keyWord}}\" 페이지",
|
||||||
"com.affine.cmdk.affine.display-language.to": "표시 언어를 다음과 같이 변경",
|
"com.affine.cmdk.affine.display-language.to": "표시 언어를 다음과 같이 변경",
|
||||||
"com.affine.cmdk.affine.editor.add-to-favourites": "즐겨찾기에 추가",
|
"com.affine.cmdk.affine.editor.add-to-favourites": "즐겨찾기에 추가",
|
||||||
"com.affine.cmdk.affine.editor.edgeless.presentation-start": "프리젠테이션 시작",
|
"com.affine.cmdk.affine.editor.edgeless.presentation-start": "프리젠테이션 시작",
|
||||||
|
|||||||
@@ -560,8 +560,8 @@
|
|||||||
"com.affine.cmdk.affine.color-mode.to": "Изменить тему на",
|
"com.affine.cmdk.affine.color-mode.to": "Изменить тему на",
|
||||||
"com.affine.cmdk.affine.color-scheme.to": "Изменить цветовую схему на",
|
"com.affine.cmdk.affine.color-scheme.to": "Изменить цветовую схему на",
|
||||||
"com.affine.cmdk.affine.contact-us": "Связаться с нами",
|
"com.affine.cmdk.affine.contact-us": "Связаться с нами",
|
||||||
"com.affine.cmdk.affine.create-new-edgeless-as": "Новый Холст \"<1>{{keyWord}}</1>\"\n",
|
"com.affine.cmdk.affine.create-new-edgeless-as": "Новый Холст \"{{keyWord}}\"\n",
|
||||||
"com.affine.cmdk.affine.create-new-page-as": "Новый документ \"<1>{{keyWord}}</1>\"",
|
"com.affine.cmdk.affine.create-new-page-as": "Новый документ \"{{keyWord}}\"",
|
||||||
"com.affine.cmdk.affine.display-language.to": "Изменить язык интерфейса на",
|
"com.affine.cmdk.affine.display-language.to": "Изменить язык интерфейса на",
|
||||||
"com.affine.cmdk.affine.editor.add-to-favourites": "Добавить в Избранное",
|
"com.affine.cmdk.affine.editor.add-to-favourites": "Добавить в Избранное",
|
||||||
"com.affine.cmdk.affine.editor.edgeless.presentation-start": "Начать презентацию",
|
"com.affine.cmdk.affine.editor.edgeless.presentation-start": "Начать презентацию",
|
||||||
|
|||||||
@@ -560,7 +560,7 @@
|
|||||||
"com.affine.cmdk.affine.color-mode.to": "更改颜色模式为",
|
"com.affine.cmdk.affine.color-mode.to": "更改颜色模式为",
|
||||||
"com.affine.cmdk.affine.color-scheme.to": "更改颜色方案为",
|
"com.affine.cmdk.affine.color-scheme.to": "更改颜色方案为",
|
||||||
"com.affine.cmdk.affine.contact-us": "联系我们",
|
"com.affine.cmdk.affine.contact-us": "联系我们",
|
||||||
"com.affine.cmdk.affine.create-new-doc-and-insert": "创建并插入文档 \"<1>{{keyWord}}</1>\" ",
|
"com.affine.cmdk.affine.create-new-doc-and-insert": "创建并插入文档 \"{{keyWord}}\" ",
|
||||||
"com.affine.cmdk.affine.create-new-edgeless-as": "新建 “{{keyWord}}” 为标题的无界页面",
|
"com.affine.cmdk.affine.create-new-edgeless-as": "新建 “{{keyWord}}” 为标题的无界页面",
|
||||||
"com.affine.cmdk.affine.create-new-page-as": "新建 “{{keyWord}}” 为标题的文档",
|
"com.affine.cmdk.affine.create-new-page-as": "新建 “{{keyWord}}” 为标题的文档",
|
||||||
"com.affine.cmdk.affine.display-language.to": "更改显示语言为",
|
"com.affine.cmdk.affine.display-language.to": "更改显示语言为",
|
||||||
|
|||||||
@@ -383,18 +383,6 @@ test('can use cmdk to delete page and restore it', async ({ page }) => {
|
|||||||
await expect(restoreButton).not.toBeVisible();
|
await expect(restoreButton).not.toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('show not found item', async ({ page }) => {
|
|
||||||
await openHomePage(page);
|
|
||||||
await waitForEditorLoad(page);
|
|
||||||
await clickNewPageButton(page);
|
|
||||||
await openQuickSearchByShortcut(page);
|
|
||||||
// input title and create new page
|
|
||||||
await insertInputText(page, 'test123456');
|
|
||||||
const notFoundItem = page.getByTestId('cmdk-search-not-found');
|
|
||||||
await expect(notFoundItem).toBeVisible();
|
|
||||||
await expect(notFoundItem).toHaveText('Search for "test123456"');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('can use cmdk to search page content and scroll to it, then the block will be selected', async ({
|
test('can use cmdk to search page content and scroll to it, then the block will be selected', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
@@ -502,7 +490,7 @@ test('can use @ to open quick search to search for doc and insert into canvas',
|
|||||||
await insertInputText(page, url);
|
await insertInputText(page, url);
|
||||||
|
|
||||||
// expect the default page to be selected
|
// expect the default page to be selected
|
||||||
await expect(page.locator('[cmdk-group-items] [cmdk-item]')).toHaveCount(1);
|
await expect(page.locator('[cmdk-group-items] [cmdk-item]')).toHaveCount(3);
|
||||||
|
|
||||||
// press enter to insert the page to canvas
|
// press enter to insert the page to canvas
|
||||||
await page.keyboard.press('Enter');
|
await page.keyboard.press('Enter');
|
||||||
|
|||||||
@@ -438,6 +438,7 @@ __metadata:
|
|||||||
fake-indexeddb: "npm:^6.0.0"
|
fake-indexeddb: "npm:^6.0.0"
|
||||||
foxact: "npm:^0.2.33"
|
foxact: "npm:^0.2.33"
|
||||||
fractional-indexing: "npm:^3.2.0"
|
fractional-indexing: "npm:^3.2.0"
|
||||||
|
fuse.js: "npm:^7.0.0"
|
||||||
graphql: "npm:^16.8.1"
|
graphql: "npm:^16.8.1"
|
||||||
history: "npm:^5.3.0"
|
history: "npm:^5.3.0"
|
||||||
idb: "npm:^8.0.0"
|
idb: "npm:^8.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user