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>(new Map()); const [isAnimating, setIsAnimating] = useState(false); - const pageRefs = useRef>(new Map()); - const fileInputRef = useRef<() => void>(null); // Undo/Redo system const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo(); @@ -204,8 +197,6 @@ const PageEditor = ({ generateThumbnails, addThumbnailToCache, getThumbnailFromCache, - stopGeneration, - destroyThumbnails } = useThumbnailGeneration(); // Start thumbnail generation process (separate from document loading) @@ -813,14 +804,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, @@ -829,10 +824,10 @@ const PageEditor = ({ ); executeCommand(command); - if (selectionMode) { + if (selectionMode || hasSelectedPages) { 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, setSelectedPages]); @@ -1181,58 +1176,12 @@ const PageEditor = ({
)} - - 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 = () => { }} > - + }} + > + language + + ) : ( + + )} { }} > {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 */} + +
+ + + select_all + + +
+
+ + {/* Deselect All Button */} + +
+ + + crop_square + + +
+
+ + {/* Select by Numbers - page editor only, with animated presence */} + {pageControlsMounted && ( + + +
+ + +
+ + + pin_end + + +
+
+ +
+ +
+
+
+
+
+ + )} + + {/* 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> +
+ + + download + + +
+
+
+ +
+
+
+ ); +} + + 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;