diff --git a/frontend/package-lock.json b/frontend/package-lock.json index e74347837..18d0fb3d0 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,7 @@ "license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", + "@dnd-kit/core": "^6.3.1", "@embedpdf/core": "^1.3.14", "@embedpdf/engines": "^1.3.14", "@embedpdf/plugin-annotation": "^1.3.14", @@ -496,6 +497,63 @@ "node": ">=18" } }, + "node_modules/@dnd-kit/accessibility": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz", + "integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/accessibility/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@dnd-kit/core": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", + "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", + "license": "MIT", + "dependencies": { + "@dnd-kit/accessibility": "^3.1.1", + "@dnd-kit/utilities": "^3.2.2", + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/core/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/@dnd-kit/utilities": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz", + "integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@dnd-kit/utilities/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/@embedpdf/core": { "version": "1.3.14", "resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.14.tgz", diff --git a/frontend/package.json b/frontend/package.json index f11184471..95812f400 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,6 +6,7 @@ "proxy": "http://localhost:8080", "dependencies": { "@atlaskit/pragmatic-drag-and-drop": "^1.7.7", + "@dnd-kit/core": "^6.3.1", "@embedpdf/core": "^1.3.14", "@embedpdf/engines": "^1.3.14", "@embedpdf/plugin-annotation": "^1.3.14", diff --git a/frontend/src/components/pageEditor/DragDropGrid.tsx b/frontend/src/components/pageEditor/DragDropGrid.tsx index f73b77e4b..61c2f6a9e 100644 --- a/frontend/src/components/pageEditor/DragDropGrid.tsx +++ b/frontend/src/components/pageEditor/DragDropGrid.tsx @@ -2,6 +2,20 @@ import React, { useRef, useEffect, useState, useCallback } from 'react'; import { Box } from '@mantine/core'; import { useVirtualizer } from '@tanstack/react-virtual'; import { GRID_CONSTANTS } from './constants'; +import { + DndContext, + DragEndEvent, + DragStartEvent, + DragOverEvent, + DragOverlay, + useSensor, + useSensors, + PointerSensor, + closestCenter, + useDndMonitor, + useDraggable, + useDroppable, +} from '@dnd-kit/core'; interface DragDropItem { id: string; @@ -15,13 +29,93 @@ interface DragDropGridProps { selectionMode: boolean; isAnimating: boolean; onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void; - renderItem: (item: T, index: number, refs: React.MutableRefObject>) => React.ReactNode; + renderItem: (item: T, index: number, refs: React.MutableRefObject>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, isOver: boolean, dropSide: 'left' | 'right' | null, dragHandleProps?: any) => React.ReactNode; renderSplitMarker?: (item: T, index: number) => React.ReactNode; + getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null; } +// Lightweight wrapper that handles dnd-kit hooks for each visible item +interface DraggableItemProps { + item: T; + index: number; + itemRefs: React.MutableRefObject>; + boxSelectedPageIds: string[]; + clearBoxSelection: () => void; + getBoxSelection: () => string[]; + activeId: string | null; + getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null; + renderItem: (item: T, index: number, refs: React.MutableRefObject>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, isOver: boolean, dropSide: 'left' | 'right' | null, dragHandleProps?: any) => React.ReactNode; +} + +const DraggableItem = ({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, getThumbnailData, renderItem }: DraggableItemProps) => { + const elementRef = React.useRef(null); + const [dropSide, setDropSide] = React.useState<'left' | 'right' | null>(null); + + 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 } + }); + + // Track cursor position when hovered to determine drop side + React.useEffect(() => { + if (!isOver || !elementRef.current) { + setDropSide(null); + return; + } + + const handlePointerMove = (e: PointerEvent) => { + if (!elementRef.current) return; + const rect = elementRef.current.getBoundingClientRect(); + const midpoint = rect.left + rect.width / 2; + setDropSide(e.clientX < midpoint ? 'left' : 'right'); + }; + + window.addEventListener('pointermove', handlePointerMove); + return () => window.removeEventListener('pointermove', handlePointerMove); + }, [isOver]); + + const setNodeRef = useCallback((element: HTMLDivElement | null) => { + elementRef.current = element; + setDraggableRef(element); + setDroppableRef(element); + }, [setDraggableRef, setDroppableRef]); + + return ( + <> + {renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, isOver, dropSide, { ref: setNodeRef, ...attributes, ...listeners })} + + ); +}; + const DragDropGrid = ({ items, renderItem, + onReorderPages, + getThumbnailData, }: DragDropGridProps) => { const itemRefs = useRef>(new Map()); const containerRef = useRef(null); @@ -29,6 +123,21 @@ const DragDropGrid = ({ // 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([]); + + // Drag state + const [activeId, setActiveId] = useState(null); + const [dragPreview, setDragPreview] = useState<{ src: string; rotation: number } | null>(null); + + // Configure sensors for dnd-kit + const sensors = useSensors( + useSensor(PointerSensor) + ); + // Responsive grid configuration const [itemsPerRow, setItemsPerRow] = useState(4); const OVERSCAN = visibleItems.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL; @@ -89,21 +198,199 @@ const DragDropGrid = ({ overscan: OVERSCAN, }); + // 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); + }, []); + + // Handle drag end + const handleDragEnd = useCallback((event: DragEndEvent) => { + const { active, over } = event; + + setActiveId(null); + setDragPreview(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; + + // Calculate drop position from final cursor position + const overElement = itemRefs.current.get(over.id as string); + if (overElement) { + const activatorEvent = (event as any).activatorEvent; + const cursorX = activatorEvent ? activatorEvent.clientX + (event.delta?.x || 0) : null; + if (cursorX) { + const rect = overElement.getBoundingClientRect(); + const midpoint = rect.left + rect.width / 2; + if (cursorX >= midpoint) { + 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, onReorderPages, clearBoxSelection]); + // Calculate optimal width for centering 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; + // 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; + return ( - + + {/* Selection box overlay */} + {selectionBoxStyle &&
} +
({ {rowItems.map((item, itemIndex) => { const actualIndex = startIndex + itemIndex; return ( - - {/* Item */} - {renderItem(item, actualIndex, itemRefs)} - + ); })} @@ -156,6 +451,66 @@ const DragDropGrid = ({ })}
+ + {/* Drag Overlay */} + + {activeId && ( +
+ {/* Multi-page badge */} + {boxSelectedPageIds.includes(activeId) && boxSelectedPageIds.length > 1 && ( +
+ {boxSelectedPageIds.length} +
+ )} + {/* Just the thumbnail image */} + {dragPreview ? ( + Dragging + ) : ( +
+ 📄 +
+ )} +
+ )} +
+ ); }; diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index e036c8b6a..87f53d055 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -3,7 +3,7 @@ import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core"; import { useFileState, useFileActions } from "../../contexts/FileContext"; import { useNavigationGuard } from "../../contexts/NavigationContext"; import { usePageEditor } from "../../contexts/PageEditorContext"; -import { PDFDocument, PageEditorFunctions } from "../../types/pageEditor"; +import { PDFDocument, PDFPage, PageEditorFunctions } from "../../types/pageEditor"; import { pdfExportService } from "../../services/pdfExportService"; import { documentManipulationService } from "../../services/documentManipulationService"; import { exportProcessedDocumentsToFiles } from "../../services/pdfExportHelpers"; @@ -604,9 +604,14 @@ const PageEditor = ({ const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => { if (!displayDocument) return; + console.log('=== HANDLE REORDER PAGES ==='); + console.log('selectedPageIds:', selectedPageIds); + // Convert selectedPageIds to page numbers for the reorder command const selectedPages = selectedPageIds ? getPageNumbersFromIds(selectedPageIds) : undefined; + console.log('selectedPages (converted to numbers):', selectedPages); + const reorderCommand = new ReorderPagesCommand( sourcePageNumber, targetIndex, @@ -961,8 +966,17 @@ const PageEditor = ({ selectionMode={selectionMode} isAnimating={isAnimating} onReorderPages={handleReorderPages} - renderItem={(page, index, refs) => { + getThumbnailData={(pageId) => { + const page = displayDocument.pages.find(p => p.id === pageId); + if (!page?.thumbnail) return null; + return { + src: page.thumbnail, + rotation: page.rotation || 0 + }; + }} + renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, getBoxSelection, activeId, isOver, dropSide, dragHandleProps) => { const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0; + const isBoxSelected = boxSelectedIds.includes(page.id); return ( void; + getBoxSelection?: () => string[]; + activeId: string | null; + isOver: boolean; + dropSide: 'left' | 'right' | null; pageRefs: React.MutableRefObject>; + dragHandleProps?: any; onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void; onTogglePage: (pageId: string) => void; onAnimateReorder: () => void; @@ -52,7 +59,15 @@ const PageThumbnail: React.FC = ({ selectionMode, movingPage, isAnimating, + isBoxSelected = false, + boxSelectedPageIds = [], + clearBoxSelection, + getBoxSelection, + activeId, + isOver, + dropSide, pageRefs, + dragHandleProps, onReorderPages, onTogglePage, onExecuteCommand, @@ -65,15 +80,17 @@ const PageThumbnail: React.FC = ({ splitPositions, onInsertFiles, }: PageThumbnailProps) => { - const [isDragging, setIsDragging] = useState(false); const [isMouseDown, setIsMouseDown] = useState(false); const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null); const lastClickTimeRef = useRef(0); - const dragElementRef = useRef(null); const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); - const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration(); + const elementRef = useRef(null); + const { getThumbnailFromCache, requestThumbnail} = useThumbnailGeneration(); const { openFilesModal } = useFilesModalContext(); + // Check if this page is currently being dragged + const isDragging = activeId === page.id; + // Calculate document aspect ratio from first non-blank page const getDocumentAspectRatio = useCallback(() => { // Find first non-blank page with a thumbnail to get aspect ratio @@ -130,63 +147,22 @@ const PageThumbnail: React.FC = ({ }; }, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]); - const pageElementRef = useCallback((element: HTMLDivElement | null) => { + // Merge refs - combine our ref tracking with dnd-kit's ref + const mergedRef = useCallback((element: HTMLDivElement | null) => { + // Track in our refs map + elementRef.current = element; if (element) { pageRefs.current.set(page.id, element); - dragElementRef.current = element; - - const dragCleanup = draggable({ - element, - getInitialData: () => ({ - pageNumber: page.pageNumber, - pageId: page.id, - selectedPageIds: [page.id] - }), - onDragStart: () => { - setIsDragging(true); - }, - onDrop: ({ location }) => { - setIsDragging(false); - - if (location.current.dropTargets.length === 0) { - return; - } - - const dropTarget = location.current.dropTargets[0]; - const targetData = dropTarget.data; - - if (targetData.type === 'page') { - const targetPageNumber = targetData.pageNumber as number; - const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber); - if (targetIndex !== -1) { - onReorderPages(page.pageNumber, targetIndex, undefined); - } - } - } - }); - - element.style.cursor = 'grab'; - - const dropCleanup = dropTargetForElements({ - element, - getData: () => ({ - type: 'page', - pageNumber: page.pageNumber - }), - onDrop: (_) => {} - }); - - (element as any).__dragCleanup = () => { - dragCleanup(); - dropCleanup(); - }; } else { pageRefs.current.delete(page.id); - if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) { - (dragElementRef.current as any).__dragCleanup(); - } } - }, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPageIds, pdfDocument.pages, onReorderPages]); + + // Call dnd-kit's ref if provided + if (dragHandleProps?.ref) { + dragHandleProps.ref(element); + } + }, [page.id, pageRefs, dragHandleProps]); + // DOM command handlers const handleRotateLeft = useCallback((e: React.MouseEvent) => { @@ -262,19 +238,28 @@ const PageThumbnail: React.FC = ({ const deltaY = Math.abs(e.clientY - mouseStartPos.y); const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY); - // If mouse moved less than 5 pixels, consider it a click (not a drag) - if (distance < 5 && !isDragging) { + // If mouse moved less than 2 pixels, consider it a click (not a drag) + if (distance < 2 && !isDragging) { // Prevent rapid double-clicks from causing issues (debounce with 100ms threshold) const now = Date.now(); if (now - lastClickTimeRef.current > 100) { lastClickTimeRef.current = now; - onTogglePage(page.id); + + // Clear box selection when clicking on a non-selected page + if (!isBoxSelected && clearBoxSelection) { + clearBoxSelection(); + } + + // Don't toggle page selection if it's box-selected (just keep the box selection) + if (!isBoxSelected) { + onTogglePage(page.id); + } } } setIsMouseDown(false); setMouseStartPos(null); - }, [isMouseDown, mouseStartPos, isDragging, page.id, onTogglePage]); + }, [isMouseDown, mouseStartPos, isDragging, page.id, isBoxSelected, clearBoxSelection, onTogglePage]); const handleMouseLeave = useCallback(() => { setIsMouseDown(false); @@ -283,9 +268,13 @@ const PageThumbnail: React.FC = ({ const fileColorBorder = page.isBlankPage ? 'transparent' : getFileColorWithOpacity(fileColorIndex, 0.3); + // Spread dragHandleProps but use our merged ref + const { ref: _, ...restDragProps } = dragHandleProps || {}; + return (
= ({ relative ${isDragging ? 'opacity-50 scale-95' : ''} ${movingPage === page.pageNumber ? 'page-moving' : ''} + ${isBoxSelected ? 'ring-4 ring-blue-400 ring-offset-2' : ''} `} style={{ transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out', + ...(isBoxSelected && { + boxShadow: '0 0 0 4px rgba(59, 130, 246, 0.5)', + }), }} - draggable={false} onMouseDown={handleMouseDown} onMouseUp={handleMouseUp} onMouseLeave={handleMouseLeave} > + {/* Drop indicator - bar on left or right edge */} + {isOver && !isDragging && dropSide && ( +
+ )} {