diff --git a/packages/frontend/component/src/index.ts b/packages/frontend/component/src/index.ts index 13b2b77e3..9ba18efae 100644 --- a/packages/frontend/component/src/index.ts +++ b/packages/frontend/component/src/index.ts @@ -1,3 +1,4 @@ +export * from './hooks'; export * from './lit-react'; export * from './styles'; export * from './ui/avatar'; diff --git a/packages/frontend/core/src/modules/quicksearch/impls/tags.ts b/packages/frontend/core/src/modules/quicksearch/impls/tags.ts new file mode 100644 index 000000000..ce5610bdd --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/impls/tags.ts @@ -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[]> = + 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>( + ({ 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, + '', + '', + titleMatches ?? [] + ) ?? + item.title) || { + key: 'Untitled', + }, + }, + group, + score: 1 - score, + icon: Icon, + matches: titleMatches, + payload: { tagId: item.id }, + }; + } + ); + }); + + query(query: string) { + this.query$.next(query); + } +} diff --git a/packages/frontend/core/src/modules/quicksearch/index.ts b/packages/frontend/core/src/modules/quicksearch/index.ts index 77feff2e2..080103700 100644 --- a/packages/frontend/core/src/modules/quicksearch/index.ts +++ b/packages/frontend/core/src/modules/quicksearch/index.ts @@ -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, diff --git a/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts b/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts index 6634f3a8d..973bc608a 100644 --- a/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts +++ b/packages/frontend/core/src/modules/quicksearch/services/cmdk.ts @@ -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({ diff --git a/packages/frontend/core/src/modules/quicksearch/views/tag-icon.tsx b/packages/frontend/core/src/modules/quicksearch/views/tag-icon.tsx new file mode 100644 index 000000000..4873a4745 --- /dev/null +++ b/packages/frontend/core/src/modules/quicksearch/views/tag-icon.tsx @@ -0,0 +1,12 @@ +export const QuickSearchTagIcon = ({ color }: { color: string }) => { + return ( + + + + ); +}; diff --git a/packages/frontend/i18n/src/resources/en.json b/packages/frontend/i18n/src/resources/en.json index 95816d769..26c5d7579 100644 --- a/packages/frontend/i18n/src/resources/en.json +++ b/packages/frontend/i18n/src/resources/en.json @@ -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.", "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" } diff --git a/packages/frontend/mobile/package.json b/packages/frontend/mobile/package.json index 9f6d669aa..498327b35 100644 --- a/packages/frontend/mobile/package.json +++ b/packages/frontend/mobile/package.json @@ -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", diff --git a/packages/frontend/mobile/src/app.tsx b/packages/frontend/mobile/src/app.tsx index ca5628661..5937cd24e 100644 --- a/packages/frontend/mobile/src/app.tsx +++ b/packages/frontend/mobile/src/app.tsx @@ -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 diff --git a/packages/frontend/mobile/src/components/doc-card/index.tsx b/packages/frontend/mobile/src/components/doc-card/index.tsx index 7cff80e98..c53eaf69f 100644 --- a/packages/frontend/mobile/src/components/doc-card/index.tsx +++ b/packages/frontend/mobile/src/components/doc-card/index.tsx @@ -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 { - meta: DocMeta; + meta: { + id: DocMeta['id']; + title?: ReactNode; + } & { [key: string]: any }; showTags?: boolean; } diff --git a/packages/frontend/mobile/src/components/index.ts b/packages/frontend/mobile/src/components/index.ts index dfd0929aa..b60e40c2e 100644 --- a/packages/frontend/mobile/src/components/index.ts +++ b/packages/frontend/mobile/src/components/index.ts @@ -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'; diff --git a/packages/frontend/mobile/src/components/search-button/index.tsx b/packages/frontend/mobile/src/components/search-button/index.tsx deleted file mode 100644 index fe584b0a0..000000000 --- a/packages/frontend/mobile/src/components/search-button/index.tsx +++ /dev/null @@ -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 ( - -
- - {t['Quick search']()} -
-
- ); -}; diff --git a/packages/frontend/mobile/src/components/search-button/styles.css.ts b/packages/frontend/mobile/src/components/search-button/styles.css.ts deleted file mode 100644 index 766150828..000000000 --- a/packages/frontend/mobile/src/components/search-button/styles.css.ts +++ /dev/null @@ -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'), -}); diff --git a/packages/frontend/mobile/src/components/search-input/index.tsx b/packages/frontend/mobile/src/components/search-input/index.tsx new file mode 100644 index 000000000..53d1352db --- /dev/null +++ b/packages/frontend/mobile/src/components/search-input/index.tsx @@ -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, 'onInput'> { + value?: string; + height?: number; + cornerRadius?: number; + cornerSmoothing?: number; + debounce?: number; + onInput?: (value: string) => void; +} +export const SearchInput = forwardRef( + function SearchInput( + { + className, + style, + placeholder = 'Search', + value = '', + height = 44, + cornerRadius = 10, + cornerSmoothing = 0.6, + autoFocus, + debounce: debounceDuration, + onInput, + onClick, + ...attrs + }, + upstreamRef + ) { + const focusRef = useAutoFocus(autoFocus); + const containerRef = useRef(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 = 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 ( +
+
+ +
+ + + + {!inputValue ? ( +
{placeholder}
+ ) : null} +
+ ); + } +); diff --git a/packages/frontend/mobile/src/components/search-input/style.css.ts b/packages/frontend/mobile/src/components/search-input/style.css.ts new file mode 100644 index 000000000..26f737441 --- /dev/null +++ b/packages/frontend/mobile/src/components/search-input/style.css.ts @@ -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'), + }, +]); diff --git a/packages/frontend/mobile/src/components/search-result/index.ts b/packages/frontend/mobile/src/components/search-result/index.ts new file mode 100644 index 000000000..be6ab8861 --- /dev/null +++ b/packages/frontend/mobile/src/components/search-result/index.ts @@ -0,0 +1,2 @@ +export * from './search-res-label'; +export * from './universal-item'; diff --git a/packages/frontend/mobile/src/components/search-result/search-res-label.tsx b/packages/frontend/mobile/src/components/search-result/search-res-label.tsx new file mode 100644 index 000000000..1ddcd04fb --- /dev/null +++ b/packages/frontend/mobile/src/components/search-result/search-res-label.tsx @@ -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 ; +}; diff --git a/packages/frontend/mobile/src/components/search-result/universal-item.css.ts b/packages/frontend/mobile/src/components/search-result/universal-item.css.ts new file mode 100644 index 000000000..2a59fdad1 --- /dev/null +++ b/packages/frontend/mobile/src/components/search-result/universal-item.css.ts @@ -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'), +}); diff --git a/packages/frontend/mobile/src/components/search-result/universal-item.tsx b/packages/frontend/mobile/src/components/search-result/universal-item.tsx new file mode 100644 index 000000000..661698a68 --- /dev/null +++ b/packages/frontend/mobile/src/components/search-result/universal-item.tsx @@ -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 ( + +
+ {item.icon && + (typeof item.icon === 'function' ? : item.icon)} +
+ +
+ +
+ + +
+ ); +}; diff --git a/packages/frontend/mobile/src/modules/index.ts b/packages/frontend/mobile/src/modules/index.ts new file mode 100644 index 000000000..ad85b29f5 --- /dev/null +++ b/packages/frontend/mobile/src/modules/index.ts @@ -0,0 +1,7 @@ +import type { Framework } from '@toeverything/infra'; + +import { configureMobileSearchModule } from './search'; + +export function configureMobileModules(framework: Framework) { + configureMobileSearchModule(framework); +} diff --git a/packages/frontend/mobile/src/modules/search/index.ts b/packages/frontend/mobile/src/modules/search/index.ts new file mode 100644 index 000000000..b4984f20b --- /dev/null +++ b/packages/frontend/mobile/src/modules/search/index.ts @@ -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); +} diff --git a/packages/frontend/mobile/src/modules/search/service/search.ts b/packages/frontend/mobile/src/modules/search/service/search.ts new file mode 100644 index 000000000..789b828dc --- /dev/null +++ b/packages/frontend/mobile/src/modules/search/service/search.ts @@ -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); +} diff --git a/packages/frontend/mobile/src/pages/workspace/search.tsx b/packages/frontend/mobile/src/pages/workspace/search.tsx index 2e42427ec..c59b4d13f 100644 --- a/packages/frontend/mobile/src/pages/workspace/search.tsx +++ b/packages/frontend/mobile/src/pages/workspace/search.tsx @@ -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: , + })), + [recentDocsList] + ); + + const collectionList = useMemo(() => { + return collections.slice(0, 3).map(item => { + return { + id: 'collection:' + item.id, + source: 'collection', + label: { title: item.name }, + icon: , + 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: , + payload: { tagId: item.id }, + } satisfies QuickSearchItem<'tag', { tagId: string }>; + }); + }, [tags]); + + return ( + + ); +}; + +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: , + })), + [docList] + ); + + return ( + + ); +}; 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 +
+ +
+ {searchInput ? : } ); diff --git a/packages/frontend/mobile/src/views/home-header/index.tsx b/packages/frontend/mobile/src/views/home-header/index.tsx index 465518a06..85c7d67ac 100644 --- a/packages/frontend/mobile/src/views/home-header/index.tsx +++ b/packages/frontend/mobile/src/views/home-header/index.tsx @@ -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 (
@@ -42,7 +59,7 @@ export const HomeHeader = () => {
- +
diff --git a/packages/frontend/mobile/src/views/search/search-results.tsx b/packages/frontend/mobile/src/views/search/search-results.tsx new file mode 100644 index 000000000..498688686 --- /dev/null +++ b/packages/frontend/mobile/src/views/search/search-results.tsx @@ -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 ( +
{t['com.affine.mobile.search.empty']()}
+ ); +}; + +export const SearchResults = ({ + title, + docs, + collections, + tags, +}: SearchResultsProps) => { + return ( + <> +
{title}
+ + {!docs?.length && !collections?.length && !tags?.length ? ( + + ) : null} + + {/* Doc Res */} + {docs?.length ? ( +
+
Docs
+
+
+ {docs.map(doc => ( + + ))} +
+
+
+ ) : null} + + {/* Collection Res */} + {collections?.length ? ( +
+
Collections
+
+ {collections.map(collection => ( + + ))} +
+
+ ) : null} + + {/* Tag Res */} + {tags?.length ? ( +
+
Tags
+
+ {tags.map(tag => ( + + ))} +
+
+ ) : null} + + ); +}; diff --git a/packages/frontend/mobile/src/views/search/style.css.ts b/packages/frontend/mobile/src/views/search/style.css.ts new file mode 100644 index 000000000..456ada42e --- /dev/null +++ b/packages/frontend/mobile/src/views/search/style.css.ts @@ -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'), +}); diff --git a/yarn.lock b/yarn.lock index 4f8222d02..eb503aef9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"