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", "currentPage": "Current Page",
"totalPages": "Total Pages" "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": { "toolPicker": {
"searchPlaceholder": "Search tools...", "searchPlaceholder": "Search tools...",
"noToolsFound": "No tools found", "noToolsFound": "No tools found",

View File

@ -55,6 +55,18 @@
"bored": "Bored Waiting?", "bored": "Bored Waiting?",
"alphabet": "Alphabet", "alphabet": "Alphabet",
"downloadPdf": "Download PDF", "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", "text": "Text",
"font": "Font", "font": "Font",
"selectFillter": "-- Select --", "selectFillter": "-- Select --",

View File

@ -239,6 +239,7 @@ const PageEditor = ({
const [exportLoading, setExportLoading] = useState(false); const [exportLoading, setExportLoading] = useState(false);
const [showExportModal, setShowExportModal] = useState(false); const [showExportModal, setShowExportModal] = useState(false);
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null); const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
const [exportSelectedOnly, setExportSelectedOnly] = useState<boolean>(false);
// Animation state // Animation state
const [movingPage, setMovingPage] = useState<number | null>(null); const [movingPage, setMovingPage] = useState<number | null>(null);
@ -247,8 +248,17 @@ const PageEditor = ({
// Undo/Redo system // Undo/Redo system
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo(); const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
// Track whether the user has manually edited the filename to avoid auto-overwrites
const userEditedFilename = useRef(false);
// Reset user edit flag when the active files change, so defaults can be applied for new docs
useEffect(() => {
userEditedFilename.current = false;
}, [filesSignature]);
// Set initial filename when document changes - use stable signature // Set initial filename when document changes - use stable signature
useEffect(() => { useEffect(() => {
if (userEditedFilename.current) return; // Do not overwrite user-typed filename
if (mergedPdfDocument) { if (mergedPdfDocument) {
if (activeFileIds.length === 1 && primaryFileId) { if (activeFileIds.length === 1 && primaryFileId) {
const record = selectors.getFileRecord(primaryFileId); const record = selectors.getFileRecord(primaryFileId);
@ -884,49 +894,52 @@ const PageEditor = ({
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]); }, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]);
const showExportPreview = useCallback((selectedOnly: boolean = false) => { const showExportPreview = useCallback((selectedOnly: boolean = false) => {
if (!mergedPdfDocument) return; const doc = editedDocument || mergedPdfDocument;
if (!doc) return;
// Convert page numbers to page IDs for export service // Convert page numbers to page IDs for export service
const exportPageIds = selectedOnly const exportPageIds = selectedOnly
? selectedPageNumbers.map(pageNum => { ? selectedPageNumbers.map(pageNum => {
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum); const page = doc.pages.find(p => p.pageNumber === pageNum);
return page?.id || ''; return page?.id || '';
}).filter(id => id) }).filter(id => id)
: []; : [];
const preview = pdfExportService.getExportInfo(doc, exportPageIds, selectedOnly);
const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly);
setExportPreview(preview); setExportPreview(preview);
setExportSelectedOnly(selectedOnly);
setShowExportModal(true); setShowExportModal(true);
}, [mergedPdfDocument, selectedPageNumbers]); }, [editedDocument, mergedPdfDocument, selectedPageNumbers]);
const handleExport = useCallback(async (selectedOnly: boolean = false) => { const handleExport = useCallback(async (selectedOnly: boolean = false) => {
if (!mergedPdfDocument) return; const doc = editedDocument || mergedPdfDocument;
if (!doc) return;
setExportLoading(true); setExportLoading(true);
try { try {
// Convert page numbers to page IDs for export service // Convert page numbers to page IDs for export service
const exportPageIds = selectedOnly const exportPageIds = selectedOnly
? selectedPageNumbers.map(pageNum => { ? selectedPageNumbers.map(pageNum => {
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum); const page = doc.pages.find(p => p.pageNumber === pageNum);
return page?.id || ''; return page?.id || '';
}).filter(id => id) }).filter(id => id)
: []; : [];
const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly); const errors = pdfExportService.validateExport(doc, exportPageIds, selectedOnly);
if (errors.length > 0) { if (errors.length > 0) {
setStatus(errors.join(', ')); setStatus(errors.join(', '));
return; return;
} }
const hasSplitMarkers = mergedPdfDocument.pages.some(page => page.splitBefore); const hasSplitMarkers = doc.pages.some(page => page.splitBefore);
if (hasSplitMarkers) { if (hasSplitMarkers) {
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, { const result = await pdfExportService.exportPDF(doc, exportPageIds, {
selectedOnly, selectedOnly,
filename, filename,
splitDocuments: true splitDocuments: true,
appendSuffix: false
}) as { blobs: Blob[]; filenames: string[] }; }) as { blobs: Blob[]; filenames: string[] };
result.blobs.forEach((blob, index) => { result.blobs.forEach((blob, index) => {
@ -937,9 +950,10 @@ const PageEditor = ({
setStatus(`Exported ${result.blobs.length} split documents`); setStatus(`Exported ${result.blobs.length} split documents`);
} else { } else {
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, { const result = await pdfExportService.exportPDF(doc, exportPageIds, {
selectedOnly, selectedOnly,
filename filename,
appendSuffix: false
}) as { blob: Blob; filename: string }; }) as { blob: Blob; filename: string };
pdfExportService.downloadFile(result.blob, result.filename); pdfExportService.downloadFile(result.blob, result.filename);
@ -952,7 +966,7 @@ const PageEditor = ({
} finally { } finally {
setExportLoading(false); setExportLoading(false);
} }
}, [mergedPdfDocument, selectedPageNumbers, filename]); }, [editedDocument, mergedPdfDocument, selectedPageNumbers, filename]);
const handleUndo = useCallback(() => { const handleUndo = useCallback(() => {
if (undo()) { if (undo()) {
@ -1240,10 +1254,11 @@ const PageEditor = ({
</Box> </Box>
)} )}
<TextInput <TextInput
label="Filename"
value={filename} value={filename}
onChange={(e) => setFilename(e.target.value)} onChange={(e) => setFilename(e.target.value)}
placeholder="Enter filename" placeholder="Enter filename"
style={{ minWidth: 200 }} style={{ minWidth: 200, maxWidth: 200, marginLeft: "1rem"}}
/> />
@ -1338,8 +1353,7 @@ const PageEditor = ({
loading={exportLoading} loading={exportLoading}
onClick={() => { onClick={() => {
setShowExportModal(false); setShowExportModal(false);
const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.pages.length || 0); handleExport(exportSelectedOnly);
handleExport(selectedOnly);
}} }}
> >
Export PDF Export PDF

View File

@ -2,14 +2,12 @@ import React from "react";
import { import {
Tooltip, Tooltip,
ActionIcon, ActionIcon,
Paper
} from "@mantine/core"; } from "@mantine/core";
import UndoIcon from "@mui/icons-material/Undo"; import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo"; import RedoIcon from "@mui/icons-material/Redo";
import ContentCutIcon from "@mui/icons-material/ContentCut"; import ContentCutIcon from "@mui/icons-material/ContentCut";
import RotateLeftIcon from "@mui/icons-material/RotateLeft"; import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight"; import RotateRightIcon from "@mui/icons-material/RotateRight";
import CloseIcon from "@mui/icons-material/Close";
interface PageEditorControlsProps { interface PageEditorControlsProps {
// Close/Reset functions // Close/Reset functions
@ -37,17 +35,12 @@ interface PageEditorControlsProps {
} }
const PageEditorControls = ({ const PageEditorControls = ({
onClosePdf,
onUndo, onUndo,
onRedo, onRedo,
canUndo, canUndo,
canRedo, canRedo,
onRotate, onRotate,
onDelete,
onSplit, onSplit,
onExportSelected,
onExportAll,
exportLoading,
selectionMode, selectionMode,
selectedPages selectedPages
}: PageEditorControlsProps) => { }: PageEditorControlsProps) => {
@ -87,19 +80,6 @@ const PageEditorControls = ({
paddingBottom: "2rem" paddingBottom: "2rem"
}} }}
> >
{/* Close PDF */}
<Tooltip label="Close PDF">
<ActionIcon
onClick={onClosePdf}
color="red"
variant="light"
size="lg"
>
<CloseIcon />
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Undo/Redo */} {/* Undo/Redo */}
<Tooltip label="Undo"> <Tooltip label="Undo">

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 { ActionIcon, Divider, Popover } from '@mantine/core';
import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import CloseRoundedIcon from '@mui/icons-material/CloseRounded';
import './rightRail/RightRail.css'; import './rightRail/RightRail.css';
@ -16,7 +16,7 @@ export default function RightRail() {
const { t } = useTranslation(); const { t } = useTranslation();
const { toggleTheme } = useRainbowThemeContext(); const { toggleTheme } = useRainbowThemeContext();
const { buttons, actions } = useRightRail(); 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 // Access PageEditor functions for page-editor-specific actions
const { pageEditorFunctions } = useToolWorkflow(); const { pageEditorFunctions } = useToolWorkflow();
@ -33,6 +33,7 @@ export default function RightRail() {
const { removeFiles } = useFileManagement(); const { removeFiles } = useFileManagement();
const activeFiles = selectors.getFiles(); const activeFiles = selectors.getFiles();
const filesSignature = selectors.getFilesSignature();
const fileRecords = selectors.getFileRecords(); const fileRecords = selectors.getFileRecords();
// Compute selection state and total items // Compute selection state and total items
@ -149,9 +150,16 @@ export default function RightRail() {
}, []); }, []);
const updatePagesFromCSV = useCallback(() => { const updatePagesFromCSV = useCallback(() => {
const pageNumbers = parseCSVInput(csvInput); const rawPages = parseCSVInput(csvInput);
setSelectedPages(pageNumbers); // Determine max page count from processed records
}, [csvInput, parseCSVInput, setSelectedPages]); 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 // Sync csvInput with selectedPageNumbers changes
useEffect(() => { useEffect(() => {
@ -162,10 +170,10 @@ export default function RightRail() {
setCsvInput(newCsvInput); setCsvInput(newCsvInput);
}, [selectedPageNumbers]); }, [selectedPageNumbers]);
// Clear CSV input when files change // Clear CSV input when files change (use stable signature to avoid ref churn)
useEffect(() => { useEffect(() => {
setCsvInput(""); setCsvInput("");
}, [activeFiles]); }, [filesSignature]);
// Mount/visibility for page-editor-only buttons to allow exit animation, then remove to avoid flex gap // Mount/visibility for page-editor-only buttons to allow exit animation, then remove to avoid flex gap
const [pageControlsMounted, setPageControlsMounted] = useState<boolean>(currentView === 'pageEditor'); const [pageControlsMounted, setPageControlsMounted] = useState<boolean>(currentView === 'pageEditor');
@ -217,7 +225,7 @@ export default function RightRail() {
> >
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
{/* Select All Button */} {/* 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> <div>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
@ -234,7 +242,7 @@ export default function RightRail() {
</Tooltip> </Tooltip>
{/* Deselect All Button */} {/* 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> <div>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
@ -252,7 +260,7 @@ export default function RightRail() {
{/* Select by Numbers - page editor only, with animated presence */} {/* Select by Numbers - page editor only, with animated presence */}
{pageControlsMounted && ( {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}> <div className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<Popover position="left" withArrow shadow="md" offset={8}> <Popover position="left" withArrow shadow="md" offset={8}>
@ -263,6 +271,7 @@ export default function RightRail() {
radius="md" radius="md"
className="right-rail-icon" className="right-rail-icon"
disabled={!pageControlsVisible || totalItems === 0} 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"> <span className="material-symbols-rounded">
pin_end pin_end
@ -288,7 +297,7 @@ export default function RightRail() {
{/* Delete Selected Pages - page editor only, with animated presence */} {/* Delete Selected Pages - page editor only, with animated presence */}
{pageControlsMounted && ( {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 className={`right-rail-fade ${pageControlsVisible ? 'enter' : 'exit'}`} aria-hidden={!pageControlsVisible}>
<div style={{ display: 'inline-flex' }}> <div style={{ display: 'inline-flex' }}>
@ -296,8 +305,9 @@ export default function RightRail() {
variant="subtle" variant="subtle"
radius="md" radius="md"
className="right-rail-icon" className="right-rail-icon"
onClick={() => pageEditorFunctions?.handleDelete?.()} onClick={() => { pageEditorFunctions?.handleDelete?.(); setSelectedPages([]); }}
disabled={!pageControlsVisible || (Array.isArray(selectedPageNumbers) ? selectedPageNumbers.length === 0 : true)} 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> <span className="material-symbols-rounded">delete</span>
</ActionIcon> </ActionIcon>
@ -308,7 +318,7 @@ export default function RightRail() {
)} )}
{/* Close (File Editor: Close Selected | Page Editor: Close PDF) */} {/* 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> <div>
<ActionIcon <ActionIcon
variant="subtle" variant="subtle"
@ -332,7 +342,7 @@ export default function RightRail() {
{/* Theme toggle and Language dropdown */} {/* Theme toggle and Language dropdown */}
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}> <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 <ActionIcon
variant="subtle" variant="subtle"
radius="md" radius="md"
@ -347,8 +357,8 @@ export default function RightRail() {
<Tooltip content={ <Tooltip content={
currentView === 'pageEditor' currentView === 'pageEditor'
? 'Export All Pages' ? t('rightRail.exportAll', 'Export PDF')
: (selectedCount > 0 ? 'Download Selected Files' : 'Download All') : (selectedCount > 0 ? t('rightRail.downloadSelected', 'Download Selected Files') : t('rightRail.downloadAll', 'Download All'))
} position="left" offset={12} arrow> } position="left" offset={12} arrow>
<div> <div>
<ActionIcon <ActionIcon

View File

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

View File

@ -18,8 +18,19 @@ export function RightRailProvider({ children }: { children: React.ReactNode }) {
const registerButtons = useCallback((newButtons: RightRailButtonConfig[]) => { const registerButtons = useCallback((newButtons: RightRailButtonConfig[]) => {
setButtons(prev => { setButtons(prev => {
const merged = [...prev.filter(b => !newButtons.some(nb => nb.id === b.id)), ...newButtons]; const byId = new Map(prev.map(b => [b.id, b] as const));
return merged.sort((a, b) => (a.order || 0) - (b.order || 0)); 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 { useRightRail } from '../contexts/RightRailContext';
import { RightRailAction, RightRailButtonConfig } from '../types/rightRail'; import { RightRailAction, RightRailButtonConfig } from '../types/rightRail';
@ -11,21 +11,36 @@ export interface RightRailButtonWithAction extends RightRailButtonConfig {
* - Automatically registers on mount and unregisters on unmount * - Automatically registers on mount and unregisters on unmount
* - Updates registration when the input array reference changes * - Updates registration when the input array reference changes
*/ */
export function useRightRailButtons(buttons: RightRailButtonWithAction[]) { export function useRightRailButtons(buttons: readonly RightRailButtonWithAction[]) {
const { registerButtons, unregisterButtons, setAction } = useRightRail(); 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(() => { useEffect(() => {
if (!buttons || buttons.length === 0) return; if (!buttons || buttons.length === 0) return;
// Register visual button configs (without onClick) // DEV warnings for duplicate ids or missing handlers
registerButtons(buttons.map(({ onClick, ...cfg }) => cfg)); 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)); buttons.forEach(({ id, onClick }) => setAction(id, onClick));
// Cleanup // Cleanup unregisters by ids present in this call
return () => { return () => unregisterButtons(ids);
unregisterButtons(buttons.map(b => b.id)); }, [registerButtons, unregisterButtons, setAction, configs, ids, buttons]);
};
}, [registerButtons, unregisterButtons, setAction, buttons]);
} }

View File

@ -5,6 +5,7 @@ export interface ExportOptions {
selectedOnly?: boolean; selectedOnly?: boolean;
filename?: string; filename?: string;
splitDocuments?: boolean; splitDocuments?: boolean;
appendSuffix?: boolean; // when false, do not append _edited/_selected
} }
export class PDFExportService { export class PDFExportService {
@ -16,7 +17,7 @@ export class PDFExportService {
selectedPageIds: string[] = [], selectedPageIds: string[] = [],
options: ExportOptions = {} options: ExportOptions = {}
): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> { ): Promise<{ blob: Blob; filename: string } | { blobs: Blob[]; filenames: string[] }> {
const { selectedOnly = false, filename, splitDocuments = false } = options; const { selectedOnly = false, filename, splitDocuments = false, appendSuffix = true } = options;
try { try {
// Determine which pages to export // Determine which pages to export
@ -36,7 +37,7 @@ export class PDFExportService {
return await this.createSplitDocuments(sourceDoc, pagesToExport, filename || pdfDocument.name); return await this.createSplitDocuments(sourceDoc, pagesToExport, filename || pdfDocument.name);
} else { } else {
const blob = await this.createSingleDocument(sourceDoc, pagesToExport); const blob = await this.createSingleDocument(sourceDoc, pagesToExport);
const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly); const exportFilename = this.generateFilename(filename || pdfDocument.name, selectedOnly, appendSuffix);
return { blob, filename: exportFilename }; return { blob, filename: exportFilename };
} }
} catch (error) { } catch (error) {
@ -56,7 +57,7 @@ export class PDFExportService {
for (const page of pages) { for (const page of pages) {
// Get the original page from source document // Get the original page from source document
const sourcePageIndex = page.pageNumber - 1; const sourcePageIndex = this.getOriginalSourceIndex(page);
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
// Copy the page // Copy the page
@ -113,7 +114,7 @@ export class PDFExportService {
const newDoc = await PDFLibDocument.create(); const newDoc = await PDFLibDocument.create();
for (const page of segmentPages) { for (const page of segmentPages) {
const sourcePageIndex = page.pageNumber - 1; const sourcePageIndex = this.getOriginalSourceIndex(page);
if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) { if (sourcePageIndex >= 0 && sourcePageIndex < sourceDoc.getPageCount()) {
const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]); const [copiedPage] = await newDoc.copyPages(sourceDoc, [sourcePageIndex]);
@ -146,11 +147,28 @@ export class PDFExportService {
return { blobs, filenames }; return { blobs, filenames };
} }
/**
* Derive the original page index from a page's stable id.
* Falls back to the current pageNumber if parsing fails.
*/
private getOriginalSourceIndex(page: PDFPage): number {
const match = page.id.match(/-page-(\d+)$/);
if (match) {
const originalNumber = parseInt(match[1], 10);
if (!Number.isNaN(originalNumber)) {
return originalNumber - 1; // zero-based index for pdf-lib
}
}
// Fallback to the visible page number
return Math.max(0, page.pageNumber - 1);
}
/** /**
* Generate appropriate filename for export * Generate appropriate filename for export
*/ */
private generateFilename(originalName: string, selectedOnly: boolean): string { private generateFilename(originalName: string, selectedOnly: boolean, appendSuffix: boolean): string {
const baseName = originalName.replace(/\.pdf$/i, ''); const baseName = originalName.replace(/\.pdf$/i, '');
if (!appendSuffix) return `${baseName}.pdf`;
const suffix = selectedOnly ? '_selected' : '_edited'; const suffix = selectedOnly ? '_selected' : '_edited';
return `${baseName}${suffix}.pdf`; return `${baseName}${suffix}.pdf`;
} }

View File

@ -1,12 +1,25 @@
import React from 'react'; import React from 'react';
export type RightRailSection = 'top' | 'middle' | 'bottom';
export interface RightRailButtonConfig { 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; icon: React.ReactNode;
tooltip: string; /** Tooltip content (can be localized node) */
section?: 'top' | 'middle' | 'bottom'; 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; order?: number;
/** Initial disabled state */
disabled?: boolean; disabled?: boolean;
/** Initial visibility */
visible?: boolean; visible?: boolean;
} }