mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-27 06:39:24 +00:00
386 lines
14 KiB
TypeScript
386 lines
14 KiB
TypeScript
![]() |
import React, { useCallback, useState, useEffect } from 'react';
|
||
|
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';
|
||
|
import { useFileContext } from '../../contexts/FileContext';
|
||
|
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();
|
||
|
const topButtons = buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true));
|
||
|
|
||
|
// Access PageEditor functions for page-editor-specific actions
|
||
|
const { pageEditorFunctions } = useToolWorkflow();
|
||
|
|
||
|
// CSV input state for page selection
|
||
|
const [csvInput, setCsvInput] = useState<string>("");
|
||
|
|
||
|
// File/page selection handlers that adapt to current view
|
||
|
const {
|
||
|
currentView,
|
||
|
activeFiles,
|
||
|
processedFiles,
|
||
|
selectedFileIds,
|
||
|
selectedPageNumbers,
|
||
|
setSelectedFiles,
|
||
|
setSelectedPages,
|
||
|
removeFiles
|
||
|
} = useFileContext();
|
||
|
|
||
|
// Compute selection state and total items
|
||
|
const getSelectionState = useCallback(() => {
|
||
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||
|
const totalItems = activeFiles.length;
|
||
|
const selectedCount = selectedFileIds.length;
|
||
|
return { totalItems, selectedCount };
|
||
|
}
|
||
|
|
||
|
if (currentView === 'pageEditor') {
|
||
|
let totalItems = 0;
|
||
|
if (activeFiles.length === 1) {
|
||
|
const pf = processedFiles.get(activeFiles[0]);
|
||
|
totalItems = (pf?.totalPages as number) || (pf?.pages?.length || 0);
|
||
|
} else if (activeFiles.length > 1) {
|
||
|
activeFiles.forEach(file => {
|
||
|
const pf = processedFiles.get(file);
|
||
|
totalItems += (pf?.totalPages as number) || (pf?.pages?.length || 0);
|
||
|
});
|
||
|
}
|
||
|
const selectedCount = selectedPageNumbers.length;
|
||
|
return { totalItems, selectedCount };
|
||
|
}
|
||
|
|
||
|
return { totalItems: 0, selectedCount: 0 };
|
||
|
}, [currentView, activeFiles, processedFiles, selectedFileIds, selectedPageNumbers]);
|
||
|
|
||
|
const { totalItems, selectedCount } = getSelectionState();
|
||
|
|
||
|
const handleSelectAll = useCallback(() => {
|
||
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||
|
const allIds = activeFiles.map(f => (f as any).id || f.name);
|
||
|
setSelectedFiles(allIds);
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
if (currentView === 'pageEditor') {
|
||
|
let totalPages = 0;
|
||
|
if (activeFiles.length === 1) {
|
||
|
const pf = processedFiles.get(activeFiles[0]);
|
||
|
totalPages = (pf?.totalPages as number) || (pf?.pages?.length || 0);
|
||
|
} else if (activeFiles.length > 1) {
|
||
|
activeFiles.forEach(file => {
|
||
|
const pf = processedFiles.get(file);
|
||
|
totalPages += (pf?.totalPages as number) || (pf?.pages?.length || 0);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
if (totalPages > 0) {
|
||
|
setSelectedPages(Array.from({ length: totalPages }, (_, i) => i + 1));
|
||
|
}
|
||
|
}
|
||
|
}, [currentView, activeFiles, processedFiles, setSelectedFiles, setSelectedPages]);
|
||
|
|
||
|
const handleDeselectAll = useCallback(() => {
|
||
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||
|
setSelectedFiles([]);
|
||
|
return;
|
||
|
}
|
||
|
if (currentView === 'pageEditor') {
|
||
|
setSelectedPages([]);
|
||
|
}
|
||
|
}, [currentView, setSelectedFiles, setSelectedPages]);
|
||
|
|
||
|
const handleExportAll = useCallback(() => {
|
||
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||
|
// Download selected files (or all if none selected)
|
||
|
const filesToDownload = selectedCount > 0
|
||
|
? activeFiles.filter(f => selectedFileIds.includes((f as any).id || f.name))
|
||
|
: activeFiles;
|
||
|
|
||
|
filesToDownload.forEach(file => {
|
||
|
const link = document.createElement('a');
|
||
|
link.href = URL.createObjectURL(file);
|
||
|
link.download = file.name;
|
||
|
document.body.appendChild(link);
|
||
|
link.click();
|
||
|
document.body.removeChild(link);
|
||
|
URL.revokeObjectURL(link.href);
|
||
|
});
|
||
|
} else if (currentView === 'pageEditor') {
|
||
|
// Export all pages (not just selected)
|
||
|
pageEditorFunctions?.onExportAll?.();
|
||
|
}
|
||
|
}, [currentView, selectedCount, activeFiles, selectedFileIds, pageEditorFunctions]);
|
||
|
|
||
|
const handleCloseSelected = useCallback(() => {
|
||
|
if (currentView !== 'fileEditor') return;
|
||
|
if (selectedCount === 0) return;
|
||
|
|
||
|
const fileIdsToClose = activeFiles.filter(f => selectedFileIds.includes((f as any).id || f.name))
|
||
|
.map(f => (f as any).id || f.name);
|
||
|
|
||
|
if (fileIdsToClose.length === 0) return;
|
||
|
|
||
|
// Close only selected files (do not delete from storage)
|
||
|
removeFiles(fileIdsToClose, false);
|
||
|
|
||
|
// Update selection state to remove closed ids
|
||
|
setSelectedFiles(selectedFileIds.filter(id => !fileIdsToClose.includes(id)));
|
||
|
}, [currentView, selectedCount, activeFiles, selectedFileIds, removeFiles, setSelectedFiles]);
|
||
|
|
||
|
// CSV parsing functions for page selection
|
||
|
const parseCSVInput = useCallback((csv: string) => {
|
||
|
const pageNumbers: number[] = [];
|
||
|
const ranges = csv.split(',').map(s => s.trim()).filter(Boolean);
|
||
|
|
||
|
ranges.forEach(range => {
|
||
|
if (range.includes('-')) {
|
||
|
const [start, end] = range.split('-').map(n => parseInt(n.trim()));
|
||
|
for (let i = start; i <= end; i++) {
|
||
|
if (i > 0) {
|
||
|
pageNumbers.push(i);
|
||
|
}
|
||
|
}
|
||
|
} else {
|
||
|
const pageNum = parseInt(range);
|
||
|
if (pageNum > 0) {
|
||
|
pageNumbers.push(pageNum);
|
||
|
}
|
||
|
}
|
||
|
});
|
||
|
|
||
|
return pageNumbers;
|
||
|
}, []);
|
||
|
|
||
|
const updatePagesFromCSV = useCallback(() => {
|
||
|
const pageNumbers = parseCSVInput(csvInput);
|
||
|
setSelectedPages(pageNumbers);
|
||
|
}, [csvInput, parseCSVInput, setSelectedPages]);
|
||
|
|
||
|
// Sync csvInput with selectedPageNumbers changes
|
||
|
useEffect(() => {
|
||
|
const sortedPageNumbers = [...selectedPageNumbers].sort((a, b) => a - b);
|
||
|
const newCsvInput = sortedPageNumbers.join(', ');
|
||
|
setCsvInput(newCsvInput);
|
||
|
}, [selectedPageNumbers]);
|
||
|
|
||
|
// Clear CSV input when files change
|
||
|
useEffect(() => {
|
||
|
setCsvInput("");
|
||
|
}, [activeFiles]);
|
||
|
|
||
|
// Mount/visibility for page-editor-only buttons to allow exit animation, then remove to avoid flex gap
|
||
|
const [pageControlsMounted, setPageControlsMounted] = useState<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 */}
|
||
|
<Tooltip content={t('pageEdit.selectAll', 'Select All')} position="left" offset={12} arrow>
|
||
|
<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 */}
|
||
|
<Tooltip content={t('pageEdit.deselectAll', 'Deselect All')} position="left" offset={12} arrow>
|
||
|
<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 && (
|
||
|
<Tooltip content={t('pageEdit.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow>
|
||
|
|
||
|
<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}
|
||
|
>
|
||
|
<span className="material-symbols-rounded">
|
||
|
pin_end
|
||
|
</span>
|
||
|
</ActionIcon>
|
||
|
</div>
|
||
|
</Popover.Target>
|
||
|
<Popover.Dropdown>
|
||
|
<div style={{ minWidth: 280 }}>
|
||
|
<BulkSelectionPanel
|
||
|
csvInput={csvInput}
|
||
|
setCsvInput={setCsvInput}
|
||
|
selectedPages={selectedPageNumbers}
|
||
|
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||
|
/>
|
||
|
</div>
|
||
|
</Popover.Dropdown>
|
||
|
</Popover>
|
||
|
</div>
|
||
|
</Tooltip>
|
||
|
|
||
|
)}
|
||
|
|
||
|
{/* Delete Selected Pages - page editor only, with animated presence */}
|
||
|
{pageControlsMounted && (
|
||
|
<Tooltip content={t('pageEdit.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow>
|
||
|
|
||
|
<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 || selectedCount === 0}
|
||
|
>
|
||
|
<span className="material-symbols-rounded">delete</span>
|
||
|
</ActionIcon>
|
||
|
</div>
|
||
|
</div>
|
||
|
</Tooltip>
|
||
|
|
||
|
)}
|
||
|
|
||
|
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
|
||
|
<Tooltip content={currentView === 'pageEditor' ? 'Close PDF' : 'Close Selected Files'} position="left" offset={12} arrow>
|
||
|
<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' }}>
|
||
|
<Tooltip content={t('app.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow>
|
||
|
<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'
|
||
|
? 'Export All Pages'
|
||
|
: (selectedCount > 0 ? 'Download Selected Files' : 'Download All')
|
||
|
} 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>
|
||
|
);
|
||
|
}
|
||
|
|
||
|
|