mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 06:09:23 +00:00
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:
parent
45d99777cc
commit
e0a865173e
@ -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",
|
||||
|
@ -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 --",
|
||||
|
@ -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
|
||||
|
@ -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">
|
||||
|
@ -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
|
||||
|
@ -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: {
|
||||
|
@ -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;
|
||||
});
|
||||
}, []);
|
||||
|
||||
|
@ -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]);
|
||||
}
|
||||
|
@ -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`;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
Loading…
x
Reference in New Issue
Block a user