refactor(component): cmdk ordering (#5722)
Replace internal CMDK command filtering/sorting logic. The new implementation includes the following for command scoring: - categories weights - highlighted fragments - original command score value The new logic should be much cleaner and remove some hacks in the original implementation. Not sure if this is optimal yet. Could be changed later. fix https://github.com/toeverything/AFFiNE/issues/5699
This commit is contained in:
@@ -25,7 +25,8 @@ export type CommandCategory =
|
|||||||
| 'affine:layout'
|
| 'affine:layout'
|
||||||
| 'affine:updates'
|
| 'affine:updates'
|
||||||
| 'affine:help'
|
| 'affine:help'
|
||||||
| 'affine:general';
|
| 'affine:general'
|
||||||
|
| 'affine:results';
|
||||||
|
|
||||||
export interface KeybindingOptions {
|
export interface KeybindingOptions {
|
||||||
binding: string;
|
binding: string;
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'vitest';
|
import { describe, expect, test } from 'vitest';
|
||||||
|
|
||||||
import { highlightTextFragments } from '../affine/use-highlight';
|
import { highlightTextFragments } from '../use-highlight';
|
||||||
|
|
||||||
describe('highlightTextFragments', () => {
|
describe('highlightTextFragments', () => {
|
||||||
test('should correctly highlight full matches', () => {
|
test('should correctly highlight full matches', () => {
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
|
import { currentPageIdAtom } from '@affine/core/atoms/mode';
|
||||||
import { useCollectionManager } from '@affine/core/components/page-list';
|
import { useCollectionManager } from '@affine/core/components/page-list';
|
||||||
import {
|
import {
|
||||||
useBlockSuitePageMeta,
|
useBlockSuitePageMeta,
|
||||||
usePageMetaHelper,
|
usePageMetaHelper,
|
||||||
} from '@affine/core/hooks/use-block-suite-page-meta';
|
} from '@affine/core/hooks/use-block-suite-page-meta';
|
||||||
|
import { useJournalHelper } from '@affine/core/hooks/use-journal';
|
||||||
|
import { CollectionService } from '@affine/core/modules/collection';
|
||||||
|
import { WorkspaceSubPath } from '@affine/core/shared';
|
||||||
import type { Collection } from '@affine/env/filter';
|
import type { Collection } from '@affine/env/filter';
|
||||||
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
||||||
import {
|
import {
|
||||||
@@ -11,8 +15,8 @@ import {
|
|||||||
TodayIcon,
|
TodayIcon,
|
||||||
ViewLayersIcon,
|
ViewLayersIcon,
|
||||||
} from '@blocksuite/icons';
|
} from '@blocksuite/icons';
|
||||||
import type { PageMeta } from '@blocksuite/store';
|
import { type PageMeta } from '@blocksuite/store';
|
||||||
import { Workspace } from '@toeverything/infra';
|
import { useService, Workspace } from '@toeverything/infra';
|
||||||
import { getCurrentStore } from '@toeverything/infra/atom';
|
import { getCurrentStore } from '@toeverything/infra/atom';
|
||||||
import {
|
import {
|
||||||
type AffineCommand,
|
type AffineCommand,
|
||||||
@@ -20,19 +24,13 @@ import {
|
|||||||
type CommandCategory,
|
type CommandCategory,
|
||||||
PreconditionStrategy,
|
PreconditionStrategy,
|
||||||
} from '@toeverything/infra/command';
|
} from '@toeverything/infra/command';
|
||||||
import { useService } from '@toeverything/infra/di';
|
|
||||||
import { commandScore } from 'cmdk';
|
|
||||||
import { atom, useAtomValue } from 'jotai';
|
import { atom, useAtomValue } from 'jotai';
|
||||||
import { groupBy } from 'lodash-es';
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { pageSettingsAtom, recentPageIdsBaseAtom } from '../../../atoms';
|
import { pageSettingsAtom, recentPageIdsBaseAtom } from '../../../atoms';
|
||||||
import { currentPageIdAtom } from '../../../atoms/mode';
|
|
||||||
import { useJournalHelper } from '../../../hooks/use-journal';
|
|
||||||
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
|
||||||
import { CollectionService } from '../../../modules/collection';
|
|
||||||
import { WorkspaceSubPath } from '../../../shared';
|
|
||||||
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
|
import { usePageHelper } from '../../blocksuite/block-suite-page-list/utils';
|
||||||
|
import { filterSortAndGroupCommands } from './filter-commands';
|
||||||
import type { CMDKCommand, CommandContext } from './types';
|
import type { CMDKCommand, CommandContext } from './types';
|
||||||
|
|
||||||
interface SearchResultsValue {
|
interface SearchResultsValue {
|
||||||
@@ -40,10 +38,6 @@ interface SearchResultsValue {
|
|||||||
content: string;
|
content: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function removeDoubleQuotes(str?: string): string | undefined {
|
|
||||||
return str?.replace(/"/g, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
export const cmdkQueryAtom = atom('');
|
export const cmdkQueryAtom = atom('');
|
||||||
export const cmdkValueAtom = atom('');
|
export const cmdkValueAtom = atom('');
|
||||||
|
|
||||||
@@ -98,9 +92,6 @@ const useRecentPages = () => {
|
|||||||
}, [recentPageIds, pages]);
|
}, [recentPageIds, pages]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const valueWrapperStart = '__>>>';
|
|
||||||
const valueWrapperEnd = '<<<__';
|
|
||||||
|
|
||||||
export const pageToCommand = (
|
export const pageToCommand = (
|
||||||
category: CommandCategory,
|
category: CommandCategory,
|
||||||
page: PageMeta,
|
page: PageMeta,
|
||||||
@@ -123,21 +114,11 @@ export const pageToCommand = (
|
|||||||
|
|
||||||
// hack: when comparing, the part between >>> and <<< will be ignored
|
// hack: when comparing, the part between >>> and <<< will be ignored
|
||||||
// adding this patch so that CMDK will not complain about duplicated commands
|
// adding this patch so that CMDK will not complain about duplicated commands
|
||||||
const id = CSS.escape(
|
const id = category + '.' + page.id;
|
||||||
title +
|
|
||||||
(label?.subTitle || '') +
|
|
||||||
valueWrapperStart +
|
|
||||||
page.id +
|
|
||||||
'.' +
|
|
||||||
category +
|
|
||||||
valueWrapperEnd
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
label: commandLabel,
|
label: commandLabel,
|
||||||
value: id,
|
|
||||||
originalValue: title,
|
|
||||||
category: category,
|
category: category,
|
||||||
run: () => {
|
run: () => {
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
@@ -154,10 +135,6 @@ export const pageToCommand = (
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const contentMatchedMagicString = '__$$content_matched$$__';
|
|
||||||
const contentMatchedWithoutSubtitle =
|
|
||||||
'__$$content_matched_without_subtitle$$__';
|
|
||||||
|
|
||||||
export const usePageCommands = () => {
|
export const usePageCommands = () => {
|
||||||
const recentPages = useRecentPages();
|
const recentPages = useRecentPages();
|
||||||
const pages = useWorkspacePages();
|
const pages = useWorkspacePages();
|
||||||
@@ -209,13 +186,6 @@ export const usePageCommands = () => {
|
|||||||
}) as unknown as Map<string, SearchResultsValue>;
|
}) as unknown as Map<string, SearchResultsValue>;
|
||||||
const resultValues = Array.from(searchResults.values());
|
const resultValues = Array.from(searchResults.values());
|
||||||
|
|
||||||
const pageIds = resultValues.map(result => {
|
|
||||||
if (result.space.startsWith('space:')) {
|
|
||||||
return result.space.slice(6);
|
|
||||||
} else {
|
|
||||||
return result.space;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const reverseMapping: Map<string, string> = new Map();
|
const reverseMapping: Map<string, string> = new Map();
|
||||||
searchResults.forEach((value, key) => {
|
searchResults.forEach((value, key) => {
|
||||||
reverseMapping.set(value.space, key);
|
reverseMapping.set(value.space, key);
|
||||||
@@ -245,16 +215,6 @@ export const usePageCommands = () => {
|
|||||||
label,
|
label,
|
||||||
blockId
|
blockId
|
||||||
);
|
);
|
||||||
|
|
||||||
if (pageIds.includes(page.id)) {
|
|
||||||
// hack to make the page always showing in the search result
|
|
||||||
command.value += contentMatchedMagicString;
|
|
||||||
}
|
|
||||||
if (!subTitle) {
|
|
||||||
// hack to make the page title result always before the content result
|
|
||||||
command.value += contentMatchedWithoutSubtitle;
|
|
||||||
}
|
|
||||||
|
|
||||||
return command;
|
return command;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -263,7 +223,7 @@ export const usePageCommands = () => {
|
|||||||
results.push({
|
results.push({
|
||||||
id: 'affine:pages:append-to-journal',
|
id: 'affine:pages:append-to-journal',
|
||||||
label: t['com.affine.journal.cmdk.append-to-today'](),
|
label: t['com.affine.journal.cmdk.append-to-today'](),
|
||||||
value: 'affine::append-journal' + query, // hack to make the page always showing in the search result
|
alwaysShow: true,
|
||||||
category: 'affine:creation',
|
category: 'affine:creation',
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const appendRes = await journalHelper.appendContentToToday(query);
|
const appendRes = await journalHelper.appendContentToToday(query);
|
||||||
@@ -283,11 +243,11 @@ export const usePageCommands = () => {
|
|||||||
label: t['com.affine.cmdk.affine.create-new-page-as']({
|
label: t['com.affine.cmdk.affine.create-new-page-as']({
|
||||||
keyWord: query,
|
keyWord: query,
|
||||||
}),
|
}),
|
||||||
value: 'affine::create-page' + query, // hack to make the page always showing in the search result
|
alwaysShow: true,
|
||||||
category: 'affine:creation',
|
category: 'affine:creation',
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const page = pageHelper.createPage();
|
const page = pageHelper.createPage();
|
||||||
await page.waitForLoaded();
|
await page.load();
|
||||||
pageMetaHelper.setPageTitle(page.id, query);
|
pageMetaHelper.setPageTitle(page.id, query);
|
||||||
},
|
},
|
||||||
icon: <PageIcon />,
|
icon: <PageIcon />,
|
||||||
@@ -298,11 +258,11 @@ export const usePageCommands = () => {
|
|||||||
label: t['com.affine.cmdk.affine.create-new-edgeless-as']({
|
label: t['com.affine.cmdk.affine.create-new-edgeless-as']({
|
||||||
keyWord: query,
|
keyWord: query,
|
||||||
}),
|
}),
|
||||||
value: 'affine::create-edgeless' + query, // hack to make the page always showing in the search result
|
alwaysShow: true,
|
||||||
category: 'affine:creation',
|
category: 'affine:creation',
|
||||||
run: async () => {
|
run: async () => {
|
||||||
const page = pageHelper.createEdgeless();
|
const page = pageHelper.createEdgeless();
|
||||||
await page.waitForLoaded();
|
await page.load();
|
||||||
pageMetaHelper.setPageTitle(page.id, query);
|
pageMetaHelper.setPageTitle(page.id, query);
|
||||||
},
|
},
|
||||||
icon: <EdgelessIcon />,
|
icon: <EdgelessIcon />,
|
||||||
@@ -337,16 +297,6 @@ export const collectionToCommand = (
|
|||||||
return {
|
return {
|
||||||
id: collection.id,
|
id: collection.id,
|
||||||
label: label,
|
label: label,
|
||||||
// hack: when comparing, the part between >>> and <<< will be ignored
|
|
||||||
// adding this patch so that CMDK will not complain about duplicated commands
|
|
||||||
value:
|
|
||||||
label +
|
|
||||||
valueWrapperStart +
|
|
||||||
collection.id +
|
|
||||||
'.' +
|
|
||||||
category +
|
|
||||||
valueWrapperEnd,
|
|
||||||
originalValue: label,
|
|
||||||
category: category,
|
category: category,
|
||||||
run: () => {
|
run: () => {
|
||||||
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
|
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
|
||||||
@@ -401,7 +351,6 @@ export const useCollectionsCommands = () => {
|
|||||||
export const useCMDKCommandGroups = () => {
|
export const useCMDKCommandGroups = () => {
|
||||||
const pageCommands = usePageCommands();
|
const pageCommands = usePageCommands();
|
||||||
const collectionCommands = useCollectionsCommands();
|
const collectionCommands = useCollectionsCommands();
|
||||||
|
|
||||||
const currentPageId = useAtomValue(currentPageIdAtom);
|
const currentPageId = useAtomValue(currentPageIdAtom);
|
||||||
const pageSettings = useAtomValue(pageSettingsAtom);
|
const pageSettings = useAtomValue(pageSettingsAtom);
|
||||||
const currentPageMode = currentPageId
|
const currentPageMode = currentPageId
|
||||||
@@ -412,6 +361,7 @@ export const useCMDKCommandGroups = () => {
|
|||||||
pageMode: currentPageMode,
|
pageMode: currentPageMode,
|
||||||
});
|
});
|
||||||
}, [currentPageMode]);
|
}, [currentPageMode]);
|
||||||
|
const query = useAtomValue(cmdkQueryAtom).trim();
|
||||||
|
|
||||||
return useMemo(() => {
|
return useMemo(() => {
|
||||||
const commands = [
|
const commands = [
|
||||||
@@ -419,63 +369,6 @@ export const useCMDKCommandGroups = () => {
|
|||||||
...pageCommands,
|
...pageCommands,
|
||||||
...affineCommands,
|
...affineCommands,
|
||||||
];
|
];
|
||||||
const groups = groupBy(commands, command => command.category);
|
return filterSortAndGroupCommands(commands, query);
|
||||||
return Object.entries(groups) as [CommandCategory, CMDKCommand[]][];
|
}, [affineCommands, collectionCommands, pageCommands, query]);
|
||||||
}, [affineCommands, collectionCommands, pageCommands]);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const customCommandFilter = (value: string, search: string) => {
|
|
||||||
// strip off the part between __>>> and <<<__
|
|
||||||
let label = value.replace(
|
|
||||||
new RegExp(valueWrapperStart + '.*' + valueWrapperEnd, 'g'),
|
|
||||||
''
|
|
||||||
);
|
|
||||||
|
|
||||||
const pageContentMatched = label.includes(contentMatchedMagicString);
|
|
||||||
if (pageContentMatched) {
|
|
||||||
label = label.replace(contentMatchedMagicString, '');
|
|
||||||
}
|
|
||||||
const pageTitleMatched = label.includes(contentMatchedWithoutSubtitle);
|
|
||||||
if (pageTitleMatched) {
|
|
||||||
label = label.replace(contentMatchedWithoutSubtitle, '');
|
|
||||||
}
|
|
||||||
|
|
||||||
// use to remove double quotes from a string until this issue is fixed
|
|
||||||
// https://github.com/pacocoursey/cmdk/issues/189
|
|
||||||
const escapedSearch = removeDoubleQuotes(search) || '';
|
|
||||||
const originalScore = commandScore(label, escapedSearch);
|
|
||||||
|
|
||||||
// hack to make the page title result always before the content result
|
|
||||||
// if the command has matched the title but not the subtitle,
|
|
||||||
// we should give it a higher score
|
|
||||||
if (originalScore > 0 && pageTitleMatched) {
|
|
||||||
return 0.999;
|
|
||||||
}
|
|
||||||
// if the command has matched the content but not the label,
|
|
||||||
// we should give it a higher score, but not too high
|
|
||||||
if (originalScore < 0.01 && pageContentMatched) {
|
|
||||||
return 0.3;
|
|
||||||
}
|
|
||||||
|
|
||||||
return originalScore;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const useCommandFilteredStatus = (
|
|
||||||
groups: [CommandCategory, CMDKCommand[]][]
|
|
||||||
) => {
|
|
||||||
// for each of the groups, show the count of commands that has matched the query
|
|
||||||
const query = useAtomValue(cmdkQueryAtom);
|
|
||||||
return useMemo(() => {
|
|
||||||
return Object.fromEntries(
|
|
||||||
groups.map(([category, commands]) => {
|
|
||||||
return [category, getCommandFilteredCount(commands, query)] as const;
|
|
||||||
})
|
|
||||||
) as Record<CommandCategory, number>;
|
|
||||||
}, [groups, query]);
|
|
||||||
};
|
|
||||||
|
|
||||||
function getCommandFilteredCount(commands: CMDKCommand[], query: string) {
|
|
||||||
return commands.filter(command => {
|
|
||||||
return command.value && customCommandFilter(command.value, query) > 0;
|
|
||||||
}).length;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { CommandCategory } from '@toeverything/infra/command';
|
||||||
|
import { commandScore } from 'cmdk';
|
||||||
|
import { groupBy } from 'lodash-es';
|
||||||
|
|
||||||
|
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;
|
||||||
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { memo } from 'react';
|
import { memo } from 'react';
|
||||||
|
|
||||||
import { useHighlight } from '../../../hooks/affine/use-highlight';
|
|
||||||
import * as styles from './highlight.css';
|
import * as styles from './highlight.css';
|
||||||
|
import { useHighlight } from './use-highlight';
|
||||||
|
|
||||||
type SearchResultLabel = {
|
type SearchResultLabel = {
|
||||||
title: string;
|
title: string;
|
||||||
@@ -22,10 +22,7 @@ export const Highlight = memo(function Highlight({
|
|||||||
text = '',
|
text = '',
|
||||||
highlight = '',
|
highlight = '',
|
||||||
}: HighlightProps) {
|
}: HighlightProps) {
|
||||||
// Use regular expression to replace all line breaks and carriage returns in the text
|
const highlights = useHighlight(text, highlight);
|
||||||
const cleanedText = text.replace(/\r?\n|\r/g, '');
|
|
||||||
|
|
||||||
const highlights = useHighlight(cleanedText, highlight.toLowerCase());
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.highlightContainer}>
|
<div className={styles.highlightContainer}>
|
||||||
|
|||||||
@@ -4,17 +4,15 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
|
|||||||
import type { PageMeta } from '@blocksuite/store';
|
import type { PageMeta } from '@blocksuite/store';
|
||||||
import type { CommandCategory } from '@toeverything/infra/command';
|
import type { CommandCategory } from '@toeverything/infra/command';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { Command, useCommandState } from 'cmdk';
|
import { Command } from 'cmdk';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { Suspense, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { Suspense, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cmdkQueryAtom,
|
cmdkQueryAtom,
|
||||||
cmdkValueAtom,
|
cmdkValueAtom,
|
||||||
customCommandFilter,
|
|
||||||
removeDoubleQuotes,
|
|
||||||
useCMDKCommandGroups,
|
useCMDKCommandGroups,
|
||||||
} from './data';
|
} from './data-hooks';
|
||||||
import { HighlightLabel } from './highlight';
|
import { HighlightLabel } from './highlight';
|
||||||
import * as styles from './main.css';
|
import * as styles from './main.css';
|
||||||
import { CMDKModal, type CMDKModalProps } from './modal';
|
import { CMDKModal, type CMDKModalProps } from './modal';
|
||||||
@@ -43,6 +41,7 @@ const categoryToI18nKey: Record<CommandCategory, i18nKey> = {
|
|||||||
'editor:insert-object':
|
'editor:insert-object':
|
||||||
'com.affine.cmdk.affine.category.editor.insert-object',
|
'com.affine.cmdk.affine.category.editor.insert-object',
|
||||||
'editor:page': 'com.affine.cmdk.affine.category.editor.page',
|
'editor:page': 'com.affine.cmdk.affine.category.editor.page',
|
||||||
|
'affine:results': 'com.affine.cmdk.affine.category.results',
|
||||||
};
|
};
|
||||||
|
|
||||||
const QuickSearchGroup = ({
|
const QuickSearchGroup = ({
|
||||||
@@ -55,7 +54,7 @@ const QuickSearchGroup = ({
|
|||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
const i18nkey = categoryToI18nKey[category];
|
const i18nKey = categoryToI18nKey[category];
|
||||||
const [query, setQuery] = useAtom(cmdkQueryAtom);
|
const [query, setQuery] = useAtom(cmdkQueryAtom);
|
||||||
|
|
||||||
const onCommendSelect = useAsyncCallback(
|
const onCommendSelect = useAsyncCallback(
|
||||||
@@ -71,7 +70,7 @@ const QuickSearchGroup = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Command.Group key={category} heading={query ? '' : t[i18nkey]()}>
|
<Command.Group key={category} heading={t[i18nKey]()}>
|
||||||
{commands.map(command => {
|
{commands.map(command => {
|
||||||
const label =
|
const label =
|
||||||
typeof command.label === 'string'
|
typeof command.label === 'string'
|
||||||
@@ -79,15 +78,11 @@ const QuickSearchGroup = ({
|
|||||||
title: command.label,
|
title: command.label,
|
||||||
}
|
}
|
||||||
: command.label;
|
: command.label;
|
||||||
|
|
||||||
// use to remove double quotes from a string until this issue is fixed
|
|
||||||
// https://github.com/pacocoursey/cmdk/issues/189
|
|
||||||
const escapeValue = removeDoubleQuotes(command.value);
|
|
||||||
return (
|
return (
|
||||||
<Command.Item
|
<Command.Item
|
||||||
key={command.id}
|
key={command.id}
|
||||||
onSelect={() => onCommendSelect(command)}
|
onSelect={() => onCommendSelect(command)}
|
||||||
value={escapeValue}
|
value={command.value}
|
||||||
data-is-danger={
|
data-is-danger={
|
||||||
command.id === 'editor:page-move-to-trash' ||
|
command.id === 'editor:page-move-to-trash' ||
|
||||||
command.id === 'editor:edgeless-move-to-trash'
|
command.id === 'editor:edgeless-move-to-trash'
|
||||||
@@ -97,9 +92,7 @@ const QuickSearchGroup = ({
|
|||||||
<div
|
<div
|
||||||
data-testid="cmdk-label"
|
data-testid="cmdk-label"
|
||||||
className={styles.itemLabel}
|
className={styles.itemLabel}
|
||||||
data-value={
|
data-value={command.value}
|
||||||
command.originalValue ? command.originalValue : undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<HighlightLabel highlight={query} label={label} />
|
<HighlightLabel highlight={query} label={label} />
|
||||||
</div>
|
</div>
|
||||||
@@ -126,34 +119,13 @@ const QuickSearchGroup = ({
|
|||||||
|
|
||||||
const QuickSearchCommands = ({
|
const QuickSearchCommands = ({
|
||||||
onOpenChange,
|
onOpenChange,
|
||||||
|
groups,
|
||||||
}: {
|
}: {
|
||||||
onOpenChange?: (open: boolean) => void;
|
onOpenChange?: (open: boolean) => void;
|
||||||
|
groups: ReturnType<typeof useCMDKCommandGroups>;
|
||||||
}) => {
|
}) => {
|
||||||
const t = useAFFiNEI18N();
|
|
||||||
const groups = useCMDKCommandGroups();
|
|
||||||
|
|
||||||
const query = useAtomValue(cmdkQueryAtom);
|
|
||||||
const resultCount = useCommandState(state => state.filtered.count);
|
|
||||||
const resultGroupHeader = useMemo(() => {
|
|
||||||
if (query) {
|
|
||||||
return (
|
|
||||||
<div className={styles.resultGroupHeader}>
|
|
||||||
{
|
|
||||||
// hack: use resultCount to determine if it is creation or results
|
|
||||||
// because the creation(as 2 results) is always shown at the top when there is no result
|
|
||||||
resultCount === 2
|
|
||||||
? t['com.affine.cmdk.affine.category.affine.creation']()
|
|
||||||
: t['com.affine.cmdk.affine.category.results']()
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}, [query, resultCount, t]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{resultGroupHeader}
|
|
||||||
{groups.map(([category, commands]) => {
|
{groups.map(([category, commands]) => {
|
||||||
return (
|
return (
|
||||||
<QuickSearchGroup
|
<QuickSearchGroup
|
||||||
@@ -181,6 +153,7 @@ export const CMDKContainer = ({
|
|||||||
className?: string;
|
className?: string;
|
||||||
query: string;
|
query: string;
|
||||||
pageMeta?: PageMeta;
|
pageMeta?: PageMeta;
|
||||||
|
groups: ReturnType<typeof useCMDKCommandGroups>;
|
||||||
onQueryChange: (query: string) => void;
|
onQueryChange: (query: string) => void;
|
||||||
}>) => {
|
}>) => {
|
||||||
const t = useAFFiNEI18N();
|
const t = useAFFiNEI18N();
|
||||||
@@ -190,7 +163,7 @@ export const CMDKContainer = ({
|
|||||||
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
// fix list height animation on openning
|
// fix list height animation on opening
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setOpening(true);
|
setOpening(true);
|
||||||
@@ -206,15 +179,15 @@ export const CMDKContainer = ({
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Command
|
<Command
|
||||||
{...rest}
|
{...rest}
|
||||||
data-testid="cmdk-quick-search"
|
data-testid="cmdk-quick-search"
|
||||||
filter={customCommandFilter}
|
shouldFilter={false}
|
||||||
className={clsx(className, styles.panelContainer)}
|
className={clsx(className, styles.panelContainer)}
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={setValue}
|
onValueChange={setValue}
|
||||||
|
loop
|
||||||
>
|
>
|
||||||
{/* todo: add page context here */}
|
{/* todo: add page context here */}
|
||||||
{isInEditor ? (
|
{isInEditor ? (
|
||||||
@@ -242,7 +215,7 @@ export const CMDKContainer = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CMDKQuickSearchModal = ({
|
const CMDKQuickSearchModalInner = ({
|
||||||
pageMeta,
|
pageMeta,
|
||||||
open,
|
open,
|
||||||
...props
|
...props
|
||||||
@@ -253,19 +226,35 @@ export const CMDKQuickSearchModal = ({
|
|||||||
setQuery('');
|
setQuery('');
|
||||||
}
|
}
|
||||||
}, [open, setQuery]);
|
}, [open, setQuery]);
|
||||||
|
const groups = useCMDKCommandGroups();
|
||||||
return (
|
return (
|
||||||
<CMDKModal open={open} {...props}>
|
|
||||||
<CMDKContainer
|
<CMDKContainer
|
||||||
className={styles.root}
|
className={styles.root}
|
||||||
query={query}
|
query={query}
|
||||||
|
groups={groups}
|
||||||
onQueryChange={setQuery}
|
onQueryChange={setQuery}
|
||||||
pageMeta={pageMeta}
|
pageMeta={pageMeta}
|
||||||
open={open}
|
open={open}
|
||||||
>
|
>
|
||||||
<Suspense fallback={<Command.Loading />}>
|
<QuickSearchCommands groups={groups} onOpenChange={props.onOpenChange} />
|
||||||
<QuickSearchCommands onOpenChange={props.onOpenChange} />
|
|
||||||
</Suspense>
|
|
||||||
</CMDKContainer>
|
</CMDKContainer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CMDKQuickSearchModal = ({
|
||||||
|
pageMeta,
|
||||||
|
open,
|
||||||
|
...props
|
||||||
|
}: CMDKModalProps & { pageMeta?: PageMeta }) => {
|
||||||
|
return (
|
||||||
|
<CMDKModal open={open} {...props}>
|
||||||
|
<Suspense fallback={<Command.Loading />}>
|
||||||
|
<CMDKQuickSearchModalInner
|
||||||
|
pageMeta={pageMeta}
|
||||||
|
open={open}
|
||||||
|
onOpenChange={props.onOpenChange}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</CMDKModal>
|
</CMDKModal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { SearchIcon } from '@blocksuite/icons';
|
|||||||
import { useCommandState } from 'cmdk';
|
import { useCommandState } from 'cmdk';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
|
|
||||||
import { cmdkQueryAtom } from './data';
|
import { cmdkQueryAtom } from './data-hooks';
|
||||||
import * as styles from './not-found.css';
|
import * as styles from './not-found.css';
|
||||||
|
|
||||||
export const NotFoundGroup = () => {
|
export const NotFoundGroup = () => {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ export interface CMDKCommand {
|
|||||||
category: CommandCategory;
|
category: CommandCategory;
|
||||||
keyBinding?: string | { binding: string };
|
keyBinding?: string | { binding: string };
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
|
alwaysShow?: boolean;
|
||||||
value?: string; // this is used for item filtering
|
value?: string; // this is used for item filtering
|
||||||
originalValue?: string; // some values may be transformed, this is the original value
|
originalValue?: string; // some values may be transformed, this is the original value
|
||||||
run: (e?: Event) => void | Promise<void>;
|
run: (e?: Event) => void | Promise<void>;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
function* highlightTextFragmentsGenerator(text: string, query: string) {
|
function* highlightTextFragmentsGenerator(text: string, query: string) {
|
||||||
const lowerCaseText = text.toLowerCase();
|
const lowerCaseText = text.replace(/\r?\n|\r/g, '').toLowerCase();
|
||||||
|
query = query.toLowerCase();
|
||||||
let startIndex = lowerCaseText.indexOf(query);
|
let startIndex = lowerCaseText.indexOf(query);
|
||||||
|
|
||||||
if (startIndex !== -1) {
|
if (startIndex !== -1) {
|
||||||
@@ -126,6 +126,7 @@ export function useRegisterBlocksuiteEditorCommands(
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// todo: should not show duplicate for journal
|
||||||
unsubs.push(
|
unsubs.push(
|
||||||
registerAffineCommand({
|
registerAffineCommand({
|
||||||
id: `editor:${mode}-duplicate`,
|
id: `editor:${mode}-duplicate`,
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ const insertInputText = async (page: Page, text: string) => {
|
|||||||
|
|
||||||
const keyboardDownAndSelect = async (page: Page, label: string) => {
|
const keyboardDownAndSelect = async (page: Page, label: string) => {
|
||||||
await page.keyboard.press('ArrowDown');
|
await page.keyboard.press('ArrowDown');
|
||||||
|
const selectedEl = page.locator(
|
||||||
|
'[cmdk-item][data-selected] [data-testid="cmdk-label"]'
|
||||||
|
);
|
||||||
if (
|
if (
|
||||||
(await page
|
!(await selectedEl.isVisible()) ||
|
||||||
.locator('[cmdk-item][data-selected] [data-testid="cmdk-label"]')
|
(await selectedEl.innerText()) !== label
|
||||||
.innerText()) !== label
|
|
||||||
) {
|
) {
|
||||||
await keyboardDownAndSelect(page, label);
|
await keyboardDownAndSelect(page, label);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button } from '@affine/component/ui/button';
|
import { Button } from '@affine/component/ui/button';
|
||||||
import { CMDKContainer, CMDKModal } from '@affine/core/components/pure/cmdk';
|
import { CMDKContainer, CMDKModal } from '@affine/core/components/pure/cmdk';
|
||||||
|
import { useCMDKCommandGroups } from '@affine/core/components/pure/cmdk/data-hooks';
|
||||||
import type { Meta, StoryFn } from '@storybook/react';
|
import type { Meta, StoryFn } from '@storybook/react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
@@ -27,9 +28,15 @@ export const CMDKModalStory: StoryFn = () => {
|
|||||||
|
|
||||||
export const CMDKPanelStory: StoryFn = () => {
|
export const CMDKPanelStory: StoryFn = () => {
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
|
const groups = useCMDKCommandGroups();
|
||||||
return (
|
return (
|
||||||
<CMDKModal open>
|
<CMDKModal open>
|
||||||
<CMDKContainer open query={query} onQueryChange={setQuery} />
|
<CMDKContainer
|
||||||
|
open
|
||||||
|
query={query}
|
||||||
|
onQueryChange={setQuery}
|
||||||
|
groups={groups}
|
||||||
|
/>
|
||||||
</CMDKModal>
|
</CMDKModal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user