diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json
index 6b720aadb..2c9a0a6cd 100644
--- a/frontend/public/locales/en-GB/translation.json
+++ b/frontend/public/locales/en-GB/translation.json
@@ -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",
diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json
index 358ccd53a..26c2e5b15 100644
--- a/frontend/public/locales/en-US/translation.json
+++ b/frontend/public/locales/en-US/translation.json
@@ -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.",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index b498b0677..ef4d663f6 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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() {
+
+
diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx
index c93e78670..df1197ab9 100644
--- a/frontend/src/components/fileEditor/FileEditor.tsx
+++ b/frontend/src/components/fileEditor/FileEditor.tsx
@@ -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 = ({
-
- {toolMode && (
- <>
-
-
- >
- )}
- {showBulkActions && !toolMode && (
- <>
-
- >
- )}
-
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
@@ -573,25 +557,29 @@ const FileEditor = ({
/>
{status && (
- setStatus(null)}
- style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
- >
- {status}
-
+
+ setStatus(null)}
+ style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10001 }}
+ >
+ {status}
+
+
)}
{error && (
- setError(null)}
- style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
- >
- {error}
-
+
+ setError(null)}
+ style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
+ >
+ {error}
+
+
)}
diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx
index fc41d2480..a98b19b99 100644
--- a/frontend/src/components/layout/Workbench.tsx
+++ b/frontend/src/components/layout/Workbench.tsx
@@ -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()}
diff --git a/frontend/src/components/pageEditor/BulkSelectionPanel.tsx b/frontend/src/components/pageEditor/BulkSelectionPanel.tsx
index 5a6b4504f..b9ebb8d2c 100644
--- a/frontend/src/components/pageEditor/BulkSelectionPanel.tsx
+++ b/frontend/src/components/pageEditor/BulkSelectionPanel.tsx
@@ -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 (
-
+ <>
)}
-
+ >
);
};
diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx
index d84eb2a16..609a31e1a 100644
--- a/frontend/src/components/pageEditor/FileThumbnail.tsx
+++ b/frontend/src/components/pageEditor/FileThumbnail.tsx
@@ -141,7 +141,6 @@ const FileThumbnail = ({
filter: isSupported ? 'none' : 'grayscale(50%)'
}}
>
- {selectionMode && (
- )}
{/* File content area */}
diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx
index 7ca640b06..543778d9e 100644
--- a/frontend/src/components/pageEditor/PageEditor.tsx
+++ b/frontend/src/components/pageEditor/PageEditor.tsx
@@ -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(false);
// Animation state
const [movingPage, setMovingPage] = useState(null);
- const [pagePositions, setPagePositions] = useState
)}
-
-
setFilename(e.target.value)}
placeholder="Enter filename"
- style={{ minWidth: 200 }}
+ style={{ minWidth: 200, maxWidth: 200, marginLeft: "1rem"}}
/>
-
- {selectionMode && (
- <>
-
-
- >
- )}
-
-
- {/* Apply Changes Button */}
- {hasUnsavedChanges && (
-
- )}
-
-
- {selectionMode && (
-
- )}
-
{
setShowExportModal(false);
- const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.pages.length || 0);
- handleExport(selectedOnly);
+ handleExport(exportSelectedOnly);
}}
>
Export PDF
@@ -1446,14 +1412,16 @@ const PageEditor = ({
{status && (
+
setStatus(null)}
- style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
+ style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10000 }}
>
{status}
+
)}
{error && (
diff --git a/frontend/src/components/pageEditor/PageEditorControls.tsx b/frontend/src/components/pageEditor/PageEditorControls.tsx
index 43e224e2b..2b0c6ee3c 100644
--- a/frontend/src/components/pageEditor/PageEditorControls.tsx
+++ b/frontend/src/components/pageEditor/PageEditorControls.tsx
@@ -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 = ({
-
- {/* Close PDF */}
-
-
-
-
-
-
-
{/* Undo/Redo */}
@@ -133,17 +118,6 @@ const PageEditorControls = ({
-
- 0 ? "light" : "default"}
- size="lg"
- >
-
-
-
-
-
- {/* Export Controls */}
- {selectionMode && selectedPages.length > 0 && (
-
-
-
-
-
- )}
-
-
-
-
-
-
+
);
};
diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx
index f1590978a..7360b4dce 100644
--- a/frontend/src/components/pageEditor/PageThumbnail.tsx
+++ b/frontend/src/components/pageEditor/PageThumbnail.tsx
@@ -205,7 +205,7 @@ const PageThumbnail = React.memo(({
}}
draggable={false}
>
- {selectionMode && (
+ {
- )}
+ }
{
const { addMultipleFiles } = useFileHandler();
const fileInputRef = React.useRef
(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 = () => {
/>
- {/* Add Files Button */}
-
+
+
+
{/* Hidden file input for native file picker */}
{
+interface LanguageSelectorProps {
+ position?: React.ComponentProps
['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 = () => {
@@ -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 = () => {
}}
>
- }
- 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 ? (
+
-
- {currentLanguage}
-
-
+ }}
+ >
+ language
+
+ ) : (
+ }
+ 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 }
+ }}
+ >
+
+ {currentLanguage}
+
+
+ )}
{
}}
>
{option.label}
-
- {/* Ripple effect */}
- {rippleEffect && pendingLanguage === option.value && (
+ {!compact && rippleEffect && pendingLanguage === option.value && (
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
("");
+
+ // 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(currentView === 'pageEditor');
+ const [pageControlsVisible, setPageControlsVisible] = useState(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 (
+
+
+ {topButtons.length > 0 && (
+ <>
+
+ {topButtons.map(btn => (
+
+ actions[btn.id]?.()}
+ disabled={btn.disabled}
+ >
+ {btn.icon}
+
+
+ ))}
+
+
+ >
+ )}
+
+ {/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
+
+
+ {/* Select All Button */}
+
+
+
+
+ {/* Deselect All Button */}
+
+
+
+
+ {/* Select by Numbers - page editor only, with animated presence */}
+ {pageControlsMounted && (
+
+
+
+
+
+ )}
+
+ {/* Delete Selected Pages - page editor only, with animated presence */}
+ {pageControlsMounted && (
+
+
+
+
+
{ 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'}
+ >
+ delete
+
+
+
+
+
+ )}
+
+ {/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
+
+
+
pageEditorFunctions?.closePdf?.() : handleCloseSelected}
+ disabled={
+ currentView === 'viewer' ||
+ (currentView === 'fileEditor' && selectedCount === 0) ||
+ (currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf))
+ }
+ >
+
+
+
+
+
+
+
+
+
+ {/* Theme toggle and Language dropdown */}
+
+
+
+ contrast
+
+
+
+
+
+
0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
+ } position="left" offset={12} arrow>
+
+
+
+
+
+
+
+ );
+}
+
+
diff --git a/frontend/src/components/shared/Tooltip.tsx b/frontend/src/components/shared/Tooltip.tsx
index c415eddf5..4c216d318 100644
--- a/frontend/src/components/shared/Tooltip.tsx
+++ b/frontend/src/components/shared/Tooltip.tsx
@@ -124,8 +124,8 @@ export const Tooltip: React.FC = ({
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 = ({
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',
diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx
index ee5591694..229c3d362 100644
--- a/frontend/src/components/shared/TopControls.tsx
+++ b/frontend/src/components/shared/TopControls.tsx
@@ -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: (
+
+ {switchingTo === "viewer" ? (
+
+ ) : (
+
+ )}
+ Read
+
+ ),
+ value: "viewer",
+ },
+ {
+ label: (
+
+ {switchingTo === "pageEditor" ? (
+
+ ) : (
+
+ )}
+ Page Editor
+
+ ),
+ value: "pageEditor",
+ },
+ {
+ label: (
+
+ {switchingTo === "fileEditor" ? (
+
+ ) : (
+
+ )}
+ File Manager
+
+ ),
+ 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(null);
+ const { isRainbowMode } = useRainbowThemeContext();
+ const [switchingTo, setSwitchingTo] = useState(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: (
-
- {switchingTo === option.value ? (
-
- ) : (
-
- )}
-
- )
- })), [switchingTo]);
-
- const getThemeIcon = () => {
- if (isRainbowMode) return ;
- if (themeMode === "dark") return ;
- return ;
- };
+ }, [setCurrentView]);
return (
-
-
-
-
{!isToolSelected && (
-
+
diff --git a/frontend/src/components/shared/rightRail/RightRail.README.md b/frontend/src/components/shared/rightRail/RightRail.README.md
new file mode 100644
index 000000000..7506e927c
--- /dev/null
+++ b/frontend/src/components/shared/rightRail/RightRail.README.md
@@ -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:
,
+ tooltip: 'Execute Action',
+ onClick: handleAction,
+ },
+ ]);
+
+ return
My Tool
;
+}
+```
+
+### Multiple Buttons
+
+```tsx
+useRightRailButtons([
+ {
+ id: 'primary',
+ icon:
,
+ tooltip: 'Primary Action',
+ order: 1,
+ onClick: handlePrimary,
+ },
+ {
+ id: 'secondary',
+ icon:
,
+ tooltip: 'Secondary Action',
+ order: 2,
+ onClick: handleSecondary,
+ },
+]);
+```
+
+### Conditional Buttons
+
+```tsx
+useRightRailButtons([
+ // Always show
+ {
+ id: 'process',
+ icon:
,
+ tooltip: 'Process',
+ disabled: isProcessing,
+ onClick: handleProcess,
+ },
+ // Only show when condition met
+ ...(hasResults ? [{
+ id: 'export',
+ icon:
,
+ 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
diff --git a/frontend/src/components/shared/rightRail/RightRail.css b/frontend/src/components/shared/rightRail/RightRail.css
new file mode 100644
index 000000000..8d01052a9
--- /dev/null
+++ b/frontend/src/components/shared/rightRail/RightRail.css
@@ -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;
+}
+
diff --git a/frontend/src/components/shared/tooltip/Tooltip.module.css b/frontend/src/components/shared/tooltip/Tooltip.module.css
index 46902c04b..50c242812 100644
--- a/frontend/src/components/shared/tooltip/Tooltip.module.css
+++ b/frontend/src/components/shared/tooltip/Tooltip.module.css
@@ -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;
}
diff --git a/frontend/src/components/tools/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx
index a8dbd7993..d81bf5ef0 100644
--- a/frontend/src/components/tools/ToolPicker.tsx
+++ b/frontend/src/components/tools/ToolPicker.tsx
@@ -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)}
>
diff --git a/frontend/src/contexts/RightRailContext.tsx b/frontend/src/contexts/RightRailContext.tsx
new file mode 100644
index 000000000..be3b7276c
--- /dev/null
+++ b/frontend/src/contexts/RightRailContext.tsx
@@ -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
;
+ registerButtons: (buttons: RightRailButtonConfig[]) => void;
+ unregisterButtons: (ids: string[]) => void;
+ setAction: (id: string, action: RightRailAction) => void;
+ clear: () => void;
+}
+
+const RightRailContext = createContext(undefined);
+
+export function RightRailProvider({ children }: { children: React.ReactNode }) {
+ const [buttons, setButtons] = useState([]);
+ const [actions, setActions] = useState>({});
+
+ 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(() => ({ buttons, actions, registerButtons, unregisterButtons, setAction, clear }), [buttons, actions, registerButtons, unregisterButtons, setAction, clear]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+export function useRightRail() {
+ const ctx = useContext(RightRailContext);
+ if (!ctx) throw new Error('useRightRail must be used within RightRailProvider');
+ return ctx;
+}
diff --git a/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts b/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts
index bb1ed5916..9ddce1e0b 100644
--- a/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts
+++ b/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts
@@ -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,
diff --git a/frontend/src/hooks/useRightRailButtons.ts b/frontend/src/hooks/useRightRailButtons.ts
new file mode 100644
index 000000000..82a4e8cd5
--- /dev/null
+++ b/frontend/src/hooks/useRightRailButtons.ts
@@ -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();
+ 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]);
+}
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index eeb23e83f..12c1f4d7f 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -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} />
+
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/services/pdfExportService.ts b/frontend/src/services/pdfExportService.ts
index b0662437e..9345133b8 100644
--- a/frontend/src/services/pdfExportService.ts
+++ b/frontend/src/services/pdfExportService.ts
@@ -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`;
}
diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css
index 634cae91c..a8efa179e 100644
--- a/frontend/src/styles/theme.css
+++ b/frontend/src/styles/theme.css
@@ -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;
diff --git a/frontend/src/tools/Automate.tsx b/frontend/src/tools/Automate.tsx
index 02d318acf..7e75f0cbf 100644
--- a/frontend/src/tools/Automate.tsx
+++ b/frontend/src/tools/Automate.tsx
@@ -36,6 +36,12 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
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 &&
stepData.automation && data.automation.id !== stepData.automation.id) {
@@ -129,7 +135,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
@@ -160,7 +171,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'),
onFileClick: (file: File) => {
diff --git a/frontend/src/types/rightRail.ts b/frontend/src/types/rightRail.ts
new file mode 100644
index 000000000..1897a7170
--- /dev/null
+++ b/frontend/src/types/rightRail.ts
@@ -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;