Page editor redesign

This commit is contained in:
Reece 2025-06-18 18:12:15 +01:00
parent ae508730c3
commit d981968e0f
5 changed files with 673 additions and 249 deletions

View File

@ -69,3 +69,20 @@
display: inline; 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;
}
}

View File

@ -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 { Menu, Button, ScrollArea, useMantineTheme, useMantineColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../i18n'; import { supportedLanguages } from '../i18n';
@ -10,6 +10,10 @@ const LanguageSelector: React.FC = () => {
const theme = useMantineTheme(); const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme(); const { colorScheme } = useMantineColorScheme();
const [opened, setOpened] = useState(false); const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false);
const [isChanging, setIsChanging] = useState(false);
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null);
const languageOptions = Object.entries(supportedLanguages) const languageOptions = Object.entries(supportedLanguages)
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB)) .sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
@ -18,21 +22,77 @@ const LanguageSelector: React.FC = () => {
label: name, label: name,
})); }));
const handleLanguageChange = (value: string) => { 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); i18n.changeLanguage(value);
setTimeout(() => {
setIsChanging(false);
setPendingLanguage(null);
setOpened(false); setOpened(false);
// Clear ripple effect
setTimeout(() => setRippleEffect(null), 100);
}, 300);
}, 200);
}; };
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] || const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
supportedLanguages['en-GB']; 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 ( return (
<>
<style>
{`
@keyframes ripple-expand {
0% {
width: 0;
height: 0;
opacity: 0.6;
}
50% {
opacity: 0.3;
}
100% {
width: 100px;
height: 100px;
opacity: 0;
}
}
`}
</style>
<Menu <Menu
opened={opened} opened={opened}
onChange={setOpened} onChange={setOpened}
width={600} width={600}
position="bottom-start" position="bottom-start"
offset={8} offset={8}
transitionProps={{
transition: 'scale-y',
duration: 200,
timingFunction: 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
}}
> >
<Menu.Target> <Menu.Target>
<Button <Button
@ -43,6 +103,7 @@ const LanguageSelector: React.FC = () => {
root: { root: {
border: 'none', border: 'none',
color: colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7], color: colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7],
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': { '&:hover': {
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1], backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
} }
@ -69,22 +130,29 @@ const LanguageSelector: React.FC = () => {
> >
<ScrollArea h={190} type="scroll"> <ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}> <div className={styles.languageGrid}>
{languageOptions.map((option) => ( {languageOptions.map((option, index) => (
<div <div
key={option.value} key={option.value}
className={styles.languageItem} className={styles.languageItem}
style={{
opacity: animationTriggered ? 1 : 0,
transform: animationTriggered ? 'translateY(0px)' : 'translateY(8px)',
transition: `opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s, transform 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94) ${index * 0.02}s`,
}}
> >
<Button <Button
variant="subtle" variant="subtle"
size="sm" size="sm"
fullWidth fullWidth
onClick={() => handleLanguageChange(option.value)} onClick={(event) => handleLanguageChange(option.value, event)}
styles={{ styles={{
root: { root: {
borderRadius: '4px', borderRadius: '4px',
minHeight: '32px', minHeight: '32px',
padding: '4px 8px', padding: '4px 8px',
justifyContent: 'flex-start', justifyContent: 'flex-start',
position: 'relative',
overflow: 'hidden',
backgroundColor: option.value === i18n.language ? ( backgroundColor: option.value === i18n.language ? (
colorScheme === 'dark' ? theme.colors.blue[8] : theme.colors.blue[1] colorScheme === 'dark' ? theme.colors.blue[8] : theme.colors.blue[1]
) : 'transparent', ) : 'transparent',
@ -93,12 +161,15 @@ const LanguageSelector: React.FC = () => {
) : ( ) : (
colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7] colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7]
), ),
transition: 'all 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': { '&:hover': {
backgroundColor: option.value === i18n.language ? ( backgroundColor: option.value === i18n.language ? (
colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2] colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2]
) : ( ) : (
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1] colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1]
), ),
transform: 'translateY(-1px)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
} }
}, },
label: { label: {
@ -108,10 +179,32 @@ const LanguageSelector: React.FC = () => {
overflow: 'hidden', overflow: 'hidden',
textOverflow: 'ellipsis', textOverflow: 'ellipsis',
whiteSpace: 'nowrap', whiteSpace: 'nowrap',
position: 'relative',
zIndex: 2,
} }
}} }}
> >
{option.label} {option.label}
{/* Ripple effect */}
{rippleEffect && pendingLanguage === option.value && (
<div
key={rippleEffect.key}
style={{
position: 'absolute',
left: rippleEffect.x,
top: rippleEffect.y,
width: 0,
height: 0,
borderRadius: '50%',
backgroundColor: theme.colors.blue[4],
opacity: 0.6,
transform: 'translate(-50%, -50%)',
animation: 'ripple-expand 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
zIndex: 1,
}}
/>
)}
</Button> </Button>
</div> </div>
))} ))}
@ -119,6 +212,7 @@ const LanguageSelector: React.FC = () => {
</ScrollArea> </ScrollArea>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu>
</>
); );
}; };

View File

@ -22,6 +22,7 @@ import DeselectIcon from "@mui/icons-material/Deselect";
import SelectAllIcon from "@mui/icons-material/SelectAll"; import SelectAllIcon from "@mui/icons-material/SelectAll";
import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ArrowBackIcon from "@mui/icons-material/ArrowBack";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import CloseIcon from "@mui/icons-material/Close";
import { usePDFProcessor } from "../hooks/usePDFProcessor"; import { usePDFProcessor } from "../hooks/usePDFProcessor";
import { PDFDocument, PDFPage } from "../types/pageEditor"; import { PDFDocument, PDFPage } from "../types/pageEditor";
import { fileStorage } from "../services/fileStorage"; import { fileStorage } from "../services/fileStorage";
@ -31,6 +32,7 @@ import {
RotatePagesCommand, RotatePagesCommand,
DeletePagesCommand, DeletePagesCommand,
ReorderPageCommand, ReorderPageCommand,
MovePagesCommand,
ToggleSplitCommand ToggleSplitCommand
} from "../commands/pageCommands"; } from "../commands/pageCommands";
import { pdfExportService } from "../services/pdfExportService"; import { pdfExportService } from "../services/pdfExportService";
@ -57,14 +59,19 @@ const PageEditor: React.FC<PageEditorProps> = ({
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [csvInput, setCsvInput] = useState<string>(""); const [csvInput, setCsvInput] = useState<string>("");
const [showPageSelect, setShowPageSelect] = useState(false); const [selectionMode, setSelectionMode] = useState(false);
const [filename, setFilename] = useState<string>(""); const [filename, setFilename] = useState<string>("");
const [draggedPage, setDraggedPage] = useState<string | null>(null); const [draggedPage, setDraggedPage] = useState<string | null>(null);
const [dropTarget, setDropTarget] = 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);
const [exportLoading, setExportLoading] = useState(false); const [exportLoading, setExportLoading] = useState(false);
const [showExportModal, setShowExportModal] = useState(false); const [showExportModal, setShowExportModal] = useState(false);
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null); const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
const [movingPage, setMovingPage] = useState<string | null>(null); 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); const fileInputRef = useRef<() => void>(null);
// Undo/Redo system // Undo/Redo system
@ -112,6 +119,32 @@ const PageEditor: React.FC<PageEditorProps> = ({
} }
}, [file, pdfDocument, handleFileUpload]); }, [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(() => { const selectAll = useCallback(() => {
if (pdfDocument) { if (pdfDocument) {
setSelectedPages(pdfDocument.pages.map(p => p.id)); setSelectedPages(pdfDocument.pages.map(p => p.id));
@ -128,6 +161,18 @@ const PageEditor: React.FC<PageEditorProps> = ({
); );
}, []); }, []);
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) => { const parseCSVInput = useCallback((csv: string) => {
if (!pdfDocument) return []; if (!pdfDocument) return [];
@ -162,6 +207,24 @@ const PageEditor: React.FC<PageEditorProps> = ({
const handleDragStart = useCallback((pageId: string) => { const handleDragStart = useCallback((pageId: string) => {
setDraggedPage(pageId); 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) => { const handleDragOver = useCallback((e: React.DragEvent) => {
@ -169,6 +232,11 @@ const PageEditor: React.FC<PageEditorProps> = ({
if (!draggedPage) return; 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 // Get the element under the mouse cursor
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY); const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
if (!elementUnderCursor) return; if (!elementUnderCursor) return;
@ -192,7 +260,7 @@ const PageEditor: React.FC<PageEditorProps> = ({
// If not over any valid drop target, clear it // If not over any valid drop target, clear it
setDropTarget(null); setDropTarget(null);
}, [draggedPage]); }, [draggedPage, multiPageDrag]);
const handleDragEnter = useCallback((pageId: string) => { const handleDragEnter = useCallback((pageId: string) => {
if (draggedPage && pageId !== draggedPage) { if (draggedPage && pageId !== draggedPage) {
@ -204,6 +272,95 @@ const PageEditor: React.FC<PageEditorProps> = ({
// Don't clear drop target on drag leave - let dragover handle it // 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<string, { x: number; y: number }>();
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<string, { x: number; y: number }>();
// 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') => { const handleDrop = useCallback((e: React.DragEvent, targetPageId: string | 'end') => {
e.preventDefault(); e.preventDefault();
if (!draggedPage || !pdfDocument || draggedPage === targetPageId) return; if (!draggedPage || !pdfDocument || draggedPage === targetPageId) return;
@ -216,18 +373,16 @@ const PageEditor: React.FC<PageEditorProps> = ({
if (targetIndex === -1) return; if (targetIndex === -1) return;
} }
const command = new ReorderPageCommand( animateReorder(draggedPage, targetIndex);
pdfDocument,
setPdfDocument,
draggedPage,
targetIndex
);
executeCommand(command);
setDraggedPage(null); setDraggedPage(null);
setDropTarget(null); setDropTarget(null);
setStatus('Page reordered'); setMultiPageDrag(null);
}, [draggedPage, pdfDocument, executeCommand]); setDragPosition(null);
const moveCount = multiPageDrag ? multiPageDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`);
}, [draggedPage, pdfDocument, animateReorder, multiPageDrag]);
const handleEndZoneDragEnter = useCallback(() => { const handleEndZoneDragEnter = useCallback(() => {
if (draggedPage) { if (draggedPage) {
@ -236,46 +391,69 @@ const PageEditor: React.FC<PageEditorProps> = ({
}, [draggedPage]); }, [draggedPage]);
const handleRotate = useCallback((direction: 'left' | 'right') => { const handleRotate = useCallback((direction: 'left' | 'right') => {
if (!pdfDocument || selectedPages.length === 0) return; if (!pdfDocument) return;
const rotation = direction === 'left' ? -90 : 90; 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( const command = new RotatePagesCommand(
pdfDocument, pdfDocument,
setPdfDocument, setPdfDocument,
selectedPages, pagesToRotate,
rotation rotation
); );
executeCommand(command); executeCommand(command);
setStatus(`Rotated ${selectedPages.length} pages ${direction}`); const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length;
}, [pdfDocument, selectedPages, executeCommand]); setStatus(`Rotated ${pageCount} pages ${direction}`);
}, [pdfDocument, selectedPages, selectionMode, executeCommand]);
const handleDelete = useCallback(() => { 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( const command = new DeletePagesCommand(
pdfDocument, pdfDocument,
setPdfDocument, setPdfDocument,
selectedPages pagesToDelete
); );
executeCommand(command); executeCommand(command);
if (selectionMode) {
setSelectedPages([]); setSelectedPages([]);
setStatus(`Deleted ${selectedPages.length} pages`); }
}, [pdfDocument, selectedPages, executeCommand]); const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length;
setStatus(`Deleted ${pageCount} pages`);
}, [pdfDocument, selectedPages, selectionMode, executeCommand]);
const handleSplit = useCallback(() => { 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( const command = new ToggleSplitCommand(
pdfDocument, pdfDocument,
setPdfDocument, setPdfDocument,
selectedPages pagesToSplit
); );
executeCommand(command); executeCommand(command);
setStatus(`Split markers toggled for ${selectedPages.length} pages`); const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length;
}, [pdfDocument, selectedPages, executeCommand]); setStatus(`Split markers toggled for ${pageCount} pages`);
}, [pdfDocument, selectedPages, selectionMode, executeCommand]);
const showExportPreview = useCallback((selectedOnly: boolean = false) => { const showExportPreview = useCallback((selectedOnly: boolean = false) => {
if (!pdfDocument) return; if (!pdfDocument) return;
@ -390,6 +568,10 @@ const PageEditor: React.FC<PageEditorProps> = ({
.page-container:hover { .page-container:hover {
transform: scale(1.02); transform: scale(1.02);
} }
.checkbox-container {
transform: none !important;
transition: none !important;
}
.page-move-animation { .page-move-animation {
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94); transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
} }
@ -398,6 +580,22 @@ const PageEditor: React.FC<PageEditorProps> = ({
transform: scale(1.05); transform: scale(1.05);
box-shadow: 0 10px 30px rgba(0,0,0,0.3); 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 { @keyframes pulse {
0%, 100% { 0%, 100% {
opacity: 1; opacity: 1;
@ -410,7 +608,7 @@ const PageEditor: React.FC<PageEditorProps> = ({
</style> </style>
<LoadingOverlay visible={loading || pdfLoading} /> <LoadingOverlay visible={loading || pdfLoading} />
<Box p="md"> <Box p="md" pt="xl">
<Group mb="md"> <Group mb="md">
<TextInput <TextInput
value={filename} value={filename}
@ -418,14 +616,30 @@ const PageEditor: React.FC<PageEditorProps> = ({
placeholder="Enter filename" placeholder="Enter filename"
style={{ minWidth: 200 }} style={{ minWidth: 200 }}
/> />
<Button onClick={() => setShowPageSelect(!showPageSelect)}> <Button
Select Pages 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> </Button>
<Button onClick={selectAll}>Select All</Button> {selectionMode && (
<Button onClick={deselectAll}>Deselect All</Button> <>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
</>
)}
</Group> </Group>
{showPageSelect && ( {selectionMode && (
<Paper p="md" mb="md" withBorder> <Paper p="md" mb="md" withBorder>
<Group> <Group>
<TextInput <TextInput
@ -449,45 +663,13 @@ const PageEditor: React.FC<PageEditorProps> = ({
</Paper> </Paper>
)} )}
<Group mb="md">
<Tooltip label="Undo">
<ActionIcon onClick={handleUndo} disabled={!canUndo}>
<UndoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Redo">
<ActionIcon onClick={handleRedo} disabled={!canRedo}>
<RedoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Rotate Left">
<ActionIcon onClick={() => handleRotate('left')} disabled={selectedPages.length === 0}>
<RotateLeftIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Rotate Right">
<ActionIcon onClick={() => handleRotate('right')} disabled={selectedPages.length === 0}>
<RotateRightIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete">
<ActionIcon onClick={handleDelete} disabled={selectedPages.length === 0} color="red">
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Split">
<ActionIcon onClick={handleSplit} disabled={selectedPages.length === 0}>
<ContentCutIcon />
</ActionIcon>
</Tooltip>
</Group>
<div <div
style={{ style={{
display: 'flex', display: 'flex',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: '1.5rem', gap: '1.5rem',
justifyContent: 'flex-start' justifyContent: 'flex-start',
paddingBottom: '100px' // Add space for floating control bar
}} }}
> >
{pdfDocument.pages.map((page, index) => ( {pdfDocument.pages.map((page, index) => (
@ -506,6 +688,13 @@ const PageEditor: React.FC<PageEditorProps> = ({
/> />
)} )}
<div <div
ref={(el) => {
if (el) {
pageRefs.current.set(page.id, el);
} else {
pageRefs.current.delete(page.id);
}
}}
data-page-id={page.id} data-page-id={page.id}
className={` className={`
!rounded-lg !rounded-lg
@ -519,31 +708,67 @@ const PageEditor: React.FC<PageEditorProps> = ({
hover:shadow-md hover:shadow-md
transition-all transition-all
relative relative
page-move-animation ${selectionMode
${selectedPages.includes(page.id) ? 'bg-white hover:bg-gray-50'
? 'ring-2 ring-blue-500 bg-blue-50'
: 'bg-white hover:bg-gray-50'} : 'bg-white hover:bg-gray-50'}
${draggedPage === page.id ? 'opacity-50 scale-95' : ''} ${draggedPage === page.id ? 'opacity-50 scale-95' : ''}
${movingPage === page.id ? 'page-moving' : ''} ${movingPage === page.id ? 'page-moving' : ''}
`} `}
style={{ style={{
transform: (() => { transform: (() => {
if (!draggedPage || page.id === draggedPage) return 'translateX(0)'; // Only apply drop target indication during drag
if (!isAnimating && draggedPage && page.id !== draggedPage && dropTarget === page.id) {
if (dropTarget === page.id) { return 'translateX(20px)';
return 'translateX(20px)'; // Move slightly right to indicate drop position
} }
return 'translateX(0)'; return 'translateX(0)';
})(), })(),
transition: 'transform 0.2s ease-in-out' transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}} }}
draggable draggable
onDragStart={() => handleDragStart(page.id)} onDragStart={() => handleDragStart(page.id)}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnter={() => handleDragEnter(page.id)} onDragEnter={() => handleDragEnter(page.id)}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={(e) => handleDrop(e, page.id)} onDrop={(e) => handleDrop(e, page.id)}
> >
{/* Selection mode checkbox - positioned outside page-container to avoid transform inheritance */}
{selectionMode && (
<div
className="checkbox-container"
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' // Ensure checkbox can be clicked
}}
onMouseDown={(e) => {
e.stopPropagation(); // Prevent drag from starting
}}
onDragStart={(e) => {
e.preventDefault(); // Prevent drag on checkbox
e.stopPropagation();
}}
>
<Checkbox
checked={selectedPages.includes(page.id)}
onChange={(event) => {
event.stopPropagation();
togglePage(page.id);
}}
onClick={(e) => {
e.stopPropagation();
}}
size="sm"
/>
</div>
)}
<div className="page-container w-[90%] h-[90%]"> <div className="page-container w-[90%] h-[90%]">
<img <img
src={page.thumbnail} src={page.thumbnail}
@ -607,19 +832,11 @@ const PageEditor: React.FC<PageEditorProps> = ({
disabled={index === 0} disabled={index === 0}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (index > 0 && !movingPage) { if (index > 0 && !movingPage && !isAnimating) {
setMovingPage(page.id); setMovingPage(page.id);
setTimeout(() => { animateReorder(page.id, index - 1);
const command = new ReorderPageCommand( setTimeout(() => setMovingPage(null), 500);
pdfDocument,
setPdfDocument,
page.id,
index - 1
);
executeCommand(command);
setTimeout(() => setMovingPage(null), 100);
setStatus(`Moved page ${page.pageNumber} left`); setStatus(`Moved page ${page.pageNumber} left`);
}, 50);
} }
}} }}
> >
@ -635,19 +852,11 @@ const PageEditor: React.FC<PageEditorProps> = ({
disabled={index === pdfDocument.pages.length - 1} disabled={index === pdfDocument.pages.length - 1}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (index < pdfDocument.pages.length - 1 && !movingPage) { if (index < pdfDocument.pages.length - 1 && !movingPage && !isAnimating) {
setMovingPage(page.id); setMovingPage(page.id);
setTimeout(() => { animateReorder(page.id, index + 1);
const command = new ReorderPageCommand( setTimeout(() => setMovingPage(null), 500);
pdfDocument,
setPdfDocument,
page.id,
index + 1
);
executeCommand(command);
setTimeout(() => setMovingPage(null), 100);
setStatus(`Moved page ${page.pageNumber} right`); setStatus(`Moved page ${page.pageNumber} right`);
}, 50);
} }
}} }}
> >
@ -739,16 +948,6 @@ const PageEditor: React.FC<PageEditorProps> = ({
</Tooltip> </Tooltip>
)} )}
<Tooltip label="Select Page">
<Checkbox
size="md"
checked={selectedPages.includes(page.id)}
onChange={() => togglePage(page.id)}
styles={{
input: { backgroundColor: 'white' }
}}
/>
</Tooltip>
</div> </div>
<DragIndicatorIcon <DragIndicatorIcon
@ -786,38 +985,142 @@ const PageEditor: React.FC<PageEditorProps> = ({
</div> </div>
</div> </div>
<Group justify="space-between" mt="md"> {/* Floating control bar */}
<Button <div
color="red" style={{
variant="light" 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={() => { onClick={() => {
setPdfDocument(null); setPdfDocument(null);
setFile && setFile(null); setFile && setFile(null);
}} }}
color="red"
variant="light"
size="lg"
> >
Close PDF <CloseIcon />
</Button> </ActionIcon>
</Tooltip>
<Group> <div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
<Button
leftSection={<DownloadIcon />} {/* Undo/Redo */}
disabled={selectedPages.length === 0 || exportLoading} <Tooltip label="Undo">
loading={exportLoading} <ActionIcon onClick={handleUndo} disabled={!canUndo} size="lg">
<UndoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Redo">
<ActionIcon onClick={handleRedo} 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={() => handleRotate('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={() => handleRotate('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={handleDelete}
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={handleSplit}
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={() => showExportPreview(true)} onClick={() => showExportPreview(true)}
>
Download Selected
</Button>
<Button
leftSection={<DownloadIcon />}
color="green"
disabled={exportLoading} disabled={exportLoading}
loading={exportLoading} color="blue"
onClick={() => showExportPreview(false)} variant="light"
size="lg"
> >
Download All <DownloadIcon />
</Button> </ActionIcon>
</Group> </Tooltip>
</Group> )}
<Tooltip label="Export All">
<ActionIcon
onClick={() => showExportPreview(false)}
disabled={exportLoading}
color="green"
variant="light"
size="lg"
>
<DownloadIcon />
</ActionIcon>
</Tooltip>
</Paper>
</div>
</Box> </Box>
<Modal <Modal
@ -890,6 +1193,19 @@ const PageEditor: React.FC<PageEditorProps> = ({
{status} {status}
</Notification> </Notification>
)} )}
{/* Multi-page drag indicator */}
{multiPageDrag && dragPosition && (
<div
className="multi-drag-indicator"
style={{
left: dragPosition.x,
top: dragPosition.y,
}}
>
{multiPageDrag.count} pages
</div>
)}
</Box> </Box>
); );
}; };

View File

@ -534,23 +534,6 @@ const Viewer: React.FC<ViewerProps> = ({
> >
{dualPage ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />} {dualPage ? <DescriptionIcon fontSize="small" /> : <ViewWeekIcon fontSize="small" />}
</Button> </Button>
<Button
variant="subtle"
color="blue"
size="md"
radius="xl"
onClick={() => setSidebarsVisible(!sidebarsVisible)}
style={{ minWidth: 36 }}
title={sidebarsVisible ? t("viewer.hideSidebars", "Hide Sidebars") : t("viewer.showSidebars", "Show Sidebars")}
>
<ViewSidebarIcon
fontSize="small"
style={{
transform: sidebarsVisible ? "none" : "scaleX(-1)",
transition: "transform 0.2s"
}}
/>
</Button>
<Group gap={4} align="center" style={{ marginLeft: 16 }}> <Group gap={4} align="center" style={{ marginLeft: 16 }}>
<Button <Button
variant="subtle" variant="subtle"

View File

@ -107,13 +107,25 @@ export default function HomePage() {
/> />
{/* Left: Tool Picker OR Selected Tool Panel */} {/* Left: Tool Picker OR Selected Tool Panel */}
{sidebarsVisible && !readerMode && (
<div <div
className={`h-screen z-sticky flex flex-col min-w-[300px] max-w-[450px] w-[25vw] ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`} className={`h-screen z-sticky flex flex-col ${isRainbowMode ? rainbowStyles.rainbowPaper : ''} overflow-hidden`}
style={{ style={{
backgroundColor: 'var(--bg-surface)', backgroundColor: 'var(--bg-surface)',
borderRight: '1px solid var(--border-subtle)', borderRight: '1px solid var(--border-subtle)',
padding: '1rem' width: sidebarsVisible && !readerMode ? '25vw' : '0px',
minWidth: sidebarsVisible && !readerMode ? '300px' : '0px',
maxWidth: sidebarsVisible && !readerMode ? '450px' : '0px',
transition: 'width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), min-width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94), max-width 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
padding: sidebarsVisible && !readerMode ? '1rem' : '0rem'
}}
>
<div
style={{
opacity: sidebarsVisible && !readerMode ? 1 : 0,
transition: 'opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
height: '100%',
display: 'flex',
flexDirection: 'column'
}} }}
> >
{leftPanelView === 'toolPicker' ? ( {leftPanelView === 'toolPicker' ? (
@ -161,12 +173,14 @@ export default function HomePage() {
</div> </div>
)} )}
</div> </div>
)} </div>
{/* Main View */} {/* Main View */}
<Box <Box
className="flex-1 h-screen min-w-80 relative flex flex-col transition-all duration-300" className="flex-1 h-screen min-w-80 relative flex flex-col"
style={{ backgroundColor: 'var(--bg-background)' }} style={{
backgroundColor: 'var(--bg-background)'
}}
> >
{/* Top Controls */} {/* Top Controls */}
<TopControls <TopControls