mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-13 21:05:19 +00:00
feat(component): grouped masonry (#11958)
- support group for masonry - expand/collapse group - sticky group header  <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Enhanced Masonry component with support for grouped items, collapsible and sticky group headers. - Added multi-view transitions enabling switching between Masonry, Grid, and List layouts. - Introduced virtual scrolling with group support for efficient handling of large datasets. - New interactive storybook demonstrations showcasing grouped and multi-view Masonry scenarios. - **Improvements** - List view styling enhancements for improved clarity and layout. - Resize panel now supports customizable offset modifications during drag interactions. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,3 +1,6 @@
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
|
||||
import { RadioGroup } from '../radio';
|
||||
import { ResizePanel } from '../resize-panel/resize-panel';
|
||||
import { Masonry } from './masonry';
|
||||
|
||||
@@ -5,20 +8,43 @@ export default {
|
||||
title: 'UI/Masonry',
|
||||
};
|
||||
|
||||
const Card = ({ children }: { children: React.ReactNode }) => {
|
||||
const Card = ({
|
||||
children,
|
||||
listView,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
listView?: boolean;
|
||||
}) => {
|
||||
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',
|
||||
borderRadius: listView ? 0 : 10,
|
||||
border: listView
|
||||
? '0px solid rgba(100, 100, 100, 0.2)'
|
||||
: '1px solid rgba(100, 100, 100, 0.2)',
|
||||
boxShadow: listView ? 'none' : '0 1px 10px rgba(0, 0, 0, 0.1)',
|
||||
padding: listView ? '0px 20px' : 10,
|
||||
backgroundColor: listView ? 'transparent' : 'white',
|
||||
display: 'flex',
|
||||
flexDirection: listView ? 'row' : 'column',
|
||||
gap: 8,
|
||||
alignItems: listView ? 'center' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{listView && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: `calc(100% + 5px)`,
|
||||
left: 0,
|
||||
borderBottom: `0.5px solid rgba(100, 100, 100, 0.2)`,
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -78,3 +104,178 @@ export const CustomTransition = () => {
|
||||
</ResizePanel>
|
||||
);
|
||||
};
|
||||
|
||||
const groups = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(letter => {
|
||||
return {
|
||||
id: letter,
|
||||
height: 20,
|
||||
children: <h1>Group header: {letter}</h1>,
|
||||
items: Array.from({ length: 100 }, (_, i) => {
|
||||
return {
|
||||
id: i,
|
||||
height: Math.round(100 + Math.random() * 100),
|
||||
children: (
|
||||
<Card>
|
||||
<div>Group: {letter}</div>
|
||||
<div>Item: {i}</div>
|
||||
</Card>
|
||||
),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
export const GroupVirtualScroll = () => {
|
||||
return (
|
||||
<ResizePanel width={800} height={600}>
|
||||
<Masonry
|
||||
gapX={10}
|
||||
gapY={10}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
paddingX={12}
|
||||
paddingY={12}
|
||||
virtualScroll
|
||||
groupsGap={10}
|
||||
groupHeaderGapWithItems={10}
|
||||
items={groups}
|
||||
locateMode="transform3d"
|
||||
/>
|
||||
</ResizePanel>
|
||||
);
|
||||
};
|
||||
|
||||
const GroupHeader = memo(function GroupHeader({
|
||||
groupId,
|
||||
collapsed,
|
||||
itemCount,
|
||||
}: {
|
||||
groupId: string;
|
||||
collapsed?: boolean;
|
||||
itemCount: number;
|
||||
}) {
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'white',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<h1>
|
||||
Group header: {groupId} - {itemCount} items{' '}
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
transform: `rotate(${collapsed ? 0 : 90}deg)`,
|
||||
transition: 'transform 0.2s ease',
|
||||
}}
|
||||
>
|
||||
>
|
||||
</span>
|
||||
</h1>
|
||||
</header>
|
||||
);
|
||||
});
|
||||
|
||||
const GroupItem = ({
|
||||
groupId,
|
||||
itemId,
|
||||
view,
|
||||
}: {
|
||||
groupId: string;
|
||||
itemId: string;
|
||||
view: 'Masonry' | 'Grid' | 'List';
|
||||
}) => {
|
||||
return (
|
||||
<Card listView={view === 'List'}>
|
||||
<div>Group: {groupId}</div>
|
||||
<div>Item: {itemId}</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const viewGroups = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('').map(letter => {
|
||||
return {
|
||||
id: letter,
|
||||
height: 40,
|
||||
Component: GroupHeader,
|
||||
style: { transition: 'all 0.4s cubic-bezier(.4,.22,0,.98)' },
|
||||
items: Array.from(
|
||||
{ length: Math.round(50 + Math.random() * 50) },
|
||||
(_, i) => {
|
||||
return {
|
||||
id: `${i}`,
|
||||
height: {
|
||||
List: 32,
|
||||
Masonry: Math.round(100 + Math.random() * 100),
|
||||
Grid: 100,
|
||||
},
|
||||
style: { transition: 'all 0.4s cubic-bezier(.4,.22,0,.98)' },
|
||||
};
|
||||
}
|
||||
),
|
||||
} as const;
|
||||
});
|
||||
|
||||
export const MultiViewTransition = () => {
|
||||
const [view, setView] = useState<'Masonry' | 'Grid' | 'List'>('List');
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<string[]>([]);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
return viewGroups.map(({ items, ...g }) => ({
|
||||
...g,
|
||||
items: items.map(({ height, ...item }) => ({
|
||||
...item,
|
||||
height: height[view],
|
||||
children: <GroupItem groupId={g.id} itemId={item.id} view={view} />,
|
||||
})),
|
||||
}));
|
||||
}, [view]);
|
||||
|
||||
const onGroupCollapse = useCallback((groupId: string, collapsed: boolean) => {
|
||||
setCollapsedGroups(prev => {
|
||||
return collapsed ? [...prev, groupId] : prev.filter(id => id !== groupId);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 10,
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<RadioGroup
|
||||
items={['Masonry', 'Grid', 'List']}
|
||||
value={view}
|
||||
onChange={setView}
|
||||
width={300}
|
||||
/>
|
||||
<ResizePanel
|
||||
width={800}
|
||||
height={600}
|
||||
offsetModifier={useCallback(([x, y]: number[]) => [x * 2, y], [])}
|
||||
>
|
||||
<Masonry
|
||||
gapX={10}
|
||||
gapY={10}
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
paddingX={12}
|
||||
paddingY={0}
|
||||
virtualScroll
|
||||
groupsGap={10}
|
||||
groupHeaderGapWithItems={10}
|
||||
items={groups}
|
||||
locateMode="transform3d"
|
||||
columns={view === 'List' ? 1 : undefined}
|
||||
collapsedGroups={collapsedGroups}
|
||||
onGroupCollapse={onGroupCollapse}
|
||||
/>
|
||||
</ResizePanel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,20 +1,35 @@
|
||||
import clsx from 'clsx';
|
||||
import { debounce } from 'lodash-es';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Fragment,
|
||||
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';
|
||||
import type { MasonryGroup, MasonryItem, MasonryItemXYWH } from './type';
|
||||
import { calcColumns, calcLayout, calcSleep, calcSticky } from './utils';
|
||||
|
||||
export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
items: MasonryItem[];
|
||||
items: MasonryItem[] | MasonryGroup[];
|
||||
|
||||
gapX?: number;
|
||||
gapY?: number;
|
||||
paddingX?: number;
|
||||
paddingY?: number;
|
||||
|
||||
groupsGap?: number;
|
||||
groupHeaderGapWithItems?: number;
|
||||
stickyGroupHeader?: boolean;
|
||||
collapsedGroups?: string[];
|
||||
onGroupCollapse?: (groupId: string, collapsed: boolean) => void;
|
||||
/**
|
||||
* Specify the width of the item.
|
||||
* - `number`: The width of the item in pixels.
|
||||
@@ -29,6 +44,10 @@ export interface MasonryProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
itemWidthMin?: number;
|
||||
virtualScroll?: boolean;
|
||||
locateMode?: 'transform' | 'leftTop' | 'transform3d';
|
||||
/**
|
||||
* Specify the number of columns, will override the calculated
|
||||
*/
|
||||
columns?: number;
|
||||
}
|
||||
|
||||
export const Masonry = ({
|
||||
@@ -42,6 +61,12 @@ export const Masonry = ({
|
||||
className,
|
||||
virtualScroll = false,
|
||||
locateMode = 'leftTop',
|
||||
groupsGap = 0,
|
||||
groupHeaderGapWithItems = 0,
|
||||
stickyGroupHeader = true,
|
||||
collapsedGroups,
|
||||
columns,
|
||||
onGroupCollapse,
|
||||
...props
|
||||
}: MasonryProps) => {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
@@ -49,9 +74,31 @@ export const Masonry = ({
|
||||
const [layoutMap, setLayoutMap] = useState<
|
||||
Map<MasonryItem['id'], MasonryItemXYWH>
|
||||
>(new Map());
|
||||
const [sleepMap, setSleepMap] = useState<Map<MasonryItem['id'], boolean>>(
|
||||
new Map()
|
||||
const [sleepMap, setSleepMap] = useState<Map<
|
||||
MasonryItem['id'],
|
||||
boolean
|
||||
> | null>(null);
|
||||
const [stickyGroupId, setStickyGroupId] = useState<string | undefined>(
|
||||
undefined
|
||||
);
|
||||
const stickyGroupCollapsed = !!(
|
||||
collapsedGroups &&
|
||||
stickyGroupId &&
|
||||
collapsedGroups.includes(stickyGroupId)
|
||||
);
|
||||
|
||||
const groups = useMemo(() => {
|
||||
if (items.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if ('items' in items[0]) return items as MasonryGroup[];
|
||||
return [{ id: '', height: 0, items: items as MasonryItem[] }];
|
||||
}, [items]);
|
||||
|
||||
const stickyGroup = useMemo(() => {
|
||||
if (!stickyGroupId) return undefined;
|
||||
return groups.find(group => group.id === stickyGroupId);
|
||||
}, [groups, stickyGroupId]);
|
||||
|
||||
const updateSleepMap = useCallback(
|
||||
(layoutMap: Map<MasonryItem['id'], MasonryItemXYWH>, _scrollY?: number) => {
|
||||
@@ -79,33 +126,48 @@ export const Masonry = ({
|
||||
if (!rootEl) return;
|
||||
|
||||
const totalWidth = rootEl.clientWidth;
|
||||
const { columns, width } = calcColumns(
|
||||
const { columns: calculatedColumns, width } = calcColumns(
|
||||
totalWidth,
|
||||
itemWidth,
|
||||
itemWidthMin,
|
||||
gapX,
|
||||
paddingX
|
||||
paddingX,
|
||||
columns
|
||||
);
|
||||
|
||||
const { layout, height } = calcLayout(items, {
|
||||
columns,
|
||||
const { layout, height } = calcLayout(groups, {
|
||||
totalWidth,
|
||||
columns: calculatedColumns,
|
||||
width,
|
||||
gapX,
|
||||
gapY,
|
||||
paddingX,
|
||||
paddingY,
|
||||
groupsGap,
|
||||
groupHeaderGapWithItems,
|
||||
collapsedGroups: collapsedGroups ?? [],
|
||||
});
|
||||
setLayoutMap(layout);
|
||||
setHeight(height);
|
||||
updateSleepMap(layout);
|
||||
if (stickyGroupHeader && rootRef.current) {
|
||||
setStickyGroupId(
|
||||
calcSticky({ scrollY: rootRef.current.scrollTop, layoutMap: layout })
|
||||
);
|
||||
}
|
||||
}, [
|
||||
collapsedGroups,
|
||||
columns,
|
||||
gapX,
|
||||
gapY,
|
||||
groupHeaderGapWithItems,
|
||||
groups,
|
||||
groupsGap,
|
||||
itemWidth,
|
||||
itemWidthMin,
|
||||
items,
|
||||
paddingX,
|
||||
paddingY,
|
||||
stickyGroupHeader,
|
||||
updateSleepMap,
|
||||
]);
|
||||
|
||||
@@ -113,7 +175,7 @@ export const Masonry = ({
|
||||
useEffect(() => {
|
||||
calculateLayout();
|
||||
if (rootRef.current) {
|
||||
return observeResize(rootRef.current, calculateLayout);
|
||||
return observeResize(rootRef.current, debounce(calculateLayout, 50));
|
||||
}
|
||||
return;
|
||||
}, [calculateLayout]);
|
||||
@@ -127,6 +189,9 @@ export const Masonry = ({
|
||||
const handler = throttle((e: Event) => {
|
||||
const scrollY = (e.target as HTMLElement).scrollTop;
|
||||
updateSleepMap(layoutMap, scrollY);
|
||||
if (stickyGroupHeader) {
|
||||
setStickyGroupId(calcSticky({ scrollY, layoutMap }));
|
||||
}
|
||||
}, 50);
|
||||
rootEl.addEventListener('scroll', handler);
|
||||
return () => {
|
||||
@@ -134,7 +199,7 @@ export const Masonry = ({
|
||||
};
|
||||
}
|
||||
return;
|
||||
}, [layoutMap, updateSleepMap, virtualScroll]);
|
||||
}, [layoutMap, stickyGroupHeader, updateSleepMap, virtualScroll]);
|
||||
|
||||
return (
|
||||
<Scrollable.Root>
|
||||
@@ -144,22 +209,100 @@ export const Masonry = ({
|
||||
className={clsx('scrollable', styles.root, className)}
|
||||
{...props}
|
||||
>
|
||||
{items.map(item => {
|
||||
{groups.map(group => {
|
||||
// sleep is not calculated, do not render
|
||||
if (virtualScroll && !sleepMap) return null;
|
||||
const {
|
||||
id: groupId,
|
||||
items,
|
||||
children,
|
||||
className,
|
||||
Component,
|
||||
...groupProps
|
||||
} = group;
|
||||
const collapsed =
|
||||
collapsedGroups && collapsedGroups.includes(groupId);
|
||||
|
||||
const sleep =
|
||||
(virtualScroll && sleepMap && sleepMap.get(groupId)) ?? false;
|
||||
|
||||
return (
|
||||
<MasonryItem
|
||||
key={item.id}
|
||||
{...item}
|
||||
locateMode={locateMode}
|
||||
xywh={layoutMap.get(item.id)}
|
||||
sleep={sleepMap.get(item.id)}
|
||||
>
|
||||
{item.children}
|
||||
</MasonryItem>
|
||||
<Fragment key={groupId}>
|
||||
{/* group header */}
|
||||
{sleep ? null : (
|
||||
<MasonryItem
|
||||
className={clsx(styles.groupHeader, className)}
|
||||
key={`header-${groupId}`}
|
||||
id={groupId}
|
||||
locateMode={locateMode}
|
||||
xywh={layoutMap.get(groupId)}
|
||||
{...groupProps}
|
||||
onClick={() => onGroupCollapse?.(groupId, !collapsed)}
|
||||
>
|
||||
{Component ? (
|
||||
<Component
|
||||
itemCount={items.length}
|
||||
groupId={groupId}
|
||||
collapsed={collapsed}
|
||||
/>
|
||||
) : (
|
||||
children
|
||||
)}
|
||||
</MasonryItem>
|
||||
)}
|
||||
{/* group items */}
|
||||
{collapsed
|
||||
? null
|
||||
: items.map(({ id: itemId, Component, ...item }) => {
|
||||
const mixId = groupId ? `${groupId}:${itemId}` : itemId;
|
||||
const sleep =
|
||||
(virtualScroll && sleepMap && sleepMap.get(mixId)) ??
|
||||
false;
|
||||
if (sleep) return null;
|
||||
return (
|
||||
<MasonryItem
|
||||
key={mixId}
|
||||
id={mixId}
|
||||
{...item}
|
||||
locateMode={locateMode}
|
||||
xywh={layoutMap.get(mixId)}
|
||||
>
|
||||
{Component ? (
|
||||
<Component groupId={groupId} itemId={itemId} />
|
||||
) : (
|
||||
item.children
|
||||
)}
|
||||
</MasonryItem>
|
||||
);
|
||||
})}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
<div data-masonry-placeholder style={{ height }} />
|
||||
</Scrollable.Viewport>
|
||||
<Scrollable.Scrollbar />
|
||||
{stickyGroup ? (
|
||||
<div
|
||||
className={styles.stickyGroupHeader}
|
||||
style={{
|
||||
padding: `0 ${paddingX}px`,
|
||||
height: stickyGroup.height,
|
||||
}}
|
||||
onClick={() =>
|
||||
onGroupCollapse?.(stickyGroup.id, !stickyGroupCollapsed)
|
||||
}
|
||||
>
|
||||
{stickyGroup.Component ? (
|
||||
<stickyGroup.Component
|
||||
groupId={stickyGroup.id}
|
||||
itemCount={stickyGroup.items.length}
|
||||
collapsed={stickyGroupCollapsed}
|
||||
/>
|
||||
) : (
|
||||
stickyGroup.children
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Scrollable.Root>
|
||||
);
|
||||
};
|
||||
@@ -168,7 +311,6 @@ interface MasonryItemProps
|
||||
extends MasonryItem,
|
||||
Omit<React.HTMLAttributes<HTMLDivElement>, 'id' | 'height'> {
|
||||
locateMode?: 'transform' | 'leftTop' | 'transform3d';
|
||||
sleep?: boolean;
|
||||
xywh?: MasonryItemXYWH;
|
||||
}
|
||||
|
||||
@@ -176,8 +318,6 @@ const MasonryItem = memo(function MasonryItem({
|
||||
id,
|
||||
xywh,
|
||||
locateMode = 'leftTop',
|
||||
sleep = false,
|
||||
className,
|
||||
children,
|
||||
style: styleProp,
|
||||
...props
|
||||
@@ -201,19 +341,14 @@ const MasonryItem = memo(function MasonryItem({
|
||||
...posStyle,
|
||||
width: `${w}px`,
|
||||
height: `${h}px`,
|
||||
position: 'absolute' as const,
|
||||
};
|
||||
}, [locateMode, styleProp, xywh]);
|
||||
|
||||
if (sleep || !xywh) return null;
|
||||
if (!xywh) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-masonry-item
|
||||
data-masonry-item-id={id}
|
||||
className={clsx(styles.item, className)}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
<div data-masonry-item data-masonry-item-id={id} style={style} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -9,6 +9,14 @@ export const root = style({
|
||||
},
|
||||
});
|
||||
|
||||
export const item = style({
|
||||
position: 'absolute',
|
||||
export const groupHeader = style({
|
||||
zIndex: 1,
|
||||
});
|
||||
|
||||
export const stickyGroupHeader = style({
|
||||
zIndex: 1,
|
||||
position: 'absolute',
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: '100%',
|
||||
});
|
||||
|
||||
@@ -1,9 +1,22 @@
|
||||
export interface MasonryItem extends React.HTMLAttributes<HTMLDivElement> {
|
||||
id: string;
|
||||
height: number;
|
||||
Component?: React.ComponentType<{ groupId: string; itemId: string }>;
|
||||
}
|
||||
|
||||
export interface MasonryGroup extends React.HTMLAttributes<HTMLDivElement> {
|
||||
id: string;
|
||||
height: number;
|
||||
items: MasonryItem[];
|
||||
Component?: React.ComponentType<{
|
||||
groupId: string;
|
||||
collapsed?: boolean;
|
||||
itemCount: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface MasonryItemXYWH {
|
||||
type: 'item' | 'group';
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import type { MasonryItem, MasonryItemXYWH } from './type';
|
||||
import type { MasonryGroup, MasonryItem, MasonryItemXYWH } from './type';
|
||||
|
||||
export const calcColumns = (
|
||||
totalWidth: number,
|
||||
itemWidth: number | 'stretch',
|
||||
itemWidthMin: number,
|
||||
gapX: number,
|
||||
paddingX: number
|
||||
paddingX: number,
|
||||
columns?: number
|
||||
) => {
|
||||
const availableWidth = totalWidth - paddingX * 2;
|
||||
|
||||
if (columns) {
|
||||
const width = (availableWidth - (columns - 1) * gapX) / columns;
|
||||
return { columns, width };
|
||||
}
|
||||
|
||||
if (itemWidth === 'stretch') {
|
||||
let columns = 1;
|
||||
while (columns * itemWidthMin + (columns - 1) * gapX < availableWidth) {
|
||||
@@ -34,33 +40,81 @@ export const calcColumns = (
|
||||
};
|
||||
|
||||
export const calcLayout = (
|
||||
items: MasonryItem[],
|
||||
groups: MasonryGroup[],
|
||||
options: {
|
||||
totalWidth: number;
|
||||
columns: number;
|
||||
width: number;
|
||||
gapX: number;
|
||||
gapY: number;
|
||||
paddingX: number;
|
||||
paddingY: number;
|
||||
groupsGap: number;
|
||||
groupHeaderGapWithItems: number;
|
||||
collapsedGroups: string[];
|
||||
}
|
||||
) => {
|
||||
const { columns, width, gapX, gapY, paddingX, paddingY } = options;
|
||||
const {
|
||||
totalWidth,
|
||||
columns,
|
||||
width,
|
||||
gapX,
|
||||
gapY,
|
||||
paddingX,
|
||||
paddingY,
|
||||
groupsGap,
|
||||
groupHeaderGapWithItems,
|
||||
collapsedGroups,
|
||||
} = options;
|
||||
|
||||
const layoutMap = new Map<MasonryItem['id'], MasonryItemXYWH>();
|
||||
const heightStack = Array.from({ length: columns }, () => paddingY);
|
||||
const layout = new Map<string, MasonryItemXYWH>();
|
||||
let finalHeight = 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 });
|
||||
groups.forEach((group, index) => {
|
||||
const heightStack = Array.from({ length: columns }, () => 0);
|
||||
if (index !== 0) {
|
||||
finalHeight += groupsGap;
|
||||
}
|
||||
|
||||
// calculate group header
|
||||
const groupHeaderLayout: MasonryItemXYWH = {
|
||||
type: 'group',
|
||||
x: paddingX,
|
||||
y: finalHeight,
|
||||
w: totalWidth - paddingX * 2,
|
||||
h: group.height,
|
||||
};
|
||||
layout.set(group.id, groupHeaderLayout);
|
||||
|
||||
if (collapsedGroups.includes(group.id)) {
|
||||
finalHeight += groupHeaderLayout.h;
|
||||
return;
|
||||
}
|
||||
|
||||
finalHeight += groupHeaderLayout.h + groupHeaderGapWithItems;
|
||||
// calculate group items
|
||||
group.items.forEach(item => {
|
||||
const itemId = group.id ? `${group.id}:${item.id}` : item.id;
|
||||
const minHeight = Math.min(...heightStack);
|
||||
const minHeightIndex = heightStack.indexOf(minHeight);
|
||||
const x = minHeightIndex * (width + gapX) + paddingX;
|
||||
const y = minHeight + finalHeight;
|
||||
|
||||
heightStack[minHeightIndex] += item.height + gapY;
|
||||
layout.set(itemId, {
|
||||
type: 'item',
|
||||
x,
|
||||
y,
|
||||
w: width,
|
||||
h: item.height,
|
||||
});
|
||||
});
|
||||
|
||||
const groupHeight = Math.max(...heightStack) + paddingY;
|
||||
finalHeight += groupHeight;
|
||||
});
|
||||
|
||||
const finalHeight = Math.max(...heightStack) + paddingY;
|
||||
|
||||
return { layout: layoutMap, height: finalHeight };
|
||||
return { layout, height: finalHeight };
|
||||
};
|
||||
|
||||
export const calcSleep = (options: {
|
||||
@@ -85,3 +139,24 @@ export const calcSleep = (options: {
|
||||
|
||||
return sleepMap;
|
||||
};
|
||||
|
||||
export const calcSticky = (options: {
|
||||
scrollY: number;
|
||||
layoutMap: Map<MasonryItem['id'], MasonryItemXYWH>;
|
||||
}) => {
|
||||
const { scrollY, layoutMap } = options;
|
||||
// find sticky group header
|
||||
const entries = Array.from(layoutMap.entries());
|
||||
const groupEntries = entries.filter(([_, layout]) => layout.type === 'group');
|
||||
|
||||
const stickyGroupEntry = groupEntries.find(([_, xywh], index) => {
|
||||
const next = groupEntries[index + 1];
|
||||
return xywh.y < scrollY && (!next || next[1].y > scrollY);
|
||||
});
|
||||
|
||||
return stickyGroupEntry
|
||||
? stickyGroupEntry[0]
|
||||
: groupEntries.length > 0
|
||||
? groupEntries[0][0]
|
||||
: '';
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface ResizePanelProps
|
||||
minHeight?: number;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
offsetModifier?: (offset: number[]) => number[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,6 +33,7 @@ export const ResizePanel = ({
|
||||
className,
|
||||
horizontal = true,
|
||||
vertical = true,
|
||||
offsetModifier,
|
||||
|
||||
...attrs
|
||||
}: ResizePanelProps) => {
|
||||
@@ -59,7 +61,8 @@ export const ResizePanel = ({
|
||||
const onDrag = (e: MouseEvent) => {
|
||||
const pos = [e.clientX, e.clientY];
|
||||
const delta = [pos[0] - startPos[0], pos[1] - startPos[1]];
|
||||
const newSize = [startSize[0] + delta[0], startSize[1] + delta[1]];
|
||||
const offset = offsetModifier ? offsetModifier(delta) : delta;
|
||||
const newSize = [startSize[0] + offset[0], startSize[1] + offset[1]];
|
||||
updateSize(newSize);
|
||||
};
|
||||
|
||||
@@ -108,6 +111,7 @@ export const ResizePanel = ({
|
||||
maxWidth,
|
||||
minHeight,
|
||||
minWidth,
|
||||
offsetModifier,
|
||||
vertical,
|
||||
width,
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user