mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 06:09:23 +00:00
Feature/v2/right rail (#4255)
# Description of Changes <!-- Please provide a summary of the changes, including: - What was changed - Why the change was made - Any challenges encountered Closes #(issue_number) --> --- ## Checklist ### General - [ ] I have read the [Contribution Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md) - [ ] I have read the [Stirling-PDF Developer Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md) (if applicable) - [ ] 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) - [ ] I have performed a self-review of my own code - [ ] 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. --------- Co-authored-by: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
This commit is contained in:
parent
61f5221c58
commit
895bcebc7b
@ -992,6 +992,7 @@
|
|||||||
},
|
},
|
||||||
"submit": "Change"
|
"submit": "Change"
|
||||||
},
|
},
|
||||||
|
|
||||||
"removePages": {
|
"removePages": {
|
||||||
"tags": "Remove pages,delete pages",
|
"tags": "Remove pages,delete pages",
|
||||||
"title": "Remove Pages",
|
"title": "Remove Pages",
|
||||||
@ -1926,6 +1927,19 @@
|
|||||||
"currentPage": "Current Page",
|
"currentPage": "Current Page",
|
||||||
"totalPages": "Total Pages"
|
"totalPages": "Total Pages"
|
||||||
},
|
},
|
||||||
|
"rightRail": {
|
||||||
|
"closeSelected": "Close Selected Files",
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"deselectAll": "Deselect All",
|
||||||
|
"selectByNumber": "Select by Page Numbers",
|
||||||
|
"deleteSelected": "Delete Selected Pages",
|
||||||
|
"closePdf": "Close PDF",
|
||||||
|
"exportAll": "Export PDF",
|
||||||
|
"downloadSelected": "Download Selected Files",
|
||||||
|
"downloadAll": "Download All",
|
||||||
|
"toggleTheme": "Toggle Theme",
|
||||||
|
"language": "Language"
|
||||||
|
},
|
||||||
"toolPicker": {
|
"toolPicker": {
|
||||||
"searchPlaceholder": "Search tools...",
|
"searchPlaceholder": "Search tools...",
|
||||||
"noToolsFound": "No tools found",
|
"noToolsFound": "No tools found",
|
||||||
|
@ -55,6 +55,7 @@
|
|||||||
"bored": "Bored Waiting?",
|
"bored": "Bored Waiting?",
|
||||||
"alphabet": "Alphabet",
|
"alphabet": "Alphabet",
|
||||||
"downloadPdf": "Download PDF",
|
"downloadPdf": "Download PDF",
|
||||||
|
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
"font": "Font",
|
"font": "Font",
|
||||||
"selectFillter": "-- Select --",
|
"selectFillter": "-- Select --",
|
||||||
@ -2072,6 +2073,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"rightRail": {
|
||||||
|
"closePdf": "Close PDF",
|
||||||
|
"closeSelected": "Close Selected Files",
|
||||||
|
"selectAll": "Select All",
|
||||||
|
"deselectAll": "Deselect All",
|
||||||
|
"selectByNumber": "Select by Page Numbers",
|
||||||
|
"deleteSelected": "Delete Selected Pages",
|
||||||
|
"toggleTheme": "Toggle Theme",
|
||||||
|
"exportAll": "Export PDF",
|
||||||
|
"downloadSelected": "Download Selected Files",
|
||||||
|
"downloadAll": "Download All"
|
||||||
|
},
|
||||||
"removePassword": {
|
"removePassword": {
|
||||||
"title": "Remove Password",
|
"title": "Remove Password",
|
||||||
"desc": "Remove password protection from your PDF document.",
|
"desc": "Remove password protection from your PDF document.",
|
||||||
|
@ -11,6 +11,7 @@ import HomePage from "./pages/HomePage";
|
|||||||
// Import global styles
|
// Import global styles
|
||||||
import "./styles/tailwind.css";
|
import "./styles/tailwind.css";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
import { RightRailProvider } from "./contexts/RightRailContext";
|
||||||
|
|
||||||
// Loading component for i18next suspense
|
// Loading component for i18next suspense
|
||||||
const LoadingFallback = () => (
|
const LoadingFallback = () => (
|
||||||
@ -38,7 +39,9 @@ export default function App() {
|
|||||||
<FilesModalProvider>
|
<FilesModalProvider>
|
||||||
<ToolWorkflowProvider>
|
<ToolWorkflowProvider>
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
|
<RightRailProvider>
|
||||||
<HomePage />
|
<HomePage />
|
||||||
|
</RightRailProvider>
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</ToolWorkflowProvider>
|
</ToolWorkflowProvider>
|
||||||
</FilesModalProvider>
|
</FilesModalProvider>
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
import React, { useState, useCallback, useRef, useEffect, useMemo } from 'react';
|
||||||
import {
|
import {
|
||||||
Button, Text, Center, Box, Notification, TextInput, LoadingOverlay, Modal, Alert, Container,
|
Text, Center, Box, Notification, LoadingOverlay, Stack, Group, Portal
|
||||||
Stack, Group
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { Dropzone } from '@mantine/dropzone';
|
import { Dropzone } from '@mantine/dropzone';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@ -466,21 +465,6 @@ const FileEditor = ({
|
|||||||
<LoadingOverlay visible={false} />
|
<LoadingOverlay visible={false} />
|
||||||
|
|
||||||
<Box p="md" pt="xl">
|
<Box p="md" pt="xl">
|
||||||
<Group mb="md">
|
|
||||||
{toolMode && (
|
|
||||||
<>
|
|
||||||
<Button onClick={selectAll} variant="light">Select All</Button>
|
|
||||||
<Button onClick={deselectAll} variant="light">Deselect All</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{showBulkActions && !toolMode && (
|
|
||||||
<>
|
|
||||||
<Button onClick={closeAllFiles} variant="light" color="orange">
|
|
||||||
Close All
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
|
|
||||||
|
|
||||||
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
|
{activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? (
|
||||||
@ -573,25 +557,29 @@ const FileEditor = ({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{status && (
|
{status && (
|
||||||
|
<Portal>
|
||||||
<Notification
|
<Notification
|
||||||
color="blue"
|
color="blue"
|
||||||
mt="md"
|
mt="md"
|
||||||
onClose={() => setStatus(null)}
|
onClose={() => setStatus(null)}
|
||||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10001 }}
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
</Notification>
|
</Notification>
|
||||||
|
</Portal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
<Portal>
|
||||||
<Notification
|
<Notification
|
||||||
color="red"
|
color="red"
|
||||||
mt="md"
|
mt="md"
|
||||||
onClose={() => setError(null)}
|
onClose={() => setError(null)}
|
||||||
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }}
|
style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }}
|
||||||
>
|
>
|
||||||
{error}
|
{error}
|
||||||
</Notification>
|
</Notification>
|
||||||
|
</Portal>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Dropzone>
|
</Dropzone>
|
||||||
|
@ -157,6 +157,7 @@ export default function Workbench() {
|
|||||||
className="flex-1 min-h-0 relative z-10"
|
className="flex-1 min-h-0 relative z-10"
|
||||||
style={{
|
style={{
|
||||||
transition: 'opacity 0.15s ease-in-out',
|
transition: 'opacity 0.15s ease-in-out',
|
||||||
|
marginTop: '1rem',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderMainContent()}
|
{renderMainContent()}
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
|
import { Group, TextInput, Button, Text } from '@mantine/core';
|
||||||
|
|
||||||
interface BulkSelectionPanelProps {
|
interface BulkSelectionPanelProps {
|
||||||
csvInput: string;
|
csvInput: string;
|
||||||
@ -15,7 +15,7 @@ const BulkSelectionPanel = ({
|
|||||||
onUpdatePagesFromCSV,
|
onUpdatePagesFromCSV,
|
||||||
}: BulkSelectionPanelProps) => {
|
}: BulkSelectionPanelProps) => {
|
||||||
return (
|
return (
|
||||||
<Paper p="md" mb="md" withBorder>
|
<>
|
||||||
<Group>
|
<Group>
|
||||||
<TextInput
|
<TextInput
|
||||||
value={csvInput}
|
value={csvInput}
|
||||||
@ -35,7 +35,7 @@ const BulkSelectionPanel = ({
|
|||||||
Selected: {selectedPages.length} pages
|
Selected: {selectedPages.length} pages
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Paper>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -141,7 +141,6 @@ const FileThumbnail = ({
|
|||||||
filter: isSupported ? 'none' : 'grayscale(50%)'
|
filter: isSupported ? 'none' : 'grayscale(50%)'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{selectionMode && (
|
|
||||||
<div
|
<div
|
||||||
className={styles.checkboxContainer}
|
className={styles.checkboxContainer}
|
||||||
data-testid="file-thumbnail-checkbox"
|
data-testid="file-thumbnail-checkbox"
|
||||||
@ -175,7 +174,6 @@ const FileThumbnail = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* File content area */}
|
{/* File content area */}
|
||||||
<div className="file-container w-[90%] h-[80%] relative">
|
<div className="file-container w-[90%] h-[80%] relative">
|
||||||
|
@ -1,14 +1,13 @@
|
|||||||
import React, { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
import React, { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||||
import {
|
import {
|
||||||
Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon,
|
Button, Text, Center, Box,
|
||||||
Notification, TextInput, LoadingOverlay, Modal, Alert,
|
Notification, TextInput, LoadingOverlay, Modal, Alert,
|
||||||
Stack, Group
|
Stack, Group, Portal
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
|
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
|
||||||
import { ModeType } from "../../contexts/NavigationContext";
|
import { ModeType } from "../../contexts/NavigationContext";
|
||||||
import { PDFDocument, PDFPage } from "../../types/pageEditor";
|
import { PDFDocument, PDFPage } from "../../types/pageEditor";
|
||||||
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
|
|
||||||
import { useUndoRedo } from "../../hooks/useUndoRedo";
|
import { useUndoRedo } from "../../hooks/useUndoRedo";
|
||||||
import {
|
import {
|
||||||
RotatePagesCommand,
|
RotatePagesCommand,
|
||||||
@ -56,7 +55,6 @@ export interface PageEditorProps {
|
|||||||
const PageEditor = ({
|
const PageEditor = ({
|
||||||
onFunctionsReady,
|
onFunctionsReady,
|
||||||
}: PageEditorProps) => {
|
}: PageEditorProps) => {
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Use split contexts to prevent re-renders
|
// Use split contexts to prevent re-renders
|
||||||
const { state, selectors } = useFileState();
|
const { state, selectors } = useFileState();
|
||||||
@ -241,19 +239,26 @@ const PageEditor = ({
|
|||||||
const [exportLoading, setExportLoading] = useState(false);
|
const [exportLoading, setExportLoading] = useState(false);
|
||||||
const [showExportModal, setShowExportModal] = useState(false);
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
|
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
|
||||||
|
const [exportSelectedOnly, setExportSelectedOnly] = useState<boolean>(false);
|
||||||
|
|
||||||
// Animation state
|
// Animation state
|
||||||
const [movingPage, setMovingPage] = useState<number | null>(null);
|
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 [isAnimating, setIsAnimating] = useState(false);
|
||||||
const pageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
||||||
const fileInputRef = useRef<() => void>(null);
|
|
||||||
|
|
||||||
// Undo/Redo system
|
// Undo/Redo system
|
||||||
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
|
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
|
||||||
|
|
||||||
|
// Track whether the user has manually edited the filename to avoid auto-overwrites
|
||||||
|
const userEditedFilename = useRef(false);
|
||||||
|
|
||||||
|
// Reset user edit flag when the active files change, so defaults can be applied for new docs
|
||||||
|
useEffect(() => {
|
||||||
|
userEditedFilename.current = false;
|
||||||
|
}, [filesSignature]);
|
||||||
|
|
||||||
// Set initial filename when document changes - use stable signature
|
// Set initial filename when document changes - use stable signature
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (userEditedFilename.current) return; // Do not overwrite user-typed filename
|
||||||
if (mergedPdfDocument) {
|
if (mergedPdfDocument) {
|
||||||
if (activeFileIds.length === 1 && primaryFileId) {
|
if (activeFileIds.length === 1 && primaryFileId) {
|
||||||
const record = selectors.getFileRecord(primaryFileId);
|
const record = selectors.getFileRecord(primaryFileId);
|
||||||
@ -838,14 +843,18 @@ const PageEditor = ({
|
|||||||
const handleDelete = useCallback(() => {
|
const handleDelete = useCallback(() => {
|
||||||
if (!displayDocument) return;
|
if (!displayDocument) return;
|
||||||
|
|
||||||
const pagesToDelete = selectionMode
|
const hasSelectedPages = selectedPageNumbers.length > 0;
|
||||||
? selectedPageNumbers.map(pageNum => {
|
|
||||||
|
const pagesToDelete = (selectionMode || hasSelectedPages)
|
||||||
|
? selectedPageNumbers
|
||||||
|
.map(pageNum => {
|
||||||
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
|
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
|
||||||
return page?.id || '';
|
return page?.id || '';
|
||||||
}).filter(id => id)
|
})
|
||||||
|
.filter(id => id)
|
||||||
: displayDocument.pages.map(p => p.id);
|
: displayDocument.pages.map(p => p.id);
|
||||||
|
|
||||||
if (selectionMode && selectedPageNumbers.length === 0) return;
|
if ((selectionMode || hasSelectedPages) && selectedPageNumbers.length === 0) return;
|
||||||
|
|
||||||
const command = new DeletePagesCommand(
|
const command = new DeletePagesCommand(
|
||||||
displayDocument,
|
displayDocument,
|
||||||
@ -857,7 +866,7 @@ const PageEditor = ({
|
|||||||
if (selectionMode) {
|
if (selectionMode) {
|
||||||
actions.setSelectedPages([]);
|
actions.setSelectedPages([]);
|
||||||
}
|
}
|
||||||
const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length;
|
const pageCount = (selectionMode || hasSelectedPages) ? selectedPageNumbers.length : displayDocument.pages.length;
|
||||||
setStatus(`Deleted ${pageCount} pages`);
|
setStatus(`Deleted ${pageCount} pages`);
|
||||||
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, actions]);
|
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, actions]);
|
||||||
|
|
||||||
@ -885,49 +894,52 @@ const PageEditor = ({
|
|||||||
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]);
|
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]);
|
||||||
|
|
||||||
const showExportPreview = useCallback((selectedOnly: boolean = false) => {
|
const showExportPreview = useCallback((selectedOnly: boolean = false) => {
|
||||||
if (!mergedPdfDocument) return;
|
const doc = editedDocument || mergedPdfDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
|
||||||
// Convert page numbers to page IDs for export service
|
// Convert page numbers to page IDs for export service
|
||||||
const exportPageIds = selectedOnly
|
const exportPageIds = selectedOnly
|
||||||
? selectedPageNumbers.map(pageNum => {
|
? selectedPageNumbers.map(pageNum => {
|
||||||
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
|
const page = doc.pages.find(p => p.pageNumber === pageNum);
|
||||||
return page?.id || '';
|
return page?.id || '';
|
||||||
}).filter(id => id)
|
}).filter(id => id)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
const preview = pdfExportService.getExportInfo(doc, exportPageIds, selectedOnly);
|
||||||
const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly);
|
|
||||||
setExportPreview(preview);
|
setExportPreview(preview);
|
||||||
|
setExportSelectedOnly(selectedOnly);
|
||||||
setShowExportModal(true);
|
setShowExportModal(true);
|
||||||
}, [mergedPdfDocument, selectedPageNumbers]);
|
}, [editedDocument, mergedPdfDocument, selectedPageNumbers]);
|
||||||
|
|
||||||
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
|
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
|
||||||
if (!mergedPdfDocument) return;
|
const doc = editedDocument || mergedPdfDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
|
||||||
setExportLoading(true);
|
setExportLoading(true);
|
||||||
try {
|
try {
|
||||||
// Convert page numbers to page IDs for export service
|
// Convert page numbers to page IDs for export service
|
||||||
const exportPageIds = selectedOnly
|
const exportPageIds = selectedOnly
|
||||||
? selectedPageNumbers.map(pageNum => {
|
? selectedPageNumbers.map(pageNum => {
|
||||||
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
|
const page = doc.pages.find(p => p.pageNumber === pageNum);
|
||||||
return page?.id || '';
|
return page?.id || '';
|
||||||
}).filter(id => id)
|
}).filter(id => id)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
|
||||||
const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly);
|
const errors = pdfExportService.validateExport(doc, exportPageIds, selectedOnly);
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
setStatus(errors.join(', '));
|
setStatus(errors.join(', '));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasSplitMarkers = mergedPdfDocument.pages.some(page => page.splitBefore);
|
const hasSplitMarkers = doc.pages.some(page => page.splitBefore);
|
||||||
|
|
||||||
if (hasSplitMarkers) {
|
if (hasSplitMarkers) {
|
||||||
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, {
|
const result = await pdfExportService.exportPDF(doc, exportPageIds, {
|
||||||
selectedOnly,
|
selectedOnly,
|
||||||
filename,
|
filename,
|
||||||
splitDocuments: true
|
splitDocuments: true,
|
||||||
|
appendSuffix: false
|
||||||
}) as { blobs: Blob[]; filenames: string[] };
|
}) as { blobs: Blob[]; filenames: string[] };
|
||||||
|
|
||||||
result.blobs.forEach((blob, index) => {
|
result.blobs.forEach((blob, index) => {
|
||||||
@ -938,9 +950,10 @@ const PageEditor = ({
|
|||||||
|
|
||||||
setStatus(`Exported ${result.blobs.length} split documents`);
|
setStatus(`Exported ${result.blobs.length} split documents`);
|
||||||
} else {
|
} else {
|
||||||
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, {
|
const result = await pdfExportService.exportPDF(doc, exportPageIds, {
|
||||||
selectedOnly,
|
selectedOnly,
|
||||||
filename
|
filename,
|
||||||
|
appendSuffix: false
|
||||||
}) as { blob: Blob; filename: string };
|
}) as { blob: Blob; filename: string };
|
||||||
|
|
||||||
pdfExportService.downloadFile(result.blob, result.filename);
|
pdfExportService.downloadFile(result.blob, result.filename);
|
||||||
@ -953,7 +966,7 @@ const PageEditor = ({
|
|||||||
} finally {
|
} finally {
|
||||||
setExportLoading(false);
|
setExportLoading(false);
|
||||||
}
|
}
|
||||||
}, [mergedPdfDocument, selectedPageNumbers, filename]);
|
}, [editedDocument, mergedPdfDocument, selectedPageNumbers, filename]);
|
||||||
|
|
||||||
const handleUndo = useCallback(() => {
|
const handleUndo = useCallback(() => {
|
||||||
if (undo()) {
|
if (undo()) {
|
||||||
@ -1240,59 +1253,13 @@ const PageEditor = ({
|
|||||||
</div>
|
</div>
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group mb="md">
|
|
||||||
<TextInput
|
<TextInput
|
||||||
|
label="Filename"
|
||||||
value={filename}
|
value={filename}
|
||||||
onChange={(e) => setFilename(e.target.value)}
|
onChange={(e) => setFilename(e.target.value)}
|
||||||
placeholder="Enter filename"
|
placeholder="Enter filename"
|
||||||
style={{ minWidth: 200 }}
|
style={{ minWidth: 200, maxWidth: 200, marginLeft: "1rem"}}
|
||||||
/>
|
/>
|
||||||
<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
|
<DragDropGrid
|
||||||
@ -1386,8 +1353,7 @@ const PageEditor = ({
|
|||||||
loading={exportLoading}
|
loading={exportLoading}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowExportModal(false);
|
setShowExportModal(false);
|
||||||
const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.pages.length || 0);
|
handleExport(exportSelectedOnly);
|
||||||
handleExport(selectedOnly);
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Export PDF
|
Export PDF
|
||||||
@ -1446,14 +1412,16 @@ const PageEditor = ({
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{status && (
|
{status && (
|
||||||
|
<Portal>
|
||||||
<Notification
|
<Notification
|
||||||
color="blue"
|
color="blue"
|
||||||
mt="md"
|
mt="md"
|
||||||
onClose={() => setStatus(null)}
|
onClose={() => setStatus(null)}
|
||||||
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
|
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10000 }}
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
</Notification>
|
</Notification>
|
||||||
|
</Portal>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
@ -2,16 +2,12 @@ import React from "react";
|
|||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Paper
|
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import UndoIcon from "@mui/icons-material/Undo";
|
import UndoIcon from "@mui/icons-material/Undo";
|
||||||
import RedoIcon from "@mui/icons-material/Redo";
|
import RedoIcon from "@mui/icons-material/Redo";
|
||||||
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
import ContentCutIcon from "@mui/icons-material/ContentCut";
|
||||||
import DownloadIcon from "@mui/icons-material/Download";
|
|
||||||
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
|
||||||
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
import RotateRightIcon from "@mui/icons-material/RotateRight";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
|
||||||
import CloseIcon from "@mui/icons-material/Close";
|
|
||||||
|
|
||||||
interface PageEditorControlsProps {
|
interface PageEditorControlsProps {
|
||||||
// Close/Reset functions
|
// Close/Reset functions
|
||||||
@ -39,17 +35,12 @@ interface PageEditorControlsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PageEditorControls = ({
|
const PageEditorControls = ({
|
||||||
onClosePdf,
|
|
||||||
onUndo,
|
onUndo,
|
||||||
onRedo,
|
onRedo,
|
||||||
canUndo,
|
canUndo,
|
||||||
canRedo,
|
canRedo,
|
||||||
onRotate,
|
onRotate,
|
||||||
onDelete,
|
|
||||||
onSplit,
|
onSplit,
|
||||||
onExportSelected,
|
|
||||||
onExportAll,
|
|
||||||
exportLoading,
|
|
||||||
selectionMode,
|
selectionMode,
|
||||||
selectedPages
|
selectedPages
|
||||||
}: PageEditorControlsProps) => {
|
}: PageEditorControlsProps) => {
|
||||||
@ -57,9 +48,9 @@ const PageEditorControls = ({
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
left: '50%',
|
left: 0,
|
||||||
bottom: '20px',
|
right: 0,
|
||||||
transform: 'translateX(-50%)',
|
bottom: 0,
|
||||||
zIndex: 50,
|
zIndex: 50,
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
@ -67,34 +58,28 @@ const PageEditorControls = ({
|
|||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Paper
|
<div
|
||||||
radius="xl"
|
|
||||||
shadow="lg"
|
|
||||||
p={16}
|
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
borderRadius: 32,
|
borderTopLeftRadius: 16,
|
||||||
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
|
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',
|
pointerEvents: 'auto',
|
||||||
minWidth: 400,
|
minWidth: 420,
|
||||||
justifyContent: 'center'
|
maxWidth: 700,
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: "1rem",
|
||||||
|
paddingBottom: "2rem"
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Close PDF */}
|
|
||||||
<Tooltip label="Close PDF">
|
|
||||||
<ActionIcon
|
|
||||||
onClick={onClosePdf}
|
|
||||||
color="red"
|
|
||||||
variant="light"
|
|
||||||
size="lg"
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
|
||||||
|
|
||||||
{/* Undo/Redo */}
|
{/* Undo/Redo */}
|
||||||
<Tooltip label="Undo">
|
<Tooltip label="Undo">
|
||||||
@ -133,17 +118,6 @@ const PageEditorControls = ({
|
|||||||
<RotateRightIcon />
|
<RotateRightIcon />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</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"}>
|
<Tooltip label={selectionMode ? "Split Selected" : "Split All"}>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
onClick={onSplit}
|
onClick={onSplit}
|
||||||
@ -156,34 +130,7 @@ const PageEditorControls = ({
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
|
</div>
|
||||||
|
|
||||||
{/* 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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -205,7 +205,7 @@ const PageThumbnail = React.memo(({
|
|||||||
}}
|
}}
|
||||||
draggable={false}
|
draggable={false}
|
||||||
>
|
>
|
||||||
{selectionMode && (
|
{
|
||||||
<div
|
<div
|
||||||
className={styles.checkboxContainer}
|
className={styles.checkboxContainer}
|
||||||
style={{
|
style={{
|
||||||
@ -213,10 +213,9 @@ const PageThumbnail = React.memo(({
|
|||||||
top: 8,
|
top: 8,
|
||||||
right: 8,
|
right: 8,
|
||||||
zIndex: 10,
|
zIndex: 10,
|
||||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
backgroundColor: 'white',
|
||||||
border: '1px solid #ccc',
|
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
padding: '4px',
|
padding: '2px',
|
||||||
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
|
||||||
pointerEvents: 'auto',
|
pointerEvents: 'auto',
|
||||||
cursor: 'pointer'
|
cursor: 'pointer'
|
||||||
@ -239,7 +238,7 @@ const PageThumbnail = React.memo(({
|
|||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
|
|
||||||
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
<div className="page-container w-[90%] h-[90%]" draggable={false}>
|
||||||
<div
|
<div
|
||||||
|
@ -1,11 +1,17 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { useTranslation } from 'react-i18next';
|
||||||
import { supportedLanguages } from '../../i18n';
|
import { supportedLanguages } from '../../i18n';
|
||||||
import LanguageIcon from '@mui/icons-material/Language';
|
import LanguageIcon from '@mui/icons-material/Language';
|
||||||
import styles from './LanguageSelector.module.css';
|
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 { i18n } = useTranslation();
|
||||||
const [opened, setOpened] = useState(false);
|
const [opened, setOpened] = useState(false);
|
||||||
const [animationTriggered, setAnimationTriggered] = useState(false);
|
const [animationTriggered, setAnimationTriggered] = useState(false);
|
||||||
@ -21,12 +27,13 @@ const LanguageSelector = () => {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
|
const handleLanguageChange = (value: string, event: React.MouseEvent) => {
|
||||||
// Create ripple effect at click position
|
// Create ripple effect at click position (only for button mode)
|
||||||
const rect = event.currentTarget.getBoundingClientRect();
|
if (!compact) {
|
||||||
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
const x = event.clientX - rect.left;
|
const x = event.clientX - rect.left;
|
||||||
const y = event.clientY - rect.top;
|
const y = event.clientY - rect.top;
|
||||||
|
|
||||||
setRippleEffect({ x, y, key: Date.now() });
|
setRippleEffect({ x, y, key: Date.now() });
|
||||||
|
}
|
||||||
|
|
||||||
// Start transition animation
|
// Start transition animation
|
||||||
setIsChanging(true);
|
setIsChanging(true);
|
||||||
@ -64,19 +71,9 @@ const LanguageSelector = () => {
|
|||||||
<style>
|
<style>
|
||||||
{`
|
{`
|
||||||
@keyframes ripple-expand {
|
@keyframes ripple-expand {
|
||||||
0% {
|
0% { width: 0; height: 0; opacity: 0.6; }
|
||||||
width: 0;
|
50% { opacity: 0.3; }
|
||||||
height: 0;
|
100% { width: 100px; height: 100px; opacity: 0; }
|
||||||
opacity: 0.6;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 0.3;
|
|
||||||
}
|
|
||||||
100% {
|
|
||||||
width: 100px;
|
|
||||||
height: 100px;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
</style>
|
</style>
|
||||||
@ -84,8 +81,8 @@ const LanguageSelector = () => {
|
|||||||
opened={opened}
|
opened={opened}
|
||||||
onChange={setOpened}
|
onChange={setOpened}
|
||||||
width={600}
|
width={600}
|
||||||
position="bottom-start"
|
position={position}
|
||||||
offset={8}
|
offset={offset}
|
||||||
transitionProps={{
|
transitionProps={{
|
||||||
transition: 'scale-y',
|
transition: 'scale-y',
|
||||||
duration: 200,
|
duration: 200,
|
||||||
@ -93,6 +90,24 @@ const LanguageSelector = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
|
{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))',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-rounded">language</span>
|
||||||
|
</ActionIcon>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -106,16 +121,14 @@ const LanguageSelector = () => {
|
|||||||
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
backgroundColor: 'light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5))',
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
label: {
|
label: { fontSize: '12px', fontWeight: 500 }
|
||||||
fontSize: '12px',
|
|
||||||
fontWeight: 500,
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className={styles.languageText}>
|
<span className={styles.languageText}>
|
||||||
{currentLanguage}
|
{currentLanguage}
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</Menu.Target>
|
</Menu.Target>
|
||||||
|
|
||||||
<Menu.Dropdown
|
<Menu.Dropdown
|
||||||
@ -181,9 +194,7 @@ const LanguageSelector = () => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
|
{!compact && rippleEffect && pendingLanguage === option.value && (
|
||||||
{/* Ripple effect */}
|
|
||||||
{rippleEffect && pendingLanguage === option.value && (
|
|
||||||
<div
|
<div
|
||||||
key={rippleEffect.key}
|
key={rippleEffect.key}
|
||||||
style={{
|
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, useMemo } 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 { useFileState, useFileSelection, useFileManagement } 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';
|
||||||
|
|
||||||
|
export default function RightRail() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { toggleTheme } = useRainbowThemeContext();
|
||||||
|
const { buttons, actions } = 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 } = useToolWorkflow();
|
||||||
|
|
||||||
|
// CSV input state for page selection
|
||||||
|
const [csvInput, setCsvInput] = useState<string>("");
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
const filesSignature = selectors.getFilesSignature();
|
||||||
|
const fileRecords = selectors.getFileRecords();
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
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;
|
||||||
|
return { totalItems, selectedCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { totalItems: 0, selectedCount: 0 };
|
||||||
|
}, [currentView, activeFiles, fileRecords, selectedFileIds, selectedPageNumbers]);
|
||||||
|
|
||||||
|
const { totalItems, selectedCount } = getSelectionState();
|
||||||
|
|
||||||
|
const handleSelectAll = useCallback(() => {
|
||||||
|
if (currentView === 'fileEditor' || currentView === 'viewer') {
|
||||||
|
// Select all file IDs
|
||||||
|
const allIds = state.files.ids;
|
||||||
|
setSelectedFiles(allIds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentView === 'pageEditor') {
|
||||||
|
let totalPages = 0;
|
||||||
|
fileRecords.forEach(rec => {
|
||||||
|
const pf = rec.processedFile;
|
||||||
|
if (pf) {
|
||||||
|
totalPages += (pf.totalPages as number) || (pf.pages?.length || 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (totalPages > 0) {
|
||||||
|
setSelectedPages(Array.from({ length: totalPages }, (_, i) => i + 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [currentView, state.files.ids, fileRecords, 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 = 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?.();
|
||||||
|
}
|
||||||
|
}, [currentView, activeFiles, selectedFiles, pageEditorFunctions]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 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 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]);
|
||||||
|
|
||||||
|
// Sync csvInput with selectedPageNumbers changes
|
||||||
|
useEffect(() => {
|
||||||
|
const sortedPageNumbers = Array.isArray(selectedPageNumbers)
|
||||||
|
? [...selectedPageNumbers].sort((a, b) => a - b)
|
||||||
|
: [];
|
||||||
|
const newCsvInput = sortedPageNumbers.join(', ');
|
||||||
|
setCsvInput(newCsvInput);
|
||||||
|
}, [selectedPageNumbers]);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}, [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('rightRail.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('rightRail.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('rightRail.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}
|
||||||
|
aria-label={typeof t === 'function' ? t('rightRail.selectByNumber', 'Select by Page Numbers') : 'Select by Page Numbers'}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-rounded">
|
||||||
|
pin_end
|
||||||
|
</span>
|
||||||
|
</ActionIcon>
|
||||||
|
</div>
|
||||||
|
</Popover.Target>
|
||||||
|
<Popover.Dropdown>
|
||||||
|
<div style={{ minWidth: 280 }}>
|
||||||
|
<BulkSelectionPanel
|
||||||
|
csvInput={csvInput}
|
||||||
|
setCsvInput={setCsvInput}
|
||||||
|
selectedPages={Array.isArray(selectedPageNumbers) ? selectedPageNumbers : []}
|
||||||
|
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>
|
||||||
|
|
||||||
|
<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?.(); setSelectedPages([]); }}
|
||||||
|
disabled={!pageControlsVisible || (Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length === 0 : true)}
|
||||||
|
aria-label={typeof t === 'function' ? t('rightRail.deleteSelected', 'Delete Selected Pages') : 'Delete Selected Pages'}
|
||||||
|
>
|
||||||
|
<span className="material-symbols-rounded">delete</span>
|
||||||
|
</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>
|
||||||
|
<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('rightRail.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'
|
||||||
|
? t('rightRail.exportAll', 'Export PDF')
|
||||||
|
: (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', '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;
|
if (sidebarTooltip) return null;
|
||||||
|
|
||||||
switch (position) {
|
switch (position) {
|
||||||
case 'top': return "tooltip-arrow tooltip-arrow-top";
|
case 'top': return "tooltip-arrow tooltip-arrow-bottom";
|
||||||
case 'bottom': return "tooltip-arrow tooltip-arrow-bottom";
|
case 'bottom': return "tooltip-arrow tooltip-arrow-top";
|
||||||
case 'left': return "tooltip-arrow tooltip-arrow-left";
|
case 'left': return "tooltip-arrow tooltip-arrow-left";
|
||||||
case 'right': return "tooltip-arrow tooltip-arrow-right";
|
case 'right': return "tooltip-arrow tooltip-arrow-right";
|
||||||
default: 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',
|
position: 'fixed',
|
||||||
top: coords.top,
|
top: coords.top,
|
||||||
left: coords.left,
|
left: coords.left,
|
||||||
width: (maxWidth !== undefined ? maxWidth : '25rem'),
|
width: (maxWidth !== undefined ? maxWidth : (sidebarTooltip ? '25rem' : undefined)),
|
||||||
minWidth: minWidth,
|
minWidth: minWidth,
|
||||||
zIndex: 9999,
|
zIndex: 9999,
|
||||||
visibility: positionReady ? 'visible' : 'hidden',
|
visibility: positionReady ? 'visible' : 'hidden',
|
||||||
|
@ -1,23 +1,64 @@
|
|||||||
import React, { useState, useCallback, useMemo } from "react";
|
import React, { useState, useCallback } from "react";
|
||||||
import { Button, SegmentedControl, Loader } from "@mantine/core";
|
import { SegmentedControl, Loader } from "@mantine/core";
|
||||||
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
import { useRainbowThemeContext } from "./RainbowThemeProvider";
|
||||||
import LanguageSelector from "./LanguageSelector";
|
|
||||||
import rainbowStyles from '../../styles/rainbow.module.css';
|
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 VisibilityIcon from "@mui/icons-material/Visibility";
|
||||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||||
import FolderIcon from "@mui/icons-material/Folder";
|
import FolderIcon from "@mui/icons-material/Folder";
|
||||||
import { Group } from "@mantine/core";
|
import { ModeType, isValidMode } from '../../contexts/NavigationContext';
|
||||||
import { ModeType } from '../../contexts/NavigationContext';
|
|
||||||
|
|
||||||
// Stable view option objects that don't recreate on every render
|
const viewOptionStyle = {
|
||||||
const VIEW_OPTIONS_BASE = [
|
display: 'inline-flex',
|
||||||
{ value: "viewer", icon: VisibilityIcon },
|
flexDirection: 'row',
|
||||||
{ value: "pageEditor", icon: EditNoteIcon },
|
alignItems: 'center',
|
||||||
{ value: "fileEditor", icon: FolderIcon },
|
gap: 6,
|
||||||
] as const;
|
whiteSpace: 'nowrap',
|
||||||
|
paddingTop: '0.3rem',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Create view options with icons and loading states
|
||||||
|
const createViewOptions = (switchingTo: ModeType | null) => [
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<div style={viewOptionStyle as React.CSSProperties}>
|
||||||
|
{switchingTo === "viewer" ? (
|
||||||
|
<Loader size="xs" />
|
||||||
|
) : (
|
||||||
|
<VisibilityIcon fontSize="small" />
|
||||||
|
)}
|
||||||
|
<span>Read</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: "viewer",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<div style={viewOptionStyle as React.CSSProperties}>
|
||||||
|
{switchingTo === "pageEditor" ? (
|
||||||
|
<Loader size="xs" />
|
||||||
|
) : (
|
||||||
|
<EditNoteIcon fontSize="small" />
|
||||||
|
)}
|
||||||
|
<span>Page Editor</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: "pageEditor",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<div style={viewOptionStyle as React.CSSProperties}>
|
||||||
|
{switchingTo === "fileEditor" ? (
|
||||||
|
<Loader size="xs" />
|
||||||
|
) : (
|
||||||
|
<FolderIcon fontSize="small" />
|
||||||
|
)}
|
||||||
|
<span>File Manager</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: "fileEditor",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
interface TopControlsProps {
|
interface TopControlsProps {
|
||||||
currentView: ModeType;
|
currentView: ModeType;
|
||||||
@ -30,90 +71,60 @@ const TopControls = ({
|
|||||||
setCurrentView,
|
setCurrentView,
|
||||||
selectedToolKey,
|
selectedToolKey,
|
||||||
}: TopControlsProps) => {
|
}: TopControlsProps) => {
|
||||||
const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext();
|
const { isRainbowMode } = useRainbowThemeContext();
|
||||||
const [switchingTo, setSwitchingTo] = useState<string | null>(null);
|
const [switchingTo, setSwitchingTo] = useState<ModeType | null>(null);
|
||||||
|
|
||||||
const isToolSelected = selectedToolKey !== null;
|
const isToolSelected = selectedToolKey !== null;
|
||||||
|
|
||||||
const handleViewChange = useCallback((view: string) => {
|
const handleViewChange = useCallback((view: string) => {
|
||||||
// Guard against redundant changes
|
if (!isValidMode(view)) {
|
||||||
if (view === currentView) return;
|
// Ignore invalid values defensively
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const mode = view as ModeType;
|
||||||
|
|
||||||
// Show immediate feedback
|
// Show immediate feedback
|
||||||
setSwitchingTo(view);
|
setSwitchingTo(mode as ModeType);
|
||||||
|
|
||||||
// Defer the heavy view change to next frame so spinner can render
|
// Defer the heavy view change to next frame so spinner can render
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
// Give the spinner one more frame to show
|
// Give the spinner one more frame to show
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
setCurrentView(view as ModeType);
|
setCurrentView(mode as ModeType);
|
||||||
|
|
||||||
// Clear the loading state after view change completes
|
// Clear the loading state after view change completes
|
||||||
setTimeout(() => setSwitchingTo(null), 300);
|
setTimeout(() => setSwitchingTo(null), 300);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}, [setCurrentView, currentView]);
|
}, [setCurrentView]);
|
||||||
|
|
||||||
// Memoize the SegmentedControl data with stable references
|
|
||||||
const viewOptions = useMemo(() =>
|
|
||||||
VIEW_OPTIONS_BASE.map(option => ({
|
|
||||||
value: option.value,
|
|
||||||
label: (
|
|
||||||
<Group gap={option.value === "viewer" ? 5 : 4}>
|
|
||||||
{switchingTo === option.value ? (
|
|
||||||
<Loader size="xs" />
|
|
||||||
) : (
|
|
||||||
<option.icon fontSize="small" />
|
|
||||||
)}
|
|
||||||
</Group>
|
|
||||||
)
|
|
||||||
})), [switchingTo]);
|
|
||||||
|
|
||||||
const getThemeIcon = () => {
|
|
||||||
if (isRainbowMode) return <AutoAwesomeIcon className={rainbowStyles.rainbowText} />;
|
|
||||||
if (themeMode === "dark") return <LightModeIcon />;
|
|
||||||
return <DarkModeIcon />;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
<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 && (
|
{!isToolSelected && (
|
||||||
<div className="flex justify-center items-center h-full pointer-events-auto">
|
<div className="flex justify-center mt-[0.5rem]">
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
data={viewOptions}
|
data={createViewOptions(switchingTo)}
|
||||||
value={currentView}
|
value={currentView}
|
||||||
onChange={handleViewChange}
|
onChange={handleViewChange}
|
||||||
color="blue"
|
color="blue"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
size="md"
|
|
||||||
fullWidth
|
fullWidth
|
||||||
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
|
className={isRainbowMode ? rainbowStyles.rainbowSegmentedControl : ''}
|
||||||
style={{
|
style={{
|
||||||
transition: 'all 0.2s ease',
|
transition: 'all 0.2s ease',
|
||||||
opacity: switchingTo ? 0.8 : 1,
|
opacity: switchingTo ? 0.8 : 1,
|
||||||
|
pointerEvents: 'auto'
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
root: {
|
||||||
|
borderRadius: 9999,
|
||||||
|
},
|
||||||
|
control: {
|
||||||
|
borderRadius: 9999,
|
||||||
|
},
|
||||||
|
indicator: {
|
||||||
|
borderRadius: 9999,
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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 {
|
.tooltip-arrow-top {
|
||||||
top: -0.25rem;
|
top: -0.25rem;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%) rotate(45deg);
|
transform: translateX(-50%) rotate(-135deg);
|
||||||
border-top: none;
|
border-top: none;
|
||||||
border-left: none;
|
border-left: none;
|
||||||
}
|
}
|
||||||
|
@ -85,7 +85,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
|||||||
overflowY: "auto",
|
overflowY: "auto",
|
||||||
overflowX: "hidden",
|
overflowX: "hidden",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
height: "100%"
|
height: "100%",
|
||||||
|
marginTop: -2
|
||||||
}}
|
}}
|
||||||
className="tool-picker-scrollable"
|
className="tool-picker-scrollable"
|
||||||
>
|
>
|
||||||
@ -109,7 +110,6 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
|||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
borderTop: `0.0625rem solid var(--tool-header-border)`,
|
||||||
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
borderBottom: `0.0625rem solid var(--tool-header-border)`,
|
||||||
marginBottom: -1,
|
|
||||||
padding: "0.5rem 1rem",
|
padding: "0.5rem 1rem",
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
background: "var(--tool-header-bg)",
|
background: "var(--tool-header-bg)",
|
||||||
@ -117,7 +117,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
|||||||
cursor: "pointer",
|
cursor: "pointer",
|
||||||
display: "flex",
|
display: "flex",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "space-between"
|
justifyContent: "space-between",
|
||||||
}}
|
}}
|
||||||
onClick={() => scrollTo(quickAccessRef)}
|
onClick={() => scrollTo(quickAccessRef)}
|
||||||
>
|
>
|
||||||
|
64
frontend/src/contexts/RightRailContext.tsx
Normal file
64
frontend/src/contexts/RightRailContext.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
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 byId = new Map(prev.map(b => [b.id, b] as const));
|
||||||
|
newButtons.forEach(nb => {
|
||||||
|
const existing = byId.get(nb.id) || ({} as RightRailButtonConfig);
|
||||||
|
byId.set(nb.id, { ...existing, ...nb });
|
||||||
|
});
|
||||||
|
const merged = Array.from(byId.values());
|
||||||
|
merged.sort((a, b) => (a.order ?? 0) - (b.order ?? 0) || a.id.localeCompare(b.id));
|
||||||
|
if (process.env.NODE_ENV === 'development') {
|
||||||
|
const ids = newButtons.map(b => b.id);
|
||||||
|
const dupes = ids.filter((id, idx) => ids.indexOf(id) !== idx);
|
||||||
|
if (dupes.length) console.warn('[RightRail] Duplicate ids in registerButtons:', dupes);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
46
frontend/src/hooks/useRightRailButtons.ts
Normal file
46
frontend/src/hooks/useRightRailButtons.ts
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import { useEffect, useMemo } 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: readonly RightRailButtonWithAction[]) {
|
||||||
|
const { registerButtons, unregisterButtons, setAction } = useRightRail();
|
||||||
|
|
||||||
|
// Memoize configs and ids to reduce churn
|
||||||
|
const configs: RightRailButtonConfig[] = useMemo(
|
||||||
|
() => buttons.map(({ onClick, ...cfg }) => cfg),
|
||||||
|
[buttons]
|
||||||
|
);
|
||||||
|
const ids: string[] = useMemo(() => buttons.map(b => b.id), [buttons]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!buttons || buttons.length === 0) return;
|
||||||
|
|
||||||
|
// DEV warnings for duplicate ids or missing handlers
|
||||||
|
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 (idSet.has(b.id)) console.warn('[RightRail] Duplicate id in buttons array:', b.id);
|
||||||
|
idSet.add(b.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register visual button configs (idempotent merge by id)
|
||||||
|
registerButtons(configs);
|
||||||
|
|
||||||
|
// Bind/update actions independent of registration
|
||||||
|
buttons.forEach(({ id, onClick }) => setAction(id, onClick));
|
||||||
|
|
||||||
|
// Cleanup unregisters by ids present in this call
|
||||||
|
return () => unregisterButtons(ids);
|
||||||
|
}, [registerButtons, unregisterButtons, setAction, configs, ids, buttons]);
|
||||||
|
}
|
@ -9,6 +9,7 @@ import { getBaseUrl } from "../constants/app";
|
|||||||
import ToolPanel from "../components/tools/ToolPanel";
|
import ToolPanel from "../components/tools/ToolPanel";
|
||||||
import Workbench from "../components/layout/Workbench";
|
import Workbench from "../components/layout/Workbench";
|
||||||
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
import QuickAccessBar from "../components/shared/QuickAccessBar";
|
||||||
|
import RightRail from "../components/shared/RightRail";
|
||||||
import FileManager from "../components/FileManager";
|
import FileManager from "../components/FileManager";
|
||||||
|
|
||||||
|
|
||||||
@ -46,6 +47,7 @@ export default function HomePage() {
|
|||||||
ref={quickAccessRef} />
|
ref={quickAccessRef} />
|
||||||
<ToolPanel />
|
<ToolPanel />
|
||||||
<Workbench />
|
<Workbench />
|
||||||
|
<RightRail />
|
||||||
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
<FileManager selectedTool={selectedTool as any /* FIX ME */} />
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
|
@ -5,6 +5,7 @@ export interface ExportOptions {
|
|||||||
selectedOnly?: boolean;
|
selectedOnly?: boolean;
|
||||||
filename?: string;
|
filename?: string;
|
||||||
splitDocuments?: boolean;
|
splitDocuments?: boolean;
|
||||||
|
appendSuffix?: boolean; // when false, do not append _edited/_selected
|
||||||
}
|
}
|
||||||
|
|
||||||
export class PDFExportService {
|
export class PDFExportService {
|
||||||
@ -16,7 +17,7 @@ export class PDFExportService {
|
|||||||
selectedPageIds: string[] = [],
|
selectedPageIds: string[] = [],
|
||||||
options: ExportOptions = {}
|
options: ExportOptions = {}
|
||||||
): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> {
|
): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> {
|
||||||
const { selectedOnly = false, filename, splitDocuments = false } = options;
|
const { selectedOnly = false, filename, splitDocuments = false, appendSuffix = true } = options;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Determine which pages to export
|
// Determine which pages to export
|
||||||
@ -36,7 +37,7 @@ export class PDFExportService {
|
|||||||
return await this.createSplitDocuments(sourceDoc, pagesToExport, filename || pdfDocument.name);
|
return await this.createSplitDocuments(sourceDoc, pagesToExport, filename || pdfDocument.name);
|
||||||
} else {
|
} else {
|
||||||
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
|
const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
|
||||||
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly);
|
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, appendSuffix);
|
||||||
return { blob, filename: exportFilename };
|
return { blob, filename: exportFilename };
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -56,7 +57,7 @@ export class PDFExportService {
|
|||||||
|
|
||||||
for (const page of pages) {
|
for (const page of pages) {
|
||||||
// Get the original page from source document
|
// Get the original page from source document
|
||||||
const sourcePageIndex = page.pageNumber - 1;
|
const sourcePageIndex = this.getOriginalSourceIndex(page);
|
||||||
|
|
||||||
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||||
// Copy the page
|
// Copy the page
|
||||||
@ -113,7 +114,7 @@ export class PDFExportService {
|
|||||||
const newDoc = await PDFLibDocument.create();
|
const newDoc = await PDFLibDocument.create();
|
||||||
|
|
||||||
for (const page of segmentPages) {
|
for (const page of segmentPages) {
|
||||||
const sourcePageIndex = page.pageNumber - 1;
|
const sourcePageIndex = this.getOriginalSourceIndex(page);
|
||||||
|
|
||||||
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
|
||||||
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
|
||||||
@ -146,11 +147,28 @@ export class PDFExportService {
|
|||||||
return { blobs, filenames };
|
return { blobs, filenames };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the original page index from a page's stable id.
|
||||||
|
* Falls back to the current pageNumber if parsing fails.
|
||||||
|
*/
|
||||||
|
private getOriginalSourceIndex(page: PDFPage): number {
|
||||||
|
const match = page.id.match(/-page-(\d+)$/);
|
||||||
|
if (match) {
|
||||||
|
const originalNumber = parseInt(match[1], 10);
|
||||||
|
if (!Number.isNaN(originalNumber)) {
|
||||||
|
return originalNumber - 1; // zero-based index for pdf-lib
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback to the visible page number
|
||||||
|
return Math.max(0, page.pageNumber - 1);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate appropriate filename for export
|
* Generate appropriate filename for export
|
||||||
*/
|
*/
|
||||||
private generateFilename(originalName: string, selectedOnly: boolean): string {
|
private generateFilename(originalName: string, selectedOnly: boolean, appendSuffix: boolean): string {
|
||||||
const baseName = originalName.replace(/\.pdf$/i, '');
|
const baseName = originalName.replace(/\.pdf$/i, '');
|
||||||
|
if (!appendSuffix) return `${baseName}.pdf`;
|
||||||
const suffix = selectedOnly ? '_selected' : '_edited';
|
const suffix = selectedOnly ? '_selected' : '_edited';
|
||||||
return `${baseName}${suffix}.pdf`;
|
return `${baseName}${suffix}.pdf`;
|
||||||
}
|
}
|
||||||
|
@ -106,6 +106,12 @@
|
|||||||
--icon-config-bg: #9CA3AF;
|
--icon-config-bg: #9CA3AF;
|
||||||
--icon-config-color: #FFFFFF;
|
--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 */
|
/* Colors for tooltips */
|
||||||
--tooltip-title-bg: #DBEFFF;
|
--tooltip-title-bg: #DBEFFF;
|
||||||
--tooltip-title-color: #31528E;
|
--tooltip-title-color: #31528E;
|
||||||
@ -234,6 +240,12 @@
|
|||||||
--icon-inactive-bg: #2A2F36;
|
--icon-inactive-bg: #2A2F36;
|
||||||
--icon-inactive-color: #6E7581;
|
--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 */
|
/* Dark mode tooltip colors */
|
||||||
--tooltip-title-bg: #4B525A;
|
--tooltip-title-bg: #4B525A;
|
||||||
--tooltip-title-color: #fff;
|
--tooltip-title-color: #fff;
|
||||||
|
26
frontend/src/types/rightRail.ts
Normal file
26
frontend/src/types/rightRail.ts
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
export type RightRailSection = 'top' | 'middle' | 'bottom';
|
||||||
|
|
||||||
|
export interface RightRailButtonConfig {
|
||||||
|
/** Unique id for the button, also used to bind action callbacks */
|
||||||
|
id: string;
|
||||||
|
/** Icon element to render */
|
||||||
|
icon: React.ReactNode;
|
||||||
|
/** Tooltip content (can be localized node) */
|
||||||
|
tooltip: React.ReactNode;
|
||||||
|
/** Optional ARIA label for a11y (separate from visual tooltip) */
|
||||||
|
ariaLabel?: string;
|
||||||
|
/** Optional i18n key carried by config */
|
||||||
|
templateKey?: string;
|
||||||
|
/** Visual grouping lane */
|
||||||
|
section?: RightRailSection;
|
||||||
|
/** Sorting within a section (lower first); ties broken by id */
|
||||||
|
order?: number;
|
||||||
|
/** Initial disabled state */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Initial visibility */
|
||||||
|
visible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RightRailAction = () => void;
|
Loading…
x
Reference in New Issue
Block a user