mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-07-29 16:45:37 +00:00
Resolve merge conflicts by using versions from Stirling-2.0
This commit is contained in:
parent
b5d84db3c8
commit
16e56e3208
3
.gitignore
vendored
3
.gitignore
vendored
@ -189,8 +189,7 @@ id_ed25519.pub
|
|||||||
.pytest_cache
|
.pytest_cache
|
||||||
.ipynb_checkpoints
|
.ipynb_checkpoints
|
||||||
|
|
||||||
# Claude.ai assistant files
|
|
||||||
CLAUDE.md
|
|
||||||
|
|
||||||
**/jcef-bundle/
|
**/jcef-bundle/
|
||||||
|
|
||||||
|
423
frontend/src/components/fileManagement/FileManager.tsx
Normal file
423
frontend/src/components/fileManagement/FileManager.tsx
Normal file
@ -0,0 +1,423 @@
|
|||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { Box, Flex, Text, Notification, Button, Group } from "@mantine/core";
|
||||||
|
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import { GlobalWorkerOptions } from "pdfjs-dist";
|
||||||
|
import { StorageStats } from "../../services/fileStorage";
|
||||||
|
import { FileWithUrl, defaultStorageConfig, initializeStorageConfig, StorageConfig } from "../../types/file";
|
||||||
|
|
||||||
|
// Refactored imports
|
||||||
|
import { fileOperationsService } from "../../services/fileOperationsService";
|
||||||
|
import { checkStorageWarnings } from "../../utils/storageUtils";
|
||||||
|
import StorageStatsCard from "./StorageStatsCard";
|
||||||
|
import FileCard from "./FileCard";
|
||||||
|
import FileUploadSelector from "../shared/FileUploadSelector";
|
||||||
|
|
||||||
|
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
|
||||||
|
|
||||||
|
interface FileManagerProps {
|
||||||
|
files: FileWithUrl[];
|
||||||
|
setFiles: React.Dispatch<React.SetStateAction<FileWithUrl[]>>;
|
||||||
|
allowMultiple?: boolean;
|
||||||
|
setCurrentView?: (view: string) => void;
|
||||||
|
onOpenFileEditor?: (selectedFiles?: FileWithUrl[]) => void;
|
||||||
|
onLoadFileToActive?: (file: File) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FileManager = ({
|
||||||
|
files = [],
|
||||||
|
setFiles,
|
||||||
|
allowMultiple = true,
|
||||||
|
setCurrentView,
|
||||||
|
onOpenFileEditor,
|
||||||
|
onLoadFileToActive,
|
||||||
|
}: FileManagerProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [storageStats, setStorageStats] = useState<StorageStats | null>(null);
|
||||||
|
const [notification, setNotification] = useState<string | null>(null);
|
||||||
|
const [filesLoaded, setFilesLoaded] = useState(false);
|
||||||
|
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
||||||
|
const [storageConfig, setStorageConfig] = useState<StorageConfig>(defaultStorageConfig);
|
||||||
|
|
||||||
|
// Extract operations from service for cleaner code
|
||||||
|
const {
|
||||||
|
loadStorageStats,
|
||||||
|
forceReloadFiles,
|
||||||
|
loadExistingFiles,
|
||||||
|
uploadFiles,
|
||||||
|
removeFile,
|
||||||
|
clearAllFiles,
|
||||||
|
createBlobUrlForFile,
|
||||||
|
checkForPurge,
|
||||||
|
updateStorageStatsIncremental
|
||||||
|
} = fileOperationsService;
|
||||||
|
|
||||||
|
// Add CSS for spinner animation
|
||||||
|
useEffect(() => {
|
||||||
|
if (!document.querySelector('#spinner-animation')) {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.id = 'spinner-animation';
|
||||||
|
style.textContent = `
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load existing files from IndexedDB on mount
|
||||||
|
useEffect(() => {
|
||||||
|
if (!filesLoaded) {
|
||||||
|
handleLoadExistingFiles();
|
||||||
|
}
|
||||||
|
}, [filesLoaded]);
|
||||||
|
|
||||||
|
// Initialize storage configuration on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const initStorage = async () => {
|
||||||
|
try {
|
||||||
|
const config = await initializeStorageConfig();
|
||||||
|
setStorageConfig(config);
|
||||||
|
console.log('Initialized storage config:', config);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to initialize storage config, using defaults:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initStorage();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load storage stats and set up periodic updates
|
||||||
|
useEffect(() => {
|
||||||
|
handleLoadStorageStats();
|
||||||
|
|
||||||
|
const interval = setInterval(async () => {
|
||||||
|
await handleLoadStorageStats();
|
||||||
|
await handleCheckForPurge();
|
||||||
|
}, 10000); // Update every 10 seconds
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Sync UI with IndexedDB whenever storage stats change
|
||||||
|
useEffect(() => {
|
||||||
|
const syncWithStorage = async () => {
|
||||||
|
if (storageStats && filesLoaded) {
|
||||||
|
// If file counts don't match, force reload
|
||||||
|
if (storageStats.fileCount !== files.length) {
|
||||||
|
console.warn('File count mismatch: storage has', storageStats.fileCount, 'but UI shows', files.length, '- forcing reload');
|
||||||
|
const reloadedFiles = await forceReloadFiles();
|
||||||
|
setFiles(reloadedFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
syncWithStorage();
|
||||||
|
}, [storageStats, filesLoaded, files.length]);
|
||||||
|
|
||||||
|
// Handlers using extracted operations
|
||||||
|
const handleLoadStorageStats = async () => {
|
||||||
|
const stats = await loadStorageStats();
|
||||||
|
if (stats) {
|
||||||
|
setStorageStats(stats);
|
||||||
|
|
||||||
|
// Check for storage warnings
|
||||||
|
const warning = checkStorageWarnings(stats);
|
||||||
|
if (warning) {
|
||||||
|
setNotification(warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLoadExistingFiles = async () => {
|
||||||
|
try {
|
||||||
|
const loadedFiles = await loadExistingFiles(filesLoaded, files);
|
||||||
|
setFiles(loadedFiles);
|
||||||
|
setFilesLoaded(true);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load existing files:', error);
|
||||||
|
setFilesLoaded(true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckForPurge = async () => {
|
||||||
|
try {
|
||||||
|
const isPurged = await checkForPurge(files);
|
||||||
|
if (isPurged) {
|
||||||
|
console.warn('IndexedDB purge detected - forcing UI reload');
|
||||||
|
setNotification(t("fileManager.storageCleared", "Browser cleared storage. Files have been removed. Please re-upload."));
|
||||||
|
const reloadedFiles = await forceReloadFiles();
|
||||||
|
setFiles(reloadedFiles);
|
||||||
|
setFilesLoaded(true);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error checking for purge:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const validateStorageLimits = (filesToUpload: File[]): { valid: boolean; error?: string } => {
|
||||||
|
// Check individual file sizes
|
||||||
|
for (const file of filesToUpload) {
|
||||||
|
if (file.size > storageConfig.maxFileSize) {
|
||||||
|
const maxSizeMB = Math.round(storageConfig.maxFileSize / (1024 * 1024));
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: `${t("storage.fileTooLarge", "File too large. Maximum size per file is")} ${maxSizeMB}MB`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check total storage capacity
|
||||||
|
if (storageStats) {
|
||||||
|
const totalNewSize = filesToUpload.reduce((sum, file) => sum + file.size, 0);
|
||||||
|
const projectedUsage = storageStats.totalSize + totalNewSize;
|
||||||
|
|
||||||
|
if (projectedUsage > storageConfig.maxTotalStorage) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: t("storage.storageQuotaExceeded", "Storage quota exceeded. Please remove some files before uploading more.")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDrop = async (uploadedFiles: File[]) => {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate storage limits before uploading
|
||||||
|
const validation = validateStorageLimits(uploadedFiles);
|
||||||
|
if (!validation.valid) {
|
||||||
|
setNotification(validation.error);
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFiles = await uploadFiles(uploadedFiles, storageConfig.useIndexedDB);
|
||||||
|
|
||||||
|
// Update files state
|
||||||
|
setFiles((prevFiles) => (allowMultiple ? [...prevFiles, ...newFiles] : newFiles));
|
||||||
|
|
||||||
|
// Update storage stats incrementally
|
||||||
|
if (storageStats) {
|
||||||
|
const updatedStats = updateStorageStatsIncremental(storageStats, 'add', newFiles);
|
||||||
|
setStorageStats(updatedStats);
|
||||||
|
|
||||||
|
// Check for storage warnings
|
||||||
|
const warning = checkStorageWarnings(updatedStats);
|
||||||
|
if (warning) {
|
||||||
|
setNotification(warning);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error handling file drop:', error);
|
||||||
|
setNotification(t("fileManager.uploadError", "Failed to upload some files."));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveFile = async (index: number) => {
|
||||||
|
const file = files[index];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await removeFile(file);
|
||||||
|
|
||||||
|
// Update storage stats incrementally
|
||||||
|
if (storageStats) {
|
||||||
|
const updatedStats = updateStorageStatsIncremental(storageStats, 'remove', [file]);
|
||||||
|
setStorageStats(updatedStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles((prevFiles) => prevFiles.filter((_, i) => i !== index));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove file:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearAll = async () => {
|
||||||
|
try {
|
||||||
|
await clearAllFiles(files);
|
||||||
|
|
||||||
|
// Reset storage stats
|
||||||
|
if (storageStats) {
|
||||||
|
const clearedStats = updateStorageStatsIncremental(storageStats, 'clear');
|
||||||
|
setStorageStats(clearedStats);
|
||||||
|
}
|
||||||
|
|
||||||
|
setFiles([]);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to clear all files:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReloadFiles = () => {
|
||||||
|
setFilesLoaded(false);
|
||||||
|
setFiles([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileDoubleClick = async (file: FileWithUrl) => {
|
||||||
|
try {
|
||||||
|
// Reconstruct File object from storage and add to active files
|
||||||
|
if (onLoadFileToActive) {
|
||||||
|
const reconstructedFile = await reconstructFileFromStorage(file);
|
||||||
|
onLoadFileToActive(reconstructedFile);
|
||||||
|
setCurrentView && setCurrentView("viewer");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load file to active set:', error);
|
||||||
|
setNotification(t("fileManager.failedToOpen", "Failed to open file. It may have been removed from storage."));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileView = async (file: FileWithUrl) => {
|
||||||
|
try {
|
||||||
|
// Reconstruct File object from storage and add to active files
|
||||||
|
if (onLoadFileToActive) {
|
||||||
|
const reconstructedFile = await reconstructFileFromStorage(file);
|
||||||
|
onLoadFileToActive(reconstructedFile);
|
||||||
|
setCurrentView && setCurrentView("viewer");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load file to active set:', error);
|
||||||
|
setNotification(t("fileManager.failedToOpen", "Failed to open file. It may have been removed from storage."));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const reconstructFileFromStorage = async (fileWithUrl: FileWithUrl): Promise<File> => {
|
||||||
|
// If it's already a regular file, return it
|
||||||
|
if (fileWithUrl instanceof File) {
|
||||||
|
return fileWithUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconstruct from IndexedDB
|
||||||
|
const arrayBuffer = await createBlobUrlForFile(fileWithUrl);
|
||||||
|
if (typeof arrayBuffer === 'string') {
|
||||||
|
// createBlobUrlForFile returned a blob URL, we need the actual data
|
||||||
|
const response = await fetch(arrayBuffer);
|
||||||
|
const data = await response.arrayBuffer();
|
||||||
|
return new File([data], fileWithUrl.name, {
|
||||||
|
type: fileWithUrl.type || 'application/pdf',
|
||||||
|
lastModified: fileWithUrl.lastModified || Date.now()
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return new File([arrayBuffer], fileWithUrl.name, {
|
||||||
|
type: fileWithUrl.type || 'application/pdf',
|
||||||
|
lastModified: fileWithUrl.lastModified || Date.now()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileEdit = (file: FileWithUrl) => {
|
||||||
|
if (onOpenFileEditor) {
|
||||||
|
onOpenFileEditor([file]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleFileSelection = (fileId: string) => {
|
||||||
|
setSelectedFiles(prev =>
|
||||||
|
prev.includes(fileId)
|
||||||
|
? prev.filter(id => id !== fileId)
|
||||||
|
: [...prev, fileId]
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenSelectedInEditor = () => {
|
||||||
|
if (onOpenFileEditor && selectedFiles.length > 0) {
|
||||||
|
const selected = files.filter(f => selectedFiles.includes(f.id || f.name));
|
||||||
|
onOpenFileEditor(selected);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
width: "100%",
|
||||||
|
justifyContent: "center",
|
||||||
|
display: "flex",
|
||||||
|
flexDirection: "column",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingTop: "3rem"
|
||||||
|
}}>
|
||||||
|
|
||||||
|
{/* File upload is now handled by FileUploadSelector when no files exist */}
|
||||||
|
|
||||||
|
{/* Storage Stats Card */}
|
||||||
|
<StorageStatsCard
|
||||||
|
storageStats={storageStats}
|
||||||
|
filesCount={files.length}
|
||||||
|
onClearAll={handleClearAll}
|
||||||
|
onReloadFiles={handleReloadFiles}
|
||||||
|
storageConfig={storageConfig}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Multi-selection controls */}
|
||||||
|
{selectedFiles.length > 0 && (
|
||||||
|
<Box mb="md" p="md" style={{ backgroundColor: 'var(--mantine-color-blue-0)', borderRadius: 8 }}>
|
||||||
|
<Group justify="space-between">
|
||||||
|
<Text size="sm">
|
||||||
|
{selectedFiles.length} {t("fileManager.filesSelected", "files selected")}
|
||||||
|
</Text>
|
||||||
|
<Group>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
onClick={() => setSelectedFiles([])}
|
||||||
|
>
|
||||||
|
{t("fileManager.clearSelection", "Clear Selection")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
color="orange"
|
||||||
|
onClick={handleOpenSelectedInEditor}
|
||||||
|
disabled={selectedFiles.length === 0}
|
||||||
|
>
|
||||||
|
{t("fileManager.openInFileEditor", "Open in File Editor")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
<Flex
|
||||||
|
wrap="wrap"
|
||||||
|
gap="lg"
|
||||||
|
justify="flex-start"
|
||||||
|
style={{ width: "90%", marginTop: "1rem"}}
|
||||||
|
>
|
||||||
|
{files.map((file, idx) => (
|
||||||
|
<FileCard
|
||||||
|
key={file.id || file.name + idx}
|
||||||
|
file={file}
|
||||||
|
onRemove={() => handleRemoveFile(idx)}
|
||||||
|
onDoubleClick={() => handleFileDoubleClick(file)}
|
||||||
|
onView={() => handleFileView(file)}
|
||||||
|
onEdit={() => handleFileEdit(file)}
|
||||||
|
isSelected={selectedFiles.includes(file.id || file.name)}
|
||||||
|
onSelect={() => toggleFileSelection(file.id || file.name)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
{notification && (
|
||||||
|
<Notification
|
||||||
|
color="blue"
|
||||||
|
onClose={() => setNotification(null)}
|
||||||
|
style={{ position: "fixed", bottom: 20, right: 20, zIndex: 1000 }}
|
||||||
|
>
|
||||||
|
{notification}
|
||||||
|
</Notification>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FileManager;
|
567
frontend/src/components/viewer/Viewer.tsx
Normal file
567
frontend/src/components/viewer/Viewer.tsx
Normal file
@ -0,0 +1,567 @@
|
|||||||
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
|
import { Paper, Stack, Text, ScrollArea, Loader, Center, Button, Group, NumberInput, useMantineTheme } from "@mantine/core";
|
||||||
|
import { getDocument, GlobalWorkerOptions } from "pdfjs-dist";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
|
||||||
|
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
|
||||||
|
import FirstPageIcon from "@mui/icons-material/FirstPage";
|
||||||
|
import LastPageIcon from "@mui/icons-material/LastPage";
|
||||||
|
import ViewSidebarIcon from "@mui/icons-material/ViewSidebar";
|
||||||
|
import ViewWeekIcon from "@mui/icons-material/ViewWeek"; // for dual page (book)
|
||||||
|
import DescriptionIcon from "@mui/icons-material/Description"; // for single page
|
||||||
|
import { useLocalStorage } from "@mantine/hooks";
|
||||||
|
import { fileStorage } from "../../services/fileStorage";
|
||||||
|
|
||||||
|
GlobalWorkerOptions.workerSrc = "/pdf.worker.js";
|
||||||
|
|
||||||
|
// Lazy loading page image component
|
||||||
|
interface LazyPageImageProps {
|
||||||
|
pageIndex: number;
|
||||||
|
zoom: number;
|
||||||
|
theme: any;
|
||||||
|
isFirst: boolean;
|
||||||
|
renderPage: (pageIndex: number) => Promise<string | null>;
|
||||||
|
pageImages: (string | null)[];
|
||||||
|
setPageRef: (index: number, ref: HTMLImageElement | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LazyPageImage = ({
|
||||||
|
pageIndex, zoom, theme, isFirst, renderPage, pageImages, setPageRef
|
||||||
|
}: LazyPageImageProps) => {
|
||||||
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
|
const [imageUrl, setImageUrl] = useState<string | null>(pageImages[pageIndex]);
|
||||||
|
const imgRef = useRef<HTMLImageElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
if (entry.isIntersecting && !imageUrl) {
|
||||||
|
setIsVisible(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rootMargin: '200px', // Start loading 200px before visible
|
||||||
|
threshold: 0.1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (imgRef.current) {
|
||||||
|
observer.observe(imgRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [imageUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isVisible && !imageUrl) {
|
||||||
|
renderPage(pageIndex).then((url) => {
|
||||||
|
if (url) setImageUrl(url);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [isVisible, imageUrl, pageIndex, renderPage]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (imgRef.current) {
|
||||||
|
setPageRef(pageIndex, imgRef.current);
|
||||||
|
}
|
||||||
|
}, [pageIndex, setPageRef]);
|
||||||
|
|
||||||
|
if (imageUrl) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
ref={imgRef}
|
||||||
|
src={imageUrl}
|
||||||
|
alt={`Page ${pageIndex + 1}`}
|
||||||
|
style={{
|
||||||
|
width: `${100 * zoom}%`,
|
||||||
|
maxWidth: 700 * zoom,
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: isFirst ? theme.spacing.xl : 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Placeholder while loading
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={imgRef}
|
||||||
|
style={{
|
||||||
|
width: `${100 * zoom}%`,
|
||||||
|
maxWidth: 700 * zoom,
|
||||||
|
height: 800 * zoom, // Estimated height
|
||||||
|
backgroundColor: '#f5f5f5',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: isFirst ? theme.spacing.xl : 0,
|
||||||
|
border: '1px dashed #ccc'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isVisible ? (
|
||||||
|
<div style={{ textAlign: 'center' }}>
|
||||||
|
<div style={{
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
border: '2px solid #ddd',
|
||||||
|
borderTop: '2px solid #666',
|
||||||
|
borderRadius: '50%',
|
||||||
|
animation: 'spin 1s linear infinite',
|
||||||
|
margin: '0 auto 8px'
|
||||||
|
}} />
|
||||||
|
<Text size="sm" c="dimmed">Loading page {pageIndex + 1}...</Text>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Text size="sm" c="dimmed">Page {pageIndex + 1}</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ViewerProps {
|
||||||
|
pdfFile: { file: File; url: string } | null; // First file in the array
|
||||||
|
setPdfFile: (file: { file: File; url: string } | null) => void;
|
||||||
|
sidebarsVisible: boolean;
|
||||||
|
setSidebarsVisible: (v: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Viewer = ({
|
||||||
|
pdfFile,
|
||||||
|
setPdfFile,
|
||||||
|
sidebarsVisible,
|
||||||
|
setSidebarsVisible,
|
||||||
|
}: ViewerProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const theme = useMantineTheme();
|
||||||
|
const [numPages, setNumPages] = useState<number>(0);
|
||||||
|
const [pageImages, setPageImages] = useState<string[]>([]);
|
||||||
|
const [loading, setLoading] = useState<boolean>(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState<number | null>(null);
|
||||||
|
const [dualPage, setDualPage] = useState(false);
|
||||||
|
const [zoom, setZoom] = useState(1); // 1 = 100%
|
||||||
|
const pageRefs = useRef<(HTMLImageElement | null)[]>([]);
|
||||||
|
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const userInitiatedRef = useRef(false);
|
||||||
|
const suppressScrollRef = useRef(false);
|
||||||
|
const pdfDocRef = useRef<any>(null);
|
||||||
|
const renderingPagesRef = useRef<Set<number>>(new Set());
|
||||||
|
const currentArrayBufferRef = useRef<ArrayBuffer | null>(null);
|
||||||
|
|
||||||
|
// Function to render a specific page on-demand
|
||||||
|
const renderPage = async (pageIndex: number): Promise<string | null> => {
|
||||||
|
if (!pdfFile || !pdfDocRef.current || renderingPagesRef.current.has(pageIndex)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageNum = pageIndex + 1;
|
||||||
|
if (pageImages[pageIndex]) {
|
||||||
|
return pageImages[pageIndex]; // Already rendered
|
||||||
|
}
|
||||||
|
|
||||||
|
renderingPagesRef.current.add(pageIndex);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = await pdfDocRef.current.getPage(pageNum);
|
||||||
|
const viewport = page.getViewport({ scale: 1.2 });
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = viewport.width;
|
||||||
|
canvas.height = viewport.height;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
|
||||||
|
if (ctx) {
|
||||||
|
await page.render({ canvasContext: ctx, viewport }).promise;
|
||||||
|
const dataUrl = canvas.toDataURL();
|
||||||
|
|
||||||
|
// Update the pageImages array
|
||||||
|
setPageImages(prev => {
|
||||||
|
const newImages = [...prev];
|
||||||
|
newImages[pageIndex] = dataUrl;
|
||||||
|
return newImages;
|
||||||
|
});
|
||||||
|
|
||||||
|
renderingPagesRef.current.delete(pageIndex);
|
||||||
|
return dataUrl;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to render page ${pageNum}:`, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
renderingPagesRef.current.delete(pageIndex);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Listen for hash changes and update currentPage
|
||||||
|
useEffect(() => {
|
||||||
|
function handleHashChange() {
|
||||||
|
if (window.location.hash.startsWith("#page=")) {
|
||||||
|
const page = parseInt(window.location.hash.replace("#page=", ""), 10);
|
||||||
|
if (!isNaN(page) && page >= 1 && page <= numPages) {
|
||||||
|
setCurrentPage(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
userInitiatedRef.current = false;
|
||||||
|
}
|
||||||
|
window.addEventListener("hashchange", handleHashChange);
|
||||||
|
handleHashChange(); // Run on mount
|
||||||
|
return () => window.removeEventListener("hashchange", handleHashChange);
|
||||||
|
}, [numPages]);
|
||||||
|
|
||||||
|
// Scroll to the current page when it changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPage && pageRefs.current[currentPage - 1]) {
|
||||||
|
suppressScrollRef.current = true;
|
||||||
|
const el = pageRefs.current[currentPage - 1];
|
||||||
|
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
|
||||||
|
// Try to use scrollend if supported
|
||||||
|
const viewport = scrollAreaRef.current;
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
let scrollEndHandler: (() => void) | null = null;
|
||||||
|
|
||||||
|
if (viewport && "onscrollend" in viewport) {
|
||||||
|
scrollEndHandler = () => {
|
||||||
|
suppressScrollRef.current = false;
|
||||||
|
viewport.removeEventListener("scrollend", scrollEndHandler!);
|
||||||
|
};
|
||||||
|
viewport.addEventListener("scrollend", scrollEndHandler);
|
||||||
|
} else {
|
||||||
|
// Fallback for non-Chromium browsers
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
suppressScrollRef.current = false;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (viewport && scrollEndHandler) {
|
||||||
|
viewport.removeEventListener("scrollend", scrollEndHandler);
|
||||||
|
}
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [currentPage, pageImages]);
|
||||||
|
|
||||||
|
// Detect visible page on scroll and update hash
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (suppressScrollRef.current) return;
|
||||||
|
const scrollArea = scrollAreaRef.current;
|
||||||
|
if (!scrollArea || !pageRefs.current.length) return;
|
||||||
|
|
||||||
|
const areaRect = scrollArea.getBoundingClientRect();
|
||||||
|
let closestIdx = 0;
|
||||||
|
let minDist = Infinity;
|
||||||
|
|
||||||
|
pageRefs.current.forEach((img, idx) => {
|
||||||
|
if (img) {
|
||||||
|
const imgRect = img.getBoundingClientRect();
|
||||||
|
const dist = Math.abs(imgRect.top - areaRect.top);
|
||||||
|
if (dist < minDist) {
|
||||||
|
minDist = dist;
|
||||||
|
closestIdx = idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentPage !== closestIdx + 1) {
|
||||||
|
setCurrentPage(closestIdx + 1);
|
||||||
|
if (window.location.hash !== `#page=${closestIdx + 1}`) {
|
||||||
|
window.location.hash = `#page=${closestIdx + 1}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
async function loadPdfInfo() {
|
||||||
|
if (!pdfFile || !pdfFile.url) {
|
||||||
|
setNumPages(0);
|
||||||
|
setPageImages([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
let pdfUrl = pdfFile.url;
|
||||||
|
|
||||||
|
// Handle special IndexedDB URLs for large files
|
||||||
|
if (pdfFile.url.startsWith('indexeddb:')) {
|
||||||
|
const fileId = pdfFile.url.replace('indexeddb:', '');
|
||||||
|
console.log('Loading large file from IndexedDB:', fileId);
|
||||||
|
|
||||||
|
// Get data directly from IndexedDB
|
||||||
|
const arrayBuffer = await fileStorage.getFileData(fileId);
|
||||||
|
if (!arrayBuffer) {
|
||||||
|
throw new Error('File not found in IndexedDB - may have been purged by browser');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store reference for cleanup
|
||||||
|
currentArrayBufferRef.current = arrayBuffer;
|
||||||
|
|
||||||
|
// Use ArrayBuffer directly instead of creating blob URL
|
||||||
|
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||||
|
pdfDocRef.current = pdf;
|
||||||
|
setNumPages(pdf.numPages);
|
||||||
|
if (!cancelled) setPageImages(new Array(pdf.numPages).fill(null));
|
||||||
|
} else {
|
||||||
|
// Standard blob URL or regular URL
|
||||||
|
const pdf = await getDocument(pdfUrl).promise;
|
||||||
|
pdfDocRef.current = pdf;
|
||||||
|
setNumPages(pdf.numPages);
|
||||||
|
if (!cancelled) setPageImages(new Array(pdf.numPages).fill(null));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load PDF:', error);
|
||||||
|
if (!cancelled) {
|
||||||
|
setPageImages([]);
|
||||||
|
setNumPages(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
loadPdfInfo();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
// Cleanup ArrayBuffer reference to help garbage collection
|
||||||
|
currentArrayBufferRef.current = null;
|
||||||
|
};
|
||||||
|
}, [pdfFile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const viewport = scrollAreaRef.current;
|
||||||
|
if (!viewport) return;
|
||||||
|
const handler = () => {
|
||||||
|
handleScroll();
|
||||||
|
};
|
||||||
|
viewport.addEventListener("scroll", handler);
|
||||||
|
return () => viewport.removeEventListener("scroll", handler);
|
||||||
|
}, [pageImages]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{!pdfFile ? (
|
||||||
|
<Center style={{ flex: 1 }}>
|
||||||
|
<Stack align="center">
|
||||||
|
<Text c="dimmed">{t("viewer.noPdfLoaded", "No PDF loaded. Click to upload a PDF.")}</Text>
|
||||||
|
<Button
|
||||||
|
component="label"
|
||||||
|
variant="outline"
|
||||||
|
color="blue"
|
||||||
|
>
|
||||||
|
{t("viewer.choosePdf", "Choose PDF")}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf"
|
||||||
|
hidden
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file && file.type === "application/pdf") {
|
||||||
|
const fileUrl = URL.createObjectURL(file);
|
||||||
|
setPdfFile({ file, url: fileUrl });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
) : loading ? (
|
||||||
|
<Center style={{ flex: 1 }}>
|
||||||
|
<Loader size="lg" />
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<ScrollArea
|
||||||
|
style={{ flex: 1, height: "100vh", position: "relative"}}
|
||||||
|
viewportRef={scrollAreaRef}
|
||||||
|
>
|
||||||
|
<Stack gap="xl" align="center" >
|
||||||
|
{numPages === 0 && (
|
||||||
|
<Text color="dimmed">{t("viewer.noPagesToDisplay", "No pages to display.")}</Text>
|
||||||
|
)}
|
||||||
|
{dualPage
|
||||||
|
? Array.from({ length: Math.ceil(numPages / 2) }).map((_, i) => (
|
||||||
|
<Group key={i} gap="md" align="flex-start" style={{ width: "100%", justifyContent: "center" }}>
|
||||||
|
<LazyPageImage
|
||||||
|
pageIndex={i * 2}
|
||||||
|
zoom={zoom}
|
||||||
|
theme={theme}
|
||||||
|
isFirst={i === 0}
|
||||||
|
renderPage={renderPage}
|
||||||
|
pageImages={pageImages}
|
||||||
|
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
|
||||||
|
/>
|
||||||
|
{i * 2 + 1 < numPages && (
|
||||||
|
<LazyPageImage
|
||||||
|
pageIndex={i * 2 + 1}
|
||||||
|
zoom={zoom}
|
||||||
|
theme={theme}
|
||||||
|
isFirst={i === 0}
|
||||||
|
renderPage={renderPage}
|
||||||
|
pageImages={pageImages}
|
||||||
|
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
))
|
||||||
|
: Array.from({ length: numPages }).map((_, idx) => (
|
||||||
|
<LazyPageImage
|
||||||
|
key={idx}
|
||||||
|
pageIndex={idx}
|
||||||
|
zoom={zoom}
|
||||||
|
theme={theme}
|
||||||
|
isFirst={idx === 0}
|
||||||
|
renderPage={renderPage}
|
||||||
|
pageImages={pageImages}
|
||||||
|
setPageRef={(index, ref) => { pageRefs.current[index] = ref; }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
{/* Navigation bar overlays the scroll area */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
zIndex: 50,
|
||||||
|
display: "flex",
|
||||||
|
justifyContent: "center",
|
||||||
|
pointerEvents: "none",
|
||||||
|
background: "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
radius="xl xl 0 0"
|
||||||
|
shadow="sm"
|
||||||
|
p={12}
|
||||||
|
style={{
|
||||||
|
display: "flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: 10,
|
||||||
|
borderTopLeftRadius: 16,
|
||||||
|
borderTopRightRadius: 16,
|
||||||
|
borderBottomLeftRadius: 0,
|
||||||
|
borderBottomRightRadius: 0,
|
||||||
|
boxShadow: "0 -2px 8px rgba(0,0,0,0.04)",
|
||||||
|
pointerEvents: "auto",
|
||||||
|
minWidth: 420,
|
||||||
|
maxWidth: 700,
|
||||||
|
flexWrap: "wrap",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="blue"
|
||||||
|
size="md"
|
||||||
|
px={8}
|
||||||
|
radius="xl"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.hash = `#page=1`;
|
||||||
|
}}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
style={{ minWidth: 36 }}
|
||||||
|
>
|
||||||
|
<FirstPageIcon fontSize="small" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="blue"
|
||||||
|
size="md"
|
||||||
|
px={8}
|
||||||
|
radius="xl"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.hash = `#page=${Math.max(1, (currentPage || 1) - 1)}`;
|
||||||
|
}}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
style={{ minWidth: 36 }}
|
||||||
|
>
|
||||||
|
<ArrowBackIosNewIcon fontSize="small" />
|
||||||
|
</Button>
|
||||||
|
<NumberInput
|
||||||
|
value={currentPage || 1}
|
||||||
|
onChange={value => {
|
||||||
|
const page = Number(value);
|
||||||
|
if (!isNaN(page) && page >= 1 && page <= numPages) {
|
||||||
|
window.location.hash = `#page=${page}`;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
min={1}
|
||||||
|
max={numPages}
|
||||||
|
hideControls
|
||||||
|
styles={{
|
||||||
|
input: { width: 48, textAlign: "center", fontWeight: 500, fontSize: 16},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span style={{ fontWeight: 500, fontSize: 16 }}>
|
||||||
|
/ {numPages}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="blue"
|
||||||
|
size="md"
|
||||||
|
px={8}
|
||||||
|
radius="xl"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.hash = `#page=${Math.min(numPages, (currentPage || 1) + 1)}`;
|
||||||
|
}}
|
||||||
|
disabled={currentPage === numPages}
|
||||||
|
style={{ minWidth: 36 }}
|
||||||
|
>
|
||||||
|
<ArrowForwardIosIcon fontSize="small" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="blue"
|
||||||
|
size="md"
|
||||||
|
px={8}
|
||||||
|
radius="xl"
|
||||||
|
onClick={() => {
|
||||||
|
window.location.hash = `#page=${numPages}`;
|
||||||
|
}}
|
||||||
|
disabled={currentPage === numPages}
|
||||||
|
style={{ minWidth: 36 }}
|
||||||
|
>
|
||||||
|
<LastPageIcon fontSize="small" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={dualPage ? "filled" : "light"}
|
||||||
|
color="blue"
|
||||||
|
size="md"
|
||||||
|
radius="xl"
|
||||||
|
onClick={() => setDualPage(v => !v)}
|
||||||
|
style={{ minWidth: 36 }}
|
||||||
|
title={dualPage ? t("viewer.singlePageView", "Single Page View") : t("viewer.dualPageView", "Dual Page View")}
|
||||||
|
>
|
||||||
|
{dualPage ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
|
||||||
|
</Button>
|
||||||
|
<Group gap={4} align="center" style={{ marginLeft: 16 }}>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="blue"
|
||||||
|
size="md"
|
||||||
|
radius="xl"
|
||||||
|
onClick={() => setZoom(z => Math.max(0.1, z - 0.1))}
|
||||||
|
style={{ minWidth: 32, padding: 0 }}
|
||||||
|
title={t("viewer.zoomOut", "Zoom out")}
|
||||||
|
>−</Button>
|
||||||
|
<span style={{ minWidth: 40, textAlign: "center" }}>{Math.round(zoom * 100)}%</span>
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
color="blue"
|
||||||
|
size="md"
|
||||||
|
radius="xl"
|
||||||
|
onClick={() => setZoom(z => Math.min(5, z + 0.1))}
|
||||||
|
style={{ minWidth: 32, padding: 0 }}
|
||||||
|
title={t("viewer.zoomIn", "Zoom in")}
|
||||||
|
>+</Button>
|
||||||
|
</Group>
|
||||||
|
</Paper>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Viewer;
|
@ -1,24 +1,29 @@
|
|||||||
import React, { useState, useCallback, useEffect } from "react";
|
import React, { useState, useCallback, useEffect } from "react";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
|
import { useToolParams } from "../hooks/useToolParams";
|
||||||
|
import { useFileWithUrl } from "../hooks/useFileWithUrl";
|
||||||
|
import { fileStorage } from "../services/fileStorage";
|
||||||
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
|
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
|
||||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||||
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
|
||||||
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
|
import { Group, Paper, Box, Button, useMantineTheme, Container } from "@mantine/core";
|
||||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
import { useRainbowThemeContext } from "../components/shared/RainbowThemeProvider";
|
||||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
import rainbowStyles from '../styles/rainbow.module.css';
|
||||||
import { Group, SegmentedControl, Paper, Center, Box, Button, useMantineTheme, useMantineColorScheme } from "@mantine/core";
|
|
||||||
|
|
||||||
import ToolPicker from "../components/ToolPicker";
|
import ToolPicker from "../components/tools/ToolPicker";
|
||||||
import FileManager from "../components/FileManager";
|
import TopControls from "../components/shared/TopControls";
|
||||||
|
import FileManager from "../components/fileManagement/FileManager";
|
||||||
|
import FileEditor from "../components/editor/FileEditor";
|
||||||
|
import PageEditor from "../components/editor/PageEditor";
|
||||||
|
import PageEditorControls from "../components/editor/PageEditorControls";
|
||||||
|
import Viewer from "../components/viewer/Viewer";
|
||||||
|
import FileUploadSelector from "../components/shared/FileUploadSelector";
|
||||||
import SplitPdfPanel from "../tools/Split";
|
import SplitPdfPanel from "../tools/Split";
|
||||||
import CompressPdfPanel from "../tools/Compress";
|
import CompressPdfPanel from "../tools/Compress";
|
||||||
import MergePdfPanel from "../tools/Merge";
|
import MergePdfPanel from "../tools/Merge";
|
||||||
import PageEditor from "../components/PageEditor";
|
import ToolRenderer from "../components/tools/ToolRenderer";
|
||||||
import Viewer from "../components/Viewer";
|
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
||||||
import LanguageSelector from "../components/LanguageSelector";
|
|
||||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
|
||||||
import LightModeIcon from '@mui/icons-material/LightMode';
|
|
||||||
|
|
||||||
type ToolRegistryEntry = {
|
type ToolRegistryEntry = {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
@ -38,412 +43,344 @@ const baseToolRegistry = {
|
|||||||
merge: { icon: <AddToPhotosIcon />, component: MergePdfPanel, view: "fileManager" },
|
merge: { icon: <AddToPhotosIcon />, component: MergePdfPanel, view: "fileManager" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const VIEW_OPTIONS = [
|
|
||||||
{
|
|
||||||
label: (
|
|
||||||
<Group gap={4}>
|
|
||||||
<VisibilityIcon fontSize="small" />
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
value: "viewer",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: (
|
|
||||||
<Group gap={4}>
|
|
||||||
<EditNoteIcon fontSize="small" />
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
value: "pageEditor",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: (
|
|
||||||
<Group gap={4}>
|
|
||||||
<InsertDriveFileIcon fontSize="small" />
|
|
||||||
</Group>
|
|
||||||
),
|
|
||||||
value: "fileManager",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// Utility to extract params for a tool from searchParams
|
|
||||||
function getToolParams(toolKey: string, searchParams: URLSearchParams) {
|
|
||||||
switch (toolKey) {
|
|
||||||
case "split":
|
|
||||||
return {
|
|
||||||
mode: searchParams.get("splitMode") || "byPages",
|
|
||||||
pages: searchParams.get("pages") || "",
|
|
||||||
hDiv: searchParams.get("hDiv") || "",
|
|
||||||
vDiv: searchParams.get("vDiv") || "",
|
|
||||||
merge: searchParams.get("merge") === "true",
|
|
||||||
splitType: searchParams.get("splitType") || "size",
|
|
||||||
splitValue: searchParams.get("splitValue") || "",
|
|
||||||
bookmarkLevel: searchParams.get("bookmarkLevel") || "0",
|
|
||||||
includeMetadata: searchParams.get("includeMetadata") === "true",
|
|
||||||
allowDuplicates: searchParams.get("allowDuplicates") === "true",
|
|
||||||
};
|
|
||||||
case "compress":
|
|
||||||
return {
|
|
||||||
compressionLevel: parseInt(searchParams.get("compressionLevel") || "5"),
|
|
||||||
grayscale: searchParams.get("grayscale") === "true",
|
|
||||||
removeMetadata: searchParams.get("removeMetadata") === "true",
|
|
||||||
expectedSize: searchParams.get("expectedSize") || "",
|
|
||||||
aggressive: searchParams.get("aggressive") === "true",
|
|
||||||
};
|
|
||||||
case "merge":
|
|
||||||
return {
|
|
||||||
order: searchParams.get("mergeOrder") || "default",
|
|
||||||
removeDuplicates: searchParams.get("removeDuplicates") === "true",
|
|
||||||
};
|
|
||||||
// Add more tools here as needed
|
|
||||||
default:
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility to update params for a tool
|
|
||||||
function updateToolParams(toolKey: string, searchParams: URLSearchParams, setSearchParams: any, newParams: any) {
|
|
||||||
const params = new URLSearchParams(searchParams);
|
|
||||||
|
|
||||||
// Clear tool-specific params
|
|
||||||
if (toolKey === "split") {
|
|
||||||
[
|
|
||||||
"splitMode", "pages", "hDiv", "vDiv", "merge",
|
|
||||||
"splitType", "splitValue", "bookmarkLevel", "includeMetadata", "allowDuplicates"
|
|
||||||
].forEach((k) => params.delete(k));
|
|
||||||
// Set new split params
|
|
||||||
const merged = { ...getToolParams("split", searchParams), ...newParams };
|
|
||||||
params.set("splitMode", merged.mode);
|
|
||||||
if (merged.mode === "byPages") params.set("pages", merged.pages);
|
|
||||||
else if (merged.mode === "bySections") {
|
|
||||||
params.set("hDiv", merged.hDiv);
|
|
||||||
params.set("vDiv", merged.vDiv);
|
|
||||||
params.set("merge", String(merged.merge));
|
|
||||||
} else if (merged.mode === "bySizeOrCount") {
|
|
||||||
params.set("splitType", merged.splitType);
|
|
||||||
params.set("splitValue", merged.splitValue);
|
|
||||||
} else if (merged.mode === "byChapters") {
|
|
||||||
params.set("bookmarkLevel", merged.bookmarkLevel);
|
|
||||||
params.set("includeMetadata", String(merged.includeMetadata));
|
|
||||||
params.set("allowDuplicates", String(merged.allowDuplicates));
|
|
||||||
}
|
|
||||||
} else if (toolKey === "compress") {
|
|
||||||
["compressionLevel", "grayscale", "removeMetadata", "expectedSize", "aggressive"].forEach((k) => params.delete(k));
|
|
||||||
const merged = { ...getToolParams("compress", searchParams), ...newParams };
|
|
||||||
params.set("compressionLevel", String(merged.compressionLevel));
|
|
||||||
params.set("grayscale", String(merged.grayscale));
|
|
||||||
params.set("removeMetadata", String(merged.removeMetadata));
|
|
||||||
if (merged.expectedSize) params.set("expectedSize", merged.expectedSize);
|
|
||||||
params.set("aggressive", String(merged.aggressive));
|
|
||||||
} else if (toolKey === "merge") {
|
|
||||||
["mergeOrder", "removeDuplicates"].forEach((k) => params.delete(k));
|
|
||||||
const merged = { ...getToolParams("merge", searchParams), ...newParams };
|
|
||||||
params.set("mergeOrder", merged.order);
|
|
||||||
params.set("removeDuplicates", String(merged.removeDuplicates));
|
|
||||||
}
|
|
||||||
// Add more tools as needed
|
|
||||||
|
|
||||||
setSearchParams(params, { replace: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// List of all tool-specific params
|
|
||||||
const TOOL_PARAMS = {
|
|
||||||
split: [
|
|
||||||
"splitMode", "pages", "hDiv", "vDiv", "merge",
|
|
||||||
"splitType", "splitValue", "bookmarkLevel", "includeMetadata", "allowDuplicates"
|
|
||||||
],
|
|
||||||
compress: [
|
|
||||||
"compressionLevel", "grayscale", "removeMetadata", "expectedSize", "aggressive"
|
|
||||||
],
|
|
||||||
merge: [
|
|
||||||
"mergeOrder", "removeDuplicates"
|
|
||||||
]
|
|
||||||
// Add more tools as needed
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const theme = useMantineTheme();
|
const theme = useMantineTheme();
|
||||||
const { colorScheme, toggleColorScheme } = useMantineColorScheme();
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
|
|
||||||
|
// Core app state
|
||||||
|
const [selectedToolKey, setSelectedToolKey] = useState<string>(searchParams.get("t") || "split");
|
||||||
|
const [currentView, setCurrentView] = useState<string>(searchParams.get("v") || "viewer");
|
||||||
|
|
||||||
|
// File state separation
|
||||||
|
const [storedFiles, setStoredFiles] = useState<any[]>([]); // IndexedDB files (FileManager)
|
||||||
|
const [activeFiles, setActiveFiles] = useState<File[]>([]); // Active working set (persisted)
|
||||||
|
const [preSelectedFiles, setPreSelectedFiles] = useState([]);
|
||||||
|
|
||||||
|
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||||
|
const [sidebarsVisible, setSidebarsVisible] = useState(true);
|
||||||
|
const [leftPanelView, setLeftPanelView] = useState<'toolPicker' | 'toolContent'>('toolPicker');
|
||||||
|
const [readerMode, setReaderMode] = useState(false);
|
||||||
|
|
||||||
|
// Page editor functions
|
||||||
|
const [pageEditorFunctions, setPageEditorFunctions] = useState<any>(null);
|
||||||
|
|
||||||
|
// URL parameter management
|
||||||
|
const { toolParams, updateParams } = useToolParams(selectedToolKey, currentView);
|
||||||
|
|
||||||
|
// Persist active files across reloads
|
||||||
|
useEffect(() => {
|
||||||
|
// Save active files to localStorage (just metadata)
|
||||||
|
const activeFileData = activeFiles.map(file => ({
|
||||||
|
name: file.name,
|
||||||
|
size: file.size,
|
||||||
|
type: file.type,
|
||||||
|
lastModified: file.lastModified
|
||||||
|
}));
|
||||||
|
localStorage.setItem('activeFiles', JSON.stringify(activeFileData));
|
||||||
|
}, [activeFiles]);
|
||||||
|
|
||||||
|
// Load stored files from IndexedDB on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadStoredFiles = async () => {
|
||||||
|
try {
|
||||||
|
const files = await fileStorage.getAllFiles();
|
||||||
|
setStoredFiles(files);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to load stored files:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadStoredFiles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Restore active files on load
|
||||||
|
useEffect(() => {
|
||||||
|
const restoreActiveFiles = async () => {
|
||||||
|
try {
|
||||||
|
const savedFileData = JSON.parse(localStorage.getItem('activeFiles') || '[]');
|
||||||
|
if (savedFileData.length > 0) {
|
||||||
|
// TODO: Reconstruct files from IndexedDB when fileStorage is available
|
||||||
|
console.log('Would restore active files:', savedFileData);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Failed to restore active files:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
restoreActiveFiles();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Create translated tool registry
|
|
||||||
const toolRegistry: ToolRegistry = {
|
const toolRegistry: ToolRegistry = {
|
||||||
split: { ...baseToolRegistry.split, name: t("home.split.title", "Split PDF") },
|
split: { ...baseToolRegistry.split, name: t("home.split.title", "Split PDF") },
|
||||||
compress: { ...baseToolRegistry.compress, name: t("home.compressPdfs.title", "Compress PDF") },
|
compress: { ...baseToolRegistry.compress, name: t("home.compressPdfs.title", "Compress PDF") },
|
||||||
merge: { ...baseToolRegistry.merge, name: t("home.merge.title", "Merge PDFs") },
|
merge: { ...baseToolRegistry.merge, name: t("home.merge.title", "Merge PDFs") },
|
||||||
};
|
};
|
||||||
|
|
||||||
// Core app state
|
|
||||||
const [selectedToolKey, setSelectedToolKey] = useState<string>(searchParams.get("tool") || "split");
|
|
||||||
const [currentView, setCurrentView] = useState<string>(searchParams.get("view") || "viewer");
|
|
||||||
const [pdfFile, setPdfFile] = useState<any>(null);
|
|
||||||
const [files, setFiles] = useState<any[]>([]);
|
|
||||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
|
||||||
const [sidebarsVisible, setSidebarsVisible] = useState(true);
|
|
||||||
|
|
||||||
const toolParams = getToolParams(selectedToolKey, searchParams);
|
|
||||||
|
|
||||||
const updateParams = (newParams: any) =>
|
|
||||||
updateToolParams(selectedToolKey, searchParams, setSearchParams, newParams);
|
|
||||||
|
|
||||||
// Update URL when core state changes
|
|
||||||
useEffect(() => {
|
|
||||||
const params = new URLSearchParams(searchParams);
|
|
||||||
|
|
||||||
// Remove all tool-specific params except for the current tool
|
|
||||||
Object.entries(TOOL_PARAMS).forEach(([tool, keys]) => {
|
|
||||||
if (tool !== selectedToolKey) {
|
|
||||||
keys.forEach((k) => params.delete(k));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Collect all params except 'view'
|
|
||||||
const entries = Array.from(params.entries()).filter(([key]) => key !== "view");
|
|
||||||
|
|
||||||
// Rebuild params with 'view' first
|
|
||||||
const newParams = new URLSearchParams();
|
|
||||||
newParams.set("view", currentView);
|
|
||||||
newParams.set("tool", selectedToolKey);
|
|
||||||
entries.forEach(([key, value]) => {
|
|
||||||
if (key !== "tool") newParams.set(key, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
setSearchParams(newParams, { replace: true });
|
|
||||||
}, [selectedToolKey, currentView, setSearchParams, searchParams]);
|
|
||||||
|
|
||||||
// Handle tool selection
|
// Handle tool selection
|
||||||
const handleToolSelect = useCallback(
|
const handleToolSelect = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
setSelectedToolKey(id);
|
setSelectedToolKey(id);
|
||||||
if (toolRegistry[id]?.view) setCurrentView(toolRegistry[id].view);
|
if (toolRegistry[id]?.view) setCurrentView(toolRegistry[id].view);
|
||||||
|
setLeftPanelView('toolContent'); // Switch to tool content view when a tool is selected
|
||||||
|
setReaderMode(false); // Exit reader mode when selecting a tool
|
||||||
},
|
},
|
||||||
[toolRegistry]
|
[toolRegistry]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle quick access actions
|
||||||
|
const handleQuickAccessTools = useCallback(() => {
|
||||||
|
setLeftPanelView('toolPicker');
|
||||||
|
setReaderMode(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleReaderToggle = useCallback(() => {
|
||||||
|
setReaderMode(!readerMode);
|
||||||
|
}, [readerMode]);
|
||||||
|
|
||||||
|
// Update URL when view changes
|
||||||
|
const handleViewChange = useCallback((view: string) => {
|
||||||
|
setCurrentView(view);
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
params.set('view', view);
|
||||||
|
const newUrl = `${window.location.pathname}?${params.toString()}`;
|
||||||
|
window.history.replaceState({}, '', newUrl);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Active file management
|
||||||
|
const addToActiveFiles = useCallback((file: File) => {
|
||||||
|
setActiveFiles(prev => {
|
||||||
|
// Avoid duplicates based on name and size
|
||||||
|
const exists = prev.some(f => f.name === file.name && f.size === file.size);
|
||||||
|
if (exists) return prev;
|
||||||
|
return [file, ...prev];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const removeFromActiveFiles = useCallback((file: File) => {
|
||||||
|
setActiveFiles(prev => prev.filter(f => !(f.name === file.name && f.size === file.size)));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setCurrentActiveFile = useCallback((file: File) => {
|
||||||
|
setActiveFiles(prev => {
|
||||||
|
const filtered = prev.filter(f => !(f.name === file.name && f.size === file.size));
|
||||||
|
return [file, ...filtered];
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle file selection from upload (adds to active files)
|
||||||
|
const handleFileSelect = useCallback((file: File) => {
|
||||||
|
addToActiveFiles(file);
|
||||||
|
}, [addToActiveFiles]);
|
||||||
|
|
||||||
|
// Handle opening file editor with selected files
|
||||||
|
const handleOpenFileEditor = useCallback((selectedFiles) => {
|
||||||
|
setPreSelectedFiles(selectedFiles || []);
|
||||||
|
handleViewChange("fileEditor");
|
||||||
|
}, [handleViewChange]);
|
||||||
|
|
||||||
const selectedTool = toolRegistry[selectedToolKey];
|
const selectedTool = toolRegistry[selectedToolKey];
|
||||||
|
|
||||||
// Tool component rendering
|
// Convert current active file to format expected by Viewer/PageEditor
|
||||||
const renderTool = () => {
|
const currentFileWithUrl = useFileWithUrl(activeFiles[0] || null);
|
||||||
if (!selectedTool || !selectedTool.component) {
|
|
||||||
return <div>Tool not found</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass tool-specific props
|
|
||||||
switch (selectedToolKey) {
|
|
||||||
case "split":
|
|
||||||
return React.createElement(selectedTool.component, {
|
|
||||||
file: pdfFile,
|
|
||||||
downloadUrl,
|
|
||||||
setDownloadUrl,
|
|
||||||
params: toolParams,
|
|
||||||
updateParams,
|
|
||||||
});
|
|
||||||
case "compress":
|
|
||||||
return React.createElement(selectedTool.component, {
|
|
||||||
files,
|
|
||||||
setDownloadUrl,
|
|
||||||
setLoading: (loading: boolean) => {}, // TODO: Add loading state
|
|
||||||
params: toolParams,
|
|
||||||
updateParams,
|
|
||||||
});
|
|
||||||
case "merge":
|
|
||||||
return React.createElement(selectedTool.component, {
|
|
||||||
files,
|
|
||||||
setDownloadUrl,
|
|
||||||
params: toolParams,
|
|
||||||
updateParams,
|
|
||||||
});
|
|
||||||
default:
|
|
||||||
return React.createElement(selectedTool.component, {
|
|
||||||
files,
|
|
||||||
setDownloadUrl,
|
|
||||||
params: toolParams,
|
|
||||||
updateParams,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group
|
<Group
|
||||||
align="flex-start"
|
align="flex-start"
|
||||||
gap={0}
|
gap={0}
|
||||||
|
className="min-h-screen w-screen overflow-hidden flex-nowrap flex"
|
||||||
|
>
|
||||||
|
{/* Quick Access Bar */}
|
||||||
|
<QuickAccessBar
|
||||||
|
onToolsClick={handleQuickAccessTools}
|
||||||
|
onReaderToggle={handleReaderToggle}
|
||||||
|
selectedToolKey={selectedToolKey}
|
||||||
|
toolRegistry={toolRegistry}
|
||||||
|
leftPanelView={leftPanelView}
|
||||||
|
readerMode={readerMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Left: Tool Picker OR Selected Tool Panel */}
|
||||||
|
<div
|
||||||
|
className={`h-screen z-sticky flex flex-col ${isRainbowMode ? rainbowStyles.rainbowPaper : ''} overflow-hidden`}
|
||||||
style={{
|
style={{
|
||||||
minHeight: "100vh",
|
backgroundColor: 'var(--bg-surface)',
|
||||||
width: "100vw",
|
borderRight: '1px solid var(--border-subtle)',
|
||||||
overflow: "hidden",
|
width: sidebarsVisible && !readerMode ? '25vw' : '0px',
|
||||||
flexWrap: "nowrap",
|
minWidth: sidebarsVisible && !readerMode ? '300px' : '0px',
|
||||||
display: "flex",
|
maxWidth: sidebarsVisible && !readerMode ? '450px' : '0px',
|
||||||
|
transition: 'width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), min-width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), max-width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
|
padding: sidebarsVisible && !readerMode ? '1rem' : '0rem'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Left: Tool Picker */}
|
<div
|
||||||
{sidebarsVisible && (
|
|
||||||
<Box
|
|
||||||
style={{
|
style={{
|
||||||
minWidth: 180,
|
opacity: sidebarsVisible && !readerMode ? 1 : 0,
|
||||||
maxWidth: 240,
|
transition: 'opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
width: "16vw",
|
height: '100%',
|
||||||
height: "100vh",
|
display: 'flex',
|
||||||
borderRight: `1px solid ${colorScheme === "dark" ? theme.colors.dark[4] : "#e9ecef"}`,
|
flexDirection: 'column'
|
||||||
background: colorScheme === "dark" ? theme.colors.dark[7] : "#fff",
|
|
||||||
zIndex: 101,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
{leftPanelView === 'toolPicker' ? (
|
||||||
|
// Tool Picker View
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
<ToolPicker
|
<ToolPicker
|
||||||
selectedToolKey={selectedToolKey}
|
selectedToolKey={selectedToolKey}
|
||||||
onSelect={handleToolSelect}
|
onSelect={handleToolSelect}
|
||||||
toolRegistry={toolRegistry}
|
toolRegistry={toolRegistry}
|
||||||
/>
|
/>
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Middle: Main View */}
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
flex: 1,
|
|
||||||
height: "100vh",
|
|
||||||
minWidth: "20rem",
|
|
||||||
position: "relative",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
transition: "all 0.3s",
|
|
||||||
background: colorScheme === "dark" ? theme.colors.dark[6] : "#f8f9fa",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Overlayed View Switcher + Theme Toggle */}
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 0,
|
|
||||||
width: "100%",
|
|
||||||
top: 0,
|
|
||||||
zIndex: 30,
|
|
||||||
pointerEvents: "none",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: "absolute",
|
|
||||||
left: 16,
|
|
||||||
top: "50%",
|
|
||||||
transform: "translateY(-50%)",
|
|
||||||
pointerEvents: "auto",
|
|
||||||
display: "flex",
|
|
||||||
gap: 12,
|
|
||||||
alignItems: "center",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
onClick={toggleColorScheme}
|
|
||||||
variant="subtle"
|
|
||||||
size="md"
|
|
||||||
aria-label="Toggle theme"
|
|
||||||
>
|
|
||||||
{colorScheme === "dark" ? <LightModeIcon /> : <DarkModeIcon />}
|
|
||||||
</Button>
|
|
||||||
<LanguageSelector />
|
|
||||||
</div>
|
</div>
|
||||||
<div
|
) : (
|
||||||
style={{
|
// Selected Tool Content View
|
||||||
display: "flex",
|
<div className="flex-1 flex flex-col">
|
||||||
justifyContent: "center",
|
{/* Back button */}
|
||||||
alignItems: "center",
|
<div className="mb-4">
|
||||||
height: "100%",
|
<Button
|
||||||
pointerEvents: "auto",
|
variant="subtle"
|
||||||
}}
|
size="sm"
|
||||||
|
onClick={() => setLeftPanelView('toolPicker')}
|
||||||
|
className="text-sm"
|
||||||
>
|
>
|
||||||
<SegmentedControl
|
← {t("fileUpload.backToTools", "Back to Tools")}
|
||||||
data={VIEW_OPTIONS}
|
</Button>
|
||||||
value={currentView}
|
</div>
|
||||||
onChange={setCurrentView}
|
|
||||||
color="blue"
|
{/* Tool title */}
|
||||||
radius="xl"
|
<div className="mb-4">
|
||||||
size="md"
|
<h2 className="text-lg font-semibold">{selectedTool?.name}</h2>
|
||||||
fullWidth
|
</div>
|
||||||
|
|
||||||
|
{/* Tool content */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<ToolRenderer
|
||||||
|
selectedToolKey={selectedToolKey}
|
||||||
|
selectedTool={selectedTool}
|
||||||
|
pdfFile={activeFiles[0] || null}
|
||||||
|
files={activeFiles}
|
||||||
|
downloadUrl={downloadUrl}
|
||||||
|
setDownloadUrl={setDownloadUrl}
|
||||||
|
toolParams={toolParams}
|
||||||
|
updateParams={updateParams}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Main content area */}
|
)}
|
||||||
<Paper
|
</div>
|
||||||
radius="0 0 xl xl"
|
</div>
|
||||||
shadow="sm"
|
|
||||||
p={0}
|
{/* Main View */}
|
||||||
|
<Box
|
||||||
|
className="flex-1 h-screen min-w-80 relative flex flex-col"
|
||||||
style={{
|
style={{
|
||||||
flex: 1,
|
backgroundColor: 'var(--bg-background)'
|
||||||
minHeight: 0,
|
|
||||||
marginTop: 0,
|
|
||||||
boxSizing: "border-box",
|
|
||||||
overflow: "hidden",
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Box style={{ flex: 1, minHeight: 0 }}>
|
{/* Top Controls */}
|
||||||
{(currentView === "viewer" || currentView === "pageEditor") && !pdfFile ? (
|
<TopControls
|
||||||
|
currentView={currentView}
|
||||||
|
setCurrentView={handleViewChange}
|
||||||
|
/>
|
||||||
|
{/* Main content area */}
|
||||||
|
<Box className="flex-1 min-h-0 margin-top-200 relative z-10">
|
||||||
|
{currentView === "fileManager" ? (
|
||||||
<FileManager
|
<FileManager
|
||||||
files={files}
|
files={storedFiles}
|
||||||
setFiles={setFiles}
|
setFiles={setStoredFiles}
|
||||||
setPdfFile={setPdfFile}
|
setCurrentView={handleViewChange}
|
||||||
setCurrentView={setCurrentView}
|
onOpenFileEditor={handleOpenFileEditor}
|
||||||
|
onLoadFileToActive={addToActiveFiles}
|
||||||
|
/>
|
||||||
|
) : (currentView != "fileManager") && !activeFiles[0] ? (
|
||||||
|
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||||
|
<FileUploadSelector
|
||||||
|
title={currentView === "viewer"
|
||||||
|
? t("fileUpload.selectPdfToView", "Select a PDF to view")
|
||||||
|
: t("fileUpload.selectPdfToEdit", "Select a PDF to edit")
|
||||||
|
}
|
||||||
|
subtitle={t("fileUpload.chooseFromStorage", "Choose a file from storage or upload a new PDF")}
|
||||||
|
sharedFiles={storedFiles}
|
||||||
|
onFileSelect={(file) => {
|
||||||
|
addToActiveFiles(file);
|
||||||
|
}}
|
||||||
|
allowMultiple={false}
|
||||||
|
accept={["application/pdf"]}
|
||||||
|
loading={false}
|
||||||
|
/>
|
||||||
|
</Container>
|
||||||
|
) : currentView === "fileEditor" ? (
|
||||||
|
<FileEditor
|
||||||
|
sharedFiles={activeFiles}
|
||||||
|
setSharedFiles={setActiveFiles}
|
||||||
|
preSelectedFiles={preSelectedFiles}
|
||||||
|
onClearPreSelection={() => setPreSelectedFiles([])}
|
||||||
|
onOpenPageEditor={(file) => {
|
||||||
|
setCurrentActiveFile(file);
|
||||||
|
handleViewChange("pageEditor");
|
||||||
|
}}
|
||||||
|
onMergeFiles={(filesToMerge) => {
|
||||||
|
// Add merged files to active set
|
||||||
|
filesToMerge.forEach(addToActiveFiles);
|
||||||
|
handleViewChange("viewer");
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : currentView === "viewer" ? (
|
) : currentView === "viewer" ? (
|
||||||
<Viewer
|
<Viewer
|
||||||
pdfFile={pdfFile}
|
pdfFile={currentFileWithUrl}
|
||||||
setPdfFile={setPdfFile}
|
setPdfFile={(fileObj) => {
|
||||||
|
if (fileObj) {
|
||||||
|
setCurrentActiveFile(fileObj.file);
|
||||||
|
} else {
|
||||||
|
setActiveFiles([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
sidebarsVisible={sidebarsVisible}
|
sidebarsVisible={sidebarsVisible}
|
||||||
setSidebarsVisible={setSidebarsVisible}
|
setSidebarsVisible={setSidebarsVisible}
|
||||||
/>
|
/>
|
||||||
) : currentView === "pageEditor" ? (
|
) : currentView === "pageEditor" ? (
|
||||||
|
<>
|
||||||
<PageEditor
|
<PageEditor
|
||||||
file={pdfFile}
|
file={currentFileWithUrl}
|
||||||
setFile={setPdfFile}
|
setFile={(fileObj) => {
|
||||||
|
if (fileObj) {
|
||||||
|
setCurrentActiveFile(fileObj.file);
|
||||||
|
} else {
|
||||||
|
setActiveFiles([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
downloadUrl={downloadUrl}
|
downloadUrl={downloadUrl}
|
||||||
setDownloadUrl={setDownloadUrl}
|
setDownloadUrl={setDownloadUrl}
|
||||||
|
onFunctionsReady={setPageEditorFunctions}
|
||||||
|
sharedFiles={activeFiles}
|
||||||
/>
|
/>
|
||||||
|
{activeFiles[0] && pageEditorFunctions && (
|
||||||
|
<PageEditorControls
|
||||||
|
onClosePdf={pageEditorFunctions.closePdf}
|
||||||
|
onUndo={pageEditorFunctions.handleUndo}
|
||||||
|
onRedo={pageEditorFunctions.handleRedo}
|
||||||
|
canUndo={pageEditorFunctions.canUndo}
|
||||||
|
canRedo={pageEditorFunctions.canRedo}
|
||||||
|
onRotate={pageEditorFunctions.handleRotate}
|
||||||
|
onDelete={pageEditorFunctions.handleDelete}
|
||||||
|
onSplit={pageEditorFunctions.handleSplit}
|
||||||
|
onExportSelected={() => pageEditorFunctions.showExportPreview(true)}
|
||||||
|
onExportAll={() => pageEditorFunctions.showExportPreview(false)}
|
||||||
|
exportLoading={pageEditorFunctions.exportLoading}
|
||||||
|
selectionMode={pageEditorFunctions.selectionMode}
|
||||||
|
selectedPages={pageEditorFunctions.selectedPages}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<FileManager
|
<FileManager
|
||||||
files={files}
|
files={storedFiles}
|
||||||
setFiles={setFiles}
|
setFiles={setStoredFiles}
|
||||||
setPdfFile={setPdfFile}
|
setCurrentView={handleViewChange}
|
||||||
setCurrentView={setCurrentView}
|
onOpenFileEditor={handleOpenFileEditor}
|
||||||
|
onLoadFileToActive={addToActiveFiles}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Right: Tool Interaction */}
|
|
||||||
{sidebarsVisible && (
|
|
||||||
<Box
|
|
||||||
style={{
|
|
||||||
minWidth: 260,
|
|
||||||
maxWidth: 400,
|
|
||||||
width: "22vw",
|
|
||||||
height: "100vh",
|
|
||||||
borderLeft: `1px solid ${colorScheme === "dark" ? theme.colors.dark[4] : "#e9ecef"}`,
|
|
||||||
background: colorScheme === "dark" ? theme.colors.dark[7] : "#fff",
|
|
||||||
padding: 24,
|
|
||||||
gap: 16,
|
|
||||||
zIndex: 100,
|
|
||||||
display: "flex",
|
|
||||||
flexDirection: "column",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{selectedTool && selectedTool.component && renderTool()}
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Sidebar toggle button */}
|
|
||||||
<Button
|
|
||||||
variant="light"
|
|
||||||
color="blue"
|
|
||||||
size="xs"
|
|
||||||
style={{ position: "fixed", top: 16, right: 16, zIndex: 200 }}
|
|
||||||
onClick={() => setSidebarsVisible((v) => !v)}
|
|
||||||
>
|
|
||||||
{t("sidebar.toggle", sidebarsVisible ? "Hide Sidebars" : "Show Sidebars")}
|
|
||||||
</Button>
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -11,9 +11,40 @@ export interface FileWithUrl extends File {
|
|||||||
|
|
||||||
export interface StorageConfig {
|
export interface StorageConfig {
|
||||||
useIndexedDB: boolean;
|
useIndexedDB: boolean;
|
||||||
// Simplified - no thresholds needed, IndexedDB for everything
|
maxFileSize: number; // Maximum size per file in bytes
|
||||||
|
maxTotalStorage: number; // Maximum total storage in bytes
|
||||||
|
warningThreshold: number; // Warning threshold (percentage 0-1)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const defaultStorageConfig: StorageConfig = {
|
export const defaultStorageConfig: StorageConfig = {
|
||||||
useIndexedDB: true,
|
useIndexedDB: true,
|
||||||
|
maxFileSize: 100 * 1024 * 1024, // 100MB per file
|
||||||
|
maxTotalStorage: 1024 * 1024 * 1024, // 1GB default, will be updated dynamically
|
||||||
|
warningThreshold: 0.8, // Warn at 80% capacity
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate and update storage limit: half of available storage or 10GB, whichever is smaller
|
||||||
|
export const initializeStorageConfig = async (): Promise<StorageConfig> => {
|
||||||
|
const tenGB = 10 * 1024 * 1024 * 1024; // 10GB in bytes
|
||||||
|
const oneGB = 1024 * 1024 * 1024; // 1GB fallback
|
||||||
|
|
||||||
|
let maxTotalStorage = oneGB; // Default fallback
|
||||||
|
|
||||||
|
// Try to estimate available storage
|
||||||
|
if ('storage' in navigator && 'estimate' in navigator.storage) {
|
||||||
|
try {
|
||||||
|
const estimate = await navigator.storage.estimate();
|
||||||
|
if (estimate.quota) {
|
||||||
|
const halfQuota = estimate.quota / 2;
|
||||||
|
maxTotalStorage = Math.min(halfQuota, tenGB);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Could not estimate storage quota, using 1GB default:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaultStorageConfig,
|
||||||
|
maxTotalStorage
|
||||||
|
};
|
||||||
};
|
};
|
Loading…
x
Reference in New Issue
Block a user