refactor(core): new quick search service (#7214)
This commit is contained in:
@@ -0,0 +1,113 @@
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import { mean } from 'lodash-es';
|
||||
|
||||
import type {
|
||||
QuickSearchSession,
|
||||
QuickSearchSource,
|
||||
QuickSearchSourceItemType,
|
||||
} from '../providers/quick-search-provider';
|
||||
import type { QuickSearchItem } from '../types/item';
|
||||
import type { QuickSearchOptions } from '../types/options';
|
||||
|
||||
export class QuickSearch extends Entity {
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
private readonly state$ = new LiveData<{
|
||||
query: string;
|
||||
sessions: QuickSearchSession<any, any>[];
|
||||
options: QuickSearchOptions;
|
||||
callback: (result: QuickSearchItem | null) => void;
|
||||
} | null>(null);
|
||||
|
||||
readonly items$ = this.state$
|
||||
.map(s => s?.sessions.map(session => session.items$) ?? [])
|
||||
.flat()
|
||||
.map(items => items.flat());
|
||||
|
||||
readonly show$ = this.state$.map(s => !!s);
|
||||
|
||||
readonly options$ = this.state$.map(s => s?.options);
|
||||
|
||||
readonly isLoading$ = this.state$
|
||||
.map(
|
||||
s =>
|
||||
s?.sessions.map(session => session.isLoading$ ?? new LiveData(false)) ??
|
||||
[]
|
||||
)
|
||||
.flat()
|
||||
.map(items => items.reduce((acc, item) => acc || item, false));
|
||||
|
||||
readonly loadingProgress$ = this.state$
|
||||
.map(
|
||||
s =>
|
||||
s?.sessions.map(
|
||||
session =>
|
||||
(session.loadingProgress$ ?? new LiveData(null)) as LiveData<
|
||||
number | null
|
||||
>
|
||||
) ?? []
|
||||
)
|
||||
.flat()
|
||||
.map(items => mean(items.filter((v): v is number => v === null)));
|
||||
|
||||
show = <const Sources extends any[]>(
|
||||
sources: Sources,
|
||||
cb: (result: QuickSearchSourceItemType<Sources[number]> | null) => void,
|
||||
options: QuickSearchOptions = {}
|
||||
) => {
|
||||
if (this.state$.value) {
|
||||
this.hide();
|
||||
}
|
||||
|
||||
const sessions = sources.map((source: QuickSearchSource<any, any>) => {
|
||||
if (typeof source === 'function') {
|
||||
const items$ = new LiveData<QuickSearchItem<any, any>[]>([]);
|
||||
return {
|
||||
items$,
|
||||
query: (query: string) => {
|
||||
items$.next(source(query));
|
||||
},
|
||||
} as QuickSearchSession<any, any>;
|
||||
} else {
|
||||
return source as QuickSearchSession<any, any>;
|
||||
}
|
||||
});
|
||||
sessions.forEach(session => {
|
||||
session.query?.(options.defaultQuery || '');
|
||||
});
|
||||
this.state$.next({
|
||||
query: options.defaultQuery ?? '',
|
||||
options,
|
||||
sessions: sessions,
|
||||
callback: cb as any,
|
||||
});
|
||||
};
|
||||
|
||||
query$ = this.state$.map(s => s?.query || '');
|
||||
|
||||
setQuery = (query: string) => {
|
||||
if (!this.state$.value) return;
|
||||
this.state$.next({
|
||||
...this.state$.value,
|
||||
query,
|
||||
});
|
||||
this.state$.value.sessions.forEach(session => session.query?.(query));
|
||||
};
|
||||
|
||||
hide() {
|
||||
if (this.state$.value) {
|
||||
this.state$.value.sessions.forEach(session => session.dispose?.());
|
||||
this.state$.value.callback?.(null);
|
||||
}
|
||||
|
||||
this.state$.next(null);
|
||||
}
|
||||
|
||||
submit(result: QuickSearchItem | null) {
|
||||
if (this.state$.value?.callback) {
|
||||
this.state$.value.callback(result);
|
||||
}
|
||||
this.state$.next(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ViewLayersIcon } from '@blocksuite/icons/rc';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
import type { CollectionService } from '../../collection';
|
||||
import type { QuickSearchSession } from '../providers/quick-search-provider';
|
||||
import type { QuickSearchGroup } from '../types/group';
|
||||
import type { QuickSearchItem } from '../types/item';
|
||||
import { highlighter } from '../utils/highlighter';
|
||||
|
||||
const group = {
|
||||
id: 'collections',
|
||||
label: {
|
||||
key: 'com.affine.cmdk.affine.category.affine.collections',
|
||||
},
|
||||
score: 10,
|
||||
} as QuickSearchGroup;
|
||||
|
||||
export class CollectionsQuickSearchSession
|
||||
extends Entity
|
||||
implements QuickSearchSession<'collections', { collectionId: string }>
|
||||
{
|
||||
constructor(private readonly collectionService: CollectionService) {
|
||||
super();
|
||||
}
|
||||
|
||||
query$ = new LiveData('');
|
||||
|
||||
items$: LiveData<QuickSearchItem<'collections', { collectionId: string }>[]> =
|
||||
LiveData.computed(get => {
|
||||
const query = get(this.query$);
|
||||
|
||||
const collections = get(this.collectionService.collections$);
|
||||
|
||||
const fuse = new Fuse(collections, {
|
||||
keys: ['name'],
|
||||
includeMatches: true,
|
||||
includeScore: true,
|
||||
});
|
||||
|
||||
const result = fuse.search(query);
|
||||
|
||||
return result.map<
|
||||
QuickSearchItem<'collections', { collectionId: string }>
|
||||
>(({ item, matches, score = 1 }) => {
|
||||
const nomalizedRange = ([start, end]: [number, number]) =>
|
||||
[
|
||||
start,
|
||||
end + 1 /* in fuse, the `end` is different from the `substring` */,
|
||||
] as [number, number];
|
||||
const titleMatches = matches
|
||||
?.filter(match => match.key === 'name')
|
||||
.flatMap(match => match.indices.map(nomalizedRange));
|
||||
|
||||
return {
|
||||
id: 'collection:' + item.id,
|
||||
source: 'collections',
|
||||
label: {
|
||||
title: (highlighter(item.name, '<b>', '</b>', titleMatches ?? []) ??
|
||||
item.name) || {
|
||||
key: 'Untitled',
|
||||
},
|
||||
},
|
||||
group,
|
||||
score:
|
||||
1 -
|
||||
score /* in fuse, the smaller the score, the better the match, so we need to reverse it */,
|
||||
icon: ViewLayersIcon,
|
||||
payload: { collectionId: item.id },
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
query(query: string) {
|
||||
this.query$.next(query);
|
||||
}
|
||||
}
|
||||
210
packages/frontend/core/src/modules/quicksearch/impls/commands.ts
Normal file
210
packages/frontend/core/src/modules/quicksearch/impls/commands.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import {
|
||||
type AffineCommand,
|
||||
AffineCommandRegistry,
|
||||
type CommandCategory,
|
||||
PreconditionStrategy,
|
||||
} from '@affine/core/commands';
|
||||
import type { DocMode, GlobalContextService } from '@toeverything/infra';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
import type { QuickSearchSession } from '../providers/quick-search-provider';
|
||||
import type { QuickSearchGroup } from '../types/group';
|
||||
import type { QuickSearchItem } from '../types/item';
|
||||
import { highlighter } from '../utils/highlighter';
|
||||
|
||||
const categories = {
|
||||
'affine:recent': {
|
||||
id: 'command:affine:recent',
|
||||
label: { key: 'com.affine.cmdk.affine.category.affine.recent' },
|
||||
score: 10,
|
||||
},
|
||||
'affine:navigation': {
|
||||
id: 'command:affine:navigation',
|
||||
label: {
|
||||
key: 'com.affine.cmdk.affine.category.affine.navigation',
|
||||
},
|
||||
score: 10,
|
||||
},
|
||||
'affine:creation': {
|
||||
id: 'command:affine:creation',
|
||||
label: { key: 'com.affine.cmdk.affine.category.affine.creation' },
|
||||
score: 10,
|
||||
},
|
||||
'affine:general': {
|
||||
id: 'command:affine:general',
|
||||
label: { key: 'com.affine.cmdk.affine.category.affine.general' },
|
||||
score: 10,
|
||||
},
|
||||
'affine:layout': {
|
||||
id: 'command:affine:layout',
|
||||
label: { key: 'com.affine.cmdk.affine.category.affine.layout' },
|
||||
score: 10,
|
||||
},
|
||||
'affine:pages': {
|
||||
id: 'command:affine:pages',
|
||||
label: { key: 'com.affine.cmdk.affine.category.affine.pages' },
|
||||
score: 10,
|
||||
},
|
||||
'affine:edgeless': {
|
||||
id: 'command:affine:edgeless',
|
||||
label: { key: 'com.affine.cmdk.affine.category.affine.edgeless' },
|
||||
score: 10,
|
||||
},
|
||||
'affine:collections': {
|
||||
id: 'command:affine:collections',
|
||||
label: {
|
||||
key: 'com.affine.cmdk.affine.category.affine.collections',
|
||||
},
|
||||
score: 10,
|
||||
},
|
||||
'affine:settings': {
|
||||
id: 'command:affine:settings',
|
||||
label: { key: 'com.affine.cmdk.affine.category.affine.settings' },
|
||||
score: 10,
|
||||
},
|
||||
'affine:updates': {
|
||||
id: 'command:affine:updates',
|
||||
label: { key: 'com.affine.cmdk.affine.category.affine.updates' },
|
||||
score: 10,
|
||||
},
|
||||
'affine:help': {
|
||||
id: 'command:affine:help',
|
||||
label: { key: 'com.affine.cmdk.affine.category.affine.help' },
|
||||
score: 10,
|
||||
},
|
||||
'editor:edgeless': {
|
||||
id: 'command:editor:edgeless',
|
||||
label: { key: 'com.affine.cmdk.affine.category.editor.edgeless' },
|
||||
score: 10,
|
||||
},
|
||||
'editor:insert-object': {
|
||||
id: 'command:editor:insert-object',
|
||||
label: { key: 'com.affine.cmdk.affine.category.editor.insert-object' },
|
||||
score: 10,
|
||||
},
|
||||
'editor:page': {
|
||||
id: 'command:editor:page',
|
||||
label: { key: 'com.affine.cmdk.affine.category.editor.page' },
|
||||
score: 10,
|
||||
},
|
||||
'affine:results': {
|
||||
id: 'command:affine:results',
|
||||
label: { key: 'com.affine.cmdk.affine.category.results' },
|
||||
score: 10,
|
||||
},
|
||||
} satisfies Required<{
|
||||
[key in CommandCategory]: QuickSearchGroup & { id: `command:${key}` };
|
||||
}>;
|
||||
|
||||
function filterCommandByContext(
|
||||
command: AffineCommand,
|
||||
context: {
|
||||
docMode: DocMode | undefined;
|
||||
}
|
||||
) {
|
||||
if (command.preconditionStrategy === PreconditionStrategy.Always) {
|
||||
return true;
|
||||
}
|
||||
if (command.preconditionStrategy === PreconditionStrategy.InEdgeless) {
|
||||
return context.docMode === 'edgeless';
|
||||
}
|
||||
if (command.preconditionStrategy === PreconditionStrategy.InPaper) {
|
||||
return context.docMode === 'page';
|
||||
}
|
||||
if (command.preconditionStrategy === PreconditionStrategy.InPaperOrEdgeless) {
|
||||
return !!context.docMode;
|
||||
}
|
||||
if (command.preconditionStrategy === PreconditionStrategy.Never) {
|
||||
return false;
|
||||
}
|
||||
if (typeof command.preconditionStrategy === 'function') {
|
||||
return command.preconditionStrategy();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getAllCommand(context: { docMode: DocMode | undefined }) {
|
||||
const commands = AffineCommandRegistry.getAll();
|
||||
return commands.filter(command => {
|
||||
return filterCommandByContext(command, context);
|
||||
});
|
||||
}
|
||||
|
||||
export class CommandsQuickSearchSession
|
||||
extends Entity
|
||||
implements QuickSearchSession<'commands', AffineCommand>
|
||||
{
|
||||
constructor(private readonly contextService: GlobalContextService) {
|
||||
super();
|
||||
}
|
||||
|
||||
query$ = new LiveData('');
|
||||
|
||||
items$ = LiveData.computed(get => {
|
||||
const query = get(this.query$);
|
||||
const docMode =
|
||||
get(this.contextService.globalContext.docMode.$) ?? undefined;
|
||||
const commands = getAllCommand({ docMode });
|
||||
|
||||
const fuse = new Fuse(commands, {
|
||||
keys: [{ name: 'label.title', weight: 2 }, 'label.subTitle'],
|
||||
includeMatches: true,
|
||||
includeScore: true,
|
||||
threshold: 0.4,
|
||||
});
|
||||
|
||||
const result = query
|
||||
? fuse.search(query)
|
||||
: commands.map(item => ({ item, matches: [], score: 0 }));
|
||||
|
||||
return result.map<QuickSearchItem<'commands', AffineCommand>>(
|
||||
({ item, matches, score = 1 }) => {
|
||||
const nomalizedRange = ([start, end]: [number, number]) =>
|
||||
[
|
||||
start,
|
||||
end + 1 /* in fuse, the `end` is different from the `substring` */,
|
||||
] as [number, number];
|
||||
const titleMatches = matches
|
||||
?.filter(match => match.key === 'label.title')
|
||||
.flatMap(match => match.indices.map(nomalizedRange));
|
||||
const subTitleMatches = matches
|
||||
?.filter(match => match.key === 'label.subTitle')
|
||||
.flatMap(match => match.indices.map(nomalizedRange));
|
||||
|
||||
return {
|
||||
id: 'command:' + item.id,
|
||||
source: 'commands',
|
||||
label: {
|
||||
title:
|
||||
highlighter(
|
||||
item.label.title,
|
||||
'<b>',
|
||||
'</b>',
|
||||
titleMatches ?? []
|
||||
) ?? item.label.title,
|
||||
subTitle: item.label.subTitle
|
||||
? highlighter(
|
||||
item.label.subTitle,
|
||||
'<b>',
|
||||
'</b>',
|
||||
subTitleMatches ?? []
|
||||
) ?? item.label.subTitle
|
||||
: undefined,
|
||||
},
|
||||
group: categories[item.category],
|
||||
score:
|
||||
1 -
|
||||
score /* in fuse, the smaller the score, the better the match, so we need to reverse it */,
|
||||
icon: item.icon,
|
||||
keyBinding: item.keyBinding?.binding,
|
||||
payload: item,
|
||||
};
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
query(query: string) {
|
||||
this.query$.next(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { EdgelessIcon, PageIcon } from '@blocksuite/icons/rc';
|
||||
import { type DocMode, Entity, LiveData } from '@toeverything/infra';
|
||||
|
||||
import type { QuickSearchSession } from '../providers/quick-search-provider';
|
||||
import type { QuickSearchGroup } from '../types/group';
|
||||
import type { QuickSearchItem } from '../types/item';
|
||||
|
||||
const group = {
|
||||
id: 'creation',
|
||||
label: { key: 'com.affine.quicksearch.group.creation' },
|
||||
score: 0,
|
||||
} as QuickSearchGroup;
|
||||
|
||||
export class CreationQuickSearchSession
|
||||
extends Entity
|
||||
implements QuickSearchSession<'creation', { title: string; mode: DocMode }>
|
||||
{
|
||||
query$ = new LiveData('');
|
||||
|
||||
items$ = LiveData.computed(get => {
|
||||
const query = get(this.query$);
|
||||
|
||||
if (!query.trim()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'creation:create-page',
|
||||
source: 'creation',
|
||||
label: {
|
||||
key: 'com.affine.cmdk.affine.create-new-page-as',
|
||||
options: { keyWord: query },
|
||||
},
|
||||
group,
|
||||
icon: PageIcon,
|
||||
payload: { mode: 'edgeless', title: query },
|
||||
},
|
||||
{
|
||||
id: 'creation:create-edgeless',
|
||||
source: 'creation',
|
||||
label: {
|
||||
key: 'com.affine.cmdk.affine.create-new-edgeless-as',
|
||||
options: { keyWord: query },
|
||||
},
|
||||
group,
|
||||
icon: EdgelessIcon,
|
||||
payload: { mode: 'edgeless', title: query },
|
||||
},
|
||||
] as QuickSearchItem<'creation', { title: string; mode: DocMode }>[];
|
||||
});
|
||||
|
||||
query(query: string) {
|
||||
this.query$.next(query);
|
||||
}
|
||||
}
|
||||
158
packages/frontend/core/src/modules/quicksearch/impls/docs.ts
Normal file
158
packages/frontend/core/src/modules/quicksearch/impls/docs.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { EdgelessIcon, PageIcon, TodayIcon } from '@blocksuite/icons/rc';
|
||||
import type { DocsService } from '@toeverything/infra';
|
||||
import {
|
||||
effect,
|
||||
Entity,
|
||||
LiveData,
|
||||
onComplete,
|
||||
onStart,
|
||||
} from '@toeverything/infra';
|
||||
import { truncate } from 'lodash-es';
|
||||
import { EMPTY, map, mergeMap, of, switchMap } from 'rxjs';
|
||||
|
||||
import type { DocsSearchService } from '../../docs-search';
|
||||
import { resolveLinkToDoc } from '../../navigation';
|
||||
import type { WorkspacePropertiesAdapter } from '../../properties';
|
||||
import type { QuickSearchSession } from '../providers/quick-search-provider';
|
||||
import type { QuickSearchItem } from '../types/item';
|
||||
|
||||
interface DocsPayload {
|
||||
docId: string;
|
||||
title?: string;
|
||||
blockId?: string | undefined;
|
||||
blockContent?: string | undefined;
|
||||
}
|
||||
|
||||
export class DocsQuickSearchSession
|
||||
extends Entity
|
||||
implements QuickSearchSession<'docs', DocsPayload>
|
||||
{
|
||||
constructor(
|
||||
private readonly docsSearchService: DocsSearchService,
|
||||
private readonly docsService: DocsService,
|
||||
private readonly propertiesAdapter: WorkspacePropertiesAdapter
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
private readonly isIndexerLoading$ =
|
||||
this.docsSearchService.indexer.status$.map(({ remaining }) => {
|
||||
return remaining === undefined || remaining > 0;
|
||||
});
|
||||
|
||||
private readonly isQueryLoading$ = new LiveData(false);
|
||||
|
||||
isLoading$ = LiveData.computed(get => {
|
||||
return get(this.isIndexerLoading$) || get(this.isQueryLoading$);
|
||||
});
|
||||
|
||||
query$ = new LiveData('');
|
||||
|
||||
items$ = new LiveData<QuickSearchItem<'docs', DocsPayload>[]>([]);
|
||||
|
||||
query = effect(
|
||||
switchMap((query: string) => {
|
||||
let out;
|
||||
if (!query) {
|
||||
out = of([] as QuickSearchItem<'docs', DocsPayload>[]);
|
||||
} else {
|
||||
const maybeLink = resolveLinkToDoc(query);
|
||||
const docRecord = maybeLink
|
||||
? this.docsService.list.doc$(maybeLink.docId).value
|
||||
: null;
|
||||
|
||||
if (docRecord) {
|
||||
const docMode = docRecord?.mode$.value;
|
||||
const icon = this.propertiesAdapter.getJournalPageDateString(
|
||||
docRecord.id
|
||||
) /* is journal */
|
||||
? TodayIcon
|
||||
: docMode === 'edgeless'
|
||||
? EdgelessIcon
|
||||
: PageIcon;
|
||||
|
||||
out = of([
|
||||
{
|
||||
id: 'doc:' + docRecord.id,
|
||||
source: 'docs',
|
||||
group: {
|
||||
id: 'docs',
|
||||
label: {
|
||||
key: 'com.affine.quicksearch.group.searchfor',
|
||||
options: { query: truncate(query) },
|
||||
},
|
||||
score: 5,
|
||||
},
|
||||
label: {
|
||||
title: docRecord.title$.value || { key: 'Untitled' },
|
||||
},
|
||||
score: 100,
|
||||
icon,
|
||||
timestamp: docRecord.meta$.value.updatedDate,
|
||||
payload: {
|
||||
docId: docRecord.id,
|
||||
},
|
||||
},
|
||||
] as QuickSearchItem<'docs', DocsPayload>[]);
|
||||
} else {
|
||||
out = this.docsSearchService.search$(query).pipe(
|
||||
map(docs =>
|
||||
docs.map(doc => {
|
||||
const docRecord = this.docsService.list.doc$(doc.docId).value;
|
||||
const docMode = docRecord?.mode$.value;
|
||||
const updatedTime = docRecord?.meta$.value.updatedDate;
|
||||
|
||||
const icon = this.propertiesAdapter.getJournalPageDateString(
|
||||
doc.docId
|
||||
) /* is journal */
|
||||
? TodayIcon
|
||||
: docMode === 'edgeless'
|
||||
? EdgelessIcon
|
||||
: PageIcon;
|
||||
|
||||
return {
|
||||
id: 'doc:' + doc.docId,
|
||||
source: 'docs',
|
||||
group: {
|
||||
id: 'docs',
|
||||
label: {
|
||||
key: 'com.affine.quicksearch.group.searchfor',
|
||||
options: { query: truncate(query) },
|
||||
},
|
||||
score: 5,
|
||||
},
|
||||
label: {
|
||||
title: doc.title || { key: 'Untitled' },
|
||||
subTitle: doc.blockContent,
|
||||
},
|
||||
score: doc.score,
|
||||
icon,
|
||||
timestamp: updatedTime,
|
||||
payload: doc,
|
||||
} as QuickSearchItem<'docs', DocsPayload>;
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
return out.pipe(
|
||||
mergeMap((items: QuickSearchItem<'docs', DocsPayload>[]) => {
|
||||
this.items$.next(items);
|
||||
this.isQueryLoading$.next(false);
|
||||
return EMPTY;
|
||||
}),
|
||||
onStart(() => {
|
||||
this.items$.next([]);
|
||||
this.isQueryLoading$.next(true);
|
||||
}),
|
||||
onComplete(() => {})
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
// TODO(@EYHN): load more
|
||||
|
||||
setQuery(query: string) {
|
||||
this.query$.next(query);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { EdgelessIcon, PageIcon, TodayIcon } from '@blocksuite/icons/rc';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
|
||||
import type { WorkspacePropertiesAdapter } from '../../properties';
|
||||
import type { QuickSearchSession } from '../providers/quick-search-provider';
|
||||
import type { RecentDocsService } from '../services/recent-pages';
|
||||
import type { QuickSearchGroup } from '../types/group';
|
||||
import type { QuickSearchItem } from '../types/item';
|
||||
|
||||
const group = {
|
||||
id: 'recent-docs',
|
||||
label: {
|
||||
key: 'com.affine.cmdk.affine.category.affine.recent',
|
||||
},
|
||||
score: 15,
|
||||
} as QuickSearchGroup;
|
||||
|
||||
export class RecentDocsQuickSearchSession
|
||||
extends Entity
|
||||
implements QuickSearchSession<'recent-doc', { docId: string }>
|
||||
{
|
||||
constructor(
|
||||
private readonly recentDocsService: RecentDocsService,
|
||||
private readonly propertiesAdapter: WorkspacePropertiesAdapter
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
query$ = new LiveData('');
|
||||
|
||||
items$: LiveData<QuickSearchItem<'recent-doc', { docId: string }>[]> =
|
||||
LiveData.computed(get => {
|
||||
const query = get(this.query$);
|
||||
|
||||
if (query) {
|
||||
return []; /* recent docs only for empty query */
|
||||
}
|
||||
|
||||
const docRecords = this.recentDocsService.getRecentDocs();
|
||||
|
||||
return docRecords.map<QuickSearchItem<'recent-doc', { docId: string }>>(
|
||||
docRecord => {
|
||||
const icon = this.propertiesAdapter.getJournalPageDateString(
|
||||
docRecord.id
|
||||
) /* is journal */
|
||||
? TodayIcon
|
||||
: docRecord.mode$.value === 'edgeless'
|
||||
? EdgelessIcon
|
||||
: PageIcon;
|
||||
|
||||
return {
|
||||
id: 'recent-doc:' + docRecord.id,
|
||||
source: 'recent-doc',
|
||||
group: group,
|
||||
label: {
|
||||
title: docRecord.meta$.value.title || { key: 'Untitled' },
|
||||
},
|
||||
score: 0,
|
||||
icon,
|
||||
timestamp: docRecord.meta$.value.updatedDate,
|
||||
payload: { docId: docRecord.id },
|
||||
};
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
query(query: string) {
|
||||
this.query$.next(query);
|
||||
}
|
||||
}
|
||||
56
packages/frontend/core/src/modules/quicksearch/index.ts
Normal file
56
packages/frontend/core/src/modules/quicksearch/index.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import {
|
||||
DocsService,
|
||||
type Framework,
|
||||
GlobalContextService,
|
||||
WorkspaceLocalState,
|
||||
WorkspaceScope,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { CollectionService } from '../collection';
|
||||
import { DocsSearchService } from '../docs-search';
|
||||
import { WorkspacePropertiesAdapter } from '../properties';
|
||||
import { WorkbenchService } from '../workbench';
|
||||
import { QuickSearch } from './entities/quick-search';
|
||||
import { CollectionsQuickSearchSession } from './impls/collections';
|
||||
import { CommandsQuickSearchSession } from './impls/commands';
|
||||
import { CreationQuickSearchSession } from './impls/creation';
|
||||
import { DocsQuickSearchSession } from './impls/docs';
|
||||
import { RecentDocsQuickSearchSession } from './impls/recent-docs';
|
||||
import { CMDKQuickSearchService } from './services/cmdk';
|
||||
import { QuickSearchService } from './services/quick-search';
|
||||
import { RecentDocsService } from './services/recent-pages';
|
||||
|
||||
export { QuickSearch } from './entities/quick-search';
|
||||
export { QuickSearchService, RecentDocsService };
|
||||
export { CollectionsQuickSearchSession } from './impls/collections';
|
||||
export { CommandsQuickSearchSession } from './impls/commands';
|
||||
export { CreationQuickSearchSession } from './impls/creation';
|
||||
export { DocsQuickSearchSession } from './impls/docs';
|
||||
export { RecentDocsQuickSearchSession } from './impls/recent-docs';
|
||||
export type { QuickSearchItem } from './types/item';
|
||||
export { QuickSearchContainer } from './views/container';
|
||||
|
||||
export function configureQuickSearchModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(QuickSearchService)
|
||||
.service(CMDKQuickSearchService, [
|
||||
QuickSearchService,
|
||||
WorkbenchService,
|
||||
DocsService,
|
||||
])
|
||||
.service(RecentDocsService, [WorkspaceLocalState, DocsService])
|
||||
.entity(QuickSearch)
|
||||
.entity(CommandsQuickSearchSession, [GlobalContextService])
|
||||
.entity(DocsQuickSearchSession, [
|
||||
DocsSearchService,
|
||||
DocsService,
|
||||
WorkspacePropertiesAdapter,
|
||||
])
|
||||
.entity(CreationQuickSearchSession)
|
||||
.entity(CollectionsQuickSearchSession, [CollectionService])
|
||||
.entity(RecentDocsQuickSearchSession, [
|
||||
RecentDocsService,
|
||||
WorkspacePropertiesAdapter,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { type LiveData } from '@toeverything/infra';
|
||||
|
||||
import type { QuickSearchItem } from '../types/item';
|
||||
|
||||
export type QuickSearchFunction<S, P> = (
|
||||
query: string
|
||||
) => QuickSearchItem<S, P>[];
|
||||
|
||||
export interface QuickSearchSession<S, P> {
|
||||
items$: LiveData<QuickSearchItem<S, P>[]>;
|
||||
isError$?: LiveData<boolean>;
|
||||
isLoading$?: LiveData<boolean>;
|
||||
loadingProgress$?: LiveData<number>;
|
||||
hasMore$?: LiveData<boolean>;
|
||||
|
||||
query?: (query: string) => void;
|
||||
loadMore?: () => void;
|
||||
dispose?: () => void;
|
||||
}
|
||||
|
||||
export type QuickSearchSource<S, P> =
|
||||
| QuickSearchFunction<S, P>
|
||||
| QuickSearchSession<S, P>;
|
||||
|
||||
export type QuickSearchSourceItemType<Source> =
|
||||
Source extends QuickSearchSource<infer S, infer P>
|
||||
? QuickSearchItem<S, P>
|
||||
: never;
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { DocsService } from '@toeverything/infra';
|
||||
import { Service } from '@toeverything/infra';
|
||||
|
||||
import type { WorkbenchService } from '../../workbench';
|
||||
import { CollectionsQuickSearchSession } from '../impls/collections';
|
||||
import { CommandsQuickSearchSession } from '../impls/commands';
|
||||
import { CreationQuickSearchSession } from '../impls/creation';
|
||||
import { DocsQuickSearchSession } from '../impls/docs';
|
||||
import { RecentDocsQuickSearchSession } from '../impls/recent-docs';
|
||||
import type { QuickSearchService } from './quick-search';
|
||||
|
||||
export class CMDKQuickSearchService extends Service {
|
||||
constructor(
|
||||
private readonly quickSearchService: QuickSearchService,
|
||||
private readonly workbenchService: WorkbenchService,
|
||||
private readonly docsService: DocsService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (this.quickSearchService.quickSearch.show$.value) {
|
||||
this.quickSearchService.quickSearch.hide();
|
||||
} else {
|
||||
this.quickSearchService.quickSearch.show(
|
||||
[
|
||||
this.framework.createEntity(RecentDocsQuickSearchSession),
|
||||
this.framework.createEntity(CollectionsQuickSearchSession),
|
||||
this.framework.createEntity(CommandsQuickSearchSession),
|
||||
this.framework.createEntity(CreationQuickSearchSession),
|
||||
this.framework.createEntity(DocsQuickSearchSession),
|
||||
],
|
||||
result => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
if (result.source === 'commands') {
|
||||
result.payload.run()?.catch(err => {
|
||||
console.error(err);
|
||||
});
|
||||
} else if (
|
||||
result.source === 'recent-doc' ||
|
||||
result.source === 'docs'
|
||||
) {
|
||||
const doc: {
|
||||
docId: string;
|
||||
blockId?: string;
|
||||
} = result.payload;
|
||||
|
||||
this.workbenchService.workbench.openDoc({
|
||||
docId: doc.docId,
|
||||
blockId: doc.blockId,
|
||||
});
|
||||
} else if (result.source === 'collections') {
|
||||
this.workbenchService.workbench.openCollection(
|
||||
result.payload.collectionId
|
||||
);
|
||||
} else if (result.source === 'creation') {
|
||||
if (result.id === 'creation:create-page') {
|
||||
const newDoc = this.docsService.createDoc({
|
||||
mode: 'page',
|
||||
title: result.payload.title,
|
||||
});
|
||||
this.workbenchService.workbench.openDoc(newDoc.id);
|
||||
} else if (result.id === 'creation:create-edgeless') {
|
||||
const newDoc = this.docsService.createDoc({
|
||||
mode: 'edgeless',
|
||||
title: result.payload.title,
|
||||
});
|
||||
this.workbenchService.workbench.openDoc(newDoc.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
placeholder: {
|
||||
key: 'com.affine.cmdk.docs.placeholder',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 RecentDocsService 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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { I18nString } from '@affine/i18n';
|
||||
|
||||
export interface QuickSearchGroup {
|
||||
id: string;
|
||||
label: I18nString;
|
||||
score?: number;
|
||||
}
|
||||
21
packages/frontend/core/src/modules/quicksearch/types/item.ts
Normal file
21
packages/frontend/core/src/modules/quicksearch/types/item.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { I18nString } from '@affine/i18n';
|
||||
|
||||
import type { QuickSearchGroup } from './group';
|
||||
|
||||
export type QuickSearchItem<S = any, P = any> = {
|
||||
id: string;
|
||||
source: S;
|
||||
label:
|
||||
| I18nString
|
||||
| {
|
||||
title: I18nString;
|
||||
subTitle?: I18nString;
|
||||
};
|
||||
score?: number;
|
||||
icon?: React.ReactNode | React.ComponentType;
|
||||
group?: QuickSearchGroup;
|
||||
disabled?: boolean;
|
||||
keyBinding?: string;
|
||||
timestamp?: number;
|
||||
payload?: P;
|
||||
} & (P extends NonNullable<unknown> ? { payload: P } : unknown);
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { I18nString } from '@affine/i18n';
|
||||
|
||||
export interface QuickSearchOptions {
|
||||
label?: I18nString;
|
||||
placeholder?: I18nString;
|
||||
defaultQuery?: string;
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export function highlighter(
|
||||
originText: string,
|
||||
before: string,
|
||||
after: string,
|
||||
matches: [number, number][],
|
||||
{
|
||||
maxLength = 50,
|
||||
maxPrefix = 20,
|
||||
}: { maxLength?: number; maxPrefix?: number } = {}
|
||||
) {
|
||||
if (!originText) {
|
||||
return;
|
||||
}
|
||||
const merged = mergeRanges(matches);
|
||||
|
||||
if (merged.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstMatch = merged[0][0];
|
||||
const start = Math.max(
|
||||
0,
|
||||
Math.min(firstMatch - maxPrefix, originText.length - maxLength)
|
||||
);
|
||||
const end = Math.min(start + maxLength, originText.length);
|
||||
const text = originText.substring(start, end);
|
||||
|
||||
let result = '';
|
||||
|
||||
let pointer = 0;
|
||||
for (const match of merged) {
|
||||
const matchStart = match[0] - start;
|
||||
const matchEnd = match[1] - start;
|
||||
if (matchStart >= text.length) {
|
||||
break;
|
||||
}
|
||||
result += text.substring(pointer, matchStart);
|
||||
pointer = matchStart;
|
||||
const highlighted = text.substring(matchStart, matchEnd);
|
||||
|
||||
if (highlighted.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result += `${before}${highlighted}${after}`;
|
||||
pointer = matchEnd;
|
||||
}
|
||||
result += text.substring(pointer);
|
||||
|
||||
if (start > 0) {
|
||||
result = `...${result}`;
|
||||
}
|
||||
|
||||
if (end < originText.length) {
|
||||
result = `${result}...`;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function mergeRanges(intervals: [number, number][]) {
|
||||
if (intervals.length === 0) return [];
|
||||
|
||||
intervals.sort((a, b) => a[0] - b[0]);
|
||||
|
||||
const merged = [intervals[0]];
|
||||
|
||||
for (let i = 1; i < intervals.length; i++) {
|
||||
const last = merged[merged.length - 1];
|
||||
const current = intervals[i];
|
||||
|
||||
if (current[0] <= last[1]) {
|
||||
last[1] = Math.max(last[1], current[1]);
|
||||
} else {
|
||||
merged.push(current);
|
||||
}
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
194
packages/frontend/core/src/modules/quicksearch/views/cmdk.css.ts
Normal file
194
packages/frontend/core/src/modules/quicksearch/views/cmdk.css.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
|
||||
export const root = style({});
|
||||
|
||||
export const itemIcon = style({
|
||||
fontSize: 20,
|
||||
marginRight: 16,
|
||||
flexShrink: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: cssVar('iconSecondary'),
|
||||
});
|
||||
|
||||
export const itemLabel = style({
|
||||
fontSize: 14,
|
||||
lineHeight: '1.5',
|
||||
color: cssVar('textPrimaryColor'),
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
});
|
||||
|
||||
globalStyle(`${root} [cmdk-root]`, {
|
||||
height: '100%',
|
||||
});
|
||||
globalStyle(`${root} [cmdk-group-heading]`, {
|
||||
padding: '8px',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.67',
|
||||
});
|
||||
globalStyle(`${root} [cmdk-group][hidden]`, {
|
||||
display: 'none',
|
||||
});
|
||||
globalStyle(`${root} [cmdk-list]`, {
|
||||
maxHeight: 400,
|
||||
minHeight: 80,
|
||||
overflow: 'auto',
|
||||
overscrollBehavior: 'contain',
|
||||
height: 'min(330px, calc(var(--cmdk-list-height) + 8px))',
|
||||
margin: '8px 6px',
|
||||
scrollbarGutter: 'stable',
|
||||
scrollPaddingBlock: '12px',
|
||||
scrollbarWidth: 'thin',
|
||||
scrollbarColor: `${cssVar('iconColor')} transparent`,
|
||||
});
|
||||
globalStyle(`${root} [cmdk-list]:not([data-opening])`, {
|
||||
transition: 'height .1s ease',
|
||||
});
|
||||
globalStyle(`${root} [cmdk-list]::-webkit-scrollbar`, {
|
||||
width: 6,
|
||||
height: 6,
|
||||
});
|
||||
globalStyle(`${root} [cmdk-list]::-webkit-scrollbar-thumb`, {
|
||||
borderRadius: 4,
|
||||
backgroundClip: 'padding-box',
|
||||
});
|
||||
globalStyle(`${root} [cmdk-list]:hover::-webkit-scrollbar-thumb`, {
|
||||
backgroundColor: cssVar('dividerColor'),
|
||||
});
|
||||
globalStyle(`${root} [cmdk-list]:hover::-webkit-scrollbar-thumb:hover`, {
|
||||
backgroundColor: cssVar('iconColor'),
|
||||
});
|
||||
globalStyle(`${root} [cmdk-item]`, {
|
||||
display: 'flex',
|
||||
minHeight: 44,
|
||||
padding: '6px 12px',
|
||||
alignItems: 'center',
|
||||
cursor: 'default',
|
||||
borderRadius: 4,
|
||||
userSelect: 'none',
|
||||
});
|
||||
globalStyle(`${root} [cmdk-item][data-selected=true]`, {
|
||||
background: cssVar('backgroundSecondaryColor'),
|
||||
});
|
||||
globalStyle(`${root} [cmdk-item][data-selected=true][data-is-danger=true]`, {
|
||||
background: cssVar('backgroundErrorColor'),
|
||||
color: cssVar('errorColor'),
|
||||
});
|
||||
globalStyle(`${root} [cmdk-item][data-selected=true] ${itemIcon}`, {
|
||||
color: cssVar('iconColor'),
|
||||
});
|
||||
globalStyle(
|
||||
`${root} [cmdk-item][data-selected=true][data-is-danger=true] ${itemIcon}`,
|
||||
{
|
||||
color: cssVar('errorColor'),
|
||||
}
|
||||
);
|
||||
globalStyle(
|
||||
`${root} [cmdk-item][data-selected=true][data-is-danger=true] ${itemLabel}`,
|
||||
{
|
||||
color: cssVar('errorColor'),
|
||||
}
|
||||
);
|
||||
|
||||
export const panelContainer = style({
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
export const pageTitleWrapper = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
padding: '18px 16px 0',
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
export const pageTitle = style({
|
||||
padding: '2px 6px',
|
||||
borderRadius: 4,
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
maxWidth: '100%',
|
||||
backgroundColor: cssVar('backgroundSecondaryColor'),
|
||||
});
|
||||
|
||||
export const searchInputContainer = style({
|
||||
height: 66,
|
||||
padding: '18px 16px',
|
||||
marginBottom: '8px',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderBottom: `1px solid ${cssVar('borderColor')}`,
|
||||
flexShrink: 0,
|
||||
});
|
||||
|
||||
export const hasInputLabel = style([
|
||||
searchInputContainer,
|
||||
{
|
||||
paddingTop: '12px',
|
||||
paddingBottom: '18px',
|
||||
},
|
||||
]);
|
||||
|
||||
export const searchInput = style({
|
||||
color: cssVar('textPrimaryColor'),
|
||||
fontSize: cssVar('fontH5'),
|
||||
width: '100%',
|
||||
'::placeholder': {
|
||||
color: cssVar('textSecondaryColor'),
|
||||
},
|
||||
});
|
||||
|
||||
export const timestamp = style({
|
||||
display: 'flex',
|
||||
fontSize: cssVar('fontXs'),
|
||||
color: cssVar('textSecondaryColor'),
|
||||
minWidth: 120,
|
||||
flexDirection: 'row-reverse',
|
||||
});
|
||||
|
||||
export const keybinding = style({
|
||||
display: 'flex',
|
||||
fontSize: cssVar('fontXs'),
|
||||
columnGap: 2,
|
||||
});
|
||||
|
||||
export const keybindingFragment = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '0 4px',
|
||||
borderRadius: 4,
|
||||
color: cssVar('textSecondaryColor'),
|
||||
backgroundColor: cssVar('backgroundTertiaryColor'),
|
||||
minWidth: 24,
|
||||
height: 20,
|
||||
textTransform: 'uppercase',
|
||||
});
|
||||
|
||||
export const itemTitle = style({
|
||||
fontSize: cssVar('fontBase'),
|
||||
lineHeight: '24px',
|
||||
fontWeight: 400,
|
||||
textAlign: 'justify',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
export const itemSubtitle = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
fontWeight: 400,
|
||||
textAlign: 'justify',
|
||||
});
|
||||
319
packages/frontend/core/src/modules/quicksearch/views/cmdk.tsx
Normal file
319
packages/frontend/core/src/modules/quicksearch/views/cmdk.tsx
Normal file
@@ -0,0 +1,319 @@
|
||||
import { Loading } from '@affine/component/ui/loading';
|
||||
import { i18nTime, isI18nString, useI18n } from '@affine/i18n';
|
||||
import clsx from 'clsx';
|
||||
import { Command } from 'cmdk';
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import type { QuickSearchGroup } from '../types/group';
|
||||
import type { QuickSearchItem } from '../types/item';
|
||||
import * as styles from './cmdk.css';
|
||||
import { HighlightText } from './highlight-text';
|
||||
|
||||
type Groups = { group?: QuickSearchGroup; items: QuickSearchItem[] }[];
|
||||
|
||||
export const CMDK = ({
|
||||
className,
|
||||
query,
|
||||
groups: newGroups = [],
|
||||
inputLabel,
|
||||
placeholder,
|
||||
loading: newLoading = false,
|
||||
loadingProgress,
|
||||
onQueryChange,
|
||||
onSubmit,
|
||||
}: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
query: string;
|
||||
inputLabel?: ReactNode;
|
||||
placeholder?: string;
|
||||
loading?: boolean;
|
||||
loadingProgress?: number;
|
||||
groups?: Groups;
|
||||
onSubmit?: (item: QuickSearchItem) => void;
|
||||
onQueryChange?: (query: string) => void;
|
||||
}>) => {
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [{ groups, selectedValue }, dispatch] = useReducer(
|
||||
(
|
||||
state: {
|
||||
groups: Groups;
|
||||
selectedValue: string;
|
||||
},
|
||||
action:
|
||||
| { type: 'select'; payload: string }
|
||||
| { type: 'reset-select' }
|
||||
| { type: 'update-groups'; payload: Groups }
|
||||
) => {
|
||||
// control the currently selected item so that when the item list changes, the selected item remains controllable
|
||||
if (action.type === 'select') {
|
||||
return {
|
||||
...state,
|
||||
selectedValue: action.payload,
|
||||
};
|
||||
}
|
||||
if (action.type === 'reset-select') {
|
||||
// reset selected item to the first item
|
||||
const firstItem = state.groups.at(0)?.items.at(0)?.id;
|
||||
return {
|
||||
...state,
|
||||
selectedValue: firstItem ?? '',
|
||||
};
|
||||
}
|
||||
if (action.type === 'update-groups') {
|
||||
const prevGroups = state.groups;
|
||||
const prevSelectedValue = state.selectedValue;
|
||||
|
||||
const prevFirstItem = prevGroups.at(0)?.items.at(0)?.id;
|
||||
const newFirstItem = action.payload.at(0)?.items.at(0)?.id;
|
||||
const isSelectingFirstItem = prevSelectedValue === prevFirstItem;
|
||||
// if previous selected item is the first item, select the new first item
|
||||
if (isSelectingFirstItem) {
|
||||
return {
|
||||
...state,
|
||||
groups: action.payload,
|
||||
selectedValue: newFirstItem ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
const selectedExists = state.groups.some(({ items }) =>
|
||||
items.some(item => item.id === prevSelectedValue)
|
||||
);
|
||||
// if previous selected item exists in the new list, keep it
|
||||
if (selectedExists) {
|
||||
return {
|
||||
...state,
|
||||
groups: action.payload,
|
||||
selectedValue: prevSelectedValue,
|
||||
};
|
||||
}
|
||||
|
||||
// if previous selected item does not exist in the new list, select the new first item
|
||||
return {
|
||||
...state,
|
||||
groups: action.payload,
|
||||
selectedExists: newFirstItem ?? '',
|
||||
};
|
||||
}
|
||||
return state;
|
||||
},
|
||||
{ groups: [], selectedValue: '' }
|
||||
);
|
||||
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// fix list height animation on opening
|
||||
useLayoutEffect(() => {
|
||||
setOpening(true);
|
||||
const timeout = setTimeout(() => {
|
||||
setOpening(false);
|
||||
inputRef.current?.focus();
|
||||
}, 150);
|
||||
return () => {
|
||||
clearTimeout(timeout);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(query: string) => {
|
||||
onQueryChange?.(query);
|
||||
dispatch({
|
||||
type: 'reset-select',
|
||||
});
|
||||
requestAnimationFrame(() => {
|
||||
if (listRef.current) listRef.current.scrollTop = 0;
|
||||
});
|
||||
},
|
||||
[onQueryChange]
|
||||
);
|
||||
|
||||
const handleSelectChange = useCallback(
|
||||
(value: string) => {
|
||||
dispatch({
|
||||
type: 'select',
|
||||
payload: value,
|
||||
});
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
// on group change
|
||||
dispatch({
|
||||
type: 'update-groups',
|
||||
payload: newGroups,
|
||||
});
|
||||
}, [newGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
// debounce loading state
|
||||
const timeout = setTimeout(() => setLoading(newLoading), 1000);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [newLoading]);
|
||||
|
||||
return (
|
||||
<Command
|
||||
data-testid="cmdk-quick-search"
|
||||
shouldFilter={false}
|
||||
className={clsx(className, styles.root, styles.panelContainer)}
|
||||
value={selectedValue}
|
||||
onValueChange={handleSelectChange}
|
||||
loop
|
||||
>
|
||||
{inputLabel ? (
|
||||
<div className={styles.pageTitleWrapper}>
|
||||
<span className={styles.pageTitle}>{inputLabel}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={clsx(className, styles.searchInputContainer, {
|
||||
[styles.hasInputLabel]: inputLabel,
|
||||
})}
|
||||
>
|
||||
<Command.Input
|
||||
placeholder={placeholder}
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onValueChange={handleValueChange}
|
||||
className={clsx(className, styles.searchInput)}
|
||||
/>
|
||||
{loading ? (
|
||||
<Loading
|
||||
size={24}
|
||||
progress={
|
||||
loadingProgress ? Math.max(loadingProgress, 0.2) : undefined
|
||||
}
|
||||
speed={loadingProgress ? 0 : undefined}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Command.List ref={listRef} data-opening={opening ? true : undefined}>
|
||||
{groups.map(({ group, items }) => {
|
||||
return (
|
||||
<CMDKGroup
|
||||
key={group?.id ?? ''}
|
||||
onSubmit={onSubmit}
|
||||
query={query}
|
||||
group={{ group, items }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Command.List>
|
||||
</Command>
|
||||
);
|
||||
};
|
||||
|
||||
export const CMDKGroup = ({
|
||||
group: { group, items },
|
||||
onSubmit,
|
||||
query,
|
||||
}: {
|
||||
group: { group?: QuickSearchGroup; items: QuickSearchItem[] };
|
||||
onSubmit?: (item: QuickSearchItem) => void;
|
||||
query: string;
|
||||
}) => {
|
||||
const i18n = useI18n();
|
||||
return (
|
||||
<Command.Group
|
||||
key={query + ':' + (group?.id ?? '')}
|
||||
heading={group && i18n.t(group.label)}
|
||||
style={{ overflowAnchor: 'none' }}
|
||||
>
|
||||
{items.map(item => {
|
||||
const title = !isI18nString(item.label)
|
||||
? i18n.t(item.label.title)
|
||||
: i18n.t(item.label);
|
||||
const subTitle = !isI18nString(item.label)
|
||||
? item.label.subTitle && i18n.t(item.label.subTitle)
|
||||
: null;
|
||||
return (
|
||||
<Command.Item
|
||||
key={item.id}
|
||||
onSelect={() => onSubmit?.(item)}
|
||||
value={item.id}
|
||||
disabled={item.disabled}
|
||||
data-is-danger={
|
||||
item.id === 'editor:page-move-to-trash' ||
|
||||
item.id === 'editor:edgeless-move-to-trash'
|
||||
}
|
||||
>
|
||||
<div className={styles.itemIcon}>
|
||||
{item.icon &&
|
||||
(typeof item.icon === 'function' ? <item.icon /> : item.icon)}
|
||||
</div>
|
||||
<div
|
||||
data-testid="cmdk-label"
|
||||
className={styles.itemLabel}
|
||||
data-value={item.id}
|
||||
>
|
||||
<div className={styles.itemTitle}>
|
||||
<HighlightText text={title} start="<b>" end="</b>" />
|
||||
</div>
|
||||
{subTitle && (
|
||||
<div className={styles.itemSubtitle}>
|
||||
<HighlightText text={subTitle} start="<b>" end="</b>" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{item.timestamp ? (
|
||||
<div className={styles.timestamp}>
|
||||
{i18nTime(new Date(item.timestamp))}
|
||||
</div>
|
||||
) : null}
|
||||
{item.keyBinding ? (
|
||||
<CMDKKeyBinding keyBinding={item.keyBinding} />
|
||||
) : null}
|
||||
</Command.Item>
|
||||
);
|
||||
})}
|
||||
</Command.Group>
|
||||
);
|
||||
};
|
||||
|
||||
const CMDKKeyBinding = ({ keyBinding }: { keyBinding: string }) => {
|
||||
const isMacOS = environment.isBrowser && environment.isMacOs;
|
||||
const fragments = useMemo(() => {
|
||||
return keyBinding.split('+').map(fragment => {
|
||||
if (fragment === '$mod') {
|
||||
return isMacOS ? '⌘' : 'Ctrl';
|
||||
}
|
||||
if (fragment === 'ArrowUp') {
|
||||
return '↑';
|
||||
}
|
||||
if (fragment === 'ArrowDown') {
|
||||
return '↓';
|
||||
}
|
||||
if (fragment === 'ArrowLeft') {
|
||||
return '←';
|
||||
}
|
||||
if (fragment === 'ArrowRight') {
|
||||
return '→';
|
||||
}
|
||||
return fragment;
|
||||
});
|
||||
}, [isMacOS, keyBinding]);
|
||||
|
||||
return (
|
||||
<div className={styles.keybinding}>
|
||||
{fragments.map((fragment, index) => {
|
||||
return (
|
||||
<div key={index} className={styles.keybindingFragment}>
|
||||
{fragment}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { useLiveData, useServices } from '@toeverything/infra';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
|
||||
import { QuickSearchService } from '../services/quick-search';
|
||||
import type { QuickSearchGroup } from '../types/group';
|
||||
import type { QuickSearchItem } from '../types/item';
|
||||
import { CMDK } from './cmdk';
|
||||
import { QuickSearchModal } from './modal';
|
||||
|
||||
export const QuickSearchContainer = () => {
|
||||
const { quickSearchService } = useServices({
|
||||
QuickSearchService,
|
||||
});
|
||||
const quickSearch = quickSearchService.quickSearch;
|
||||
const open = useLiveData(quickSearch.show$);
|
||||
const query = useLiveData(quickSearch.query$);
|
||||
const loading = useLiveData(quickSearch.isLoading$);
|
||||
const loadingProgress = useLiveData(quickSearch.loadingProgress$);
|
||||
const items = useLiveData(quickSearch.items$);
|
||||
const options = useLiveData(quickSearch.options$);
|
||||
const i18n = useI18n();
|
||||
|
||||
const onToggleQuickSearch = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open) {
|
||||
// should never be here
|
||||
} else {
|
||||
quickSearch.hide();
|
||||
}
|
||||
},
|
||||
[quickSearch]
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const groups: { group?: QuickSearchGroup; items: QuickSearchItem[] }[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const group = item.group;
|
||||
const existingGroup = groups.find(g => g.group?.id === group?.id);
|
||||
if (existingGroup) {
|
||||
existingGroup.items.push(item);
|
||||
} else {
|
||||
groups.push({ group, items: [item] });
|
||||
}
|
||||
}
|
||||
|
||||
for (const { items } of groups) {
|
||||
items.sort((a, b) => (b.score ?? 0) - (a.score ?? 0));
|
||||
}
|
||||
|
||||
groups.sort((a, b) => {
|
||||
const group = (b.group?.score ?? 0) - (a.group?.score ?? 0);
|
||||
if (group !== 0) {
|
||||
return group;
|
||||
}
|
||||
return (b.items[0].score ?? 0) - (a.items[0].score ?? 0);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}, [items]);
|
||||
|
||||
const handleChangeQuery = useCallback(
|
||||
(query: string) => {
|
||||
quickSearch.setQuery(query);
|
||||
},
|
||||
[quickSearch]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(item: QuickSearchItem) => {
|
||||
quickSearch.submit(item);
|
||||
},
|
||||
[quickSearch]
|
||||
);
|
||||
|
||||
return (
|
||||
<QuickSearchModal open={open} onOpenChange={onToggleQuickSearch}>
|
||||
<CMDK
|
||||
query={query}
|
||||
groups={groups}
|
||||
loading={loading}
|
||||
loadingProgress={loadingProgress}
|
||||
onQueryChange={handleChangeQuery}
|
||||
onSubmit={handleSubmit}
|
||||
inputLabel={options?.label && i18n.t(options.label)}
|
||||
placeholder={options?.placeholder && i18n.t(options.placeholder)}
|
||||
/>
|
||||
</QuickSearchModal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const highlightText = style({
|
||||
whiteSpace: 'pre',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
export const highlightKeyword = style({
|
||||
display: 'inline-block',
|
||||
verticalAlign: 'bottom',
|
||||
color: cssVar('primaryColor'),
|
||||
whiteSpace: 'pre',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
flexShrink: 0,
|
||||
maxWidth: '360px',
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Fragment, useMemo } from 'react';
|
||||
|
||||
import * as styles from './highlight-text.css';
|
||||
|
||||
type HighlightProps = {
|
||||
text: string;
|
||||
start: string;
|
||||
end: string;
|
||||
};
|
||||
|
||||
export const HighlightText = ({ text = '', end, start }: HighlightProps) => {
|
||||
const parts = useMemo(
|
||||
() =>
|
||||
text.split(start).flatMap(part => {
|
||||
if (part.includes(end)) {
|
||||
const [highlighted, ...ending] = part.split(end);
|
||||
|
||||
return [
|
||||
{
|
||||
h: highlighted,
|
||||
},
|
||||
ending.join(),
|
||||
];
|
||||
} else {
|
||||
return part;
|
||||
}
|
||||
}),
|
||||
[end, start, text]
|
||||
);
|
||||
|
||||
return (
|
||||
<span className={styles.highlightText}>
|
||||
{parts.map((part, i) =>
|
||||
typeof part === 'string' ? (
|
||||
<Fragment key={i}>{part}</Fragment>
|
||||
) : (
|
||||
<span key={i} className={styles.highlightKeyword}>
|
||||
{part.h}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import { cssVar } from '@toeverything/theme';
|
||||
import { createVar, keyframes, style } from '@vanilla-extract/css';
|
||||
const contentShow = keyframes({
|
||||
from: {
|
||||
opacity: 0,
|
||||
transform: 'translateY(-2%) scale(0.96)',
|
||||
},
|
||||
to: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0) scale(1)',
|
||||
},
|
||||
});
|
||||
const contentHide = keyframes({
|
||||
to: {
|
||||
opacity: 0,
|
||||
transform: 'translateY(-2%) scale(0.96)',
|
||||
},
|
||||
from: {
|
||||
opacity: 1,
|
||||
transform: 'translateY(0) scale(1)',
|
||||
},
|
||||
});
|
||||
export const modalOverlay = style({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'transparent',
|
||||
zIndex: cssVar('zIndexModal'),
|
||||
});
|
||||
export const modalContentWrapper = style({
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
justifyContent: 'center',
|
||||
zIndex: cssVar('zIndexModal'),
|
||||
padding: '13vh 16px 16px',
|
||||
});
|
||||
|
||||
export const animationTimeout = createVar();
|
||||
|
||||
export const modalContent = style({
|
||||
width: 640,
|
||||
// height: 530,
|
||||
backgroundColor: cssVar('backgroundOverlayPanelColor'),
|
||||
boxShadow: cssVar('cmdShadow'),
|
||||
borderRadius: '12px',
|
||||
maxWidth: 'calc(100vw - 50px)',
|
||||
minWidth: 480,
|
||||
// minHeight: 420,
|
||||
// :focus-visible will set outline
|
||||
outline: 'none',
|
||||
position: 'relative',
|
||||
zIndex: cssVar('zIndexModal'),
|
||||
willChange: 'transform, opacity',
|
||||
selectors: {
|
||||
'&[data-state=entered], &[data-state=entering]': {
|
||||
animation: `${contentShow} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`,
|
||||
animationFillMode: 'forwards',
|
||||
},
|
||||
'&[data-state=exited], &[data-state=exiting]': {
|
||||
animation: `${contentHide} ${animationTimeout} cubic-bezier(0.42, 0, 0.58, 1)`,
|
||||
animationFillMode: 'forwards',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog';
|
||||
import { assignInlineVars } from '@vanilla-extract/dynamic';
|
||||
import { useEffect } from 'react';
|
||||
import { useTransition } from 'react-transition-state';
|
||||
|
||||
import * as styles from './modal.css';
|
||||
|
||||
// a QuickSearch modal that can be used to display a QuickSearch command
|
||||
// it has a smooth animation and can be closed by clicking outside of the modal
|
||||
|
||||
export interface QuickSearchModalProps {
|
||||
open: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const animationTimeout = 120;
|
||||
|
||||
export const QuickSearchModal = ({
|
||||
onOpenChange,
|
||||
open,
|
||||
children,
|
||||
}: React.PropsWithChildren<QuickSearchModalProps>) => {
|
||||
const [{ status }, toggle] = useTransition({
|
||||
timeout: animationTimeout,
|
||||
});
|
||||
useEffect(() => {
|
||||
toggle(open);
|
||||
}, [open]);
|
||||
return (
|
||||
<Dialog.Root modal open={status !== 'exited'} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className={styles.modalOverlay} />
|
||||
<div className={styles.modalContentWrapper}>
|
||||
<Dialog.Content
|
||||
style={assignInlineVars({
|
||||
[styles.animationTimeout]: `${animationTimeout}ms`,
|
||||
})}
|
||||
className={styles.modalContent}
|
||||
data-state={status}
|
||||
>
|
||||
{children}
|
||||
</Dialog.Content>
|
||||
</div>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user