2025-08-22 16:51:24 +01:00
|
|
|
import React, { useCallback, useState, useEffect, useMemo } from 'react';
|
2025-08-22 13:09:44 +01:00
|
|
|
import { ActionIcon, Divider, Popover } from '@mantine/core';
|
|
|
|
import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
|
|
|
|
import './rightRail/RightRail.css';
|
|
|
|
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
|
|
|
import { useRightRail } from '../../contexts/RightRailContext';
|
2025-08-22 14:34:57 +01:00
|
|
|
import { useFileState, useFileSelection, useFileManagement } from '../../contexts/FileContext';
|
|
|
|
import { useNavigationState } from '../../contexts/NavigationContext';
|
2025-08-22 13:09:44 +01:00
|
|
|
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';
|
|
|
|
|
|
|
|
export default function RightRail() {
|
|
|
|
const { t } = useTranslation();
|
|
|
|
const { toggleTheme } = useRainbowThemeContext();
|
|
|
|
const { buttons, actions } = useRightRail();
|
2025-08-22 16:51:24 +01:00
|
|
|
const topButtons = useMemo(() => buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)), [buttons]);
|
2025-08-22 13:09:44 +01:00
|
|
|
|
|
|
|
// Access PageEditor functions for page-editor-specific actions
|
|
|
|
const { pageEditorFunctions } = useToolWorkflow();
|
|
|
|
|
|
|
|
// CSV input state for page selection
|
|
|
|
const [csvInput, setCsvInput] = useState<string>("");
|
|
|
|
|
2025-08-22 14:34:57 +01:00
|
|
|
// Navigation view
|
|
|
|
const { currentMode: currentView } = useNavigationState();
|
|
|
|
|
|
|
|
// File state and selection
|
|
|
|
const { state, selectors } = useFileState();
|
|
|
|
const { selectedFiles, selectedFileIds, selectedPageNumbers, setSelectedFiles, setSelectedPages } = useFileSelection();
|
|
|
|
const { removeFiles } = useFileManagement();
|
|
|
|
|
|
|
|
const activeFiles = selectors.getFiles();
|
2025-08-22 16:51:24 +01:00
|
|
|
const filesSignature = selectors.getFilesSignature();
|
2025-08-22 14:34:57 +01:00
|
|
|
const fileRecords = selectors.getFileRecords();
|
2025-08-22 13:09:44 +01:00
|
|
|
|
|
|
|
// Compute selection state and total items
|
|
|
|
const getSelectionState = useCallback(() => {
|
|
|
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
|
|
|
const totalItems = activeFiles.length;
|
|
|
|
const selectedCount = selectedFileIds.length;
|
|
|
|
return { totalItems, selectedCount };
|
|
|
|
}
|
|
|
|
|
|
|
|
if (currentView === 'pageEditor') {
|
|
|
|
let totalItems = 0;
|
2025-08-22 14:34:57 +01:00
|
|
|
fileRecords.forEach(rec => {
|
|
|
|
const pf = rec.processedFile;
|
|
|
|
if (pf) {
|
|
|
|
totalItems += (pf.totalPages as number) || (pf.pages?.length || 0);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
const selectedCount = Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length : 0;
|
2025-08-22 13:09:44 +01:00
|
|
|
return { totalItems, selectedCount };
|
|
|
|
}
|
|
|
|
|
|
|
|
return { totalItems: 0, selectedCount: 0 };
|
2025-08-22 14:34:57 +01:00
|
|
|
}, [currentView, activeFiles, fileRecords, selectedFileIds, selectedPageNumbers]);
|
2025-08-22 13:09:44 +01:00
|
|
|
|
|
|
|
const { totalItems, selectedCount } = getSelectionState();
|
|
|
|
|
|
|
|
const handleSelectAll = useCallback(() => {
|
|
|
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
2025-08-22 14:34:57 +01:00
|
|
|
// Select all file IDs
|
|
|
|
const allIds = state.files.ids;
|
2025-08-22 13:09:44 +01:00
|
|
|
setSelectedFiles(allIds);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (currentView === 'pageEditor') {
|
|
|
|
let totalPages = 0;
|
2025-08-22 14:34:57 +01:00
|
|
|
fileRecords.forEach(rec => {
|
|
|
|
const pf = rec.processedFile;
|
|
|
|
if (pf) {
|
|
|
|
totalPages += (pf.totalPages as number) || (pf.pages?.length || 0);
|
|
|
|
}
|
|
|
|
});
|
2025-08-22 13:09:44 +01:00
|
|
|
|
|
|
|
if (totalPages > 0) {
|
|
|
|
setSelectedPages(Array.from({ length: totalPages }, (_, i) => i + 1));
|
|
|
|
}
|
|
|
|
}
|
2025-08-22 14:34:57 +01:00
|
|
|
}, [currentView, state.files.ids, fileRecords, setSelectedFiles, setSelectedPages]);
|
2025-08-22 13:09:44 +01:00
|
|
|
|
|
|
|
const handleDeselectAll = useCallback(() => {
|
|
|
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
|
|
|
setSelectedFiles([]);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (currentView === 'pageEditor') {
|
|
|
|
setSelectedPages([]);
|
|
|
|
}
|
|
|
|
}, [currentView, setSelectedFiles, setSelectedPages]);
|
|
|
|
|
|
|
|
const handleExportAll = useCallback(() => {
|
|
|
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
|
|
|
// Download selected files (or all if none selected)
|
2025-08-22 14:34:57 +01:00
|
|
|
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
2025-08-22 13:09:44 +01:00
|
|
|
|
|
|
|
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?.();
|
|
|
|
}
|
2025-08-22 14:34:57 +01:00
|
|
|
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
|
2025-08-22 13:09:44 +01:00
|
|
|
|
|
|
|
const handleCloseSelected = useCallback(() => {
|
|
|
|
if (currentView !== 'fileEditor') return;
|
2025-08-22 14:34:57 +01:00
|
|
|
if (selectedFileIds.length === 0) return;
|
2025-08-22 13:09:44 +01:00
|
|
|
|
|
|
|
// Close only selected files (do not delete from storage)
|
2025-08-22 14:34:57 +01:00
|
|
|
removeFiles(selectedFileIds, false);
|
2025-08-22 13:09:44 +01:00
|
|
|
|
2025-08-22 14:34:57 +01:00
|
|
|
// Clear selection after closing
|
|
|
|
setSelectedFiles([]);
|
|
|
|
}, [currentView, selectedFileIds, removeFiles, setSelectedFiles]);
|
2025-08-22 13:09:44 +01:00
|
|
|
|
|
|
|
// CSV parsing functions for page selection
|
|
|
|
const parseCSVInput = useCallback((csv: string) => {
|
|
|
|
const pageNumbers: number[] = [];
|
|
|
|
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
|
|
|
|
|
|
|
|
ranges.forEach(range => {
|
|
|
|
if (range.includes('-')) {
|
|
|
|
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
|
|
if (i > 0) {
|
|
|
|
pageNumbers.push(i);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
const pageNum = parseInt(range);
|
|
|
|
if (pageNum > 0) {
|
|
|
|
pageNumbers.push(pageNum);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
});
|
|
|
|
|
|
|
|
return pageNumbers;
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
const updatePagesFromCSV = useCallback(() => {
|
2025-08-22 16:51:24 +01:00
|
|
|
const rawPages = parseCSVInput(csvInput);
|
|
|
|
// Determine max page count from processed records
|
|
|
|
const maxPages = fileRecords.reduce((sum, rec) => {
|
|
|
|
const pf = rec.processedFile;
|
|
|
|
if (!pf) return sum;
|
|
|
|
return sum + ((pf.totalPages as number) || (pf.pages?.length || 0));
|
|
|
|
}, 0);
|
|
|
|
const normalized = Array.from(new Set(rawPages.filter(n => Number.isFinite(n) && n > 0 && n <= maxPages))).sort((a,b)=>a-b);
|
|
|
|
setSelectedPages(normalized);
|
|
|
|
}, [csvInput, parseCSVInput, fileRecords, setSelectedPages]);
|
2025-08-22 13:09:44 +01:00
|
|
|
|
|
|
|
// Sync csvInput with selectedPageNumbers changes
|
|
|
|
useEffect(() => {
|
2025-08-22 14:34:57 +01:00
|
|
|
const sortedPageNumbers = Array.isArray(selectedPageNumbers)
|
|
|
|
? [...selectedPageNumbers].sort((a, b) => a - b)
|
|
|
|
: [];
|
2025-08-22 13:09:44 +01:00
|
|
|
const newCsvInput = sortedPageNumbers.join(', ');
|
|
|
|
setCsvInput(newCsvInput);
|
|
|
|
}, [selectedPageNumbers]);
|
|
|
|
|
2025-08-22 16:51:24 +01:00
|
|
|
// Clear CSV input when files change (use stable signature to avoid ref churn)
|
2025-08-22 13:09:44 +01:00
|
|
|
useEffect(() => {
|
|
|
|
setCsvInput("");
|
2025-08-22 16:51:24 +01:00
|
|
|
}, [filesSignature]);
|
2025-08-22 13:09:44 +01:00
|
|
|
|
|
|
|
// 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);
|
|
|
|
}
|
|
|
|
}, [currentView]);
|
|
|
|
|
|
|
|
return (
|
|
|
|
<div className="right-rail">
|
|
|
|
<div className="right-rail-inner">
|
|
|
|
{topButtons.length > 0 && (
|
|
|
|
<>
|
|
|
|
<div className="right-rail-section">
|
|
|
|
{topButtons.map(btn => (
|
|
|
|
<Tooltip key={btn.id} content={btn.tooltip} position="left" offset={12} arrow>
|
|
|
|
<ActionIcon
|
|
|
|
variant="subtle"
|
|
|
|
radius="md"
|
|
|
|
className="right-rail-icon"
|
|
|
|
onClick={() => actions[btn.id]?.()}
|
|
|
|
disabled={btn.disabled}
|
|
|
|
>
|
|
|
|
{btn.icon}
|
|
|
|
</ActionIcon>
|
|
|
|
</Tooltip>
|
|
|
|
))}
|
|
|
|
</div>
|
|
|
|
<Divider className="right-rail-divider" />
|
|
|
|
</>
|
|
|
|
)}
|
|
|
|
|
|
|
|
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
|
|
|
<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 */}
|
2025-08-22 16:51:24 +01:00
|
|
|
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow>
|
2025-08-22 13:09:44 +01:00
|
|
|
<div>
|
|
|
|
<ActionIcon
|
|
|
|
variant="subtle"
|
|
|
|
radius="md"
|
|
|
|
className="right-rail-icon"
|
|
|
|
onClick={handleSelectAll}
|
|
|
|
disabled={currentView === 'viewer' || totalItems === 0 || selectedCount === totalItems}
|
|
|
|
>
|
|
|
|
<span className="material-symbols-rounded">
|
|
|
|
select_all
|
|
|
|
</span>
|
|
|
|
</ActionIcon>
|
|
|
|
</div>
|
|
|
|
</Tooltip>
|
|
|
|
|
|
|
|
{/* Deselect All Button */}
|
2025-08-22 16:51:24 +01:00
|
|
|
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow>
|
2025-08-22 13:09:44 +01:00
|
|
|
<div>
|
|
|
|
<ActionIcon
|
|
|
|
variant="subtle"
|
|
|
|
radius="md"
|
|
|
|
className="right-rail-icon"
|
|
|
|
onClick={handleDeselectAll}
|
|
|
|
disabled={currentView === 'viewer' || selectedCount === 0}
|
|
|
|
>
|
|
|
|
<span className="material-symbols-rounded">
|
|
|
|
crop_square
|
|
|
|
</span>
|
|
|
|
</ActionIcon>
|
|
|
|
</div>
|
|
|
|
</Tooltip>
|
|
|
|
|
|
|
|
{/* Select by Numbers - page editor only, with animated presence */}
|
|
|
|
{pageControlsMounted && (
|
2025-08-22 16:51:24 +01:00
|
|
|
<Tooltip content={t('rightRail.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow>
|
2025-08-22 13:09:44 +01:00
|
|
|
|
|
|
|
<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}
|
2025-08-22 16:51:24 +01:00
|
|
|
aria-label={typeof t === 'function' ? t('rightRail.selectByNumber', 'Select by Page Numbers') : 'Select by Page Numbers'}
|
2025-08-22 13:09:44 +01:00
|
|
|
>
|
|
|
|
<span className="material-symbols-rounded">
|
|
|
|
pin_end
|
|
|
|
</span>
|
|
|
|
</ActionIcon>
|
|
|
|
</div>
|
|
|
|
</Popover.Target>
|
|
|
|
<Popover.Dropdown>
|
|
|
|
<div style={{ minWidth: 280 }}>
|
|
|
|
<BulkSelectionPanel
|
|
|
|
csvInput={csvInput}
|
|
|
|
setCsvInput={setCsvInput}
|
2025-08-22 14:34:57 +01:00
|
|
|
selectedPages={Array.isArray(selectedPageNumbers) ? selectedPageNumbers : []}
|
2025-08-22 13:09:44 +01:00
|
|
|
onUpdatePagesFromCSV={updatePagesFromCSV}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
</Popover.Dropdown>
|
|
|
|
</Popover>
|
|
|
|
</div>
|
|
|
|
</Tooltip>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
{/* Delete Selected Pages - page editor only, with animated presence */}
|
|
|
|
{pageControlsMounted && (
|
2025-08-22 16:51:24 +01:00
|
|
|
<Tooltip content={t('rightRail.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow>
|
2025-08-22 13:09:44 +01:00
|
|
|
|
|
|
|
<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"
|
2025-08-22 16:51:24 +01:00
|
|
|
onClick={() => { pageEditorFunctions?.handleDelete?.(); setSelectedPages([]); }}
|
2025-08-22 14:34:57 +01:00
|
|
|
disabled={!pageControlsVisible || (Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length === 0 : true)}
|
2025-08-22 16:51:24 +01:00
|
|
|
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
|
2025-08-22 13:09:44 +01:00
|
|
|
>
|
|
|
|
<span className="material-symbols-rounded">delete</span>
|
|
|
|
</ActionIcon>
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
</Tooltip>
|
|
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
|
2025-08-22 16:51:24 +01:00
|
|
|
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.downloadSelected', 'Download Selected Files')} position="left" offset={12} arrow>
|
2025-08-22 13:09:44 +01:00
|
|
|
<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))
|
|
|
|
}
|
|
|
|
>
|
|
|
|
<CloseRoundedIcon />
|
|
|
|
</ActionIcon>
|
|
|
|
</div>
|
|
|
|
</Tooltip>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<Divider className="right-rail-divider" />
|
|
|
|
</div>
|
|
|
|
|
|
|
|
{/* Theme toggle and Language dropdown */}
|
|
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
2025-08-22 16:51:24 +01:00
|
|
|
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow>
|
2025-08-22 13:09:44 +01:00
|
|
|
<ActionIcon
|
|
|
|
variant="subtle"
|
|
|
|
radius="md"
|
|
|
|
className="right-rail-icon"
|
|
|
|
onClick={toggleTheme}
|
|
|
|
>
|
|
|
|
<span className="material-symbols-rounded">contrast</span>
|
|
|
|
</ActionIcon>
|
|
|
|
</Tooltip>
|
|
|
|
|
|
|
|
<LanguageSelector position="left-start" offset={6} compact />
|
|
|
|
|
|
|
|
<Tooltip content={
|
|
|
|
currentView === 'pageEditor'
|
2025-08-22 16:51:24 +01:00
|
|
|
? t('rightRail.exportAll', 'Export PDF')
|
|
|
|
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
|
2025-08-22 13:09:44 +01:00
|
|
|
} position="left" offset={12} arrow>
|
|
|
|
<div>
|
|
|
|
<ActionIcon
|
|
|
|
variant="subtle"
|
|
|
|
radius="md"
|
|
|
|
className="right-rail-icon"
|
|
|
|
onClick={handleExportAll}
|
|
|
|
disabled={currentView === 'viewer' || totalItems === 0}
|
|
|
|
>
|
|
|
|
<span className="material-symbols-rounded">
|
|
|
|
download
|
|
|
|
</span>
|
|
|
|
</ActionIcon>
|
|
|
|
</div>
|
|
|
|
</Tooltip>
|
|
|
|
</div>
|
|
|
|
|
|
|
|
<div className="right-rail-spacer" />
|
|
|
|
</div>
|
|
|
|
</div>
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
|