mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 12:28:42 +00:00
feat(component, mobile): masonry layout with virtual scroll support, adapted with all docs (#9208)
### Preview  ### Render when scrolling  ### api ```tsx const items = useMemo(() => { return { id: '', height: 100, children: <div></div> } }, []) <Masonry items={items} /> ```
This commit is contained in:
@@ -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';
|
||||
|
||||
1
packages/frontend/component/src/ui/masonry/index.ts
Normal file
1
packages/frontend/component/src/ui/masonry/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './masonry';
|
||||
@@ -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
packages/frontend/component/src/ui/masonry/masonry.tsx
Normal file
220
packages/frontend/component/src/ui/masonry/masonry.tsx
Normal file
@@ -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
packages/frontend/component/src/ui/masonry/styles.css.ts
Normal file
14
packages/frontend/component/src/ui/masonry/styles.css.ts
Normal file
@@ -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
packages/frontend/component/src/ui/masonry/type.ts
Normal file
11
packages/frontend/component/src/ui/masonry/type.ts
Normal file
@@ -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;
|
||||
}
|
||||
87
packages/frontend/component/src/ui/masonry/utils.ts
Normal file
87
packages/frontend/component/src/ui/masonry/utils.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { MasonryItem, MasonryItemXYWH } from './type';
|
||||
|
||||
export const calcColumns = (
|
||||
totalWidth: number,
|
||||
itemWidth: number | 'stretch',
|
||||
itemWidthMin: number,
|
||||
gapX: number,
|
||||
paddingX: number
|
||||
) => {
|
||||
const availableWidth = totalWidth - paddingX * 2;
|
||||
|
||||
if (itemWidth === 'stretch') {
|
||||
let columns = 1;
|
||||
while (columns * itemWidthMin + (columns - 1) * gapX < availableWidth) {
|
||||
columns++;
|
||||
}
|
||||
const finalColumns = columns - 1;
|
||||
const finalWidth =
|
||||
(availableWidth - (finalColumns - 1) * gapX) / finalColumns;
|
||||
return {
|
||||
columns: finalColumns,
|
||||
width: finalWidth,
|
||||
};
|
||||
} else {
|
||||
let columns = 1;
|
||||
while (columns * itemWidth + (columns - 1) * gapX < availableWidth) {
|
||||
columns++;
|
||||
}
|
||||
return {
|
||||
columns: columns - 1,
|
||||
width: itemWidth,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export const calcLayout = (
|
||||
items: MasonryItem[],
|
||||
options: {
|
||||
columns: number;
|
||||
width: number;
|
||||
gapX: number;
|
||||
gapY: number;
|
||||
paddingX: number;
|
||||
paddingY: number;
|
||||
}
|
||||
) => {
|
||||
const { columns, width, gapX, gapY, paddingX, paddingY } = options;
|
||||
|
||||
const layoutMap = new Map<MasonryItem['id'], MasonryItemXYWH>();
|
||||
const heightStack = Array.from({ length: columns }, () => paddingY);
|
||||
|
||||
items.forEach(item => {
|
||||
const minHeight = Math.min(...heightStack);
|
||||
const minHeightIndex = heightStack.indexOf(minHeight);
|
||||
const x = minHeightIndex * (width + gapX) + paddingX;
|
||||
const y = minHeight + gapY;
|
||||
heightStack[minHeightIndex] = y + item.height;
|
||||
layoutMap.set(item.id, { x, y, w: width, h: item.height });
|
||||
});
|
||||
|
||||
const finalHeight = Math.max(...heightStack) + paddingY;
|
||||
|
||||
return { layout: layoutMap, height: finalHeight };
|
||||
};
|
||||
|
||||
export const calcSleep = (options: {
|
||||
viewportHeight: number;
|
||||
scrollY: number;
|
||||
layoutMap: Map<MasonryItem['id'], MasonryItemXYWH>;
|
||||
preloadHeight: number;
|
||||
}) => {
|
||||
const { viewportHeight, scrollY, layoutMap, preloadHeight } = options;
|
||||
|
||||
const sleepMap = new Map<MasonryItem['id'], boolean>();
|
||||
|
||||
layoutMap.forEach((layout, id) => {
|
||||
const { y, h } = layout;
|
||||
|
||||
const isInView =
|
||||
y + h + preloadHeight > scrollY &&
|
||||
y - preloadHeight < scrollY + viewportHeight;
|
||||
|
||||
sleepMap.set(id, !isInView);
|
||||
});
|
||||
|
||||
return sleepMap;
|
||||
};
|
||||
Reference in New Issue
Block a user