diff --git a/frontend/src/components/pageEditor/DragDropGrid.tsx b/frontend/src/components/pageEditor/DragDropGrid.tsx index 69abcf6e2..845d0d450 100644 --- a/frontend/src/components/pageEditor/DragDropGrid.tsx +++ b/frontend/src/components/pageEditor/DragDropGrid.tsx @@ -24,7 +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, 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; } @@ -38,13 +38,15 @@ interface DraggableItemProps { clearBoxSelection: () => void; getBoxSelection: () => string[]; activeId: string | null; + activeDragIds: string[]; + 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, 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, 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: { @@ -90,7 +92,7 @@ const DraggableItem = ({ item, index, itemRefs, boxSelec return ( <> - {renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)} + {renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)} ); }; @@ -117,6 +119,8 @@ const DragDropGrid = ({ const [boxSelectEnd, setBoxSelectEnd] = useState<{ x: number; y: number } | null>(null); const [isBoxSelecting, setIsBoxSelecting] = useState(false); const [boxSelectedPageIds, setBoxSelectedPageIds] = useState([]); + const justMovedIdsRef = useRef(new Set()); + const [, forceUpdate] = useState(0); // Drag state const [activeId, setActiveId] = useState(null); @@ -301,24 +305,37 @@ const DragDropGrid = ({ // 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; + if (e.button !== 0) return; // Only respond to primary button - // 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) { + const container = containerRef.current; + if (!container) return; + + const clickTarget = e.target as Node; + let clickedPageId: string | null = null; + + itemRefs.current.forEach((element, pageId) => { + if (element.contains(clickTarget)) { + clickedPageId = pageId; + } + }); + + 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; } - }, [boxSelectedPageIds.length]); + + 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; @@ -421,18 +438,32 @@ const DragDropGrid = ({ // Get data from hooks const activeData = active.data.current; - const overData = over.data.current; - - if (!activeData || !overData) return; + if (!activeData) return; const sourcePageNumber = activeData.pageNumber; - let targetIndex = overData.index; - // Use the final drop side to adjust target index - if (finalDropSide === 'right') { - targetIndex = targetIndex + 1; + let targetIndex: number | null = null; + + if (hoveredItemId) { + const hoveredIndex = visibleItems.findIndex(item => item.id === hoveredItemId); + if (hoveredIndex !== -1) { + targetIndex = hoveredIndex + (finalDropSide === 'right' ? 1 : 0); + } } + if (targetIndex === null && over) { + const overData = over.data.current; + if (overData) { + targetIndex = overData.index + (finalDropSide === 'right' ? 1 : 0); + } + } + + if (targetIndex === null) return; + + // Clamp to bounds + if (targetIndex < 0) targetIndex = 0; + if (targetIndex > visibleItems.length) targetIndex = visibleItems.length; + // Check if this page is box-selected const isBoxSelected = boxSelectedPageIds.includes(active.id as string); const pagesToDrag = isBoxSelected && boxSelectedPageIds.length > 0 ? boxSelectedPageIds : undefined; @@ -440,6 +471,17 @@ const DragDropGrid = ({ // Call reorder with page number and target index onReorderPages(sourcePageNumber, targetIndex, pagesToDrag); + // Highlight moved pages briefly + const movedIds = new Set(pagesToDrag ?? [active.id as string]); + justMovedIdsRef.current = movedIds; + forceUpdate(prev => prev + 1); + window.setTimeout(() => { + if (justMovedIdsRef.current === movedIds) { + justMovedIdsRef.current = new Set(); + forceUpdate(prev => prev + 1); + } + }, 1200); + // Clear box selection after drag if (pagesToDrag) { clearBoxSelection(); @@ -495,6 +537,14 @@ const DragDropGrid = ({ }; }, [hoveredItemId, dropSide, activeId, itemGap, zoomLevel]); + const activeDragIds = useMemo(() => { + if (!activeId) return []; + if (boxSelectedPageIds.includes(activeId)) { + return boxSelectedPageIds; + } + return [activeId]; + }, [activeId, boxSelectedPageIds]); + const handleWheelWhileDragging = useCallback((event: React.WheelEvent) => { if (!activeId) { return; @@ -589,6 +639,8 @@ const DragDropGrid = ({ clearBoxSelection={clearBoxSelection} getBoxSelection={getBoxSelection} activeId={activeId} + activeDragIds={activeDragIds} + justMoved={justMovedIdsRef.current.has(item.id)} getThumbnailData={getThumbnailData} onUpdateDropTarget={setHoveredItemId} renderItem={renderItem} diff --git a/frontend/src/components/pageEditor/PageEditor.module.css b/frontend/src/components/pageEditor/PageEditor.module.css index ab2fd691b..a7f08087c 100644 --- a/frontend/src/components/pageEditor/PageEditor.module.css +++ b/frontend/src/components/pageEditor/PageEditor.module.css @@ -11,6 +11,26 @@ transform: scale(1.02) translateZ(0); } +.pageSurface { + transition: background-color 0.4s ease; +} + +.pageJustMoved { + animation: pageMovedHighlight 1.2s ease-out; +} + +@keyframes pageMovedHighlight { + 0% { + background-color: rgba(59, 130, 246, 0.32); + } + 60% { + background-color: rgba(59, 130, 246, 0.12); + } + 100% { + background-color: rgba(59, 130, 246, 0); + } +} + .pageContainer:hover .pageNumber { opacity: 1 !important; } diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 2eb5ec5b3..682fd077e 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -350,6 +350,20 @@ const PageEditor = ({ } }, [displayDocument, setSelectedPageIds, setSelectionMode]); + // Automatically include newly added pages in the current selection + useEffect(() => { + if (!displayDocument || displayDocument.pages.length === 0) return; + + const currentSelection = new Set(selectedPageIds); + const newlyAddedPageIds = displayDocument.pages + .map(page => page.id) + .filter(pageId => !currentSelection.has(pageId)); + + if (newlyAddedPageIds.length > 0) { + setSelectedPageIds([...selectedPageIds, ...newlyAddedPageIds]); + } + }, [displayDocument, selectedPageIds, setSelectedPageIds]); + // DOM-first command handlers const handleRotatePages = useCallback((pageIds: string[], rotation: number) => { const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation); @@ -845,6 +859,7 @@ const PageEditor = ({ handleDeselectAll, handleDelete, onExportSelected, + onSaveChanges: applyChanges, exportLoading, activeFileCount: activeFileIds.length, closePdf, @@ -970,22 +985,25 @@ const PageEditor = ({ // Create a stable mapping of fileId to color index (preserves colors on reorder) const fileColorIndexMap = useMemo(() => { + const assignments = fileColorAssignments.current; + + // Remove colors for files that no longer exist + const activeIds = new Set(orderedFileIds); + for (const fileId of Array.from(assignments.keys())) { + if (!activeIds.has(fileId)) { + assignments.delete(fileId); + } + } + // Assign colors to new files based on insertion order orderedFileIds.forEach(fileId => { - if (!fileColorAssignments.current.has(fileId)) { - fileColorAssignments.current.set(fileId, fileColorAssignments.current.size); + if (!assignments.has(fileId)) { + assignments.set(fileId, assignments.size); } }); // Clean up removed files (only remove files that are completely gone, not just deselected) - const allFilesSet = new Set(orderedFileIds); - for (const fileId of fileColorAssignments.current.keys()) { - if (!allFilesSet.has(fileId)) { - fileColorAssignments.current.delete(fileId); - } - } - - return fileColorAssignments.current; + return assignments; }, [orderedFileIds.join(',')]); // Only recalculate when the set of files changes, not the order return ( @@ -1089,7 +1107,7 @@ const PageEditor = ({ rotation: page.rotation || 0 }; }} - renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, getBoxSelection, activeId, isOver, dragHandleProps, zoomLevel) => { + renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, isOver, dragHandleProps, zoomLevel) => { const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0; const isBoxSelected = boxSelectedIds.includes(page.id); return ( @@ -1109,6 +1127,8 @@ const PageEditor = ({ clearBoxSelection={clearBoxSelection} getBoxSelection={getBoxSelection} activeId={activeId} + activeDragIds={activeDragIds} + justMoved={justMoved} isOver={isOver} pageRefs={refs} dragHandleProps={dragHandleProps} diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index 080a60cf1..32a778198 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -32,6 +32,8 @@ interface PageThumbnailProps { clearBoxSelection?: () => void; getBoxSelection?: () => string[]; activeId: string | null; + activeDragIds: string[]; + justMoved?: boolean; isOver: boolean; pageRefs: React.MutableRefObject>; dragHandleProps?: any; @@ -67,6 +69,7 @@ const PageThumbnail: React.FC = ({ clearBoxSelection, // getBoxSelection, activeId, + activeDragIds, // isOver, pageRefs, dragHandleProps, @@ -82,6 +85,7 @@ const PageThumbnail: React.FC = ({ splitPositions, onInsertFiles, zoomLevel = 1.0, + justMoved = false, }: PageThumbnailProps) => { const [isMouseDown, setIsMouseDown] = useState(false); const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null); @@ -94,7 +98,7 @@ const PageThumbnail: React.FC = ({ const { openFilesModal } = useFilesModalContext(); // Check if this page is currently being dragged - const isDragging = activeId === page.id; + const isDragging = activeDragIds.includes(page.id); // Calculate document aspect ratio from first non-blank page const getDocumentAspectRatio = useCallback(() => { @@ -415,6 +419,7 @@ const PageThumbnail: React.FC = ({
void; handleDelete: () => void; onExportSelected: () => void; + onSaveChanges: () => void; exportLoading: boolean; activeFileCount: number; closePdf: () => void; @@ -34,6 +35,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons handleDeselectAll, handleDelete, onExportSelected, + onSaveChanges, exportLoading, activeFileCount, closePdf, @@ -47,6 +49,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers'); const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages'); const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages'); + const saveChangesLabel = t('rightRail.saveChanges', 'Save Changes'); const closePdfLabel = t('rightRail.closePdf', 'Close PDF'); const buttons = useMemo(() => { @@ -116,6 +119,17 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons visible: totalPages > 0, onClick: onExportSelected, }, + { + id: 'page-save-changes', + icon: , + tooltip: saveChangesLabel, + ariaLabel: saveChangesLabel, + section: 'top' as const, + order: 55, + disabled: totalPages === 0 || exportLoading, + visible: totalPages > 0, + onClick: onSaveChanges, + }, { id: 'page-close-pdf', icon: , @@ -135,6 +149,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons selectByNumberLabel, deleteSelectedLabel, exportSelectedLabel, + saveChangesLabel, closePdfLabel, totalPages, selectedPageCount, @@ -147,6 +162,7 @@ export function usePageEditorRightRailButtons(params: PageEditorRightRailButtons handleDeselectAll, handleDelete, onExportSelected, + onSaveChanges, exportLoading, activeFileCount, closePdf,