{
- if (!isAnimating && draggedPage && page.pageNumber !== draggedPage && dropTarget === page.pageNumber) {
- return 'translateX(20px)';
- }
- return 'translateX(0)';
- })(),
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}}
- draggable
- onDragStart={() => onDragStart(page.pageNumber)}
- onDragEnd={onDragEnd}
- onDragOver={onDragOver}
- onDragEnter={() => onDragEnter(page.pageNumber)}
- onDragLeave={onDragLeave}
- onDrop={(e) => onDrop(e, page.pageNumber)}
+ draggable={false}
>
{selectionMode && (
{
- console.log('πΈ Checkbox clicked for page', page.pageNumber);
e.stopPropagation();
onTogglePage(page.pageNumber);
}}
@@ -204,7 +241,7 @@ const PageThumbnail = React.memo(({
)}
-
+
- ) : isLoadingThumbnail ? (
-
-
- Loading...
-
) : (
π
@@ -408,30 +441,25 @@ const PageThumbnail = React.memo(({
)}
-
);
}, (prevProps, nextProps) => {
+ // Helper for shallow array comparison
+ const arraysEqual = (a: number[], b: number[]) => {
+ return a.length === b.length && a.every((val, i) => val === b[i]);
+ };
+
// Only re-render if essential props change
return (
prevProps.page.id === nextProps.page.id &&
prevProps.page.pageNumber === nextProps.page.pageNumber &&
prevProps.page.rotation === nextProps.page.rotation &&
prevProps.page.thumbnail === nextProps.page.thumbnail &&
- prevProps.selectedPages === nextProps.selectedPages && // Compare array reference - will re-render when selection changes
+ // Shallow compare selectedPages array for better stability
+ (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
);
diff --git a/frontend/src/components/shared/FileCard.tsx b/frontend/src/components/shared/FileCard.tsx
index e4ff60eea..73ae01dba 100644
--- a/frontend/src/components/shared/FileCard.tsx
+++ b/frontend/src/components/shared/FileCard.tsx
@@ -6,12 +6,13 @@ import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit";
-import { FileWithUrl } from "../../types/file";
+import { FileRecord } from "../../types/fileContext";
import { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
interface FileCardProps {
- file: FileWithUrl;
+ file: File;
+ record?: FileRecord;
onRemove: () => void;
onDoubleClick?: () => void;
onView?: () => void;
@@ -21,9 +22,12 @@ interface FileCardProps {
isSupported?: boolean; // Whether the file format is supported by the current tool
}
-const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
+const FileCard = ({ file, record, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect, isSupported = true }: FileCardProps) => {
const { t } = useTranslation();
- const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
+ // Use record thumbnail if available, otherwise fall back to IndexedDB lookup
+ const fileMetadata = record ? { id: record.id, name: file.name, type: file.type, size: file.size, lastModified: file.lastModified } : null;
+ const { thumbnail: indexedDBThumb, isGenerating } = useIndexedDBThumbnail(fileMetadata);
+ const thumb = record?.thumbnailUrl || indexedDBThumb;
const [isHovered, setIsHovered] = useState(false);
return (
@@ -173,7 +177,7 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
{getFileDate(file)}
- {file.storedInIndexedDB && (
+ {record?.id && (
;
onRemove?: (index: number) => void;
- onDoubleClick?: (file: FileWithUrl) => void;
- onView?: (file: FileWithUrl) => void;
- onEdit?: (file: FileWithUrl) => void;
+ onDoubleClick?: (item: { file: File; record?: FileRecord }) => void;
+ onView?: (item: { file: File; record?: FileRecord }) => void;
+ onEdit?: (item: { file: File; record?: FileRecord }) => void;
onSelect?: (fileId: string) => void;
selectedFiles?: string[];
showSearch?: boolean;
@@ -46,19 +46,19 @@ const FileGrid = ({
const [sortBy, setSortBy] = useState('date');
// Filter files based on search term
- const filteredFiles = files.filter(file =>
- file.name.toLowerCase().includes(searchTerm.toLowerCase())
+ const filteredFiles = files.filter(item =>
+ item.file.name.toLowerCase().includes(searchTerm.toLowerCase())
);
// Sort files
const sortedFiles = [...filteredFiles].sort((a, b) => {
switch (sortBy) {
case 'date':
- return (b.lastModified || 0) - (a.lastModified || 0);
+ return (b.file.lastModified || 0) - (a.file.lastModified || 0);
case 'name':
- return a.name.localeCompare(b.name);
+ return a.file.name.localeCompare(b.file.name);
case 'size':
- return (b.size || 0) - (a.size || 0);
+ return (b.file.size || 0) - (a.file.size || 0);
default:
return 0;
}
@@ -122,18 +122,19 @@ const FileGrid = ({
h="30rem"
style={{ overflowY: "auto", width: "100%" }}
>
- {displayFiles.map((file, idx) => {
- const fileId = file.id || file.name;
- const originalIdx = files.findIndex(f => (f.id || f.name) === fileId);
- const supported = isFileSupported ? isFileSupported(file.name) : true;
+ {displayFiles.map((item, idx) => {
+ const fileId = item.record?.id || item.file.name;
+ const originalIdx = files.findIndex(f => (f.record?.id || f.file.name) === fileId);
+ const supported = isFileSupported ? isFileSupported(item.file.name) : true;
return (
onRemove(originalIdx) : () => {}}
- onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(file) : undefined}
- onView={onView && supported ? () => onView(file) : undefined}
- onEdit={onEdit && supported ? () => onEdit(file) : undefined}
+ onDoubleClick={onDoubleClick && supported ? () => onDoubleClick(item) : undefined}
+ onView={onView && supported ? () => onView(item) : undefined}
+ onEdit={onEdit && supported ? () => onEdit(item) : undefined}
isSelected={selectedFiles.includes(fileId)}
onSelect={onSelect && supported ? () => onSelect(fileId) : undefined}
isSupported={supported}
diff --git a/frontend/src/components/shared/FilePickerModal.tsx b/frontend/src/components/shared/FilePickerModal.tsx
index f489c5a11..8aa054f25 100644
--- a/frontend/src/components/shared/FilePickerModal.tsx
+++ b/frontend/src/components/shared/FilePickerModal.tsx
@@ -19,7 +19,7 @@ import { useTranslation } from 'react-i18next';
interface FilePickerModalProps {
opened: boolean;
onClose: () => void;
- storedFiles: any[]; // Files from storage (FileWithUrl format)
+ storedFiles: any[]; // Files from storage (various formats supported)
onSelectFiles: (selectedFiles: File[]) => void;
}
@@ -48,7 +48,7 @@ const FilePickerModal = ({
};
const selectAll = () => {
- setSelectedFileIds(storedFiles.map(f => f.id || f.name));
+ setSelectedFileIds(storedFiles.map(f => f.id).filter(Boolean));
};
const selectNone = () => {
@@ -57,7 +57,7 @@ const FilePickerModal = ({
const handleConfirm = async () => {
const selectedFiles = storedFiles.filter(f =>
- selectedFileIds.includes(f.id || f.name)
+ selectedFileIds.includes(f.id)
);
// Convert stored files to File objects
@@ -154,7 +154,7 @@ const FilePickerModal = ({
{storedFiles.map((file) => {
- const fileId = file.id || file.name;
+ const fileId = file.id;
const isSelected = selectedFileIds.includes(fileId);
return (
diff --git a/frontend/src/components/shared/FilePreview.tsx b/frontend/src/components/shared/FilePreview.tsx
index 4a58d4671..a13894040 100644
--- a/frontend/src/components/shared/FilePreview.tsx
+++ b/frontend/src/components/shared/FilePreview.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { Box } from '@mantine/core';
-import { FileWithUrl } from '../../types/file';
+import { FileMetadata } from '../../types/file';
import DocumentThumbnail from './filePreview/DocumentThumbnail';
import DocumentStack from './filePreview/DocumentStack';
import HoverOverlay from './filePreview/HoverOverlay';
@@ -8,7 +8,7 @@ import NavigationArrows from './filePreview/NavigationArrows';
export interface FilePreviewProps {
// Core file data
- file: File | FileWithUrl | null;
+ file: File | FileMetadata | null;
thumbnail?: string | null;
// Optional features
@@ -21,7 +21,7 @@ export interface FilePreviewProps {
isAnimating?: boolean;
// Event handlers
- onFileClick?: (file: File | FileWithUrl | null) => void;
+ onFileClick?: (file: File | FileMetadata | null) => void;
onPrevious?: () => void;
onNext?: () => void;
}
diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx
index 4af3d1202..6c1668a43 100644
--- a/frontend/src/components/shared/LandingPage.tsx
+++ b/frontend/src/components/shared/LandingPage.tsx
@@ -33,7 +33,7 @@ const LandingPage = () => {
{/* White PDF Page Background */}
{
ref={fileInputRef}
type="file"
multiple
- accept="*/*"
+ accept=".pdf,.zip"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>
diff --git a/frontend/src/components/shared/NavigationWarningModal.tsx b/frontend/src/components/shared/NavigationWarningModal.tsx
index a3d3983d2..c7f591c71 100644
--- a/frontend/src/components/shared/NavigationWarningModal.tsx
+++ b/frontend/src/components/shared/NavigationWarningModal.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import { Modal, Text, Button, Group, Stack } from '@mantine/core';
-import { useFileContext } from '../../contexts/FileContext';
+import { useNavigationGuard } from '../../contexts/NavigationContext';
interface NavigationWarningModalProps {
onApplyAndContinue?: () => Promise;
@@ -11,13 +11,13 @@ const NavigationWarningModal = ({
onApplyAndContinue,
onExportAndContinue
}: NavigationWarningModalProps) => {
- const {
- showNavigationWarning,
+ const {
+ showNavigationWarning,
hasUnsavedChanges,
- confirmNavigation,
cancelNavigation,
+ confirmNavigation,
setHasUnsavedChanges
- } = useFileContext();
+ } = useNavigationGuard();
const handleKeepWorking = () => {
cancelNavigation();
diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx
index 98f8d0115..ee5591694 100644
--- a/frontend/src/components/shared/TopControls.tsx
+++ b/frontend/src/components/shared/TopControls.tsx
@@ -1,4 +1,4 @@
-import React, { useState, useCallback } from "react";
+import React, { useState, useCallback, useMemo } from "react";
import { Button, SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import LanguageSelector from "./LanguageSelector";
@@ -10,50 +10,18 @@ import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core";
+import { ModeType } from '../../contexts/NavigationContext';
-// This will be created inside the component to access switchingTo
-const createViewOptions = (switchingTo: string | null) => [
- {
- label: (
-
- {switchingTo === "viewer" ? (
-
- ) : (
-
- )}
-
- ),
- value: "viewer",
- },
- {
- label: (
-
- {switchingTo === "pageEditor" ? (
-
- ) : (
-
- )}
-
- ),
- value: "pageEditor",
- },
- {
- label: (
-
- {switchingTo === "fileEditor" ? (
-
- ) : (
-
- )}
-
- ),
- value: "fileEditor",
- },
-];
+// Stable view option objects that don't recreate on every render
+const VIEW_OPTIONS_BASE = [
+ { value: "viewer", icon: VisibilityIcon },
+ { value: "pageEditor", icon: EditNoteIcon },
+ { value: "fileEditor", icon: FolderIcon },
+] as const;
interface TopControlsProps {
- currentView: string;
- setCurrentView: (view: string) => void;
+ currentView: ModeType;
+ setCurrentView: (view: ModeType) => void;
selectedToolKey?: string | null;
}
@@ -68,6 +36,9 @@ const TopControls = ({
const isToolSelected = selectedToolKey !== null;
const handleViewChange = useCallback((view: string) => {
+ // Guard against redundant changes
+ if (view === currentView) return;
+
// Show immediate feedback
setSwitchingTo(view);
@@ -75,13 +46,28 @@ const TopControls = ({
requestAnimationFrame(() => {
// Give the spinner one more frame to show
requestAnimationFrame(() => {
- setCurrentView(view);
-
+ setCurrentView(view as ModeType);
+
// Clear the loading state after view change completes
setTimeout(() => setSwitchingTo(null), 300);
});
});
- }, [setCurrentView]);
+ }, [setCurrentView, currentView]);
+
+ // Memoize the SegmentedControl data with stable references
+ const viewOptions = useMemo(() =>
+ VIEW_OPTIONS_BASE.map(option => ({
+ value: option.value,
+ label: (
+
+ {switchingTo === option.value ? (
+
+ ) : (
+
+ )}
+
+ )
+ })), [switchingTo]);
const getThemeIcon = () => {
if (isRainbowMode) return ;
@@ -117,7 +103,7 @@ const TopControls = ({
{!isToolSelected && (
void;
diff --git a/frontend/src/components/tools/convert/ConvertSettings.tsx b/frontend/src/components/tools/convert/ConvertSettings.tsx
index 03fce1e7e..bec6b272f 100644
--- a/frontend/src/components/tools/convert/ConvertSettings.tsx
+++ b/frontend/src/components/tools/convert/ConvertSettings.tsx
@@ -5,8 +5,8 @@ import { useTranslation } from "react-i18next";
import { useMultipleEndpointsEnabled } from "../../../hooks/useEndpointConfig";
import { isImageFormat, isWebFormat } from "../../../utils/convertUtils";
import { getConversionEndpoints } from "../../../data/toolsTaxonomy";
-import { useFileSelectionActions } from "../../../contexts/FileSelectionContext";
-import { useFileContext } from "../../../contexts/FileContext";
+import { useFileSelection } from "../../../contexts/FileContext";
+import { useFileState } from "../../../contexts/FileContext";
import { detectFileExtension } from "../../../utils/fileUtils";
import GroupedFormatDropdown from "./GroupedFormatDropdown";
import ConvertToImageSettings from "./ConvertToImageSettings";
@@ -41,8 +41,9 @@ const ConvertSettings = ({
const { t } = useTranslation();
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
- const { setSelectedFiles } = useFileSelectionActions();
- const { activeFiles, setSelectedFiles: setContextSelectedFiles } = useFileContext();
+ const { setSelectedFiles } = useFileSelection();
+ const { state, selectors } = useFileState();
+ const activeFiles = state.files.ids;
const allEndpoints = useMemo(() => getConversionEndpoints(EXTENSION_TO_ENDPOINT), []);
@@ -85,9 +86,9 @@ const ConvertSettings = ({
}
return baseOptions;
- }, [getAvailableToExtensions, endpointStatus, parameters.fromExtension]);
+ }, [parameters.fromExtension, endpointStatus]);
- // Enhanced TO options with endpoint availability
+ // Enhanced TO options with endpoint availability
const enhancedToOptions = useMemo(() => {
if (!parameters.fromExtension) return [];
@@ -96,7 +97,7 @@ const ConvertSettings = ({
...option,
enabled: isConversionAvailable(parameters.fromExtension, option.value)
}));
- }, [parameters.fromExtension, getAvailableToExtensions, endpointStatus]);
+ }, [parameters.fromExtension, endpointStatus]);
const resetParametersToDefaults = () => {
onParameterChange('imageOptions', {
@@ -127,7 +128,8 @@ const ConvertSettings = ({
};
const filterFilesByExtension = (extension: string) => {
- return activeFiles.filter(file => {
+ const files = activeFiles.map(fileId => selectors.getFile(fileId)).filter(Boolean) as File[];
+ return files.filter(file => {
const fileExtension = detectFileExtension(file.name);
if (extension === 'any') {
@@ -141,9 +143,21 @@ const ConvertSettings = ({
};
const updateFileSelection = (files: File[]) => {
- setSelectedFiles(files);
- const fileIds = files.map(file => (file as any).id || file.name);
- setContextSelectedFiles(fileIds);
+ // Map File objects to their actual IDs in FileContext
+ const fileIds = files.map(file => {
+ // Find the file ID by matching file properties
+ const fileRecord = state.files.ids
+ .map(id => selectors.getFileRecord(id))
+ .find(record =>
+ record &&
+ record.name === file.name &&
+ record.size === file.size &&
+ record.lastModified === file.lastModified
+ );
+ return fileRecord?.id;
+ }).filter((id): id is string => id !== undefined); // Type guard to ensure only strings
+
+ setSelectedFiles(fileIds);
};
const handleFromExtensionChange = (value: string) => {
diff --git a/frontend/src/components/viewer/Viewer.tsx b/frontend/src/components/viewer/Viewer.tsx
index 6e08962b1..c1a2b440a 100644
--- a/frontend/src/components/viewer/Viewer.tsx
+++ b/frontend/src/components/viewer/Viewer.tsx
@@ -1,7 +1,7 @@
import React, { useEffect, useState, useRef, useCallback } from "react";
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme, ActionIcon, Box, Tabs } from "@mantine/core";
-import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
import { useTranslation } from "react-i18next";
+import { pdfWorkerManager } from "../../services/pdfWorkerManager";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
import FirstPageIcon from "@mui/icons-material/FirstPage";
@@ -13,10 +13,9 @@ import CloseIcon from "@mui/icons-material/Close";
import { useLocalStorage } from "@mantine/hooks";
import { fileStorage } from "../../services/fileStorage";
import SkeletonLoader from '../shared/SkeletonLoader';
-import { useFileContext } from "../../contexts/FileContext";
+import { useFileState, useFileActions, useCurrentFile } from "../../contexts/FileContext";
import { useFileWithUrl } from "../../hooks/useFileWithUrl";
-GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
// Lazy loading page image component
interface LazyPageImageProps {
@@ -150,7 +149,15 @@ const Viewer = ({
const theme = useMantineTheme();
// Get current file from FileContext
- const { getCurrentFile, getCurrentProcessedFile, clearAllFiles, addFiles, activeFiles } = useFileContext();
+ const { selectors } = useFileState();
+ const { actions } = useFileActions();
+ const currentFile = useCurrentFile();
+
+ const getCurrentFile = () => currentFile.file;
+ const getCurrentProcessedFile = () => currentFile.record?.processedFile || undefined;
+ const clearAllFiles = actions.clearAllFiles;
+ const addFiles = actions.addFiles;
+ const activeFiles = selectors.getFiles();
// Tab management for multiple files
const [activeTab, setActiveTab] = useState("0");
@@ -171,6 +178,10 @@ const Viewer = ({
const [zoom, setZoom] = useState(1); // 1 = 100%
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
+ // Memoize setPageRef to prevent infinite re-renders
+ const setPageRef = useCallback((index: number, ref: HTMLImageElement | null) => {
+ pageRefs.current[index] = ref;
+ }, []);
// Get files with URLs for tabs - we'll need to create these individually
const file0WithUrl = useFileWithUrl(activeFiles[0]);
@@ -385,7 +396,7 @@ const Viewer = ({
throw new Error('No valid PDF source available');
}
- const pdf = await getDocument(pdfData).promise;
+ const pdf = await pdfWorkerManager.createDocument(pdfData);
pdfDocRef.current = pdf;
setNumPages(pdf.numPages);
if (!cancelled) {
@@ -406,6 +417,11 @@ const Viewer = ({
cancelled = true;
// Stop any ongoing preloading
preloadingRef.current = false;
+ // Cleanup PDF document using worker manager
+ if (pdfDocRef.current) {
+ pdfWorkerManager.destroyDocument(pdfDocRef.current);
+ pdfDocRef.current = null;
+ }
// Cleanup ArrayBuffer reference to help garbage collection
currentArrayBufferRef.current = null;
};
@@ -461,7 +477,7 @@ const Viewer = ({
>
handleTabChange(value || "0")}>
- {activeFiles.map((file, index) => (
+ {activeFiles.map((file: any, index: number) => (
{file.name.length > 20 ? `${file.name.substring(0, 20)}...` : file.name}
@@ -494,7 +510,7 @@ const Viewer = ({
isFirst={i === 0}
renderPage={renderPage}
pageImages={pageImages}
- setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
+ setPageRef={setPageRef}
/>
{i * 2 + 1 < numPages && (
{ pageRefs.current[index] = ref; }}
+ setPageRef={setPageRef}
/>
)}
@@ -518,7 +534,7 @@ const Viewer = ({
isFirst={idx === 0}
renderPage={renderPage}
pageImages={pageImages}
- setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
+ setPageRef={setPageRef}
/>
))}
diff --git a/frontend/src/contexts/FileContext.tsx b/frontend/src/contexts/FileContext.tsx
index 13b1a77be..44adf6b28 100644
--- a/frontend/src/contexts/FileContext.tsx
+++ b/frontend/src/contexts/FileContext.tsx
@@ -1,961 +1,279 @@
/**
- * Global file context for managing files, edits, and navigation across all views and tools
+ * FileContext - Manages PDF files for Stirling PDF multi-tool workflow
+ *
+ * Handles file state, memory management, and resource cleanup for large PDFs (up to 100GB+).
+ * Users upload PDFs once and chain tools (split β merge β compress β view) without reloading.
+ *
+ * Key hooks:
+ * - useFileState() - access file state and UI state
+ * - useFileActions() - file operations (add/remove/update)
+ * - useFileSelection() - for file selection state and actions
+ *
+ * Memory management handled by FileLifecycleManager (PDF.js cleanup, blob URL revocation).
*/
-import React, { createContext, useContext, useReducer, useCallback, useEffect, useRef } from 'react';
+import React, { useReducer, useCallback, useEffect, useRef, useMemo } from 'react';
import {
- FileContextValue,
- FileContextState,
FileContextProviderProps,
- ModeType,
- ViewType,
- ToolType,
- FileOperation,
- FileEditHistory,
- FileOperationHistory,
- ViewerConfig,
- FileContextUrlParams
+ FileContextSelectors,
+ FileContextStateValue,
+ FileContextActionsValue,
+ FileContextActions,
+ FileId,
+ FileRecord
} from '../types/fileContext';
-import { ProcessedFile } from '../types/processing';
-import { PageOperation, PDFDocument } from '../types/pageEditor';
-import { useEnhancedProcessedFiles } from '../hooks/useEnhancedProcessedFiles';
-import { fileStorage } from '../services/fileStorage';
-import { enhancedPDFProcessingService } from '../services/enhancedPDFProcessingService';
-import { thumbnailGenerationService } from '../services/thumbnailGenerationService';
-import { getFileId } from '../utils/fileUtils';
-// Initial state
-const initialViewerConfig: ViewerConfig = {
- zoom: 1.0,
- currentPage: 1,
- viewMode: 'single',
- sidebarOpen: false
-};
+// Import modular components
+import { fileContextReducer, initialFileContextState } from './file/FileReducer';
+import { createFileSelectors } from './file/fileSelectors';
+import { addFiles, consumeFiles, createFileActions } from './file/fileActions';
+import { FileLifecycleManager } from './file/lifecycle';
+import { FileStateContext, FileActionsContext } from './file/contexts';
+import { IndexedDBProvider, useIndexedDB } from './IndexedDBContext';
-const initialState: FileContextState = {
- activeFiles: [],
- processedFiles: new Map(),
- pinnedFiles: new Set(),
- currentMode: 'pageEditor',
- currentView: 'fileEditor', // Legacy field
- currentTool: null, // Legacy field
- fileEditHistory: new Map(),
- globalFileOperations: [],
- fileOperationHistory: new Map(),
- selectedFileIds: [],
- selectedPageNumbers: [],
- viewerConfig: initialViewerConfig,
- isProcessing: false,
- processingProgress: 0,
- lastExportConfig: undefined,
- hasUnsavedChanges: false,
- pendingNavigation: null,
- showNavigationWarning: false
-};
+const DEBUG = process.env.NODE_ENV === 'development';
-// Action types
-type FileContextAction =
- | { type: 'SET_ACTIVE_FILES'; payload: File[] }
- | { type: 'ADD_FILES'; payload: File[] }
- | { type: 'REMOVE_FILES'; payload: string[] }
- | { type: 'SET_PROCESSED_FILES'; payload: Map }
- | { type: 'UPDATE_PROCESSED_FILE'; payload: { file: File; processedFile: ProcessedFile } }
- | { type: 'SET_CURRENT_MODE'; payload: ModeType }
- | { type: 'SET_CURRENT_VIEW'; payload: ViewType }
- | { type: 'SET_CURRENT_TOOL'; payload: ToolType }
- | { type: 'SET_SELECTED_FILES'; payload: string[] }
- | { type: 'SET_SELECTED_PAGES'; payload: number[] }
- | { type: 'CLEAR_SELECTIONS' }
- | { type: 'SET_PROCESSING'; payload: { isProcessing: boolean; progress: number } }
- | { type: 'UPDATE_VIEWER_CONFIG'; payload: Partial }
- | { type: 'ADD_PAGE_OPERATIONS'; payload: { fileId: string; operations: PageOperation[] } }
- | { type: 'ADD_FILE_OPERATION'; payload: FileOperation }
- | { type: 'RECORD_OPERATION'; payload: { fileId: string; operation: FileOperation | PageOperation } }
- | { type: 'MARK_OPERATION_APPLIED'; payload: { fileId: string; operationId: string } }
- | { type: 'MARK_OPERATION_FAILED'; payload: { fileId: string; operationId: string; error: string } }
- | { type: 'CLEAR_FILE_HISTORY'; payload: string }
- | { type: 'SET_EXPORT_CONFIG'; payload: FileContextState['lastExportConfig'] }
- | { type: 'SET_UNSAVED_CHANGES'; payload: boolean }
- | { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null }
- | { type: 'SHOW_NAVIGATION_WARNING'; payload: boolean }
- | { type: 'PIN_FILE'; payload: File }
- | { type: 'UNPIN_FILE'; payload: File }
- | { type: 'CONSUME_FILES'; payload: { inputFiles: File[]; outputFiles: File[] } }
- | { type: 'RESET_CONTEXT' }
- | { type: 'LOAD_STATE'; payload: Partial };
-// Reducer
-function fileContextReducer(state: FileContextState, action: FileContextAction): FileContextState {
- switch (action.type) {
- case 'SET_ACTIVE_FILES':
- return {
- ...state,
- activeFiles: action.payload,
- selectedFileIds: [], // Clear selections when files change
- selectedPageNumbers: []
- };
-
- case 'ADD_FILES':
- return {
- ...state,
- activeFiles: [...state.activeFiles, ...action.payload]
- };
-
- case 'REMOVE_FILES':
- const remainingFiles = state.activeFiles.filter(file => {
- const fileId = getFileId(file);
- return !fileId || !action.payload.includes(fileId);
- });
- const safeSelectedFileIds = Array.isArray(state.selectedFileIds) ? state.selectedFileIds : [];
- return {
- ...state,
- activeFiles: remainingFiles,
- selectedFileIds: safeSelectedFileIds.filter(id => !action.payload.includes(id))
- };
-
- case 'SET_PROCESSED_FILES':
- return {
- ...state,
- processedFiles: action.payload
- };
-
- case 'UPDATE_PROCESSED_FILE':
- const updatedProcessedFiles = new Map(state.processedFiles);
- updatedProcessedFiles.set(action.payload.file, action.payload.processedFile);
- return {
- ...state,
- processedFiles: updatedProcessedFiles
- };
-
- case 'SET_CURRENT_MODE':
- const coreViews = ['viewer', 'pageEditor', 'fileEditor'];
- const isToolMode = !coreViews.includes(action.payload);
-
- return {
- ...state,
- currentMode: action.payload,
- // Update legacy fields for backward compatibility
- currentView: isToolMode ? 'fileEditor' : action.payload as ViewType,
- currentTool: isToolMode ? action.payload as ToolType : null
- };
-
- case 'SET_CURRENT_VIEW':
- // Legacy action - just update currentMode
- return {
- ...state,
- currentMode: action.payload as ModeType,
- currentView: action.payload,
- currentTool: null
- };
-
- case 'SET_CURRENT_TOOL':
- // Legacy action - just update currentMode
- return {
- ...state,
- currentMode: action.payload ? action.payload as ModeType : 'pageEditor',
- currentView: action.payload ? 'fileEditor' : 'pageEditor',
- currentTool: action.payload
- };
-
- case 'SET_SELECTED_FILES':
- return {
- ...state,
- selectedFileIds: action.payload
- };
-
- case 'SET_SELECTED_PAGES':
- return {
- ...state,
- selectedPageNumbers: action.payload
- };
-
- case 'CLEAR_SELECTIONS':
- return {
- ...state,
- selectedFileIds: [],
- selectedPageNumbers: []
- };
-
- case 'SET_PROCESSING':
- return {
- ...state,
- isProcessing: action.payload.isProcessing,
- processingProgress: action.payload.progress
- };
-
- case 'UPDATE_VIEWER_CONFIG':
- return {
- ...state,
- viewerConfig: {
- ...state.viewerConfig,
- ...action.payload
- }
- };
-
- case 'ADD_PAGE_OPERATIONS':
- const newHistory = new Map(state.fileEditHistory);
- const existing = newHistory.get(action.payload.fileId);
- newHistory.set(action.payload.fileId, {
- fileId: action.payload.fileId,
- pageOperations: existing ?
- [...existing.pageOperations, ...action.payload.operations] :
- action.payload.operations,
- lastModified: Date.now()
- });
- return {
- ...state,
- fileEditHistory: newHistory
- };
-
- case 'ADD_FILE_OPERATION':
- return {
- ...state,
- globalFileOperations: [...state.globalFileOperations, action.payload]
- };
-
- case 'RECORD_OPERATION':
- const { fileId, operation } = action.payload;
- const newOperationHistory = new Map(state.fileOperationHistory);
- const existingHistory = newOperationHistory.get(fileId);
-
- if (existingHistory) {
- // Add operation to existing history
- newOperationHistory.set(fileId, {
- ...existingHistory,
- operations: [...existingHistory.operations, operation],
- lastModified: Date.now()
- });
- } else {
- // Create new history for this file
- newOperationHistory.set(fileId, {
- fileId,
- fileName: fileId, // Will be updated with actual filename when available
- operations: [operation],
- createdAt: Date.now(),
- lastModified: Date.now()
- });
- }
-
- return {
- ...state,
- fileOperationHistory: newOperationHistory
- };
-
- case 'MARK_OPERATION_APPLIED':
- const appliedHistory = new Map(state.fileOperationHistory);
- const appliedFileHistory = appliedHistory.get(action.payload.fileId);
-
- if (appliedFileHistory) {
- const updatedOperations = appliedFileHistory.operations.map(op =>
- op.id === action.payload.operationId
- ? { ...op, status: 'applied' as const }
- : op
- );
- appliedHistory.set(action.payload.fileId, {
- ...appliedFileHistory,
- operations: updatedOperations,
- lastModified: Date.now()
- });
- }
-
- return {
- ...state,
- fileOperationHistory: appliedHistory
- };
-
- case 'MARK_OPERATION_FAILED':
- const failedHistory = new Map(state.fileOperationHistory);
- const failedFileHistory = failedHistory.get(action.payload.fileId);
-
- if (failedFileHistory) {
- const updatedOperations = failedFileHistory.operations.map(op =>
- op.id === action.payload.operationId
- ? {
- ...op,
- status: 'failed' as const,
- metadata: { ...op.metadata, error: action.payload.error }
- }
- : op
- );
- failedHistory.set(action.payload.fileId, {
- ...failedFileHistory,
- operations: updatedOperations,
- lastModified: Date.now()
- });
- }
-
- return {
- ...state,
- fileOperationHistory: failedHistory
- };
-
- case 'CLEAR_FILE_HISTORY':
- const clearedHistory = new Map(state.fileOperationHistory);
- clearedHistory.delete(action.payload);
- return {
- ...state,
- fileOperationHistory: clearedHistory
- };
-
- case 'SET_EXPORT_CONFIG':
- return {
- ...state,
- lastExportConfig: action.payload
- };
-
- case 'SET_UNSAVED_CHANGES':
- return {
- ...state,
- hasUnsavedChanges: action.payload
- };
-
- case 'SET_PENDING_NAVIGATION':
- return {
- ...state,
- pendingNavigation: action.payload
- };
-
- case 'SHOW_NAVIGATION_WARNING':
- return {
- ...state,
- showNavigationWarning: action.payload
- };
-
- case 'PIN_FILE':
- return {
- ...state,
- pinnedFiles: new Set([...state.pinnedFiles, action.payload])
- };
-
- case 'UNPIN_FILE':
- const newPinnedFiles = new Set(state.pinnedFiles);
- newPinnedFiles.delete(action.payload);
- return {
- ...state,
- pinnedFiles: newPinnedFiles
- };
-
- case 'CONSUME_FILES': {
- const { inputFiles, outputFiles } = action.payload;
- const unpinnedInputFiles = inputFiles.filter(file => !state.pinnedFiles.has(file));
-
- // Remove unpinned input files and add output files
- const newActiveFiles = [
- ...state.activeFiles.filter(file => !unpinnedInputFiles.includes(file)),
- ...outputFiles
- ];
-
- // Update processed files map - remove consumed files, keep pinned ones
- const newProcessedFiles = new Map(state.processedFiles);
- unpinnedInputFiles.forEach(file => {
- newProcessedFiles.delete(file);
- });
-
- return {
- ...state,
- activeFiles: newActiveFiles,
- processedFiles: newProcessedFiles
- };
- }
-
- case 'RESET_CONTEXT':
- return {
- ...initialState
- };
-
- case 'LOAD_STATE':
- return {
- ...state,
- ...action.payload
- };
-
- default:
- return state;
- }
-}
-
-// Context
-const FileContext = createContext(undefined);
-
-// Provider component
-export function FileContextProvider({
+// Inner provider component that has access to IndexedDB
+function FileContextInner({
children,
enableUrlSync = true,
- enablePersistence = true,
- maxCacheSize = 1024 * 1024 * 1024 // 1GB
+ enablePersistence = true
}: FileContextProviderProps) {
- const [state, dispatch] = useReducer(fileContextReducer, initialState);
+ const [state, dispatch] = useReducer(fileContextReducer, initialFileContextState);
+
+ // IndexedDB context for persistence
+ const indexedDB = enablePersistence ? useIndexedDB() : null;
- // Cleanup timers and refs
- const cleanupTimers = useRef