Merge remote-tracking branch 'origin/V2' into automateFixes

This commit is contained in:
Connor Yoh 2025-08-25 14:40:59 +01:00
commit 890ecc1d4b
27 changed files with 1268 additions and 369 deletions

View File

@ -604,6 +604,10 @@
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
}
},
"landing": {
"addFiles": "Add Files",
"uploadFromComputer": "Upload from computer"
},
"viewPdf": {
"tags": "view,read,annotate,text,image,highlight,edit",
"title": "View/Edit PDF",
@ -988,6 +992,7 @@
},
"submit": "Change"
},
"removePages": {
"tags": "Remove pages,delete pages",
"title": "Remove Pages",
@ -1922,6 +1927,19 @@
"currentPage": "Current Page",
"totalPages": "Total Pages"
},
"rightRail": {
"closeSelected": "Close Selected Files",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"selectByNumber": "Select by Page Numbers",
"deleteSelected": "Delete Selected Pages",
"closePdf": "Close PDF",
"exportAll": "Export PDF",
"downloadSelected": "Download Selected Files",
"downloadAll": "Download All",
"toggleTheme": "Toggle Theme",
"language": "Language"
},
"toolPicker": {
"searchPlaceholder": "Search tools...",
"noToolsFound": "No tools found",

View File

@ -55,6 +55,7 @@
"bored": "Bored Waiting?",
"alphabet": "Alphabet",
"downloadPdf": "Download PDF",
"text": "Text",
"font": "Font",
"selectFillter": "-- Select --",
@ -607,6 +608,10 @@
"desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size"
}
},
"landing": {
"addFiles": "Add Files",
"uploadFromComputer": "Upload from computer"
},
"viewPdf": {
"tags": "view,read,annotate,text,image,highlight,edit",
"title": "View/Edit PDF",
@ -2068,6 +2073,18 @@
}
}
},
"rightRail": {
"closePdf": "Close PDF",
"closeSelected": "Close Selected Files",
"selectAll": "Select All",
"deselectAll": "Deselect All",
"selectByNumber": "Select by Page Numbers",
"deleteSelected": "Delete Selected Pages",
"toggleTheme": "Toggle Theme",
"exportAll": "Export PDF",
"downloadSelected": "Download Selected Files",
"downloadAll": "Download All"
},
"removePassword": {
"title": "Remove Password",
"desc": "Remove password protection from your PDF document.",

View File

@ -11,6 +11,7 @@ import HomePage from "./pages/HomePage";
// Import global styles
import "./styles/tailwind.css";
import "./index.css";
import { RightRailProvider } from "./contexts/RightRailContext";
// Loading component for i18next suspense
const LoadingFallback = () => (
@ -38,7 +39,9 @@ export default function App() {
<FilesModalProvider>
<ToolWorkflowProvider>
<SidebarProvider>
<RightRailProvider>
<HomePage />
</RightRailProvider>
</SidebarProvider>
</ToolWorkflowProvider>
</FilesModalProvider>

View File

@ -1,7 +1,6 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import {
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
Stack, Group
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useTranslation } from 'react-i18next';
@ -466,21 +465,6 @@ const FileEditor = ({
<LoadingOverlay visible={false} />
<Box p="md" pt="xl">
<Group mb="md">
{toolMode && (
<>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
</>
)}
{showBulkActions && !toolMode && (
<>
<Button onClick={closeAllFiles} variant="light" color="orange">
Close All
</Button>
</>
)}
</Group>
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
@ -573,25 +557,29 @@ const FileEditor = ({
/>
{status && (
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
>
{status}
</Notification>
<Portal>
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10001 }}
>
{status}
</Notification>
</Portal>
)}
{error && (
<Notification
color="red"
mt="md"
onClose={() => setError(null)}
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
>
{error}
</Notification>
<Portal>
<Notification
color="red"
mt="md"
onClose={() => setError(null)}
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
>
{error}
</Notification>
</Portal>
)}
</Box>
</Dropzone>

View File

@ -157,6 +157,7 @@ export default function Workbench() {
className="flex-1 min-h-0 relative z-10"
style={{
transition: 'opacity 0.15s ease-in-out',
marginTop: '1rem',
}}
>
{renderMainContent()}

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
import { Group, TextInput, Button, Text } from '@mantine/core';
interface BulkSelectionPanelProps {
csvInput: string;
@ -15,7 +15,7 @@ const BulkSelectionPanel = ({
onUpdatePagesFromCSV,
}: BulkSelectionPanelProps) => {
return (
<Paper p="md" mb="md" withBorder>
<>
<Group>
<TextInput
value={csvInput}
@ -35,7 +35,7 @@ const BulkSelectionPanel = ({
Selected: {selectedPages.length} pages
</Text>
)}
</Paper>
</>
);
};

View File

@ -141,7 +141,6 @@ const FileThumbnail = ({
filter: isSupported ? 'none' : 'grayscale(50%)'
}}
>
{selectionMode && (
<div
className={styles.checkboxContainer}
data-testid="file-thumbnail-checkbox"
@ -175,7 +174,6 @@ const FileThumbnail = ({
size="sm"
/>
</div>
)}
{/* File content area */}
<div className="file-container w-[90%] h-[80%] relative">

View File

@ -1,14 +1,13 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from "react";
import {
Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon,
Button, Text, Center, Box,
Notification, TextInput, LoadingOverlay, Modal, Alert,
Stack, Group
Stack, Group, Portal
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
import { ModeType } from "../../contexts/NavigationContext";
import { PDFDocument, PDFPage } from "../../types/pageEditor";
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
import { useUndoRedo } from "../../hooks/useUndoRedo";
import {
RotatePagesCommand,
@ -56,7 +55,6 @@ export interface PageEditorProps {
const PageEditor = ({
onFunctionsReady,
}: PageEditorProps) => {
const { t } = useTranslation();
// Use split contexts to prevent re-renders
const { state, selectors } = useFileState();
@ -241,19 +239,26 @@ const PageEditor = ({
const [exportLoading, setExportLoading] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
const [exportSelectedOnly, setExportSelectedOnly] = useState<boolean>(false);
// Animation state
const [movingPage, setMovingPage] = useState<number | 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();
// Track whether the user has manually edited the filename to avoid auto-overwrites
const userEditedFilename = useRef(false);
// Reset user edit flag when the active files change, so defaults can be applied for new docs
useEffect(() => {
userEditedFilename.current = false;
}, [filesSignature]);
// Set initial filename when document changes - use stable signature
useEffect(() => {
if (userEditedFilename.current) return; // Do not overwrite user-typed filename
if (mergedPdfDocument) {
if (activeFileIds.length === 1 && primaryFileId) {
const record = selectors.getFileRecord(primaryFileId);
@ -838,14 +843,18 @@ const PageEditor = ({
const handleDelete = useCallback(() => {
if (!displayDocument) return;
const pagesToDelete = selectionMode
? selectedPageNumbers.map(pageNum => {
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
return page?.id || '';
}).filter(id => id)
const hasSelectedPages = selectedPageNumbers.length > 0;
const pagesToDelete = (selectionMode || hasSelectedPages)
? selectedPageNumbers
.map(pageNum => {
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
return page?.id || '';
})
.filter(id => id)
: displayDocument.pages.map(p => p.id);
if (selectionMode && selectedPageNumbers.length === 0) return;
if ((selectionMode || hasSelectedPages) && selectedPageNumbers.length === 0) return;
const command = new DeletePagesCommand(
displayDocument,
@ -857,7 +866,7 @@ const PageEditor = ({
if (selectionMode) {
actions.setSelectedPages([]);
}
const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length;
const pageCount = (selectionMode || hasSelectedPages) ? selectedPageNumbers.length : displayDocument.pages.length;
setStatus(`Deleted ${pageCount} pages`);
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, actions]);
@ -885,49 +894,52 @@ const PageEditor = ({
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]);
const showExportPreview = useCallback((selectedOnly: boolean = false) => {
if (!mergedPdfDocument) return;
const doc = editedDocument || mergedPdfDocument;
if (!doc) return;
// Convert page numbers to page IDs for export service
const exportPageIds = selectedOnly
? selectedPageNumbers.map(pageNum => {
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
const page = doc.pages.find(p => p.pageNumber === pageNum);
return page?.id || '';
}).filter(id => id)
: [];
const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly);
const preview = pdfExportService.getExportInfo(doc, exportPageIds, selectedOnly);
setExportPreview(preview);
setExportSelectedOnly(selectedOnly);
setShowExportModal(true);
}, [mergedPdfDocument, selectedPageNumbers]);
}, [editedDocument, mergedPdfDocument, selectedPageNumbers]);
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
if (!mergedPdfDocument) return;
const doc = editedDocument || mergedPdfDocument;
if (!doc) return;
setExportLoading(true);
try {
// Convert page numbers to page IDs for export service
const exportPageIds = selectedOnly
? selectedPageNumbers.map(pageNum => {
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
const page = doc.pages.find(p => p.pageNumber === pageNum);
return page?.id || '';
}).filter(id => id)
: [];
const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly);
const errors = pdfExportService.validateExport(doc, exportPageIds, selectedOnly);
if (errors.length > 0) {
setStatus(errors.join(', '));
return;
}
const hasSplitMarkers = mergedPdfDocument.pages.some(page => page.splitBefore);
const hasSplitMarkers = doc.pages.some(page => page.splitBefore);
if (hasSplitMarkers) {
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, {
const result = await pdfExportService.exportPDF(doc, exportPageIds, {
selectedOnly,
filename,
splitDocuments: true
splitDocuments: true,
appendSuffix: false
}) as { blobs: Blob[]; filenames: string[] };
result.blobs.forEach((blob, index) => {
@ -938,9 +950,10 @@ const PageEditor = ({
setStatus(`Exported ${result.blobs.length} split documents`);
} else {
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, {
const result = await pdfExportService.exportPDF(doc, exportPageIds, {
selectedOnly,
filename
filename,
appendSuffix: false
}) as { blob: Blob; filename: string };
pdfExportService.downloadFile(result.blob, result.filename);
@ -953,7 +966,7 @@ const PageEditor = ({
} finally {
setExportLoading(false);
}
}, [mergedPdfDocument, selectedPageNumbers, filename]);
}, [editedDocument, mergedPdfDocument, selectedPageNumbers, filename]);
const handleUndo = useCallback(() => {
if (undo()) {
@ -1240,59 +1253,13 @@ const PageEditor = ({
</div>
</Box>
)}
<Group mb="md">
<TextInput
label="Filename"
value={filename}
onChange={(e) => setFilename(e.target.value)}
placeholder="Enter filename"
style={{ minWidth: 200 }}
style={{ minWidth: 200, maxWidth: 200, marginLeft: "1rem"}}
/>
<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>
</>
)}
{/* Apply Changes Button */}
{hasUnsavedChanges && (
<Button
onClick={applyChanges}
color="green"
variant="filled"
style={{ marginLeft: 'auto' }}
>
Apply Changes
</Button>
)}
</Group>
{selectionMode && (
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPages={selectedPageNumbers}
onUpdatePagesFromCSV={updatePagesFromCSV}
/>
)}
<DragDropGrid
@ -1386,8 +1353,7 @@ const PageEditor = ({
loading={exportLoading}
onClick={() => {
setShowExportModal(false);
const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.pages.length || 0);
handleExport(selectedOnly);
handleExport(exportSelectedOnly);
}}
>
Export PDF
@ -1446,14 +1412,16 @@ const PageEditor = ({
</Modal>
{status && (
<Portal>
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10000 }}
>
{status}
</Notification>
</Portal>
)}
{error && (

View File

@ -2,16 +2,12 @@ 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
@ -39,17 +35,12 @@ interface PageEditorControlsProps {
}
const PageEditorControls = ({
onClosePdf,
onUndo,
onRedo,
canUndo,
canRedo,
onRotate,
onDelete,
onSplit,
onExportSelected,
onExportAll,
exportLoading,
selectionMode,
selectedPages
}: PageEditorControlsProps) => {
@ -57,9 +48,9 @@ const PageEditorControls = ({
<div
style={{
position: 'absolute',
left: '50%',
bottom: '20px',
transform: 'translateX(-50%)',
left: 0,
right: 0,
bottom: 0,
zIndex: 50,
display: 'flex',
justifyContent: 'center',
@ -67,34 +58,28 @@ const PageEditorControls = ({
background: 'transparent',
}}
>
<Paper
radius="xl"
shadow="lg"
p={16}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
borderRadius: 32,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
boxShadow: '0 -2px 8px rgba(0,0,0,0.04)',
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
borderRadius: '16px 16px 0 0',
pointerEvents: 'auto',
minWidth: 400,
justifyContent: 'center'
minWidth: 420,
maxWidth: 700,
flexWrap: 'wrap',
justifyContent: 'center',
padding: "1rem",
paddingBottom: "2rem"
}}
>
{/* 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">
@ -133,17 +118,6 @@ const PageEditorControls = ({
<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}
@ -156,34 +130,7 @@ const PageEditorControls = ({
</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>
</div>
);
};

View File

@ -205,7 +205,7 @@ const PageThumbnail = React.memo(({
}}
draggable={false}
>
{selectionMode && (
{
<div
className={styles.checkboxContainer}
style={{
@ -213,10 +213,9 @@ const PageThumbnail = React.memo(({
top: 8,
right: 8,
zIndex: 10,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #ccc',
backgroundColor: 'white',
borderRadius: '4px',
padding: '4px',
padding: '2px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'auto',
cursor: 'pointer'
@ -239,7 +238,7 @@ const PageThumbnail = React.memo(({
size="sm"
/>
</div>
)}
}
<div className="page-container w-[90%] h-[90%]" draggable={false}>
<div

View File

@ -4,18 +4,25 @@ import { Dropzone } from '@mantine/dropzone';
import AddIcon from '@mui/icons-material/Add';
import { useTranslation } from 'react-i18next';
import { useFileHandler } from '../../hooks/useFileHandler';
import { useFilesModalContext } from '../../contexts/FilesModalContext';
const LandingPage = () => {
const { addMultipleFiles } = useFileHandler();
const fileInputRef = React.useRef<HTMLInputElement>(null);
const { colorScheme } = useMantineColorScheme();
const { t } = useTranslation();
const { openFilesModal } = useFilesModalContext();
const [isUploadHover, setIsUploadHover] = React.useState(false);
const handleFileDrop = async (files: File[]) => {
await addMultipleFiles(files);
};
const handleAddFilesClick = () => {
const handleOpenFilesModal = () => {
openFilesModal();
};
const handleNativeUploadClick = () => {
fileInputRef.current?.click();
};
@ -44,7 +51,7 @@ const LandingPage = () => {
borderRadius: '0.5rem 0.5rem 0 0',
filter: 'var(--drop-shadow-filter)',
backgroundColor: 'var(--landing-paper-bg)',
transition: 'background-color 0.2s ease',
transition: 'background-color 0.4s ease',
}}
activateOnClick={false}
styles={{
@ -99,26 +106,73 @@ const LandingPage = () => {
/>
</Group>
{/* Add Files Button */}
<Button
{/* Add Files + Native Upload Buttons */}
<div
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.6rem',
width: '80%',
marginTop: '0.8rem',
marginBottom: '0.8rem',
marginBottom: '0.8rem'
}}
onClick={handleAddFilesClick}
onMouseLeave={() => setIsUploadHover(false)}
>
<AddIcon className="text-[var(--accent-interactive)]" />
<span>
{t('fileUpload.uploadFiles', 'Upload Files')}
</span>
</Button>
<Button
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '2rem',
height: '38px',
paddingLeft: isUploadHover ? 0 : '1rem',
paddingRight: isUploadHover ? 0 : '1rem',
width: isUploadHover ? '58px' : 'calc(100% - 58px - 0.6rem)',
minWidth: isUploadHover ? '58px' : undefined,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleOpenFilesModal}
onMouseEnter={() => setIsUploadHover(false)}
>
<AddIcon className="text-[var(--accent-interactive)]" />
{!isUploadHover && (
<span>
{t('landing.addFiles', 'Add Files')}
</span>
)}
</Button>
<Button
aria-label="Upload"
style={{
backgroundColor: 'var(--landing-button-bg)',
color: 'var(--landing-button-color)',
border: '1px solid var(--landing-button-border)',
borderRadius: '1rem',
height: '38px',
width: isUploadHover ? 'calc(100% - 50px)' : '58px',
minWidth: '58px',
paddingLeft: isUploadHover ? '1rem' : 0,
paddingRight: isUploadHover ? '1rem' : 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'width .5s ease, padding .5s ease'
}}
onClick={handleNativeUploadClick}
onMouseEnter={() => setIsUploadHover(true)}
>
<span className="material-symbols-rounded" style={{ fontSize: '1.25rem', color: 'var(--accent-interactive)' }}>upload</span>
{isUploadHover && (
<span style={{ marginLeft: '.5rem' }}>
{t('landing.uploadFromComputer', 'Upload from computer')}
</span>
)}
</Button>
</div>
{/* Hidden file input for native file picker */}
<input

View File

@ -1,11 +1,17 @@
import React, { useState, useEffect } from 'react';
import { Menu, Button, ScrollArea } from '@mantine/core';
import { Menu, Button, ScrollArea, ActionIcon } 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 = () => {
interface LanguageSelectorProps {
position?: React.ComponentProps<typeof Menu>['position'];
offset?: number;
compact?: boolean; // icon-only trigger
}
const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = false }: LanguageSelectorProps) => {
const { i18n } = useTranslation();
const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false);
@ -21,26 +27,27 @@ const LanguageSelector = () => {
}));
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() });
// Create ripple effect at click position (only for button mode)
if (!compact) {
const rect = (event.currentTarget as HTMLElement).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);
@ -64,19 +71,9 @@ const LanguageSelector = () => {
<style>
{`
@keyframes ripple-expand {
0% {
width: 0;
height: 0;
opacity: 0.6;
}
50% {
opacity: 0.3;
}
100% {
width: 100px;
height: 100px;
opacity: 0;
}
0% { width: 0; height: 0; opacity: 0.6; }
50% { opacity: 0.3; }
100% { width: 100px; height: 100px; opacity: 0; }
}
`}
</style>
@ -84,8 +81,8 @@ const LanguageSelector = () => {
opened={opened}
onChange={setOpened}
width={600}
position="bottom-start"
offset={8}
position={position}
offset={offset}
transitionProps={{
transition: 'scale-y',
duration: 200,
@ -93,29 +90,45 @@ const LanguageSelector = () => {
}}
>
<Menu.Target>
<Button
variant="subtle"
size="sm"
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
styles={{
root: {
border: 'none',
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': {
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
{compact ? (
<ActionIcon
variant="subtle"
radius="md"
title={currentLanguage}
className="right-rail-icon"
styles={{
root: {
color: 'var(--right-rail-icon)',
'&:hover': {
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
}
}
},
label: {
fontSize: '12px',
fontWeight: 500,
}
}}
>
<span className={styles.languageText}>
{currentLanguage}
</span>
</Button>
}}
>
<span className="material-symbols-rounded">language</span>
</ActionIcon>
) : (
<Button
variant="subtle"
size="sm"
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
styles={{
root: {
border: 'none',
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
'&:hover': {
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
}
},
label: { fontSize: '12px', fontWeight: 500 }
}}
>
<span className={styles.languageText}>
{currentLanguage}
</span>
</Button>
)}
</Menu.Target>
<Menu.Dropdown
@ -181,9 +194,7 @@ const LanguageSelector = () => {
}}
>
{option.label}
{/* Ripple effect */}
{rippleEffect && pendingLanguage === option.value && (
{!compact && rippleEffect && pendingLanguage === option.value && (
<div
key={rippleEffect.key}
style={{

View File

@ -0,0 +1,385 @@
import React, { useCallback, useState, useEffect, useMemo } from 'react';
import { ActionIcon, Divider, Popover } from '@mantine/core';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import './rightRail/RightRail.css';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import { useRightRail } from '../../contexts/RightRailContext';
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
import { useNavigationState } from '../../contexts/NavigationContext';
import { useTranslation } from 'react-i18next';
import LanguageSelector from '../shared/LanguageSelector';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { Tooltip } from '../shared/Tooltip';
import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel';
export default function RightRail() {
const { t } = useTranslation();
const { toggleTheme } = useRainbowThemeContext();
const { buttons, actions } = useRightRail();
const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]);
// Access PageEditor functions for page-editor-specific actions
const { pageEditorFunctions } = useToolWorkflow();
// CSV input state for page selection
const [csvInput, setCsvInput] = useState<string>("");
// Navigation view
const { currentMode: currentView } = useNavigationState();
// File state and selection
const { state, selectors } = useFileState();
const { selectedFiles, selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection();
const { removeFiles } = useFileManagement();
const activeFiles = selectors.getFiles();
const filesSignature = selectors.getFilesSignature();
const fileRecords = selectors.getFileRecords();
// Compute selection state and total items
const getSelectionState = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
const totalItems = activeFiles.length;
const selectedCount = selectedFileIds.length;
return { totalItems, selectedCount };
}
if (currentView === 'pageEditor') {
let totalItems = 0;
fileRecords.forEach(rec => {
const pf = rec.processedFile;
if (pf) {
totalItems += (pf.totalPages as number) || (pf.pages?.length || 0);
}
});
const selectedCount = Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length : 0;
return { totalItems, selectedCount };
}
return { totalItems: 0, selectedCount: 0 };
}, [currentView, activeFiles, fileRecords, selectedFileIds, selectedPageNumbers]);
const { totalItems, selectedCount } = getSelectionState();
const handleSelectAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
// Select all file IDs
const allIds = state.files.ids;
setSelectedFiles(allIds);
return;
}
if (currentView === 'pageEditor') {
let totalPages = 0;
fileRecords.forEach(rec => {
const pf = rec.processedFile;
if (pf) {
totalPages += (pf.totalPages as number) || (pf.pages?.length || 0);
}
});
if (totalPages > 0) {
setSelectedPages(Array.from({ length: totalPages }, (_, i) => i + 1));
}
}
}, [currentView, state.files.ids, fileRecords, setSelectedFiles, setSelectedPages]);
const handleDeselectAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
setSelectedFiles([]);
return;
}
if (currentView === 'pageEditor') {
setSelectedPages([]);
}
}, [currentView, setSelectedFiles, setSelectedPages]);
const handleExportAll = useCallback(() => {
if (currentView === 'fileEditor' || currentView === 'viewer') {
// Download selected files (or all if none selected)
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
filesToDownload.forEach(file => {
const link = document.createElement('a');
link.href = URL.createObjectURL(file);
link.download = file.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
});
} else if (currentView === 'pageEditor') {
// Export all pages (not just selected)
pageEditorFunctions?.onExportAll?.();
}
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
const handleCloseSelected = useCallback(() => {
if (currentView !== 'fileEditor') return;
if (selectedFileIds.length === 0) return;
// Close only selected files (do not delete from storage)
removeFiles(selectedFileIds, false);
// Clear selection after closing
setSelectedFiles([]);
}, [currentView, selectedFileIds, removeFiles, setSelectedFiles]);
// CSV parsing functions for page selection
const parseCSVInput = useCallback((csv: string) => {
const pageNumbers: number[] = [];
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++) {
if (i > 0) {
pageNumbers.push(i);
}
}
} else {
const pageNum = parseInt(range);
if (pageNum > 0) {
pageNumbers.push(pageNum);
}
}
});
return pageNumbers;
}, []);
const updatePagesFromCSV = useCallback(() => {
const rawPages = parseCSVInput(csvInput);
// Determine max page count from processed records
const maxPages = fileRecords.reduce((sum, rec) => {
const pf = rec.processedFile;
if (!pf) return sum;
return sum + ((pf.totalPages as number) || (pf.pages?.length || 0));
}, 0);
const normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b);
setSelectedPages(normalized);
}, [csvInput, parseCSVInput, fileRecords, setSelectedPages]);
// Sync csvInput with selectedPageNumbers changes
useEffect(() => {
const sortedPageNumbers = Array.isArray(selectedPageNumbers)
? [...selectedPageNumbers].sort((a, b) => a - b)
: [];
const newCsvInput = sortedPageNumbers.join(', ');
setCsvInput(newCsvInput);
}, [selectedPageNumbers]);
// Clear CSV input when files change (use stable signature to avoid ref churn)
useEffect(() => {
setCsvInput("");
}, [filesSignature]);
// Mount/visibility for page-editor-only buttons to allow exit animation, then remove to avoid flex gap
const [pageControlsMounted, setPageControlsMounted] = useState<boolean>(currentView === 'pageEditor');
const [pageControlsVisible, setPageControlsVisible] = useState<boolean>(currentView === 'pageEditor');
useEffect(() => {
if (currentView === 'pageEditor') {
// Mount and show
setPageControlsMounted(true);
// Next tick to ensure transition applies
requestAnimationFrame(() => setPageControlsVisible(true));
} else {
// Start exit animation
setPageControlsVisible(false);
// After transition, unmount to remove flex gap
const timer = setTimeout(() => setPageControlsMounted(false), 240);
return () => clearTimeout(timer);
}
}, [currentView]);
return (
<div className="right-rail">
<div className="right-rail-inner">
{topButtons.length > 0 && (
<>
<div className="right-rail-section">
{topButtons.map(btn => (
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => actions[btn.id]?.()}
disabled={btn.disabled}
>
{btn.icon}
</ActionIcon>
</Tooltip>
))}
</div>
<Divider className="right-rail-divider" />
</>
)}
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
<div
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
aria-hidden={currentView === 'viewer'}
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
{/* Select All Button */}
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleSelectAll}
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems}
>
<span className="material-symbols-rounded">
select_all
</span>
</ActionIcon>
</div>
</Tooltip>
{/* Deselect All Button */}
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleDeselectAll}
disabled={currentView === 'viewer' || selectedCount === 0}
>
<span className="material-symbols-rounded">
crop_square
</span>
</ActionIcon>
</div>
</Tooltip>
{/* Select by Numbers - page editor only, with animated presence */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={!pageControlsVisible || totalItems === 0}
aria-label={typeof t === 'function' ? t('rightRail.selectByNumber', 'Select by Page Numbers') : 'Select by Page Numbers'}
>
<span className="material-symbols-rounded">
pin_end
</span>
</ActionIcon>
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: 280 }}>
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPages={Array.isArray(selectedPageNumbers) ? selectedPageNumbers : []}
onUpdatePagesFromCSV={updatePagesFromCSV}
/>
</div>
</Popover.Dropdown>
</Popover>
</div>
</Tooltip>
)}
{/* Delete Selected Pages - page editor only, with animated presence */}
{pageControlsMounted && (
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow>
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={() => { pageEditorFunctions?.handleDelete?.(); setSelectedPages([]); }}
disabled={!pageControlsVisible || (Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length === 0 : true)}
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
>
<span className="material-symbols-rounded">delete</span>
</ActionIcon>
</div>
</div>
</Tooltip>
)}
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={currentView === 'pageEditor' ? () => pageEditorFunctions?.closePdf?.() : handleCloseSelected}
disabled={
currentView === 'viewer' ||
(currentView === 'fileEditor' && selectedCount === 0) ||
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf))
}
>
<CloseRoundedIcon />
</ActionIcon>
</div>
</Tooltip>
</div>
<Divider className="right-rail-divider" />
</div>
{/* Theme toggle and Language dropdown */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={toggleTheme}
>
<span className="material-symbols-rounded">contrast</span>
</ActionIcon>
</Tooltip>
<LanguageSelector position="left-start" offset={6} compact />
<Tooltip content={
currentView === 'pageEditor'
? t('rightRail.exportAll', 'Export PDF')
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
onClick={handleExportAll}
disabled={currentView === 'viewer' || totalItems === 0}
>
<span className="material-symbols-rounded">
download
</span>
</ActionIcon>
</div>
</Tooltip>
</div>
<div className="right-rail-spacer" />
</div>
</div>
);
}

View File

@ -124,8 +124,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
if (sidebarTooltip) return null;
switch (position) {
case 'top': return "tooltip-arrow tooltip-arrow-top";
case 'bottom': return "tooltip-arrow tooltip-arrow-bottom";
case 'top': return "tooltip-arrow tooltip-arrow-bottom";
case 'bottom': return "tooltip-arrow tooltip-arrow-top";
case 'left': return "tooltip-arrow tooltip-arrow-left";
case 'right': return "tooltip-arrow tooltip-arrow-right";
default: return "tooltip-arrow tooltip-arrow-right";
@ -150,7 +150,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
position: 'fixed',
top: coords.top,
left: coords.left,
width: (maxWidth !== undefined ? maxWidth : '25rem'),
width: (maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' : undefined)),
minWidth: minWidth,
zIndex: 9999,
visibility: positionReady ? 'visible' : 'hidden',

View File

@ -1,23 +1,64 @@
import React, { useState, useCallback, useMemo } from "react";
import { Button, SegmentedControl, Loader } from "@mantine/core";
import React, { useState, useCallback } from "react";
import { SegmentedControl, Loader } 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 FolderIcon from "@mui/icons-material/Folder";
import { Group } from "@mantine/core";
import { ModeType } from '../../contexts/NavigationContext';
import { ModeType, isValidMode } from '../../contexts/NavigationContext';
// Stable view option objects that don't recreate on every render
const VIEW_OPTIONS_BASE = [
{ value: "viewer", icon: VisibilityIcon },
{ value: "pageEditor", icon: EditNoteIcon },
{ value: "fileEditor", icon: FolderIcon },
] as const;
const viewOptionStyle = {
display: 'inline-flex',
flexDirection: 'row',
alignItems: 'center',
gap: 6,
whiteSpace: 'nowrap',
paddingTop: '0.3rem',
}
// Create view options with icons and loading states
const createViewOptions = (switchingTo: ModeType | null) => [
{
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
<VisibilityIcon fontSize="small" />
)}
<span>Read</span>
</div>
),
value: "viewer",
},
{
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
<EditNoteIcon fontSize="small" />
)}
<span>Page Editor</span>
</div>
),
value: "pageEditor",
},
{
label: (
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === "fileEditor" ? (
<Loader size="xs" />
) : (
<FolderIcon fontSize="small" />
)}
<span>File Manager</span>
</div>
),
value: "fileEditor",
},
];
interface TopControlsProps {
currentView: ModeType;
@ -30,90 +71,60 @@ const TopControls = ({
setCurrentView,
selectedToolKey,
}: TopControlsProps) => {
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<string | null>(null);
const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<ModeType | null>(null);
const isToolSelected = selectedToolKey !== null;
const handleViewChange = useCallback((view: string) => {
// Guard against redundant changes
if (view === currentView) return;
if (!isValidMode(view)) {
// Ignore invalid values defensively
return;
}
const mode = view as ModeType;
// Show immediate feedback
setSwitchingTo(view);
setSwitchingTo(mode as ModeType);
// Defer the heavy view change to next frame so spinner can render
requestAnimationFrame(() => {
// Give the spinner one more frame to show
requestAnimationFrame(() => {
setCurrentView(view as ModeType);
setCurrentView(mode as ModeType);
// Clear the loading state after view change completes
setTimeout(() => setSwitchingTo(null), 300);
});
});
}, [setCurrentView, currentView]);
// Memoize the SegmentedControl data with stable references
const viewOptions = useMemo(() =>
VIEW_OPTIONS_BASE.map(option => ({
value: option.value,
label: (
<Group gap={option.value === "viewer" ? 5 : 4}>
{switchingTo === option.value ? (
<Loader size="xs" />
) : (
<option.icon fontSize="small" />
)}
</Group>
)
})), [switchingTo]);
const getThemeIcon = () => {
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
if (themeMode === "dark") return <LightModeIcon />;
return <DarkModeIcon />;
};
}, [setCurrentView]);
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
<div className={`absolute left-4 pointer-events-auto flex gap-2 items-center ${
isToolSelected ? 'top-4' : 'top-1/2 -translate-y-1/2'
}`}>
<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>
{!isToolSelected && (
<div className="flex justify-center items-center h-full pointer-events-auto">
<div className="flex justify-center mt-[0.5rem]">
<SegmentedControl
data={viewOptions}
data={createViewOptions(switchingTo)}
value={currentView}
onChange={handleViewChange}
color="blue"
radius="xl"
size="md"
fullWidth
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
style={{
transition: 'all 0.2s ease',
opacity: switchingTo ? 0.8 : 1,
pointerEvents: 'auto'
}}
styles={{
root: {
borderRadius: 9999,
},
control: {
borderRadius: 9999,
},
indicator: {
borderRadius: 9999,
},
}}
/>
</div>

View File

@ -0,0 +1,108 @@
# RightRail Component
A dynamic vertical toolbar on the right side of the application that supports both static buttons (Undo/Redo, Save, Print, Share) and dynamic buttons registered by tools.
## Structure
- **Top Section**: Dynamic buttons from tools (empty when none)
- **Middle Section**: Grid, Cut, Undo, Redo
- **Bottom Section**: Save, Print, Share
## Usage
### For Tools (Recommended)
```tsx
import { useRightRailButtons } from '../hooks/useRightRailButtons';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
function MyTool() {
const handleAction = useCallback(() => {
// Your action here
}, []);
useRightRailButtons([
{
id: 'my-action',
icon: <PlayArrowIcon />,
tooltip: 'Execute Action',
onClick: handleAction,
},
]);
return <div>My Tool</div>;
}
```
### Multiple Buttons
```tsx
useRightRailButtons([
{
id: 'primary',
icon: <StarIcon />,
tooltip: 'Primary Action',
order: 1,
onClick: handlePrimary,
},
{
id: 'secondary',
icon: <SettingsIcon />,
tooltip: 'Secondary Action',
order: 2,
onClick: handleSecondary,
},
]);
```
### Conditional Buttons
```tsx
useRightRailButtons([
// Always show
{
id: 'process',
icon: <PlayArrowIcon />,
tooltip: 'Process',
disabled: isProcessing,
onClick: handleProcess,
},
// Only show when condition met
...(hasResults ? [{
id: 'export',
icon: <DownloadIcon />,
tooltip: 'Export',
onClick: handleExport,
}] : []),
]);
```
## API
### Button Config
```typescript
interface RightRailButtonWithAction {
id: string; // Unique identifier
icon: React.ReactNode; // Icon component
tooltip: string; // Hover tooltip
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
order?: number; // Sort order (default: 0)
disabled?: boolean; // Disabled state (default: false)
visible?: boolean; // Visibility (default: true)
onClick: () => void; // Click handler
}
```
## Built-in Features
- **Undo/Redo**: Automatically integrates with Page Editor
- **Theme Support**: Light/dark mode with CSS variables
- **Auto Cleanup**: Buttons unregister when tool unmounts
## Best Practices
- Use descriptive IDs: `'compress-optimize'`, `'ocr-process'`
- Choose appropriate Material-UI icons
- Keep tooltips concise: `'Compress PDF'`, `'Process with OCR'`
- Use `useCallback` for click handlers to prevent re-registration

View File

@ -0,0 +1,127 @@
.right-rail {
background-color: var(--right-rail-bg);
width: 3.5rem;
min-width: 3.5rem;
max-width: 3.5rem;
position: relative;
z-index: 10;
display: flex;
flex-direction: column;
height: 100vh;
border-left: 1px solid var(--border-subtle);
}
.right-rail-inner {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
padding: 1rem 0.5rem;
}
.right-rail-section {
background-color: var(--right-rail-foreground);
border-radius: 12px;
padding: 0.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
}
.right-rail-divider {
width: 2.75rem;
border: none;
border-top: 1px solid var(--tool-subcategory-rule-color);
margin: 0.25rem 0;
}
.right-rail-icon {
color: var(--right-rail-icon);
}
.right-rail-icon[aria-disabled="true"],
.right-rail-icon[disabled] {
color: var(--right-rail-icon-disabled) !important;
background-color: transparent !important;
}
.right-rail-spacer {
flex: 1;
}
/* Animated grow-down slot for buttons (mirrors current-tool-slot behavior) */
.right-rail-slot {
overflow: hidden;
max-height: 0;
opacity: 0;
transition: max-height 450ms ease-out, opacity 300ms ease-out;
}
.right-rail-enter {
animation: rightRailGrowDown 450ms ease-out;
}
.right-rail-exit {
animation: rightRailShrinkUp 450ms ease-out;
}
.right-rail-slot.visible {
max-height: 18rem; /* increased to fit additional controls + divider */
opacity: 1;
}
@keyframes rightRailGrowDown {
0% {
max-height: 0;
opacity: 0;
}
100% {
max-height: 18rem;
opacity: 1;
}
}
@keyframes rightRailShrinkUp {
0% {
max-height: 18rem;
opacity: 1;
}
100% {
max-height: 0;
opacity: 0;
}
}
/* Remove bottom margin from close icon */
.right-rail-slot .right-rail-icon {
margin-bottom: 0;
}
/* Inline appear/disappear animation for page-number selector button */
.right-rail-fade {
transition-property: opacity, transform, max-height, visibility;
transition-duration: 220ms, 220ms, 220ms, 0s;
transition-timing-function: ease, ease, ease, linear;
transition-delay: 0s, 0s, 0s, 0s;
transform-origin: top center;
overflow: hidden;
}
.right-rail-fade.enter {
opacity: 1;
transform: scale(1);
max-height: 3rem;
visibility: visible;
}
.right-rail-fade.exit {
opacity: 0;
transform: scale(0.85);
max-height: 0;
visibility: hidden;
/* delay visibility change so opacity/max-height can finish */
transition-delay: 0s, 0s, 0s, 220ms;
pointer-events: none;
}

View File

@ -160,7 +160,7 @@
.tooltip-arrow-top {
top: -0.25rem;
left: 50%;
transform: translateX(-50%) rotate(45deg);
transform: translateX(-50%) rotate(-135deg);
border-top: none;
border-left: none;
}

View File

@ -85,7 +85,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
overflowY: "auto",
overflowX: "hidden",
minHeight: 0,
height: "100%"
height: "100%",
marginTop: -2
}}
className="tool-picker-scrollable"
>
@ -109,7 +110,6 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
zIndex: 2,
borderTop: `0.0625rem solid var(--tool-header-border)`,
borderBottom: `0.0625rem solid var(--tool-header-border)`,
marginBottom: -1,
padding: "0.5rem 1rem",
fontWeight: 700,
background: "var(--tool-header-bg)",
@ -117,7 +117,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
cursor: "pointer",
display: "flex",
alignItems: "center",
justifyContent: "space-between"
justifyContent: "space-between",
}}
onClick={() => scrollTo(quickAccessRef)}
>

View File

@ -0,0 +1,64 @@
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { RightRailAction, RightRailButtonConfig } from '../types/rightRail';
interface RightRailContextValue {
buttons: RightRailButtonConfig[];
actions: Record<string, RightRailAction>;
registerButtons: (buttons: RightRailButtonConfig[]) => void;
unregisterButtons: (ids: string[]) => void;
setAction: (id: string, action: RightRailAction) => void;
clear: () => void;
}
const RightRailContext = createContext<RightRailContextValue | undefined>(undefined);
export function RightRailProvider({ children }: { children: React.ReactNode }) {
const [buttons, setButtons] = useState<RightRailButtonConfig[]>([]);
const [actions, setActions] = useState<Record<string, RightRailAction>>({});
const registerButtons = useCallback((newButtons: RightRailButtonConfig[]) => {
setButtons(prev => {
const byId = new Map(prev.map(b => [b.id, b] as const));
newButtons.forEach(nb => {
const existing = byId.get(nb.id) || ({} as RightRailButtonConfig);
byId.set(nb.id, { ...existing, ...nb });
});
const merged = Array.from(byId.values());
merged.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id));
if (process.env.NODE_ENV === 'development') {
const ids = newButtons.map(b => b.id);
const dupes = ids.filter((id, idx) => ids.indexOf(id) !== idx);
if (dupes.length) console.warn('[RightRail] Duplicate ids in registerButtons:', dupes);
}
return merged;
});
}, []);
const unregisterButtons = useCallback((ids: string[]) => {
setButtons(prev => prev.filter(b => !ids.includes(b.id)));
setActions(prev => Object.fromEntries(Object.entries(prev).filter(([id]) => !ids.includes(id))));
}, []);
const setAction = useCallback((id: string, action: RightRailAction) => {
setActions(prev => ({ ...prev, [id]: action }));
}, []);
const clear = useCallback(() => {
setButtons([]);
setActions({});
}, []);
const value = useMemo<RightRailContextValue>(() => ({ buttons, actions, registerButtons, unregisterButtons, setAction, clear }), [buttons, actions, registerButtons, unregisterButtons, setAction, clear]);
return (
<RightRailContext.Provider value={value}>
{children}
</RightRailContext.Provider>
);
}
export function useRightRail() {
const ctx = useContext(RightRailContext);
if (!ctx) throw new Error('useRightRail must be used within RightRailProvider');
return ctx;
}

View File

@ -1,6 +1,9 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import StarIcon from '@mui/icons-material/Star';
import CompressIcon from '@mui/icons-material/Compress';
import SecurityIcon from '@mui/icons-material/Security';
import TextFieldsIcon from '@mui/icons-material/TextFields';
import { SuggestedAutomation } from '../../../types/automation';
export function useSuggestedAutomations(): SuggestedAutomation[] {
@ -10,37 +13,119 @@ export function useSuggestedAutomations(): SuggestedAutomation[] {
const now = new Date().toISOString();
return [
{
id: "compress-and-merge",
name: t("automation.suggested.compressAndMerge", "Compress & Merge"),
description: t("automation.suggested.compressAndMergeDesc", "Compress PDFs and merge them into one file"),
id: "compress-and-split",
name: t("automation.suggested.compressAndSplit", "Compress & Split"),
description: t("automation.suggested.compressAndSplitDesc", "Compress PDFs and split them by pages"),
operations: [
{ operation: "compress", parameters: {} },
{ operation: "merge", parameters: {} }
{
operation: "compress",
parameters: {
compressionLevel: 5,
grayscale: false,
expectedSize: '',
compressionMethod: 'quality',
fileSizeValue: '',
fileSizeUnit: 'MB',
}
},
{
operation: "splitPdf",
parameters: {
mode: 'bySizeOrCount',
pages: '1',
hDiv: '2',
vDiv: '2',
merge: false,
splitType: 'pages',
splitValue: '1',
bookmarkLevel: '1',
includeMetadata: false,
allowDuplicates: false,
}
}
],
createdAt: now,
updatedAt: now,
icon: StarIcon,
icon: CompressIcon,
},
{
id: "ocr-and-convert",
name: t("automation.suggested.ocrAndConvert", "OCR & Convert"),
description: t("automation.suggested.ocrAndConvertDesc", "Extract text via OCR and convert to different format"),
id: "ocr-workflow",
name: t("automation.suggested.ocrWorkflow", "OCR Processing"),
description: t("automation.suggested.ocrWorkflowDesc", "Extract text from PDFs using OCR technology"),
operations: [
{ operation: "ocr", parameters: {} },
{ operation: "convert", parameters: {} }
{
operation: "ocr",
parameters: {
languages: ['eng'],
ocrType: 'skip-text',
ocrRenderType: 'hocr',
additionalOptions: [],
}
}
],
createdAt: now,
updatedAt: now,
icon: StarIcon,
icon: TextFieldsIcon,
},
{
id: "secure-workflow",
name: t("automation.suggested.secureWorkflow", "Secure Workflow"),
description: t("automation.suggested.secureWorkflowDesc", "Sanitize, add password, and set permissions"),
name: t("automation.suggested.secureWorkflow", "Security Workflow"),
description: t("automation.suggested.secureWorkflowDesc", "Sanitize PDFs and add password protection"),
operations: [
{ operation: "sanitize", parameters: {} },
{ operation: "addPassword", parameters: {} },
{ operation: "changePermissions", parameters: {} }
{
operation: "sanitize",
parameters: {
removeJavaScript: true,
removeEmbeddedFiles: true,
removeXMPMetadata: false,
removeMetadata: false,
removeLinks: false,
removeFonts: false,
}
},
{
operation: "addPassword",
parameters: {
password: 'password',
ownerPassword: '',
keyLength: 128,
permissions: {
preventAssembly: false,
preventExtractContent: false,
preventExtractForAccessibility: false,
preventFillInForm: false,
preventModify: false,
preventModifyAnnotations: false,
preventPrinting: false,
preventPrintingFaithful: false,
}
}
}
],
createdAt: now,
updatedAt: now,
icon: SecurityIcon,
},
{
id: "optimization-workflow",
name: t("automation.suggested.optimizationWorkflow", "Optimization Workflow"),
description: t("automation.suggested.optimizationWorkflowDesc", "Repair and compress PDFs for better performance"),
operations: [
{
operation: "repair",
parameters: {}
},
{
operation: "compress",
parameters: {
compressionLevel: 7,
grayscale: false,
expectedSize: '',
compressionMethod: 'quality',
fileSizeValue: '',
fileSizeUnit: 'MB',
}
}
],
createdAt: now,
updatedAt: now,

View File

@ -0,0 +1,46 @@
import { useEffect, useMemo } from 'react';
import { useRightRail } from '../contexts/RightRailContext';
import { RightRailAction, RightRailButtonConfig } from '../types/rightRail';
export interface RightRailButtonWithAction extends RightRailButtonConfig {
onClick: RightRailAction;
}
/**
* Registers one or more RightRail buttons and their actions.
* - Automatically registers on mount and unregisters on unmount
* - Updates registration when the input array reference changes
*/
export function useRightRailButtons(buttons: readonly RightRailButtonWithAction[]) {
const { registerButtons, unregisterButtons, setAction } = useRightRail();
// Memoize configs and ids to reduce churn
const configs: RightRailButtonConfig[] = useMemo(
() => buttons.map(({ onClick, ...cfg }) => cfg),
[buttons]
);
const ids: string[] = useMemo(() => buttons.map(b => b.id), [buttons]);
useEffect(() => {
if (!buttons || buttons.length === 0) return;
// DEV warnings for duplicate ids or missing handlers
if (process.env.NODE_ENV === 'development') {
const idSet = new Set<string>();
buttons.forEach(b => {
if (!b.onClick) console.warn('[RightRail] Missing onClick for id:', b.id);
if (idSet.has(b.id)) console.warn('[RightRail] Duplicate id in buttons array:', b.id);
idSet.add(b.id);
});
}
// Register visual button configs (idempotent merge by id)
registerButtons(configs);
// Bind/update actions independent of registration
buttons.forEach(({ id, onClick }) => setAction(id, onClick));
// Cleanup unregisters by ids present in this call
return () => unregisterButtons(ids);
}, [registerButtons, unregisterButtons, setAction, configs, ids, buttons]);
}

View File

@ -9,6 +9,7 @@ import { getBaseUrl } from "../constants/app";
import ToolPanel from "../components/tools/ToolPanel";
import Workbench from "../components/layout/Workbench";
import QuickAccessBar from "../components/shared/QuickAccessBar";
import RightRail from "../components/shared/RightRail";
import FileManager from "../components/FileManager";
@ -46,7 +47,8 @@ export default function HomePage() {
ref={quickAccessRef} />
<ToolPanel />
<Workbench />
<RightRail />
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
</Group>
);
}
}

View File

@ -5,6 +5,7 @@ export interface ExportOptions {
selectedOnly?: boolean;
filename?: string;
splitDocuments?: boolean;
appendSuffix?: boolean; // when false, do not append _edited/_selected
}
export class PDFExportService {
@ -16,7 +17,7 @@ export class PDFExportService {
selectedPageIds: string[] = [],
options: ExportOptions = {}
): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> {
const { selectedOnly = false, filename, splitDocuments = false } = options;
const { selectedOnly = false, filename, splitDocuments = false, appendSuffix = true } = options;
try {
// Determine which pages to export
@ -36,7 +37,7 @@ export class PDFExportService {
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);
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, appendSuffix);
return { blob, filename: exportFilename };
}
} catch (error) {
@ -56,7 +57,7 @@ export class PDFExportService {
for (const page of pages) {
// Get the original page from source document
const sourcePageIndex = page.pageNumber - 1;
const sourcePageIndex = this.getOriginalSourceIndex(page);
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
// Copy the page
@ -113,7 +114,7 @@ export class PDFExportService {
const newDoc = await PDFLibDocument.create();
for (const page of segmentPages) {
const sourcePageIndex = page.pageNumber - 1;
const sourcePageIndex = this.getOriginalSourceIndex(page);
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
@ -146,11 +147,28 @@ export class PDFExportService {
return { blobs, filenames };
}
/**
* Derive the original page index from a page's stable id.
* Falls back to the current pageNumber if parsing fails.
*/
private getOriginalSourceIndex(page: PDFPage): number {
const match = page.id.match(/-page-(\d+)$/);
if (match) {
const originalNumber = parseInt(match[1], 10);
if (!Number.isNaN(originalNumber)) {
return originalNumber - 1; // zero-based index for pdf-lib
}
}
// Fallback to the visible page number
return Math.max(0, page.pageNumber - 1);
}
/**
* Generate appropriate filename for export
*/
private generateFilename(originalName: string, selectedOnly: boolean): string {
private generateFilename(originalName: string, selectedOnly: boolean, appendSuffix: boolean): string {
const baseName = originalName.replace(/\.pdf$/i, '');
if (!appendSuffix) return `${baseName}.pdf`;
const suffix = selectedOnly ? '_selected' : '_edited';
return `${baseName}${suffix}.pdf`;
}

View File

@ -106,6 +106,12 @@
--icon-config-bg: #9CA3AF;
--icon-config-color: #FFFFFF;
/* RightRail (light) */
--right-rail-bg: #F5F6F8; /* light background */
--right-rail-foreground: #CDD4E1; /* panel behind custom tool icons */
--right-rail-icon: #4B5563; /* icon color */
--right-rail-icon-disabled: #CECECE;/* disabled icon */
/* Colors for tooltips */
--tooltip-title-bg: #DBEFFF;
--tooltip-title-color: #31528E;
@ -234,6 +240,12 @@
--icon-inactive-bg: #2A2F36;
--icon-inactive-color: #6E7581;
/* RightRail (dark) */
--right-rail-bg: #1F2329; /* dark background */
--right-rail-foreground: #2A2F36; /* panel behind custom tool icons */
--right-rail-icon: #BCBEBF; /* icon color */
--right-rail-icon-disabled: #43464B;/* disabled icon */
/* Dark mode tooltip colors */
--tooltip-title-bg: #4B525A;
--tooltip-title-color: #fff;

View File

@ -33,13 +33,19 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
if (currentStep === AUTOMATION_STEPS.RUN && data.step !== AUTOMATION_STEPS.RUN) {
automateOperation.resetResults();
}
// If navigating to selection step, always clear results
if (data.step === AUTOMATION_STEPS.SELECTION) {
automateOperation.resetResults();
automateOperation.clearError();
}
// If navigating to run step with a different automation, reset results
if (data.step === AUTOMATION_STEPS.RUN && data.automation &&
if (data.step === AUTOMATION_STEPS.RUN && data.automation &&
stepData.automation && data.automation.id !== stepData.automation.id) {
automateOperation.resetResults();
}
setStepData(data);
setCurrentStep(data.step);
};
@ -47,7 +53,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const handleComplete = () => {
// Reset automation results when completing
automateOperation.resetResults();
// Reset to selection step
setCurrentStep(AUTOMATION_STEPS.SELECTION);
setStepData({ step: AUTOMATION_STEPS.SELECTION });
@ -127,7 +133,12 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
createStep(t('automate.selection.title', 'Automation Selection'), {
isVisible: true,
isCollapsed: currentStep !== AUTOMATION_STEPS.SELECTION,
onCollapsedClick: () => setCurrentStep(AUTOMATION_STEPS.SELECTION)
onCollapsedClick: () => {
// Clear results when clicking back to selection
automateOperation.resetResults();
setCurrentStep(AUTOMATION_STEPS.SELECTION);
setStepData({ step: AUTOMATION_STEPS.SELECTION });
}
}, currentStep === AUTOMATION_STEPS.SELECTION ? renderCurrentStep() : null),
createStep(stepData.mode === AutomationMode.EDIT
@ -158,7 +169,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
},
steps: automationSteps,
review: {
isVisible: hasResults,
isVisible: hasResults && currentStep === AUTOMATION_STEPS.RUN,
operation: automateOperation,
title: t('automate.reviewTitle', 'Automation Results')
}

View File

@ -0,0 +1,26 @@
import React from 'react';
export type RightRailSection = 'top' | 'middle' | 'bottom';
export interface RightRailButtonConfig {
/** Unique id for the button, also used to bind action callbacks */
id: string;
/** Icon element to render */
icon: React.ReactNode;
/** Tooltip content (can be localized node) */
tooltip: React.ReactNode;
/** Optional ARIA label for a11y (separate from visual tooltip) */
ariaLabel?: string;
/** Optional i18n key carried by config */
templateKey?: string;
/** Visual grouping lane */
section?: RightRailSection;
/** Sorting within a section (lower first); ties broken by id */
order?: number;
/** Initial disabled state */
disabled?: boolean;
/** Initial visibility */
visible?: boolean;
}
export type RightRailAction = () => void;