This commit is contained in:
Reece 2025-10-28 15:15:20 +00:00
parent 7de8347472
commit 3bd1c9f83a
2 changed files with 515 additions and 381 deletions

View File

@ -1,7 +1,7 @@
import React, { useRef, useEffect, useState, useCallback, useMemo } from 'react';
import { Box } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
import { GRID_CONSTANTS } from './constants';
import {
DndContext,
DragEndEvent,
@ -24,20 +24,7 @@ interface DragDropItem {
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,
activeDragIds: string[],
justMoved: boolean,
isOver: boolean,
dragHandleProps?: any,
zoomLevel?: number,
) => React.ReactNode;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
zoomLevel?: number;
}
@ -154,6 +141,7 @@ function resolveTargetIndex<T extends DragDropItem>(
return null;
}
// Lightweight wrapper that handles dnd-kit hooks for each visible item
interface DraggableItemProps<T extends DragDropItem> {
item: T;
index: number;
@ -166,38 +154,11 @@ interface DraggableItemProps<T extends DragDropItem> {
justMoved: boolean;
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,
activeDragIds: string[],
justMoved: boolean,
isOver: boolean,
dragHandleProps?: any,
zoomLevel?: number,
) => React.ReactNode;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
zoomLevel: number;
}
const DraggableItem = <T extends DragDropItem>({
item,
index,
itemRefs,
boxSelectedPageIds,
clearBoxSelection,
getBoxSelection,
activeId,
activeDragIds,
justMoved,
getThumbnailData,
renderItem,
onUpdateDropTarget,
zoomLevel,
}: DraggableItemProps<T>) => {
const DraggableItem = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
id: item.id,
data: {
@ -214,19 +175,20 @@ const DraggableItem = <T extends DragDropItem>({
if (imgElement?.src) {
return {
src: imgElement.src,
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0,
rotation: imgElement.dataset.originalRotation ? parseInt(imgElement.dataset.originalRotation) : 0
};
}
return null;
},
},
}
}
});
const { setNodeRef: setDroppableRef, isOver } = useDroppable({
id: item.id,
data: { index, pageNumber: index + 1 },
data: { index, pageNumber: index + 1 }
});
// Notify parent when hover state changes
React.useEffect(() => {
if (isOver) {
onUpdateDropTarget(item.id);
@ -238,149 +200,140 @@ const DraggableItem = <T extends DragDropItem>({
const setNodeRef = useCallback((element: HTMLDivElement | null) => {
setDraggableRef(element);
setDroppableRef(element);
if (element) {
itemRefs.current.set(item.id, element);
} else {
itemRefs.current.delete(item.id);
}
}, [item.id, setDraggableRef, setDroppableRef]);
return renderItem(
item,
index,
itemRefs,
boxSelectedPageIds,
clearBoxSelection,
getBoxSelection,
activeId,
activeDragIds,
justMoved,
isOver,
{
...attributes,
...listeners,
ref: setNodeRef,
onPointerDown: (event: React.PointerEvent) => {
event.preventDefault();
listeners.onPointerDown?.(event as any);
},
},
zoomLevel,
);
};
interface DragOverlayContentProps<T extends DragDropItem> {
activeItem: T | null;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
zoomLevel: number;
}
const DragOverlayContent = <T extends DragDropItem>({ activeItem, getThumbnailData, zoomLevel }: DragOverlayContentProps<T>) => {
const thumbnailData = activeItem && getThumbnailData ? getThumbnailData(activeItem.id) : null;
if (!activeItem) {
return null;
}
}, [setDraggableRef, setDroppableRef]);
return (
<div
style={{
transform: zoomLevel !== 1 ? `scale(${zoomLevel})` : undefined,
transformOrigin: 'top left',
pointerEvents: 'none',
backgroundColor: 'var(--mantine-color-body)',
border: '1px solid var(--mantine-color-border)',
borderRadius: '8px',
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.2)',
}}
>
{thumbnailData?.src ? (
<img
src={thumbnailData.src}
alt="drag-preview"
style={{
display: 'block',
width: `calc(${GRID_CONSTANTS.ITEM_WIDTH} * ${zoomLevel})`,
borderRadius: '6px',
}}
/>
) : (
<div
style={{
width: GRID_CONSTANTS.ITEM_WIDTH,
height: GRID_CONSTANTS.ITEM_HEIGHT,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--mantine-color-dimmed)',
}}
>
Moving page...
</div>
)}
</div>
<>
{renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)}
</>
);
};
const DragDropGrid = <T extends DragDropItem>({
items,
onReorderPages,
renderItem,
onReorderPages,
getThumbnailData,
zoomLevel = 1,
zoomLevel = 1.0,
}: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const containerRef = useRef<HTMLDivElement>(null);
const boxSelectionRef = useRef<Set<string>>(new Set());
const getBoxSelection = useCallback(() => Array.from(boxSelectionRef.current), []);
const clearBoxSelection = useCallback(() => {
boxSelectionRef.current.clear();
const getScrollElement = useCallback(() => {
return containerRef.current?.closest('[data-scrolling-container]') as HTMLElement | null;
}, []);
const [itemsPerRow, setItemsPerRow] = useState(4);
const overscan = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => {
const filtered: T[] = [];
const indexMap: number[] = [];
items.forEach((item, index) => {
if (!item.isPlaceholder) {
filtered.push(item);
indexMap.push(index);
}
});
return { filteredItems: filtered, filteredToOriginalIndex: indexMap };
}, [items]);
// 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[]>([]);
const [justMovedIds, setJustMovedIds] = useState<string[]>([]);
const highlightTimeoutRef = useRef<number | null>(null);
// Drag state
const [activeId, setActiveId] = useState<string | null>(null);
const [activeDragIds, setActiveDragIds] = useState<string[]>([]);
const [justMovedIds, setJustMovedIds] = useState<Set<string>>(new Set());
const [dropHint, setDropHint] = useState<DropHint>({ hoveredId: null, dropSide: null });
const [dropTargetId, setDropTargetId] = 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<DropSide>(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: 8,
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(() => {
const hint = resolveDropHint(activeId, itemRefs, cursorX, cursorY);
setHoveredItemId(hint.hoveredId);
setDropSide(hint.dropSide);
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;
if (!containerRef.current) return 4; // Default fallback
const containerWidth = containerRef.current.offsetWidth;
if (containerWidth === 0) return 4;
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;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx * zoomLevel;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx * zoomLevel;
const availableWidth = containerWidth - ITEM_GAP;
// 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);
}, []);
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);
@ -390,159 +343,285 @@ const DragDropGrid = <T extends DragDropItem>({
window.removeEventListener('resize', updateLayout);
resizeObserver.disconnect();
};
}, [calculateItemsPerRow]);
const filteredItems = useMemo(() => items.filter(item => !item.isPlaceholder), [items]);
const filteredToOriginalIndex = useMemo(() => {
const result: number[] = [];
items.forEach((item, index) => {
if (!item.isPlaceholder) {
result.push(index);
}
});
return result;
}, [items]);
}, [calculateItemsPerRow, zoomLevel]);
// Virtualization with react-virtual library
const rowVirtualizer = useVirtualizer({
count: Math.ceil(filteredItems.length / itemsPerRow),
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
count: Math.ceil(visibleItems.length / itemsPerRow),
getScrollElement,
estimateSize: () => {
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx * zoomLevel;
},
overscan,
overscan: OVERSCAN,
});
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
// Re-measure virtualizer when zoom or items per row changes
useEffect(() => {
rowVirtualizer.measure();
}, [zoomLevel, itemsPerRow]);
const activeItem = activeId ? items.find(item => item.id === activeId) || null : null;
// Cleanup highlight timeout on unmount
useEffect(() => {
return () => {
if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current);
highlightTimeoutRef.current = null;
}
};
}, []);
const handleDragStart = useCallback((event: DragStartEvent) => {
const { active } = event;
setActiveId(active.id as string);
// Box selection handlers
const handleMouseDown = useCallback((e: React.MouseEvent) => {
if (e.button !== 0) return; // Only respond to primary button
const activeElement = itemRefs.current.get(active.id as string);
if (activeElement) {
activeElement.style.opacity = '0.2';
}
const container = containerRef.current;
if (!container) return;
const selectedIds = getBoxSelection();
if (selectedIds.includes(active.id as string)) {
setActiveDragIds(selectedIds);
} else {
setActiveDragIds([active.id as string]);
}
}, [getBoxSelection]);
const clickTarget = e.target as Node;
let clickedPageId: string | null = null;
const handleDragEnd = useCallback((event: DragEndEvent) => {
const { active } = event;
const activeIndex = filteredItems.findIndex(item => item.id === active.id);
const fallbackIndex = activeIndex !== -1 ? filteredToOriginalIndex[activeIndex] : null;
itemRefs.current.forEach((element, pageId) => {
if (element.contains(clickTarget)) {
clickedPageId = pageId;
}
});
if (!dropHint.hoveredId && fallbackIndex === null) {
setDropHint({ hoveredId: null, dropSide: null });
setActiveId(null);
setActiveDragIds([]);
if (clickedPageId) {
// Clicking directly on a page shouldn't initiate box selection
// but clear previous box selection if clicking outside current group
if (boxSelectedPageIds.length > 0 && !boxSelectedPageIds.includes(clickedPageId)) {
setBoxSelectedPageIds([]);
}
return;
}
const targetIndex = resolveTargetIndex(
dropHint.hoveredId,
dropHint.dropSide,
filteredItems,
e.preventDefault();
const rect = container.getBoundingClientRect();
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([]);
}, [boxSelectedPageIds]);
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;
if (!activeData) return;
const sourcePageNumber = activeData.pageNumber;
const overData = over?.data.current;
let targetIndex = resolveTargetIndex(
hoveredItemId,
finalDropSide,
visibleItems,
filteredToOriginalIndex,
items.length,
fallbackIndex,
overData ? overData.index : null
);
if (targetIndex !== null) {
const pageNumber = filteredItems.findIndex(item => item.id === active.id) + 1;
if (pageNumber > 0) {
onReorderPages(pageNumber, targetIndex, activeDragIds);
if (targetIndex === null) return;
if (targetIndex < 0) targetIndex = 0;
if (targetIndex > items.length) targetIndex = items.length;
const updatedJustMoved = new Set<string>(activeDragIds);
setJustMovedIds(updatedJustMoved);
// Check if this page is box-selected
const isBoxSelected = boxSelectedPageIds.includes(active.id as string);
const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined;
setTimeout(() => {
setJustMovedIds(prev => {
const next = new Set(prev);
activeDragIds.forEach(id => next.delete(id));
return next;
});
}, 500);
}
// Call reorder with page number and target index
onReorderPages(sourcePageNumber, targetIndex, pagesToDrag);
// Highlight moved pages briefly
const movedIds = pagesToDrag ?? [active.id as string];
setJustMovedIds(movedIds);
if (highlightTimeoutRef.current) {
window.clearTimeout(highlightTimeoutRef.current);
}
highlightTimeoutRef.current = window.setTimeout(() => {
setJustMovedIds([]);
highlightTimeoutRef.current = null;
}, 1200);
setDropHint({ hoveredId: null, dropSide: null });
setActiveId(null);
setActiveDragIds([]);
const activeElement = itemRefs.current.get(active.id as string);
if (activeElement) {
activeElement.style.opacity = '';
// Clear box selection after drag
if (pagesToDrag) {
clearBoxSelection();
}
}, [activeDragIds, dropHint, filteredItems, filteredToOriginalIndex, items.length, onReorderPages]);
}, [boxSelectedPageIds, dropSide, hoveredItemId, visibleItems, filteredToOriginalIndex, items, onReorderPages, clearBoxSelection]);
const handleDragCancel = useCallback(() => {
setDropHint({ hoveredId: null, dropSide: null });
setActiveId(null);
setActiveDragIds([]);
}, []);
// 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;
const handleDragMove = useCallback((event: DragStartEvent | DragEndEvent) => {
const { active, delta } = event;
if (!active) return;
// 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;
const referenceElement = itemRefs.current.get(active.id as string);
if (!referenceElement) return;
// Calculate drop indicator position
const dropIndicatorStyle = useMemo(() => {
if (!hoveredItemId || !dropSide || !activeId) return null;
const referenceRect = referenceElement.getBoundingClientRect();
const cursorX = referenceRect.left + delta.x;
const cursorY = referenceRect.top + delta.y;
const element = itemRefs.current.get(hoveredItemId);
const container = containerRef.current;
if (!element || !container) return null;
const hint = resolveDropHint(active.id as string, itemRefs, cursorX, cursorY);
setDropHint(hint);
}, []);
const itemRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
useEffect(() => {
const scrollContainer = containerRef.current?.closest('[data-scrolling-container]');
if (!scrollContainer) return;
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;
const handleWheel = (event: WheelEvent) => {
if (event.ctrlKey || event.metaKey) {
event.preventDefault();
}
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]);
scrollContainer.addEventListener('wheel', handleWheel, { passive: false });
const activeDragIds = useMemo(() => {
if (!activeId) return [];
if (boxSelectedPageIds.includes(activeId)) {
return boxSelectedPageIds;
}
return [activeId];
}, [activeId, boxSelectedPageIds]);
return () => {
scrollContainer.removeEventListener('wheel', handleWheel);
};
}, []);
const getDropIndicatorStyle = useCallback((itemId: string) => {
if (dropHint.hoveredId !== itemId) {
return {};
const handleWheelWhileDragging = useCallback((event: React.WheelEvent<HTMLDivElement>) => {
if (!activeId) {
return;
}
if (dropHint.dropSide === 'left') {
return {
boxShadow: '-4px 0 0 0 var(--mantine-primary-color-filled)',
};
const scrollElement = getScrollElement();
if (!scrollElement) {
return;
}
if (dropHint.dropSide === 'right') {
return {
boxShadow: '4px 0 0 0 var(--mantine-primary-color-filled)',
};
}
scrollElement.scrollBy({
top: event.deltaY,
left: event.deltaX,
});
return {};
}, [dropHint]);
event.preventDefault();
}, [activeId, getScrollElement]);
return (
<DndContext
@ -551,111 +630,152 @@ const DragDropGrid = <T extends DragDropItem>({
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
onDragMove={handleDragMove}
>
<Box
ref={containerRef}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onWheel={handleWheelWhileDragging}
style={{
// Basic container styles
width: '100%',
height: '100%',
position: 'relative',
overflow: 'hidden',
}}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
margin: '0 auto',
maxWidth: `${gridWidth}px`,
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * itemsPerRow;
const endIndex = Math.min(startIndex + itemsPerRow, filteredItems.length);
const rowItems = filteredItems.slice(startIndex, endIndex);
{/* Selection box overlay */}
{selectionBoxStyle && <div style={selectionBoxStyle} />}
return (
{/* 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
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
display: 'flex',
gap: `calc(${GRID_CONSTANTS.ITEM_GAP} * ${zoomLevel})`,
justifyContent: 'flex-start',
height: '100%',
alignItems: 'center',
position: 'relative'
}}
>
<div
style={{
display: 'flex',
gap: GRID_CONSTANTS.ITEM_GAP,
justifyContent: 'flex-start',
height: '100%',
alignItems: 'center',
position: 'relative',
}}
>
{rowItems.map((item, itemIndex) => {
const actualIndex = startIndex + itemIndex;
const originalIndex = filteredToOriginalIndex[actualIndex];
return (
<React.Fragment key={item.id}>
<DraggableItem
item={item}
index={originalIndex}
itemRefs={itemRefs}
boxSelectedPageIds={getBoxSelection()}
clearBoxSelection={clearBoxSelection}
getBoxSelection={getBoxSelection}
activeId={activeId}
activeDragIds={activeDragIds}
justMoved={justMovedIds.has(item.id)}
getThumbnailData={getThumbnailData}
zoomLevel={zoomLevel}
onUpdateDropTarget={setDropTargetId}
renderItem={(...args) => {
const node = renderItem(...args);
const style = getDropIndicatorStyle(item.id);
return <div style={style}>{node}</div>;
}}
/>
</React.Fragment>
);
})}
{dropTargetId && dropHint.hoveredId === dropTargetId && dropHint.dropSide && (
<div
style={{
position: 'absolute',
top: 0,
bottom: 0,
left: dropHint.dropSide === 'left' ? '-0.25rem' : undefined,
right: dropHint.dropSide === 'right' ? '-0.25rem' : undefined,
width: '0.25rem',
backgroundColor: 'var(--mantine-primary-color-filled)',
borderRadius: '9999px',
}}
{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}
activeDragIds={activeDragIds}
justMoved={justMovedIds.includes(item.id)}
getThumbnailData={getThumbnailData}
onUpdateDropTarget={setHoveredItemId}
renderItem={renderItem}
zoomLevel={zoomLevel}
/>
)}
</div>
</div>
);
})}
</div>
</Box>
);
})}
<DragOverlay dropAnimation={null}>
<DragOverlayContent
activeItem={activeItem || null}
getThumbnailData={getThumbnailData}
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 type { DragDropItem };
export default DragDropGrid;

View File

@ -75,6 +75,7 @@ const PageEditor = ({
const fileObjectsRef = useRef(new Map<FileId, any>());
const pagePositionCacheRef = useRef<Map<string, number>>(new Map());
const pageNeighborCacheRef = useRef<Map<string, string | null>>(new Map());
const gridItemRefsRef = useRef<React.MutableRefObject<Map<string, HTMLDivElement>> | null>(null);
const pageEditorFiles = useMemo(() => {
const cache = fileObjectsRef.current;
@ -1094,45 +1095,57 @@ const PageEditor = ({
}}
>
{(() => {
// Calculate remToPx once outside the map to avoid layout thrashing
const containerWidth = containerDimensions.width;
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const ITEM_HEIGHT = parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
const refsMap = gridItemRefsRef.current?.current;
const containerEl = gridContainerRef.current;
if (!refsMap || !containerEl) {
return null;
}
const containerRect = containerEl.getBoundingClientRect();
return Array.from(splitPositions).map((position) => {
const currentPage = displayedPages[position];
if (!currentPage) {
return null;
}
// Calculate items per row using DragDropGrid's logic
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
const itemsPerRow = Math.max(1, Math.floor(availableWidth / itemWithGap));
const currentEl = refsMap.get(currentPage.id);
if (!currentEl) {
return null;
}
// Calculate position within the grid (same as DragDropGrid)
const row = Math.floor(position / itemsPerRow);
const col = position % itemsPerRow;
const currentRect = currentEl.getBoundingClientRect();
const nextPage = displayedPages[position + 1];
let lineLeft = currentRect.right;
// Position split line between pages (after the current page)
// Calculate grid centering offset (same as DragDropGrid)
const gridWidth = itemsPerRow * ITEM_WIDTH + (itemsPerRow - 1) * ITEM_GAP;
const gridOffset = Math.max(0, (containerWidth - gridWidth) / 2);
if (nextPage) {
const nextEl = refsMap.get(nextPage.id);
if (nextEl) {
const nextRect = nextEl.getBoundingClientRect();
const sameRow = Math.abs(nextRect.top - currentRect.top) < currentRect.height / 2;
if (sameRow) {
lineLeft = (currentRect.right + nextRect.left) / 2;
} else {
lineLeft = currentRect.right + nextRect.width * 0.1;
}
}
} else {
lineLeft = currentRect.right + currentRect.width * 0.1;
}
const leftPosition = gridOffset + col * itemWithGap + ITEM_WIDTH + (ITEM_GAP / 2);
const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05) + ITEM_GAP; // Center vertically (5% offset since page is 90% height) + gap offset
return (
<div
key={`split-${position}`}
style={{
position: 'absolute',
left: leftPosition,
top: topPosition,
width: '1px',
height: `calc(${GRID_CONSTANTS.ITEM_HEIGHT} * 0.9)`, // Match page container height (90%)
borderLeft: '1px dashed #3b82f6'
}}
/>
);
return (
<div
key={`split-${position}`}
style={{
position: 'absolute',
left: `${lineLeft - containerRect.left}px`,
top: `${currentRect.top - containerRect.top}px`,
width: '1px',
height: `${currentRect.height}px`,
borderLeft: '1px dashed #3b82f6',
}}
/>
);
});
})()}
</div>
@ -1151,6 +1164,7 @@ const PageEditor = ({
};
}}
renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, _getBoxSelection, _activeId, activeDragIds, justMoved, _isOver, dragHandleProps, zoomLevel) => {
gridItemRefsRef.current = refs;
const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0;
const isBoxSelected = boxSelectedIds.includes(page.id);
return (