Feature/v2/right rail (#4255)

# Description of Changes

<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/DeveloperGuide.md#6-testing)
for more details.

---------

Co-authored-by: ConnorYoh <[email protected]>
Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
EthanHealy01
2025-08-25 12:53:33 +01:00
committed by GitHub
co-authored by ConnorYoh Anthony Stirling
parent 61f5221c58
commit 895bcebc7b
24 changed files with 1070 additions and 329 deletions
@@ -1,5 +1,5 @@
import React from 'react';
import { Paper, Group, TextInput, Button, Text } from '@mantine/core';
import { Group, TextInput, Button, Text } from '@mantine/core';
interface BulkSelectionPanelProps {
csvInput: string;
@@ -15,7 +15,7 @@ const BulkSelectionPanel = ({
onUpdatePagesFromCSV,
}: BulkSelectionPanelProps) => {
return (
<Paper p="md" mb="md" withBorder>
<>
<Group>
<TextInput
value={csvInput}
@@ -35,7 +35,7 @@ const BulkSelectionPanel = ({
Selected: {selectedPages.length} pages
</Text>
)}
</Paper>
</>
);
};
@@ -141,7 +141,6 @@ const FileThumbnail = ({
filter: isSupported ? 'none' : 'grayscale(50%)'
}}
>
{selectionMode && (
<div
className={styles.checkboxContainer}
data-testid="file-thumbnail-checkbox"
@@ -175,7 +174,6 @@ const FileThumbnail = ({
size="sm"
/>
</div>
)}
{/* File content area */}
<div className="file-container w-[90%] h-[80%] relative">
@@ -1,14 +1,13 @@
import React, { useState, useCallback, useRef, useEffect, useMemo } from "react";
import {
Button, Text, Center, Checkbox, Box, Tooltip, ActionIcon,
Button, Text, Center, Box,
Notification, TextInput, LoadingOverlay, Modal, Alert,
Stack, Group
Stack, Group, Portal
} from "@mantine/core";
import { useTranslation } from "react-i18next";
import { useFileState, useFileActions, useCurrentFile, useFileSelection } from "../../contexts/FileContext";
import { ModeType } from "../../contexts/NavigationContext";
import { PDFDocument, PDFPage } from "../../types/pageEditor";
import { ProcessedFile as EnhancedProcessedFile } from "../../types/processing";
import { useUndoRedo } from "../../hooks/useUndoRedo";
import {
RotatePagesCommand,
@@ -56,7 +55,6 @@ export interface PageEditorProps {
const PageEditor = ({
onFunctionsReady,
}: PageEditorProps) => {
const { t } = useTranslation();
// Use split contexts to prevent re-renders
const { state, selectors } = useFileState();
@@ -241,19 +239,26 @@ const PageEditor = ({
const [exportLoading, setExportLoading] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
const [exportPreview, setExportPreview] = useState<{pageCount: number; splitCount: number; estimatedSize: string} | null>(null);
const [exportSelectedOnly, setExportSelectedOnly] = useState<boolean>(false);
// Animation state
const [movingPage, setMovingPage] = useState<number | null>(null);
const [pagePositions, setPagePositions] = useState<Map<string, { x: number; y: number }>>(new Map());
const [isAnimating, setIsAnimating] = useState(false);
const pageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const fileInputRef = useRef<() => void>(null);
// Undo/Redo system
const { executeCommand, undo, redo, canUndo, canRedo } = useUndoRedo();
// Track whether the user has manually edited the filename to avoid auto-overwrites
const userEditedFilename = useRef(false);
// Reset user edit flag when the active files change, so defaults can be applied for new docs
useEffect(() => {
userEditedFilename.current = false;
}, [filesSignature]);
// Set initial filename when document changes - use stable signature
useEffect(() => {
if (userEditedFilename.current) return; // Do not overwrite user-typed filename
if (mergedPdfDocument) {
if (activeFileIds.length === 1 && primaryFileId) {
const record = selectors.getFileRecord(primaryFileId);
@@ -838,14 +843,18 @@ const PageEditor = ({
const handleDelete = useCallback(() => {
if (!displayDocument) return;
const pagesToDelete = selectionMode
? selectedPageNumbers.map(pageNum => {
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
return page?.id || '';
}).filter(id => id)
const hasSelectedPages = selectedPageNumbers.length > 0;
const pagesToDelete = (selectionMode || hasSelectedPages)
? selectedPageNumbers
.map(pageNum => {
const page = displayDocument.pages.find(p => p.pageNumber === pageNum);
return page?.id || '';
})
.filter(id => id)
: displayDocument.pages.map(p => p.id);
if (selectionMode && selectedPageNumbers.length === 0) return;
if ((selectionMode || hasSelectedPages) && selectedPageNumbers.length === 0) return;
const command = new DeletePagesCommand(
displayDocument,
@@ -857,7 +866,7 @@ const PageEditor = ({
if (selectionMode) {
actions.setSelectedPages([]);
}
const pageCount = selectionMode ? selectedPageNumbers.length : displayDocument.pages.length;
const pageCount = (selectionMode || hasSelectedPages) ? selectedPageNumbers.length : displayDocument.pages.length;
setStatus(`Deleted ${pageCount} pages`);
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument, actions]);
@@ -885,49 +894,52 @@ const PageEditor = ({
}, [displayDocument, selectedPageNumbers, selectionMode, executeCommand, setPdfDocument]);
const showExportPreview = useCallback((selectedOnly: boolean = false) => {
if (!mergedPdfDocument) return;
const doc = editedDocument || mergedPdfDocument;
if (!doc) return;
// Convert page numbers to page IDs for export service
const exportPageIds = selectedOnly
? selectedPageNumbers.map(pageNum => {
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
const page = doc.pages.find(p => p.pageNumber === pageNum);
return page?.id || '';
}).filter(id => id)
: [];
const preview = pdfExportService.getExportInfo(mergedPdfDocument, exportPageIds, selectedOnly);
const preview = pdfExportService.getExportInfo(doc, exportPageIds, selectedOnly);
setExportPreview(preview);
setExportSelectedOnly(selectedOnly);
setShowExportModal(true);
}, [mergedPdfDocument, selectedPageNumbers]);
}, [editedDocument, mergedPdfDocument, selectedPageNumbers]);
const handleExport = useCallback(async (selectedOnly: boolean = false) => {
if (!mergedPdfDocument) return;
const doc = editedDocument || mergedPdfDocument;
if (!doc) return;
setExportLoading(true);
try {
// Convert page numbers to page IDs for export service
const exportPageIds = selectedOnly
? selectedPageNumbers.map(pageNum => {
const page = mergedPdfDocument.pages.find(p => p.pageNumber === pageNum);
const page = doc.pages.find(p => p.pageNumber === pageNum);
return page?.id || '';
}).filter(id => id)
: [];
const errors = pdfExportService.validateExport(mergedPdfDocument, exportPageIds, selectedOnly);
const errors = pdfExportService.validateExport(doc, exportPageIds, selectedOnly);
if (errors.length > 0) {
setStatus(errors.join(', '));
return;
}
const hasSplitMarkers = mergedPdfDocument.pages.some(page => page.splitBefore);
const hasSplitMarkers = doc.pages.some(page => page.splitBefore);
if (hasSplitMarkers) {
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, {
const result = await pdfExportService.exportPDF(doc, exportPageIds, {
selectedOnly,
filename,
splitDocuments: true
splitDocuments: true,
appendSuffix: false
}) as { blobs: Blob[]; filenames: string[] };
result.blobs.forEach((blob, index) => {
@@ -938,9 +950,10 @@ const PageEditor = ({
setStatus(`Exported ${result.blobs.length} split documents`);
} else {
const result = await pdfExportService.exportPDF(mergedPdfDocument, exportPageIds, {
const result = await pdfExportService.exportPDF(doc, exportPageIds, {
selectedOnly,
filename
filename,
appendSuffix: false
}) as { blob: Blob; filename: string };
pdfExportService.downloadFile(result.blob, result.filename);
@@ -953,7 +966,7 @@ const PageEditor = ({
} finally {
setExportLoading(false);
}
}, [mergedPdfDocument, selectedPageNumbers, filename]);
}, [editedDocument, mergedPdfDocument, selectedPageNumbers, filename]);
const handleUndo = useCallback(() => {
if (undo()) {
@@ -1240,59 +1253,13 @@ const PageEditor = ({
</div>
</Box>
)}
<Group mb="md">
<TextInput
label="Filename"
value={filename}
onChange={(e) => setFilename(e.target.value)}
placeholder="Enter filename"
style={{ minWidth: 200 }}
style={{ minWidth: 200, maxWidth: 200, marginLeft: "1rem"}}
/>
<Button
onClick={toggleSelectionMode}
variant={selectionMode ? "filled" : "outline"}
color={selectionMode ? "blue" : "gray"}
styles={{
root: {
transition: 'all 0.2s ease',
...(selectionMode && {
boxShadow: '0 2px 8px rgba(59, 130, 246, 0.3)',
})
}
}}
>
{selectionMode ? "Exit Selection" : "Select Pages"}
</Button>
{selectionMode && (
<>
<Button onClick={selectAll} variant="light">Select All</Button>
<Button onClick={deselectAll} variant="light">Deselect All</Button>
</>
)}
{/* Apply Changes Button */}
{hasUnsavedChanges && (
<Button
onClick={applyChanges}
color="green"
variant="filled"
style={{ marginLeft: 'auto' }}
>
Apply Changes
</Button>
)}
</Group>
{selectionMode && (
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPages={selectedPageNumbers}
onUpdatePagesFromCSV={updatePagesFromCSV}
/>
)}
<DragDropGrid
@@ -1386,8 +1353,7 @@ const PageEditor = ({
loading={exportLoading}
onClick={() => {
setShowExportModal(false);
const selectedOnly = exportPreview.pageCount < (mergedPdfDocument?.pages.length || 0);
handleExport(selectedOnly);
handleExport(exportSelectedOnly);
}}
>
Export PDF
@@ -1446,14 +1412,16 @@ const PageEditor = ({
</Modal>
{status && (
<Portal>
<Notification
color="blue"
mt="md"
onClose={() => setStatus(null)}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 1000 }}
style={{ position: 'fixed', bottom: 20, right: 20, zIndex: 10000 }}
>
{status}
</Notification>
</Portal>
)}
{error && (
@@ -2,16 +2,12 @@ import React from "react";
import {
Tooltip,
ActionIcon,
Paper
} from "@mantine/core";
import UndoIcon from "@mui/icons-material/Undo";
import RedoIcon from "@mui/icons-material/Redo";
import ContentCutIcon from "@mui/icons-material/ContentCut";
import DownloadIcon from "@mui/icons-material/Download";
import RotateLeftIcon from "@mui/icons-material/RotateLeft";
import RotateRightIcon from "@mui/icons-material/RotateRight";
import DeleteIcon from "@mui/icons-material/Delete";
import CloseIcon from "@mui/icons-material/Close";
interface PageEditorControlsProps {
// Close/Reset functions
@@ -39,17 +35,12 @@ interface PageEditorControlsProps {
}
const PageEditorControls = ({
onClosePdf,
onUndo,
onRedo,
canUndo,
canRedo,
onRotate,
onDelete,
onSplit,
onExportSelected,
onExportAll,
exportLoading,
selectionMode,
selectedPages
}: PageEditorControlsProps) => {
@@ -57,9 +48,9 @@ const PageEditorControls = ({
<div
style={{
position: 'absolute',
left: '50%',
bottom: '20px',
transform: 'translateX(-50%)',
left: 0,
right: 0,
bottom: 0,
zIndex: 50,
display: 'flex',
justifyContent: 'center',
@@ -67,34 +58,28 @@ const PageEditorControls = ({
background: 'transparent',
}}
>
<Paper
radius="xl"
shadow="lg"
p={16}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 12,
borderRadius: 32,
boxShadow: '0 8px 32px rgba(0,0,0,0.12)',
borderTopLeftRadius: 16,
borderTopRightRadius: 16,
borderBottomLeftRadius: 0,
borderBottomRightRadius: 0,
boxShadow: '0 -2px 8px rgba(0,0,0,0.04)',
backgroundColor: 'var(--bg-toolbar)',
border: '1px solid var(--border-default)',
borderRadius: '16px 16px 0 0',
pointerEvents: 'auto',
minWidth: 400,
justifyContent: 'center'
minWidth: 420,
maxWidth: 700,
flexWrap: 'wrap',
justifyContent: 'center',
padding: "1rem",
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">
@@ -133,17 +118,6 @@ const PageEditorControls = ({
<RotateRightIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={selectionMode ? "Delete Selected" : "Delete All"}>
<ActionIcon
onClick={onDelete}
disabled={selectionMode && selectedPages.length === 0}
color="red"
variant={selectionMode && selectedPages.length > 0 ? "light" : "default"}
size="lg"
>
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={selectionMode ? "Split Selected" : "Split All"}>
<ActionIcon
onClick={onSplit}
@@ -156,34 +130,7 @@ const PageEditorControls = ({
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Export Controls */}
{selectionMode && selectedPages.length > 0 && (
<Tooltip label="Export Selected">
<ActionIcon
onClick={onExportSelected}
disabled={exportLoading}
color="blue"
variant="light"
size="lg"
>
<DownloadIcon />
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Export All">
<ActionIcon
onClick={onExportAll}
disabled={exportLoading}
color="green"
variant="light"
size="lg"
>
<DownloadIcon />
</ActionIcon>
</Tooltip>
</Paper>
</div>
</div>
);
};
@@ -205,7 +205,7 @@ const PageThumbnail = React.memo(({
}}
draggable={false}
>
{selectionMode && (
{
<div
className={styles.checkboxContainer}
style={{
@@ -213,10 +213,9 @@ const PageThumbnail = React.memo(({
top: 8,
right: 8,
zIndex: 10,
backgroundColor: 'rgba(255, 255, 255, 0.95)',
border: '1px solid #ccc',
backgroundColor: 'white',
borderRadius: '4px',
padding: '4px',
padding: '2px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'auto',
cursor: 'pointer'
@@ -239,7 +238,7 @@ const PageThumbnail = React.memo(({
size="sm"
/>
</div>
)}
}
<div className="page-container w-[90%] h-[90%]" draggable={false}>
<div