670 lines
22 KiB
TypeScript
670 lines
22 KiB
TypeScript
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
|
|
import { Box } from '@mantine/core';
|
|
import { useVirtualizer } from '@tanstack/react-virtual';
|
|
import { GRID_CONSTANTS } from './constants';
|
|
import {
|
|
DndContext,
|
|
DragEndEvent,
|
|
DragStartEvent,
|
|
DragOverlay,
|
|
useSensor,
|
|
useSensors,
|
|
PointerSensor,
|
|
closestCenter,
|
|
useDraggable,
|
|
useDroppable,
|
|
} from '@dnd-kit/core';
|
|
|
|
interface DragDropItem {
|
|
id: string;
|
|
splitAfter?: boolean;
|
|
isPlaceholder?: boolean;
|
|
}
|
|
|
|
interface DragDropGridProps<T extends DragDropItem> {
|
|
items: T[];
|
|
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
|
|
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
|
|
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
|
zoomLevel?: number;
|
|
}
|
|
|
|
// Lightweight wrapper that handles dnd-kit hooks for each visible item
|
|
interface DraggableItemProps<T extends DragDropItem> {
|
|
item: T;
|
|
index: number;
|
|
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
|
boxSelectedPageIds: string[];
|
|
clearBoxSelection: () => void;
|
|
getBoxSelection: () => string[];
|
|
activeId: string | null;
|
|
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
|
|
onUpdateDropTarget: (itemId: string | null) => void;
|
|
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
|
|
zoomLevel: number;
|
|
}
|
|
|
|
const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
|
|
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
|
|
id: item.id,
|
|
data: {
|
|
index,
|
|
pageNumber: index + 1,
|
|
getThumbnail: () => {
|
|
if (getThumbnailData) {
|
|
const data = getThumbnailData(item.id);
|
|
if (data?.src) return data;
|
|
}
|
|
|
|
const element = itemRefs.current.get(item.id);
|
|
const imgElement = element?.querySelector('img.ph-no-capture') as HTMLImageElement;
|
|
if (imgElement?.src) {
|
|
return {
|
|
src: imgElement.src,
|
|
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
});
|
|
|
|
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
|
|
id: item.id,
|
|
data: { index, pageNumber: index + 1 }
|
|
});
|
|
|
|
// Notify parent when hover state changes
|
|
React.useEffect(() => {
|
|
if (isOver) {
|
|
onUpdateDropTarget(item.id);
|
|
} else {
|
|
onUpdateDropTarget(null);
|
|
}
|
|
}, [isOver, item.id, onUpdateDropTarget]);
|
|
|
|
const setNodeRef = useCallback((element: HTMLDivElement | null) => {
|
|
setDraggableRef(element);
|
|
setDroppableRef(element);
|
|
}, [setDraggableRef, setDroppableRef]);
|
|
|
|
return (
|
|
<>
|
|
{renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
const DragDropGrid = <T extends DragDropItem>({
|
|
items,
|
|
renderItem,
|
|
onReorderPages,
|
|
getThumbnailData,
|
|
zoomLevel = 1.0,
|
|
}: DragDropGridProps<T>) => {
|
|
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const getScrollElement = useCallback(() => {
|
|
return containerRef.current?.closest('[data-scrolling-container]') as HTMLElement | null;
|
|
}, []);
|
|
|
|
// Filter out placeholder items (invisible pages for deselected files)
|
|
const visibleItems = items.filter(item => !item.isPlaceholder);
|
|
|
|
// Box selection state
|
|
const [boxSelectStart, setBoxSelectStart] = useState<{ x: number; y: number } | null>(null);
|
|
const [boxSelectEnd, setBoxSelectEnd] = useState<{ x: number; y: number } | null>(null);
|
|
const [isBoxSelecting, setIsBoxSelecting] = useState(false);
|
|
const [boxSelectedPageIds, setBoxSelectedPageIds] = useState<string[]>([]);
|
|
|
|
// Drag state
|
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
const [dragPreview, setDragPreview] = useState<{ src: string; rotation: number } | null>(null);
|
|
const [hoveredItemId, setHoveredItemId] = useState<string | null>(null);
|
|
const [dropSide, setDropSide] = useState<'left' | 'right' | null>(null);
|
|
|
|
// Configure sensors for dnd-kit with activation constraint
|
|
// Require 10px movement before drag starts to allow clicks for selection
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
distance: 10,
|
|
},
|
|
})
|
|
);
|
|
|
|
// Throttled pointer move handler for drop indicator
|
|
// Calculate drop position based on cursor location relative to ALL items, not just hovered item
|
|
useEffect(() => {
|
|
if (!activeId) {
|
|
setDropSide(null);
|
|
setHoveredItemId(null);
|
|
return;
|
|
}
|
|
|
|
let rafId: number | null = null;
|
|
|
|
const handlePointerMove = (e: PointerEvent) => {
|
|
// Use the actual cursor position (pointer coordinates)
|
|
const cursorX = e.clientX;
|
|
const cursorY = e.clientY;
|
|
|
|
if (rafId === null) {
|
|
rafId = requestAnimationFrame(() => {
|
|
// Step 1: Group items by rows and find closest row to cursor
|
|
const rows = new Map<number, Array<{ id: string; element: HTMLDivElement; rect: DOMRect }>>();
|
|
|
|
itemRefs.current.forEach((element, itemId) => {
|
|
// Skip the item being dragged
|
|
if (itemId === activeId) return;
|
|
|
|
const rect = element.getBoundingClientRect();
|
|
const rowCenter = rect.top + rect.height / 2;
|
|
|
|
// Group items by their vertical center position (items in same row will have similar centers)
|
|
let foundRow = false;
|
|
rows.forEach((items, rowY) => {
|
|
if (Math.abs(rowY - rowCenter) < rect.height / 4) {
|
|
items.push({ id: itemId, element, rect });
|
|
foundRow = true;
|
|
}
|
|
});
|
|
|
|
if (!foundRow) {
|
|
rows.set(rowCenter, [{ id: itemId, element, rect }]);
|
|
}
|
|
});
|
|
|
|
// Step 2: Find the closest row to cursor Y position
|
|
let closestRowY = 0;
|
|
let closestRowDistance = Infinity;
|
|
Array.from(rows.keys()).forEach((rowY) => {
|
|
const distance = Math.abs(cursorY - rowY);
|
|
if (distance < closestRowDistance) {
|
|
closestRowDistance = distance;
|
|
closestRowY = rowY;
|
|
}
|
|
});
|
|
|
|
const closestRow = rows.get(closestRowY);
|
|
if (!closestRow || closestRow.length === 0) {
|
|
setHoveredItemId(null);
|
|
setDropSide(null);
|
|
rafId = null;
|
|
return;
|
|
}
|
|
|
|
// Step 3: Within the closest row, find the closest edge to cursor X position
|
|
let closestItemId: string | null = null;
|
|
let closestDistance = Infinity;
|
|
let closestSide: 'left' | 'right' = 'left';
|
|
|
|
closestRow.forEach(({ id, rect }) => {
|
|
// Calculate distance to left and right edges
|
|
const distanceToLeft = Math.abs(cursorX - rect.left);
|
|
const distanceToRight = Math.abs(cursorX - rect.right);
|
|
|
|
// Find the closest edge
|
|
if (distanceToLeft < closestDistance) {
|
|
closestDistance = distanceToLeft;
|
|
closestItemId = id;
|
|
closestSide = 'left';
|
|
}
|
|
if (distanceToRight < closestDistance) {
|
|
closestDistance = distanceToRight;
|
|
closestItemId = id;
|
|
closestSide = 'right';
|
|
}
|
|
});
|
|
|
|
setHoveredItemId(closestItemId);
|
|
setDropSide(closestSide);
|
|
rafId = null;
|
|
});
|
|
}
|
|
};
|
|
|
|
window.addEventListener('pointermove', handlePointerMove, { passive: true });
|
|
return () => {
|
|
window.removeEventListener('pointermove', handlePointerMove);
|
|
if (rafId !== null) {
|
|
cancelAnimationFrame(rafId);
|
|
}
|
|
};
|
|
}, [activeId]);
|
|
|
|
// Responsive grid configuration
|
|
const [itemsPerRow, setItemsPerRow] = useState(4);
|
|
const OVERSCAN = visibleItems.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
|
|
|
|
// 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
|
|
|
|
// Convert rem to pixels for calculation
|
|
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
|
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
|
|
|
|
// 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
|
|
}, [zoomLevel]);
|
|
|
|
// Update items per row when container resizes or zoom changes
|
|
useEffect(() => {
|
|
const updateLayout = () => {
|
|
const newItemsPerRow = calculateItemsPerRow();
|
|
setItemsPerRow(newItemsPerRow);
|
|
};
|
|
|
|
// 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);
|
|
}
|
|
|
|
return () => {
|
|
window.removeEventListener('resize', updateLayout);
|
|
resizeObserver.disconnect();
|
|
};
|
|
}, [calculateItemsPerRow, zoomLevel]);
|
|
|
|
// Virtualization with react-virtual library
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: Math.ceil(visibleItems.length / itemsPerRow),
|
|
getScrollElement,
|
|
estimateSize: () => {
|
|
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel;
|
|
},
|
|
overscan: OVERSCAN,
|
|
});
|
|
|
|
// Re-measure virtualizer when zoom or items per row changes
|
|
useEffect(() => {
|
|
rowVirtualizer.measure();
|
|
}, [zoomLevel, itemsPerRow]);
|
|
|
|
// Box selection handlers
|
|
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
|
// Only start box select if Ctrl/Cmd is held
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
const rect = containerRef.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
|
|
// Clear previous box selection when starting new one
|
|
setIsBoxSelecting(true);
|
|
setBoxSelectStart({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
|
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
|
setBoxSelectedPageIds([]);
|
|
} else {
|
|
// Clear box selection when clicking without Ctrl
|
|
if (boxSelectedPageIds.length > 0) {
|
|
setBoxSelectedPageIds([]);
|
|
}
|
|
}
|
|
}, [boxSelectedPageIds.length]);
|
|
|
|
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
|
if (!isBoxSelecting || !boxSelectStart) return;
|
|
|
|
const rect = containerRef.current?.getBoundingClientRect();
|
|
if (!rect) return;
|
|
|
|
setBoxSelectEnd({ x: e.clientX - rect.left, y: e.clientY - rect.top });
|
|
|
|
// Calculate which pages intersect with selection box
|
|
const boxLeft = Math.min(boxSelectStart.x, e.clientX - rect.left);
|
|
const boxRight = Math.max(boxSelectStart.x, e.clientX - rect.left);
|
|
const boxTop = Math.min(boxSelectStart.y, e.clientY - rect.top);
|
|
const boxBottom = Math.max(boxSelectStart.y, e.clientY - rect.top);
|
|
|
|
const selectedIds: string[] = [];
|
|
itemRefs.current.forEach((pageEl, pageId) => {
|
|
const pageRect = pageEl.getBoundingClientRect();
|
|
const pageLeft = pageRect.left - rect.left;
|
|
const pageRight = pageRect.right - rect.left;
|
|
const pageTop = pageRect.top - rect.top;
|
|
const pageBottom = pageRect.bottom - rect.top;
|
|
|
|
// Check if page intersects with selection box
|
|
const intersects = !(
|
|
pageRight < boxLeft ||
|
|
pageLeft > boxRight ||
|
|
pageBottom < boxTop ||
|
|
pageTop > boxBottom
|
|
);
|
|
|
|
if (intersects) {
|
|
selectedIds.push(pageId);
|
|
}
|
|
});
|
|
|
|
setBoxSelectedPageIds(selectedIds);
|
|
}, [isBoxSelecting, boxSelectStart]);
|
|
|
|
const handleMouseUp = useCallback(() => {
|
|
if (isBoxSelecting) {
|
|
// Keep box-selected pages highlighted (don't clear boxSelectedPageIds yet)
|
|
// They will remain highlighted until next interaction
|
|
setIsBoxSelecting(false);
|
|
setBoxSelectStart(null);
|
|
setBoxSelectEnd(null);
|
|
}
|
|
}, [isBoxSelecting]);
|
|
|
|
// Function to clear box selection (exposed to child components)
|
|
const clearBoxSelection = useCallback(() => {
|
|
setBoxSelectedPageIds([]);
|
|
}, []);
|
|
|
|
// Function to get current box selection (exposed to child components)
|
|
const getBoxSelection = useCallback(() => {
|
|
return boxSelectedPageIds;
|
|
}, [boxSelectedPageIds]);
|
|
|
|
// Handle drag start
|
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
|
const activeId = event.active.id as string;
|
|
setActiveId(activeId);
|
|
|
|
// Call the getter function to get fresh thumbnail data
|
|
const getThumbnail = event.active.data.current?.getThumbnail;
|
|
if (getThumbnail) {
|
|
const thumbnailData = getThumbnail();
|
|
if (thumbnailData?.src) {
|
|
setDragPreview({ src: thumbnailData.src, rotation: thumbnailData.rotation });
|
|
return;
|
|
}
|
|
}
|
|
|
|
setDragPreview(null);
|
|
}, []);
|
|
|
|
|
|
// Handle drag cancel
|
|
const handleDragCancel = useCallback(() => {
|
|
setActiveId(null);
|
|
setDragPreview(null);
|
|
setHoveredItemId(null);
|
|
setDropSide(null);
|
|
}, []);
|
|
|
|
// Handle drag end
|
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
|
|
const finalDropSide = dropSide;
|
|
setActiveId(null);
|
|
setDragPreview(null);
|
|
setHoveredItemId(null);
|
|
setDropSide(null);
|
|
|
|
if (!over || active.id === over.id) {
|
|
return;
|
|
}
|
|
|
|
// Get data from hooks
|
|
const activeData = active.data.current;
|
|
const overData = over.data.current;
|
|
|
|
if (!activeData || !overData) return;
|
|
|
|
const sourcePageNumber = activeData.pageNumber;
|
|
let targetIndex = overData.index;
|
|
|
|
// Use the final drop side to adjust target index
|
|
if (finalDropSide === 'right') {
|
|
targetIndex = targetIndex + 1;
|
|
}
|
|
|
|
// Check if this page is box-selected
|
|
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
|
|
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
|
|
|
|
// Call reorder with page number and target index
|
|
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
|
|
|
|
// Clear box selection after drag
|
|
if (pagesToDrag) {
|
|
clearBoxSelection();
|
|
}
|
|
}, [boxSelectedPageIds, dropSide, onReorderPages, clearBoxSelection]);
|
|
|
|
// Calculate optimal width for centering
|
|
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
|
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
|
|
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
|
|
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
|
|
|
|
// Calculate selection box dimensions
|
|
const selectionBoxStyle = isBoxSelecting && boxSelectStart && boxSelectEnd ? {
|
|
position: 'absolute' as const,
|
|
left: Math.min(boxSelectStart.x, boxSelectEnd.x),
|
|
top: Math.min(boxSelectStart.y, boxSelectEnd.y),
|
|
width: Math.abs(boxSelectEnd.x - boxSelectStart.x),
|
|
height: Math.abs(boxSelectEnd.y - boxSelectStart.y),
|
|
border: '2px dashed #3b82f6',
|
|
backgroundColor: 'rgba(59, 130, 246, 0.1)',
|
|
pointerEvents: 'none' as const,
|
|
zIndex: 1000,
|
|
} : null;
|
|
|
|
// Calculate drop indicator position
|
|
const dropIndicatorStyle = useMemo(() => {
|
|
if (!hoveredItemId || !dropSide || !activeId) return null;
|
|
|
|
const element = itemRefs.current.get(hoveredItemId);
|
|
const container = containerRef.current;
|
|
if (!element || !container) return null;
|
|
|
|
const itemRect = element.getBoundingClientRect();
|
|
const containerRect = container.getBoundingClientRect();
|
|
|
|
const top = itemRect.top - containerRect.top;
|
|
const height = itemRect.height;
|
|
const left = dropSide === 'left'
|
|
? itemRect.left - containerRect.left - itemGap / 2
|
|
: itemRect.right - containerRect.left + itemGap / 2;
|
|
|
|
return {
|
|
position: 'absolute' as const,
|
|
left: `${left}px`,
|
|
top: `${top}px`,
|
|
width: '4px',
|
|
height: `${height}px`,
|
|
backgroundColor: 'rgba(96, 165, 250, 0.8)',
|
|
borderRadius: '2px',
|
|
zIndex: 1001,
|
|
pointerEvents: 'none' as const,
|
|
};
|
|
}, [hoveredItemId, dropSide, activeId, itemGap, zoomLevel]);
|
|
|
|
const handleWheelWhileDragging = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
|
|
if (!activeId) {
|
|
return;
|
|
}
|
|
|
|
const scrollElement = getScrollElement();
|
|
if (!scrollElement) {
|
|
return;
|
|
}
|
|
|
|
scrollElement.scrollBy({
|
|
top: event.deltaY,
|
|
left: event.deltaX,
|
|
});
|
|
|
|
event.preventDefault();
|
|
}, [activeId, getScrollElement]);
|
|
|
|
return (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={closestCenter}
|
|
onDragStart={handleDragStart}
|
|
onDragEnd={handleDragEnd}
|
|
onDragCancel={handleDragCancel}
|
|
>
|
|
<Box
|
|
ref={containerRef}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onWheel={handleWheelWhileDragging}
|
|
style={{
|
|
// Basic container styles
|
|
width: '100%',
|
|
height: '100%',
|
|
position: 'relative',
|
|
overflow: 'hidden',
|
|
}}
|
|
>
|
|
{/* Selection box overlay */}
|
|
{selectionBoxStyle && <div style={selectionBoxStyle} />}
|
|
|
|
{/* Global drop indicator */}
|
|
{dropIndicatorStyle && <div style={dropIndicatorStyle} />}
|
|
|
|
<div
|
|
style={{
|
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
|
width: '100%',
|
|
maxWidth: `${gridWidth}px`,
|
|
position: 'relative',
|
|
margin: '0 auto',
|
|
}}
|
|
>
|
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
|
const startIndex = virtualRow.index * itemsPerRow;
|
|
const endIndex = Math.min(startIndex + itemsPerRow, visibleItems.length);
|
|
const rowItems = visibleItems.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: `calc(${GRID_CONSTANTS.ITEM_GAP} * ${zoomLevel})`,
|
|
justifyContent: 'flex-start',
|
|
height: '100%',
|
|
alignItems: 'center',
|
|
position: 'relative'
|
|
}}
|
|
>
|
|
{rowItems.map((item, itemIndex) => {
|
|
const actualIndex = startIndex + itemIndex;
|
|
return (
|
|
<DraggableItem
|
|
key={item.id}
|
|
item={item}
|
|
index={actualIndex}
|
|
itemRefs={itemRefs}
|
|
boxSelectedPageIds={boxSelectedPageIds}
|
|
clearBoxSelection={clearBoxSelection}
|
|
getBoxSelection={getBoxSelection}
|
|
activeId={activeId}
|
|
getThumbnailData={getThumbnailData}
|
|
onUpdateDropTarget={setHoveredItemId}
|
|
renderItem={renderItem}
|
|
zoomLevel={zoomLevel}
|
|
/>
|
|
);
|
|
})}
|
|
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</Box>
|
|
|
|
{/* Drag Overlay */}
|
|
<DragOverlay>
|
|
{activeId && (
|
|
<div style={{ position: 'relative', cursor: 'grabbing' }}>
|
|
{/* Multi-page badge */}
|
|
{boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && (
|
|
<div
|
|
style={{
|
|
position: 'absolute',
|
|
top: '-8px',
|
|
right: '-8px',
|
|
backgroundColor: '#3b82f6',
|
|
color: 'white',
|
|
borderRadius: '50%',
|
|
width: '32px',
|
|
height: '32px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: '14px',
|
|
fontWeight: 'bold',
|
|
boxShadow: '0 2px 8px rgba(0,0,0,0.2)',
|
|
zIndex: 1001
|
|
}}
|
|
>
|
|
{boxSelectedPageIds.length}
|
|
</div>
|
|
)}
|
|
{/* Just the thumbnail image */}
|
|
{dragPreview ? (
|
|
<img
|
|
src={dragPreview.src}
|
|
alt="Dragging"
|
|
style={{
|
|
width: `calc(20rem * ${zoomLevel})`,
|
|
height: `calc(20rem * ${zoomLevel})`,
|
|
objectFit: 'contain',
|
|
transform: `rotate(${dragPreview.rotation}deg)`,
|
|
pointerEvents: 'none',
|
|
opacity: 0.5,
|
|
}}
|
|
/>
|
|
) : (
|
|
<div style={{
|
|
width: `calc(20rem * ${zoomLevel})`,
|
|
height: `calc(20rem * ${zoomLevel})`,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
fontSize: '48px',
|
|
opacity: 0.5,
|
|
}}>
|
|
📄
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
);
|
|
};
|
|
|
|
export default DragDropGrid;
|