diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index 856c55b83..54901902c 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -3,7 +3,7 @@ import { Text, Center, Box, LoadingOverlay, Stack } from '@mantine/core'; import { Dropzone } from '@mantine/dropzone'; -import { useFileSelection, useFileState, useFileManagement, useFileActions } from '../../contexts/FileContext'; +import { useFileSelection, useFileState, useFileManagement, useFileActions, useFileContext } from '../../contexts/FileContext'; import { useNavigationActions } from '../../contexts/NavigationContext'; import { zipFileService } from '../../services/zipFileService'; import { detectFileExtension } from '../../utils/fileUtils'; @@ -13,6 +13,7 @@ import FilePickerModal from '../shared/FilePickerModal'; import { FileId, StirlingFile } from '../../types/fileContext'; import { alert } from '../toast'; import { downloadBlob } from '../../utils/downloadUtils'; +import { useFileEditorRightRailButtons } from './fileEditorRightRailButtons'; interface FileEditorProps { @@ -36,11 +37,15 @@ const FileEditor = ({ // Use optimized FileContext hooks const { state, selectors } = useFileState(); const { addFiles, removeFiles, reorderFiles } = useFileManagement(); - const { actions } = useFileActions(); + const { actions: fileActions } = useFileActions(); + const { actions: fileContextActions } = useFileContext(); + const { clearAllFileErrors } = fileContextActions; // Extract needed values from state (memoized to prevent infinite loops) const activeStirlingFileStubs = useMemo(() => selectors.getStirlingFileStubs(), [selectors.getFilesSignature()]); const selectedFileIds = state.ui.selectedFileIds; + const totalItems = state.files.ids.length; + const selectedCount = selectedFileIds.length; // Get navigation actions const { actions: navActions } = useNavigationActions(); @@ -77,6 +82,42 @@ const FileEditor = ({ // Use activeStirlingFileStubs directly - no conversion needed const localSelectedIds = contextSelectedIds; + const handleSelectAllFiles = useCallback(() => { + setSelectedFiles(state.files.ids); + try { + clearAllFileErrors(); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('Failed to clear file errors on select all:', error); + } + } + }, [state.files.ids, setSelectedFiles, clearAllFileErrors]); + + const handleDeselectAllFiles = useCallback(() => { + setSelectedFiles([]); + try { + clearAllFileErrors(); + } catch (error) { + if (process.env.NODE_ENV === 'development') { + console.warn('Failed to clear file errors on deselect:', error); + } + } + }, [setSelectedFiles, clearAllFileErrors]); + + const handleCloseSelectedFiles = useCallback(() => { + if (selectedFileIds.length === 0) return; + void removeFiles(selectedFileIds, false); + setSelectedFiles([]); + }, [selectedFileIds, removeFiles, setSelectedFiles]); + + useFileEditorRightRailButtons({ + totalItems, + selectedCount, + onSelectAll: handleSelectAllFiles, + onDeselectAll: handleDeselectAllFiles, + onCloseSelected: handleCloseSelectedFiles, + }); + // Process uploaded files using context // ZIP extraction is now handled automatically in FileContext based on user preferences const handleFileUpload = useCallback(async (uploadedFiles: File[]) => { @@ -226,7 +267,7 @@ const FileEditor = ({ if (result.success && result.extractedStubs.length > 0) { // Add extracted file stubs to FileContext - await actions.addStirlingFileStubs(result.extractedStubs); + await fileActions.addStirlingFileStubs(result.extractedStubs); // Remove the original ZIP file removeFiles([fileId], false); @@ -256,7 +297,7 @@ const FileEditor = ({ }); } } - }, [activeStirlingFileStubs, selectors, actions, removeFiles]); + }, [activeStirlingFileStubs, selectors, fileActions, removeFiles]); const handleViewFile = useCallback((fileId: FileId) => { const record = activeStirlingFileStubs.find(r => r.id === fileId); diff --git a/frontend/src/components/fileEditor/fileEditorRightRailButtons.tsx b/frontend/src/components/fileEditor/fileEditorRightRailButtons.tsx new file mode 100644 index 000000000..054f7c535 --- /dev/null +++ b/frontend/src/components/fileEditor/fileEditorRightRailButtons.tsx @@ -0,0 +1,60 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useRightRailButtons, RightRailButtonWithAction } from '../../hooks/useRightRailButtons'; +import LocalIcon from '../shared/LocalIcon'; + +interface FileEditorRightRailButtonsParams { + totalItems: number; + selectedCount: number; + onSelectAll: () => void; + onDeselectAll: () => void; + onCloseSelected: () => void; +} + +export function useFileEditorRightRailButtons({ + totalItems, + selectedCount, + onSelectAll, + onDeselectAll, + onCloseSelected, +}: FileEditorRightRailButtonsParams) { + const { t } = useTranslation(); + + const buttons = useMemo(() => [ + { + id: 'file-select-all', + icon: , + tooltip: t('rightRail.selectAll', 'Select All'), + ariaLabel: typeof t === 'function' ? t('rightRail.selectAll', 'Select All') : 'Select All', + section: 'top' as const, + order: 10, + disabled: totalItems === 0 || selectedCount === totalItems, + visible: totalItems > 0, + onClick: onSelectAll, + }, + { + id: 'file-deselect-all', + icon: , + tooltip: t('rightRail.deselectAll', 'Deselect All'), + ariaLabel: typeof t === 'function' ? t('rightRail.deselectAll', 'Deselect All') : 'Deselect All', + section: 'top' as const, + order: 20, + disabled: selectedCount === 0, + visible: totalItems > 0, + onClick: onDeselectAll, + }, + { + id: 'file-close-selected', + icon: , + tooltip: t('rightRail.closeSelected', 'Close Selected Files'), + ariaLabel: typeof t === 'function' ? t('rightRail.closeSelected', 'Close Selected Files') : 'Close Selected Files', + section: 'top' as const, + order: 30, + disabled: selectedCount === 0, + visible: totalItems > 0, + onClick: onCloseSelected, + }, + ], [t, totalItems, selectedCount, onSelectAll, onDeselectAll, onCloseSelected]); + + useRightRailButtons(buttons); +} diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 0cab1f947..13f702e69 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -26,6 +26,8 @@ import { import { GRID_CONSTANTS } from './constants'; import { usePageDocument } from './hooks/usePageDocument'; import { usePageEditorState } from './hooks/usePageEditorState'; +import { parseSelection } from "../../utils/bulkselection/parseSelection"; +import { usePageEditorRightRailButtons } from "./pageEditorRightRailButtons"; export interface PageEditorProps { onFunctionsReady?: (functions: PageEditorFunctions) => void; @@ -44,6 +46,7 @@ const PageEditor = ({ // Prefer IDs + selectors to avoid array identity churn const activeFileIds = state.files.ids; + const activeFilesSignature = selectors.getFilesSignature(); // UI state const globalProcessing = state.ui.isProcessing; @@ -65,6 +68,12 @@ const PageEditor = ({ togglePage, toggleSelectAll, animateReorder } = usePageEditorState(); + const [csvInput, setCsvInput] = useState(''); + + useEffect(() => { + setCsvInput(''); + }, [activeFilesSignature]); + // Grid container ref for positioning split indicators const gridContainerRef = useRef(null); @@ -118,6 +127,8 @@ const PageEditor = ({ // Interface functions for parent component const displayDocument = editedDocument || mergedPdfDocument; + const totalPages = displayDocument?.pages.length ?? 0; + const selectedPageCount = selectedPageIds.length; // Utility functions to convert between page IDs and page numbers const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => { @@ -414,6 +425,12 @@ const PageEditor = ({ setSelectedPageIds(pageIds); }, [getPageIdsFromNumbers, setSelectedPageIds]); + const updatePagesFromCSV = useCallback((override?: string) => { + if (totalPages === 0) return; + const normalized = parseSelection(override ?? csvInput, totalPages); + handleSetSelectedPages(normalized); + }, [csvInput, totalPages, handleSetSelectedPages]); + const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => { if (!displayDocument) return; @@ -609,6 +626,23 @@ const PageEditor = ({ setSelectionMode(false); }, [actions]); + usePageEditorRightRailButtons({ + totalPages, + selectedPageCount, + csvInput, + setCsvInput, + selectedPageIds, + displayDocument: displayDocument || undefined, + updatePagesFromCSV, + handleSelectAll, + handleDeselectAll, + handleDelete, + onExportSelected, + exportLoading, + activeFileCount: activeFileIds.length, + closePdf, + }); + // Export preview function - defined after export functions to avoid circular dependency const handleExportPreview = useCallback((selectedOnly: boolean = false) => { if (!displayDocument) return; diff --git a/frontend/src/components/pageEditor/PageSelectByNumberButton.tsx b/frontend/src/components/pageEditor/PageSelectByNumberButton.tsx new file mode 100644 index 000000000..3017d3a11 --- /dev/null +++ b/frontend/src/components/pageEditor/PageSelectByNumberButton.tsx @@ -0,0 +1,59 @@ +import { ActionIcon, Popover } from '@mantine/core'; +import LocalIcon from '../shared/LocalIcon'; +import { Tooltip } from '../shared/Tooltip'; +import BulkSelectionPanel from './BulkSelectionPanel'; + +interface PageSelectByNumberButtonProps { + disabled: boolean; + totalPages: number; + label: string; + csvInput: string; + setCsvInput: (value: string) => void; + selectedPageIds: string[]; + displayDocument?: { pages: { id: string; pageNumber: number }[] }; + updatePagesFromCSV: (override?: string) => void; +} + +export default function PageSelectByNumberButton({ + disabled, + totalPages, + label, + csvInput, + setCsvInput, + selectedPageIds, + displayDocument, + updatePagesFromCSV, +}: PageSelectByNumberButtonProps) { + return ( + +
+ + +
+ + + +
+
+ +
+ +
+
+
+
+
+ ); +} diff --git a/frontend/src/components/pageEditor/hooks/usePageDocument.ts b/frontend/src/components/pageEditor/hooks/usePageDocument.ts index 0b7aa00b4..e921903f9 100644 --- a/frontend/src/components/pageEditor/hooks/usePageDocument.ts +++ b/frontend/src/components/pageEditor/hooks/usePageDocument.ts @@ -21,7 +21,7 @@ export function usePageDocument(): PageDocumentHook { const primaryFileId = activeFileIds[0] ?? null; // Stable signature for effects (prevents loops) - const filesSignature = selectors.getFilesSignature(); + const activeFilesSignature = selectors.getFilesSignature(); // UI state const globalProcessing = state.ui.isProcessing; @@ -156,7 +156,7 @@ export function usePageDocument(): PageDocumentHook { }; return mergedDoc; - }, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, filesSignature]); + }, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature]); // Large document detection for smart loading const isVeryLargeDocument = useMemo(() => { diff --git a/frontend/src/components/pageEditor/pageEditorRightRailButtons.tsx b/frontend/src/components/pageEditor/pageEditorRightRailButtons.tsx new file mode 100644 index 000000000..23faa99af --- /dev/null +++ b/frontend/src/components/pageEditor/pageEditorRightRailButtons.tsx @@ -0,0 +1,156 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useRightRailButtons, RightRailButtonWithAction } from '../../hooks/useRightRailButtons'; +import LocalIcon from '../shared/LocalIcon'; +import PageSelectByNumberButton from './PageSelectByNumberButton'; + +interface PageEditorRightRailButtonsParams { + totalPages: number; + selectedPageCount: number; + csvInput: string; + setCsvInput: (value: string) => void; + selectedPageIds: string[]; + displayDocument?: { pages: { id: string; pageNumber: number }[] }; + updatePagesFromCSV: (override?: string) => void; + handleSelectAll: () => void; + handleDeselectAll: () => void; + handleDelete: () => void; + onExportSelected: () => void; + exportLoading: boolean; + activeFileCount: number; + closePdf: () => void; +} + +export function usePageEditorRightRailButtons(params: PageEditorRightRailButtonsParams) { + const { + totalPages, + selectedPageCount, + csvInput, + setCsvInput, + selectedPageIds, + displayDocument, + updatePagesFromCSV, + handleSelectAll, + handleDeselectAll, + handleDelete, + onExportSelected, + exportLoading, + activeFileCount, + closePdf, + } = params; + + const { t } = useTranslation(); + + // Lift i18n labels out of memo for clarity + const selectAllLabel = t('rightRail.selectAll', 'Select All'); + const deselectAllLabel = t('rightRail.deselectAll', 'Deselect All'); + const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers'); + const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages'); + const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages'); + const closePdfLabel = t('rightRail.closePdf', 'Close PDF'); + + const buttons = useMemo(() => { + return [ + { + id: 'page-select-all', + icon: , + tooltip: selectAllLabel, + ariaLabel: selectAllLabel, + section: 'top' as const, + order: 10, + disabled: totalPages === 0 || selectedPageCount === totalPages, + visible: totalPages > 0, + onClick: handleSelectAll, + }, + { + id: 'page-deselect-all', + icon: , + tooltip: deselectAllLabel, + ariaLabel: deselectAllLabel, + section: 'top' as const, + order: 20, + disabled: selectedPageCount === 0, + visible: totalPages > 0, + onClick: handleDeselectAll, + }, + { + id: 'page-select-by-number', + tooltip: selectByNumberLabel, + ariaLabel: selectByNumberLabel, + section: 'top' as const, + order: 30, + disabled: totalPages === 0, + visible: totalPages > 0, + render: ({ disabled }) => ( + + ), + }, + { + id: 'page-delete-selected', + icon: , + tooltip: deleteSelectedLabel, + ariaLabel: deleteSelectedLabel, + section: 'top' as const, + order: 40, + disabled: selectedPageCount === 0, + visible: totalPages > 0, + onClick: handleDelete, + }, + { + id: 'page-export-selected', + icon: , + tooltip: exportSelectedLabel, + ariaLabel: exportSelectedLabel, + section: 'top' as const, + order: 50, + disabled: selectedPageCount === 0 || exportLoading, + visible: totalPages > 0, + onClick: onExportSelected, + }, + { + id: 'page-close-pdf', + icon: , + tooltip: closePdfLabel, + ariaLabel: closePdfLabel, + section: 'top' as const, + order: 60, + disabled: activeFileCount === 0, + visible: activeFileCount > 0, + onClick: closePdf, + }, + ]; + }, [ + t, + selectAllLabel, + deselectAllLabel, + selectByNumberLabel, + deleteSelectedLabel, + exportSelectedLabel, + closePdfLabel, + totalPages, + selectedPageCount, + csvInput, + setCsvInput, + selectedPageIds, + displayDocument, + updatePagesFromCSV, + handleSelectAll, + handleDeselectAll, + handleDelete, + onExportSelected, + exportLoading, + activeFileCount, + closePdf, + ]); + + useRightRailButtons(buttons); +} diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index 858e24f53..c8168f373 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -1,462 +1,193 @@ -import React, { useCallback, useState, useEffect, useMemo } from 'react'; -import { ActionIcon, Divider, Popover } from '@mantine/core'; -import LocalIcon from './LocalIcon'; +import React, { useCallback, useMemo } from 'react'; +import { ActionIcon, Divider } from '@mantine/core'; import './rightRail/RightRail.css'; import { useToolWorkflow } from '../../contexts/ToolWorkflowContext'; import { useRightRail } from '../../contexts/RightRailContext'; -import { useFileState, useFileSelection, useFileManagement, useFileContext } from '../../contexts/FileContext'; +import { useFileState, useFileSelection } from '../../contexts/FileContext'; import { useNavigationState } from '../../contexts/NavigationContext'; import { useTranslation } from 'react-i18next'; import LanguageSelector from '../shared/LanguageSelector'; import { useRainbowThemeContext } from '../shared/RainbowThemeProvider'; import { Tooltip } from '../shared/Tooltip'; -import BulkSelectionPanel from '../pageEditor/BulkSelectionPanel'; -import { SearchInterface } from '../viewer/SearchInterface'; import { ViewerContext } from '../../contexts/ViewerContext'; import { useSignature } from '../../contexts/SignatureContext'; -import ViewerAnnotationControls from './rightRail/ViewerAnnotationControls'; - -import { parseSelection } from '../../utils/bulkselection/parseSelection'; - +import LocalIcon from './LocalIcon'; import { useSidebarContext } from '../../contexts/SidebarContext'; +import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '../../types/rightRail'; + +const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom']; + +function renderWithTooltip( + node: React.ReactNode, + tooltip: React.ReactNode | undefined +) { + if (!tooltip) return node; + + const portalTarget = typeof document !== 'undefined' ? document.body : undefined; + + return ( + +
{node}
+
+ ); +} export default function RightRail() { const { sidebarRefs } = useSidebarContext(); const { t } = useTranslation(); - const [isPanning, setIsPanning] = useState(false); - - // Viewer context for PDF controls - safely handle when not available const viewerContext = React.useContext(ViewerContext); const { toggleTheme } = useRainbowThemeContext(); const { buttons, actions, allButtonsDisabled } = useRightRail(); - const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]); - - // Access PageEditor functions for page-editor-specific actions const { pageEditorFunctions, toolPanelMode, leftPanelView } = useToolWorkflow(); const disableForFullscreen = toolPanelMode === 'fullscreen' && leftPanelView === 'toolPicker'; - // CSV input state for page selection - const [csvInput, setCsvInput] = useState(""); - - // Navigation view const { workbench: currentView } = useNavigationState(); - const isCustomWorkbench = typeof currentView === 'string' && currentView.startsWith('custom:'); - // File state and selection - const { state, selectors } = useFileState(); - const { actions: fileActions } = useFileContext(); - const { selectedFiles, selectedFileIds, setSelectedFiles } = useFileSelection(); - const { removeFiles } = useFileManagement(); - - // Signature context for checking if signatures have been applied + const { selectors } = useFileState(); + const { selectedFiles, selectedFileIds } = useFileSelection(); const { signaturesApplied } = useSignature(); const activeFiles = selectors.getFiles(); - const filesSignature = selectors.getFilesSignature(); - - // 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') { - // Use PageEditor's own state - const totalItems = pageEditorFunctions?.totalPages || 0; - const selectedCount = pageEditorFunctions?.selectedPageIds?.length || 0; - return { totalItems, selectedCount }; - } - - return { totalItems: 0, selectedCount: 0 }; - }, [currentView, activeFiles, selectedFileIds, pageEditorFunctions]); - - const { totalItems, selectedCount } = getSelectionState(); - - // Get export state for viewer mode + const pageEditorTotalPages = pageEditorFunctions?.totalPages ?? 0; + const pageEditorSelectedCount = pageEditorFunctions?.selectedPageIds?.length ?? 0; const exportState = viewerContext?.getExportState?.(); - const handleSelectAll = useCallback(() => { - if (currentView === 'fileEditor' || currentView === 'viewer') { - // Select all file IDs - const allIds = state.files.ids; - setSelectedFiles(allIds); - // Clear any previous error flags when selecting all - try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; } - return; - } + const totalItems = useMemo(() => { + if (currentView === 'pageEditor') return pageEditorTotalPages; + return activeFiles.length; + }, [currentView, pageEditorTotalPages, activeFiles.length]); + const selectedCount = useMemo(() => { if (currentView === 'pageEditor') { - // Use PageEditor's select all function - pageEditorFunctions?.handleSelectAll?.(); + return pageEditorSelectedCount; } - }, [currentView, state.files.ids, setSelectedFiles, pageEditorFunctions]); + return selectedFileIds.length; + }, [currentView, pageEditorSelectedCount, selectedFileIds.length]); - const handleDeselectAll = useCallback(() => { - if (currentView === 'fileEditor' || currentView === 'viewer') { - setSelectedFiles([]); - // Clear any previous error flags when deselecting all - try { fileActions.clearAllFileErrors(); } catch (_e) { void _e; } - return; - } - if (currentView === 'pageEditor') { - // Use PageEditor's deselect all function - pageEditorFunctions?.handleDeselectAll?.(); - } - }, [currentView, setSelectedFiles, pageEditorFunctions]); + const sectionsWithButtons = useMemo(() => { + return SECTION_ORDER + .map(section => { + const sectionButtons = buttons.filter(btn => (btn.section ?? 'top') === section && (btn.visible ?? true)); + return { section, buttons: sectionButtons }; + }) + .filter(entry => entry.buttons.length > 0); + }, [buttons]); + + const renderButton = useCallback( + (btn: RightRailButtonConfig) => { + const action = actions[btn.id]; + const disabled = Boolean(btn.disabled || allButtonsDisabled || disableForFullscreen); + + const triggerAction = () => { + if (!disabled) action?.(); + }; + + if (btn.render) { + const context: RightRailRenderContext = { + id: btn.id, + disabled, + allButtonsDisabled, + action, + triggerAction, + }; + return btn.render(context) ?? null; + } + + if (!btn.icon) return null; + + const ariaLabel = + btn.ariaLabel || (typeof btn.tooltip === 'string' ? (btn.tooltip as string) : undefined); + const className = ['right-rail-icon', btn.className].filter(Boolean).join(' '); + const buttonNode = ( + + {btn.icon} + + ); + + return renderWithTooltip(buttonNode, btn.tooltip); + }, + [actions, allButtonsDisabled, disableForFullscreen] + ); const handleExportAll = useCallback(async () => { if (currentView === 'viewer') { - // Check if signatures have been applied if (!signaturesApplied) { alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.'); return; } - - // Use EmbedPDF export functionality for viewer mode viewerContext?.exportActions?.download(); - } else if (currentView === 'fileEditor') { - // 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?.(); + return; } - }, [currentView, activeFiles, selectedFiles, pageEditorFunctions, viewerContext, signaturesApplied, selectors, fileActions]); - 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]); - - const updatePagesFromCSV = useCallback((override?: string) => { - const maxPages = pageEditorFunctions?.totalPages || 0; - const normalized = parseSelection(override ?? csvInput, maxPages); - pageEditorFunctions?.handleSetSelectedPages?.(normalized); - }, [csvInput, pageEditorFunctions]); - - // Do not overwrite user's expression input when selection changes. - - // 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); + pageEditorFunctions?.onExportAll?.(); + return; } - }, [currentView]); + + 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); + }); + }, [ + currentView, + selectedFiles, + activeFiles, + pageEditorFunctions, + viewerContext, + signaturesApplied + ]); + + const downloadTooltip = useMemo(() => { + if (currentView === 'pageEditor') { + return t('rightRail.exportAll', 'Export PDF'); + } + if (selectedCount > 0) { + return t('rightRail.downloadSelected', 'Download Selected Files'); + } + return t('rightRail.downloadAll', 'Download All'); + }, [currentView, selectedCount, t]); return ( -
+
- {topButtons.length > 0 && !isCustomWorkbench && ( - <> + {sectionsWithButtons.map(({ section, buttons: sectionButtons }) => ( +
- {topButtons.map(btn => ( - - actions[btn.id]?.()} - disabled={btn.disabled || allButtonsDisabled || disableForFullscreen} + {sectionButtons.map((btn, index) => { + const content = renderButton(btn); + if (!content) return null; + return ( +
- {btn.icon} - - - ))} + {content} +
+ ); + })}
- - )} - - {/* Group: PDF Viewer Controls - visible only in viewer mode */} - {!isCustomWorkbench && ( -
-
- {/* Search */} - - - -
- - - -
-
- -
- {}} - /> -
-
-
-
- - - {/* Pan Mode */} - - { - viewerContext?.panActions.togglePan(); - setIsPanning(!isPanning); - }} - disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen} - > - - - - - {/* Rotate Left */} - - { - viewerContext?.rotationActions.rotateBackward(); - }} - disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen} - > - - - - - {/* Rotate Right */} - - { - viewerContext?.rotationActions.rotateForward(); - }} - disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen} - > - - - - - {/* Sidebar Toggle */} - - { - viewerContext?.toggleThumbnailSidebar(); - }} - disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen} - > - - - - - {/* Annotation Controls */} - -
- -
- )} - - {/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */} - {!isCustomWorkbench && ( -
-
- {/* 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 || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || allButtonsDisabled || disableForFullscreen} - aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'} - > - - -
-
-
- - )} - - {/* Export Selected Pages - page editor only */} - {pageControlsMounted && ( - -
-
- { pageEditorFunctions?.onExportSelected?.(); }} - disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || pageEditorFunctions?.exportLoading || allButtonsDisabled || disableForFullscreen} - aria-label={typeof t === 'function' ? t('rightRail.exportSelected', 'Export Selected Pages') : 'Export Selected Pages'} - > - - -
-
-
- )} - - {/* 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)) || - allButtonsDisabled || disableForFullscreen - } - > - - -
-
-
- - -
- )} - - {/* Theme toggle and Language dropdown */} +
+ ))}
- + {renderWithTooltip( - - + , + t('rightRail.toggleTheme', 'Toggle Theme') + )} - + {renderWithTooltip(
-
-
+
, + t('rightRail.language', 'Language') + )} - 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All')) - } position="left" offset={12} arrow portalTarget={document.body}> -
- - - -
-
+ {renderWithTooltip( + + + , + downloadTooltip + )}
@@ -499,4 +228,3 @@ export default function RightRail() {
); } - diff --git a/frontend/src/components/shared/rightRail/RightRail.README.md b/frontend/src/components/shared/rightRail/RightRail.README.md index 7506e927c..a6aab574a 100644 --- a/frontend/src/components/shared/rightRail/RightRail.README.md +++ b/frontend/src/components/shared/rightRail/RightRail.README.md @@ -84,14 +84,48 @@ useRightRailButtons([ ```typescript interface RightRailButtonWithAction { id: string; // Unique identifier - icon: React.ReactNode; // Icon component - tooltip: string; // Hover tooltip + icon?: React.ReactNode; // Icon component (omit when using render) + tooltip?: React.ReactNode; // Hover tooltip / description 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 + render?: (ctx: RightRailRenderContext) => React.ReactNode; // Custom renderer + onClick?: () => void; // Click handler (optional if using render) } + +interface RightRailRenderContext { + id: string; + disabled: boolean; + allButtonsDisabled: boolean; + action?: () => void; + triggerAction: () => void; +} +``` + +### Custom Rendering (Popovers, Multi-button Blocks) + +```tsx +useRightRailButtons([ + { + id: 'viewer-search', + tooltip: t('rightRail.search', 'Search PDF'), + render: ({ disabled }) => ( + + + + + + + + + + + + + ), + }, +]); ``` ## Built-in Features @@ -106,3 +140,4 @@ interface RightRailButtonWithAction { - Choose appropriate Material-UI icons - Keep tooltips concise: `'Compress PDF'`, `'Process with OCR'` - Use `useCallback` for click handlers to prevent re-registration +- Reach for `render` when you need popovers or multi-control groups inside the rail diff --git a/frontend/src/components/shared/rightRail/RightRail.css b/frontend/src/components/shared/rightRail/RightRail.css index 94b1f551c..6ea40538d 100644 --- a/frontend/src/components/shared/rightRail/RightRail.css +++ b/frontend/src/components/shared/rightRail/RightRail.css @@ -29,6 +29,34 @@ gap: 0.75rem; } +.right-rail-button-wrapper { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + width: 100%; + animation: rightRailButtonReveal 200ms ease forwards; + transform-origin: top center; + opacity: 0; +} + +.right-rail-tooltip-wrapper { + display: inline-flex; + width: 100%; + justify-content: center; +} + +@keyframes rightRailButtonReveal { + 0% { + opacity: 0; + transform: scaleY(0.6) translateY(-6px); + } + 100% { + opacity: 1; + transform: scaleY(1) translateY(0); + } +} + .right-rail-divider { width: 2.75rem; border: none; @@ -131,4 +159,3 @@ transition-delay: 0s, 0s, 0s, 220ms; pointer-events: none; } - diff --git a/frontend/src/components/viewer/EmbedPdfViewer.tsx b/frontend/src/components/viewer/EmbedPdfViewer.tsx index a8609c0f3..4daa0c554 100644 --- a/frontend/src/components/viewer/EmbedPdfViewer.tsx +++ b/frontend/src/components/viewer/EmbedPdfViewer.tsx @@ -13,6 +13,7 @@ import { useSignature } from '../../contexts/SignatureContext'; import { createStirlingFilesAndStubs } from '../../services/fileStubHelpers'; import NavigationWarningModal from '../shared/NavigationWarningModal'; import { isStirlingFile } from '../../types/fileContext'; +import { useViewerRightRailButtons } from './useViewerRightRailButtons'; export interface EmbedPdfViewerProps { sidebarsVisible: boolean; @@ -36,6 +37,9 @@ const EmbedPdfViewerContent = ({ const { isThumbnailSidebarVisible, toggleThumbnailSidebar, zoomActions, spreadActions, panActions: _panActions, rotationActions: _rotationActions, getScrollState, getZoomState, getSpreadState, getRotationState, isAnnotationMode, isAnnotationsVisible, exportActions } = useViewer(); + // Register viewer right-rail buttons + useViewerRightRailButtons(); + const scrollState = getScrollState(); const zoomState = getZoomState(); const spreadState = getSpreadState(); diff --git a/frontend/src/components/viewer/useViewerRightRailButtons.tsx b/frontend/src/components/viewer/useViewerRightRailButtons.tsx new file mode 100644 index 000000000..ea0237518 --- /dev/null +++ b/frontend/src/components/viewer/useViewerRightRailButtons.tsx @@ -0,0 +1,125 @@ +import { useMemo, useState } from 'react'; +import { ActionIcon, Popover } from '@mantine/core'; +import { useTranslation } from 'react-i18next'; +import { useViewer } from '../../contexts/ViewerContext'; +import { useRightRailButtons, RightRailButtonWithAction } from '../../hooks/useRightRailButtons'; +import LocalIcon from '../shared/LocalIcon'; +import { Tooltip } from '../shared/Tooltip'; +import { SearchInterface } from './SearchInterface'; +import ViewerAnnotationControls from '../shared/rightRail/ViewerAnnotationControls'; + +export function useViewerRightRailButtons() { + const { t } = useTranslation(); + const viewer = useViewer(); + const [isPanning, setIsPanning] = useState(() => viewer.getPanState()?.isPanning ?? false); + + // Lift i18n labels out of memo for clarity + const searchLabel = t('rightRail.search', 'Search PDF'); + const panLabel = t('rightRail.panMode', 'Pan Mode'); + const rotateLeftLabel = t('rightRail.rotateLeft', 'Rotate Left'); + const rotateRightLabel = t('rightRail.rotateRight', 'Rotate Right'); + const sidebarLabel = t('rightRail.toggleSidebar', 'Toggle Sidebar'); + + const viewerButtons = useMemo(() => { + return [ + { + id: 'viewer-search', + tooltip: searchLabel, + ariaLabel: searchLabel, + section: 'top' as const, + order: 10, + render: ({ disabled }) => ( + + + +
+ + + +
+
+ +
+ {}} /> +
+
+
+
+ ) + }, + { + id: 'viewer-pan-mode', + tooltip: panLabel, + ariaLabel: panLabel, + section: 'top' as const, + order: 20, + render: ({ disabled }) => ( + + { + viewer.panActions.togglePan(); + setIsPanning(prev => !prev); + }} + disabled={disabled} + > + + + + ) + }, + { + id: 'viewer-rotate-left', + icon: , + tooltip: rotateLeftLabel, + ariaLabel: rotateLeftLabel, + section: 'top' as const, + order: 30, + onClick: () => { + viewer.rotationActions.rotateBackward(); + } + }, + { + id: 'viewer-rotate-right', + icon: , + tooltip: rotateRightLabel, + ariaLabel: rotateRightLabel, + section: 'top' as const, + order: 40, + onClick: () => { + viewer.rotationActions.rotateForward(); + } + }, + { + id: 'viewer-toggle-sidebar', + icon: , + tooltip: sidebarLabel, + ariaLabel: sidebarLabel, + section: 'top' as const, + order: 50, + onClick: () => { + viewer.toggleThumbnailSidebar(); + } + }, + { + id: 'viewer-annotation-controls', + section: 'top' as const, + order: 60, + render: ({ disabled }) => ( + + ) + } + ]; + }, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]); + + useRightRailButtons(viewerButtons); +} diff --git a/frontend/src/contexts/RightRailContext.tsx b/frontend/src/contexts/RightRailContext.tsx index 0d51e265e..6366545af 100644 --- a/frontend/src/contexts/RightRailContext.tsx +++ b/frontend/src/contexts/RightRailContext.tsx @@ -7,7 +7,7 @@ interface RightRailContextValue { allButtonsDisabled: boolean; registerButtons: (buttons: RightRailButtonConfig[]) => void; unregisterButtons: (ids: string[]) => void; - setAction: (id: string, action: RightRailAction) => void; + setAction: (id: string, action?: RightRailAction) => void; setAllRightRailButtonsDisabled: (disabled: boolean) => void; clear: () => void; } @@ -42,8 +42,16 @@ export function RightRailProvider({ children }: { children: React.ReactNode }) { 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 setAction = useCallback((id: string, action?: RightRailAction) => { + setActions(prev => { + if (!action) { + if (!(id in prev)) return prev; + const next = { ...prev }; + delete next[id]; + return next; + } + return { ...prev, [id]: action }; + }); }, []); const setAllRightRailButtonsDisabled = useCallback((disabled: boolean) => { diff --git a/frontend/src/hooks/useRightRailButtons.ts b/frontend/src/hooks/useRightRailButtons.ts index 82a4e8cd5..c90101733 100644 --- a/frontend/src/hooks/useRightRailButtons.ts +++ b/frontend/src/hooks/useRightRailButtons.ts @@ -3,7 +3,7 @@ import { useRightRail } from '../contexts/RightRailContext'; import { RightRailAction, RightRailButtonConfig } from '../types/rightRail'; export interface RightRailButtonWithAction extends RightRailButtonConfig { - onClick: RightRailAction; + onClick?: RightRailAction; } /** @@ -28,7 +28,7 @@ export function useRightRailButtons(buttons: readonly RightRailButtonWithAction[ 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 (!b.onClick && !b.render) console.warn('[RightRail] Missing onClick/render for id:', b.id); if (idSet.has(b.id)) console.warn('[RightRail] Duplicate id in buttons array:', b.id); idSet.add(b.id); }); diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 932fbf283..a3b9b0b89 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -171,7 +171,7 @@ /* RightRail (light) */ --right-rail-bg: #F5F6F8; /* light background */ - --right-rail-foreground: #CDD4E1; /* panel behind custom tool icons */ + --right-rail-foreground: #E3E4E5; /* panel behind custom tool icons */ --right-rail-icon: #4B5563; /* icon color */ --right-rail-icon-disabled: #CECECE;/* disabled icon */ diff --git a/frontend/src/types/rightRail.ts b/frontend/src/types/rightRail.ts index 1897a7170..f29890adb 100644 --- a/frontend/src/types/rightRail.ts +++ b/frontend/src/types/rightRail.ts @@ -2,13 +2,23 @@ import React from 'react'; export type RightRailSection = 'top' | 'middle' | 'bottom'; +export type RightRailAction = () => void; + +export interface RightRailRenderContext { + id: string; + disabled: boolean; + allButtonsDisabled: boolean; + action?: RightRailAction; + triggerAction: () => void; +} + export interface RightRailButtonConfig { /** Unique id for the button, also used to bind action callbacks */ id: string; - /** Icon element to render */ - icon: React.ReactNode; + /** Icon element to render when using default renderer */ + icon?: React.ReactNode; /** Tooltip content (can be localized node) */ - tooltip: React.ReactNode; + tooltip?: React.ReactNode; /** Optional ARIA label for a11y (separate from visual tooltip) */ ariaLabel?: string; /** Optional i18n key carried by config */ @@ -21,6 +31,8 @@ export interface RightRailButtonConfig { disabled?: boolean; /** Initial visibility */ visible?: boolean; + /** Optional custom renderer for advanced layouts */ + render?: (ctx: RightRailRenderContext) => React.ReactNode; + /** Optional className applied to wrapper when using default renderer */ + className?: string; } - -export type RightRailAction = () => void;