feat(core): add search result highlighting (#4667)

Co-authored-by: Peng Xiao <pengxiao@outlook.com>
This commit is contained in:
JimmFly
2023-10-24 13:54:37 +08:00
committed by GitHub
parent 14bee1811c
commit 5226d6c568
35 changed files with 479 additions and 305 deletions
@@ -3,10 +3,10 @@ import {
type WorkspaceRootProps,
} from '@affine/component/workspace';
import { useAppSetting } from '../../atoms/settings';
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
export const AppContainer = (props: WorkspaceRootProps) => {
const [appSettings] = useAppSetting();
const { appSettings } = useAppSettingHelper();
return (
<AppContainerWithoutSettings
@@ -5,7 +5,8 @@ import { useLanguageHelper } from '../../../hooks/affine/use-language-helper';
// Fixme: keyboard focus should be supported by Menu component
const LanguageMenuContent = memo(function LanguageMenuContent() {
const { currentLanguage, languagesList, onSelect } = useLanguageHelper();
const { currentLanguage, languagesList, onLanguageChange } =
useLanguageHelper();
return (
<>
{languagesList.map(option => {
@@ -14,7 +15,7 @@ const LanguageMenuContent = memo(function LanguageMenuContent() {
key={option.name}
selected={currentLanguage?.originalName === option.originalName}
title={option.name}
onSelect={() => onSelect(option.tag)}
onSelect={() => onLanguageChange(option.tag)}
>
{option.originalName}
</MenuItem>
@@ -66,12 +66,7 @@ const PublishPanelAffine = (props: PublishPanelAffineProps) => {
marginBottom: isPublic ? '12px' : '25px',
}}
>
<Switch
checked={isPublic}
// onChange={useCallback(value => {
// console.log('onChange', value);
// }, [])}
/>
<Switch checked={isPublic} />
</SettingRow>
{isPublic ? (
<FlexWrapper justifyContent="space-between" marginBottom={25}>
@@ -4,21 +4,14 @@ import { SettingRow } from '@affine/component/setting-components';
import { SettingWrapper } from '@affine/component/setting-components';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { ArrowRightSmallIcon, OpenInNewIcon } from '@blocksuite/icons';
import { useCallback } from 'react';
import { type AppSetting, useAppSetting } from '../../../../../atoms/settings';
import { useAppSettingHelper } from '../../../../../hooks/affine/use-app-setting-helper';
import { relatedLinks } from './config';
import { communityItem, communityWrapper, link } from './style.css';
export const AboutAffine = () => {
const t = useAFFiNEI18N();
const [appSettings, setAppSettings] = useAppSetting();
const changeSwitch = useCallback(
(key: keyof AppSetting, checked: boolean) => {
setAppSettings({ [key]: checked });
},
[setAppSettings]
);
const { appSettings, updateSettings } = useAppSettingHelper();
return (
<>
<SettingHeader
@@ -47,7 +40,7 @@ export const AboutAffine = () => {
>
<Switch
checked={appSettings.autoCheckUpdate}
onChange={checked => changeSwitch('autoCheckUpdate', checked)}
onChange={checked => updateSettings('autoCheckUpdate', checked)}
/>
</SettingRow>
<SettingRow
@@ -58,7 +51,7 @@ export const AboutAffine = () => {
>
<Switch
checked={appSettings.autoCheckUpdate}
onChange={checked => changeSwitch('autoCheckUpdate', checked)}
onChange={checked => updateSettings('autoCheckUpdate', checked)}
/>
</SettingRow>
<SettingRow
@@ -5,8 +5,8 @@ import { useCallback } from 'react';
import {
dateFormatOptions,
type DateFormats,
useAppSetting,
} from '../../../../../atoms/settings';
import { useAppSettingHelper } from '../../../../../hooks/affine/use-app-setting-helper';
interface DateFormatMenuContentProps {
currentOption: DateFormats;
@@ -35,12 +35,12 @@ const DateFormatMenuContent = ({
};
export const DateFormatSetting = () => {
const [appearanceSettings, setAppSettings] = useAppSetting();
const { appSettings, updateSettings } = useAppSettingHelper();
const handleSelect = useCallback(
(option: DateFormats) => {
setAppSettings({ dateFormat: option });
updateSettings('dateFormat', option);
},
[setAppSettings]
[updateSettings]
);
return (
@@ -48,12 +48,12 @@ export const DateFormatSetting = () => {
items={
<DateFormatMenuContent
onSelect={handleSelect}
currentOption={appearanceSettings.dateFormat}
currentOption={appSettings.dateFormat}
/>
}
>
<MenuTrigger data-testid="date-format-menu-trigger" block>
{dayjs(new Date()).format(appearanceSettings.dateFormat)}
{dayjs(new Date()).format(appSettings.dateFormat)}
</MenuTrigger>
</Menu>
);
@@ -9,9 +9,9 @@ import { useCallback } from 'react';
import {
type AppSetting,
fontStyleOptions,
useAppSetting,
windowFrameStyleOptions,
} from '../../../../../atoms/settings';
import { useAppSettingHelper } from '../../../../../hooks/affine/use-app-setting-helper';
import { LanguageMenu } from '../../../language-menu';
import { DateFormatSetting } from './date-format-setting';
import { settingWrapper } from './style.css';
@@ -47,7 +47,7 @@ export const ThemeSettings = () => {
const FontFamilySettings = () => {
const t = useAFFiNEI18N();
const [appSettings, setAppSettings] = useAppSetting();
const { appSettings, updateSettings } = useAppSettingHelper();
return (
<RadioButtonGroup
width={250}
@@ -55,9 +55,9 @@ const FontFamilySettings = () => {
value={appSettings.fontStyle}
onValueChange={useCallback(
(key: AppSetting['fontStyle']) => {
setAppSettings({ fontStyle: key });
updateSettings('fontStyle', key);
},
[setAppSettings]
[updateSettings]
)}
>
{fontStyleOptions.map(({ key, value }) => {
@@ -95,14 +95,8 @@ const FontFamilySettings = () => {
export const AppearanceSettings = () => {
const t = useAFFiNEI18N();
const [appSettings, setAppSettings] = useAppSetting();
const { appSettings, updateSettings } = useAppSettingHelper();
const changeSwitch = useCallback(
(key: keyof AppSetting, checked: boolean) => {
setAppSettings({ [key]: checked });
},
[setAppSettings]
);
return (
<>
<SettingHeader
@@ -139,7 +133,7 @@ export const AppearanceSettings = () => {
>
<Switch
checked={appSettings.clientBorder}
onChange={checked => changeSwitch('clientBorder', checked)}
onChange={checked => updateSettings('clientBorder', checked)}
/>
</SettingRow>
) : null}
@@ -151,7 +145,7 @@ export const AppearanceSettings = () => {
<Switch
data-testid="full-width-layout-trigger"
checked={appSettings.fullWidthLayout}
onChange={checked => changeSwitch('fullWidthLayout', checked)}
onChange={checked => updateSettings('fullWidthLayout', checked)}
/>
</SettingRow>
{runtimeConfig.enableNewSettingUnstableApi && environment.isDesktop ? (
@@ -164,7 +158,7 @@ export const AppearanceSettings = () => {
width={250}
defaultValue={appSettings.windowFrameStyle}
onValueChange={(value: AppSetting['windowFrameStyle']) => {
setAppSettings({ windowFrameStyle: value });
updateSettings('windowFrameStyle', value);
}}
>
{windowFrameStyleOptions.map(option => {
@@ -194,7 +188,7 @@ export const AppearanceSettings = () => {
>
<Switch
checked={appSettings.startWeekOnMonday}
onChange={checked => changeSwitch('startWeekOnMonday', checked)}
onChange={checked => updateSettings('startWeekOnMonday', checked)}
/>
</SettingRow>
</SettingWrapper>
@@ -213,7 +207,7 @@ export const AppearanceSettings = () => {
<Switch
checked={appSettings.enableNoisyBackground}
onChange={checked =>
changeSwitch('enableNoisyBackground', checked)
updateSettings('enableNoisyBackground', checked)
}
/>
</SettingRow>
@@ -227,7 +221,7 @@ export const AppearanceSettings = () => {
<Switch
checked={appSettings.enableBlurBackground}
onChange={checked =>
changeSwitch('enableBlurBackground', checked)
updateSettings('enableBlurBackground', checked)
}
/>
</SettingRow>
@@ -29,7 +29,8 @@ import {
import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels';
import { pageSettingFamily } from '../atoms';
import { fontStyleOptions, useAppSetting } from '../atoms/settings';
import { fontStyleOptions } from '../atoms/settings';
import { useAppSettingHelper } from '../hooks/affine/use-app-setting-helper';
import { BlockSuiteEditor as Editor } from './blocksuite/block-suite-editor';
import { Bookmark } from './bookmark';
import * as styles from './page-detail-editor.css';
@@ -68,7 +69,7 @@ const EditorWrapper = memo(function EditorWrapper({
const currentMode = pageSetting?.mode ?? 'page';
const setBlockHub = useSetAtom(rootBlockHubAtom);
const [appSettings] = useAppSetting();
const { appSettings } = useAppSettingHelper();
assertExists(meta);
const value = useMemo(() => {
@@ -1,7 +1,6 @@
import { commandScore } from '@affine/cmdk';
import { useCollectionManager } from '@affine/component/page-list';
import type { Collection } from '@affine/env/filter';
import { Trans } from '@affine/i18n';
import { useAFFiNEI18N } from '@affine/i18n/hooks';
import { EdgelessIcon, PageIcon, ViewLayersIcon } from '@blocksuite/icons';
import type { Page, PageMeta } from '@blocksuite/store';
@@ -150,19 +149,27 @@ export const pageToCommand = (
page: PageMeta,
store: ReturnType<typeof getCurrentStore>,
navigationHelper: ReturnType<typeof useNavigateHelper>,
t: ReturnType<typeof useAFFiNEI18N>
t: ReturnType<typeof useAFFiNEI18N>,
label?: {
title: string;
subTitle?: string;
}
): CMDKCommand => {
const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode;
const currentWorkspaceId = store.get(currentWorkspaceIdAtom);
const label = page.title || t['Untitled']();
const title = page.title || t['Untitled']();
const commandLabel = label || {
title: title,
};
return {
id: page.id,
label: label,
label: commandLabel,
// 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 + page.id + '.' + category + valueWrapperEnd,
originalValue: label,
title + valueWrapperStart + page.id + '.' + category + valueWrapperEnd,
originalValue: title,
category: category,
run: () => {
if (!currentWorkspaceId) {
@@ -179,8 +186,6 @@ export const pageToCommand = (
const contentMatchedMagicString = '__$$content_matched$$__';
export const usePageCommands = () => {
// todo: considering collections for searching pages
// const { savedCollections } = useCollectionManager(currentCollectionsAtom);
const recentPages = useRecentPages();
const pages = useWorkspacePages();
const store = getCurrentStore();
@@ -203,11 +208,11 @@ export const usePageCommands = () => {
workspace.blockSuiteWorkspace.search({ query }).values()
) as unknown as { space: string; content: string }[];
const pageIds = searchResults.map(id => {
if (id.space.startsWith('space:')) {
return id.space.slice(6);
const pageIds = searchResults.map(result => {
if (result.space.startsWith('space:')) {
return result.space.slice(6);
} else {
return id.space;
return result.space;
}
});
@@ -215,12 +220,21 @@ export const usePageCommands = () => {
const pageMode = store.get(pageSettingsAtom)?.[page.id]?.mode;
const category =
pageMode === 'edgeless' ? 'affine:edgeless' : 'affine:pages';
const label = {
title: page.title,
subTitle:
searchResults.find(result => result.space === page.id)?.content ||
'',
};
const command = pageToCommand(
category,
page,
store,
navigationHelper,
t
t,
label
);
if (pageIds.includes(page.id)) {
@@ -235,14 +249,7 @@ export const usePageCommands = () => {
if (results.every(command => command.originalValue !== query)) {
results.push({
id: 'affine:pages:create-page',
label: (
<Trans
i18nKey="com.affine.cmdk.affine.create-new-page-as"
values={{ query }}
>
Create New Page as: <strong>query</strong>
</Trans>
),
label: `${t['com.affine.cmdk.affine.create-new-page-as']()} ${query}`,
value: 'affine::create-page' + query, // hack to make the page always showing in the search result
category: 'affine:creation',
run: async () => {
@@ -255,14 +262,9 @@ export const usePageCommands = () => {
results.push({
id: 'affine:pages:create-edgeless',
label: (
<Trans
values={{ query }}
i18nKey="com.affine.cmdk.affine.create-new-edgeless-as"
>
Create New Edgeless as: <strong>query</strong>
</Trans>
),
label: `${t[
'com.affine.cmdk.affine.create-new-edgeless-as'
]()} ${query}`,
value: 'affine::create-edgeless' + query, // hack to make the page always showing in the search result
category: 'affine:creation',
run: async () => {
@@ -0,0 +1,33 @@
import { style } from '@vanilla-extract/css';
export const highlightContainer = style({
display: 'flex',
flexWrap: 'nowrap',
});
export const highlightText = style({
whiteSpace: 'pre',
overflow: 'hidden',
textOverflow: 'ellipsis',
});
export const highlightKeyword = style({
color: 'var(--affine-primary-color)',
whiteSpace: 'pre',
overflow: 'visible',
flexShrink: 0,
});
export const labelTitle = style({
fontSize: 'var(--affine-font-base)',
lineHeight: '24px',
fontWeight: 400,
textAlign: 'justify',
});
export const labelContent = style({
fontSize: 'var(--affine-font-xs)',
lineHeight: '20px',
fontWeight: 400,
textAlign: 'justify',
});
@@ -0,0 +1,78 @@
import { escapeRegExp } from 'lodash-es';
import { memo } from 'react';
import {
highlightContainer,
highlightKeyword,
highlightText,
labelContent,
labelTitle,
} from './highlight.css';
type SearchResultLabel = {
title: string;
subTitle?: string;
};
type HighlightProps = {
text: string;
highlight: string;
};
type HighlightLabelProps = {
label: SearchResultLabel;
highlight: string;
};
export const Highlight = memo(function Highlight({
text = '',
highlight = '',
}: HighlightProps) {
//Regex is used to ignore case
const regex = highlight.trim()
? new RegExp(`(${escapeRegExp(highlight)})`, 'ig')
: null;
if (!regex) {
return <span>{text}</span>;
}
const parts = text.split(regex);
return (
<div className={highlightContainer}>
{parts.map((part, i) => {
if (regex.test(part)) {
return (
<span key={i} className={highlightKeyword}>
{part}
</span>
);
} else {
return (
<span key={i} className={highlightText}>
{part}
</span>
);
}
})}
</div>
);
});
export const HighlightLabel = memo(function HighlightLabel({
label,
highlight,
}: HighlightLabelProps) {
return (
<div>
<div className={labelTitle}>
<Highlight text={label.title} highlight={highlight} />
</div>
{label.subTitle ? (
<div className={labelContent}>
<Highlight text={label.subTitle} highlight={highlight} />
</div>
) : null}
</div>
);
});
@@ -76,6 +76,8 @@ export const timestamp = style({
display: 'flex',
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
minWidth: 120,
flexDirection: 'row-reverse',
});
export const keybinding = style({
@@ -153,8 +155,8 @@ globalStyle(`${root} [cmdk-list]:hover::-webkit-scrollbar-thumb:hover`, {
globalStyle(`${root} [cmdk-item]`, {
display: 'flex',
height: 44,
padding: '0 12px',
minHeight: 44,
padding: '6px 12px',
alignItems: 'center',
cursor: 'default',
borderRadius: 4,
@@ -4,7 +4,7 @@ import { useAFFiNEI18N } from '@affine/i18n/hooks';
import type { PageMeta } from '@blocksuite/store';
import type { CommandCategory } from '@toeverything/infra/command';
import clsx from 'clsx';
import { useAtom, useSetAtom } from 'jotai';
import { useAtom } from 'jotai';
import { Suspense, useLayoutEffect, useMemo, useState } from 'react';
import {
@@ -13,8 +13,10 @@ import {
customCommandFilter,
useCMDKCommandGroups,
} from './data';
import { HighlightLabel } from './highlight';
import * as styles from './main.css';
import { CMDKModal, type CMDKModalProps } from './modal';
import { NotFoundGroup } from './not-found';
import type { CMDKCommand } from './types';
type NoParametersKeys<T> = {
@@ -52,10 +54,16 @@ const QuickSearchGroup = ({
}) => {
const t = useAFFiNEI18N();
const i18nkey = categoryToI18nKey[category];
const setQuery = useSetAtom(cmdkQueryAtom);
const [query, setQuery] = useAtom(cmdkQueryAtom);
return (
<Command.Group key={category} heading={t[i18nkey]()}>
{commands.map(command => {
const label =
typeof command.label === 'string'
? {
title: command.label,
}
: command.label;
return (
<Command.Item
key={command.id}
@@ -78,7 +86,7 @@ const QuickSearchGroup = ({
command.originalValue ? command.originalValue : undefined
}
>
{command.label}
<HighlightLabel highlight={query} label={label} />
</div>
{command.timestamp ? (
<div className={styles.timestamp}>
@@ -197,6 +205,7 @@ export const CMDKContainer = ({
<Command.List data-opening={opening ? true : undefined}>
{children}
</Command.List>
<NotFoundGroup />
</Command>
);
};
@@ -0,0 +1,40 @@
import { style } from '@vanilla-extract/css';
export const notFoundContainer = style({
display: 'flex',
flexDirection: 'column',
padding: '0 8px',
marginBottom: 8,
});
export const notFoundItem = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-start',
padding: '0 12px',
gap: 16,
});
export const notFoundIcon = style({
display: 'flex',
alignItems: 'center',
fontSize: 20,
color: 'var(--affine-icon-secondary)',
padding: '12px 0',
});
export const notFoundTitle = style({
fontSize: 'var(--affine-font-xs)',
color: 'var(--affine-text-secondary-color)',
fontWeight: '600',
lineHeight: '20px',
textAlign: 'justify',
padding: '8px',
});
export const notFoundText = style({
fontSize: 'var(--affine-font-sm)',
color: 'var(--affine-text-primary-color)',
lineHeight: '22px',
fontWeight: '400',
});
@@ -0,0 +1,31 @@
import { useCommandState } from '@affine/cmdk';
import { SearchIcon } from '@blocksuite/icons';
import { useAtomValue } from 'jotai';
import { cmdkQueryAtom } from './data';
import * as styles from './not-found.css';
export const NotFoundGroup = () => {
const query = useAtomValue(cmdkQueryAtom);
// hack: we know that the filtered count is 2 when there is no result (create page & edgeless)
const renderNoResult =
useCommandState(state => state.filtered.count === 2) || false;
if (!renderNoResult) {
return null;
}
return (
<div className={styles.notFoundContainer}>
<div
className={styles.notFoundTitle}
data-testid="cmdk-search-not-found"
>{`Search for "${query}"`}</div>
<div className={styles.notFoundItem}>
<div className={styles.notFoundIcon}>
<SearchIcon />
</div>
<div className={styles.notFoundText}>No results found</div>
</div>
</div>
);
};
@@ -11,7 +11,12 @@ export interface CommandContext {
// we can use a single render function to render all different commands
export interface CMDKCommand {
id: string;
label: string | React.ReactNode;
label:
| string
| {
title: string;
subTitle?: string;
};
icon?: React.ReactNode;
category: CommandCategory;
keyBinding?: string | { binding: string };
@@ -10,7 +10,7 @@ import { currentPageIdAtom } from '@toeverything/infra/atom';
import { useAtomValue } from 'jotai';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useAppSetting } from '../../../atoms/settings';
import { useAppSettingHelper } from '../../../hooks/affine/use-app-setting-helper';
import { useBlockSuiteMetaHelper } from '../../../hooks/affine/use-block-suite-meta-helper';
import { useCurrentWorkspace } from '../../../hooks/current/use-current-workspace';
import { useNavigateHelper } from '../../../hooks/use-navigate-helper';
@@ -29,7 +29,7 @@ export const TrashButtonGroup = () => {
);
assertExists(pageMeta);
const t = useAFFiNEI18N();
const [appSettings] = useAppSetting();
const { appSettings } = useAppSettingHelper();
const { jumpToSubPath } = useNavigateHelper();
const { restoreFromTrash } = useBlockSuiteMetaHelper(blockSuiteWorkspace);
const restoreRef = useRef(null);
@@ -26,7 +26,7 @@ import { forwardRef, useCallback, useEffect, useMemo } from 'react';
import { openWorkspaceListModalAtom } from '../../atoms';
import { useHistoryAtom } from '../../atoms/history';
import { useAppSetting } from '../../atoms/settings';
import { useAppSettingHelper } from '../../hooks/affine/use-app-setting-helper';
import { useGeneralShortcuts } from '../../hooks/affine/use-shortcuts';
import { useTrashModalHelper } from '../../hooks/affine/use-trash-modal-helper';
import { useRegisterBlocksuiteEditorCommands } from '../../hooks/use-shortcut-commands';
@@ -100,7 +100,7 @@ export const RootAppSidebar = ({
onOpenSettingModal,
}: RootAppSidebarProps): ReactElement => {
const currentWorkspaceId = currentWorkspace.id;
const [appSettings] = useAppSetting();
const { appSettings } = useAppSettingHelper();
const { backToAll } = useCollectionManager(currentCollectionsAtom);
const blockSuiteWorkspace = currentWorkspace.blockSuiteWorkspace;
const t = useAFFiNEI18N();