diff --git a/frontend/src/components/LanguageSelector.module.css b/frontend/src/components/LanguageSelector.module.css index 22053b457..09010dc4a 100644 --- a/frontend/src/components/LanguageSelector.module.css +++ b/frontend/src/components/LanguageSelector.module.css @@ -68,4 +68,21 @@ .languageText { display: inline; } +} + +/* Ripple animation */ +@keyframes ripple { + 0% { + width: 0; + height: 0; + opacity: 0.6; + } + 50% { + opacity: 0.3; + } + 100% { + width: 100px; + height: 100px; + opacity: 0; + } } \ No newline at end of file diff --git a/frontend/src/components/LanguageSelector.tsx b/frontend/src/components/LanguageSelector.tsx index 3f793a031..8a3337ccc 100644 --- a/frontend/src/components/LanguageSelector.tsx +++ b/frontend/src/components/LanguageSelector.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Menu, Button, ScrollArea, useMantineTheme, useMantineColorScheme } from '@mantine/core'; import { useTranslation } from 'react-i18next'; import { supportedLanguages } from '../i18n'; @@ -10,6 +10,10 @@ const LanguageSelector: React.FC = () => { const theme = useMantineTheme(); const { colorScheme } = useMantineColorScheme(); const [opened, setOpened] = useState(false); + const [animationTriggered, setAnimationTriggered] = useState(false); + const [isChanging, setIsChanging] = useState(false); + const [pendingLanguage, setPendingLanguage] = useState(null); + const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null); const languageOptions = Object.entries(supportedLanguages) .sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB)) @@ -18,22 +22,78 @@ const LanguageSelector: React.FC = () => { label: name, })); - const handleLanguageChange = (value: string) => { - i18n.changeLanguage(value); - setOpened(false); + const handleLanguageChange = (value: string, event: React.MouseEvent) => { + // Create ripple effect at click position + const rect = event.currentTarget.getBoundingClientRect(); + const x = event.clientX - rect.left; + const y = event.clientY - rect.top; + + setRippleEffect({ x, y, key: Date.now() }); + + // Start transition animation + setIsChanging(true); + setPendingLanguage(value); + + // Simulate processing time for smooth transition + setTimeout(() => { + i18n.changeLanguage(value); + + setTimeout(() => { + setIsChanging(false); + setPendingLanguage(null); + setOpened(false); + + // Clear ripple effect + setTimeout(() => setRippleEffect(null), 100); + }, 300); + }, 200); }; const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] || supportedLanguages['en-GB']; + // Trigger animation when dropdown opens + useEffect(() => { + if (opened) { + setAnimationTriggered(false); + // Small delay to ensure DOM is ready + setTimeout(() => setAnimationTriggered(true), 50); + } + }, [opened]); + return ( - + <> + + ))} - + + ); }; diff --git a/frontend/src/components/PageEditor.tsx b/frontend/src/components/PageEditor.tsx index de00d6b00..c807010ad 100644 --- a/frontend/src/components/PageEditor.tsx +++ b/frontend/src/components/PageEditor.tsx @@ -22,6 +22,7 @@ import DeselectIcon from "@mui/icons-material/Deselect"; import SelectAllIcon from "@mui/icons-material/SelectAll"; import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; +import CloseIcon from "@mui/icons-material/Close"; import { usePDFProcessor } from "../hooks/usePDFProcessor"; import { PDFDocument, PDFPage } from "../types/pageEditor"; import { fileStorage } from "../services/fileStorage"; @@ -31,6 +32,7 @@ import { RotatePagesCommand, DeletePagesCommand, ReorderPageCommand, + MovePagesCommand, ToggleSplitCommand } from "../commands/pageCommands"; import { pdfExportService } from "../services/pdfExportService"; @@ -57,14 +59,19 @@ const PageEditor: React.FC = ({ const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [csvInput, setCsvInput] = useState(""); - const [showPageSelect, setShowPageSelect] = useState(false); + const [selectionMode, setSelectionMode] = useState(false); const [filename, setFilename] = useState(""); const [draggedPage, setDraggedPage] = useState(null); const [dropTarget, setDropTarget] = useState(null); + const [multiPageDrag, setMultiPageDrag] = useState<{pageIds: string[], count: number} | null>(null); + const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null); const [exportLoading, setExportLoading] = useState(false); const [showExportModal, setShowExportModal] = useState(false); const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null); const [movingPage, setMovingPage] = useState(null); + const [pagePositions, setPagePositions] = useState>(new Map()); + const [isAnimating, setIsAnimating] = useState(false); + const pageRefs = useRef>(new Map()); const fileInputRef = useRef<() => void>(null); // Undo/Redo system @@ -112,6 +119,32 @@ const PageEditor: React.FC = ({ } }, [file, pdfDocument, handleFileUpload]); + // 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 (pdfDocument) { setSelectedPages(pdfDocument.pages.map(p => p.id)); @@ -128,6 +161,18 @@ const PageEditor: React.FC = ({ ); }, []); + 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 (!pdfDocument) return []; @@ -162,12 +207,35 @@ const PageEditor: React.FC = ({ 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); @@ -192,7 +260,7 @@ const PageEditor: React.FC = ({ // If not over any valid drop target, clear it setDropTarget(null); - }, [draggedPage]); + }, [draggedPage, multiPageDrag]); const handleDragEnter = useCallback((pageId: string) => { if (draggedPage && pageId !== draggedPage) { @@ -204,6 +272,95 @@ const PageEditor: React.FC = ({ // Don't clear drop target on drag leave - let dragover handle it }, []); + const animateReorder = useCallback((pageId: string, targetIndex: number) => { + if (!pdfDocument || 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 = pdfDocument.pages.findIndex(p => p.id === pageId); + if (originalIndex === -1 || originalIndex === targetIndex) return; + + setIsAnimating(true); + + // Get current positions of all pages + const currentPositions = new Map(); + pdfDocument.pages.forEach((page) => { + const element = pageRefs.current.get(page.id); + if (element) { + const rect = element.getBoundingClientRect(); + currentPositions.set(page.id, { 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(pdfDocument, setPdfDocument, pagesToMove, targetIndex); + executeCommand(command); + } else { + // Single page move + const command = new ReorderPageCommand(pdfDocument, setPdfDocument, pageId, targetIndex); + executeCommand(command); + } + + // Wait for DOM to update, then get new positions and animate + requestAnimationFrame(() => { + requestAnimationFrame(() => { + const newPositions = new Map(); + + // Get the updated document from the state after command execution + // The command has already updated the document, so we need to get the new order + const currentDoc = pdfDocument; // This should be the updated version after command + + currentDoc.pages.forEach((page) => { + const element = pageRefs.current.get(page.id); + if (element) { + const rect = element.getBoundingClientRect(); + newPositions.set(page.id, { x: rect.left, y: rect.top }); + } + }); + + // Calculate and apply animations + currentDoc.pages.forEach((page) => { + const element = pageRefs.current.get(page.id); + const currentPos = currentPositions.get(page.id); + const newPos = newPositions.get(page.id); + + if (element && currentPos && newPos) { + const deltaX = currentPos.x - newPos.x; + const deltaY = currentPos.y - newPos.y; + + // Apply initial transform (from new position back to old position) + element.style.transform = `translate(${deltaX}px, ${deltaY}px)`; + element.style.transition = 'none'; + + // Force reflow + element.offsetHeight; + + // Animate to final position + element.style.transition = 'transform 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94)'; + element.style.transform = 'translate(0px, 0px)'; + } + }); + + // Clean up after animation + setTimeout(() => { + currentDoc.pages.forEach((page) => { + const element = pageRefs.current.get(page.id); + if (element) { + element.style.transform = ''; + element.style.transition = ''; + } + }); + setIsAnimating(false); + }, 400); + }); + }); + }, [pdfDocument, isAnimating, executeCommand, selectionMode, selectedPages]); + const handleDrop = useCallback((e: React.DragEvent, targetPageId: string | 'end') => { e.preventDefault(); if (!draggedPage || !pdfDocument || draggedPage === targetPageId) return; @@ -216,18 +373,16 @@ const PageEditor: React.FC = ({ if (targetIndex === -1) return; } - const command = new ReorderPageCommand( - pdfDocument, - setPdfDocument, - draggedPage, - targetIndex - ); - - executeCommand(command); + animateReorder(draggedPage, targetIndex); + setDraggedPage(null); setDropTarget(null); - setStatus('Page reordered'); - }, [draggedPage, pdfDocument, executeCommand]); + setMultiPageDrag(null); + setDragPosition(null); + + const moveCount = multiPageDrag ? multiPageDrag.count : 1; + setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`); + }, [draggedPage, pdfDocument, animateReorder, multiPageDrag]); const handleEndZoneDragEnter = useCallback(() => { if (draggedPage) { @@ -236,46 +391,69 @@ const PageEditor: React.FC = ({ }, [draggedPage]); const handleRotate = useCallback((direction: 'left' | 'right') => { - if (!pdfDocument || selectedPages.length === 0) return; + if (!pdfDocument) return; const rotation = direction === 'left' ? -90 : 90; + const pagesToRotate = selectionMode + ? selectedPages + : pdfDocument.pages.map(p => p.id); + + if (selectionMode && selectedPages.length === 0) return; + const command = new RotatePagesCommand( pdfDocument, setPdfDocument, - selectedPages, + pagesToRotate, rotation ); executeCommand(command); - setStatus(`Rotated ${selectedPages.length} pages ${direction}`); - }, [pdfDocument, selectedPages, executeCommand]); + const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length; + setStatus(`Rotated ${pageCount} pages ${direction}`); + }, [pdfDocument, selectedPages, selectionMode, executeCommand]); const handleDelete = useCallback(() => { - if (!pdfDocument || selectedPages.length === 0) return; + if (!pdfDocument) return; + + const pagesToDelete = selectionMode + ? selectedPages + : pdfDocument.pages.map(p => p.id); + + if (selectionMode && selectedPages.length === 0) return; const command = new DeletePagesCommand( pdfDocument, setPdfDocument, - selectedPages + pagesToDelete ); executeCommand(command); - setSelectedPages([]); - setStatus(`Deleted ${selectedPages.length} pages`); - }, [pdfDocument, selectedPages, executeCommand]); + if (selectionMode) { + setSelectedPages([]); + } + const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length; + setStatus(`Deleted ${pageCount} pages`); + }, [pdfDocument, selectedPages, selectionMode, executeCommand]); const handleSplit = useCallback(() => { - if (!pdfDocument || selectedPages.length === 0) return; + if (!pdfDocument) return; + + const pagesToSplit = selectionMode + ? selectedPages + : pdfDocument.pages.map(p => p.id); + + if (selectionMode && selectedPages.length === 0) return; const command = new ToggleSplitCommand( pdfDocument, setPdfDocument, - selectedPages + pagesToSplit ); executeCommand(command); - setStatus(`Split markers toggled for ${selectedPages.length} pages`); - }, [pdfDocument, selectedPages, executeCommand]); + const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length; + setStatus(`Split markers toggled for ${pageCount} pages`); + }, [pdfDocument, selectedPages, selectionMode, executeCommand]); const showExportPreview = useCallback((selectedOnly: boolean = false) => { if (!pdfDocument) return; @@ -390,6 +568,10 @@ const PageEditor: React.FC = ({ .page-container:hover { transform: scale(1.02); } + .checkbox-container { + transform: none !important; + transition: none !important; + } .page-move-animation { transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); } @@ -398,6 +580,22 @@ const PageEditor: React.FC = ({ transform: scale(1.05); box-shadow: 0 10px 30px rgba(0,0,0,0.3); } + + .multi-drag-indicator { + 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); + } + @keyframes pulse { 0%, 100% { opacity: 1; @@ -410,7 +608,7 @@ const PageEditor: React.FC = ({ - + = ({ placeholder="Enter filename" style={{ minWidth: 200 }} /> - - - + {selectionMode && ( + <> + + + + )} - {showPageSelect && ( - - - setCsvInput(e.target.value)} - placeholder="1,3,5-10" - label="Page Selection" - onBlur={updatePagesFromCSV} - onKeyDown={(e) => e.key === 'Enter' && updatePagesFromCSV()} - style={{ flex: 1 }} - /> - - - {selectedPages.length > 0 && ( - - Selected: {selectedPages.length} pages - - )} - - )} - - - - - - - - - - - - - - handleRotate('left')} disabled={selectedPages.length === 0}> - - - - - handleRotate('right')} disabled={selectedPages.length === 0}> - - - - - - - - - - - - - - + {selectionMode && ( + + + setCsvInput(e.target.value)} + placeholder="1,3,5-10" + label="Page Selection" + onBlur={updatePagesFromCSV} + onKeyDown={(e) => e.key === 'Enter' && updatePagesFromCSV()} + style={{ flex: 1 }} + /> + + + {selectedPages.length > 0 && ( + + Selected: {selectedPages.length} pages + + )} + + )}
{pdfDocument.pages.map((page, index) => ( @@ -506,6 +688,13 @@ const PageEditor: React.FC = ({ /> )}
{ + if (el) { + pageRefs.current.set(page.id, el); + } else { + pageRefs.current.delete(page.id); + } + }} data-page-id={page.id} className={` !rounded-lg @@ -519,31 +708,67 @@ const PageEditor: React.FC = ({ hover:shadow-md transition-all relative - page-move-animation - ${selectedPages.includes(page.id) - ? 'ring-2 ring-blue-500 bg-blue-50' + ${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 (!draggedPage || page.id === draggedPage) return 'translateX(0)'; - - if (dropTarget === page.id) { - return 'translateX(20px)'; // Move slightly right to indicate drop position - } - return 'translateX(0)'; - })(), - transition: 'transform 0.2s ease-in-out' - }} + style={{ + transform: (() => { + // Only apply drop target indication during drag + 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={() => handleDragStart(page.id)} + onDragEnd={handleDragEnd} onDragOver={handleDragOver} onDragEnter={() => handleDragEnter(page.id)} onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, page.id)} > + {/* Selection mode checkbox - positioned outside page-container to avoid transform inheritance */} + {selectionMode && ( +
{ + e.stopPropagation(); // Prevent drag from starting + }} + onDragStart={(e) => { + e.preventDefault(); // Prevent drag on checkbox + e.stopPropagation(); + }} + > + { + event.stopPropagation(); + togglePage(page.id); + }} + onClick={(e) => { + e.stopPropagation(); + }} + size="sm" + /> +
+ )} +
= ({ disabled={index === 0} onClick={(e) => { e.stopPropagation(); - if (index > 0 && !movingPage) { + if (index > 0 && !movingPage && !isAnimating) { setMovingPage(page.id); - setTimeout(() => { - const command = new ReorderPageCommand( - pdfDocument, - setPdfDocument, - page.id, - index - 1 - ); - executeCommand(command); - setTimeout(() => setMovingPage(null), 100); - setStatus(`Moved page ${page.pageNumber} left`); - }, 50); + animateReorder(page.id, index - 1); + setTimeout(() => setMovingPage(null), 500); + setStatus(`Moved page ${page.pageNumber} left`); } }} > @@ -635,19 +852,11 @@ const PageEditor: React.FC = ({ disabled={index === pdfDocument.pages.length - 1} onClick={(e) => { e.stopPropagation(); - if (index < pdfDocument.pages.length - 1 && !movingPage) { + if (index < pdfDocument.pages.length - 1 && !movingPage && !isAnimating) { setMovingPage(page.id); - setTimeout(() => { - const command = new ReorderPageCommand( - pdfDocument, - setPdfDocument, - page.id, - index + 1 - ); - executeCommand(command); - setTimeout(() => setMovingPage(null), 100); - setStatus(`Moved page ${page.pageNumber} right`); - }, 50); + animateReorder(page.id, index + 1); + setTimeout(() => setMovingPage(null), 500); + setStatus(`Moved page ${page.pageNumber} right`); } }} > @@ -739,16 +948,6 @@ const PageEditor: React.FC = ({ )} - - togglePage(page.id)} - styles={{ - input: { backgroundColor: 'white' } - }} - /> -
= ({
- - - - - - - - + + + + + +
= ({ {status} )} + + {/* Multi-page drag indicator */} + {multiPageDrag && dragPosition && ( +
+ {multiPageDrag.count} pages +
+ )}
); }; diff --git a/frontend/src/components/Viewer.tsx b/frontend/src/components/Viewer.tsx index 439e0d530..b02100029 100644 --- a/frontend/src/components/Viewer.tsx +++ b/frontend/src/components/Viewer.tsx @@ -534,23 +534,6 @@ const Viewer: React.FC = ({ > {dualPage ? : } - - - - {/* Tool title */} -
-

{selectedTool?.name}

-
- - {/* Tool content */} -
- +
+ {leftPanelView === 'toolPicker' ? ( + // Tool Picker View +
+
-
- )} -
- )} + ) : ( + // Selected Tool Content View +
+ {/* Back button */} +
+ +
+ + {/* Tool title */} +
+

{selectedTool?.name}

+
+ + {/* Tool content */} +
+ +
+
+ )} + + {/* Main View */} {/* Top Controls */}