|
|
|
@ -9,22 +9,15 @@ interface Props {
|
|
|
|
|
listMode?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Minimum width required to show more than one column
|
|
|
|
|
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);
|
|
|
|
@ -39,41 +32,40 @@ const MemoItem = ({ memo, renderer, onHeightChange }: MemoItemProps) => {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Initial measurement
|
|
|
|
|
measureHeight();
|
|
|
|
|
|
|
|
|
|
// Set up ResizeObserver for dynamic content changes
|
|
|
|
|
resizeObserverRef.current = new ResizeObserver(() => {
|
|
|
|
|
measureHeight();
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Set up ResizeObserver to track dynamic content changes (images, expanded text, etc.)
|
|
|
|
|
resizeObserverRef.current = new ResizeObserver(measureHeight);
|
|
|
|
|
resizeObserverRef.current.observe(itemRef.current);
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
if (resizeObserverRef.current) {
|
|
|
|
|
resizeObserverRef.current.disconnect();
|
|
|
|
|
}
|
|
|
|
|
resizeObserverRef.current?.disconnect();
|
|
|
|
|
};
|
|
|
|
|
}, [memo.name, onHeightChange]);
|
|
|
|
|
|
|
|
|
|
return <div ref={itemRef}>{renderer(memo)}</div>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Algorithm to distribute memos into columns based on height
|
|
|
|
|
/**
|
|
|
|
|
* Algorithm to distribute memos into columns based on height for balanced layout
|
|
|
|
|
* Uses greedy approach: always place next memo in the shortest column
|
|
|
|
|
*/
|
|
|
|
|
const distributeMemosToColumns = (
|
|
|
|
|
memos: Memo[],
|
|
|
|
|
columns: number,
|
|
|
|
|
itemHeights: Map<string, number>,
|
|
|
|
|
prefixElementHeight: number = 0,
|
|
|
|
|
): { distribution: number[][]; columnHeights: number[] } => {
|
|
|
|
|
// List mode: all memos in single column
|
|
|
|
|
if (columns === 1) {
|
|
|
|
|
// List mode - all memos in single column
|
|
|
|
|
const totalHeight = memos.reduce((sum, memo) => sum + (itemHeights.get(memo.name) || 0), prefixElementHeight);
|
|
|
|
|
return {
|
|
|
|
|
distribution: [Array.from(Array(memos.length).keys())],
|
|
|
|
|
columnHeights: [memos.reduce((sum, memo) => sum + (itemHeights.get(memo.name) || 0), prefixElementHeight)],
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
@ -82,15 +74,12 @@ const distributeMemosToColumns = (
|
|
|
|
|
columnHeights[0] = prefixElementHeight;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Distribute memos to the shortest column each time
|
|
|
|
|
// Distribute each memo to the shortest column
|
|
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
// Find column with minimum height
|
|
|
|
|
const shortestColumnIndex = columnHeights.indexOf(Math.min(...columnHeights));
|
|
|
|
|
|
|
|
|
|
distribution[shortestColumnIndex].push(index);
|
|
|
|
|
columnHeights[shortestColumnIndex] += height;
|
|
|
|
@ -100,97 +89,82 @@ const distributeMemosToColumns = (
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const MasonryView = (props: Props) => {
|
|
|
|
|
const [state, setState] = useState<LocalState>({
|
|
|
|
|
columns: 1,
|
|
|
|
|
itemHeights: new Map(),
|
|
|
|
|
columnHeights: [0],
|
|
|
|
|
distribution: [[]],
|
|
|
|
|
});
|
|
|
|
|
const [columns, setColumns] = useState(1);
|
|
|
|
|
const [itemHeights, setItemHeights] = useState<Map<string, number>>(new Map());
|
|
|
|
|
const [distribution, setDistribution] = useState<number[][]>([[]]);
|
|
|
|
|
|
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const prefixElementRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
|
|
// Calculate optimal number of columns based on container width
|
|
|
|
|
const calculateColumns = useCallback(() => {
|
|
|
|
|
if (!containerRef.current || props.listMode) return 1;
|
|
|
|
|
|
|
|
|
|
const containerWidth = containerRef.current.offsetWidth;
|
|
|
|
|
const scale = containerWidth / MINIMUM_MEMO_VIEWPORT_WIDTH;
|
|
|
|
|
return scale >= 2 ? Math.round(scale) : 1;
|
|
|
|
|
}, [props.listMode]);
|
|
|
|
|
|
|
|
|
|
// Recalculate memo distribution when layout changes
|
|
|
|
|
const redistributeMemos = useCallback(() => {
|
|
|
|
|
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
|
|
|
|
|
const { distribution: newDistribution } = distributeMemosToColumns(props.memoList, columns, itemHeights, prefixHeight);
|
|
|
|
|
setDistribution(newDistribution);
|
|
|
|
|
}, [props.memoList, columns, itemHeights]);
|
|
|
|
|
|
|
|
|
|
// Handle height changes from individual memo items
|
|
|
|
|
const handleHeightChange = useCallback(
|
|
|
|
|
(memoName: string, height: number) => {
|
|
|
|
|
setState((prevState) => {
|
|
|
|
|
const newItemHeights = new Map(prevState.itemHeights);
|
|
|
|
|
setItemHeights((prevHeights) => {
|
|
|
|
|
const newItemHeights = new Map(prevHeights);
|
|
|
|
|
newItemHeights.set(memoName, height);
|
|
|
|
|
|
|
|
|
|
// Recalculate distribution with new heights
|
|
|
|
|
const prefixHeight = prefixElementRef.current?.offsetHeight || 0;
|
|
|
|
|
const { distribution, columnHeights } = distributeMemosToColumns(props.memoList, prevState.columns, newItemHeights, prefixHeight);
|
|
|
|
|
const { distribution: newDistribution } = distributeMemosToColumns(props.memoList, columns, newItemHeights, prefixHeight);
|
|
|
|
|
setDistribution(newDistribution);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
...prevState,
|
|
|
|
|
itemHeights: newItemHeights,
|
|
|
|
|
distribution,
|
|
|
|
|
columnHeights,
|
|
|
|
|
};
|
|
|
|
|
return newItemHeights;
|
|
|
|
|
});
|
|
|
|
|
},
|
|
|
|
|
[props.memoList],
|
|
|
|
|
[props.memoList, columns],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Handle window resize and column count changes
|
|
|
|
|
// Handle window resize and calculate new column count
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
const handleResize = () => {
|
|
|
|
|
if (!containerRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (!containerRef.current) return;
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}));
|
|
|
|
|
const newColumns = calculateColumns();
|
|
|
|
|
if (newColumns !== columns) {
|
|
|
|
|
setColumns(newColumns);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
handleResize();
|
|
|
|
|
window.addEventListener("resize", handleResize);
|
|
|
|
|
return () => window.removeEventListener("resize", handleResize);
|
|
|
|
|
}, [props.listMode, state.columns, state.itemHeights, props.memoList]);
|
|
|
|
|
}, [calculateColumns, columns]);
|
|
|
|
|
|
|
|
|
|
// Redistribute when memo list changes
|
|
|
|
|
// Redistribute memos when columns, memo list, or heights change
|
|
|
|
|
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]);
|
|
|
|
|
redistributeMemos();
|
|
|
|
|
}, [redistributeMemos]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
ref={containerRef}
|
|
|
|
|
className={cn("w-full grid gap-2")}
|
|
|
|
|
style={{
|
|
|
|
|
gridTemplateColumns: `repeat(${state.columns}, 1fr)`,
|
|
|
|
|
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{Array.from({ length: state.columns }).map((_, columnIndex) => (
|
|
|
|
|
{Array.from({ length: columns }).map((_, columnIndex) => (
|
|
|
|
|
<div key={columnIndex} className="min-w-0 mx-auto w-full max-w-2xl">
|
|
|
|
|
{props.prefixElement && columnIndex === 0 && (
|
|
|
|
|
<div ref={prefixElementRef} className="mb-2">
|
|
|
|
|
{props.prefixElement}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{state.distribution[columnIndex]?.map((memoIndex) => {
|
|
|
|
|
{/* Prefix element (like memo editor) goes in first column */}
|
|
|
|
|
{props.prefixElement && columnIndex === 0 && <div ref={prefixElementRef}>{props.prefixElement}</div>}
|
|
|
|
|
|
|
|
|
|
{distribution[columnIndex]?.map((memoIndex) => {
|
|
|
|
|
const memo = props.memoList[memoIndex];
|
|
|
|
|
return memo ? (
|
|
|
|
|
<MemoItem
|
|
|
|
|