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 { 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>
|
||||||
|
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 { 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 = () => {
|
||||||
|
Reference in New Issue
Block a user