feat: add search doc modal (#7136)

This commit is contained in:
pengx17 2024-06-06 06:29:36 +00:00
parent de81527e29
commit 1439d00b61
No known key found for this signature in database
GPG Key ID: 23F23D9E8B3971ED
35 changed files with 766 additions and 274 deletions

View File

@ -1,5 +1,4 @@
import { atom } from 'jotai';
import { atomWithStorage } from 'jotai/utils';
import type { AuthProps } from '../components/affine/auth';
import type { CreateWorkspaceMode } from '../components/affine/create-workspace-modal';
@ -7,7 +6,6 @@ import type { SettingProps } from '../components/affine/setting-modal';
// modal atoms
export const openWorkspacesModalAtom = atom(false);
export const openCreateWorkspaceModalAtom = atom<CreateWorkspaceMode>(false);
export const openQuickSearchModalAtom = atom(false);
export const openSignOutModalAtom = atom(false);
export const openPaymentDisableAtom = atom(false);
export const openQuotaModalAtom = atom(false);
@ -46,11 +44,6 @@ export const authAtom = atom<AuthAtom>({
export const openDisableCloudAlertModalAtom = atom(false);
export const recentPageIdsBaseAtom = atomWithStorage<string[]>(
'recentPageSettings',
[]
);
export type AllPageFilterOption = 'docs' | 'collections' | 'tags';
export const allPageFilterSelectAtom = atom<AllPageFilterOption>('docs');

View File

@ -5,17 +5,14 @@ import { appSettingAtom } from '@toeverything/infra';
import type { createStore } from 'jotai';
import type { useTheme } from 'next-themes';
import { openQuickSearchModalAtom } from '../atoms';
import type { useLanguageHelper } from '../hooks/affine/use-language-helper';
import { mixpanel } from '../utils';
import { PreconditionStrategy, registerAffineCommand } from './registry';
import { registerAffineCommand } from './registry';
export function registerAffineSettingsCommands({
t,
store,
theme,
languageHelper,
editor,
}: {
t: ReturnType<typeof useAFFiNEI18N>;
store: ReturnType<typeof createStore>;
@ -25,36 +22,6 @@ export function registerAffineSettingsCommands({
}) {
const unsubs: Array<() => void> = [];
const { onLanguageChange, languagesList, currentLanguage } = languageHelper;
unsubs.push(
registerAffineCommand({
id: 'affine:show-quick-search',
preconditionStrategy: PreconditionStrategy.Never,
category: 'affine:general',
keyBinding: {
binding: '$mod+K',
},
label: '',
icon: <SettingsIcon />,
run() {
mixpanel.track('QuickSearchOpened', {
control: 'shortcut',
});
const quickSearchModalState = store.get(openQuickSearchModalAtom);
if (!editor) {
return store.set(openQuickSearchModalAtom, !quickSearchModalState);
}
// Due to a conflict with the shortcut for creating a link after selecting text in blocksuite,
// opening the quick search modal is disabled when link-popup is visitable.
const textSelection = editor.host?.std.selection.find('text');
if (textSelection && textSelection.from.length > 0) {
const linkPopup = document.querySelector('link-popup');
if (linkPopup) return;
}
return store.set(openQuickSearchModalAtom, !quickSearchModalState);
},
})
);
// color modes
unsubs.push(

View File

@ -5,6 +5,7 @@ import {
usePromptModal,
} from '@affine/component';
import { useJournalInfoHelper } from '@affine/core/hooks/use-journal';
import { QuickSearchService } from '@affine/core/modules/cmdk';
import { PeekViewService } from '@affine/core/modules/peek-view';
import { WorkbenchService } from '@affine/core/modules/workbench';
import type { BlockSpec } from '@blocksuite/block-std';
@ -33,6 +34,7 @@ import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title';
import {
patchNotificationService,
patchPeekViewService,
patchQuickSearchService,
patchReferenceRenderer,
type ReferenceReactRenderer,
} from './specs/custom/spec-patchers';
@ -70,6 +72,7 @@ interface BlocksuiteEditorProps {
const usePatchSpecs = (page: Doc, specs: BlockSpec[]) => {
const [reactToLit, portals] = useLitPortalFactory();
const peekViewService = useService(PeekViewService);
const quickSearchService = useService(QuickSearchService);
const referenceRenderer: ReferenceReactRenderer = useMemo(() => {
return function customReference(reference) {
const pageId = reference.delta.attributes?.reference?.pageId;
@ -91,12 +94,16 @@ const usePatchSpecs = (page: Doc, specs: BlockSpec[]) => {
if (!page.readonly && runtimeConfig.enablePeekView) {
patched = patchPeekViewService(patched, peekViewService);
}
if (!page.readonly) {
patched = patchQuickSearchService(patched, quickSearchService);
}
return patched;
}, [
confirmModal,
openPromptModal,
page.readonly,
peekViewService,
quickSearchService,
reactToLit,
referenceRenderer,
specs,

View File

@ -7,6 +7,7 @@ import {
type useConfirmModal,
type usePromptModal,
} from '@affine/component';
import type { QuickSearchService } from '@affine/core/modules/cmdk';
import type { PeekViewService } from '@affine/core/modules/peek-view';
import type { ActivePeekView } from '@affine/core/modules/peek-view/entities/peek-view';
import { DebugLogger } from '@affine/debug';
@ -237,3 +238,37 @@ export function patchPeekViewService(
return specs;
}
export function patchQuickSearchService(
specs: BlockSpec[],
service: QuickSearchService
) {
const rootSpec = specs.find(
spec => spec.schema.model.flavour === 'affine:page'
) as BlockSpec<string, RootService>;
if (!rootSpec) {
return specs;
}
patchSpecService(rootSpec, pageService => {
pageService.quickSearchService = {
async searchDoc(options) {
const result = await service.quickSearch.search(options.userInput);
if (result) {
if ('docId' in result) {
return result;
} else {
return {
userInput: result.query,
action: 'insert',
};
}
}
return null;
},
};
});
return specs;
}

View File

@ -22,19 +22,22 @@ export const usePageHelper = (docCollection: DocCollection) => {
);
const createPageAndOpen = useCallback(
(mode?: 'page' | 'edgeless') => {
(mode?: 'page' | 'edgeless', open?: boolean) => {
const page = createDoc();
initEmptyPage(page);
docRecordList.doc$(page.id).value?.setMode(mode || 'page');
openPage(docCollection.id, page.id);
if (open !== false) openPage(docCollection.id, page.id);
return page;
},
[docCollection.id, createDoc, openPage, docRecordList]
);
const createEdgelessAndOpen = useCallback(() => {
return createPageAndOpen('edgeless');
}, [createPageAndOpen]);
const createEdgelessAndOpen = useCallback(
(open?: boolean) => {
return createPageAndOpen('edgeless', open);
},
[createPageAndOpen]
);
const importFileAndOpen = useMemo(
() => async () => {

View File

@ -1,10 +1,13 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { AffineEditorContainer } from '@blocksuite/presets';
import { useService, WorkspaceService } from '@toeverything/infra';
import { useStore } from 'jotai';
import { useTheme } from 'next-themes';
import { useEffect } from 'react';
import {
PreconditionStrategy,
registerAffineCommand,
registerAffineCreationCommands,
registerAffineHelpCommands,
registerAffineLayoutCommands,
@ -13,10 +16,46 @@ import {
registerAffineUpdatesCommands,
} from '../commands';
import { usePageHelper } from '../components/blocksuite/block-suite-page-list/utils';
import { QuickSearchService } from '../modules/cmdk';
import { useLanguageHelper } from './affine/use-language-helper';
import { useActiveBlocksuiteEditor } from './use-block-suite-editor';
import { useNavigateHelper } from './use-navigate-helper';
function hasLinkPopover(editor: AffineEditorContainer | null) {
const textSelection = editor?.host?.std.selection.find('text');
if (textSelection && textSelection.from.length > 0) {
const linkPopup = document.querySelector('link-popup');
if (linkPopup) {
return true;
}
}
return false;
}
function registerCMDKCommand(
qsService: QuickSearchService,
editor: AffineEditorContainer | null
) {
return registerAffineCommand({
id: 'affine:show-quick-search',
preconditionStrategy: PreconditionStrategy.Never,
category: 'affine:general',
keyBinding: {
binding: '$mod+K',
},
label: '',
icon: '',
run() {
// Due to a conflict with the shortcut for creating a link after selecting text in blocksuite,
// opening the quick search modal is disabled when link-popup is visitable.
if (hasLinkPopover(editor)) {
return;
}
qsService.quickSearch.toggle();
},
});
}
export function useRegisterWorkspaceCommands() {
const store = useStore();
const t = useAFFiNEI18N();
@ -26,6 +65,15 @@ export function useRegisterWorkspaceCommands() {
const pageHelper = usePageHelper(currentWorkspace.docCollection);
const navigationHelper = useNavigateHelper();
const [editor] = useActiveBlocksuiteEditor();
const quickSearch = useService(QuickSearchService);
useEffect(() => {
const unsub = registerCMDKCommand(quickSearch, editor);
return () => {
unsub();
};
}, [editor, quickSearch]);
// register AffineUpdatesCommands
useEffect(() => {

View File

@ -14,13 +14,13 @@ import {
useService,
WorkspaceService,
} from '@toeverything/infra';
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
import { useAtomValue, useSetAtom } from 'jotai';
import type { PropsWithChildren, ReactNode } from 'react';
import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { Map as YMap } from 'yjs';
import { openQuickSearchModalAtom, openSettingModalAtom } from '../atoms';
import { openSettingModalAtom } from '../atoms';
import { WorkspaceAIOnboarding } from '../components/affine/ai-onboarding';
import { AppContainer } from '../components/affine/app-container';
import { SyncAwareness } from '../components/affine/awareness';
@ -38,6 +38,7 @@ import {
import { useRegisterFindInPageCommands } from '../hooks/affine/use-register-find-in-page-commands';
import { useNavigateHelper } from '../hooks/use-navigate-helper';
import { useRegisterWorkspaceCommands } from '../hooks/use-register-workspace-commands';
import { QuickSearchService } from '../modules/cmdk';
import { useRegisterNavigationCommands } from '../modules/navigation/view/use-register-navigation-commands';
import { WorkbenchService } from '../modules/workbench';
import {
@ -50,21 +51,25 @@ import { mixpanel } from '../utils';
import * as styles from './styles.css';
const CMDKQuickSearchModal = lazy(() =>
import('../components/pure/cmdk').then(module => ({
import('../modules/cmdk/views').then(module => ({
default: module.CMDKQuickSearchModal,
}))
);
export const QuickSearch = () => {
const [openQuickSearchModal, setOpenQuickSearchModalAtom] = useAtom(
openQuickSearchModalAtom
);
const quickSearch = useService(QuickSearchService).quickSearch;
const open = useLiveData(quickSearch.show$);
const onToggleQuickSearch = useCallback(
(open: boolean) => {
setOpenQuickSearchModalAtom(open);
if (open) {
// should never be here
quickSearch.show();
} else {
quickSearch.hide();
}
},
[setOpenQuickSearchModalAtom]
[quickSearch]
);
const docRecordList = useService(DocsService).list;
@ -78,7 +83,7 @@ export const QuickSearch = () => {
return (
<CMDKQuickSearchModal
open={openQuickSearchModal}
open={open}
onOpenChange={onToggleQuickSearch}
pageMeta={pageMeta}
/>
@ -145,14 +150,14 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
return pageHelper.createPage();
}, [pageHelper]);
const [, setOpenQuickSearchModalAtom] = useAtom(openQuickSearchModalAtom);
const quickSearch = useService(QuickSearchService).quickSearch;
const handleOpenQuickSearchModal = useCallback(() => {
setOpenQuickSearchModalAtom(true);
quickSearch.show();
mixpanel.track('QuickSearchOpened', {
segment: 'navigation panel',
control: 'search button',
});
}, [setOpenQuickSearchModalAtom]);
}, [quickSearch]);
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom);

View File

@ -0,0 +1,146 @@
import type {
DocRecord,
DocsService,
WorkspaceService,
} from '@toeverything/infra';
import { Entity, LiveData } from '@toeverything/infra';
import { resolveLinkToDoc } from '../../navigation';
type QuickSearchMode = 'commands' | 'docs';
export type SearchCallbackResult =
| {
docId: string;
blockId?: string;
}
| {
query: string;
action: 'insert';
};
// todo: move command registry to entity as well
export class QuickSearch extends Entity {
constructor(
private readonly docsService: DocsService,
private readonly workspaceService: WorkspaceService
) {
super();
}
private readonly state$ = new LiveData<{
mode: QuickSearchMode;
query: string;
callback?: (result: SearchCallbackResult | null) => void;
} | null>(null);
readonly show$ = this.state$.map(s => !!s);
show = (
mode: QuickSearchMode | null = 'commands',
opts: {
callback?: (res: SearchCallbackResult | null) => void;
query?: string;
} = {}
) => {
if (this.state$.value?.callback) {
this.state$.value.callback(null);
}
if (mode === null) {
this.state$.next(null);
} else {
this.state$.next({
mode,
query: opts.query ?? '',
callback: opts.callback,
});
}
};
mode$ = this.state$.map(s => s?.mode);
query$ = this.state$.map(s => s?.query || '');
setQuery = (query: string) => {
if (!this.state$.value) return;
this.state$.next({
...this.state$.value,
query,
});
};
hide() {
return this.show(null);
}
toggle() {
return this.show$.value ? this.hide() : this.show();
}
search(query?: string) {
const { promise, resolve } =
Promise.withResolvers<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;
}
}

View File

@ -0,0 +1,22 @@
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]);
}

View File

@ -0,0 +1,7 @@
import { Service } from '@toeverything/infra';
import { QuickSearch } from '../entities/quick-search';
export class QuickSearchService extends Service {
public readonly quickSearch = this.framework.createEntity(QuickSearch);
}

View File

@ -0,0 +1,43 @@
import type {
DocRecord,
DocsService,
WorkspaceLocalState,
} from '@toeverything/infra';
import { Service } from '@toeverything/infra';
const RECENT_PAGES_LIMIT = 3; // adjust this?
const RECENT_PAGES_KEY = 'recent-pages';
const EMPTY_ARRAY: string[] = [];
export class RecentPagesService extends Service {
constructor(
private readonly localState: WorkspaceLocalState,
private readonly docsService: DocsService
) {
super();
}
addRecentDoc(pageId: string) {
let recentPages = this.getRecentDocIds();
recentPages = recentPages.filter(id => id !== pageId);
if (recentPages.length >= RECENT_PAGES_LIMIT) {
recentPages.pop();
}
recentPages.unshift(pageId);
this.localState.set(RECENT_PAGES_KEY, recentPages);
}
getRecentDocs() {
const docs = this.docsService.list.docs$.value;
return this.getRecentDocIds()
.map(id => docs.find(doc => doc.id === id))
.filter((d): d is DocRecord => !!d);
}
private getRecentDocIds() {
return (
this.localState.get<string[] | null>(RECENT_PAGES_KEY) || EMPTY_ARRAY
);
}
}

View File

@ -7,6 +7,11 @@ import {
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';
@ -20,27 +25,19 @@ import {
} from '@blocksuite/icons';
import type { DocRecord, Workspace } from '@toeverything/infra';
import {
DocsService,
GlobalContextService,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { atom, useAtomValue } from 'jotai';
import { atom } from 'jotai';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { recentPageIdsBaseAtom } from '../../../atoms';
import { usePageHelper } from '../../../components/blocksuite/block-suite-page-list/utils';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
import { filterSortAndGroupCommands } from './filter-commands';
import type { CMDKCommand, CommandContext } from './types';
interface SearchResultsValue {
space: string;
content: string;
}
export const cmdkQueryAtom = atom('');
export const cmdkValueAtom = atom('');
function filterCommandByContext(
@ -75,29 +72,14 @@ function getAllCommand(context: CommandContext) {
});
}
const useRecentDocs = () => {
const docs = useLiveData(useService(DocsService).list.docs$);
const recentPageIds = useAtomValue(recentPageIdsBaseAtom);
return useMemo(() => {
return recentPageIds
.map(pageId => {
const page = docs.find(page => page.id === pageId);
return page;
})
.filter((p): p is DocRecord => !!p);
}, [recentPageIds, docs]);
};
export const docToCommand = (
const docToCommand = (
category: CommandCategory,
doc: DocRecord,
navigationHelper: ReturnType<typeof useNavigateHelper>,
run: () => void,
getPageTitle: ReturnType<typeof useGetDocCollectionPageTitle>,
isPageJournal: (pageId: string) => boolean,
t: ReturnType<typeof useAFFiNEI18N>,
workspace: Workspace,
subTitle?: string,
blockId?: string
subTitle?: string
): CMDKCommand => {
const docMode = doc.mode$.value;
@ -107,8 +89,6 @@ export const docToCommand = (
subTitle: subTitle,
};
// hack: when comparing, the part between >>> and <<< will be ignored
// adding this patch so that CMDK will not complain about duplicated commands
const id = category + '.' + doc.id;
const icon = isPageJournal(doc.id) ? (
@ -123,33 +103,23 @@ export const docToCommand = (
id,
label: commandLabel,
category: category,
run: () => {
if (!workspace) {
console.error('current workspace not found');
return;
}
if (blockId) {
return navigationHelper.jumpToPageBlock(workspace.id, doc.id, blockId);
}
return navigationHelper.jumpToPage(workspace.id, doc.id);
},
originalValue: title,
run: run,
icon: icon,
timestamp: doc.meta?.updatedDate,
};
};
export const usePageCommands = () => {
const recentDocs = useRecentDocs();
const docs = useLiveData(useService(DocsService).list.docs$);
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 pageHelper = usePageHelper(workspace.docCollection);
const pageMetaHelper = useDocMetaHelper(workspace.docCollection);
const query = useAtomValue(cmdkQueryAtom);
const navigationHelper = useNavigateHelper();
const journalHelper = useJournalHelper(workspace.docCollection);
const t = useAFFiNEI18N();
const getPageTitle = useGetDocCollectionPageTitle(workspace.docCollection);
const { isPageJournal } = useJournalHelper(workspace.docCollection);
const t = useAFFiNEI18N();
const [searchTime, setSearchTime] = useState<number>(0);
@ -170,134 +140,229 @@ export const usePageCommands = () => {
return useMemo(() => {
searchTime; // hack to make the searchTime as a dependency
let results: CMDKCommand[] = [];
if (query.trim() === '') {
results = recentDocs.map(doc => {
if (query.trim().length === 0) {
return recentPages.getRecentDocs().map(doc => {
return docToCommand(
'affine:recent',
doc,
navigationHelper,
() => onSelect({ docId: doc.id }),
getPageTitle,
isPageJournal,
t,
workspace
t
);
});
} else {
// queried pages that has matched contents
// TODO: we shall have a debounce for global search here
const searchResults = workspace.docCollection.search({
query,
}) as unknown as Map<string, SearchResultsValue>;
const resultValues = Array.from(searchResults.values());
return quickSearch
.getSearchedDocs(query)
.map(({ blockId, content, doc, source }) => {
const category = 'affine:pages';
const reverseMapping: Map<string, string> = new Map();
searchResults.forEach((value, key) => {
reverseMapping.set(value.space, key);
});
const command = docToCommand(
category,
doc,
() =>
onSelect({
docId: doc.id,
blockId,
}),
getPageTitle,
isPageJournal,
t,
content
);
results = docs.map(doc => {
const category = 'affine:pages';
if (source === 'link-ref') {
command.alwaysShow = true;
command.originalValue = query;
}
const subTitle = resultValues.find(
result => result.space === doc.id
)?.content;
const blockId = reverseMapping.get(doc.id);
const command = docToCommand(
category,
doc,
navigationHelper,
getPageTitle,
isPageJournal,
t,
workspace,
subTitle,
blockId
);
return command;
});
// check if the pages have exact match. if not, we should show the "create page" command
if (results.every(command => command.originalValue !== query)) {
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 />,
return command;
});
results.push({
id: 'affine:pages:create-page',
label: t['com.affine.cmdk.affine.create-new-page-as']({
keyWord: query,
}),
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: t['com.affine.cmdk.affine.create-new-edgeless-as']({
keyWord: query,
}),
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;
}, [
searchTime,
query,
recentDocs,
navigationHelper,
recentPages,
getPageTitle,
isPageJournal,
t,
workspace,
docs,
onSelect,
quickSearch,
]);
}
export const usePageCommands = () => {
const quickSearch = useService(QuickSearchService).quickSearch;
const workspace = useService(WorkspaceService).workspace;
const pageHelper = usePageHelper(workspace.docCollection);
const pageMetaHelper = useDocMetaHelper(workspace.docCollection);
const query = useLiveData(quickSearch.query$);
const navigationHelper = useNavigateHelper();
const journalHelper = useJournalHelper(workspace.docCollection);
const t = useAFFiNEI18N();
const onSelectPage = useCallback(
(opts: { docId: string; blockId?: string }) => {
if (!workspace) {
console.error('current workspace not found');
return;
}
if (opts.blockId) {
navigationHelper.jumpToPageBlock(
workspace.id,
opts.docId,
opts.blockId
);
} else {
navigationHelper.jumpToPage(workspace.id, opts.docId);
}
},
[navigationHelper, workspace]
);
const searchedDocsCommands = useSearchedDocCommands(onSelectPage);
return useMemo(() => {
const results: CMDKCommand[] = [...searchedDocsCommands];
// check if the pages have exact match. if not, we should show the "create page" command
if (
results.every(command => command.originalValue !== query) &&
query.trim()
) {
results.push({
id: 'affine:pages:append-to-journal',
label: t['com.affine.journal.cmdk.append-to-today'](),
alwaysShow: true,
category: 'affine:creation',
run: async () => {
const appendRes = await journalHelper.appendContentToToday(query);
if (!appendRes) return;
const { page, blockId } = appendRes;
navigationHelper.jumpToPageBlock(
page.collection.id,
page.id,
blockId
);
mixpanel.track('AppendToJournal', {
control: 'cmdk',
});
},
icon: <TodayIcon />,
});
results.push({
id: 'affine:pages:create-page',
label: t['com.affine.cmdk.affine.create-new-page-as']({
keyWord: query,
}),
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: t['com.affine.cmdk.affine.create-new-edgeless-as']({
keyWord: query,
}),
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: 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 t = useAFFiNEI18N();
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()
) {
results.push({
id: 'affine:pages:create-page',
label: t['com.affine.cmdk.affine.create-new-doc-and-insert']({
keyWord: query,
}),
alwaysShow: true,
category: 'affine:creation',
run: async () => {
const page = pageHelper.createPage('page', false);
page.load();
pageMetaHelper.setDocTitle(page.id, query);
mixpanel.track('DocCreated', {
control: 'cmdk',
type: 'doc',
});
onSelectPage({ docId: page.id });
},
icon: <PageIcon />,
});
}
return results;
}, [
searchedDocsCommands,
query,
t,
pageHelper,
pageMetaHelper,
onSelectPage,
]);
};
export const collectionToCommand = (
collection: Collection,
navigationHelper: ReturnType<typeof useNavigateHelper>,
@ -323,7 +388,8 @@ export const useCollectionsCommands = () => {
// todo: considering collections for searching pages
const collectionService = useService(CollectionService);
const collections = useLiveData(collectionService.collections$);
const query = useAtomValue(cmdkQueryAtom);
const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$);
const navigationHelper = useNavigateHelper();
const t = useAFFiNEI18N();
const workspace = useService(WorkspaceService).workspace;
@ -365,7 +431,8 @@ export const useCMDKCommandGroups = () => {
docMode: currentDocMode,
});
}, [currentDocMode]);
const query = useAtomValue(cmdkQueryAtom).trim();
const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$).trim();
return useMemo(() => {
const commands = [
@ -376,3 +443,15 @@ export const useCMDKCommandGroups = () => {
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]);
};

View File

@ -15,13 +15,16 @@ export const searchInputContainer = style({
gap: 12,
borderBottom: `1px solid ${cssVar('borderColor')}`,
flexShrink: 0,
selectors: {
'&.inEditor': {
paddingTop: '12px',
paddingBottom: '18px',
},
},
});
export const hasInputLabel = style([
searchInputContainer,
{
paddingTop: '12px',
paddingBottom: '18px',
},
]);
export const searchInput = style({
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontH5'),
@ -109,7 +112,7 @@ globalStyle(`${root} [cmdk-group][hidden]`, {
});
globalStyle(`${root} [cmdk-list]`, {
maxHeight: 400,
minHeight: 120,
minHeight: 80,
overflow: 'auto',
overscrollBehavior: 'contain',
height: 'min(330px, calc(var(--cmdk-list-height) + 8px))',

View File

@ -3,18 +3,27 @@ import type { CommandCategory } from '@affine/core/commands';
import { formatDate } from '@affine/core/components/page-list';
import { useDocEngineStatus } from '@affine/core/hooks/affine/use-doc-engine-status';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { QuickSearchService } from '@affine/core/modules/cmdk';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { Command } from 'cmdk';
import { useDebouncedValue } from 'foxact/use-debounced-value';
import { useAtom } from 'jotai';
import { Suspense, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
type ReactNode,
Suspense,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
cmdkQueryAtom,
cmdkValueAtom,
useCMDKCommandGroups,
useSearchCallbackCommandGroups,
} from './data-hooks';
import { HighlightLabel } from './highlight';
import * as styles from './main.css';
@ -59,18 +68,18 @@ const QuickSearchGroup = ({
}) => {
const t = useAFFiNEI18N();
const i18nKey = categoryToI18nKey[category];
const [query, setQuery] = useAtom(cmdkQueryAtom);
const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$);
const onCommendSelect = useAsyncCallback(
async (command: CMDKCommand) => {
try {
await command.run();
} finally {
setQuery('');
onOpenChange?.(false);
}
},
[setQuery, onOpenChange]
[onOpenChange]
);
return (
@ -149,20 +158,19 @@ export const CMDKContainer = ({
onQueryChange,
query,
children,
pageMeta,
inputLabel,
open,
...rest
}: React.PropsWithChildren<{
open: boolean;
className?: string;
query: string;
pageMeta?: Partial<DocMeta>;
inputLabel?: ReactNode;
groups: ReturnType<typeof useCMDKCommandGroups>;
onQueryChange: (query: string) => void;
}>) => {
const t = useAFFiNEI18N();
const [value, setValue] = useAtom(cmdkValueAtom);
const isInEditor = pageMeta !== undefined;
const [opening, setOpening] = useState(open);
const { syncing, progress } = useDocEngineStatus();
const showLoading = useDebouncedValue(syncing, 500);
@ -197,16 +205,14 @@ export const CMDKContainer = ({
loop
>
{/* todo: add page context here */}
{isInEditor ? (
{inputLabel ? (
<div className={styles.pageTitleWrapper}>
<span className={styles.pageTitle}>
{pageMeta.title ? pageMeta.title : t['Untitled']()}
</span>
<span className={styles.pageTitle}>{inputLabel}</span>
</div>
) : null}
<div
className={clsx(className, styles.searchInputContainer, {
inEditor: isInEditor,
[styles.hasInputLabel]: inputLabel,
})}
>
{showLoading ? (
@ -239,20 +245,41 @@ const CMDKQuickSearchModalInner = ({
open,
...props
}: CMDKModalProps & { pageMeta?: Partial<DocMeta> }) => {
const [query, setQuery] = useAtom(cmdkQueryAtom);
useLayoutEffect(() => {
if (open) {
setQuery('');
}
}, [open, setQuery]);
const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$);
const groups = useCMDKCommandGroups();
const t = useAFFiNEI18N();
return (
<CMDKContainer
className={styles.root}
query={query}
groups={groups}
onQueryChange={setQuery}
pageMeta={pageMeta}
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 = useAFFiNEI18N();
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} />
@ -265,10 +292,17 @@ export const CMDKQuickSearchModal = ({
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 />}>
<CMDKQuickSearchModalInner
<InnerComp
pageMeta={pageMeta}
open={open}
onOpenChange={props.onOpenChange}

View File

@ -1,15 +1,20 @@
import { QuickSearchService } from '@affine/core/modules/cmdk';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { SearchIcon } from '@blocksuite/icons';
import { useLiveData, useService } from '@toeverything/infra';
import { useCommandState } from 'cmdk';
import { useAtomValue } from 'jotai';
import { cmdkQueryAtom } from './data-hooks';
import * as styles from './not-found.css';
export const NotFoundGroup = () => {
const query = useAtomValue(cmdkQueryAtom);
// hack: we know that the filtered count is 3 when there is no result (create page & edgeless & append to journal)
const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$);
const mode = useLiveData(quickSearch.mode$);
// 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) || false;
useCommandState(state => state.filtered.count === 3) && mode === 'commands';
const t = useAFFiNEI18N();
if (!renderNoResult) {
return null;
@ -24,7 +29,9 @@ export const NotFoundGroup = () => {
<div className={styles.notFoundIcon}>
<SearchIcon />
</div>
<div className={styles.notFoundText}>No results found</div>
<div className={styles.notFoundText}>
{t['com.affine.cmdk.no-results']()}
</div>
</div>
</div>
);

View File

@ -2,6 +2,7 @@ import { configureQuotaModule } from '@affine/core/modules/quota';
import { configureInfraModules, type Framework } from '@toeverything/infra';
import { configureCloudModule } from './cloud';
import { configureQuickSearchModule } from './cmdk';
import { configureCollectionModule } from './collection';
import { configureFindInPageModule } from './find-in-page';
import { configureNavigationModule } from './navigation';
@ -30,6 +31,7 @@ export function configureCommonModules(framework: Framework) {
configureTelemetryModule(framework);
configureFindInPageModule(framework);
configurePeekViewModule(framework);
configureQuickSearchModule(framework);
}
export function configureImpls(framework: Framework) {

View File

@ -0,0 +1,67 @@
import { afterEach } from 'node:test';
import { beforeEach, expect, test, vi } from 'vitest';
import { resolveLinkToDoc } from '../utils';
function defineTest(
input: string,
expected: ReturnType<typeof resolveLinkToDoc>
) {
test(`resolveLinkToDoc(${input})`, () => {
const result = resolveLinkToDoc(input);
expect(result).toEqual(expected);
});
}
beforeEach(() => {
vi.stubGlobal('location', { origin: 'http://affine.pro' });
});
afterEach(() => {
vi.restoreAllMocks();
});
const testCases: [string, ReturnType<typeof resolveLinkToDoc>][] = [
['http://example.com/', null],
[
'/workspace/48__RTCSwASvWZxyAk3Jw/-Uge-K6SYcAbcNYfQ5U-j#xxxx',
{
workspaceId: '48__RTCSwASvWZxyAk3Jw',
docId: '-Uge-K6SYcAbcNYfQ5U-j',
blockId: 'xxxx',
},
],
[
'http://affine.pro/workspace/48__RTCSwASvWZxyAk3Jw/-Uge-K6SYcAbcNYfQ5U-j#xxxx',
{
workspaceId: '48__RTCSwASvWZxyAk3Jw',
docId: '-Uge-K6SYcAbcNYfQ5U-j',
blockId: 'xxxx',
},
],
['http://affine.pro/workspace/48__RTCSwASvWZxyAk3Jw/all', null],
['http://affine.pro/workspace/48__RTCSwASvWZxyAk3Jw/collection', null],
['http://affine.pro/workspace/48__RTCSwASvWZxyAk3Jw/tag', null],
['http://affine.pro/workspace/48__RTCSwASvWZxyAk3Jw/trash', null],
[
'file//./workspace/48__RTCSwASvWZxyAk3Jw/-Uge-K6SYcAbcNYfQ5U-j#xxxx',
{
workspaceId: '48__RTCSwASvWZxyAk3Jw',
docId: '-Uge-K6SYcAbcNYfQ5U-j',
blockId: 'xxxx',
},
],
[
'http//localhost:8000/workspace/48__RTCSwASvWZxyAk3Jw/-Uge-K6SYcAbcNYfQ5U-j#xxxx',
{
workspaceId: '48__RTCSwASvWZxyAk3Jw',
docId: '-Uge-K6SYcAbcNYfQ5U-j',
blockId: 'xxxx',
},
],
];
for (const [input, expected] of testCases) {
defineTest(input, expected);
}

View File

@ -1,4 +1,5 @@
export { Navigator } from './entities/navigator';
export { resolveLinkToDoc } from './utils';
export { NavigationButtons } from './view/navigation-buttons';
import { type Framework, WorkspaceScope } from '@toeverything/infra';

View File

@ -0,0 +1,40 @@
function maybeAffineOrigin(origin: string) {
return (
origin.startsWith('file://.') ||
origin.startsWith('affine://') ||
origin.endsWith('affine.pro') || // stable/beta
origin.endsWith('affine.fail') || // canary
origin.includes('localhost') // dev
);
}
export const resolveLinkToDoc = (href: string) => {
try {
const url = new URL(href, location.origin);
// check if origin is one of affine's origins
if (!maybeAffineOrigin(url.origin)) {
return null;
}
// http://xxx/workspace/48__RTCSwASvWZxyAk3Jw/-Uge-K6SYcAbcNYfQ5U-j#xxxx
// to { workspaceId: '48__RTCSwASvWZxyAk3Jw', docId: '-Uge-K6SYcAbcNYfQ5U-j', blockId: 'xxxx' }
const [_, workspaceId, docId, blockId] =
url.toString().match(/\/workspace\/([^/]+)\/([^#]+)(?:#(.+))?/) || [];
/**
* @see /packages/frontend/core/src/router.tsx
*/
const excludedPaths = ['all', 'collection', 'tag', 'trash'];
if (!docId || excludedPaths.includes(docId)) {
return null;
}
return { workspaceId, docId, blockId };
} catch {
return null;
}
};

View File

@ -29,6 +29,8 @@ export type ActivePeekView = {
import type { BlockModel } from '@blocksuite/store';
import { resolveLinkToDoc } from '../../navigation';
const EMBED_DOC_FLAVOURS = [
'affine:embed-linked-doc',
'affine:embed-synced-doc',
@ -46,25 +48,6 @@ const isSurfaceRefModel = (
return blockModel.flavour === 'affine:surface-ref';
};
const resolveLinkToDoc = (href: string) => {
// http://xxx/workspace/48__RTCSwASvWZxyAk3Jw/-Uge-K6SYcAbcNYfQ5U-j#xxxx
// to { workspaceId: '48__RTCSwASvWZxyAk3Jw', docId: '-Uge-K6SYcAbcNYfQ5U-j', blockId: 'xxxx' }
const [_, workspaceId, docId, blockId] =
href.match(/\/workspace\/([^/]+)\/([^#]+)(?:#(.+))?/) || [];
/**
* @see /packages/frontend/core/src/router.tsx
*/
const excludedPaths = ['all', 'collection', 'tag', 'trash'];
if (!docId || excludedPaths.includes(docId)) {
return null;
}
return { workspaceId, docId, blockId };
};
function resolvePeekInfoFromPeekTarget(
peekTarget?: PeekViewTarget
): DocPeekViewInfo | null {

View File

@ -2,6 +2,7 @@ import { Scrollable } from '@affine/component';
import { PageDetailSkeleton } from '@affine/component/page-detail-skeleton';
import { PageAIOnboarding } from '@affine/core/components/affine/ai-onboarding';
import { useAppSettingHelper } from '@affine/core/hooks/affine/use-app-setting-helper';
import { RecentPagesService } from '@affine/core/modules/cmdk';
import type { PageRootService } from '@blocksuite/blocks';
import {
BookmarkBlockService,
@ -28,13 +29,11 @@ import {
WorkspaceService,
} from '@toeverything/infra';
import clsx from 'clsx';
import { useSetAtom } from 'jotai';
import type { ReactElement } from 'react';
import { memo, useCallback, useEffect, useLayoutEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import type { Map as YMap } from 'yjs';
import { recentPageIdsBaseAtom } from '../../../atoms';
import { AffineErrorBoundary } from '../../../components/affine/affine-error-boundary';
import { GlobalPageHistoryModal } from '../../../components/affine/page-history-modal';
import { ImagePreviewModal } from '../../../components/image-preview';
@ -369,19 +368,16 @@ export const Component = () => {
performanceRenderLogger.info('DetailPage');
const params = useParams();
const setRecentPageIds = useSetAtom(recentPageIdsBaseAtom);
const recentPages = useService(RecentPagesService);
useEffect(() => {
if (params.pageId) {
const pageId = params.pageId;
localStorage.setItem('last_page_id', pageId);
setRecentPageIds(ids => {
// pick 3 recent page ids
return [...new Set([pageId, ...ids]).values()].slice(0, 3);
});
recentPages.addRecentDoc(pageId);
}
}, [params, setRecentPageIds]);
}, [params, recentPages]);
const pageId = params.pageId;

View File

@ -555,6 +555,7 @@
"com.affine.cmdk.affine.contact-us": "Contact Us",
"com.affine.cmdk.affine.create-new-edgeless-as": "New \"{{keyWord}}\" Edgeless",
"com.affine.cmdk.affine.create-new-page-as": "New \"{{keyWord}}\" Page",
"com.affine.cmdk.affine.create-new-doc-and-insert": "Create \"{{keyWord}}\" Doc and insert",
"com.affine.cmdk.affine.display-language.to": "Change Display Language to",
"com.affine.cmdk.affine.editor.add-to-favourites": "Add to Favourites",
"com.affine.cmdk.affine.editor.edgeless.presentation-start": "Start Presentation",
@ -585,6 +586,9 @@
"com.affine.cmdk.affine.translucent-ui-on-the-sidebar.to": "Change Translucent UI On The Sidebar to",
"com.affine.cmdk.affine.whats-new": "What's New",
"com.affine.cmdk.placeholder": "Type a command or search anything...",
"com.affine.cmdk.no-results": "No results found",
"com.affine.cmdk.no-results-for": "No results found for",
"com.affine.cmdk.insert-links": "Insert links",
"com.affine.collection-bar.action.tooltip.delete": "Delete",
"com.affine.collection-bar.action.tooltip.edit": "Edit",
"com.affine.collection-bar.action.tooltip.pin": "Pin to Sidebar",