Feature/v2/file handling improvements (#4222)

# Description of Changes

A new universal file context rather than the splintered ones for the
main views, tools and manager we had before (manager still has its own
but its better integreated with the core context)
File context has been split it into a handful of different files
managing various file related issues separately to reduce the monolith -
FileReducer.ts - State management
  fileActions.ts - File operations
  fileSelectors.ts - Data access patterns
  lifecycle.ts - Resource cleanup and memory management
  fileHooks.ts - React hooks interface
  contexts.ts - Context providers
Improved thumbnail generation
Improved indexxedb handling
Stopped handling files as blobs were not necessary to improve
performance
A new library handling drag and drop
https://github.com/atlassian/pragmatic-drag-and-drop (Out of scope yes
but I broke the old one with the new filecontext and it needed doing so
it was a might as well)
A new library handling virtualisation on page editor
@tanstack/react-virtual, as above.
Quickly ripped out the last remnants of the old URL params stuff and
replaced with the beginnings of what will later become the new URL
navigation system (for now it just restores the tool name in url
behavior)
Fixed selected file not regestered when opening a tool
Fixed png thumbnails
Closes #(issue_number)

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: Reece Browne <you@example.com>
This commit is contained in:
Reece Browne
2025-08-21 17:30:26 +01:00
committed by GitHub
parent a33e51351b
commit 949ffa01ad
90 changed files with 5416 additions and 4164 deletions

View File

@@ -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
<Badge color="blue" variant="light" size="sm">
{getFileDate(file)}
</Badge>
{file.storedInIndexedDB && (
{record?.id && (
<Badge
color="green"
variant="light"

View File

@@ -4,14 +4,14 @@ import { useTranslation } from "react-i18next";
import SearchIcon from "@mui/icons-material/Search";
import SortIcon from "@mui/icons-material/Sort";
import FileCard from "./FileCard";
import { FileWithUrl } from "../../types/file";
import { FileRecord } from "../../types/fileContext";
interface FileGridProps {
files: FileWithUrl[];
files: Array<{ file: File; record?: FileRecord }>;
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<SortOption>('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 (
<FileCard
key={fileId + idx}
file={file}
file={item.file}
record={item.record}
onRemove={onRemove ? () => 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}

View File

@@ -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 = ({
<ScrollArea.Autosize mah={400}>
<SimpleGrid cols={2} spacing="md">
{storedFiles.map((file) => {
const fileId = file.id || file.name;
const fileId = file.id;
const isSelected = selectedFileIds.includes(fileId);
return (

View File

@@ -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;
}

View File

@@ -33,7 +33,7 @@ const LandingPage = () => {
{/* White PDF Page Background */}
<Dropzone
onDrop={handleFileDrop}
accept={["*/*"] as any}
accept={["application/pdf", "application/zip", "application/x-zip-compressed"]}
multiple={true}
className="w-4/5 flex items-center justify-center h-[95vh]"
style={{
@@ -125,7 +125,7 @@ const LandingPage = () => {
ref={fileInputRef}
type="file"
multiple
accept="*/*"
accept=".pdf,.zip"
onChange={handleFileSelect}
style={{ display: 'none' }}
/>

View File

@@ -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<void>;
@@ -11,13 +11,13 @@ const NavigationWarningModal = ({
onApplyAndContinue,
onExportAndContinue
}: NavigationWarningModalProps) => {
const {
showNavigationWarning,
const {
showNavigationWarning,
hasUnsavedChanges,
confirmNavigation,
cancelNavigation,
confirmNavigation,
setHasUnsavedChanges
} = useFileContext();
} = useNavigationGuard();
const handleKeepWorking = () => {
cancelNavigation();

View File

@@ -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: (
<Group gap={5}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
</Group>
),
value: "viewer",
},
{
label: (
<Group gap={4}>
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
<EditNoteIcon fontSize="small" />
)}
</Group>
),
value: "pageEditor",
},
{
label: (
<Group gap={4}>
{switchingTo === "fileEditor" ? (
<Loader size="xs" />
) : (
<FolderIcon fontSize="small" />
)}
</Group>
),
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: (
<Group gap={option.value === "viewer" ? 5 : 4}>
{switchingTo === option.value ? (
<Loader size="xs" />
) : (
<option.icon fontSize="small" />
)}
</Group>
)
})), [switchingTo]);
const getThemeIcon = () => {
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
@@ -117,7 +103,7 @@ const TopControls = ({
{!isToolSelected && (
<div className="flex justify-center items-center h-full pointer-events-auto">
<SegmentedControl
data={createViewOptions(switchingTo)}
data={viewOptions}
value={currentView}
onChange={handleViewChange}
color="blue"

View File

@@ -1,10 +1,10 @@
import React from 'react';
import { Box, Center, Image } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { FileWithUrl } from '../../../types/file';
import { FileMetadata } from '../../../types/file';
export interface DocumentThumbnailProps {
file: File | FileWithUrl | null;
file: File | FileMetadata | null;
thumbnail?: string | null;
style?: React.CSSProperties;
onClick?: () => void;