Enforce type checking in CI (#4126)

# Description of Changes
Currently, the `tsconfig.json` file enforces strict type checking, but
nothing in CI checks that the code is actually correctly typed. [Vite
only transpiles TypeScript
code](https://vite.dev/guide/features.html#transpile-only) so doesn't
ensure that the TS code we're running is correct.

This PR adds running of the type checker to CI and fixes the type errors
that have already crept into the codebase.

Note that many of the changes I've made to 'fix the types' are just
using `any` to disable the type checker because the code is under too
much churn to fix anything properly at the moment. I still think
enabling the type checker now is the best course of action though
because otherwise we'll never be able to fix all of them, and it should
at least help us not break things when adding new code.

Co-authored-by: James <[email protected]>
This commit is contained in:
James Brunton
2025-08-11 09:16:16 +01:00
committed by GitHub
co-authored by James
parent 507ad1dc61
commit af5a9d1ae1
52 changed files with 1141 additions and 919 deletions
@@ -70,11 +70,11 @@ const FileEditor = ({
} = fileContext;
// Get file selection context
const {
selectedFiles: toolSelectedFiles,
setSelectedFiles: setToolSelectedFiles,
maxFiles,
isToolMode
const {
selectedFiles: toolSelectedFiles,
setSelectedFiles: setToolSelectedFiles,
maxFiles,
isToolMode
} = useFileSelection();
const [files, setFiles] = useState<FileItem[]>([]);
@@ -82,7 +82,7 @@ const FileEditor = ({
const [error, setError] = useState<string | null>(null);
const [localLoading, setLocalLoading] = useState(false);
const [selectionMode, setSelectionMode] = useState(toolMode);
// Enable selection mode automatically in tool mode
React.useEffect(() => {
if (toolMode) {
@@ -115,7 +115,7 @@ const FileEditor = ({
// Get selected file IDs from context (defensive programming)
const contextSelectedIds = Array.isArray(selectedFileIds) ? selectedFileIds : [];
// Map context selections to local file IDs for UI display
const localSelectedIds = files
.filter(file => {
@@ -144,33 +144,33 @@ const FileEditor = ({
// Check if the actual content has changed, not just references
const currentActiveFileNames = activeFiles.map(f => f.name);
const currentProcessedFilesSize = processedFiles.size;
const activeFilesChanged = JSON.stringify(currentActiveFileNames) !== JSON.stringify(lastActiveFilesRef.current);
const processedFilesChanged = currentProcessedFilesSize !== lastProcessedFilesRef.current;
if (!activeFilesChanged && !processedFilesChanged) {
return;
}
// Update refs
lastActiveFilesRef.current = currentActiveFileNames;
lastProcessedFilesRef.current = currentProcessedFilesSize;
const convertActiveFiles = async () => {
if (activeFiles.length > 0) {
setLocalLoading(true);
try {
// Process files in chunks to avoid blocking UI
const convertedFiles: FileItem[] = [];
for (let i = 0; i < activeFiles.length; i++) {
const file = activeFiles[i];
// Try to get thumbnail from processed file first
const processedFile = processedFiles.get(file);
let thumbnail = processedFile?.pages?.[0]?.thumbnail;
// If no thumbnail from processed file, try to generate one
if (!thumbnail) {
try {
@@ -180,28 +180,28 @@ const FileEditor = ({
thumbnail = undefined; // Use placeholder
}
}
const convertedFile = {
id: `file-${Date.now()}-${Math.random()}`,
name: file.name,
pageCount: processedFile?.totalPages || Math.floor(Math.random() * 20) + 1,
thumbnail,
thumbnail: thumbnail || '',
size: file.size,
file,
};
convertedFiles.push(convertedFile);
// Update progress
setConversionProgress(((i + 1) / activeFiles.length) * 100);
// Yield to main thread between files
if (i < activeFiles.length - 1) {
await new Promise(resolve => requestAnimationFrame(resolve));
}
}
setFiles(convertedFiles);
} catch (err) {
console.error('Error converting active files:', err);
@@ -237,7 +237,7 @@ const FileEditor = ({
try {
// Validate ZIP file first
const validation = await zipFileService.validateZipFile(file);
if (validation.isValid && validation.containsPDFs) {
// ZIP contains PDFs - extract them
setZipExtractionProgress({
@@ -269,7 +269,7 @@ const FileEditor = ({
if (extractionResult.success) {
allExtractedFiles.push(...extractionResult.extractedFiles);
// Record ZIP extraction operation
const operationId = `zip-extract-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const operation: FileOperation = {
@@ -289,10 +289,10 @@ const FileEditor = ({
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
if (extractionResult.errors.length > 0) {
errors.push(...extractionResult.errors);
}
@@ -344,7 +344,7 @@ const FileEditor = ({
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
}
@@ -357,7 +357,7 @@ const FileEditor = ({
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
setError(errorMessage);
console.error('File processing error:', err);
// Reset extraction progress on error
setZipExtractionProgress({
isExtracting: false,
@@ -377,7 +377,7 @@ const FileEditor = ({
const closeAllFiles = useCallback(() => {
if (activeFiles.length === 0) return;
// Record close all operation for each file
activeFiles.forEach(file => {
const operationId = `close-all-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
@@ -396,14 +396,14 @@ const FileEditor = ({
}
}
};
recordOperation(file.name, operation);
markOperationApplied(file.name, operationId);
});
// Remove all files from context but keep in storage
removeFiles(activeFiles.map(f => (f as any).id || f.name), false);
// Clear selections
setContextSelectedFiles([]);
}, [activeFiles, removeFiles, setContextSelectedFiles, recordOperation, markOperationApplied]);
@@ -411,12 +411,12 @@ const FileEditor = ({
const toggleFile = useCallback((fileId: string) => {
const targetFile = files.find(f => f.id === fileId);
if (!targetFile) return;
const contextFileId = (targetFile.file as any).id || targetFile.name;
const isSelected = contextSelectedIds.includes(contextFileId);
let newSelection: string[];
if (isSelected) {
// Remove file from selection
newSelection = contextSelectedIds.filter(id => id !== contextFileId);
@@ -433,10 +433,10 @@ const FileEditor = ({
newSelection = [...contextSelectedIds, contextFileId];
}
}
// Update context
setContextSelectedFiles(newSelection);
// Update tool selection context if in tool mode
if (isToolMode || toolMode) {
const selectedFiles = files
@@ -572,12 +572,12 @@ const FileEditor = ({
console.log('handleDeleteFile called with fileId:', fileId);
const file = files.find(f => f.id === fileId);
console.log('Found file:', file);
if (file) {
console.log('Attempting to remove file:', file.name);
console.log('Actual file object:', file.file);
console.log('Actual file.file.name:', file.file.name);
// Record close operation
const fileName = file.file.name;
const fileId = (file.file as any).id || fileName;
@@ -597,19 +597,16 @@ const FileEditor = ({
}
}
};
recordOperation(fileName, operation);
// Remove file from context but keep in storage (close, don't delete)
console.log('Calling removeFiles with:', [fileId]);
removeFiles([fileId], false);
// Remove from context selections
setContextSelectedFiles(prev => {
const safePrev = Array.isArray(prev) ? prev : [];
return safePrev.filter(id => id !== fileId);
});
const newSelection = contextSelectedIds.filter(id => id !== fileId);
setContextSelectedFiles(newSelection);
// Mark operation as applied
markOperationApplied(fileName, operationId);
} else {
@@ -670,7 +667,7 @@ const FileEditor = ({
accept={["*/*"]}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{
style={{
height: '100vh',
border: 'none',
borderRadius: 0,
@@ -707,7 +704,7 @@ const FileEditor = ({
) : files.length === 0 && (localLoading || zipExtractionProgress.isExtracting) ? (
<Box>
<SkeletonLoader type="controls" />
{/* ZIP Extraction Progress */}
{zipExtractionProgress.isExtracting && (
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-orange-0)', borderRadius: 8 }}>
@@ -721,10 +718,10 @@ const FileEditor = ({
<Text size="xs" c="dimmed" mb="xs">
{zipExtractionProgress.extractedCount} of {zipExtractionProgress.totalFiles} files extracted
</Text>
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '2px',
overflow: 'hidden'
}}>
@@ -737,7 +734,7 @@ const FileEditor = ({
</div>
</Box>
)}
{/* Processing indicator */}
{localLoading && (
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
@@ -745,10 +742,10 @@ const FileEditor = ({
<Text size="sm" fw={500}>Loading files...</Text>
<Text size="sm" c="dimmed">{Math.round(conversionProgress)}%</Text>
</Group>
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '2px',
overflow: 'hidden'
}}>
@@ -761,27 +758,27 @@ const FileEditor = ({
</div>
</Box>
)}
<SkeletonLoader type="fileGrid" count={6} />
</Box>
) : (
<DragDropGrid
items={files}
selectedItems={localSelectedIds}
selectedItems={localSelectedIds as any /* FIX ME */}
selectionMode={selectionMode}
isAnimating={isAnimating}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onEndZoneDragEnter={handleEndZoneDragEnter}
draggedItem={draggedFile}
dropTarget={dropTarget}
multiItemDrag={multiFileDrag}
dragPosition={dragPosition}
renderItem={(file, index, refs) => (
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}
@@ -801,8 +798,6 @@ const FileEditor = ({
onToggleFile={toggleFile}
onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile}
onMergeFromHere={handleMergeFromHere}
onSplitFile={handleSplitFile}
onSetStatus={setStatus}
toolMode={toolMode}
isSupported={isFileSupported(file.name)}
@@ -831,7 +826,6 @@ const FileEditor = ({
onClose={() => setShowFilePickerModal(false)}
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
onSelectFiles={handleLoadFromStorage}
allowMultiple={true}
/>
{status && (
@@ -27,9 +27,10 @@ const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
maxHeight = 400
}) => {
const { getFileHistory, getAppliedOperations } = useFileContext();
const history = getFileHistory(fileId);
const operations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
const allOperations = showOnlyApplied ? getAppliedOperations(fileId) : history?.operations || [];
const operations = allOperations.filter(op => 'fileIds' in op) as FileOperation[];
const formatTimestamp = (timestamp: number) => {
return new Date(timestamp).toLocaleString();
@@ -62,7 +63,7 @@ const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
}
};
const renderOperationDetails = (operation: FileOperation | PageOperation) => {
const renderOperationDetails = (operation: FileOperation) => {
if ('metadata' in operation && operation.metadata) {
const { metadata } = operation;
return (
@@ -142,7 +143,7 @@ const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
</Text>
</Box>
</Group>
<Badge
variant="filled"
color={getStatusColor(operation.status)}
@@ -174,4 +175,4 @@ const FileOperationHistory: React.FC<FileOperationHistoryProps> = ({
);
};
export default FileOperationHistory;
export default FileOperationHistory;
+9 -9
View File
@@ -18,18 +18,18 @@ import LandingPage from '../shared/LandingPage';
export default function Workbench() {
const { t } = useTranslation();
const { isRainbowMode } = useRainbowThemeContext();
// Use context-based hooks to eliminate all prop drilling
const { activeFiles, currentView, setCurrentView } = useFileContext();
const {
previewFile,
pageEditorFunctions,
const {
previewFile,
pageEditorFunctions,
sidebarsVisible,
setPreviewFile,
setPreviewFile,
setPageEditorFunctions,
setSidebarsVisible
} = useWorkbenchState();
const { selectedToolKey, selectedTool, handleToolSelect } = useToolSelection();
const { addToActiveFiles } = useFileHandler();
@@ -142,10 +142,10 @@ export default function Workbench() {
{/* Top Controls */}
<TopControls
currentView={currentView}
setCurrentView={setCurrentView}
setCurrentView={setCurrentView as any /* FIX ME */}
selectedToolKey={selectedToolKey}
/>
{/* Main content area */}
<Box
className="flex-1 min-h-0 relative z-10"
@@ -157,4 +157,4 @@ export default function Workbench() {
</Box>
</Box>
);
}
}
@@ -22,7 +22,7 @@ interface DragDropGridProps<T extends DragDropItem> {
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 | null;
dropTarget: number | 'end' | null;
multiItemDrag: {pageNumbers: number[], count: number} | null;
dragPosition: {x: number, y: number} | null;
}
@@ -345,7 +345,7 @@ const FileThumbnail = ({
onClose={() => setShowHistory(false)}
title={`Operation History - ${file.name}`}
size="lg"
scrollAreaComponent="div"
scrollAreaComponent={'div' as any}
>
<FileOperationHistory
fileId={file.name}
@@ -43,7 +43,7 @@ export interface PageEditorProps {
onExportAll: () => void;
exportLoading: boolean;
selectionMode: boolean;
selectedPages: string[];
selectedPages: number[];
closePdf: () => void;
}) => void;
}
@@ -56,7 +56,7 @@ const PageEditor = ({
// Get file context
const fileContext = useFileContext();
const { file: currentFile, processedFile: currentProcessedFile } = useCurrentFile();
// Use file context state
const {
activeFiles,
@@ -81,12 +81,12 @@ const PageEditor = ({
// Simple computed document from processed files (no caching needed)
const mergedPdfDocument = useMemo(() => {
if (activeFiles.length === 0) return null;
if (activeFiles.length === 1) {
// Single file
const processedFile = processedFiles.get(activeFiles[0]);
if (!processedFile) return null;
return {
id: processedFile.id,
name: activeFiles[0].name,
@@ -108,7 +108,7 @@ const PageEditor = ({
const processedFile = processedFiles.get(file);
if (processedFile) {
filenames.push(file.name.replace(/\.pdf$/i, ''));
processedFile.pages.forEach((page, pageIndex) => {
const newPage: PDFPage = {
...page,
@@ -119,7 +119,7 @@ const PageEditor = ({
};
allPages.push(newPage);
});
totalPages += processedFile.pages.length;
}
});
@@ -140,7 +140,7 @@ const PageEditor = ({
const displayDocument = editedDocument || mergedPdfDocument;
const [filename, setFilename] = useState<string>("");
// Page editor state (use context for selectedPages)
const [status, setStatus] = useState<string | null>(null);
@@ -149,7 +149,7 @@ const PageEditor = ({
// Drag and drop state
const [draggedPage, setDraggedPage] = useState<number | null>(null);
const [dropTarget, setDropTarget] = 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);
@@ -200,54 +200,54 @@ const PageEditor = ({
const [thumbnailGenerationStarted, setThumbnailGenerationStarted] = useState(false);
// Thumbnail generation (opt-in for visual tools)
const {
const {
generateThumbnails,
addThumbnailToCache,
getThumbnailFromCache,
addThumbnailToCache,
getThumbnailFromCache,
stopGeneration,
destroyThumbnails
destroyThumbnails
} = useThumbnailGeneration();
// Start thumbnail generation process (separate from document loading)
const startThumbnailGeneration = useCallback(() => {
console.log('🎬 PageEditor: startThumbnailGeneration called');
console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'activeFiles:', activeFiles.length, 'started:', thumbnailGenerationStarted);
if (!mergedPdfDocument || activeFiles.length !== 1 || thumbnailGenerationStarted) {
console.log('🎬 PageEditor: Skipping thumbnail generation due to conditions');
return;
}
const file = activeFiles[0];
const totalPages = mergedPdfDocument.totalPages;
console.log('🎬 PageEditor: Starting thumbnail generation for', totalPages, 'pages');
setThumbnailGenerationStarted(true);
// Run everything asynchronously to avoid blocking the main thread
setTimeout(async () => {
try {
// Load PDF array buffer for Web Workers
const arrayBuffer = await file.arrayBuffer();
// Generate page numbers for pages that don't have thumbnails yet
const pageNumbers = Array.from({ length: totalPages }, (_, i) => i + 1)
.filter(pageNum => {
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
return !page?.thumbnail; // Only generate for pages without thumbnails
});
console.log(`🎬 PageEditor: Generating thumbnails for ${pageNumbers.length} pages (out of ${totalPages} total):`, pageNumbers.slice(0, 10), pageNumbers.length > 10 ? '...' : '');
// If no pages need thumbnails, we're done
if (pageNumbers.length === 0) {
console.log('🎬 PageEditor: All pages already have thumbnails, no generation needed');
return;
}
// Calculate quality scale based on file size
const scale = activeFiles.length === 1 ? calculateScaleFromFileSize(activeFiles[0].size) : 0.2;
// Start parallel thumbnail generation WITHOUT blocking the main thread
const generationPromise = generateThumbnails(
arrayBuffer,
@@ -267,11 +267,11 @@ const PageEditor = ({
// Check cache first, then send thumbnail
const pageId = `${file.name}-page-${pageNumber}`;
const cached = getThumbnailFromCache(pageId);
if (!cached) {
// Cache and send to component
addThumbnailToCache(pageId, thumbnail);
window.dispatchEvent(new CustomEvent('thumbnailReady', {
detail: { pageNumber, thumbnail, pageId }
}));
@@ -292,7 +292,7 @@ const PageEditor = ({
console.error('✗ PageEditor: Web Worker thumbnail generation failed:', error);
setThumbnailGenerationStarted(false);
});
} catch (error) {
console.error('Failed to start Web Worker thumbnail generation:', error);
setThumbnailGenerationStarted(false);
@@ -304,25 +304,25 @@ const PageEditor = ({
useEffect(() => {
console.log('🎬 PageEditor: Thumbnail generation effect triggered');
console.log('🎬 Conditions - mergedPdfDocument:', !!mergedPdfDocument, 'started:', thumbnailGenerationStarted);
if (mergedPdfDocument && !thumbnailGenerationStarted) {
// Check if ALL pages already have thumbnails from processed files
const totalPages = mergedPdfDocument.pages.length;
const pagesWithThumbnails = mergedPdfDocument.pages.filter(page => page.thumbnail).length;
const hasAllThumbnails = pagesWithThumbnails === totalPages;
console.log('🎬 PageEditor: Thumbnail status:', {
totalPages,
pagesWithThumbnails,
hasAllThumbnails,
missingThumbnails: totalPages - pagesWithThumbnails
});
if (hasAllThumbnails) {
console.log('🎬 PageEditor: Skipping generation - all thumbnails already exist');
return; // Skip generation if ALL thumbnails already exist
}
console.log('🎬 PageEditor: Some thumbnails missing, proceeding with generation');
// Small delay to let document render, then start thumbnail generation
console.log('🎬 PageEditor: Scheduling thumbnail generation in 500ms');
@@ -394,10 +394,10 @@ const PageEditor = ({
const togglePage = useCallback((pageNumber: number) => {
console.log('🔄 Toggling page', pageNumber);
// Check if currently selected and update accordingly
const isCurrentlySelected = selectedPageNumbers.includes(pageNumber);
if (isCurrentlySelected) {
// Remove from selection
console.log('🔄 Removing page', pageNumber);
@@ -524,24 +524,24 @@ const PageEditor = ({
// Update PDF document state with edit tracking
const setPdfDocument = useCallback((updatedDoc: PDFDocument) => {
console.log('setPdfDocument called - setting edited state');
// Update local edit state for immediate visual feedback
setEditedDocument(updatedDoc);
setHasUnsavedChanges(true); // Use global state
setHasUnsavedDraft(true); // Mark that we have unsaved draft changes
// Auto-save to drafts (debounced) - only if we have new changes
if (autoSaveTimer.current) {
clearTimeout(autoSaveTimer.current);
}
autoSaveTimer.current = setTimeout(() => {
if (hasUnsavedDraft) {
saveDraftToIndexedDB(updatedDoc);
setHasUnsavedDraft(false); // Mark draft as saved
}
}, 30000); // Auto-save after 30 seconds of inactivity
return updatedDoc;
}, [setHasUnsavedChanges, hasUnsavedDraft]);
@@ -554,7 +554,7 @@ const PageEditor = ({
timestamp: Date.now(),
originalFiles: activeFiles.map(f => f.name)
};
// Save to 'pdf-drafts' store in IndexedDB
const request = indexedDB.open('stirling-pdf-drafts', 1);
request.onupgradeneeded = () => {
@@ -563,7 +563,7 @@ const PageEditor = ({
db.createObjectStore('drafts');
}
};
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction('drafts', 'readwrite');
@@ -581,7 +581,7 @@ const PageEditor = ({
try {
const draftKey = `draft-${mergedPdfDocument?.id || 'merged'}`;
const request = indexedDB.open('stirling-pdf-drafts', 1);
request.onsuccess = () => {
const db = request.result;
const transaction = db.transaction('drafts', 'readwrite');
@@ -596,12 +596,12 @@ const PageEditor = ({
// Apply changes to create new processed file
const applyChanges = useCallback(async () => {
if (!editedDocument || !mergedPdfDocument) return;
try {
if (activeFiles.length === 1) {
const file = activeFiles[0];
const currentProcessedFile = processedFiles.get(file);
if (currentProcessedFile) {
const updatedProcessedFile = {
...currentProcessedFile,
@@ -614,14 +614,14 @@ const PageEditor = ({
totalPages: editedDocument.pages.length,
lastModified: Date.now()
};
updateProcessedFile(file, updatedProcessedFile);
}
} else if (activeFiles.length > 1) {
setStatus('Apply changes for multiple files not yet supported');
return;
}
// Wait for the processed file update to complete before clearing edit state
setTimeout(() => {
setEditedDocument(null);
@@ -630,7 +630,7 @@ const PageEditor = ({
cleanupDraft();
setStatus('Changes applied successfully');
}, 100);
} catch (error) {
console.error('Failed to apply changes:', error);
setStatus('Failed to apply changes');
@@ -653,7 +653,7 @@ const PageEditor = ({
// Skip animation for large documents (500+ pages) to improve performance
const isLargeDocument = displayDocument.pages.length > 500;
if (isLargeDocument) {
// For large documents, just execute the command without animation
if (pagesToMove.length > 1) {
@@ -678,7 +678,7 @@ const PageEditor = ({
// Only capture positions for potentially affected pages
const currentPositions = new Map<string, { x: number; y: number }>();
affectedPageIds.forEach(pageId => {
const element = document.querySelector(`[data-page-number="${pageId}"]`);
if (element) {
@@ -728,14 +728,14 @@ const PageEditor = ({
if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) {
elementsToAnimate.push(element);
// Apply initial transform
element.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
element.style.transition = 'none';
// Force reflow
element.offsetHeight;
// Animate to final position
element.style.transition = 'transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
element.style.transform = 'translate(0px, 0px)';
@@ -863,13 +863,13 @@ const PageEditor = ({
if (!mergedPdfDocument) return;
// Convert page numbers to page IDs for export service
const exportPageIds = selectedOnly
const exportPageIds = selectedOnly
? selectedPageNumbers.map(pageNum => {
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
return page?.id || '';
}).filter(id => id)
: [];
const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly);
setExportPreview(preview);
setShowExportModal(true);
@@ -881,16 +881,16 @@ const PageEditor = ({
setExportLoading(true);
try {
// Convert page numbers to page IDs for export service
const exportPageIds = selectedOnly
const exportPageIds = selectedOnly
? selectedPageNumbers.map(pageNum => {
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
return page?.id || '';
}).filter(id => id)
: [];
const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly);
if (errors.length > 0) {
setError(errors.join(', '));
setStatus(errors.join(', '));
return;
}
@@ -921,7 +921,7 @@ const PageEditor = ({
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Export failed';
setError(errorMessage);
setStatus(errorMessage);
} finally {
setExportLoading(false);
}
@@ -1009,26 +1009,26 @@ const PageEditor = ({
// Check for existing drafts
const checkForDrafts = useCallback(async () => {
if (!mergedPdfDocument) return;
try {
const draftKey = `draft-${mergedPdfDocument.id || 'merged'}`;
const request = indexedDB.open('stirling-pdf-drafts', 1);
request.onsuccess = () => {
const db = request.result;
if (!db.objectStoreNames.contains('drafts')) return;
const transaction = db.transaction('drafts', 'readonly');
const store = transaction.objectStore('drafts');
const getRequest = store.get(draftKey);
getRequest.onsuccess = () => {
const draft = getRequest.result;
if (draft && draft.timestamp) {
// Check if draft is recent (within last 24 hours)
const draftAge = Date.now() - draft.timestamp;
const twentyFourHours = 24 * 60 * 60 * 1000;
if (draftAge < twentyFourHours) {
setFoundDraft(draft);
setShowResumeModal(true);
@@ -1066,12 +1066,12 @@ const PageEditor = ({
useEffect(() => {
return () => {
console.log('PageEditor unmounting - cleaning up resources');
// Clear auto-save timer
if (autoSaveTimer.current) {
clearTimeout(autoSaveTimer.current);
}
// Clean up draft if component unmounts with unsaved changes
if (hasUnsavedChanges) {
cleanupDraft();
@@ -1125,7 +1125,7 @@ const PageEditor = ({
{showLoading && (
<Box p="md" pt="xl">
<SkeletonLoader type="controls" />
{/* Progress indicator */}
<Box mb="md" p="sm" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
<Group justify="space-between" mb="xs">
@@ -1136,10 +1136,10 @@ const PageEditor = ({
{Math.round(processingProgress || 0)}%
</Text>
</Group>
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '2px',
overflow: 'hidden'
}}>
@@ -1151,7 +1151,7 @@ const PageEditor = ({
}} />
</div>
</Box>
<SkeletonLoader type="pageGrid" count={8} />
</Box>
)}
@@ -1165,10 +1165,10 @@ const PageEditor = ({
<Text size="sm" fw={500}>Processing thumbnails...</Text>
<Text size="sm" c="dimmed">{Math.round(processingProgress || 0)}%</Text>
</Group>
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
<div style={{
width: '100%',
height: '4px',
backgroundColor: 'var(--mantine-color-gray-2)',
borderRadius: '2px',
overflow: 'hidden'
}}>
@@ -1210,7 +1210,7 @@ const PageEditor = ({
<Button onClick={deselectAll} variant="light">Deselect All</Button>
</>
)}
{/* Apply Changes Button */}
{hasUnsavedChanges && (
<Button
@@ -1233,7 +1233,7 @@ const PageEditor = ({
/>
)}
<DragDropGrid
items={displayedPages}
selectedItems={selectedPageNumbers}
@@ -1259,7 +1259,7 @@ const PageEditor = ({
selectedPages={selectedPageNumbers}
selectionMode={selectionMode}
draggedPage={draggedPage}
dropTarget={dropTarget}
dropTarget={dropTarget === 'end' ? null : dropTarget}
movingPage={movingPage}
isAnimating={isAnimating}
pageRefs={refs}
@@ -1372,13 +1372,13 @@ const PageEditor = ({
<Text>
We found unsaved changes from a previous session. Would you like to resume where you left off?
</Text>
{foundDraft && (
<Text size="sm" c="dimmed">
Last saved: {new Date(foundDraft.timestamp).toLocaleString()}
</Text>
)}
<Group justify="flex-end" gap="sm">
<Button
variant="light"
@@ -1387,7 +1387,7 @@ const PageEditor = ({
>
Start Fresh
</Button>
<Button
color="blue"
onClick={resumeWork}
@@ -35,7 +35,7 @@ interface PageEditorControlsProps {
// Selection state
selectionMode: boolean;
selectedPages: string[];
selectedPages: number[];
}
const PageEditorControls = ({
@@ -7,9 +7,9 @@ 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 { PDFPage, PDFDocument } from '../../../types/pageEditor';
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../../commands/pageCommands';
import { Command } from '../../../hooks/useUndoRedo';
import { PDFPage, PDFDocument } from '../../types/pageEditor';
import { RotatePagesCommand, DeletePagesCommand, ToggleSplitCommand } from '../../commands/pageCommands';
import { Command } from '../../hooks/useUndoRedo';
import styles from './PageEditor.module.css';
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
@@ -29,7 +29,7 @@ interface PageThumbnailProps {
selectedPages: number[];
selectionMode: boolean;
draggedPage: number | null;
dropTarget: number | null;
dropTarget: number | 'end' | null;
movingPage: number | null;
isAnimating: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
@@ -82,7 +82,7 @@ const PageThumbnail = React.memo(({
}: PageThumbnailProps) => {
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const [isLoadingThumbnail, setIsLoadingThumbnail] = useState(false);
// Update thumbnail URL when page prop changes
useEffect(() => {
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
@@ -97,13 +97,13 @@ const PageThumbnail = React.memo(({
console.log(`📸 PageThumbnail: Page ${page.pageNumber} already has thumbnail, skipping worker listener`);
return; // Skip if we already have a thumbnail
}
console.log(`📸 PageThumbnail: Setting up worker listener for page ${page.pageNumber} (${page.id})`);
const handleThumbnailReady = (event: CustomEvent) => {
const { pageNumber, thumbnail, pageId } = event.detail;
console.log(`📸 PageThumbnail: Received worker thumbnail for page ${pageNumber}, looking for page ${page.pageNumber} (${page.id})`);
if (pageNumber === page.pageNumber && pageId === page.id) {
console.log(`✓ PageThumbnail: Thumbnail matched for page ${page.pageNumber}, setting URL`);
setThumbnailUrl(thumbnail);
+1 -2
View File
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import { useState } from "react";
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
@@ -9,7 +9,6 @@ import EditIcon from "@mui/icons-material/Edit";
import { FileWithUrl } from "../../types/file";
import { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
import { fileStorage } from "../../services/fileStorage";
interface FileCardProps {
file: FileWithUrl;
+3 -3
View File
@@ -80,7 +80,7 @@ const FileGrid = ({
{showSearch && (
<TextInput
placeholder={t("fileManager.searchFiles", "Search files...")}
leftSection={<SearchIcon size={16} />}
leftSection={<SearchIcon fontSize="small" />}
value={searchTerm}
onChange={(e) => setSearchTerm(e.currentTarget.value)}
style={{ flexGrow: 1, maxWidth: 300, minWidth: 200 }}
@@ -96,7 +96,7 @@ const FileGrid = ({
]}
value={sortBy}
onChange={(value) => setSortBy(value as SortOption)}
leftSection={<SortIcon size={16} />}
leftSection={<SortIcon fontSize="small" />}
style={{ minWidth: 150 }}
/>
)}
@@ -130,7 +130,7 @@ const FileGrid = ({
<FileCard
key={fileId + idx}
file={file}
onRemove={onRemove ? () => onRemove(originalIdx) : undefined}
onRemove={onRemove ? () => onRemove(originalIdx) : () => {}}
onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(file) : undefined}
onView={onView && supported ? () => onView(file) : undefined}
onEdit={onEdit && supported ? () => onEdit(file) : undefined}
@@ -64,19 +64,19 @@ const TopControls = ({
}: TopControlsProps) => {
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<string | null>(null);
const isToolSelected = selectedToolKey !== null;
const handleViewChange = useCallback((view: string) => {
// Show immediate feedback
setSwitchingTo(view);
// Defer the heavy view change to next frame so spinner can render
requestAnimationFrame(() => {
// Give the spinner one more frame to show
requestAnimationFrame(() => {
setCurrentView(view);
// Clear the loading state after view change completes
setTimeout(() => setSwitchingTo(null), 300);
});
+9 -9
View File
@@ -16,24 +16,24 @@ export default function ToolPanel() {
const { sidebarRefs } = useSidebarContext();
const { toolPanelRef } = sidebarRefs;
// Use context-based hooks to eliminate prop drilling
const {
leftPanelView,
isPanelVisible,
searchQuery,
const {
leftPanelView,
isPanelVisible,
searchQuery,
filteredTools,
setSearchQuery,
handleBackToTools
} = useToolPanelState();
const { selectedToolKey, handleToolSelect } = useToolSelection();
const { setPreviewFile } = useWorkbenchState();
return (
<div
ref={toolPanelRef}
data-sidebar="tool-panel"
data-sidebar="tool-panel"
className={`h-screen flex flex-col overflow-hidden bg-[var(--bg-toolbar)] border-r border-[var(--border-subtle)] transition-all duration-300 ease-out ${
isRainbowMode ? rainbowStyles.rainbowPaper : ''
}`}
@@ -77,7 +77,7 @@ export default function ToolPanel() {
{/* Tool content */}
<div className="flex-1 min-h-0">
<ToolRenderer
selectedToolKey={selectedToolKey}
selectedToolKey={selectedToolKey || ''}
onPreviewFile={setPreviewFile}
/>
</div>
@@ -86,4 +86,4 @@ export default function ToolPanel() {
</div>
</div>
);
}
}
@@ -30,12 +30,12 @@ const ConvertFromImageSettings = ({
})}
data={[
{ value: COLOR_TYPES.COLOR, label: t("convert.color", "Color") },
{ value: COLOR_TYPES.GREYSCALE, label: t("convert.greyscale", "Greyscale") },
{ value: COLOR_TYPES.GRAYSCALE, label: t("convert.grayscale", "Grayscale") },
{ value: COLOR_TYPES.BLACK_WHITE, label: t("convert.blackwhite", "Black & White") },
]}
disabled={disabled}
/>
<Select
data-testid="fit-option-select"
label={t("convert.fitOption", "Fit Option")}
@@ -51,7 +51,7 @@ const ConvertFromImageSettings = ({
]}
disabled={disabled}
/>
<Switch
data-testid="auto-rotate-switch"
label={t("convert.autoRotate", "Auto Rotate")}
@@ -63,7 +63,7 @@ const ConvertFromImageSettings = ({
})}
disabled={disabled}
/>
<Switch
data-testid="combine-images-switch"
label={t("convert.combineImages", "Combine Images")}
@@ -79,4 +79,4 @@ const ConvertFromImageSettings = ({
);
};
export default ConvertFromImageSettings;
export default ConvertFromImageSettings;
@@ -31,7 +31,6 @@ const ConvertFromWebSettings = ({
min={0.1}
max={3.0}
step={0.1}
precision={1}
disabled={disabled}
data-testid="zoom-level-input"
/>
@@ -31,7 +31,7 @@ const ConvertToImageSettings = ({
})}
data={[
{ value: COLOR_TYPES.COLOR, label: t("convert.color", "Color") },
{ value: COLOR_TYPES.GREYSCALE, label: t("convert.greyscale", "Greyscale") },
{ value: COLOR_TYPES.GRAYSCALE, label: t("convert.grayscale", "Grayscale") },
{ value: COLOR_TYPES.BLACK_WHITE, label: t("convert.blackwhite", "Black & White") },
]}
disabled={disabled}
@@ -68,4 +68,4 @@ const ConvertToImageSettings = ({
);
};
export default ConvertToImageSettings;
export default ConvertToImageSettings;
@@ -30,7 +30,7 @@ const ConvertToPdfaSettings = ({
<Text size="sm" fw={500}>{t("convert.pdfaOptions", "PDF/A Options")}:</Text>
{hasDigitalSignatures && (
<Alert color="yellow" size="sm">
<Alert color="yellow">
<Text size="sm">
{t("convert.pdfaDigitalSignatureWarning", "The PDF contains a digital signature. This will be removed in the next step.")}
</Text>
@@ -1,6 +1,6 @@
import { Stack, TextInput, Select, Checkbox } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants';
import { isSplitMode, SPLIT_MODES, SPLIT_TYPES, type SplitMode, type SplitType } from '../../../constants/splitConstants';
export interface SplitParameters {
mode: SplitMode | '';
@@ -123,7 +123,7 @@ const SplitSettings = ({
label="Choose split method"
placeholder="Select how to split the PDF"
value={parameters.mode}
onChange={(v) => v && onParameterChange('mode', v)}
onChange={(v) => isSplitMode(v) && onParameterChange('mode', v)}
disabled={disabled}
data={[
{ value: SPLIT_MODES.BY_PAGES, label: t("split.header", "Split by Pages") + " (e.g. 1,3,5-10)" },
+20 -25
View File
@@ -137,7 +137,7 @@ export interface ViewerProps {
sidebarsVisible: boolean;
setSidebarsVisible: (v: boolean) => void;
onClose?: () => void;
previewFile?: File; // For preview mode - bypasses context
previewFile: File | null; // For preview mode - bypasses context
}
const Viewer = ({
@@ -148,18 +148,13 @@ const Viewer = ({
}: ViewerProps) => {
const { t } = useTranslation();
const theme = useMantineTheme();
// Get current file from FileContext
const { getCurrentFile, getCurrentProcessedFile, clearAllFiles, addFiles, activeFiles } = useFileContext();
const currentFile = getCurrentFile();
const processedFile = getCurrentProcessedFile();
// Convert File to FileWithUrl format for viewer
const pdfFile = useFileWithUrl(currentFile);
// Tab management for multiple files
const [activeTab, setActiveTab] = useState<string>("0");
// Reset PDF state when switching tabs
const handleTabChange = (newTab: string) => {
setActiveTab(newTab);
@@ -183,7 +178,7 @@ const Viewer = ({
const file2WithUrl = useFileWithUrl(activeFiles[2]);
const file3WithUrl = useFileWithUrl(activeFiles[3]);
const file4WithUrl = useFileWithUrl(activeFiles[4]);
const filesWithUrls = React.useMemo(() => {
return [file0WithUrl, file1WithUrl, file2WithUrl, file3WithUrl, file4WithUrl]
.slice(0, activeFiles.length)
@@ -197,11 +192,11 @@ const Viewer = ({
if (!(previewFile instanceof File)) {
return null;
}
if (previewFile.size === 0) {
return null;
}
return { file: previewFile, url: null };
} else {
// Use the file from the active tab
@@ -262,12 +257,12 @@ const Viewer = ({
// Progressive preloading function
const startProgressivePreload = async () => {
if (!pdfDocRef.current || preloadingRef.current || numPages === 0) return;
preloadingRef.current = true;
// Start with first few pages for immediate viewing
const priorityPages = [0, 1, 2, 3, 4]; // First 5 pages
// Render priority pages first
for (const pageIndex of priorityPages) {
if (pageIndex < numPages && !pageImages[pageIndex]) {
@@ -276,7 +271,7 @@ const Viewer = ({
await new Promise(resolve => setTimeout(resolve, 50));
}
}
// Then render remaining pages in background
for (let pageIndex = 5; pageIndex < numPages; pageIndex++) {
if (!pageImages[pageIndex]) {
@@ -285,7 +280,7 @@ const Viewer = ({
await new Promise(resolve => setTimeout(resolve, 100));
}
}
preloadingRef.current = false;
};
@@ -300,15 +295,15 @@ const Viewer = ({
const scrollToPage = (pageNumber: number) => {
const el = pageRefs.current[pageNumber - 1];
const scrollArea = scrollAreaRef.current;
if (el && scrollArea) {
const scrollAreaRect = scrollArea.getBoundingClientRect();
const elRect = el.getBoundingClientRect();
const currentScrollTop = scrollArea.scrollTop;
// Position page near top of viewport with some padding
const targetScrollTop = currentScrollTop + (elRect.top - scrollAreaRect.top) - 20;
scrollArea.scrollTo({
top: targetScrollTop,
behavior: "smooth"
@@ -364,7 +359,7 @@ const Viewer = ({
setLoading(true);
try {
let pdfData;
// For preview files, use ArrayBuffer directly to avoid blob URL issues
if (previewFile && effectiveFile.file === previewFile) {
const arrayBuffer = await previewFile.arrayBuffer();
@@ -446,7 +441,7 @@ const Viewer = ({
<CloseIcon />
</ActionIcon>
)}
{!effectiveFile ? (
<Center style={{ flex: 1 }}>
<Text c="red">Error: No file provided to viewer</Text>
@@ -455,8 +450,8 @@ const Viewer = ({
<>
{/* Tabs for multiple files */}
{activeFiles.length > 1 && !previewFile && (
<Box
style={{
<Box
style={{
borderBottom: '1px solid var(--mantine-color-gray-3)',
backgroundColor: 'var(--mantine-color-body)',
position: 'relative',
@@ -475,7 +470,7 @@ const Viewer = ({
</Tabs>
</Box>
)}
{loading ? (
<div style={{ flex: 1, padding: '1rem' }}>
<SkeletonLoader type="viewer" />