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
13 changed files with 304 additions and 189 deletions

View File

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

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 { 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', () => {

View File

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

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 { 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}>

View File

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

View File

@@ -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 = () => {

View File

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

View File

@@ -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) {

View File

@@ -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`,

View File

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

View File

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