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(npm test:*)",
"Bash(ls:*)", "Bash(ls:*)",
"Bash(npx tsc:*)", "Bash(npx tsc:*)",
"Bash(node:*)" "Bash(node:*)",
"Bash(npm run dev:*)"
], ],
"deny": [], "deny": [],
"defaultMode": "acceptEdits" "defaultMode": "acceptEdits"

View File

@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE", "license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@mantine/core": "^8.0.1", "@mantine/core": "^8.0.1",
@ -17,6 +18,7 @@
"@mui/icons-material": "^7.1.0", "@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0", "@mui/material": "^7.1.0",
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
"@tanstack/react-virtual": "^3.13.12",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
@ -119,6 +121,17 @@
"is-potential-custom-element-name": "^1.0.1" "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": { "node_modules/@babel/code-frame": {
"version": "7.27.1", "version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz",
@ -2226,6 +2239,33 @@
"tailwindcss": "4.1.8" "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": { "node_modules/@testing-library/dom": {
"version": "10.4.0", "version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@ -2876,6 +2916,12 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.11",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
@ -6261,6 +6307,12 @@
], ],
"license": "MIT" "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": { "node_modules/react": {
"version": "19.1.0", "version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", "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", "license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"proxy": "http://localhost:8080", "proxy": "http://localhost:8080",
"dependencies": { "dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.4",
"@emotion/react": "^11.14.0", "@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.0", "@emotion/styled": "^11.14.0",
"@mantine/core": "^8.0.1", "@mantine/core": "^8.0.1",
@ -13,6 +14,7 @@
"@mui/icons-material": "^7.1.0", "@mui/icons-material": "^7.1.0",
"@mui/material": "^7.1.0", "@mui/material": "^7.1.0",
"@tailwindcss/postcss": "^4.1.8", "@tailwindcss/postcss": "^4.1.8",
"@tanstack/react-virtual": "^3.13.12",
"@testing-library/dom": "^10.4.0", "@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
@ -34,8 +36,6 @@
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {
"dev": "npx tsc --noEmit && vite",
"build": "npx tsc --noEmit && vite build",
"dev": "npx tsc --noEmit && vite", "dev": "npx tsc --noEmit && vite",
"build": "npx tsc --noEmit && vite build", "build": "npx tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",

View File

@ -752,61 +752,34 @@ const FileEditor = ({
<SkeletonLoader type="fileGrid" count={6} /> <SkeletonLoader type="fileGrid" count={6} />
</Box> </Box>
) : ( ) : (
<DragDropGrid <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '1.5rem', padding: '1rem' }}>
items={files} {files.map((file, index) => (
selectedItems={localSelectedIds as any /* FIX ME */} <FileThumbnail
selectionMode={selectionMode} key={file.id}
isAnimating={isAnimating} file={file}
onDragStart={handleDragStart as any /* FIX ME */} index={index}
onDragEnd={handleDragEnd} totalFiles={files.length}
onDragOver={handleDragOver} selectedFiles={localSelectedIds}
onDragEnter={handleDragEnter as any /* FIX ME */} selectionMode={selectionMode}
onDragLeave={handleDragLeave} draggedFile={draggedFile}
onDrop={handleDrop as any /* FIX ME */} dropTarget={dropTarget}
onEndZoneDragEnter={handleEndZoneDragEnter} isAnimating={isAnimating}
draggedItem={draggedFile as any /* FIX ME */} fileRefs={fileRefs}
dropTarget={dropTarget as any /* FIX ME */} onDragStart={handleDragStart}
multiItemDrag={multiFileDrag as any /* FIX ME */} onDragEnd={handleDragEnd}
dragPosition={dragPosition} onDragOver={handleDragOver}
renderItem={(file, index, refs) => ( onDragEnter={handleDragEnter}
<FileThumbnail onDragLeave={handleDragLeave}
file={file} onDrop={handleDrop}
index={index} onToggleFile={toggleFile}
totalFiles={files.length} onDeleteFile={handleDeleteFile}
selectedFiles={localSelectedIds} onViewFile={handleViewFile}
selectionMode={selectionMode} onSetStatus={setStatus}
draggedFile={draggedFile} toolMode={toolMode}
dropTarget={dropTarget} isSupported={isFileSupported(file.name)}
isAnimating={isAnimating} />
fileRefs={refs} ))}
onDragStart={handleDragStart} </div>
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
}}
/>
)}
/>
)} )}
</Box> </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 { 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'; import styles from './PageEditor.module.css';
interface DragDropItem { interface DragDropItem {
@ -12,19 +14,9 @@ interface DragDropGridProps<T extends DragDropItem> {
selectedItems: number[]; selectedItems: number[];
selectionMode: boolean; selectionMode: boolean;
isAnimating: boolean; isAnimating: boolean;
onDragStart: (pageNumber: number) => void; onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: 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;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode; renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
renderSplitMarker?: (item: T, index: number) => 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>({ const DragDropGrid = <T extends DragDropItem>({
@ -32,217 +24,88 @@ const DragDropGrid = <T extends DragDropItem>({
selectedItems, selectedItems,
selectionMode, selectionMode,
isAnimating, isAnimating,
onDragStart, onReorderPages,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onEndZoneDragEnter,
renderItem, renderItem,
renderSplitMarker, renderSplitMarker,
draggedItem,
dropTarget,
multiItemDrag,
dragPosition,
}: DragDropGridProps<T>) => { }: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map()); const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const containerRef = useRef<HTMLDivElement>(null); 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 // Grid configuration
React.useEffect(() => { const ITEMS_PER_ROW = 4;
if (items.length > 100) { const ITEM_HEIGHT = 340; // 20rem + gap
console.log(`📊 DragDropGrid: Virtualizing ${items.length} items (large doc: ${isLargeDocument}, buffer: ${BUFFER_SIZE})`); const OVERSCAN = items.length > 1000 ? 8 : 4; // More overscan for large documents
}
}, [items.length, isLargeDocument, BUFFER_SIZE]);
// Throttled scroll handler to prevent excessive re-renders
const throttleRef = useRef<number | undefined>(undefined);
// Detect scroll position from parent container // Virtualization with react-virtual library
useEffect(() => { const rowVirtualizer = useVirtualizer({
const updateScrollPosition = () => { count: Math.ceil(items.length / ITEMS_PER_ROW),
// Throttle scroll updates for better performance getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
if (throttleRef.current) { estimateSize: () => ITEM_HEIGHT,
cancelAnimationFrame(throttleRef.current); overscan: OVERSCAN,
} });
throttleRef.current = requestAnimationFrame(() => {
const scrollingParent = containerRef.current?.closest('[data-scrolling-container]') ||
containerRef.current?.offsetParent?.closest('div[style*="overflow"]');
if (scrollingParent) {
setScrollTop(scrollingParent.scrollTop || 0);
}
});
};
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 ( return (
<Box <Box
ref={containerRef} ref={containerRef}
style={{ style={{
// Performance optimizations for smooth scrolling // Basic container styles
transform: 'translateZ(0)', // Force hardware acceleration width: '100%',
backfaceVisibility: 'hidden', // Better rendering performance height: '100%',
WebkitOverflowScrolling: 'touch', // Smooth scrolling on iOS
}} }}
> >
<div <div
style={{ style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative', position: 'relative',
height: totalHeight,
paddingBottom: '100px'
}} }}
> >
{/* Top spacer for virtualization */} {rowVirtualizer.getVirtualItems().map((virtualRow) => {
<div style={{ height: topSpacer }} /> const startIndex = virtualRow.index * ITEMS_PER_ROW;
const endIndex = Math.min(startIndex + ITEMS_PER_ROW, items.length);
{/* Visible items container */} const rowItems = items.slice(startIndex, endIndex);
<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>
);
})}
{/* End drop zone - inline with pages */} return (
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
<div <div
data-drop-zone="end" key={virtualRow.index}
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 ${ style={{
dropTarget === 'end' position: 'absolute',
? 'ring-2 ring-green-500 bg-green-50' top: 0,
: 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400' left: 0,
}`} width: '100%',
style={{ borderRadius: '12px' }} height: `${virtualRow.size}px`,
onDragOver={onDragOver} transform: `translateY(${virtualRow.start}px)`,
onDragEnter={onEndZoneDragEnter} }}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, 'end')}
> >
<div className="text-gray-500 text-sm text-center font-medium"> <div
Drop here to<br />move to end 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> );
</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> </Box>
); );
}; };

View File

@ -232,11 +232,6 @@ const PageEditor = ({
const [csvInput, setCsvInput] = useState<string>(""); const [csvInput, setCsvInput] = useState<string>("");
const [selectionMode, setSelectionMode] = useState(false); 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 // Export state
const [exportLoading, setExportLoading] = useState(false); const [exportLoading, setExportLoading] = useState(false);
@ -287,7 +282,7 @@ const PageEditor = ({
// Simple cache-first thumbnail generation (no complex detection needed) // 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 () => { const generateMissingThumbnails = useCallback(async () => {
if (!mergedPdfDocument || !primaryFileId || activeFileIds.length !== 1) { if (!mergedPdfDocument || !primaryFileId || activeFileIds.length !== 1) {
return; return;
@ -299,7 +294,34 @@ const PageEditor = ({
const totalPages = mergedPdfDocument.totalPages; const totalPages = mergedPdfDocument.totalPages;
if (totalPages <= 1) return; // Only page 1, nothing to generate 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 = []; const pageNumbersToGenerate = [];
for (let pageNum = 2; pageNum <= totalPages; pageNum++) { for (let pageNum = 2; pageNum <= totalPages; pageNum++) {
const pageId = `${primaryFileId}-page-${pageNum}`; const pageId = `${primaryFileId}-page-${pageNum}`;
@ -313,19 +335,23 @@ const PageEditor = ({
return; 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 { try {
// Load PDF array buffer for Web Workers // Load PDF array buffer for Web Workers
const arrayBuffer = await file.arrayBuffer(); const arrayBuffer = await file.arrayBuffer();
// Calculate quality scale based on file size // 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 // Start parallel thumbnail generation WITHOUT blocking the main thread
await generateThumbnails( await generateThumbnails(
arrayBuffer, arrayBuffer,
pageNumbersToGenerate, pageNumbers,
{ {
scale, // Dynamic quality based on file size scale, // Dynamic quality based on file size
quality: 0.8, quality: 0.8,
@ -338,7 +364,7 @@ const PageEditor = ({
requestAnimationFrame(() => { requestAnimationFrame(() => {
progress.thumbnails.forEach(({ pageNumber, thumbnail }) => { progress.thumbnails.forEach(({ pageNumber, thumbnail }) => {
// Use stable fileId for cache key // Use stable fileId for cache key
const pageId = `${primaryFileId}-page-${pageNumber}`; const pageId = `${fileId}-page-${pageNumber}`;
addThumbnailToCache(pageId, thumbnail); addThumbnailToCache(pageId, thumbnail);
// Don't update context state - thumbnails stay in cache only // 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) { } catch (error) {
console.error('PageEditor: Thumbnail generation failed:', 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 // Simple useEffect - just generate missing thumbnails when document is ready
useEffect(() => { useEffect(() => {
@ -388,30 +443,6 @@ const PageEditor = ({
setCsvInput(newCsvInput); setCsvInput(newCsvInput);
}, [selectedPageNumbers]); }, [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(() => { const selectAll = useCallback(() => {
if (mergedPdfDocument) { if (mergedPdfDocument) {
@ -484,73 +515,8 @@ const PageEditor = ({
actions.setSelectedPages(pageNumbers); actions.setSelectedPages(pageNumbers);
}, [csvInput, parseCSVInput, actions]); }, [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 // Update PDF document state with edit tracking
const setPdfDocument = useCallback((updatedDoc: PDFDocument) => { const setPdfDocument = useCallback((updatedDoc: PDFDocument) => {
@ -791,34 +757,22 @@ const PageEditor = ({
}, 10); // Small delay to allow state update }, 10); // Small delay to allow state update
}, [displayDocument, isAnimating, executeCommand, selectionMode, selectedPageNumbers, setPdfDocument]); }, [displayDocument, isAnimating, executeCommand, selectionMode, selectedPageNumbers, setPdfDocument]);
const handleDrop = useCallback((e: React.DragEvent, targetPageNumber: number | 'end') => { const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => {
e.preventDefault(); if (!displayDocument) return;
if (!draggedPage || !displayDocument || draggedPage === targetPageNumber) return;
let targetIndex: number; const pagesToMove = selectedPages && selectedPages.length > 1
if (targetPageNumber === 'end') { ? selectedPages
targetIndex = displayDocument.pages.length; : [sourcePageNumber];
} else {
targetIndex = displayDocument.pages.findIndex(p => p.pageNumber === targetPageNumber); const sourceIndex = displayDocument.pages.findIndex(p => p.pageNumber === sourcePageNumber);
if (targetIndex === -1) return; if (sourceIndex === -1 || sourceIndex === targetIndex) return;
}
animateReorder(draggedPage, targetIndex); animateReorder(sourcePageNumber, targetIndex);
setDraggedPage(null); const moveCount = pagesToMove.length;
setDropTarget(null);
setMultiPageDrag(null);
setDragPosition(null);
const moveCount = multiPageDrag ? multiPageDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`); 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') => { const handleRotate = useCallback((direction: 'left' | 'right') => {
if (!displayDocument) return; if (!displayDocument) return;
@ -1058,6 +1012,13 @@ const PageEditor = ({
const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`; const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`;
// Use centralized IndexedDB manager // Use centralized IndexedDB manager
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS); 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 transaction = db.transaction('drafts', 'readonly');
const store = transaction.objectStore('drafts'); const store = transaction.objectStore('drafts');
const getRequest = store.get(draftKey); const getRequest = store.get(draftKey);
@ -1288,17 +1249,7 @@ const PageEditor = ({
selectedItems={selectedPageNumbers} selectedItems={selectedPageNumbers}
selectionMode={selectionMode} selectionMode={selectionMode}
isAnimating={isAnimating} isAnimating={isAnimating}
onDragStart={handleDragStart} onReorderPages={handleReorderPages}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onEndZoneDragEnter={handleEndZoneDragEnter}
draggedItem={draggedPage}
dropTarget={dropTarget}
multiItemDrag={multiPageDrag}
dragPosition={dragPosition}
renderItem={(page, index, refs) => ( renderItem={(page, index, refs) => (
<PageThumbnail <PageThumbnail
page={page} page={page}
@ -1307,17 +1258,10 @@ const PageEditor = ({
originalFile={activeFileIds.length === 1 && primaryFileId ? selectors.getFile(primaryFileId) : undefined} originalFile={activeFileIds.length === 1 && primaryFileId ? selectors.getFile(primaryFileId) : undefined}
selectedPages={selectedPageNumbers} selectedPages={selectedPageNumbers}
selectionMode={selectionMode} selectionMode={selectionMode}
draggedPage={draggedPage}
dropTarget={dropTarget === 'end' ? null : dropTarget}
movingPage={movingPage} movingPage={movingPage}
isAnimating={isAnimating} isAnimating={isAnimating}
pageRefs={refs} pageRefs={refs}
onDragStart={handleDragStart} onReorderPages={handleReorderPages}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onTogglePage={togglePage} onTogglePage={togglePage}
onAnimateReorder={animateReorder} onAnimateReorder={animateReorder}
onExecuteCommand={executeCommand} onExecuteCommand={executeCommand}

View File

@ -6,7 +6,7 @@ import RotateLeftIcon from '@mui/icons-material/RotateLeft';
import RotateRightIcon from '@mui/icons-material/RotateRight'; import RotateRightIcon from '@mui/icons-material/RotateRight';
import DeleteIcon from '@mui/icons-material/Delete'; import DeleteIcon from '@mui/icons-material/Delete';
import ContentCutIcon from '@mui/icons-material/ContentCut'; 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 { PDFPage, PDFDocument } from '../../types/pageEditor';
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands'; import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
import { Command } from '../../hooks/useUndoRedo'; import { Command } from '../../hooks/useUndoRedo';
@ -27,22 +27,15 @@ interface PageThumbnailProps {
originalFile?: File; // For lazy thumbnail generation originalFile?: File; // For lazy thumbnail generation
selectedPages: number[]; selectedPages: number[];
selectionMode: boolean; selectionMode: boolean;
draggedPage: number | null;
dropTarget: number | 'end' | null;
movingPage: number | null; movingPage: number | null;
isAnimating: boolean; isAnimating: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>; 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; onTogglePage: (pageNumber: number) => void;
onAnimateReorder: (pageNumber: number, targetIndex: number) => void; onAnimateReorder: (pageNumber: number, targetIndex: number) => void;
onExecuteCommand: (command: Command) => void; onExecuteCommand: (command: Command) => void;
onSetStatus: (status: string) => void; onSetStatus: (status: string) => void;
onSetMovingPage: (pageNumber: number | null) => void; onSetMovingPage: (pageNumber: number | null) => void;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPages?: number[]) => void;
RotatePagesCommand: typeof RotatePagesCommand; RotatePagesCommand: typeof RotatePagesCommand;
DeletePagesCommand: typeof DeletePagesCommand; DeletePagesCommand: typeof DeletePagesCommand;
ToggleSplitCommand: typeof ToggleSplitCommand; ToggleSplitCommand: typeof ToggleSplitCommand;
@ -57,22 +50,15 @@ const PageThumbnail = React.memo(({
originalFile, originalFile,
selectedPages, selectedPages,
selectionMode, selectionMode,
draggedPage,
dropTarget,
movingPage, movingPage,
isAnimating, isAnimating,
pageRefs, pageRefs,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onTogglePage, onTogglePage,
onAnimateReorder, onAnimateReorder,
onExecuteCommand, onExecuteCommand,
onSetStatus, onSetStatus,
onSetMovingPage, onSetMovingPage,
onReorderPages,
RotatePagesCommand, RotatePagesCommand,
DeletePagesCommand, DeletePagesCommand,
ToggleSplitCommand, ToggleSplitCommand,
@ -80,6 +66,8 @@ const PageThumbnail = React.memo(({
setPdfDocument, setPdfDocument,
}: PageThumbnailProps) => { }: PageThumbnailProps) => {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail); const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement>(null);
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration(); const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
@ -130,14 +118,69 @@ const PageThumbnail = React.memo(({
}, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops }, [page.id, originalFile, requestThumbnail, getThumbnailFromCache]); // Removed thumbnailUrl to prevent loops
// Register this component with pageRefs for animations
const pageElementRef = useCallback((element: HTMLDivElement | null) => { const pageElementRef = useCallback((element: HTMLDivElement | null) => {
if (element) { if (element) {
pageRefs.current.set(page.id, 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 { } else {
pageRefs.current.delete(page.id); 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 ( return (
<div <div
@ -159,25 +202,13 @@ const PageThumbnail = React.memo(({
${selectionMode ${selectionMode
? 'bg-white hover:bg-gray-50' ? 'bg-white hover:bg-gray-50'
: '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' : ''} ${movingPage === page.pageNumber ? 'page-moving' : ''}
`} `}
style={{ 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' transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}} }}
draggable draggable={false}
onDragStart={() => onDragStart(page.pageNumber)}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragEnter={() => onDragEnter(page.pageNumber)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, page.pageNumber)}
> >
{selectionMode && ( {selectionMode && (
<div <div
@ -201,7 +232,6 @@ const PageThumbnail = React.memo(({
e.stopPropagation(); e.stopPropagation();
}} }}
onClick={(e) => { onClick={(e) => {
console.log('📸 Checkbox clicked for page', page.pageNumber);
e.stopPropagation(); e.stopPropagation();
onTogglePage(page.pageNumber); onTogglePage(page.pageNumber);
}} }}
@ -216,7 +246,7 @@ const PageThumbnail = React.memo(({
</div> </div>
)} )}
<div className="page-container w-[90%] h-[90%]"> <div className="page-container w-[90%] h-[90%]" draggable={false}>
<div <div
style={{ style={{
width: '100%', width: '100%',
@ -234,6 +264,7 @@ const PageThumbnail = React.memo(({
<img <img
src={thumbnailUrl} src={thumbnailUrl}
alt={`Page ${page.pageNumber}`} alt={`Page ${page.pageNumber}`}
draggable={false}
style={{ style={{
width: '100%', width: '100%',
height: '100%', height: '100%',
@ -415,16 +446,6 @@ const PageThumbnail = React.memo(({
)} )}
</div> </div>
<DragIndicatorIcon
style={{
position: 'absolute',
bottom: 4,
right: 4,
color: 'rgba(0,0,0,0.3)',
fontSize: 16,
zIndex: 1
}}
/>
</div> </div>
</div> </div>
); );
@ -444,8 +465,6 @@ const PageThumbnail = React.memo(({
(prevProps.selectedPages === nextProps.selectedPages || (prevProps.selectedPages === nextProps.selectedPages ||
arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) && arraysEqual(prevProps.selectedPages, nextProps.selectedPages)) &&
prevProps.selectionMode === nextProps.selectionMode && prevProps.selectionMode === nextProps.selectionMode &&
prevProps.draggedPage === nextProps.draggedPage &&
prevProps.dropTarget === nextProps.dropTarget &&
prevProps.movingPage === nextProps.movingPage && prevProps.movingPage === nextProps.movingPage &&
prevProps.isAnimating === nextProps.isAnimating prevProps.isAnimating === nextProps.isAnimating
); );