feat(mobile): search page ui (#8012)

feat(mobile): search page ui

fix(core): quick search tags performance issue
This commit is contained in:
CatsJuice
2024-08-29 09:05:23 +00:00
parent 5e8683c9be
commit f1bb1fc9b8
26 changed files with 731 additions and 50 deletions
+2
View File
@@ -26,6 +26,7 @@ import {
import { Suspense } from 'react';
import { RouterProvider } from 'react-router-dom';
import { configureMobileModules } from './modules';
import { router } from './router';
if (!environment.isBrowser && environment.isDebug) {
@@ -58,6 +59,7 @@ configureBrowserWorkbenchModule(framework);
configureLocalStorageStateStorageImpls(framework);
configureBrowserWorkspaceFlavours(framework);
configureIndexedDBWorkspaceEngineStorageProvider(framework);
configureMobileModules(framework);
const frameworkProvider = framework.provider();
// setup application lifecycle events, and emit application start event
@@ -9,13 +9,16 @@ import {
import type { DocMeta } from '@blocksuite/store';
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
import clsx from 'clsx';
import { forwardRef, useCallback } from 'react';
import { forwardRef, type ReactNode, useCallback } from 'react';
import * as styles from './styles.css';
import { DocCardTags } from './tag';
export interface DocCardProps extends Omit<WorkbenchLinkProps, 'to'> {
meta: DocMeta;
meta: {
id: DocMeta['id'];
title?: ReactNode;
} & { [key: string]: any };
showTags?: boolean;
}
@@ -1,5 +1,6 @@
export * from './app-tabs';
export * from './doc-card';
export * from './page-header';
export * from './search-button';
export * from './search-input';
export * from './search-result';
export * from './workspace-selector';
@@ -1,17 +0,0 @@
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { SearchIcon } from '@blocksuite/icons/rc';
import * as styles from './styles.css';
export const SearchButton = () => {
const t = useI18n();
return (
<WorkbenchLink to="/search">
<div className={styles.search}>
<SearchIcon className={styles.icon} />
{t['Quick search']()}
</div>
</WorkbenchLink>
);
};
@@ -1,25 +0,0 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const search = style({
width: '100%',
height: 44,
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '7px 8px',
background: cssVarV2('layer/background/primary'),
borderRadius: 10,
color: cssVarV2('text/secondary'),
fontSize: 17,
fontWeight: 400,
lineHeight: '22px',
letterSpacing: -0.43,
});
export const icon = style({
width: 20,
height: 20,
color: cssVarV2('icon/primary'),
});
@@ -0,0 +1,110 @@
import { useAutoFocus } from '@affine/component';
import { SearchIcon } from '@blocksuite/icons/rc';
import clsx from 'clsx';
import { getSvgPath } from 'figma-squircle';
import { debounce } from 'lodash-es';
import {
type FormEventHandler,
forwardRef,
type HTMLProps,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import * as styles from './style.css';
export interface SearchInputProps
extends Omit<HTMLProps<HTMLInputElement>, 'onInput'> {
value?: string;
height?: number;
cornerRadius?: number;
cornerSmoothing?: number;
debounce?: number;
onInput?: (value: string) => void;
}
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
function SearchInput(
{
className,
style,
placeholder = 'Search',
value = '',
height = 44,
cornerRadius = 10,
cornerSmoothing = 0.6,
autoFocus,
debounce: debounceDuration,
onInput,
onClick,
...attrs
},
upstreamRef
) {
const focusRef = useAutoFocus<HTMLInputElement>(autoFocus);
const containerRef = useRef<HTMLDivElement>(null);
const [width, setWidth] = useState(window.innerWidth);
const [inputValue, setInputValue] = useState(value);
const clipPath = useMemo(
() => getSvgPath({ width, height, cornerRadius, cornerSmoothing }),
[cornerRadius, cornerSmoothing, height, width]
);
useEffect(() => {
setWidth(containerRef.current?.offsetWidth ?? 0);
}, []);
const emitValue = useMemo(() => {
const cb = (value: string) => onInput?.(value);
return debounceDuration ? debounce(cb, debounceDuration) : cb;
}, [debounceDuration, onInput]);
const handleInput: FormEventHandler<HTMLInputElement> = useCallback(
e => {
const value = e.currentTarget.value;
setInputValue(value);
emitValue(value);
},
[emitValue]
);
const inputRef = (el: HTMLInputElement | null) => {
focusRef.current = el;
if (upstreamRef) {
if (typeof upstreamRef === 'function') {
upstreamRef(el);
} else {
upstreamRef.current = el;
}
}
};
return (
<div
onClick={onClick}
ref={containerRef}
className={clsx(styles.wrapper, className)}
style={{ ...style, height, clipPath: `path('${clipPath}')` }}
>
<div className={styles.prefixIcon}>
<SearchIcon width="20" height="20" />
</div>
<input
ref={inputRef}
{...attrs}
value={inputValue}
onInput={handleInput}
className={styles.input}
/>
{!inputValue ? (
<div className={styles.placeholder}>{placeholder}</div>
) : null}
</div>
);
}
);
@@ -0,0 +1,43 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const wrapper = style({
position: 'relative',
backgroundColor: cssVarV2('layer/background/primary'),
viewTransitionName: 'mobile-search-input',
});
export const prefixIcon = style({
position: 'absolute',
width: 36,
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: cssVarV2('icon/primary'),
pointerEvents: 'none',
});
export const input = style({
padding: '11px 8px 11px 36px',
width: '100%',
height: '100%',
outline: 'none',
border: 'none',
fontWeight: 400,
fontSize: 17,
lineHeight: '22px',
letterSpacing: -0.43,
});
export const placeholder = style([
input,
{
position: 'absolute',
left: 0,
top: 0,
pointerEvents: 'none',
color: cssVarV2('text/secondary'),
},
]);
@@ -0,0 +1,2 @@
export * from './search-res-label';
export * from './universal-item';
@@ -0,0 +1,15 @@
import type { QuickSearchItem } from '@affine/core/modules/quicksearch';
import { HighlightText } from '@affine/core/modules/quicksearch/views/highlight-text';
import { isI18nString, useI18n } from '@affine/i18n';
export interface SearchResLabelProps {
item: QuickSearchItem;
}
export const SearchResLabel = ({ item }: SearchResLabelProps) => {
const i18n = useI18n();
const text = !isI18nString(item.label)
? i18n.t(item.label.title)
: i18n.t(item.label);
return <HighlightText text={text} start="<b>" end="</b>" />;
};
@@ -0,0 +1,43 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const item = style({
display: 'flex',
alignItems: 'center',
gap: 12,
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
height: 44,
color: 'unset',
':visited': { color: 'unset' },
':hover': { color: 'unset' },
':active': { color: 'unset' },
':focus': { color: 'unset' },
});
export const iconWrapper = style({
width: 32,
height: 32,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
fontSize: 24,
color: cssVarV2('icon/primary'),
});
export const content = style({
width: 0,
flex: 1,
fontSize: 17,
lineHeight: '22px',
fontWeight: 400,
letterSpacing: -0.43,
color: cssVarV2('text/primary'),
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
overflow: 'hidden',
});
export const suffixIcon = style({
color: cssVarV2('icon/secondary'),
});
@@ -0,0 +1,32 @@
import type { QuickSearchItem } from '@affine/core/modules/quicksearch';
import { WorkbenchLink } from '@affine/core/modules/workbench';
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
import { SearchResLabel } from './search-res-label';
import * as styles from './universal-item.css';
export interface UniversalSearchResultItemProps {
id: string;
item: QuickSearchItem;
category: 'tag' | 'collection';
}
export const UniversalSearchResultItem = ({
id,
item,
category,
}: UniversalSearchResultItemProps) => {
return (
<WorkbenchLink to={`/${category}/${id}`} className={styles.item}>
<div className={styles.iconWrapper}>
{item.icon &&
(typeof item.icon === 'function' ? <item.icon /> : item.icon)}
</div>
<div className={styles.content}>
<SearchResLabel item={item} />
</div>
<ArrowRightSmallIcon fontSize="16px" className={styles.suffixIcon} />
</WorkbenchLink>
);
};
@@ -0,0 +1,7 @@
import type { Framework } from '@toeverything/infra';
import { configureMobileSearchModule } from './search';
export function configureMobileModules(framework: Framework) {
configureMobileSearchModule(framework);
}
@@ -0,0 +1,9 @@
import { type Framework, WorkspaceScope } from '@toeverything/infra';
import { MobileSearchService } from './service/search';
export { MobileSearchService };
export function configureMobileSearchModule(framework: Framework) {
framework.scope(WorkspaceScope).service(MobileSearchService);
}
@@ -0,0 +1,18 @@
import {
CollectionsQuickSearchSession,
DocsQuickSearchSession,
RecentDocsQuickSearchSession,
TagsQuickSearchSession,
} from '@affine/core/modules/quicksearch';
import { Service } from '@toeverything/infra';
export class MobileSearchService extends Service {
readonly recentDocs = this.framework.createEntity(
RecentDocsQuickSearchSession
);
readonly collections = this.framework.createEntity(
CollectionsQuickSearchSession
);
readonly docs = this.framework.createEntity(DocsQuickSearchSession);
readonly tags = this.framework.createEntity(TagsQuickSearchSession);
}
@@ -1,9 +1,148 @@
import { AppTabs } from '../../components';
import { CollectionService } from '@affine/core/modules/collection';
import {
type QuickSearchItem,
QuickSearchTagIcon,
} from '@affine/core/modules/quicksearch';
import { TagService } from '@affine/core/modules/tag';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import {
LiveData,
useLiveData,
useService,
useServices,
} from '@toeverything/infra';
import { useCallback, useMemo } from 'react';
import { AppTabs, SearchInput, SearchResLabel } from '../../components';
import { MobileSearchService } from '../../modules/search';
import { SearchResults } from '../../views/search/search-results';
import * as styles from '../../views/search/style.css';
const searchInput$ = new LiveData('');
const RecentList = () => {
const { mobileSearchService, collectionService, tagService } = useServices({
MobileSearchService,
CollectionService,
TagService,
});
const recentDocsList = useLiveData(mobileSearchService.recentDocs.items$);
const collections = useLiveData(collectionService.collections$);
const tags = useLiveData(
LiveData.computed(get =>
get(tagService.tagList.tags$).map(tag => ({
id: tag.id,
title: get(tag.value$),
color: get(tag.color$),
}))
)
);
const docs = useMemo(
() =>
recentDocsList.map(item => ({
id: item.payload.docId,
icon: item.icon,
title: <SearchResLabel item={item} />,
})),
[recentDocsList]
);
const collectionList = useMemo(() => {
return collections.slice(0, 3).map(item => {
return {
id: 'collection:' + item.id,
source: 'collection',
label: { title: item.name },
icon: <ViewLayersIcon />,
payload: { collectionId: item.id },
} satisfies QuickSearchItem<'collection', { collectionId: string }>;
});
}, [collections]);
const tagList = useMemo(() => {
return tags
.reverse()
.slice(0, 3)
.map(item => {
return {
id: 'tag:' + item.id,
source: 'tag',
label: { title: item.title },
icon: <QuickSearchTagIcon color={item.color} />,
payload: { tagId: item.id },
} satisfies QuickSearchItem<'tag', { tagId: string }>;
});
}, [tags]);
return (
<SearchResults
title="Recent"
docs={docs}
collections={collectionList}
tags={tagList}
/>
);
};
const WithQueryList = () => {
const searchService = useService(MobileSearchService);
const collectionList = useLiveData(searchService.collections.items$);
const docList = useLiveData(searchService.docs.items$);
const tagList = useLiveData(searchService.tags.items$);
const docs = useMemo(
() =>
docList.map(item => ({
id: item.payload.docId,
icon: item.icon,
title: <SearchResLabel item={item} />,
})),
[docList]
);
return (
<SearchResults
title="Search result"
docs={docs}
collections={collectionList}
tags={tagList}
/>
);
};
export const Component = () => {
const searchInput = useLiveData(searchInput$);
const searchService = useService(MobileSearchService);
const onSearch = useCallback(
(v: string) => {
searchInput$.next(v);
searchService.recentDocs.query(v);
searchService.collections.query(v);
searchService.docs.query(v);
searchService.tags.query(v);
},
[
searchService.collections,
searchService.docs,
searchService.recentDocs,
searchService.tags,
]
);
return (
<>
Search
<div className={styles.searchHeader}>
<SearchInput
debounce={300}
autoFocus={!searchInput}
value={searchInput}
onInput={onSearch}
placeholder="Search Docs, Collections"
/>
</div>
{searchInput ? <WithQueryList /> : <RecentList />}
<AppTabs />
</>
);
@@ -1,10 +1,13 @@
import { IconButton } from '@affine/component';
import { WorkbenchService } from '@affine/core/modules/workbench';
import { useI18n } from '@affine/i18n';
import { SettingsIcon } from '@blocksuite/icons/rc';
import { useService } from '@toeverything/infra';
import clsx from 'clsx';
import { useCallback, useState } from 'react';
import { Link } from 'react-router-dom';
import { SearchButton, WorkspaceSelector } from '../../components';
import { SearchInput, WorkspaceSelector } from '../../components';
import { useGlobalEvent } from '../../hooks/use-global-events';
import * as styles from './styles.css';
@@ -15,6 +18,9 @@ import * as styles from './styles.css';
* - hide Search
*/
export const HomeHeader = () => {
const t = useI18n();
const workbench = useService(WorkbenchService).workbench;
const [dense, setDense] = useState(false);
useGlobalEvent(
@@ -24,6 +30,17 @@ export const HomeHeader = () => {
}, [])
);
const navSearch = useCallback(() => {
if (!document.startViewTransition) {
return workbench.open('/search');
}
document.startViewTransition(() => {
workbench.open('/search');
return new Promise(resolve => setTimeout(resolve, 150));
});
}, [workbench]);
return (
<div className={clsx(styles.root, { dense })}>
<div className={styles.float}>
@@ -42,7 +59,7 @@ export const HomeHeader = () => {
</div>
</div>
<div className={styles.searchWrapper}>
<SearchButton />
<SearchInput placeholder={t['Quick search']()} onClick={navSearch} />
</div>
</div>
<div className={styles.space} />
@@ -0,0 +1,87 @@
import { useI18n } from '@affine/i18n';
import { DocCard, type DocCardProps } from '../../components';
import {
UniversalSearchResultItem,
type UniversalSearchResultItemProps,
} from '../../components/search-result/universal-item';
import * as styles from './style.css';
export interface SearchResultsProps {
title: string;
docs?: DocCardProps['meta'][];
collections?: UniversalSearchResultItemProps['item'][];
tags?: UniversalSearchResultItemProps['item'][];
}
const Empty = () => {
const t = useI18n();
return (
<div className={styles.empty}>{t['com.affine.mobile.search.empty']()}</div>
);
};
export const SearchResults = ({
title,
docs,
collections,
tags,
}: SearchResultsProps) => {
return (
<>
<div className={styles.resTitle}>{title}</div>
{!docs?.length && !collections?.length && !tags?.length ? (
<Empty />
) : null}
{/* Doc Res */}
{docs?.length ? (
<div className={styles.resBlock} data-scroll>
<div className={styles.resBlockTitle}>Docs</div>
<div className={styles.resBlockScrollContent}>
<div className={styles.scrollDocsContent}>
{docs.map(doc => (
<DocCard meta={doc} key={doc.id} className={styles.docCard} />
))}
</div>
</div>
</div>
) : null}
{/* Collection Res */}
{collections?.length ? (
<div className={styles.resBlock}>
<div className={styles.resBlockTitle}>Collections</div>
<div className={styles.resBlockListContent}>
{collections.map(collection => (
<UniversalSearchResultItem
category="collection"
id={collection.payload.collectionId}
key={collection.id}
item={collection}
/>
))}
</div>
</div>
) : null}
{/* Tag Res */}
{tags?.length ? (
<div className={styles.resBlock}>
<div className={styles.resBlockTitle}>Tags</div>
<div className={styles.resBlockListContent}>
{tags.map(tag => (
<UniversalSearchResultItem
category="tag"
id={tag.payload.tagId}
key={tag.id}
item={tag}
/>
))}
</div>
</div>
) : null}
</>
);
};
@@ -0,0 +1,74 @@
import { cssVarV2 } from '@toeverything/theme/v2';
import { style } from '@vanilla-extract/css';
export const searchHeader = style({
padding: 16,
});
export const resTitle = style({
padding: '6px 16px',
marginBottom: 8,
height: 30,
fontSize: 13,
lineHeight: '18px',
fontWeight: 400,
letterSpacing: -0.08,
color: cssVarV2('text/secondary'),
});
export const resBlock = style({
paddingBottom: 32,
selectors: {
'&[data-scroll]': {
paddingBottom: 0,
},
},
});
export const resBlockTitle = style({
padding: '0 16px',
fontSize: 20,
lineHeight: '25px',
fontWeight: 400,
letterSpacing: -0.45,
color: cssVarV2('text/primary'),
});
const resBlockContent = style({
padding: '12px 0px',
});
export const resBlockListContent = style([
resBlockContent,
{
paddingLeft: 16,
paddingRight: 16,
},
]);
export const resBlockScrollContent = style([
resBlockContent,
{
width: '100%',
overflowX: 'auto',
paddingBottom: 32,
},
]);
export const scrollDocsContent = style({
display: 'flex',
gap: 12,
padding: '0 16px',
width: 'fit-content',
});
export const docCard = style({
width: 170,
height: 210,
flexShrink: 0,
});
export const empty = style({
padding: '0 16px',
fontSize: 20,
fontWeight: 400,
lineHeight: '25px',
letterSpacing: -0.45,
color: cssVarV2('text/primary'),
});