refactor(core): new quick search service (#7214)

This commit is contained in:
EYHN
2024-07-02 09:17:53 +00:00
parent 15e99c7819
commit 40e381e272
63 changed files with 1809 additions and 2007 deletions

View File

@@ -1,147 +0,0 @@
import type {
DocRecord,
DocsService,
WorkspaceService,
} from '@toeverything/infra';
import { Entity, LiveData } from '@toeverything/infra';
import { resolveLinkToDoc } from '../../navigation';
type QuickSearchMode = 'commands' | 'docs';
export type SearchCallbackResult =
| {
docId: string;
blockId?: string;
isNewDoc?: boolean;
}
| {
query: string;
action: 'insert';
};
// TODO(@Peng): move command registry to entity as well
export class QuickSearch extends Entity {
constructor(
private readonly docsService: DocsService,
private readonly workspaceService: WorkspaceService
) {
super();
}
private readonly state$ = new LiveData<{
mode: QuickSearchMode;
query: string;
callback?: (result: SearchCallbackResult | null) => void;
} | null>(null);
readonly show$ = this.state$.map(s => !!s);
show = (
mode: QuickSearchMode | null = 'commands',
opts: {
callback?: (res: SearchCallbackResult | null) => void;
query?: string;
} = {}
) => {
if (this.state$.value?.callback) {
this.state$.value.callback(null);
}
if (mode === null) {
this.state$.next(null);
} else {
this.state$.next({
mode,
query: opts.query ?? '',
callback: opts.callback,
});
}
};
mode$ = this.state$.map(s => s?.mode);
query$ = this.state$.map(s => s?.query || '');
setQuery = (query: string) => {
if (!this.state$.value) return;
this.state$.next({
...this.state$.value,
query,
});
};
hide() {
return this.show(null);
}
toggle() {
return this.show$.value ? this.hide() : this.show();
}
search(query?: string) {
const { promise, resolve } =
Promise.withResolvers<SearchCallbackResult | null>();
this.show('docs', {
callback: resolve,
query,
});
return promise;
}
setSearchCallbackResult(result: SearchCallbackResult) {
if (this.state$.value?.callback) {
this.state$.value.callback(result);
}
}
getSearchedDocs(query: string) {
const searchResults = this.workspaceService.workspace.docCollection.search(
query
) as unknown as Map<
string,
{
space: string;
content: string;
}
>;
// make sure we don't add the same page multiple times
const added = new Set<string>();
const docs = this.docsService.list.docs$.value;
const searchedDocs: {
doc: DocRecord;
blockId: string;
content?: string;
source: 'search' | 'link-ref';
}[] = Array.from(searchResults.entries())
.map(([blockId, { space, content }]) => {
const doc = docs.find(doc => doc.id === space && !added.has(doc.id));
if (!doc) return null;
added.add(doc.id);
return {
doc,
blockId,
content,
source: 'search' as const,
};
})
.filter((res): res is NonNullable<typeof res> => !!res);
const maybeRefLink = resolveLinkToDoc(query);
if (maybeRefLink) {
const doc = this.docsService.list.docs$.value.find(
doc => doc.id === maybeRefLink.docId
);
if (doc) {
searchedDocs.push({
doc,
blockId: maybeRefLink.blockId,
source: 'link-ref',
});
}
}
return searchedDocs;
}
}

View File

@@ -1,22 +0,0 @@
import {
DocsService,
type Framework,
WorkspaceLocalState,
WorkspaceScope,
WorkspaceService,
} from '@toeverything/infra';
import { QuickSearch } from './entities/quick-search';
import { QuickSearchService } from './services/quick-search';
import { RecentPagesService } from './services/recent-pages';
export * from './entities/quick-search';
export { QuickSearchService, RecentPagesService };
export function configureQuickSearchModule(framework: Framework) {
framework
.scope(WorkspaceScope)
.service(QuickSearchService)
.service(RecentPagesService, [WorkspaceLocalState, DocsService])
.entity(QuickSearch, [DocsService, WorkspaceService]);
}

View File

@@ -1,99 +0,0 @@
import { describe, expect, it } from 'vitest';
import { commandScore } from '../command-score';
describe('commandScore', function () {
it('should match exact strings exactly', function () {
expect(commandScore('hello', 'hello')).to.equal(1);
});
it('should prefer case-sensitive matches', function () {
expect(commandScore('Hello', 'Hello')).to.be.greaterThan(
commandScore('Hello', 'hello')
);
});
it('should mark down prefixes', function () {
expect(commandScore('hello', 'hello')).to.be.greaterThan(
commandScore('hello', 'he')
);
});
it('should score all prefixes the same', function () {
expect(commandScore('help', 'he')).to.equal(commandScore('hello', 'he'));
});
it('should mark down word jumps', function () {
expect(commandScore('hello world', 'hello')).to.be.greaterThan(
commandScore('hello world', 'hewo')
);
});
it('should score similar word jumps the same', function () {
expect(commandScore('hello world', 'hewo')).to.equal(
commandScore('hey world', 'hewo')
);
});
it('should penalize long word jumps', function () {
expect(commandScore('hello world', 'hewo')).to.be.greaterThan(
commandScore('hello kind world', 'hewo')
);
});
it('should match missing characters', function () {
expect(commandScore('hello', 'hl')).to.be.greaterThan(0);
});
it('should penalize more for more missing characters', function () {
expect(commandScore('hello', 'hllo')).to.be.greaterThan(
commandScore('hello', 'hlo')
);
});
it('should penalize more for missing characters than case', function () {
expect(commandScore('go to Inbox', 'in')).to.be.greaterThan(
commandScore('go to Unversity/Societies/CUE/info@cue.org.uk', 'in')
);
});
it('should match transpotisions', function () {
expect(commandScore('hello', 'hle')).to.be.greaterThan(0);
});
it('should not match with a trailing letter', function () {
expect(commandScore('ss', 'sss')).to.equal(0.1);
});
it('should match long jumps', function () {
expect(commandScore('go to @QuickFix', 'fix')).to.be.greaterThan(0);
expect(commandScore('go to Quick Fix', 'fix')).to.be.greaterThan(
commandScore('go to @QuickFix', 'fix')
);
});
it('should work well with the presence of an m-dash', function () {
expect(commandScore('no go — Windows', 'windows')).to.be.greaterThan(0);
});
it('should be robust to duplicated letters', function () {
expect(commandScore('talent', 'tall')).to.be.equal(0.099);
});
it('should not allow letter insertion', function () {
expect(commandScore('talent', 'tadlent')).to.be.equal(0);
});
it('should match - with " " characters', function () {
expect(commandScore('Auto-Advance', 'Auto Advance')).to.be.equal(0.9999);
});
it('should score long strings quickly', function () {
expect(
commandScore(
'go to this is a really long label that is really longthis is a really long label that is really longthis is a really long label that is really longthis is a really long label that is really long',
'this is a'
)
).to.be.equal(0.891);
});
});

View File

@@ -1,121 +0,0 @@
/**
* @vitest-environment happy-dom
*/
import { describe, expect, test } from 'vitest';
import { filterSortAndGroupCommands } from '../filter-commands';
import type { CMDKCommand } from '../types';
const commands: CMDKCommand[] = (
[
{
id: 'affine:goto-all-pages',
category: 'affine:navigation',
label: { title: 'Go to All Pages' },
},
{
id: 'affine:goto-page-list',
category: 'affine:navigation',
label: { title: 'Go to Page List' },
},
{
id: 'affine:new-page',
category: 'affine:creation',
alwaysShow: true,
label: { title: 'New Page' },
},
{
id: 'affine:new-edgeless-page',
category: 'affine:creation',
alwaysShow: true,
label: { title: 'New Edgeless' },
},
{
id: 'affine:pages.foo',
category: 'affine:pages',
label: { title: 'New Page', subTitle: 'foo' },
},
{
id: 'affine:pages.bar',
category: 'affine:pages',
label: { title: 'New Page', subTitle: 'bar' },
},
] as const
).map(c => {
return {
...c,
run: () => {},
};
});
describe('filterSortAndGroupCommands', () => {
function defineTest(
name: string,
query: string,
expected: [string, string[]][]
) {
test(name, () => {
// Call the function
const result = filterSortAndGroupCommands(commands, query);
const sortedIds = result.map(([category, commands]) => {
return [category, commands.map(command => command.id)];
});
console.log(JSON.stringify(sortedIds));
// Assert the result
expect(sortedIds).toEqual(expected);
});
}
defineTest('without query', '', [
['affine:navigation', ['affine:goto-all-pages', 'affine:goto-page-list']],
['affine:creation', ['affine:new-page', 'affine:new-edgeless-page']],
['affine:pages', ['affine:pages.foo', 'affine:pages.bar']],
]);
defineTest('with query = a', 'a', [
[
'affine:results',
[
'affine:goto-all-pages',
'affine:pages.foo',
'affine:pages.bar',
'affine:new-page',
'affine:new-edgeless-page',
'affine:goto-page-list',
],
],
]);
defineTest('with query = nepa', 'nepa', [
[
'affine:results',
[
'affine:pages.foo',
'affine:pages.bar',
'affine:new-page',
'affine:new-edgeless-page',
],
],
]);
defineTest('with query = new', 'new', [
[
'affine:results',
[
'affine:pages.foo',
'affine:pages.bar',
'affine:new-page',
'affine:new-edgeless-page',
],
],
]);
defineTest('with query = foo', 'foo', [
[
'affine:results',
['affine:pages.foo', 'affine:new-page', 'affine:new-edgeless-page'],
],
]);
});

View File

@@ -1,33 +0,0 @@
import { describe, expect, test } from 'vitest';
import { highlightTextFragments } from '../use-highlight';
describe('highlightTextFragments', () => {
test('should correctly highlight full matches', () => {
const highlights = highlightTextFragments('This is a test', 'is');
expect(highlights).toStrictEqual([
{ text: 'Th', highlight: false },
{ text: 'is', highlight: true },
{ text: ' is a test', highlight: false },
]);
});
test('highlight with space', () => {
const result = highlightTextFragments('Hello World', 'lo w');
expect(result).toEqual([
{ text: 'Hel', highlight: false },
{ text: 'lo W', highlight: true },
{ text: 'orld', highlight: false },
]);
});
test('should correctly perform partial matching', () => {
const highlights = highlightTextFragments('Hello World', 'hw');
expect(highlights).toStrictEqual([
{ text: 'H', highlight: true },
{ text: 'ello ', highlight: false },
{ text: 'W', highlight: true },
{ text: 'orld', highlight: false },
]);
});
});

View File

@@ -1,195 +0,0 @@
// The scores are arranged so that a continuous match of characters will
// result in a total score of 1.
//
// The best case, this character is a match, and either this is the start
// of the string, or the previous character was also a match.
const SCORE_CONTINUE_MATCH = 1,
// A new match at the start of a word scores better than a new match
// elsewhere as it's more likely that the user will type the starts
// of fragments.
// NOTE: We score word jumps between spaces slightly higher than slashes, brackets
// hyphens, etc.
SCORE_SPACE_WORD_JUMP = 0.9,
SCORE_NON_SPACE_WORD_JUMP = 0.8,
// Any other match isn't ideal, but we include it for completeness.
SCORE_CHARACTER_JUMP = 0.17,
// If the user transposed two letters, it should be significantly penalized.
//
// i.e. "ouch" is more likely than "curtain" when "uc" is typed.
SCORE_TRANSPOSITION = 0.1,
// The goodness of a match should decay slightly with each missing
// character.
//
// i.e. "bad" is more likely than "bard" when "bd" is typed.
//
// This will not change the order of suggestions based on SCORE_* until
// 100 characters are inserted between matches.
PENALTY_SKIPPED = 0.999,
// The goodness of an exact-case match should be higher than a
// case-insensitive match by a small amount.
//
// i.e. "HTML" is more likely than "haml" when "HM" is typed.
//
// This will not change the order of suggestions based on SCORE_* until
// 1000 characters are inserted between matches.
PENALTY_CASE_MISMATCH = 0.9999,
// If the word has more characters than the user typed, it should
// be penalised slightly.
//
// i.e. "html" is more likely than "html5" if I type "html".
//
// However, it may well be the case that there's a sensible secondary
// ordering (like alphabetical) that it makes sense to rely on when
// there are many prefix matches, so we don't make the penalty increase
// with the number of tokens.
PENALTY_NOT_COMPLETE = 0.99;
const IS_GAP_REGEXP = /[\\/_+.#"@[({&]/,
COUNT_GAPS_REGEXP = /[\\/_+.#"@[({&]/g,
IS_SPACE_REGEXP = /[\s-]/,
COUNT_SPACE_REGEXP = /[\s-]/g;
const MAX_RECUR = 1500;
function commandScoreInner(
string: string,
abbreviation: string,
lowerString: string,
lowerAbbreviation: string,
stringIndex: number,
abbreviationIndex: number,
memoizedResults: Record<string, number>,
recur: number = 0
) {
recur += 1;
if (abbreviationIndex === abbreviation.length) {
if (stringIndex === string.length) {
return SCORE_CONTINUE_MATCH;
}
return PENALTY_NOT_COMPLETE;
}
const memoizeKey = `${stringIndex},${abbreviationIndex}`;
if (memoizedResults[memoizeKey] !== undefined) {
return memoizedResults[memoizeKey];
}
const abbreviationChar = lowerAbbreviation.charAt(abbreviationIndex);
let index = lowerString.indexOf(abbreviationChar, stringIndex);
let highScore = 0;
let score, transposedScore, wordBreaks, spaceBreaks;
while (index >= 0) {
score = commandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
index + 1,
abbreviationIndex + 1,
memoizedResults,
recur
);
if (score > highScore) {
if (index === stringIndex) {
score *= SCORE_CONTINUE_MATCH;
} else if (IS_GAP_REGEXP.test(string.charAt(index - 1))) {
score *= SCORE_NON_SPACE_WORD_JUMP;
wordBreaks = string
.slice(stringIndex, index - 1)
.match(COUNT_GAPS_REGEXP);
if (wordBreaks && stringIndex > 0) {
score *= Math.pow(PENALTY_SKIPPED, wordBreaks.length);
}
} else if (IS_SPACE_REGEXP.test(string.charAt(index - 1))) {
score *= SCORE_SPACE_WORD_JUMP;
spaceBreaks = string
.slice(stringIndex, index - 1)
.match(COUNT_SPACE_REGEXP);
if (spaceBreaks && stringIndex > 0) {
score *= Math.pow(PENALTY_SKIPPED, spaceBreaks.length);
}
} else {
score *= SCORE_CHARACTER_JUMP;
if (stringIndex > 0) {
score *= Math.pow(PENALTY_SKIPPED, index - stringIndex);
}
}
if (string.charAt(index) !== abbreviation.charAt(abbreviationIndex)) {
score *= PENALTY_CASE_MISMATCH;
}
}
if (
(score < SCORE_TRANSPOSITION &&
lowerString.charAt(index - 1) ===
lowerAbbreviation.charAt(abbreviationIndex + 1)) ||
(lowerAbbreviation.charAt(abbreviationIndex + 1) ===
lowerAbbreviation.charAt(abbreviationIndex) && // allow duplicate letters. Ref #7428
lowerString.charAt(index - 1) !==
lowerAbbreviation.charAt(abbreviationIndex))
) {
transposedScore = commandScoreInner(
string,
abbreviation,
lowerString,
lowerAbbreviation,
index + 1,
abbreviationIndex + 2,
memoizedResults,
recur
);
if (transposedScore * SCORE_TRANSPOSITION > score) {
score = transposedScore * SCORE_TRANSPOSITION;
}
}
if (score > highScore) {
highScore = score;
}
index = lowerString.indexOf(abbreviationChar, index + 1);
if (recur > MAX_RECUR || score > 0.85) {
break;
}
}
memoizedResults[memoizeKey] = highScore;
return highScore;
}
function formatInput(string: string) {
// convert all valid space characters to space so they match each other
return string.toLowerCase().replace(COUNT_SPACE_REGEXP, ' ');
}
export function commandScore(
string: string,
abbreviation: string,
aliases?: string[]
): number {
/* NOTE:
* in the original, we used to do the lower-casing on each recursive call, but this meant that toLowerCase()
* was the dominating cost in the algorithm, passing both is a little ugly, but considerably faster.
*/
string =
aliases && aliases.length > 0
? `${string + ' ' + aliases.join(' ')}`
: string;
const memoizedResults = {};
const result = commandScoreInner(
string,
abbreviation,
formatInput(string),
formatInput(abbreviation),
0,
0,
memoizedResults
);
return result;
}

View File

@@ -1,487 +0,0 @@
import {
type AffineCommand,
AffineCommandRegistry,
type CommandCategory,
PreconditionStrategy,
} from '@affine/core/commands';
import { useDocMetaHelper } from '@affine/core/hooks/use-block-suite-page-meta';
import { useGetDocCollectionPageTitle } from '@affine/core/hooks/use-block-suite-workspace-page-title';
import { useJournalHelper } from '@affine/core/hooks/use-journal';
import {
QuickSearchService,
RecentPagesService,
type SearchCallbackResult,
} from '@affine/core/modules/cmdk';
import { CollectionService } from '@affine/core/modules/collection';
import { WorkspaceSubPath } from '@affine/core/shared';
import { mixpanel } from '@affine/core/utils';
import type { Collection } from '@affine/env/filter';
import { Trans, useI18n } from '@affine/i18n';
import {
EdgelessIcon,
LinkIcon,
PageIcon,
TodayIcon,
ViewLayersIcon,
} from '@blocksuite/icons/rc';
import type { DocRecord, Workspace } from '@toeverything/infra';
import {
GlobalContextService,
useLiveData,
useService,
WorkspaceService,
} from '@toeverything/infra';
import { atom } from 'jotai';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { usePageHelper } from '../../../components/blocksuite/block-suite-page-list/utils';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
import { filterSortAndGroupCommands } from './filter-commands';
import * as hlStyles from './highlight.css';
import type { CMDKCommand, CommandContext } from './types';
export const cmdkValueAtom = atom('');
function filterCommandByContext(
command: AffineCommand,
context: CommandContext
) {
if (command.preconditionStrategy === PreconditionStrategy.Always) {
return true;
}
if (command.preconditionStrategy === PreconditionStrategy.InEdgeless) {
return context.docMode === 'edgeless';
}
if (command.preconditionStrategy === PreconditionStrategy.InPaper) {
return context.docMode === 'page';
}
if (command.preconditionStrategy === PreconditionStrategy.InPaperOrEdgeless) {
return !!context.docMode;
}
if (command.preconditionStrategy === PreconditionStrategy.Never) {
return false;
}
if (typeof command.preconditionStrategy === 'function') {
return command.preconditionStrategy();
}
return true;
}
function getAllCommand(context: CommandContext) {
const commands = AffineCommandRegistry.getAll();
return commands.filter(command => {
return filterCommandByContext(command, context);
});
}
const docToCommand = (
category: CommandCategory,
doc: DocRecord,
run: () => void,
getPageTitle: ReturnType<typeof useGetDocCollectionPageTitle>,
isPageJournal: (pageId: string) => boolean,
t: ReturnType<typeof useI18n>,
subTitle?: string
): CMDKCommand => {
const docMode = doc.mode$.value;
const title = getPageTitle(doc.id) || t['Untitled']();
const commandLabel = {
title: title,
subTitle: subTitle,
};
const id = category + '.' + doc.id;
const icon = isPageJournal(doc.id) ? (
<TodayIcon />
) : docMode === 'edgeless' ? (
<EdgelessIcon />
) : (
<PageIcon />
);
return {
id,
label: commandLabel,
category: category,
originalValue: title,
run: run,
icon: icon,
timestamp: doc.meta?.updatedDate,
};
};
function useSearchedDocCommands(
onSelect: (opts: { docId: string; blockId?: string }) => void
) {
const quickSearch = useService(QuickSearchService).quickSearch;
const recentPages = useService(RecentPagesService);
const query = useLiveData(quickSearch.query$);
const workspace = useService(WorkspaceService).workspace;
const getPageTitle = useGetDocCollectionPageTitle(workspace.docCollection);
const { isPageJournal } = useJournalHelper(workspace.docCollection);
const t = useI18n();
const [searchTime, setSearchTime] = useState<number>(0);
// HACK: blocksuite indexer is async,
// so we need to re-search after it has been updated
useEffect(() => {
let timer: NodeJS.Timeout | null = null;
const dosearch = () => {
setSearchTime(Date.now());
timer = setTimeout(dosearch, 500);
};
timer = setTimeout(dosearch, 500);
return () => {
if (timer) clearTimeout(timer);
};
}, []);
return useMemo(() => {
searchTime; // hack to make the searchTime as a dependency
if (query.trim().length === 0) {
return recentPages.getRecentDocs().map(doc => {
return docToCommand(
'affine:recent',
doc,
() => onSelect({ docId: doc.id }),
getPageTitle,
isPageJournal,
t
);
});
} else {
return quickSearch
.getSearchedDocs(query)
.map(({ blockId, content, doc, source }) => {
const category = 'affine:pages';
const command = docToCommand(
category,
doc,
() =>
onSelect({
docId: doc.id,
blockId,
}),
getPageTitle,
isPageJournal,
t,
content
);
if (source === 'link-ref') {
command.alwaysShow = true;
command.originalValue = query;
}
return command;
});
}
}, [
searchTime,
query,
recentPages,
getPageTitle,
isPageJournal,
t,
onSelect,
quickSearch,
]);
}
export const usePageCommands = () => {
const quickSearch = useService(QuickSearchService).quickSearch;
const workspace = useService(WorkspaceService).workspace;
const pageHelper = usePageHelper(workspace.docCollection);
const pageMetaHelper = useDocMetaHelper(workspace.docCollection);
const query = useLiveData(quickSearch.query$);
const navigationHelper = useNavigateHelper();
const journalHelper = useJournalHelper(workspace.docCollection);
const t = useI18n();
const onSelectPage = useCallback(
(opts: { docId: string; blockId?: string }) => {
if (!workspace) {
console.error('current workspace not found');
return;
}
if (opts.blockId) {
navigationHelper.jumpToPageBlock(
workspace.id,
opts.docId,
opts.blockId
);
} else {
navigationHelper.jumpToPage(workspace.id, opts.docId);
}
},
[navigationHelper, workspace]
);
const searchedDocsCommands = useSearchedDocCommands(onSelectPage);
return useMemo(() => {
const results: CMDKCommand[] = [...searchedDocsCommands];
// check if the pages have exact match. if not, we should show the "create page" command
if (
results.every(command => command.originalValue !== query) &&
query.trim()
) {
results.push({
id: 'affine:pages:append-to-journal',
label: t['com.affine.journal.cmdk.append-to-today'](),
alwaysShow: true,
category: 'affine:creation',
run: async () => {
const appendRes = await journalHelper.appendContentToToday(query);
if (!appendRes) return;
const { page, blockId } = appendRes;
navigationHelper.jumpToPageBlock(
page.collection.id,
page.id,
blockId
);
mixpanel.track('AppendToJournal', {
control: 'cmdk',
});
},
icon: <TodayIcon />,
});
results.push({
id: 'affine:pages:create-page',
label: (
<Trans
i18nKey="com.affine.cmdk.affine.create-new-page-as"
values={{
keyWord: query,
}}
components={{
1: <span className={hlStyles.highlightKeyword} />,
}}
/>
),
alwaysShow: true,
category: 'affine:creation',
run: async () => {
const page = pageHelper.createPage();
page.load();
pageMetaHelper.setDocTitle(page.id, query);
mixpanel.track('DocCreated', {
control: 'cmdk',
type: 'doc',
});
},
icon: <PageIcon />,
});
results.push({
id: 'affine:pages:create-edgeless',
label: (
<Trans
i18nKey="com.affine.cmdk.affine.create-new-edgeless-as"
values={{
keyWord: query,
}}
components={{
1: <span className={hlStyles.highlightKeyword} />,
}}
/>
),
alwaysShow: true,
category: 'affine:creation',
run: async () => {
const page = pageHelper.createEdgeless();
page.load();
pageMetaHelper.setDocTitle(page.id, query);
mixpanel.track('DocCreated', {
control: 'cmdk',
type: 'whiteboard',
});
},
icon: <EdgelessIcon />,
});
}
return results;
}, [
searchedDocsCommands,
t,
query,
journalHelper,
navigationHelper,
pageHelper,
pageMetaHelper,
]);
};
// TODO(@Peng): refactor to reduce duplication with usePageCommands
export const useSearchCallbackCommands = () => {
const quickSearch = useService(QuickSearchService).quickSearch;
const workspace = useService(WorkspaceService).workspace;
const pageHelper = usePageHelper(workspace.docCollection);
const pageMetaHelper = useDocMetaHelper(workspace.docCollection);
const query = useLiveData(quickSearch.query$);
const onSelectPage = useCallback(
(searchResult: SearchCallbackResult) => {
if (!workspace) {
console.error('current workspace not found');
return;
}
quickSearch.setSearchCallbackResult(searchResult);
},
[quickSearch, workspace]
);
const searchedDocsCommands = useSearchedDocCommands(onSelectPage);
return useMemo(() => {
const results: CMDKCommand[] = [...searchedDocsCommands];
// check if the pages have exact match. if not, we should show the "create page" command
if (
results.every(command => command.originalValue !== query) &&
query.trim()
) {
if (query.startsWith('http://') || query.startsWith('https://')) {
results.push({
id: 'affine:pages:create-page',
label: <Trans i18nKey="com.affine.cmdk.affine.insert-link" />,
alwaysShow: true,
category: 'affine:creation',
run: async () => {
onSelectPage({
query,
action: 'insert',
});
},
icon: <LinkIcon />,
});
} else {
results.push({
id: 'affine:pages:create-page',
label: (
<Trans
i18nKey="com.affine.cmdk.affine.create-new-doc-and-insert"
values={{
keyWord: query,
}}
components={{
1: <span className={hlStyles.highlightKeyword} />,
}}
/>
),
alwaysShow: true,
category: 'affine:creation',
run: async () => {
const page = pageHelper.createPage('page', false);
page.load();
pageMetaHelper.setDocTitle(page.id, query);
onSelectPage({ docId: page.id, isNewDoc: true });
},
icon: <PageIcon />,
});
}
}
return results;
}, [searchedDocsCommands, query, pageHelper, pageMetaHelper, onSelectPage]);
};
export const collectionToCommand = (
collection: Collection,
navigationHelper: ReturnType<typeof useNavigateHelper>,
selectCollection: (id: string) => void,
t: ReturnType<typeof useI18n>,
workspace: Workspace
): CMDKCommand => {
const label = collection.name || t['Untitled']();
const category = 'affine:collections';
return {
id: collection.id,
label: label,
category: category,
run: () => {
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
selectCollection(collection.id);
},
icon: <ViewLayersIcon />,
};
};
export const useCollectionsCommands = () => {
// TODO(@eyhn): considering collections for searching pages
const collectionService = useService(CollectionService);
const collections = useLiveData(collectionService.collections$);
const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$);
const navigationHelper = useNavigateHelper();
const t = useI18n();
const workspace = useService(WorkspaceService).workspace;
const selectCollection = useCallback(
(id: string) => {
navigationHelper.jumpToCollection(workspace.id, id);
},
[navigationHelper, workspace.id]
);
return useMemo(() => {
let results: CMDKCommand[] = [];
if (query.trim() === '') {
return results;
} else {
results = collections.map(collection => {
const command = collectionToCommand(
collection,
navigationHelper,
selectCollection,
t,
workspace
);
return command;
});
return results;
}
}, [query, collections, navigationHelper, selectCollection, t, workspace]);
};
export const useCMDKCommandGroups = () => {
const pageCommands = usePageCommands();
const collectionCommands = useCollectionsCommands();
const currentDocMode =
useLiveData(useService(GlobalContextService).globalContext.docMode.$) ??
undefined;
const affineCommands = useMemo(() => {
return getAllCommand({
docMode: currentDocMode,
});
}, [currentDocMode]);
const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$).trim();
return useMemo(() => {
const commands = [
...collectionCommands,
...pageCommands,
...affineCommands,
];
return filterSortAndGroupCommands(commands, query);
}, [affineCommands, collectionCommands, pageCommands, query]);
};
export const useSearchCallbackCommandGroups = () => {
const searchCallbackCommands = useSearchCallbackCommands();
const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$).trim();
return useMemo(() => {
const commands = [...searchCallbackCommands];
return filterSortAndGroupCommands(commands, query);
}, [searchCallbackCommands, query]);
};

View File

@@ -1,112 +0,0 @@
import type { CommandCategory } from '@affine/core/commands';
import { groupBy } from 'lodash-es';
import { commandScore } from './command-score';
import type { CMDKCommand } from './types';
import { highlightTextFragments } from './use-highlight';
export function filterSortAndGroupCommands(
commands: CMDKCommand[],
query: string
): [CommandCategory, CMDKCommand[]][] {
const scoredCommands = commands
.map(command => {
// attach value = id to each command
return {
...command,
value: command.id.toLowerCase(), // required by cmdk library
score: getCommandScore(command, query),
};
})
.filter(c => c.score > 0);
const sorted = scoredCommands.sort((a, b) => {
return b.score - a.score;
});
if (query) {
const onlyCreation = sorted.every(
command => command.category === 'affine:creation'
);
if (onlyCreation) {
return [['affine:creation', sorted]];
} else {
return [['affine:results', sorted]];
}
} else {
const groups = groupBy(sorted, command => command.category);
return Object.entries(groups) as [CommandCategory, CMDKCommand[]][];
}
}
const highlightScore = (text: string, search: string) => {
if (text.trim().length === 0) {
return 0;
}
const fragments = highlightTextFragments(text, search);
const highlightedFragment = fragments.filter(fragment => fragment.highlight);
// check the longest highlighted fragment
const longestFragment = Math.max(
0,
...highlightedFragment.map(fragment => fragment.text.length)
);
return longestFragment / search.length;
};
const getCategoryWeight = (command: CommandCategory) => {
switch (command) {
case 'affine:recent':
return 1;
case 'affine:pages':
case 'affine:edgeless':
case 'affine:collections':
return 0.8;
case 'affine:creation':
return 0.2;
default:
return 0.5;
}
};
const subTitleWeight = 0.8;
export const getCommandScore = (command: CMDKCommand, search: string) => {
if (search.trim() === '') {
return 1;
}
const label = command.label;
const title =
label && typeof label === 'object' && 'title' in label
? label.title
: typeof label === 'string'
? label
: '';
const subTitle =
label && typeof label === 'object' && 'title' in label
? label.subTitle ?? ''
: typeof label === 'string'
? label
: '';
const catWeight = getCategoryWeight(command.category);
const zeroComScore = Math.max(
commandScore(title, search),
commandScore(subTitle, search) * subTitleWeight
);
// if both title and subtitle has matched, we will use the higher score
const hlScore = Math.max(
highlightScore(title, search),
highlightScore(subTitle, search) * subTitleWeight
);
const score = Math.max(
zeroComScore * hlScore * catWeight,
command.alwaysShow ? 0.1 : 0
);
return score;
};

View File

@@ -1,63 +0,0 @@
import { memo, type ReactNode } from 'react';
import * as styles from './highlight.css';
import { useHighlight } from './use-highlight';
type SearchResultLabel = {
title: string;
subTitle?: string;
};
type HighlightProps = {
text: string;
highlight: string;
};
type HighlightLabelProps = {
label: SearchResultLabel | ReactNode;
highlight: string;
};
export const Highlight = memo(function Highlight({
text = '',
highlight = '',
}: HighlightProps) {
const highlights = useHighlight(text, highlight);
return (
<div className={styles.highlightContainer}>
{highlights.map((part, i) => (
<span
key={i}
className={
part.highlight ? styles.highlightKeyword : styles.highlightText
}
>
{part.text}
</span>
))}
</div>
);
});
export const HighlightLabel = memo(function HighlightLabel({
label,
highlight,
}: HighlightLabelProps) {
if (label && typeof label === 'object' && 'title' in label) {
return (
<div>
<div className={styles.labelTitle}>
<Highlight text={label.title} highlight={highlight} />
</div>
{label.subTitle ? (
<div className={styles.labelContent}>
<Highlight text={label.subTitle} highlight={highlight} />
</div>
) : null}
</div>
);
}
return <div className={styles.labelTitle}>{label}</div>;
});

View File

@@ -1,2 +0,0 @@
export * from './main';
export * from './modal';

View File

@@ -1,349 +0,0 @@
import { Loading } from '@affine/component/ui/loading';
import type { CommandCategory } from '@affine/core/commands';
import { useDocEngineStatus } from '@affine/core/hooks/affine/use-doc-engine-status';
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
import { QuickSearchService } from '@affine/core/modules/cmdk';
import { type I18nKeys, i18nTime } from '@affine/i18n';
import { useI18n } from '@affine/i18n';
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import { Command } from 'cmdk';
import { useDebouncedValue } from 'foxact/use-debounced-value';
import { useAtom } from 'jotai';
import {
type ReactNode,
Suspense,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import {
cmdkValueAtom,
useCMDKCommandGroups,
useSearchCallbackCommandGroups,
} from './data-hooks';
import { HighlightLabel } from './highlight';
import * as styles from './main.css';
import type { CMDKModalProps } from './modal';
import { CMDKModal } from './modal';
import { NotFoundGroup } from './not-found';
import type { CMDKCommand } from './types';
const categoryToI18nKey = {
'affine:recent': 'com.affine.cmdk.affine.category.affine.recent',
'affine:navigation': 'com.affine.cmdk.affine.category.affine.navigation',
'affine:creation': 'com.affine.cmdk.affine.category.affine.creation',
'affine:general': 'com.affine.cmdk.affine.category.affine.general',
'affine:layout': 'com.affine.cmdk.affine.category.affine.layout',
'affine:pages': 'com.affine.cmdk.affine.category.affine.pages',
'affine:edgeless': 'com.affine.cmdk.affine.category.affine.edgeless',
'affine:collections': 'com.affine.cmdk.affine.category.affine.collections',
'affine:settings': 'com.affine.cmdk.affine.category.affine.settings',
'affine:updates': 'com.affine.cmdk.affine.category.affine.updates',
'affine:help': 'com.affine.cmdk.affine.category.affine.help',
'editor:edgeless': 'com.affine.cmdk.affine.category.editor.edgeless',
'editor:insert-object':
'com.affine.cmdk.affine.category.editor.insert-object',
'editor:page': 'com.affine.cmdk.affine.category.editor.page',
'affine:results': 'com.affine.cmdk.affine.category.results',
} satisfies Record<CommandCategory, I18nKeys>;
const QuickSearchGroup = ({
category,
commands,
onOpenChange,
}: {
category: CommandCategory;
commands: CMDKCommand[];
onOpenChange?: (open: boolean) => void;
}) => {
const t = useI18n();
const i18nKey = categoryToI18nKey[category];
const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$);
const onCommendSelect = useAsyncCallback(
async (command: CMDKCommand) => {
try {
await command.run();
} finally {
onOpenChange?.(false);
}
},
[onOpenChange]
);
return (
<Command.Group key={category} heading={t[i18nKey]()}>
{commands.map(command => {
const label =
typeof command.label === 'string'
? {
title: command.label,
}
: command.label;
return (
<Command.Item
key={command.id}
onSelect={() => onCommendSelect(command)}
value={command.value}
data-is-danger={
command.id === 'editor:page-move-to-trash' ||
command.id === 'editor:edgeless-move-to-trash'
}
>
<div className={styles.itemIcon}>{command.icon}</div>
<div
data-testid="cmdk-label"
className={styles.itemLabel}
data-value={command.value}
>
<HighlightLabel highlight={query} label={label} />
</div>
{command.timestamp ? (
<div className={styles.timestamp}>
{i18nTime(command.timestamp, { relative: true })}
</div>
) : null}
{command.keyBinding ? (
<CMDKKeyBinding
keyBinding={
typeof command.keyBinding === 'string'
? command.keyBinding
: command.keyBinding.binding
}
/>
) : null}
</Command.Item>
);
})}
</Command.Group>
);
};
const QuickSearchCommands = ({
onOpenChange,
groups,
}: {
onOpenChange?: (open: boolean) => void;
groups: ReturnType<typeof useCMDKCommandGroups>;
}) => {
return (
<>
{groups.map(([category, commands]) => {
return (
<QuickSearchGroup
key={category}
onOpenChange={onOpenChange}
category={category}
commands={commands}
/>
);
})}
</>
);
};
export const CMDKContainer = ({
className,
onQueryChange,
query,
children,
inputLabel,
open,
...rest
}: React.PropsWithChildren<{
open: boolean;
className?: string;
query: string;
inputLabel?: ReactNode;
groups: ReturnType<typeof useCMDKCommandGroups>;
onQueryChange: (query: string) => void;
}>) => {
const t = useI18n();
const [value, setValue] = useAtom(cmdkValueAtom);
const [opening, setOpening] = useState(open);
const { syncing, progress } = useDocEngineStatus();
const showLoading = useDebouncedValue(syncing, 500);
const quickSearch = useService(QuickSearchService).quickSearch;
const mode = useLiveData(quickSearch.mode$);
const inputRef = useRef<HTMLInputElement>(null);
// fix list height animation on opening
useLayoutEffect(() => {
if (open) {
setOpening(true);
const timeout = setTimeout(() => {
setOpening(false);
inputRef.current?.focus();
}, 150);
return () => {
clearTimeout(timeout);
};
} else {
setOpening(false);
}
return;
}, [open]);
return (
<Command
{...rest}
data-testid="cmdk-quick-search"
shouldFilter={false}
className={clsx(className, styles.panelContainer)}
value={value}
onValueChange={setValue}
loop
>
{/* TODO(@Peng): add page context here */}
{inputLabel ? (
<div className={styles.pageTitleWrapper}>
<span className={styles.pageTitle}>{inputLabel}</span>
</div>
) : null}
<div
className={clsx(className, styles.searchInputContainer, {
[styles.hasInputLabel]: inputLabel,
})}
>
{showLoading ? (
<Loading
size={24}
progress={progress ? Math.max(progress, 0.2) : undefined}
speed={progress ? 0 : undefined}
/>
) : null}
<Command.Input
placeholder={t[
mode === 'commands'
? 'com.affine.cmdk.placeholder'
: 'com.affine.cmdk.docs.placeholder'
]()}
ref={inputRef}
{...rest}
value={query}
onValueChange={onQueryChange}
className={clsx(className, styles.searchInput)}
/>
</div>
<Command.List data-opening={opening ? true : undefined}>
{children}
</Command.List>
{mode === 'commands' ? <NotFoundGroup /> : null}
</Command>
);
};
const CMDKQuickSearchModalInner = ({
pageMeta,
open,
...props
}: CMDKModalProps & { pageMeta?: Partial<DocMeta> }) => {
const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$);
const groups = useCMDKCommandGroups();
const t = useI18n();
return (
<CMDKContainer
className={styles.root}
query={query}
groups={groups}
onQueryChange={quickSearch.setQuery}
inputLabel={
pageMeta ? (pageMeta.title ? pageMeta.title : t['Untitled']()) : null
}
open={open}
>
<QuickSearchCommands groups={groups} onOpenChange={props.onOpenChange} />
</CMDKContainer>
);
};
const CMDKQuickSearchCallbackModalInner = ({
open,
...props
}: CMDKModalProps & { pageMeta?: Partial<DocMeta> }) => {
const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$);
const groups = useSearchCallbackCommandGroups();
const t = useI18n();
return (
<CMDKContainer
className={styles.root}
query={query}
groups={groups}
onQueryChange={quickSearch.setQuery}
inputLabel={t['com.affine.cmdk.insert-links']()}
open={open}
>
<QuickSearchCommands groups={groups} onOpenChange={props.onOpenChange} />
</CMDKContainer>
);
};
export const CMDKQuickSearchModal = ({
pageMeta,
open,
...props
}: CMDKModalProps & { pageMeta?: Partial<DocMeta> }) => {
const quickSearch = useService(QuickSearchService).quickSearch;
const mode = useLiveData(quickSearch.mode$);
const InnerComp =
mode === 'commands'
? CMDKQuickSearchModalInner
: CMDKQuickSearchCallbackModalInner;
return (
<CMDKModal open={open} {...props}>
<Suspense fallback={<Command.Loading />}>
<InnerComp
pageMeta={pageMeta}
open={open}
onOpenChange={props.onOpenChange}
/>
</Suspense>
</CMDKModal>
);
};
const CMDKKeyBinding = ({ keyBinding }: { keyBinding: string }) => {
const isMacOS = environment.isBrowser && environment.isMacOs;
const fragments = useMemo(() => {
return keyBinding.split('+').map(fragment => {
if (fragment === '$mod') {
return isMacOS ? '⌘' : 'Ctrl';
}
if (fragment === 'ArrowUp') {
return '↑';
}
if (fragment === 'ArrowDown') {
return '↓';
}
if (fragment === 'ArrowLeft') {
return '←';
}
if (fragment === 'ArrowRight') {
return '→';
}
return fragment;
});
}, [isMacOS, keyBinding]);
return (
<div className={styles.keybinding}>
{fragments.map((fragment, index) => {
return (
<div key={index} className={styles.keybindingFragment}>
{fragment}
</div>
);
})}
</div>
);
};

View File

@@ -1,39 +0,0 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const notFoundContainer = style({
display: 'flex',
flexDirection: 'column',
padding: '0 8px',
marginBottom: 8,
});
export const notFoundItem = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
padding: '0 12px',
gap: 16,
});
export const notFoundIcon = style({
display: 'flex',
alignItems: 'center',
fontSize: 20,
color: cssVar('iconSecondary'),
padding: '12px 0',
});
export const notFoundTitle = style({
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
fontWeight: '600',
lineHeight: '20px',
whiteSpace: 'nowrap',
wordBreak: 'break-word',
textOverflow: 'ellipsis',
overflow: 'hidden',
padding: '8px',
});
export const notFoundText = style({
fontSize: cssVar('fontSm'),
color: cssVar('textPrimaryColor'),
lineHeight: '22px',
fontWeight: '400',
});

View File

@@ -1,36 +0,0 @@
import { QuickSearchService } from '@affine/core/modules/cmdk';
import { useI18n } from '@affine/i18n';
import { SearchIcon } from '@blocksuite/icons/rc';
import { useLiveData, useService } from '@toeverything/infra';
import { useCommandState } from 'cmdk';
import * as styles from './not-found.css';
export const NotFoundGroup = () => {
const quickSearch = useService(QuickSearchService).quickSearch;
const query = useLiveData(quickSearch.query$);
// hack: we know that the filtered count is 3 when there is no result (create page & edgeless & append to journal, for mode === 'cmdk')
const renderNoResult = useCommandState(state => state.filtered.count === 3);
const t = useI18n();
if (!renderNoResult) {
return null;
}
return (
<div className={styles.notFoundContainer}>
<div
className={styles.notFoundTitle}
data-testid="cmdk-search-not-found"
>{`Search for "${query}"`}</div>
<div className={styles.notFoundItem}>
<div className={styles.notFoundIcon}>
<SearchIcon />
</div>
<div className={styles.notFoundText}>
{t['com.affine.cmdk.no-results']()}
</div>
</div>
</div>
);
};

View File

@@ -1,29 +0,0 @@
import type { CommandCategory } from '@affine/core/commands';
import type { DocMode } from '@toeverything/infra';
import type { ReactNode } from 'react';
export interface CommandContext {
docMode: DocMode | undefined;
}
// similar to AffineCommand, but for rendering into the UI
// it unifies all possible commands into a single type so that
// we can use a single render function to render all different commands
export interface CMDKCommand {
id: string;
label:
| ReactNode
| string
| {
title: string;
subTitle?: string;
};
icon?: React.ReactNode;
category: CommandCategory;
keyBinding?: string | { binding: string };
timestamp?: number;
alwaysShow?: boolean;
value?: string; // this is used for item filtering
originalValue?: string; // some values may be transformed, this is the original value
run: (e?: Event) => void | Promise<void>;
}

View File

@@ -1,52 +0,0 @@
import { useMemo } from 'react';
function* highlightTextFragmentsGenerator(text: string, query: string) {
const cleanedText = text.replace(/\r?\n|\r|\t/g, '');
const lowerCaseText = cleanedText.toLowerCase();
query = query.toLowerCase();
let startIndex = lowerCaseText.indexOf(query);
if (startIndex !== -1) {
if (startIndex > 0) {
yield { text: cleanedText.substring(0, startIndex), highlight: false };
}
yield {
text: cleanedText.substring(startIndex, startIndex + query.length),
highlight: true,
};
if (startIndex + query.length < cleanedText.length) {
yield {
text: cleanedText.substring(startIndex + query.length),
highlight: false,
};
}
} else {
startIndex = 0;
for (const char of query) {
const pos = cleanedText.toLowerCase().indexOf(char, startIndex);
if (pos !== -1) {
if (pos > startIndex) {
yield {
text: cleanedText.substring(startIndex, pos),
highlight: false,
};
}
yield { text: cleanedText.substring(pos, pos + 1), highlight: true };
startIndex = pos + 1;
}
}
if (startIndex < cleanedText.length) {
yield { text: cleanedText.substring(startIndex), highlight: false };
}
}
}
export function highlightTextFragments(text: string, query: string) {
return Array.from(highlightTextFragmentsGenerator(text, query));
}
export function useHighlight(text: string, query: string) {
return useMemo(() => highlightTextFragments(text, query), [text, query]);
}

View File

@@ -1,14 +1,16 @@
import {
fromPromise,
OnEvent,
Service,
WorkspaceEngineBeforeStart,
} from '@toeverything/infra';
import { type Observable, switchMap } from 'rxjs';
import { DocsIndexer } from '../entities/docs-indexer';
@OnEvent(WorkspaceEngineBeforeStart, s => s.handleWorkspaceEngineBeforeStart)
export class DocsSearchService extends Service {
private readonly indexer = this.framework.createEntity(DocsIndexer);
readonly indexer = this.framework.createEntity(DocsIndexer);
handleWorkspaceEngineBeforeStart() {
this.indexer.setupListener();
@@ -115,6 +117,114 @@ export class DocsSearchService extends Service {
return result;
}
search$(query: string): Observable<
{
docId: string;
title: string;
score: number;
blockId?: string;
blockContent?: string;
}[]
> {
return this.indexer.blockIndex
.aggregate$(
{
type: 'boolean',
occur: 'must',
queries: [
{
type: 'match',
field: 'content',
match: query,
},
{
type: 'boolean',
occur: 'should',
queries: [
{
type: 'all',
},
{
type: 'boost',
boost: 100,
query: {
type: 'match',
field: 'flavour',
match: 'affine:page',
},
},
],
},
],
},
'docId',
{
pagination: {
limit: 50,
skip: 0,
},
hits: {
pagination: {
limit: 2,
skip: 0,
},
fields: ['blockId', 'flavour'],
highlights: [
{
field: 'content',
before: '<b>',
end: '</b>',
},
],
},
}
)
.pipe(
switchMap(({ buckets }) => {
return fromPromise(async () => {
const docData = await this.indexer.docIndex.getAll(
buckets.map(bucket => bucket.key)
);
const result = [];
for (const bucket of buckets) {
const firstMatchFlavour = bucket.hits.nodes[0]?.fields.flavour;
if (firstMatchFlavour === 'affine:page') {
// is title match
const blockContent =
bucket.hits.nodes[1]?.highlights.content[0]; // try to get block content
result.push({
docId: bucket.key,
title: bucket.hits.nodes[0].highlights.content[0],
score: bucket.score,
blockContent,
});
} else {
const title =
docData.find(doc => doc.id === bucket.key)?.get('title') ??
'';
const matchedBlockId = bucket.hits.nodes[0]?.fields.blockId;
// is block match
result.push({
docId: bucket.key,
title: typeof title === 'string' ? title : title[0],
blockId:
typeof matchedBlockId === 'string'
? matchedBlockId
: matchedBlockId[0],
score: bucket.score,
blockContent: bucket.hits.nodes[0]?.highlights.content[0],
});
}
}
return result;
});
})
);
}
async getDocTitle(docId: string) {
const doc = await this.indexer.docIndex.get(docId);
const title = doc?.get('title');

View File

@@ -2,13 +2,14 @@ import { configureQuotaModule } from '@affine/core/modules/quota';
import { configureInfraModules, type Framework } from '@toeverything/infra';
import { configureCloudModule } from './cloud';
import { configureQuickSearchModule } from './cmdk';
import { configureCollectionModule } from './collection';
import { configureDocsSearchModule } from './docs-search';
import { configureFindInPageModule } from './find-in-page';
import { configureNavigationModule } from './navigation';
import { configurePeekViewModule } from './peek-view';
import { configurePermissionsModule } from './permissions';
import { configureWorkspacePropertiesModule } from './properties';
import { configureQuickSearchModule } from './quicksearch';
import { configureRightSidebarModule } from './right-sidebar';
import { configureShareDocsModule } from './share-doc';
import { configureStorageImpls } from './storage';
@@ -32,6 +33,7 @@ export function configureCommonModules(framework: Framework) {
configureFindInPageModule(framework);
configurePeekViewModule(framework);
configureQuickSearchModule(framework);
configureDocsSearchModule(framework);
}
export function configureImpls(framework: Framework) {

View File

@@ -161,7 +161,7 @@ export class PeekViewEntity extends Entity {
// if there is an active peek view and it is a doc peek view, we will navigate it first
if (active?.info.type === 'doc' && this.show$.value) {
// TODO(@pengx17): scroll to the viewing position?
this.workbenchService.workbench.openPage(active.info.docId);
this.workbenchService.workbench.openDoc(active.info.docId);
}
this._active$.next({ target, info: resolvedInfo });

View File

@@ -97,7 +97,7 @@ export function DocPeekPreview({
useEffect(() => {
const disposable = AIProvider.slots.requestOpenWithChat.on(() => {
if (doc) {
workbench.openPage(doc.id);
workbench.openDoc(doc.id);
peekView.close();
// chat panel open is already handled in <DetailPageImpl />
}

View File

@@ -119,7 +119,7 @@ export const DocPeekViewControls = ({
// TODO(@Peng): for frame blocks, we should mimic "view in edgeless" button behavior
blockId
? jumpToPageBlock(workspace.id, docId, blockId)
: workbench.openPage(docId);
: workbench.openDoc(docId);
if (mode) {
doc?.setMode(mode);
}
@@ -131,7 +131,7 @@ export const DocPeekViewControls = ({
nameKey: 'split-view',
name: t['com.affine.peek-view-controls.open-doc-in-split-view'](),
onClick: () => {
workbench.openPage(docId, { at: 'beside' });
workbench.openDoc(docId, { at: 'beside' });
peekView.close();
},
},

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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);
}
}

View 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);
}
}

View File

@@ -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);
}
}

View 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,
]);
}

View File

@@ -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;

View File

@@ -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',
},
}
);
}
}
}

View File

@@ -10,7 +10,7 @@ const RECENT_PAGES_KEY = 'recent-pages';
const EMPTY_ARRAY: string[] = [];
export class RecentPagesService extends Service {
export class RecentDocsService extends Service {
constructor(
private readonly localState: WorkspaceLocalState,
private readonly docsService: DocsService

View File

@@ -0,0 +1,7 @@
import type { I18nString } from '@affine/i18n';
export interface QuickSearchGroup {
id: string;
label: I18nString;
score?: number;
}

View 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);

View File

@@ -0,0 +1,7 @@
import type { I18nString } from '@affine/i18n';
export interface QuickSearchOptions {
label?: I18nString;
placeholder?: I18nString;
defaultQuery?: string;
}

View File

@@ -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;
}

View File

@@ -1,61 +1,8 @@
import { cssVar } from '@toeverything/theme';
import { globalStyle, style } from '@vanilla-extract/css';
export const root = style({});
export const commandsContainer = style({
height: 'calc(100% - 65px)',
padding: '8px 6px 18px 6px',
});
export const searchInputContainer = style({
height: 66,
padding: '18px 16px',
marginBottom: '8px',
width: '100%',
display: 'flex',
alignItems: 'center',
gap: 12,
borderBottom: `1px solid ${cssVar('borderColor')}`,
flexShrink: 0,
});
export const hasInputLabel = style([
searchInputContainer,
{
paddingTop: '12px',
paddingBottom: '18px',
},
]);
export const searchInput = style({
color: cssVar('textPrimaryColor'),
fontSize: cssVar('fontH5'),
width: '100%',
'::placeholder': {
color: cssVar('textSecondaryColor'),
},
});
export const pageTitleWrapper = style({
display: 'flex',
alignItems: 'center',
padding: '18px 16px 0',
width: '100%',
});
export const pageTitle = style({
padding: '2px 6px',
borderRadius: 4,
fontSize: cssVar('fontXs'),
lineHeight: '20px',
color: cssVar('textSecondaryColor'),
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
maxWidth: '100%',
backgroundColor: cssVar('backgroundSecondaryColor'),
});
export const panelContainer = style({
height: '100%',
display: 'flex',
flexDirection: 'column',
});
export const itemIcon = style({
fontSize: 20,
marginRight: 16,
@@ -64,6 +11,7 @@ export const itemIcon = style({
alignItems: 'center',
color: cssVar('iconSecondary'),
});
export const itemLabel = style({
fontSize: 14,
lineHeight: '1.5',
@@ -73,30 +21,7 @@ export const itemLabel = style({
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const timestamp = style({
display: 'flex',
fontSize: cssVar('fontXs'),
color: cssVar('textSecondaryColor'),
minWidth: 120,
flexDirection: 'row-reverse',
});
export const keybinding = style({
display: 'flex',
fontSize: cssVar('fontXs'),
columnGap: 2,
});
export const keybindingFragment = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '0 4px',
borderRadius: 4,
color: cssVar('textSecondaryColor'),
backgroundColor: cssVar('backgroundTertiaryColor'),
minWidth: 24,
height: 20,
textTransform: 'uppercase',
});
globalStyle(`${root} [cmdk-root]`, {
height: '100%',
});
@@ -170,10 +95,100 @@ globalStyle(
color: cssVar('errorColor'),
}
);
export const resultGroupHeader = style({
padding: '8px',
color: cssVar('textSecondaryColor'),
fontSize: cssVar('fontXs'),
fontWeight: 600,
lineHeight: '1.67',
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',
});

View 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>
);
};

View File

@@ -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>
);
};

View File

@@ -1,9 +1,6 @@
import { cssVar } from '@toeverything/theme';
import { style } from '@vanilla-extract/css';
export const highlightContainer = style({
display: 'flex',
flexWrap: 'nowrap',
});
export const highlightText = style({
whiteSpace: 'pre',
overflow: 'hidden',
@@ -19,17 +16,3 @@ export const highlightKeyword = style({
flexShrink: 0,
maxWidth: '360px',
});
export const labelTitle = style({
fontSize: cssVar('fontBase'),
lineHeight: '24px',
fontWeight: 400,
textAlign: 'justify',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export const labelContent = style({
fontSize: cssVar('fontXs'),
lineHeight: '20px',
fontWeight: 400,
textAlign: 'justify',
});

View File

@@ -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>
);
};

View File

@@ -5,21 +5,21 @@ import { useTransition } from 'react-transition-state';
import * as styles from './modal.css';
// a CMDK modal that can be used to display a CMDK command
// a QuickSearch modal that can be used to display a QuickSearch command
// it has a smooth animation and can be closed by clicking outside of the modal
export interface CMDKModalProps {
export interface QuickSearchModalProps {
open: boolean;
onOpenChange?: (open: boolean) => void;
}
const animationTimeout = 120;
export const CMDKModal = ({
export const QuickSearchModal = ({
onOpenChange,
open,
children,
}: React.PropsWithChildren<CMDKModalProps>) => {
}: React.PropsWithChildren<QuickSearchModalProps>) => {
const [{ status }, toggle] = useTransition({
timeout: animationTimeout,
});

View File

@@ -71,8 +71,13 @@ export class Workbench extends Entity {
}
}
openPage(pageId: string, options?: WorkbenchOpenOptions) {
this.open(`/${pageId}`, options);
openDoc(
id: string | { docId: string; blockId?: string },
options?: WorkbenchOpenOptions
) {
const docId = typeof id === 'string' ? id : id.docId;
const blockId = typeof id === 'string' ? undefined : id.blockId;
this.open(blockId ? `/${docId}#${blockId}` : `/${docId}`, options);
}
openCollections(options?: WorkbenchOpenOptions) {