mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-02-17 13:52:14 +01:00
Structural tweaks
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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' : ''}
|
||||
`}
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user