mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-07-30 17:15:21 +00:00
Page editor redesign
This commit is contained in:
parent
ae508730c3
commit
d981968e0f
@ -68,4 +68,21 @@
|
|||||||
.languageText {
|
.languageText {
|
||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
@ -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,22 +22,78 @@ const LanguageSelector: React.FC = () => {
|
|||||||
label: name,
|
label: name,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const handleLanguageChange = (value: string) => {
|
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
|
||||||
i18n.changeLanguage(value);
|
// Create ripple effect at click position
|
||||||
setOpened(false);
|
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] ||
|
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 (
|
||||||
<Menu
|
<>
|
||||||
opened={opened}
|
<style>
|
||||||
onChange={setOpened}
|
{`
|
||||||
width={600}
|
@keyframes ripple-expand {
|
||||||
position="bottom-start"
|
0% {
|
||||||
offset={8}
|
width: 0;
|
||||||
>
|
height: 0;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
</style>
|
||||||
|
<Menu
|
||||||
|
opened={opened}
|
||||||
|
onChange={setOpened}
|
||||||
|
width={600}
|
||||||
|
position="bottom-start"
|
||||||
|
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
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
@ -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,17 +179,40 @@ 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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -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,12 +207,35 @@ 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) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
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);
|
||||||
@ -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);
|
||||||
setSelectedPages([]);
|
if (selectionMode) {
|
||||||
setStatus(`Deleted ${selectedPages.length} pages`);
|
setSelectedPages([]);
|
||||||
}, [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,76 +616,60 @@ 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
|
||||||
value={csvInput}
|
value={csvInput}
|
||||||
onChange={(e) => setCsvInput(e.target.value)}
|
onChange={(e) => setCsvInput(e.target.value)}
|
||||||
placeholder="1,3,5-10"
|
placeholder="1,3,5-10"
|
||||||
label="Page Selection"
|
label="Page Selection"
|
||||||
onBlur={updatePagesFromCSV}
|
onBlur={updatePagesFromCSV}
|
||||||
onKeyDown={(e) => e.key === 'Enter' && updatePagesFromCSV()}
|
onKeyDown={(e) => e.key === 'Enter' && updatePagesFromCSV()}
|
||||||
style={{ flex: 1 }}
|
style={{ flex: 1 }}
|
||||||
/>
|
/>
|
||||||
<Button onClick={updatePagesFromCSV} mt="xl">
|
<Button onClick={updatePagesFromCSV} mt="xl">
|
||||||
Apply
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
{selectedPages.length > 0 && (
|
{selectedPages.length > 0 && (
|
||||||
<Text size="sm" c="dimmed" mt="sm">
|
<Text size="sm" c="dimmed" mt="sm">
|
||||||
Selected: {selectedPages.length} pages
|
Selected: {selectedPages.length} pages
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</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: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
|
||||||
transition: '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,
|
setStatus(`Moved page ${page.pageNumber} left`);
|
||||||
setPdfDocument,
|
|
||||||
page.id,
|
|
||||||
index - 1
|
|
||||||
);
|
|
||||||
executeCommand(command);
|
|
||||||
setTimeout(() => setMovingPage(null), 100);
|
|
||||||
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,
|
setStatus(`Moved page ${page.pageNumber} right`);
|
||||||
setPdfDocument,
|
|
||||||
page.id,
|
|
||||||
index + 1
|
|
||||||
);
|
|
||||||
executeCommand(command);
|
|
||||||
setTimeout(() => setMovingPage(null), 100);
|
|
||||||
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',
|
||||||
onClick={() => {
|
left: '50%',
|
||||||
setPdfDocument(null);
|
bottom: '20px',
|
||||||
setFile && setFile(null);
|
transform: 'translateX(-50%)',
|
||||||
}}
|
zIndex: 50,
|
||||||
>
|
display: 'flex',
|
||||||
Close PDF
|
justifyContent: 'center',
|
||||||
</Button>
|
pointerEvents: 'none',
|
||||||
|
background: 'transparent',
|
||||||
<Group>
|
}}
|
||||||
<Button
|
>
|
||||||
leftSection={<DownloadIcon />}
|
<Paper
|
||||||
disabled={selectedPages.length === 0 || exportLoading}
|
radius="xl"
|
||||||
loading={exportLoading}
|
shadow="lg"
|
||||||
onClick={() => showExportPreview(true)}
|
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={() => {
|
||||||
|
setPdfDocument(null);
|
||||||
|
setFile && setFile(null);
|
||||||
|
}}
|
||||||
|
color="red"
|
||||||
|
variant="light"
|
||||||
|
size="lg"
|
||||||
>
|
>
|
||||||
Download Selected
|
<CloseIcon />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
<Button
|
</Tooltip>
|
||||||
leftSection={<DownloadIcon />}
|
|
||||||
color="green"
|
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||||
disabled={exportLoading}
|
|
||||||
loading={exportLoading}
|
{/* Undo/Redo */}
|
||||||
|
<Tooltip label="Undo">
|
||||||
|
<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)}
|
||||||
|
disabled={exportLoading}
|
||||||
|
color="blue"
|
||||||
|
variant="light"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<DownloadIcon />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip label="Export All">
|
||||||
|
<ActionIcon
|
||||||
onClick={() => showExportPreview(false)}
|
onClick={() => showExportPreview(false)}
|
||||||
|
disabled={exportLoading}
|
||||||
|
color="green"
|
||||||
|
variant="light"
|
||||||
|
size="lg"
|
||||||
>
|
>
|
||||||
Download All
|
<DownloadIcon />
|
||||||
</Button>
|
</ActionIcon>
|
||||||
</Group>
|
</Tooltip>
|
||||||
</Group>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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"
|
||||||
|
@ -107,66 +107,80 @@ 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 ${isRainbowMode ? rainbowStyles.rainbowPaper : ''} overflow-hidden`}
|
||||||
className={`h-screen z-sticky flex flex-col min-w-[300px] max-w-[450px] w-[25vw] ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`}
|
style={{
|
||||||
style={{
|
backgroundColor: 'var(--bg-surface)',
|
||||||
backgroundColor: 'var(--bg-surface)',
|
borderRight: '1px solid var(--border-subtle)',
|
||||||
borderRight: '1px solid var(--border-subtle)',
|
width: sidebarsVisible && !readerMode ? '25vw' : '0px',
|
||||||
padding: '1rem'
|
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)',
|
||||||
{leftPanelView === 'toolPicker' ? (
|
padding: sidebarsVisible && !readerMode ? '1rem' : '0rem'
|
||||||
// Tool Picker View
|
}}
|
||||||
<div className="flex-1 flex flex-col">
|
>
|
||||||
<ToolPicker
|
<div
|
||||||
selectedToolKey={selectedToolKey}
|
style={{
|
||||||
onSelect={handleToolSelect}
|
opacity: sidebarsVisible && !readerMode ? 1 : 0,
|
||||||
toolRegistry={toolRegistry}
|
transition: 'opacity 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||||
/>
|
height: '100%',
|
||||||
</div>
|
display: 'flex',
|
||||||
) : (
|
flexDirection: 'column'
|
||||||
// Selected Tool Content View
|
}}
|
||||||
<div className="flex-1 flex flex-col">
|
>
|
||||||
{/* Back button */}
|
{leftPanelView === 'toolPicker' ? (
|
||||||
<div className="mb-4">
|
// Tool Picker View
|
||||||
<Button
|
<div className="flex-1 flex flex-col">
|
||||||
variant="subtle"
|
<ToolPicker
|
||||||
size="sm"
|
|
||||||
onClick={() => setLeftPanelView('toolPicker')}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
← Back to Tools
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tool title */}
|
|
||||||
<div className="mb-4">
|
|
||||||
<h2 className="text-lg font-semibold">{selectedTool?.name}</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tool content */}
|
|
||||||
<div className="flex-1 min-h-0">
|
|
||||||
<ToolRenderer
|
|
||||||
selectedToolKey={selectedToolKey}
|
selectedToolKey={selectedToolKey}
|
||||||
selectedTool={selectedTool}
|
onSelect={handleToolSelect}
|
||||||
pdfFile={pdfFile}
|
toolRegistry={toolRegistry}
|
||||||
files={files}
|
|
||||||
downloadUrl={downloadUrl}
|
|
||||||
setDownloadUrl={setDownloadUrl}
|
|
||||||
toolParams={toolParams}
|
|
||||||
updateParams={updateParams}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
)}
|
// Selected Tool Content View
|
||||||
</div>
|
<div className="flex-1 flex flex-col">
|
||||||
)}
|
{/* Back button */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<Button
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setLeftPanelView('toolPicker')}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
← Back to Tools
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool title */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<h2 className="text-lg font-semibold">{selectedTool?.name}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tool content */}
|
||||||
|
<div className="flex-1 min-h-0">
|
||||||
|
<ToolRenderer
|
||||||
|
selectedToolKey={selectedToolKey}
|
||||||
|
selectedTool={selectedTool}
|
||||||
|
pdfFile={pdfFile}
|
||||||
|
files={files}
|
||||||
|
downloadUrl={downloadUrl}
|
||||||
|
setDownloadUrl={setDownloadUrl}
|
||||||
|
toolParams={toolParams}
|
||||||
|
updateParams={updateParams}
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
Loading…
x
Reference in New Issue
Block a user