diff --git a/frontend/src/core/components/pageEditor/DragDropGrid.tsx b/frontend/src/core/components/pageEditor/DragDropGrid.tsx index ce93bfd7cc..c7f7a84b4e 100644 --- a/frontend/src/core/components/pageEditor/DragDropGrid.tsx +++ b/frontend/src/core/components/pageEditor/DragDropGrid.tsx @@ -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 { items: T[]; onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void; - renderItem: ( - item: T, - index: number, - refs: React.MutableRefObject>, - 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>, 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( return null; } +// Lightweight wrapper that handles dnd-kit hooks for each visible item interface DraggableItemProps { item: T; index: number; @@ -166,38 +154,11 @@ interface DraggableItemProps { justMoved: boolean; getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null; onUpdateDropTarget: (itemId: string | null) => void; - renderItem: ( - item: T, - index: number, - refs: React.MutableRefObject>, - 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>, 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 = ({ - item, - index, - itemRefs, - boxSelectedPageIds, - clearBoxSelection, - getBoxSelection, - activeId, - activeDragIds, - justMoved, - getThumbnailData, - renderItem, - onUpdateDropTarget, - zoomLevel, -}: DraggableItemProps) => { +const DraggableItem = ({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps) => { const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({ id: item.id, data: { @@ -214,19 +175,20 @@ const DraggableItem = ({ 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 = ({ 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 { - activeItem: T | null; - getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null; - zoomLevel: number; -} - -const DragOverlayContent = ({ activeItem, getThumbnailData, zoomLevel }: DragOverlayContentProps) => { - const thumbnailData = activeItem && getThumbnailData ? getThumbnailData(activeItem.id) : null; - - if (!activeItem) { - return null; - } + }, [setDraggableRef, setDroppableRef]); return ( -
- {thumbnailData?.src ? ( - drag-preview - ) : ( -
- Moving page... -
- )} -
+ <> + {renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)} + ); }; const DragDropGrid = ({ items, - onReorderPages, renderItem, + onReorderPages, getThumbnailData, - zoomLevel = 1, + zoomLevel = 1.0, }: DragDropGridProps) => { const itemRefs = useRef>(new Map()); const containerRef = useRef(null); - const boxSelectionRef = useRef>(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([]); + const [justMovedIds, setJustMovedIds] = useState([]); + const highlightTimeoutRef = useRef(null); + + // Drag state const [activeId, setActiveId] = useState(null); - const [activeDragIds, setActiveDragIds] = useState([]); - const [justMovedIds, setJustMovedIds] = useState>(new Set()); - const [dropHint, setDropHint] = useState({ hoveredId: null, dropSide: null }); - const [dropTargetId, setDropTargetId] = useState(null); + const [dragPreview, setDragPreview] = useState<{ src: string; rotation: number } | null>(null); + const [hoveredItemId, setHoveredItemId] = useState(null); + const [dropSide, setDropSide] = useState(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 = ({ 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(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) => { + 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 ( ({ onDragStart={handleDragStart} onDragEnd={handleDragEnd} onDragCancel={handleDragCancel} - onDragMove={handleDragMove} > -
- {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 &&
} - return ( + {/* Global drop indicator */} + {dropIndicatorStyle &&
} + +
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const startIndex = virtualRow.index * itemsPerRow; + const endIndex = Math.min(startIndex + itemsPerRow, visibleItems.length); + const rowItems = visibleItems.slice(startIndex, endIndex); + + return ( +
-
- {rowItems.map((item, itemIndex) => { - const actualIndex = startIndex + itemIndex; - const originalIndex = filteredToOriginalIndex[actualIndex]; - - return ( - - { - const node = renderItem(...args); - const style = getDropIndicatorStyle(item.id); - return
{node}
; - }} - /> -
- ); - })} - - {dropTargetId && dropHint.hoveredId === dropTargetId && dropHint.dropSide && ( -
{ + const actualIndex = startIndex + itemIndex; + return ( + - )} -
-
- ); - })} -
- + ); + })} - - +
+
+ ); + })} +
+ + + {/* Drag Overlay */} + + {activeId && ( +
+ {/* Multi-page badge */} + {boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && ( +
+ {boxSelectedPageIds.length} +
+ )} + {/* Just the thumbnail image */} + {dragPreview ? ( + Dragging + ) : ( +
+ ?? +
+ )} +
+ )}
); }; -export type { DragDropItem }; export default DragDropGrid; diff --git a/frontend/src/core/components/pageEditor/PageEditor.tsx b/frontend/src/core/components/pageEditor/PageEditor.tsx index fd1f57f491..632fbfdf4b 100644 --- a/frontend/src/core/components/pageEditor/PageEditor.tsx +++ b/frontend/src/core/components/pageEditor/PageEditor.tsx @@ -75,6 +75,7 @@ const PageEditor = ({ const fileObjectsRef = useRef(new Map()); const pagePositionCacheRef = useRef>(new Map()); const pageNeighborCacheRef = useRef>(new Map()); + const gridItemRefsRef = useRef> | 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 ( -
- ); + return ( +
+ ); }); })()}
@@ -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 (