feat(component, mobile): masonry layout with virtual scroll support, adapted with all docs (#9208)

### Preview

![CleanShot 2024-12-19 at 20.41.47.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/60a701ea-bca0-42d5-8a06-f10af44c8fc8.gif)

### Render when scrolling

![CleanShot 2024-12-20 at 09.54.26.gif](https://graphite-user-uploaded-assets-prod.s3.amazonaws.com/LakojjjzZNf6ogjOVwKE/df0008d7-5bd9-4e98-b426-cb1036dbb611.gif)

### api
```tsx
const items = useMemo(() => {
    return {
        id: '',
        height: 100,
        children: <div></div>
    }
}, [])

<Masonry items={items} />
```
This commit is contained in:
CatsJuice
2024-12-20 05:32:17 +00:00
parent 2988dc284e
commit a53e231bad
20 changed files with 572 additions and 186 deletions

View File

@@ -10,15 +10,23 @@ import { type AppTabLink, tabs } from './data';
import * as styles from './styles.css';
import { TabItem } from './tab-item';
export const AppTabs = ({ background }: { background?: string }) => {
export const AppTabs = ({
background,
fixed = true,
}: {
background?: string;
fixed?: boolean;
}) => {
const virtualKeyboardService = useService(VirtualKeyboardService);
const virtualKeyboardVisible = useLiveData(virtualKeyboardService.show$);
return createPortal(
const tab = (
<SafeArea
id="app-tabs"
bottom
className={styles.appTabs}
bottomOffset={2}
data-fixed={fixed}
style={{
...assignInlineVars({
[styles.appTabsBackground]: background,
@@ -26,7 +34,7 @@ export const AppTabs = ({ background }: { background?: string }) => {
visibility: virtualKeyboardVisible ? 'hidden' : 'visible',
}}
>
<ul className={styles.appTabsInner} id="app-tabs" role="tablist">
<ul className={styles.appTabsInner} role="tablist">
{tabs.map(tab => {
if ('to' in tab) {
return <AppTabLink route={tab} key={tab.key} />;
@@ -39,9 +47,10 @@ export const AppTabs = ({ background }: { background?: string }) => {
}
})}
</ul>
</SafeArea>,
document.body
</SafeArea>
);
return fixed ? createPortal(tab, document.body) : tab;
};
const AppTabLink = ({ route }: { route: AppTabLink }) => {

View File

@@ -14,9 +14,16 @@ export const appTabs = style({
width: '100dvw',
position: 'fixed',
bottom: -2,
zIndex: 1,
marginBottom: -2,
selectors: {
'&[data-fixed="true"]': {
position: 'fixed',
bottom: -2,
marginBottom: 0,
},
},
});
export const appTabsInner = style({
display: 'flex',

View File

@@ -1,4 +1,4 @@
import { IconButton, observeIntersection, Skeleton } from '@affine/component';
import { IconButton, Skeleton } from '@affine/component';
import { useCatchEventCallback } from '@affine/core/components/hooks/use-catch-event-hook';
import { PagePreview } from '@affine/core/components/page-list/page-content-preview';
import { IsFavoriteIcon } from '@affine/core/components/pure/icons';
@@ -11,14 +11,7 @@ import {
import type { DocMeta } from '@blocksuite/affine/store';
import { useLiveData, useService } from '@toeverything/infra';
import clsx from 'clsx';
import {
forwardRef,
type ReactNode,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { forwardRef, type ReactNode, useMemo, useRef } from 'react';
import * as styles from './styles.css';
import { DocCardTags } from './tag';
@@ -66,20 +59,6 @@ export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
return { height: `${rows * 18}px` };
}, [autoHeightById, meta.id]);
const [visible, setVisible] = useState(false);
useEffect(() => {
if (!containerRef.current) return;
const dispose = observeIntersection(containerRef.current, entry => {
setVisible(entry.isIntersecting);
});
return () => {
dispose();
};
}, []);
return (
<WorkbenchLink
to={`/${meta.id}`}
@@ -94,38 +73,30 @@ export const DocCard = forwardRef<HTMLAnchorElement, DocCardProps>(
className={clsx(styles.card, className)}
data-testid="doc-card"
data-doc-id={meta.id}
data-visible={visible}
{...attrs}
>
{visible && (
<>
<header className={styles.head} data-testid="doc-card-header">
<h3 className={styles.title}>{title}</h3>
<IconButton
aria-label="favorite"
icon={
<IsFavoriteIcon
onClick={toggleFavorite}
favorite={favorited}
/>
}
/>
</header>
<main className={styles.content} style={contentStyle}>
<PagePreview
fallback={
<>
<Skeleton />
<Skeleton width={'60%'} />
</>
}
pageId={meta.id}
emptyFallback={<div className={styles.contentEmpty}>Empty</div>}
/>
</main>
{showTags ? <DocCardTags docId={meta.id} rows={2} /> : null}
</>
)}
<header className={styles.head} data-testid="doc-card-header">
<h3 className={styles.title}>{title}</h3>
<IconButton
aria-label="favorite"
icon={
<IsFavoriteIcon onClick={toggleFavorite} favorite={favorited} />
}
/>
</header>
<main className={styles.content} style={contentStyle}>
<PagePreview
fallback={
<>
<Skeleton />
<Skeleton width={'60%'} />
</>
}
pageId={meta.id}
emptyFallback={<div className={styles.contentEmpty}>Empty</div>}
/>
</main>
{showTags ? <DocCardTags docId={meta.id} rows={2} /> : null}
</WorkbenchLink>
);
}

View File

@@ -0,0 +1,31 @@
import { type HTMLAttributes, type ReactNode, useEffect } from 'react';
import { AppTabs } from '../app-tabs';
import * as styles from './styles.css';
interface PageProps extends HTMLAttributes<HTMLDivElement> {
tab?: boolean;
header?: ReactNode;
}
/**
* A Page is a full-screen container that will not scroll on document.
*/
export const Page = ({ children, tab = true, header, ...attrs }: PageProps) => {
// disable scroll on body
useEffect(() => {
const prevOverflowY = document.body.style.overflowY;
document.body.style.overflowY = 'hidden';
return () => {
document.body.style.overflowY = prevOverflowY;
};
}, []);
return (
<main className={styles.page} {...attrs} data-tab={tab}>
{header}
{children}
{tab ? <AppTabs fixed={false} /> : null}
</main>
);
};

View File

@@ -0,0 +1,9 @@
import { style } from '@vanilla-extract/css';
export const page = style({
width: '100dvw',
height: '100dvh',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
});

View File

@@ -1,18 +1,14 @@
import { SafeArea, useThemeColorV2 } from '@affine/component';
import { useThemeColorV2 } from '@affine/component';
import { AppTabs } from '../../components';
import { AllDocList, AllDocsHeader, AllDocsMenu } from '../../views';
import { Page } from '../../components/page';
import { AllDocList, AllDocsHeader } from '../../views';
export const Component = () => {
useThemeColorV2('layer/background/mobile/primary');
return (
<>
<AllDocsHeader operations={<AllDocsMenu />} />
<SafeArea bottom>
<AllDocList />
</SafeArea>
<AppTabs />
</>
<Page header={<AllDocsHeader />} tab>
<AllDocList />
</Page>
);
};

View File

@@ -10,7 +10,6 @@ import {
import { useCallback, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { AppTabs } from '../../../components';
import { CollectionDetail } from '../../../views';
export const Component = () => {
@@ -68,10 +67,5 @@ export const Component = () => {
return null;
}
return (
<>
<CollectionDetail collection={collection} />
<AppTabs />
</>
);
return <CollectionDetail collection={collection} />;
};

View File

@@ -3,11 +3,13 @@ import { createVar, globalStyle } from '@vanilla-extract/css';
export const globalVars = {
appTabHeight: createVar('appTabHeight'),
appTabSafeArea: createVar('appTabSafeArea'),
};
globalStyle(':root', {
vars: {
[globalVars.appTabHeight]: BUILD_CONFIG.isIOS ? '49px' : '62px',
[globalVars.appTabSafeArea]: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
},
userSelect: 'none',
WebkitUserSelect: 'none',
@@ -18,17 +20,17 @@ globalStyle('body', {
minHeight: '100dvh',
overflowY: 'unset',
});
globalStyle('body:has(#app-tabs)', {
paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
globalStyle('body:has(> #app-tabs)', {
paddingBottom: globalVars.appTabSafeArea,
});
globalStyle('body:has(#app-tabs) affine-keyboard-toolbar[data-shrink="true"]', {
paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
paddingBottom: globalVars.appTabSafeArea,
});
globalStyle('body:has(#app-tabs) affine-keyboard-tool-panel', {
paddingBottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom) + 8px)`,
});
globalStyle('body:has(#app-tabs) edgeless-toolbar-widget', {
bottom: `calc(${globalVars.appTabHeight} + env(safe-area-inset-bottom))`,
bottom: globalVars.appTabSafeArea,
});
globalStyle('html', {
height: '100dvh',

View File

@@ -1,29 +1,16 @@
import { IconButton, MobileMenu } from '@affine/component';
import { EmptyCollectionDetail } from '@affine/core/components/affine/empty';
import { isEmptyCollection } from '@affine/core/desktop/pages/workspace/collection';
import { PageHeader } from '@affine/core/mobile/components';
import { AppTabs, PageHeader } from '@affine/core/mobile/components';
import { Page } from '@affine/core/mobile/components/page';
import type { Collection } from '@affine/env/filter';
import { MoreHorizontalIcon, ViewLayersIcon } from '@blocksuite/icons/rc';
import { ViewLayersIcon } from '@blocksuite/icons/rc';
import { AllDocList } from '../doc/list';
import { AllDocsMenu } from '../doc/menu';
import * as styles from './detail.css';
export const DetailHeader = ({ collection }: { collection: Collection }) => {
return (
<PageHeader
className={styles.header}
back
suffix={
<MobileMenu items={<AllDocsMenu />}>
<IconButton
size="24"
style={{ padding: 10 }}
icon={<MoreHorizontalIcon />}
/>
</MobileMenu>
}
>
<PageHeader className={styles.header} back>
<div className={styles.headerContent}>
<ViewLayersIcon className={styles.headerIcon} />
{collection.name}
@@ -42,14 +29,14 @@ export const CollectionDetail = ({
<>
<DetailHeader collection={collection} />
<EmptyCollectionDetail collection={collection} absoluteCenter />
<AppTabs />
</>
);
}
return (
<>
<DetailHeader collection={collection} />
<Page header={<DetailHeader collection={collection} />}>
<AllDocList collection={collection} />
</>
</Page>
);
};

View File

@@ -1,13 +1,10 @@
import { EmptyDocs } from '@affine/core/components/affine/empty';
import { useBlockSuiteDocMeta } from '@affine/core/components/hooks/use-block-suite-page-meta';
import {
type ItemGroupDefinition,
type ItemGroupProps,
useAllDocDisplayProperties,
useFilteredPageMetas,
usePageItemGroupDefinitions,
} from '@affine/core/components/page-list';
import { itemsToItemGroups } from '@affine/core/components/page-list/items-to-item-group';
import type { Tag } from '@affine/core/modules/tag';
import type { Collection, Filter } from '@affine/env/filter';
import type { DocMeta } from '@blocksuite/affine/store';
@@ -19,7 +16,7 @@ import { useMemo } from 'react';
import * as styles from './list.css';
import { MasonryDocs } from './masonry';
const DocGroup = ({ group }: { group: ItemGroupProps<DocMeta> }) => {
export const DocGroup = ({ group }: { group: ItemGroupProps<DocMeta> }) => {
const [properties] = useAllDocDisplayProperties();
const showTags = properties.displayProperties.tags;
@@ -53,6 +50,7 @@ export const AllDocList = ({
tag,
filters = [],
}: AllDocListProps) => {
const [properties] = useAllDocDisplayProperties();
const workspace = useService(WorkspaceService).workspace;
const allPageMetas = useBlockSuiteDocMeta(workspace.docCollection);
@@ -72,22 +70,29 @@ export const AllDocList = ({
return filteredPageMetas;
}, [filteredPageMetas, tag, tagPageIds]);
const groupDefs =
usePageItemGroupDefinitions() as ItemGroupDefinition<DocMeta>[];
// const groupDefs =
// usePageItemGroupDefinitions() as ItemGroupDefinition<DocMeta>[];
const groups = useMemo(() => {
return itemsToItemGroups(finalPageMetas ?? [], groupDefs);
}, [finalPageMetas, groupDefs]);
// const groups = useMemo(() => {
// return itemsToItemGroups(finalPageMetas ?? [], groupDefs);
// }, [finalPageMetas, groupDefs]);
if (!groups.length) {
if (!finalPageMetas.length) {
return <EmptyDocs absoluteCenter tagId={tag?.id} />;
}
// return (
// <div className={styles.groups}>
// {groups.map(group => (
// <DocGroup key={group.id} group={group} />
// ))}
// </div>
// );
return (
<div className={styles.groups}>
{groups.map(group => (
<DocGroup key={group.id} group={group} />
))}
</div>
<MasonryDocs
items={finalPageMetas}
showTags={properties.displayProperties.tags}
/>
);
};

View File

@@ -1,31 +1,12 @@
import { useGlobalEvent } from '@affine/core/mobile/hooks/use-global-events';
import { Masonry } from '@affine/component';
import type { DocMeta } from '@blocksuite/affine/store';
import { useCallback, useMemo, useState } from 'react';
import { useMemo } from 'react';
import { calcRowsById, DocCard } from '../../../components';
import * as styles from './masonry.css';
const calcColumnCount = () => {
const maxCardWidth = 220;
const windowWidth = window.innerWidth;
const newColumnCount = Math.floor(
(windowWidth - styles.paddingX * 2 - styles.columnGap) / maxCardWidth
);
return Math.max(newColumnCount, 2);
};
const calcColumns = (items: DocMeta[], length: number) => {
const columns = Array.from({ length }, () => [] as DocMeta[]);
const heights = Array.from({ length }, () => 0);
items.forEach(item => {
const itemHeight = calcRowsById(item.id);
const minHeightIndex = heights.indexOf(Math.min(...heights));
heights[minHeightIndex] += itemHeight;
columns[minHeightIndex].push(item);
});
return columns;
const fullStyle = {
width: '100%',
height: '100%',
};
export const MasonryDocs = ({
@@ -35,32 +16,29 @@ export const MasonryDocs = ({
items: DocMeta[];
showTags?: boolean;
}) => {
const [columnCount, setColumnCount] = useState(calcColumnCount);
const updateColumnCount = useCallback(() => {
setColumnCount(calcColumnCount());
}, []);
useGlobalEvent('resize', updateColumnCount);
const columns = useMemo(
() => calcColumns(items, columnCount),
[items, columnCount]
const masonryItems = useMemo(
() =>
items.map(item => {
return {
id: item.id,
height: calcRowsById(item.id) * 18 + 95,
children: (
<DocCard style={fullStyle} meta={item} showTags={showTags} />
),
};
}),
[items, showTags]
);
return (
<div className={styles.columns}>
{columns.map((col, index) => (
<div key={`${columnCount}-${index}`} className={styles.column}>
{col.map(item => (
<DocCard
key={item.id}
showTags={showTags}
meta={item}
autoHeightById
/>
))}
</div>
))}
</div>
<Masonry
style={fullStyle}
itemWidthMin={160}
gapX={17}
gapY={10}
paddingX={16}
paddingY={16}
virtualScroll
items={masonryItems}
/>
);
};

View File

@@ -1,29 +1,14 @@
import { IconButton, MobileMenu } from '@affine/component';
import { PageHeader } from '@affine/core/mobile/components';
import type { Tag } from '@affine/core/modules/tag';
import { MoreHorizontalIcon } from '@blocksuite/icons/rc';
import { useLiveData } from '@toeverything/infra';
import { AllDocsMenu } from '../doc';
import * as styles from './detail.css';
export const TagDetailHeader = ({ tag }: { tag: Tag }) => {
const name = useLiveData(tag.value$);
const color = useLiveData(tag.color$);
return (
<PageHeader
className={styles.header}
back
suffix={
<MobileMenu items={<AllDocsMenu />}>
<IconButton
size="24"
style={{ padding: 10 }}
icon={<MoreHorizontalIcon />}
/>
</MobileMenu>
}
>
<PageHeader className={styles.header} back>
<div className={styles.headerContent}>
<div className={styles.headerIcon} style={{ color }} />
{name}

View File

@@ -1,15 +1,13 @@
import { Page } from '@affine/core/mobile/components/page';
import type { Tag } from '@affine/core/modules/tag';
import { AppTabs } from '../../../components';
import { AllDocList } from '../doc';
import { TagDetailHeader } from './detail-header';
export const TagDetail = ({ tag }: { tag: Tag }) => {
return (
<>
<TagDetailHeader tag={tag} />
<Page header={<TagDetailHeader tag={tag} />} tab>
<AllDocList tag={tag} />
<AppTabs />
</>
</Page>
);
};