diff --git a/frontend/public/locales/en-GB/translation.json b/frontend/public/locales/en-GB/translation.json index 0a03fd01a..b9ad0614a 100644 --- a/frontend/public/locales/en-GB/translation.json +++ b/frontend/public/locales/en-GB/translation.json @@ -1920,6 +1920,18 @@ "currentPage": "Current Page", "totalPages": "Total Pages" }, + "rightRail": { + "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 358ccd53a..6ef88a2fc 100644 --- a/frontend/public/locales/en-US/translation.json +++ b/frontend/public/locales/en-US/translation.json @@ -55,6 +55,18 @@ "bored": "Bored Waiting?", "alphabet": "Alphabet", "downloadPdf": "Download PDF", + "rightRail": { + "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" + }, "text": "Text", "font": "Font", "selectFillter": "-- Select --", diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index dd2de63ce..543778d9e 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -239,6 +239,7 @@ 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); @@ -247,8 +248,17 @@ const PageEditor = ({ // 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); @@ -884,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) => { @@ -937,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); @@ -952,7 +966,7 @@ const PageEditor = ({ } finally { setExportLoading(false); } - }, [mergedPdfDocument, selectedPageNumbers, filename]); + }, [editedDocument, mergedPdfDocument, selectedPageNumbers, filename]); const handleUndo = useCallback(() => { if (undo()) { @@ -1240,10 +1254,11 @@ const PageEditor = ({ )} setFilename(e.target.value)} placeholder="Enter filename" - style={{ minWidth: 200 }} + style={{ minWidth: 200, maxWidth: 200, marginLeft: "1rem"}} /> @@ -1338,8 +1353,7 @@ const PageEditor = ({ loading={exportLoading} onClick={() => { setShowExportModal(false); - const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.pages.length || 0); - handleExport(selectedOnly); + handleExport(exportSelectedOnly); }} > Export PDF diff --git a/frontend/src/components/pageEditor/PageEditorControls.tsx b/frontend/src/components/pageEditor/PageEditorControls.tsx index 726fdff6b..2b0c6ee3c 100644 --- a/frontend/src/components/pageEditor/PageEditorControls.tsx +++ b/frontend/src/components/pageEditor/PageEditorControls.tsx @@ -2,14 +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 RotateLeftIcon from "@mui/icons-material/RotateLeft"; import RotateRightIcon from "@mui/icons-material/RotateRight"; -import CloseIcon from "@mui/icons-material/Close"; interface PageEditorControlsProps { // Close/Reset functions @@ -37,17 +35,12 @@ interface PageEditorControlsProps { } const PageEditorControls = ({ - onClosePdf, onUndo, onRedo, canUndo, canRedo, onRotate, - onDelete, onSplit, - onExportSelected, - onExportAll, - exportLoading, selectionMode, selectedPages }: PageEditorControlsProps) => { @@ -87,19 +80,6 @@ const PageEditorControls = ({ paddingBottom: "2rem" }} > - {/* Close PDF */} - - - - - - -
{/* Undo/Redo */} diff --git a/frontend/src/components/shared/RightRail.tsx b/frontend/src/components/shared/RightRail.tsx index f3eaa0470..6676e47ae 100644 --- a/frontend/src/components/shared/RightRail.tsx +++ b/frontend/src/components/shared/RightRail.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState, useEffect } from 'react'; +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'; @@ -16,7 +16,7 @@ export default function RightRail() { const { t } = useTranslation(); const { toggleTheme } = useRainbowThemeContext(); const { buttons, actions } = useRightRail(); - const topButtons = buttons.filter(b => (b.section || 'top') === 'top' && (b.visible ?? true)); + 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(); @@ -33,6 +33,7 @@ export default function RightRail() { const { removeFiles } = useFileManagement(); const activeFiles = selectors.getFiles(); + const filesSignature = selectors.getFilesSignature(); const fileRecords = selectors.getFileRecords(); // Compute selection state and total items @@ -149,9 +150,16 @@ export default function RightRail() { }, []); const updatePagesFromCSV = useCallback(() => { - const pageNumbers = parseCSVInput(csvInput); - setSelectedPages(pageNumbers); - }, [csvInput, parseCSVInput, setSelectedPages]); + 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(() => { @@ -162,10 +170,10 @@ export default function RightRail() { setCsvInput(newCsvInput); }, [selectedPageNumbers]); - // Clear CSV input when files change + // Clear CSV input when files change (use stable signature to avoid ref churn) useEffect(() => { setCsvInput(""); - }, [activeFiles]); + }, [filesSignature]); // Mount/visibility for page-editor-only buttons to allow exit animation, then remove to avoid flex gap const [pageControlsMounted, setPageControlsMounted] = useState(currentView === 'pageEditor'); @@ -217,7 +225,7 @@ export default function RightRail() { >
{/* Select All Button */} - +
{/* Deselect All Button */} - +
+
@@ -263,6 +271,7 @@ export default function RightRail() { 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'} > pin_end @@ -288,7 +297,7 @@ export default function RightRail() { {/* Delete Selected Pages - page editor only, with animated presence */} {pageControlsMounted && ( - +
@@ -296,8 +305,9 @@ export default function RightRail() { variant="subtle" radius="md" className="right-rail-icon" - onClick={() => pageEditorFunctions?.handleDelete?.()} + 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'} > delete @@ -308,7 +318,7 @@ export default function RightRail() { )} {/* Close (File Editor: Close Selected | Page Editor: Close PDF) */} - +
- + 0 ? 'Download Selected Files' : 'Download All') + ? t('rightRail.exportAll', 'Export PDF') + : (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All')) } position="left" offset={12} arrow>
[ { label: ( -
+
{switchingTo === "viewer" ? ( ) : ( @@ -24,7 +34,7 @@ const createViewOptions = (switchingTo: ModeType | null) => [ }, { label: ( -
+
{switchingTo === "pageEditor" ? ( ) : ( @@ -37,7 +47,7 @@ const createViewOptions = (switchingTo: ModeType | null) => [ }, { label: ( -
+
{switchingTo === "fileEditor" ? ( ) : ( @@ -91,7 +101,7 @@ const TopControls = ({ return (
{!isToolSelected && ( -
+
{ setButtons(prev => { - const merged = [...prev.filter(b => !newButtons.some(nb => nb.id === b.id)), ...newButtons]; - return merged.sort((a, b) => (a.order || 0) - (b.order || 0)); + const 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; }); }, []); diff --git a/frontend/src/hooks/useRightRailButtons.ts b/frontend/src/hooks/useRightRailButtons.ts index a30f1b2bc..82a4e8cd5 100644 --- a/frontend/src/hooks/useRightRailButtons.ts +++ b/frontend/src/hooks/useRightRailButtons.ts @@ -1,4 +1,4 @@ -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { useRightRail } from '../contexts/RightRailContext'; import { RightRailAction, RightRailButtonConfig } from '../types/rightRail'; @@ -11,21 +11,36 @@ export interface RightRailButtonWithAction extends RightRailButtonConfig { * - Automatically registers on mount and unregisters on unmount * - Updates registration when the input array reference changes */ -export function useRightRailButtons(buttons: RightRailButtonWithAction[]) { +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; - // Register visual button configs (without onClick) - registerButtons(buttons.map(({ onClick, ...cfg }) => cfg)); + // 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); + }); + } - // Bind actions + // Register visual button configs (idempotent merge by id) + registerButtons(configs); + + // Bind/update actions independent of registration buttons.forEach(({ id, onClick }) => setAction(id, onClick)); - // Cleanup - return () => { - unregisterButtons(buttons.map(b => b.id)); - }; - }, [registerButtons, unregisterButtons, setAction, buttons]); + // Cleanup unregisters by ids present in this call + return () => unregisterButtons(ids); + }, [registerButtons, unregisterButtons, setAction, configs, ids, buttons]); } 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/types/rightRail.ts b/frontend/src/types/rightRail.ts index eacf01dbe..1897a7170 100644 --- a/frontend/src/types/rightRail.ts +++ b/frontend/src/types/rightRail.ts @@ -1,12 +1,25 @@ import React from 'react'; +export type RightRailSection = 'top' | 'middle' | 'bottom'; + export interface RightRailButtonConfig { - id: string; // unique id for the button, also used to bind action callbacks + /** Unique id for the button, also used to bind action callbacks */ + id: string; + /** Icon element to render */ icon: React.ReactNode; - tooltip: string; - section?: 'top' | 'middle' | 'bottom'; + /** 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; }