2025-08-21 17:30:26 +01:00
|
|
|
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
2025-06-20 17:51:24 +01:00
|
|
|
import { Box } from '@mantine/core';
|
2025-08-21 17:30:26 +01:00
|
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
|
|
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
2025-06-24 23:31:21 +01:00
|
|
|
import styles from './PageEditor.module.css';
|
2025-06-20 17:51:24 +01:00
|
|
|
|
|
|
|
interface DragDropItem {
|
|
|
|
id: string;
|
|
|
|
splitBefore?: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface DragDropGridProps<T extends DragDropItem> {
|
|
|
|
items: T[];
|
2025-07-16 17:53:50 +01:00
|
|
|
selectedItems: number[];
|
2025-06-20 17:51:24 +01:00
|
|
|
selectionMode: boolean;
|
|
|
|
isAnimating: boolean;
|
2025-08-21 17:30:26 +01:00
|
|
|
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
|
2025-06-20 17:51:24 +01:00
|
|
|
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
|
|
|
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
|
|
|
}
|
|
|
|
|
|
|
|
const DragDropGrid = <T extends DragDropItem>({
|
|
|
|
items,
|
|
|
|
selectedItems,
|
|
|
|
selectionMode,
|
|
|
|
isAnimating,
|
2025-08-21 17:30:26 +01:00
|
|
|
onReorderPages,
|
2025-06-20 17:51:24 +01:00
|
|
|
renderItem,
|
|
|
|
renderSplitMarker,
|
|
|
|
}: DragDropGridProps<T>) => {
|
|
|
|
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
2025-08-21 17:30:26 +01:00
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
|
|
// Responsive grid configuration
|
|
|
|
const [itemsPerRow, setItemsPerRow] = useState(4);
|
|
|
|
const ITEM_WIDTH = 320; // 20rem (page width)
|
|
|
|
const ITEM_GAP = 24; // 1.5rem gap between items
|
|
|
|
const ITEM_HEIGHT = 340; // 20rem + gap
|
|
|
|
const OVERSCAN = items.length > 1000 ? 8 : 4; // More overscan for large documents
|
|
|
|
|
|
|
|
// Calculate items per row based on container width
|
|
|
|
const calculateItemsPerRow = useCallback(() => {
|
|
|
|
if (!containerRef.current) return 4; // Default fallback
|
|
|
|
|
|
|
|
const containerWidth = containerRef.current.offsetWidth;
|
|
|
|
if (containerWidth === 0) return 4; // Container not measured yet
|
|
|
|
|
|
|
|
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
|
|
|
|
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
|
|
|
|
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
|
|
|
|
const calculated = Math.floor(availableWidth / itemWithGap);
|
|
|
|
|
|
|
|
return Math.max(1, calculated); // At least 1 item per row
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
// Update items per row when container resizes
|
2025-06-20 17:51:24 +01:00
|
|
|
useEffect(() => {
|
2025-08-21 17:30:26 +01:00
|
|
|
const updateLayout = () => {
|
|
|
|
const newItemsPerRow = calculateItemsPerRow();
|
|
|
|
setItemsPerRow(newItemsPerRow);
|
2025-06-20 17:51:24 +01:00
|
|
|
};
|
2025-08-21 17:30:26 +01:00
|
|
|
|
|
|
|
// Initial calculation
|
|
|
|
updateLayout();
|
|
|
|
|
|
|
|
// Listen for window resize
|
|
|
|
window.addEventListener('resize', updateLayout);
|
|
|
|
|
|
|
|
// Use ResizeObserver for container size changes
|
|
|
|
const resizeObserver = new ResizeObserver(updateLayout);
|
|
|
|
if (containerRef.current) {
|
|
|
|
resizeObserver.observe(containerRef.current);
|
2025-06-20 17:51:24 +01:00
|
|
|
}
|
2025-08-21 17:30:26 +01:00
|
|
|
|
2025-06-20 17:51:24 +01:00
|
|
|
return () => {
|
2025-08-21 17:30:26 +01:00
|
|
|
window.removeEventListener('resize', updateLayout);
|
|
|
|
resizeObserver.disconnect();
|
2025-06-20 17:51:24 +01:00
|
|
|
};
|
2025-08-21 17:30:26 +01:00
|
|
|
}, [calculateItemsPerRow]);
|
|
|
|
|
|
|
|
// Virtualization with react-virtual library
|
|
|
|
const rowVirtualizer = useVirtualizer({
|
|
|
|
count: Math.ceil(items.length / itemsPerRow),
|
|
|
|
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
|
|
|
|
estimateSize: () => ITEM_HEIGHT,
|
|
|
|
overscan: OVERSCAN,
|
|
|
|
});
|
|
|
|
|
|
|
|
|
2025-06-20 17:51:24 +01:00
|
|
|
|
|
|
|
return (
|
2025-08-21 17:30:26 +01:00
|
|
|
<Box
|
|
|
|
ref={containerRef}
|
|
|
|
style={{
|
|
|
|
// Basic container styles
|
|
|
|
width: '100%',
|
|
|
|
height: '100%',
|
|
|
|
}}
|
|
|
|
>
|
2025-06-20 17:51:24 +01:00
|
|
|
<div
|
|
|
|
style={{
|
2025-08-21 17:30:26 +01:00
|
|
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
|
|
|
width: '100%',
|
|
|
|
position: 'relative',
|
2025-06-20 17:51:24 +01:00
|
|
|
}}
|
|
|
|
>
|
2025-08-21 17:30:26 +01:00
|
|
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
|
|
|
const startIndex = virtualRow.index * itemsPerRow;
|
|
|
|
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
|
|
|
|
const rowItems = items.slice(startIndex, endIndex);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div
|
|
|
|
key={virtualRow.index}
|
|
|
|
style={{
|
|
|
|
position: 'absolute',
|
|
|
|
top: 0,
|
|
|
|
left: 0,
|
|
|
|
width: '100%',
|
|
|
|
height: `${virtualRow.size}px`,
|
|
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
<div
|
|
|
|
style={{
|
|
|
|
display: 'flex',
|
|
|
|
gap: '1.5rem',
|
|
|
|
justifyContent: 'flex-start',
|
|
|
|
height: '100%',
|
|
|
|
alignItems: 'center',
|
|
|
|
}}
|
|
|
|
>
|
|
|
|
{rowItems.map((item, itemIndex) => {
|
|
|
|
const actualIndex = startIndex + itemIndex;
|
|
|
|
return (
|
|
|
|
<React.Fragment key={item.id}>
|
|
|
|
{/* Split marker */}
|
|
|
|
{renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)}
|
|
|
|
{/* Item */}
|
|
|
|
{renderItem(item, actualIndex, itemRefs)}
|
|
|
|
</React.Fragment>
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
|
|
|
|
</div>
|
2025-06-20 17:51:24 +01:00
|
|
|
</div>
|
2025-08-21 17:30:26 +01:00
|
|
|
);
|
|
|
|
})}
|
2025-06-20 17:51:24 +01:00
|
|
|
</div>
|
|
|
|
</Box>
|
|
|
|
);
|
|
|
|
};
|
|
|
|
|
2025-06-24 23:31:21 +01:00
|
|
|
export default DragDropGrid;
|