diff --git a/web/src/components/MasonryView/MasonryColumn.tsx b/web/src/components/MasonryView/MasonryColumn.tsx
new file mode 100644
index 000000000..74b8d6ce1
--- /dev/null
+++ b/web/src/components/MasonryView/MasonryColumn.tsx
@@ -0,0 +1,43 @@
+import { MasonryItem } from "./MasonryItem";
+import { MasonryColumnProps } from "./types";
+
+/**
+ * Column component for masonry layout
+ *
+ * Responsibilities:
+ * - Render a single column in the masonry grid
+ * - Display prefix element in the first column (e.g., memo editor)
+ * - Render all assigned memo items in order
+ * - Pass render context to items (includes compact mode flag)
+ */
+export function MasonryColumn({
+ memoIndices,
+ memoList,
+ renderer,
+ renderContext,
+ onHeightChange,
+ isFirstColumn,
+ prefixElement,
+ prefixElementRef,
+}: MasonryColumnProps) {
+ return (
+
+ {/* Prefix element (like memo editor) goes in first column */}
+ {isFirstColumn && prefixElement &&
{prefixElement}
}
+
+ {/* Render all memos assigned to this column */}
+ {memoIndices?.map((memoIndex) => {
+ const memo = memoList[memoIndex];
+ return memo ? (
+
+ ) : null;
+ })}
+
+ );
+}
diff --git a/web/src/components/MasonryView/MasonryItem.tsx b/web/src/components/MasonryView/MasonryItem.tsx
new file mode 100644
index 000000000..110461cf4
--- /dev/null
+++ b/web/src/components/MasonryView/MasonryItem.tsx
@@ -0,0 +1,45 @@
+import { useEffect, useRef } from "react";
+import { MasonryItemProps } from "./types";
+
+/**
+ * Individual item wrapper component for masonry layout
+ *
+ * Responsibilities:
+ * - Render the memo using the provided renderer with context
+ * - Measure its own height using ResizeObserver
+ * - Report height changes to parent for redistribution
+ *
+ * The ResizeObserver automatically tracks dynamic content changes such as:
+ * - Images loading
+ * - Expanded/collapsed text
+ * - Any other content size changes
+ */
+export function MasonryItem({ memo, renderer, renderContext, onHeightChange }: MasonryItemProps) {
+ 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 to track dynamic content changes
+ resizeObserverRef.current = new ResizeObserver(measureHeight);
+ resizeObserverRef.current.observe(itemRef.current);
+
+ // Cleanup on unmount
+ return () => {
+ resizeObserverRef.current?.disconnect();
+ };
+ }, [memo.name, onHeightChange]);
+
+ return {renderer(memo, renderContext)}
;
+}
diff --git a/web/src/components/MasonryView/MasonryView.tsx b/web/src/components/MasonryView/MasonryView.tsx
index aca8369d5..249f8cb90 100644
--- a/web/src/components/MasonryView/MasonryView.tsx
+++ b/web/src/components/MasonryView/MasonryView.tsx
@@ -1,156 +1,42 @@
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useMemo, useRef } from "react";
import { cn } from "@/lib/utils";
-import { Memo } from "@/types/proto/api/v1/memo_service";
-
-interface Props {
- memoList: Memo[];
- renderer: (memo: Memo) => JSX.Element;
- prefixElement?: JSX.Element;
- listMode?: boolean;
-}
-
-interface MemoItemProps {
- memo: Memo;
- renderer: (memo: Memo) => JSX.Element;
- onHeightChange: (memoName: string, height: number) => void;
-}
-
-// Minimum width required to show more than one column
-const MINIMUM_MEMO_VIEWPORT_WIDTH = 512;
-
-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);
- }
- };
-
- measureHeight();
-
- // Set up ResizeObserver to track dynamic content changes (images, expanded text, etc.)
- resizeObserverRef.current = new ResizeObserver(measureHeight);
- resizeObserverRef.current.observe(itemRef.current);
-
- return () => {
- resizeObserverRef.current?.disconnect();
- };
- }, [memo.name, onHeightChange]);
-
- return {renderer(memo)}
;
-};
+import { MasonryColumn } from "./MasonryColumn";
+import { MasonryViewProps, MemoRenderContext } from "./types";
+import { useMasonryLayout } from "./useMasonryLayout";
/**
- * Algorithm to distribute memos into columns based on height for balanced layout
- * Uses greedy approach: always place next memo in the shortest column
+ * Masonry layout component for displaying memos in a balanced, multi-column grid
+ *
+ * Features:
+ * - Responsive column count based on viewport width
+ * - Longest Processing-Time First (LPT) algorithm for optimal distribution
+ * - Pins editor and first memo to first column for stability
+ * - Debounced redistribution for performance
+ * - Automatic height tracking with ResizeObserver
+ * - Auto-enables compact mode in multi-column layouts
+ *
+ * The layout automatically adjusts to:
+ * - Window resizing
+ * - Content changes (images loading, text expansion)
+ * - Dynamic memo additions/removals
+ *
+ * Algorithm guarantee: Layout is never more than 34% longer than optimal (proven)
*/
-const distributeMemosToColumns = (
- memos: Memo[],
- columns: number,
- itemHeights: Map,
- prefixElementHeight: number = 0,
-): { distribution: number[][]; columnHeights: number[] } => {
- // List mode: all memos in single column
- if (columns === 1) {
- const totalHeight = memos.reduce((sum, memo) => sum + (itemHeights.get(memo.name) || 0), prefixElementHeight);
- return {
- distribution: [Array.from({ length: memos.length }, (_, i) => i)],
- columnHeights: [totalHeight],
- };
- }
-
- // Initialize columns and heights
- 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 each memo to the shortest column
- memos.forEach((memo, index) => {
- const height = itemHeights.get(memo.name) || 0;
-
- // Find column with minimum height
- const shortestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
-
- distribution[shortestColumnIndex].push(index);
- columnHeights[shortestColumnIndex] += height;
- });
-
- return { distribution, columnHeights };
-};
-
-const MasonryView = (props: Props) => {
- const [columns, setColumns] = useState(1);
- const [itemHeights, setItemHeights] = useState