@zapperwing/pinterest-view
Version:
A Pinterest-style grid layout component for React.js with responsive design and dynamic content support
773 lines (664 loc) • 21.7 kB
JavaScript
// src/components/StackGrid.js
/* eslint-disable max-classes-per-file */
/* eslint-disable react/default-props-match-prop-types */
/* eslint-disable react/no-unused-prop-types */
/* eslint-disable react/jsx-filename-extension */
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable no-param-reassign */
import React, { Component, isValidElement } from 'react';
import PropTypes from 'prop-types';
import sizeMe from 'react-sizeme';
const isNumber = (v) => typeof v === 'number' && Number.isFinite(v);
const isPercentageNumber = (v) => typeof v === 'string' && /^\d+(\.\d+)?%$/.test(v);
const getColumnConfig = (containerWidth, columnWidth, gutterWidth) => {
if (isNumber(columnWidth)) {
// Keep the column width fixed, only calculate number of columns
const columnCount = Math.floor((containerWidth + gutterWidth) / (columnWidth + gutterWidth));
return { columnCount: Math.max(1, columnCount), columnWidth };
}
if (isPercentageNumber(columnWidth)) {
const percentage = parseFloat(columnWidth) / 100;
const columnCount = Math.floor(1 / percentage);
const actualColumnWidth = (containerWidth - (columnCount - 1) * gutterWidth) / columnCount;
return { columnCount, columnWidth: actualColumnWidth };
}
throw new Error('columnWidth must be a number or percentage string');
};
// Optimized column finding - O(n) instead of O(n*m)
const getShortestColumn = (heights) => {
let minIndex = 0;
let minHeight = heights[0];
for (let i = 1; i < heights.length; i += 1) {
if (heights[i] < minHeight) {
minHeight = heights[i];
minIndex = i;
}
}
return minIndex;
};
const GridItem = React.memo(React.forwardRef((
{
itemKey,
component: Element,
rect = {
top: 0,
left: 0,
width: 0,
height: 0,
},
style,
rtl,
children,
onHeightChange,
...rest
},
ref,
) => {
const itemRef = React.useRef(null);
const [isInitialRender, setIsInitialRender] = React.useState(true);
React.useEffect(() => {
setIsInitialRender(false);
}, []);
React.useEffect(() => {
if (!itemRef.current || typeof onHeightChange !== 'function') return;
// Debounced height update to prevent cascade of updates
let timeoutId;
const updateHeight = () => {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
if (!itemRef.current) return;
const { height } = itemRef.current.getBoundingClientRect();
onHeightChange(height);
}, 100); // Debounce by 100ms
};
// Initial height
updateHeight();
// ResizeObserver for height changes - use contentRect to avoid reflow
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
const { height } = entry.contentRect;
onHeightChange(height);
}
});
ro.observe(itemRef.current);
return () => {
clearTimeout(timeoutId);
ro.disconnect();
};
}, [onHeightChange, itemKey]);
const itemStyle = {
...style,
position: 'absolute',
top: rect.top,
left: rtl ? 'auto' : rect.left,
right: rtl ? rect.left : 'auto',
width: rect.width,
zIndex: 1,
// Only apply transitions after initial render to prevent performance issues
transition: isInitialRender ? 'none' : 'top 0.3s ease-out, left 0.3s ease-out, right 0.3s ease-out, width 0.3s ease-out',
// CSS containment for better reflow isolation
contain: 'layout style',
willChange: isInitialRender ? 'auto' : 'transform',
};
return (
<Element
{...rest}
ref={(node) => {
if (typeof ref === 'function') ref(node);
else if (ref) ref.current = node;
itemRef.current = node;
}}
className="grid-item"
style={itemStyle}
>
{children}
</Element>
);
}), (prevProps, nextProps) => prevProps.itemKey === nextProps.itemKey
&& prevProps.rect.top === nextProps.rect.top
&& prevProps.rect.left === nextProps.rect.left
&& prevProps.rect.width === nextProps.rect.width
&& prevProps.rect.height === nextProps.rect.height
&& prevProps.rtl === nextProps.rtl
&& prevProps.children === nextProps.children);
GridItem.displayName = 'GridItem';
GridItem.propTypes = {
itemKey: PropTypes.string,
component: PropTypes.string,
rect: PropTypes.shape({
top: PropTypes.number,
left: PropTypes.number,
width: PropTypes.number,
height: PropTypes.number,
}),
style: PropTypes.shape({}),
rtl: PropTypes.bool,
children: PropTypes.node,
onHeightChange: PropTypes.func,
};
GridItem.defaultProps = {
itemKey: '',
component: 'div',
rect: {
top: 0,
left: 0,
width: 0,
height: 0,
},
style: {},
rtl: false,
children: null,
onHeightChange: null,
};
const GridInlinePropTypes = {
className: PropTypes.string,
style: PropTypes.shape({}),
component: PropTypes.string,
itemComponent: PropTypes.string,
children: PropTypes.node,
rtl: PropTypes.bool,
onLayout: PropTypes.func,
gridRef: PropTypes.func,
size: PropTypes.shape({
width: PropTypes.number,
height: PropTypes.number,
registerRef: PropTypes.func,
unregisterRef: PropTypes.func,
}),
columnWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
gutterWidth: PropTypes.number,
gutterHeight: PropTypes.number,
virtualized: PropTypes.bool,
debug: PropTypes.bool,
virtualizationBuffer: PropTypes.number,
scrollContainer: PropTypes.instanceOf(HTMLElement),
};
const GridInlineDefaultProps = {
className: '',
style: {},
component: 'div',
itemComponent: 'div',
children: null,
rtl: false,
onLayout: () => {},
gridRef: null,
size: null,
columnWidth: 150,
gutterWidth: 5,
gutterHeight: 5,
virtualized: false,
debug: true,
virtualizationBuffer: 800,
scrollContainer: null,
};
class GridInline extends Component {
constructor(props) {
super(props);
this.containerRef = React.createRef();
this.heightCache = new Map(); // Using Map for better performance
this.columnAssignments = new Map(); // Track which column each item belongs to
this.mounted = false;
this.layoutRequestId = null; // For debounced layout updates
this.scrollRAF = null; // For optimized scroll handling
this.lastLogTime = 0; // For throttling debug logs
// Initialize scroller in constructor to prevent null access in render
this.scroller = props.scrollContainer || window;
this.state = {
rects: [],
height: 0,
scrollTop: 0,
};
}
componentDidMount() {
this.mounted = true;
const {
size,
gridRef,
virtualized,
columnWidth,
children,
scrollContainer,
} = this.props;
size?.registerRef?.(this);
gridRef?.(this);
// Update scroller if it changed
this.scroller = scrollContainer || window;
this.debugLog('Component mounted', {
virtualized,
columnWidth,
childrenCount: React.Children.count(children),
scrollContainer: scrollContainer ? 'custom' : 'window',
}, true);
// Listen to scroll events only if virtualized
if (virtualized) {
this.scroller.addEventListener('scroll', this.handleScroll, { passive: true });
this.debugLog('Scroll listener attached (virtualized mode)', {
scroller: this.scroller === window ? 'window' : 'custom container',
});
}
window.addEventListener('resize', this.handleResize, { passive: true });
// Initial layout
this.scheduleLayout();
}
componentDidUpdate(prevProps) {
const {
children,
size,
columnWidth,
gutterWidth,
gutterHeight,
scrollContainer,
virtualized,
} = this.props;
const childrenChanged = prevProps.children !== children;
const sizeChanged = prevProps.size?.width !== size?.width;
const layoutPropsChanged = prevProps.columnWidth !== columnWidth
|| prevProps.gutterWidth !== gutterWidth
|| prevProps.gutterHeight !== gutterHeight;
const scrollContainerChanged = prevProps.scrollContainer !== scrollContainer;
// Handle scroll container changes
if (scrollContainerChanged && this.mounted) {
// Remove old scroll listener
if (prevProps.virtualized) {
const oldScroller = prevProps.scrollContainer || window;
oldScroller.removeEventListener('scroll', this.handleScroll);
}
// Set up new scroll container
this.scroller = scrollContainer || window;
// Add new scroll listener
if (virtualized) {
this.scroller.addEventListener('scroll', this.handleScroll, { passive: true });
this.debugLog('Scroll container changed', {
from: prevProps.scrollContainer ? 'custom' : 'window',
to: scrollContainer ? 'custom' : 'window',
});
}
}
// Clean up height cache for removed children
if (childrenChanged) {
const currentKeys = new Set(
React.Children.toArray(children)
.filter(isValidElement)
.map((child) => child.key),
);
this.heightCache.forEach((value, key) => {
if (!currentKeys.has(key)) {
this.heightCache.delete(key);
this.columnAssignments.delete(key);
}
});
}
if (childrenChanged || sizeChanged || layoutPropsChanged) {
this.debugLog('Layout update triggered', {
childrenChanged,
sizeChanged: sizeChanged ? `${prevProps.size?.width} → ${size?.width}` : false,
layoutPropsChanged,
newChildrenCount: React.Children.count(children),
});
this.scheduleLayout();
}
}
componentWillUnmount() {
this.mounted = false;
const { size, virtualized } = this.props;
size?.unregisterRef?.(this);
this.debugLog('Component unmounting', null, true);
if (virtualized && this.scroller) {
this.scroller.removeEventListener('scroll', this.handleScroll);
}
window.removeEventListener('resize', this.handleResize);
if (this.layoutRequestId) {
cancelAnimationFrame(this.layoutRequestId);
}
if (this.scrollRAF) {
cancelAnimationFrame(this.scrollRAF);
}
}
// Throttled debug logging
debugLog = (message, data = null, force = false) => {
const { debug } = this.props;
if (!debug) return;
const now = Date.now();
const timeSinceLastLog = now - this.lastLogTime;
// Throttle logs to prevent console spam, but allow forced logs
if (!force && timeSinceLastLog < 1000) return;
this.lastLogTime = now;
if (data) {
console.log(`[StackGrid] ${message}`, data);
} else {
console.log(`[StackGrid] ${message}`);
}
};
handleScroll = () => {
const { virtualized } = this.props;
if (!this.mounted || !virtualized || !this.scroller) return;
// Use requestAnimationFrame for better performance
if (this.scrollRAF) return;
this.scrollRAF = requestAnimationFrame(() => {
// Get scroll position from the appropriate container
const scrollTop = this.scroller === window
? (window.pageYOffset || document.documentElement.scrollTop)
: this.scroller.scrollTop;
const { scrollTop: currentScrollTop } = this.state;
// Only update if scroll position changed significantly
if (Math.abs(scrollTop - currentScrollTop) > 50) {
this.debugLog('Scroll position changed', {
from: currentScrollTop,
to: scrollTop,
delta: scrollTop - currentScrollTop,
scroller: this.scroller === window ? 'window' : 'custom container',
});
this.setState({ scrollTop });
}
this.scrollRAF = null;
});
};
handleResize = () => {
if (!this.mounted) return;
this.debugLog('Window resize detected');
this.scheduleLayout();
};
scheduleLayout = () => {
if (this.layoutRequestId) {
cancelAnimationFrame(this.layoutRequestId);
}
this.layoutRequestId = requestAnimationFrame(() => {
this.updateLayout();
this.layoutRequestId = null;
});
};
updateLayout = () => {
if (!this.mounted) return;
const {
size,
children,
columnWidth,
gutterWidth,
gutterHeight,
} = this.props;
const containerWidth = size?.width;
if (!containerWidth || containerWidth <= 0) return;
const validChildren = React.Children.toArray(children).filter(isValidElement);
if (validChildren.length === 0) {
this.setState({ rects: [], height: 0 });
return;
}
try {
const { columnCount, columnWidth: actualColumnWidth } = getColumnConfig(
containerWidth,
columnWidth,
gutterWidth,
);
const columnHeights = new Array(columnCount).fill(0);
const rects = validChildren.map((child) => {
// Find shortest column
const shortestColumnIndex = getShortestColumn(columnHeights);
// Get cached height or use default
const itemHeight = this.heightCache.get(child.key) || 200;
// Calculate position
const left = shortestColumnIndex * (actualColumnWidth + gutterWidth);
const top = columnHeights[shortestColumnIndex];
// Update column height
columnHeights[shortestColumnIndex] = top + itemHeight + gutterHeight;
// Store column assignment for efficient updates
this.columnAssignments.set(child.key, shortestColumnIndex);
return {
top,
left,
width: actualColumnWidth,
height: itemHeight,
};
});
const height = Math.max(...columnHeights) - gutterHeight;
this.debugLog('Layout calculated', {
columns: columnCount,
items: validChildren.length,
containerWidth,
columnWidth: actualColumnWidth,
totalHeight: height,
heightCacheSize: this.heightCache.size,
});
this.setState({ rects, height }, () => {
const { onLayout } = this.props;
if (typeof onLayout === 'function') {
onLayout({ height });
}
});
} catch (error) {
console.error('Layout calculation error:', error);
}
};
handleHeightChange = (key, height) => {
const oldHeight = this.heightCache.get(key);
if (oldHeight !== height) {
this.debugLog('Item height changed', {
key,
from: oldHeight,
to: height,
delta: height - (oldHeight || 0),
});
this.heightCache.set(key, height);
// Update the layout to push other cards down
this.updateLayoutForHeightChange(key, oldHeight, height);
}
};
updateLayoutForHeightChange = (changedKey, oldHeight, newHeight) => {
if (!this.mounted) return;
const {
children,
columnWidth,
gutterWidth,
gutterHeight,
size,
} = this.props;
const containerWidth = size?.width;
if (!containerWidth || containerWidth <= 0) return;
const validChildren = React.Children.toArray(children).filter(isValidElement);
const changedIndex = validChildren.findIndex((child) => child.key === changedKey);
if (changedIndex === -1) return;
try {
const { columnCount, columnWidth: actualColumnWidth } = getColumnConfig(
containerWidth,
columnWidth,
gutterWidth,
);
// Recalculate layout from the changed item onwards
const columnHeights = new Array(columnCount).fill(0);
const rects = [];
// First pass: calculate positions up to the changed item
for (let i = 0; i < changedIndex; i += 1) {
const child = validChildren[i];
const itemHeight = this.heightCache.get(child.key) || 200;
// Find shortest column
const shortestColumnIndex = getShortestColumn(columnHeights);
const left = shortestColumnIndex * (actualColumnWidth + gutterWidth);
const top = columnHeights[shortestColumnIndex];
columnHeights[shortestColumnIndex] = top + itemHeight + gutterHeight;
rects.push({
top,
left,
width: actualColumnWidth,
height: itemHeight,
});
}
// Second pass: recalculate from changed item onwards
for (let i = changedIndex; i < validChildren.length; i += 1) {
const child = validChildren[i];
const itemHeight = this.heightCache.get(child.key) || 200;
// Find shortest column
const shortestColumnIndex = getShortestColumn(columnHeights);
const left = shortestColumnIndex * (actualColumnWidth + gutterWidth);
const top = columnHeights[shortestColumnIndex];
columnHeights[shortestColumnIndex] = top + itemHeight + gutterHeight;
rects.push({
top,
left,
width: actualColumnWidth,
height: itemHeight,
});
}
const height = Math.max(...columnHeights) - gutterHeight;
this.debugLog('Layout updated for height change', {
changedKey,
oldHeight,
newHeight,
totalHeight: height,
});
this.setState({ rects, height }, () => {
const { onLayout } = this.props;
if (typeof onLayout === 'function') {
onLayout({ height });
}
});
} catch (error) {
console.error('Layout update error:', error);
}
};
render() {
const {
className,
style,
component: ElementType,
itemComponent,
children,
rtl,
virtualized,
} = this.props;
const { rects, height, scrollTop } = this.state;
const validChildren = React.Children.toArray(children).filter(isValidElement);
const containerStyle = {
position: 'relative',
height,
...style,
};
let renderedItems = validChildren;
let virtualizedCount = 0;
// Optimized virtualization
if (virtualized && this.scroller) {
const { virtualizationBuffer } = this.props;
// Get viewport height from the appropriate container
const viewportHeight = this.scroller === window
? window.innerHeight
: this.scroller.clientHeight;
const visibleTop = scrollTop - virtualizationBuffer;
const visibleBottom = scrollTop + viewportHeight + virtualizationBuffer;
const beforeCount = renderedItems.length;
renderedItems = validChildren.filter((child, index) => {
const rect = rects[index];
if (!rect) return false;
const itemTop = rect.top;
const itemBottom = itemTop + rect.height;
return itemBottom >= visibleTop && itemTop <= visibleBottom;
});
virtualizedCount = beforeCount - renderedItems.length;
if (virtualizedCount > 0) {
this.debugLog('Virtualization active', {
total: validChildren.length,
rendered: renderedItems.length,
hidden: virtualizedCount,
scrollTop,
visibleRange: [visibleTop, visibleBottom],
viewportHeight,
scroller: this.scroller === window ? 'window' : 'custom container',
});
}
}
const gridItems = renderedItems.map((child) => {
const originalIndex = validChildren.indexOf(child);
const rect = rects[originalIndex];
if (!rect) return null;
return (
<GridItem
key={child.key}
itemKey={child.key}
component={itemComponent}
rect={rect}
rtl={rtl}
onHeightChange={(itemHeight) => this.handleHeightChange(child.key, itemHeight)}
>
{child}
</GridItem>
);
});
return (
<ElementType
className={className}
style={containerStyle}
ref={this.containerRef}
>
{gridItems}
</ElementType>
);
}
}
// Move propTypes and defaultProps outside the class
GridInline.propTypes = GridInlinePropTypes;
GridInline.defaultProps = GridInlineDefaultProps;
const StackGridPropTypes = {
children: PropTypes.node,
className: PropTypes.string,
style: PropTypes.shape({}),
gridRef: PropTypes.func,
component: PropTypes.string,
itemComponent: PropTypes.string,
columnWidth: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
gutterWidth: PropTypes.number,
gutterHeight: PropTypes.number,
onLayout: PropTypes.func,
rtl: PropTypes.bool,
virtualized: PropTypes.bool,
debug: PropTypes.bool,
size: PropTypes.shape({
width: PropTypes.number,
height: PropTypes.number,
registerRef: PropTypes.func,
unregisterRef: PropTypes.func,
}),
scrollContainer: PropTypes.instanceOf(HTMLElement),
};
const StackGridDefaultProps = {
children: null,
className: '',
style: {},
gridRef: null,
component: 'div',
itemComponent: 'div',
gutterWidth: 5,
gutterHeight: 5,
onLayout: null,
rtl: false,
virtualized: false,
debug: false,
size: null,
scrollContainer: null,
};
class StackGrid extends Component {
constructor(props) {
super(props);
// eslint-disable-next-line no-unused-vars
this.grid = null; // Used for gridRef callback
}
handleRef = (gridInlineInstance) => {
// eslint-disable-next-line no-unused-vars
this.grid = gridInlineInstance; // Store reference for potential future use
const { gridRef } = this.props;
gridRef?.(this);
};
render() {
const { gridRef, children, ...restOfProps } = this.props;
return (
<GridInline
{...restOfProps}
gridRef={this.handleRef}
>
{children}
</GridInline>
);
}
}
// Move propTypes and defaultProps outside the class
StackGrid.propTypes = StackGridPropTypes;
StackGrid.defaultProps = StackGridDefaultProps;
export default sizeMe({ monitorHeight: false, monitorWidth: true })(StackGrid);
export { GridInline };