add language support and fix export issue with page editor where the exported PDF was just the same as the originally uploaded file

This commit is contained in:
EthanHealy01 2025-08-22 16:51:24 +01:00
parent 45d99777cc
commit e0a865173e
10 changed files with 173 additions and 77 deletions

View File

@ -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",

View File

@ -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 --",

View File

@ -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<boolean>(false);
// Animation state
const [movingPage, setMovingPage] = useState<number | null>(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 = ({
</Box>
)}
<TextInput
label="Filename"
value={filename}
onChange={(e) => 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

View File

@ -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 */}
<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 */}
<Tooltip label="Undo">

View File

@ -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<boolean>(currentView === 'pageEditor');
@ -217,7 +225,7 @@ export default function RightRail() {
>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
{/* Select All Button */}
<Tooltip content={t('pageEdit.selectAll', 'Select All')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.selectAll', 'Select All')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
@ -234,7 +242,7 @@ export default function RightRail() {
</Tooltip>
{/* Deselect All Button */}
<Tooltip content={t('pageEdit.deselectAll', 'Deselect All')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.deselectAll', 'Deselect All')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
@ -252,7 +260,7 @@ export default function RightRail() {
{/* Select by Numbers - page editor only, with animated presence */}
{pageControlsMounted && (
<Tooltip content={t('pageEdit.selectByNumber', 'Select by Page Numbers')} position="left" offset={12} arrow>
<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}>
@ -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'}
>
<span className="material-symbols-rounded">
pin_end
@ -288,7 +297,7 @@ export default function RightRail() {
{/* Delete Selected Pages - page editor only, with animated presence */}
{pageControlsMounted && (
<Tooltip content={t('pageEdit.deleteSelected', 'Delete Selected Pages')} position="left" offset={12} arrow>
<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' }}>
@ -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'}
>
<span className="material-symbols-rounded">delete</span>
</ActionIcon>
@ -308,7 +318,7 @@ export default function RightRail() {
)}
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */}
<Tooltip content={currentView === 'pageEditor' ? 'Close PDF' : 'Close Selected Files'} position="left" offset={12} arrow>
<Tooltip content={currentView === 'pageEditor' ? t('rightRail.closePdf', 'Close PDF') : t('rightRail.downloadSelected', 'Download Selected Files')} position="left" offset={12} arrow>
<div>
<ActionIcon
variant="subtle"
@ -332,7 +342,7 @@ export default function RightRail() {
{/* Theme toggle and Language dropdown */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
<Tooltip content={t('app.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow>
<Tooltip content={t('rightRail.toggleTheme', 'Toggle Theme')} position="left" offset={12} arrow>
<ActionIcon
variant="subtle"
radius="md"
@ -347,8 +357,8 @@ export default function RightRail() {
<Tooltip content={
currentView === 'pageEditor'
? 'Export All Pages'
: (selectedCount > 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>
<div>
<ActionIcon

View File

@ -7,11 +7,21 @@ import EditNoteIcon from "@mui/icons-material/EditNote";
import FolderIcon from "@mui/icons-material/Folder";
import { ModeType, isValidMode } from '../../contexts/NavigationContext';
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: (
<div style={{ display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: 6, whiteSpace: 'nowrap'}}>
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === "viewer" ? (
<Loader size="xs" />
) : (
@ -24,7 +34,7 @@ const createViewOptions = (switchingTo: ModeType | null) => [
},
{
label: (
<div style={{ display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}>
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === "pageEditor" ? (
<Loader size="xs" />
) : (
@ -37,7 +47,7 @@ const createViewOptions = (switchingTo: ModeType | null) => [
},
{
label: (
<div style={{ display: 'inline-flex', flexDirection: 'row', alignItems: 'center', gap: 6, whiteSpace: 'nowrap' }}>
<div style={viewOptionStyle as React.CSSProperties}>
{switchingTo === "fileEditor" ? (
<Loader size="xs" />
) : (
@ -91,7 +101,7 @@ const TopControls = ({
return (
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
{!isToolSelected && (
<div className="flex justify-center items-center h-full pointer-events-auto mt-[0.5rem] rounded-full">
<div className="flex justify-center mt-[0.5rem]">
<SegmentedControl
data={createViewOptions(switchingTo)}
value={currentView}
@ -103,6 +113,7 @@ const TopControls = ({
style={{
transition: 'all 0.2s ease',
opacity: switchingTo ? 0.8 : 1,
pointerEvents: 'auto'
}}
styles={{
root: {

View File

@ -18,8 +18,19 @@ export function RightRailProvider({ children }: { children: React.ReactNode }) {
const registerButtons = useCallback((newButtons: RightRailButtonConfig[]) => {
setButtons(prev => {
const merged = [...prev.filter(b => !newButtons.some(nb => nb.id === b.id)), ...newButtons];
return merged.sort((a, b) => (a.order || 0) - (b.order || 0));
const 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;
});
}, []);

View File

@ -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<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);
});
}
// 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]);
}

View File

@ -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`;
}

View File

@ -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;
}