mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-22 12:36:24 +08:00
feat(mobile): search page ui (#8012)
feat(mobile): search page ui fix(core): quick search tags performance issue
This commit is contained in:
@@ -9,13 +9,16 @@ import {
|
||||
import type { DocMeta } from '@blocksuite/store';
|
||||
import { useLiveData, useService, WorkspaceService } from '@toeverything/infra';
|
||||
import clsx from 'clsx';
|
||||
import { forwardRef, useCallback } from 'react';
|
||||
import { forwardRef, type ReactNode, useCallback } from 'react';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
import { DocCardTags } from './tag';
|
||||
|
||||
export interface DocCardProps extends Omit<WorkbenchLinkProps, 'to'> {
|
||||
meta: DocMeta;
|
||||
meta: {
|
||||
id: DocMeta['id'];
|
||||
title?: ReactNode;
|
||||
} & { [key: string]: any };
|
||||
showTags?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './app-tabs';
|
||||
export * from './doc-card';
|
||||
export * from './page-header';
|
||||
export * from './search-button';
|
||||
export * from './search-input';
|
||||
export * from './search-result';
|
||||
export * from './workspace-selector';
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||
import { useI18n } from '@affine/i18n';
|
||||
import { SearchIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import * as styles from './styles.css';
|
||||
|
||||
export const SearchButton = () => {
|
||||
const t = useI18n();
|
||||
return (
|
||||
<WorkbenchLink to="/search">
|
||||
<div className={styles.search}>
|
||||
<SearchIcon className={styles.icon} />
|
||||
{t['Quick search']()}
|
||||
</div>
|
||||
</WorkbenchLink>
|
||||
);
|
||||
};
|
||||
@@ -1,25 +0,0 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const search = style({
|
||||
width: '100%',
|
||||
height: 44,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
padding: '7px 8px',
|
||||
background: cssVarV2('layer/background/primary'),
|
||||
borderRadius: 10,
|
||||
|
||||
color: cssVarV2('text/secondary'),
|
||||
fontSize: 17,
|
||||
fontWeight: 400,
|
||||
lineHeight: '22px',
|
||||
letterSpacing: -0.43,
|
||||
});
|
||||
|
||||
export const icon = style({
|
||||
width: 20,
|
||||
height: 20,
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useAutoFocus } from '@affine/component';
|
||||
import { SearchIcon } from '@blocksuite/icons/rc';
|
||||
import clsx from 'clsx';
|
||||
import { getSvgPath } from 'figma-squircle';
|
||||
import { debounce } from 'lodash-es';
|
||||
import {
|
||||
type FormEventHandler,
|
||||
forwardRef,
|
||||
type HTMLProps,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import * as styles from './style.css';
|
||||
|
||||
export interface SearchInputProps
|
||||
extends Omit<HTMLProps<HTMLInputElement>, 'onInput'> {
|
||||
value?: string;
|
||||
height?: number;
|
||||
cornerRadius?: number;
|
||||
cornerSmoothing?: number;
|
||||
debounce?: number;
|
||||
onInput?: (value: string) => void;
|
||||
}
|
||||
export const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
function SearchInput(
|
||||
{
|
||||
className,
|
||||
style,
|
||||
placeholder = 'Search',
|
||||
value = '',
|
||||
height = 44,
|
||||
cornerRadius = 10,
|
||||
cornerSmoothing = 0.6,
|
||||
autoFocus,
|
||||
debounce: debounceDuration,
|
||||
onInput,
|
||||
onClick,
|
||||
...attrs
|
||||
},
|
||||
upstreamRef
|
||||
) {
|
||||
const focusRef = useAutoFocus<HTMLInputElement>(autoFocus);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(window.innerWidth);
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
|
||||
const clipPath = useMemo(
|
||||
() => getSvgPath({ width, height, cornerRadius, cornerSmoothing }),
|
||||
[cornerRadius, cornerSmoothing, height, width]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setWidth(containerRef.current?.offsetWidth ?? 0);
|
||||
}, []);
|
||||
|
||||
const emitValue = useMemo(() => {
|
||||
const cb = (value: string) => onInput?.(value);
|
||||
return debounceDuration ? debounce(cb, debounceDuration) : cb;
|
||||
}, [debounceDuration, onInput]);
|
||||
|
||||
const handleInput: FormEventHandler<HTMLInputElement> = useCallback(
|
||||
e => {
|
||||
const value = e.currentTarget.value;
|
||||
setInputValue(value);
|
||||
emitValue(value);
|
||||
},
|
||||
[emitValue]
|
||||
);
|
||||
|
||||
const inputRef = (el: HTMLInputElement | null) => {
|
||||
focusRef.current = el;
|
||||
if (upstreamRef) {
|
||||
if (typeof upstreamRef === 'function') {
|
||||
upstreamRef(el);
|
||||
} else {
|
||||
upstreamRef.current = el;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
ref={containerRef}
|
||||
className={clsx(styles.wrapper, className)}
|
||||
style={{ ...style, height, clipPath: `path('${clipPath}')` }}
|
||||
>
|
||||
<div className={styles.prefixIcon}>
|
||||
<SearchIcon width="20" height="20" />
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={inputRef}
|
||||
{...attrs}
|
||||
value={inputValue}
|
||||
onInput={handleInput}
|
||||
className={styles.input}
|
||||
/>
|
||||
|
||||
{!inputValue ? (
|
||||
<div className={styles.placeholder}>{placeholder}</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const wrapper = style({
|
||||
position: 'relative',
|
||||
backgroundColor: cssVarV2('layer/background/primary'),
|
||||
viewTransitionName: 'mobile-search-input',
|
||||
});
|
||||
|
||||
export const prefixIcon = style({
|
||||
position: 'absolute',
|
||||
width: 36,
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: cssVarV2('icon/primary'),
|
||||
pointerEvents: 'none',
|
||||
});
|
||||
|
||||
export const input = style({
|
||||
padding: '11px 8px 11px 36px',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
outline: 'none',
|
||||
border: 'none',
|
||||
|
||||
fontWeight: 400,
|
||||
fontSize: 17,
|
||||
lineHeight: '22px',
|
||||
letterSpacing: -0.43,
|
||||
});
|
||||
|
||||
export const placeholder = style([
|
||||
input,
|
||||
{
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
pointerEvents: 'none',
|
||||
color: cssVarV2('text/secondary'),
|
||||
},
|
||||
]);
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './search-res-label';
|
||||
export * from './universal-item';
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { QuickSearchItem } from '@affine/core/modules/quicksearch';
|
||||
import { HighlightText } from '@affine/core/modules/quicksearch/views/highlight-text';
|
||||
import { isI18nString, useI18n } from '@affine/i18n';
|
||||
|
||||
export interface SearchResLabelProps {
|
||||
item: QuickSearchItem;
|
||||
}
|
||||
export const SearchResLabel = ({ item }: SearchResLabelProps) => {
|
||||
const i18n = useI18n();
|
||||
|
||||
const text = !isI18nString(item.label)
|
||||
? i18n.t(item.label.title)
|
||||
: i18n.t(item.label);
|
||||
return <HighlightText text={text} start="<b>" end="</b>" />;
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import { cssVarV2 } from '@toeverything/theme/v2';
|
||||
import { style } from '@vanilla-extract/css';
|
||||
|
||||
export const item = style({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
|
||||
borderBottom: `0.5px solid ${cssVarV2('layer/insideBorder/border')}`,
|
||||
height: 44,
|
||||
|
||||
color: 'unset',
|
||||
':visited': { color: 'unset' },
|
||||
':hover': { color: 'unset' },
|
||||
':active': { color: 'unset' },
|
||||
':focus': { color: 'unset' },
|
||||
});
|
||||
export const iconWrapper = style({
|
||||
width: 32,
|
||||
height: 32,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
flexShrink: 0,
|
||||
fontSize: 24,
|
||||
color: cssVarV2('icon/primary'),
|
||||
});
|
||||
export const content = style({
|
||||
width: 0,
|
||||
flex: 1,
|
||||
fontSize: 17,
|
||||
lineHeight: '22px',
|
||||
fontWeight: 400,
|
||||
letterSpacing: -0.43,
|
||||
color: cssVarV2('text/primary'),
|
||||
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
});
|
||||
export const suffixIcon = style({
|
||||
color: cssVarV2('icon/secondary'),
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { QuickSearchItem } from '@affine/core/modules/quicksearch';
|
||||
import { WorkbenchLink } from '@affine/core/modules/workbench';
|
||||
import { ArrowRightSmallIcon } from '@blocksuite/icons/rc';
|
||||
|
||||
import { SearchResLabel } from './search-res-label';
|
||||
import * as styles from './universal-item.css';
|
||||
|
||||
export interface UniversalSearchResultItemProps {
|
||||
id: string;
|
||||
item: QuickSearchItem;
|
||||
category: 'tag' | 'collection';
|
||||
}
|
||||
export const UniversalSearchResultItem = ({
|
||||
id,
|
||||
item,
|
||||
category,
|
||||
}: UniversalSearchResultItemProps) => {
|
||||
return (
|
||||
<WorkbenchLink to={`/${category}/${id}`} className={styles.item}>
|
||||
<div className={styles.iconWrapper}>
|
||||
{item.icon &&
|
||||
(typeof item.icon === 'function' ? <item.icon /> : item.icon)}
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<SearchResLabel item={item} />
|
||||
</div>
|
||||
|
||||
<ArrowRightSmallIcon fontSize="16px" className={styles.suffixIcon} />
|
||||
</WorkbenchLink>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user