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

View File

@ -5,17 +5,14 @@ import { appSettingAtom } from '@toeverything/infra';
import type { createStore } from 'jotai'; import type { createStore } from 'jotai';
import type { useTheme } from 'next-themes'; import type { useTheme } from 'next-themes';
import { openQuickSearchModalAtom } from '../atoms';
import type { useLanguageHelper } from '../hooks/affine/use-language-helper'; import type { useLanguageHelper } from '../hooks/affine/use-language-helper';
import { mixpanel } from '../utils'; import { registerAffineCommand } from './registry';
import { PreconditionStrategy, registerAffineCommand } from './registry';
export function registerAffineSettingsCommands({ export function registerAffineSettingsCommands({
t, t,
store, store,
theme, theme,
languageHelper, languageHelper,
editor,
}: { }: {
t: ReturnType<typeof useAFFiNEI18N>; t: ReturnType<typeof useAFFiNEI18N>;
store: ReturnType<typeof createStore>; store: ReturnType<typeof createStore>;
@ -25,36 +22,6 @@ export function registerAffineSettingsCommands({
}) { }) {
const unsubs: Array<() => void> = []; const unsubs: Array<() => void> = [];
const { onLanguageChange, languagesList, currentLanguage } = languageHelper; 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 // color modes
unsubs.push( unsubs.push(

View File

@ -5,6 +5,7 @@ import {
usePromptModal, usePromptModal,
} 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 type { BlockSpec } from '@blocksuite/block-std'; import type { BlockSpec } from '@blocksuite/block-std';
@ -33,6 +34,7 @@ import { BlocksuiteEditorJournalDocTitle } from './journal-doc-title';
import { import {
patchNotificationService, patchNotificationService,
patchPeekViewService, patchPeekViewService,
patchQuickSearchService,
patchReferenceRenderer, patchReferenceRenderer,
type ReferenceReactRenderer, type ReferenceReactRenderer,
} from './specs/custom/spec-patchers'; } from './specs/custom/spec-patchers';
@ -70,6 +72,7 @@ interface BlocksuiteEditorProps {
const usePatchSpecs = (page: Doc, specs: BlockSpec[]) => { const usePatchSpecs = (page: Doc, specs: BlockSpec[]) => {
const [reactToLit, portals] = useLitPortalFactory(); const [reactToLit, portals] = useLitPortalFactory();
const peekViewService = useService(PeekViewService); const peekViewService = useService(PeekViewService);
const quickSearchService = useService(QuickSearchService);
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;
@ -91,12 +94,16 @@ const usePatchSpecs = (page: Doc, specs: BlockSpec[]) => {
if (!page.readonly && runtimeConfig.enablePeekView) { if (!page.readonly && runtimeConfig.enablePeekView) {
patched = patchPeekViewService(patched, peekViewService); patched = patchPeekViewService(patched, peekViewService);
} }
if (!page.readonly) {
patched = patchQuickSearchService(patched, quickSearchService);
}
return patched; return patched;
}, [ }, [
confirmModal, confirmModal,
openPromptModal, openPromptModal,
page.readonly, page.readonly,
peekViewService, peekViewService,
quickSearchService,
reactToLit, reactToLit,
referenceRenderer, referenceRenderer,
specs, specs,

View File

@ -7,6 +7,7 @@ import {
type useConfirmModal, type useConfirmModal,
type usePromptModal, type usePromptModal,
} from '@affine/component'; } from '@affine/component';
import type { QuickSearchService } 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 { DebugLogger } from '@affine/debug'; import { DebugLogger } from '@affine/debug';
@ -237,3 +238,37 @@ export function patchPeekViewService(
return specs; 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( const createPageAndOpen = useCallback(
(mode?: 'page' | 'edgeless') => { (mode?: 'page' | 'edgeless', open?: boolean) => {
const page = createDoc(); const page = createDoc();
initEmptyPage(page); initEmptyPage(page);
docRecordList.doc$(page.id).value?.setMode(mode || 'page'); docRecordList.doc$(page.id).value?.setMode(mode || 'page');
openPage(docCollection.id, page.id); if (open !== false) openPage(docCollection.id, page.id);
return page; return page;
}, },
[docCollection.id, createDoc, openPage, docRecordList] [docCollection.id, createDoc, openPage, docRecordList]
); );
const createEdgelessAndOpen = useCallback(() => { const createEdgelessAndOpen = useCallback(
return createPageAndOpen('edgeless'); (open?: boolean) => {
}, [createPageAndOpen]); return createPageAndOpen('edgeless', open);
},
[createPageAndOpen]
);
const importFileAndOpen = useMemo( const importFileAndOpen = useMemo(
() => async () => { () => async () => {

View File

@ -1,10 +1,13 @@
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { AffineEditorContainer } from '@blocksuite/presets';
import { useService, WorkspaceService } from '@toeverything/infra'; import { useService, WorkspaceService } from '@toeverything/infra';
import { useStore } from 'jotai'; import { useStore } from 'jotai';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { import {
PreconditionStrategy,
registerAffineCommand,
registerAffineCreationCommands, registerAffineCreationCommands,
registerAffineHelpCommands, registerAffineHelpCommands,
registerAffineLayoutCommands, registerAffineLayoutCommands,
@ -13,10 +16,46 @@ 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 { 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';
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() { export function useRegisterWorkspaceCommands() {
const store = useStore(); const store = useStore();
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
@ -26,6 +65,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);
useEffect(() => {
const unsub = registerCMDKCommand(quickSearch, editor);
return () => {
unsub();
};
}, [editor, quickSearch]);
// register AffineUpdatesCommands // register AffineUpdatesCommands
useEffect(() => { useEffect(() => {

View File

@ -14,13 +14,13 @@ import {
useService, useService,
WorkspaceService, WorkspaceService,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue, useSetAtom } from 'jotai';
import type { PropsWithChildren, ReactNode } from 'react'; import type { PropsWithChildren, ReactNode } from 'react';
import { lazy, useCallback, useEffect, useMemo, useState } from 'react'; import { lazy, useCallback, useEffect, useMemo, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { Map as YMap } from 'yjs'; import { Map as YMap } from 'yjs';
import { openQuickSearchModalAtom, openSettingModalAtom } from '../atoms'; import { openSettingModalAtom } from '../atoms';
import { WorkspaceAIOnboarding } from '../components/affine/ai-onboarding'; import { WorkspaceAIOnboarding } from '../components/affine/ai-onboarding';
import { AppContainer } from '../components/affine/app-container'; import { AppContainer } from '../components/affine/app-container';
import { SyncAwareness } from '../components/affine/awareness'; import { SyncAwareness } from '../components/affine/awareness';
@ -38,6 +38,7 @@ 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 { useRegisterNavigationCommands } from '../modules/navigation/view/use-register-navigation-commands'; import { useRegisterNavigationCommands } from '../modules/navigation/view/use-register-navigation-commands';
import { WorkbenchService } from '../modules/workbench'; import { WorkbenchService } from '../modules/workbench';
import { import {
@ -50,21 +51,25 @@ import { mixpanel } from '../utils';
import * as styles from './styles.css'; import * as styles from './styles.css';
const CMDKQuickSearchModal = lazy(() => const CMDKQuickSearchModal = lazy(() =>
import('../components/pure/cmdk').then(module => ({ import('../modules/cmdk/views').then(module => ({
default: module.CMDKQuickSearchModal, default: module.CMDKQuickSearchModal,
})) }))
); );
export const QuickSearch = () => { export const QuickSearch = () => {
const [openQuickSearchModal, setOpenQuickSearchModalAtom] = useAtom( const quickSearch = useService(QuickSearchService).quickSearch;
openQuickSearchModalAtom const open = useLiveData(quickSearch.show$);
);
const onToggleQuickSearch = useCallback( const onToggleQuickSearch = useCallback(
(open: boolean) => { (open: boolean) => {
setOpenQuickSearchModalAtom(open); if (open) {
// should never be here
quickSearch.show();
} else {
quickSearch.hide();
}
}, },
[setOpenQuickSearchModalAtom] [quickSearch]
); );
const docRecordList = useService(DocsService).list; const docRecordList = useService(DocsService).list;
@ -78,7 +83,7 @@ export const QuickSearch = () => {
return ( return (
<CMDKQuickSearchModal <CMDKQuickSearchModal
open={openQuickSearchModal} open={open}
onOpenChange={onToggleQuickSearch} onOpenChange={onToggleQuickSearch}
pageMeta={pageMeta} pageMeta={pageMeta}
/> />
@ -145,14 +150,14 @@ export const WorkspaceLayoutInner = ({ children }: PropsWithChildren) => {
return pageHelper.createPage(); return pageHelper.createPage();
}, [pageHelper]); }, [pageHelper]);
const [, setOpenQuickSearchModalAtom] = useAtom(openQuickSearchModalAtom); const quickSearch = useService(QuickSearchService).quickSearch;
const handleOpenQuickSearchModal = useCallback(() => { const handleOpenQuickSearchModal = useCallback(() => {
setOpenQuickSearchModalAtom(true); quickSearch.show();
mixpanel.track('QuickSearchOpened', { mixpanel.track('QuickSearchOpened', {
segment: 'navigation panel', segment: 'navigation panel',
control: 'search button', control: 'search button',
}); });
}, [setOpenQuickSearchModalAtom]); }, [quickSearch]);
const setOpenSettingModalAtom = useSetAtom(openSettingModalAtom); 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 { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { useGetDocCollectionPageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title'; import { useGetDocCollectionPageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title';
import { useJournalHelper } from '@affine/core/hooks/use-journal'; 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 { CollectionService } from '@affine/core/modules/collection';
import { WorkspaceSubPath } from '@affine/core/shared'; import { WorkspaceSubPath } from '@affine/core/shared';
import { mixpanel } from '@affine/core/utils'; import { mixpanel } from '@affine/core/utils';
@ -20,27 +25,19 @@ import {
} from '@blocksuite/icons'; } from '@blocksuite/icons';
import type { DocRecord, Workspace } from '@toeverything/infra'; import type { DocRecord, Workspace } from '@toeverything/infra';
import { import {
DocsService,
GlobalContextService, GlobalContextService,
useLiveData, useLiveData,
useService, useService,
WorkspaceService, WorkspaceService,
} from '@toeverything/infra'; } from '@toeverything/infra';
import { atom, useAtomValue } from 'jotai'; import { atom } from 'jotai';
import { useCallback, useEffect, useMemo, useState } from 'react'; 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 { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
import { filterSortAndGroupCommands } from './filter-commands'; import { filterSortAndGroupCommands } from './filter-commands';
import type { CMDKCommand, CommandContext } from './types'; import type { CMDKCommand, CommandContext } from './types';
interface SearchResultsValue {
space: string;
content: string;
}
export const cmdkQueryAtom = atom('');
export const cmdkValueAtom = atom(''); export const cmdkValueAtom = atom('');
function filterCommandByContext( function filterCommandByContext(
@ -75,29 +72,14 @@ function getAllCommand(context: CommandContext) {
}); });
} }
const useRecentDocs = () => { const docToCommand = (
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 = (
category: CommandCategory, category: CommandCategory,
doc: DocRecord, doc: DocRecord,
navigationHelper: ReturnType<typeof useNavigateHelper>, run: () => void,
getPageTitle: ReturnType<typeof useGetDocCollectionPageTitle>, getPageTitle: ReturnType<typeof useGetDocCollectionPageTitle>,
isPageJournal: (pageId: string) => boolean, isPageJournal: (pageId: string) => boolean,
t: ReturnType<typeof useAFFiNEI18N>, t: ReturnType<typeof useAFFiNEI18N>,
workspace: Workspace, subTitle?: string
subTitle?: string,
blockId?: string
): CMDKCommand => { ): CMDKCommand => {
const docMode = doc.mode$.value; const docMode = doc.mode$.value;
@ -107,8 +89,6 @@ export const docToCommand = (
subTitle: subTitle, 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 id = category + '.' + doc.id;
const icon = isPageJournal(doc.id) ? ( const icon = isPageJournal(doc.id) ? (
@ -123,33 +103,23 @@ export const docToCommand = (
id, id,
label: commandLabel, label: commandLabel,
category: category, category: category,
run: () => { originalValue: title,
if (!workspace) { run: run,
console.error('current workspace not found');
return;
}
if (blockId) {
return navigationHelper.jumpToPageBlock(workspace.id, doc.id, blockId);
}
return navigationHelper.jumpToPage(workspace.id, doc.id);
},
icon: icon, icon: icon,
timestamp: doc.meta?.updatedDate, timestamp: doc.meta?.updatedDate,
}; };
}; };
export const usePageCommands = () => { function useSearchedDocCommands(
const recentDocs = useRecentDocs(); onSelect: (opts: { docId: string; blockId?: string }) => void
const docs = useLiveData(useService(DocsService).list.docs$); ) {
const quickSearch = useService(QuickSearchService).quickSearch;
const recentPages = useService(RecentPagesService);
const query = useLiveData(quickSearch.query$);
const workspace = useService(WorkspaceService).workspace; 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 getPageTitle = useGetDocCollectionPageTitle(workspace.docCollection);
const { isPageJournal } = useJournalHelper(workspace.docCollection); const { isPageJournal } = useJournalHelper(workspace.docCollection);
const t = useAFFiNEI18N();
const [searchTime, setSearchTime] = useState<number>(0); const [searchTime, setSearchTime] = useState<number>(0);
@ -170,134 +140,229 @@ export const usePageCommands = () => {
return useMemo(() => { return useMemo(() => {
searchTime; // hack to make the searchTime as a dependency searchTime; // hack to make the searchTime as a dependency
let results: CMDKCommand[] = []; if (query.trim().length === 0) {
if (query.trim() === '') { return recentPages.getRecentDocs().map(doc => {
results = recentDocs.map(doc => {
return docToCommand( return docToCommand(
'affine:recent', 'affine:recent',
doc, doc,
navigationHelper, () => onSelect({ docId: doc.id }),
getPageTitle, getPageTitle,
isPageJournal, isPageJournal,
t, t
workspace
); );
}); });
} else { } else {
// queried pages that has matched contents return quickSearch
// TODO: we shall have a debounce for global search here .getSearchedDocs(query)
const searchResults = workspace.docCollection.search({ .map(({ blockId, content, doc, source }) => {
query, const category = 'affine:pages';
}) as unknown as Map<string, SearchResultsValue>;
const resultValues = Array.from(searchResults.values());
const reverseMapping: Map<string, string> = new Map(); const command = docToCommand(
searchResults.forEach((value, key) => { category,
reverseMapping.set(value.space, key); doc,
}); () =>
onSelect({
docId: doc.id,
blockId,
}),
getPageTitle,
isPageJournal,
t,
content
);
results = docs.map(doc => { if (source === 'link-ref') {
const category = 'affine:pages'; command.alwaysShow = true;
command.originalValue = query;
}
const subTitle = resultValues.find( return command;
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 />,
}); });
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, searchTime,
query, query,
recentDocs, recentPages,
navigationHelper,
getPageTitle, getPageTitle,
isPageJournal, isPageJournal,
t, t,
workspace, onSelect,
docs, 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, journalHelper,
navigationHelper,
pageHelper, pageHelper,
pageMetaHelper, 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 = ( export const collectionToCommand = (
collection: Collection, collection: Collection,
navigationHelper: ReturnType<typeof useNavigateHelper>, navigationHelper: ReturnType<typeof useNavigateHelper>,
@ -323,7 +388,8 @@ export const useCollectionsCommands = () => {
// todo: considering collections for searching pages // todo: considering collections for searching pages
const collectionService = useService(CollectionService); const collectionService = useService(CollectionService);
const collections = useLiveData(collectionService.collections$); const collections = useLiveData(collectionService.collections$);
const query = useAtomValue(cmdkQueryAtom); const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$);
const navigationHelper = useNavigateHelper(); const navigationHelper = useNavigateHelper();
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const workspace = useService(WorkspaceService).workspace; const workspace = useService(WorkspaceService).workspace;
@ -365,7 +431,8 @@ export const useCMDKCommandGroups = () => {
docMode: currentDocMode, docMode: currentDocMode,
}); });
}, [currentDocMode]); }, [currentDocMode]);
const query = useAtomValue(cmdkQueryAtom).trim(); const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$).trim();
return useMemo(() => { return useMemo(() => {
const commands = [ const commands = [
@ -376,3 +443,15 @@ export const useCMDKCommandGroups = () => {
return filterSortAndGroupCommands(commands, query); return filterSortAndGroupCommands(commands, query);
}, [affineCommands, collectionCommands, pageCommands, 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, gap: 12,
borderBottom: `1px solid ${cssVar('borderColor')}`, borderBottom: `1px solid ${cssVar('borderColor')}`,
flexShrink: 0, flexShrink: 0,
selectors: {
'&.inEditor': {
paddingTop: '12px',
paddingBottom: '18px',
},
},
}); });
export const hasInputLabel = style([
searchInputContainer,
{
paddingTop: '12px',
paddingBottom: '18px',
},
]);
export const searchInput = style({ export const searchInput = style({
color: cssVar('textPrimaryColor'), color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontH5'), fontSize: cssVar('fontH5'),
@ -109,7 +112,7 @@ globalStyle(`${root} [cmdk-group][hidden]`, {
}); });
globalStyle(`${root} [cmdk-list]`, { globalStyle(`${root} [cmdk-list]`, {
maxHeight: 400, maxHeight: 400,
minHeight: 120, minHeight: 80,
overflow: 'auto', overflow: 'auto',
overscrollBehavior: 'contain', overscrollBehavior: 'contain',
height: 'min(330px, calc(var(--cmdk-list-height) + 8px))', 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 { formatDate } from '@affine/core/components/page-list';
import { useDocEngineStatus } from '@affine/core/hooks/affine/use-doc-engine-status'; import { useDocEngineStatus } from '@affine/core/hooks/affine/use-doc-engine-status';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks'; import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { QuickSearchService } from '@affine/core/modules/cmdk';
import { useAFFiNEI18N } from '@affine/i18n/hooks'; import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { DocMeta } from '@blocksuite/store'; import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx'; import clsx from 'clsx';
import { Command } from 'cmdk'; import { Command } from 'cmdk';
import { useDebouncedValue } from 'foxact/use-debounced-value'; import { useDebouncedValue } from 'foxact/use-debounced-value';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { Suspense, useLayoutEffect, useMemo, useRef, useState } from 'react'; import {
type ReactNode,
Suspense,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { import {
cmdkQueryAtom,
cmdkValueAtom, cmdkValueAtom,
useCMDKCommandGroups, useCMDKCommandGroups,
useSearchCallbackCommandGroups,
} from './data-hooks'; } from './data-hooks';
import { HighlightLabel } from './highlight'; import { HighlightLabel } from './highlight';
import * as styles from './main.css'; import * as styles from './main.css';
@ -59,18 +68,18 @@ const QuickSearchGroup = ({
}) => { }) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const i18nKey = categoryToI18nKey[category]; const i18nKey = categoryToI18nKey[category];
const [query, setQuery] = useAtom(cmdkQueryAtom); const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$);
const onCommendSelect = useAsyncCallback( const onCommendSelect = useAsyncCallback(
async (command: CMDKCommand) => { async (command: CMDKCommand) => {
try { try {
await command.run(); await command.run();
} finally { } finally {
setQuery('');
onOpenChange?.(false); onOpenChange?.(false);
} }
}, },
[setQuery, onOpenChange] [onOpenChange]
); );
return ( return (
@ -149,20 +158,19 @@ export const CMDKContainer = ({
onQueryChange, onQueryChange,
query, query,
children, children,
pageMeta, inputLabel,
open, open,
...rest ...rest
}: React.PropsWithChildren<{ }: React.PropsWithChildren<{
open: boolean; open: boolean;
className?: string; className?: string;
query: string; query: string;
pageMeta?: Partial<DocMeta>; inputLabel?: ReactNode;
groups: ReturnType<typeof useCMDKCommandGroups>; groups: ReturnType<typeof useCMDKCommandGroups>;
onQueryChange: (query: string) => void; onQueryChange: (query: string) => void;
}>) => { }>) => {
const t = useAFFiNEI18N(); const t = useAFFiNEI18N();
const [value, setValue] = useAtom(cmdkValueAtom); const [value, setValue] = useAtom(cmdkValueAtom);
const isInEditor = pageMeta !== undefined;
const [opening, setOpening] = useState(open); const [opening, setOpening] = useState(open);
const { syncing, progress } = useDocEngineStatus(); const { syncing, progress } = useDocEngineStatus();
const showLoading = useDebouncedValue(syncing, 500); const showLoading = useDebouncedValue(syncing, 500);
@ -197,16 +205,14 @@ export const CMDKContainer = ({
loop loop
> >
{/* todo: add page context here */} {/* todo: add page context here */}
{isInEditor ? ( {inputLabel ? (
<div className={styles.pageTitleWrapper}> <div className={styles.pageTitleWrapper}>
<span className={styles.pageTitle}> <span className={styles.pageTitle}>{inputLabel}</span>
{pageMeta.title ? pageMeta.title : t['Untitled']()}
</span>
</div> </div>
) : null} ) : null}
<div <div
className={clsx(className, styles.searchInputContainer, { className={clsx(className, styles.searchInputContainer, {
inEditor: isInEditor, [styles.hasInputLabel]: inputLabel,
})} })}
> >
{showLoading ? ( {showLoading ? (
@ -239,20 +245,41 @@ const CMDKQuickSearchModalInner = ({
open, open,
...props ...props
}: CMDKModalProps & { pageMeta?: Partial<DocMeta> }) => { }: CMDKModalProps & { pageMeta?: Partial<DocMeta> }) => {
const [query, setQuery] = useAtom(cmdkQueryAtom); const quickSearch = useService(QuickSearchService).quickSearch;
useLayoutEffect(() => { const query = useLiveData(quickSearch.query$);
if (open) {
setQuery('');
}
}, [open, setQuery]);
const groups = useCMDKCommandGroups(); const groups = useCMDKCommandGroups();
const t = useAFFiNEI18N();
return ( return (
<CMDKContainer <CMDKContainer
className={styles.root} className={styles.root}
query={query} query={query}
groups={groups} groups={groups}
onQueryChange={setQuery} onQueryChange={quickSearch.setQuery}
pageMeta={pageMeta} 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} open={open}
> >
<QuickSearchCommands groups={groups} onOpenChange={props.onOpenChange} /> <QuickSearchCommands groups={groups} onOpenChange={props.onOpenChange} />
@ -265,10 +292,17 @@ export const CMDKQuickSearchModal = ({
open, open,
...props ...props
}: CMDKModalProps & { pageMeta?: Partial<DocMeta> }) => { }: CMDKModalProps & { pageMeta?: Partial<DocMeta> }) => {
const quickSearch = useService(QuickSearchService).quickSearch;
const mode = useLiveData(quickSearch.mode$);
const InnerComp =
mode === 'commands'
? CMDKQuickSearchModalInner
: CMDKQuickSearchCallbackModalInner;
return ( return (
<CMDKModal open={open} {...props}> <CMDKModal open={open} {...props}>
<Suspense fallback={<Command.Loading />}> <Suspense fallback={<Command.Loading />}>
<CMDKQuickSearchModalInner <InnerComp
pageMeta={pageMeta} pageMeta={pageMeta}
open={open} open={open}
onOpenChange={props.onOpenChange} 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 { SearchIcon } from '@blocksuite/icons';
import { useLiveData, useService } from '@toeverything/infra';
import { useCommandState } from 'cmdk'; import { useCommandState } from 'cmdk';
import { useAtomValue } from 'jotai';
import { cmdkQueryAtom } from './data-hooks';
import * as styles from './not-found.css'; import * as styles from './not-found.css';
export const NotFoundGroup = () => { export const NotFoundGroup = () => {
const query = useAtomValue(cmdkQueryAtom); const quickSearch = useService(QuickSearchService).quickSearch;
// hack: we know that the filtered count is 3 when there is no result (create page & edgeless & append to journal) 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 = const renderNoResult =
useCommandState(state => state.filtered.count === 3) || false; useCommandState(state => state.filtered.count === 3) && mode === 'commands';
const t = useAFFiNEI18N();
if (!renderNoResult) { if (!renderNoResult) {
return null; return null;
@ -24,7 +29,9 @@ export const NotFoundGroup = () => {
<div className={styles.notFoundIcon}> <div className={styles.notFoundIcon}>
<SearchIcon /> <SearchIcon />
</div> </div>
<div className={styles.notFoundText}>No results found</div> <div className={styles.notFoundText}>
{t['com.affine.cmdk.no-results']()}
</div>
</div> </div>
</div> </div>
); );

View File

@ -2,6 +2,7 @@ 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 { configureFindInPageModule } from './find-in-page'; import { configureFindInPageModule } from './find-in-page';
import { configureNavigationModule } from './navigation'; import { configureNavigationModule } from './navigation';
@ -30,6 +31,7 @@ export function configureCommonModules(framework: Framework) {
configureTelemetryModule(framework); configureTelemetryModule(framework);
configureFindInPageModule(framework); configureFindInPageModule(framework);
configurePeekViewModule(framework); configurePeekViewModule(framework);
configureQuickSearchModule(framework);
} }
export function configureImpls(framework: 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 { Navigator } from './entities/navigator';
export { resolveLinkToDoc } from './utils';
export { NavigationButtons } from './view/navigation-buttons'; export { NavigationButtons } from './view/navigation-buttons';
import { type Framework, WorkspaceScope } from '@toeverything/infra'; 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 type { BlockModel } from '@blocksuite/store';
import { resolveLinkToDoc } from '../../navigation';
const EMBED_DOC_FLAVOURS = [ const EMBED_DOC_FLAVOURS = [
'affine:embed-linked-doc', 'affine:embed-linked-doc',
'affine:embed-synced-doc', 'affine:embed-synced-doc',
@ -46,25 +48,6 @@ const isSurfaceRefModel = (
return blockModel.flavour === 'affine:surface-ref'; 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( function resolvePeekInfoFromPeekTarget(
peekTarget?: PeekViewTarget peekTarget?: PeekViewTarget
): DocPeekViewInfo | null { ): DocPeekViewInfo | null {

View File

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

View File

@ -555,6 +555,7 @@
"com.affine.cmdk.affine.contact-us": "Contact Us", "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-edgeless-as": "New \"{{keyWord}}\" Edgeless",
"com.affine.cmdk.affine.create-new-page-as": "New \"{{keyWord}}\" Page", "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.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",
@ -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.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.affine.whats-new": "What's New",
"com.affine.cmdk.placeholder": "Type a command or search anything...", "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.delete": "Delete",
"com.affine.collection-bar.action.tooltip.edit": "Edit", "com.affine.collection-bar.action.tooltip.edit": "Edit",
"com.affine.collection-bar.action.tooltip.pin": "Pin to Sidebar", "com.affine.collection-bar.action.tooltip.pin": "Pin to Sidebar",