mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2025-11-16 01:21:16 +01:00
Chore/v2/right rail cleanup (#4689)
# Description of Changes
- Migrated all dynamic right-rail controls into their owning views (file
editor, page editor, viewer) using dedicated helper hooks, so the rail
is
purely a renderer now.
- Tightened layout/animation: dynamic buttons grow in with a top-down
reveal
and center correctly.
---
## Checklist
### General
- [x] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [x] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [x] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings
### Documentation
- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)
### UI Changes (if applicable)
- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)
### Testing (if applicable)
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.
This commit is contained in:
parent
a8573c99b7
commit
5354f08766
@ -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);
|
||||
|
||||
@ -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<RightRailButtonWithAction[]>(() => [
|
||||
{
|
||||
id: 'file-select-all',
|
||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||
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: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
||||
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: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
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);
|
||||
}
|
||||
@ -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<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
setCsvInput('');
|
||||
}, [activeFilesSignature]);
|
||||
|
||||
// Grid container ref for positioning split indicators
|
||||
const gridContainerRef = useRef<HTMLDivElement>(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;
|
||||
|
||||
@ -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 (
|
||||
<Tooltip content={label} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<div className={`right-rail-fade enter`}>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={disabled || totalPages === 0}
|
||||
aria-label={label}
|
||||
>
|
||||
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={selectedPageIds}
|
||||
displayDocument={displayDocument}
|
||||
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@ -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(() => {
|
||||
|
||||
@ -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<RightRailButtonWithAction[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'page-select-all',
|
||||
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
|
||||
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: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
|
||||
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 }) => (
|
||||
<PageSelectByNumberButton
|
||||
disabled={disabled}
|
||||
totalPages={totalPages}
|
||||
label={selectByNumberLabel}
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={selectedPageIds}
|
||||
displayDocument={displayDocument}
|
||||
updatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'page-delete-selected',
|
||||
icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: deleteSelectedLabel,
|
||||
ariaLabel: deleteSelectedLabel,
|
||||
section: 'top' as const,
|
||||
order: 40,
|
||||
disabled: selectedPageCount === 0,
|
||||
visible: totalPages > 0,
|
||||
onClick: handleDelete,
|
||||
},
|
||||
{
|
||||
id: 'page-export-selected',
|
||||
icon: <LocalIcon icon="download" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: exportSelectedLabel,
|
||||
ariaLabel: exportSelectedLabel,
|
||||
section: 'top' as const,
|
||||
order: 50,
|
||||
disabled: selectedPageCount === 0 || exportLoading,
|
||||
visible: totalPages > 0,
|
||||
onClick: onExportSelected,
|
||||
},
|
||||
{
|
||||
id: 'page-close-pdf',
|
||||
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
|
||||
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);
|
||||
}
|
||||
@ -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 (
|
||||
<Tooltip content={tooltip} position="left" offset={12} arrow portalTarget={portalTarget}>
|
||||
<div className="right-rail-tooltip-wrapper">{node}</div>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
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<string>("");
|
||||
|
||||
// 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 = (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className={className}
|
||||
onClick={triggerAction}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
{btn.icon}
|
||||
</ActionIcon>
|
||||
);
|
||||
|
||||
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<boolean>(currentView === 'pageEditor');
|
||||
const [pageControlsVisible, setPageControlsVisible] = useState<boolean>(currentView === 'pageEditor');
|
||||
|
||||
useEffect(() => {
|
||||
if (currentView === 'pageEditor') {
|
||||
// Mount and show
|
||||
setPageControlsMounted(true);
|
||||
// Next tick to ensure transition applies
|
||||
requestAnimationFrame(() => setPageControlsVisible(true));
|
||||
} else {
|
||||
// Start exit animation
|
||||
setPageControlsVisible(false);
|
||||
// After transition, unmount to remove flex gap
|
||||
const timer = setTimeout(() => setPageControlsMounted(false), 240);
|
||||
return () => clearTimeout(timer);
|
||||
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 (
|
||||
<div ref={sidebarRefs.rightRailRef} className={`right-rail`} data-sidebar="right-rail">
|
||||
<div ref={sidebarRefs.rightRailRef} className="right-rail" data-sidebar="right-rail">
|
||||
<div className="right-rail-inner">
|
||||
{topButtons.length > 0 && !isCustomWorkbench && (
|
||||
<>
|
||||
{sectionsWithButtons.map(({ section, buttons: sectionButtons }) => (
|
||||
<React.Fragment key={section}>
|
||||
<div className="right-rail-section">
|
||||
{topButtons.map(btn => (
|
||||
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => actions[btn.id]?.()}
|
||||
disabled={btn.disabled || allButtonsDisabled || disableForFullscreen}
|
||||
{sectionButtons.map((btn, index) => {
|
||||
const content = renderButton(btn);
|
||||
if (!content) return null;
|
||||
return (
|
||||
<div
|
||||
key={btn.id}
|
||||
className="right-rail-button-wrapper"
|
||||
style={{ animationDelay: `${index * 50}ms` }}
|
||||
>
|
||||
{btn.icon}
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
))}
|
||||
{content}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Group: PDF Viewer Controls - visible only in viewer mode */}
|
||||
{!isCustomWorkbench && (
|
||||
<div
|
||||
className={`right-rail-slot ${currentView === 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||
aria-hidden={currentView !== 'viewer'}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
{/* Search */}
|
||||
<Tooltip content={t('rightRail.search', 'Search PDF')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.search', 'Search PDF') : 'Search PDF'}
|
||||
>
|
||||
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '20rem' }}>
|
||||
<SearchInterface
|
||||
visible={true}
|
||||
onClose={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
|
||||
|
||||
{/* Pan Mode */}
|
||||
<Tooltip content={t('rightRail.panMode', 'Pan Mode')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant={isPanning ? "filled" : "subtle"}
|
||||
color={isPanning ? "blue" : undefined}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.panActions.togglePan();
|
||||
setIsPanning(!isPanning);
|
||||
}}
|
||||
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
|
||||
>
|
||||
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Rotate Left */}
|
||||
<Tooltip content={t('rightRail.rotateLeft', 'Rotate Left')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.rotationActions.rotateBackward();
|
||||
}}
|
||||
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
|
||||
>
|
||||
<LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Rotate Right */}
|
||||
<Tooltip content={t('rightRail.rotateRight', 'Rotate Right')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.rotationActions.rotateForward();
|
||||
}}
|
||||
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
|
||||
>
|
||||
<LocalIcon icon="rotate-right" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Sidebar Toggle */}
|
||||
<Tooltip content={t('rightRail.toggleSidebar', 'Toggle Sidebar')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewerContext?.toggleThumbnailSidebar();
|
||||
}}
|
||||
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
|
||||
>
|
||||
<LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
{/* Annotation Controls */}
|
||||
<ViewerAnnotationControls
|
||||
currentView={currentView}
|
||||
disabled={currentView !== 'viewer' || allButtonsDisabled || disableForFullscreen}
|
||||
/>
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
||||
{!isCustomWorkbench && (
|
||||
<div
|
||||
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||
aria-hidden={currentView === 'viewer'}
|
||||
>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
{/* Select All Button */}
|
||||
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleSelectAll}
|
||||
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems || allButtonsDisabled || disableForFullscreen}
|
||||
>
|
||||
<LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Deselect All Button */}
|
||||
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleDeselectAll}
|
||||
disabled={currentView === 'viewer' || selectedCount === 0 || allButtonsDisabled || disableForFullscreen}
|
||||
>
|
||||
<LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
{/* Select by Numbers - page editor only, with animated presence */}
|
||||
{pageControlsMounted && (
|
||||
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
|
||||
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={!pageControlsVisible || totalItems === 0 || allButtonsDisabled || disableForFullscreen}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.selectByNumber', 'Select by Page Numbers') : 'Select by Page Numbers'}
|
||||
>
|
||||
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
|
||||
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPageIds={Array.isArray(pageEditorFunctions?.selectedPageIds) ? pageEditorFunctions.selectedPageIds : []}
|
||||
displayDocument={pageEditorFunctions?.displayDocument}
|
||||
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
)}
|
||||
|
||||
{/* Delete Selected Pages - page editor only, with animated presence */}
|
||||
{pageControlsMounted && (
|
||||
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
|
||||
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => { pageEditorFunctions?.handleDelete?.(); }}
|
||||
disabled={!pageControlsVisible || (pageEditorFunctions?.selectedPageIds?.length || 0) === 0 || allButtonsDisabled || disableForFullscreen}
|
||||
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
|
||||
>
|
||||
<LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
)}
|
||||
|
||||
{/* Export Selected Pages - page editor only */}
|
||||
{pageControlsMounted && (
|
||||
<Tooltip content={t('rightRail.exportSelected', 'Export Selected Pages')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => { pageEditorFunctions?.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'}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
|
||||
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.closeSelected', 'Close Selected Files')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={currentView === 'pageEditor' ? () => pageEditorFunctions?.closePdf?.() : handleCloseSelected}
|
||||
disabled={
|
||||
currentView === 'viewer' ||
|
||||
(currentView === 'fileEditor' && selectedCount === 0) ||
|
||||
(currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf)) ||
|
||||
allButtonsDisabled || disableForFullscreen
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme toggle and Language dropdown */}
|
||||
</React.Fragment>
|
||||
))}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow portalTarget={document.body}
|
||||
>
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
@ -464,34 +195,32 @@ export default function RightRail() {
|
||||
onClick={toggleTheme}
|
||||
>
|
||||
<LocalIcon icon="contrast" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</ActionIcon>,
|
||||
t('rightRail.toggleTheme', 'Toggle Theme')
|
||||
)}
|
||||
|
||||
<Tooltip content={t('rightRail.language', 'Language')} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
{renderWithTooltip(
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<LanguageSelector position="left-start" offset={6} compact />
|
||||
</div>
|
||||
</Tooltip>
|
||||
</div>,
|
||||
t('rightRail.language', 'Language')
|
||||
)}
|
||||
|
||||
<Tooltip content={
|
||||
currentView === 'pageEditor'
|
||||
? t('rightRail.exportAll', 'Export PDF')
|
||||
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
|
||||
} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<div>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleExportAll}
|
||||
disabled={
|
||||
disableForFullscreen || (currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled)
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{renderWithTooltip(
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={handleExportAll}
|
||||
disabled={
|
||||
disableForFullscreen ||
|
||||
(currentView === 'viewer' ? !exportState?.canExport : totalItems === 0 || allButtonsDisabled)
|
||||
}
|
||||
>
|
||||
<LocalIcon icon="download" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>,
|
||||
downloadTooltip
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="right-rail-spacer" />
|
||||
@ -499,4 +228,3 @@ export default function RightRail() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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 }) => (
|
||||
<Tooltip content={t('rightRail.search', 'Search PDF')}>
|
||||
<Popover position="left">
|
||||
<Popover.Target>
|
||||
<ActionIcon disabled={disabled}>
|
||||
<SearchIcon />
|
||||
</ActionIcon>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<SearchInterface />
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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();
|
||||
|
||||
125
frontend/src/components/viewer/useViewerRightRailButtons.tsx
Normal file
125
frontend/src/components/viewer/useViewerRightRailButtons.tsx
Normal file
@ -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<boolean>(() => 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<RightRailButtonWithAction[]>(() => {
|
||||
return [
|
||||
{
|
||||
id: 'viewer-search',
|
||||
tooltip: searchLabel,
|
||||
ariaLabel: searchLabel,
|
||||
section: 'top' as const,
|
||||
order: 10,
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={searchLabel} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<Popover position="left" withArrow shadow="md" offset={8}>
|
||||
<Popover.Target>
|
||||
<div style={{ display: 'inline-flex' }}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
disabled={disabled}
|
||||
aria-label={searchLabel}
|
||||
>
|
||||
<LocalIcon icon="search" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Popover.Target>
|
||||
<Popover.Dropdown>
|
||||
<div style={{ minWidth: '20rem' }}>
|
||||
<SearchInterface visible={true} onClose={() => {}} />
|
||||
</div>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'viewer-pan-mode',
|
||||
tooltip: panLabel,
|
||||
ariaLabel: panLabel,
|
||||
section: 'top' as const,
|
||||
order: 20,
|
||||
render: ({ disabled }) => (
|
||||
<Tooltip content={panLabel} position="left" offset={12} arrow portalTarget={document.body}>
|
||||
<ActionIcon
|
||||
variant={isPanning ? 'filled' : 'subtle'}
|
||||
color={isPanning ? 'blue' : undefined}
|
||||
radius="md"
|
||||
className="right-rail-icon"
|
||||
onClick={() => {
|
||||
viewer.panActions.togglePan();
|
||||
setIsPanning(prev => !prev);
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<LocalIcon icon="pan-tool-rounded" width="1.5rem" height="1.5rem" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'viewer-rotate-left',
|
||||
icon: <LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: rotateLeftLabel,
|
||||
ariaLabel: rotateLeftLabel,
|
||||
section: 'top' as const,
|
||||
order: 30,
|
||||
onClick: () => {
|
||||
viewer.rotationActions.rotateBackward();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'viewer-rotate-right',
|
||||
icon: <LocalIcon icon="rotate-right" width="1.5rem" height="1.5rem" />,
|
||||
tooltip: rotateRightLabel,
|
||||
ariaLabel: rotateRightLabel,
|
||||
section: 'top' as const,
|
||||
order: 40,
|
||||
onClick: () => {
|
||||
viewer.rotationActions.rotateForward();
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'viewer-toggle-sidebar',
|
||||
icon: <LocalIcon icon="view-list" width="1.5rem" height="1.5rem" />,
|
||||
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 }) => (
|
||||
<ViewerAnnotationControls currentView="viewer" disabled={disabled} />
|
||||
)
|
||||
}
|
||||
];
|
||||
}, [t, viewer, isPanning, searchLabel, panLabel, rotateLeftLabel, rotateRightLabel, sidebarLabel]);
|
||||
|
||||
useRightRailButtons(viewerButtons);
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
@ -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<string>();
|
||||
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);
|
||||
});
|
||||
|
||||
@ -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 */
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user