mirror of
https://github.com/usememos/memos.git
synced 2025-06-05 22:09:59 +02:00
feat: implement height-based masonry view
This commit is contained in:
@@ -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 { cn } from "@/utils";
|
||||
|
||||
@@ -11,39 +11,169 @@ interface Props {
|
||||
|
||||
interface LocalState {
|
||||
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;
|
||||
|
||||
// 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 [state, setState] = useState<LocalState>({
|
||||
columns: 1,
|
||||
itemHeights: new Map(),
|
||||
columnHeights: [0],
|
||||
distribution: [[]],
|
||||
});
|
||||
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(() => {
|
||||
const handleResize = () => {
|
||||
if (!containerRef.current) {
|
||||
return;
|
||||
}
|
||||
if (props.listMode) {
|
||||
setState({
|
||||
columns: 1,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const containerWidth = containerRef.current.offsetWidth;
|
||||
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
|
||||
setState({
|
||||
columns: scale >= 2 ? Math.round(scale) : 1,
|
||||
});
|
||||
const newColumns = props.listMode
|
||||
? 1
|
||||
: (() => {
|
||||
const containerWidth = containerRef.current!.offsetWidth;
|
||||
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
|
||||
return 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();
|
||||
window.addEventListener("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 (
|
||||
<div
|
||||
@@ -55,8 +185,22 @@ const MasonryView = (props: Props) => {
|
||||
>
|
||||
{Array.from({ length: state.columns }).map((_, columnIndex) => (
|
||||
<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.memoList.filter((_, index) => index % state.columns === columnIndex).map((memo) => props.renderer(memo))}
|
||||
{props.prefixElement && columnIndex === 0 && (
|
||||
<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>
|
||||
|
116
web/src/components/MasonryView/README.md
Normal file
116
web/src/components/MasonryView/README.md
Normal file
@@ -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 { ArrowUpIcon, LoaderIcon } from "lucide-react";
|
||||
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 PullToRefresh from "react-simple-pull-to-refresh";
|
||||
import { DEFAULT_LIST_MEMOS_PAGE_SIZE } from "@/helpers/consts";
|
||||
@@ -38,6 +38,7 @@ const PagedMemoList = observer((props: Props) => {
|
||||
isRequesting: true, // Initial request
|
||||
nextPageToken: "",
|
||||
});
|
||||
const checkTimeoutRef = useRef<number | null>(null);
|
||||
const sortedMemoList = props.listSort ? props.listSort(memoStore.state.memos) : memoStore.state.memos;
|
||||
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 () => {
|
||||
memoStore.state.updateStateId();
|
||||
setState((state) => ({ ...state, nextPageToken: "" }));
|
||||
@@ -68,6 +101,22 @@ const PagedMemoList = observer((props: Props) => {
|
||||
refreshList();
|
||||
}, [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(() => {
|
||||
if (!state.nextPageToken) return;
|
||||
const handleScroll = () => {
|
||||
|
Reference in New Issue
Block a user