Fix and improve pageeditor

This commit is contained in:
Reece Browne 2025-08-18 23:19:44 +01:00
parent 9b14609236
commit 646cedfb0f
7 changed files with 298 additions and 446 deletions

View File

@ -11,7 +11,8 @@
"Bash(npm test:*)",
"Bash(ls:*)",
"Bash(npx tsc:*)",
"Bash(node:*)"
"Bash(node:*)",
"Bash(npm run dev:*)"
],
"deny": [],
"defaultMode": "acceptEdits"

View File

@ -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",

View File

@ -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",

View File

@ -752,61 +752,34 @@ const FileEditor = ({
<SkeletonLoader type="fileGrid" count={6} />
</Box>
) : (
<DragDropGrid
items={files}
selectedItems={localSelectedIds as any /* FIX ME */}
selectionMode={selectionMode}
isAnimating={isAnimating}
onDragStart={handleDragStart as any /* FIX ME */}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter as any /* FIX ME */}
onDragLeave={handleDragLeave}
onDrop={handleDrop as any /* FIX ME */}
onEndZoneDragEnter={handleEndZoneDragEnter}
draggedItem={draggedFile as any /* FIX ME */}
dropTarget={dropTarget as any /* FIX ME */}
multiItemDrag={multiFileDrag as any /* FIX ME */}
dragPosition={dragPosition}
renderItem={(file, index, refs) => (
<FileThumbnail
file={file}
index={index}
totalFiles={files.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
draggedFile={draggedFile}
dropTarget={dropTarget}
isAnimating={isAnimating}
fileRefs={refs}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onToggleFile={toggleFile}
onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile}
onSetStatus={setStatus}
toolMode={toolMode}
isSupported={isFileSupported(file.name)}
/>
)}
renderSplitMarker={(file, index) => (
<div
style={{
width: '2px',
height: '24rem',
borderLeft: '2px dashed #3b82f6',
backgroundColor: 'transparent',
marginLeft: '-0.75rem',
marginRight: '-0.75rem',
flexShrink: 0
}}
/>
)}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '1.5rem', padding: '1rem' }}>
{files.map((file, index) => (
<FileThumbnail
key={file.id}
file={file}
index={index}
totalFiles={files.length}
selectedFiles={localSelectedIds}
selectionMode={selectionMode}
draggedFile={draggedFile}
dropTarget={dropTarget}
isAnimating={isAnimating}
fileRefs={fileRefs}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onToggleFile={toggleFile}
onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile}
onSetStatus={setStatus}
toolMode={toolMode}
isSupported={isFileSupported(file.name)}
/>
))}
</div>
)}
</Box>

View File

@ -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<T extends DragDropItem> {
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<Map<string, HTMLDivElement>>) => 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 = <T extends DragDropItem>({
@ -32,217 +24,88 @@ const DragDropGrid = <T extends DragDropItem>({
selectedItems,
selectionMode,
isAnimating,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onEndZoneDragEnter,
onReorderPages,
renderItem,
renderSplitMarker,
draggedItem,
dropTarget,
multiItemDrag,
dragPosition,
}: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const containerRef = useRef<HTMLDivElement>(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<number | undefined>(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 (
<Box
ref={containerRef}
style={{
// Performance optimizations for smooth scrolling
transform: 'translateZ(0)', // Force hardware acceleration
backfaceVisibility: 'hidden', // Better rendering performance
WebkitOverflowScrolling: 'touch', // Smooth scrolling on iOS
// Basic container styles
width: '100%',
height: '100%',
}}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
height: totalHeight,
paddingBottom: '100px'
}}
>
{/* Top spacer for virtualization */}
<div style={{ height: topSpacer }} />
{/* Visible items container */}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '1.5rem',
justifyContent: 'flex-start',
// Prevent layout shifts during scrolling
containIntrinsicSize: '20rem 20rem',
contain: 'layout style',
}}
>
{visibleItems.map((item, visibleIndex) => {
const actualIndex = startIndex + visibleIndex;
return (
<React.Fragment key={item.id}>
{/* Split marker */}
{renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)}
{/* Item */}
{renderItem(item, actualIndex, itemRefs)}
</React.Fragment>
);
})}
{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 */}
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
return (
<div
data-drop-zone="end"
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${
dropTarget === 'end'
? 'ring-2 ring-green-500 bg-green-50'
: 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'
}`}
style={{ borderRadius: '12px' }}
onDragOver={onDragOver}
onDragEnter={onEndZoneDragEnter}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, 'end')}
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div className="text-gray-500 text-sm text-center font-medium">
Drop here to<br />move to end
<div
style={{
display: 'flex',
gap: '1.5rem',
justifyContent: 'flex-start',
height: '100%',
alignItems: 'center',
}}
>
{rowItems.map((item, itemIndex) => {
const actualIndex = startIndex + itemIndex;
return (
<React.Fragment key={item.id}>
{/* Split marker */}
{renderSplitMarker && item.splitBefore && actualIndex > 0 && renderSplitMarker(item, actualIndex)}
{/* Item */}
{renderItem(item, actualIndex, itemRefs)}
</React.Fragment>
);
})}
</div>
</div>
</div>
</div>
);
})}
</div>
{/* Multi-item drag indicator */}
{multiItemDrag && dragPosition && (
<div
className={styles.multiDragIndicator}
style={{
left: dragPosition.x,
top: dragPosition.y,
}}
>
{multiItemDrag.count} items
</div>
)}
</Box>
);
};

View File

@ -232,11 +232,6 @@ const PageEditor = ({
const [csvInput, setCsvInput] = useState<string>("");
const [selectionMode, setSelectionMode] = useState(false);
// Drag and drop state
const [draggedPage, setDraggedPage] = useState<number | null>(null);
const [dropTarget, setDropTarget] = useState<number | 'end' | null>(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) => (
<PageThumbnail
page={page}
@ -1307,17 +1258,10 @@ const PageEditor = ({
originalFile={activeFileIds.length === 1 && primaryFileId ? selectors.getFile(primaryFileId) : undefined}
selectedPages={selectedPageNumbers}
selectionMode={selectionMode}
draggedPage={draggedPage}
dropTarget={dropTarget === 'end' ? null : dropTarget}
movingPage={movingPage}
isAnimating={isAnimating}
pageRefs={refs}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onReorderPages={handleReorderPages}
onTogglePage={togglePage}
onAnimateReorder={animateReorder}
onExecuteCommand={executeCommand}

View File

@ -6,7 +6,7 @@ import RotateLeftIcon from '@mui/icons-material/RotateLeft';
import RotateRightIcon from '@mui/icons-material/RotateRight';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { PDFPage, PDFDocument } from '../../types/pageEditor';
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
import { Command } from '../../hooks/useUndoRedo';
@ -27,22 +27,15 @@ interface PageThumbnailProps {
originalFile?: File; // For lazy thumbnail generation
selectedPages: number[];
selectionMode: boolean;
draggedPage: number | null;
dropTarget: number | 'end' | null;
movingPage: number | null;
isAnimating: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
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<string | null>(page.thumbnail);
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement>(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 (
<div
@ -159,25 +202,13 @@ const PageThumbnail = React.memo(({
${selectionMode
? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'}
${draggedPage === page.pageNumber ? 'opacity-50 scale-95' : ''}
${isDragging ? 'opacity-50 scale-95' : ''}
${movingPage === page.pageNumber ? 'page-moving' : ''}
`}
style={{
transform: (() => {
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 && (
<div
@ -201,7 +232,6 @@ const PageThumbnail = React.memo(({
e.stopPropagation();
}}
onClick={(e) => {
console.log('📸 Checkbox clicked for page', page.pageNumber);
e.stopPropagation();
onTogglePage(page.pageNumber);
}}
@ -216,7 +246,7 @@ const PageThumbnail = React.memo(({
</div>
)}
<div className="page-container w-[90%] h-[90%]">
<div className="page-container w-[90%] h-[90%]" draggable={false}>
<div
style={{
width: '100%',
@ -234,6 +264,7 @@ const PageThumbnail = React.memo(({
<img
src={thumbnailUrl}
alt={`Page ${page.pageNumber}`}
draggable={false}
style={{
width: '100%',
height: '100%',
@ -415,16 +446,6 @@ const PageThumbnail = React.memo(({
)}
</div>
<DragIndicatorIcon
style={{
position: 'absolute',
bottom: 4,
right: 4,
color: 'rgba(0,0,0,0.3)',
fontSize: 16,
zIndex: 1
}}
/>
</div>
</div>
);
@ -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
);