mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-23 09:22:38 +03:00
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:
parent
9e7eb5629c
commit
d1c4e6141a
@ -25,7 +25,8 @@ export type CommandCategory =
|
||||
| 'affine:layout'
|
||||
| 'affine:updates'
|
||||
| 'affine:help'
|
||||
| 'affine:general';
|
||||
| 'affine:general'
|
||||
| 'affine:results';
|
||||
|
||||
export interface KeybindingOptions {
|
||||
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 { highlightTextFragments } from '../affine/use-highlight';
|
||||
import { highlightTextFragments } from '../use-highlight';
|
||||
|
||||
describe('highlightTextFragments', () => {
|
||||
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 {
|
||||
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;
|
||||
}
|
@ -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 { 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}>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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 = () => {
|
||||
|
@ -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>;
|
||||
|
@ -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) {
|
@ -126,6 +126,7 @@ export function useRegisterBlocksuiteEditorCommands(
|
||||
})
|
||||
);
|
||||
|
||||
// todo: should not show duplicate for journal
|
||||
unsubs.push(
|
||||
registerAffineCommand({
|
||||
id: `editor:${mode}-duplicate`,
|
||||
|
@ -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 {
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user