mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-09-13 12:15:29 +02:00
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:
@@ -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;
|
||||
+147
@@ -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}`;
|
||||
}
|
||||
|
||||
|
||||
+295
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user