diff --git a/web/src/components/MasonryView/MasonryView.tsx b/web/src/components/MasonryView/MasonryView.tsx index a579f5c6a..d6fc532dc 100644 --- a/web/src/components/MasonryView/MasonryView.tsx +++ b/web/src/components/MasonryView/MasonryView.tsx @@ -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; + 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(null); + const resizeObserverRef = useRef(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
{renderer(memo)}
; +}; + +// Algorithm to distribute memos into columns based on height +const distributeMemosToColumns = ( + memos: Memo[], + columns: number, + itemHeights: Map, + 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({ columns: 1, + itemHeights: new Map(), + columnHeights: [0], + distribution: [[]], }); const containerRef = useRef(null); + const prefixElementRef = useRef(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 (
{ > {Array.from({ length: state.columns }).map((_, columnIndex) => (
- {props.prefixElement && columnIndex === 0 &&
{props.prefixElement}
} - {props.memoList.filter((_, index) => index % state.columns === columnIndex).map((memo) => props.renderer(memo))} + {props.prefixElement && columnIndex === 0 && ( +
+ {props.prefixElement} +
+ )} + {state.distribution[columnIndex]?.map((memoIndex) => { + const memo = props.memoList[memoIndex]; + return memo ? ( + + ) : null; + })}
))}
diff --git a/web/src/components/MasonryView/README.md b/web/src/components/MasonryView/README.md new file mode 100644 index 000000000..c9b4673dd --- /dev/null +++ b/web/src/components/MasonryView/README.md @@ -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 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 + } prefixElement={} 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 diff --git a/web/src/components/PagedMemoList/PagedMemoList.tsx b/web/src/components/PagedMemoList/PagedMemoList.tsx index cac610b27..a3dade7d1 100644 --- a/web/src/components/PagedMemoList/PagedMemoList.tsx +++ b/web/src/components/PagedMemoList/PagedMemoList.tsx @@ -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(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 = () => {