From 646cedfb0f7eea135a7d83faa47693842a88d185 Mon Sep 17 00:00:00 2001 From: Reece Browne Date: Mon, 18 Aug 2025 23:19:44 +0100 Subject: [PATCH] Fix and improve pageeditor --- .claude/settings.local.json | 3 +- frontend/package-lock.json | 52 ++++ frontend/package.json | 4 +- .../src/components/fileEditor/FileEditor.tsx | 83 ++---- .../components/pageEditor/DragDropGrid.tsx | 253 ++++-------------- .../src/components/pageEditor/PageEditor.tsx | 236 +++++++--------- .../components/pageEditor/PageThumbnail.tsx | 113 ++++---- 7 files changed, 298 insertions(+), 446 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index f18dd96c4..8ee1dbf70 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -11,7 +11,8 @@ "Bash(npm test:*)", "Bash(ls:*)", "Bash(npx tsc:*)", - "Bash(node:*)" + "Bash(node:*)", + "Bash(npm run dev:*)" ], "deny": [], "defaultMode": "acceptEdits" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 877b5c48a..f9ec204a6 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "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.4", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mantine/core": "^8.0.1", @@ -17,6 +18,7 @@ "@mui/icons-material": "^7.1.0", "@mui/material": "^7.1.0", "@tailwindcss/postcss": "^4.1.8", + "@tanstack/react-virtual": "^3.13.12", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -119,6 +121,17 @@ "is-potential-custom-element-name": "^1.0.1" } }, + "node_modules/@atlaskit/pragmatic-drag-and-drop": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@atlaskit/pragmatic-drag-and-drop/-/pragmatic-drag-and-drop-1.7.4.tgz", + "integrity": "sha512-lZHnO9BJdHPKnwB0uvVUCyDnIhL+WAHzXQ2EXX0qacogOsnvIUiCgY0BLKhBqTCWln3/f/Ox5jU54MKO6ayh9A==", + "license": "Apache-2.0", + "dependencies": { + "@babel/runtime": "^7.0.0", + "bind-event-listener": "^3.0.0", + "raf-schd": "^4.0.3" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -2226,6 +2239,33 @@ "tailwindcss": "4.1.8" } }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.12.tgz", + "integrity": "sha512-Gd13QdxPSukP8ZrkbgS2RwoZseTTbQPLnQEn7HY/rqtM+8Zt95f7xKC7N0EsKs7aoz0WzZ+fditZux+F8EzYxA==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.12", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.12.tgz", + "integrity": "sha512-1YBOJfRHV4sXUmWsFSf5rQor4Ss82G8dQWLRbnk3GA4jeP8hQt1hxXh0tmflpC0dz3VgEv/1+qwPyLeWkQuPFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", @@ -2876,6 +2916,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/bind-event-listener": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/bind-event-listener/-/bind-event-listener-3.0.0.tgz", + "integrity": "sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -6261,6 +6307,12 @@ ], "license": "MIT" }, + "node_modules/raf-schd": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz", + "integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==", + "license": "MIT" + }, "node_modules/react": { "version": "19.1.0", "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3b22f0391..ad945dbc2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,7 @@ "license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE", "proxy": "http://localhost:8080", "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.7.4", "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", "@mantine/core": "^8.0.1", @@ -13,6 +14,7 @@ "@mui/icons-material": "^7.1.0", "@mui/material": "^7.1.0", "@tailwindcss/postcss": "^4.1.8", + "@tanstack/react-virtual": "^3.13.12", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", @@ -34,8 +36,6 @@ "web-vitals": "^2.1.4" }, "scripts": { - "dev": "npx tsc --noEmit && vite", - "build": "npx tsc --noEmit && vite build", "dev": "npx tsc --noEmit && vite", "build": "npx tsc --noEmit && vite build", "preview": "vite preview", diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index 15baba94c..827b1f53e 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -752,61 +752,34 @@ const FileEditor = ({ ) : ( - ( - - )} - renderSplitMarker={(file, index) => ( -
- )} - /> +
+ {files.map((file, index) => ( + + ))} +
)} diff --git a/frontend/src/components/pageEditor/DragDropGrid.tsx b/frontend/src/components/pageEditor/DragDropGrid.tsx index ac2290dc1..245b12991 100644 --- a/frontend/src/components/pageEditor/DragDropGrid.tsx +++ b/frontend/src/components/pageEditor/DragDropGrid.tsx @@ -1,5 +1,7 @@ -import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import React, { useRef, useEffect } from 'react'; import { Box } from '@mantine/core'; +import { useVirtualizer } from '@tanstack/react-virtual'; +import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import styles from './PageEditor.module.css'; interface DragDropItem { @@ -12,19 +14,9 @@ interface DragDropGridProps { selectedItems: number[]; selectionMode: boolean; isAnimating: boolean; - onDragStart: (pageNumber: number) => void; - onDragEnd: () => void; - onDragOver: (e: React.DragEvent) => void; - onDragEnter: (pageNumber: number) => void; - onDragLeave: () => void; - onDrop: (e: React.DragEvent, targetPageNumber: number | 'end') => void; - onEndZoneDragEnter: () => void; + onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void; renderItem: (item: T, index: number, refs: React.MutableRefObject>) => React.ReactNode; renderSplitMarker?: (item: T, index: number) => React.ReactNode; - draggedItem: number | null; - dropTarget: number | 'end' | null; - multiItemDrag: {pageNumbers: number[], count: number} | null; - dragPosition: {x: number, y: number} | null; } const DragDropGrid = ({ @@ -32,217 +24,88 @@ const DragDropGrid = ({ selectedItems, selectionMode, isAnimating, - onDragStart, - onDragEnd, - onDragOver, - onDragEnter, - onDragLeave, - onDrop, - onEndZoneDragEnter, + onReorderPages, renderItem, renderSplitMarker, - draggedItem, - dropTarget, - multiItemDrag, - dragPosition, }: DragDropGridProps) => { const itemRefs = useRef>(new Map()); const containerRef = useRef(null); - const [scrollTop, setScrollTop] = useState(0); - // Virtualization configuration - adjust for document size - const isLargeDocument = items.length > 1000; // Only virtualize for very large documents - const ITEM_HEIGHT = 340; // Height of PageThumbnail + gap (20rem + gap) - const ITEMS_PER_ROW = 4; // Approximate items per row - const BUFFER_SIZE = isLargeDocument ? 2 : 3; // Larger buffer for smoother scrolling - const OVERSCAN = ITEMS_PER_ROW * BUFFER_SIZE; // Total buffer items - // Log virtualization stats for debugging - React.useEffect(() => { - if (items.length > 100) { - console.log(`📊 DragDropGrid: Virtualizing ${items.length} items (large doc: ${isLargeDocument}, buffer: ${BUFFER_SIZE})`); - } - }, [items.length, isLargeDocument, BUFFER_SIZE]); - - // Throttled scroll handler to prevent excessive re-renders - const throttleRef = useRef(undefined); + // Grid configuration + const ITEMS_PER_ROW = 4; + const ITEM_HEIGHT = 340; // 20rem + gap + const OVERSCAN = items.length > 1000 ? 8 : 4; // More overscan for large documents - // Detect scroll position from parent container - useEffect(() => { - const updateScrollPosition = () => { - // Throttle scroll updates for better performance - if (throttleRef.current) { - cancelAnimationFrame(throttleRef.current); - } - - throttleRef.current = requestAnimationFrame(() => { - const scrollingParent = containerRef.current?.closest('[data-scrolling-container]') || - containerRef.current?.offsetParent?.closest('div[style*="overflow"]'); - - if (scrollingParent) { - setScrollTop(scrollingParent.scrollTop || 0); - } - }); - }; + // Virtualization with react-virtual library + const rowVirtualizer = useVirtualizer({ + count: Math.ceil(items.length / ITEMS_PER_ROW), + getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element, + estimateSize: () => ITEM_HEIGHT, + overscan: OVERSCAN, + }); - const scrollingParent = containerRef.current?.closest('[data-scrolling-container]') || - containerRef.current?.offsetParent?.closest('div[style*="overflow"]'); - - if (scrollingParent) { - // Use passive listener for better scrolling performance - scrollingParent.addEventListener('scroll', updateScrollPosition, { passive: true }); - updateScrollPosition(); // Initial position - - return () => { - scrollingParent.removeEventListener('scroll', updateScrollPosition); - if (throttleRef.current) { - cancelAnimationFrame(throttleRef.current); - } - }; - } - }, []); - // Calculate visible range with virtualization (only for very large documents) - const { startIndex, endIndex, totalHeight, topSpacer } = useMemo(() => { - // Skip virtualization for smaller documents to avoid jankiness - if (!isLargeDocument) { - return { - startIndex: 0, - endIndex: items.length, - totalHeight: Math.ceil(items.length / ITEMS_PER_ROW) * ITEM_HEIGHT, - topSpacer: 0 - }; - } - - const containerHeight = containerRef.current?.clientHeight || 600; - const rowHeight = ITEM_HEIGHT; - const totalRows = Math.ceil(items.length / ITEMS_PER_ROW); - const visibleRows = Math.ceil(containerHeight / rowHeight); - - const startRow = Math.max(0, Math.floor(scrollTop / rowHeight) - BUFFER_SIZE); - const endRow = Math.min(totalRows, startRow + visibleRows + BUFFER_SIZE * 2); - - const startIndex = startRow * ITEMS_PER_ROW; - const endIndex = Math.min(items.length, endRow * ITEMS_PER_ROW); - const totalHeight = totalRows * rowHeight; - const topSpacer = startRow * rowHeight; - - return { startIndex, endIndex, totalHeight, topSpacer }; - }, [items.length, scrollTop, ITEM_HEIGHT, ITEMS_PER_ROW, BUFFER_SIZE, isLargeDocument]); - - // Only render visible items for performance - const visibleItems = useMemo(() => { - const visible = items.slice(startIndex, endIndex); - - // Debug logging for large documents - if (items.length > 500 && visible.length > 0) { - console.log(`📊 DragDropGrid: Rendering ${visible.length} items (${startIndex}-${endIndex-1}) of ${items.length} total`); - } - - return visible; - }, [items, startIndex, endIndex]); - - // Global drag cleanup - useEffect(() => { - const handleGlobalDragEnd = () => { - onDragEnd(); - }; - - const handleGlobalDrop = (e: DragEvent) => { - e.preventDefault(); - }; - - if (draggedItem) { - document.addEventListener('dragend', handleGlobalDragEnd); - document.addEventListener('drop', handleGlobalDrop); - } - - return () => { - document.removeEventListener('dragend', handleGlobalDragEnd); - document.removeEventListener('drop', handleGlobalDrop); - }; - }, [draggedItem, onDragEnd]); return (
- {/* Top spacer for virtualization */} -
- - {/* Visible items container */} -
- {visibleItems.map((item, visibleIndex) => { - const actualIndex = startIndex + visibleIndex; - return ( - - {/* Split marker */} - {renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)} - - {/* Item */} - {renderItem(item, actualIndex, itemRefs)} - - ); - })} + {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const startIndex = virtualRow.index * ITEMS_PER_ROW; + const endIndex = Math.min(startIndex + ITEMS_PER_ROW, items.length); + const rowItems = items.slice(startIndex, endIndex); - {/* End drop zone - inline with pages */} -
+ return (
onDrop(e, 'end')} + key={virtualRow.index} + style={{ + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: `${virtualRow.size}px`, + transform: `translateY(${virtualRow.start}px)`, + }} > -
- Drop here to
move to end +
+ {rowItems.map((item, itemIndex) => { + const actualIndex = startIndex + itemIndex; + return ( + + {/* Split marker */} + {renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)} + {/* Item */} + {renderItem(item, actualIndex, itemRefs)} + + ); + })} +
-
-
+ ); + })}
- - {/* Multi-item drag indicator */} - {multiItemDrag && dragPosition && ( -
- {multiItemDrag.count} items -
- )} ); }; diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index c4d0456b8..ecc032e16 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -232,11 +232,6 @@ const PageEditor = ({ const [csvInput, setCsvInput] = useState(""); const [selectionMode, setSelectionMode] = useState(false); - // Drag and drop state - const [draggedPage, setDraggedPage] = useState(null); - const [dropTarget, setDropTarget] = useState(null); - const [multiPageDrag, setMultiPageDrag] = useState<{pageNumbers: number[], count: number} | null>(null); - const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null); // Export state const [exportLoading, setExportLoading] = useState(false); @@ -287,7 +282,7 @@ const PageEditor = ({ // Simple cache-first thumbnail generation (no complex detection needed) - // Simple thumbnail generation - generate pages 2+ that aren't cached + // Lazy thumbnail generation - only generate when needed, with intelligent batching const generateMissingThumbnails = useCallback(async () => { if (!mergedPdfDocument || !primaryFileId || activeFileIds.length !== 1) { return; @@ -299,7 +294,34 @@ const PageEditor = ({ const totalPages = mergedPdfDocument.totalPages; if (totalPages <= 1) return; // Only page 1, nothing to generate - // Check which pages 2+ need thumbnails (not in cache) + // For very large documents (2000+ pages), be much more conservative + const isVeryLargeDocument = totalPages > 2000; + + if (isVeryLargeDocument) { + console.log(`📸 PageEditor: Very large document (${totalPages} pages) - using minimal thumbnail generation`); + // For very large docs, only generate the next visible batch (pages 2-25) to avoid UI blocking + const pageNumbersToGenerate = []; + for (let pageNum = 2; pageNum <= Math.min(25, totalPages); pageNum++) { + const pageId = `${primaryFileId}-page-${pageNum}`; + if (!getThumbnailFromCache(pageId)) { + pageNumbersToGenerate.push(pageNum); + } + } + + if (pageNumbersToGenerate.length > 0) { + console.log(`📸 PageEditor: Generating initial batch for large doc: pages [${pageNumbersToGenerate.join(', ')}]`); + await generateThumbnailBatch(file, primaryFileId, pageNumbersToGenerate); + } + + // Schedule remaining thumbnails with delay to avoid blocking + setTimeout(() => { + generateRemainingThumbnailsLazily(file, primaryFileId, totalPages, 26); + }, 2000); // 2 second delay before starting background generation + + return; + } + + // For smaller documents, check which pages 2+ need thumbnails const pageNumbersToGenerate = []; for (let pageNum = 2; pageNum <= totalPages; pageNum++) { const pageId = `${primaryFileId}-page-${pageNum}`; @@ -313,19 +335,23 @@ const PageEditor = ({ return; } - console.log(`📸 PageEditor: Generating thumbnails for pages: [${pageNumbersToGenerate.join(', ')}]`); - + console.log(`📸 PageEditor: Generating thumbnails for pages: [${pageNumbersToGenerate.slice(0, 5).join(', ')}${pageNumbersToGenerate.length > 5 ? '...' : ''}]`); + await generateThumbnailBatch(file, primaryFileId, pageNumbersToGenerate); + }, [mergedPdfDocument, primaryFileId, activeFileIds, selectors]); + + // Helper function to generate thumbnails in batches + const generateThumbnailBatch = useCallback(async (file: File, fileId: string, pageNumbers: number[]) => { try { // Load PDF array buffer for Web Workers const arrayBuffer = await file.arrayBuffer(); // Calculate quality scale based on file size - const scale = calculateScaleFromFileSize(selectors.getFileRecord(primaryFileId)?.size || 0); + const scale = calculateScaleFromFileSize(selectors.getFileRecord(fileId)?.size || 0); // Start parallel thumbnail generation WITHOUT blocking the main thread await generateThumbnails( arrayBuffer, - pageNumbersToGenerate, + pageNumbers, { scale, // Dynamic quality based on file size quality: 0.8, @@ -338,7 +364,7 @@ const PageEditor = ({ requestAnimationFrame(() => { progress.thumbnails.forEach(({ pageNumber, thumbnail }) => { // Use stable fileId for cache key - const pageId = `${primaryFileId}-page-${pageNumber}`; + const pageId = `${fileId}-page-${pageNumber}`; addThumbnailToCache(pageId, thumbnail); // Don't update context state - thumbnails stay in cache only @@ -349,11 +375,40 @@ const PageEditor = ({ } ); - console.log(`📸 PageEditor: Thumbnail generation completed for pages [${pageNumbersToGenerate.join(', ')}]`); + console.log(`📸 PageEditor: Thumbnail generation completed for ${pageNumbers.length} pages`); } catch (error) { console.error('PageEditor: Thumbnail generation failed:', error); } - }, [mergedPdfDocument, primaryFileId, activeFileIds, generateThumbnails, getThumbnailFromCache, addThumbnailToCache, selectors]); + }, [generateThumbnails, addThumbnailToCache, selectors]); + + // Background generation for remaining pages in very large documents + const generateRemainingThumbnailsLazily = useCallback(async (file: File, fileId: string, totalPages: number, startPage: number) => { + console.log(`📸 PageEditor: Starting background thumbnail generation from page ${startPage} to ${totalPages}`); + + // Generate in small chunks to avoid blocking + const CHUNK_SIZE = 50; + for (let start = startPage; start <= totalPages; start += CHUNK_SIZE) { + const end = Math.min(start + CHUNK_SIZE - 1, totalPages); + const chunkPageNumbers = []; + + for (let pageNum = start; pageNum <= end; pageNum++) { + const pageId = `${fileId}-page-${pageNum}`; + if (!getThumbnailFromCache(pageId)) { + chunkPageNumbers.push(pageNum); + } + } + + if (chunkPageNumbers.length > 0) { + console.log(`📸 PageEditor: Background generating chunk: pages ${start}-${end} (${chunkPageNumbers.length} needed)`); + await generateThumbnailBatch(file, fileId, chunkPageNumbers); + + // Small delay between chunks to keep UI responsive + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + console.log(`📸 PageEditor: Background thumbnail generation completed for ${totalPages} pages`); + }, [getThumbnailFromCache, generateThumbnailBatch]); // Simple useEffect - just generate missing thumbnails when document is ready useEffect(() => { @@ -388,30 +443,6 @@ const PageEditor = ({ setCsvInput(newCsvInput); }, [selectedPageNumbers]); - useEffect(() => { - const handleGlobalDragEnd = () => { - // Clean up drag state when drag operation ends anywhere - setDraggedPage(null); - setDropTarget(null); - setMultiPageDrag(null); - setDragPosition(null); - }; - - const handleGlobalDrop = (e: DragEvent) => { - // Prevent default to handle invalid drops - e.preventDefault(); - }; - - if (draggedPage) { - document.addEventListener('dragend', handleGlobalDragEnd); - document.addEventListener('drop', handleGlobalDrop); - } - - return () => { - document.removeEventListener('dragend', handleGlobalDragEnd); - document.removeEventListener('drop', handleGlobalDrop); - }; - }, [draggedPage]); const selectAll = useCallback(() => { if (mergedPdfDocument) { @@ -484,73 +515,8 @@ const PageEditor = ({ actions.setSelectedPages(pageNumbers); }, [csvInput, parseCSVInput, actions]); - const handleDragStart = useCallback((pageNumber: number) => { - setDraggedPage(pageNumber); - // Check if this is a multi-page drag in selection mode - if (selectionMode && selectedPageNumbers.includes(pageNumber) && selectedPageNumbers.length > 1) { - setMultiPageDrag({ - pageNumbers: selectedPageNumbers, - count: selectedPageNumbers.length - }); - } else { - setMultiPageDrag(null); - } - }, [selectionMode, selectedPageNumbers]); - const handleDragEnd = useCallback(() => { - // Clean up drag state regardless of where the drop happened - setDraggedPage(null); - setDropTarget(null); - setMultiPageDrag(null); - setDragPosition(null); - }, []); - - const handleDragOver = useCallback((e: React.DragEvent) => { - e.preventDefault(); - - if (!draggedPage) return; - - // Update drag position for multi-page indicator - if (multiPageDrag) { - setDragPosition({ x: e.clientX, y: e.clientY }); - } - - // Get the element under the mouse cursor - const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY); - if (!elementUnderCursor) return; - - // Find the closest page container - const pageContainer = elementUnderCursor.closest('[data-page-number]'); - if (pageContainer) { - const pageNumberStr = pageContainer.getAttribute('data-page-number'); - const pageNumber = pageNumberStr ? parseInt(pageNumberStr) : null; - if (pageNumber && pageNumber !== draggedPage) { - setDropTarget(pageNumber); - return; - } - } - - // Check if over the end zone - const endZone = elementUnderCursor.closest('[data-drop-zone="end"]'); - if (endZone) { - setDropTarget('end'); - return; - } - - // If not over any valid drop target, clear it - setDropTarget(null); - }, [draggedPage, multiPageDrag]); - - const handleDragEnter = useCallback((pageNumber: number) => { - if (draggedPage && pageNumber !== draggedPage) { - setDropTarget(pageNumber); - } - }, [draggedPage]); - - const handleDragLeave = useCallback(() => { - // Don't clear drop target on drag leave - let dragover handle it - }, []); // Update PDF document state with edit tracking const setPdfDocument = useCallback((updatedDoc: PDFDocument) => { @@ -791,34 +757,22 @@ const PageEditor = ({ }, 10); // Small delay to allow state update }, [displayDocument, isAnimating, executeCommand, selectionMode, selectedPageNumbers, setPdfDocument]); - const handleDrop = useCallback((e: React.DragEvent, targetPageNumber: number | 'end') => { - e.preventDefault(); - if (!draggedPage || !displayDocument || draggedPage === targetPageNumber) return; + const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => { + if (!displayDocument) return; - let targetIndex: number; - if (targetPageNumber === 'end') { - targetIndex = displayDocument.pages.length; - } else { - targetIndex = displayDocument.pages.findIndex(p => p.pageNumber === targetPageNumber); - if (targetIndex === -1) return; - } + const pagesToMove = selectedPages && selectedPages.length > 1 + ? selectedPages + : [sourcePageNumber]; + + const sourceIndex = displayDocument.pages.findIndex(p => p.pageNumber === sourcePageNumber); + if (sourceIndex === -1 || sourceIndex === targetIndex) return; - animateReorder(draggedPage, targetIndex); - - setDraggedPage(null); - setDropTarget(null); - setMultiPageDrag(null); - setDragPosition(null); - - const moveCount = multiPageDrag ? multiPageDrag.count : 1; + animateReorder(sourcePageNumber, targetIndex); + + const moveCount = pagesToMove.length; setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`); - }, [draggedPage, displayDocument, animateReorder, multiPageDrag]); + }, [displayDocument, animateReorder]); - const handleEndZoneDragEnter = useCallback(() => { - if (draggedPage) { - setDropTarget('end'); - } - }, [draggedPage]); const handleRotate = useCallback((direction: 'left' | 'right') => { if (!displayDocument) return; @@ -1058,6 +1012,13 @@ const PageEditor = ({ const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`; // Use centralized IndexedDB manager const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS); + + // Check if the drafts object store exists before using it + if (!db.objectStoreNames.contains('drafts')) { + console.log('📝 Drafts object store not found, skipping draft check'); + return; + } + const transaction = db.transaction('drafts', 'readonly'); const store = transaction.objectStore('drafts'); const getRequest = store.get(draftKey); @@ -1288,17 +1249,7 @@ const PageEditor = ({ selectedItems={selectedPageNumbers} selectionMode={selectionMode} isAnimating={isAnimating} - onDragStart={handleDragStart} - onDragEnd={handleDragEnd} - onDragOver={handleDragOver} - onDragEnter={handleDragEnter} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - onEndZoneDragEnter={handleEndZoneDragEnter} - draggedItem={draggedPage} - dropTarget={dropTarget} - multiItemDrag={multiPageDrag} - dragPosition={dragPosition} + onReorderPages={handleReorderPages} renderItem={(page, index, refs) => ( >; - onDragStart: (pageNumber: number) => void; - onDragEnd: () => void; - onDragOver: (e: React.DragEvent) => void; - onDragEnter: (pageNumber: number) => void; - onDragLeave: () => void; - onDrop: (e: React.DragEvent, pageNumber: number) => void; onTogglePage: (pageNumber: number) => void; onAnimateReorder: (pageNumber: number, targetIndex: number) => void; onExecuteCommand: (command: Command) => void; onSetStatus: (status: string) => void; onSetMovingPage: (pageNumber: number | null) => void; + onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void; RotatePagesCommand: typeof RotatePagesCommand; DeletePagesCommand: typeof DeletePagesCommand; ToggleSplitCommand: typeof ToggleSplitCommand; @@ -57,22 +50,15 @@ const PageThumbnail = React.memo(({ originalFile, selectedPages, selectionMode, - draggedPage, - dropTarget, movingPage, isAnimating, pageRefs, - onDragStart, - onDragEnd, - onDragOver, - onDragEnter, - onDragLeave, - onDrop, onTogglePage, onAnimateReorder, onExecuteCommand, onSetStatus, onSetMovingPage, + onReorderPages, RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand, @@ -80,6 +66,8 @@ const PageThumbnail = React.memo(({ setPdfDocument, }: PageThumbnailProps) => { const [thumbnailUrl, setThumbnailUrl] = useState(page.thumbnail); + const [isDragging, setIsDragging] = useState(false); + const dragElementRef = useRef(null); const { state, selectors } = useFileState(); const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration(); @@ -130,14 +118,69 @@ const PageThumbnail = React.memo(({ }, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops - // Register this component with pageRefs for animations const pageElementRef = useCallback((element: HTMLDivElement | null) => { if (element) { pageRefs.current.set(page.id, element); + dragElementRef.current = element; + + const dragCleanup = draggable({ + element, + getInitialData: () => ({ + pageNumber: page.pageNumber, + pageId: page.id, + selectedPages: selectionMode && selectedPages.includes(page.pageNumber) + ? selectedPages + : [page.pageNumber] + }), + 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) { + const pagesToMove = selectionMode && selectedPages.includes(page.pageNumber) + ? selectedPages + : undefined; + onReorderPages(page.pageNumber, targetIndex, pagesToMove); + } + } + }); + + element.style.cursor = 'grab'; + + + const dropCleanup = dropTargetForElements({ + element, + getData: () => ({ + type: 'page', + pageNumber: page.pageNumber + }), + onDrop: ({ source }) => {} + }); + + (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, pageRefs]); + }, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPages, pdfDocument.pages, onReorderPages]); + return (
{ - if (!isAnimating && draggedPage && page.pageNumber !== draggedPage && dropTarget === page.pageNumber) { - return 'translateX(20px)'; - } - return 'translateX(0)'; - })(), transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out' }} - draggable - onDragStart={() => onDragStart(page.pageNumber)} - onDragEnd={onDragEnd} - onDragOver={onDragOver} - onDragEnter={() => onDragEnter(page.pageNumber)} - onDragLeave={onDragLeave} - onDrop={(e) => onDrop(e, page.pageNumber)} + draggable={false} > {selectionMode && (
{ - console.log('📸 Checkbox clicked for page', page.pageNumber); e.stopPropagation(); onTogglePage(page.pageNumber); }} @@ -216,7 +246,7 @@ const PageThumbnail = React.memo(({
)} -
+
-
); @@ -444,8 +465,6 @@ const PageThumbnail = React.memo(({ (prevProps.selectedPages === nextProps.selectedPages || arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) && prevProps.selectionMode === nextProps.selectionMode && - prevProps.draggedPage === nextProps.draggedPage && - prevProps.dropTarget === nextProps.dropTarget && prevProps.movingPage === nextProps.movingPage && prevProps.isAnimating === nextProps.isAnimating );