diff --git a/packages/frontend/component/src/index.ts b/packages/frontend/component/src/index.ts index 13b2b77e33..9ba18efae0 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 0000000000..ce5610bddb --- /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 77feff2e2f..0801037003 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 6634f3a8da..973bc608a3 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 0000000000..4873a47456 --- /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 95816d7692..26c5d75796 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 9f6d669aa4..498327b353 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 ca56286610..5937cd24e6 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 7cff80e980..c53eaf69f0 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 dfd0929aa0..b60e40c2eb 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 fe584b0a09..0000000000 --- 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 7661508288..0000000000 --- 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 0000000000..53d1352dbd --- /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 0000000000..26f737441c --- /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 0000000000..be6ab88611 --- /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 0000000000..1ddcd04fbe --- /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 0000000000..2a59fdad11 --- /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 0000000000..661698a688 --- /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 0000000000..ad85b29f5a --- /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 0000000000..b4984f20b8 --- /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 0000000000..789b828dc6 --- /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 2e42427ecc..c59b4d13f6 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 465518a060..85c7d67ac8 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 0000000000..4986886863 --- /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 0000000000..456ada42e2 --- /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 4f8222d026..eb503aef9e 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"