"use client"; import * as React from "react"; import { Slot } from "@radix-ui/react-slot"; import { useComposedRefs } from "@djangocfg/ui-core/lib"; import { cn } from "@djangocfg/ui-core/lib"; import type { MasonryProps, MasonryItemProps, Positioner, PositionerItem } from "../types"; // ============================================================================ // Interval Tree (red-black tree for efficient range queries) // ============================================================================ const NODE_COLOR = { RED: 0, BLACK: 1, SENTINEL: 2 } as const; type NodeColor = (typeof NODE_COLOR)[keyof typeof NODE_COLOR]; const NODE_OPERATION = { REMOVE: 0, PRESERVE: 1 } as const; type NodeOperation = (typeof NODE_OPERATION)[keyof typeof NODE_OPERATION]; interface ListNode { index: number; high: number; next: ListNode | null; } interface TreeNode { max: number; low: number; high: number; color: NodeColor; parent: TreeNode; right: TreeNode; left: TreeNode; list: ListNode; } interface Tree { root: TreeNode; size: number; } function addInterval(treeNode: TreeNode, high: number, index: number): boolean { let node: ListNode | null = treeNode.list; let prevNode: ListNode | undefined; while (node) { if (node.index === index) return false; if (high > node.high) break; prevNode = node; node = node.next; } if (!prevNode) treeNode.list = { index, high, next: node }; if (prevNode) prevNode.next = { index, high, next: prevNode.next }; return true; } function removeInterval(treeNode: TreeNode, index: number): NodeOperation | undefined { let node: ListNode | null = treeNode.list; if (node.index === index) { if (node.next === null) return NODE_OPERATION.REMOVE; treeNode.list = node.next; return NODE_OPERATION.PRESERVE; } let prevNode: ListNode | undefined = node; node = node.next; while (node !== null) { if (node.index === index) { prevNode.next = node.next; return NODE_OPERATION.PRESERVE; } prevNode = node; node = node.next; } } const SENTINEL_NODE: TreeNode = { low: 0, max: 0, high: 0, color: NODE_COLOR.SENTINEL, parent: undefined as unknown as TreeNode, right: undefined as unknown as TreeNode, left: undefined as unknown as TreeNode, list: undefined as unknown as ListNode, }; SENTINEL_NODE.parent = SENTINEL_NODE; SENTINEL_NODE.left = SENTINEL_NODE; SENTINEL_NODE.right = SENTINEL_NODE; function updateMax(node: TreeNode) { const max = node.high; if (node.left === SENTINEL_NODE && node.right === SENTINEL_NODE) node.max = max; else if (node.left === SENTINEL_NODE) node.max = Math.max(node.right.max, max); else if (node.right === SENTINEL_NODE) node.max = Math.max(node.left.max, max); else node.max = Math.max(Math.max(node.left.max, node.right.max), max); } function updateMaxUp(node: TreeNode) { let x = node; while (x.parent !== SENTINEL_NODE) { updateMax(x.parent); x = x.parent; } } function rotateLeft(tree: Tree, x: TreeNode) { if (x.right === SENTINEL_NODE) return; const y = x.right; x.right = y.left; if (y.left !== SENTINEL_NODE) y.left.parent = x; y.parent = x.parent; if (x.parent === SENTINEL_NODE) tree.root = y; else if (x === x.parent.left) x.parent.left = y; else x.parent.right = y; y.left = x; x.parent = y; updateMax(x); updateMax(y); } function rotateRight(tree: Tree, x: TreeNode) { if (x.left === SENTINEL_NODE) return; const y = x.left; x.left = y.right; if (y.right !== SENTINEL_NODE) y.right.parent = x; y.parent = x.parent; if (x.parent === SENTINEL_NODE) tree.root = y; else if (x === x.parent.right) x.parent.right = y; else x.parent.left = y; y.right = x; x.parent = y; updateMax(x); updateMax(y); } function replaceNode(tree: Tree, x: TreeNode, y: TreeNode) { if (x.parent === SENTINEL_NODE) tree.root = y; else if (x === x.parent.left) x.parent.left = y; else x.parent.right = y; y.parent = x.parent; } function fixRemove(tree: Tree, node: TreeNode) { let x = node; let w: TreeNode; while (x !== SENTINEL_NODE && x.color === NODE_COLOR.BLACK) { if (x === x.parent.left) { w = x.parent.right; if (w.color === NODE_COLOR.RED) { w.color = NODE_COLOR.BLACK; x.parent.color = NODE_COLOR.RED; rotateLeft(tree, x.parent); w = x.parent.right; } if (w.left.color === NODE_COLOR.BLACK && w.right.color === NODE_COLOR.BLACK) { w.color = NODE_COLOR.RED; x = x.parent; } else { if (w.right.color === NODE_COLOR.BLACK) { w.left.color = NODE_COLOR.BLACK; w.color = NODE_COLOR.RED; rotateRight(tree, w); w = x.parent.right; } w.color = x.parent.color; x.parent.color = NODE_COLOR.BLACK; w.right.color = NODE_COLOR.BLACK; rotateLeft(tree, x.parent); x = tree.root; } } else { w = x.parent.left; if (w.color === NODE_COLOR.RED) { w.color = NODE_COLOR.BLACK; x.parent.color = NODE_COLOR.RED; rotateRight(tree, x.parent); w = x.parent.left; } if (w.right.color === NODE_COLOR.BLACK && w.left.color === NODE_COLOR.BLACK) { w.color = NODE_COLOR.RED; x = x.parent; } else { if (w.left.color === NODE_COLOR.BLACK) { w.right.color = NODE_COLOR.BLACK; w.color = NODE_COLOR.RED; rotateLeft(tree, w); w = x.parent.left; } w.color = x.parent.color; x.parent.color = NODE_COLOR.BLACK; w.left.color = NODE_COLOR.BLACK; rotateRight(tree, x.parent); x = tree.root; } } } x.color = NODE_COLOR.BLACK; } function minimumTree(node: TreeNode) { let current = node; while (current.left !== SENTINEL_NODE) current = current.left; return current; } function fixInsert(tree: Tree, node: TreeNode) { let current = node; let y: TreeNode; while (current.parent.color === NODE_COLOR.RED) { if (current.parent === current.parent.parent.left) { y = current.parent.parent.right; if (y.color === NODE_COLOR.RED) { current.parent.color = NODE_COLOR.BLACK; y.color = NODE_COLOR.BLACK; current.parent.parent.color = NODE_COLOR.RED; current = current.parent.parent; } else { if (current === current.parent.right) { current = current.parent; rotateLeft(tree, current); } current.parent.color = NODE_COLOR.BLACK; current.parent.parent.color = NODE_COLOR.RED; rotateRight(tree, current.parent.parent); } } else { y = current.parent.parent.left; if (y.color === NODE_COLOR.RED) { current.parent.color = NODE_COLOR.BLACK; y.color = NODE_COLOR.BLACK; current.parent.parent.color = NODE_COLOR.RED; current = current.parent.parent; } else { if (current === current.parent.left) { current = current.parent; rotateRight(tree, current); } current.parent.color = NODE_COLOR.BLACK; current.parent.parent.color = NODE_COLOR.RED; rotateLeft(tree, current.parent.parent); } } } tree.root.color = NODE_COLOR.BLACK; } interface IntervalTree { insert(low: number, high: number, index: number): void; remove(index: number): void; search(low: number, high: number, onCallback: (index: number, low: number) => void): void; size: number; } function createIntervalTree(): IntervalTree { const tree: Tree = { root: SENTINEL_NODE, size: 0 }; const indexMap: Record = {}; return { insert(low, high, index) { let x: TreeNode = tree.root; let y: TreeNode = SENTINEL_NODE; while (x !== SENTINEL_NODE) { y = x; if (low === y.low) break; if (low < x.low) x = x.left; else x = x.right; } if (low === y.low && y !== SENTINEL_NODE) { if (!addInterval(y, high, index)) return; y.high = Math.max(y.high, high); updateMax(y); updateMaxUp(y); indexMap[index] = y; tree.size++; return; } const z: TreeNode = { low, high, max: high, color: NODE_COLOR.RED, parent: y, left: SENTINEL_NODE, right: SENTINEL_NODE, list: { index, high, next: null }, }; if (y === SENTINEL_NODE) { tree.root = z; } else { if (z.low < y.low) y.left = z; else y.right = z; updateMaxUp(z); } fixInsert(tree, z); indexMap[index] = z; tree.size++; }, remove(index) { const z = indexMap[index]; if (z === void 0) return; delete indexMap[index]; const intervalResult = removeInterval(z, index); if (intervalResult === void 0) return; if (intervalResult === NODE_OPERATION.PRESERVE) { z.high = z.list.high; updateMax(z); updateMaxUp(z); tree.size--; return; } let y = z; let originalYColor = y.color; let x: TreeNode; if (z.left === SENTINEL_NODE) { x = z.right; replaceNode(tree, z, z.right); } else if (z.right === SENTINEL_NODE) { x = z.left; replaceNode(tree, z, z.left); } else { y = minimumTree(z.right); originalYColor = y.color; x = y.right; if (y.parent === z) { x.parent = y; } else { replaceNode(tree, y, y.right); y.right = z.right; y.right.parent = y; } replaceNode(tree, z, y); y.left = z.left; y.left.parent = y; y.color = z.color; } updateMax(x); updateMaxUp(x); if (originalYColor === NODE_COLOR.BLACK) fixRemove(tree, x); tree.size--; }, search(low, high, onCallback) { const stack = [tree.root]; while (stack.length !== 0) { const node = stack.pop(); if (!node) continue; if (node === SENTINEL_NODE || low > node.max) continue; if (node.left !== SENTINEL_NODE) stack.push(node.left); if (node.right !== SENTINEL_NODE) stack.push(node.right); if (node.low <= high && node.high >= low) { let curr: ListNode | null = node.list; while (curr !== null) { if (curr.high >= low) onCallback(curr.index, node.low); curr = curr.next; } } } }, get size() { return tree.size; }, }; } // ============================================================================ // Positioner // ============================================================================ const COLUMN_WIDTH = 200; const GAP = 0; const ITEM_HEIGHT = 300; interface UsePositionerOptions { width: number; columnWidth?: number; columnGap?: number; rowGap?: number; columnCount?: number; maxColumnCount?: number; linear?: boolean; } function usePositioner( { width, columnWidth = COLUMN_WIDTH, columnGap = GAP, rowGap, columnCount, maxColumnCount, linear = false }: UsePositionerOptions, deps: React.DependencyList = [], ): Positioner { const initPositioner = React.useCallback((): Positioner => { function binarySearch(a: number[], y: number): number { let l = 0; let h = a.length - 1; while (l <= h) { const m = (l + h) >>> 1; const x = a[m]; if (x === y) return m; if (x === undefined || x <= y) l = m + 1; else h = m - 1; } return -1; } const computedColumnCount = columnCount || Math.min( Math.floor((width + columnGap) / (columnWidth + columnGap)), maxColumnCount || Number.POSITIVE_INFINITY, ) || 1; const computedColumnWidth = Math.floor( (width - columnGap * (computedColumnCount - 1)) / computedColumnCount, ); const intervalTree = createIntervalTree(); const columnHeights: number[] = new Array(computedColumnCount).fill(0); const items: (PositionerItem | undefined)[] = []; const columnItems: number[][] = new Array(computedColumnCount).fill(0).map(() => []); for (let i = 0; i < computedColumnCount; i++) { columnHeights[i] = 0; columnItems[i] = []; } return { columnCount: computedColumnCount, columnWidth: computedColumnWidth, set: (index: number, height = 0) => { let columnIndex = 0; if (linear) { const preferredColumn = index % computedColumnCount; let shortestHeight = columnHeights[0] ?? 0; let tallestHeight = shortestHeight; let shortestIndex = 0; for (let i = 0; i < columnHeights.length; i++) { const currentHeight = columnHeights[i] ?? 0; if (currentHeight < shortestHeight) { shortestHeight = currentHeight; shortestIndex = i; } if (currentHeight > tallestHeight) tallestHeight = currentHeight; } const preferredHeight = (columnHeights[preferredColumn] ?? 0) + height; const maxAllowedHeight = shortestHeight + height * 2.5; columnIndex = preferredHeight <= maxAllowedHeight ? preferredColumn : shortestIndex; } else { for (let i = 1; i < columnHeights.length; i++) { const currentHeight = columnHeights[i]; const shortestHeight = columnHeights[columnIndex]; if (currentHeight !== undefined && shortestHeight !== undefined && currentHeight < shortestHeight) { columnIndex = i; } } } const columnHeight = columnHeights[columnIndex]; if (columnHeight === undefined) return; const top = columnHeight; columnHeights[columnIndex] = top + height + (rowGap ?? columnGap); const columnItemsList = columnItems[columnIndex]; if (!columnItemsList) return; columnItemsList.push(index); items[index] = { left: columnIndex * (computedColumnWidth + columnGap), top, height, columnIndex, }; intervalTree.insert(top, top + height, index); }, get: (index: number) => items[index], update: (updates: number[]) => { const columns: (number | undefined)[] = new Array(computedColumnCount); let i = 0; let j = 0; for (; i < updates.length - 1; i++) { const currentIndex = updates[i]; if (typeof currentIndex !== "number") continue; const item = items[currentIndex]; if (!item) continue; const nextHeight = updates[++i]; if (typeof nextHeight !== "number") continue; item.height = nextHeight; intervalTree.remove(currentIndex); intervalTree.insert(item.top, item.top + item.height, currentIndex); columns[item.columnIndex] = columns[item.columnIndex] === void 0 ? currentIndex : Math.min(currentIndex, columns[item.columnIndex] ?? currentIndex); } for (i = 0; i < columns.length; i++) { const currentColumn = columns[i]; if (currentColumn === void 0) continue; const itemsInColumn = columnItems[i]; if (!itemsInColumn) continue; const startIndex = binarySearch(itemsInColumn, currentColumn); if (startIndex === -1) continue; const currentItemIndex = itemsInColumn[startIndex]; if (typeof currentItemIndex !== "number") continue; const startItem = items[currentItemIndex]; if (!startItem) continue; const currentHeight = columnHeights[i]; if (typeof currentHeight !== "number") continue; columnHeights[i] = startItem.top + startItem.height + (rowGap ?? columnGap); for (j = startIndex + 1; j < itemsInColumn.length; j++) { const currentIndex = itemsInColumn[j]; if (typeof currentIndex !== "number") continue; const item = items[currentIndex]; if (!item) continue; const columnHeight = columnHeights[i]; if (typeof columnHeight !== "number") continue; item.top = columnHeight; columnHeights[i] = item.top + item.height + (rowGap ?? columnGap); intervalTree.remove(currentIndex); intervalTree.insert(item.top, item.top + item.height, currentIndex); } } }, range: (low, high, onItemRender) => intervalTree.search(low, high, (index: number, top: number) => { const item = items[index]; if (!item) return; onItemRender(index, item.left, top); }), estimateHeight: (itemCount, defaultItemHeight): number => { const tallestColumn = Math.max(0, Math.max.apply(null, columnHeights)); return itemCount === intervalTree.size ? tallestColumn : tallestColumn + Math.ceil((itemCount - intervalTree.size) / computedColumnCount) * defaultItemHeight; }, shortestColumn: () => { if (columnHeights.length > 1) return Math.min.apply(null, columnHeights); return columnHeights[0] ?? 0; }, size(): number { return intervalTree.size; }, all(): PositionerItem[] { return items.filter(Boolean) as PositionerItem[]; }, }; }, [width, columnWidth, columnGap, rowGap, columnCount, maxColumnCount, linear]); const positionerRef = React.useRef(null); if (positionerRef.current === null) positionerRef.current = initPositioner(); const prevDepsRef = React.useRef(deps); const opts = [width, columnWidth, columnGap, rowGap, columnCount, maxColumnCount, linear]; const prevOptsRef = React.useRef(opts); const optsChanged = !opts.every((item, i) => prevOptsRef.current[i] === item); if (optsChanged || !deps.every((item, i) => prevDepsRef.current[i] === item)) { const prevPositioner = positionerRef.current; const positioner = initPositioner(); prevDepsRef.current = deps; prevOptsRef.current = opts; if (optsChanged) { const cacheSize = prevPositioner.size(); for (let index = 0; index < cacheSize; index++) { const pos = prevPositioner.get(index); positioner.set(index, pos !== void 0 ? pos.height : 0); } } positionerRef.current = positioner; } return positionerRef.current; } // ============================================================================ // Hooks // ============================================================================ const DEBOUNCE_DELAY = 300; const SCROLL_FPS = 12; interface DebouncedWindowSizeOptions { containerRef: React.RefObject; defaultWidth?: number; defaultHeight?: number; delayMs?: number; } function useDebouncedWindowSize(options: DebouncedWindowSizeOptions) { const { containerRef, defaultWidth = 0, defaultHeight = 0, delayMs = DEBOUNCE_DELAY } = options; const getDocumentSize = React.useCallback(() => { if (typeof document === "undefined") { return { width: defaultWidth, height: defaultHeight }; } return { width: document.documentElement.clientWidth, height: document.documentElement.clientHeight, }; }, [defaultWidth, defaultHeight]); const [size, setSize] = React.useState(getDocumentSize()); const timeoutRef = React.useRef(null); const setDebouncedSize = React.useCallback( (value: { width: number; height: number }) => { if (timeoutRef.current) clearTimeout(timeoutRef.current); timeoutRef.current = setTimeout(() => { setSize(value); }, delayMs); }, [delayMs], ); React.useEffect(() => { function onResize() { if (containerRef.current) { setDebouncedSize({ width: containerRef.current.offsetWidth, height: document.documentElement.clientHeight, }); } else { setDebouncedSize(getDocumentSize()); } } window?.addEventListener("resize", onResize, { passive: true }); window?.addEventListener("orientationchange", onResize); window.visualViewport?.addEventListener("resize", onResize); return () => { window?.removeEventListener("resize", onResize); window?.removeEventListener("orientationchange", onResize); window.visualViewport?.removeEventListener("resize", onResize); if (timeoutRef.current) clearTimeout(timeoutRef.current); }; }, [setDebouncedSize, containerRef, getDocumentSize]); return size; } function useLazyRef(init: () => T): React.RefObject { const ref = React.useRef(null); if (ref.current === null) { ref.current = init(); } return ref as React.RefObject; } type OnRafScheduleReturn = { (...args: T): void; cancel: () => void }; function onRafSchedule(callback: (...args: T) => void): OnRafScheduleReturn { let lastArgs: T = [] as unknown as T; let frameId: number | null = null; function onCallback(...args: T) { lastArgs = args; if (frameId === null) { frameId = requestAnimationFrame(() => { frameId = null; callback(...lastArgs); }); } } onCallback.cancel = () => { if (frameId === null) return; cancelAnimationFrame(frameId); frameId = null; }; return onCallback; } function useResizeObserver(positioner: Positioner) { const [, setLayoutVersion] = React.useState(0); const createResizeObserver = React.useMemo(() => { if (typeof window === "undefined") { return () => ({ disconnect: () => {}, observe: () => {}, unobserve: () => {} }); } const cache = new WeakMap(); return (positioner: Positioner, onUpdate: () => void) => { const key = { positioner, onUpdate }; if (cache.has(key)) return cache.get(key)!; const updates: number[] = []; const itemMap = new WeakMap(); const update = onRafSchedule(() => { if (updates.length > 0) { positioner.update(updates); onUpdate(); } updates.length = 0; }); function onItemResize(target: HTMLDivElement) { const height = target.offsetHeight; if (height > 0) { const index = itemMap.get(target); if (index !== void 0) { const pos = positioner.get(index); if (pos !== void 0 && height !== pos.height) { updates.push(index, height); } } } update(); } const scheduledItemMap = new Map>(); function onResizeObserver(entries: ResizeObserverEntry[]) { for (const entry of entries) { if (!entry) continue; const index = itemMap.get(entry.target); if (index === void 0) continue; let handler = scheduledItemMap.get(index); if (!handler) { handler = onRafSchedule(onItemResize); scheduledItemMap.set(index, handler); } handler(entry.target as HTMLDivElement); } } const observer = new ResizeObserver(onResizeObserver); const disconnect = observer.disconnect.bind(observer); observer.disconnect = () => { disconnect(); for (const [, scheduleItem] of scheduledItemMap) { scheduleItem.cancel(); } }; cache.set(key, observer); return observer; }; }, []); const resizeObserver = createResizeObserver(positioner, () => setLayoutVersion((prev) => prev + 1), ); React.useEffect(() => () => resizeObserver.disconnect(), [resizeObserver]); return resizeObserver; } function useThrottle( initialState: State | (() => State), options: { fps?: number; leading?: boolean } = {}, ): [State, React.Dispatch>] { const { fps = 30, leading = false } = options; const [state, setState] = React.useState(initialState); const latestSetState = React.useRef(setState); latestSetState.current = setState; const ms = 1000 / fps; const prevCountRef = React.useRef(0); const trailingTimeout = React.useRef | null>(null); const clearTrailing = React.useCallback(() => { if (trailingTimeout.current) clearTimeout(trailingTimeout.current); }, []); React.useEffect(() => { return () => { prevCountRef.current = 0; clearTrailing(); }; }, [clearTrailing]); const throttledSetState = React.useCallback( (action: React.SetStateAction) => { const perf = typeof performance !== "undefined" ? performance : Date; const now = () => perf.now(); const rightNow = now(); const call = () => { prevCountRef.current = rightNow; clearTrailing(); latestSetState.current(action); }; const current = prevCountRef.current; if (leading && current === 0) return call(); if (rightNow - current > ms) { if (current > 0) return call(); prevCountRef.current = rightNow; } clearTrailing(); trailingTimeout.current = setTimeout(() => { call(); prevCountRef.current = 0; }, ms); }, [leading, ms, clearTrailing], ); return [state, throttledSetState]; } function useScroller({ offset = 0, fps = SCROLL_FPS, }: { offset?: number; fps?: number } = {}): { scrollTop: number; isScrolling: boolean } { const [scrollY, setScrollY] = useThrottle( typeof globalThis.window === "undefined" ? 0 : (globalThis.window.scrollY ?? document.documentElement.scrollTop ?? 0), { fps, leading: true }, ); const onScroll = React.useCallback(() => { setScrollY(globalThis.window.scrollY ?? document.documentElement.scrollTop ?? 0); }, [setScrollY]); React.useEffect(() => { if (typeof globalThis.window === "undefined") return; globalThis.window.addEventListener("scroll", onScroll, { passive: true }); return () => globalThis.window.removeEventListener("scroll", onScroll); }, [onScroll]); const [isScrolling, setIsScrolling] = React.useState(false); const hasMountedRef = React.useRef(0); React.useEffect(() => { if (hasMountedRef.current === 1) setIsScrolling(true); let didUnsubscribe = false; function requestTimeout(fn: () => void, delay: number) { const start = performance.now(); const handle = { id: requestAnimationFrame(function tick(timestamp) { if (timestamp - start >= delay) fn(); else handle.id = requestAnimationFrame(tick); }), }; return handle; } const timeout = requestTimeout(() => { if (didUnsubscribe) return; setIsScrolling(false); }, 40 + 1000 / fps); hasMountedRef.current = 1; return () => { didUnsubscribe = true; cancelAnimationFrame(timeout.id); }; }, [fps, scrollY]); return { scrollTop: Math.max(0, scrollY - offset), isScrolling }; } // ============================================================================ // Masonry Components // ============================================================================ const ROOT_NAME = "MasonryRoot"; const VIEWPORT_NAME = "MasonryViewport"; const ITEM_NAME = "MasonryItem"; const MASONRY_ERROR = { [ROOT_NAME]: `\`${ROOT_NAME}\` components must be within \`${ROOT_NAME}\``, [VIEWPORT_NAME]: `\`${VIEWPORT_NAME}\` components must be within \`${ROOT_NAME}\``, [ITEM_NAME]: `\`${ITEM_NAME}\` must be within \`${VIEWPORT_NAME}\``, } as const; type RootElement = React.ComponentRef; type ItemElement = React.ComponentRef; interface MasonryContextValue { positioner: Positioner; resizeObserver?: ResizeObserver; columnWidth: number; onItemRegister: (index: number) => (node: ItemElement | null) => void; scrollTop: number; windowHeight: number; itemHeight: number; overscan: number; isScrolling?: boolean; fallback?: React.ReactNode; } const MasonryContext = React.createContext(null); function useMasonryContext(name: keyof typeof MASONRY_ERROR) { const context = React.useContext(MasonryContext); if (!context) { throw new Error(MASONRY_ERROR[name]); } return context; } const OVERSCAN = 2; function Masonry(props: MasonryProps) { const { columnWidth = COLUMN_WIDTH, columnCount, maxColumnCount, gap = GAP, itemHeight = ITEM_HEIGHT, defaultWidth, defaultHeight, overscan = OVERSCAN, scrollFps = SCROLL_FPS, fallback, linear = false, asChild, children, style, ref, ...rootProps } = props; const gapValue = typeof gap === "object" ? gap : { column: gap, row: gap }; const columnGap = gapValue.column; const rowGap = gapValue.row; const containerRef = React.useRef(null); const composedRef = useComposedRefs(ref, containerRef); const size = useDebouncedWindowSize({ containerRef, defaultWidth, defaultHeight, delayMs: DEBOUNCE_DELAY, }); const [containerPosition, setContainerPosition] = React.useState<{ offset: number; width: number }>({ offset: 0, width: 0, }); const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect; useIsomorphicLayoutEffect(() => { if (!containerRef.current) return; let offset = 0; let container = containerRef.current; do { offset += container.offsetTop ?? 0; container = container.offsetParent as RootElement; } while (container); if ( offset !== containerPosition.offset || containerRef.current.offsetWidth !== containerPosition.width ) { setContainerPosition({ offset, width: containerRef.current.offsetWidth, }); } }, [containerPosition, size]); const positioner = usePositioner({ width: containerPosition.width ?? size.width, columnWidth, columnGap, rowGap, columnCount, maxColumnCount, linear, }); const resizeObserver = useResizeObserver(positioner); const { scrollTop, isScrolling } = useScroller({ offset: containerPosition.offset, fps: scrollFps, }); const itemMap = React.useRef(new WeakMap()).current; const onItemRegister = React.useCallback( (index: number) => (node: ItemElement | null) => { if (!node) return; itemMap.set(node, index); if (resizeObserver) { resizeObserver.observe(node); } if (positioner.get(index) === void 0) { positioner.set(index, node.offsetHeight); } }, [itemMap, positioner, resizeObserver], ); const contextValue = React.useMemo( () => ({ positioner, resizeObserver, columnWidth: positioner.columnWidth, onItemRegister, scrollTop, windowHeight: size.height, itemHeight, overscan, fallback, isScrolling, }), [positioner, resizeObserver, onItemRegister, scrollTop, size.height, itemHeight, overscan, fallback, isScrolling], ); const RootPrimitive = asChild ? Slot : "div"; return ( {children} ); } interface MasonryItemPropsWithRef extends MasonryItemProps { ref: React.Ref; } function MasonryViewport(props: React.ComponentProps<"div">) { const { children, style, ref, ...viewportProps } = props; const context = useMasonryContext(VIEWPORT_NAME); const [layoutVersion, setLayoutVersion] = React.useState(0); const rafId = React.useRef(null); const [mounted, setMounted] = React.useState(false); const useIsomorphicLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect; useIsomorphicLayoutEffect(() => { setMounted(true); }, []); let startIndex = 0; let stopIndex: number | undefined; const validChildren = React.Children.toArray(children).filter( (child): child is React.ReactElement => React.isValidElement(child) && (child.type === MasonryItem || child.type === MasonryItem), ); const itemCount = validChildren.length; const shortestColumnSize = context.positioner.shortestColumn(); const measuredCount = context.positioner.size(); const overscanPixels = context.windowHeight * context.overscan; const rangeStart = Math.max(0, context.scrollTop - overscanPixels / 2); const rangeEnd = context.scrollTop + overscanPixels; const layoutOutdated = shortestColumnSize < rangeEnd && measuredCount < itemCount; const positionedChildren: React.ReactElement[] = []; const visibleItemStyle = React.useMemo( (): React.CSSProperties => ({ position: "absolute", writingMode: "horizontal-tb", visibility: "visible", width: context.columnWidth, transform: context.isScrolling ? "translateZ(0)" : undefined, willChange: context.isScrolling ? "transform" : undefined, }), [context.columnWidth, context.isScrolling], ); const hiddenItemStyle = React.useMemo( (): React.CSSProperties => ({ position: "absolute", writingMode: "horizontal-tb", visibility: "hidden", width: context.columnWidth, zIndex: -1000, }), [context.columnWidth], ); context.positioner.range(rangeStart, rangeEnd, (index, left, top) => { const child = validChildren[index]; if (!child) return; const itemStyle = { ...visibleItemStyle, top, left, ...child.props.style, }; positionedChildren.push( React.cloneElement(child, { key: child.key ?? index, ref: context.onItemRegister(index), style: itemStyle, }), ); if (stopIndex === undefined) { startIndex = index; stopIndex = index; } else { startIndex = Math.min(startIndex, index); stopIndex = Math.max(stopIndex, index); } }); if (layoutOutdated && mounted) { const batchSize = Math.min( itemCount - measuredCount, Math.ceil( ((context.scrollTop + overscanPixels - shortestColumnSize) / context.itemHeight) * context.positioner.columnCount, ), ); for (let index = measuredCount; index < measuredCount + batchSize; index++) { const child = validChildren[index]; if (!child) continue; const itemStyle = { ...hiddenItemStyle, ...child.props.style, }; positionedChildren.push( React.cloneElement(child, { key: child.key ?? index, ref: context.onItemRegister(index), style: itemStyle, }), ); } } React.useEffect(() => { if (layoutOutdated && mounted) { if (rafId.current) cancelAnimationFrame(rafId.current); rafId.current = requestAnimationFrame(() => { setLayoutVersion((v) => v + 1); }); } return () => { if (rafId.current) cancelAnimationFrame(rafId.current); }; }, [layoutOutdated, mounted]); const estimatedHeight = React.useMemo(() => { const measuredHeight = context.positioner.estimateHeight(measuredCount, context.itemHeight); if (measuredCount === itemCount) return measuredHeight; const remainingItems = itemCount - measuredCount; const estimatedRemainingHeight = Math.ceil( (remainingItems / context.positioner.columnCount) * context.itemHeight, ); return measuredHeight + estimatedRemainingHeight; }, [context.positioner, context.itemHeight, measuredCount, itemCount]); const containerStyle = React.useMemo( () => ({ position: "relative" as const, width: "100%", maxWidth: "100%", height: Math.ceil(estimatedHeight), maxHeight: Math.ceil(estimatedHeight), willChange: context.isScrolling ? "contents" : undefined, pointerEvents: context.isScrolling ? ("none" as const) : undefined, ...style, }), [context.isScrolling, estimatedHeight, style], ); // First-paint placeholder: positioned children are still empty until the // ResizeObserver has measured at least one item. Without this branch the // viewport renders `null` on the first frame and feels broken to first- // time users. Consumer-supplied `fallback` wins; otherwise we render a // minimal muted skeleton so something is on screen immediately. if (!mounted) { if (context.fallback) { return context.fallback as React.ReactElement; } return ; } return (
{positionedChildren}
); } function MasonryFallback({ itemCount }: { itemCount: number }) { const placeholders = Math.min(Math.max(itemCount, 1), 6); return (