mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
Pushing to merge Reeces changes. Added the right rail and made minimal changes to the selection logic on page editor, file manager and thumbnail
This commit is contained in:
parent
cedfad0979
commit
3f89087f12
@ -1,7 +1,6 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
|
||||
Stack, Group
|
||||
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
|
||||
} from '@mantine/core';
|
||||
import { Dropzone } from '@mantine/dropzone';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@ -679,17 +678,6 @@ const FileEditor = ({
|
||||
<LoadingOverlay visible={false} />
|
||||
|
||||
<Box p="md" pt="xl">
|
||||
<Group mb="md">
|
||||
{showBulkActions && !toolMode && (
|
||||
<>
|
||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
||||
<Button onClick={closeAllFiles} variant="light" color="orange">
|
||||
Close All
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
|
||||
{files.length === 0 && !localLoading && !zipExtractionProgress.isExtracting ? (
|
||||
@ -828,25 +816,29 @@ const FileEditor = ({
|
||||
/>
|
||||
|
||||
{status && (
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
<Portal>
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10001 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
</Portal>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Notification
|
||||
color="red"
|
||||
mt="md"
|
||||
onClose={() => setError(null)}
|
||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
<Portal>
|
||||
<Notification
|
||||
color="red"
|
||||
mt="md"
|
||||
onClose={() => setError(null)}
|
||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
|
||||
>
|
||||
{error}
|
||||
</Notification>
|
||||
</Portal>
|
||||
)}
|
||||
</Box>
|
||||
</Dropzone>
|
||||
|
@ -151,6 +151,7 @@ export default function Workbench() {
|
||||
className="flex-1 min-h-0 relative z-10"
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
marginTop: '1rem',
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
|
||||
import { Group, TextInput, Button, Text } from '@mantine/core';
|
||||
|
||||
interface BulkSelectionPanelProps {
|
||||
csvInput: string;
|
||||
@ -15,7 +15,7 @@ const BulkSelectionPanel = ({
|
||||
onUpdatePagesFromCSV,
|
||||
}: BulkSelectionPanelProps) => {
|
||||
return (
|
||||
<Paper p="md" mb="md" withBorder>
|
||||
<>
|
||||
<Group>
|
||||
<TextInput
|
||||
value={csvInput}
|
||||
@ -35,7 +35,7 @@ const BulkSelectionPanel = ({
|
||||
Selected: {selectedPages.length} pages
|
||||
</Text>
|
||||
)}
|
||||
</Paper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
@ -130,7 +130,6 @@ const FileThumbnail = ({
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, file.id)}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div
|
||||
className={styles.checkboxContainer}
|
||||
data-testid="file-thumbnail-checkbox"
|
||||
@ -164,7 +163,6 @@ const FileThumbnail = ({
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File content area */}
|
||||
<div className="file-container w-[90%] h-[80%] relative">
|
||||
|
@ -1,14 +1,12 @@
|
||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import {
|
||||
Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon,
|
||||
Button, Text, Center, Box,
|
||||
Notification, TextInput, LoadingOverlay, Modal, Alert,
|
||||
Stack, Group
|
||||
Stack, Group, Portal
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useFileContext, useCurrentFile } from "../../contexts/FileContext";
|
||||
import { ViewType, ToolType } from "../../types/fileContext";
|
||||
import { PDFDocument, PDFPage } from "../../types/pageEditor";
|
||||
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
|
||||
import { useUndoRedo } from "../../hooks/useUndoRedo";
|
||||
import {
|
||||
RotatePagesCommand,
|
||||
@ -51,11 +49,9 @@ export interface PageEditorProps {
|
||||
const PageEditor = ({
|
||||
onFunctionsReady,
|
||||
}: PageEditorProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Get file context
|
||||
const fileContext = useFileContext();
|
||||
const { file: currentFile, processedFile: currentProcessedFile } = useCurrentFile();
|
||||
|
||||
// Use file context state
|
||||
const {
|
||||
@ -160,10 +156,7 @@ const PageEditor = ({
|
||||
|
||||
// Animation state
|
||||
const [movingPage, setMovingPage] = useState<number | null>(null);
|
||||
const [pagePositions, setPagePositions] = useState<Map<string, { x: number; y: number }>>(new Map());
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const pageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const fileInputRef = useRef<() => void>(null);
|
||||
|
||||
// Undo/Redo system
|
||||
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
|
||||
@ -204,8 +197,6 @@ const PageEditor = ({
|
||||
generateThumbnails,
|
||||
addThumbnailToCache,
|
||||
getThumbnailFromCache,
|
||||
stopGeneration,
|
||||
destroyThumbnails
|
||||
} = useThumbnailGeneration();
|
||||
|
||||
// Start thumbnail generation process (separate from document loading)
|
||||
@ -813,14 +804,18 @@ const PageEditor = ({
|
||||
const handleDelete = useCallback(() => {
|
||||
if (!displayDocument) return;
|
||||
|
||||
const pagesToDelete = selectionMode
|
||||
? selectedPageNumbers.map(pageNum => {
|
||||
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
|
||||
return page?.id || '';
|
||||
}).filter(id => id)
|
||||
const hasSelectedPages = selectedPageNumbers.length > 0;
|
||||
|
||||
const pagesToDelete = (selectionMode || hasSelectedPages)
|
||||
? selectedPageNumbers
|
||||
.map(pageNum => {
|
||||
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
|
||||
return page?.id || '';
|
||||
})
|
||||
.filter(id => id)
|
||||
: displayDocument.pages.map(p => p.id);
|
||||
|
||||
if (selectionMode && selectedPageNumbers.length === 0) return;
|
||||
if ((selectionMode || hasSelectedPages) && selectedPageNumbers.length === 0) return;
|
||||
|
||||
const command = new DeletePagesCommand(
|
||||
displayDocument,
|
||||
@ -829,10 +824,10 @@ const PageEditor = ({
|
||||
);
|
||||
|
||||
executeCommand(command);
|
||||
if (selectionMode) {
|
||||
if (selectionMode || hasSelectedPages) {
|
||||
setSelectedPages([]);
|
||||
}
|
||||
const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length;
|
||||
const pageCount = (selectionMode || hasSelectedPages) ? selectedPageNumbers.length : displayDocument.pages.length;
|
||||
setStatus(`Deleted ${pageCount} pages`);
|
||||
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, setSelectedPages]);
|
||||
|
||||
@ -1181,58 +1176,12 @@ const PageEditor = ({
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Group mb="md">
|
||||
<TextInput
|
||||
value={filename}
|
||||
onChange={(e) => setFilename(e.target.value)}
|
||||
placeholder="Enter filename"
|
||||
style={{ minWidth: 200 }}
|
||||
/>
|
||||
<Button
|
||||
onClick={toggleSelectionMode}
|
||||
variant={selectionMode ? "filled" : "outline"}
|
||||
color={selectionMode ? "blue" : "gray"}
|
||||
styles={{
|
||||
root: {
|
||||
transition: 'all 0.2s ease',
|
||||
...(selectionMode && {
|
||||
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
{selectionMode ? "Exit Selection" : "Select Pages"}
|
||||
</Button>
|
||||
{selectionMode && (
|
||||
<>
|
||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Apply Changes Button */}
|
||||
{hasUnsavedChanges && (
|
||||
<Button
|
||||
onClick={applyChanges}
|
||||
color="green"
|
||||
variant="filled"
|
||||
style={{ marginLeft: 'auto' }}
|
||||
>
|
||||
Apply Changes
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{selectionMode && (
|
||||
<BulkSelectionPanel
|
||||
csvInput={csvInput}
|
||||
setCsvInput={setCsvInput}
|
||||
selectedPages={selectedPageNumbers}
|
||||
onUpdatePagesFromCSV={updatePagesFromCSV}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
<DragDropGrid
|
||||
items={displayedPages}
|
||||
@ -1399,14 +1348,16 @@ const PageEditor = ({
|
||||
</Modal>
|
||||
|
||||
{status && (
|
||||
<Portal>
|
||||
<Notification
|
||||
color="blue"
|
||||
mt="md"
|
||||
onClose={() => setStatus(null)}
|
||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10000 }}
|
||||
>
|
||||
{status}
|
||||
</Notification>
|
||||
</Portal>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
@ -7,10 +7,8 @@ import {
|
||||
import UndoIcon from "@mui/icons-material/Undo";
|
||||
import RedoIcon from "@mui/icons-material/Redo";
|
||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||
import DownloadIcon from "@mui/icons-material/Download";
|
||||
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||
import DeleteIcon from "@mui/icons-material/Delete";
|
||||
import CloseIcon from "@mui/icons-material/Close";
|
||||
|
||||
interface PageEditorControlsProps {
|
||||
@ -57,9 +55,9 @@ const PageEditorControls = ({
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
bottom: '20px',
|
||||
transform: 'translateX(-50%)',
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
zIndex: 50,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
@ -67,19 +65,26 @@ const PageEditorControls = ({
|
||||
background: 'transparent',
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
radius="xl"
|
||||
shadow="lg"
|
||||
p={16}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
borderRadius: 32,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
||||
borderTopLeftRadius: 16,
|
||||
borderTopRightRadius: 16,
|
||||
borderBottomLeftRadius: 0,
|
||||
borderBottomRightRadius: 0,
|
||||
boxShadow: '0 -2px 8px rgba(0,0,0,0.04)',
|
||||
backgroundColor: 'var(--bg-toolbar)',
|
||||
border: '1px solid var(--border-default)',
|
||||
borderRadius: '16px 16px 0 0',
|
||||
pointerEvents: 'auto',
|
||||
minWidth: 400,
|
||||
justifyContent: 'center'
|
||||
minWidth: 420,
|
||||
maxWidth: 700,
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'center',
|
||||
padding: "1rem",
|
||||
paddingBottom: "2rem"
|
||||
}}
|
||||
>
|
||||
{/* Close PDF */}
|
||||
@ -133,17 +138,6 @@ const PageEditorControls = ({
|
||||
<RotateRightIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={selectionMode ? "Delete Selected" : "Delete All"}>
|
||||
<ActionIcon
|
||||
onClick={onDelete}
|
||||
disabled={selectionMode && selectedPages.length === 0}
|
||||
color="red"
|
||||
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
|
||||
size="lg"
|
||||
>
|
||||
<DeleteIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={selectionMode ? "Split Selected" : "Split All"}>
|
||||
<ActionIcon
|
||||
onClick={onSplit}
|
||||
@ -156,34 +150,7 @@ const PageEditorControls = ({
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
||||
|
||||
{/* Export Controls */}
|
||||
{selectionMode && selectedPages.length > 0 && (
|
||||
<Tooltip label="Export Selected">
|
||||
<ActionIcon
|
||||
onClick={onExportSelected}
|
||||
disabled={exportLoading}
|
||||
color="blue"
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip label="Export All">
|
||||
<ActionIcon
|
||||
onClick={onExportAll}
|
||||
disabled={exportLoading}
|
||||
color="green"
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
<DownloadIcon />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Paper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -167,7 +167,7 @@ const PageThumbnail = React.memo(({
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, page.pageNumber)}
|
||||
>
|
||||
{selectionMode && (
|
||||
{
|
||||
<div
|
||||
className={styles.checkboxContainer}
|
||||
style={{
|
||||
@ -175,10 +175,9 @@ const PageThumbnail = React.memo(({
|
||||
top: 8,
|
||||
right: 8,
|
||||
zIndex: 10,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
border: '1px solid #ccc',
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '4px',
|
||||
padding: '4px',
|
||||
padding: '2px',
|
||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||
pointerEvents: 'auto',
|
||||
cursor: 'pointer'
|
||||
@ -202,7 +201,7 @@ const PageThumbnail = React.memo(({
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
}
|
||||
|
||||
<div className="page-container w-[90%] h-[90%]">
|
||||
<div
|
||||
|
@ -1,11 +1,17 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Menu, Button, ScrollArea } from '@mantine/core';
|
||||
import { Menu, Button, ScrollArea, ActionIcon } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { supportedLanguages } from '../../i18n';
|
||||
import LanguageIcon from '@mui/icons-material/Language';
|
||||
import styles from './LanguageSelector.module.css';
|
||||
|
||||
const LanguageSelector = () => {
|
||||
interface LanguageSelectorProps {
|
||||
position?: React.ComponentProps<typeof Menu>['position'];
|
||||
offset?: number;
|
||||
compact?: boolean; // icon-only trigger
|
||||
}
|
||||
|
||||
const LanguageSelector = ({ position = 'bottom-start', offset = 8, compact = false }: LanguageSelectorProps) => {
|
||||
const { i18n } = useTranslation();
|
||||
const [opened, setOpened] = useState(false);
|
||||
const [animationTriggered, setAnimationTriggered] = useState(false);
|
||||
@ -21,26 +27,27 @@ const LanguageSelector = () => {
|
||||
}));
|
||||
|
||||
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
|
||||
// Create ripple effect at click position
|
||||
const rect = event.currentTarget.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
setRippleEffect({ x, y, key: Date.now() });
|
||||
|
||||
// Create ripple effect at click position (only for button mode)
|
||||
if (!compact) {
|
||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
setRippleEffect({ x, y, key: Date.now() });
|
||||
}
|
||||
|
||||
// Start transition animation
|
||||
setIsChanging(true);
|
||||
setPendingLanguage(value);
|
||||
|
||||
|
||||
// Simulate processing time for smooth transition
|
||||
setTimeout(() => {
|
||||
i18n.changeLanguage(value);
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
setIsChanging(false);
|
||||
setPendingLanguage(null);
|
||||
setOpened(false);
|
||||
|
||||
|
||||
// Clear ripple effect
|
||||
setTimeout(() => setRippleEffect(null), 100);
|
||||
}, 300);
|
||||
@ -64,19 +71,9 @@ const LanguageSelector = () => {
|
||||
<style>
|
||||
{`
|
||||
@keyframes ripple-expand {
|
||||
0% {
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0.6;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
100% {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
opacity: 0;
|
||||
}
|
||||
0% { width: 0; height: 0; opacity: 0.6; }
|
||||
50% { opacity: 0.3; }
|
||||
100% { width: 100px; height: 100px; opacity: 0; }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
@ -84,8 +81,8 @@ const LanguageSelector = () => {
|
||||
opened={opened}
|
||||
onChange={setOpened}
|
||||
width={600}
|
||||
position="bottom-start"
|
||||
offset={8}
|
||||
position={position}
|
||||
offset={offset}
|
||||
transitionProps={{
|
||||
transition: 'scale-y',
|
||||
duration: 200,
|
||||
@ -93,29 +90,45 @@ const LanguageSelector = () => {
|
||||
}}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
|
||||
styles={{
|
||||
root: {
|
||||
border: 'none',
|
||||
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
|
||||
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||
{compact ? (
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
radius="md"
|
||||
title={currentLanguage}
|
||||
className="right-rail-icon"
|
||||
styles={{
|
||||
root: {
|
||||
color: 'var(--right-rail-icon)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||
}
|
||||
}
|
||||
},
|
||||
label: {
|
||||
fontSize: '12px',
|
||||
fontWeight: 500,
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className={styles.languageText}>
|
||||
{currentLanguage}
|
||||
</span>
|
||||
</Button>
|
||||
}}
|
||||
>
|
||||
<span className="material-symbols-rounded">language</span>
|
||||
</ActionIcon>
|
||||
) : (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
leftSection={<LanguageIcon style={{ fontSize: 18 }} />}
|
||||
styles={{
|
||||
root: {
|
||||
border: 'none',
|
||||
color: 'light-dark(var(--mantine-color-gray-7), var(--mantine-color-gray-1))',
|
||||
transition: 'background-color 0.2s cubic-bezier(0.25, 0.46, 0.45, 0.94)',
|
||||
'&:hover': {
|
||||
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||
}
|
||||
},
|
||||
label: { fontSize: '12px', fontWeight: 500 }
|
||||
}}
|
||||
>
|
||||
<span className={styles.languageText}>
|
||||
{currentLanguage}
|
||||
</span>
|
||||
</Button>
|
||||
)}
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown
|
||||
@ -181,9 +194,7 @@ const LanguageSelector = () => {
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
|
||||
{/* Ripple effect */}
|
||||
{rippleEffect && pendingLanguage === option.value && (
|
||||
{!compact && rippleEffect && pendingLanguage === option.value && (
|
||||
<div
|
||||
key={rippleEffect.key}
|
||||
style={{
|
||||
|
385
frontend/src/components/shared/RightRail.tsx
Normal file
385
frontend/src/components/shared/RightRail.tsx
Normal file
@ -0,0 +1,385 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -124,8 +124,8 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
if (sidebarTooltip) return null;
|
||||
|
||||
switch (position) {
|
||||
case 'top': return "tooltip-arrow tooltip-arrow-top";
|
||||
case 'bottom': return "tooltip-arrow tooltip-arrow-bottom";
|
||||
case 'top': return "tooltip-arrow tooltip-arrow-bottom";
|
||||
case 'bottom': return "tooltip-arrow tooltip-arrow-top";
|
||||
case 'left': return "tooltip-arrow tooltip-arrow-left";
|
||||
case 'right': return "tooltip-arrow tooltip-arrow-right";
|
||||
default: return "tooltip-arrow tooltip-arrow-right";
|
||||
@ -150,7 +150,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
|
||||
position: 'fixed',
|
||||
top: coords.top,
|
||||
left: coords.left,
|
||||
width: (maxWidth !== undefined ? maxWidth : '25rem'),
|
||||
width: (maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' : undefined)),
|
||||
minWidth: minWidth,
|
||||
zIndex: 9999,
|
||||
visibility: positionReady ? 'visible' : 'hidden',
|
||||
|
@ -1,51 +1,49 @@
|
||||
import React, { useState, useCallback } from "react";
|
||||
import { Button, SegmentedControl, Loader } from "@mantine/core";
|
||||
import { SegmentedControl, Loader } from "@mantine/core";
|
||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||
import LanguageSelector from "./LanguageSelector";
|
||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import DarkModeIcon from '@mui/icons-material/DarkMode';
|
||||
import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
import AutoAwesomeIcon from '@mui/icons-material/AutoAwesome';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import { Group } from "@mantine/core";
|
||||
|
||||
// This will be created inside the component to access switchingTo
|
||||
// Create view options with icons and loading states
|
||||
const createViewOptions = (switchingTo: string | null) => [
|
||||
{
|
||||
label: (
|
||||
<Group gap={5}>
|
||||
<div style={{ display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: 6, whiteSpace: 'nowrap'}}>
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
)}
|
||||
</Group>
|
||||
<span>Read</span>
|
||||
</div>
|
||||
),
|
||||
value: "viewer",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Group gap={4}>
|
||||
<div style={{ display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}>
|
||||
{switchingTo === "pageEditor" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<EditNoteIcon fontSize="small" />
|
||||
)}
|
||||
</Group>
|
||||
<span>Page Editor</span>
|
||||
</div>
|
||||
),
|
||||
value: "pageEditor",
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Group gap={4}>
|
||||
<div style={{ display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}>
|
||||
{switchingTo === "fileEditor" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<FolderIcon fontSize="small" />
|
||||
)}
|
||||
</Group>
|
||||
<span>File Manager</span>
|
||||
</div>
|
||||
),
|
||||
value: "fileEditor",
|
||||
},
|
||||
@ -62,7 +60,7 @@ const TopControls = ({
|
||||
setCurrentView,
|
||||
selectedToolKey,
|
||||
}: TopControlsProps) => {
|
||||
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<string | null>(null);
|
||||
|
||||
const isToolSelected = selectedToolKey !== null;
|
||||
@ -83,52 +81,33 @@ const TopControls = ({
|
||||
});
|
||||
}, [setCurrentView]);
|
||||
|
||||
const getThemeIcon = () => {
|
||||
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
|
||||
if (themeMode === "dark") return <LightModeIcon />;
|
||||
return <DarkModeIcon />;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className={`absolute left-4 pointer-events-auto flex gap-2 items-center ${
|
||||
isToolSelected ? 'top-4' : 'top-1/2 -translate-y-1/2'
|
||||
}`}>
|
||||
<Button
|
||||
onClick={toggleTheme}
|
||||
variant="subtle"
|
||||
size="md"
|
||||
aria-label="Toggle theme"
|
||||
disabled={isToggleDisabled}
|
||||
className={isRainbowMode ? rainbowStyles.rainbowButton : ''}
|
||||
title={
|
||||
isToggleDisabled
|
||||
? "Button disabled for 3 seconds..."
|
||||
: isRainbowMode
|
||||
? "Rainbow Mode Active! Click to exit"
|
||||
: "Toggle theme (click rapidly 6 times for a surprise!)"
|
||||
}
|
||||
style={isToggleDisabled ? { opacity: 0.5, cursor: 'not-allowed' } : {}}
|
||||
>
|
||||
{getThemeIcon()}
|
||||
</Button>
|
||||
<LanguageSelector />
|
||||
</div>
|
||||
{!isToolSelected && (
|
||||
<div className="flex justify-center items-center h-full pointer-events-auto">
|
||||
<div className="flex justify-center items-center h-full pointer-events-auto mt-[0.5rem] rounded-full">
|
||||
<SegmentedControl
|
||||
data={createViewOptions(switchingTo)}
|
||||
value={currentView}
|
||||
onChange={handleViewChange}
|
||||
color="blue"
|
||||
radius="xl"
|
||||
size="md"
|
||||
fullWidth
|
||||
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
|
||||
style={{
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: switchingTo ? 0.8 : 1,
|
||||
}}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 9999,
|
||||
},
|
||||
control: {
|
||||
borderRadius: 9999,
|
||||
},
|
||||
indicator: {
|
||||
borderRadius: 9999,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
108
frontend/src/components/shared/rightRail/RightRail.README.md
Normal file
108
frontend/src/components/shared/rightRail/RightRail.README.md
Normal file
@ -0,0 +1,108 @@
|
||||
# RightRail Component
|
||||
|
||||
A dynamic vertical toolbar on the right side of the application that supports both static buttons (Undo/Redo, Save, Print, Share) and dynamic buttons registered by tools.
|
||||
|
||||
## Structure
|
||||
|
||||
- **Top Section**: Dynamic buttons from tools (empty when none)
|
||||
- **Middle Section**: Grid, Cut, Undo, Redo
|
||||
- **Bottom Section**: Save, Print, Share
|
||||
|
||||
## Usage
|
||||
|
||||
### For Tools (Recommended)
|
||||
|
||||
```tsx
|
||||
import { useRightRailButtons } from '../hooks/useRightRailButtons';
|
||||
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
|
||||
|
||||
function MyTool() {
|
||||
const handleAction = useCallback(() => {
|
||||
// Your action here
|
||||
}, []);
|
||||
|
||||
useRightRailButtons([
|
||||
{
|
||||
id: 'my-action',
|
||||
icon: <PlayArrowIcon />,
|
||||
tooltip: 'Execute Action',
|
||||
onClick: handleAction,
|
||||
},
|
||||
]);
|
||||
|
||||
return <div>My Tool</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple Buttons
|
||||
|
||||
```tsx
|
||||
useRightRailButtons([
|
||||
{
|
||||
id: 'primary',
|
||||
icon: <StarIcon />,
|
||||
tooltip: 'Primary Action',
|
||||
order: 1,
|
||||
onClick: handlePrimary,
|
||||
},
|
||||
{
|
||||
id: 'secondary',
|
||||
icon: <SettingsIcon />,
|
||||
tooltip: 'Secondary Action',
|
||||
order: 2,
|
||||
onClick: handleSecondary,
|
||||
},
|
||||
]);
|
||||
```
|
||||
|
||||
### Conditional Buttons
|
||||
|
||||
```tsx
|
||||
useRightRailButtons([
|
||||
// Always show
|
||||
{
|
||||
id: 'process',
|
||||
icon: <PlayArrowIcon />,
|
||||
tooltip: 'Process',
|
||||
disabled: isProcessing,
|
||||
onClick: handleProcess,
|
||||
},
|
||||
// Only show when condition met
|
||||
...(hasResults ? [{
|
||||
id: 'export',
|
||||
icon: <DownloadIcon />,
|
||||
tooltip: 'Export',
|
||||
onClick: handleExport,
|
||||
}] : []),
|
||||
]);
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
### Button Config
|
||||
|
||||
```typescript
|
||||
interface RightRailButtonWithAction {
|
||||
id: string; // Unique identifier
|
||||
icon: React.ReactNode; // Icon component
|
||||
tooltip: string; // Hover tooltip
|
||||
section?: 'top' | 'middle' | 'bottom'; // Section (default: 'top')
|
||||
order?: number; // Sort order (default: 0)
|
||||
disabled?: boolean; // Disabled state (default: false)
|
||||
visible?: boolean; // Visibility (default: true)
|
||||
onClick: () => void; // Click handler
|
||||
}
|
||||
```
|
||||
|
||||
## Built-in Features
|
||||
|
||||
- **Undo/Redo**: Automatically integrates with Page Editor
|
||||
- **Theme Support**: Light/dark mode with CSS variables
|
||||
- **Auto Cleanup**: Buttons unregister when tool unmounts
|
||||
|
||||
## Best Practices
|
||||
|
||||
- Use descriptive IDs: `'compress-optimize'`, `'ocr-process'`
|
||||
- Choose appropriate Material-UI icons
|
||||
- Keep tooltips concise: `'Compress PDF'`, `'Process with OCR'`
|
||||
- Use `useCallback` for click handlers to prevent re-registration
|
127
frontend/src/components/shared/rightRail/RightRail.css
Normal file
127
frontend/src/components/shared/rightRail/RightRail.css
Normal file
@ -0,0 +1,127 @@
|
||||
.right-rail {
|
||||
background-color: var(--right-rail-bg);
|
||||
width: 3.5rem;
|
||||
min-width: 3.5rem;
|
||||
max-width: 3.5rem;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
border-left: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.right-rail-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 0.5rem;
|
||||
}
|
||||
|
||||
.right-rail-section {
|
||||
background-color: var(--right-rail-foreground);
|
||||
border-radius: 12px;
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.right-rail-divider {
|
||||
width: 2.75rem;
|
||||
border: none;
|
||||
border-top: 1px solid var(--tool-subcategory-rule-color);
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.right-rail-icon {
|
||||
color: var(--right-rail-icon);
|
||||
}
|
||||
|
||||
.right-rail-icon[aria-disabled="true"],
|
||||
.right-rail-icon[disabled] {
|
||||
color: var(--right-rail-icon-disabled) !important;
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.right-rail-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Animated grow-down slot for buttons (mirrors current-tool-slot behavior) */
|
||||
.right-rail-slot {
|
||||
overflow: hidden;
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
transition: max-height 450ms ease-out, opacity 300ms ease-out;
|
||||
}
|
||||
|
||||
.right-rail-enter {
|
||||
animation: rightRailGrowDown 450ms ease-out;
|
||||
}
|
||||
|
||||
.right-rail-exit {
|
||||
animation: rightRailShrinkUp 450ms ease-out;
|
||||
}
|
||||
|
||||
.right-rail-slot.visible {
|
||||
max-height: 18rem; /* increased to fit additional controls + divider */
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@keyframes rightRailGrowDown {
|
||||
0% {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
max-height: 18rem;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes rightRailShrinkUp {
|
||||
0% {
|
||||
max-height: 18rem;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Remove bottom margin from close icon */
|
||||
.right-rail-slot .right-rail-icon {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Inline appear/disappear animation for page-number selector button */
|
||||
.right-rail-fade {
|
||||
transition-property: opacity, transform, max-height, visibility;
|
||||
transition-duration: 220ms, 220ms, 220ms, 0s;
|
||||
transition-timing-function: ease, ease, ease, linear;
|
||||
transition-delay: 0s, 0s, 0s, 0s;
|
||||
transform-origin: top center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.right-rail-fade.enter {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
max-height: 3rem;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.right-rail-fade.exit {
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
max-height: 0;
|
||||
visibility: hidden;
|
||||
/* delay visibility change so opacity/max-height can finish */
|
||||
transition-delay: 0s, 0s, 0s, 220ms;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
@ -160,7 +160,7 @@
|
||||
.tooltip-arrow-top {
|
||||
top: -0.25rem;
|
||||
left: 50%;
|
||||
transform: translateX(-50%) rotate(45deg);
|
||||
transform: translateX(-50%) rotate(-135deg);
|
||||
border-top: none;
|
||||
border-left: none;
|
||||
}
|
||||
|
@ -111,7 +111,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
overflowY: "auto",
|
||||
overflowX: "hidden",
|
||||
minHeight: 0,
|
||||
height: "100%"
|
||||
height: "100%",
|
||||
marginTop: -2
|
||||
}}
|
||||
className="tool-picker-scrollable"
|
||||
>
|
||||
@ -135,7 +136,6 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
zIndex: 2,
|
||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
||||
marginBottom: -1,
|
||||
padding: "0.5rem 1rem",
|
||||
fontWeight: 700,
|
||||
background: "var(--tool-header-bg)",
|
||||
@ -143,7 +143,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
cursor: "pointer",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between"
|
||||
justifyContent: "space-between",
|
||||
}}
|
||||
onClick={() => scrollTo(quickAccessRef)}
|
||||
>
|
||||
|
53
frontend/src/contexts/RightRailContext.tsx
Normal file
53
frontend/src/contexts/RightRailContext.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useState } from 'react';
|
||||
import { RightRailAction, RightRailButtonConfig } from '../types/rightRail';
|
||||
|
||||
interface RightRailContextValue {
|
||||
buttons: RightRailButtonConfig[];
|
||||
actions: Record<string, RightRailAction>;
|
||||
registerButtons: (buttons: RightRailButtonConfig[]) => void;
|
||||
unregisterButtons: (ids: string[]) => void;
|
||||
setAction: (id: string, action: RightRailAction) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const RightRailContext = createContext<RightRailContextValue | undefined>(undefined);
|
||||
|
||||
export function RightRailProvider({ children }: { children: React.ReactNode }) {
|
||||
const [buttons, setButtons] = useState<RightRailButtonConfig[]>([]);
|
||||
const [actions, setActions] = useState<Record<string, RightRailAction>>({});
|
||||
|
||||
const registerButtons = useCallback((newButtons: RightRailButtonConfig[]) => {
|
||||
setButtons(prev => {
|
||||
const merged = [...prev.filter(b => !newButtons.some(nb => nb.id === b.id)), ...newButtons];
|
||||
return merged.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const unregisterButtons = useCallback((ids: string[]) => {
|
||||
setButtons(prev => prev.filter(b => !ids.includes(b.id)));
|
||||
setActions(prev => Object.fromEntries(Object.entries(prev).filter(([id]) => !ids.includes(id))));
|
||||
}, []);
|
||||
|
||||
const setAction = useCallback((id: string, action: RightRailAction) => {
|
||||
setActions(prev => ({ ...prev, [id]: action }));
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
setButtons([]);
|
||||
setActions({});
|
||||
}, []);
|
||||
|
||||
const value = useMemo<RightRailContextValue>(() => ({ buttons, actions, registerButtons, unregisterButtons, setAction, clear }), [buttons, actions, registerButtons, unregisterButtons, setAction, clear]);
|
||||
|
||||
return (
|
||||
<RightRailContext.Provider value={value}>
|
||||
{children}
|
||||
</RightRailContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useRightRail() {
|
||||
const ctx = useContext(RightRailContext);
|
||||
if (!ctx) throw new Error('useRightRail must be used within RightRailProvider');
|
||||
return ctx;
|
||||
}
|
31
frontend/src/hooks/useRightRailButtons.ts
Normal file
31
frontend/src/hooks/useRightRailButtons.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useRightRail } from '../contexts/RightRailContext';
|
||||
import { RightRailAction, RightRailButtonConfig } from '../types/rightRail';
|
||||
|
||||
export interface RightRailButtonWithAction extends RightRailButtonConfig {
|
||||
onClick: RightRailAction;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers one or more RightRail buttons and their actions.
|
||||
* - Automatically registers on mount and unregisters on unmount
|
||||
* - Updates registration when the input array reference changes
|
||||
*/
|
||||
export function useRightRailButtons(buttons: RightRailButtonWithAction[]) {
|
||||
const { registerButtons, unregisterButtons, setAction } = useRightRail();
|
||||
|
||||
useEffect(() => {
|
||||
if (!buttons || buttons.length === 0) return;
|
||||
|
||||
// Register visual button configs (without onClick)
|
||||
registerButtons(buttons.map(({ onClick, ...cfg }) => cfg));
|
||||
|
||||
// Bind actions
|
||||
buttons.forEach(({ id, onClick }) => setAction(id, onClick));
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
unregisterButtons(buttons.map(b => b.id));
|
||||
};
|
||||
}, [registerButtons, unregisterButtons, setAction, buttons]);
|
||||
}
|
@ -11,7 +11,9 @@ import { getBaseUrl } from "../constants/app";
|
||||
import ToolPanel from "../components/tools/ToolPanel";
|
||||
import Workbench from "../components/layout/Workbench";
|
||||
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
||||
import RightRail from "../components/shared/RightRail";
|
||||
import FileManager from "../components/FileManager";
|
||||
import { RightRailProvider } from "../contexts/RightRailContext";
|
||||
|
||||
|
||||
function HomePageContent() {
|
||||
@ -37,7 +39,6 @@ function HomePageContent() {
|
||||
ogImage: selectedToolKey ? `${baseUrl}/og_images/${selectedToolKey}.png` : `${baseUrl}/og_images/home.png`,
|
||||
ogUrl: selectedTool ? `${baseUrl}${window.location.pathname}` : baseUrl
|
||||
});
|
||||
|
||||
// Update file selection context when tool changes
|
||||
useEffect(() => {
|
||||
if (selectedTool) {
|
||||
@ -60,6 +61,7 @@ function HomePageContent() {
|
||||
ref={quickAccessRef} />
|
||||
<ToolPanel />
|
||||
<Workbench />
|
||||
<RightRail />
|
||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||
</Group>
|
||||
);
|
||||
@ -71,7 +73,9 @@ export default function HomePage() {
|
||||
<FileSelectionProvider>
|
||||
<ToolWorkflowProvider onViewChange={setCurrentView as any /* FIX ME */}>
|
||||
<SidebarProvider>
|
||||
<HomePageContent />
|
||||
<RightRailProvider>
|
||||
<HomePageContent />
|
||||
</RightRailProvider>
|
||||
</SidebarProvider>
|
||||
</ToolWorkflowProvider>
|
||||
</FileSelectionProvider>
|
||||
|
@ -106,6 +106,12 @@
|
||||
--icon-config-bg: #9CA3AF;
|
||||
--icon-config-color: #FFFFFF;
|
||||
|
||||
/* RightRail (light) */
|
||||
--right-rail-bg: #F5F6F8; /* light background */
|
||||
--right-rail-foreground: #CDD4E1; /* panel behind custom tool icons */
|
||||
--right-rail-icon: #4B5563; /* icon color */
|
||||
--right-rail-icon-disabled: #CECECE;/* disabled icon */
|
||||
|
||||
/* Colors for tooltips */
|
||||
--tooltip-title-bg: #DBEFFF;
|
||||
--tooltip-title-color: #31528E;
|
||||
@ -234,6 +240,12 @@
|
||||
--icon-inactive-bg: #2A2F36;
|
||||
--icon-inactive-color: #6E7581;
|
||||
|
||||
/* RightRail (dark) */
|
||||
--right-rail-bg: #1F2329; /* dark background */
|
||||
--right-rail-foreground: #2A2F36; /* panel behind custom tool icons */
|
||||
--right-rail-icon: #BCBEBF; /* icon color */
|
||||
--right-rail-icon-disabled: #43464B;/* disabled icon */
|
||||
|
||||
/* Dark mode tooltip colors */
|
||||
--tooltip-title-bg: #4B525A;
|
||||
--tooltip-title-color: #fff;
|
||||
|
13
frontend/src/types/rightRail.ts
Normal file
13
frontend/src/types/rightRail.ts
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
|
||||
export interface RightRailButtonConfig {
|
||||
id: string; // unique id for the button, also used to bind action callbacks
|
||||
icon: React.ReactNode;
|
||||
tooltip: string;
|
||||
section?: 'top' | 'middle' | 'bottom';
|
||||
order?: number;
|
||||
disabled?: boolean;
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
export type RightRailAction = () => void;
|
Loading…
x
Reference in New Issue
Block a user