feat: implement height-based masonry view

pull/4728/head
Steven 1 month ago
parent 8520e30721
commit f5ecb66fb8

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Memo } from "@/types/proto/api/v1/memo_service"; import { Memo } from "@/types/proto/api/v1/memo_service";
import { cn } from "@/utils"; import { cn } from "@/utils";
@ -11,39 +11,169 @@ interface Props {
interface LocalState { interface LocalState {
columns: number; columns: number;
itemHeights: Map<string, number>;
columnHeights: number[];
distribution: number[][];
}
interface MemoItemProps {
memo: Memo;
renderer: (memo: Memo) => JSX.Element;
onHeightChange: (memoName: string, height: number) => void;
} }
const MINIMUM_MEMO_VIEWPORT_WIDTH = 512; const MINIMUM_MEMO_VIEWPORT_WIDTH = 512;
// Component to wrap each memo and measure its height
const MemoItem = ({ memo, renderer, onHeightChange }: MemoItemProps) => {
const itemRef = useRef<HTMLDivElement>(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
useEffect(() => {
if (!itemRef.current) return;
const measureHeight = () => {
if (itemRef.current) {
const height = itemRef.current.offsetHeight;
onHeightChange(memo.name, height);
}
};
// Initial measurement
measureHeight();
// Set up ResizeObserver for dynamic content changes
resizeObserverRef.current = new ResizeObserver(() => {
measureHeight();
});
resizeObserverRef.current.observe(itemRef.current);
return () => {
if (resizeObserverRef.current) {
resizeObserverRef.current.disconnect();
}
};
}, [memo.name, onHeightChange]);
return <div ref={itemRef}>{renderer(memo)}</div>;
};
// Algorithm to distribute memos into columns based on height
const distributeMemosToColumns = (
memos: Memo[],
columns: number,
itemHeights: Map<string, number>,
prefixElementHeight: number = 0,
): { distribution: number[][]; columnHeights: number[] } => {
if (columns === 1) {
// List mode - all memos in single column
return {
distribution: [Array.from(Array(memos.length).keys())],
columnHeights: [memos.reduce((sum, memo) => sum + (itemHeights.get(memo.name) || 0), prefixElementHeight)],
};
}
const distribution: number[][] = Array.from({ length: columns }, () => []);
const columnHeights: number[] = Array(columns).fill(0);
// Add prefix element height to first column
if (prefixElementHeight > 0) {
columnHeights[0] = prefixElementHeight;
}
// Distribute memos to the shortest column each time
memos.forEach((memo, index) => {
const height = itemHeights.get(memo.name) || 0;
// Find the shortest column
const shortestColumnIndex = columnHeights.reduce(
(minIndex, currentHeight, currentIndex) => (currentHeight < columnHeights[minIndex] ? currentIndex : minIndex),
0,
);
distribution[shortestColumnIndex].push(index);
columnHeights[shortestColumnIndex] += height;
});
return { distribution, columnHeights };
};
const MasonryView = (props: Props) => { const MasonryView = (props: Props) => {
const [state, setState] = useState<LocalState>({ const [state, setState] = useState<LocalState>({
columns: 1, columns: 1,
itemHeights: new Map(),
columnHeights: [0],
distribution: [[]],
}); });
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const prefixElementRef = useRef<HTMLDivElement>(null);
// Handle height changes from individual memo items
const handleHeightChange = useCallback(
(memoName: string, height: number) => {
setState((prevState) => {
const newItemHeights = new Map(prevState.itemHeights);
newItemHeights.set(memoName, height);
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
const { distribution, columnHeights } = distributeMemosToColumns(props.memoList, prevState.columns, newItemHeights, prefixHeight);
return {
...prevState,
itemHeights: newItemHeights,
distribution,
columnHeights,
};
});
},
[props.memoList],
);
// Handle window resize and column count changes
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
if (!containerRef.current) { if (!containerRef.current) {
return; return;
} }
if (props.listMode) {
setState({
columns: 1,
});
return;
}
const containerWidth = containerRef.current.offsetWidth; const newColumns = props.listMode
? 1
: (() => {
const containerWidth = containerRef.current!.offsetWidth;
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH; const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
setState({ return scale >= 2 ? Math.round(scale) : 1;
columns: scale >= 2 ? Math.round(scale) : 1, })();
});
if (newColumns !== state.columns) {
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
const { distribution, columnHeights } = distributeMemosToColumns(props.memoList, newColumns, state.itemHeights, prefixHeight);
setState((prevState) => ({
...prevState,
columns: newColumns,
distribution,
columnHeights,
}));
}
}; };
handleResize(); handleResize();
window.addEventListener("resize", handleResize); window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize); return () => window.removeEventListener("resize", handleResize);
}, [props.listMode]); }, [props.listMode, state.columns, state.itemHeights, props.memoList]);
// Redistribute when memo list changes
useEffect(() => {
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
const { distribution, columnHeights } = distributeMemosToColumns(props.memoList, state.columns, state.itemHeights, prefixHeight);
setState((prevState) => ({
...prevState,
distribution,
columnHeights,
}));
}, [props.memoList, state.columns, state.itemHeights]);
return ( return (
<div <div
@ -55,8 +185,22 @@ const MasonryView = (props: Props) => {
> >
{Array.from({ length: state.columns }).map((_, columnIndex) => ( {Array.from({ length: state.columns }).map((_, columnIndex) => (
<div key={columnIndex} className="min-w-0 mx-auto w-full max-w-2xl"> <div key={columnIndex} className="min-w-0 mx-auto w-full max-w-2xl">
{props.prefixElement && columnIndex === 0 && <div className="mb-2">{props.prefixElement}</div>} {props.prefixElement && columnIndex === 0 && (
{props.memoList.filter((_, index) => index % state.columns === columnIndex).map((memo) => props.renderer(memo))} <div ref={prefixElementRef} className="mb-2">
{props.prefixElement}
</div>
)}
{state.distribution[columnIndex]?.map((memoIndex) => {
const memo = props.memoList[memoIndex];
return memo ? (
<MemoItem
key={`${memo.name}-${memo.displayTime}`}
memo={memo}
renderer={props.renderer}
onHeightChange={handleHeightChange}
/>
) : null;
})}
</div> </div>
))} ))}
</div> </div>

@ -0,0 +1,116 @@
# MasonryView - Height-Based Masonry Layout
## Overview
This improved MasonryView component implements a true masonry layout that distributes memo cards based on their actual rendered heights, creating a balanced waterfall-style layout instead of naive sequential distribution.
## Key Features
### 1. Height Measurement
- **MemoItem Wrapper**: Each memo is wrapped in a `MemoItem` component that measures its actual height
- **ResizeObserver**: Automatically detects height changes when content changes (e.g., images load, content expands)
- **Real-time Updates**: Heights are measured on mount and updated dynamically
### 2. Smart Distribution Algorithm
- **Shortest Column First**: Memos are assigned to the column with the smallest total height
- **Dynamic Balancing**: As new memos are added or heights change, the layout rebalances
- **Prefix Element Support**: Properly accounts for the MemoEditor height in the first column
### 3. Performance Optimizations
- **Memoized Callbacks**: `handleHeightChange` is memoized to prevent unnecessary re-renders
- **Efficient State Updates**: Only redistributes when necessary (memo list changes, column count changes)
- **ResizeObserver Cleanup**: Properly disconnects observers to prevent memory leaks
## Architecture
```
MasonryView
├── State Management
│ ├── columns: number of columns based on viewport width
│ ├── itemHeights: Map<memoName, height> for each memo
│ ├── columnHeights: current total height of each column
│ └── distribution: which memos belong to which column
├── MemoItem (for each memo)
│ ├── Ref for height measurement
│ ├── ResizeObserver for dynamic updates
│ └── Callback to parent on height changes
└── Distribution Algorithm
├── Finds shortest column
├── Assigns memo to that column
└── Updates column height tracking
```
## Usage
The component maintains the same API as before, so no changes are needed in consuming components:
```tsx
<MasonryView memoList={memos} renderer={(memo) => <MemoView memo={memo} />} prefixElement={<MemoEditor />} listMode={false} />
```
## Benefits vs Previous Implementation
### Before (Naive)
- Distributed memos by index: `memo[i % columns]`
- No consideration of actual heights
- Resulted in unbalanced columns
- Static layout that didn't adapt to content
### After (Height-Based)
- Distributes memos by actual rendered height
- Creates balanced columns with similar total heights
- Adapts to dynamic content changes
- Smoother visual layout
## Technical Implementation Details
### Height Measurement
```tsx
const measureHeight = () => {
if (itemRef.current) {
const height = itemRef.current.offsetHeight;
onHeightChange(memo.name, height);
}
};
```
### Distribution Algorithm
```tsx
const shortestColumnIndex = columnHeights.reduce(
(minIndex, currentHeight, currentIndex) => (currentHeight < columnHeights[minIndex] ? currentIndex : minIndex),
0,
);
```
### Dynamic Updates
- **Window Resize**: Recalculates column count and redistributes
- **Content Changes**: ResizeObserver triggers height remeasurement
- **Memo List Changes**: Redistributes all memos with new ordering
## Browser Support
- Modern browsers with ResizeObserver support
- Fallback behavior: Falls back to sequential distribution if ResizeObserver is not available
- CSS Grid support required for column layout
## Performance Considerations
1. **Initial Load**: Slight delay as heights are measured
2. **Memory Usage**: Stores height data for each memo
3. **Re-renders**: Optimized to only update when necessary
4. **Large Lists**: Scales well with proper virtualization (if needed in future)
## Future Enhancements
1. **Virtualization**: For very large memo lists
2. **Animation**: Smooth transitions when items change position
3. **Gap Optimization**: More sophisticated gap handling
4. **Estimated Heights**: Faster initial layout with height estimation

@ -1,7 +1,7 @@
import { Button } from "@usememos/mui"; import { Button } from "@usememos/mui";
import { ArrowUpIcon, LoaderIcon } from "lucide-react"; import { ArrowUpIcon, LoaderIcon } from "lucide-react";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { matchPath } from "react-router-dom"; import { matchPath } from "react-router-dom";
import PullToRefresh from "react-simple-pull-to-refresh"; import PullToRefresh from "react-simple-pull-to-refresh";
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts"; import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
@ -38,6 +38,7 @@ const PagedMemoList = observer((props: Props) => {
isRequesting: true, // Initial request isRequesting: true, // Initial request
nextPageToken: "", nextPageToken: "",
}); });
const checkTimeoutRef = useRef<number | null>(null);
const sortedMemoList = props.listSort ? props.listSort(memoStore.state.memos) : memoStore.state.memos; const sortedMemoList = props.listSort ? props.listSort(memoStore.state.memos) : memoStore.state.memos;
const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname)); const showMemoEditor = Boolean(matchPath(Routes.ROOT, window.location.pathname));
@ -58,6 +59,38 @@ const PagedMemoList = observer((props: Props) => {
})); }));
}; };
// Check if content fills the viewport and fetch more if needed
const checkAndFetchIfNeeded = useCallback(async () => {
// Clear any pending checks
if (checkTimeoutRef.current) {
clearTimeout(checkTimeoutRef.current);
}
// Wait a bit for DOM to update after memo list changes
await new Promise((resolve) => setTimeout(resolve, 200));
// Check if page is scrollable using multiple methods for better reliability
const documentHeight = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight,
);
const windowHeight = window.innerHeight;
const isScrollable = documentHeight > windowHeight + 100; // 100px buffer
// If not scrollable and we have more data to fetch and not currently fetching
if (!isScrollable && state.nextPageToken && !state.isRequesting && sortedMemoList.length > 0) {
await fetchMoreMemos(state.nextPageToken);
// Schedule another check after a delay to prevent rapid successive calls
checkTimeoutRef.current = window.setTimeout(() => {
checkAndFetchIfNeeded();
}, 500);
}
}, [state.nextPageToken, state.isRequesting, sortedMemoList.length]);
const refreshList = async () => { const refreshList = async () => {
memoStore.state.updateStateId(); memoStore.state.updateStateId();
setState((state) => ({ ...state, nextPageToken: "" })); setState((state) => ({ ...state, nextPageToken: "" }));
@ -68,6 +101,22 @@ const PagedMemoList = observer((props: Props) => {
refreshList(); refreshList();
}, [props.owner, props.state, props.direction, props.filter, props.oldFilter, props.pageSize]); }, [props.owner, props.state, props.direction, props.filter, props.oldFilter, props.pageSize]);
// Check if we need to fetch more data when content changes.
useEffect(() => {
if (!state.isRequesting && sortedMemoList.length > 0) {
checkAndFetchIfNeeded();
}
}, [sortedMemoList.length, state.isRequesting, state.nextPageToken, checkAndFetchIfNeeded]);
// Cleanup timeout on unmount.
useEffect(() => {
return () => {
if (checkTimeoutRef.current) {
clearTimeout(checkTimeoutRef.current);
}
};
}, []);
useEffect(() => { useEffect(() => {
if (!state.nextPageToken) return; if (!state.nextPageToken) return;
const handleScroll = () => { const handleScroll = () => {

Loading…
Cancel
Save