Restructure frontend code to allow for extensions (#4721)

# Description of Changes
Move frontend code into `core` folder and add infrastructure for
`proprietary` folder to include premium, non-OSS features
This commit is contained in:
James Brunton
2025-10-28 10:29:36 +00:00
committed by GitHub
parent 960d48f80c
commit d2b38ef4b8
725 changed files with 2485 additions and 2226 deletions
@@ -0,0 +1,76 @@
import { useState, useEffect } from 'react';
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
import { parseSelectionWithDiagnostics } from '@app/utils/bulkselection/parseSelection';
import PageSelectionInput from '@app/components/pageEditor/bulkSelectionPanel/PageSelectionInput';
import SelectedPagesDisplay from '@app/components/pageEditor/bulkSelectionPanel/SelectedPagesDisplay';
import AdvancedSelectionPanel from '@app/components/pageEditor/bulkSelectionPanel/AdvancedSelectionPanel';
interface BulkSelectionPanelProps {
csvInput: string;
setCsvInput: (value: string) => void;
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
onUpdatePagesFromCSV: (override?: string) => void;
}
const BulkSelectionPanel = ({
csvInput,
setCsvInput,
selectedPageIds,
displayDocument,
onUpdatePagesFromCSV,
}: BulkSelectionPanelProps) => {
const [syntaxError, setSyntaxError] = useState<string | null>(null);
const [advancedOpened, setAdvancedOpened] = useState<boolean>(false);
const maxPages = displayDocument?.pages?.length ?? 0;
// Validate input syntax and show lightweight feedback
useEffect(() => {
const text = (csvInput || '').trim();
if (!text) {
setSyntaxError(null);
return;
}
try {
const { warning } = parseSelectionWithDiagnostics(text, maxPages);
setSyntaxError(warning ? 'There is a syntax issue. See Page Selection tips for help.' : null);
} catch {
setSyntaxError('There is a syntax issue. See Page Selection tips for help.');
}
}, [csvInput, maxPages]);
const handleClear = () => {
setCsvInput('');
onUpdatePagesFromCSV('');
};
return (
<div className={classes.panelContainer}>
<PageSelectionInput
csvInput={csvInput}
setCsvInput={setCsvInput}
onUpdatePagesFromCSV={onUpdatePagesFromCSV}
onClear={handleClear}
advancedOpened={advancedOpened}
onToggleAdvanced={setAdvancedOpened}
/>
<SelectedPagesDisplay
selectedPageIds={selectedPageIds}
displayDocument={displayDocument}
syntaxError={syntaxError}
/>
<AdvancedSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
onUpdatePagesFromCSV={onUpdatePagesFromCSV}
maxPages={maxPages}
advancedOpened={advancedOpened}
/>
</div>
);
};
export default BulkSelectionPanel;
@@ -0,0 +1,158 @@
import React, { useRef, useEffect, useState, useCallback } from 'react';
import { Box } from '@mantine/core';
import { useVirtualizer } from '@tanstack/react-virtual';
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
interface DragDropItem {
id: string;
splitAfter?: boolean;
}
interface DragDropGridProps<T extends DragDropItem> {
items: T[];
selectedItems: string[];
selectionMode: boolean;
isAnimating: boolean;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>) => React.ReactNode;
renderSplitMarker?: (item: T, index: number) => React.ReactNode;
}
const DragDropGrid = <T extends DragDropItem>({
items,
renderItem,
}: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const containerRef = useRef<HTMLDivElement>(null);
// Responsive grid configuration
const [itemsPerRow, setItemsPerRow] = useState(4);
const OVERSCAN = items.length > 1000 ? GRID_CONSTANTS.OVERSCAN_LARGE : GRID_CONSTANTS.OVERSCAN_SMALL;
// Calculate items per row based on container width
const calculateItemsPerRow = useCallback(() => {
if (!containerRef.current) return 4; // Default fallback
const containerWidth = containerRef.current.offsetWidth;
if (containerWidth === 0) return 4; // Container not measured yet
// Convert rem to pixels for calculation
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
// Calculate how many items fit: (width - gap) / (itemWidth + gap)
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
const calculated = Math.floor(availableWidth / itemWithGap);
return Math.max(1, calculated); // At least 1 item per row
}, []);
// Update items per row when container resizes
useEffect(() => {
const updateLayout = () => {
const newItemsPerRow = calculateItemsPerRow();
setItemsPerRow(newItemsPerRow);
};
// Initial calculation
updateLayout();
// Listen for window resize
window.addEventListener('resize', updateLayout);
// Use ResizeObserver for container size changes
const resizeObserver = new ResizeObserver(updateLayout);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
window.removeEventListener('resize', updateLayout);
resizeObserver.disconnect();
};
}, [calculateItemsPerRow]);
// Virtualization with react-virtual library
const rowVirtualizer = useVirtualizer({
count: Math.ceil(items.length / itemsPerRow),
getScrollElement: () => containerRef.current?.closest('[data-scrolling-container]') as Element,
estimateSize: () => {
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
return parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
},
overscan: OVERSCAN,
});
// Calculate optimal width for centering
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const itemWidth = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const itemGap = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
const gridWidth = itemsPerRow * itemWidth + (itemsPerRow - 1) * itemGap;
return (
<Box
ref={containerRef}
style={{
// Basic container styles
width: '100%',
height: '100%',
}}
>
<div
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
margin: '0 auto',
maxWidth: `${gridWidth}px`,
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const startIndex = virtualRow.index * itemsPerRow;
const endIndex = Math.min(startIndex + itemsPerRow, items.length);
const rowItems = items.slice(startIndex, endIndex);
return (
<div
key={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div
style={{
display: 'flex',
gap: GRID_CONSTANTS.ITEM_GAP,
justifyContent: 'flex-start',
height: '100%',
alignItems: 'center',
position: 'relative'
}}
>
{rowItems.map((item, itemIndex) => {
const actualIndex = startIndex + itemIndex;
return (
<React.Fragment key={item.id}>
{/* Item */}
{renderItem(item, actualIndex, itemRefs)}
</React.Fragment>
);
})}
</div>
</div>
);
})}
</div>
</Box>
);
};
export default DragDropGrid;
@@ -0,0 +1,361 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { ActionIcon, CheckboxIndicator } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import MoreVertIcon from '@mui/icons-material/MoreVert';
import DownloadOutlinedIcon from '@mui/icons-material/DownloadOutlined';
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import styles from '@app/components/pageEditor/PageEditor.module.css';
import { useFileContext } from '@app/contexts/FileContext';
import { FileId } from '@app/types/file';
interface FileItem {
id: FileId;
name: string;
pageCount: number;
thumbnail: string | null;
size: number;
modifiedAt?: number | string | Date;
}
interface FileThumbnailProps {
file: FileItem;
index: number;
totalFiles: number;
selectedFiles: string[];
selectionMode: boolean;
onToggleFile: (fileId: FileId) => void;
onDeleteFile: (fileId: FileId) => void;
onViewFile: (fileId: FileId) => void;
onSetStatus: (status: string) => void;
onReorderFiles?: (sourceFileId: FileId, targetFileId: FileId, selectedFileIds: FileId[]) => void;
onDownloadFile?: (fileId: FileId) => void;
toolMode?: boolean;
isSupported?: boolean;
}
const FileThumbnail = ({
file,
index,
selectedFiles,
onToggleFile,
onDeleteFile,
onSetStatus,
onReorderFiles,
onDownloadFile,
isSupported = true,
}: FileThumbnailProps) => {
const { t } = useTranslation();
const { pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
// ---- Drag state ----
const [isDragging, setIsDragging] = useState(false);
const dragElementRef = useRef<HTMLDivElement | null>(null);
const [actionsWidth, setActionsWidth] = useState<number | undefined>(undefined);
const [showActions, setShowActions] = useState(false);
// Resolve the actual File object for pin/unpin operations
const actualFile = useMemo(() => {
return activeFiles.find(f => f.fileId === file.id);
}, [activeFiles, file.id]);
const isPinned = actualFile ? isFilePinned(actualFile) : false;
const downloadSelectedFile = useCallback(() => {
// Prefer parent-provided handler if available
if (typeof onDownloadFile === 'function') {
onDownloadFile(file.id);
return;
}
// Fallback: attempt to download using the File object if provided
const maybeFile = (file as unknown as { file?: File }).file;
if (maybeFile instanceof File) {
const link = document.createElement('a');
link.href = URL.createObjectURL(maybeFile);
link.download = maybeFile.name || file.name || 'download';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(link.href);
return;
}
// If we can't find a way to download, surface a status message
onSetStatus?.(typeof t === 'function' ? t('downloadUnavailable', 'Download unavailable for this item') : 'Download unavailable for this item');
}, [file, onDownloadFile, onSetStatus, t]);
const handleRef = useRef<HTMLSpanElement | null>(null);
// ---- Selection ----
const isSelected = selectedFiles.includes(file.id);
// ---- Drag & drop wiring ----
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
if (!element) return;
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: 'file',
fileId: file.id,
fileName: file.name,
selectedFiles: [file.id] // Always drag only this file, ignore selection state
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
}
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'file',
fileId: file.id
}),
canDrop: ({ source }) => {
const sourceData = source.data;
return sourceData.type === 'file' && sourceData.fileId !== file.id;
},
onDrop: ({ source }) => {
const sourceData = source.data;
if (sourceData.type === 'file' && onReorderFiles) {
const sourceFileId = sourceData.fileId as FileId;
const selectedFileIds = sourceData.selectedFiles as FileId[];
onReorderFiles(sourceFileId, file.id, selectedFileIds);
}
}
});
return () => {
dragCleanup();
dropCleanup();
};
}, [file.id, file.name, selectedFiles, onReorderFiles]);
// Update dropdown width on resize
useEffect(() => {
const update = () => {
if (dragElementRef.current) setActionsWidth(dragElementRef.current.offsetWidth);
};
update();
window.addEventListener('resize', update);
return () => window.removeEventListener('resize', update);
}, []);
// Close the actions dropdown when hovering outside this file card (and its dropdown)
useEffect(() => {
if (!showActions) return;
const isInsideCard = (target: EventTarget | null) => {
const container = dragElementRef.current;
if (!container) return false;
return target instanceof Node && container.contains(target);
};
const handleMouseMove = (e: MouseEvent) => {
if (!isInsideCard(e.target)) {
setShowActions(false);
}
};
const handleTouchStart = (e: TouchEvent) => {
// On touch devices, close if the touch target is outside the card
if (!isInsideCard(e.target)) {
setShowActions(false);
}
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('touchstart', handleTouchStart, { passive: true });
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('touchstart', handleTouchStart);
};
}, [showActions]);
// ---- Card interactions ----
const handleCardClick = () => {
if (!isSupported) return;
onToggleFile(file.id);
};
return (
<div
ref={fileElementRef}
data-file-id={file.id}
data-testid="file-thumbnail"
data-selected={isSelected}
data-supported={isSupported}
className={`${styles.card} w-[18rem] h-[22rem] select-none flex flex-col shadow-sm transition-all relative`}
style={{
opacity: isSupported ? (isDragging ? 0.9 : 1) : 0.5,
filter: isSupported ? 'none' : 'grayscale(50%)',
}}
tabIndex={0}
role="listitem"
aria-selected={isSelected}
onClick={handleCardClick}
>
{/* Header bar */}
<div
className={`${styles.header} ${
isSelected ? styles.headerSelected : styles.headerResting
}`}
>
{/* Logo/checkbox area */}
<div className={styles.logoMark}>
{isSupported ? (
<CheckboxIndicator
checked={isSelected}
onChange={() => onToggleFile(file.id)}
color="var(--checkbox-checked-bg)"
/>
) : (
<div className={styles.unsupportedPill}>
<span>
{t('unsupported', 'Unsupported')}
</span>
</div>
)}
</div>
{/* Centered index */}
<div className={styles.headerIndex} aria-label={`Position ${index + 1}`}>
{index + 1}
</div>
{/* Kebab menu */}
<ActionIcon
aria-label={t('moreOptions', 'More options')}
variant="subtle"
className={styles.kebab}
onClick={(e) => {
e.stopPropagation();
setShowActions((v) => !v);
}}
>
<MoreVertIcon fontSize="small" />
</ActionIcon>
</div>
{/* Actions overlay */}
{showActions && (
<div
className={styles.actionsOverlay}
style={{ width: actionsWidth }}
onClick={(e) => e.stopPropagation()}
>
<button
className={styles.actionRow}
onClick={() => {
if (actualFile) {
if (isPinned) {
unpinFile(actualFile);
onSetStatus?.(`Unpinned ${file.name}`);
} else {
pinFile(actualFile);
onSetStatus?.(`Pinned ${file.name}`);
}
}
setShowActions(false);
}}
>
{isPinned ? <PushPinIcon fontSize="small" /> : <PushPinOutlinedIcon fontSize="small" />}
<span>{isPinned ? t('unpin', 'Unpin') : t('pin', 'Pin')}</span>
</button>
<button
className={styles.actionRow}
onClick={() => { downloadSelectedFile(); setShowActions(false); }}
>
<DownloadOutlinedIcon fontSize="small" />
<span>{t('download', 'Download')}</span>
</button>
<div className={styles.actionsDivider} />
<button
className={`${styles.actionRow} ${styles.actionDanger}`}
onClick={() => {
onDeleteFile(file.id);
onSetStatus(`Deleted ${file.name}`);
setShowActions(false);
}}
>
<DeleteOutlineIcon fontSize="small" />
<span>{t('delete', 'Delete')}</span>
</button>
</div>
)}
{/* File content area */}
<div className="file-container w-[90%] h-[80%] relative">
{/* Stacked file effect - multiple shadows to simulate pages */}
<div
style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 6,
border: '1px solid var(--mantine-color-gray-3)',
padding: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
position: 'relative',
boxShadow: '2px 2px 0 rgba(0,0,0,0.1), 4px 4px 0 rgba(0,0,0,0.05)'
}}
>
{file.thumbnail && (
<img
className="ph-no-capture"
src={file.thumbnail}
alt={file.name}
draggable={false}
onError={(e) => {
// Hide broken image if blob URL was revoked
const img = e.target as HTMLImageElement;
img.style.display = 'none';
}}
style={{
maxWidth: '80%',
maxHeight: '80%',
objectFit: 'contain',
borderRadius: 0,
background: '#ffffff',
border: '1px solid var(--border-default)',
display: 'block',
marginLeft: 'auto',
marginRight: 'auto',
alignSelf: 'start'
}}
/>
)}
</div>
{/* Pin indicator (bottom-left) */}
{isPinned && (
<span className={styles.pinIndicator} aria-hidden>
<PushPinIcon fontSize="small" />
</span>
)}
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
<DragIndicatorIcon fontSize="small" />
</span>
</div>
</div>
);
};
export default React.memo(FileThumbnail);
@@ -0,0 +1,101 @@
/* Page container hover effects - optimized for smooth scrolling */
.pageContainer {
transition: transform 0.2s ease-in-out;
/* Enable hardware acceleration for smoother scrolling */
will-change: transform;
transform: translateZ(0);
backface-visibility: hidden;
}
.pageContainer:hover {
transform: scale(1.02) translateZ(0);
}
.pageContainer:hover .pageNumber {
opacity: 1 !important;
}
.pageContainer:hover .pageHoverControls {
opacity: 0.95 !important;
}
/* Checkbox container - prevent transform inheritance */
.checkboxContainer {
transform: none !important;
transition: none !important;
}
/* Page movement animations */
.pageMoveAnimation {
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.pageMoving {
z-index: 10;
transform: scale(1.05);
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
}
/* Multi-page drag indicator */
.multiDragIndicator {
position: fixed;
background: rgba(59, 130, 246, 0.9);
color: white;
padding: 8px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
pointer-events: none;
z-index: 1000;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
transform: translate(-50%, -50%);
backdrop-filter: blur(4px);
}
/* Animations */
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
/* Action styles */
.actionRow:hover {
background: var(--hover-bg);
}
.actionDanger {
color: var(--text-brand-accent);
}
.actionsDivider {
height: 1px;
background: var(--border-default);
margin: 4px 0;
}
.pinIndicator {
position: absolute;
bottom: 4px;
left: 4px;
z-index: 1;
color: rgba(0, 0, 0, 0.35); /* match drag handle color */
}
.unsupportedPill {
margin-left: 1.75rem;
background: #6B7280;
color: white;
padding: 4px 8px;
border-radius: 12px;
font-size: 10px;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
min-width: 80px;
height: 20px;
}
@@ -0,0 +1,832 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { useNavigationGuard } from "@app/contexts/NavigationContext";
import { PDFDocument, PageEditorFunctions } from "@app/types/pageEditor";
import { pdfExportService } from "@app/services/pdfExportService";
import { documentManipulationService } from "@app/services/documentManipulationService";
import { exportProcessedDocumentsToFiles } from "@app/services/pdfExportHelpers";
import { createStirlingFilesAndStubs } from "@app/services/fileStubHelpers";
// Thumbnail generation is now handled by individual PageThumbnail components
import '@app/components/pageEditor/PageEditor.module.css';
import PageThumbnail from '@app/components/pageEditor/PageThumbnail';
import DragDropGrid from '@app/components/pageEditor/DragDropGrid';
import SkeletonLoader from '@app/components/shared/SkeletonLoader';
import NavigationWarningModal from '@app/components/shared/NavigationWarningModal';
import { FileId } from "@app/types/file";
import {
DeletePagesCommand,
ReorderPagesCommand,
SplitCommand,
BulkRotateCommand,
PageBreakCommand,
UndoManager
} from '@app/components/pageEditor/commands/pageCommands';
import { GRID_CONSTANTS } from '@app/components/pageEditor/constants';
import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument';
import { usePageEditorState } from '@app/components/pageEditor/hooks/usePageEditorState';
import { parseSelection } from "@app/utils/bulkselection/parseSelection";
import { usePageEditorRightRailButtons } from "@app/components/pageEditor/pageEditorRightRailButtons";
export interface PageEditorProps {
onFunctionsReady?: (functions: PageEditorFunctions) => void;
}
const PageEditor = ({
onFunctionsReady,
}: PageEditorProps) => {
// Use split contexts to prevent re-renders
const { state, selectors } = useFileState();
const { actions } = useFileActions();
// Navigation guard for unsaved changes
const { setHasUnsavedChanges } = useNavigationGuard();
// Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids;
const activeFilesSignature = selectors.getFilesSignature();
// UI state
const globalProcessing = state.ui.isProcessing;
// Edit state management
const [editedDocument, setEditedDocument] = useState<PDFDocument | null>(null);
// DOM-first undo manager (replaces the old React state undo system)
const undoManagerRef = useRef(new UndoManager());
// Document state management
const { document: mergedPdfDocument } = usePageDocument();
// UI state management
const {
selectionMode, selectedPageIds, movingPage, isAnimating, splitPositions, exportLoading,
setSelectionMode, setSelectedPageIds, setMovingPage, setSplitPositions, setExportLoading,
togglePage, toggleSelectAll, animateReorder
} = usePageEditorState();
const [csvInput, setCsvInput] = useState<string>('');
useEffect(() => {
setCsvInput('');
}, [activeFilesSignature]);
// Grid container ref for positioning split indicators
const gridContainerRef = useRef<HTMLDivElement>(null);
// State to trigger re-renders when container size changes
const [containerDimensions, setContainerDimensions] = useState({ width: 0, height: 0 });
// Undo/Redo state
const [canUndo, setCanUndo] = useState(false);
const [canRedo, setCanRedo] = useState(false);
// Update undo/redo state
const updateUndoRedoState = useCallback(() => {
setCanUndo(undoManagerRef.current.canUndo());
setCanRedo(undoManagerRef.current.canRedo());
}, []);
// Set up undo manager callback
useEffect(() => {
undoManagerRef.current.setStateChangeCallback(updateUndoRedoState);
// Initialize state
updateUndoRedoState();
}, [updateUndoRedoState]);
// Wrapper for executeCommand to track unsaved changes
const executeCommandWithTracking = useCallback((command: any) => {
undoManagerRef.current.executeCommand(command);
setHasUnsavedChanges(true);
}, [setHasUnsavedChanges]);
// Watch for container size changes to update split line positions
useEffect(() => {
const container = gridContainerRef.current;
if (!container) return;
const resizeObserver = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setContainerDimensions({
width: entry.contentRect.width,
height: entry.contentRect.height
});
}
});
resizeObserver.observe(container);
return () => {
resizeObserver.disconnect();
};
}, []);
// Interface functions for parent component
const displayDocument = editedDocument || mergedPdfDocument;
const totalPages = displayDocument?.pages.length ?? 0;
const selectedPageCount = selectedPageIds.length;
// Utility functions to convert between page IDs and page numbers
const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => {
if (!displayDocument) return [];
return pageIds.map(id => {
const page = displayDocument.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(num => num > 0);
}, [displayDocument]);
const getPageIdsFromNumbers = useCallback((pageNumbers: number[]): string[] => {
if (!displayDocument) return [];
return pageNumbers.map(num => {
const page = displayDocument.pages.find(p => p.pageNumber === num);
return page?.id || '';
}).filter(id => id !== '');
}, [displayDocument]);
// Select all pages by default when document initially loads
const hasInitializedSelection = useRef(false);
useEffect(() => {
if (displayDocument && displayDocument.pages.length > 0 && !hasInitializedSelection.current) {
const allPageIds = displayDocument.pages.map(p => p.id);
setSelectedPageIds(allPageIds);
setSelectionMode(true);
hasInitializedSelection.current = true;
}
}, [displayDocument, setSelectedPageIds, setSelectionMode]);
// DOM-first command handlers
const handleRotatePages = useCallback((pageIds: string[], rotation: number) => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
executeCommandWithTracking(bulkRotateCommand);
}, [executeCommandWithTracking]);
// Command factory functions for PageThumbnail
const createRotateCommand = useCallback((pageIds: string[], rotation: number) => ({
execute: () => {
const bulkRotateCommand = new BulkRotateCommand(pageIds, rotation);
executeCommandWithTracking(bulkRotateCommand);
}
}), [executeCommandWithTracking]);
const createDeleteCommand = useCallback((pageIds: string[]) => ({
execute: () => {
if (!displayDocument) return;
const pagesToDelete = pageIds.map(pageId => {
const page = displayDocument.pages.find(p => p.id === pageId);
return page?.pageNumber || 0;
}).filter(num => num > 0);
if (pagesToDelete.length > 0) {
const deleteCommand = new DeletePagesCommand(
pagesToDelete,
() => displayDocument,
setEditedDocument,
(pageNumbers: number[]) => {
const pageIds = getPageIdsFromNumbers(pageNumbers);
setSelectedPageIds(pageIds);
},
() => splitPositions,
setSplitPositions,
() => getPageNumbersFromIds(selectedPageIds),
closePdf
);
executeCommandWithTracking(deleteCommand);
}
}
}), [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
const createSplitCommand = useCallback((position: number) => ({
execute: () => {
const splitCommand = new SplitCommand(
position,
() => splitPositions,
setSplitPositions
);
executeCommandWithTracking(splitCommand);
}
}), [splitPositions, executeCommandWithTracking]);
// Command executor for PageThumbnail
const executeCommand = useCallback((command: any) => {
if (command && typeof command.execute === 'function') {
command.execute();
}
}, []);
const handleUndo = useCallback(() => {
undoManagerRef.current.undo();
}, []);
const handleRedo = useCallback(() => {
undoManagerRef.current.redo();
}, []);
const handleRotate = useCallback((direction: 'left' | 'right') => {
if (!displayDocument || selectedPageIds.length === 0) return;
const rotation = direction === 'left' ? -90 : 90;
handleRotatePages(selectedPageIds, rotation);
}, [displayDocument, selectedPageIds, handleRotatePages]);
const handleDelete = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
// Convert selected page IDs to page numbers for the command
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const deleteCommand = new DeletePagesCommand(
selectedPageNumbers,
() => displayDocument,
setEditedDocument,
(pageNumbers: number[]) => {
const pageIds = getPageIdsFromNumbers(pageNumbers);
setSelectedPageIds(pageIds);
},
() => splitPositions,
setSplitPositions,
() => selectedPageNumbers,
closePdf
);
executeCommandWithTracking(deleteCommand);
}, [selectedPageIds, displayDocument, splitPositions, getPageNumbersFromIds, getPageIdsFromNumbers, executeCommandWithTracking]);
const handleDeletePage = useCallback((pageNumber: number) => {
if (!displayDocument) return;
const deleteCommand = new DeletePagesCommand(
[pageNumber],
() => displayDocument,
setEditedDocument,
(pageNumbers: number[]) => {
const pageIds = getPageIdsFromNumbers(pageNumbers);
setSelectedPageIds(pageIds);
},
() => splitPositions,
setSplitPositions,
() => getPageNumbersFromIds(selectedPageIds),
closePdf
);
executeCommandWithTracking(deleteCommand);
}, [displayDocument, splitPositions, selectedPageIds, getPageNumbersFromIds, executeCommandWithTracking]);
const handleSplit = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
// Convert selected page IDs to page numbers, then to split positions (0-based indices)
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const selectedPositions: number[] = [];
selectedPageNumbers.forEach(pageNum => {
const pageIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNum);
if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) {
// Only allow splits before the last page
selectedPositions.push(pageIndex);
}
});
if (selectedPositions.length === 0) return;
// Smart toggle logic: follow the majority, default to adding splits if equal
const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length;
const noSplitsCount = selectedPositions.length - existingSplitsCount;
// Remove splits only if majority already have splits
// If equal (50/50), default to adding splits
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
const newSplitPositions = new Set(splitPositions);
if (shouldRemoveSplits) {
// Remove splits from all selected positions
selectedPositions.forEach(pos => newSplitPositions.delete(pos));
} else {
// Add splits to all selected positions
selectedPositions.forEach(pos => newSplitPositions.add(pos));
}
// Create a custom command that sets the final state directly
const smartSplitCommand = {
execute: () => setSplitPositions(newSplitPositions),
undo: () => setSplitPositions(splitPositions),
description: shouldRemoveSplits
? `Remove ${selectedPositions.length} split(s)`
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
};
executeCommandWithTracking(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
const handleSplitAll = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
// Convert selected page IDs to page numbers, then to split positions (0-based indices)
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const selectedPositions: number[] = [];
selectedPageNumbers.forEach(pageNum => {
const pageIndex = displayDocument.pages.findIndex(p => p.pageNumber === pageNum);
if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) {
// Only allow splits before the last page
selectedPositions.push(pageIndex);
}
});
if (selectedPositions.length === 0) return;
// Smart toggle logic: follow the majority, default to adding splits if equal
const existingSplitsCount = selectedPositions.filter(pos => splitPositions.has(pos)).length;
const noSplitsCount = selectedPositions.length - existingSplitsCount;
// Remove splits only if majority already have splits
// If equal (50/50), default to adding splits
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
const newSplitPositions = new Set(splitPositions);
if (shouldRemoveSplits) {
// Remove splits from all selected positions
selectedPositions.forEach(pos => newSplitPositions.delete(pos));
} else {
// Add splits to all selected positions
selectedPositions.forEach(pos => newSplitPositions.add(pos));
}
// Create a custom command that sets the final state directly
const smartSplitCommand = {
execute: () => setSplitPositions(newSplitPositions),
undo: () => setSplitPositions(splitPositions),
description: shouldRemoveSplits
? `Remove ${selectedPositions.length} split(s)`
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`
};
executeCommandWithTracking(smartSplitCommand);
}, [selectedPageIds, displayDocument, splitPositions, setSplitPositions, getPageNumbersFromIds, executeCommandWithTracking]);
const handlePageBreak = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
// Convert selected page IDs to page numbers for the command
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const pageBreakCommand = new PageBreakCommand(
selectedPageNumbers,
() => displayDocument,
setEditedDocument
);
executeCommandWithTracking(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
const handlePageBreakAll = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
// Convert selected page IDs to page numbers for the command
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const pageBreakCommand = new PageBreakCommand(
selectedPageNumbers,
() => displayDocument,
setEditedDocument
);
executeCommandWithTracking(pageBreakCommand);
}, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => {
if (!displayDocument || files.length === 0) return;
try {
const targetPage = displayDocument.pages.find(p => p.pageNumber === insertAfterPage);
if (!targetPage) return;
await actions.addFiles(files, { insertAfterPageId: targetPage.id });
} catch (error) {
console.error('Failed to insert files:', error);
}
}, [displayDocument, actions]);
const handleSelectAll = useCallback(() => {
if (!displayDocument) return;
const allPageIds = displayDocument.pages.map(p => p.id);
toggleSelectAll(allPageIds);
}, [displayDocument, toggleSelectAll]);
const handleDeselectAll = useCallback(() => {
setSelectedPageIds([]);
}, [setSelectedPageIds]);
const handleSetSelectedPages = useCallback((pageNumbers: number[]) => {
const pageIds = getPageIdsFromNumbers(pageNumbers);
setSelectedPageIds(pageIds);
}, [getPageIdsFromNumbers, setSelectedPageIds]);
const updatePagesFromCSV = useCallback((override?: string) => {
if (totalPages === 0) return;
const normalized = parseSelection(override ?? csvInput, totalPages);
handleSetSelectedPages(normalized);
}, [csvInput, totalPages, handleSetSelectedPages]);
const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => {
if (!displayDocument) return;
// Convert selectedPageIds to page numbers for the reorder command
const selectedPages = selectedPageIds ? getPageNumbersFromIds(selectedPageIds) : undefined;
const reorderCommand = new ReorderPagesCommand(
sourcePageNumber,
targetIndex,
selectedPages,
() => displayDocument,
setEditedDocument
);
executeCommandWithTracking(reorderCommand);
}, [displayDocument, getPageNumbersFromIds, executeCommandWithTracking]);
// Helper function to collect source files for multi-file export
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
const sourceFiles = new Map<FileId, File>();
// Always include original files
activeFileIds.forEach(fileId => {
const file = selectors.getFile(fileId);
if (file) {
sourceFiles.set(fileId, file);
}
});
// Use multi-file export if we have multiple original files
const hasInsertedFiles = false;
const hasMultipleOriginalFiles = activeFileIds.length > 1;
if (!hasInsertedFiles && !hasMultipleOriginalFiles) {
return null; // Use single-file export method
}
return sourceFiles.size > 0 ? sourceFiles : null;
}, [activeFileIds, selectors]);
// Helper function to generate proper filename for exports
const getExportFilename = useCallback((): string => {
if (activeFileIds.length <= 1) {
// Single file - use original name
return displayDocument?.name || 'document.pdf';
}
// Multiple files - use first file name with " (merged)" suffix
const firstFile = selectors.getFile(activeFileIds[0]);
if (firstFile) {
const baseName = firstFile.name.replace(/\.pdf$/i, '');
return `${baseName} (merged).pdf`;
}
return 'merged-document.pdf';
}, [activeFileIds, selectors, displayDocument]);
const onExportSelected = useCallback(async () => {
if (!displayDocument || selectedPageIds.length === 0) return;
setExportLoading(true);
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument, // Original order
displayDocument, // Current display order (includes reordering)
splitPositions // Position-based splits
);
// For selected pages export, we work with the first document (or single document)
const documentWithDOMState = Array.isArray(processedDocuments) ? processedDocuments[0] : processedDocuments;
// Step 2: Use the already selected page IDs
// Filter to only include IDs that exist in the document with DOM state
const validSelectedPageIds = selectedPageIds.filter(pageId =>
documentWithDOMState.pages.some(p => p.id === pageId)
);
// Step 3: Export with pdfExportService
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const result = sourceFiles
? await pdfExportService.exportPDFMultiFile(
documentWithDOMState,
sourceFiles,
validSelectedPageIds,
{ selectedOnly: true, filename: exportFilename }
)
: await pdfExportService.exportPDF(
documentWithDOMState,
validSelectedPageIds,
{ selectedOnly: true, filename: exportFilename }
);
// Step 4: Download the result
pdfExportService.downloadFile(result.blob, result.filename);
setHasUnsavedChanges(false); // Clear unsaved changes after successful export
setExportLoading(false);
} catch (error) {
console.error('Export failed:', error);
setExportLoading(false);
}
}, [displayDocument, selectedPageIds, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
const onExportAll = useCallback(async () => {
if (!displayDocument) return;
setExportLoading(true);
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument,
displayDocument,
splitPositions
);
// Step 2: Export to files
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
// Step 3: Download
if (files.length > 1) {
// Multiple files - create ZIP
const JSZip = await import('jszip');
const zip = new JSZip.default();
files.forEach((file) => {
zip.file(file.name, file);
});
const zipBlob = await zip.generateAsync({ type: 'blob' });
const exportFilename = getExportFilename();
const zipFilename = exportFilename.replace(/\.pdf$/i, '.zip');
pdfExportService.downloadFile(zipBlob, zipFilename);
} else {
// Single file - download directly
const file = files[0];
pdfExportService.downloadFile(file, file.name);
}
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error('Export failed:', error);
setExportLoading(false);
}
}, [displayDocument, mergedPdfDocument, splitPositions, getSourceFiles, getExportFilename, setHasUnsavedChanges]);
// Apply DOM changes to document state using dedicated service
const applyChanges = useCallback(async () => {
if (!displayDocument) return;
setExportLoading(true);
try {
// Step 1: Apply DOM changes to document state first
const processedDocuments = documentManipulationService.applyDOMChangesToDocument(
mergedPdfDocument || displayDocument,
displayDocument,
splitPositions
);
// Step 2: Export to files
const sourceFiles = getSourceFiles();
const exportFilename = getExportFilename();
const files = await exportProcessedDocumentsToFiles(processedDocuments, sourceFiles, exportFilename);
// Step 3: Create StirlingFiles and stubs for version history
const parentStub = selectors.getStirlingFileStub(activeFileIds[0]);
if (!parentStub) throw new Error('Parent stub not found');
const { stirlingFiles, stubs } = await createStirlingFilesAndStubs(files, parentStub, 'multiTool');
// Step 4: Consume files (replace in context)
await actions.consumeFiles(activeFileIds, stirlingFiles, stubs);
setHasUnsavedChanges(false);
setExportLoading(false);
} catch (error) {
console.error('Apply changes failed:', error);
setExportLoading(false);
}
}, [displayDocument, mergedPdfDocument, splitPositions, activeFileIds, getSourceFiles, getExportFilename, actions, selectors, setHasUnsavedChanges]);
const closePdf = useCallback(() => {
actions.clearAllFiles();
undoManagerRef.current.clear();
setSelectedPageIds([]);
setSelectionMode(false);
}, [actions]);
usePageEditorRightRailButtons({
totalPages,
selectedPageCount,
csvInput,
setCsvInput,
selectedPageIds,
displayDocument: displayDocument || undefined,
updatePagesFromCSV,
handleSelectAll,
handleDeselectAll,
handleDelete,
onExportSelected,
exportLoading,
activeFileCount: activeFileIds.length,
closePdf,
});
// Export preview function - defined after export functions to avoid circular dependency
const handleExportPreview = useCallback((selectedOnly: boolean = false) => {
if (!displayDocument) return;
// For now, trigger the actual export directly
// In the original, this would show a preview modal first
if (selectedOnly) {
onExportSelected();
} else {
onExportAll();
}
}, [displayDocument, onExportSelected, onExportAll]);
// Expose functions to parent component
useEffect(() => {
if (onFunctionsReady) {
onFunctionsReady({
handleUndo,
handleRedo,
canUndo,
canRedo,
handleRotate,
handleDelete,
handleSplit,
handleSplitAll,
handlePageBreak,
handlePageBreakAll,
handleSelectAll,
handleDeselectAll,
handleSetSelectedPages,
showExportPreview: handleExportPreview,
onExportSelected,
onExportAll,
applyChanges,
exportLoading,
selectionMode,
selectedPageIds,
displayDocument: displayDocument || undefined,
splitPositions,
totalPages: displayDocument?.pages.length || 0,
closePdf,
});
}
}, [
onFunctionsReady, handleUndo, handleRedo, canUndo, canRedo, handleRotate, handleDelete, handleSplit, handleSplitAll,
handlePageBreak, handlePageBreakAll, handleSelectAll, handleDeselectAll, handleSetSelectedPages, handleExportPreview, onExportSelected, onExportAll, applyChanges, exportLoading,
selectionMode, selectedPageIds, splitPositions, displayDocument?.pages.length, closePdf
]);
// Display all pages - use edited or original document
const displayedPages = displayDocument?.pages || [];
return (
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
{!mergedPdfDocument && !globalProcessing && activeFileIds.length === 0 && (
<Center h='100%'>
<Stack align="center" gap="md">
<Text size="lg" c="dimmed">📄</Text>
<Text c="dimmed">No PDF files loaded</Text>
<Text size="sm" c="dimmed">Add files to start editing pages</Text>
</Stack>
</Center>
)}
{!mergedPdfDocument && globalProcessing && (
<Box p={0}>
<SkeletonLoader type="controls" />
<SkeletonLoader type="pageGrid" count={8} />
</Box>
)}
{displayDocument && (
<Box ref={gridContainerRef} p={0} pb="15rem" style={{ position: 'relative' }}>
{/* Split Lines Overlay */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
pointerEvents: 'none',
zIndex: 10
}}
>
{(() => {
// Calculate remToPx once outside the map to avoid layout thrashing
const containerWidth = containerDimensions.width;
const remToPx = parseFloat(getComputedStyle(document.documentElement).fontSize);
const ITEM_WIDTH = parseFloat(GRID_CONSTANTS.ITEM_WIDTH) * remToPx;
const ITEM_HEIGHT = parseFloat(GRID_CONSTANTS.ITEM_HEIGHT) * remToPx;
const ITEM_GAP = parseFloat(GRID_CONSTANTS.ITEM_GAP) * remToPx;
return Array.from(splitPositions).map((position) => {
// Calculate items per row using DragDropGrid's logic
const availableWidth = containerWidth - ITEM_GAP; // Account for first gap
const itemWithGap = ITEM_WIDTH + ITEM_GAP;
const itemsPerRow = Math.max(1, Math.floor(availableWidth / itemWithGap));
// Calculate position within the grid (same as DragDropGrid)
const row = Math.floor(position / itemsPerRow);
const col = position % itemsPerRow;
// Position split line between pages (after the current page)
// Calculate grid centering offset (same as DragDropGrid)
const gridWidth = itemsPerRow * ITEM_WIDTH + (itemsPerRow - 1) * ITEM_GAP;
const gridOffset = Math.max(0, (containerWidth - gridWidth) / 2);
const leftPosition = gridOffset + col * itemWithGap + ITEM_WIDTH + (ITEM_GAP / 2);
const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05); // Center vertically (5% offset since page is 90% height)
return (
<div
key={`split-${position}`}
style={{
position: 'absolute',
left: leftPosition,
top: topPosition,
width: '1px',
height: `calc(${GRID_CONSTANTS.ITEM_HEIGHT} * 0.9)`, // Match page container height (90%)
borderLeft: '1px dashed #3b82f6'
}}
/>
);
});
})()}
</div>
{/* Pages Grid */}
<DragDropGrid
items={displayedPages}
selectedItems={selectedPageIds}
selectionMode={selectionMode}
isAnimating={isAnimating}
onReorderPages={handleReorderPages}
renderItem={(page, index, refs) => (
<PageThumbnail
key={page.id}
page={page}
index={index}
totalPages={displayDocument.pages.length}
originalFile={(page as any).originalFileId ? selectors.getFile((page as any).originalFileId) : undefined}
selectedPageIds={selectedPageIds}
selectionMode={selectionMode}
movingPage={movingPage}
isAnimating={isAnimating}
pageRefs={refs}
onReorderPages={handleReorderPages}
onTogglePage={togglePage}
onAnimateReorder={animateReorder}
onExecuteCommand={executeCommand}
onSetStatus={() => {}}
onSetMovingPage={setMovingPage}
onDeletePage={handleDeletePage}
createRotateCommand={createRotateCommand}
createDeleteCommand={createDeleteCommand}
createSplitCommand={createSplitCommand}
pdfDocument={displayDocument}
setPdfDocument={setEditedDocument}
splitPositions={splitPositions}
onInsertFiles={handleInsertFiles}
/>
)}
/>
</Box>
)}
<NavigationWarningModal
onApplyAndContinue={async () => {
await applyChanges();
}}
onExportAndContinue={async () => {
await onExportAll();
}}
/>
</Box>
);
};
export default PageEditor;
@@ -0,0 +1,219 @@
import {
Tooltip,
ActionIcon,
} 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 DeleteIcon from "@mui/icons-material/Delete";
import InsertPageBreakIcon from "@mui/icons-material/InsertPageBreak";
interface PageEditorControlsProps {
// Close/Reset functions
onClosePdf: () => void;
// Undo/Redo
onUndo: () => void;
onRedo: () => void;
canUndo: boolean;
canRedo: boolean;
// Page operations
onRotate: (direction: 'left' | 'right') => void;
onDelete: () => void;
onSplit: () => void;
onSplitAll: () => void;
onPageBreak: () => void;
onPageBreakAll: () => void;
// Export functions (moved to right rail)
onExportAll: () => void;
exportLoading: boolean;
// Selection state
selectionMode: boolean;
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
// Split state (for tooltip logic)
splitPositions?: Set<number>;
totalPages?: number;
}
const PageEditorControls = ({
onUndo,
onRedo,
canUndo,
canRedo,
onRotate,
onDelete,
onSplit,
onPageBreak,
selectedPageIds,
displayDocument,
splitPositions,
totalPages
}: PageEditorControlsProps) => {
// Calculate split tooltip text using smart toggle logic
const getSplitTooltip = () => {
if (!splitPositions || !totalPages || selectedPageIds.length === 0) {
return "Split Selected";
}
// Convert selected pages to split positions (same logic as handleSplit)
const selectedPageNumbers = displayDocument ? selectedPageIds.map(id => {
const page = displayDocument.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(num => num > 0) : [];
const selectedSplitPositions = selectedPageNumbers.map(pageNum => pageNum - 1).filter(pos => pos < totalPages - 1);
if (selectedSplitPositions.length === 0) {
return "Split Selected";
}
// Smart toggle logic: follow the majority, default to adding splits if equal
const existingSplitsCount = selectedSplitPositions.filter(pos => splitPositions.has(pos)).length;
const noSplitsCount = selectedSplitPositions.length - existingSplitsCount;
// Remove splits only if majority already have splits
// If equal (50/50), default to adding splits
const willRemoveSplits = existingSplitsCount > noSplitsCount;
if (willRemoveSplits) {
return existingSplitsCount === selectedSplitPositions.length
? "Remove All Selected Splits"
: "Remove Selected Splits";
} else {
return existingSplitsCount === 0
? "Split Selected"
: "Complete Selected Splits";
}
};
// Calculate page break tooltip text
const getPageBreakTooltip = () => {
return selectedPageIds.length > 0
? `Insert ${selectedPageIds.length} Page Break${selectedPageIds.length > 1 ? 's' : ''}`
: "Insert Page Breaks";
};
return (
<div
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
zIndex: 50,
display: 'flex',
justifyContent: 'center',
pointerEvents: 'none',
background: 'transparent',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 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: 360,
maxWidth: 700,
flexWrap: 'wrap',
justifyContent: 'center',
padding: "1rem",
paddingBottom: "1rem"
}}
>
{/* Undo/Redo */}
<Tooltip label="Undo">
<ActionIcon onClick={onUndo} disabled={!canUndo} variant="subtle" radius="md" size="lg">
<UndoIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Redo">
<ActionIcon onClick={onRedo} disabled={!canRedo} variant="subtle" radius="md" size="lg">
<RedoIcon />
</ActionIcon>
</Tooltip>
<div style={{ width: 1, height: 28, backgroundColor: 'var(--mantine-color-gray-3)', margin: '0 8px' }} />
{/* Page Operations */}
<Tooltip label="Rotate Selected Left">
<ActionIcon
onClick={() => onRotate('left')}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg"
>
<RotateLeftIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Rotate Selected Right">
<ActionIcon
onClick={() => onRotate('right')}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg"
>
<RotateRightIcon />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Selected">
<ActionIcon
onClick={onDelete}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg"
>
<DeleteIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={getSplitTooltip()}>
<ActionIcon
onClick={onSplit}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg"
>
<ContentCutIcon />
</ActionIcon>
</Tooltip>
<Tooltip label={getPageBreakTooltip()}>
<ActionIcon
onClick={onPageBreak}
disabled={selectedPageIds.length === 0}
variant="subtle"
style={{ color: 'var(--mantine-color-dimmed)' }}
radius="md"
size="lg"
>
<InsertPageBreakIcon />
</ActionIcon>
</Tooltip>
</div>
</div>
);
};
export default PageEditorControls;
@@ -0,0 +1,59 @@
import { ActionIcon, Popover } from '@mantine/core';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip } from '@app/components/shared/Tooltip';
import BulkSelectionPanel from '@app/components/pageEditor/BulkSelectionPanel';
interface PageSelectByNumberButtonProps {
disabled: boolean;
totalPages: number;
label: string;
csvInput: string;
setCsvInput: (value: string) => void;
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
updatePagesFromCSV: (override?: string) => void;
}
export default function PageSelectByNumberButton({
disabled,
totalPages,
label,
csvInput,
setCsvInput,
selectedPageIds,
displayDocument,
updatePagesFromCSV,
}: PageSelectByNumberButtonProps) {
return (
<Tooltip content={label} position="left" offset={12} arrow portalTarget={document.body}>
<div className={`right-rail-fade enter`}>
<Popover position="left" withArrow shadow="md" offset={8}>
<Popover.Target>
<div style={{ display: 'inline-flex' }}>
<ActionIcon
variant="subtle"
radius="md"
className="right-rail-icon"
disabled={disabled || totalPages === 0}
aria-label={label}
>
<LocalIcon icon="pin-end" width="1.5rem" height="1.5rem" />
</ActionIcon>
</div>
</Popover.Target>
<Popover.Dropdown>
<div style={{ minWidth: '24rem', maxWidth: '32rem' }}>
<BulkSelectionPanel
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPageIds={selectedPageIds}
displayDocument={displayDocument}
onUpdatePagesFromCSV={updatePagesFromCSV}
/>
</div>
</Popover.Dropdown>
</Popover>
</div>
</Tooltip>
);
}
@@ -0,0 +1,501 @@
import React, { useCallback, useState, useEffect, useRef, useMemo } from 'react';
import { Text, Checkbox } from '@mantine/core';
import { useMediaQuery } from '@mantine/hooks';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import ArrowForwardIcon from '@mui/icons-material/ArrowForward';
import RotateLeftIcon from '@mui/icons-material/RotateLeft';
import RotateRightIcon from '@mui/icons-material/RotateRight';
import DeleteIcon from '@mui/icons-material/Delete';
import ContentCutIcon from '@mui/icons-material/ContentCut';
import AddIcon from '@mui/icons-material/Add';
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
import { PDFPage, PDFDocument } from '@app/types/pageEditor';
import { useThumbnailGeneration } from '@app/hooks/useThumbnailGeneration';
import { useFilesModalContext } from '@app/contexts/FilesModalContext';
import styles from '@app/components/pageEditor/PageEditor.module.css';
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
interface PageThumbnailProps {
page: PDFPage;
index: number;
totalPages: number;
originalFile?: File;
selectedPageIds: string[];
selectionMode: boolean;
movingPage: number | null;
isAnimating: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
onTogglePage: (pageId: string) => void;
onAnimateReorder: () => void;
onExecuteCommand: (command: { execute: () => void }) => void;
onSetStatus: (status: string) => void;
onSetMovingPage: (page: number | null) => void;
onDeletePage: (pageNumber: number) => void;
createRotateCommand: (pageIds: string[], rotation: number) => { execute: () => void };
createDeleteCommand: (pageIds: string[]) => { execute: () => void };
createSplitCommand: (position: number) => { execute: () => void };
pdfDocument: PDFDocument;
setPdfDocument: (doc: PDFDocument) => void;
splitPositions: Set<number>;
onInsertFiles?: (files: File[], insertAfterPage: number) => void;
}
const PageThumbnail: React.FC<PageThumbnailProps> = ({
page,
index,
totalPages,
originalFile,
selectedPageIds,
selectionMode,
movingPage,
isAnimating,
pageRefs,
onReorderPages,
onTogglePage,
onExecuteCommand,
onSetStatus,
onSetMovingPage,
onDeletePage,
createRotateCommand,
createSplitCommand,
pdfDocument,
splitPositions,
onInsertFiles,
}: PageThumbnailProps) => {
const [isDragging, setIsDragging] = useState(false);
const [isMouseDown, setIsMouseDown] = useState(false);
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
const [isHovered, setIsHovered] = useState(false);
const isMobile = useMediaQuery('(max-width: 1024px)');
const dragElementRef = useRef<HTMLDivElement>(null);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
const { openFilesModal } = useFilesModalContext();
// Calculate document aspect ratio from first non-blank page
const getDocumentAspectRatio = useCallback(() => {
// Find first non-blank page with a thumbnail to get aspect ratio
const firstRealPage = pdfDocument.pages.find(p => !p.isBlankPage && p.thumbnail);
if (firstRealPage?.thumbnail) {
// Try to get aspect ratio from an actual thumbnail image
// For now, default to A4 but could be enhanced to measure image dimensions
return '1 / 1.414'; // A4 ratio as fallback
}
return '1 / 1.414'; // Default A4 ratio
}, [pdfDocument.pages]);
// Update thumbnail URL when page prop changes
useEffect(() => {
if (page.thumbnail && page.thumbnail !== thumbnailUrl) {
setThumbnailUrl(page.thumbnail);
}
}, [page.thumbnail, thumbnailUrl]);
// Request thumbnail if missing (on-demand, virtualized approach)
useEffect(() => {
let isCancelled = false;
// If we already have a thumbnail, use it
if (page.thumbnail) {
setThumbnailUrl(page.thumbnail);
return;
}
// Check cache first
const cachedThumbnail = getThumbnailFromCache(page.id);
if (cachedThumbnail) {
setThumbnailUrl(cachedThumbnail);
return;
}
// Request thumbnail generation if we have the original file
if (originalFile) {
const pageNumber = page.originalPageNumber;
requestThumbnail(page.id, originalFile, pageNumber)
.then(thumbnail => {
if (!isCancelled && thumbnail) {
setThumbnailUrl(thumbnail);
}
})
.catch(error => {
console.warn(`Failed to generate thumbnail for ${page.id}:`, error);
});
}
return () => {
isCancelled = true;
};
}, [page.id, page.thumbnail, originalFile, getThumbnailFromCache, requestThumbnail]);
const pageElementRef = useCallback((element: HTMLDivElement | null) => {
if (element) {
pageRefs.current.set(page.id, element);
dragElementRef.current = element;
const dragCleanup = draggable({
element,
getInitialData: () => ({
pageNumber: page.pageNumber,
pageId: page.id,
selectedPageIds: [page.id]
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: ({ location }) => {
setIsDragging(false);
if (location.current.dropTargets.length === 0) {
return;
}
const dropTarget = location.current.dropTargets[0];
const targetData = dropTarget.data;
if (targetData.type === 'page') {
const targetPageNumber = targetData.pageNumber as number;
const targetIndex = pdfDocument.pages.findIndex(p => p.pageNumber === targetPageNumber);
if (targetIndex !== -1) {
onReorderPages(page.pageNumber, targetIndex, undefined);
}
}
}
});
element.style.cursor = 'grab';
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'page',
pageNumber: page.pageNumber
}),
onDrop: (_) => {}
});
(element as any).__dragCleanup = () => {
dragCleanup();
dropCleanup();
};
} else {
pageRefs.current.delete(page.id);
if (dragElementRef.current && (dragElementRef.current as any).__dragCleanup) {
(dragElementRef.current as any).__dragCleanup();
}
}
}, [page.id, page.pageNumber, pageRefs, selectionMode, selectedPageIds, pdfDocument.pages, onReorderPages]);
// DOM command handlers
const handleRotateLeft = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
// Use the command system for undo/redo support
const command = createRotateCommand([page.id], -90);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} left`);
}, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]);
const handleRotateRight = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
// Use the command system for undo/redo support
const command = createRotateCommand([page.id], 90);
onExecuteCommand(command);
onSetStatus(`Rotated page ${page.pageNumber} right`);
}, [page.id, page.pageNumber, onExecuteCommand, onSetStatus, createRotateCommand]);
const handleDelete = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onDeletePage(page.pageNumber);
onSetStatus(`Deleted page ${page.pageNumber}`);
}, [page.pageNumber, onDeletePage, onSetStatus]);
const handleSplit = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
// Create a command to toggle split at this position
const command = createSplitCommand(index);
onExecuteCommand(command);
const hasSplit = splitPositions.has(index);
const action = hasSplit ? 'removed' : 'added';
onSetStatus(`Split marker ${action} after position ${index + 1}`);
}, [index, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
const handleInsertFileAfter = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
if (onInsertFiles) {
// Open file manager modal with custom handler for page insertion
openFilesModal({
insertAfterPage: page.pageNumber,
customHandler: (files: File[], insertAfterPage?: number) => {
if (insertAfterPage !== undefined) {
onInsertFiles(files, insertAfterPage);
}
}
});
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
} else {
// Fallback to normal file handling
openFilesModal({ insertAfterPage: page.pageNumber });
onSetStatus(`Select files to insert after page ${page.pageNumber}`);
}
}, [openFilesModal, page.pageNumber, onSetStatus, onInsertFiles]);
// Handle click vs drag differentiation
const handleMouseDown = useCallback((e: React.MouseEvent) => {
setIsMouseDown(true);
setMouseStartPos({ x: e.clientX, y: e.clientY });
}, []);
const handleMouseUp = useCallback((e: React.MouseEvent) => {
if (!isMouseDown || !mouseStartPos) {
setIsMouseDown(false);
setMouseStartPos(null);
return;
}
// Calculate distance moved
const deltaX = Math.abs(e.clientX - mouseStartPos.x);
const deltaY = Math.abs(e.clientY - mouseStartPos.y);
const distance = Math.sqrt(deltaX * deltaX + deltaY * deltaY);
// If mouse moved less than 5 pixels, consider it a click (not a drag)
if (distance < 5 && !isDragging) {
onTogglePage(page.id);
}
setIsMouseDown(false);
setMouseStartPos(null);
}, [isMouseDown, mouseStartPos, isDragging, page.id, onTogglePage]);
const handleMouseLeave = useCallback(() => {
setIsMouseDown(false);
setMouseStartPos(null);
setIsHovered(false);
}, []);
// Build hover menu actions
const hoverActions = useMemo<HoverAction[]>(() => [
{
id: 'move-left',
icon: <ArrowBackIcon style={{ fontSize: 20 }} />,
label: 'Move Left',
onClick: (e) => {
e.stopPropagation();
if (index > 0 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber);
onReorderPages(page.pageNumber, index - 1);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} left`);
}
},
disabled: index === 0
},
{
id: 'move-right',
icon: <ArrowForwardIcon style={{ fontSize: 20 }} />,
label: 'Move Right',
onClick: (e) => {
e.stopPropagation();
if (index < totalPages - 1 && !movingPage && !isAnimating) {
onSetMovingPage(page.pageNumber);
onReorderPages(page.pageNumber, index + 1);
setTimeout(() => onSetMovingPage(null), 650);
onSetStatus(`Moved page ${page.pageNumber} right`);
}
},
disabled: index === totalPages - 1
},
{
id: 'rotate-left',
icon: <RotateLeftIcon style={{ fontSize: 20 }} />,
label: 'Rotate Left',
onClick: handleRotateLeft,
},
{
id: 'rotate-right',
icon: <RotateRightIcon style={{ fontSize: 20 }} />,
label: 'Rotate Right',
onClick: handleRotateRight,
},
{
id: 'delete',
icon: <DeleteIcon style={{ fontSize: 20 }} />,
label: 'Delete Page',
onClick: handleDelete,
color: 'red',
},
{
id: 'split',
icon: <ContentCutIcon style={{ fontSize: 20 }} />,
label: 'Split After',
onClick: handleSplit,
hidden: index >= totalPages - 1,
},
{
id: 'insert',
icon: <AddIcon style={{ fontSize: 20 }} />,
label: 'Insert File After',
onClick: handleInsertFileAfter,
}
], [index, totalPages, movingPage, isAnimating, page.pageNumber, handleRotateLeft, handleRotateRight, handleDelete, handleSplit, handleInsertFileAfter, onReorderPages, onSetMovingPage, onSetStatus]);
return (
<div
ref={pageElementRef}
data-page-id={page.id}
data-page-number={page.pageNumber}
className={`
${styles.pageContainer}
!rounded-lg
${selectionMode ? 'cursor-pointer' : 'cursor-grab'}
select-none
w-[20rem]
h-[20rem]
flex items-center justify-center
flex-shrink-0
shadow-sm
hover:shadow-md
transition-all
relative
${selectionMode
? 'bg-white hover:bg-gray-50'
: 'bg-white hover:bg-gray-50'}
${isDragging ? 'opacity-50 scale-95' : ''}
${movingPage === page.pageNumber ? 'page-moving' : ''}
`}
style={{
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out'
}}
draggable={false}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={handleMouseLeave}
>
{
<div
className={styles.checkboxContainer}
style={{
position: 'absolute',
top: 8,
right: 8,
zIndex: 10,
backgroundColor: 'white',
borderRadius: '4px',
padding: '2px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
pointerEvents: 'auto'
}}
onMouseDown={(e) => {
e.stopPropagation();
onTogglePage(page.id);
}}
onMouseUp={(e) => e.stopPropagation()}
onDragStart={(e) => {
e.preventDefault();
e.stopPropagation();
}}
>
<Checkbox
checked={Array.isArray(selectedPageIds) ? selectedPageIds.includes(page.id) : false}
onChange={() => {
// Selection is handled by container mouseDown
}}
size="sm"
style={{ pointerEvents: 'none' }}
/>
</div>
}
<div className="page-container w-[90%] h-[90%]" draggable={false}>
<div
style={{
width: '100%',
height: '100%',
backgroundColor: 'var(--mantine-color-gray-1)',
borderRadius: 6,
border: '1px solid var(--mantine-color-gray-3)',
padding: 4,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
>
{page.isBlankPage ? (
<div style={{
width: '100%',
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<div style={{
width: '70%',
aspectRatio: getDocumentAspectRatio(),
backgroundColor: 'white',
border: '1px solid #e9ecef',
borderRadius: 2
}}></div>
</div>
) : thumbnailUrl ? (
<img
className="ph-no-capture"
src={thumbnailUrl}
alt={`Page ${page.pageNumber}`}
draggable={false}
data-original-rotation={page.rotation}
style={{
width: '100%',
height: '100%',
objectFit: 'contain',
borderRadius: 2,
transform: `rotate(${page.rotation}deg)`,
transition: 'transform 0.3s ease-in-out'
}}
/>
) : (
<div style={{ textAlign: 'center' }}>
<Text size="lg" c="dimmed">📄</Text>
<Text size="xs" c="dimmed" mt={4}>Page {page.pageNumber}</Text>
</div>
)}
</div>
<Text
className={styles.pageNumber}
size="sm"
fw={500}
style={{
color: 'var(--mantine-color-white)', // Use theme token for consistency
position: 'absolute',
top: 5,
left: 5,
background: page.isBlankPage ? 'rgba(255, 165, 0, 0.8)' : 'rgba(162, 201, 255, 0.8)',
padding: '6px 8px',
borderRadius: 8,
zIndex: 2,
opacity: 0,
transition: 'opacity 0.2s ease-in-out'
}}
>
{page.pageNumber}
</Text>
<HoverActionMenu
show={isHovered || isMobile}
actions={hoverActions}
position="inside"
className={styles.pageHoverControls}
/>
</div>
</div>
);
};
export default PageThumbnail;
@@ -0,0 +1,147 @@
import { useState } from 'react';
import { Flex } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
import {
appendExpression,
insertOperatorSmart,
firstNExpression,
lastNExpression,
everyNthExpression,
rangeExpression,
LogicalOperator,
} from '@app/components/pageEditor/bulkSelectionPanel/BulkSelection';
import SelectPages from '@app/components/pageEditor/bulkSelectionPanel/SelectPages';
import OperatorsSection from '@app/components/pageEditor/bulkSelectionPanel/OperatorsSection';
interface AdvancedSelectionPanelProps {
csvInput: string;
setCsvInput: (value: string) => void;
onUpdatePagesFromCSV: (override?: string) => void;
maxPages: number;
advancedOpened?: boolean;
}
const AdvancedSelectionPanel = ({
csvInput,
setCsvInput,
onUpdatePagesFromCSV,
maxPages,
advancedOpened,
}: AdvancedSelectionPanelProps) => {
const { t } = useTranslation();
const [rangeEnd, setRangeEnd] = useState<number | ''>('');
const handleRangeEndChange = (val: string | number) => {
const next = typeof val === 'number' ? val : '';
setRangeEnd(next);
};
// Named validation functions
const validatePositiveNumber = (value: number): string | null => {
return value <= 0 ? 'Enter a positive number' : null;
};
const validateRangeStart = (start: number): string | null => {
if (start <= 0) return 'Values must be positive';
if (typeof rangeEnd === 'number' && start > rangeEnd) {
return 'From must be less than or equal to To';
}
return null;
};
// Named callback functions
const applyExpression = (expr: string) => {
const nextInput = appendExpression(csvInput, expr);
setCsvInput(nextInput);
onUpdatePagesFromCSV(nextInput);
};
const insertOperator = (op: LogicalOperator) => {
const next = insertOperatorSmart(csvInput, op);
setCsvInput(next);
// Trigger visual selection update for 'even' and 'odd' operators
if (op === 'even' || op === 'odd') {
onUpdatePagesFromCSV(next);
}
};
const handleFirstNApply = (value: number) => {
const expr = firstNExpression(value, maxPages);
if (expr) applyExpression(expr);
};
const handleLastNApply = (value: number) => {
const expr = lastNExpression(value, maxPages);
if (expr) applyExpression(expr);
};
const handleEveryNthApply = (value: number) => {
const expr = everyNthExpression(value);
if (expr) applyExpression(expr);
};
const handleRangeApply = (start: number) => {
if (typeof rangeEnd !== 'number') return;
const expr = rangeExpression(start, rangeEnd, maxPages);
if (expr) applyExpression(expr);
setRangeEnd('');
};
return (
<>
{/* Advanced section */}
{advancedOpened && (
<div className={classes.advancedSection}>
<div className={classes.advancedContent}>
{/* Cards row */}
<Flex direction="row" mb="xs" wrap="wrap">
<SelectPages
title={t('bulkSelection.firstNPages.title', 'First N Pages')}
placeholder={t('bulkSelection.firstNPages.placeholder', 'Number of pages')}
onApply={handleFirstNApply}
maxPages={maxPages}
validationFn={validatePositiveNumber}
/>
<SelectPages
title={t('bulkSelection.range.title', 'Range')}
placeholder={t('bulkSelection.range.fromPlaceholder', 'From')}
onApply={handleRangeApply}
maxPages={maxPages}
validationFn={validateRangeStart}
isRange={true}
rangeEndValue={rangeEnd}
onRangeEndChange={handleRangeEndChange}
rangeEndPlaceholder={t('bulkSelection.range.toPlaceholder', 'To')}
/>
<SelectPages
title={t('bulkSelection.lastNPages.title', 'Last N Pages')}
placeholder={t('bulkSelection.lastNPages.placeholder', 'Number of pages')}
onApply={handleLastNApply}
maxPages={maxPages}
validationFn={validatePositiveNumber}
/>
<SelectPages
title={t('bulkSelection.everyNthPage.title', 'Every Nth Page')}
placeholder={t('bulkSelection.everyNthPage.placeholder', 'Step size')}
onApply={handleEveryNthApply}
maxPages={maxPages}
/>
</Flex>
{/* Operators row at bottom */}
<OperatorsSection
csvInput={csvInput}
onInsertOperator={insertOperator}
/>
</div>
</div>
)}
</>
);
};
export default AdvancedSelectionPanel;
@@ -0,0 +1,136 @@
// Pure helper utilities for the BulkSelectionPanel UI
export type LogicalOperator = 'and' | 'or' | 'not' | 'even' | 'odd';
// Returns a new CSV expression with expr appended.
// If current ends with an operator token, expr is appended directly.
// Otherwise, it is joined with " or ".
export function appendExpression(currentInput: string, expr: string): string {
const current = (currentInput || '').trim();
if (!current) return expr;
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(current);
// Add space if operator doesn't already have one
if (endsWithOperator) {
const needsSpace = !current.endsWith(' ');
return `${current}${needsSpace ? ' ' : ''}${expr}`;
}
return `${current} or ${expr}`;
}
// Smartly inserts/normalizes a logical operator at the end of the current input.
// Produces a trailing space to allow the next token to be typed naturally.
export function insertOperatorSmart(currentInput: string, op: LogicalOperator): string {
const text = (currentInput || '').trim();
// Handle 'even' and 'odd' as page selection expressions, not logical operators
if (op === 'even' || op === 'odd') {
if (text.length === 0) return `${op} `;
// If current input ends with a logical operator, append the page selection with proper spacing
const endsWithOperator = /(\b(and|not|or)\s*|[&|,!]\s*)$/i.test(text);
if (endsWithOperator) {
// Add space if the operator doesn't already have one
const needsSpace = !text.endsWith(' ');
return `${text}${needsSpace ? ' ' : ''}${op} `;
}
return `${text} or ${op} `;
}
if (text.length === 0) return `${op} `;
// Extract up to the last two operator tokens (words or symbols) from the end
const tokens: string[] = [];
let rest = text;
for (let i = 0; i < 2; i++) {
const m = rest.match(/(?:\s*)(?:(&|\||,|!|\band\b|\bor\b|\bnot\b))\s*$/i);
if (!m || m.index === undefined) break;
const raw = m[1].toLowerCase();
const word = raw === '&' ? 'and' : raw === '|' || raw === ',' ? 'or' : raw === '!' ? 'not' : raw;
tokens.unshift(word);
rest = rest.slice(0, m.index).trimEnd();
}
const emit = (base: string, phrase: string) => `${base} ${phrase} `;
const click = op; // desired operator
if (tokens.length === 0) {
return emit(text, click);
}
// Normalize to allowed set
const phrase = tokens.join(' ');
const allowed = new Set(['and', 'or', 'not', 'and not', 'or not']);
// Helpers for transitions from a single trailing token
const fromSingle = (t: string): string => {
if (t === 'and') {
if (click === 'and') return 'and';
if (click === 'or') return 'or'; // 'and or' is invalid, so just use 'or'
return 'and not';
}
if (t === 'or') {
if (click === 'and') return 'and';
if (click === 'or') return 'or';
return 'or not';
}
// t === 'not'
if (click === 'and') return 'and';
if (click === 'or') return 'or';
return 'not';
};
// From combined phrase
const fromCombo = (p: string): string => {
if (p === 'and not') {
if (click === 'not') return 'and not';
if (click === 'and') return 'and';
if (click === 'or') return 'or'; // 'and not or' is invalid, so just use 'or'
return 'and not';
}
if (p === 'or not') {
if (click === 'not') return 'or not';
if (click === 'or') return 'or';
if (click === 'and') return 'and'; // 'or not and' is invalid, so just use 'and'
return 'or not';
}
// Invalid combos (e.g., 'not and', 'not or', 'or and', 'and or') → collapse to clicked op
return click;
};
const base = rest.trim();
const nextPhrase = tokens.length === 1 ? fromSingle(tokens[0]) : fromCombo(phrase);
if (!allowed.has(nextPhrase)) {
return emit(base, click);
}
return emit(base, nextPhrase);
}
// Expression builders for Advanced actions
export function firstNExpression(n: number, maxPages: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
const end = Math.min(maxPages, Math.max(1, Math.floor(n)));
return `1-${end}`;
}
export function lastNExpression(n: number, maxPages: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
const count = Math.max(1, Math.floor(n));
const start = Math.max(1, maxPages - count + 1);
if (maxPages <= 0) return null;
return `${start}-${maxPages}`;
}
export function everyNthExpression(n: number): string | null {
if (!Number.isFinite(n) || n <= 0) return null;
return `${Math.max(1, Math.floor(n))}n`;
}
export function rangeExpression(start: number, end: number, maxPages: number): string | null {
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
let s = Math.floor(start);
let e = Math.floor(end);
if (s > e) [s, e] = [e, s];
s = Math.max(1, s);
e = maxPages > 0 ? Math.min(maxPages, e) : e;
return `${s}-${e}`;
}
@@ -0,0 +1,295 @@
.panelGroup {
max-width: 100%;
flex-wrap: wrap;
min-width: 24rem;
}
.textInput {
flex: 1;
max-width: 100%;
}
.dropdownContainer {
margin-top: 0.5rem;
}
.menuDropdown {
min-width: 22.5rem;
}
.dropdownContent {
display: flex;
gap: 0.75rem;
}
.leftCol {
flex: 1 1 auto;
min-width: 0;
max-width: calc(100% - 8rem - 0.75rem);
overflow: hidden;
}
.rightCol {
width: 8rem;
border-left: 0.0625rem solid var(--border-default);
padding-left: 0.75rem;
display: flex;
flex-direction: column;
}
.operatorGroup {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.operatorChip {
width: 100%;
border-radius: 1.25rem;
border: 0.0625rem solid var(--bulk-card-border);
background-color: var(--bulk-card-bg);
color: var(--text-primary);
transition: all 0.2s ease;
min-height: 2rem;
}
.operatorChip:hover:not(:disabled) {
border-color: var(--bulk-card-hover-border);
background-color: var(--hover-bg);
transform: translateY(-0.0625rem);
box-shadow: var(--shadow-sm);
}
.operatorChip:active:not(:disabled) {
transform: translateY(0);
box-shadow: var(--shadow-xs);
}
.operatorChip:disabled {
opacity: 0.4;
cursor: not-allowed;
}
:global([data-mantine-color-scheme='dark']) .operatorChip {
background-color: var(--bulk-card-bg);
border-color: var(--bulk-card-border);
color: var(--text-primary);
}
:global([data-mantine-color-scheme='dark']) .operatorChip:hover:not(:disabled) {
background-color: var(--hover-bg);
border-color: var(--bulk-card-hover-border);
color: var(--text-primary);
}
.dropdownHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 0.0625rem solid var(--border-default);
margin-bottom: 0.5rem;
}
.closeButton {
min-width: 1.5rem;
height: 1.5rem;
padding: 0;
font-size: 1.25rem;
font-weight: bold;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.menuItemRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.chevron {
color: var(--text-muted);
}
/* Icon-based chevrons */
.chevronIcon {
transition: transform 150ms ease;
display: inline-flex;
align-items: center;
}
.chevronDown {
transform: rotate(90deg);
}
.chevronUp {
transform: rotate(270deg);
}
.inlineRow {
padding: 0.75rem 0.5rem;
}
.inlineRowCompact {
padding: 0.5rem 0.5rem 0.75rem 0.5rem;
}
.menuItemCloseHover {
background-color: var(--text-brand-accent);
opacity: 0.1;
transition: background-color 150ms ease;
}
:global([data-mantine-color-scheme='dark']) .menuItemCloseHover {
background-color: var(--text-brand-accent);
opacity: 0.2;
}
.selectedList {
max-height: 8rem;
overflow: auto;
background-color: var(--bg-raised);
border: 0.0625rem solid var(--border-default);
border-radius: 0.75rem;
padding: 0.5rem 0.75rem;
margin-top: 0.5rem;
min-width: 24rem;
color: var(--text-primary);
}
.selectedText {
word-break: break-word;
max-width: 100%;
}
.advancedSection {
margin-top: 0.5rem;
min-width: 24rem;
}
.advancedHeader {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem;
border-bottom: 0.0625rem solid var(--border-default);
margin-bottom: 0.5rem;
}
.advancedContent {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.advancedItem {
padding: 0.5rem;
cursor: pointer;
border-radius: 0.25rem;
transition: background-color 150ms ease;
}
.advancedItem:hover {
background-color: var(--hover-bg);
}
:global([data-mantine-color-scheme='dark']) .advancedItem:hover {
background-color: var(--hover-bg);
}
.advancedCard {
background-color: var(--bulk-card-bg);
border: none;
border-radius: 0.75rem;
padding: 0.25rem;
margin-bottom: 0.5rem;
width: 100%;
box-sizing: border-box;
color: var(--text-primary);
}
:global([data-mantine-color-scheme='dark']) .advancedCard {
background-color: var(--bulk-card-bg);
}
.inputGroup {
width: 100%;
}
.fullWidthInput {
flex: 1;
}
.applyButton {
min-width: 4rem;
flex-shrink: 0;
}
/* Style inputs and buttons within advanced cards to match bg-raised */
.advancedCard :global(.mantine-NumberInput-input) {
background-color: var(--bg-raised) !important;
border-color: var(--border-default) !important;
color: var(--text-primary) !important;
}
.advancedCard :global(.mantine-Button-root) {
background-color: var(--bg-raised) !important;
border-color: var(--border-default) !important;
color: var(--text-primary) !important;
}
.advancedCard :global(.mantine-Button-root:hover) {
background-color: var(--hover-bg) !important;
border-color: var(--border-strong) !important;
}
/* Error helper text above the input */
.errorText {
margin-top: 0.25rem;
color: var(--text-brand-accent);
}
/* Dark-mode adjustments */
:global([data-mantine-color-scheme='dark']) .selectedList {
background-color: var(--bg-raised);
}
/* Small screens: allow the section to shrink instead of enforcing a large min width */
@media (max-width: 480px) {
.panelGroup,
.selectedList,
.advancedSection,
.panelContainer {
min-width: 0;
}
}
/* Outermost panel container scrolling */
.panelContainer {
max-height: 95vh;
overflow: auto;
background-color: var(--bulk-panel-bg);
color: var(--text-primary);
border-radius: 0.5rem;
}
/* Override Mantine Popover dropdown background */
:global(.mantine-Popover-dropdown) {
background-color: var(--bulk-panel-bg) !important;
border-color: var(--bulk-card-border) !important;
color: var(--text-primary) !important;
}
/* Override Mantine Switch outline */
.advancedSwitch :global(.mantine-Switch-input) {
outline: none !important;
}
.advancedSwitch :global(.mantine-Switch-input:focus) {
outline: none !important;
box-shadow: none !important;
}
@@ -0,0 +1,74 @@
import { Button, Text, Group, Divider } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
import { LogicalOperator } from '@app/components/pageEditor/bulkSelectionPanel/BulkSelection';
interface OperatorsSectionProps {
csvInput: string;
onInsertOperator: (op: LogicalOperator) => void;
}
const OperatorsSection = ({ csvInput, onInsertOperator }: OperatorsSectionProps) => {
const { t } = useTranslation();
return (
<div>
<Text size="xs" c="var(--text-muted)" fw={500} mb="xs">{t('bulkSelection.keywords.title', 'Keywords')}:</Text>
<Group gap="sm" wrap="nowrap">
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator('and')}
disabled={!csvInput.trim()}
title="Combine selections (both conditions must be true)"
>
<Text size="xs" fw={500}>and</Text>
</Button>
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator('or')}
disabled={!csvInput.trim()}
title="Add to selection (either condition can be true)"
>
<Text size="xs" fw={500}>or</Text>
</Button>
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator('not')}
disabled={!csvInput.trim()}
title="Exclude from selection"
>
<Text size="xs" fw={500}>not</Text>
</Button>
</Group>
<Divider my="sm" />
<Group gap="sm" wrap="nowrap">
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator('even')}
title="Select all even-numbered pages (2, 4, 6, 8...)"
>
<Text size="xs" fw={500}>even</Text>
</Button>
<Button
size="sm"
variant="outline"
className={classes.operatorChip}
onClick={() => onInsertOperator('odd')}
title="Select all odd-numbered pages (1, 3, 5, 7...)"
>
<Text size="xs" fw={500}>odd</Text>
</Button>
</Group>
</div>
);
};
export default OperatorsSection;
@@ -0,0 +1,94 @@
import { TextInput, Button, Text, Flex, Switch } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import { Tooltip } from '@app/components/shared/Tooltip';
import { usePageSelectionTips } from '@app/components/tooltips/usePageSelectionTips';
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
interface PageSelectionInputProps {
csvInput: string;
setCsvInput: (value: string) => void;
onUpdatePagesFromCSV: (override?: string) => void;
onClear: () => void;
advancedOpened?: boolean;
onToggleAdvanced?: (v: boolean) => void;
}
const PageSelectionInput = ({
csvInput,
setCsvInput,
onUpdatePagesFromCSV,
onClear,
advancedOpened,
onToggleAdvanced,
}: PageSelectionInputProps) => {
const { t } = useTranslation();
const pageSelectionTips = usePageSelectionTips();
return (
<div className={classes.panelGroup}>
{/* Header row with tooltip/title and advanced toggle */}
<Flex justify="space-between" align="center" mb="sm">
<Tooltip
position="left"
offset={20}
header={pageSelectionTips.header}
portalTarget={document.body}
pinOnClick={true}
containerStyle={{ marginTop: "1rem"}}
tips={pageSelectionTips.tips}
>
<Flex onClick={(e) => e.stopPropagation()} align="center" gap="xs">
<LocalIcon icon="gpp-maybe-outline-rounded" width="1rem" height="1rem" style={{ color: 'var(--text-instruction)' }} />
<Text>Page Selection</Text>
</Flex>
</Tooltip>
{typeof advancedOpened === 'boolean' && (
<Flex align="center" gap="xs">
<Text size="sm" c="var(--text-secondary)">{t('bulkSelection.advanced.title', 'Advanced')}</Text>
<Switch
size="sm"
checked={!!advancedOpened}
onChange={(e) => onToggleAdvanced?.(e.currentTarget.checked)}
title={t('bulkSelection.advanced.title', 'Advanced')}
className={classes.advancedSwitch}
/>
</Flex>
)}
</Flex>
{/* Text input */}
<TextInput
value={csvInput}
onChange={(e) => {
const next = e.target.value;
setCsvInput(next);
onUpdatePagesFromCSV(next);
}}
placeholder="1,3,5-10"
rightSection={
csvInput && (
<Button
variant="subtle"
size="xs"
onClick={onClear}
style={{
color: 'var(--text-muted)',
minWidth: 'auto',
width: '24px',
height: '24px',
padding: 0
}}
>
×
</Button>
)
}
onKeyDown={(e) => e.key === 'Enter' && onUpdatePagesFromCSV()}
className={classes.textInput}
/>
</div>
);
};
export default PageSelectionInput;
@@ -0,0 +1,104 @@
import { useState } from 'react';
import { Button, Text, NumberInput, Group } from '@mantine/core';
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
interface SelectPagesProps {
title: string;
placeholder: string;
onApply: (value: number) => void;
maxPages: number;
validationFn?: (value: number) => string | null;
isRange?: boolean;
rangeEndValue?: number | '';
onRangeEndChange?: (value: string | number) => void;
rangeEndPlaceholder?: string;
}
const SelectPages = ({
title,
placeholder,
onApply,
validationFn,
isRange = false,
rangeEndValue,
onRangeEndChange,
rangeEndPlaceholder,
}: SelectPagesProps) => {
const [value, setValue] = useState<number | ''>('');
const [error, setError] = useState<string | null>(null);
const handleValueChange = (val: string | number) => {
const next = typeof val === 'number' ? val : '';
setValue(next);
if (validationFn && typeof next === 'number') {
setError(validationFn(next));
} else {
setError(null);
}
};
const handleApply = () => {
if (value === '' || typeof value !== 'number') return;
onApply(value);
setValue('');
setError(null);
};
const isDisabled = Boolean(error) || value === '';
return (
<div className={classes.advancedCard}>
<Text size="sm" fw={600} c="var(--text-secondary)" mb="xs">{title}</Text>
{error && (<Text size="xs" c="var(--text-brand-accent)" mb="xs">{error}</Text>)}
<div className={classes.inputGroup}>
<Group gap="sm" align="flex-end" wrap="nowrap">
{isRange ? (
<>
<div style={{ flex: 1 }}>
<NumberInput
size="sm"
value={value}
onChange={handleValueChange}
min={1}
placeholder={placeholder}
error={Boolean(error)}
/>
</div>
<div style={{ flex: 1 }}>
<NumberInput
size="sm"
value={rangeEndValue}
onChange={onRangeEndChange}
min={1}
placeholder={rangeEndPlaceholder}
error={Boolean(error)}
/>
</div>
</>
) : (
<NumberInput
size="sm"
value={value}
onChange={handleValueChange}
min={1}
placeholder={placeholder}
className={classes.fullWidthInput}
error={Boolean(error)}
/>
)}
<Button
size="sm"
className={classes.applyButton}
onClick={handleApply}
disabled={isDisabled}
>
Apply
</Button>
</Group>
</div>
</div>
);
};
export default SelectPages;
@@ -0,0 +1,35 @@
import { Text } from '@mantine/core';
import classes from '@app/components/pageEditor/bulkSelectionPanel/BulkSelectionPanel.module.css';
interface SelectedPagesDisplayProps {
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
syntaxError: string | null;
}
const SelectedPagesDisplay = ({
selectedPageIds,
displayDocument,
syntaxError,
}: SelectedPagesDisplayProps) => {
if (selectedPageIds.length === 0 && !syntaxError) {
return null;
}
return (
<div className={classes.selectedList}>
{syntaxError ? (
<Text size="xs" className={classes.errorText}>{syntaxError}</Text>
) : (
<Text size="sm" c="dimmed" className={classes.selectedText}>
Selected: {selectedPageIds.length} pages ({displayDocument ? selectedPageIds.map(id => {
const page = displayDocument.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(n => n > 0).join(', ') : ''})
</Text>
)}
</div>
);
};
export default SelectedPagesDisplay;
@@ -0,0 +1,891 @@
import { FileId } from '@app/types/file';
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
// V1-style DOM-first command system (replaces the old React state commands)
export abstract class DOMCommand {
abstract execute(): void;
abstract undo(): void;
abstract description: string;
}
export class RotatePageCommand extends DOMCommand {
constructor(
private pageId: string,
private degrees: number
) {
super();
}
execute(): void {
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
let newRotation = currentRotation + this.degrees;
newRotation = ((newRotation % 360) + 360) % 360;
img.style.transform = `rotate(${newRotation}deg)`;
}
}
}
undo(): void {
const pageElement = document.querySelector(`[data-page-id="${this.pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
let previousRotation = currentRotation - this.degrees;
previousRotation = ((previousRotation % 360) + 360) % 360;
img.style.transform = `rotate(${previousRotation}deg)`;
}
}
}
get description(): string {
return `Rotate page ${this.degrees > 0 ? 'right' : 'left'}`;
}
}
export class DeletePagesCommand extends DOMCommand {
private originalDocument: PDFDocument | null = null;
private originalSplitPositions: Set<number> = new Set();
private originalSelectedPages: number[] = [];
private hasExecuted: boolean = false;
private pageIdsToDelete: string[] = [];
private onAllPagesDeleted?: () => void;
constructor(
private pagesToDelete: number[],
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void,
private setSelectedPages: (pages: number[]) => void,
private getSplitPositions: () => Set<number>,
private setSplitPositions: (positions: Set<number>) => void,
private getSelectedPages: () => number[],
onAllPagesDeleted?: () => void
) {
super();
this.onAllPagesDeleted = onAllPagesDeleted;
}
execute(): void {
const currentDoc = this.getCurrentDocument();
if (!currentDoc || this.pagesToDelete.length === 0) return;
// Store complete original state for undo (only on first execution)
if (!this.hasExecuted) {
this.originalDocument = {
...currentDoc,
pages: currentDoc.pages.map(page => ({...page})) // Deep copy pages
};
this.originalSplitPositions = new Set(this.getSplitPositions());
this.originalSelectedPages = [...this.getSelectedPages()];
// Convert page numbers to page IDs for stable identification
this.pageIdsToDelete = this.pagesToDelete.map(pageNum => {
const page = currentDoc.pages.find(p => p.pageNumber === pageNum);
return page?.id || '';
}).filter(id => id);
this.hasExecuted = true;
}
// Filter out deleted pages by ID (stable across undo/redo)
const remainingPages = currentDoc.pages.filter(page =>
!this.pageIdsToDelete.includes(page.id)
);
if (remainingPages.length === 0) {
// If all pages would be deleted, clear selection/splits and close PDF
this.setSelectedPages([]);
this.setSplitPositions(new Set());
this.onAllPagesDeleted?.();
return;
}
// Renumber remaining pages
remainingPages.forEach((page, index) => {
page.pageNumber = index + 1;
});
// Update document
const updatedDocument: PDFDocument = {
...currentDoc,
pages: remainingPages,
totalPages: remainingPages.length,
};
// Adjust split positions
const currentSplitPositions = this.getSplitPositions();
const newPositions = new Set<number>();
currentSplitPositions.forEach(pos => {
if (pos < remainingPages.length - 1) {
newPositions.add(pos);
}
});
// Apply changes
this.setDocument(updatedDocument);
this.setSelectedPages([]);
this.setSplitPositions(newPositions);
}
undo(): void {
if (!this.originalDocument) return;
// Simply restore the complete original document state
this.setDocument(this.originalDocument);
this.setSplitPositions(this.originalSplitPositions);
this.setSelectedPages(this.originalSelectedPages);
}
get description(): string {
return `Delete ${this.pagesToDelete.length} page(s)`;
}
}
export class ReorderPagesCommand extends DOMCommand {
private originalPages: PDFPage[] = [];
constructor(
private sourcePageNumber: number,
private targetIndex: number,
private selectedPages: number[] | undefined,
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void
) {
super();
}
execute(): void {
const currentDoc = this.getCurrentDocument();
if (!currentDoc) return;
// Store original state for undo
this.originalPages = currentDoc.pages.map(page => ({...page}));
// Perform the reorder
const sourceIndex = currentDoc.pages.findIndex(p => p.pageNumber === this.sourcePageNumber);
if (sourceIndex === -1) return;
const newPages = [...currentDoc.pages];
if (this.selectedPages && this.selectedPages.length > 1 && this.selectedPages.includes(this.sourcePageNumber)) {
// Multi-page reorder
const selectedPageObjects = this.selectedPages
.map(pageNum => currentDoc.pages.find(p => p.pageNumber === pageNum))
.filter(page => page !== undefined) as PDFPage[];
const remainingPages = newPages.filter(page => !this.selectedPages!.includes(page.pageNumber));
remainingPages.splice(this.targetIndex, 0, ...selectedPageObjects);
remainingPages.forEach((page, index) => {
page.pageNumber = index + 1;
});
newPages.splice(0, newPages.length, ...remainingPages);
} else {
// Single page reorder
const [movedPage] = newPages.splice(sourceIndex, 1);
newPages.splice(this.targetIndex, 0, movedPage);
newPages.forEach((page, index) => {
page.pageNumber = index + 1;
});
}
const reorderedDocument: PDFDocument = {
...currentDoc,
pages: newPages,
totalPages: newPages.length,
};
this.setDocument(reorderedDocument);
}
undo(): void {
const currentDoc = this.getCurrentDocument();
if (!currentDoc || this.originalPages.length === 0) return;
// Restore original page order
const restoredDocument: PDFDocument = {
...currentDoc,
pages: this.originalPages,
totalPages: this.originalPages.length,
};
this.setDocument(restoredDocument);
}
get description(): string {
return `Reorder page(s)`;
}
}
export class SplitCommand extends DOMCommand {
private originalSplitPositions: Set<number> = new Set();
constructor(
private position: number,
private getSplitPositions: () => Set<number>,
private setSplitPositions: (positions: Set<number>) => void
) {
super();
}
execute(): void {
// Store original state for undo
this.originalSplitPositions = new Set(this.getSplitPositions());
// Toggle the split position
const currentPositions = this.getSplitPositions();
const newPositions = new Set(currentPositions);
if (newPositions.has(this.position)) {
newPositions.delete(this.position);
} else {
newPositions.add(this.position);
}
this.setSplitPositions(newPositions);
}
undo(): void {
// Restore original split positions
this.setSplitPositions(this.originalSplitPositions);
}
get description(): string {
const currentPositions = this.getSplitPositions();
const willAdd = !currentPositions.has(this.position);
return `${willAdd ? 'Add' : 'Remove'} split at position ${this.position + 1}`;
}
}
export class BulkRotateCommand extends DOMCommand {
private originalRotations: Map<string, number> = new Map();
constructor(
private pageIds: string[],
private degrees: number
) {
super();
}
execute(): void {
this.pageIds.forEach(pageId => {
const pageElement = document.querySelector(`[data-page-id="${pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img) {
// Store original rotation for undo (only on first execution)
if (!this.originalRotations.has(pageId)) {
const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
this.originalRotations.set(pageId, currentRotation);
}
// Apply rotation using transform to trigger CSS animation
const currentTransform = img.style.transform || '';
const rotateMatch = currentTransform.match(/rotate\(([^)]+)\)/);
const currentRotation = rotateMatch ? parseInt(rotateMatch[1]) : 0;
const newRotation = currentRotation + this.degrees;
img.style.transform = `rotate(${newRotation}deg)`;
}
}
});
}
undo(): void {
this.pageIds.forEach(pageId => {
const pageElement = document.querySelector(`[data-page-id="${pageId}"]`);
if (pageElement) {
const img = pageElement.querySelector('img');
if (img && this.originalRotations.has(pageId)) {
img.style.transform = `rotate(${this.originalRotations.get(pageId)}deg)`;
}
}
});
}
get description(): string {
return `Rotate ${this.pageIds.length} page(s) ${this.degrees > 0 ? 'right' : 'left'}`;
}
}
export class BulkSplitCommand extends DOMCommand {
private originalSplitPositions: Set<number> = new Set();
constructor(
private positions: number[],
private getSplitPositions: () => Set<number>,
private setSplitPositions: (positions: Set<number>) => void
) {
super();
}
execute(): void {
// Store original state for undo (only on first execution)
if (this.originalSplitPositions.size === 0) {
this.originalSplitPositions = new Set(this.getSplitPositions());
}
// Toggle each position
const currentPositions = new Set(this.getSplitPositions());
this.positions.forEach(position => {
if (currentPositions.has(position)) {
currentPositions.delete(position);
} else {
currentPositions.add(position);
}
});
this.setSplitPositions(currentPositions);
}
undo(): void {
// Restore original split positions
this.setSplitPositions(this.originalSplitPositions);
}
get description(): string {
return `Toggle ${this.positions.length} split position(s)`;
}
}
export class SplitAllCommand extends DOMCommand {
private originalSplitPositions: Set<number> = new Set();
private allPossibleSplits: Set<number> = new Set();
constructor(
private totalPages: number,
private getSplitPositions: () => Set<number>,
private setSplitPositions: (positions: Set<number>) => void
) {
super();
// Calculate all possible split positions (between pages, not after last page)
for (let i = 0; i < this.totalPages - 1; i++) {
this.allPossibleSplits.add(i);
}
}
execute(): void {
// Store original state for undo
this.originalSplitPositions = new Set(this.getSplitPositions());
// Check if all splits are already active
const currentSplits = this.getSplitPositions();
const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos));
if (hasAllSplits) {
// Remove all splits
this.setSplitPositions(new Set());
} else {
// Add all splits
this.setSplitPositions(this.allPossibleSplits);
}
}
undo(): void {
// Restore original split positions
this.setSplitPositions(this.originalSplitPositions);
}
get description(): string {
const currentSplits = this.getSplitPositions();
const hasAllSplits = Array.from(this.allPossibleSplits).every(pos => currentSplits.has(pos));
return hasAllSplits ? 'Remove all splits' : 'Split all pages';
}
}
export class PageBreakCommand extends DOMCommand {
private insertedPages: PDFPage[] = [];
private originalDocument: PDFDocument | null = null;
constructor(
private selectedPageNumbers: number[],
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void
) {
super();
}
execute(): void {
const currentDoc = this.getCurrentDocument();
if (!currentDoc || this.selectedPageNumbers.length === 0) return;
// Store original state for undo
this.originalDocument = {
...currentDoc,
pages: currentDoc.pages.map(page => ({...page}))
};
// Create new pages array with blank pages inserted
const newPages: PDFPage[] = [];
this.insertedPages = [];
let pageNumberCounter = 1;
currentDoc.pages.forEach((page, index) => {
// Add the current page
const updatedPage = { ...page, pageNumber: pageNumberCounter++ };
newPages.push(updatedPage);
// If this page is selected for page break insertion, add a blank page after it
if (this.selectedPageNumbers.includes(page.pageNumber)) {
const blankPage: PDFPage = {
id: `blank-${Date.now()}-${index}`,
pageNumber: pageNumberCounter++,
originalPageNumber: -1, // Mark as blank page
thumbnail: null,
rotation: 0,
selected: false,
splitAfter: false,
isBlankPage: true // Custom flag for blank pages
};
newPages.push(blankPage);
this.insertedPages.push(blankPage);
}
});
// Update document
const updatedDocument: PDFDocument = {
...currentDoc,
pages: newPages,
totalPages: newPages.length,
};
this.setDocument(updatedDocument);
// No need to maintain selection - page IDs remain stable, so selection persists automatically
}
undo(): void {
if (!this.originalDocument) return;
this.setDocument(this.originalDocument);
}
get description(): string {
return `Insert ${this.selectedPageNumbers.length} page break(s)`;
}
}
export class BulkPageBreakCommand extends DOMCommand {
private insertedPages: PDFPage[] = [];
private originalDocument: PDFDocument | null = null;
private originalSelectedPages: number[] = [];
constructor(
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void,
private setSelectedPages: (pages: number[]) => void,
private getSelectedPages: () => number[]
) {
super();
}
execute(): void {
const currentDoc = this.getCurrentDocument();
if (!currentDoc) return;
// Store original selection to restore later
this.originalSelectedPages = this.getSelectedPages();
// Store original state for undo
this.originalDocument = {
...currentDoc,
pages: currentDoc.pages.map(page => ({...page}))
};
// Create new pages array with blank pages inserted after each page (except the last)
const newPages: PDFPage[] = [];
this.insertedPages = [];
let pageNumberCounter = 1;
currentDoc.pages.forEach((page, index) => {
// Add the current page
const updatedPage = { ...page, pageNumber: pageNumberCounter++ };
newPages.push(updatedPage);
// Add blank page after each page except the last one
if (index < currentDoc.pages.length - 1) {
const blankPage: PDFPage = {
id: `blank-${Date.now()}-${index}`,
pageNumber: pageNumberCounter++,
originalPageNumber: -1,
thumbnail: null,
rotation: 0,
selected: false,
splitAfter: false,
isBlankPage: true
};
newPages.push(blankPage);
this.insertedPages.push(blankPage);
}
});
// Update document
const updatedDocument: PDFDocument = {
...currentDoc,
pages: newPages,
totalPages: newPages.length,
};
this.setDocument(updatedDocument);
// Maintain existing selection by mapping original selected pages to their new positions
const updatedSelection: number[] = [];
this.originalSelectedPages.forEach(originalPageNum => {
// Find the original page by matching the page ID from the original document
const originalPage = this.originalDocument?.pages[originalPageNum - 1];
if (originalPage) {
const foundPage = newPages.find(page => page.id === originalPage.id && !page.isBlankPage);
if (foundPage) {
updatedSelection.push(foundPage.pageNumber);
}
}
});
this.setSelectedPages(updatedSelection);
}
undo(): void {
if (!this.originalDocument) return;
this.setDocument(this.originalDocument);
}
get description(): string {
return `Insert page breaks after all pages`;
}
}
export class InsertFilesCommand extends DOMCommand {
private insertedPages: PDFPage[] = [];
private originalDocument: PDFDocument | null = null;
private fileDataMap = new Map<FileId, ArrayBuffer>(); // Store file data for thumbnail generation
private originalProcessedFile: any = null; // Store original ProcessedFile for undo
private insertedFileMap = new Map<FileId, File>(); // Store inserted files for export
constructor(
private files: File[],
private insertAfterPageNumber: number,
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void,
private setSelectedPages: (pages: number[]) => void,
private getSelectedPages: () => number[],
private updateFileContext?: (updatedDocument: PDFDocument, insertedFiles?: Map<FileId, File>) => void
) {
super();
}
async execute(): Promise<void> {
const currentDoc = this.getCurrentDocument();
if (!currentDoc || this.files.length === 0) return;
// Store original state for undo
this.originalDocument = {
...currentDoc,
pages: currentDoc.pages.map(page => ({...page}))
};
try {
// Process each file to extract pages and wait for all to complete
const allNewPages: PDFPage[] = [];
// Process all files and wait for their completion
const baseTimestamp = Date.now();
const extractionPromises = this.files.map(async (file, index) => {
const fileId = `inserted-${file.name}-${baseTimestamp + index}` as FileId;
// Store inserted file for export
this.insertedFileMap.set(fileId, file);
// Use base timestamp + index to ensure unique but predictable file IDs
return await this.extractPagesFromFile(file, baseTimestamp + index);
});
const extractedPageArrays = await Promise.all(extractionPromises);
// Flatten all extracted pages
for (const pages of extractedPageArrays) {
allNewPages.push(...pages);
}
if (allNewPages.length === 0) return;
// Find insertion point (after the specified page)
const insertIndex = this.insertAfterPageNumber; // Insert after page N means insert at index N
// Create new pages array with inserted pages
const newPages: PDFPage[] = [];
let pageNumberCounter = 1;
// Add pages before insertion point
for (let i = 0; i < insertIndex && i < currentDoc.pages.length; i++) {
const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ };
newPages.push(page);
}
// Add inserted pages
for (const newPage of allNewPages) {
const insertedPage: PDFPage = {
...newPage,
pageNumber: pageNumberCounter++,
selected: false,
splitAfter: false
};
newPages.push(insertedPage);
this.insertedPages.push(insertedPage);
}
// Add remaining pages after insertion point
for (let i = insertIndex; i < currentDoc.pages.length; i++) {
const page = { ...currentDoc.pages[i], pageNumber: pageNumberCounter++ };
newPages.push(page);
}
// Update document
const updatedDocument: PDFDocument = {
...currentDoc,
pages: newPages,
totalPages: newPages.length,
};
this.setDocument(updatedDocument);
// Update FileContext with the new document structure and inserted files
if (this.updateFileContext) {
this.updateFileContext(updatedDocument, this.insertedFileMap);
}
// Generate thumbnails for inserted pages (all files should be read by now)
this.generateThumbnailsForInsertedPages(updatedDocument);
// Maintain existing selection by mapping original selected pages to their new positions
const originalSelection = this.getSelectedPages();
const updatedSelection: number[] = [];
originalSelection.forEach(originalPageNum => {
if (originalPageNum <= this.insertAfterPageNumber) {
// Pages before insertion point keep same number
updatedSelection.push(originalPageNum);
} else {
// Pages after insertion point are shifted by number of inserted pages
updatedSelection.push(originalPageNum + allNewPages.length);
}
});
this.setSelectedPages(updatedSelection);
} catch (error) {
console.error('Failed to insert files:', error);
// Revert to original state if error occurs
if (this.originalDocument) {
this.setDocument(this.originalDocument);
}
}
}
private async generateThumbnailsForInsertedPages(updatedDocument: PDFDocument): Promise<void> {
try {
const { thumbnailGenerationService } = await import('@app/services/thumbnailGenerationService');
// Group pages by file ID to generate thumbnails efficiently
const pagesByFileId = new Map<FileId, PDFPage[]>();
for (const page of this.insertedPages) {
const fileId = page.id.substring(0, page.id.lastIndexOf('-page-')) as FileId /* FIX ME: This looks wrong - like we've thrown away info too early and need to recreate it */;
if (!pagesByFileId.has(fileId)) {
pagesByFileId.set(fileId, []);
}
pagesByFileId.get(fileId)!.push(page);
}
// Generate thumbnails for each file
for (const [fileId, pages] of pagesByFileId) {
const arrayBuffer = this.fileDataMap.get(fileId);
console.log('Generating thumbnails for file:', fileId);
console.log('Pages:', pages.length);
console.log('ArrayBuffer size:', arrayBuffer?.byteLength || 'undefined');
if (arrayBuffer && arrayBuffer.byteLength > 0) {
// Extract page numbers for all pages from this file
const pageNumbers = pages.map(page => {
const pageNumMatch = page.id.match(/-page-(\d+)$/);
return pageNumMatch ? parseInt(pageNumMatch[1]) : 1;
});
console.log('Generating thumbnails for page numbers:', pageNumbers);
// Generate thumbnails for all pages from this file at once
const results = await thumbnailGenerationService.generateThumbnails(
fileId,
arrayBuffer,
pageNumbers,
{ scale: 0.2, quality: 0.8 }
);
console.log('Thumbnail generation results:', results.length, 'thumbnails generated');
// Update pages with generated thumbnails
for (let i = 0; i < results.length && i < pages.length; i++) {
const result = results[i];
const page = pages[i];
if (result.success) {
const pageIndex = updatedDocument.pages.findIndex(p => p.id === page.id);
if (pageIndex >= 0) {
updatedDocument.pages[pageIndex].thumbnail = result.thumbnail;
console.log('Updated thumbnail for page:', page.id);
}
}
}
// Trigger re-render by updating the document
this.setDocument({ ...updatedDocument });
} else {
console.error('No valid ArrayBuffer found for file ID:', fileId);
}
}
} catch (error) {
console.error('Failed to generate thumbnails for inserted pages:', error);
}
}
private async extractPagesFromFile(file: File, baseTimestamp: number): Promise<PDFPage[]> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (event) => {
try {
const arrayBuffer = event.target?.result as ArrayBuffer;
console.log('File reader onload - arrayBuffer size:', arrayBuffer?.byteLength || 'undefined');
if (!arrayBuffer) {
reject(new Error('Failed to read file'));
return;
}
// Clone the ArrayBuffer before passing to PDF.js (it might consume it)
const clonedArrayBuffer = arrayBuffer.slice(0);
// Use PDF.js via the worker manager to extract pages
const { pdfWorkerManager } = await import('@app/services/pdfWorkerManager');
const pdf = await pdfWorkerManager.createDocument(clonedArrayBuffer);
const pageCount = pdf.numPages;
const pages: PDFPage[] = [];
const fileId = `inserted-${file.name}-${baseTimestamp}` as FileId;
console.log('Original ArrayBuffer size:', arrayBuffer.byteLength);
console.log('Storing ArrayBuffer for fileId:', fileId, 'size:', arrayBuffer.byteLength);
// Store the original ArrayBuffer for thumbnail generation
this.fileDataMap.set(fileId, arrayBuffer);
console.log('After storing - fileDataMap size:', this.fileDataMap.size);
console.log('Stored value size:', this.fileDataMap.get(fileId)?.byteLength || 'undefined');
for (let i = 1; i <= pageCount; i++) {
const pageId = `${fileId}-page-${i}`;
pages.push({
id: pageId,
pageNumber: i, // Will be renumbered in execute()
originalPageNumber: i,
thumbnail: null, // Will be generated after insertion
rotation: 0,
selected: false,
splitAfter: false,
isBlankPage: false
});
}
// Clean up PDF document
pdfWorkerManager.destroyDocument(pdf);
resolve(pages);
} catch (error) {
reject(error);
}
};
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsArrayBuffer(file);
});
}
undo(): void {
if (!this.originalDocument) return;
this.setDocument(this.originalDocument);
}
get description(): string {
return `Insert ${this.files.length} file(s) after page ${this.insertAfterPageNumber}`;
}
}
// Simple undo manager for DOM commands
export class UndoManager {
private undoStack: DOMCommand[] = [];
private redoStack: DOMCommand[] = [];
private onStateChange?: () => void;
setStateChangeCallback(callback: () => void): void {
this.onStateChange = callback;
}
executeCommand(command: DOMCommand): void {
command.execute();
this.undoStack.push(command);
this.redoStack = [];
this.onStateChange?.();
}
// For async commands that need to be executed manually
addToUndoStack(command: DOMCommand): void {
this.undoStack.push(command);
this.redoStack = [];
this.onStateChange?.();
}
undo(): boolean {
const command = this.undoStack.pop();
if (command) {
command.undo();
this.redoStack.push(command);
this.onStateChange?.();
return true;
}
return false;
}
redo(): boolean {
const command = this.redoStack.pop();
if (command) {
command.execute();
this.undoStack.push(command);
this.onStateChange?.();
return true;
}
return false;
}
canUndo(): boolean {
return this.undoStack.length > 0;
}
canRedo(): boolean {
return this.redoStack.length > 0;
}
clear(): void {
this.undoStack = [];
this.redoStack = [];
this.onStateChange?.();
}
}
@@ -0,0 +1,8 @@
// Shared constants for PageEditor grid layout
export const GRID_CONSTANTS = {
ITEM_WIDTH: '20rem', // page width
ITEM_HEIGHT: '21.5rem', // 20rem + 1.5rem gap
ITEM_GAP: '1.5rem', // gap between items
OVERSCAN_SMALL: 4, // Overscan for normal documents
OVERSCAN_LARGE: 8, // Overscan for large documents (>1000 pages)
} as const;
@@ -0,0 +1,174 @@
import { useMemo } from 'react';
import { useFileState } from '@app/contexts/FileContext';
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
import { FileId } from '@app/types/file';
export interface PageDocumentHook {
document: PDFDocument | null;
isVeryLargeDocument: boolean;
isLoading: boolean;
}
/**
* Hook for managing PDF document state and metadata in PageEditor
* Handles document merging, large document detection, and loading states
*/
export function usePageDocument(): PageDocumentHook {
const { state, selectors } = useFileState();
// Prefer IDs + selectors to avoid array identity churn
const activeFileIds = state.files.ids;
const primaryFileId = activeFileIds[0] ?? null;
// Stable signature for effects (prevents loops)
const activeFilesSignature = selectors.getFilesSignature();
// UI state
const globalProcessing = state.ui.isProcessing;
// Get primary file record outside useMemo to track processedFile changes
const primaryStirlingFileStub = primaryFileId ? selectors.getStirlingFileStub(primaryFileId) : null;
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
// Compute merged document with stable signature (prevents infinite loops)
const mergedPdfDocument = useMemo((): PDFDocument | null => {
if (activeFileIds.length === 0) return null;
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
// If we have file IDs but no file record, something is wrong - return null to show loading
if (!primaryStirlingFileStub) {
console.log('🎬 PageEditor: No primary file record found, showing loading');
return null;
}
const name =
activeFileIds.length === 1
? (primaryStirlingFileStub.name ?? 'document.pdf')
: activeFileIds
.map(id => (selectors.getStirlingFileStub(id)?.name ?? 'file').replace(/\.pdf$/i, ''))
.join(' + ');
// Build page insertion map from files with insertion positions
const insertionMap = new Map<string, FileId[]>(); // insertAfterPageId -> fileIds
const originalFileIds: FileId[] = [];
activeFileIds.forEach(fileId => {
const record = selectors.getStirlingFileStub(fileId);
if (record?.insertAfterPageId !== undefined) {
if (!insertionMap.has(record.insertAfterPageId)) {
insertionMap.set(record.insertAfterPageId, []);
}
insertionMap.get(record.insertAfterPageId)!.push(fileId);
} else {
originalFileIds.push(fileId);
}
});
// Build pages by interleaving original pages with insertions
let pages: PDFPage[] = [];
// Helper function to create pages from a file
const createPagesFromFile = (fileId: FileId, startPageNumber: number): PDFPage[] => {
const stirlingFileStub = selectors.getStirlingFileStub(fileId);
if (!stirlingFileStub) {
return [];
}
const processedFile = stirlingFileStub.processedFile;
let filePages: PDFPage[] = [];
if (processedFile?.pages && processedFile.pages.length > 0) {
// Use fully processed pages with thumbnails
filePages = processedFile.pages.map((page, pageIndex) => ({
id: `${fileId}-${page.pageNumber}`,
pageNumber: startPageNumber + pageIndex,
thumbnail: page.thumbnail || null,
rotation: page.rotation || 0,
selected: false,
splitAfter: page.splitAfter || false,
originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1,
originalFileId: fileId,
}));
} else if (processedFile?.totalPages) {
// Fallback: create pages without thumbnails but with correct count
filePages = Array.from({ length: processedFile.totalPages }, (_, pageIndex) => ({
id: `${fileId}-${pageIndex + 1}`,
pageNumber: startPageNumber + pageIndex,
originalPageNumber: pageIndex + 1,
originalFileId: fileId,
rotation: 0,
thumbnail: null,
selected: false,
splitAfter: false,
}));
}
return filePages;
};
// Collect all pages from original files (without renumbering yet)
const originalFilePages: PDFPage[] = [];
originalFileIds.forEach(fileId => {
const filePages = createPagesFromFile(fileId, 1); // Temporary numbering
originalFilePages.push(...filePages);
});
// Start with all original pages numbered sequentially
pages = originalFilePages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
// Process each insertion by finding the page ID and inserting after it
for (const [insertAfterPageId, fileIds] of insertionMap.entries()) {
const targetPageIndex = pages.findIndex(p => p.id === insertAfterPageId);
if (targetPageIndex === -1) continue;
// Collect all pages to insert
const allNewPages: PDFPage[] = [];
fileIds.forEach(fileId => {
const insertedPages = createPagesFromFile(fileId, 1);
allNewPages.push(...insertedPages);
});
// Insert all new pages after the target page
pages.splice(targetPageIndex + 1, 0, ...allNewPages);
// Renumber all pages after insertion
pages.forEach((page, index) => {
page.pageNumber = index + 1;
});
}
if (pages.length === 0) {
return null;
}
const mergedDoc: PDFDocument = {
id: activeFileIds.join('-'),
name,
file: primaryFile!,
pages,
totalPages: pages.length,
};
return mergedDoc;
}, [activeFileIds, primaryFileId, primaryStirlingFileStub, processedFilePages, processedFileTotalPages, selectors, activeFilesSignature]);
// Large document detection for smart loading
const isVeryLargeDocument = useMemo(() => {
return mergedPdfDocument ? mergedPdfDocument.totalPages > 2000 : false;
}, [mergedPdfDocument?.totalPages]);
// Loading state
const isLoading = globalProcessing && !mergedPdfDocument;
return {
document: mergedPdfDocument,
isVeryLargeDocument,
isLoading
};
}
@@ -0,0 +1,95 @@
import { useState, useCallback } from 'react';
export interface PageEditorState {
// Selection state
selectionMode: boolean;
selectedPageIds: string[];
// Animation state
movingPage: number | null;
isAnimating: boolean;
// Split state
splitPositions: Set<number>;
// Export state
exportLoading: boolean;
// Actions
setSelectionMode: (mode: boolean) => void;
setSelectedPageIds: (pages: string[]) => void;
setMovingPage: (pageNumber: number | null) => void;
setIsAnimating: (animating: boolean) => void;
setSplitPositions: (positions: Set<number>) => void;
setExportLoading: (loading: boolean) => void;
// Helper functions
togglePage: (pageId: string) => void;
toggleSelectAll: (allPageIds: string[]) => void;
animateReorder: () => void;
}
/**
* Hook for managing PageEditor UI state
* Handles selection, animation, splits, and export states
*/
export function usePageEditorState(): PageEditorState {
// Selection state
const [selectionMode, setSelectionMode] = useState(false);
const [selectedPageIds, setSelectedPageIds] = useState<string[]>([]);
// Animation state
const [movingPage, setMovingPage] = useState<number | null>(null);
const [isAnimating, setIsAnimating] = useState(false);
// Split state - position-based split tracking (replaces page-based splitAfter)
const [splitPositions, setSplitPositions] = useState<Set<number>>(new Set());
// Export state
const [exportLoading, setExportLoading] = useState(false);
// Helper functions
const togglePage = useCallback((pageId: string) => {
setSelectedPageIds(prev =>
prev.includes(pageId)
? prev.filter(id => id !== pageId)
: [...prev, pageId]
);
}, []);
const toggleSelectAll = useCallback((allPageIds: string[]) => {
if (!allPageIds.length) return;
setSelectedPageIds(prev =>
prev.length === allPageIds.length ? [] : allPageIds
);
}, []);
const animateReorder = useCallback(() => {
setIsAnimating(true);
setTimeout(() => setIsAnimating(false), 500);
}, []);
return {
// State
selectionMode,
selectedPageIds,
movingPage,
isAnimating,
splitPositions,
exportLoading,
// Setters
setSelectionMode,
setSelectedPageIds,
setMovingPage,
setIsAnimating,
setSplitPositions,
setExportLoading,
// Helpers
togglePage,
toggleSelectAll,
animateReorder,
};
}
@@ -0,0 +1,156 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useRightRailButtons, RightRailButtonWithAction } from '@app/hooks/useRightRailButtons';
import LocalIcon from '@app/components/shared/LocalIcon';
import PageSelectByNumberButton from '@app/components/pageEditor/PageSelectByNumberButton';
interface PageEditorRightRailButtonsParams {
totalPages: number;
selectedPageCount: number;
csvInput: string;
setCsvInput: (value: string) => void;
selectedPageIds: string[];
displayDocument?: { pages: { id: string; pageNumber: number }[] };
updatePagesFromCSV: (override?: string) => void;
handleSelectAll: () => void;
handleDeselectAll: () => void;
handleDelete: () => void;
onExportSelected: () => void;
exportLoading: boolean;
activeFileCount: number;
closePdf: () => void;
}
export function usePageEditorRightRailButtons(params: PageEditorRightRailButtonsParams) {
const {
totalPages,
selectedPageCount,
csvInput,
setCsvInput,
selectedPageIds,
displayDocument,
updatePagesFromCSV,
handleSelectAll,
handleDeselectAll,
handleDelete,
onExportSelected,
exportLoading,
activeFileCount,
closePdf,
} = params;
const { t } = useTranslation();
// Lift i18n labels out of memo for clarity
const selectAllLabel = t('rightRail.selectAll', 'Select All');
const deselectAllLabel = t('rightRail.deselectAll', 'Deselect All');
const selectByNumberLabel = t('rightRail.selectByNumber', 'Select by Page Numbers');
const deleteSelectedLabel = t('rightRail.deleteSelected', 'Delete Selected Pages');
const exportSelectedLabel = t('rightRail.exportSelected', 'Export Selected Pages');
const closePdfLabel = t('rightRail.closePdf', 'Close PDF');
const buttons = useMemo<RightRailButtonWithAction[]>(() => {
return [
{
id: 'page-select-all',
icon: <LocalIcon icon="select-all" width="1.5rem" height="1.5rem" />,
tooltip: selectAllLabel,
ariaLabel: selectAllLabel,
section: 'top' as const,
order: 10,
disabled: totalPages === 0 || selectedPageCount === totalPages,
visible: totalPages > 0,
onClick: handleSelectAll,
},
{
id: 'page-deselect-all',
icon: <LocalIcon icon="crop-square-outline" width="1.5rem" height="1.5rem" />,
tooltip: deselectAllLabel,
ariaLabel: deselectAllLabel,
section: 'top' as const,
order: 20,
disabled: selectedPageCount === 0,
visible: totalPages > 0,
onClick: handleDeselectAll,
},
{
id: 'page-select-by-number',
tooltip: selectByNumberLabel,
ariaLabel: selectByNumberLabel,
section: 'top' as const,
order: 30,
disabled: totalPages === 0,
visible: totalPages > 0,
render: ({ disabled }) => (
<PageSelectByNumberButton
disabled={disabled}
totalPages={totalPages}
label={selectByNumberLabel}
csvInput={csvInput}
setCsvInput={setCsvInput}
selectedPageIds={selectedPageIds}
displayDocument={displayDocument}
updatePagesFromCSV={updatePagesFromCSV}
/>
),
},
{
id: 'page-delete-selected',
icon: <LocalIcon icon="delete-outline-rounded" width="1.5rem" height="1.5rem" />,
tooltip: deleteSelectedLabel,
ariaLabel: deleteSelectedLabel,
section: 'top' as const,
order: 40,
disabled: selectedPageCount === 0,
visible: totalPages > 0,
onClick: handleDelete,
},
{
id: 'page-export-selected',
icon: <LocalIcon icon="download" width="1.5rem" height="1.5rem" />,
tooltip: exportSelectedLabel,
ariaLabel: exportSelectedLabel,
section: 'top' as const,
order: 50,
disabled: selectedPageCount === 0 || exportLoading,
visible: totalPages > 0,
onClick: onExportSelected,
},
{
id: 'page-close-pdf',
icon: <LocalIcon icon="close-rounded" width="1.5rem" height="1.5rem" />,
tooltip: closePdfLabel,
ariaLabel: closePdfLabel,
section: 'top' as const,
order: 60,
disabled: activeFileCount === 0,
visible: activeFileCount > 0,
onClick: closePdf,
},
];
}, [
t,
selectAllLabel,
deselectAllLabel,
selectByNumberLabel,
deleteSelectedLabel,
exportSelectedLabel,
closePdfLabel,
totalPages,
selectedPageCount,
csvInput,
setCsvInput,
selectedPageIds,
displayDocument,
updatePagesFromCSV,
handleSelectAll,
handleDeselectAll,
handleDelete,
onExportSelected,
exportLoading,
activeFileCount,
closePdf,
]);
useRightRailButtons(buttons);
}