mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-03-04 02:20:19 +01:00
Page editor fixes and improvements and restructuring
This commit is contained in:
42
frontend/src/components/pageEditor/BulkSelectionPanel.tsx
Normal file
42
frontend/src/components/pageEditor/BulkSelectionPanel.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React from 'react';
|
||||
import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
|
||||
|
||||
interface BulkSelectionPanelProps {
|
||||
csvInput: string;
|
||||
setCsvInput: (value: string) => void;
|
||||
selectedPages: string[];
|
||||
onUpdatePagesFromCSV: () => void;
|
||||
}
|
||||
|
||||
const BulkSelectionPanel = ({
|
||||
csvInput,
|
||||
setCsvInput,
|
||||
selectedPages,
|
||||
onUpdatePagesFromCSV,
|
||||
}: BulkSelectionPanelProps) => {
|
||||
return (
|
||||
<Paper p="md" mb="md" withBorder>
|
||||
<Group>
|
||||
<TextInput
|
||||
value={csvInput}
|
||||
onChange={(e) => setCsvInput(e.target.value)}
|
||||
placeholder="1,3,5-10"
|
||||
label="Page Selection"
|
||||
onBlur={onUpdatePagesFromCSV}
|
||||
onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
<Button onClick={onUpdatePagesFromCSV} mt="xl">
|
||||
Apply
|
||||
</Button>
|
||||
</Group>
|
||||
{selectedPages.length > 0 && (
|
||||
<Text size="sm" c="dimmed" mt="sm">
|
||||
Selected: {selectedPages.length} pages
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkSelectionPanel;
|
||||
131
frontend/src/components/pageEditor/DragDropGrid.tsx
Normal file
131
frontend/src/components/pageEditor/DragDropGrid.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import styles from './PageEditor.module.css';
|
||||
|
||||
interface DragDropItem {
|
||||
id: string;
|
||||
splitBefore?: boolean;
|
||||
}
|
||||
|
||||
interface DragDropGridProps<T extends DragDropItem> {
|
||||
items: T[];
|
||||
selectedItems: string[];
|
||||
selectionMode: boolean;
|
||||
isAnimating: boolean;
|
||||
onDragStart: (itemId: string) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (itemId: string) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent, targetId: string | 'end') => void;
|
||||
onEndZoneDragEnter: () => void;
|
||||
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
|
||||
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
|
||||
draggedItem: string | null;
|
||||
dropTarget: string | null;
|
||||
multiItemDrag: {itemIds: string[], count: number} | null;
|
||||
dragPosition: {x: number, y: number} | null;
|
||||
}
|
||||
|
||||
const DragDropGrid = <T extends DragDropItem>({
|
||||
items,
|
||||
selectedItems,
|
||||
selectionMode,
|
||||
isAnimating,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onEndZoneDragEnter,
|
||||
renderItem,
|
||||
renderSplitMarker,
|
||||
draggedItem,
|
||||
dropTarget,
|
||||
multiItemDrag,
|
||||
dragPosition,
|
||||
}: DragDropGridProps<T>) => {
|
||||
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Global drag cleanup
|
||||
useEffect(() => {
|
||||
const handleGlobalDragEnd = () => {
|
||||
onDragEnd();
|
||||
};
|
||||
|
||||
const handleGlobalDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
if (draggedItem) {
|
||||
document.addEventListener('dragend', handleGlobalDragEnd);
|
||||
document.addEventListener('drop', handleGlobalDrop);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragend', handleGlobalDragEnd);
|
||||
document.removeEventListener('drop', handleGlobalDrop);
|
||||
};
|
||||
}, [draggedItem, onDragEnd]);
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '1.5rem',
|
||||
justifyContent: 'flex-start',
|
||||
paddingBottom: '100px'
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<React.Fragment key={item.id}>
|
||||
{/* Split marker */}
|
||||
{renderSplitMarker && item.splitBefore && index > 0 && renderSplitMarker(item, index)}
|
||||
|
||||
{/* Item */}
|
||||
{renderItem(item, index, itemRefs)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
|
||||
{/* End drop zone */}
|
||||
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
|
||||
<div
|
||||
data-drop-zone="end"
|
||||
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${
|
||||
dropTarget === 'end'
|
||||
? 'ring-2 ring-green-500 bg-green-50'
|
||||
: 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'
|
||||
}`}
|
||||
style={{ borderRadius: '12px' }}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnter={onEndZoneDragEnter}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, 'end')}
|
||||
>
|
||||
<div className="text-gray-500 text-sm text-center font-medium">
|
||||
Drop here to<br />move to end
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Multi-item drag indicator */}
|
||||
{multiItemDrag && dragPosition && (
|
||||
<div
|
||||
className={styles.multiDragIndicator}
|
||||
style={{
|
||||
left: dragPosition.x,
|
||||
top: dragPosition.y,
|
||||
}}
|
||||
>
|
||||
{multiItemDrag.count} items
|
||||
</div>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default DragDropGrid;
|
||||
561
frontend/src/components/pageEditor/FileEditor.tsx
Normal file
561
frontend/src/components/pageEditor/FileEditor.tsx
Normal file
@@ -0,0 +1,561 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
|
||||
Stack, Group
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import UploadFileIcon from '@mui/icons-material/UploadFile';
|
||||
import { fileStorage } from '../../services/fileStorage';
|
||||
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
|
||||
import styles from './PageEditor.module.css';
|
||||
import FileThumbnail from './FileThumbnail';
|
||||
import BulkSelectionPanel from './BulkSelectionPanel';
|
||||
import DragDropGrid from './DragDropGrid';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
pageCount: number;
|
||||
thumbnail: string;
|
||||
size: number;
|
||||
file: File;
|
||||
splitBefore?: boolean;
|
||||
}
|
||||
|
||||
interface FileEditorProps {
|
||||
onOpenPageEditor?: (file: File) => void;
|
||||
onMergeFiles?: (files: File[]) => void;
|
||||
activeFiles?: File[];
|
||||
setActiveFiles?: (files: File[]) => void;
|
||||
preSelectedFiles?: { file: File; url: string }[];
|
||||
onClearPreSelection?: () => void;
|
||||
}
|
||||
|
||||
const FileEditor = ({
|
||||
onOpenPageEditor,
|
||||
onMergeFiles,
|
||||
activeFiles = [],
|
||||
setActiveFiles,
|
||||
preSelectedFiles = [],
|
||||
onClearPreSelection
|
||||
}: FileEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [files, setFiles] = useState<FileItem[]>([]);
|
||||
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [csvInput, setCsvInput] = useState<string>('');
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
const [draggedFile, setDraggedFile] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [multiFileDrag, setMultiFileDrag] = useState<{fileIds: string[], count: number} | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
|
||||
// Convert shared files to FileEditor format
|
||||
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
|
||||
// Generate thumbnail if not already available
|
||||
const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile);
|
||||
|
||||
return {
|
||||
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
|
||||
name: (sharedFile.file?.name || sharedFile.name || 'unknown').replace(/\.pdf$/i, ''),
|
||||
pageCount: sharedFile.pageCount || Math.floor(Math.random() * 20) + 1, // Mock for now
|
||||
thumbnail,
|
||||
size: sharedFile.file?.size || sharedFile.size || 0,
|
||||
file: sharedFile.file || sharedFile,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Convert activeFiles to FileItem format
|
||||
useEffect(() => {
|
||||
const convertActiveFiles = async () => {
|
||||
if (activeFiles.length > 0) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const convertedFiles = await Promise.all(
|
||||
activeFiles.map(async (file) => {
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
return {
|
||||
id: `file-${Date.now()}-${Math.random()}`,
|
||||
name: file.name.replace(/\.pdf$/i, ''),
|
||||
pageCount: Math.floor(Math.random() * 20) + 1, // Mock for now
|
||||
thumbnail,
|
||||
size: file.size,
|
||||
file,
|
||||
};
|
||||
})
|
||||
);
|
||||
setFiles(convertedFiles);
|
||||
} catch (err) {
|
||||
console.error('Error converting active files:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setFiles([]);
|
||||
}
|
||||
};
|
||||
|
||||
convertActiveFiles();
|
||||
}, [activeFiles]);
|
||||
|
||||
// Only load shared files when explicitly passed (not on mount)
|
||||
useEffect(() => {
|
||||
const loadSharedFiles = async () => {
|
||||
// Only load if we have pre-selected files (coming from FileManager)
|
||||
if (preSelectedFiles.length > 0) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const convertedFiles = await Promise.all(
|
||||
preSelectedFiles.map(convertToFileItem)
|
||||
);
|
||||
if (setActiveFiles) {
|
||||
const updatedActiveFiles = convertedFiles.map(fileItem => fileItem.file);
|
||||
setActiveFiles(updatedActiveFiles);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error converting pre-selected files:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSharedFiles();
|
||||
}, [preSelectedFiles, convertToFileItem]);
|
||||
|
||||
// Handle pre-selected files
|
||||
useEffect(() => {
|
||||
if (preSelectedFiles.length > 0) {
|
||||
const preSelectedIds = preSelectedFiles.map(f => f.id || f.name);
|
||||
setSelectedFiles(preSelectedIds);
|
||||
onClearPreSelection?.();
|
||||
}
|
||||
}, [preSelectedFiles, onClearPreSelection]);
|
||||
|
||||
// Process uploaded files
|
||||
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const newFiles: FileItem[] = [];
|
||||
|
||||
for (const file of uploadedFiles) {
|
||||
if (file.type !== 'application/pdf') {
|
||||
setError('Please upload only PDF files');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Generate thumbnail and get page count
|
||||
const thumbnail = await generateThumbnailForFile(file);
|
||||
|
||||
const fileItem: FileItem = {
|
||||
id: `file-${Date.now()}-${Math.random()}`,
|
||||
name: file.name.replace(/\.pdf$/i, ''),
|
||||
pageCount: Math.floor(Math.random() * 20) + 1, // Mock page count
|
||||
thumbnail,
|
||||
size: file.size,
|
||||
file,
|
||||
};
|
||||
|
||||
newFiles.push(fileItem);
|
||||
|
||||
// Store in IndexedDB
|
||||
await fileStorage.storeFile(file, thumbnail);
|
||||
}
|
||||
|
||||
if (setActiveFiles) {
|
||||
setActiveFiles(prev => [...prev, ...newFiles.map(f => f.file)]);
|
||||
}
|
||||
|
||||
setStatus(`Added ${newFiles.length} files`);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
|
||||
setError(errorMessage);
|
||||
console.error('File processing error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [setActiveFiles]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
setSelectedFiles(files.map(f => f.id));
|
||||
}, [files]);
|
||||
|
||||
const deselectAll = useCallback(() => setSelectedFiles([]), []);
|
||||
|
||||
const toggleFile = useCallback((fileId: string) => {
|
||||
setSelectedFiles(prev =>
|
||||
prev.includes(fileId)
|
||||
? prev.filter(id => id !== fileId)
|
||||
: [...prev, fileId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
setSelectionMode(prev => {
|
||||
const newMode = !prev;
|
||||
if (!newMode) {
|
||||
setSelectedFiles([]);
|
||||
setCsvInput('');
|
||||
}
|
||||
return newMode;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const parseCSVInput = useCallback((csv: string) => {
|
||||
const fileIds: string[] = [];
|
||||
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
ranges.forEach(range => {
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let i = start; i <= end && i <= files.length; i++) {
|
||||
if (i > 0) {
|
||||
const file = files[i - 1];
|
||||
if (file) fileIds.push(file.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const fileIndex = parseInt(range);
|
||||
if (fileIndex > 0 && fileIndex <= files.length) {
|
||||
const file = files[fileIndex - 1];
|
||||
if (file) fileIds.push(file.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return fileIds;
|
||||
}, [files]);
|
||||
|
||||
const updateFilesFromCSV = useCallback(() => {
|
||||
const fileIds = parseCSVInput(csvInput);
|
||||
setSelectedFiles(fileIds);
|
||||
}, [csvInput, parseCSVInput]);
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = useCallback((fileId: string) => {
|
||||
setDraggedFile(fileId);
|
||||
|
||||
if (selectionMode && selectedFiles.includes(fileId) && selectedFiles.length > 1) {
|
||||
setMultiFileDrag({
|
||||
fileIds: selectedFiles,
|
||||
count: selectedFiles.length
|
||||
});
|
||||
} else {
|
||||
setMultiFileDrag(null);
|
||||
}
|
||||
}, [selectionMode, selectedFiles]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedFile(null);
|
||||
setDropTarget(null);
|
||||
setMultiFileDrag(null);
|
||||
setDragPosition(null);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggedFile) return;
|
||||
|
||||
if (multiFileDrag) {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!elementUnderCursor) return;
|
||||
|
||||
const fileContainer = elementUnderCursor.closest('[data-file-id]');
|
||||
if (fileContainer) {
|
||||
const fileId = fileContainer.getAttribute('data-file-id');
|
||||
if (fileId && fileId !== draggedFile) {
|
||||
setDropTarget(fileId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
|
||||
if (endZone) {
|
||||
setDropTarget('end');
|
||||
return;
|
||||
}
|
||||
|
||||
setDropTarget(null);
|
||||
}, [draggedFile, multiFileDrag]);
|
||||
|
||||
const handleDragEnter = useCallback((fileId: string) => {
|
||||
if (draggedFile && fileId !== draggedFile) {
|
||||
setDropTarget(fileId);
|
||||
}
|
||||
}, [draggedFile]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
// Let dragover handle this
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetFileId: string | 'end') => {
|
||||
e.preventDefault();
|
||||
if (!draggedFile || draggedFile === targetFileId) return;
|
||||
|
||||
let targetIndex: number;
|
||||
if (targetFileId === 'end') {
|
||||
targetIndex = files.length;
|
||||
} else {
|
||||
targetIndex = files.findIndex(f => f.id === targetFileId);
|
||||
if (targetIndex === -1) return;
|
||||
}
|
||||
|
||||
const filesToMove = selectionMode && selectedFiles.includes(draggedFile)
|
||||
? selectedFiles
|
||||
: [draggedFile];
|
||||
|
||||
if (setActiveFiles) {
|
||||
// Update the local files state and sync with activeFiles
|
||||
setFiles(prev => {
|
||||
const newFiles = [...prev];
|
||||
const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean);
|
||||
|
||||
// Remove moved files
|
||||
filesToMove.forEach(id => {
|
||||
const index = newFiles.findIndex(f => f.id === id);
|
||||
if (index !== -1) newFiles.splice(index, 1);
|
||||
});
|
||||
|
||||
// Insert at target position
|
||||
newFiles.splice(targetIndex, 0, ...movedFiles);
|
||||
|
||||
// Update activeFiles with the reordered File objects
|
||||
setActiveFiles(newFiles.map(f => f.file));
|
||||
|
||||
return newFiles;
|
||||
});
|
||||
}
|
||||
|
||||
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
|
||||
handleDragEnd();
|
||||
}, [draggedFile, files, selectionMode, selectedFiles, multiFileDrag, handleDragEnd, setActiveFiles]);
|
||||
|
||||
const handleEndZoneDragEnter = useCallback(() => {
|
||||
if (draggedFile) {
|
||||
setDropTarget('end');
|
||||
}
|
||||
}, [draggedFile]);
|
||||
|
||||
// File operations
|
||||
const handleDeleteFile = useCallback((fileId: string) => {
|
||||
if (setActiveFiles) {
|
||||
// Remove from local files and sync with activeFiles
|
||||
setFiles(prev => {
|
||||
const newFiles = prev.filter(f => f.id !== fileId);
|
||||
setActiveFiles(newFiles.map(f => f.file));
|
||||
return newFiles;
|
||||
});
|
||||
}
|
||||
setSelectedFiles(prev => prev.filter(id => id !== fileId));
|
||||
}, [setActiveFiles]);
|
||||
|
||||
const handleViewFile = useCallback((fileId: string) => {
|
||||
const file = files.find(f => f.id === fileId);
|
||||
if (file && onOpenPageEditor) {
|
||||
onOpenPageEditor(file.file);
|
||||
}
|
||||
}, [files, onOpenPageEditor]);
|
||||
|
||||
const handleMergeFromHere = useCallback((fileId: string) => {
|
||||
const startIndex = files.findIndex(f => f.id === fileId);
|
||||
if (startIndex === -1) return;
|
||||
|
||||
const filesToMerge = files.slice(startIndex).map(f => f.file);
|
||||
if (onMergeFiles) {
|
||||
onMergeFiles(filesToMerge);
|
||||
}
|
||||
}, [files, onMergeFiles]);
|
||||
|
||||
const handleSplitFile = useCallback((fileId: string) => {
|
||||
const file = files.find(f => f.id === fileId);
|
||||
if (file && onOpenPageEditor) {
|
||||
onOpenPageEditor(file.file);
|
||||
}
|
||||
}, [files, onOpenPageEditor]);
|
||||
|
||||
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const convertedFiles = await Promise.all(
|
||||
selectedFiles.map(convertToFileItem)
|
||||
);
|
||||
setFiles(prev => [...prev, ...convertedFiles]);
|
||||
setStatus(`Loaded ${selectedFiles.length} files from storage`);
|
||||
} catch (err) {
|
||||
console.error('Error loading files from storage:', err);
|
||||
setError('Failed to load some files from storage');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [convertToFileItem]);
|
||||
|
||||
|
||||
return (
|
||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={loading} />
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
<Group mb="md">
|
||||
<Button
|
||||
onClick={toggleSelectionMode}
|
||||
variant={selectionMode ? "filled" : "outline"}
|
||||
color={selectionMode ? "blue" : "gray"}
|
||||
styles={{
|
||||
root: {
|
||||
transition: 'all 0.2s ease',
|
||||
...(selectionMode && {
|
||||
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectionMode ? "Exit Selection" : "Select Files"}
|
||||
</Button>
|
||||
{selectionMode && (
|
||||
<>
|
||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Load from storage and upload buttons */}
|
||||
<Button
|
||||
variant="outline"
|
||||
color="blue"
|
||||
onClick={() => setShowFilePickerModal(true)}
|
||||
>
|
||||
Load from Storage
|
||||
</Button>
|
||||
|
||||
<Dropzone
|
||||
onDrop={handleFileUpload}
|
||||
accept={["application/pdf"]}
|
||||
multiple={true}
|
||||
maxSize={2 * 1024 * 1024 * 1024}
|
||||
style={{ display: 'contents' }}
|
||||
>
|
||||
<Button variant="outline" color="green">
|
||||
Upload Files
|
||||
</Button>
|
||||
</Dropzone>
|
||||
</Group>
|
||||
|
||||
{selectionMode && (
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPages={selectedFiles}
|
||||
onUpdatePagesFromCSV={updateFilesFromCSV}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DragDropGrid
|
||||
items={files}
|
||||
selectedItems={selectedFiles}
|
||||
selectionMode={selectionMode}
|
||||
isAnimating={isAnimating}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onEndZoneDragEnter={handleEndZoneDragEnter}
|
||||
draggedItem={draggedFile}
|
||||
dropTarget={dropTarget}
|
||||
multiItemDrag={multiFileDrag}
|
||||
dragPosition={dragPosition}
|
||||
renderItem={(file, index, refs) => (
|
||||
<FileThumbnail
|
||||
file={file}
|
||||
index={index}
|
||||
totalFiles={files.length}
|
||||
selectedFiles={selectedFiles}
|
||||
selectionMode={selectionMode}
|
||||
draggedFile={draggedFile}
|
||||
dropTarget={dropTarget}
|
||||
isAnimating={isAnimating}
|
||||
fileRefs={refs}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onToggleFile={toggleFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onViewFile={handleViewFile}
|
||||
onMergeFromHere={handleMergeFromHere}
|
||||
onSplitFile={handleSplitFile}
|
||||
onSetStatus={setStatus}
|
||||
/>
|
||||
)}
|
||||
renderSplitMarker={(file, index) => (
|
||||
<div
|
||||
style={{
|
||||
width: '2px',
|
||||
height: '24rem',
|
||||
borderLeft: '2px dashed #3b82f6',
|
||||
backgroundColor: 'transparent',
|
||||
marginLeft: '-0.75rem',
|
||||
marginRight: '-0.75rem',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* File Picker Modal */}
|
||||
<FilePickerModal
|
||||
opened={showFilePickerModal}
|
||||
onClose={() => setShowFilePickerModal(false)}
|
||||
storedFiles={[]} // FileEditor doesn't have access to stored files, needs to be passed from parent
|
||||
onSelectFiles={handleLoadFromStorage}
|
||||
allowMultiple={true}
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
color="red"
|
||||
mt="md"
|
||||
onClose={() => setError(null)}
|
||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileEditor;
|
||||
327
frontend/src/components/pageEditor/FileThumbnail.tsx
Normal file
327
frontend/src/components/pageEditor/FileThumbnail.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
import React from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import MergeIcon from '@mui/icons-material/Merge';
|
||||
import SplitscreenIcon from '@mui/icons-material/Splitscreen';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import styles from './PageEditor.module.css';
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
name: string;
|
||||
pageCount: number;
|
||||
thumbnail: string;
|
||||
size: number;
|
||||
splitBefore?: boolean;
|
||||
}
|
||||
|
||||
interface FileThumbnailProps {
|
||||
file: FileItem;
|
||||
index: number;
|
||||
totalFiles: number;
|
||||
selectedFiles: string[];
|
||||
selectionMode: boolean;
|
||||
draggedFile: string | null;
|
||||
dropTarget: string | null;
|
||||
isAnimating: boolean;
|
||||
fileRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
onDragStart: (fileId: string) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (fileId: string) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent, fileId: string) => void;
|
||||
onToggleFile: (fileId: string) => void;
|
||||
onDeleteFile: (fileId: string) => void;
|
||||
onViewFile: (fileId: string) => void;
|
||||
onMergeFromHere: (fileId: string) => void;
|
||||
onSplitFile: (fileId: string) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
}
|
||||
|
||||
const FileThumbnail = ({
|
||||
file,
|
||||
index,
|
||||
totalFiles,
|
||||
selectedFiles,
|
||||
selectionMode,
|
||||
draggedFile,
|
||||
dropTarget,
|
||||
isAnimating,
|
||||
fileRefs,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onViewFile,
|
||||
onMergeFromHere,
|
||||
onSplitFile,
|
||||
onSetStatus,
|
||||
}: FileThumbnailProps) => {
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={(el) => {
|
||||
if (el) {
|
||||
fileRefs.current.set(file.id, el);
|
||||
} else {
|
||||
fileRefs.current.delete(file.id);
|
||||
}
|
||||
}}
|
||||
data-file-id={file.id}
|
||||
className={`
|
||||
${styles.pageContainer}
|
||||
!rounded-lg
|
||||
cursor-grab
|
||||
select-none
|
||||
w-[20rem]
|
||||
h-[24rem]
|
||||
flex flex-col items-center justify-center
|
||||
flex-shrink-0
|
||||
shadow-sm
|
||||
hover:shadow-md
|
||||
transition-all
|
||||
relative
|
||||
${selectionMode
|
||||
? 'bg-white hover:bg-gray-50'
|
||||
: 'bg-white hover:bg-gray-50'}
|
||||
${draggedFile === file.id ? 'opacity-50 scale-95' : ''}
|
||||
`}
|
||||
style={{
|
||||
transform: (() => {
|
||||
if (!isAnimating && draggedFile && file.id !== draggedFile && dropTarget === file.id) {
|
||||
return 'translateX(20px)';
|
||||
}
|
||||
return 'translateX(0)';
|
||||
})(),
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||
}}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(file.id)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnter={() => onDragEnter(file.id)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, file.id)}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div
|
||||
className={styles.checkboxContainer}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 4,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedFiles.includes(file.id)}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
onToggleFile(file.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File content area */}
|
||||
<div className="file-container w-[90%] h-[80%] relative">
|
||||
{/* Stacked file effect - multiple shadows to simulate pages */}
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
boxShadow: '2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={file.thumbnail}
|
||||
alt={file.name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Page count badge */}
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="filled"
|
||||
color="blue"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
left: 8,
|
||||
zIndex: 3,
|
||||
}}
|
||||
>
|
||||
{file.pageCount} pages
|
||||
</Badge>
|
||||
|
||||
{/* File name overlay */}
|
||||
<Text
|
||||
className={styles.pageNumber}
|
||||
size="xs"
|
||||
fw={500}
|
||||
c="white"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 5,
|
||||
left: 5,
|
||||
right: 5,
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: '4px 6px',
|
||||
borderRadius: 4,
|
||||
zIndex: 2,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{file.name}
|
||||
</Text>
|
||||
|
||||
{/* Hover controls */}
|
||||
<div
|
||||
className={styles.pageHoverControls}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: '8px 12px',
|
||||
borderRadius: 20,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
zIndex: 3,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
<Tooltip label="View File">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onViewFile(file.id);
|
||||
onSetStatus(`Opened ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<VisibilityIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Merge from here">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onMergeFromHere(file.id);
|
||||
onSetStatus(`Starting merge from ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<MergeIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Split File">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onSplitFile(file.id);
|
||||
onSetStatus(`Opening ${file.name} in page editor`);
|
||||
}}
|
||||
>
|
||||
<SplitscreenIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Delete File">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="red"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteFile(file.id);
|
||||
onSetStatus(`Deleted ${file.name}`);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<DragIndicatorIcon
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
fontSize: 16,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File info */}
|
||||
<div className="w-full px-4 py-2 text-center">
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{formatFileSize(file.size)}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FileThumbnail;
|
||||
63
frontend/src/components/pageEditor/PageEditor.module.css
Normal file
63
frontend/src/components/pageEditor/PageEditor.module.css
Normal file
@@ -0,0 +1,63 @@
|
||||
/* Page container hover effects */
|
||||
.pageContainer {
|
||||
transition: transform 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.pageContainer:hover {
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.pageContainer:hover .pageNumber {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.pageContainer:hover .pageHoverControls {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
/* Checkbox container - prevent transform inheritance */
|
||||
.checkboxContainer {
|
||||
transform: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
|
||||
/* Page movement animations */
|
||||
.pageMoveAnimation {
|
||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.pageMoving {
|
||||
z-index: 10;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
/* Multi-page drag indicator */
|
||||
.multiDragIndicator {
|
||||
position: fixed;
|
||||
background: rgba(59, 130, 246, 0.9);
|
||||
color: white;
|
||||
padding: 8px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
pointer-events: none;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
966
frontend/src/components/pageEditor/PageEditor.tsx
Normal file
966
frontend/src/components/pageEditor/PageEditor.tsx
Normal file
@@ -0,0 +1,966 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from "react";
|
||||
import {
|
||||
Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon,
|
||||
Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container,
|
||||
Stack, Group, Paper, SimpleGrid
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import UploadFileIcon from "@mui/icons-material/UploadFile";
|
||||
import { usePDFProcessor } from "../../hooks/usePDFProcessor";
|
||||
import { PDFDocument, PDFPage } from "../../types/pageEditor";
|
||||
import { fileStorage } from "../../services/fileStorage";
|
||||
import { generateThumbnailForFile } from "../../utils/thumbnailUtils";
|
||||
import { useUndoRedo } from "../../hooks/useUndoRedo";
|
||||
import {
|
||||
RotatePagesCommand,
|
||||
DeletePagesCommand,
|
||||
ReorderPageCommand,
|
||||
MovePagesCommand,
|
||||
ToggleSplitCommand
|
||||
} from "../../commands/pageCommands";
|
||||
import { pdfExportService } from "../../services/pdfExportService";
|
||||
import styles from './pageEditor.module.css';
|
||||
import PageThumbnail from './PageThumbnail';
|
||||
import BulkSelectionPanel from './BulkSelectionPanel';
|
||||
import DragDropGrid from './DragDropGrid';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import FileUploadSelector from '../shared/FileUploadSelector';
|
||||
|
||||
export interface PageEditorProps {
|
||||
activeFiles: File[];
|
||||
setActiveFiles: (files: File[]) => void;
|
||||
downloadUrl?: string | null;
|
||||
setDownloadUrl?: (url: string | null) => void;
|
||||
sharedFiles?: any[]; // For FileUploadSelector when no files loaded
|
||||
|
||||
// Optional callbacks to expose internal functions for PageEditorControls
|
||||
onFunctionsReady?: (functions: {
|
||||
handleUndo: () => void;
|
||||
handleRedo: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
handleRotate: (direction: 'left' | 'right') => void;
|
||||
handleDelete: () => void;
|
||||
handleSplit: () => void;
|
||||
showExportPreview: (selectedOnly: boolean) => void;
|
||||
onExportSelected: () => void;
|
||||
onExportAll: () => void;
|
||||
exportLoading: boolean;
|
||||
selectionMode: boolean;
|
||||
selectedPages: string[];
|
||||
closePdf: () => void;
|
||||
}) => void;
|
||||
}
|
||||
|
||||
const PageEditor = ({
|
||||
activeFiles,
|
||||
setActiveFiles,
|
||||
downloadUrl,
|
||||
setDownloadUrl,
|
||||
sharedFiles = [],
|
||||
onFunctionsReady,
|
||||
}: PageEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { processPDFFile, loading: pdfLoading } = usePDFProcessor();
|
||||
|
||||
// Single merged document state
|
||||
const [mergedPdfDocument, setMergedPdfDocument] = useState<PDFDocument | null>(null);
|
||||
const [processedFiles, setProcessedFiles] = useState<Map<string, PDFDocument>>(new Map());
|
||||
const [filename, setFilename] = useState<string>("");
|
||||
|
||||
// Page editor state
|
||||
const [selectedPages, setSelectedPages] = useState<string[]>([]);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [csvInput, setCsvInput] = useState<string>("");
|
||||
const [selectionMode, setSelectionMode] = useState(false);
|
||||
|
||||
// Drag and drop state
|
||||
const [draggedPage, setDraggedPage] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [multiPageDrag, setMultiPageDrag] = useState<{pageIds: string[], count: number} | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
|
||||
|
||||
// Export state
|
||||
const [exportLoading, setExportLoading] = useState(false);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
|
||||
|
||||
// Animation state
|
||||
const [movingPage, setMovingPage] = useState<string | null>(null);
|
||||
const [pagePositions, setPagePositions] = useState<Map<string, { x: number; y: number }>>(new Map());
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const pageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const fileInputRef = useRef<() => void>(null);
|
||||
|
||||
// Undo/Redo system
|
||||
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
|
||||
|
||||
// Process uploaded file
|
||||
const handleFileUpload = useCallback(async (uploadedFile: File | any) => {
|
||||
if (!uploadedFile) {
|
||||
setError('No file provided');
|
||||
return;
|
||||
}
|
||||
|
||||
let fileToProcess: File;
|
||||
|
||||
// Handle FileWithUrl objects from storage
|
||||
if (uploadedFile.storedInIndexedDB && uploadedFile.arrayBuffer) {
|
||||
try {
|
||||
console.log('Converting FileWithUrl to File:', uploadedFile.name);
|
||||
const arrayBuffer = await uploadedFile.arrayBuffer();
|
||||
const blob = new Blob([arrayBuffer], { type: uploadedFile.type || 'application/pdf' });
|
||||
fileToProcess = new File([blob], uploadedFile.name, {
|
||||
type: uploadedFile.type || 'application/pdf',
|
||||
lastModified: uploadedFile.lastModified || Date.now()
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error converting FileWithUrl:', error);
|
||||
setError('Unable to load file from storage');
|
||||
return;
|
||||
}
|
||||
} else if (uploadedFile instanceof File) {
|
||||
fileToProcess = uploadedFile;
|
||||
} else {
|
||||
setError('Invalid file object');
|
||||
console.error('handleFileUpload received unsupported object:', uploadedFile);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileToProcess.type !== 'application/pdf') {
|
||||
setError('Please upload a valid PDF file');
|
||||
return;
|
||||
}
|
||||
|
||||
const fileKey = `${fileToProcess.name}-${fileToProcess.size}`;
|
||||
|
||||
// Skip processing if already processed
|
||||
if (processedFiles.has(fileKey)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const document = await processPDFFile(fileToProcess);
|
||||
|
||||
// Store processed document
|
||||
setProcessedFiles(prev => new Map(prev).set(fileKey, document));
|
||||
setFilename(fileToProcess.name.replace(/\.pdf$/i, ''));
|
||||
setSelectedPages([]);
|
||||
|
||||
|
||||
if (document.pages.length > 0) {
|
||||
// Only store if it's a new file (not from storage)
|
||||
if (!uploadedFile.storedInIndexedDB) {
|
||||
const thumbnail = await generateThumbnailForFile(fileToProcess);
|
||||
await fileStorage.storeFile(fileToProcess, thumbnail);
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(`PDF loaded successfully with ${document.totalPages} pages`);
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Failed to process PDF';
|
||||
setError(errorMessage);
|
||||
console.error('PDF processing error:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [processPDFFile, activeFiles, setActiveFiles, processedFiles]);
|
||||
|
||||
// Process multiple uploaded files - just add them to activeFiles like FileManager does
|
||||
const handleMultipleFileUpload = useCallback((uploadedFiles: File[]) => {
|
||||
if (!uploadedFiles || uploadedFiles.length === 0) {
|
||||
setError('No files provided');
|
||||
return;
|
||||
}
|
||||
|
||||
// Simply set the activeFiles to the selected files (same as FileManager approach)
|
||||
setActiveFiles(uploadedFiles);
|
||||
}, []);
|
||||
|
||||
// Merge multiple PDF documents into one
|
||||
const mergeAllPDFs = useCallback(() => {
|
||||
if (activeFiles.length === 0) {
|
||||
setMergedPdfDocument(null);
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeFiles.length === 1) {
|
||||
// Single file - use it directly
|
||||
const fileKey = `${activeFiles[0].name}-${activeFiles[0].size}`;
|
||||
const pdfDoc = processedFiles.get(fileKey);
|
||||
if (pdfDoc) {
|
||||
setMergedPdfDocument(pdfDoc);
|
||||
setFilename(activeFiles[0].name.replace(/\.pdf$/i, ''));
|
||||
}
|
||||
} else {
|
||||
// Multiple files - merge them
|
||||
const allPages: PDFPage[] = [];
|
||||
let totalPages = 0;
|
||||
const filenames: string[] = [];
|
||||
|
||||
activeFiles.forEach((file, fileIndex) => {
|
||||
const fileKey = `${file.name}-${file.size}`;
|
||||
const pdfDoc = processedFiles.get(fileKey);
|
||||
if (pdfDoc) {
|
||||
filenames.push(file.name.replace(/\.pdf$/i, ''));
|
||||
pdfDoc.pages.forEach((page, pageIndex) => {
|
||||
// Create new page with updated IDs and page numbers for merged document
|
||||
const newPage: PDFPage = {
|
||||
...page,
|
||||
id: `${fileIndex}-${page.id}`, // Unique ID across all files
|
||||
pageNumber: totalPages + pageIndex + 1,
|
||||
sourceFile: file.name // Track which file this page came from
|
||||
};
|
||||
allPages.push(newPage);
|
||||
});
|
||||
totalPages += pdfDoc.pages.length;
|
||||
}
|
||||
});
|
||||
|
||||
const mergedDocument: PDFDocument = {
|
||||
pages: allPages,
|
||||
totalPages: totalPages,
|
||||
title: filenames.join(' + '),
|
||||
metadata: {
|
||||
title: filenames.join(' + '),
|
||||
createdAt: new Date().toISOString(),
|
||||
modifiedAt: new Date().toISOString(),
|
||||
}
|
||||
};
|
||||
|
||||
setMergedPdfDocument(mergedDocument);
|
||||
setFilename(filenames.join('_'));
|
||||
}
|
||||
}, [activeFiles, processedFiles]);
|
||||
|
||||
// Auto-process files from activeFiles
|
||||
useEffect(() => {
|
||||
console.log('Auto-processing effect triggered:', {
|
||||
activeFilesCount: activeFiles.length,
|
||||
processedFilesCount: processedFiles.size,
|
||||
activeFileNames: activeFiles.map(f => f.name)
|
||||
});
|
||||
|
||||
activeFiles.forEach(file => {
|
||||
const fileKey = `${file.name}-${file.size}`;
|
||||
console.log(`Checking file ${file.name}: processed =`, processedFiles.has(fileKey));
|
||||
if (!processedFiles.has(fileKey)) {
|
||||
console.log('Processing file:', file.name);
|
||||
handleFileUpload(file);
|
||||
}
|
||||
});
|
||||
}, [activeFiles, processedFiles, handleFileUpload]);
|
||||
|
||||
// Merge multiple PDF documents into one when all files are processed
|
||||
useEffect(() => {
|
||||
if (activeFiles.length > 0) {
|
||||
const allProcessed = activeFiles.every(file => {
|
||||
const fileKey = `${file.name}-${file.size}`;
|
||||
return processedFiles.has(fileKey);
|
||||
});
|
||||
|
||||
if (allProcessed && activeFiles.length > 0) {
|
||||
mergeAllPDFs();
|
||||
}
|
||||
}
|
||||
}, [activeFiles, processedFiles, mergeAllPDFs]);
|
||||
|
||||
// Clear selections when files change
|
||||
useEffect(() => {
|
||||
setSelectedPages([]);
|
||||
setCsvInput("");
|
||||
setSelectionMode(false);
|
||||
}, [activeFiles]);
|
||||
|
||||
// Global drag cleanup to handle drops outside valid areas
|
||||
useEffect(() => {
|
||||
const handleGlobalDragEnd = () => {
|
||||
// Clean up drag state when drag operation ends anywhere
|
||||
setDraggedPage(null);
|
||||
setDropTarget(null);
|
||||
setMultiPageDrag(null);
|
||||
setDragPosition(null);
|
||||
};
|
||||
|
||||
const handleGlobalDrop = (e: DragEvent) => {
|
||||
// Prevent default to avoid browser navigation on invalid drops
|
||||
e.preventDefault();
|
||||
};
|
||||
|
||||
if (draggedPage) {
|
||||
document.addEventListener('dragend', handleGlobalDragEnd);
|
||||
document.addEventListener('drop', handleGlobalDrop);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('dragend', handleGlobalDragEnd);
|
||||
document.removeEventListener('drop', handleGlobalDrop);
|
||||
};
|
||||
}, [draggedPage]);
|
||||
|
||||
const selectAll = useCallback(() => {
|
||||
if (mergedPdfDocument) {
|
||||
setSelectedPages(mergedPdfDocument.pages.map(p => p.id));
|
||||
}
|
||||
}, [mergedPdfDocument]);
|
||||
|
||||
const deselectAll = useCallback(() => setSelectedPages([]), []);
|
||||
|
||||
const togglePage = useCallback((pageId: string) => {
|
||||
setSelectedPages(prev =>
|
||||
prev.includes(pageId)
|
||||
? prev.filter(id => id !== pageId)
|
||||
: [...prev, pageId]
|
||||
);
|
||||
}, []);
|
||||
|
||||
const toggleSelectionMode = useCallback(() => {
|
||||
setSelectionMode(prev => {
|
||||
const newMode = !prev;
|
||||
if (!newMode) {
|
||||
// Clear selections when exiting selection mode
|
||||
setSelectedPages([]);
|
||||
setCsvInput("");
|
||||
}
|
||||
return newMode;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const parseCSVInput = useCallback((csv: string) => {
|
||||
if (!mergedPdfDocument) return [];
|
||||
|
||||
const pageIds: string[] = [];
|
||||
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
|
||||
|
||||
ranges.forEach(range => {
|
||||
if (range.includes('-')) {
|
||||
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||||
for (let i = start; i <= end && i <= mergedPdfDocument.totalPages; i++) {
|
||||
if (i > 0) {
|
||||
const page = mergedPdfDocument.pages.find(p => p.pageNumber === i);
|
||||
if (page) pageIds.push(page.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const pageNum = parseInt(range);
|
||||
if (pageNum > 0 && pageNum <= mergedPdfDocument.totalPages) {
|
||||
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
|
||||
if (page) pageIds.push(page.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return pageIds;
|
||||
}, [mergedPdfDocument]);
|
||||
|
||||
const updatePagesFromCSV = useCallback(() => {
|
||||
const pageIds = parseCSVInput(csvInput);
|
||||
setSelectedPages(pageIds);
|
||||
}, [csvInput, parseCSVInput]);
|
||||
|
||||
const handleDragStart = useCallback((pageId: string) => {
|
||||
setDraggedPage(pageId);
|
||||
|
||||
// Check if this is a multi-page drag in selection mode
|
||||
if (selectionMode && selectedPages.includes(pageId) && selectedPages.length > 1) {
|
||||
setMultiPageDrag({
|
||||
pageIds: selectedPages,
|
||||
count: selectedPages.length
|
||||
});
|
||||
} else {
|
||||
setMultiPageDrag(null);
|
||||
}
|
||||
}, [selectionMode, selectedPages]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
// Clean up drag state regardless of where the drop happened
|
||||
setDraggedPage(null);
|
||||
setDropTarget(null);
|
||||
setMultiPageDrag(null);
|
||||
setDragPosition(null);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggedPage) return;
|
||||
|
||||
// Update drag position for multi-page indicator
|
||||
if (multiPageDrag) {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
// Get the element under the mouse cursor
|
||||
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!elementUnderCursor) return;
|
||||
|
||||
// Find the closest page container
|
||||
const pageContainer = elementUnderCursor.closest('[data-page-id]');
|
||||
if (pageContainer) {
|
||||
const pageId = pageContainer.getAttribute('data-page-id');
|
||||
if (pageId && pageId !== draggedPage) {
|
||||
setDropTarget(pageId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if over the end zone
|
||||
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
|
||||
if (endZone) {
|
||||
setDropTarget('end');
|
||||
return;
|
||||
}
|
||||
|
||||
// If not over any valid drop target, clear it
|
||||
setDropTarget(null);
|
||||
}, [draggedPage, multiPageDrag]);
|
||||
|
||||
const handleDragEnter = useCallback((pageId: string) => {
|
||||
if (draggedPage && pageId !== draggedPage) {
|
||||
setDropTarget(pageId);
|
||||
}
|
||||
}, [draggedPage]);
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
// Don't clear drop target on drag leave - let dragover handle it
|
||||
}, []);
|
||||
|
||||
// Create setPdfDocument wrapper for merged document
|
||||
const setPdfDocument = useCallback((updatedDoc: PDFDocument) => {
|
||||
setMergedPdfDocument(updatedDoc);
|
||||
// Return the updated document for immediate use in animations
|
||||
return updatedDoc;
|
||||
}, []);
|
||||
|
||||
const animateReorder = useCallback((pageId: string, targetIndex: number) => {
|
||||
if (!mergedPdfDocument || isAnimating) return;
|
||||
|
||||
|
||||
// In selection mode, if the dragged page is selected, move all selected pages
|
||||
const pagesToMove = selectionMode && selectedPages.includes(pageId)
|
||||
? selectedPages
|
||||
: [pageId];
|
||||
|
||||
const originalIndex = mergedPdfDocument.pages.findIndex(p => p.id === pageId);
|
||||
if (originalIndex === -1 || originalIndex === targetIndex) return;
|
||||
|
||||
setIsAnimating(true);
|
||||
|
||||
// Get current positions of all pages by querying DOM directly
|
||||
const currentPositions = new Map<string, { x: number; y: number }>();
|
||||
const allCurrentElements = Array.from(document.querySelectorAll('[data-page-id]'));
|
||||
|
||||
|
||||
// Capture positions from actual DOM elements
|
||||
allCurrentElements.forEach((element) => {
|
||||
const pageId = element.getAttribute('data-page-id');
|
||||
if (pageId) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
currentPositions.set(pageId, { x: rect.left, y: rect.top });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Execute the reorder - for multi-page, we use a different command
|
||||
if (pagesToMove.length > 1) {
|
||||
// Multi-page move - use MovePagesCommand
|
||||
const command = new MovePagesCommand(mergedPdfDocument, setPdfDocument, pagesToMove, targetIndex);
|
||||
executeCommand(command);
|
||||
} else {
|
||||
// Single page move
|
||||
const command = new ReorderPageCommand(mergedPdfDocument, setPdfDocument, pageId, targetIndex);
|
||||
executeCommand(command);
|
||||
}
|
||||
|
||||
// Wait for state update and DOM to update, then get new positions and animate
|
||||
setTimeout(() => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const newPositions = new Map<string, { x: number; y: number }>();
|
||||
|
||||
// Re-get all page elements after state update
|
||||
const allPageElements = Array.from(document.querySelectorAll('[data-page-id]'));
|
||||
|
||||
allPageElements.forEach((element) => {
|
||||
const pageId = element.getAttribute('data-page-id');
|
||||
if (pageId) {
|
||||
const rect = element.getBoundingClientRect();
|
||||
newPositions.set(pageId, { x: rect.left, y: rect.top });
|
||||
}
|
||||
});
|
||||
|
||||
let animationCount = 0;
|
||||
|
||||
// Calculate and apply animations using DOM elements directly
|
||||
allPageElements.forEach((element) => {
|
||||
const pageId = element.getAttribute('data-page-id');
|
||||
if (!pageId) return;
|
||||
|
||||
const currentPos = currentPositions.get(pageId);
|
||||
const newPos = newPositions.get(pageId);
|
||||
|
||||
if (element && currentPos && newPos) {
|
||||
const deltaX = currentPos.x - newPos.x;
|
||||
const deltaY = currentPos.y - newPos.y;
|
||||
|
||||
|
||||
if (Math.abs(deltaX) > 1 || Math.abs(deltaY) > 1) {
|
||||
animationCount++;
|
||||
const htmlElement = element as HTMLElement;
|
||||
// Apply initial transform (from new position back to old position)
|
||||
htmlElement.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
|
||||
htmlElement.style.transition = 'none';
|
||||
|
||||
// Force reflow
|
||||
htmlElement.offsetHeight;
|
||||
|
||||
// Animate to final position
|
||||
htmlElement.style.transition = 'transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)';
|
||||
htmlElement.style.transform = 'translate(0px, 0px)';
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Clean up after animation
|
||||
setTimeout(() => {
|
||||
const elementsToCleanup = Array.from(document.querySelectorAll('[data-page-id]'));
|
||||
elementsToCleanup.forEach((element) => {
|
||||
const htmlElement = element as HTMLElement;
|
||||
htmlElement.style.transform = '';
|
||||
htmlElement.style.transition = '';
|
||||
});
|
||||
setIsAnimating(false);
|
||||
}, 400);
|
||||
});
|
||||
});
|
||||
}, 10); // Small delay to allow state update
|
||||
}, [mergedPdfDocument, isAnimating, executeCommand, selectionMode, selectedPages, setPdfDocument]);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetPageId: string | 'end') => {
|
||||
e.preventDefault();
|
||||
if (!draggedPage || !mergedPdfDocument || draggedPage === targetPageId) return;
|
||||
|
||||
let targetIndex: number;
|
||||
if (targetPageId === 'end') {
|
||||
targetIndex = mergedPdfDocument.pages.length;
|
||||
} else {
|
||||
targetIndex = mergedPdfDocument.pages.findIndex(p => p.id === targetPageId);
|
||||
if (targetIndex === -1) return;
|
||||
}
|
||||
|
||||
animateReorder(draggedPage, targetIndex);
|
||||
|
||||
setDraggedPage(null);
|
||||
setDropTarget(null);
|
||||
setMultiPageDrag(null);
|
||||
setDragPosition(null);
|
||||
|
||||
const moveCount = multiPageDrag ? multiPageDrag.count : 1;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`);
|
||||
}, [draggedPage, mergedPdfDocument, animateReorder, multiPageDrag]);
|
||||
|
||||
const handleEndZoneDragEnter = useCallback(() => {
|
||||
if (draggedPage) {
|
||||
setDropTarget('end');
|
||||
}
|
||||
}, [draggedPage]);
|
||||
|
||||
const handleRotate = useCallback((direction: 'left' | 'right') => {
|
||||
if (!mergedPdfDocument) return;
|
||||
|
||||
const rotation = direction === 'left' ? -90 : 90;
|
||||
const pagesToRotate = selectionMode
|
||||
? selectedPages
|
||||
: mergedPdfDocument.pages.map(p => p.id);
|
||||
|
||||
if (selectionMode && selectedPages.length === 0) return;
|
||||
|
||||
const command = new RotatePagesCommand(
|
||||
mergedPdfDocument,
|
||||
setPdfDocument,
|
||||
pagesToRotate,
|
||||
rotation
|
||||
);
|
||||
|
||||
executeCommand(command);
|
||||
const pageCount = selectionMode ? selectedPages.length : mergedPdfDocument.pages.length;
|
||||
setStatus(`Rotated ${pageCount} pages ${direction}`);
|
||||
}, [mergedPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!mergedPdfDocument) return;
|
||||
|
||||
const pagesToDelete = selectionMode
|
||||
? selectedPages
|
||||
: mergedPdfDocument.pages.map(p => p.id);
|
||||
|
||||
if (selectionMode && selectedPages.length === 0) return;
|
||||
|
||||
const command = new DeletePagesCommand(
|
||||
mergedPdfDocument,
|
||||
setPdfDocument,
|
||||
pagesToDelete
|
||||
);
|
||||
|
||||
executeCommand(command);
|
||||
if (selectionMode) {
|
||||
setSelectedPages([]);
|
||||
}
|
||||
const pageCount = selectionMode ? selectedPages.length : mergedPdfDocument.pages.length;
|
||||
setStatus(`Deleted ${pageCount} pages`);
|
||||
}, [mergedPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]);
|
||||
|
||||
const handleSplit = useCallback(() => {
|
||||
if (!mergedPdfDocument) return;
|
||||
|
||||
const pagesToSplit = selectionMode
|
||||
? selectedPages
|
||||
: mergedPdfDocument.pages.map(p => p.id);
|
||||
|
||||
if (selectionMode && selectedPages.length === 0) return;
|
||||
|
||||
const command = new ToggleSplitCommand(
|
||||
mergedPdfDocument,
|
||||
setPdfDocument,
|
||||
pagesToSplit
|
||||
);
|
||||
|
||||
executeCommand(command);
|
||||
const pageCount = selectionMode ? selectedPages.length : mergedPdfDocument.pages.length;
|
||||
setStatus(`Split markers toggled for ${pageCount} pages`);
|
||||
}, [mergedPdfDocument, selectedPages, selectionMode, executeCommand, setPdfDocument]);
|
||||
|
||||
const showExportPreview = useCallback((selectedOnly: boolean = false) => {
|
||||
if (!mergedPdfDocument) return;
|
||||
|
||||
const exportPageIds = selectedOnly ? selectedPages : [];
|
||||
const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly);
|
||||
setExportPreview(preview);
|
||||
setShowExportModal(true);
|
||||
}, [mergedPdfDocument, selectedPages]);
|
||||
|
||||
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
|
||||
if (!mergedPdfDocument) return;
|
||||
|
||||
setExportLoading(true);
|
||||
try {
|
||||
const exportPageIds = selectedOnly ? selectedPages : [];
|
||||
const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly);
|
||||
if (errors.length > 0) {
|
||||
setError(errors.join(', '));
|
||||
return;
|
||||
}
|
||||
|
||||
const hasSplitMarkers = mergedPdfDocument.pages.some(page => page.splitBefore);
|
||||
|
||||
if (hasSplitMarkers) {
|
||||
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, {
|
||||
selectedOnly,
|
||||
filename,
|
||||
splitDocuments: true
|
||||
}) as { blobs: Blob[]; filenames: string[] };
|
||||
|
||||
result.blobs.forEach((blob, index) => {
|
||||
setTimeout(() => {
|
||||
pdfExportService.downloadFile(blob, result.filenames[index]);
|
||||
}, index * 500);
|
||||
});
|
||||
|
||||
setStatus(`Exported ${result.blobs.length} split documents`);
|
||||
} else {
|
||||
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, {
|
||||
selectedOnly,
|
||||
filename
|
||||
}) as { blob: Blob; filename: string };
|
||||
|
||||
pdfExportService.downloadFile(result.blob, result.filename);
|
||||
setStatus('PDF exported successfully');
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Export failed';
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setExportLoading(false);
|
||||
}
|
||||
}, [mergedPdfDocument, selectedPages, filename]);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
if (undo()) {
|
||||
setStatus('Operation undone');
|
||||
}
|
||||
}, [undo]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
if (redo()) {
|
||||
setStatus('Operation redone');
|
||||
}
|
||||
}, [redo]);
|
||||
|
||||
const closePdf = useCallback(() => {
|
||||
setActiveFiles([]);
|
||||
setProcessedFiles(new Map());
|
||||
setMergedPdfDocument(null);
|
||||
setSelectedPages([]);
|
||||
}, [setActiveFiles]);
|
||||
|
||||
// PageEditorControls needs onExportSelected and onExportAll
|
||||
const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]);
|
||||
const onExportAll = useCallback(() => showExportPreview(false), [showExportPreview]);
|
||||
|
||||
// Expose functions to parent component for PageEditorControls
|
||||
useEffect(() => {
|
||||
if (onFunctionsReady) {
|
||||
onFunctionsReady({
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
handleRotate,
|
||||
handleDelete,
|
||||
handleSplit,
|
||||
showExportPreview,
|
||||
onExportSelected,
|
||||
onExportAll,
|
||||
exportLoading,
|
||||
selectionMode,
|
||||
selectedPages,
|
||||
closePdf,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
onFunctionsReady,
|
||||
handleUndo,
|
||||
handleRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
handleRotate,
|
||||
handleDelete,
|
||||
handleSplit,
|
||||
showExportPreview,
|
||||
onExportSelected,
|
||||
onExportAll,
|
||||
exportLoading,
|
||||
selectionMode,
|
||||
selectedPages,
|
||||
closePdf
|
||||
]);
|
||||
|
||||
if (!mergedPdfDocument) {
|
||||
return (
|
||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={loading || pdfLoading} />
|
||||
|
||||
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<FileUploadSelector
|
||||
title="Select PDFs to edit"
|
||||
subtitle="Choose files from storage or upload PDFs - multiple files will be merged"
|
||||
sharedFiles={sharedFiles}
|
||||
onFilesSelect={handleMultipleFileUpload}
|
||||
accept={["application/pdf"]}
|
||||
loading={loading || pdfLoading}
|
||||
/>
|
||||
</Container>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
|
||||
<LoadingOverlay visible={loading || pdfLoading} />
|
||||
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
<Group mb="md">
|
||||
<TextInput
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
placeholder="Enter filename"
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
<Button
|
||||
onClick={toggleSelectionMode}
|
||||
variant={selectionMode ? "filled" : "outline"}
|
||||
color={selectionMode ? "blue" : "gray"}
|
||||
styles={{
|
||||
root: {
|
||||
transition: 'all 0.2s ease',
|
||||
...(selectionMode && {
|
||||
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectionMode ? "Exit Selection" : "Select Pages"}
|
||||
</Button>
|
||||
{selectionMode && (
|
||||
<>
|
||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{selectionMode && (
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPages={selectedPages}
|
||||
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DragDropGrid
|
||||
items={mergedPdfDocument.pages}
|
||||
selectedItems={selectedPages}
|
||||
selectionMode={selectionMode}
|
||||
isAnimating={isAnimating}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onEndZoneDragEnter={handleEndZoneDragEnter}
|
||||
draggedItem={draggedPage}
|
||||
dropTarget={dropTarget}
|
||||
multiItemDrag={multiPageDrag}
|
||||
dragPosition={dragPosition}
|
||||
renderItem={(page, index, refs) => (
|
||||
<PageThumbnail
|
||||
page={page}
|
||||
index={index}
|
||||
totalPages={mergedPdfDocument.pages.length}
|
||||
selectedPages={selectedPages}
|
||||
selectionMode={selectionMode}
|
||||
draggedPage={draggedPage}
|
||||
dropTarget={dropTarget}
|
||||
movingPage={movingPage}
|
||||
isAnimating={isAnimating}
|
||||
pageRefs={refs}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onTogglePage={togglePage}
|
||||
onAnimateReorder={animateReorder}
|
||||
onExecuteCommand={executeCommand}
|
||||
onSetStatus={setStatus}
|
||||
onSetMovingPage={setMovingPage}
|
||||
RotatePagesCommand={RotatePagesCommand}
|
||||
DeletePagesCommand={DeletePagesCommand}
|
||||
ToggleSplitCommand={ToggleSplitCommand}
|
||||
pdfDocument={mergedPdfDocument}
|
||||
setPdfDocument={setPdfDocument}
|
||||
/>
|
||||
)}
|
||||
renderSplitMarker={(page, index) => (
|
||||
<div
|
||||
style={{
|
||||
width: '2px',
|
||||
height: '20rem',
|
||||
borderLeft: '2px dashed #3b82f6',
|
||||
backgroundColor: 'transparent',
|
||||
marginLeft: '-0.75rem',
|
||||
marginRight: '-0.75rem',
|
||||
flexShrink: 0
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
|
||||
|
||||
</Box>
|
||||
|
||||
<Modal
|
||||
opened={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
title="Export Preview"
|
||||
>
|
||||
{exportPreview && (
|
||||
<Stack gap="md">
|
||||
<Group justify="space-between">
|
||||
<Text>Pages to export:</Text>
|
||||
<Text fw={500}>{exportPreview.pageCount}</Text>
|
||||
</Group>
|
||||
|
||||
{exportPreview.splitCount > 1 && (
|
||||
<Group justify="space-between">
|
||||
<Text>Split into documents:</Text>
|
||||
<Text fw={500}>{exportPreview.splitCount}</Text>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text>Estimated size:</Text>
|
||||
<Text fw={500}>{exportPreview.estimatedSize}</Text>
|
||||
</Group>
|
||||
|
||||
{mergedPdfDocument && mergedPdfDocument.pages.some(p => p.splitBefore) && (
|
||||
<Alert color="blue">
|
||||
This will create multiple PDF files based on split markers.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => setShowExportModal(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="green"
|
||||
loading={exportLoading}
|
||||
onClick={() => {
|
||||
setShowExportModal(false);
|
||||
const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.totalPages || 0);
|
||||
handleExport(selectedOnly);
|
||||
}}
|
||||
>
|
||||
Export PDF
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<FileInput
|
||||
ref={fileInputRef}
|
||||
accept="application/pdf"
|
||||
onChange={(file) => file && handleFileUpload(file)}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
color="red"
|
||||
mt="md"
|
||||
onClose={() => setError(null)}
|
||||
style={{ position: 'fixed', bottom: 70, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
)}
|
||||
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageEditor;
|
||||
191
frontend/src/components/pageEditor/PageEditorControls.tsx
Normal file
191
frontend/src/components/pageEditor/PageEditorControls.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import React from "react";
|
||||
import {
|
||||
Tooltip,
|
||||
ActionIcon,
|
||||
Paper
|
||||
} from "@mantine/core";
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import RedoIcon from "@mui/icons-material/Redo";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
interface PageEditorControlsProps {
|
||||
// Close/Reset functions
|
||||
onClosePdf: () => void;
|
||||
|
||||
// Undo/Redo
|
||||
onUndo: () => void;
|
||||
onRedo: () => void;
|
||||
canUndo: boolean;
|
||||
canRedo: boolean;
|
||||
|
||||
// Page operations
|
||||
onRotate: (direction: 'left' | 'right') => void;
|
||||
onDelete: () => void;
|
||||
onSplit: () => void;
|
||||
|
||||
// Export functions
|
||||
onExportSelected: () => void;
|
||||
onExportAll: () => void;
|
||||
exportLoading: boolean;
|
||||
|
||||
// Selection state
|
||||
selectionMode: boolean;
|
||||
selectedPages: string[];
|
||||
}
|
||||
|
||||
const PageEditorControls = ({
|
||||
onClosePdf,
|
||||
onUndo,
|
||||
onRedo,
|
||||
canUndo,
|
||||
canRedo,
|
||||
onRotate,
|
||||
onDelete,
|
||||
onSplit,
|
||||
onExportSelected,
|
||||
onExportAll,
|
||||
exportLoading,
|
||||
selectionMode,
|
||||
selectedPages
|
||||
}: PageEditorControlsProps) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
left: '50%',
|
||||
bottom: '20px',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
pointerEvents: 'none',
|
||||
background: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
radius="xl"
|
||||
shadow="lg"
|
||||
p={16}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderRadius: 32,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||
pointerEvents: 'auto',
|
||||
minWidth: 400,
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{/* Close PDF */}
|
||||
<Tooltip label="Close PDF">
|
||||
<ActionIcon
|
||||
onClick={onClosePdf}
|
||||
color="red"
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
<CloseIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||
|
||||
{/* Undo/Redo */}
|
||||
<Tooltip label="Undo">
|
||||
<ActionIcon onClick={onUndo} disabled={!canUndo} size="lg">
|
||||
<UndoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Redo">
|
||||
<ActionIcon onClick={onRedo} disabled={!canRedo} size="lg">
|
||||
<RedoIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||
|
||||
{/* Page Operations */}
|
||||
<Tooltip label={selectionMode ? "Rotate Selected Left" : "Rotate All Left"}>
|
||||
<ActionIcon
|
||||
onClick={() => onRotate('left')}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
|
||||
size="lg"
|
||||
>
|
||||
<RotateLeftIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={selectionMode ? "Rotate Selected Right" : "Rotate All Right"}>
|
||||
<ActionIcon
|
||||
onClick={() => onRotate('right')}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
|
||||
size="lg"
|
||||
>
|
||||
<RotateRightIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={selectionMode ? "Delete Selected" : "Delete All"}>
|
||||
<ActionIcon
|
||||
onClick={onDelete}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
color="red"
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
size="lg"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={selectionMode ? "Split Selected" : "Split All"}>
|
||||
<ActionIcon
|
||||
onClick={onSplit}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
|
||||
size="lg"
|
||||
>
|
||||
<ContentCutIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||
|
||||
{/* Export Controls */}
|
||||
{selectionMode && selectedPages.length > 0 && (
|
||||
<Tooltip label="Export Selected">
|
||||
<ActionIcon
|
||||
onClick={onExportSelected}
|
||||
disabled={exportLoading}
|
||||
color="blue"
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Export All">
|
||||
<ActionIcon
|
||||
onClick={onExportAll}
|
||||
disabled={exportLoading}
|
||||
color="green"
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Paper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageEditorControls;
|
||||
358
frontend/src/components/pageEditor/PageThumbnail.tsx
Normal file
358
frontend/src/components/pageEditor/PageThumbnail.tsx
Normal file
@@ -0,0 +1,358 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core';
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
|
||||
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
|
||||
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
|
||||
import RotateRightIcon from '@mui/icons-material/RotateRight';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import ContentCutIcon from '@mui/icons-material/ContentCut';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { PDFPage } from '../../../types/pageEditor';
|
||||
import styles from './PageEditor.module.css';
|
||||
|
||||
interface PageThumbnailProps {
|
||||
page: PDFPage;
|
||||
index: number;
|
||||
totalPages: number;
|
||||
selectedPages: string[];
|
||||
selectionMode: boolean;
|
||||
draggedPage: string | null;
|
||||
dropTarget: string | null;
|
||||
movingPage: string | null;
|
||||
isAnimating: boolean;
|
||||
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
onDragStart: (pageId: string) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (pageId: string) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent, pageId: string) => void;
|
||||
onTogglePage: (pageId: string) => void;
|
||||
onAnimateReorder: (pageId: string, targetIndex: number) => void;
|
||||
onExecuteCommand: (command: any) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onSetMovingPage: (pageId: string | null) => void;
|
||||
RotatePagesCommand: any;
|
||||
DeletePagesCommand: any;
|
||||
ToggleSplitCommand: any;
|
||||
pdfDocument: any;
|
||||
setPdfDocument: any;
|
||||
}
|
||||
|
||||
const PageThumbnail = ({
|
||||
page,
|
||||
index,
|
||||
totalPages,
|
||||
selectedPages,
|
||||
selectionMode,
|
||||
draggedPage,
|
||||
dropTarget,
|
||||
movingPage,
|
||||
isAnimating,
|
||||
pageRefs,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onTogglePage,
|
||||
onAnimateReorder,
|
||||
onExecuteCommand,
|
||||
onSetStatus,
|
||||
onSetMovingPage,
|
||||
RotatePagesCommand,
|
||||
DeletePagesCommand,
|
||||
ToggleSplitCommand,
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
}: PageThumbnailProps) => {
|
||||
// Register this component with pageRefs for animations
|
||||
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
pageRefs.current.set(page.id, element);
|
||||
} else {
|
||||
pageRefs.current.delete(page.id);
|
||||
}
|
||||
}, [page.id, pageRefs]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageElementRef}
|
||||
data-page-id={page.id}
|
||||
className={`
|
||||
${styles.pageContainer}
|
||||
!rounded-lg
|
||||
cursor-grab
|
||||
select-none
|
||||
w-[20rem]
|
||||
h-[20rem]
|
||||
flex items-center justify-center
|
||||
flex-shrink-0
|
||||
shadow-sm
|
||||
hover:shadow-md
|
||||
transition-all
|
||||
relative
|
||||
${selectionMode
|
||||
? 'bg-white hover:bg-gray-50'
|
||||
: 'bg-white hover:bg-gray-50'}
|
||||
${draggedPage === page.id ? 'opacity-50 scale-95' : ''}
|
||||
${movingPage === page.id ? 'page-moving' : ''}
|
||||
`}
|
||||
style={{
|
||||
transform: (() => {
|
||||
if (!isAnimating && draggedPage && page.id !== draggedPage && dropTarget === page.id) {
|
||||
return 'translateX(20px)';
|
||||
}
|
||||
return 'translateX(0)';
|
||||
})(),
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||
}}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(page.id)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnter={() => onDragEnter(page.id)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, page.id)}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div
|
||||
className={styles.checkboxContainer}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 4,
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
padding: '2px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onDragStart={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedPages.includes(page.id)}
|
||||
onChange={(event) => {
|
||||
event.stopPropagation();
|
||||
onTogglePage(page.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="page-container w-[90%] h-[90%]">
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={page.thumbnail}
|
||||
alt={`Page ${page.pageNumber}`}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
objectFit: 'contain',
|
||||
borderRadius: 2,
|
||||
transform: `rotate(${page.rotation}deg)`,
|
||||
transition: 'transform 0.3s ease-in-out'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Text
|
||||
className={styles.pageNumber}
|
||||
size="sm"
|
||||
fw={500}
|
||||
c="white"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 5,
|
||||
left: 5,
|
||||
background: 'rgba(162, 201, 255, 0.8)',
|
||||
padding: '6px 8px',
|
||||
borderRadius: 8,
|
||||
zIndex: 2,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out'
|
||||
}}
|
||||
>
|
||||
{page.pageNumber}
|
||||
</Text>
|
||||
|
||||
<div
|
||||
className={styles.pageHoverControls}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 8,
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
background: 'rgba(0, 0, 0, 0.8)',
|
||||
padding: '6px 12px',
|
||||
borderRadius: 20,
|
||||
opacity: 0,
|
||||
transition: 'opacity 0.2s ease-in-out',
|
||||
zIndex: 3,
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
<Tooltip label="Move Left">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
disabled={index === 0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (index > 0 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.id);
|
||||
onAnimateReorder(page.id, index - 1);
|
||||
setTimeout(() => onSetMovingPage(null), 500);
|
||||
onSetStatus(`Moved page ${page.pageNumber} left`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowBackIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Move Right">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
disabled={index === totalPages - 1}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (index < totalPages - 1 && !movingPage && !isAnimating) {
|
||||
onSetMovingPage(page.id);
|
||||
onAnimateReorder(page.id, index + 1);
|
||||
setTimeout(() => onSetMovingPage(null), 500);
|
||||
onSetStatus(`Moved page ${page.pageNumber} right`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ArrowForwardIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Rotate Left">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const command = new RotatePagesCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
[page.id],
|
||||
-90
|
||||
);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Rotated page ${page.pageNumber} left`);
|
||||
}}
|
||||
>
|
||||
<RotateLeftIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Rotate Right">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const command = new RotatePagesCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
[page.id],
|
||||
90
|
||||
);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Rotated page ${page.pageNumber} right`);
|
||||
}}
|
||||
>
|
||||
<RotateRightIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip label="Delete Page">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="red"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const command = new DeletePagesCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
[page.id]
|
||||
);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Deleted page ${page.pageNumber}`);
|
||||
}}
|
||||
>
|
||||
<DeleteIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{index > 0 && (
|
||||
<Tooltip label="Split Here">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
variant="subtle"
|
||||
c="white"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
const command = new ToggleSplitCommand(
|
||||
pdfDocument,
|
||||
setPdfDocument,
|
||||
[page.id]
|
||||
);
|
||||
onExecuteCommand(command);
|
||||
onSetStatus(`Split marker toggled for page ${page.pageNumber}`);
|
||||
}}
|
||||
>
|
||||
<ContentCutIcon style={{ fontSize: 20 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DragIndicatorIcon
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 4,
|
||||
color: 'rgba(0,0,0,0.3)',
|
||||
fontSize: 16,
|
||||
zIndex: 1
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageThumbnail;
|
||||
Reference in New Issue
Block a user