mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
feat: add search doc modal (#7136)
This commit is contained in:
146
packages/frontend/core/src/modules/cmdk/entities/quick-search.ts
Normal file
146
packages/frontend/core/src/modules/cmdk/entities/quick-search.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import type {
|
||||
DocRecord,
|
||||
DocsService,
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
|
||||
import { resolveLinkToDoc } from '../../navigation';
|
||||
|
||||
type QuickSearchMode = 'commands' | 'docs';
|
||||
|
||||
export type SearchCallbackResult =
|
||||
| {
|
||||
docId: string;
|
||||
blockId?: string;
|
||||
}
|
||||
| {
|
||||
query: string;
|
||||
action: 'insert';
|
||||
};
|
||||
|
||||
// todo: move command registry to entity as well
|
||||
export class QuickSearch extends Entity {
|
||||
constructor(
|
||||
private readonly docsService: DocsService,
|
||||
private readonly workspaceService: WorkspaceService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
private readonly state$ = new LiveData<{
|
||||
mode: QuickSearchMode;
|
||||
query: string;
|
||||
callback?: (result: SearchCallbackResult | null) => void;
|
||||
} | null>(null);
|
||||
|
||||
readonly show$ = this.state$.map(s => !!s);
|
||||
|
||||
show = (
|
||||
mode: QuickSearchMode | null = 'commands',
|
||||
opts: {
|
||||
callback?: (res: SearchCallbackResult | null) => void;
|
||||
query?: string;
|
||||
} = {}
|
||||
) => {
|
||||
if (this.state$.value?.callback) {
|
||||
this.state$.value.callback(null);
|
||||
}
|
||||
if (mode === null) {
|
||||
this.state$.next(null);
|
||||
} else {
|
||||
this.state$.next({
|
||||
mode,
|
||||
query: opts.query ?? '',
|
||||
callback: opts.callback,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
mode$ = this.state$.map(s => s?.mode);
|
||||
query$ = this.state$.map(s => s?.query || '');
|
||||
|
||||
setQuery = (query: string) => {
|
||||
if (!this.state$.value) return;
|
||||
this.state$.next({
|
||||
...this.state$.value,
|
||||
query,
|
||||
});
|
||||
};
|
||||
|
||||
hide() {
|
||||
return this.show(null);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
return this.show$.value ? this.hide() : this.show();
|
||||
}
|
||||
|
||||
search(query?: string) {
|
||||
const { promise, resolve } =
|
||||
Promise.withResolvers<SearchCallbackResult | null>();
|
||||
|
||||
this.show('docs', {
|
||||
callback: resolve,
|
||||
query,
|
||||
});
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
setSearchCallbackResult(result: SearchCallbackResult) {
|
||||
if (this.state$.value?.callback) {
|
||||
this.state$.value.callback(result);
|
||||
}
|
||||
}
|
||||
|
||||
getSearchedDocs(query: string) {
|
||||
const searchResults = this.workspaceService.workspace.docCollection.search(
|
||||
query
|
||||
) as unknown as Map<
|
||||
string,
|
||||
{
|
||||
space: string;
|
||||
content: string;
|
||||
}
|
||||
>;
|
||||
// make sure we don't add the same page multiple times
|
||||
const added = new Set<string>();
|
||||
const docs = this.docsService.list.docs$.value;
|
||||
const searchedDocs: {
|
||||
doc: DocRecord;
|
||||
blockId: string;
|
||||
content?: string;
|
||||
source: 'search' | 'link-ref';
|
||||
}[] = Array.from(searchResults.entries())
|
||||
.map(([blockId, { space, content }]) => {
|
||||
const doc = docs.find(doc => doc.id === space && !added.has(doc.id));
|
||||
if (!doc) return null;
|
||||
added.add(doc.id);
|
||||
|
||||
return {
|
||||
doc,
|
||||
blockId,
|
||||
content,
|
||||
source: 'search' as const,
|
||||
};
|
||||
})
|
||||
.filter((res): res is NonNullable<typeof res> => !!res);
|
||||
|
||||
const maybeRefLink = resolveLinkToDoc(query);
|
||||
|
||||
if (maybeRefLink) {
|
||||
const doc = this.docsService.list.docs$.value.find(
|
||||
doc => doc.id === maybeRefLink.docId
|
||||
);
|
||||
if (doc) {
|
||||
searchedDocs.push({
|
||||
doc,
|
||||
blockId: maybeRefLink.blockId,
|
||||
source: 'link-ref',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return searchedDocs;
|
||||
}
|
||||
}
|
||||
22
packages/frontend/core/src/modules/cmdk/index.ts
Normal file
22
packages/frontend/core/src/modules/cmdk/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import {
|
||||
DocsService,
|
||||
type Framework,
|
||||
WorkspaceLocalState,
|
||||
WorkspaceScope,
|
||||
WorkspaceService,
|
||||
} from '@toeverything/infra';
|
||||
|
||||
import { QuickSearch } from './entities/quick-search';
|
||||
import { QuickSearchService } from './services/quick-search';
|
||||
import { RecentPagesService } from './services/recent-pages';
|
||||
|
||||
export * from './entities/quick-search';
|
||||
export { QuickSearchService, RecentPagesService };
|
||||
|
||||
export function configureQuickSearchModule(framework: Framework) {
|
||||
framework
|
||||
.scope(WorkspaceScope)
|
||||
.service(QuickSearchService)
|
||||
.service(RecentPagesService, [WorkspaceLocalState, DocsService])
|
||||
.entity(QuickSearch, [DocsService, WorkspaceService]);
|
||||
}
|
||||
@@ -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 RecentPagesService extends Service {
|
||||
constructor(
|
||||
private readonly localState: WorkspaceLocalState,
|
||||
private readonly docsService: DocsService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
addRecentDoc(pageId: string) {
|
||||
let recentPages = this.getRecentDocIds();
|
||||
recentPages = recentPages.filter(id => id !== pageId);
|
||||
if (recentPages.length >= RECENT_PAGES_LIMIT) {
|
||||
recentPages.pop();
|
||||
}
|
||||
recentPages.unshift(pageId);
|
||||
this.localState.set(RECENT_PAGES_KEY, recentPages);
|
||||
}
|
||||
|
||||
getRecentDocs() {
|
||||
const docs = this.docsService.list.docs$.value;
|
||||
return this.getRecentDocIds()
|
||||
.map(id => docs.find(doc => doc.id === id))
|
||||
.filter((d): d is DocRecord => !!d);
|
||||
}
|
||||
|
||||
private getRecentDocIds() {
|
||||
return (
|
||||
this.localState.get<string[] | null>(RECENT_PAGES_KEY) || EMPTY_ARRAY
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* @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'],
|
||||
],
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
195
packages/frontend/core/src/modules/cmdk/views/command-score.ts
Normal file
195
packages/frontend/core/src/modules/cmdk/views/command-score.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
// 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;
|
||||
}
|
||||
457
packages/frontend/core/src/modules/cmdk/views/data-hooks.tsx
Normal file
457
packages/frontend/core/src/modules/cmdk/views/data-hooks.tsx
Normal file
@@ -0,0 +1,457 @@
|
||||
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 { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import {
|
||||
EdgelessIcon,
|
||||
PageIcon,
|
||||
TodayIcon,
|
||||
ViewLayersIcon,
|
||||
} from '@blocksuite/icons';
|
||||
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 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 useAFFiNEI18N>,
|
||||
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 = useAFFiNEI18N();
|
||||
|
||||
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 = useAFFiNEI18N();
|
||||
|
||||
const onSelectPage = useCallback(
|
||||
(opts: { docId: string; blockId?: string }) => {
|
||||
if (!workspace) {
|
||||
console.error('current workspace not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (opts.blockId) {
|
||||
navigationHelper.jumpToPageBlock(
|
||||
workspace.id,
|
||||
opts.docId,
|
||||
opts.blockId
|
||||
);
|
||||
} else {
|
||||
navigationHelper.jumpToPage(workspace.id, opts.docId);
|
||||
}
|
||||
},
|
||||
[navigationHelper, workspace]
|
||||
);
|
||||
|
||||
const searchedDocsCommands = useSearchedDocCommands(onSelectPage);
|
||||
|
||||
return useMemo(() => {
|
||||
const results: CMDKCommand[] = [...searchedDocsCommands];
|
||||
|
||||
// check if the pages have exact match. if not, we should show the "create page" command
|
||||
if (
|
||||
results.every(command => command.originalValue !== query) &&
|
||||
query.trim()
|
||||
) {
|
||||
results.push({
|
||||
id: 'affine:pages:append-to-journal',
|
||||
label: t['com.affine.journal.cmdk.append-to-today'](),
|
||||
alwaysShow: true,
|
||||
category: 'affine:creation',
|
||||
run: async () => {
|
||||
const appendRes = await journalHelper.appendContentToToday(query);
|
||||
if (!appendRes) return;
|
||||
const { page, blockId } = appendRes;
|
||||
navigationHelper.jumpToPageBlock(
|
||||
page.collection.id,
|
||||
page.id,
|
||||
blockId
|
||||
);
|
||||
mixpanel.track('AppendToJournal', {
|
||||
control: 'cmdk',
|
||||
});
|
||||
},
|
||||
icon: <TodayIcon />,
|
||||
});
|
||||
|
||||
results.push({
|
||||
id: 'affine:pages:create-page',
|
||||
label: t['com.affine.cmdk.affine.create-new-page-as']({
|
||||
keyWord: query,
|
||||
}),
|
||||
alwaysShow: true,
|
||||
category: 'affine:creation',
|
||||
run: async () => {
|
||||
const page = pageHelper.createPage();
|
||||
page.load();
|
||||
pageMetaHelper.setDocTitle(page.id, query);
|
||||
mixpanel.track('DocCreated', {
|
||||
control: 'cmdk',
|
||||
type: 'doc',
|
||||
});
|
||||
},
|
||||
icon: <PageIcon />,
|
||||
});
|
||||
|
||||
results.push({
|
||||
id: 'affine:pages:create-edgeless',
|
||||
label: t['com.affine.cmdk.affine.create-new-edgeless-as']({
|
||||
keyWord: query,
|
||||
}),
|
||||
alwaysShow: true,
|
||||
category: 'affine:creation',
|
||||
run: async () => {
|
||||
const page = pageHelper.createEdgeless();
|
||||
page.load();
|
||||
pageMetaHelper.setDocTitle(page.id, query);
|
||||
mixpanel.track('DocCreated', {
|
||||
control: 'cmdk',
|
||||
type: 'whiteboard',
|
||||
});
|
||||
},
|
||||
icon: <EdgelessIcon />,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}, [
|
||||
searchedDocsCommands,
|
||||
t,
|
||||
query,
|
||||
journalHelper,
|
||||
navigationHelper,
|
||||
pageHelper,
|
||||
pageMetaHelper,
|
||||
]);
|
||||
};
|
||||
|
||||
// todo: refactor to reduce duplication with usePageCommands
|
||||
export const useSearchCallbackCommands = () => {
|
||||
const quickSearch = useService(QuickSearchService).quickSearch;
|
||||
const workspace = useService(WorkspaceService).workspace;
|
||||
const pageHelper = usePageHelper(workspace.docCollection);
|
||||
const pageMetaHelper = useDocMetaHelper(workspace.docCollection);
|
||||
const query = useLiveData(quickSearch.query$);
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
const onSelectPage = useCallback(
|
||||
(searchResult: SearchCallbackResult) => {
|
||||
if (!workspace) {
|
||||
console.error('current workspace not found');
|
||||
return;
|
||||
}
|
||||
quickSearch.setSearchCallbackResult(searchResult);
|
||||
},
|
||||
[quickSearch, workspace]
|
||||
);
|
||||
|
||||
const searchedDocsCommands = useSearchedDocCommands(onSelectPage);
|
||||
|
||||
return useMemo(() => {
|
||||
const results: CMDKCommand[] = [...searchedDocsCommands];
|
||||
|
||||
// check if the pages have exact match. if not, we should show the "create page" command
|
||||
if (
|
||||
results.every(command => command.originalValue !== query) &&
|
||||
query.trim()
|
||||
) {
|
||||
results.push({
|
||||
id: 'affine:pages:create-page',
|
||||
label: t['com.affine.cmdk.affine.create-new-doc-and-insert']({
|
||||
keyWord: query,
|
||||
}),
|
||||
alwaysShow: true,
|
||||
category: 'affine:creation',
|
||||
run: async () => {
|
||||
const page = pageHelper.createPage('page', false);
|
||||
page.load();
|
||||
pageMetaHelper.setDocTitle(page.id, query);
|
||||
mixpanel.track('DocCreated', {
|
||||
control: 'cmdk',
|
||||
type: 'doc',
|
||||
});
|
||||
onSelectPage({ docId: page.id });
|
||||
},
|
||||
icon: <PageIcon />,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}, [
|
||||
searchedDocsCommands,
|
||||
query,
|
||||
t,
|
||||
pageHelper,
|
||||
pageMetaHelper,
|
||||
onSelectPage,
|
||||
]);
|
||||
};
|
||||
|
||||
export const collectionToCommand = (
|
||||
collection: Collection,
|
||||
navigationHelper: ReturnType<typeof useNavigateHelper>,
|
||||
selectCollection: (id: string) => void,
|
||||
t: ReturnType<typeof useAFFiNEI18N>,
|
||||
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: 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 = useAFFiNEI18N();
|
||||
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]);
|
||||
};
|
||||
102
packages/frontend/core/src/modules/cmdk/views/filter-commands.ts
Normal file
102
packages/frontend/core/src/modules/cmdk/views/filter-commands.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
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 title =
|
||||
(typeof command?.label === 'string'
|
||||
? command.label
|
||||
: command?.label.title) || '';
|
||||
const subTitle =
|
||||
(typeof command?.label === 'string' ? '' : command?.label.subTitle) || '';
|
||||
|
||||
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;
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
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',
|
||||
textOverflow: 'ellipsis',
|
||||
});
|
||||
export const highlightKeyword = style({
|
||||
color: cssVar('primaryColor'),
|
||||
whiteSpace: 'pre',
|
||||
overflow: 'visible',
|
||||
flexShrink: 0,
|
||||
});
|
||||
export const labelTitle = style({
|
||||
fontSize: cssVar('fontBase'),
|
||||
lineHeight: '24px',
|
||||
fontWeight: 400,
|
||||
textAlign: 'justify',
|
||||
});
|
||||
export const labelContent = style({
|
||||
fontSize: cssVar('fontXs'),
|
||||
lineHeight: '20px',
|
||||
fontWeight: 400,
|
||||
textAlign: 'justify',
|
||||
});
|
||||
59
packages/frontend/core/src/modules/cmdk/views/highlight.tsx
Normal file
59
packages/frontend/core/src/modules/cmdk/views/highlight.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { memo } 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;
|
||||
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) {
|
||||
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>
|
||||
);
|
||||
});
|
||||
2
packages/frontend/core/src/modules/cmdk/views/index.tsx
Normal file
2
packages/frontend/core/src/modules/cmdk/views/index.tsx
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './main';
|
||||
export * from './modal';
|
||||
179
packages/frontend/core/src/modules/cmdk/views/main.css.ts
Normal file
179
packages/frontend/core/src/modules/cmdk/views/main.css.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
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,
|
||||
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',
|
||||
});
|
||||
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%',
|
||||
});
|
||||
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 resultGroupHeader = style({
|
||||
padding: '8px',
|
||||
color: cssVar('textSecondaryColor'),
|
||||
fontSize: cssVar('fontXs'),
|
||||
fontWeight: 600,
|
||||
lineHeight: '1.67',
|
||||
});
|
||||
349
packages/frontend/core/src/modules/cmdk/views/main.tsx
Normal file
349
packages/frontend/core/src/modules/cmdk/views/main.tsx
Normal file
@@ -0,0 +1,349 @@
|
||||
import { Loading } from '@affine/component/ui/loading';
|
||||
import type { CommandCategory } from '@affine/core/commands';
|
||||
import { formatDate } from '@affine/core/components/page-list';
|
||||
import { useDocEngineStatus } from '@affine/core/hooks/affine/use-doc-engine-status';
|
||||
import { useAsyncCallback } from '@affine/core/hooks/affine-async-hooks';
|
||||
import { QuickSearchService } from '@affine/core/modules/cmdk';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import type { DocMeta } from '@blocksuite/store';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { Command } from 'cmdk';
|
||||
import { useDebouncedValue } from 'foxact/use-debounced-value';
|
||||
import { useAtom } from 'jotai';
|
||||
import {
|
||||
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';
|
||||
|
||||
type NoParametersKeys<T> = {
|
||||
[K in keyof T]: T[K] extends () => any ? K : never;
|
||||
}[keyof T];
|
||||
|
||||
type i18nKey = NoParametersKeys<ReturnType<typeof useAFFiNEI18N>>;
|
||||
|
||||
const categoryToI18nKey: Record<CommandCategory, i18nKey> = {
|
||||
'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',
|
||||
};
|
||||
|
||||
const QuickSearchGroup = ({
|
||||
category,
|
||||
commands,
|
||||
onOpenChange,
|
||||
}: {
|
||||
category: CommandCategory;
|
||||
commands: CMDKCommand[];
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}) => {
|
||||
const t = useAFFiNEI18N();
|
||||
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}>
|
||||
{formatDate(new Date(command.timestamp))}
|
||||
</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 = useAFFiNEI18N();
|
||||
const [value, setValue] = useAtom(cmdkValueAtom);
|
||||
const [opening, setOpening] = useState(open);
|
||||
const { syncing, progress } = useDocEngineStatus();
|
||||
const showLoading = useDebouncedValue(syncing, 500);
|
||||
|
||||
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: 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['com.affine.cmdk.placeholder']()}
|
||||
ref={inputRef}
|
||||
{...rest}
|
||||
value={query}
|
||||
onValueChange={onQueryChange}
|
||||
className={clsx(className, styles.searchInput)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Command.List data-opening={opening ? true : undefined}>
|
||||
{children}
|
||||
</Command.List>
|
||||
<NotFoundGroup />
|
||||
</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 = useAFFiNEI18N();
|
||||
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 = useAFFiNEI18N();
|
||||
return (
|
||||
<CMDKContainer
|
||||
className={styles.root}
|
||||
query={query}
|
||||
groups={groups}
|
||||
onQueryChange={quickSearch.setQuery}
|
||||
inputLabel={t['com.affine.cmdk.insert-links']()}
|
||||
open={open}
|
||||
>
|
||||
<QuickSearchCommands groups={groups} onOpenChange={props.onOpenChange} />
|
||||
</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>
|
||||
);
|
||||
};
|
||||
65
packages/frontend/core/src/modules/cmdk/views/modal.css.ts
Normal file
65
packages/frontend/core/src/modules/cmdk/views/modal.css.ts
Normal file
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
47
packages/frontend/core/src/modules/cmdk/views/modal.tsx
Normal file
47
packages/frontend/core/src/modules/cmdk/views/modal.tsx
Normal file
@@ -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 CMDK modal that can be used to display a CMDK command
|
||||
// it has a smooth animation and can be closed by clicking outside of the modal
|
||||
|
||||
export interface CMDKModalProps {
|
||||
open: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
}
|
||||
|
||||
const animationTimeout = 120;
|
||||
|
||||
export const CMDKModal = ({
|
||||
onOpenChange,
|
||||
open,
|
||||
children,
|
||||
}: React.PropsWithChildren<CMDKModalProps>) => {
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
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',
|
||||
textAlign: 'justify',
|
||||
padding: '8px',
|
||||
});
|
||||
export const notFoundText = style({
|
||||
fontSize: cssVar('fontSm'),
|
||||
color: cssVar('textPrimaryColor'),
|
||||
lineHeight: '22px',
|
||||
fontWeight: '400',
|
||||
});
|
||||
38
packages/frontend/core/src/modules/cmdk/views/not-found.tsx
Normal file
38
packages/frontend/core/src/modules/cmdk/views/not-found.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { QuickSearchService } from '@affine/core/modules/cmdk';
|
||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||
import { SearchIcon } from '@blocksuite/icons';
|
||||
import { useLiveData, useService } from '@toeverything/infra';
|
||||
import { useCommandState } from 'cmdk';
|
||||
|
||||
import * as styles from './not-found.css';
|
||||
|
||||
export const NotFoundGroup = () => {
|
||||
const quickSearch = useService(QuickSearchService).quickSearch;
|
||||
const query = useLiveData(quickSearch.query$);
|
||||
const mode = useLiveData(quickSearch.mode$);
|
||||
// hack: we know that the filtered count is 3 when there is no result (create page & edgeless & append to journal, for mode === 'cmdk')
|
||||
const renderNoResult =
|
||||
useCommandState(state => state.filtered.count === 3) && mode === 'commands';
|
||||
|
||||
const t = useAFFiNEI18N();
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
27
packages/frontend/core/src/modules/cmdk/views/types.ts
Normal file
27
packages/frontend/core/src/modules/cmdk/views/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { CommandCategory } from '@affine/core/commands';
|
||||
import type { DocMode } from '@toeverything/infra';
|
||||
|
||||
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:
|
||||
| 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>;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
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]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { configureQuotaModule } from '@affine/core/modules/quota';
|
||||
import { configureInfraModules, type Framework } from '@toeverything/infra';
|
||||
|
||||
import { configureCloudModule } from './cloud';
|
||||
import { configureQuickSearchModule } from './cmdk';
|
||||
import { configureCollectionModule } from './collection';
|
||||
import { configureFindInPageModule } from './find-in-page';
|
||||
import { configureNavigationModule } from './navigation';
|
||||
@@ -30,6 +31,7 @@ export function configureCommonModules(framework: Framework) {
|
||||
configureTelemetryModule(framework);
|
||||
configureFindInPageModule(framework);
|
||||
configurePeekViewModule(framework);
|
||||
configureQuickSearchModule(framework);
|
||||
}
|
||||
|
||||
export function configureImpls(framework: Framework) {
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { afterEach } from 'node:test';
|
||||
|
||||
import { beforeEach, expect, test, vi } from 'vitest';
|
||||
|
||||
import { resolveLinkToDoc } from '../utils';
|
||||
|
||||
function defineTest(
|
||||
input: string,
|
||||
expected: ReturnType<typeof resolveLinkToDoc>
|
||||
) {
|
||||
test(`resolveLinkToDoc(${input})`, () => {
|
||||
const result = resolveLinkToDoc(input);
|
||||
expect(result).toEqual(expected);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('location', { origin: 'http://affine.pro' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
const testCases: [string, ReturnType<typeof resolveLinkToDoc>][] = [
|
||||
['http://example.com/', null],
|
||||
[
|
||||
'/workspace/48__RTCSwASvWZxyAk3Jw/-Uge-K6SYcAbcNYfQ5U-j#xxxx',
|
||||
{
|
||||
workspaceId: '48__RTCSwASvWZxyAk3Jw',
|
||||
docId: '-Uge-K6SYcAbcNYfQ5U-j',
|
||||
blockId: 'xxxx',
|
||||
},
|
||||
],
|
||||
[
|
||||
'http://affine.pro/workspace/48__RTCSwASvWZxyAk3Jw/-Uge-K6SYcAbcNYfQ5U-j#xxxx',
|
||||
{
|
||||
workspaceId: '48__RTCSwASvWZxyAk3Jw',
|
||||
docId: '-Uge-K6SYcAbcNYfQ5U-j',
|
||||
blockId: 'xxxx',
|
||||
},
|
||||
],
|
||||
['http://affine.pro/workspace/48__RTCSwASvWZxyAk3Jw/all', null],
|
||||
['http://affine.pro/workspace/48__RTCSwASvWZxyAk3Jw/collection', null],
|
||||
['http://affine.pro/workspace/48__RTCSwASvWZxyAk3Jw/tag', null],
|
||||
['http://affine.pro/workspace/48__RTCSwASvWZxyAk3Jw/trash', null],
|
||||
[
|
||||
'file//./workspace/48__RTCSwASvWZxyAk3Jw/-Uge-K6SYcAbcNYfQ5U-j#xxxx',
|
||||
{
|
||||
workspaceId: '48__RTCSwASvWZxyAk3Jw',
|
||||
docId: '-Uge-K6SYcAbcNYfQ5U-j',
|
||||
blockId: 'xxxx',
|
||||
},
|
||||
],
|
||||
[
|
||||
'http//localhost:8000/workspace/48__RTCSwASvWZxyAk3Jw/-Uge-K6SYcAbcNYfQ5U-j#xxxx',
|
||||
{
|
||||
workspaceId: '48__RTCSwASvWZxyAk3Jw',
|
||||
docId: '-Uge-K6SYcAbcNYfQ5U-j',
|
||||
blockId: 'xxxx',
|
||||
},
|
||||
],
|
||||
];
|
||||
|
||||
for (const [input, expected] of testCases) {
|
||||
defineTest(input, expected);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { Navigator } from './entities/navigator';
|
||||
export { resolveLinkToDoc } from './utils';
|
||||
export { NavigationButtons } from './view/navigation-buttons';
|
||||
|
||||
import { type Framework, WorkspaceScope } from '@toeverything/infra';
|
||||
|
||||
40
packages/frontend/core/src/modules/navigation/utils.ts
Normal file
40
packages/frontend/core/src/modules/navigation/utils.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
function maybeAffineOrigin(origin: string) {
|
||||
return (
|
||||
origin.startsWith('file://.') ||
|
||||
origin.startsWith('affine://') ||
|
||||
origin.endsWith('affine.pro') || // stable/beta
|
||||
origin.endsWith('affine.fail') || // canary
|
||||
origin.includes('localhost') // dev
|
||||
);
|
||||
}
|
||||
|
||||
export const resolveLinkToDoc = (href: string) => {
|
||||
try {
|
||||
const url = new URL(href, location.origin);
|
||||
|
||||
// check if origin is one of affine's origins
|
||||
|
||||
if (!maybeAffineOrigin(url.origin)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// http://xxx/workspace/48__RTCSwASvWZxyAk3Jw/-Uge-K6SYcAbcNYfQ5U-j#xxxx
|
||||
// to { workspaceId: '48__RTCSwASvWZxyAk3Jw', docId: '-Uge-K6SYcAbcNYfQ5U-j', blockId: 'xxxx' }
|
||||
|
||||
const [_, workspaceId, docId, blockId] =
|
||||
url.toString().match(/\/workspace\/([^/]+)\/([^#]+)(?:#(.+))?/) || [];
|
||||
|
||||
/**
|
||||
* @see /packages/frontend/core/src/router.tsx
|
||||
*/
|
||||
const excludedPaths = ['all', 'collection', 'tag', 'trash'];
|
||||
|
||||
if (!docId || excludedPaths.includes(docId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { workspaceId, docId, blockId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -29,6 +29,8 @@ export type ActivePeekView = {
|
||||
|
||||
import type { BlockModel } from '@blocksuite/store';
|
||||
|
||||
import { resolveLinkToDoc } from '../../navigation';
|
||||
|
||||
const EMBED_DOC_FLAVOURS = [
|
||||
'affine:embed-linked-doc',
|
||||
'affine:embed-synced-doc',
|
||||
@@ -46,25 +48,6 @@ const isSurfaceRefModel = (
|
||||
return blockModel.flavour === 'affine:surface-ref';
|
||||
};
|
||||
|
||||
const resolveLinkToDoc = (href: string) => {
|
||||
// http://xxx/workspace/48__RTCSwASvWZxyAk3Jw/-Uge-K6SYcAbcNYfQ5U-j#xxxx
|
||||
// to { workspaceId: '48__RTCSwASvWZxyAk3Jw', docId: '-Uge-K6SYcAbcNYfQ5U-j', blockId: 'xxxx' }
|
||||
|
||||
const [_, workspaceId, docId, blockId] =
|
||||
href.match(/\/workspace\/([^/]+)\/([^#]+)(?:#(.+))?/) || [];
|
||||
|
||||
/**
|
||||
* @see /packages/frontend/core/src/router.tsx
|
||||
*/
|
||||
const excludedPaths = ['all', 'collection', 'tag', 'trash'];
|
||||
|
||||
if (!docId || excludedPaths.includes(docId)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { workspaceId, docId, blockId };
|
||||
};
|
||||
|
||||
function resolvePeekInfoFromPeekTarget(
|
||||
peekTarget?: PeekViewTarget
|
||||
): DocPeekViewInfo | null {
|
||||
|
||||
Reference in New Issue
Block a user