mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
feat(mobile): search page ui (#8012)
feat(mobile): search page ui fix(core): quick search tags performance issue
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
export * from './hooks';
|
||||
export * from './lit-react';
|
||||
export * from './styles';
|
||||
export * from './ui/avatar';
|
||||
|
||||
91
packages/frontend/core/src/modules/quicksearch/impls/tags.ts
Normal file
91
packages/frontend/core/src/modules/quicksearch/impls/tags.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { Entity, LiveData } from '@toeverything/infra';
|
||||
import Fuse from 'fuse.js';
|
||||
|
||||
import type { TagService } from '../../tag';
|
||||
import type { QuickSearchSession } from '../providers/quick-search-provider';
|
||||
import type { QuickSearchGroup } from '../types/group';
|
||||
import type { QuickSearchItem } from '../types/item';
|
||||
import { highlighter } from '../utils/highlighter';
|
||||
import { QuickSearchTagIcon } from '../views/tag-icon';
|
||||
|
||||
const group: QuickSearchGroup = {
|
||||
id: 'tags',
|
||||
label: {
|
||||
key: 'com.affine.cmdk.affine.category.affine.tags',
|
||||
},
|
||||
score: 10,
|
||||
};
|
||||
|
||||
export class TagsQuickSearchSession
|
||||
extends Entity
|
||||
implements QuickSearchSession<'tags', { tagId: string }>
|
||||
{
|
||||
constructor(private readonly tagService: TagService) {
|
||||
super();
|
||||
}
|
||||
|
||||
query$ = new LiveData('');
|
||||
|
||||
items$: LiveData<QuickSearchItem<'tags', { tagId: string }>[]> =
|
||||
LiveData.computed(get => {
|
||||
const query = get(this.query$);
|
||||
|
||||
// has performance issues with `tagList.tagMetas$`
|
||||
const tags = get(this.tagService.tagList.tags$).map(tag => ({
|
||||
id: tag.id,
|
||||
title: get(tag.value$),
|
||||
color: get(tag.color$),
|
||||
}));
|
||||
|
||||
const fuse = new Fuse(tags, {
|
||||
keys: ['title'],
|
||||
includeMatches: true,
|
||||
includeScore: true,
|
||||
ignoreLocation: true,
|
||||
threshold: 0.0,
|
||||
});
|
||||
|
||||
const result = fuse.search(query);
|
||||
|
||||
return result.map<QuickSearchItem<'tags', { tagId: string }>>(
|
||||
({ item, matches, score = 1 }) => {
|
||||
const normalizedRange = ([start, end]: [number, number]) =>
|
||||
[
|
||||
start,
|
||||
end +
|
||||
1 /* in fuse, the `end` is different from the `substring` */,
|
||||
] as [number, number];
|
||||
const titleMatches = matches
|
||||
?.filter(match => match.key === 'title')
|
||||
.flatMap(match => match.indices.map(normalizedRange));
|
||||
|
||||
const Icon = () => QuickSearchTagIcon({ color: item.color });
|
||||
|
||||
return {
|
||||
id: 'tag:' + item.id,
|
||||
source: 'tags',
|
||||
label: {
|
||||
title: (highlighter(
|
||||
item.title,
|
||||
'<b>',
|
||||
'</b>',
|
||||
titleMatches ?? []
|
||||
) ??
|
||||
item.title) || {
|
||||
key: 'Untitled',
|
||||
},
|
||||
},
|
||||
group,
|
||||
score: 1 - score,
|
||||
icon: Icon,
|
||||
matches: titleMatches,
|
||||
payload: { tagId: item.id },
|
||||
};
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
query(query: string) {
|
||||
this.query$.next(query);
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { CollectionService } from '../collection';
|
||||
import { DocsSearchService } from '../docs-search';
|
||||
import { WorkspacePropertiesAdapter } from '../properties';
|
||||
import { TagService } from '../tag';
|
||||
import { WorkbenchService } from '../workbench';
|
||||
import { QuickSearch } from './entities/quick-search';
|
||||
import { CollectionsQuickSearchSession } from './impls/collections';
|
||||
@@ -16,6 +17,7 @@ import { CommandsQuickSearchSession } from './impls/commands';
|
||||
import { CreationQuickSearchSession } from './impls/creation';
|
||||
import { DocsQuickSearchSession } from './impls/docs';
|
||||
import { RecentDocsQuickSearchSession } from './impls/recent-docs';
|
||||
import { TagsQuickSearchSession } from './impls/tags';
|
||||
import { CMDKQuickSearchService } from './services/cmdk';
|
||||
import { DocDisplayMetaService } from './services/doc-display-meta';
|
||||
import { QuickSearchService } from './services/quick-search';
|
||||
@@ -28,8 +30,10 @@ export { CommandsQuickSearchSession } from './impls/commands';
|
||||
export { CreationQuickSearchSession } from './impls/creation';
|
||||
export { DocsQuickSearchSession } from './impls/docs';
|
||||
export { RecentDocsQuickSearchSession } from './impls/recent-docs';
|
||||
export { TagsQuickSearchSession } from './impls/tags';
|
||||
export type { QuickSearchItem } from './types/item';
|
||||
export { QuickSearchContainer } from './views/container';
|
||||
export { QuickSearchTagIcon } from './views/tag-icon';
|
||||
|
||||
export function configureQuickSearchModule(framework: Framework) {
|
||||
framework
|
||||
@@ -51,6 +55,7 @@ export function configureQuickSearchModule(framework: Framework) {
|
||||
])
|
||||
.entity(CreationQuickSearchSession)
|
||||
.entity(CollectionsQuickSearchSession, [CollectionService])
|
||||
.entity(TagsQuickSearchSession, [TagService])
|
||||
.entity(RecentDocsQuickSearchSession, [
|
||||
RecentDocsService,
|
||||
DocDisplayMetaService,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CommandsQuickSearchSession } from '../impls/commands';
|
||||
import { CreationQuickSearchSession } from '../impls/creation';
|
||||
import { DocsQuickSearchSession } from '../impls/docs';
|
||||
import { RecentDocsQuickSearchSession } from '../impls/recent-docs';
|
||||
import { TagsQuickSearchSession } from '../impls/tags';
|
||||
import type { QuickSearchService } from './quick-search';
|
||||
|
||||
export class CMDKQuickSearchService extends Service {
|
||||
@@ -30,6 +31,7 @@ export class CMDKQuickSearchService extends Service {
|
||||
this.framework.createEntity(CommandsQuickSearchSession),
|
||||
this.framework.createEntity(CreationQuickSearchSession),
|
||||
this.framework.createEntity(DocsQuickSearchSession),
|
||||
this.framework.createEntity(TagsQuickSearchSession),
|
||||
],
|
||||
result => {
|
||||
if (!result) {
|
||||
@@ -60,6 +62,8 @@ export class CMDKQuickSearchService extends Service {
|
||||
this.workbenchService.workbench.openCollection(
|
||||
result.payload.collectionId
|
||||
);
|
||||
} else if (result.source === 'tags') {
|
||||
this.workbenchService.workbench.openTag(result.payload.tagId);
|
||||
} else if (result.source === 'creation') {
|
||||
if (result.id === 'creation:create-page') {
|
||||
const newDoc = this.docsService.createDoc({
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
export const QuickSearchTagIcon = ({ color }: { color: string }) => {
|
||||
return (
|
||||
<svg
|
||||
width="1em"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<circle cx="12" cy="12" fill={color} r="5" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
@@ -544,6 +544,7 @@
|
||||
"com.affine.cloudTempDisable.description": "We are upgrading the AFFiNE Cloud service and it is temporarily unavailable on the client side. If you wish to stay updated on the progress and be notified on availability, you can fill out the <1>AFFiNE Cloud Signup</1>.",
|
||||
"com.affine.cloudTempDisable.title": "AFFiNE Cloud is upgrading now.",
|
||||
"com.affine.cmdk.affine.category.affine.collections": "Collections",
|
||||
"com.affine.cmdk.affine.category.affine.tags": "Tags",
|
||||
"com.affine.cmdk.affine.category.affine.creation": "Create",
|
||||
"com.affine.cmdk.affine.category.affine.edgeless": "Edgeless",
|
||||
"com.affine.cmdk.affine.category.affine.general": "General",
|
||||
@@ -1557,5 +1558,6 @@
|
||||
"com.affine.import-template.dialog.errorLoad": "Failed to load template, please try again.",
|
||||
"com.affine.import-template.dialog.createDocToWorkspace": "Create doc to \"{{workspace}}\"",
|
||||
"com.affine.import-template.dialog.createDocToNewWorkspace": "Create into a New Workspace",
|
||||
"com.affine.import-template.dialog.createDocWithTemplate": "Create doc with \"{{templateName}}\" template"
|
||||
"com.affine.import-template.dialog.createDocWithTemplate": "Create doc with \"{{templateName}}\" template",
|
||||
"com.affine.mobile.search.empty": "No results found"
|
||||
}
|
||||
|
||||
@@ -17,13 +17,16 @@
|
||||
"@toeverything/theme": "^1.0.7",
|
||||
"clsx": "^2.1.1",
|
||||
"core-js": "^3.36.1",
|
||||
"figma-squircle": "^0.3.1",
|
||||
"intl-segmenter-polyfill-rs": "^0.1.7",
|
||||
"lodash-es": "^4.17.21",
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-router-dom": "^6.26.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@affine/cli": "workspace:*",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/react": "^18.2.75",
|
||||
"@types/react-dom": "^18.2.24",
|
||||
"@vanilla-extract/css": "^1.15.5",
|
||||
|
||||
@@ -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'),
|
||||
});
|
||||
110
packages/frontend/mobile/src/components/search-input/index.tsx
Normal file
110
packages/frontend/mobile/src/components/search-input/index.tsx
Normal file
@@ -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>
|
||||
);
|
||||
};
|
||||
7
packages/frontend/mobile/src/modules/index.ts
Normal file
7
packages/frontend/mobile/src/modules/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Framework } from '@toeverything/infra';
|
||||
|
||||
import { configureMobileSearchModule } from './search';
|
||||
|
||||
export function configureMobileModules(framework: Framework) {
|
||||
configureMobileSearchModule(framework);
|
||||
}
|
||||
9
packages/frontend/mobile/src/modules/search/index.ts
Normal file
9
packages/frontend/mobile/src/modules/search/index.ts
Normal file
@@ -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} />
|
||||
|
||||
87
packages/frontend/mobile/src/views/search/search-results.tsx
Normal file
87
packages/frontend/mobile/src/views/search/search-results.tsx
Normal file
@@ -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}
|
||||
</>
|
||||
);
|
||||
};
|
||||
74
packages/frontend/mobile/src/views/search/style.css.ts
Normal file
74
packages/frontend/mobile/src/views/search/style.css.ts
Normal file
@@ -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'),
|
||||
});
|
||||
@@ -663,6 +663,7 @@ __metadata:
|
||||
"@blocksuite/icons": "npm:^2.1.64"
|
||||
"@sentry/react": "npm:^8.0.0"
|
||||
"@toeverything/theme": "npm:^1.0.7"
|
||||
"@types/lodash-es": "npm:^4.17.12"
|
||||
"@types/react": "npm:^18.2.75"
|
||||
"@types/react-dom": "npm:^18.2.24"
|
||||
"@vanilla-extract/css": "npm:^1.15.5"
|
||||
@@ -670,7 +671,9 @@ __metadata:
|
||||
clsx: "npm:^2.1.1"
|
||||
core-js: "npm:^3.36.1"
|
||||
cross-env: "npm:^7.0.3"
|
||||
figma-squircle: "npm:^0.3.1"
|
||||
intl-segmenter-polyfill-rs: "npm:^0.1.7"
|
||||
lodash-es: "npm:^4.17.21"
|
||||
react: "npm:^18.2.0"
|
||||
react-dom: "npm:^18.2.0"
|
||||
react-router-dom: "npm:^6.26.1"
|
||||
|
||||
Reference in New Issue
Block a user