Skip to content

Commit

Permalink
feat(component, mobile): masonry layout with virtual scroll support, …
Browse files Browse the repository at this point in the history
…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} />
```
  • Loading branch information
CatsJuice committed Dec 20, 2024
1 parent 2988dc2 commit a53e231
Show file tree
Hide file tree
Showing 20 changed files with 572 additions and 186 deletions.
1 change: 1 addition & 0 deletions packages/frontend/component/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export * from './ui/loading';
export * from './ui/lottie/collections-icon';
export * from './ui/lottie/delete-icon';
export * from './ui/lottie/folder-icon';
export * from './ui/masonry';
export * from './ui/menu';
export * from './ui/modal';
export * from './ui/notification';
Expand Down
1 change: 1 addition & 0 deletions packages/frontend/component/src/ui/masonry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './masonry';
80 changes: 80 additions & 0 deletions packages/frontend/component/src/ui/masonry/masonry.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { ResizePanel } from '../resize-panel/resize-panel';
import { Masonry } from './masonry';

export default {
title: 'UI/Masonry',
};

const Card = ({ children }: { children: React.ReactNode }) => {
return (
<div
style={{
width: '100%',
height: '100%',
borderRadius: 10,
border: `1px solid rgba(100, 100, 100, 0.2)`,
boxShadow: '0 1px 10px rgba(0, 0, 0, 0.1)',
padding: 10,
backgroundColor: 'white',
}}
>
{children}
</div>
);
};

const basicCards = Array.from({ length: 10000 }, (_, i) => {
return {
id: 'card-' + i,
height: Math.round(100 + Math.random() * 100),
children: (
<Card>
<h1>Hello</h1>
<p>World</p>
{i}
</Card>
),
};
});

export const BasicVirtualScroll = () => {
return (
<ResizePanel width={800} height={600}>
<Masonry
gapX={10}
gapY={10}
style={{ width: '100%', height: '100%' }}
paddingX={12}
paddingY={12}
virtualScroll
items={basicCards}
/>
</ResizePanel>
);
};

const transitionCards = Array.from({ length: 10000 }, (_, i) => {
return {
id: 'card-' + i,
height: Math.round(100 + Math.random() * 100),
children: <Card>{i}</Card>,
style: { transition: 'transform 0.2s ease' },
};
});

export const CustomTransition = () => {
return (
<ResizePanel width={800} height={600}>
<Masonry
gapX={10}
gapY={10}
style={{ width: '100%', height: '100%' }}
paddingX={12}
paddingY={12}
virtualScroll
items={transitionCards}
locateMode="transform3d"
/>
</ResizePanel>
);
};
220 changes: 220 additions & 0 deletions packages/frontend/component/src/ui/masonry/masonry.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { throttle } from '@blocksuite/affine/global/utils';
import clsx from 'clsx';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';

import { observeResize } from '../../utils';
import { Scrollable } from '../scrollbar';
import * as styles from './styles.css';
import type { MasonryItem, MasonryItemXYWH } from './type';
import { calcColumns, calcLayout, calcSleep } from './utils';

export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
items: MasonryItem[];

gapX?: number;
gapY?: number;
paddingX?: number;
paddingY?: number;
/**
* Specify the width of the item.
* - `number`: The width of the item in pixels.
* - `'stretch'`: The item will stretch to fill the container.
* @default 'stretch'
*/
itemWidth?: number | 'stretch';
/**
* The minimum width of the item in pixels.
* @default 100
*/
itemWidthMin?: number;
virtualScroll?: boolean;
locateMode?: 'transform' | 'leftTop' | 'transform3d';
}

export const Masonry = ({
items,
gapX = 12,
gapY = 12,
itemWidth = 'stretch',
itemWidthMin = 100,
paddingX = 0,
paddingY = 0,
className,
virtualScroll = false,
locateMode = 'leftTop',
...props
}: MasonryProps) => {
const rootRef = useRef<HTMLDivElement>(null);
const [height, setHeight] = useState(0);
const [layoutMap, setLayoutMap] = useState<
Map<MasonryItem['id'], MasonryItemXYWH>
>(new Map());
const [sleepMap, setSleepMap] = useState<Map<MasonryItem['id'], boolean>>(
new Map()
);

const updateSleepMap = useCallback(
(layoutMap: Map<MasonryItem['id'], MasonryItemXYWH>, _scrollY?: number) => {
if (!virtualScroll) return;

const rootEl = rootRef.current;
if (!rootEl) return;

requestAnimationFrame(() => {
const scrollY = _scrollY ?? rootEl.scrollTop;
const sleepMap = calcSleep({
viewportHeight: rootEl.clientHeight,
scrollY,
layoutMap,
preloadHeight: 50,
});
setSleepMap(sleepMap);
});
},
[virtualScroll]
);

const calculateLayout = useCallback(() => {
const rootEl = rootRef.current;
if (!rootEl) return;

const totalWidth = rootEl.clientWidth;
const { columns, width } = calcColumns(
totalWidth,
itemWidth,
itemWidthMin,
gapX,
paddingX
);

const { layout, height } = calcLayout(items, {
columns,
width,
gapX,
gapY,
paddingX,
paddingY,
});
setLayoutMap(layout);
setHeight(height);
updateSleepMap(layout);
}, [
gapX,
gapY,
itemWidth,
itemWidthMin,
items,
paddingX,
paddingY,
updateSleepMap,
]);

// handle resize
useEffect(() => {
calculateLayout();
if (rootRef.current) {
return observeResize(rootRef.current, calculateLayout);
}
return;
}, [calculateLayout]);

// handle scroll
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;

if (virtualScroll) {
const handler = throttle((e: Event) => {
const scrollY = (e.target as HTMLElement).scrollTop;
updateSleepMap(layoutMap, scrollY);
}, 50);
rootEl.addEventListener('scroll', handler);
return () => {
rootEl.removeEventListener('scroll', handler);
};
}
return;
}, [layoutMap, updateSleepMap, virtualScroll]);

return (
<Scrollable.Root>
<Scrollable.Viewport
ref={rootRef}
data-masonry-root
className={clsx('scrollable', styles.root, className)}
{...props}
>
{items.map(item => {
return (
<MasonryItem
key={item.id}
{...item}
locateMode={locateMode}
xywh={layoutMap.get(item.id)}
sleep={sleepMap.get(item.id)}
>
{item.children}
</MasonryItem>
);
})}
<div data-masonry-placeholder style={{ height }} />
</Scrollable.Viewport>
<Scrollable.Scrollbar />
</Scrollable.Root>
);
};

interface MasonryItemProps
extends MasonryItem,
Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'> {
locateMode?: 'transform' | 'leftTop' | 'transform3d';
sleep?: boolean;
xywh?: MasonryItemXYWH;
}

const MasonryItem = memo(function MasonryItem({
id,
xywh,
locateMode = 'leftTop',
sleep = false,
className,
children,
style: styleProp,
...props
}: MasonryItemProps) {
const style = useMemo(() => {
if (!xywh) return { display: 'none' };

const { x, y, w, h } = xywh;

const posStyle =
locateMode === 'transform'
? { transform: `translate(${x}px, ${y}px)` }
: locateMode === 'leftTop'
? { left: `${x}px`, top: `${y}px` }
: { transform: `translate3d(${x}px, ${y}px, 0)` };

return {
left: 0,
top: 0,
...styleProp,
...posStyle,
width: `${w}px`,
height: `${h}px`,
};
}, [locateMode, styleProp, xywh]);

if (sleep || !xywh) return null;

return (
<div
data-masonry-item
data-masonry-item-id={id}
className={clsx(styles.item, className)}
style={style}
{...props}
>
{children}
</div>
);
});
14 changes: 14 additions & 0 deletions packages/frontend/component/src/ui/masonry/styles.css.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { style } from '@vanilla-extract/css';

export const root = style({
position: 'relative',
selectors: {
'&.scrollable': {
overflowY: 'auto',
},
},
});

export const item = style({
position: 'absolute',
});
11 changes: 11 additions & 0 deletions packages/frontend/component/src/ui/masonry/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export interface MasonryItem extends React.HTMLAttributes<HTMLDivElement> {
id: string;
height: number;
}

export interface MasonryItemXYWH {
x: number;
y: number;
w: number;
h: number;
}
Loading

0 comments on commit a53e231

Please sign in to comment.