Structural tweaks

This commit is contained in:
Reece
2025-10-15 00:01:30 +01:00
parent 39267e795c
commit 05a7161412
8 changed files with 377 additions and 164 deletions

View File

@@ -707,6 +707,16 @@
"tags": "workflow,sequence,automation",
"title": "Automate",
"desc": "Build multi-step workflows by chaining together PDF actions. Ideal for recurring tasks."
},
"mobile": {
"brandAlt": "Stirling PDF logo",
"viewSwitcher": "Switch workspace view",
"tools": "Tools",
"workspace": "Workspace",
"swipeHint": "Swipe left or right to switch views",
"toolsSlide": "Tool selection panel",
"workbenchSlide": "Workspace panel",
"openFiles": "Open files"
}
},
"landing": {

View File

@@ -1,4 +1,4 @@
import React, { useMemo } from 'react';
import React, { useMemo, useEffect } from 'react';
import { Box } from '@mantine/core';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
@@ -6,7 +6,7 @@ import { useFileHandler } from '../../hooks/useFileHandler';
import { useFileState } from '../../contexts/FileContext';
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import { useViewer } from '../../contexts/ViewerContext';
import { PageEditorProvider } from '../../contexts/PageEditorContext';
import { PageEditorProvider, usePageEditor } from '../../contexts/PageEditorContext';
import './Workbench.css';
import TopControls from '../shared/TopControls';
@@ -18,16 +18,45 @@ import LandingPage from '../shared/LandingPage';
import Footer from '../shared/Footer';
import DismissAllErrorsButton from '../shared/DismissAllErrorsButton';
// Syncs PageEditorContext with FileContext on activeFiles change
function PageEditorSync({ activeFiles }: { activeFiles: Array<{ fileId: any; name: string; versionNumber?: number }> }) {
const { syncWithFileContext } = usePageEditor();
// Create stable signature to prevent unnecessary syncs
const fileSignature = useMemo(
() => activeFiles.map(f => `${f.fileId}-${f.name}-${f.versionNumber || 0}`).join('|'),
[activeFiles]
);
useEffect(() => {
// Filter for PDFs only - Page Editor doesn't support images
const pdfFiles = activeFiles.filter(f =>
f.name.toLowerCase().endsWith('.pdf')
);
const fileData = pdfFiles.map(f => ({
fileId: f.fileId,
name: f.name,
versionNumber: f.versionNumber,
}));
syncWithFileContext(fileData);
}, [fileSignature, syncWithFileContext, activeFiles]);
return null;
}
// No props needed - component uses contexts directly
export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext();
// Use context-based hooks to eliminate all prop drilling
const { selectors } = useFileState();
const { state, selectors } = useFileState();
const { workbench: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setWorkbench;
const activeFiles = selectors.getFiles();
// Create stable reference for activeFiles based on file IDs
const activeFiles = useMemo(() => selectors.getFiles(), [state.files.ids.join(',')]);
const {
previewFile,
pageEditorFunctions,
@@ -149,6 +178,7 @@ export default function Workbench() {
return (
<PageEditorProvider initialFileIds={allFileIds}>
<PageEditorSync activeFiles={activeFiles} />
<Box
className="flex-1 h-full min-w-80 relative flex flex-col"
style={

View File

@@ -43,25 +43,13 @@ const PageEditor = ({
// Navigation guard for unsaved changes
const { setHasUnsavedChanges } = useNavigationGuard();
// Get selected files from PageEditorContext instead of all files
const { selectedFileIds, syncWithFileContext, updateCurrentPages, reorderedPages, clearReorderedPages, updateFileOrderFromPages } = usePageEditor();
// Get files from PageEditorContext (synced by Workbench)
const { files: pageEditorFiles, updateCurrentPages, reorderedPages, clearReorderedPages, updateFileOrderFromPages, lastReorderSource, clearReorderSource } = usePageEditor();
// Stable reference to file IDs to prevent infinite loops
const fileIdsString = state.files.ids.join(',');
const selectedIdsString = Array.from(selectedFileIds).sort().join(',');
// Sync with FileContext when files change
useEffect(() => {
syncWithFileContext(state.files.ids);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileIdsString]); // Only re-run when the actual IDs change
// Get active file IDs from selected files (maintains order from FileContext)
// Get active file IDs from SELECTED files only
const activeFileIds = useMemo(() => {
return state.files.ids.filter(id => selectedFileIds.has(id));
// Using string representations to prevent infinite loops
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileIdsString, selectedIdsString]);
return pageEditorFiles.filter(f => f.isSelected).map(f => f.fileId);
}, [pageEditorFiles]);
// UI state
const globalProcessing = state.ui.isProcessing;
@@ -155,11 +143,19 @@ const PageEditor = ({
};
setEditedDocument(reorderedDocument);
clearReorderedPages();
// Clear the source after applying to prevent feedback loop
clearReorderSource();
}
}, [reorderedPages, displayDocument, clearReorderedPages]);
}, [reorderedPages, displayDocument, clearReorderedPages, clearReorderSource]);
// Update file order when pages are manually reordered
useEffect(() => {
// Skip if the last reorder came from file-level (prevent feedback loop)
if (lastReorderSource === 'file') {
clearReorderSource();
return;
}
if (editedDocument?.pages && editedDocument.pages.length > 0 && activeFileIds.length > 1) {
// Compute the file order based on page positions
const fileFirstPagePositions = new Map<FileId, number>();
@@ -177,7 +173,7 @@ const PageEditor = ({
.map(entry => entry[0]);
// Check if the order has actually changed
const currentFileOrder = state.files.ids.filter(id => selectedFileIds.has(id));
const currentFileOrder = pageEditorFiles.filter(f => f.isSelected).map(f => f.fileId);
const orderChanged = computedFileOrder.length !== currentFileOrder.length ||
computedFileOrder.some((id, index) => id !== currentFileOrder[index]);
@@ -185,7 +181,7 @@ const PageEditor = ({
updateFileOrderFromPages(editedDocument.pages);
}
}
}, [editedDocument?.pages, activeFileIds.length, state.files.ids, selectedFileIds, updateFileOrderFromPages]);
}, [editedDocument?.pages, activeFileIds.length, state.files.ids, pageEditorFiles, updateFileOrderFromPages, lastReorderSource, clearReorderSource]);
// Utility functions to convert between page IDs and page numbers
const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => {
@@ -729,14 +725,28 @@ 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
// Track color assignments by insertion order (files keep their color)
const fileColorAssignments = useRef(new Map<FileId, number>());
// Create a stable mapping of fileId to color index (preserves colors on reorder)
const fileColorIndexMap = useMemo(() => {
const map = new Map<FileId, number>();
activeFileIds.forEach((fileId, index) => {
map.set(fileId, index);
// Assign colors to new files based on insertion order
activeFileIds.forEach(fileId => {
if (!fileColorAssignments.current.has(fileId)) {
fileColorAssignments.current.set(fileId, fileColorAssignments.current.size);
}
});
return map;
}, [activeFileIds]);
// Clean up removed files
const activeSet = new Set(activeFileIds);
for (const fileId of fileColorAssignments.current.keys()) {
if (!activeSet.has(fileId)) {
fileColorAssignments.current.delete(fileId);
}
}
return fileColorAssignments.current;
}, [activeFileIds.join(',')]); // Only recalculate when the set of files changes, not the order
return (
<Box pos="relative" h='100%' style={{ overflow: 'auto' }} data-scrolling-container="true">

View File

@@ -68,6 +68,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
const [isDragging, setIsDragging] = useState(false);
const [isMouseDown, setIsMouseDown] = useState(false);
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
const lastClickTimeRef = useRef<number>(0);
const dragElementRef = useRef<HTMLDivElement>(null);
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(page.thumbnail);
const { getThumbnailFromCache, requestThumbnail } = useThumbnailGeneration();
@@ -263,7 +264,12 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
// If mouse moved less than 5 pixels, consider it a click (not a drag)
if (distance < 5 && !isDragging) {
onTogglePage(page.id);
// Prevent rapid double-clicks from causing issues (debounce with 100ms threshold)
const now = Date.now();
if (now - lastClickTimeRef.current > 100) {
lastClickTimeRef.current = now;
onTogglePage(page.id);
}
}
setIsMouseDown(false);
@@ -275,7 +281,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
setMouseStartPos(null);
}, []);
const fileColorBorder = getFileColorWithOpacity(fileColorIndex, 0.5);
const fileColorBorder = getFileColorWithOpacity(fileColorIndex, 0.3);
return (
<div
@@ -295,7 +301,6 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
hover:shadow-md
transition-all
relative
bg-white hover:bg-gray-50
${isDragging ? 'opacity-50 scale-95' : ''}
${movingPage === page.pageNumber ? 'page-moving' : ''}
`}

View File

@@ -16,18 +16,20 @@ export interface PageDocumentHook {
*/
export function usePageDocument(): PageDocumentHook {
const { state, selectors } = useFileState();
const { selectedFileIds } = usePageEditor();
const { files: pageEditorFiles } = usePageEditor();
// Convert Set to array and filter to maintain file order from FileContext
const allFileIds = state.files.ids;
// Create stable string representations for useMemo dependencies
const allFileIdsString = allFileIds.join(',');
const selectedIdsString = Array.from(selectedFileIds).sort().join(',');
const selectedFiles = pageEditorFiles.filter(f => f.isSelected);
const selectedIdsString = selectedFiles.map(f => f.fileId).sort().join(',');
const activeFileIds = useMemo(() => {
const selectedFileIds = new Set(selectedFiles.map(f => f.fileId));
return allFileIds.filter(id => selectedFileIds.has(id));
// Using string representations to prevent infinite loops from Set reference changes
// Using string representations to prevent infinite loops
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [allFileIdsString, selectedIdsString]);

View File

@@ -12,11 +12,12 @@ import FitText from './FitText';
import { getFileColorWithOpacity } from '../pageEditor/fileColors';
import { FileId } from '../../types/file';
import { PageEditorFile } from '../../contexts/PageEditorContext';
interface FileMenuItemProps {
file: { fileId: FileId; name: string; versionNumber?: number };
file: PageEditorFile;
index: number;
isSelected: boolean;
colorIndex: number;
isFirst: boolean;
isLast: boolean;
onToggleSelection: (fileId: FileId) => void;
@@ -30,7 +31,7 @@ interface FileMenuItemProps {
const FileMenuItem: React.FC<FileMenuItemProps> = ({
file,
index,
isSelected,
colorIndex,
isFirst,
isLast,
onToggleSelection,
@@ -101,8 +102,8 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
}, [file.fileId, index, onReorder]);
const itemName = file?.name || 'Untitled';
const fileColorBorder = getFileColorWithOpacity(index, 1);
const fileColorBorderHover = getFileColorWithOpacity(index, 1.0);
const fileColorBorder = getFileColorWithOpacity(colorIndex, 1);
const fileColorBorderHover = getFileColorWithOpacity(colorIndex, 1.0);
return (
<div
@@ -115,12 +116,12 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
padding: '0.75rem 0.75rem',
marginBottom: '0.5rem',
cursor: isDragging ? 'grabbing' : 'grab',
backgroundColor: isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
backgroundColor: isDragOver ? 'rgba(59, 130, 246, 0.15)' : (file.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',
borderTop: isDragOver ? '3px solid rgb(59, 130, 246)' : 'none',
borderBottom: isDragOver ? '3px solid rgb(59, 130, 246)' : 'none',
opacity: isDragging ? 0.5 : 1,
transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease, border-color 0.15s ease',
transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease, border 0.15s ease',
userSelect: 'none',
}}
onMouseEnter={(e) => {
@@ -131,7 +132,7 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
}}
onMouseLeave={(e) => {
if (!isDragging) {
(e.currentTarget as HTMLDivElement).style.backgroundColor = isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent';
(e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent';
(e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder;
}
}}
@@ -149,7 +150,7 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
<DragIndicatorIcon fontSize="small" />
</div>
<Checkbox
checked={isSelected}
checked={file.isSelected}
onChange={() => onToggleSelection(file.fileId)}
onClick={(e) => e.stopPropagation()}
size="sm"
@@ -169,7 +170,7 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
size="sm"
variant="subtle"
disabled={isFirst}
onClick={onMoveToTop}
onClick={(e) => onMoveToTop(e, index)}
title="Move to top"
>
<VerticalAlignTopIcon fontSize="small" />
@@ -179,7 +180,7 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
size="sm"
variant="subtle"
disabled={isFirst}
onClick={onMoveUp}
onClick={(e) => onMoveUp(e, index)}
title="Move up"
>
<ArrowUpwardIcon fontSize="small" />
@@ -189,7 +190,7 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
size="sm"
variant="subtle"
disabled={isLast}
onClick={onMoveDown}
onClick={(e) => onMoveDown(e, index)}
title="Move down"
>
<ArrowDownwardIcon fontSize="small" />
@@ -199,7 +200,7 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
size="sm"
variant="subtle"
disabled={isLast}
onClick={onMoveToBottom}
onClick={(e) => onMoveToBottom(e, index)}
title="Move to bottom"
>
<VerticalAlignBottomIcon fontSize="small" />
@@ -212,22 +213,22 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
interface PageEditorFileDropdownProps {
displayName: string;
allFiles: Array<{ fileId: FileId; name: string; versionNumber?: number }>;
selectedFileIds: Set<FileId>;
files: PageEditorFile[];
onToggleSelection: (fileId: FileId) => void;
onReorder: (fromIndex: number, toIndex: number) => void;
switchingTo?: string | null;
viewOptionStyle: React.CSSProperties;
fileColorMap: Map<string, number>;
}
export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
displayName,
allFiles,
selectedFileIds,
files,
onToggleSelection,
onReorder,
switchingTo,
viewOptionStyle,
fileColorMap,
}) => {
const handleMoveUp = (e: React.MouseEvent, index: number) => {
e.stopPropagation();
@@ -238,7 +239,7 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
const handleMoveDown = (e: React.MouseEvent, index: number) => {
e.stopPropagation();
if (index < allFiles.length - 1) {
if (index < files.length - 1) {
onReorder(index, index + 1);
}
};
@@ -252,8 +253,8 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
const handleMoveToBottom = (e: React.MouseEvent, index: number) => {
e.stopPropagation();
if (index < allFiles.length - 1) {
onReorder(index, allFiles.length - 1);
if (index < files.length - 1) {
onReorder(index, files.length - 1);
}
};
@@ -278,17 +279,17 @@ export const PageEditorFileDropdown: React.FC<PageEditorFileDropdownProps> = ({
maxHeight: '80vh',
overflowY: 'auto'
}}>
{allFiles.map((file, index) => {
const isSelected = selectedFileIds.has(file.fileId);
{files.map((file, index) => {
const isFirst = index === 0;
const isLast = index === allFiles.length - 1;
const isLast = index === files.length - 1;
const colorIndex = fileColorMap.get(file.fileId as string) ?? 0;
return (
<FileMenuItem
key={file.fileId}
file={file}
index={index}
isSelected={isSelected}
colorIndex={colorIndex}
isFirst={isFirst}
isLast={isLast}
onToggleSelection={onToggleSelection}

View File

@@ -1,4 +1,6 @@
import React, { useState, useCallback } from "react";
import React, { useState, useCallback, useMemo } from "react";
// Component to sync PageEditorContext with FileContext
// Must be inside PageEditorProvider to access usePageEditor
import { SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider";
import rainbowStyles from '../../styles/rainbow.module.css';
@@ -30,12 +32,12 @@ const createViewOptions = (
currentFileIndex: number,
onFileSelect?: (index: number) => void,
pageEditorState?: {
allFiles: Array<{ fileId: FileId; name: string; versionNumber?: number }>;
selectedFileIds: Set<FileId>;
files: Array<{ fileId: FileId; name: string; versionNumber?: number; isSelected: boolean }>;
selectedCount: number;
totalCount: number;
onToggleSelection: (fileId: FileId) => void;
onReorder: (fromIndex: number, toIndex: number) => void;
fileColorMap: Map<string, number>;
}
) => {
const currentFile = activeFiles[currentFileIndex];
@@ -82,12 +84,12 @@ const createViewOptions = (
label: showPageEditorDropdown ? (
<PageEditorFileDropdown
displayName={pageEditorDisplayName}
allFiles={pageEditorState!.allFiles}
selectedFileIds={pageEditorState!.selectedFileIds}
files={pageEditorState!.files}
onToggleSelection={pageEditorState!.onToggleSelection}
onReorder={pageEditorState!.onReorder}
switchingTo={switchingTo}
viewOptionStyle={viewOptionStyle}
fileColorMap={pageEditorState!.fileColorMap}
/>
) : (
<div style={viewOptionStyle}>
@@ -149,14 +151,50 @@ const TopControls = ({
// Get page editor state for dropdown
const {
selectedFileIds,
files: pageEditorFiles = [],
toggleFileSelection,
reorderFiles: pageEditorReorderFiles,
} = usePageEditor();
// Convert Set to array for counting
const selectedCount = selectedFileIds.size;
const totalCount = activeFiles.length;
// Convert to counts
const selectedCount = pageEditorFiles?.filter(f => f.isSelected).length || 0;
const totalCount = pageEditorFiles?.length || 0;
// Create stable file IDs string for dependency (only changes when file set changes)
const fileIdsString = (pageEditorFiles || []).map(f => f.fileId).sort().join(',');
// Track color assignments by insertion order (files keep their color)
const fileColorAssignments = React.useRef(new Map<string, number>());
// Create stable file color mapping (preserves colors on reorder)
const fileColorMap = useMemo(() => {
const map = new Map<string, number>();
if (!pageEditorFiles || pageEditorFiles.length === 0) return map;
const allFileIds = (pageEditorFiles || []).map(f => f.fileId as string);
// Assign colors to new files based on insertion order
allFileIds.forEach(fileId => {
if (!fileColorAssignments.current.has(fileId)) {
fileColorAssignments.current.set(fileId, fileColorAssignments.current.size);
}
});
// Clean up removed files
const activeSet = new Set(allFileIds);
for (const fileId of fileColorAssignments.current.keys()) {
if (!activeSet.has(fileId)) {
fileColorAssignments.current.delete(fileId);
}
}
return fileColorAssignments.current;
}, [fileIdsString]);
// Memoize the reorder handler - now much simpler!
const handleReorder = useCallback((fromIndex: number, toIndex: number) => {
pageEditorReorderFiles(fromIndex, toIndex);
}, [pageEditorReorderFiles]);
const handleViewChange = useCallback((view: string) => {
if (!isValidWorkbench(view)) {
@@ -191,12 +229,12 @@ const TopControls = ({
currentFileIndex,
onFileSelect,
{
allFiles: activeFiles as Array<{ fileId: FileId; name: string; versionNumber?: number }>,
selectedFileIds,
files: pageEditorFiles,
selectedCount,
totalCount,
onToggleSelection: toggleFileSelection,
onReorder: (fromIndex, toIndex) => pageEditorReorderFiles(fromIndex, toIndex, activeFiles.map(f => f.fileId as FileId)),
onReorder: handleReorder,
fileColorMap,
}
)}
value={currentView}

View File

@@ -4,6 +4,13 @@ import { useFileActions } from './FileContext';
import { PDFPage } from '../types/pageEditor';
import { MAX_PAGE_EDITOR_FILES } from '../components/pageEditor/fileColors';
export interface PageEditorFile {
fileId: FileId;
name: string;
versionNumber?: number;
isSelected: boolean;
}
/**
* Computes file order based on the position of each file's first page
* @param pages - Current page order
@@ -31,10 +38,10 @@ function computeFileOrderFromPages(pages: PDFPage[]): FileId[] {
}
/**
* 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
* Reorders pages based on file reordering while preserving interlacing and manual page order
* @param currentPages - Current page order (may include manual reordering and interlacing)
* @param fromIndex - Source file index in the file order
* @param toIndex - Target file index in the file order
* @param orderedFileIds - File IDs in their current order
* @returns Reordered pages with updated page numbers
*/
@@ -44,43 +51,60 @@ function reorderPagesForFileMove(
toIndex: number,
orderedFileIds: FileId[]
): PDFPage[] {
// Group pages by originalFileId, preserving their current relative positions
const fileGroups = new Map<FileId, PDFPage[]>();
// Get the file ID being moved
const movedFileId = orderedFileIds[fromIndex];
const targetFileId = orderedFileIds[toIndex];
// Extract pages belonging to the moved file (maintaining their relative order)
const movedFilePages: PDFPage[] = [];
const remainingPages: PDFPage[] = [];
currentPages.forEach(page => {
const fileId = page.originalFileId;
if (!fileId) return;
if (!fileGroups.has(fileId)) {
fileGroups.set(fileId, []);
if (page.originalFileId === movedFileId) {
movedFilePages.push(page);
} else {
remainingPages.push(page);
}
fileGroups.get(fileId)!.push(page);
});
// Reorder the file IDs
const newFileOrder = [...orderedFileIds];
const [movedFileId] = newFileOrder.splice(fromIndex, 1);
newFileOrder.splice(toIndex, 0, movedFileId);
// Find the insertion point based on the target file
let insertionIndex = 0;
// Rebuild pages in new file order, preserving page order within each file
const reorderedPages: PDFPage[] = [];
if (fromIndex < toIndex) {
// Moving down: insert AFTER the last page of target file
for (let i = remainingPages.length - 1; i >= 0; i--) {
if (remainingPages[i].originalFileId === targetFileId) {
insertionIndex = i + 1;
break;
}
}
} else {
// Moving up: insert BEFORE the first page of target file
for (let i = 0; i < remainingPages.length; i++) {
if (remainingPages[i].originalFileId === targetFileId) {
insertionIndex = i;
break;
}
}
}
newFileOrder.forEach(fileId => {
const filePages = fileGroups.get(fileId) || [];
reorderedPages.push(...filePages);
});
// Insert moved pages at the calculated position
const reorderedPages = [
...remainingPages.slice(0, insertionIndex),
...movedFilePages,
...remainingPages.slice(insertionIndex)
];
// Renumber all pages sequentially
reorderedPages.forEach((page, index) => {
page.pageNumber = index + 1;
});
return reorderedPages;
// Renumber all pages sequentially (clone to avoid mutation)
return reorderedPages.map((page, index) => ({
...page,
pageNumber: index + 1
}));
}
interface PageEditorContextValue {
// Set of selected file IDs (for quick lookup)
selectedFileIds: Set<FileId>;
// Single array of files with selection state
files: PageEditorFile[];
// Current page order (updated by PageEditor, used for file reordering)
currentPages: PDFPage[] | null;
@@ -90,21 +114,28 @@ interface PageEditorContextValue {
reorderedPages: PDFPage[] | null;
clearReorderedPages: () => void;
// Set file selection
setFileSelection: (fileId: FileId, selected: boolean) => void;
// Toggle file selection
toggleFileSelection: (fileId: FileId) => void;
// Select/deselect all files
selectAll: (fileIds: FileId[]) => void;
selectAll: () => void;
deselectAll: () => void;
// Reorder ALL files in FileContext (maintains selection state and page order)
reorderFiles: (fromIndex: number, toIndex: number, allFileIds: FileId[]) => void;
// Reorder files (simple array reordering)
reorderFiles: (fromIndex: number, toIndex: number) => void;
// Update file order based on page positions (when pages are manually reordered)
updateFileOrderFromPages: (pages: PDFPage[]) => void;
// Track mutation source to prevent feedback loops
lastReorderSource: 'file' | 'page' | null;
clearReorderSource: () => void;
// Sync with FileContext when files change
syncWithFileContext: (allFileIds: FileId[]) => void;
syncWithFileContext: (fileContextFiles: Array<{ fileId: FileId; name: string; versionNumber?: number }>) => void;
}
const PageEditorContext = createContext<PageEditorContextValue | undefined>(undefined);
@@ -115,10 +146,12 @@ interface PageEditorProviderProps {
}
export function PageEditorProvider({ children, initialFileIds = [] }: PageEditorProviderProps) {
// Use Set for O(1) selection lookup
const [selectedFileIds, setSelectedFileIds] = useState<Set<FileId>>(new Set(initialFileIds));
// Single array of files with selection state
const [files, setFiles] = useState<PageEditorFile[]>([]);
const [currentPages, setCurrentPages] = useState<PDFPage[] | null>(null);
const [reorderedPages, setReorderedPages] = useState<PDFPage[] | null>(null);
const [lastReorderSource, setLastReorderSource] = useState<'file' | 'page' | null>(null);
const lastReorderSourceAtRef = React.useRef<number>(0);
const { actions: fileActions } = useFileActions();
const updateCurrentPages = useCallback((pages: PDFPage[] | null) => {
@@ -129,56 +162,129 @@ export function PageEditorProvider({ children, initialFileIds = [] }: PageEditor
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) {
const clearReorderSource = useCallback(() => {
setLastReorderSource(null);
}, []);
const setFileSelection = useCallback((fileId: FileId, selected: boolean) => {
setFiles(prev => {
const selectedCount = prev.filter(f => f.isSelected).length;
// Check if we're trying to select when at limit
if (selected && selectedCount >= MAX_PAGE_EDITOR_FILES) {
const alreadySelected = prev.find(f => f.fileId === fileId)?.isSelected;
if (!alreadySelected) {
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`);
return prev;
}
next.add(fileId);
}
return next;
return prev.map(f =>
f.fileId === fileId ? { ...f, isSelected: selected } : f
);
});
}, []);
const selectAll = useCallback((fileIds: FileId[]) => {
// 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 toggleFileSelection = useCallback((fileId: FileId) => {
setFiles(prev => {
const file = prev.find(f => f.fileId === fileId);
if (!file) return prev;
const selectedCount = prev.filter(f => f.isSelected).length;
// If toggling on and at limit, don't allow
if (!file.isSelected && selectedCount >= MAX_PAGE_EDITOR_FILES) {
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`);
return prev;
}
return prev.map(f =>
f.fileId === fileId ? { ...f, isSelected: !f.isSelected } : f
);
});
}, []);
const selectAll = useCallback(() => {
setFiles(prev => {
if (prev.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 prev.map((f, index) => ({ ...f, isSelected: index < MAX_PAGE_EDITOR_FILES }));
}
return prev.map(f => ({ ...f, isSelected: true }));
});
}, []);
const deselectAll = useCallback(() => {
setSelectedFileIds(new Set());
setFiles(prev => prev.map(f => ({ ...f, isSelected: false })));
}, []);
const reorderFiles = useCallback((fromIndex: number, toIndex: number, allFileIds: FileId[]) => {
// Reorder the entire file list in FileContext
const newOrder = [...allFileIds];
const [movedFile] = newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, movedFile);
const reorderFiles = useCallback((fromIndex: number, toIndex: number) => {
let newFileIds: FileId[] = [];
let reorderedPagesResult: PDFPage[] | null = null;
// Update global FileContext order
fileActions.reorderFiles(newOrder);
// Mark that this reorder came from file-level action
setLastReorderSource('file');
lastReorderSourceAtRef.current = Date.now();
// If current pages available, reorder them based on file move
if (currentPages && currentPages.length > 0) {
const reordered = reorderPagesForFileMove(currentPages, fromIndex, toIndex, allFileIds);
setReorderedPages(reordered);
setFiles(prev => {
// Simple array reordering
const newOrder = [...prev];
const [movedFile] = newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, movedFile);
// Collect file IDs for later FileContext update
newFileIds = newOrder.map(f => f.fileId);
// If current pages available, reorder them based on file move
if (currentPages && currentPages.length > 0 && fromIndex !== toIndex) {
// Get the current file order from pages (files that have pages loaded)
const currentFileOrder: FileId[] = [];
const filesSeen = new Set<FileId>();
currentPages.forEach(page => {
const fileId = page.originalFileId;
if (fileId && !filesSeen.has(fileId)) {
filesSeen.add(fileId);
currentFileOrder.push(fileId);
}
});
// Get the moved and target file IDs
const movedFileId = prev[fromIndex].fileId;
const targetFileId = prev[toIndex].fileId;
// Find their positions in the current page order (not the full file list)
const pageOrderFromIndex = currentFileOrder.findIndex(id => id === movedFileId);
const pageOrderToIndex = currentFileOrder.findIndex(id => id === targetFileId);
// Only reorder pages if both files have pages loaded
if (pageOrderFromIndex >= 0 && pageOrderToIndex >= 0) {
reorderedPagesResult = reorderPagesForFileMove(currentPages, pageOrderFromIndex, pageOrderToIndex, currentFileOrder);
}
}
return newOrder;
});
// Update FileContext after state settles
if (newFileIds.length > 0) {
fileActions.reorderFiles(newFileIds);
}
// Update reordered pages after state settles
if (reorderedPagesResult) {
setReorderedPages(reorderedPagesResult);
}
}, [fileActions, currentPages]);
const updateFileOrderFromPages = useCallback((pages: PDFPage[]) => {
if (!pages || pages.length === 0) return;
// Suppress page-derived reorder if a recent explicit file reorder just occurred (prevents feedback loop)
if (lastReorderSource === 'file' && Date.now() - lastReorderSourceAtRef.current < 500) {
return;
}
setLastReorderSource('page');
lastReorderSourceAtRef.current = Date.now();
// Compute the new file order based on page positions
const newFileOrder = computeFileOrderFromPages(pages);
@@ -187,54 +293,65 @@ export function PageEditorProvider({ children, initialFileIds = [] }: PageEditor
// Update global FileContext order
fileActions.reorderFiles(newFileOrder);
}
}, [fileActions]);
}, [fileActions, lastReorderSource]);
const syncWithFileContext = useCallback((allFileIds: FileId[]) => {
setSelectedFileIds(prev => {
// Remove IDs that no longer exist in FileContext
const next = new Set<FileId>();
allFileIds.forEach(id => {
if (prev.has(id)) {
next.add(id);
}
const syncWithFileContext = useCallback((fileContextFiles: Array<{ fileId: FileId; name: string; versionNumber?: number }>) => {
setFiles(prev => {
// Create a map of existing files for quick lookup
const existingMap = new Map(prev.map(f => [f.fileId, f]));
// Build new files array from FileContext, preserving selection state
const newFiles: PageEditorFile[] = fileContextFiles.map(file => {
const existing = existingMap.get(file.fileId);
return {
fileId: file.fileId,
name: file.name,
versionNumber: file.versionNumber,
isSelected: existing?.isSelected ?? false, // Preserve selection or default to false
};
});
// If no files selected, select all by default (up to MAX_PAGE_EDITOR_FILES)
if (next.size === 0 && allFileIds.length > 0) {
const filesToSelect = allFileIds.slice(0, MAX_PAGE_EDITOR_FILES);
if (allFileIds.length > MAX_PAGE_EDITOR_FILES) {
const selectedCount = newFiles.filter(f => f.isSelected).length;
if (selectedCount === 0 && newFiles.length > 0) {
const maxToSelect = Math.min(newFiles.length, MAX_PAGE_EDITOR_FILES);
if (newFiles.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);
return newFiles.map((f, index) => ({
...f,
isSelected: index < maxToSelect,
}));
}
// Enforce maximum file limit
if (next.size > MAX_PAGE_EDITOR_FILES) {
if (selectedCount > 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);
let selectedSoFar = 0;
return newFiles.map(f => ({
...f,
isSelected: f.isSelected && selectedSoFar++ < MAX_PAGE_EDITOR_FILES,
}));
}
// Only update if there's an actual change
if (next.size === prev.size && Array.from(next).every(id => prev.has(id))) {
return prev; // No change, return same reference
}
return next;
return newFiles;
});
}, []);
const value: PageEditorContextValue = {
selectedFileIds,
files,
currentPages,
updateCurrentPages,
reorderedPages,
clearReorderedPages,
setFileSelection,
toggleFileSelection,
selectAll,
deselectAll,
reorderFiles,
updateFileOrderFromPages,
lastReorderSource,
clearReorderSource,
syncWithFileContext,
};