diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx
index c45e7e902..1d794ed87 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 } 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';
@@ -679,17 +678,6 @@ const FileEditor = ({
-
- {showBulkActions && !toolMode && (
- <>
-
-
-
- >
- )}
-
{files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
@@ -828,25 +816,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 732b37d7b..0b7393e00 100644
--- a/frontend/src/components/layout/Workbench.tsx
+++ b/frontend/src/components/layout/Workbench.tsx
@@ -151,6 +151,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 c328a350d..0a1829657 100644
--- a/frontend/src/components/pageEditor/FileThumbnail.tsx
+++ b/frontend/src/components/pageEditor/FileThumbnail.tsx
@@ -130,7 +130,6 @@ const FileThumbnail = ({
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, file.id)}
>
- {selectionMode && (
- )}
{/* File content area */}
diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx
index 60467d45d..759d20a28 100644
--- a/frontend/src/components/pageEditor/PageEditor.tsx
+++ b/frontend/src/components/pageEditor/PageEditor.tsx
@@ -1,14 +1,12 @@
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 { useFileContext, useCurrentFile } from "../../contexts/FileContext";
-import { ViewType, ToolType } from "../../types/fileContext";
import { PDFDocument, PDFPage } from "../../types/pageEditor";
-import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
import { useUndoRedo } from "../../hooks/useUndoRedo";
import {
RotatePagesCommand,
@@ -51,11 +49,9 @@ export interface PageEditorProps {
const PageEditor = ({
onFunctionsReady,
}: PageEditorProps) => {
- const { t } = useTranslation();
// Get file context
const fileContext = useFileContext();
- const { file: currentFile, processedFile: currentProcessedFile } = useCurrentFile();
// Use file context state
const {
@@ -160,10 +156,7 @@ const PageEditor = ({
// Animation state
const [movingPage, setMovingPage] = useState(null);
- const [pagePositions, setPagePositions] = useState
)}
-
-
setFilename(e.target.value)}
placeholder="Enter filename"
style={{ minWidth: 200 }}
/>
-
- {selectionMode && (
- <>
-
-
- >
- )}
-
- {/* Apply Changes Button */}
- {hasUnsavedChanges && (
-
- )}
-
-
- {selectionMode && (
-
- )}
-
{status && (
+
setStatus(null)}
- style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
+ style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10000 }}
>
{status}
+
)}
);
diff --git a/frontend/src/components/pageEditor/PageEditorControls.tsx b/frontend/src/components/pageEditor/PageEditorControls.tsx
index 43e224e2b..726fdff6b 100644
--- a/frontend/src/components/pageEditor/PageEditorControls.tsx
+++ b/frontend/src/components/pageEditor/PageEditorControls.tsx
@@ -7,10 +7,8 @@ import {
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 {
@@ -57,9 +55,9 @@ const PageEditorControls = ({
-
{/* Close PDF */}
@@ -133,17 +138,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 15c6bbe37..e4e61b098 100644
--- a/frontend/src/components/pageEditor/PageThumbnail.tsx
+++ b/frontend/src/components/pageEditor/PageThumbnail.tsx
@@ -167,7 +167,7 @@ const PageThumbnail = React.memo(({
onDragLeave={onDragLeave}
onDrop={(e) => onDrop(e, page.pageNumber)}
>
- {selectionMode && (
+ {
- )}
+ }
{
+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 && (
(b.section || 'top') === 'top' && (b.visible ?? true));
+
+ // Access PageEditor functions for page-editor-specific actions
+ const { pageEditorFunctions } = useToolWorkflow();
+
+ // CSV input state for page selection
+ const [csvInput, setCsvInput] = useState
("");
+
+ // File/page selection handlers that adapt to current view
+ const {
+ currentView,
+ activeFiles,
+ processedFiles,
+ selectedFileIds,
+ selectedPageNumbers,
+ setSelectedFiles,
+ setSelectedPages,
+ removeFiles
+ } = useFileContext();
+
+ // 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;
+ if (activeFiles.length === 1) {
+ const pf = processedFiles.get(activeFiles[0]);
+ totalItems = (pf?.totalPages as number) || (pf?.pages?.length || 0);
+ } else if (activeFiles.length > 1) {
+ activeFiles.forEach(file => {
+ const pf = processedFiles.get(file);
+ totalItems += (pf?.totalPages as number) || (pf?.pages?.length || 0);
+ });
+ }
+ const selectedCount = selectedPageNumbers.length;
+ return { totalItems, selectedCount };
+ }
+
+ return { totalItems: 0, selectedCount: 0 };
+ }, [currentView, activeFiles, processedFiles, selectedFileIds, selectedPageNumbers]);
+
+ const { totalItems, selectedCount } = getSelectionState();
+
+ const handleSelectAll = useCallback(() => {
+ if (currentView === 'fileEditor' || currentView === 'viewer') {
+ const allIds = activeFiles.map(f => (f as any).id || f.name);
+ setSelectedFiles(allIds);
+ return;
+ }
+
+ if (currentView === 'pageEditor') {
+ let totalPages = 0;
+ if (activeFiles.length === 1) {
+ const pf = processedFiles.get(activeFiles[0]);
+ totalPages = (pf?.totalPages as number) || (pf?.pages?.length || 0);
+ } else if (activeFiles.length > 1) {
+ activeFiles.forEach(file => {
+ const pf = processedFiles.get(file);
+ totalPages += (pf?.totalPages as number) || (pf?.pages?.length || 0);
+ });
+ }
+
+ if (totalPages > 0) {
+ setSelectedPages(Array.from({ length: totalPages }, (_, i) => i + 1));
+ }
+ }
+ }, [currentView, activeFiles, processedFiles, 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 = selectedCount > 0
+ ? activeFiles.filter(f => selectedFileIds.includes((f as any).id || f.name))
+ : 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, selectedCount, activeFiles, selectedFileIds, pageEditorFunctions]);
+
+ const handleCloseSelected = useCallback(() => {
+ if (currentView !== 'fileEditor') return;
+ if (selectedCount === 0) return;
+
+ const fileIdsToClose = activeFiles.filter(f => selectedFileIds.includes((f as any).id || f.name))
+ .map(f => (f as any).id || f.name);
+
+ if (fileIdsToClose.length === 0) return;
+
+ // Close only selected files (do not delete from storage)
+ removeFiles(fileIdsToClose, false);
+
+ // Update selection state to remove closed ids
+ setSelectedFiles(selectedFileIds.filter(id => !fileIdsToClose.includes(id)));
+ }, [currentView, selectedCount, activeFiles, 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 pageNumbers = parseCSVInput(csvInput);
+ setSelectedPages(pageNumbers);
+ }, [csvInput, parseCSVInput, setSelectedPages]);
+
+ // Sync csvInput with selectedPageNumbers changes
+ useEffect(() => {
+ const sortedPageNumbers = [...selectedPageNumbers].sort((a, b) => a - b);
+ const newCsvInput = sortedPageNumbers.join(', ');
+ setCsvInput(newCsvInput);
+ }, [selectedPageNumbers]);
+
+ // Clear CSV input when files change
+ useEffect(() => {
+ setCsvInput("");
+ }, [activeFiles]);
+
+ // 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?.()}
+ disabled={!pageControlsVisible || selectedCount === 0}
+ >
+ 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 ? 'Download Selected Files' : '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 98f8d0115..fc3c45d21 100644
--- a/frontend/src/components/shared/TopControls.tsx
+++ b/frontend/src/components/shared/TopControls.tsx
@@ -1,51 +1,49 @@
import React, { useState, useCallback } from "react";
-import { Button, SegmentedControl, Loader } from "@mantine/core";
+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";
-// This will be created inside the component to access switchingTo
+// Create view options with icons and loading states
const createViewOptions = (switchingTo: string | null) => [
{
label: (
-
+
{switchingTo === "viewer" ? (
) : (
)}
-
+ Read
+
),
value: "viewer",
},
{
label: (
-
+
{switchingTo === "pageEditor" ? (
) : (
)}
-
+ Page Editor
+
),
value: "pageEditor",
},
{
label: (
-
+
{switchingTo === "fileEditor" ? (
) : (
)}
-
+ File Manager
+
),
value: "fileEditor",
},
@@ -62,7 +60,7 @@ const TopControls = ({
setCurrentView,
selectedToolKey,
}: TopControlsProps) => {
- const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
+ const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState(null);
const isToolSelected = selectedToolKey !== null;
@@ -83,52 +81,33 @@ const TopControls = ({
});
}, [setCurrentView]);
- const getThemeIcon = () => {
- if (isRainbowMode) return ;
- if (themeMode === "dark") return ;
- return ;
- };
-
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 9dec8f45a..0941a0b69 100644
--- a/frontend/src/components/tools/ToolPicker.tsx
+++ b/frontend/src/components/tools/ToolPicker.tsx
@@ -111,7 +111,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
overflowY: "auto",
overflowX: "hidden",
minHeight: 0,
- height: "100%"
+ height: "100%",
+ marginTop: -2
}}
className="tool-picker-scrollable"
>
@@ -135,7 +136,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)",
@@ -143,7 +143,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..1bb3a8d1e
--- /dev/null
+++ b/frontend/src/contexts/RightRailContext.tsx
@@ -0,0 +1,53 @@
+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 merged = [...prev.filter(b => !newButtons.some(nb => nb.id === b.id)), ...newButtons];
+ return merged.sort((a, b) => (a.order || 0) - (b.order || 0));
+ });
+ }, []);
+
+ 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/useRightRailButtons.ts b/frontend/src/hooks/useRightRailButtons.ts
new file mode 100644
index 000000000..a30f1b2bc
--- /dev/null
+++ b/frontend/src/hooks/useRightRailButtons.ts
@@ -0,0 +1,31 @@
+import { useEffect } 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: RightRailButtonWithAction[]) {
+ const { registerButtons, unregisterButtons, setAction } = useRightRail();
+
+ useEffect(() => {
+ if (!buttons || buttons.length === 0) return;
+
+ // Register visual button configs (without onClick)
+ registerButtons(buttons.map(({ onClick, ...cfg }) => cfg));
+
+ // Bind actions
+ buttons.forEach(({ id, onClick }) => setAction(id, onClick));
+
+ // Cleanup
+ return () => {
+ unregisterButtons(buttons.map(b => b.id));
+ };
+ }, [registerButtons, unregisterButtons, setAction, buttons]);
+}
diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx
index d26a40caa..6ec4ca63c 100644
--- a/frontend/src/pages/HomePage.tsx
+++ b/frontend/src/pages/HomePage.tsx
@@ -11,7 +11,9 @@ 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";
+import { RightRailProvider } from "../contexts/RightRailContext";
function HomePageContent() {
@@ -37,7 +39,6 @@ function HomePageContent() {
ogImage: selectedToolKey ? `${baseUrl}/og_images/${selectedToolKey}.png` : `${baseUrl}/og_images/home.png`,
ogUrl: selectedTool ? `${baseUrl}${window.location.pathname}` : baseUrl
});
-
// Update file selection context when tool changes
useEffect(() => {
if (selectedTool) {
@@ -60,6 +61,7 @@ function HomePageContent() {
ref={quickAccessRef} />
+
);
@@ -71,7 +73,9 @@ export default function HomePage() {
-
+
+
+
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/types/rightRail.ts b/frontend/src/types/rightRail.ts
new file mode 100644
index 000000000..eacf01dbe
--- /dev/null
+++ b/frontend/src/types/rightRail.ts
@@ -0,0 +1,13 @@
+import React from 'react';
+
+export interface RightRailButtonConfig {
+ id: string; // unique id for the button, also used to bind action callbacks
+ icon: React.ReactNode;
+ tooltip: string;
+ section?: 'top' | 'middle' | 'bottom';
+ order?: number;
+ disabled?: boolean;
+ visible?: boolean;
+}
+
+export type RightRailAction = () => void;