Merge pull request #3663 from reecebrowne/Stirling-2.0

Stirling 2.0
This commit is contained in:
ConnorYoh
2025-06-24 15:04:28 +01:00
committed by GitHub
45 changed files with 5861 additions and 415 deletions
+11 -2
View File
@@ -1,6 +1,15 @@
import './index.css';
import React from 'react';
import { RainbowThemeProvider } from './components/shared/RainbowThemeProvider';
import HomePage from './pages/HomePage';
// Import global styles
import './styles/tailwind.css';
import './index.css';
export default function App() {
return <HomePage/>;
return (
<RainbowThemeProvider>
<HomePage />
</RainbowThemeProvider>
);
}
+334
View File
@@ -0,0 +1,334 @@
import { Command, CommandSequence } from '../hooks/useUndoRedo';
import { PDFDocument, PDFPage } from '../types/pageEditor';
// Base class for page operations
abstract class PageCommand implements Command {
protected pdfDocument: PDFDocument;
protected setPdfDocument: (doc: PDFDocument) => void;
protected previousState: PDFDocument;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void
) {
this.pdfDocument = pdfDocument;
this.setPdfDocument = setPdfDocument;
this.previousState = JSON.parse(JSON.stringify(pdfDocument)); // Deep clone
}
abstract execute(): void;
abstract description: string;
undo(): void {
this.setPdfDocument(this.previousState);
}
}
// Rotate pages command
export class RotatePagesCommand extends PageCommand {
private pageIds: string[];
private rotation: number;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[],
rotation: number
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.rotation = rotation;
}
execute(): void {
const updatedPages = this.pdfDocument.pages.map(page => {
if (this.pageIds.includes(page.id)) {
return { ...page, rotation: page.rotation + this.rotation };
}
return page;
});
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
}
get description(): string {
const direction = this.rotation > 0 ? 'right' : 'left';
return `Rotate ${this.pageIds.length} page(s) ${direction}`;
}
}
// Delete pages command
export class DeletePagesCommand extends PageCommand {
private pageIds: string[];
private deletedPages: PDFPage[];
private deletedPositions: Map<string, number>;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[]
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.deletedPages = [];
this.deletedPositions = new Map();
}
execute(): void {
// Store deleted pages and their positions for undo
this.deletedPages = this.pdfDocument.pages.filter(page =>
this.pageIds.includes(page.id)
);
this.deletedPages.forEach(page => {
const index = this.pdfDocument.pages.findIndex(p => p.id === page.id);
this.deletedPositions.set(page.id, index);
});
const updatedPages = this.pdfDocument.pages
.filter(page => !this.pageIds.includes(page.id))
.map((page, index) => ({ ...page, pageNumber: index + 1 }));
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
undo(): void {
let restoredPages = [...this.pdfDocument.pages];
// Insert deleted pages back at their original positions
this.deletedPages
.sort((a, b) => (this.deletedPositions.get(a.id) || 0) - (this.deletedPositions.get(b.id) || 0))
.forEach(page => {
const originalIndex = this.deletedPositions.get(page.id) || 0;
restoredPages.splice(originalIndex, 0, page);
});
// Update page numbers
restoredPages = restoredPages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
this.setPdfDocument({
...this.pdfDocument,
pages: restoredPages,
totalPages: restoredPages.length
});
}
get description(): string {
return `Delete ${this.pageIds.length} page(s)`;
}
}
// Move pages command
export class MovePagesCommand extends PageCommand {
private pageIds: string[];
private targetIndex: number;
private originalIndices: Map<string, number>;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[],
targetIndex: number
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.targetIndex = targetIndex;
this.originalIndices = new Map();
}
execute(): void {
// Store original positions
this.pageIds.forEach(pageId => {
const index = this.pdfDocument.pages.findIndex(p => p.id === pageId);
this.originalIndices.set(pageId, index);
});
let newPages = [...this.pdfDocument.pages];
const pagesToMove = this.pageIds
.map(id => this.pdfDocument.pages.find(p => p.id === id))
.filter((page): page is PDFPage => page !== undefined);
// Remove pages to move
newPages = newPages.filter(page => !this.pageIds.includes(page.id));
// Insert pages at target position
newPages.splice(this.targetIndex, 0, ...pagesToMove);
// Update page numbers
newPages = newPages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
this.setPdfDocument({ ...this.pdfDocument, pages: newPages });
}
get description(): string {
return `Move ${this.pageIds.length} page(s)`;
}
}
// Reorder single page command (for drag-and-drop)
export class ReorderPageCommand extends PageCommand {
private pageId: string;
private targetIndex: number;
private originalIndex: number;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageId: string,
targetIndex: number
) {
super(pdfDocument, setPdfDocument);
this.pageId = pageId;
this.targetIndex = targetIndex;
this.originalIndex = pdfDocument.pages.findIndex(p => p.id === pageId);
}
execute(): void {
const newPages = [...this.pdfDocument.pages];
const [movedPage] = newPages.splice(this.originalIndex, 1);
newPages.splice(this.targetIndex, 0, movedPage);
// Update page numbers
const updatedPages = newPages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
}
get description(): string {
return `Reorder page ${this.originalIndex + 1} to position ${this.targetIndex + 1}`;
}
}
// Toggle split markers command
export class ToggleSplitCommand extends PageCommand {
private pageIds: string[];
private previousSplitStates: Map<string, boolean>;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
pageIds: string[]
) {
super(pdfDocument, setPdfDocument);
this.pageIds = pageIds;
this.previousSplitStates = new Map();
}
execute(): void {
// Store previous split states
this.pageIds.forEach(pageId => {
const page = this.pdfDocument.pages.find(p => p.id === pageId);
if (page) {
this.previousSplitStates.set(pageId, !!page.splitBefore);
}
});
const updatedPages = this.pdfDocument.pages.map(page => {
if (this.pageIds.includes(page.id)) {
return { ...page, splitBefore: !page.splitBefore };
}
return page;
});
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
}
undo(): void {
const updatedPages = this.pdfDocument.pages.map(page => {
if (this.pageIds.includes(page.id)) {
const previousState = this.previousSplitStates.get(page.id);
return { ...page, splitBefore: previousState };
}
return page;
});
this.setPdfDocument({ ...this.pdfDocument, pages: updatedPages });
}
get description(): string {
return `Toggle split markers for ${this.pageIds.length} page(s)`;
}
}
// Add pages command (for inserting new files)
export class AddPagesCommand extends PageCommand {
private newPages: PDFPage[];
private insertIndex: number;
constructor(
pdfDocument: PDFDocument,
setPdfDocument: (doc: PDFDocument) => void,
newPages: PDFPage[],
insertIndex: number = -1 // -1 means append to end
) {
super(pdfDocument, setPdfDocument);
this.newPages = newPages;
this.insertIndex = insertIndex === -1 ? pdfDocument.pages.length : insertIndex;
}
execute(): void {
const newPagesArray = [...this.pdfDocument.pages];
newPagesArray.splice(this.insertIndex, 0, ...this.newPages);
// Update page numbers for all pages
const updatedPages = newPagesArray.map((page, index) => ({
...page,
pageNumber: index + 1
}));
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
undo(): void {
const updatedPages = this.pdfDocument.pages
.filter(page => !this.newPages.some(newPage => newPage.id === page.id))
.map((page, index) => ({ ...page, pageNumber: index + 1 }));
this.setPdfDocument({
...this.pdfDocument,
pages: updatedPages,
totalPages: updatedPages.length
});
}
get description(): string {
return `Add ${this.newPages.length} page(s)`;
}
}
// Command sequence for bulk operations
export class PageCommandSequence implements CommandSequence {
commands: Command[];
description: string;
constructor(commands: Command[], description?: string) {
this.commands = commands;
this.description = description || `Execute ${commands.length} operations`;
}
execute(): void {
this.commands.forEach(command => command.execute());
}
undo(): void {
// Undo in reverse order
[...this.commands].reverse().forEach(command => command.undo());
}
}
-44
View File
@@ -1,44 +0,0 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { Button, Stack, Text, Group } from '@mantine/core';
const DeepLinks: React.FC = () => {
const commonLinks = [
{
name: "Split PDF Pages 1-5",
url: "/?tool=split&splitMode=byPages&pages=1-5&view=viewer",
description: "Split a PDF and extract pages 1-5"
},
{
name: "Compress PDF (High)",
url: "/?tool=compress&level=9&grayscale=true&view=viewer",
description: "Compress a PDF with high compression level"
},
{
name: "Merge PDFs",
url: "/?tool=merge&view=fileManager",
description: "Combine multiple PDF files into one"
}
];
return (
<Stack>
<Text fw={500}>Common PDF Operations</Text>
{commonLinks.map((link, index) => (
<Group key={index}>
<Button
component={Link}
to={link.url}
variant="subtle"
size="sm"
>
{link.name}
</Button>
<Text size="sm" color="dimmed">{link.description}</Text>
</Group>
))}
</Stack>
);
};
export default DeepLinks;
@@ -1,125 +0,0 @@
import React, { useState } from 'react';
import { Menu, Button, ScrollArea, useMantineTheme, useMantineColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../i18n';
import LanguageIcon from '@mui/icons-material/Language';
import styles from './LanguageSelector.module.css';
const LanguageSelector: React.FC = () => {
const { i18n } = useTranslation();
const theme = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
const [opened, setOpened] = useState(false);
const languageOptions = Object.entries(supportedLanguages)
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([code, name]) => ({
value: code,
label: name,
}));
const handleLanguageChange = (value: string) => {
i18n.changeLanguage(value);
setOpened(false);
};
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
supportedLanguages['en-GB'];
return (
<Menu
opened={opened}
onChange={setOpened}
width={600}
position="bottom-start"
offset={8}
>
<Menu.Target>
<Button
variant="subtle"
size="sm"
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
styles={{
root: {
border: 'none',
color: colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7],
'&:hover': {
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
}
},
label: {
fontSize: '12px',
fontWeight: 500,
}
}}
>
<span className={styles.languageText}>
{currentLanguage}
</span>
</Button>
</Menu.Target>
<Menu.Dropdown
style={{
padding: '12px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
border: colorScheme === 'dark' ? `1px solid ${theme.colors.dark[4]}` : `1px solid ${theme.colors.gray[3]}`,
}}
>
<ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}>
{languageOptions.map((option) => (
<div
key={option.value}
className={styles.languageItem}
>
<Button
variant="subtle"
size="sm"
fullWidth
onClick={() => handleLanguageChange(option.value)}
styles={{
root: {
borderRadius: '4px',
minHeight: '32px',
padding: '4px 8px',
justifyContent: 'flex-start',
backgroundColor: option.value === i18n.language ? (
colorScheme === 'dark' ? theme.colors.blue[8] : theme.colors.blue[1]
) : 'transparent',
color: option.value === i18n.language ? (
colorScheme === 'dark' ? theme.colors.blue[2] : theme.colors.blue[7]
) : (
colorScheme === 'dark' ? theme.colors.gray[3] : theme.colors.gray[7]
),
'&:hover': {
backgroundColor: option.value === i18n.language ? (
colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2]
) : (
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1]
),
}
},
label: {
fontSize: '13px',
fontWeight: option.value === i18n.language ? 600 : 400,
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}
}}
>
{option.label}
</Button>
</div>
))}
</div>
</ScrollArea>
</Menu.Dropdown>
</Menu>
);
};
export default LanguageSelector;
-198
View File
@@ -1,198 +0,0 @@
import React, { useState } from "react";
import {
Paper, Button, Group, Text, Stack, Center, Checkbox, ScrollArea, Box, Tooltip, ActionIcon, Notification
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import AddIcon from "@mui/icons-material/Add";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import DownloadIcon from "@mui/icons-material/Download";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import ArrowBackIosNewIcon from "@mui/icons-material/ArrowBackIosNew";
import ArrowForwardIosIcon from "@mui/icons-material/ArrowForwardIos";
export interface PageEditorProps {
file: { file: File; url: string } | null;
setFile?: (file: { file: File; url: string } | null) => void;
downloadUrl?: string | null;
setDownloadUrl?: (url: string | null) => void;
}
const DUMMY_PAGE_COUNT = 8; // Replace with real page count from PDF
const PageEditor: React.FC<PageEditorProps> = ({
file,
setFile,
downloadUrl,
setDownloadUrl,
}) => {
const { t } = useTranslation();
const [selectedPages, setSelectedPages] = useState<number[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [undoStack, setUndoStack] = useState<number[][]>([]);
const [redoStack, setRedoStack] = useState<number[][]>([]);
// Dummy page thumbnails
const pages = Array.from({ length: DUMMY_PAGE_COUNT }, (_, i) => i + 1);
const selectAll = () => setSelectedPages(pages);
const deselectAll = () => setSelectedPages([]);
const togglePage = (page: number) =>
setSelectedPages((prev) =>
prev.includes(page) ? prev.filter((p) => p !== page) : [...prev, page]
);
// Undo/redo logic for selection
const handleUndo = () => {
if (undoStack.length > 0) {
setRedoStack([selectedPages, ...redoStack]);
setSelectedPages(undoStack[0]);
setUndoStack(undoStack.slice(1));
}
};
const handleRedo = () => {
if (redoStack.length > 0) {
setUndoStack([selectedPages, ...undoStack]);
setSelectedPages(redoStack[0]);
setRedoStack(redoStack.slice(1));
}
};
// Example action handlers (replace with real API calls)
const handleRotateLeft = () => setStatus(t("pageEditor.rotatedLeft", "Rotated left: ") + selectedPages.join(", "));
const handleRotateRight = () => setStatus(t("pageEditor.rotatedRight", "Rotated right: ") + selectedPages.join(", "));
const handleDelete = () => setStatus(t("pageEditor.deleted", "Deleted: ") + selectedPages.join(", "));
const handleMoveLeft = () => setStatus(t("pageEditor.movedLeft", "Moved left: ") + selectedPages.join(", "));
const handleMoveRight = () => setStatus(t("pageEditor.movedRight", "Moved right: ") + selectedPages.join(", "));
const handleSplit = () => setStatus(t("pageEditor.splitAt", "Split at: ") + selectedPages.join(", "));
const handleInsertPageBreak = () => setStatus(t("pageEditor.insertedPageBreak", "Inserted page break at: ") + selectedPages.join(", "));
const handleAddFile = () => setStatus(t("pageEditor.addFileNotImplemented", "Add file not implemented in demo"));
if (!file) {
return (
<Paper shadow="xs" radius="md" p="md">
<Center>
<Text color="dimmed">{t("pageEditor.noPdfLoaded", "No PDF loaded. Please upload a PDF to edit.")}</Text>
</Center>
</Paper>
);
}
return (
<Paper shadow="xs" radius="md" p="md">
<Group align="flex-start" gap="lg">
{/* Sidebar */}
<Stack w={180} gap="xs">
<Text fw={600} size="lg">{t("pageEditor.title", "PDF Multitool")}</Text>
<Button onClick={selectAll} fullWidth variant="light">{t("multiTool.selectAll", "Select All")}</Button>
<Button onClick={deselectAll} fullWidth variant="light">{t("multiTool.deselectAll", "Deselect All")}</Button>
<Button onClick={handleUndo} leftSection={<UndoIcon fontSize="small" />} fullWidth disabled={undoStack.length === 0}>{t("multiTool.undo", "Undo")}</Button>
<Button onClick={handleRedo} leftSection={<RedoIcon fontSize="small" />} fullWidth disabled={redoStack.length === 0}>{t("multiTool.redo", "Redo")}</Button>
<Button onClick={handleAddFile} leftSection={<AddIcon fontSize="small" />} fullWidth>{t("multiTool.addFile", "Add File")}</Button>
<Button onClick={handleInsertPageBreak} leftSection={<ContentCutIcon fontSize="small" />} fullWidth>{t("multiTool.insertPageBreak", "Insert Page Break")}</Button>
<Button onClick={handleSplit} leftSection={<ContentCutIcon fontSize="small" />} fullWidth>{t("multiTool.split", "Split")}</Button>
<Button
component="a"
href={downloadUrl || "#"}
download="edited.pdf"
leftSection={<DownloadIcon fontSize="small" />}
fullWidth
color="green"
variant="light"
disabled={!downloadUrl}
>
{t("multiTool.downloadAll", "Download All")}
</Button>
<Button
component="a"
href={downloadUrl || "#"}
download="selected.pdf"
leftSection={<DownloadIcon fontSize="small" />}
fullWidth
color="blue"
variant="light"
disabled={!downloadUrl || selectedPages.length === 0}
>
{t("multiTool.downloadSelected", "Download Selected")}
</Button>
<Button
color="red"
variant="light"
onClick={() => setFile && setFile(null)}
fullWidth
>
{t("pageEditor.closePdf", "Close PDF")}
</Button>
</Stack>
{/* Main multitool area */}
<Box style={{ flex: 1 }}>
<Group mb="sm">
<Tooltip label={t("multiTool.rotateLeft", "Rotate Left")}>
<ActionIcon onClick={handleRotateLeft} disabled={selectedPages.length === 0} color="blue" variant="light">
<RotateLeftIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={t("multiTool.rotateRight", "Rotate Right")}>
<ActionIcon onClick={handleRotateRight} disabled={selectedPages.length === 0} color="blue" variant="light">
<RotateRightIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={t("delete", "Delete")}>
<ActionIcon onClick={handleDelete} disabled={selectedPages.length === 0} color="red" variant="light">
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={t("multiTool.moveLeft", "Move Left")}>
<ActionIcon onClick={handleMoveLeft} disabled={selectedPages.length === 0} color="gray" variant="light">
<ArrowBackIosNewIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={t("multiTool.moveRight", "Move Right")}>
<ActionIcon onClick={handleMoveRight} disabled={selectedPages.length === 0} color="gray" variant="light">
<ArrowForwardIosIcon />
</ActionIcon>
</Tooltip>
</Group>
<ScrollArea h={350}>
<Group>
{pages.map((page) => (
<Stack key={page} align="center" gap={2}>
<Checkbox
checked={selectedPages.includes(page)}
onChange={() => togglePage(page)}
label={t("page", "Page") + ` ${page}`}
/>
<Box
w={60}
h={80}
bg={selectedPages.includes(page) ? "blue.1" : "gray.1"}
style={{ border: "1px solid #ccc", borderRadius: 4 }}
>
{/* Replace with real thumbnail */}
<Center h="100%">
<Text size="xs" color="dimmed">
{page}
</Text>
</Center>
</Box>
</Stack>
))}
</Group>
</ScrollArea>
</Box>
</Group>
{status && (
<Notification color="blue" mt="md" onClose={() => setStatus(null)}>
{status}
</Notification>
)}
</Paper>
);
};
export default PageEditor;
@@ -0,0 +1,42 @@
import React from 'react';
import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
interface BulkSelectionPanelProps {
csvInput: string;
setCsvInput: (value: string) => void;
selectedPages: string[];
onUpdatePagesFromCSV: () => void;
}
const BulkSelectionPanel = ({
csvInput,
setCsvInput,
selectedPages,
onUpdatePagesFromCSV,
}: BulkSelectionPanelProps) => {
return (
<Paper p="md" mb="md" withBorder>
<Group>
<TextInput
value={csvInput}
onChange={(e) => setCsvInput(e.target.value)}
placeholder="1,3,5-10"
label="Page Selection"
onBlur={onUpdatePagesFromCSV}
onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()}
style={{ flex: 1 }}
/>
<Button onClick={onUpdatePagesFromCSV} mt="xl">
Apply
</Button>
</Group>
{selectedPages.length > 0 && (
<Text size="sm" c="dimmed" mt="sm">
Selected: {selectedPages.length} pages
</Text>
)}
</Paper>
);
};
export default BulkSelectionPanel;
@@ -0,0 +1,515 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import {
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
Stack, Group
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { fileStorage } from '../../services/fileStorage';
import { generateThumbnailForFile } from '../../utils/thumbnailUtils';
import styles from './PageEditor.module.css';
import FileThumbnail from './FileThumbnail';
import BulkSelectionPanel from './BulkSelectionPanel';
import DragDropGrid from './shared/DragDropGrid';
import FilePickerModal from '../shared/FilePickerModal';
interface FileItem {
id: string;
name: string;
pageCount: number;
thumbnail: string;
size: number;
file: File;
splitBefore?: boolean;
}
interface FileEditorProps {
onOpenPageEditor?: (file: File) => void;
onMergeFiles?: (files: File[]) => void;
sharedFiles?: { file: File; url: string }[];
setSharedFiles?: (files: { file: File; url: string }[]) => void;
preSelectedFiles?: { file: File; url: string }[];
onClearPreSelection?: () => void;
}
const FileEditor = ({
onOpenPageEditor,
onMergeFiles,
sharedFiles = [],
setSharedFiles,
preSelectedFiles = [],
onClearPreSelection
}: FileEditorProps) => {
const { t } = useTranslation();
const files = sharedFiles; // Use sharedFiles as the source of truth
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [csvInput, setCsvInput] = useState<string>('');
const [selectionMode, setSelectionMode] = useState(false);
const [draggedFile, setDraggedFile] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [multiFileDrag, setMultiFileDrag] = useState<{fileIds: string[], count: number} | null>(null);
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Convert shared files to FileEditor format
const convertToFileItem = useCallback(async (sharedFile: any): Promise<FileItem> => {
// Generate thumbnail if not already available
const thumbnail = sharedFile.thumbnail || await generateThumbnailForFile(sharedFile.file || sharedFile);
return {
id: sharedFile.id || `file-${Date.now()}-${Math.random()}`,
name: (sharedFile.file?.name || sharedFile.name || 'unknown').replace(/\.pdf$/i, ''),
pageCount: sharedFile.pageCount || Math.floor(Math.random() * 20) + 1, // Mock for now
thumbnail,
size: sharedFile.file?.size || sharedFile.size || 0,
file: sharedFile.file || sharedFile,
};
}, []);
// Only load shared files when explicitly passed (not on mount)
useEffect(() => {
const loadSharedFiles = async () => {
// Only load if we have pre-selected files (coming from FileManager)
if (preSelectedFiles.length > 0) {
setLoading(true);
try {
const convertedFiles = await Promise.all(
preSelectedFiles.map(convertToFileItem)
);
setFiles(convertedFiles);
} catch (err) {
console.error('Error converting pre-selected files:', err);
} finally {
setLoading(false);
}
}
};
loadSharedFiles();
}, [preSelectedFiles, convertToFileItem]);
// Handle pre-selected files
useEffect(() => {
if (preSelectedFiles.length > 0) {
const preSelectedIds = preSelectedFiles.map(f => f.id || f.name);
setSelectedFiles(preSelectedIds);
onClearPreSelection?.();
}
}, [preSelectedFiles, onClearPreSelection]);
// Process uploaded files
const handleFileUpload = useCallback(async (uploadedFiles: File[]) => {
setLoading(true);
setError(null);
try {
const newFiles: FileItem[] = [];
for (const file of uploadedFiles) {
if (file.type !== 'application/pdf') {
setError('Please upload only PDF files');
continue;
}
// Generate thumbnail and get page count
const thumbnail = await generateThumbnailForFile(file);
const fileItem: FileItem = {
id: `file-${Date.now()}-${Math.random()}`,
name: file.name.replace(/\.pdf$/i, ''),
pageCount: Math.floor(Math.random() * 20) + 1, // Mock page count
thumbnail,
size: file.size,
file,
};
newFiles.push(fileItem);
// Store in IndexedDB
await fileStorage.storeFile(file, thumbnail);
}
if (setSharedFiles) {
setSharedFiles(prev => [...prev, ...newFiles]);
}
setStatus(`Added ${newFiles.length} files`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process files';
setError(errorMessage);
console.error('File processing error:', err);
} finally {
setLoading(false);
}
}, [setSharedFiles]);
const selectAll = useCallback(() => {
setSelectedFiles(files.map(f => f.id));
}, [files]);
const deselectAll = useCallback(() => setSelectedFiles([]), []);
const toggleFile = useCallback((fileId: string) => {
setSelectedFiles(prev =>
prev.includes(fileId)
? prev.filter(id => id !== fileId)
: [...prev, fileId]
);
}, []);
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
const newMode = !prev;
if (!newMode) {
setSelectedFiles([]);
setCsvInput('');
}
return newMode;
});
}, []);
const parseCSVInput = useCallback((csv: string) => {
const fileIds: string[] = [];
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
ranges.forEach(range => {
if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
for (let i = start; i <= end && i <= files.length; i++) {
if (i > 0) {
const file = files[i - 1];
if (file) fileIds.push(file.id);
}
}
} else {
const fileIndex = parseInt(range);
if (fileIndex > 0 && fileIndex <= files.length) {
const file = files[fileIndex - 1];
if (file) fileIds.push(file.id);
}
}
});
return fileIds;
}, [files]);
const updateFilesFromCSV = useCallback(() => {
const fileIds = parseCSVInput(csvInput);
setSelectedFiles(fileIds);
}, [csvInput, parseCSVInput]);
// Drag and drop handlers
const handleDragStart = useCallback((fileId: string) => {
setDraggedFile(fileId);
if (selectionMode && selectedFiles.includes(fileId) && selectedFiles.length > 1) {
setMultiFileDrag({
fileIds: selectedFiles,
count: selectedFiles.length
});
} else {
setMultiFileDrag(null);
}
}, [selectionMode, selectedFiles]);
const handleDragEnd = useCallback(() => {
setDraggedFile(null);
setDropTarget(null);
setMultiFileDrag(null);
setDragPosition(null);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!draggedFile) return;
if (multiFileDrag) {
setDragPosition({ x: e.clientX, y: e.clientY });
}
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
if (!elementUnderCursor) return;
const fileContainer = elementUnderCursor.closest('[data-file-id]');
if (fileContainer) {
const fileId = fileContainer.getAttribute('data-file-id');
if (fileId && fileId !== draggedFile) {
setDropTarget(fileId);
return;
}
}
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
if (endZone) {
setDropTarget('end');
return;
}
setDropTarget(null);
}, [draggedFile, multiFileDrag]);
const handleDragEnter = useCallback((fileId: string) => {
if (draggedFile && fileId !== draggedFile) {
setDropTarget(fileId);
}
}, [draggedFile]);
const handleDragLeave = useCallback(() => {
// Let dragover handle this
}, []);
const handleDrop = useCallback((e: React.DragEvent, targetFileId: string | 'end') => {
e.preventDefault();
if (!draggedFile || draggedFile === targetFileId) return;
let targetIndex: number;
if (targetFileId === 'end') {
targetIndex = files.length;
} else {
targetIndex = files.findIndex(f => f.id === targetFileId);
if (targetIndex === -1) return;
}
const filesToMove = selectionMode && selectedFiles.includes(draggedFile)
? selectedFiles
: [draggedFile];
if (setSharedFiles) {
setSharedFiles(prev => {
const newFiles = [...prev];
const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean);
// Remove moved files
filesToMove.forEach(id => {
const index = newFiles.findIndex(f => f.id === id);
if (index !== -1) newFiles.splice(index, 1);
});
// Insert at target position
newFiles.splice(targetIndex, 0, ...movedFiles);
return newFiles;
});
}
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
handleDragEnd();
}, [draggedFile, files, selectionMode, selectedFiles, multiFileDrag, handleDragEnd, setSharedFiles]);
const handleEndZoneDragEnter = useCallback(() => {
if (draggedFile) {
setDropTarget('end');
}
}, [draggedFile]);
// File operations
const handleDeleteFile = useCallback((fileId: string) => {
if (setSharedFiles) {
setSharedFiles(prev => prev.filter(f => f.id !== fileId));
}
setSelectedFiles(prev => prev.filter(id => id !== fileId));
}, [setSharedFiles]);
const handleViewFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
if (file && onOpenPageEditor) {
onOpenPageEditor(file.file);
}
}, [files, onOpenPageEditor]);
const handleMergeFromHere = useCallback((fileId: string) => {
const startIndex = files.findIndex(f => f.id === fileId);
if (startIndex === -1) return;
const filesToMerge = files.slice(startIndex).map(f => f.file);
if (onMergeFiles) {
onMergeFiles(filesToMerge);
}
}, [files, onMergeFiles]);
const handleSplitFile = useCallback((fileId: string) => {
const file = files.find(f => f.id === fileId);
if (file && onOpenPageEditor) {
onOpenPageEditor(file.file);
}
}, [files, onOpenPageEditor]);
const handleLoadFromStorage = useCallback(async (selectedFiles: any[]) => {
if (selectedFiles.length === 0) return;
setLoading(true);
try {
const convertedFiles = await Promise.all(
selectedFiles.map(convertToFileItem)
);
setFiles(prev => [...prev, ...convertedFiles]);
setStatus(`Loaded ${selectedFiles.length} files from storage`);
} catch (err) {
console.error('Error loading files from storage:', err);
setError('Failed to load some files from storage');
} finally {
setLoading(false);
}
}, [convertToFileItem]);
return (
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={loading} />
<Box p="md" pt="xl">
<Group mb="md">
<Button
onClick={toggleSelectionMode}
variant={selectionMode ? "filled" : "outline"}
color={selectionMode ? "blue" : "gray"}
styles={{
root: {
transition: 'all 0.2s ease',
...(selectionMode && {
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
})
}
}}
>
{selectionMode ? "Exit Selection" : "Select Files"}
</Button>
{selectionMode && (
<>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
</>
)}
{/* Load from storage and upload buttons */}
<Button
variant="outline"
color="blue"
onClick={() => setShowFilePickerModal(true)}
>
Load from Storage
</Button>
<Dropzone
onDrop={handleFileUpload}
accept={["application/pdf"]}
multiple={true}
maxSize={2 * 1024 * 1024 * 1024}
style={{ display: 'contents' }}
>
<Button variant="outline" color="green">
Upload Files
</Button>
</Dropzone>
</Group>
{selectionMode && (
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPages={selectedFiles}
onUpdatePagesFromCSV={updateFilesFromCSV}
/>
)}
<DragDropGrid
items={files}
selectedItems={selectedFiles}
selectionMode={selectionMode}
isAnimating={isAnimating}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onEndZoneDragEnter={handleEndZoneDragEnter}
draggedItem={draggedFile}
dropTarget={dropTarget}
multiItemDrag={multiFileDrag}
dragPosition={dragPosition}
renderItem={(file, index, refs) => (
<FileThumbnail
file={file}
index={index}
totalFiles={files.length}
selectedFiles={selectedFiles}
selectionMode={selectionMode}
draggedFile={draggedFile}
dropTarget={dropTarget}
isAnimating={isAnimating}
fileRefs={refs}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onToggleFile={toggleFile}
onDeleteFile={handleDeleteFile}
onViewFile={handleViewFile}
onMergeFromHere={handleMergeFromHere}
onSplitFile={handleSplitFile}
onSetStatus={setStatus}
/>
)}
renderSplitMarker={(file, index) => (
<div
style={{
width: '2px',
height: '24rem',
borderLeft: '2px dashed #3b82f6',
backgroundColor: 'transparent',
marginLeft: '-0.75rem',
marginRight: '-0.75rem',
flexShrink: 0
}}
/>
)}
/>
</Box>
{/* File Picker Modal */}
<FilePickerModal
opened={showFilePickerModal}
onClose={() => setShowFilePickerModal(false)}
sharedFiles={sharedFiles || []}
onSelectFiles={handleLoadFromStorage}
/>
{status && (
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
>
{status}
</Notification>
)}
{error && (
<Notification
color="red"
mt="md"
onClose={() => setError(null)}
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
>
{error}
</Notification>
)}
</Box>
);
};
export default FileEditor;
@@ -0,0 +1,327 @@
import React from 'react';
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
import DeleteIcon from '@mui/icons-material/Delete';
import VisibilityIcon from '@mui/icons-material/Visibility';
import MergeIcon from '@mui/icons-material/Merge';
import SplitscreenIcon from '@mui/icons-material/Splitscreen';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import styles from './PageEditor.module.css';
interface FileItem {
id: string;
name: string;
pageCount: number;
thumbnail: string;
size: number;
splitBefore?: boolean;
}
interface FileThumbnailProps {
file: FileItem;
index: number;
totalFiles: number;
selectedFiles: string[];
selectionMode: boolean;
draggedFile: string | null;
dropTarget: string | null;
isAnimating: boolean;
fileRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
onDragStart: (fileId: string) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (fileId: string) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, fileId: string) => void;
onToggleFile: (fileId: string) => void;
onDeleteFile: (fileId: string) => void;
onViewFile: (fileId: string) => void;
onMergeFromHere: (fileId: string) => void;
onSplitFile: (fileId: string) => void;
onSetStatus: (status: string) => void;
}
const FileThumbnail = ({
file,
index,
totalFiles,
selectedFiles,
selectionMode,
draggedFile,
dropTarget,
isAnimating,
fileRefs,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onToggleFile,
onDeleteFile,
onViewFile,
onMergeFromHere,
onSplitFile,
onSetStatus,
}: FileThumbnailProps) => {
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
return (
<div
ref={(el) => {
if (el) {
fileRefs.current.set(file.id, el);
} else {
fileRefs.current.delete(file.id);
}
}}
data-file-id={file.id}
className={`
${styles.pageContainer}
!rounded-lg
cursor-grab
select-none
w-[20rem]
h-[24rem]
flex flex-col items-center justify-center
flex-shrink-0
shadow-sm
hover:shadow-md
transition-all
relative
${selectionMode
? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'}
${draggedFile === file.id ? 'opacity-50 scale-95' : ''}
`}
style={{
transform: (() => {
if (!isAnimating && draggedFile && file.id !== draggedFile && dropTarget === file.id) {
return 'translateX(20px)';
}
return 'translateX(0)';
})(),
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}}
draggable
onDragStart={() => onDragStart(file.id)}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragEnter={() => onDragEnter(file.id)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, file.id)}
>
{selectionMode && (
<div
className={styles.checkboxContainer}
style={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 4,
backgroundColor: 'white',
borderRadius: '4px',
padding: '2px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'auto'
}}
onMouseDown={(e) => e.stopPropagation()}
onDragStart={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Checkbox
checked={selectedFiles.includes(file.id)}
onChange={(event) => {
event.stopPropagation();
onToggleFile(file.id);
}}
onClick={(e) => e.stopPropagation()}
size="sm"
/>
</div>
)}
{/* File content area */}
<div className="file-container w-[90%] h-[80%] relative">
{/* Stacked file effect - multiple shadows to simulate pages */}
<div
style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 6,
border: '1px solid var(--mantine-color-gray-3)',
padding: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
boxShadow: '2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)'
}}
>
<img
src={file.thumbnail}
alt={file.name}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: 2,
}}
/>
</div>
{/* Page count badge */}
<Badge
size="sm"
variant="filled"
color="blue"
style={{
position: 'absolute',
top: 8,
left: 8,
zIndex: 3,
}}
>
{file.pageCount} pages
</Badge>
{/* File name overlay */}
<Text
className={styles.pageNumber}
size="xs"
fw={500}
c="white"
style={{
position: 'absolute',
bottom: 5,
left: 5,
right: 5,
background: 'rgba(0, 0, 0, 0.8)',
padding: '4px 6px',
borderRadius: 4,
zIndex: 2,
opacity: 0,
transition: 'opacity 0.2s ease-in-out',
textOverflow: 'ellipsis',
overflow: 'hidden',
whiteSpace: 'nowrap'
}}
>
{file.name}
</Text>
{/* Hover controls */}
<div
className={styles.pageHoverControls}
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
background: 'rgba(0, 0, 0, 0.8)',
padding: '8px 12px',
borderRadius: 20,
opacity: 0,
transition: 'opacity 0.2s ease-in-out',
zIndex: 3,
display: 'flex',
gap: '8px',
alignItems: 'center',
whiteSpace: 'nowrap'
}}
>
<Tooltip label="View File">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
onViewFile(file.id);
onSetStatus(`Opened ${file.name}`);
}}
>
<VisibilityIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Merge from here">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
onMergeFromHere(file.id);
onSetStatus(`Starting merge from ${file.name}`);
}}
>
<MergeIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Split File">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
onSplitFile(file.id);
onSetStatus(`Opening ${file.name} in page editor`);
}}
>
<SplitscreenIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete File">
<ActionIcon
size="md"
variant="subtle"
c="red"
onClick={(e) => {
e.stopPropagation();
onDeleteFile(file.id);
onSetStatus(`Deleted ${file.name}`);
}}
>
<DeleteIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
</div>
<DragIndicatorIcon
style={{
position: 'absolute',
bottom: 4,
right: 4,
color: 'rgba(0,0,0,0.3)',
fontSize: 16,
zIndex: 1
}}
/>
</div>
{/* File info */}
<div className="w-full px-4 py-2 text-center">
<Text size="sm" fw={500} truncate>
{file.name}
</Text>
<Text size="xs" c="dimmed">
{formatFileSize(file.size)}
</Text>
</div>
</div>
);
};
export default FileThumbnail;
@@ -0,0 +1,63 @@
/* Page container hover effects */
.pageContainer {
transition: transform 0.2s ease-in-out;
}
.pageContainer:hover {
transform: scale(1.02);
}
.pageContainer:hover .pageNumber {
opacity: 1 !important;
}
.pageContainer:hover .pageHoverControls {
opacity: 1 !important;
}
/* Checkbox container - prevent transform inheritance */
.checkboxContainer {
transform: none !important;
transition: none !important;
}
/* Page movement animations */
.pageMoveAnimation {
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.pageMoving {
z-index: 10;
transform: scale(1.05);
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
/* Multi-page drag indicator */
.multiDragIndicator {
position: fixed;
background: rgba(59, 130, 246, 0.9);
color: white;
padding: 8px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
pointer-events: none;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
transform: translate(-50%, -50%);
backdrop-filter: blur(4px);
}
/* Animations */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
.pulse {
animation: pulse 1s infinite;
}
@@ -0,0 +1,808 @@
import React, { useState, useCallback, useRef, useEffect } from "react";
import {
Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon,
Notification, TextInput, FileInput, LoadingOverlay, Modal, Alert, Container,
Stack, Group, Paper, SimpleGrid
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import UploadFileIcon from "@mui/icons-material/UploadFile";
import { usePDFProcessor } from "../../hooks/usePDFProcessor";
import { PDFDocument, PDFPage } from "../../types/pageEditor";
import { fileStorage } from "../../services/fileStorage";
import { generateThumbnailForFile } from "../../utils/thumbnailUtils";
import { useUndoRedo } from "../../hooks/useUndoRedo";
import {
RotatePagesCommand,
DeletePagesCommand,
ReorderPageCommand,
MovePagesCommand,
ToggleSplitCommand
} from "../../commands/pageCommands";
import { pdfExportService } from "../../services/pdfExportService";
import styles from './PageEditor.module.css';
import PageThumbnail from './PageThumbnail';
import BulkSelectionPanel from './BulkSelectionPanel';
import DragDropGrid from './shared/DragDropGrid';
import FilePickerModal from '../shared/FilePickerModal';
import FileUploadSelector from '../shared/FileUploadSelector';
export interface PageEditorProps {
file: { file: File; url: string } | null;
setFile?: (file: { file: File; url: string } | null) => void;
downloadUrl?: string | null;
setDownloadUrl?: (url: string | null) => void;
sharedFiles?: { file: File; url: string }[];
// Optional callbacks to expose internal functions
onFunctionsReady?: (functions: {
handleUndo: () => void;
handleRedo: () => void;
canUndo: boolean;
canRedo: boolean;
handleRotate: (direction: 'left' | 'right') => void;
handleDelete: () => void;
handleSplit: () => void;
showExportPreview: (selectedOnly: boolean) => void;
exportLoading: boolean;
selectionMode: boolean;
selectedPages: string[];
closePdf: () => void;
}) => void;
}
const PageEditor = ({
file,
setFile,
downloadUrl,
setDownloadUrl,
onFunctionsReady,
sharedFiles,
}: PageEditorProps) => {
const { t } = useTranslation();
const { processPDFFile, loading: pdfLoading } = usePDFProcessor();
const [pdfDocument, setPdfDocument] = useState<PDFDocument | null>(null);
const [selectedPages, setSelectedPages] = useState<string[]>([]);
const [status, setStatus] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [csvInput, setCsvInput] = useState<string>("");
const [selectionMode, setSelectionMode] = useState(false);
const [filename, setFilename] = useState<string>("");
const [draggedPage, setDraggedPage] = useState<string | null>(null);
const [dropTarget, setDropTarget] = useState<string | null>(null);
const [multiPageDrag, setMultiPageDrag] = useState<{pageIds: string[], count: number} | null>(null);
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
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<string | null>(null);
const [pagePositions, setPagePositions] = useState<Map<string, { x: number; y: number }>>(new Map());
const [isAnimating, setIsAnimating] = useState(false);
const pageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const fileInputRef = useRef<() => void>(null);
// Undo/Redo system
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
// Process uploaded file
const handleFileUpload = useCallback(async (uploadedFile: File | any) => {
if (!uploadedFile) {
setError('No file provided');
return;
}
let fileToProcess: File;
// Handle FileWithUrl objects from storage
if (uploadedFile.storedInIndexedDB && uploadedFile.arrayBuffer) {
try {
console.log('Converting FileWithUrl to File:', uploadedFile.name);
const arrayBuffer = await uploadedFile.arrayBuffer();
const blob = new Blob([arrayBuffer], { type: uploadedFile.type || 'application/pdf' });
fileToProcess = new File([blob], uploadedFile.name, {
type: uploadedFile.type || 'application/pdf',
lastModified: uploadedFile.lastModified || Date.now()
});
} catch (error) {
console.error('Error converting FileWithUrl:', error);
setError('Unable to load file from storage');
return;
}
} else if (uploadedFile instanceof File) {
fileToProcess = uploadedFile;
} else {
setError('Invalid file object');
console.error('handleFileUpload received unsupported object:', uploadedFile);
return;
}
if (fileToProcess.type !== 'application/pdf') {
setError('Please upload a valid PDF file');
return;
}
setLoading(true);
setError(null);
try {
const document = await processPDFFile(fileToProcess);
setPdfDocument(document);
setFilename(fileToProcess.name.replace(/\.pdf$/i, ''));
setSelectedPages([]);
if (document.pages.length > 0) {
// Only store if it's a new file (not from storage)
if (!uploadedFile.storedInIndexedDB) {
const thumbnail = await generateThumbnailForFile(fileToProcess);
await fileStorage.storeFile(fileToProcess, thumbnail);
}
}
if (setFile) {
const fileUrl = URL.createObjectURL(fileToProcess);
setFile({ file: fileToProcess, url: fileUrl });
}
setStatus(`PDF loaded successfully with ${document.totalPages} pages`);
} catch (err) {
const errorMessage = err instanceof Error ? err.message : 'Failed to process PDF';
setError(errorMessage);
console.error('PDF processing error:', err);
} finally {
setLoading(false);
}
}, [processPDFFile, setFile]);
useEffect(() => {
if (file?.file && !pdfDocument) {
handleFileUpload(file.file);
}
}, [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));
}
}, [pdfDocument]);
const deselectAll = useCallback(() => setSelectedPages([]), []);
const togglePage = useCallback((pageId: string) => {
setSelectedPages(prev =>
prev.includes(pageId)
? prev.filter(id => id !== pageId)
: [...prev, pageId]
);
}, []);
const toggleSelectionMode = useCallback(() => {
setSelectionMode(prev => {
const newMode = !prev;
if (!newMode) {
// Clear selections when exiting selection mode
setSelectedPages([]);
setCsvInput("");
}
return newMode;
});
}, []);
const parseCSVInput = useCallback((csv: string) => {
if (!pdfDocument) return [];
const pageIds: string[] = [];
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
ranges.forEach(range => {
if (range.includes('-')) {
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
for (let i = start; i <= end && i <= pdfDocument.totalPages; i++) {
if (i > 0) {
const page = pdfDocument.pages.find(p => p.pageNumber === i);
if (page) pageIds.push(page.id);
}
}
} else {
const pageNum = parseInt(range);
if (pageNum > 0 && pageNum <= pdfDocument.totalPages) {
const page = pdfDocument.pages.find(p => p.pageNumber === pageNum);
if (page) pageIds.push(page.id);
}
}
});
return pageIds;
}, [pdfDocument]);
const updatePagesFromCSV = useCallback(() => {
const pageIds = parseCSVInput(csvInput);
setSelectedPages(pageIds);
}, [csvInput, parseCSVInput]);
const handleDragStart = useCallback((pageId: string) => {
setDraggedPage(pageId);
// Check if this is a multi-page drag in selection mode
if (selectionMode && selectedPages.includes(pageId) && selectedPages.length > 1) {
setMultiPageDrag({
pageIds: selectedPages,
count: selectedPages.length
});
} else {
setMultiPageDrag(null);
}
}, [selectionMode, selectedPages]);
const handleDragEnd = useCallback(() => {
// Clean up drag state regardless of where the drop happened
setDraggedPage(null);
setDropTarget(null);
setMultiPageDrag(null);
setDragPosition(null);
}, []);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
if (!draggedPage) return;
// Update drag position for multi-page indicator
if (multiPageDrag) {
setDragPosition({ x: e.clientX, y: e.clientY });
}
// Get the element under the mouse cursor
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
if (!elementUnderCursor) return;
// Find the closest page container
const pageContainer = elementUnderCursor.closest('[data-page-id]');
if (pageContainer) {
const pageId = pageContainer.getAttribute('data-page-id');
if (pageId && pageId !== draggedPage) {
setDropTarget(pageId);
return;
}
}
// Check if over the end zone
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
if (endZone) {
setDropTarget('end');
return;
}
// If not over any valid drop target, clear it
setDropTarget(null);
}, [draggedPage, multiPageDrag]);
const handleDragEnter = useCallback((pageId: string) => {
if (draggedPage && pageId !== draggedPage) {
setDropTarget(pageId);
}
}, [draggedPage]);
const handleDragLeave = useCallback(() => {
// Don't clear drop target on drag leave - let dragover handle it
}, []);
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') => {
e.preventDefault();
if (!draggedPage || !pdfDocument || draggedPage === targetPageId) return;
let targetIndex: number;
if (targetPageId === 'end') {
targetIndex = pdfDocument.pages.length;
} else {
targetIndex = pdfDocument.pages.findIndex(p => p.id === targetPageId);
if (targetIndex === -1) return;
}
animateReorder(draggedPage, targetIndex);
setDraggedPage(null);
setDropTarget(null);
setMultiPageDrag(null);
setDragPosition(null);
const moveCount = multiPageDrag ? multiPageDrag.count : 1;
setStatus(`${moveCount > 1 ? `${moveCount} pages` : 'Page'} reordered`);
}, [draggedPage, pdfDocument, animateReorder, multiPageDrag]);
const handleEndZoneDragEnter = useCallback(() => {
if (draggedPage) {
setDropTarget('end');
}
}, [draggedPage]);
const handleRotate = useCallback((direction: 'left' | 'right') => {
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,
pagesToRotate,
rotation
);
executeCommand(command);
const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length;
setStatus(`Rotated ${pageCount} pages ${direction}`);
}, [pdfDocument, selectedPages, selectionMode, executeCommand]);
const handleDelete = useCallback(() => {
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,
pagesToDelete
);
executeCommand(command);
if (selectionMode) {
setSelectedPages([]);
}
const pageCount = selectionMode ? selectedPages.length : pdfDocument.pages.length;
setStatus(`Deleted ${pageCount} pages`);
}, [pdfDocument, selectedPages, selectionMode, executeCommand]);
const handleSplit = useCallback(() => {
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,
pagesToSplit
);
executeCommand(command);
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;
const exportPageIds = selectedOnly ? selectedPages : [];
const preview = pdfExportService.getExportInfo(pdfDocument, exportPageIds, selectedOnly);
setExportPreview(preview);
setShowExportModal(true);
}, [pdfDocument, selectedPages]);
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
if (!pdfDocument) return;
setExportLoading(true);
try {
const exportPageIds = selectedOnly ? selectedPages : [];
const errors = pdfExportService.validateExport(pdfDocument, exportPageIds, selectedOnly);
if (errors.length > 0) {
setError(errors.join(', '));
return;
}
const hasSplitMarkers = pdfDocument.pages.some(page => page.splitBefore);
if (hasSplitMarkers) {
const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, {
selectedOnly,
filename,
splitDocuments: true
}) as { blobs: Blob[]; filenames: string[] };
result.blobs.forEach((blob, index) => {
setTimeout(() => {
pdfExportService.downloadFile(blob, result.filenames[index]);
}, index * 500);
});
setStatus(`Exported ${result.blobs.length} split documents`);
} else {
const result = await pdfExportService.exportPDF(pdfDocument, exportPageIds, {
selectedOnly,
filename
}) as { blob: Blob; filename: string };
pdfExportService.downloadFile(result.blob, result.filename);
setStatus('PDF exported successfully');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Export failed';
setError(errorMessage);
} finally {
setExportLoading(false);
}
}, [pdfDocument, selectedPages, filename]);
const handleUndo = useCallback(() => {
if (undo()) {
setStatus('Operation undone');
}
}, [undo]);
const handleRedo = useCallback(() => {
if (redo()) {
setStatus('Operation redone');
}
}, [redo]);
const closePdf = useCallback(() => {
setPdfDocument(null);
setFile && setFile(null);
}, [setFile]);
// Expose functions to parent component
useEffect(() => {
if (onFunctionsReady) {
onFunctionsReady({
handleUndo,
handleRedo,
canUndo,
canRedo,
handleRotate,
handleDelete,
handleSplit,
showExportPreview,
exportLoading,
selectionMode,
selectedPages,
closePdf,
});
}
}, [
onFunctionsReady,
handleUndo,
handleRedo,
canUndo,
canRedo,
handleRotate,
handleDelete,
handleSplit,
showExportPreview,
exportLoading,
selectionMode,
selectedPages,
closePdf
]);
if (!pdfDocument) {
return (
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={loading || pdfLoading} />
<Container size="lg" p="xl" h="100%" style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<FileUploadSelector
title="Select a PDF to edit"
subtitle="Choose a file from storage or upload a new PDF"
sharedFiles={sharedFiles || []}
onFileSelect={handleFileUpload}
allowMultiple={false}
accept={["application/pdf"]}
loading={loading || pdfLoading}
/>
</Container>
</Box>
);
}
return (
<Box pos="relative" h="100vh" style={{ overflow: 'auto' }}>
<LoadingOverlay visible={loading || pdfLoading} />
<Box p="md" pt="xl">
<Group mb="md">
<TextInput
value={filename}
onChange={(e) => setFilename(e.target.value)}
placeholder="Enter filename"
style={{ minWidth: 200 }}
/>
<Button
onClick={toggleSelectionMode}
variant={selectionMode ? "filled" : "outline"}
color={selectionMode ? "blue" : "gray"}
styles={{
root: {
transition: 'all 0.2s ease',
...(selectionMode && {
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
})
}
}}
>
{selectionMode ? "Exit Selection" : "Select Pages"}
</Button>
{selectionMode && (
<>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
</>
)}
</Group>
{selectionMode && (
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPages={selectedPages}
onUpdatePagesFromCSV={updatePagesFromCSV}
/>
)}
<DragDropGrid
items={pdfDocument.pages}
selectedItems={selectedPages}
selectionMode={selectionMode}
isAnimating={isAnimating}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onEndZoneDragEnter={handleEndZoneDragEnter}
draggedItem={draggedPage}
dropTarget={dropTarget}
multiItemDrag={multiPageDrag}
dragPosition={dragPosition}
renderItem={(page, index, refs) => (
<PageThumbnail
page={page}
index={index}
totalPages={pdfDocument.pages.length}
selectedPages={selectedPages}
selectionMode={selectionMode}
draggedPage={draggedPage}
dropTarget={dropTarget}
movingPage={movingPage}
isAnimating={isAnimating}
pageRefs={refs}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragEnter={handleDragEnter}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onTogglePage={togglePage}
onAnimateReorder={animateReorder}
onExecuteCommand={executeCommand}
onSetStatus={setStatus}
onSetMovingPage={setMovingPage}
RotatePagesCommand={RotatePagesCommand}
DeletePagesCommand={DeletePagesCommand}
ToggleSplitCommand={ToggleSplitCommand}
pdfDocument={pdfDocument}
setPdfDocument={setPdfDocument}
/>
)}
renderSplitMarker={(page, index) => (
<div
style={{
width: '2px',
height: '20rem',
borderLeft: '2px dashed #3b82f6',
backgroundColor: 'transparent',
marginLeft: '-0.75rem',
marginRight: '-0.75rem',
flexShrink: 0
}}
/>
)}
/>
</Box>
<Modal
opened={showExportModal}
onClose={() => setShowExportModal(false)}
title="Export Preview"
>
{exportPreview && (
<Stack gap="md">
<Group justify="space-between">
<Text>Pages to export:</Text>
<Text fw={500}>{exportPreview.pageCount}</Text>
</Group>
{exportPreview.splitCount > 1 && (
<Group justify="space-between">
<Text>Split into documents:</Text>
<Text fw={500}>{exportPreview.splitCount}</Text>
</Group>
)}
<Group justify="space-between">
<Text>Estimated size:</Text>
<Text fw={500}>{exportPreview.estimatedSize}</Text>
</Group>
{pdfDocument && pdfDocument.pages.some(p => p.splitBefore) && (
<Alert color="blue">
This will create multiple PDF files based on split markers.
</Alert>
)}
<Group justify="flex-end" mt="md">
<Button
variant="light"
onClick={() => setShowExportModal(false)}
>
Cancel
</Button>
<Button
color="green"
loading={exportLoading}
onClick={() => {
setShowExportModal(false);
const selectedOnly = exportPreview.pageCount < (pdfDocument?.totalPages || 0);
handleExport(selectedOnly);
}}
>
Export PDF
</Button>
</Group>
</Stack>
)}
</Modal>
<FileInput
ref={fileInputRef}
accept="application/pdf"
onChange={(file) => file && handleFileUpload(file)}
style={{ display: 'none' }}
/>
{status && (
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
>
{status}
</Notification>
)}
</Box>
);
};
export default PageEditor;
@@ -0,0 +1,191 @@
import React from "react";
import {
Tooltip,
ActionIcon,
Paper
} from "@mantine/core";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import DownloadIcon from "@mui/icons-material/Download";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import CloseIcon from "@mui/icons-material/Close";
interface PageEditorControlsProps {
// Close/Reset functions
onClosePdf: () => void;
// Undo/Redo
onUndo: () => void;
onRedo: () => void;
canUndo: boolean;
canRedo: boolean;
// Page operations
onRotate: (direction: 'left' | 'right') => void;
onDelete: () => void;
onSplit: () => void;
// Export functions
onExportSelected: () => void;
onExportAll: () => void;
exportLoading: boolean;
// Selection state
selectionMode: boolean;
selectedPages: string[];
}
const PageEditorControls = ({
onClosePdf,
onUndo,
onRedo,
canUndo,
canRedo,
onRotate,
onDelete,
onSplit,
onExportSelected,
onExportAll,
exportLoading,
selectionMode,
selectedPages
}: PageEditorControlsProps) => {
return (
<div
style={{
position: 'fixed',
left: '50%',
bottom: '20px',
transform: 'translateX(-50%)',
zIndex: 50,
display: 'flex',
justifyContent: 'center',
pointerEvents: 'none',
background: 'transparent',
}}
>
<Paper
radius="xl"
shadow="lg"
p={16}
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
borderRadius: 32,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
pointerEvents: 'auto',
minWidth: 400,
justifyContent: 'center'
}}
>
{/* Close PDF */}
<Tooltip label="Close PDF">
<ActionIcon
onClick={onClosePdf}
color="red"
variant="light"
size="lg"
>
<CloseIcon />
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Undo/Redo */}
<Tooltip label="Undo">
<ActionIcon onClick={onUndo} disabled={!canUndo} size="lg">
<UndoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Redo">
<ActionIcon onClick={onRedo} disabled={!canRedo} size="lg">
<RedoIcon />
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Page Operations */}
<Tooltip label={selectionMode ? "Rotate Selected Left" : "Rotate All Left"}>
<ActionIcon
onClick={() => onRotate('left')}
disabled={selectionMode && selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
size="lg"
>
<RotateLeftIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={selectionMode ? "Rotate Selected Right" : "Rotate All Right"}>
<ActionIcon
onClick={() => onRotate('right')}
disabled={selectionMode && selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
size="lg"
>
<RotateRightIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={selectionMode ? "Delete Selected" : "Delete All"}>
<ActionIcon
onClick={onDelete}
disabled={selectionMode && selectedPages.length === 0}
color="red"
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
size="lg"
>
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={selectionMode ? "Split Selected" : "Split All"}>
<ActionIcon
onClick={onSplit}
disabled={selectionMode && selectedPages.length === 0}
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
color={selectionMode && selectedPages.length > 0 ? "blue" : undefined}
size="lg"
>
<ContentCutIcon />
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Export Controls */}
{selectionMode && selectedPages.length > 0 && (
<Tooltip label="Export Selected">
<ActionIcon
onClick={onExportSelected}
disabled={exportLoading}
color="blue"
variant="light"
size="lg"
>
<DownloadIcon />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Export All">
<ActionIcon
onClick={onExportAll}
disabled={exportLoading}
color="green"
variant="light"
size="lg"
>
<DownloadIcon />
</ActionIcon>
</Tooltip>
</Paper>
</div>
);
};
export default PageEditorControls;
@@ -0,0 +1,348 @@
import React from 'react';
import { Text, Checkbox, Tooltip, ActionIcon } from '@mantine/core';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
import RotateRightIcon from '@mui/icons-material/RotateRight';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { PDFPage } from '../../types/pageEditor';
import styles from './PageEditor.module.css';
interface PageThumbnailProps {
page: PDFPage;
index: number;
totalPages: number;
selectedPages: string[];
selectionMode: boolean;
draggedPage: string | null;
dropTarget: string | null;
movingPage: string | null;
isAnimating: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
onDragStart: (pageId: string) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (pageId: string) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, pageId: string) => void;
onTogglePage: (pageId: string) => void;
onAnimateReorder: (pageId: string, targetIndex: number) => void;
onExecuteCommand: (command: any) => void;
onSetStatus: (status: string) => void;
onSetMovingPage: (pageId: string | null) => void;
RotatePagesCommand: any;
DeletePagesCommand: any;
ToggleSplitCommand: any;
pdfDocument: any;
setPdfDocument: any;
}
const PageThumbnail = ({
page,
index,
totalPages,
selectedPages,
selectionMode,
draggedPage,
dropTarget,
movingPage,
isAnimating,
pageRefs,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onTogglePage,
onAnimateReorder,
onExecuteCommand,
onSetStatus,
onSetMovingPage,
RotatePagesCommand,
DeletePagesCommand,
ToggleSplitCommand,
pdfDocument,
setPdfDocument,
}: PageThumbnailProps) => {
return (
<div
data-page-id={page.id}
className={`
${styles.pageContainer}
!rounded-lg
cursor-grab
select-none
w-[20rem]
h-[20rem]
flex items-center justify-center
flex-shrink-0
shadow-sm
hover:shadow-md
transition-all
relative
${selectionMode
? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'}
${draggedPage === page.id ? 'opacity-50 scale-95' : ''}
${movingPage === page.id ? 'page-moving' : ''}
`}
style={{
transform: (() => {
if (!isAnimating && draggedPage && page.id !== draggedPage && dropTarget === page.id) {
return 'translateX(20px)';
}
return 'translateX(0)';
})(),
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}}
draggable
onDragStart={() => onDragStart(page.id)}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
onDragEnter={() => onDragEnter(page.id)}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, page.id)}
>
{selectionMode && (
<div
className={styles.checkboxContainer}
style={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 4,
backgroundColor: 'white',
borderRadius: '4px',
padding: '2px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'auto'
}}
onMouseDown={(e) => e.stopPropagation()}
onDragStart={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Checkbox
checked={selectedPages.includes(page.id)}
onChange={(event) => {
event.stopPropagation();
onTogglePage(page.id);
}}
onClick={(e) => e.stopPropagation()}
size="sm"
/>
</div>
)}
<div className="page-container w-[90%] h-[90%]">
<div
style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 6,
border: '1px solid var(--mantine-color-gray-3)',
padding: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
<img
src={page.thumbnail}
alt={`Page ${page.pageNumber}`}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
borderRadius: 2,
transform: `rotate(${page.rotation}deg)`,
transition: 'transform 0.3s ease-in-out'
}}
/>
</div>
<Text
className={styles.pageNumber}
size="sm"
fw={500}
c="white"
style={{
position: 'absolute',
top: 5,
left: 5,
background: 'rgba(162, 201, 255, 0.8)',
padding: '6px 8px',
borderRadius: 8,
zIndex: 2,
opacity: 0,
transition: 'opacity 0.2s ease-in-out'
}}
>
{page.pageNumber}
</Text>
<div
className={styles.pageHoverControls}
style={{
position: 'absolute',
bottom: 8,
left: '50%',
transform: 'translateX(-50%)',
background: 'rgba(0, 0, 0, 0.8)',
padding: '6px 12px',
borderRadius: 20,
opacity: 0,
transition: 'opacity 0.2s ease-in-out',
zIndex: 3,
display: 'flex',
gap: '8px',
alignItems: 'center',
whiteSpace: 'nowrap'
}}
>
<Tooltip label="Move Left">
<ActionIcon
size="md"
variant="subtle"
c="white"
disabled={index === 0}
onClick={(e) => {
e.stopPropagation();
if (index > 0 && !movingPage && !isAnimating) {
onSetMovingPage(page.id);
onAnimateReorder(page.id, index - 1);
setTimeout(() => onSetMovingPage(null), 500);
onSetStatus(`Moved page ${page.pageNumber} left`);
}
}}
>
<ArrowBackIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Move Right">
<ActionIcon
size="md"
variant="subtle"
c="white"
disabled={index === totalPages - 1}
onClick={(e) => {
e.stopPropagation();
if (index < totalPages - 1 && !movingPage && !isAnimating) {
onSetMovingPage(page.id);
onAnimateReorder(page.id, index + 1);
setTimeout(() => onSetMovingPage(null), 500);
onSetStatus(`Moved page ${page.pageNumber} right`);
}
}}
>
<ArrowForwardIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Rotate Left">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
const command = new RotatePagesCommand(
pdfDocument,
setPdfDocument,
[page.id],
-90
);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} left`);
}}
>
<RotateLeftIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Rotate Right">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
const command = new RotatePagesCommand(
pdfDocument,
setPdfDocument,
[page.id],
90
);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} right`);
}}
>
<RotateRightIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Page">
<ActionIcon
size="md"
variant="subtle"
c="red"
onClick={(e) => {
e.stopPropagation();
const command = new DeletePagesCommand(
pdfDocument,
setPdfDocument,
[page.id]
);
onExecuteCommand(command);
onSetStatus(`Deleted page ${page.pageNumber}`);
}}
>
<DeleteIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
{index > 0 && (
<Tooltip label="Split Here">
<ActionIcon
size="md"
variant="subtle"
c="white"
onClick={(e) => {
e.stopPropagation();
const command = new ToggleSplitCommand(
pdfDocument,
setPdfDocument,
[page.id]
);
onExecuteCommand(command);
onSetStatus(`Split marker toggled for page ${page.pageNumber}`);
}}
>
<ContentCutIcon style={{ fontSize: 20 }} />
</ActionIcon>
</Tooltip>
)}
</div>
<DragIndicatorIcon
style={{
position: 'absolute',
bottom: 4,
right: 4,
color: 'rgba(0,0,0,0.3)',
fontSize: 16,
zIndex: 1
}}
/>
</div>
</div>
);
};
export default PageThumbnail;
@@ -0,0 +1,131 @@
import React, { useState, useCallback, useRef, useEffect } from 'react';
import { Box } from '@mantine/core';
import styles from '../PageEditor.module.css';
interface DragDropItem {
id: string;
splitBefore?: boolean;
}
interface DragDropGridProps<T extends DragDropItem> {
items: T[];
selectedItems: string[];
selectionMode: boolean;
isAnimating: boolean;
onDragStart: (itemId: string) => void;
onDragEnd: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragEnter: (itemId: string) => void;
onDragLeave: () => void;
onDrop: (e: React.DragEvent, targetId: string | 'end') => void;
onEndZoneDragEnter: () => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
draggedItem: string | null;
dropTarget: string | null;
multiItemDrag: {itemIds: string[], count: number} | null;
dragPosition: {x: number, y: number} | null;
}
const DragDropGrid = <T extends DragDropItem>({
items,
selectedItems,
selectionMode,
isAnimating,
onDragStart,
onDragEnd,
onDragOver,
onDragEnter,
onDragLeave,
onDrop,
onEndZoneDragEnter,
renderItem,
renderSplitMarker,
draggedItem,
dropTarget,
multiItemDrag,
dragPosition,
}: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Global drag cleanup
useEffect(() => {
const handleGlobalDragEnd = () => {
onDragEnd();
};
const handleGlobalDrop = (e: DragEvent) => {
e.preventDefault();
};
if (draggedItem) {
document.addEventListener('dragend', handleGlobalDragEnd);
document.addEventListener('drop', handleGlobalDrop);
}
return () => {
document.removeEventListener('dragend', handleGlobalDragEnd);
document.removeEventListener('drop', handleGlobalDrop);
};
}, [draggedItem, onDragEnd]);
return (
<Box>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '1.5rem',
justifyContent: 'flex-start',
paddingBottom: '100px'
}}
>
{items.map((item, index) => (
<React.Fragment key={item.id}>
{/* Split marker */}
{renderSplitMarker && item.splitBefore && index > 0 && renderSplitMarker(item, index)}
{/* Item */}
{renderItem(item, index, itemRefs)}
</React.Fragment>
))}
{/* End drop zone */}
<div className="w-[20rem] h-[20rem] flex items-center justify-center flex-shrink-0">
<div
data-drop-zone="end"
className={`cursor-pointer select-none w-[15rem] h-[15rem] flex items-center justify-center flex-shrink-0 shadow-sm hover:shadow-md transition-all relative ${
dropTarget === 'end'
? 'ring-2 ring-green-500 bg-green-50'
: 'bg-white hover:bg-blue-50 border-2 border-dashed border-gray-300 hover:border-blue-400'
}`}
style={{ borderRadius: '12px' }}
onDragOver={onDragOver}
onDragEnter={onEndZoneDragEnter}
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, 'end')}
>
<div className="text-gray-500 text-sm text-center font-medium">
Drop here to<br />move to end
</div>
</div>
</div>
</div>
{/* Multi-item drag indicator */}
{multiItemDrag && dragPosition && (
<div
className={styles.multiDragIndicator}
style={{
left: dragPosition.x,
top: dragPosition.y,
}}
>
{multiItemDrag.count} items
</div>
)}
</Box>
);
};
export default DragDropGrid;
@@ -0,0 +1,201 @@
import React, { useState } from "react";
import { Card, Stack, Text, Group, Badge, Button, Box, Image, ThemeIcon, ActionIcon, Tooltip } from "@mantine/core";
import { useTranslation } from "react-i18next";
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit";
import { FileWithUrl } from "../../types/file";
import { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
interface FileCardProps {
file: FileWithUrl;
onRemove: () => void;
onDoubleClick?: () => void;
onView?: () => void;
onEdit?: () => void;
isSelected?: boolean;
onSelect?: () => void;
}
const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, onSelect }: FileCardProps) => {
const { t } = useTranslation();
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
const [isHovered, setIsHovered] = useState(false);
return (
<Card
shadow="xs"
radius="md"
withBorder
p="xs"
style={{
width: 225,
minWidth: 180,
maxWidth: 260,
cursor: onDoubleClick ? "pointer" : undefined,
position: 'relative',
border: isSelected ? '2px solid var(--mantine-color-blue-6)' : undefined,
backgroundColor: isSelected ? 'var(--mantine-color-blue-0)' : undefined
}}
onDoubleClick={onDoubleClick}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
onClick={onSelect}
>
<Stack gap={6} align="center">
<Box
style={{
border: "2px solid #e0e0e0",
borderRadius: 8,
width: 90,
height: 120,
display: "flex",
alignItems: "center",
justifyContent: "center",
margin: "0 auto",
background: "#fafbfc",
position: 'relative'
}}
>
{/* Hover action buttons */}
{isHovered && (onView || onEdit) && (
<div
style={{
position: 'absolute',
top: 4,
right: 4,
display: 'flex',
gap: 4,
zIndex: 10,
backgroundColor: 'rgba(255, 255, 255, 0.9)',
borderRadius: 4,
padding: 2
}}
onClick={(e) => e.stopPropagation()}
>
{onView && (
<Tooltip label="View in Viewer">
<ActionIcon
size="sm"
variant="subtle"
color="blue"
onClick={(e) => {
e.stopPropagation();
onView();
}}
>
<VisibilityIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
)}
{onEdit && (
<Tooltip label="Open in File Editor">
<ActionIcon
size="sm"
variant="subtle"
color="orange"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<EditIcon style={{ fontSize: 16 }} />
</ActionIcon>
</Tooltip>
)}
</div>
)}
{thumb ? (
<Image
src={thumb}
alt="PDF thumbnail"
height={110}
width={80}
fit="contain"
radius="sm"
/>
) : isGenerating ? (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: 20,
height: 20,
border: '2px solid #ddd',
borderTop: '2px solid #666',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
marginBottom: 8
}} />
<Text size="xs" c="dimmed">Generating...</Text>
</div>
) : (
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center'
}}>
<ThemeIcon
variant="light"
color={file.size > 100 * 1024 * 1024 ? "orange" : "red"}
size={60}
radius="sm"
style={{ display: "flex", alignItems: "center", justifyContent: "center" }}
>
<PictureAsPdfIcon style={{ fontSize: 40 }} />
</ThemeIcon>
{file.size > 100 * 1024 * 1024 && (
<Text size="xs" c="dimmed" mt={4}>Large File</Text>
)}
</div>
)}
</Box>
<Text fw={500} size="sm" lineClamp={1} ta="center">
{file.name}
</Text>
<Group gap="xs" justify="center">
<Badge color="red" variant="light" size="sm">
{getFileSize(file)}
</Badge>
<Badge color="blue" variant="light" size="sm">
{getFileDate(file)}
</Badge>
{file.storedInIndexedDB && (
<Badge
color="green"
variant="light"
size="sm"
leftSection={<StorageIcon style={{ fontSize: 12 }} />}
>
DB
</Badge>
)}
</Group>
<Button
color="red"
size="xs"
variant="light"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
mt={4}
>
{t("delete", "Remove")}
</Button>
</Stack>
</Card>
);
};
export default FileCard;
@@ -0,0 +1,92 @@
import React from "react";
import { Card, Group, Text, Button, Progress, Alert, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import StorageIcon from "@mui/icons-material/Storage";
import DeleteIcon from "@mui/icons-material/Delete";
import WarningIcon from "@mui/icons-material/Warning";
import { StorageStats } from "../../services/fileStorage";
import { formatFileSize } from "../../utils/fileUtils";
import { getStorageUsagePercent } from "../../utils/storageUtils";
import { StorageConfig } from "../../types/file";
interface StorageStatsCardProps {
storageStats: StorageStats | null;
filesCount: number;
onClearAll: () => void;
onReloadFiles: () => void;
storageConfig: StorageConfig;
}
const StorageStatsCard = ({
storageStats,
filesCount,
onClearAll,
onReloadFiles,
storageConfig,
}: StorageStatsCardProps) => {
const { t } = useTranslation();
if (!storageStats) return null;
const storageUsagePercent = getStorageUsagePercent(storageStats);
const totalUsed = storageStats.totalSize || storageStats.used;
const hardLimitPercent = (totalUsed / storageConfig.maxTotalStorage) * 100;
const isNearLimit = hardLimitPercent >= storageConfig.warningThreshold * 100;
return (
<Stack gap="sm" style={{ width: "90%", maxWidth: 600 }}>
<Card withBorder p="sm">
<Group align="center" gap="md">
<StorageIcon />
<div style={{ flex: 1 }}>
<Text size="sm" fw={500}>
{t("storage.storageUsed", "Storage used")}: {formatFileSize(totalUsed)} / {formatFileSize(storageConfig.maxTotalStorage)}
</Text>
<Progress
value={hardLimitPercent}
color={isNearLimit ? "red" : hardLimitPercent > 60 ? "yellow" : "blue"}
size="sm"
mt={4}
/>
<Group justify="space-between" mt={2}>
<Text size="xs" c="dimmed">
{storageStats.fileCount} files {t("storage.approximateSize", "Approximate size")}
</Text>
<Text size="xs" c={isNearLimit ? "red" : "dimmed"}>
{Math.round(hardLimitPercent)}% used
</Text>
</Group>
{isNearLimit && (
<Text size="xs" c="red" mt={4}>
{t("storage.storageFull", "Storage is nearly full. Consider removing some files.")}
</Text>
)}
</div>
<Group gap="xs">
{filesCount > 0 && (
<Button
variant="light"
color="red"
size="xs"
onClick={onClearAll}
leftSection={<DeleteIcon style={{ fontSize: 16 }} />}
>
{t("fileManager.clearAll", "Clear All")}
</Button>
)}
<Button
variant="light"
color="blue"
size="xs"
onClick={onReloadFiles}
>
{t("fileManager.reloadFiles", "Reload Files")}
</Button>
</Group>
</Group>
</Card>
</Stack>
);
};
export default StorageStatsCard;
@@ -0,0 +1,263 @@
import React, { useState, useEffect } from 'react';
import {
Modal,
Text,
Button,
Group,
Stack,
Checkbox,
ScrollArea,
Box,
Image,
Badge,
ThemeIcon,
SimpleGrid
} from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { useTranslation } from 'react-i18next';
interface FilePickerModalProps {
opened: boolean;
onClose: () => void;
sharedFiles: any[];
onSelectFiles: (selectedFiles: any[]) => void;
}
const FilePickerModal = ({
opened,
onClose,
sharedFiles,
onSelectFiles,
}: FilePickerModalProps) => {
const { t } = useTranslation();
const [selectedFileIds, setSelectedFileIds] = useState<string[]>([]);
// Reset selection when modal opens
useEffect(() => {
if (opened) {
setSelectedFileIds([]);
}
}, [opened]);
const toggleFileSelection = (fileId: string) => {
setSelectedFileIds(prev =>
prev.includes(fileId)
? prev.filter(id => id !== fileId)
: [...prev, fileId]
);
};
const selectAll = () => {
setSelectedFileIds(sharedFiles.map(f => f.id || f.name));
};
const selectNone = () => {
setSelectedFileIds([]);
};
const handleConfirm = async () => {
const selectedFiles = sharedFiles.filter(f =>
selectedFileIds.includes(f.id || f.name)
);
// Convert FileWithUrl objects to proper File objects if needed
const convertedFiles = await Promise.all(
selectedFiles.map(async (fileItem) => {
console.log('Converting file item:', fileItem);
// If it's already a File object, return as is
if (fileItem instanceof File) {
console.log('File is already a File object');
return fileItem;
}
// If it has a file property, use that
if (fileItem.file && fileItem.file instanceof File) {
console.log('Using .file property');
return fileItem.file;
}
// If it's a FileWithUrl from storage, reconstruct the File
if (fileItem.arrayBuffer && typeof fileItem.arrayBuffer === 'function') {
try {
console.log('Reconstructing file from storage:', fileItem.name, fileItem);
const arrayBuffer = await fileItem.arrayBuffer();
console.log('Got arrayBuffer:', arrayBuffer);
const blob = new Blob([arrayBuffer], { type: fileItem.type || 'application/pdf' });
console.log('Created blob:', blob);
const reconstructedFile = new File([blob], fileItem.name, {
type: fileItem.type || 'application/pdf',
lastModified: fileItem.lastModified || Date.now()
});
console.log('Reconstructed file:', reconstructedFile, 'instanceof File:', reconstructedFile instanceof File);
return reconstructedFile;
} catch (error) {
console.error('Error reconstructing file:', error, fileItem);
return null;
}
}
console.log('No valid conversion method found for:', fileItem);
return null; // Don't return invalid objects
})
);
// Filter out any null values from failed conversions
const validFiles = convertedFiles.filter(f => f !== null);
onSelectFiles(validFiles);
onClose();
};
const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
};
return (
<Modal
opened={opened}
onClose={onClose}
title={t("fileUpload.selectFromStorage", "Select Files from Storage")}
size="lg"
scrollAreaComponent={ScrollArea.Autosize}
>
<Stack gap="md">
{sharedFiles.length === 0 ? (
<Text c="dimmed" ta="center" py="xl">
{t("fileUpload.noFilesInStorage", "No files available in storage. Upload some files first.")}
</Text>
) : (
<>
{/* Selection controls */}
<Group justify="space-between">
<Text size="sm" c="dimmed">
{sharedFiles.length} {t("fileUpload.filesAvailable", "files available")}
</Text>
<Group gap="xs">
<Button size="xs" variant="light" onClick={selectAll}>
{t("pageEdit.selectAll", "Select All")}
</Button>
<Button size="xs" variant="light" onClick={selectNone}>
{t("pageEdit.deselectAll", "Select None")}
</Button>
</Group>
</Group>
{/* File grid */}
<ScrollArea.Autosize mah={400}>
<SimpleGrid cols={2} spacing="md">
{sharedFiles.map((file) => {
const fileId = file.id || file.name;
const isSelected = selectedFileIds.includes(fileId);
return (
<Box
key={fileId}
p="sm"
style={{
border: isSelected
? '2px solid var(--mantine-color-blue-6)'
: '1px solid var(--mantine-color-gray-3)',
borderRadius: 8,
backgroundColor: isSelected
? 'var(--mantine-color-blue-0)'
: 'transparent',
cursor: 'pointer',
transition: 'all 0.2s ease'
}}
onClick={() => toggleFileSelection(fileId)}
>
<Group gap="sm" align="flex-start">
<Checkbox
checked={isSelected}
onChange={() => toggleFileSelection(fileId)}
onClick={(e) => e.stopPropagation()}
/>
{/* Thumbnail */}
<Box
style={{
width: 60,
height: 80,
border: '1px solid var(--mantine-color-gray-3)',
borderRadius: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backgroundColor: 'var(--mantine-color-gray-0)',
flexShrink: 0
}}
>
{file.thumbnail ? (
<Image
src={file.thumbnail}
alt="PDF thumbnail"
height={70}
width={50}
fit="contain"
/>
) : (
<ThemeIcon
variant="light"
color="red"
size={40}
>
<PictureAsPdfIcon style={{ fontSize: 24 }} />
</ThemeIcon>
)}
</Box>
{/* File info */}
<Stack gap="xs" style={{ flex: 1, minWidth: 0 }}>
<Text size="sm" fw={500} lineClamp={2}>
{file.name}
</Text>
<Group gap="xs">
<Badge size="xs" variant="light" color="gray">
{formatFileSize(file.size || (file.file?.size || 0))}
</Badge>
</Group>
</Stack>
</Group>
</Box>
);
})}
</SimpleGrid>
</ScrollArea.Autosize>
{/* Selection summary */}
{selectedFileIds.length > 0 && (
<Text size="sm" c="blue" ta="center">
{selectedFileIds.length} {t("fileManager.filesSelected", "files selected")}
</Text>
)}
</>
)}
{/* Action buttons */}
<Group justify="flex-end" mt="md">
<Button variant="light" onClick={onClose}>
{t("close", "Cancel")}
</Button>
<Button
onClick={handleConfirm}
disabled={selectedFileIds.length === 0}
>
{selectedFileIds.length > 0
? `${t("fileUpload.loadFromStorage", "Load")} ${selectedFileIds.length} ${t("fileUpload.uploadFiles", "Files")}`
: t("fileUpload.loadFromStorage", "Load Files")
}
</Button>
</Group>
</Stack>
</Modal>
);
};
export default FilePickerModal;
@@ -0,0 +1,153 @@
import React, { useState, useCallback } from 'react';
import { Stack, Button, Text, Center } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import UploadFileIcon from '@mui/icons-material/UploadFile';
import { useTranslation } from 'react-i18next';
import FilePickerModal from './FilePickerModal';
interface FileUploadSelectorProps {
// Appearance
title?: string;
subtitle?: string;
showDropzone?: boolean;
// File handling
sharedFiles?: any[];
onFileSelect: (file: File) => void;
onFilesSelect?: (files: File[]) => void;
allowMultiple?: boolean;
accept?: string[];
// Loading state
loading?: boolean;
disabled?: boolean;
}
const FileUploadSelector = ({
title,
subtitle,
showDropzone = true,
sharedFiles = [],
onFileSelect,
onFilesSelect,
allowMultiple = false,
accept = ["application/pdf"],
loading = false,
disabled = false,
}: FileUploadSelectorProps) => {
const { t } = useTranslation();
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
const handleFileUpload = useCallback((uploadedFiles: File[]) => {
if (uploadedFiles.length === 0) return;
if (allowMultiple && onFilesSelect) {
onFilesSelect(uploadedFiles);
} else {
onFileSelect(uploadedFiles[0]);
}
}, [allowMultiple, onFileSelect, onFilesSelect]);
const handleStorageSelection = useCallback((selectedFiles: File[]) => {
if (selectedFiles.length === 0) return;
if (allowMultiple && onFilesSelect) {
onFilesSelect(selectedFiles);
} else {
onFileSelect(selectedFiles[0]);
}
}, [allowMultiple, onFileSelect, onFilesSelect]);
// Get default title and subtitle from translations if not provided
const displayTitle = title || t(allowMultiple ? "fileUpload.selectFiles" : "fileUpload.selectFile",
allowMultiple ? "Select files" : "Select a file");
const displaySubtitle = subtitle || t(allowMultiple ? "fileUpload.chooseFromStorageMultiple" : "fileUpload.chooseFromStorage",
allowMultiple ? "Choose files from storage or upload new PDFs" : "Choose a file from storage or upload a new PDF");
return (
<>
<Stack align="center" gap="xl">
{/* Title and description */}
<Stack align="center" gap="md">
<UploadFileIcon style={{ fontSize: 64 }} />
<Text size="xl" fw={500}>
{displayTitle}
</Text>
<Text size="md" c="dimmed">
{displaySubtitle}
</Text>
</Stack>
{/* Action buttons */}
<Stack align="center" gap="md" w="100%">
<Button
variant="filled"
size="lg"
onClick={() => setShowFilePickerModal(true)}
disabled={disabled || sharedFiles.length === 0}
loading={loading}
>
{loading ? "Loading..." : `Load from Storage (${sharedFiles.length} files available)`}
</Button>
<Text size="md" c="dimmed">
{t("fileUpload.or", "or")}
</Text>
{showDropzone ? (
<Dropzone
onDrop={handleFileUpload}
accept={accept}
multiple={allowMultiple}
disabled={disabled || loading}
style={{ width: '100%', minHeight: 120 }}
>
<Center>
<Stack align="center" gap="sm">
<Text size="md" fw={500}>
{t(allowMultiple ? "fileUpload.dropFilesHere" : "fileUpload.dropFileHere",
allowMultiple ? "Drop files here or click to upload" : "Drop file here or click to upload")}
</Text>
<Text size="sm" c="dimmed">
{accept.includes('application/pdf')
? t("fileUpload.pdfFilesOnly", "PDF files only")
: t("fileUpload.supportedFileTypes", "Supported file types")
}
</Text>
</Stack>
</Center>
</Dropzone>
) : (
<Dropzone
onDrop={handleFileUpload}
accept={accept}
multiple={allowMultiple}
disabled={disabled || loading}
style={{ display: 'contents' }}
>
<Button
variant="outline"
size="lg"
disabled={disabled}
loading={loading}
>
{t(allowMultiple ? "fileUpload.uploadFiles" : "fileUpload.uploadFile",
allowMultiple ? "Upload Files" : "Upload File")}
</Button>
</Dropzone>
)}
</Stack>
</Stack>
{/* File Picker Modal */}
<FilePickerModal
opened={showFilePickerModal}
onClose={() => setShowFilePickerModal(false)}
sharedFiles={sharedFiles}
onSelectFiles={handleStorageSelection}
/>
</>
);
};
export default FileUploadSelector;
@@ -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;
}
}
@@ -0,0 +1,219 @@
import React, { useState, useEffect } from 'react';
import { Menu, Button, ScrollArea, useMantineTheme, useMantineColorScheme } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { supportedLanguages } from '../../i18n';
import LanguageIcon from '@mui/icons-material/Language';
import styles from './LanguageSelector.module.css';
const LanguageSelector = () => {
const { i18n } = useTranslation();
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<string | null>(null);
const [rippleEffect, setRippleEffect] = useState<{x: number, y: number, key: number} | null>(null);
const languageOptions = Object.entries(supportedLanguages)
.sort(([, nameA], [, nameB]) => nameA.localeCompare(nameB))
.map(([code, name]) => ({
value: code,
label: name,
}));
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 (
<>
<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
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>
<Button
variant="subtle"
size="sm"
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
styles={{
root: {
border: 'none',
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': {
backgroundColor: colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1],
}
},
label: {
fontSize: '12px',
fontWeight: 500,
}
}}
>
<span className={styles.languageText}>
{currentLanguage}
</span>
</Button>
</Menu.Target>
<Menu.Dropdown
style={{
padding: '12px',
borderRadius: '8px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.1)',
border: colorScheme === 'dark' ? `1px solid ${theme.colors.dark[4]}` : `1px solid ${theme.colors.gray[3]}`,
}}
>
<ScrollArea h={190} type="scroll">
<div className={styles.languageGrid}>
{languageOptions.map((option, index) => (
<div
key={option.value}
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
variant="subtle"
size="sm"
fullWidth
onClick={(event) => handleLanguageChange(option.value, event)}
styles={{
root: {
borderRadius: '4px',
minHeight: '32px',
padding: '4px 8px',
justifyContent: 'flex-start',
position: 'relative',
overflow: 'hidden',
backgroundColor: option.value === i18n.language ? (
colorScheme === 'dark' ? theme.colors.blue[8] : theme.colors.blue[1]
) : 'transparent',
color: option.value === i18n.language ? (
colorScheme === 'dark' ? theme.colors.blue[2] : theme.colors.blue[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': {
backgroundColor: option.value === i18n.language ? (
colorScheme === 'dark' ? theme.colors.blue[7] : theme.colors.blue[2]
) : (
colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.gray[1]
),
transform: 'translateY(-1px)',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)',
}
},
label: {
fontSize: '13px',
fontWeight: option.value === i18n.language ? 600 : 400,
textAlign: 'left',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
position: 'relative',
zIndex: 2,
}
}}
>
{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>
</div>
))}
</div>
</ScrollArea>
</Menu.Dropdown>
</Menu>
</>
);
};
export default LanguageSelector;
@@ -0,0 +1,70 @@
import React from "react";
import { ActionIcon, Stack, Tooltip } from "@mantine/core";
import AddToPhotosIcon from "@mui/icons-material/AddToPhotos";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import ZoomInMapIcon from "@mui/icons-material/ZoomInMap";
import MenuBookIcon from "@mui/icons-material/MenuBook";
import AppsIcon from "@mui/icons-material/Apps";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import rainbowStyles from '../../styles/rainbow.module.css';
interface QuickAccessBarProps {
onToolsClick: () => void;
onReaderToggle: () => void;
selectedToolKey?: string;
toolRegistry: any;
leftPanelView: 'toolPicker' | 'toolContent';
readerMode: boolean;
}
const QuickAccessBar = ({
onToolsClick,
onReaderToggle,
selectedToolKey,
toolRegistry,
leftPanelView,
readerMode,
}: QuickAccessBarProps) => {
const { isRainbowMode } = useRainbowThemeContext();
return (
<div
className={`h-screen flex flex-col w-20 ${isRainbowMode ? rainbowStyles.rainbowPaper : ''}`}
style={{
padding: '1rem 0.5rem',
backgroundColor: 'var(--bg-muted)'
}}
>
<Stack gap="lg" align="center" className="flex-1">
{/* All Tools Button */}
<div className="flex flex-col items-center gap-1">
<ActionIcon
size="xl"
variant={leftPanelView === 'toolPicker' && !readerMode ? "filled" : "subtle"}
onClick={onToolsClick}
>
<AppsIcon sx={{ fontSize: 28 }} />
</ActionIcon>
<span className="text-xs text-center leading-tight" style={{ color: 'var(--text-secondary)' }}>Tools</span>
</div>
{/* Reader Mode Button */}
<div className="flex flex-col items-center gap-1">
<ActionIcon
size="xl"
variant={readerMode ? "filled" : "subtle"}
onClick={onReaderToggle}
>
<MenuBookIcon sx={{ fontSize: 28 }} />
</ActionIcon>
<span className="text-xs text-center leading-tight" style={{ color: 'var(--text-secondary)' }}>Read</span>
</div>
{/* Spacer */}
<div className="flex-1" />
</Stack>
</div>
);
};
export default QuickAccessBar;
@@ -0,0 +1,55 @@
import React, { createContext, useContext, ReactNode } from 'react';
import { MantineProvider, ColorSchemeScript } from '@mantine/core';
import { useRainbowTheme } from '../../hooks/useRainbowTheme';
import { mantineTheme } from '../../theme/mantineTheme';
import rainbowStyles from '../../styles/rainbow.module.css';
interface RainbowThemeContextType {
themeMode: 'light' | 'dark' | 'rainbow';
isRainbowMode: boolean;
isToggleDisabled: boolean;
toggleTheme: () => void;
activateRainbow: () => void;
deactivateRainbow: () => void;
}
const RainbowThemeContext = createContext<RainbowThemeContextType | null>(null);
export function useRainbowThemeContext() {
const context = useContext(RainbowThemeContext);
if (!context) {
throw new Error('useRainbowThemeContext must be used within RainbowThemeProvider');
}
return context;
}
interface RainbowThemeProviderProps {
children: ReactNode;
}
export function RainbowThemeProvider({ children }: RainbowThemeProviderProps) {
const rainbowTheme = useRainbowTheme();
// Determine the Mantine color scheme
const mantineColorScheme = rainbowTheme.themeMode === 'rainbow' ? 'dark' : rainbowTheme.themeMode;
return (
<>
<ColorSchemeScript defaultColorScheme={mantineColorScheme} />
<RainbowThemeContext.Provider value={rainbowTheme}>
<MantineProvider
theme={mantineTheme}
defaultColorScheme={mantineColorScheme}
forceColorScheme={mantineColorScheme}
>
<div
className={rainbowTheme.isRainbowMode ? rainbowStyles.rainbowMode : ''}
style={{ minHeight: '100vh' }}
>
{children}
</div>
</MantineProvider>
</RainbowThemeContext.Provider>
</>
);
}
@@ -0,0 +1,106 @@
import React from "react";
import { Button, SegmentedControl } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import LanguageSelector from "./LanguageSelector";
import rainbowStyles from '../../styles/rainbow.module.css';
import DarkModeIcon from '@mui/icons-material/DarkMode';
import LightModeIcon from '@mui/icons-material/LightMode';
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
import VisibilityIcon from "@mui/icons-material/Visibility";
import EditNoteIcon from "@mui/icons-material/EditNote";
import InsertDriveFileIcon from "@mui/icons-material/InsertDriveFile";
import FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core";
const VIEW_OPTIONS = [
{
label: (
<Group gap={5}>
<VisibilityIcon fontSize="small" />
</Group>
),
value: "viewer",
},
{
label: (
<Group gap={4}>
<EditNoteIcon fontSize="small" />
</Group>
),
value: "pageEditor",
},
{
label: (
<Group gap={4}>
<InsertDriveFileIcon fontSize="small" />
</Group>
),
value: "fileManager",
},
{
label: (
<Group gap={4}>
<FolderIcon fontSize="small" />
</Group>
),
value: "fileEditor",
},
];
interface TopControlsProps {
currentView: string;
setCurrentView: (view: string) => void;
}
const TopControls = ({
currentView,
setCurrentView,
}: TopControlsProps) => {
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
const getThemeIcon = () => {
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
if (themeMode === "dark") return <LightModeIcon />;
return <DarkModeIcon />;
};
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-auto flex gap-2 items-center">
<Button
onClick={toggleTheme}
variant="subtle"
size="md"
aria-label="Toggle theme"
disabled={isToggleDisabled}
className={isRainbowMode ? rainbowStyles.rainbowButton : ''}
title={
isToggleDisabled
? "Button disabled for 3 seconds..."
: isRainbowMode
? "Rainbow Mode Active! Click to exit"
: "Toggle theme (click rapidly 6 times for a surprise!)"
}
style={isToggleDisabled ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
>
{getThemeIcon()}
</Button>
<LanguageSelector />
</div>
<div className="flex justify-center items-center h-full pointer-events-auto">
<SegmentedControl
data={VIEW_OPTIONS}
value={currentView}
onChange={setCurrentView}
color="blue"
radius="xl"
size="md"
fullWidth
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
/>
</div>
</div>
);
};
export default TopControls;
@@ -17,7 +17,7 @@ interface ToolPickerProps {
toolRegistry: ToolRegistry;
}
const ToolPicker: React.FC<ToolPickerProps> = ({ selectedToolKey, onSelect, toolRegistry }) => {
const ToolPicker = ({ selectedToolKey, onSelect, toolRegistry }: ToolPickerProps) => {
const { t } = useTranslation();
const [search, setSearch] = useState("");
@@ -0,0 +1,74 @@
import React from "react";
import { FileWithUrl } from "../../types/file";
interface ToolRendererProps {
selectedToolKey: string;
selectedTool: any;
pdfFile: any;
files: FileWithUrl[];
downloadUrl: string | null;
setDownloadUrl: (url: string | null) => void;
toolParams: any;
updateParams: (params: any) => void;
}
const ToolRenderer = ({
selectedToolKey,
selectedTool,
pdfFile,
files,
downloadUrl,
setDownloadUrl,
toolParams,
updateParams,
}: ToolRendererProps) => {
if (!selectedTool || !selectedTool.component) {
return <div>Tool not found</div>;
}
const ToolComponent = selectedTool.component;
// Pass tool-specific props
switch (selectedToolKey) {
case "split":
return (
<ToolComponent
file={pdfFile}
downloadUrl={downloadUrl}
setDownloadUrl={setDownloadUrl}
params={toolParams}
updateParams={updateParams}
/>
);
case "compress":
return (
<ToolComponent
files={files}
setDownloadUrl={setDownloadUrl}
setLoading={(loading: boolean) => {}} // TODO: Add loading state
params={toolParams}
updateParams={updateParams}
/>
);
case "merge":
return (
<ToolComponent
files={files}
setDownloadUrl={setDownloadUrl}
params={toolParams}
updateParams={updateParams}
/>
);
default:
return (
<ToolComponent
files={files}
setDownloadUrl={setDownloadUrl}
params={toolParams}
updateParams={updateParams}
/>
);
}
};
export default ToolRenderer;
+39
View File
@@ -0,0 +1,39 @@
import { useMemo } from 'react';
/**
* Hook to convert a File object to { file: File; url: string } format
* Creates blob URL on-demand and handles cleanup
*/
export function useFileWithUrl(file: File | null): { file: File; url: string } | null {
return useMemo(() => {
if (!file) return null;
const url = URL.createObjectURL(file);
// Return object with cleanup function
const result = { file, url };
// Store cleanup function for later use
(result as any)._cleanup = () => URL.revokeObjectURL(url);
return result;
}, [file]);
}
/**
* Hook variant that returns cleanup function separately
*/
export function useFileWithUrlAndCleanup(file: File | null): {
fileObj: { file: File; url: string } | null;
cleanup: () => void;
} {
return useMemo(() => {
if (!file) return { fileObj: null, cleanup: () => {} };
const url = URL.createObjectURL(file);
const fileObj = { file, url };
const cleanup = () => URL.revokeObjectURL(url);
return { fileObj, cleanup };
}, [file]);
}
+92
View File
@@ -0,0 +1,92 @@
import { useState, useCallback } from 'react';
import { getDocument } from 'pdfjs-dist';
import { PDFDocument, PDFPage } from '../types/pageEditor';
export function usePDFProcessor() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const generatePageThumbnail = useCallback(async (
file: File,
pageNumber: number,
scale: number = 0.5
): Promise<string> => {
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({ data: arrayBuffer }).promise;
const page = await pdf.getPage(pageNumber);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
const context = canvas.getContext('2d');
if (!context) {
throw new Error('Could not get canvas context');
}
await page.render({ canvasContext: context, viewport }).promise;
const thumbnail = canvas.toDataURL();
// Clean up
pdf.destroy();
return thumbnail;
} catch (error) {
console.error('Failed to generate thumbnail:', error);
throw error;
}
}, []);
const processPDFFile = useCallback(async (file: File): Promise<PDFDocument> => {
setLoading(true);
setError(null);
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await getDocument({ data: arrayBuffer }).promise;
const totalPages = pdf.numPages;
const pages: PDFPage[] = [];
// Generate thumbnails for all pages
for (let i = 1; i <= totalPages; i++) {
const thumbnail = await generatePageThumbnail(file, i);
pages.push({
id: `${file.name}-page-${i}`,
pageNumber: i,
thumbnail,
rotation: 0,
selected: false
});
}
// Clean up
pdf.destroy();
const document: PDFDocument = {
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
name: file.name,
file,
pages,
totalPages
};
return document;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Failed to process PDF';
setError(errorMessage);
throw error;
} finally {
setLoading(false);
}
}, [generatePageThumbnail]);
return {
processPDFFile,
generatePageThumbnail,
loading,
error
};
}
+200
View File
@@ -0,0 +1,200 @@
import { useState, useCallback, useRef, useEffect } from 'react';
type ThemeMode = 'light' | 'dark' | 'rainbow';
interface RainbowThemeHook {
themeMode: ThemeMode;
isRainbowMode: boolean;
isToggleDisabled: boolean;
toggleTheme: () => void;
activateRainbow: () => void;
deactivateRainbow: () => void;
}
export function useRainbowTheme(initialTheme: 'light' | 'dark' = 'light'): RainbowThemeHook {
// Get theme from localStorage or use initial
const [themeMode, setThemeMode] = useState<ThemeMode>(() => {
const stored = localStorage.getItem('stirling-theme');
if (stored && ['light', 'dark', 'rainbow'].includes(stored)) {
return stored as ThemeMode;
}
return initialTheme;
});
// Track rapid toggles for easter egg
const toggleCount = useRef(0);
const lastToggleTime = useRef(Date.now());
const [isToggleDisabled, setIsToggleDisabled] = useState(false);
// Save theme to localStorage whenever it changes
useEffect(() => {
localStorage.setItem('stirling-theme', themeMode);
// Apply rainbow class to body if in rainbow mode
if (themeMode === 'rainbow') {
document.body.classList.add('rainbow-mode-active');
// Show easter egg notification
showRainbowNotification();
} else {
document.body.classList.remove('rainbow-mode-active');
}
}, [themeMode]);
const showRainbowNotification = () => {
// Remove any existing notification
const existingNotification = document.getElementById('rainbow-notification');
if (existingNotification) {
existingNotification.remove();
}
// Create and show rainbow notification
const notification = document.createElement('div');
notification.id = 'rainbow-notification';
notification.innerHTML = '🌈 RAINBOW MODE ACTIVATED! 🌈<br><small>Button disabled for 3 seconds, then click to exit</small>';
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: linear-gradient(45deg, #ff0000, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff);
background-size: 300% 300%;
animation: rainbowBackground 1s ease infinite;
color: white;
padding: 15px 20px;
border-radius: 25px;
font-weight: bold;
font-size: 16px;
z-index: 1000;
border: 2px solid white;
box-shadow: 0 0 20px rgba(255, 255, 255, 0.8);
user-select: none;
pointer-events: none;
transition: opacity 0.3s ease;
`;
document.body.appendChild(notification);
// Auto-remove notification after 3 seconds
setTimeout(() => {
if (notification) {
notification.style.opacity = '0';
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
}
}, 3000);
};
const showExitNotification = () => {
// Remove any existing notification
const existingNotification = document.getElementById('rainbow-exit-notification');
if (existingNotification) {
existingNotification.remove();
}
// Create and show exit notification
const notification = document.createElement('div');
notification.id = 'rainbow-exit-notification';
notification.innerHTML = '🌙 Rainbow mode deactivated';
notification.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
background: linear-gradient(45deg, #333, #666);
color: white;
padding: 15px 20px;
border-radius: 25px;
font-weight: bold;
font-size: 16px;
z-index: 1000;
border: 2px solid #999;
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
user-select: none;
pointer-events: none;
transition: opacity 0.3s ease;
`;
document.body.appendChild(notification);
// Auto-remove notification after 2 seconds
setTimeout(() => {
if (notification) {
notification.style.opacity = '0';
setTimeout(() => {
if (notification.parentNode) {
notification.parentNode.removeChild(notification);
}
}, 300);
}
}, 2000);
};
const toggleTheme = useCallback(() => {
// Don't allow toggle if disabled
if (isToggleDisabled) {
return;
}
const currentTime = Date.now();
// Simple exit from rainbow mode with single click (after cooldown period)
if (themeMode === 'rainbow') {
setThemeMode('light');
console.log('🌈 Rainbow mode deactivated. Thanks for trying it!');
showExitNotification();
return;
}
// Reset counter if too much time has passed (2.5 seconds)
if (currentTime - lastToggleTime.current > 2500) {
toggleCount.current = 1;
} else {
toggleCount.current++;
}
lastToggleTime.current = currentTime;
// Easter egg: Activate rainbow mode after 6 rapid toggles
if (toggleCount.current >= 6) {
setThemeMode('rainbow');
console.log('🌈 RAINBOW MODE ACTIVATED! 🌈 You found the secret easter egg!');
console.log('🌈 Button will be disabled for 3 seconds, then click once to exit!');
// Disable toggle for 3 seconds
setIsToggleDisabled(true);
setTimeout(() => {
setIsToggleDisabled(false);
console.log('🌈 Theme toggle re-enabled! Click once to exit rainbow mode.');
}, 3000);
// Reset counter
toggleCount.current = 0;
return;
}
// Normal theme switching
setThemeMode(prevMode => prevMode === 'light' ? 'dark' : 'light');
}, [themeMode, isToggleDisabled]);
const activateRainbow = useCallback(() => {
setThemeMode('rainbow');
console.log('🌈 Rainbow mode manually activated!');
}, []);
const deactivateRainbow = useCallback(() => {
if (themeMode === 'rainbow') {
setThemeMode('light');
console.log('🌈 Rainbow mode manually deactivated.');
}
}, [themeMode]);
return {
themeMode,
isRainbowMode: themeMode === 'rainbow',
isToggleDisabled,
toggleTheme,
activateRainbow,
deactivateRainbow,
};
}
+130
View File
@@ -0,0 +1,130 @@
import { useSearchParams } from "react-router-dom";
import { useEffect } from "react";
// Tool parameter definitions (shortened URLs)
const TOOL_PARAMS = {
split: [
"mode", "p", "hd", "vd", "m",
"type", "val", "level", "meta", "dupes"
],
compress: [
"level", "gray", "rmeta", "size", "agg"
],
merge: [
"order", "rdupes"
]
};
// Extract params for a specific tool from URL
function getToolParams(toolKey: string, searchParams: URLSearchParams) {
switch (toolKey) {
case "split":
return {
mode: searchParams.get("mode") || "byPages",
pages: searchParams.get("p") || "",
hDiv: searchParams.get("hd") || "",
vDiv: searchParams.get("vd") || "",
merge: searchParams.get("m") === "true",
splitType: searchParams.get("type") || "size",
splitValue: searchParams.get("val") || "",
bookmarkLevel: searchParams.get("level") || "0",
includeMetadata: searchParams.get("meta") === "true",
allowDuplicates: searchParams.get("dupes") === "true",
};
case "compress":
return {
compressionLevel: parseInt(searchParams.get("level") || "5"),
grayscale: searchParams.get("gray") === "true",
removeMetadata: searchParams.get("rmeta") === "true",
expectedSize: searchParams.get("size") || "",
aggressive: searchParams.get("agg") === "true",
};
case "merge":
return {
order: searchParams.get("order") || "default",
removeDuplicates: searchParams.get("rdupes") === "true",
};
default:
return {};
}
}
// Update tool-specific params in URL
function updateToolParams(toolKey: string, searchParams: URLSearchParams, setSearchParams: any, newParams: any) {
const params = new URLSearchParams(searchParams);
// Clear tool-specific params
if (toolKey === "split") {
["mode", "p", "hd", "vd", "m", "type", "val", "level", "meta", "dupes"].forEach((k) => params.delete(k));
// Set new split params
const merged = { ...getToolParams("split", searchParams), ...newParams };
params.set("mode", merged.mode);
if (merged.mode === "byPages") params.set("p", merged.pages);
else if (merged.mode === "bySections") {
params.set("hd", merged.hDiv);
params.set("vd", merged.vDiv);
params.set("m", String(merged.merge));
} else if (merged.mode === "bySizeOrCount") {
params.set("type", merged.splitType);
params.set("val", merged.splitValue);
} else if (merged.mode === "byChapters") {
params.set("level", merged.bookmarkLevel);
params.set("meta", String(merged.includeMetadata));
params.set("dupes", String(merged.allowDuplicates));
}
} else if (toolKey === "compress") {
["level", "gray", "rmeta", "size", "agg"].forEach((k) => params.delete(k));
const merged = { ...getToolParams("compress", searchParams), ...newParams };
params.set("level", String(merged.compressionLevel));
params.set("gray", String(merged.grayscale));
params.set("rmeta", String(merged.removeMetadata));
if (merged.expectedSize) params.set("size", merged.expectedSize);
params.set("agg", String(merged.aggressive));
} else if (toolKey === "merge") {
["order", "rdupes"].forEach((k) => params.delete(k));
const merged = { ...getToolParams("merge", searchParams), ...newParams };
params.set("order", merged.order);
params.set("rdupes", String(merged.removeDuplicates));
}
setSearchParams(params, { replace: true });
}
export function useToolParams(selectedToolKey: string, currentView: string) {
const [searchParams, setSearchParams] = useSearchParams();
const toolParams = getToolParams(selectedToolKey, searchParams);
const updateParams = (newParams: any) =>
updateToolParams(selectedToolKey, searchParams, setSearchParams, newParams);
// Update URL when core state changes
useEffect(() => {
const params = new URLSearchParams(searchParams);
// Remove all tool-specific params except for the current tool
Object.entries(TOOL_PARAMS).forEach(([tool, keys]) => {
if (tool !== selectedToolKey) {
keys.forEach((k) => params.delete(k));
}
});
// Collect all params except 'v'
const entries = Array.from(params.entries()).filter(([key]) => key !== "v");
// Rebuild params with 'v' first
const newParams = new URLSearchParams();
newParams.set("v", currentView);
newParams.set("t", selectedToolKey);
entries.forEach(([key, value]) => {
if (key !== "t") newParams.set(key, value);
});
setSearchParams(newParams, { replace: true });
}, [selectedToolKey, currentView, setSearchParams, searchParams]);
return {
toolParams,
updateParams,
};
}
+68
View File
@@ -0,0 +1,68 @@
import { useState, useCallback } from 'react';
export interface Command {
execute(): void;
undo(): void;
description: string;
}
export interface CommandSequence {
commands: Command[];
execute(): void;
undo(): void;
description: string;
}
export function useUndoRedo() {
const [undoStack, setUndoStack] = useState<(Command | CommandSequence)[]>([]);
const [redoStack, setRedoStack] = useState<(Command | CommandSequence)[]>([]);
const executeCommand = useCallback((command: Command | CommandSequence) => {
command.execute();
setUndoStack(prev => [command, ...prev]);
setRedoStack([]); // Clear redo stack when new command is executed
}, []);
const undo = useCallback(() => {
if (undoStack.length === 0) return false;
const command = undoStack[0];
command.undo();
setUndoStack(prev => prev.slice(1));
setRedoStack(prev => [command, ...prev]);
return true;
}, [undoStack]);
const redo = useCallback(() => {
if (redoStack.length === 0) return false;
const command = redoStack[0];
command.execute();
setRedoStack(prev => prev.slice(1));
setUndoStack(prev => [command, ...prev]);
return true;
}, [redoStack]);
const clear = useCallback(() => {
setUndoStack([]);
setRedoStack([]);
}, []);
const canUndo = undoStack.length > 0;
const canRedo = redoStack.length > 0;
return {
executeCommand,
undo,
redo,
clear,
canUndo,
canRedo,
undoStack,
redoStack
};
}
-4
View File
@@ -1,7 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
+1 -1
View File
@@ -1,9 +1,9 @@
import '@mantine/core/styles.css';
import './index.css'; // Import Tailwind CSS
import React from 'react';
import ReactDOM from 'react-dom/client';
import { ColorSchemeScript, MantineProvider, mantineHtmlProps } from '@mantine/core';
import { BrowserRouter } from 'react-router-dom';
import './index.css';
import App from './App';
import './i18n'; // Initialize i18next
+263
View File
@@ -0,0 +1,263 @@
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib';
import { PDFDocument, PDFPage } from '../types/pageEditor';
export interface ExportOptions {
selectedOnly?: boolean;
filename?: string;
splitDocuments?: boolean;
}
export class PDFExportService {
/**
* Export PDF document with applied operations
*/
async exportPDF(
pdfDocument: PDFDocument,
selectedPageIds: string[] = [],
options: ExportOptions = {}
): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> {
const { selectedOnly = false, filename, splitDocuments = false } = options;
try {
// Determine which pages to export
const pagesToExport = selectedOnly && selectedPageIds.length > 0
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
: pdfDocument.pages;
if (pagesToExport.length === 0) {
throw new Error('No pages to export');
}
// Load original PDF once
const originalPDFBytes = await pdfDocument.file.arrayBuffer();
const sourceDoc = await PDFLibDocument.load(originalPDFBytes);
if (splitDocuments) {
return await this.createSplitDocuments(sourceDoc, pagesToExport, filename || pdfDocument.name);
} else {
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly);
return { blob, filename: exportFilename };
}
} catch (error) {
console.error('PDF export error:', error);
throw new Error(`Failed to export PDF: ${error instanceof Error ? error.message : 'Unknown error'}`);
}
}
/**
* Create a single PDF document with all operations applied
*/
private async createSingleDocument(
sourceDoc: PDFLibDocument,
pages: PDFPage[]
): Promise<Blob> {
const newDoc = await PDFLibDocument.create();
for (const page of pages) {
// Get the original page from source document
const sourcePageIndex = page.pageNumber - 1;
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
// Copy the page
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
// Apply rotation
if (page.rotation !== 0) {
copiedPage.setRotation(degrees(page.rotation));
}
newDoc.addPage(copiedPage);
}
}
// Set metadata
newDoc.setCreator('Stirling PDF');
newDoc.setProducer('Stirling PDF');
newDoc.setCreationDate(new Date());
newDoc.setModificationDate(new Date());
const pdfBytes = await newDoc.save();
return new Blob([pdfBytes], { type: 'application/pdf' });
}
/**
* Create multiple PDF documents based on split markers
*/
private async createSplitDocuments(
sourceDoc: PDFLibDocument,
pages: PDFPage[],
baseFilename: string
): Promise<{ blobs: Blob[]; filenames: string[] }> {
const splitPoints: number[] = [];
const blobs: Blob[] = [];
const filenames: string[] = [];
// Find split points
pages.forEach((page, index) => {
if (page.splitBefore && index > 0) {
splitPoints.push(index);
}
});
// Add end point
splitPoints.push(pages.length);
let startIndex = 0;
let partNumber = 1;
for (const endIndex of splitPoints) {
const segmentPages = pages.slice(startIndex, endIndex);
if (segmentPages.length > 0) {
const newDoc = await PDFLibDocument.create();
for (const page of segmentPages) {
const sourcePageIndex = page.pageNumber - 1;
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
if (page.rotation !== 0) {
copiedPage.setRotation(degrees(page.rotation));
}
newDoc.addPage(copiedPage);
}
}
// Set metadata
newDoc.setCreator('Stirling PDF');
newDoc.setProducer('Stirling PDF');
newDoc.setTitle(`${baseFilename} - Part ${partNumber}`);
const pdfBytes = await newDoc.save();
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
const filename = this.generateSplitFilename(baseFilename, partNumber);
blobs.push(blob);
filenames.push(filename);
partNumber++;
}
startIndex = endIndex;
}
return { blobs, filenames };
}
/**
* Generate appropriate filename for export
*/
private generateFilename(originalName: string, selectedOnly: boolean): string {
const baseName = originalName.replace(/\.pdf$/i, '');
const suffix = selectedOnly ? '_selected' : '_edited';
return `${baseName}${suffix}.pdf`;
}
/**
* Generate filename for split documents
*/
private generateSplitFilename(baseName: string, partNumber: number): string {
const cleanBaseName = baseName.replace(/\.pdf$/i, '');
return `${cleanBaseName}_part_${partNumber}.pdf`;
}
/**
* Download a single file
*/
downloadFile(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.style.display = 'none';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Clean up the URL after a short delay
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
/**
* Download multiple files as a ZIP
*/
async downloadAsZip(blobs: Blob[], filenames: string[], zipFilename: string): Promise<void> {
// For now, download files individually
// TODO: Implement ZIP creation when needed
blobs.forEach((blob, index) => {
setTimeout(() => {
this.downloadFile(blob, filenames[index]);
}, index * 500); // Stagger downloads
});
}
/**
* Validate PDF operations before export
*/
validateExport(pdfDocument: PDFDocument, selectedPageIds: string[], selectedOnly: boolean): string[] {
const errors: string[] = [];
if (selectedOnly && selectedPageIds.length === 0) {
errors.push('No pages selected for export');
}
if (pdfDocument.pages.length === 0) {
errors.push('No pages available to export');
}
const pagesToExport = selectedOnly
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
: pdfDocument.pages;
if (pagesToExport.length === 0) {
errors.push('No valid pages to export after applying filters');
}
return errors;
}
/**
* Get export preview information
*/
getExportInfo(pdfDocument: PDFDocument, selectedPageIds: string[], selectedOnly: boolean): {
pageCount: number;
splitCount: number;
estimatedSize: string;
} {
const pagesToExport = selectedOnly
? pdfDocument.pages.filter(page => selectedPageIds.includes(page.id))
: pdfDocument.pages;
const splitCount = pagesToExport.reduce((count, page, index) => {
return count + (page.splitBefore && index > 0 ? 1 : 0);
}, 1); // At least 1 document
// Rough size estimation (very approximate)
const avgPageSize = pdfDocument.file.size / pdfDocument.totalPages;
const estimatedBytes = avgPageSize * pagesToExport.length;
const estimatedSize = this.formatFileSize(estimatedBytes);
return {
pageCount: pagesToExport.length,
splitCount,
estimatedSize
};
}
/**
* Format file size for display
*/
private formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
}
// Export singleton instance
export const pdfExportService = new PDFExportService();
+202
View File
@@ -0,0 +1,202 @@
/* Rainbow Mode Styles - Easter Egg! */
@keyframes rainbowBackground {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
@keyframes rainbowBorder {
0% { border-color: #ff0000; box-shadow: 0 0 15px #ff0000; }
14% { border-color: #ff8800; box-shadow: 0 0 15px #ff8800; }
28% { border-color: #ffff00; box-shadow: 0 0 15px #ffff00; }
42% { border-color: #88ff00; box-shadow: 0 0 15px #88ff00; }
57% { border-color: #00ff88; box-shadow: 0 0 15px #00ff88; }
71% { border-color: #0088ff; box-shadow: 0 0 15px #0088ff; }
85% { border-color: #8800ff; box-shadow: 0 0 15px #8800ff; }
100% { border-color: #ff0000; box-shadow: 0 0 15px #ff0000; }
}
@keyframes rainbowText {
0% { color: #ff0000; text-shadow: 0 0 10px #ff0000; }
14% { color: #ff8800; text-shadow: 0 0 10px #ff8800; }
28% { color: #ffff00; text-shadow: 0 0 10px #ffff00; }
42% { color: #88ff00; text-shadow: 0 0 10px #88ff00; }
57% { color: #00ff88; text-shadow: 0 0 10px #00ff88; }
71% { color: #0088ff; text-shadow: 0 0 10px #0088ff; }
85% { color: #8800ff; text-shadow: 0 0 10px #8800ff; }
100% { color: #ff0000; text-shadow: 0 0 10px #ff0000; }
}
@keyframes rainbowPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.05); }
}
/* Main rainbow theme class */
.rainbowMode {
background: linear-gradient(
-45deg,
#ff0000, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff, #ff0088, #ff0000
) !important;
background-size: 400% 400% !important;
animation: rainbowBackground 3s ease infinite !important;
color: white !important;
overflow-x: hidden;
}
/* Rainbow components */
.rainbowCard {
background: linear-gradient(
45deg,
#ff0000, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff, #ff0088
) !important;
background-size: 400% 400% !important;
animation: rainbowBackground 4s ease infinite, rainbowBorder 2s linear infinite !important;
color: white !important;
border: 2px solid !important;
border-radius: 15px !important;
box-shadow: 0 0 20px rgba(255, 255, 255, 0.3) !important;
}
.rainbowButton {
background: linear-gradient(
45deg,
#ff0000, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff, #ff0088
) !important;
background-size: 300% 300% !important;
animation: rainbowBackground 2s ease infinite, rainbowBorder 1s linear infinite !important;
border: 2px solid !important;
color: white !important;
border-radius: 8px !important;
transition: all 0.3s ease !important;
font-weight: bold !important;
}
.rainbowButton:hover {
transform: scale(1.05) !important;
animation: rainbowBackground 1s ease infinite, rainbowBorder 0.5s linear infinite, rainbowPulse 0.5s ease infinite !important;
box-shadow: 0 0 25px rgba(255, 255, 255, 0.6) !important;
}
.rainbowInput {
background: linear-gradient(
90deg,
#ff0000, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff, #ff0088
) !important;
background-size: 300% 300% !important;
animation: rainbowBackground 2.5s ease infinite, rainbowBorder 1.5s linear infinite !important;
border: 2px solid !important;
color: white !important;
border-radius: 8px !important;
}
.rainbowInput::placeholder {
color: rgba(255, 255, 255, 0.8) !important;
}
.rainbowText {
animation: rainbowText 3s linear infinite !important;
font-weight: bold !important;
text-shadow: 0 0 10px currentColor !important;
}
.rainbowSegmentedControl {
background: linear-gradient(
45deg,
#ff0000, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff, #ff0088
) !important;
background-size: 400% 400% !important;
animation: rainbowBackground 3s ease infinite, rainbowBorder 2s linear infinite !important;
border: 2px solid !important;
border-radius: 12px !important;
padding: 4px !important;
}
.rainbowPaper {
background: linear-gradient(
90deg,
#00ffff, #0088ff, #8800ff, #ff0088
) !important;
background-size: 100% 100% !important;
animation: rainbowBackground 3.5s ease infinite, rainbowBorder 2s linear infinite !important;
border: 2px solid !important;
color: white !important;
border-radius: 12px !important;
}
/* Easter egg notification */
.rainbowNotification {
position: fixed !important;
top: 20px !important;
right: 20px !important;
background: linear-gradient(45deg,#ffff00, #88ff00, #00ff88, #00ffff) !important;
background-size: 300% 300% !important;
animation: rainbowBackground 1s ease infinite, rainbowBorder 0.5s linear infinite !important;
color: white !important;
padding: 15px 20px !important;
border-radius: 25px !important;
font-weight: bold !important;
font-size: 16px !important;
z-index: 10000 !important;
border: 2px solid white !important;
box-shadow: 0 0 20px rgba(255, 255, 255, 0.8) !important;
user-select: none !important;
pointer-events: none !important;
}
/* Specific component overrides */
.rainbowMode [data-mantine-color-scheme] {
background: linear-gradient(
-45deg,
#ff0000, #ff8800, #ffff00, #88ff00, #00ff88, #00ffff, #0088ff, #8800ff, #ff0088, #ff0000
) !important;
background-size: 400% 400% !important;
animation: rainbowBackground 3s ease infinite !important;
}
/* Make all buttons rainbow in rainbow mode */
.rainbowMode button {
background: linear-gradient(
45deg,
#ffff00, #88ff00, #00ff88, #00ffff
) !important;
background-size: 100% 100% !important;
animation: rainbowBackground 2s ease infinite !important;
border: 2px solid !important;
animation: rainbowBackground 2s ease infinite, rainbowBorder 1.5s linear infinite !important;
color: white !important;
font-weight: bold !important;
}
/* Make all inputs rainbow in rainbow mode */
.rainbowMode input,
.rainbowMode select,
.rainbowMode textarea {
background: linear-gradient(
90deg,
#ffff00, #88ff00, #00ff88, #00ffff
) !important;
background-size: 100% 100% !important;
animation: rainbowBackground 2.5s ease infinite !important;
border: 2px solid !important;
animation: rainbowBackground 2.5s ease infinite, rainbowBorder 1.5s linear infinite !important;
color: white !important;
}
/* Rainbow text class */
.rainbowText {
animation: rainbowText 3s linear infinite !important;
font-weight: bold !important;
text-shadow: 0 0 10px currentColor !important;
}
/* Make all text rainbow */
.rainbowMode h1,
.rainbowMode h2,
.rainbowMode h3,
.rainbowMode h4,
.rainbowMode h5,
.rainbowMode h6 {
animation: rainbowText 3s linear infinite !important;
font-weight: bold !important;
}
+15
View File
@@ -0,0 +1,15 @@
/* Import minimal theme variables */
@import './theme.css';
@layer base {
@tailwind base;
}
@layer components {
@tailwind components;
}
@layer utilities {
@tailwind utilities;
}
+139
View File
@@ -0,0 +1,139 @@
/* CSS variables for Tailwind + Mantine integration */
:root {
/* Standard gray scale */
--gray-50: 249 250 251;
--gray-100: 243 244 246;
--gray-200: 229 231 235;
--gray-300: 209 213 219;
--gray-400: 156 163 175;
--gray-500: 107 114 128;
--gray-600: 75 85 99;
--gray-700: 55 65 81;
--gray-800: 31 41 55;
--gray-900: 17 24 39;
/* Semantic colors for Tailwind */
--surface: 255 255 255;
--background: 249 250 251;
--border: 229 231 235;
/* Colors for Mantine integration */
--color-primary-50: #eff6ff;
--color-primary-100: #dbeafe;
--color-primary-200: #bfdbfe;
--color-primary-300: #93c5fd;
--color-primary-400: #60a5fa;
--color-primary-500: #3b82f6;
--color-primary-600: #2563eb;
--color-primary-700: #1d4ed8;
--color-primary-800: #1e40af;
--color-primary-900: #1e3a8a;
--color-gray-50: #f9fafb;
--color-gray-100: #f3f4f6;
--color-gray-200: #e5e7eb;
--color-gray-300: #d1d5db;
--color-gray-400: #9ca3af;
--color-gray-500: #6b7280;
--color-gray-600: #4b5563;
--color-gray-700: #374151;
--color-gray-800: #1f2937;
--color-gray-900: #111827;
/* Spacing system */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 32px;
/* Radius system */
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-xl: 16px;
/* Shadow system */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.1);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
/* Font weights */
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-weight-bold: 700;
/* Light theme semantic colors */
--bg-surface: #ffffff;
--bg-raised: #f9fafb;
--bg-muted: #f3f4f6;
--text-primary: #111827;
--text-secondary: #4b5563;
--text-muted: #6b7280;
--border-subtle: #e5e7eb;
--border-default: #d1d5db;
--border-strong: #9ca3af;
--hover-bg: #f9fafb;
--active-bg: #f3f4f6;
}
[data-mantine-color-scheme="dark"] {
/* Dark theme gray scale (inverted) */
--gray-50: 17 24 39;
--gray-100: 31 41 55;
--gray-200: 55 65 81;
--gray-300: 75 85 99;
--gray-400: 107 114 128;
--gray-500: 156 163 175;
--gray-600: 209 213 219;
--gray-700: 229 231 235;
--gray-800: 243 244 246;
--gray-900: 249 250 251;
/* Dark semantic colors for Tailwind */
--surface: 31 41 55;
--background: 17 24 39;
--border: 75 85 99;
/* Dark theme Mantine colors */
--color-gray-50: #111827;
--color-gray-100: #1f2937;
--color-gray-200: #374151;
--color-gray-300: #4b5563;
--color-gray-400: #6b7280;
--color-gray-500: #9ca3af;
--color-gray-600: #d1d5db;
--color-gray-700: #e5e7eb;
--color-gray-800: #f3f4f6;
--color-gray-900: #f9fafb;
/* Dark theme semantic colors */
--bg-surface: #1f2937;
--bg-raised: #374151;
--bg-muted: #374151;
--text-primary: #f9fafb;
--text-secondary: #d1d5db;
--text-muted: #9ca3af;
--border-subtle: #374151;
--border-default: #4b5563;
--border-strong: #6b7280;
--hover-bg: #374151;
--active-bg: #4b5563;
/* Adjust shadows for dark mode */
--shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.4);
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.4);
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.4);
}
/* Smooth transitions for theme switching */
* {
transition: background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease;
}
+281
View File
@@ -0,0 +1,281 @@
import { createTheme, MantineColorsTuple } from '@mantine/core';
// Define color tuples using CSS variables
const primary: MantineColorsTuple = [
'var(--color-primary-50)',
'var(--color-primary-100)',
'var(--color-primary-200)',
'var(--color-primary-300)',
'var(--color-primary-400)',
'var(--color-primary-500)',
'var(--color-primary-600)',
'var(--color-primary-700)',
'var(--color-primary-800)',
'var(--color-primary-900)',
];
const gray: MantineColorsTuple = [
'var(--color-gray-50)',
'var(--color-gray-100)',
'var(--color-gray-200)',
'var(--color-gray-300)',
'var(--color-gray-400)',
'var(--color-gray-500)',
'var(--color-gray-600)',
'var(--color-gray-700)',
'var(--color-gray-800)',
'var(--color-gray-900)',
];
export const mantineTheme = createTheme({
// Primary color
primaryColor: 'primary',
// Color palette
colors: {
primary,
gray,
},
// Spacing system - uses CSS variables
spacing: {
xs: 'var(--space-xs)',
sm: 'var(--space-sm)',
md: 'var(--space-md)',
lg: 'var(--space-lg)',
xl: 'var(--space-xl)',
},
// Border radius system
radius: {
xs: 'var(--radius-xs)',
sm: 'var(--radius-sm)',
md: 'var(--radius-md)',
lg: 'var(--radius-lg)',
xl: 'var(--radius-xl)',
},
// Shadow system
shadows: {
xs: 'var(--shadow-xs)',
sm: 'var(--shadow-sm)',
md: 'var(--shadow-md)',
lg: 'var(--shadow-lg)',
xl: 'var(--shadow-xl)',
},
// Font weights
fontWeights: {
normal: 'var(--font-weight-normal)',
medium: 'var(--font-weight-medium)',
semibold: 'var(--font-weight-semibold)',
bold: 'var(--font-weight-bold)',
},
// Component customizations
components: {
Button: {
styles: {
root: {
fontWeight: 'var(--font-weight-medium)',
transition: 'all 0.2s ease',
},
},
variants: {
// Custom button variant for PDF tools
pdfTool: (theme) => ({
root: {
backgroundColor: 'var(--bg-surface)',
border: '1px solid var(--border-default)',
color: 'var(--text-primary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--color-primary-500)',
},
},
}),
},
},
Paper: {
styles: {
root: {
backgroundColor: 'var(--bg-surface)',
border: '1px solid var(--border-subtle)',
},
},
},
Card: {
styles: {
root: {
backgroundColor: 'var(--bg-surface)',
border: '1px solid var(--border-subtle)',
boxShadow: 'var(--shadow-sm)',
},
},
},
TextInput: {
styles: {
input: {
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)',
color: 'var(--text-primary)',
'&:focus': {
borderColor: 'var(--color-primary-500)',
boxShadow: '0 0 0 1px var(--color-primary-500)',
},
},
label: {
color: 'var(--text-secondary)',
fontWeight: 'var(--font-weight-medium)',
},
},
},
Select: {
styles: {
input: {
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-default)',
color: 'var(--text-primary)',
'&:focus': {
borderColor: 'var(--color-primary-500)',
boxShadow: '0 0 0 1px var(--color-primary-500)',
},
},
label: {
color: 'var(--text-secondary)',
fontWeight: 'var(--font-weight-medium)',
},
dropdown: {
backgroundColor: 'var(--bg-surface)',
borderColor: 'var(--border-subtle)',
boxShadow: 'var(--shadow-lg)',
},
option: {
color: 'var(--text-primary)',
'&[data-hovered]': {
backgroundColor: 'var(--hover-bg)',
},
'&[data-selected]': {
backgroundColor: 'var(--color-primary-100)',
color: 'var(--color-primary-900)',
},
},
},
},
Checkbox: {
styles: {
input: {
borderColor: 'var(--border-default)',
'&:checked': {
backgroundColor: 'var(--color-primary-500)',
borderColor: 'var(--color-primary-500)',
},
},
label: {
color: 'var(--text-primary)',
},
},
},
Slider: {
styles: {
track: {
backgroundColor: 'var(--bg-muted)',
},
bar: {
backgroundColor: 'var(--color-primary-500)',
},
thumb: {
backgroundColor: 'var(--color-primary-500)',
borderColor: 'var(--color-primary-500)',
},
mark: {
borderColor: 'var(--border-default)',
},
markLabel: {
color: 'var(--text-muted)',
},
},
},
Modal: {
styles: {
content: {
backgroundColor: 'var(--bg-surface)',
border: '1px solid var(--border-subtle)',
boxShadow: 'var(--shadow-xl)',
},
header: {
backgroundColor: 'var(--bg-surface)',
borderBottom: '1px solid var(--border-subtle)',
},
title: {
color: 'var(--text-primary)',
fontWeight: 'var(--font-weight-semibold)',
},
},
},
Notification: {
styles: {
root: {
backgroundColor: 'var(--bg-surface)',
border: '1px solid var(--border-subtle)',
boxShadow: 'var(--shadow-lg)',
},
title: {
color: 'var(--text-primary)',
},
description: {
color: 'var(--text-secondary)',
},
},
},
SegmentedControl: {
styles: {
root: {
backgroundColor: 'var(--bg-muted)',
border: '1px solid var(--border-subtle)',
},
control: {
color: 'var(--text-secondary)',
'[dataActive]': {
backgroundColor: 'var(--bg-surface)',
color: 'var(--text-primary)',
boxShadow: 'var(--shadow-sm)',
},
},
},
},
},
// Global styles
globalStyles: () => ({
// Ensure smooth color transitions
'*': {
transition: 'background-color 0.2s ease, border-color 0.2s ease, color 0.2s ease',
},
// Custom scrollbar styling
'*::-webkit-scrollbar': {
width: '8px',
height: '8px',
},
'*::-webkit-scrollbar-track': {
backgroundColor: 'var(--bg-muted)',
},
'*::-webkit-scrollbar-thumb': {
backgroundColor: 'var(--border-strong)',
borderRadius: 'var(--radius-md)',
},
'*::-webkit-scrollbar-thumb:hover': {
backgroundColor: 'var(--color-primary-500)',
},
}),
});
+2 -2
View File
@@ -143,7 +143,7 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
};
return (
<form onSubmit={handleSubmit}>
<form onSubmit={handleSubmit} className="app-surface p-app-md rounded-app-md">
<Stack gap="sm" mb={16}>
<Select
label={t("split-by-size-or-count.type.label", "Split Mode")}
@@ -240,7 +240,7 @@ const SplitPdfPanel: React.FC<SplitPdfPanelProps> = ({
{isLoading ? t("loading") : t("split.submit", "Split PDF")}
</Button>
{status && <p className="text-xs text-gray-600">{status}</p>}
{status && <p className="text-xs text-text-muted">{status}</p>}
{errorMessage && (
<Notification color="red" title={t("error._value", "Error")} onClose={() => setErrorMessage(null)}>
+27
View File
@@ -0,0 +1,27 @@
export interface PDFPage {
id: string;
pageNumber: number;
thumbnail: string;
rotation: number;
selected: boolean;
splitBefore?: boolean;
}
export interface PDFDocument {
id: string;
name: string;
file: File;
pages: PDFPage[];
totalPages: number;
}
export interface PageOperation {
type: 'rotate' | 'delete' | 'move' | 'split' | 'insert';
pageIds: string[];
data?: any;
}
export interface UndoRedoState {
operations: PageOperation[];
currentIndex: number;
}