From 61f5221c585de6ccf5b01a209e29d214f9d2b034 Mon Sep 17 00:00:00 2001 From: EthanHealy01 <80844253+EthanHealy01@users.noreply.github.com> Date: Mon, 25 Aug 2025 12:43:20 +0100 Subject: [PATCH 1/3] Feature/v2/landing page upload buttons (#4259) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Description of Changes Screenshot 2025-08-22 at 5 28 53 PM Screenshot 2025-08-22 at 5 30 00 PM --- ## 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. --- .../public/locales/en-GB/translation.json | 4 + .../public/locales/en-US/translation.json | 4 + .../src/components/shared/LandingPage.tsx | 88 +++++++++++++++---- 3 files changed, 79 insertions(+), 17 deletions(-) diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 6b720aadb..256fab60e 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -604,6 +604,10 @@ "desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks." } }, + "landing": { + "addFiles": "Add Files", + "uploadFromComputer": "Upload from computer" + }, "viewPdf": { "tags": "view,read,annotate,text,image,highlight,edit", "title": "View/Edit PDF", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index 358ccd53a..eb0483cbe 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -607,6 +607,10 @@ "desc": "Replace color for text and background in PDF and invert full color of pdf to reduce file size" } }, + "landing": { + "addFiles": "Add Files", + "uploadFromComputer": "Upload from computer" + }, "viewPdf": { "tags": "view,read,annotate,text,image,highlight,edit", "title": "View/Edit PDF", diff --git a/frontend/src/components/shared/LandingPage.tsx b/frontend/src/components/shared/LandingPage.tsx index 6c1668a43..937e9cfbd 100644 --- a/frontend/src/components/shared/LandingPage.tsx +++ b/frontend/src/components/shared/LandingPage.tsx @@ -4,18 +4,25 @@ import { Dropzone } from '@mantine/dropzone'; import AddIcon from '@mui/icons-material/Add'; import { useTranslation } from 'react-i18next'; import { useFileHandler } from '../../hooks/useFileHandler'; +import { useFilesModalContext } from '../../contexts/FilesModalContext'; const LandingPage = () => { const { addMultipleFiles } = useFileHandler(); const fileInputRef = React.useRef(null); const { colorScheme } = useMantineColorScheme(); const { t } = useTranslation(); + const { openFilesModal } = useFilesModalContext(); + const [isUploadHover, setIsUploadHover] = React.useState(false); const handleFileDrop = async (files: File[]) => { await addMultipleFiles(files); }; - const handleAddFilesClick = () => { + const handleOpenFilesModal = () => { + openFilesModal(); + }; + + const handleNativeUploadClick = () => { fileInputRef.current?.click(); }; @@ -44,7 +51,7 @@ const LandingPage = () => { borderRadius: '0.5rem 0.5rem 0 0', filter: 'var(--drop-shadow-filter)', backgroundColor: 'var(--landing-paper-bg)', - transition: 'background-color 0.2s ease', + transition: 'background-color 0.4s ease', }} activateOnClick={false} styles={{ @@ -99,26 +106,73 @@ const LandingPage = () => { /> - {/* Add Files Button */} - + + + {/* Hidden file input for native file picker */} Date: Mon, 25 Aug 2025 12:53:33 +0100 Subject: [PATCH 2/3] Feature/v2/right rail (#4255) # Description of Changes --- ## 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> --- .../public/locales/en-GB/translation.json | 14 + .../public/locales/en-US/translation.json | 13 + frontend/src/App.tsx | 3 + .../src/components/fileEditor/FileEditor.tsx | 54 +-- frontend/src/components/layout/Workbench.tsx | 1 + .../pageEditor/BulkSelectionPanel.tsx | 6 +- .../components/pageEditor/FileThumbnail.tsx | 2 - .../src/components/pageEditor/PageEditor.tsx | 126 +++--- .../pageEditor/PageEditorControls.tsx | 91 +---- .../components/pageEditor/PageThumbnail.tsx | 9 +- .../components/shared/LanguageSelector.tsx | 115 +++--- frontend/src/components/shared/RightRail.tsx | 385 ++++++++++++++++++ frontend/src/components/shared/Tooltip.tsx | 6 +- .../src/components/shared/TopControls.tsx | 151 +++---- .../shared/rightRail/RightRail.README.md | 108 +++++ .../components/shared/rightRail/RightRail.css | 127 ++++++ .../shared/tooltip/Tooltip.module.css | 2 +- frontend/src/components/tools/ToolPicker.tsx | 6 +- frontend/src/contexts/RightRailContext.tsx | 64 +++ frontend/src/hooks/useRightRailButtons.ts | 46 +++ frontend/src/pages/HomePage.tsx | 4 +- frontend/src/services/pdfExportService.ts | 28 +- frontend/src/styles/theme.css | 12 + frontend/src/types/rightRail.ts | 26 ++ 24 files changed, 1070 insertions(+), 329 deletions(-) create mode 100644 frontend/src/components/shared/RightRail.tsx create mode 100644 frontend/src/components/shared/rightRail/RightRail.README.md create mode 100644 frontend/src/components/shared/rightRail/RightRail.css create mode 100644 frontend/src/contexts/RightRailContext.tsx create mode 100644 frontend/src/hooks/useRightRailButtons.ts create mode 100644 frontend/src/types/rightRail.ts diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 256fab60e..2c9a0a6cd 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -992,6 +992,7 @@ }, "submit": "Change" }, + "removePages": { "tags": "Remove pages,delete pages", "title": "Remove Pages", @@ -1926,6 +1927,19 @@ "currentPage": "Current Page", "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": { "searchPlaceholder": "Search tools...", "noToolsFound": "No tools found", diff --git a/frontend/public/locales/en-US/translation.json b/frontend/public/locales/en-US/translation.json index eb0483cbe..26c2e5b15 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -55,6 +55,7 @@ "bored": "Bored Waiting?", "alphabet": "Alphabet", "downloadPdf": "Download PDF", + "text": "Text", "font": "Font", "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": { "title": "Remove Password", "desc": "Remove password protection from your PDF document.", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b498b0677..ef4d663f6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -11,6 +11,7 @@ import HomePage from "./pages/HomePage"; // Import global styles import "./styles/tailwind.css"; import "./index.css"; +import { RightRailProvider } from "./contexts/RightRailContext"; // Loading component for i18next suspense const LoadingFallback = () => ( @@ -38,7 +39,9 @@ export default function App() { + + diff --git a/frontend/src/components/fileEditor/FileEditor.tsx b/frontend/src/components/fileEditor/FileEditor.tsx index c93e78670..df1197ab9 100644 --- a/frontend/src/components/fileEditor/FileEditor.tsx +++ b/frontend/src/components/fileEditor/FileEditor.tsx @@ -1,7 +1,6 @@ import React, { useState, useCallback, useRef, useEffect, useMemo } 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'; @@ -466,21 +465,6 @@ const FileEditor = ({ - - {toolMode && ( - <> - - - - )} - {showBulkActions && !toolMode && ( - <> - - - )} - {activeFileRecords.length === 0 && !zipExtractionProgress.isExtracting ? ( @@ -573,25 +557,29 @@ const FileEditor = ({ /> {status && ( - setStatus(null)} - style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} - > - {status} - + + setStatus(null)} + style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10001 }} + > + {status} + + )} {error && ( - setError(null)} - style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 1000 }} - > - {error} - + + setError(null)} + style={{ position: 'fixed', bottom: 80, right: 20, zIndex: 10001 }} + > + {error} + + )} diff --git a/frontend/src/components/layout/Workbench.tsx b/frontend/src/components/layout/Workbench.tsx index fc41d2480..a98b19b99 100644 --- a/frontend/src/components/layout/Workbench.tsx +++ b/frontend/src/components/layout/Workbench.tsx @@ -157,6 +157,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()} diff --git a/frontend/src/components/pageEditor/BulkSelectionPanel.tsx b/frontend/src/components/pageEditor/BulkSelectionPanel.tsx index 5a6b4504f..b9ebb8d2c 100644 --- a/frontend/src/components/pageEditor/BulkSelectionPanel.tsx +++ b/frontend/src/components/pageEditor/BulkSelectionPanel.tsx @@ -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 ( - + <> )} - + ); }; diff --git a/frontend/src/components/pageEditor/FileThumbnail.tsx b/frontend/src/components/pageEditor/FileThumbnail.tsx index d84eb2a16..609a31e1a 100644 --- a/frontend/src/components/pageEditor/FileThumbnail.tsx +++ b/frontend/src/components/pageEditor/FileThumbnail.tsx @@ -141,7 +141,6 @@ const FileThumbnail = ({ filter: isSupported ? 'none' : 'grayscale(50%)' }} > - {selectionMode && (
- )} {/* File content area */}
diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 7ca640b06..543778d9e 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -1,14 +1,13 @@ 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 { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext"; import { ModeType } from "../../contexts/NavigationContext"; import { PDFDocument, PDFPage } from "../../types/pageEditor"; -import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing"; import { useUndoRedo } from "../../hooks/useUndoRedo"; import { RotatePagesCommand, @@ -56,7 +55,6 @@ export interface PageEditorProps { const PageEditor = ({ onFunctionsReady, }: PageEditorProps) => { - const { t } = useTranslation(); // Use split contexts to prevent re-renders const { state, selectors } = useFileState(); @@ -241,19 +239,26 @@ const PageEditor = ({ const [exportLoading, setExportLoading] = useState(false); const [showExportModal, setShowExportModal] = useState(false); const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null); + const [exportSelectedOnly, setExportSelectedOnly] = useState(false); // Animation state const [movingPage, setMovingPage] = useState(null); - const [pagePositions, setPagePositions] = useState>(new Map()); const [isAnimating, setIsAnimating] = useState(false); - const pageRefs = useRef>(new Map()); - const fileInputRef = useRef<() => void>(null); // Undo/Redo system 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 useEffect(() => { + if (userEditedFilename.current) return; // Do not overwrite user-typed filename if (mergedPdfDocument) { if (activeFileIds.length === 1 && primaryFileId) { const record = selectors.getFileRecord(primaryFileId); @@ -838,14 +843,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, @@ -857,7 +866,7 @@ const PageEditor = ({ if (selectionMode) { actions.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, actions]); @@ -885,49 +894,52 @@ const PageEditor = ({ }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]); 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 const exportPageIds = selectedOnly ? selectedPageNumbers.map(pageNum => { - const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum); + const page = doc.pages.find(p => p.pageNumber === pageNum); return page?.id || ''; }).filter(id => id) : []; - - const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly); + const preview = pdfExportService.getExportInfo(doc, exportPageIds, selectedOnly); setExportPreview(preview); + setExportSelectedOnly(selectedOnly); setShowExportModal(true); - }, [mergedPdfDocument, selectedPageNumbers]); + }, [editedDocument, mergedPdfDocument, selectedPageNumbers]); const handleExport = useCallback(async (selectedOnly: boolean = false) => { - if (!mergedPdfDocument) return; + const doc = editedDocument || mergedPdfDocument; + if (!doc) return; setExportLoading(true); try { // Convert page numbers to page IDs for export service const exportPageIds = selectedOnly ? selectedPageNumbers.map(pageNum => { - const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum); + const page = doc.pages.find(p => p.pageNumber === pageNum); return page?.id || ''; }).filter(id => id) : []; - const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly); + const errors = pdfExportService.validateExport(doc, exportPageIds, selectedOnly); if (errors.length > 0) { setStatus(errors.join(', ')); return; } - const hasSplitMarkers = mergedPdfDocument.pages.some(page => page.splitBefore); + const hasSplitMarkers = doc.pages.some(page => page.splitBefore); if (hasSplitMarkers) { - const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, { + const result = await pdfExportService.exportPDF(doc, exportPageIds, { selectedOnly, filename, - splitDocuments: true + splitDocuments: true, + appendSuffix: false }) as { blobs: Blob[]; filenames: string[] }; result.blobs.forEach((blob, index) => { @@ -938,9 +950,10 @@ const PageEditor = ({ setStatus(`Exported ${result.blobs.length} split documents`); } else { - const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, { + const result = await pdfExportService.exportPDF(doc, exportPageIds, { selectedOnly, - filename + filename, + appendSuffix: false }) as { blob: Blob; filename: string }; pdfExportService.downloadFile(result.blob, result.filename); @@ -953,7 +966,7 @@ const PageEditor = ({ } finally { setExportLoading(false); } - }, [mergedPdfDocument, selectedPageNumbers, filename]); + }, [editedDocument, mergedPdfDocument, selectedPageNumbers, filename]); const handleUndo = useCallback(() => { if (undo()) { @@ -1240,59 +1253,13 @@ const PageEditor = ({
)} - - setFilename(e.target.value)} placeholder="Enter filename" - style={{ minWidth: 200 }} + style={{ minWidth: 200, maxWidth: 200, marginLeft: "1rem"}} /> - - {selectionMode && ( - <> - - - - )} - - - {/* Apply Changes Button */} - {hasUnsavedChanges && ( - - )} - - - {selectionMode && ( - - )} - { setShowExportModal(false); - const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.pages.length || 0); - handleExport(selectedOnly); + handleExport(exportSelectedOnly); }} > Export PDF @@ -1446,14 +1412,16 @@ const PageEditor = ({ {status && ( + setStatus(null)} - style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }} + style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10000 }} > {status} + )} {error && ( diff --git a/frontend/src/components/pageEditor/PageEditorControls.tsx b/frontend/src/components/pageEditor/PageEditorControls.tsx index 43e224e2b..2b0c6ee3c 100644 --- a/frontend/src/components/pageEditor/PageEditorControls.tsx +++ b/frontend/src/components/pageEditor/PageEditorControls.tsx @@ -2,16 +2,12 @@ import React from "react"; import { Tooltip, ActionIcon, - Paper } from "@mantine/core"; 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 { // Close/Reset functions @@ -39,17 +35,12 @@ interface PageEditorControlsProps { } const PageEditorControls = ({ - onClosePdf, onUndo, onRedo, canUndo, canRedo, onRotate, - onDelete, onSplit, - onExportSelected, - onExportAll, - exportLoading, selectionMode, selectedPages }: PageEditorControlsProps) => { @@ -57,9 +48,9 @@ const PageEditorControls = ({
- - {/* Close PDF */} - - - - - - -
{/* Undo/Redo */} @@ -133,17 +118,6 @@ const PageEditorControls = ({ - - 0 ? "light" : "default"} - size="lg" - > - - - -
- - {/* Export Controls */} - {selectionMode && selectedPages.length > 0 && ( - - - - - - )} - - - - - - +
); }; diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index f1590978a..7360b4dce 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -205,7 +205,7 @@ const PageThumbnail = React.memo(({ }} draggable={false} > - {selectionMode && ( + {
- )} + }
{ +interface LanguageSelectorProps { + position?: React.ComponentProps['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 = () => { @@ -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 = () => { }} > - + }} + > + language + + ) : ( + + )} { }} > {option.label} - - {/* Ripple effect */} - {rippleEffect && pendingLanguage === option.value && ( + {!compact && rippleEffect && pendingLanguage === option.value && (
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(""); + + // 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(currentView === 'pageEditor'); + const [pageControlsVisible, setPageControlsVisible] = useState(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 ( +
+
+ {topButtons.length > 0 && ( + <> +
+ {topButtons.map(btn => ( + + actions[btn.id]?.()} + disabled={btn.disabled} + > + {btn.icon} + + + ))} +
+ + + )} + + {/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */} +
+
+ {/* Select All Button */} + +
+ + + select_all + + +
+
+ + {/* Deselect All Button */} + +
+ + + crop_square + + +
+
+ + {/* Select by Numbers - page editor only, with animated presence */} + {pageControlsMounted && ( + + +
+ + +
+ + + pin_end + + +
+
+ +
+ +
+
+
+
+
+ + )} + + {/* Delete Selected Pages - page editor only, with animated presence */} + {pageControlsMounted && ( + + +
+
+ { 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'} + > + delete + +
+
+
+ + )} + + {/* Close (File Editor: Close Selected | Page Editor: Close PDF) */} + +
+ pageEditorFunctions?.closePdf?.() : handleCloseSelected} + disabled={ + currentView === 'viewer' || + (currentView === 'fileEditor' && selectedCount === 0) || + (currentView === 'pageEditor' && (activeFiles.length === 0 || !pageEditorFunctions?.closePdf)) + } + > + + +
+
+
+ + +
+ + {/* Theme toggle and Language dropdown */} +
+ + + contrast + + + + + + 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All')) + } position="left" offset={12} arrow> +
+ + + download + + +
+
+
+ +
+
+
+ ); +} + + diff --git a/frontend/src/components/shared/Tooltip.tsx b/frontend/src/components/shared/Tooltip.tsx index c415eddf5..4c216d318 100644 --- a/frontend/src/components/shared/Tooltip.tsx +++ b/frontend/src/components/shared/Tooltip.tsx @@ -124,8 +124,8 @@ export const Tooltip: React.FC = ({ 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 = ({ 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', diff --git a/frontend/src/components/shared/TopControls.tsx b/frontend/src/components/shared/TopControls.tsx index ee5591694..229c3d362 100644 --- a/frontend/src/components/shared/TopControls.tsx +++ b/frontend/src/components/shared/TopControls.tsx @@ -1,23 +1,64 @@ -import React, { useState, useCallback, useMemo } from "react"; -import { Button, SegmentedControl, Loader } from "@mantine/core"; +import React, { useState, useCallback } from "react"; +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"; -import { ModeType } from '../../contexts/NavigationContext'; +import { ModeType, isValidMode } from '../../contexts/NavigationContext'; -// Stable view option objects that don't recreate on every render -const VIEW_OPTIONS_BASE = [ - { value: "viewer", icon: VisibilityIcon }, - { value: "pageEditor", icon: EditNoteIcon }, - { value: "fileEditor", icon: FolderIcon }, -] as const; +const viewOptionStyle = { + display: 'inline-flex', + flexDirection: 'row', + alignItems: 'center', + gap: 6, + whiteSpace: 'nowrap', + paddingTop: '0.3rem', +} + + +// Create view options with icons and loading states +const createViewOptions = (switchingTo: ModeType | null) => [ + { + label: ( +
+ {switchingTo === "viewer" ? ( + + ) : ( + + )} + Read +
+ ), + value: "viewer", + }, + { + label: ( +
+ {switchingTo === "pageEditor" ? ( + + ) : ( + + )} + Page Editor +
+ ), + value: "pageEditor", + }, + { + label: ( +
+ {switchingTo === "fileEditor" ? ( + + ) : ( + + )} + File Manager +
+ ), + value: "fileEditor", + }, +]; interface TopControlsProps { currentView: ModeType; @@ -30,90 +71,60 @@ const TopControls = ({ setCurrentView, selectedToolKey, }: TopControlsProps) => { - const { themeMode, isRainbowMode, isToggleDisabled, toggleTheme } = useRainbowThemeContext(); - const [switchingTo, setSwitchingTo] = useState(null); + const { isRainbowMode } = useRainbowThemeContext(); + const [switchingTo, setSwitchingTo] = useState(null); const isToolSelected = selectedToolKey !== null; const handleViewChange = useCallback((view: string) => { - // Guard against redundant changes - if (view === currentView) return; - + if (!isValidMode(view)) { + // Ignore invalid values defensively + return; + } + const mode = view as ModeType; + // Show immediate feedback - setSwitchingTo(view); + setSwitchingTo(mode as ModeType); // Defer the heavy view change to next frame so spinner can render requestAnimationFrame(() => { // Give the spinner one more frame to show requestAnimationFrame(() => { - setCurrentView(view as ModeType); - + setCurrentView(mode as ModeType); + // Clear the loading state after view change completes setTimeout(() => setSwitchingTo(null), 300); }); }); - }, [setCurrentView, currentView]); - - // Memoize the SegmentedControl data with stable references - const viewOptions = useMemo(() => - VIEW_OPTIONS_BASE.map(option => ({ - value: option.value, - label: ( - - {switchingTo === option.value ? ( - - ) : ( - - )} - - ) - })), [switchingTo]); - - const getThemeIcon = () => { - if (isRainbowMode) return ; - if (themeMode === "dark") return ; - return ; - }; + }, [setCurrentView]); return (
-
- - -
{!isToolSelected && ( -
+
diff --git a/frontend/src/components/shared/rightRail/RightRail.README.md b/frontend/src/components/shared/rightRail/RightRail.README.md new file mode 100644 index 000000000..7506e927c --- /dev/null +++ b/frontend/src/components/shared/rightRail/RightRail.README.md @@ -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: , + tooltip: 'Execute Action', + onClick: handleAction, + }, + ]); + + return
My Tool
; +} +``` + +### Multiple Buttons + +```tsx +useRightRailButtons([ + { + id: 'primary', + icon: , + tooltip: 'Primary Action', + order: 1, + onClick: handlePrimary, + }, + { + id: 'secondary', + icon: , + tooltip: 'Secondary Action', + order: 2, + onClick: handleSecondary, + }, +]); +``` + +### Conditional Buttons + +```tsx +useRightRailButtons([ + // Always show + { + id: 'process', + icon: , + tooltip: 'Process', + disabled: isProcessing, + onClick: handleProcess, + }, + // Only show when condition met + ...(hasResults ? [{ + id: 'export', + icon: , + 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 diff --git a/frontend/src/components/shared/rightRail/RightRail.css b/frontend/src/components/shared/rightRail/RightRail.css new file mode 100644 index 000000000..8d01052a9 --- /dev/null +++ b/frontend/src/components/shared/rightRail/RightRail.css @@ -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; +} + diff --git a/frontend/src/components/shared/tooltip/Tooltip.module.css b/frontend/src/components/shared/tooltip/Tooltip.module.css index 46902c04b..50c242812 100644 --- a/frontend/src/components/shared/tooltip/Tooltip.module.css +++ b/frontend/src/components/shared/tooltip/Tooltip.module.css @@ -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; } diff --git a/frontend/src/components/tools/ToolPicker.tsx b/frontend/src/components/tools/ToolPicker.tsx index a8dbd7993..d81bf5ef0 100644 --- a/frontend/src/components/tools/ToolPicker.tsx +++ b/frontend/src/components/tools/ToolPicker.tsx @@ -85,7 +85,8 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa overflowY: "auto", overflowX: "hidden", minHeight: 0, - height: "100%" + height: "100%", + marginTop: -2 }} className="tool-picker-scrollable" > @@ -109,7 +110,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)", @@ -117,7 +117,7 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa cursor: "pointer", display: "flex", alignItems: "center", - justifyContent: "space-between" + justifyContent: "space-between", }} onClick={() => scrollTo(quickAccessRef)} > diff --git a/frontend/src/contexts/RightRailContext.tsx b/frontend/src/contexts/RightRailContext.tsx new file mode 100644 index 000000000..be3b7276c --- /dev/null +++ b/frontend/src/contexts/RightRailContext.tsx @@ -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; + registerButtons: (buttons: RightRailButtonConfig[]) => void; + unregisterButtons: (ids: string[]) => void; + setAction: (id: string, action: RightRailAction) => void; + clear: () => void; +} + +const RightRailContext = createContext(undefined); + +export function RightRailProvider({ children }: { children: React.ReactNode }) { + const [buttons, setButtons] = useState([]); + const [actions, setActions] = useState>({}); + + 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(() => ({ buttons, actions, registerButtons, unregisterButtons, setAction, clear }), [buttons, actions, registerButtons, unregisterButtons, setAction, clear]); + + return ( + + {children} + + ); +} + +export function useRightRail() { + const ctx = useContext(RightRailContext); + if (!ctx) throw new Error('useRightRail must be used within RightRailProvider'); + return ctx; +} diff --git a/frontend/src/hooks/useRightRailButtons.ts b/frontend/src/hooks/useRightRailButtons.ts new file mode 100644 index 000000000..82a4e8cd5 --- /dev/null +++ b/frontend/src/hooks/useRightRailButtons.ts @@ -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(); + 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]); +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index eeb23e83f..12c1f4d7f 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -9,6 +9,7 @@ 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"; @@ -46,7 +47,8 @@ export default function HomePage() { ref={quickAccessRef} /> + ); -} +} \ No newline at end of file diff --git a/frontend/src/services/pdfExportService.ts b/frontend/src/services/pdfExportService.ts index b0662437e..9345133b8 100644 --- a/frontend/src/services/pdfExportService.ts +++ b/frontend/src/services/pdfExportService.ts @@ -5,6 +5,7 @@ export interface ExportOptions { selectedOnly?: boolean; filename?: string; splitDocuments?: boolean; + appendSuffix?: boolean; // when false, do not append _edited/_selected } export class PDFExportService { @@ -16,7 +17,7 @@ export class PDFExportService { selectedPageIds: string[] = [], options: ExportOptions = {} ): 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 { // Determine which pages to export @@ -36,7 +37,7 @@ export class PDFExportService { return await this.createSplitDocuments(sourceDoc, pagesToExport, filename || pdfDocument.name); } else { 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 }; } } catch (error) { @@ -56,7 +57,7 @@ export class PDFExportService { for (const page of pages) { // Get the original page from source document - const sourcePageIndex = page.pageNumber - 1; + const sourcePageIndex = this.getOriginalSourceIndex(page); if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { // Copy the page @@ -113,7 +114,7 @@ export class PDFExportService { const newDoc = await PDFLibDocument.create(); for (const page of segmentPages) { - const sourcePageIndex = page.pageNumber - 1; + const sourcePageIndex = this.getOriginalSourceIndex(page); if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); @@ -146,11 +147,28 @@ export class PDFExportService { 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 */ - private generateFilename(originalName: string, selectedOnly: boolean): string { + private generateFilename(originalName: string, selectedOnly: boolean, appendSuffix: boolean): string { const baseName = originalName.replace(/\.pdf$/i, ''); + if (!appendSuffix) return `${baseName}.pdf`; const suffix = selectedOnly ? '_selected' : '_edited'; return `${baseName}${suffix}.pdf`; } diff --git a/frontend/src/styles/theme.css b/frontend/src/styles/theme.css index 634cae91c..a8efa179e 100644 --- a/frontend/src/styles/theme.css +++ b/frontend/src/styles/theme.css @@ -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; diff --git a/frontend/src/types/rightRail.ts b/frontend/src/types/rightRail.ts new file mode 100644 index 000000000..1897a7170 --- /dev/null +++ b/frontend/src/types/rightRail.ts @@ -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; From e6f4cfb3188f3be73dacfbec656623258db5bc05 Mon Sep 17 00:00:00 2001 From: ConnorYoh <40631091+ConnorYoh@users.noreply.github.com> Date: Mon, 25 Aug 2025 13:10:13 +0100 Subject: [PATCH 3/3] Automate/v2/suggested (#4257) Suggested pipelines now work --------- Co-authored-by: Connor Yoh Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../tools/automate/useSuggestedAutomations.ts | 119 +++++++++++++++--- frontend/src/tools/Automate.tsx | 23 +++- 2 files changed, 119 insertions(+), 23 deletions(-) diff --git a/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts b/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts index bb1ed5916..9ddce1e0b 100644 --- a/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts +++ b/frontend/src/hooks/tools/automate/useSuggestedAutomations.ts @@ -1,6 +1,9 @@ import { useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import StarIcon from '@mui/icons-material/Star'; +import CompressIcon from '@mui/icons-material/Compress'; +import SecurityIcon from '@mui/icons-material/Security'; +import TextFieldsIcon from '@mui/icons-material/TextFields'; import { SuggestedAutomation } from '../../../types/automation'; export function useSuggestedAutomations(): SuggestedAutomation[] { @@ -10,37 +13,119 @@ export function useSuggestedAutomations(): SuggestedAutomation[] { const now = new Date().toISOString(); return [ { - id: "compress-and-merge", - name: t("automation.suggested.compressAndMerge", "Compress & Merge"), - description: t("automation.suggested.compressAndMergeDesc", "Compress PDFs and merge them into one file"), + id: "compress-and-split", + name: t("automation.suggested.compressAndSplit", "Compress & Split"), + description: t("automation.suggested.compressAndSplitDesc", "Compress PDFs and split them by pages"), operations: [ - { operation: "compress", parameters: {} }, - { operation: "merge", parameters: {} } + { + operation: "compress", + parameters: { + compressionLevel: 5, + grayscale: false, + expectedSize: '', + compressionMethod: 'quality', + fileSizeValue: '', + fileSizeUnit: 'MB', + } + }, + { + operation: "splitPdf", + parameters: { + mode: 'bySizeOrCount', + pages: '1', + hDiv: '2', + vDiv: '2', + merge: false, + splitType: 'pages', + splitValue: '1', + bookmarkLevel: '1', + includeMetadata: false, + allowDuplicates: false, + } + } ], createdAt: now, updatedAt: now, - icon: StarIcon, + icon: CompressIcon, }, { - id: "ocr-and-convert", - name: t("automation.suggested.ocrAndConvert", "OCR & Convert"), - description: t("automation.suggested.ocrAndConvertDesc", "Extract text via OCR and convert to different format"), + id: "ocr-workflow", + name: t("automation.suggested.ocrWorkflow", "OCR Processing"), + description: t("automation.suggested.ocrWorkflowDesc", "Extract text from PDFs using OCR technology"), operations: [ - { operation: "ocr", parameters: {} }, - { operation: "convert", parameters: {} } + { + operation: "ocr", + parameters: { + languages: ['eng'], + ocrType: 'skip-text', + ocrRenderType: 'hocr', + additionalOptions: [], + } + } ], createdAt: now, updatedAt: now, - icon: StarIcon, + icon: TextFieldsIcon, }, { id: "secure-workflow", - name: t("automation.suggested.secureWorkflow", "Secure Workflow"), - description: t("automation.suggested.secureWorkflowDesc", "Sanitize, add password, and set permissions"), + name: t("automation.suggested.secureWorkflow", "Security Workflow"), + description: t("automation.suggested.secureWorkflowDesc", "Sanitize PDFs and add password protection"), operations: [ - { operation: "sanitize", parameters: {} }, - { operation: "addPassword", parameters: {} }, - { operation: "changePermissions", parameters: {} } + { + operation: "sanitize", + parameters: { + removeJavaScript: true, + removeEmbeddedFiles: true, + removeXMPMetadata: false, + removeMetadata: false, + removeLinks: false, + removeFonts: false, + } + }, + { + operation: "addPassword", + parameters: { + password: 'password', + ownerPassword: '', + keyLength: 128, + permissions: { + preventAssembly: false, + preventExtractContent: false, + preventExtractForAccessibility: false, + preventFillInForm: false, + preventModify: false, + preventModifyAnnotations: false, + preventPrinting: false, + preventPrintingFaithful: false, + } + } + } + ], + createdAt: now, + updatedAt: now, + icon: SecurityIcon, + }, + { + id: "optimization-workflow", + name: t("automation.suggested.optimizationWorkflow", "Optimization Workflow"), + description: t("automation.suggested.optimizationWorkflowDesc", "Repair and compress PDFs for better performance"), + operations: [ + { + operation: "repair", + parameters: {} + }, + { + operation: "compress", + parameters: { + compressionLevel: 7, + grayscale: false, + expectedSize: '', + compressionMethod: 'quality', + fileSizeValue: '', + fileSizeUnit: 'MB', + } + } ], createdAt: now, updatedAt: now, diff --git a/frontend/src/tools/Automate.tsx b/frontend/src/tools/Automate.tsx index 54538781b..af6b3d411 100644 --- a/frontend/src/tools/Automate.tsx +++ b/frontend/src/tools/Automate.tsx @@ -33,13 +33,19 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { if (currentStep === AUTOMATION_STEPS.RUN && data.step !== AUTOMATION_STEPS.RUN) { automateOperation.resetResults(); } - + + // If navigating to selection step, always clear results + if (data.step === AUTOMATION_STEPS.SELECTION) { + automateOperation.resetResults(); + automateOperation.clearError(); + } + // If navigating to run step with a different automation, reset results - if (data.step === AUTOMATION_STEPS.RUN && data.automation && + if (data.step === AUTOMATION_STEPS.RUN && data.automation && stepData.automation && data.automation.id !== stepData.automation.id) { automateOperation.resetResults(); } - + setStepData(data); setCurrentStep(data.step); }; @@ -47,7 +53,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { const handleComplete = () => { // Reset automation results when completing automateOperation.resetResults(); - + // Reset to selection step setCurrentStep(AUTOMATION_STEPS.SELECTION); setStepData({ step: AUTOMATION_STEPS.SELECTION }); @@ -127,7 +133,12 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { createStep(t('automate.selection.title', 'Automation Selection'), { isVisible: true, isCollapsed: currentStep !== AUTOMATION_STEPS.SELECTION, - onCollapsedClick: () => setCurrentStep(AUTOMATION_STEPS.SELECTION) + onCollapsedClick: () => { + // Clear results when clicking back to selection + automateOperation.resetResults(); + setCurrentStep(AUTOMATION_STEPS.SELECTION); + setStepData({ step: AUTOMATION_STEPS.SELECTION }); + } }, currentStep === AUTOMATION_STEPS.SELECTION ? renderCurrentStep() : null), createStep(stepData.mode === AutomationMode.EDIT @@ -158,7 +169,7 @@ const Automate = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => { }, steps: automationSteps, review: { - isVisible: hasResults, + isVisible: hasResults && currentStep === AUTOMATION_STEPS.RUN, operation: automateOperation, title: t('automate.reviewTitle', 'Automation Results') }