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:
Peng Xiao 2024-02-16 13:20:24 +00:00
parent 9e7eb5629c
commit d1c4e6141a
No known key found for this signature in database
GPG Key ID: 23F23D9E8B3971ED
13 changed files with 304 additions and 189 deletions

View File

@ -25,7 +25,8 @@ export type CommandCategory =
| 'affine:layout'
| 'affine:updates'
| 'affine:help'
| 'affine:general';
| 'affine:general'
| 'affine:results';
export interface KeybindingOptions {
binding: string;

View File

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

View File

@ -1,6 +1,6 @@
import { describe, expect, test } from 'vitest';
import { highlightTextFragments } from '../affine/use-highlight';
import { highlightTextFragments } from '../use-highlight';
describe('highlightTextFragments', () => {
test('should correctly highlight full matches', () => {

View File

@ -1,8 +1,12 @@
import { currentPageIdAtom } from '@affine/core/atoms/mode';
import { useCollectionManager } from '@affine/core/components/page-list';
import {
useBlockSuitePageMeta,
usePageMetaHelper,
} 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 { useAFFiNEI18N } from '@affine/i18n/hooks';
import {
@ -11,8 +15,8 @@ import {
TodayIcon,
ViewLayersIcon,
} from '@blocksuite/icons';
import type { PageMeta } from '@blocksuite/store';
import { Workspace } from '@toeverything/infra';
import { type PageMeta } from '@blocksuite/store';
import { useService, Workspace } from '@toeverything/infra';
import { getCurrentStore } from '@toeverything/infra/atom';
import {
type AffineCommand,
@ -20,19 +24,13 @@ import {
type CommandCategory,
PreconditionStrategy,
} from '@toeverything/infra/command';
import { useService } from '@toeverything/infra/di';
import { commandScore } from 'cmdk';
import { atom, useAtomValue } from 'jotai';
import { groupBy } from 'lodash-es';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { pageSettingsAtom, recentPageIdsBaseAtom } from '../../../atoms';
import { currentPageIdAtom } from '../../../atoms/mode';
import { useJournalHelper } from '../../../hooks/use-journal';
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 { filterSortAndGroupCommands } from './filter-commands';
import type { CMDKCommand, CommandContext } from './types';
interface SearchResultsValue {
@ -40,10 +38,6 @@ interface SearchResultsValue {
content: string;
}
export function removeDoubleQuotes(str?: string): string | undefined {
return str?.replace(/"/g, '');
}
export const cmdkQueryAtom = atom('');
export const cmdkValueAtom = atom('');
@ -98,9 +92,6 @@ const useRecentPages = () => {
}, [recentPageIds, pages]);
};
const valueWrapperStart = '__>>>';
const valueWrapperEnd = '<<<__';
export const pageToCommand = (
category: CommandCategory,
page: PageMeta,
@ -123,21 +114,11 @@ export const pageToCommand = (
// hack: when comparing, the part between >>> and <<< will be ignored
// adding this patch so that CMDK will not complain about duplicated commands
const id = CSS.escape(
title +
(label?.subTitle || '') +
valueWrapperStart +
page.id +
'.' +
category +
valueWrapperEnd
);
const id = category + '.' + page.id;
return {
id,
label: commandLabel,
value: id,
originalValue: title,
category: category,
run: () => {
if (!workspace) {
@ -154,10 +135,6 @@ export const pageToCommand = (
};
};
const contentMatchedMagicString = '__$$content_matched$$__';
const contentMatchedWithoutSubtitle =
'__$$content_matched_without_subtitle$$__';
export const usePageCommands = () => {
const recentPages = useRecentPages();
const pages = useWorkspacePages();
@ -209,13 +186,6 @@ export const usePageCommands = () => {
}) as unknown as Map<string, SearchResultsValue>;
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();
searchResults.forEach((value, key) => {
reverseMapping.set(value.space, key);
@ -245,16 +215,6 @@ export const usePageCommands = () => {
label,
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;
});
@ -263,7 +223,7 @@ export const usePageCommands = () => {
results.push({
id: 'affine:pages:append-to-journal',
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',
run: async () => {
const appendRes = await journalHelper.appendContentToToday(query);
@ -283,11 +243,11 @@ export const usePageCommands = () => {
label: t['com.affine.cmdk.affine.create-new-page-as']({
keyWord: query,
}),
value: 'affine::create-page' + query, // hack to make the page always showing in the search result
alwaysShow: true,
category: 'affine:creation',
run: async () => {
const page = pageHelper.createPage();
await page.waitForLoaded();
await page.load();
pageMetaHelper.setPageTitle(page.id, query);
},
icon: <PageIcon />,
@ -298,11 +258,11 @@ export const usePageCommands = () => {
label: t['com.affine.cmdk.affine.create-new-edgeless-as']({
keyWord: query,
}),
value: 'affine::create-edgeless' + query, // hack to make the page always showing in the search result
alwaysShow: true,
category: 'affine:creation',
run: async () => {
const page = pageHelper.createEdgeless();
await page.waitForLoaded();
await page.load();
pageMetaHelper.setPageTitle(page.id, query);
},
icon: <EdgelessIcon />,
@ -337,16 +297,6 @@ export const collectionToCommand = (
return {
id: collection.id,
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,
run: () => {
navigationHelper.jumpToSubPath(workspace.id, WorkspaceSubPath.ALL);
@ -401,7 +351,6 @@ export const useCollectionsCommands = () => {
export const useCMDKCommandGroups = () => {
const pageCommands = usePageCommands();
const collectionCommands = useCollectionsCommands();
const currentPageId = useAtomValue(currentPageIdAtom);
const pageSettings = useAtomValue(pageSettingsAtom);
const currentPageMode = currentPageId
@ -412,6 +361,7 @@ export const useCMDKCommandGroups = () => {
pageMode: currentPageMode,
});
}, [currentPageMode]);
const query = useAtomValue(cmdkQueryAtom).trim();
return useMemo(() => {
const commands = [
@ -419,63 +369,6 @@ export const useCMDKCommandGroups = () => {
...pageCommands,
...affineCommands,
];
const groups = groupBy(commands, command => command.category);
return Object.entries(groups) as [CommandCategory, CMDKCommand[]][];
}, [affineCommands, collectionCommands, pageCommands]);
return filterSortAndGroupCommands(commands, query);
}, [affineCommands, collectionCommands, pageCommands, query]);
};
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;
}

View File

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

View File

@ -1,7 +1,7 @@
import { memo } from 'react';
import { useHighlight } from '../../../hooks/affine/use-highlight';
import * as styles from './highlight.css';
import { useHighlight } from './use-highlight';
type SearchResultLabel = {
title: string;
@ -22,10 +22,7 @@ export const Highlight = memo(function Highlight({
text = '',
highlight = '',
}: HighlightProps) {
// Use regular expression to replace all line breaks and carriage returns in the text
const cleanedText = text.replace(/\r?\n|\r/g, '');
const highlights = useHighlight(cleanedText, highlight.toLowerCase());
const highlights = useHighlight(text, highlight);
return (
<div className={styles.highlightContainer}>

View File

@ -4,17 +4,15 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { PageMeta } from '@blocksuite/store';
import type { CommandCategory } from '@toeverything/infra/command';
import clsx from 'clsx';
import { Command, useCommandState } from 'cmdk';
import { useAtom, useAtomValue } from 'jotai';
import { Command } from 'cmdk';
import { useAtom } from 'jotai';
import { Suspense, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
cmdkQueryAtom,
cmdkValueAtom,
customCommandFilter,
removeDoubleQuotes,
useCMDKCommandGroups,
} from './data';
} from './data-hooks';
import { HighlightLabel } from './highlight';
import * as styles from './main.css';
import { CMDKModal, type CMDKModalProps } from './modal';
@ -43,6 +41,7 @@ const categoryToI18nKey: Record<CommandCategory, i18nKey> = {
'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 = ({
@ -55,7 +54,7 @@ const QuickSearchGroup = ({
onOpenChange?: (open: boolean) => void;
}) => {
const t = useAFFiNEI18N();
const i18nkey = categoryToI18nKey[category];
const i18nKey = categoryToI18nKey[category];
const [query, setQuery] = useAtom(cmdkQueryAtom);
const onCommendSelect = useAsyncCallback(
@ -71,7 +70,7 @@ const QuickSearchGroup = ({
);
return (
<Command.Group key={category} heading={query ? '' : t[i18nkey]()}>
<Command.Group key={category} heading={t[i18nKey]()}>
{commands.map(command => {
const label =
typeof command.label === 'string'
@ -79,15 +78,11 @@ const QuickSearchGroup = ({
title: 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 (
<Command.Item
key={command.id}
onSelect={() => onCommendSelect(command)}
value={escapeValue}
value={command.value}
data-is-danger={
command.id === 'editor:page-move-to-trash' ||
command.id === 'editor:edgeless-move-to-trash'
@ -97,9 +92,7 @@ const QuickSearchGroup = ({
<div
data-testid="cmdk-label"
className={styles.itemLabel}
data-value={
command.originalValue ? command.originalValue : undefined
}
data-value={command.value}
>
<HighlightLabel highlight={query} label={label} />
</div>
@ -126,34 +119,13 @@ const QuickSearchGroup = ({
const QuickSearchCommands = ({
onOpenChange,
groups,
}: {
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 (
<>
{resultGroupHeader}
{groups.map(([category, commands]) => {
return (
<QuickSearchGroup
@ -181,6 +153,7 @@ export const CMDKContainer = ({
className?: string;
query: string;
pageMeta?: PageMeta;
groups: ReturnType<typeof useCMDKCommandGroups>;
onQueryChange: (query: string) => void;
}>) => {
const t = useAFFiNEI18N();
@ -190,7 +163,7 @@ export const CMDKContainer = ({
const inputRef = useRef<HTMLInputElement>(null);
// fix list height animation on openning
// fix list height animation on opening
useLayoutEffect(() => {
if (open) {
setOpening(true);
@ -206,15 +179,15 @@ export const CMDKContainer = ({
}
return;
}, [open]);
return (
<Command
{...rest}
data-testid="cmdk-quick-search"
filter={customCommandFilter}
shouldFilter={false}
className={clsx(className, styles.panelContainer)}
value={value}
onValueChange={setValue}
loop
>
{/* todo: add page context here */}
{isInEditor ? (
@ -242,7 +215,7 @@ export const CMDKContainer = ({
);
};
export const CMDKQuickSearchModal = ({
const CMDKQuickSearchModalInner = ({
pageMeta,
open,
...props
@ -253,19 +226,35 @@ export const CMDKQuickSearchModal = ({
setQuery('');
}
}, [open, setQuery]);
const groups = useCMDKCommandGroups();
return (
<CMDKContainer
className={styles.root}
query={query}
groups={groups}
onQueryChange={setQuery}
pageMeta={pageMeta}
open={open}
>
<QuickSearchCommands groups={groups} onOpenChange={props.onOpenChange} />
</CMDKContainer>
);
};
export const CMDKQuickSearchModal = ({
pageMeta,
open,
...props
}: CMDKModalProps & { pageMeta?: PageMeta }) => {
return (
<CMDKModal open={open} {...props}>
<CMDKContainer
className={styles.root}
query={query}
onQueryChange={setQuery}
pageMeta={pageMeta}
open={open}
>
<Suspense fallback={<Command.Loading />}>
<QuickSearchCommands onOpenChange={props.onOpenChange} />
</Suspense>
</CMDKContainer>
<Suspense fallback={<Command.Loading />}>
<CMDKQuickSearchModalInner
pageMeta={pageMeta}
open={open}
onOpenChange={props.onOpenChange}
/>
</Suspense>
</CMDKModal>
);
};

View File

@ -2,7 +2,7 @@ import { SearchIcon } from '@blocksuite/icons';
import { useCommandState } from 'cmdk';
import { useAtomValue } from 'jotai';
import { cmdkQueryAtom } from './data';
import { cmdkQueryAtom } from './data-hooks';
import * as styles from './not-found.css';
export const NotFoundGroup = () => {

View File

@ -19,6 +19,7 @@ export interface CMDKCommand {
category: CommandCategory;
keyBinding?: string | { binding: string };
timestamp?: number;
alwaysShow?: boolean;
value?: string; // this is used for item filtering
originalValue?: string; // some values may be transformed, this is the original value
run: (e?: Event) => void | Promise<void>;

View File

@ -1,7 +1,8 @@
import { useMemo } from 'react';
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);
if (startIndex !== -1) {

View File

@ -126,6 +126,7 @@ export function useRegisterBlocksuiteEditorCommands(
})
);
// todo: should not show duplicate for journal
unsubs.push(
registerAffineCommand({
id: `editor:${mode}-duplicate`,

View File

@ -24,10 +24,12 @@ const insertInputText = async (page: Page, text: string) => {
const keyboardDownAndSelect = async (page: Page, label: string) => {
await page.keyboard.press('ArrowDown');
const selectedEl = page.locator(
'[cmdk-item][data-selected] [data-testid="cmdk-label"]'
);
if (
(await page
.locator('[cmdk-item][data-selected] [data-testid="cmdk-label"]')
.innerText()) !== label
!(await selectedEl.isVisible()) ||
(await selectedEl.innerText()) !== label
) {
await keyboardDownAndSelect(page, label);
} else {

View File

@ -1,5 +1,6 @@
import { Button } from '@affine/component/ui/button';
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 { useState } from 'react';
@ -27,9 +28,15 @@ export const CMDKModalStory: StoryFn = () => {
export const CMDKPanelStory: StoryFn = () => {
const [query, setQuery] = useState('');
const groups = useCMDKCommandGroups();
return (
<CMDKModal open>
<CMDKContainer open query={query} onQueryChange={setQuery} />
<CMDKContainer
open
query={query}
onQueryChange={setQuery}
groups={groups}
/>
</CMDKModal>
);
};