mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-01-14 20:11:17 +01:00
Reworked page editor - dirty commit
This commit is contained in:
parent
6acce968a5
commit
39267e795c
@ -44,7 +44,7 @@ const PageEditor = ({
|
||||
const { setHasUnsavedChanges } = useNavigationGuard();
|
||||
|
||||
// Get selected files from PageEditorContext instead of all files
|
||||
const { selectedFileIds, syncWithFileContext } = usePageEditor();
|
||||
const { selectedFileIds, syncWithFileContext, updateCurrentPages, reorderedPages, clearReorderedPages, updateFileOrderFromPages } = usePageEditor();
|
||||
|
||||
// Stable reference to file IDs to prevent infinite loops
|
||||
const fileIdsString = state.files.ids.join(',');
|
||||
@ -137,6 +137,56 @@ const PageEditor = ({
|
||||
// Interface functions for parent component
|
||||
const displayDocument = editedDocument || mergedPdfDocument;
|
||||
|
||||
// Update current pages in context whenever displayDocument changes
|
||||
useEffect(() => {
|
||||
if (displayDocument?.pages) {
|
||||
updateCurrentPages(displayDocument.pages);
|
||||
}
|
||||
}, [displayDocument?.pages, updateCurrentPages]);
|
||||
|
||||
// Apply reordered pages from file reordering
|
||||
useEffect(() => {
|
||||
if (reorderedPages && reorderedPages.length > 0 && displayDocument) {
|
||||
// Create a new document with reordered pages
|
||||
const reorderedDocument: PDFDocument = {
|
||||
...displayDocument,
|
||||
pages: reorderedPages,
|
||||
totalPages: reorderedPages.length,
|
||||
};
|
||||
setEditedDocument(reorderedDocument);
|
||||
clearReorderedPages();
|
||||
}
|
||||
}, [reorderedPages, displayDocument, clearReorderedPages]);
|
||||
|
||||
// Update file order when pages are manually reordered
|
||||
useEffect(() => {
|
||||
if (editedDocument?.pages && editedDocument.pages.length > 0 && activeFileIds.length > 1) {
|
||||
// Compute the file order based on page positions
|
||||
const fileFirstPagePositions = new Map<FileId, number>();
|
||||
editedDocument.pages.forEach((page, index) => {
|
||||
const fileId = page.originalFileId;
|
||||
if (!fileId) return;
|
||||
if (!fileFirstPagePositions.has(fileId)) {
|
||||
fileFirstPagePositions.set(fileId, index);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort files by their first page position
|
||||
const computedFileOrder = Array.from(fileFirstPagePositions.entries())
|
||||
.sort((a, b) => a[1] - b[1])
|
||||
.map(entry => entry[0]);
|
||||
|
||||
// Check if the order has actually changed
|
||||
const currentFileOrder = state.files.ids.filter(id => selectedFileIds.has(id));
|
||||
const orderChanged = computedFileOrder.length !== currentFileOrder.length ||
|
||||
computedFileOrder.some((id, index) => id !== currentFileOrder[index]);
|
||||
|
||||
if (orderChanged && computedFileOrder.length > 0) {
|
||||
updateFileOrderFromPages(editedDocument.pages);
|
||||
}
|
||||
}
|
||||
}, [editedDocument?.pages, activeFileIds.length, state.files.ids, selectedFileIds, updateFileOrderFromPages]);
|
||||
|
||||
// Utility functions to convert between page IDs and page numbers
|
||||
const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => {
|
||||
if (!displayDocument) return [];
|
||||
@ -679,6 +729,15 @@ const PageEditor = ({
|
||||
// Display all pages - use edited or original document
|
||||
const displayedPages = displayDocument?.pages || [];
|
||||
|
||||
// Create a mapping of fileId to color index for page highlighting
|
||||
const fileColorIndexMap = useMemo(() => {
|
||||
const map = new Map<FileId, number>();
|
||||
activeFileIds.forEach((fileId, index) => {
|
||||
map.set(fileId, index);
|
||||
});
|
||||
return map;
|
||||
}, [activeFileIds]);
|
||||
|
||||
return (
|
||||
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">
|
||||
<LoadingOverlay visible={globalProcessing && !mergedPdfDocument} />
|
||||
@ -767,34 +826,38 @@ const PageEditor = ({
|
||||
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}
|
||||
/>
|
||||
)}
|
||||
renderItem={(page, index, refs) => {
|
||||
const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0;
|
||||
return (
|
||||
<PageThumbnail
|
||||
key={page.id}
|
||||
page={page}
|
||||
index={index}
|
||||
totalPages={displayDocument.pages.length}
|
||||
originalFile={(page as any).originalFileId ? selectors.getFile((page as any).originalFileId) : undefined}
|
||||
fileColorIndex={fileColorIndex}
|
||||
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>
|
||||
|
||||
@ -11,6 +11,7 @@ import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-d
|
||||
import { PDFPage, PDFDocument } from '../../types/pageEditor';
|
||||
import { useThumbnailGeneration } from '../../hooks/useThumbnailGeneration';
|
||||
import { useFilesModalContext } from '../../contexts/FilesModalContext';
|
||||
import { getFileColorWithOpacity } from './fileColors';
|
||||
import styles from './PageEditor.module.css';
|
||||
|
||||
|
||||
@ -19,6 +20,7 @@ interface PageThumbnailProps {
|
||||
index: number;
|
||||
totalPages: number;
|
||||
originalFile?: File;
|
||||
fileColorIndex: number;
|
||||
selectedPageIds: string[];
|
||||
selectionMode: boolean;
|
||||
movingPage: number | null;
|
||||
@ -45,6 +47,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
index,
|
||||
totalPages,
|
||||
originalFile,
|
||||
fileColorIndex,
|
||||
selectedPageIds,
|
||||
selectionMode,
|
||||
movingPage,
|
||||
@ -272,6 +275,8 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
setMouseStartPos(null);
|
||||
}, []);
|
||||
|
||||
const fileColorBorder = getFileColorWithOpacity(fileColorIndex, 0.5);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={pageElementRef}
|
||||
@ -290,14 +295,12 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
hover:shadow-md
|
||||
transition-all
|
||||
relative
|
||||
${selectionMode
|
||||
? 'bg-white hover:bg-gray-50'
|
||||
: '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'
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
|
||||
}}
|
||||
draggable={false}
|
||||
onMouseDown={handleMouseDown}
|
||||
@ -346,7 +349,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
|
||||
height: '100%',
|
||||
backgroundColor: 'var(--mantine-color-gray-1)',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--mantine-color-gray-3)',
|
||||
border: `4px solid ${fileColorBorder}`,
|
||||
padding: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
61
frontend/src/components/pageEditor/fileColors.ts
Normal file
61
frontend/src/components/pageEditor/fileColors.ts
Normal file
@ -0,0 +1,61 @@
|
||||
/**
|
||||
* File color palette for page editor
|
||||
* Each file gets a distinct color for visual organization
|
||||
* Colors are applied at 0.3 opacity for subtle highlighting
|
||||
* Maximum 20 files supported in page editor
|
||||
*/
|
||||
|
||||
export const FILE_COLORS = [
|
||||
// Subtle colors (1-6) - fit well with UI theme
|
||||
'rgb(59, 130, 246)', // Blue
|
||||
'rgb(16, 185, 129)', // Green
|
||||
'rgb(139, 92, 246)', // Purple
|
||||
'rgb(6, 182, 212)', // Cyan
|
||||
'rgb(20, 184, 166)', // Teal
|
||||
'rgb(99, 102, 241)', // Indigo
|
||||
|
||||
// Mid-range colors (7-12) - more distinct
|
||||
'rgb(244, 114, 182)', // Pink
|
||||
'rgb(251, 146, 60)', // Orange
|
||||
'rgb(234, 179, 8)', // Yellow
|
||||
'rgb(132, 204, 22)', // Lime
|
||||
'rgb(248, 113, 113)', // Red
|
||||
'rgb(168, 85, 247)', // Violet
|
||||
|
||||
// Vibrant colors (13-20) - maximum distinction
|
||||
'rgb(236, 72, 153)', // Fuchsia
|
||||
'rgb(245, 158, 11)', // Amber
|
||||
'rgb(34, 197, 94)', // Emerald
|
||||
'rgb(14, 165, 233)', // Sky
|
||||
'rgb(239, 68, 68)', // Rose
|
||||
'rgb(168, 162, 158)', // Stone
|
||||
'rgb(251, 191, 36)', // Gold
|
||||
'rgb(192, 132, 252)', // Light Purple
|
||||
] as const;
|
||||
|
||||
export const MAX_PAGE_EDITOR_FILES = 20;
|
||||
|
||||
/**
|
||||
* Get color for a file by its index
|
||||
* @param index - Zero-based file index
|
||||
* @returns RGB color string
|
||||
*/
|
||||
export function getFileColor(index: number): string {
|
||||
if (index < 0 || index >= FILE_COLORS.length) {
|
||||
console.warn(`File index ${index} out of range, using default color`);
|
||||
return FILE_COLORS[0];
|
||||
}
|
||||
return FILE_COLORS[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color with specified opacity
|
||||
* @param index - Zero-based file index
|
||||
* @param opacity - Opacity value (0-1), defaults to 0.3
|
||||
* @returns RGBA color string
|
||||
*/
|
||||
export function getFileColorWithOpacity(index: number, opacity: number = 0.2): string {
|
||||
const rgb = getFileColor(index);
|
||||
// Convert rgb(r, g, b) to rgba(r, g, b, a)
|
||||
return rgb.replace('rgb(', 'rgba(').replace(')', `, ${opacity})`);
|
||||
}
|
||||
@ -1,13 +1,215 @@
|
||||
import React from 'react';
|
||||
import React, { useRef, useCallback, useState } from 'react';
|
||||
import { Menu, Loader, Group, Text, Checkbox, ActionIcon } from '@mantine/core';
|
||||
import EditNoteIcon from '@mui/icons-material/EditNote';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
|
||||
import ArrowDownwardIcon from '@mui/icons-material/ArrowDownward';
|
||||
import VerticalAlignTopIcon from '@mui/icons-material/VerticalAlignTop';
|
||||
import VerticalAlignBottomIcon from '@mui/icons-material/VerticalAlignBottom';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import FitText from './FitText';
|
||||
import { getFileColorWithOpacity } from '../pageEditor/fileColors';
|
||||
|
||||
import { FileId } from '../../types/file';
|
||||
|
||||
interface FileMenuItemProps {
|
||||
file: { fileId: FileId; name: string; versionNumber?: number };
|
||||
index: number;
|
||||
isSelected: boolean;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
onToggleSelection: (fileId: FileId) => void;
|
||||
onMoveUp: (e: React.MouseEvent, index: number) => void;
|
||||
onMoveDown: (e: React.MouseEvent, index: number) => void;
|
||||
onMoveToTop: (e: React.MouseEvent, index: number) => void;
|
||||
onMoveToBottom: (e: React.MouseEvent, index: number) => void;
|
||||
onReorder: (fromIndex: number, toIndex: number) => void;
|
||||
}
|
||||
|
||||
const FileMenuItem: React.FC<FileMenuItemProps> = ({
|
||||
file,
|
||||
index,
|
||||
isSelected,
|
||||
isFirst,
|
||||
isLast,
|
||||
onToggleSelection,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onMoveToTop,
|
||||
onMoveToBottom,
|
||||
onReorder,
|
||||
}) => {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const itemRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const itemElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
if (element) {
|
||||
itemRef.current = element;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
type: 'file-item',
|
||||
fileId: file.fileId,
|
||||
fromIndex: index,
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
},
|
||||
canDrag: () => true,
|
||||
});
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'file-item',
|
||||
fileId: file.fileId,
|
||||
toIndex: index,
|
||||
}),
|
||||
onDragEnter: () => {
|
||||
setIsDragOver(true);
|
||||
},
|
||||
onDragLeave: () => {
|
||||
setIsDragOver(false);
|
||||
},
|
||||
onDrop: ({ source }) => {
|
||||
setIsDragOver(false);
|
||||
const sourceData = source.data;
|
||||
if (sourceData.type === 'file-item') {
|
||||
const fromIndex = sourceData.fromIndex as number;
|
||||
if (fromIndex !== index) {
|
||||
onReorder(fromIndex, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
(element as any).__dragCleanup = () => {
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
};
|
||||
} else {
|
||||
if (itemRef.current && (itemRef.current as any).__dragCleanup) {
|
||||
(itemRef.current as any).__dragCleanup();
|
||||
}
|
||||
}
|
||||
}, [file.fileId, index, onReorder]);
|
||||
|
||||
const itemName = file?.name || 'Untitled';
|
||||
const fileColorBorder = getFileColorWithOpacity(index, 1);
|
||||
const fileColorBorderHover = getFileColorWithOpacity(index, 1.0);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={itemElementRef}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelection(file.fileId);
|
||||
}}
|
||||
style={{
|
||||
padding: '0.75rem 0.75rem',
|
||||
marginBottom: '0.5rem',
|
||||
cursor: isDragging ? 'grabbing' : 'grab',
|
||||
backgroundColor: isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||
borderLeft: `6px solid ${fileColorBorder}`,
|
||||
borderTop: isDragOver ? '2px solid rgba(0, 0, 0, 0.5)' : 'none',
|
||||
borderBottom: isDragOver ? '2px solid rgba(0, 0, 0, 0.5)' : 'none',
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease, border-color 0.15s ease',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isDragging) {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0, 0, 0, 0.05)';
|
||||
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover;
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isDragging) {
|
||||
(e.currentTarget as HTMLDivElement).style.backgroundColor = isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent';
|
||||
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flex: 1, minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
cursor: 'grab',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
color: 'var(--mantine-color-dimmed)',
|
||||
}}
|
||||
>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
</div>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => onToggleSelection(file.fileId)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
size="sm"
|
||||
/>
|
||||
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
|
||||
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} />
|
||||
</div>
|
||||
{file.versionNumber && file.versionNumber > 1 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
v{file.versionNumber}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.25rem', alignItems: 'center' }} onClick={(e) => e.stopPropagation()}>
|
||||
<ActionIcon
|
||||
component="div"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
disabled={isFirst}
|
||||
onClick={onMoveToTop}
|
||||
title="Move to top"
|
||||
>
|
||||
<VerticalAlignTopIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
component="div"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
disabled={isFirst}
|
||||
onClick={onMoveUp}
|
||||
title="Move up"
|
||||
>
|
||||
<ArrowUpwardIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
component="div"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
disabled={isLast}
|
||||
onClick={onMoveDown}
|
||||
title="Move down"
|
||||
>
|
||||
<ArrowDownwardIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
component="div"
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
disabled={isLast}
|
||||
onClick={onMoveToBottom}
|
||||
title="Move to bottom"
|
||||
>
|
||||
<VerticalAlignBottomIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Group>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface PageEditorFileDropdownProps {
|
||||
displayName: string;
|
||||
allFiles: Array<{ fileId: FileId; name: string; versionNumber?: number }>;
|
||||
@ -27,11 +229,6 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
|
||||
switchingTo,
|
||||
viewOptionStyle,
|
||||
}) => {
|
||||
const handleCheckboxClick = (e: React.MouseEvent, fileId: FileId) => {
|
||||
e.stopPropagation();
|
||||
onToggleSelection(fileId);
|
||||
};
|
||||
|
||||
const handleMoveUp = (e: React.MouseEvent, index: number) => {
|
||||
e.stopPropagation();
|
||||
if (index > 0) {
|
||||
@ -46,8 +243,22 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveToTop = (e: React.MouseEvent, index: number) => {
|
||||
e.stopPropagation();
|
||||
if (index > 0) {
|
||||
onReorder(index, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMoveToBottom = (e: React.MouseEvent, index: number) => {
|
||||
e.stopPropagation();
|
||||
if (index < allFiles.length - 1) {
|
||||
onReorder(index, allFiles.length - 1);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu trigger="click" position="bottom" width="30rem">
|
||||
<Menu trigger="click" position="bottom" width="40rem">
|
||||
<Menu.Target>
|
||||
<div style={{...viewOptionStyle, cursor: 'pointer'}}>
|
||||
{switchingTo === "pageEditor" ? (
|
||||
@ -64,64 +275,29 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
maxHeight: '50vh',
|
||||
maxHeight: '80vh',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{allFiles.map((file, index) => {
|
||||
const itemName = file?.name || 'Untitled';
|
||||
const isSelected = selectedFileIds.has(file.fileId);
|
||||
const isFirst = index === 0;
|
||||
const isLast = index === allFiles.length - 1;
|
||||
|
||||
return (
|
||||
<Menu.Item
|
||||
<FileMenuItem
|
||||
key={file.fileId}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
cursor: 'default',
|
||||
backgroundColor: isSelected ? 'var(--bg-hover)' : undefined,
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flex: 1, minWidth: 0 }}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => {}}
|
||||
onClick={(e) => handleCheckboxClick(e, file.fileId)}
|
||||
size="sm"
|
||||
/>
|
||||
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
|
||||
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} />
|
||||
</div>
|
||||
{file.versionNumber && file.versionNumber > 1 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
v{file.versionNumber}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '0.25rem', alignItems: 'center' }}>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
disabled={isFirst}
|
||||
onClick={(e) => handleMoveUp(e, index)}
|
||||
title="Move up"
|
||||
>
|
||||
<ArrowUpwardIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
size="sm"
|
||||
variant="subtle"
|
||||
disabled={isLast}
|
||||
onClick={(e) => handleMoveDown(e, index)}
|
||||
title="Move down"
|
||||
>
|
||||
<ArrowDownwardIcon fontSize="small" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
file={file}
|
||||
index={index}
|
||||
isSelected={isSelected}
|
||||
isFirst={isFirst}
|
||||
isLast={isLast}
|
||||
onToggleSelection={onToggleSelection}
|
||||
onMoveUp={(e) => handleMoveUp(e, index)}
|
||||
onMoveDown={(e) => handleMoveDown(e, index)}
|
||||
onMoveToTop={(e) => handleMoveToTop(e, index)}
|
||||
onMoveToBottom={(e) => handleMoveToBottom(e, index)}
|
||||
onReorder={onReorder}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
|
||||
@ -75,11 +75,7 @@ const createViewOptions = (
|
||||
|
||||
let pageEditorDisplayName = 'Page Editor';
|
||||
if (isInPageEditor && pageEditorState) {
|
||||
if (pageEditorState.selectedCount === pageEditorState.totalCount) {
|
||||
pageEditorDisplayName = `${pageEditorState.selectedCount} file${pageEditorState.selectedCount !== 1 ? 's' : ''}`;
|
||||
} else {
|
||||
pageEditorDisplayName = `${pageEditorState.selectedCount}/${pageEditorState.totalCount} selected`;
|
||||
}
|
||||
pageEditorDisplayName = `${pageEditorState.selectedCount}/${pageEditorState.totalCount} selected`;
|
||||
}
|
||||
|
||||
const pageEditorOption = {
|
||||
|
||||
@ -1,11 +1,95 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import { FileId } from '../types/file';
|
||||
import { useFileActions } from './FileContext';
|
||||
import { PDFPage } from '../types/pageEditor';
|
||||
import { MAX_PAGE_EDITOR_FILES } from '../components/pageEditor/fileColors';
|
||||
|
||||
/**
|
||||
* Computes file order based on the position of each file's first page
|
||||
* @param pages - Current page order
|
||||
* @returns Array of FileIds in order based on first page positions
|
||||
*/
|
||||
function computeFileOrderFromPages(pages: PDFPage[]): FileId[] {
|
||||
// Find the first page for each file
|
||||
const fileFirstPagePositions = new Map<FileId, number>();
|
||||
|
||||
pages.forEach((page, index) => {
|
||||
const fileId = page.originalFileId;
|
||||
if (!fileId) return;
|
||||
|
||||
if (!fileFirstPagePositions.has(fileId)) {
|
||||
fileFirstPagePositions.set(fileId, index);
|
||||
}
|
||||
});
|
||||
|
||||
// Sort files by their first page position
|
||||
const fileOrder = Array.from(fileFirstPagePositions.entries())
|
||||
.sort((a, b) => a[1] - b[1])
|
||||
.map(entry => entry[0]);
|
||||
|
||||
return fileOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reorders pages based on file reordering while preserving manual page order within files
|
||||
* @param currentPages - Current page order (may include manual reordering)
|
||||
* @param fromIndex - Source file index
|
||||
* @param toIndex - Target file index
|
||||
* @param orderedFileIds - File IDs in their current order
|
||||
* @returns Reordered pages with updated page numbers
|
||||
*/
|
||||
function reorderPagesForFileMove(
|
||||
currentPages: PDFPage[],
|
||||
fromIndex: number,
|
||||
toIndex: number,
|
||||
orderedFileIds: FileId[]
|
||||
): PDFPage[] {
|
||||
// Group pages by originalFileId, preserving their current relative positions
|
||||
const fileGroups = new Map<FileId, PDFPage[]>();
|
||||
|
||||
currentPages.forEach(page => {
|
||||
const fileId = page.originalFileId;
|
||||
if (!fileId) return;
|
||||
|
||||
if (!fileGroups.has(fileId)) {
|
||||
fileGroups.set(fileId, []);
|
||||
}
|
||||
fileGroups.get(fileId)!.push(page);
|
||||
});
|
||||
|
||||
// Reorder the file IDs
|
||||
const newFileOrder = [...orderedFileIds];
|
||||
const [movedFileId] = newFileOrder.splice(fromIndex, 1);
|
||||
newFileOrder.splice(toIndex, 0, movedFileId);
|
||||
|
||||
// Rebuild pages in new file order, preserving page order within each file
|
||||
const reorderedPages: PDFPage[] = [];
|
||||
|
||||
newFileOrder.forEach(fileId => {
|
||||
const filePages = fileGroups.get(fileId) || [];
|
||||
reorderedPages.push(...filePages);
|
||||
});
|
||||
|
||||
// Renumber all pages sequentially
|
||||
reorderedPages.forEach((page, index) => {
|
||||
page.pageNumber = index + 1;
|
||||
});
|
||||
|
||||
return reorderedPages;
|
||||
}
|
||||
|
||||
interface PageEditorContextValue {
|
||||
// Set of selected file IDs (for quick lookup)
|
||||
selectedFileIds: Set<FileId>;
|
||||
|
||||
// Current page order (updated by PageEditor, used for file reordering)
|
||||
currentPages: PDFPage[] | null;
|
||||
updateCurrentPages: (pages: PDFPage[] | null) => void;
|
||||
|
||||
// Reordered pages (when file reordering happens)
|
||||
reorderedPages: PDFPage[] | null;
|
||||
clearReorderedPages: () => void;
|
||||
|
||||
// Toggle file selection
|
||||
toggleFileSelection: (fileId: FileId) => void;
|
||||
|
||||
@ -13,9 +97,12 @@ interface PageEditorContextValue {
|
||||
selectAll: (fileIds: FileId[]) => void;
|
||||
deselectAll: () => void;
|
||||
|
||||
// Reorder ALL files in FileContext (maintains selection state)
|
||||
// Reorder ALL files in FileContext (maintains selection state and page order)
|
||||
reorderFiles: (fromIndex: number, toIndex: number, allFileIds: FileId[]) => void;
|
||||
|
||||
// Update file order based on page positions (when pages are manually reordered)
|
||||
updateFileOrderFromPages: (pages: PDFPage[]) => void;
|
||||
|
||||
// Sync with FileContext when files change
|
||||
syncWithFileContext: (allFileIds: FileId[]) => void;
|
||||
}
|
||||
@ -30,14 +117,29 @@ interface PageEditorProviderProps {
|
||||
export function PageEditorProvider({ children, initialFileIds = [] }: PageEditorProviderProps) {
|
||||
// Use Set for O(1) selection lookup
|
||||
const [selectedFileIds, setSelectedFileIds] = useState<Set<FileId>>(new Set(initialFileIds));
|
||||
const [currentPages, setCurrentPages] = useState<PDFPage[] | null>(null);
|
||||
const [reorderedPages, setReorderedPages] = useState<PDFPage[] | null>(null);
|
||||
const { actions: fileActions } = useFileActions();
|
||||
|
||||
const updateCurrentPages = useCallback((pages: PDFPage[] | null) => {
|
||||
setCurrentPages(pages);
|
||||
}, []);
|
||||
|
||||
const clearReorderedPages = useCallback(() => {
|
||||
setReorderedPages(null);
|
||||
}, []);
|
||||
|
||||
const toggleFileSelection = useCallback((fileId: FileId) => {
|
||||
setSelectedFileIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(fileId)) {
|
||||
next.delete(fileId);
|
||||
} else {
|
||||
// Check if adding this file would exceed the limit
|
||||
if (next.size >= MAX_PAGE_EDITOR_FILES) {
|
||||
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`);
|
||||
return prev;
|
||||
}
|
||||
next.add(fileId);
|
||||
}
|
||||
return next;
|
||||
@ -45,7 +147,14 @@ export function PageEditorProvider({ children, initialFileIds = [] }: PageEditor
|
||||
}, []);
|
||||
|
||||
const selectAll = useCallback((fileIds: FileId[]) => {
|
||||
setSelectedFileIds(new Set(fileIds));
|
||||
// Enforce maximum file limit
|
||||
if (fileIds.length > MAX_PAGE_EDITOR_FILES) {
|
||||
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Only first ${MAX_PAGE_EDITOR_FILES} files will be selected.`);
|
||||
const limitedFiles = fileIds.slice(0, MAX_PAGE_EDITOR_FILES);
|
||||
setSelectedFileIds(new Set(limitedFiles));
|
||||
} else {
|
||||
setSelectedFileIds(new Set(fileIds));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const deselectAll = useCallback(() => {
|
||||
@ -60,6 +169,24 @@ export function PageEditorProvider({ children, initialFileIds = [] }: PageEditor
|
||||
|
||||
// Update global FileContext order
|
||||
fileActions.reorderFiles(newOrder);
|
||||
|
||||
// If current pages available, reorder them based on file move
|
||||
if (currentPages && currentPages.length > 0) {
|
||||
const reordered = reorderPagesForFileMove(currentPages, fromIndex, toIndex, allFileIds);
|
||||
setReorderedPages(reordered);
|
||||
}
|
||||
}, [fileActions, currentPages]);
|
||||
|
||||
const updateFileOrderFromPages = useCallback((pages: PDFPage[]) => {
|
||||
if (!pages || pages.length === 0) return;
|
||||
|
||||
// Compute the new file order based on page positions
|
||||
const newFileOrder = computeFileOrderFromPages(pages);
|
||||
|
||||
if (newFileOrder.length > 0) {
|
||||
// Update global FileContext order
|
||||
fileActions.reorderFiles(newFileOrder);
|
||||
}
|
||||
}, [fileActions]);
|
||||
|
||||
const syncWithFileContext = useCallback((allFileIds: FileId[]) => {
|
||||
@ -72,9 +199,20 @@ export function PageEditorProvider({ children, initialFileIds = [] }: PageEditor
|
||||
}
|
||||
});
|
||||
|
||||
// If no files selected, select all by default
|
||||
// If no files selected, select all by default (up to MAX_PAGE_EDITOR_FILES)
|
||||
if (next.size === 0 && allFileIds.length > 0) {
|
||||
return new Set(allFileIds);
|
||||
const filesToSelect = allFileIds.slice(0, MAX_PAGE_EDITOR_FILES);
|
||||
if (allFileIds.length > MAX_PAGE_EDITOR_FILES) {
|
||||
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Only first ${MAX_PAGE_EDITOR_FILES} files will be selected.`);
|
||||
}
|
||||
return new Set(filesToSelect);
|
||||
}
|
||||
|
||||
// Enforce maximum file limit
|
||||
if (next.size > MAX_PAGE_EDITOR_FILES) {
|
||||
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Limiting selection.`);
|
||||
const limitedFiles = Array.from(next).slice(0, MAX_PAGE_EDITOR_FILES);
|
||||
return new Set(limitedFiles);
|
||||
}
|
||||
|
||||
// Only update if there's an actual change
|
||||
@ -88,10 +226,15 @@ export function PageEditorProvider({ children, initialFileIds = [] }: PageEditor
|
||||
|
||||
const value: PageEditorContextValue = {
|
||||
selectedFileIds,
|
||||
currentPages,
|
||||
updateCurrentPages,
|
||||
reorderedPages,
|
||||
clearReorderedPages,
|
||||
toggleFileSelection,
|
||||
selectAll,
|
||||
deselectAll,
|
||||
reorderFiles,
|
||||
updateFileOrderFromPages,
|
||||
syncWithFileContext,
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user