Rejig arrays

This commit is contained in:
Reece 2025-10-15 16:31:30 +01:00
parent 05a7161412
commit e7c6db082c
7 changed files with 407 additions and 370 deletions

View File

@ -6,7 +6,7 @@ import { useFileHandler } from '../../hooks/useFileHandler';
import { useFileState } from '../../contexts/FileContext'; import { useFileState } from '../../contexts/FileContext';
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext'; import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
import { useViewer } from '../../contexts/ViewerContext'; import { useViewer } from '../../contexts/ViewerContext';
import { PageEditorProvider, usePageEditor } from '../../contexts/PageEditorContext'; import { PageEditorProvider } from '../../contexts/PageEditorContext';
import './Workbench.css'; import './Workbench.css';
import TopControls from '../shared/TopControls'; import TopControls from '../shared/TopControls';
@ -18,33 +18,6 @@ import LandingPage from '../shared/LandingPage';
import Footer from '../shared/Footer'; import Footer from '../shared/Footer';
import DismissAllErrorsButton from '../shared/DismissAllErrorsButton'; 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 // No props needed - component uses contexts directly
export default function Workbench() { export default function Workbench() {
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
@ -79,9 +52,6 @@ export default function Workbench() {
// Get active file index from ViewerContext // Get active file index from ViewerContext
const { activeFileIndex, setActiveFileIndex } = useViewer(); const { activeFileIndex, setActiveFileIndex } = useViewer();
// Get all file IDs for PageEditor initialization
const allFileIds = useMemo(() => activeFiles.map(f => f.fileId), [activeFiles]);
const handlePreviewClose = () => { const handlePreviewClose = () => {
setPreviewFile(null); setPreviewFile(null);
const previousMode = sessionStorage.getItem('previousMode'); const previousMode = sessionStorage.getItem('previousMode');
@ -177,8 +147,7 @@ export default function Workbench() {
}; };
return ( return (
<PageEditorProvider initialFileIds={allFileIds}> <PageEditorProvider>
<PageEditorSync activeFiles={activeFiles} />
<Box <Box
className="flex-1 h-full min-w-80 relative flex flex-col" className="flex-1 h-full min-w-80 relative flex flex-col"
style={ style={

View File

@ -43,8 +43,64 @@ const PageEditor = ({
// Navigation guard for unsaved changes // Navigation guard for unsaved changes
const { setHasUnsavedChanges } = useNavigationGuard(); const { setHasUnsavedChanges } = useNavigationGuard();
// Get files from PageEditorContext (synced by Workbench) // Get PageEditor coordination functions
const { files: pageEditorFiles, updateCurrentPages, reorderedPages, clearReorderedPages, updateFileOrderFromPages, lastReorderSource, clearReorderSource } = usePageEditor(); const { updateCurrentPages, reorderedPages, clearReorderedPages, updateFileOrderFromPages } = usePageEditor();
// Derive page editor files from FileContext (single source of truth)
// Filter to only show PDF files (PageEditor only supports PDFs)
// Use stable string keys to prevent infinite loops
// Cache file objects to prevent infinite re-renders from new object references
const fileIdsKey = state.files.ids.join(',');
const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(',');
const filesSignature = selectors.getFilesSignature();
const fileObjectsRef = useRef(new Map<FileId, any>());
const pageEditorFiles = useMemo(() => {
const cache = fileObjectsRef.current;
const newFiles: any[] = [];
state.files.ids.forEach(fileId => {
const stub = selectors.getStirlingFileStub(fileId);
const isSelected = state.ui.selectedFileIds.includes(fileId);
const isPdf = stub?.name?.toLowerCase().endsWith('.pdf') ?? false;
if (!isPdf) return; // Skip non-PDFs
const cached = cache.get(fileId);
// Check if data actually changed (compare by fileId, not position)
if (cached &&
cached.fileId === fileId &&
cached.name === (stub?.name || '') &&
cached.versionNumber === stub?.versionNumber &&
cached.isSelected === isSelected) {
// Reuse existing object reference
newFiles.push(cached);
} else {
// Create new object only if data changed
const newFile = {
fileId,
name: stub?.name || '',
versionNumber: stub?.versionNumber,
isSelected,
};
cache.set(fileId, newFile);
newFiles.push(newFile);
}
});
// Clean up removed files from cache
const activeIds = new Set(newFiles.map(f => f.fileId));
for (const cachedId of cache.keys()) {
if (!activeIds.has(cachedId)) {
cache.delete(cachedId);
}
}
return newFiles;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileIdsKey, selectedIdsKey, filesSignature]);
// Get active file IDs from SELECTED files only // Get active file IDs from SELECTED files only
const activeFileIds = useMemo(() => { const activeFileIds = useMemo(() => {
@ -143,45 +199,15 @@ const PageEditor = ({
}; };
setEditedDocument(reorderedDocument); setEditedDocument(reorderedDocument);
clearReorderedPages(); clearReorderedPages();
// Clear the source after applying to prevent feedback loop
clearReorderSource();
} }
}, [reorderedPages, displayDocument, clearReorderedPages, clearReorderSource]); }, [reorderedPages, displayDocument, clearReorderedPages]);
// Update file order when pages are manually reordered // Update file order when pages are manually reordered
useEffect(() => { 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) { if (editedDocument?.pages && editedDocument.pages.length > 0 && activeFileIds.length > 1) {
// Compute the file order based on page positions updateFileOrderFromPages(editedDocument.pages);
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 = pageEditorFiles.filter(f => f.isSelected).map(f => f.fileId);
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, pageEditorFiles, updateFileOrderFromPages, lastReorderSource, clearReorderSource]); }, [editedDocument?.pages, activeFileIds.length, updateFileOrderFromPages]);
// Utility functions to convert between page IDs and page numbers // Utility functions to convert between page IDs and page numbers
const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => { const getPageNumbersFromIds = useCallback((pageIds: string[]): number[] => {

View File

@ -1,6 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useFileState } from '../../../contexts/FileContext'; import { useFileState } from '../../../contexts/FileContext';
import { usePageEditor } from '../../../contexts/PageEditorContext';
import { PDFDocument, PDFPage } from '../../../types/pageEditor'; import { PDFDocument, PDFPage } from '../../../types/pageEditor';
import { FileId } from '../../../types/file'; import { FileId } from '../../../types/file';
@ -16,28 +15,29 @@ export interface PageDocumentHook {
*/ */
export function usePageDocument(): PageDocumentHook { export function usePageDocument(): PageDocumentHook {
const { state, selectors } = useFileState(); const { state, selectors } = useFileState();
const { files: pageEditorFiles } = usePageEditor();
// Convert Set to array and filter to maintain file order from FileContext // Convert Set to array and filter to maintain file order from FileContext
const allFileIds = state.files.ids; const allFileIds = state.files.ids;
// Create stable string representations for useMemo dependencies // Derive selected file IDs directly from FileContext (single source of truth)
const allFileIdsString = allFileIds.join(','); // Filter to only include PDF files (PageEditor only supports PDFs)
const selectedFiles = pageEditorFiles.filter(f => f.isSelected); // Use stable string keys to prevent infinite loops
const selectedIdsString = selectedFiles.map(f => f.fileId).sort().join(','); const allFileIdsKey = allFileIds.join(',');
const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(',');
const filesSignature = selectors.getFilesSignature();
const activeFileIds = useMemo(() => { const activeFileIds = useMemo(() => {
const selectedFileIds = new Set(selectedFiles.map(f => f.fileId)); const selectedFileIds = new Set(state.ui.selectedFileIds);
return allFileIds.filter(id => selectedFileIds.has(id)); return allFileIds.filter(id => {
// Using string representations to prevent infinite loops if (!selectedFileIds.has(id)) return false;
const stub = selectors.getStirlingFileStub(id);
return stub?.name?.toLowerCase().endsWith('.pdf') ?? false;
});
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [allFileIdsString, selectedIdsString]); }, [allFileIdsKey, selectedIdsKey, filesSignature]);
const primaryFileId = activeFileIds[0] ?? null; const primaryFileId = activeFileIds[0] ?? null;
// Stable signature for effects (prevents loops)
const filesSignature = selectors.getFilesSignature();
// UI state // UI state
const globalProcessing = state.ui.isProcessing; const globalProcessing = state.ui.isProcessing;

View File

@ -1,4 +1,4 @@
import React, { useRef, useCallback, useState } from 'react'; import React, { useRef, useCallback, useState, useEffect } from 'react';
import { Menu, Loader, Group, Text, Checkbox, ActionIcon } from '@mantine/core'; import { Menu, Loader, Group, Text, Checkbox, ActionIcon } from '@mantine/core';
import EditNoteIcon from '@mui/icons-material/EditNote'; import EditNoteIcon from '@mui/icons-material/EditNote';
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
@ -12,7 +12,14 @@ import FitText from './FitText';
import { getFileColorWithOpacity } from '../pageEditor/fileColors'; import { getFileColorWithOpacity } from '../pageEditor/fileColors';
import { FileId } from '../../types/file'; import { FileId } from '../../types/file';
import { PageEditorFile } from '../../contexts/PageEditorContext';
// Local interface for PageEditor file display
interface PageEditorFile {
fileId: FileId;
name: string;
versionNumber?: number;
isSelected: boolean;
}
interface FileMenuItemProps { interface FileMenuItemProps {
file: PageEditorFile; file: PageEditorFile;
@ -45,61 +52,79 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
const [isDragOver, setIsDragOver] = useState(false); const [isDragOver, setIsDragOver] = useState(false);
const itemRef = useRef<HTMLDivElement>(null); const itemRef = useRef<HTMLDivElement>(null);
const itemElementRef = useCallback((element: HTMLDivElement | null) => { // Keep latest values without re-registering DnD
if (element) { const indexRef = useRef(index);
itemRef.current = element; const fileIdRef = useRef(file.fileId);
useEffect(() => { indexRef.current = index; }, [index]);
useEffect(() => { fileIdRef.current = file.fileId; }, [file.fileId]);
const dragCleanup = draggable({ // NEW: keep latest onReorder without effect re-run
element, const onReorderRef = useRef(onReorder);
getInitialData: () => ({ useEffect(() => { onReorderRef.current = onReorder; }, [onReorder]);
type: 'file-item',
fileId: file.fileId,
fromIndex: index,
}),
onDragStart: () => {
setIsDragging(true);
},
onDrop: () => {
setIsDragging(false);
},
canDrag: () => true,
});
const dropCleanup = dropTargetForElements({ // Gesture guard for row click vs drag
element, const movedRef = useRef(false);
getData: () => ({ const startRef = useRef<{ x: number; y: number } | null>(null);
type: 'file-item',
fileId: file.fileId, const onPointerDown = (e: React.PointerEvent) => {
toIndex: index, startRef.current = { x: e.clientX, y: e.clientY };
}), movedRef.current = false;
onDragEnter: () => { };
setIsDragOver(true);
}, const onPointerMove = (e: React.PointerEvent) => {
onDragLeave: () => { if (!startRef.current) return;
setIsDragOver(false); const dx = e.clientX - startRef.current.x;
}, const dy = e.clientY - startRef.current.y;
onDrop: ({ source }) => { if (dx * dx + dy * dy > 25) movedRef.current = true; // ~5px threshold
setIsDragOver(false); };
const sourceData = source.data;
if (sourceData.type === 'file-item') { const onPointerUp = () => {
const fromIndex = sourceData.fromIndex as number; startRef.current = null;
if (fromIndex !== index) { };
onReorder(fromIndex, index);
} useEffect(() => {
const element = itemRef.current;
if (!element) return;
const dragCleanup = draggable({
element,
getInitialData: () => ({
type: 'file-item',
fileId: fileIdRef.current,
fromIndex: indexRef.current,
}),
onDragStart: () => setIsDragging((p) => (p ? p : true)),
onDrop: () => setIsDragging((p) => (p ? false : p)),
canDrag: () => true,
});
const dropCleanup = dropTargetForElements({
element,
getData: () => ({
type: 'file-item',
fileId: fileIdRef.current,
toIndex: indexRef.current,
}),
onDragEnter: () => setIsDragOver((p) => (p ? p : true)),
onDragLeave: () => setIsDragOver((p) => (p ? false : p)),
onDrop: ({ source }) => {
setIsDragOver(false);
const sourceData = source.data as any;
if (sourceData?.type === 'file-item') {
const fromIndex = sourceData.fromIndex as number;
const toIndex = indexRef.current;
if (fromIndex !== toIndex) {
onReorderRef.current(fromIndex, toIndex); // use ref, no re-register
} }
} }
});
(element as any).__dragCleanup = () => {
dragCleanup();
dropCleanup();
};
} else {
if (itemRef.current && (itemRef.current as any).__dragCleanup) {
(itemRef.current as any).__dragCleanup();
} }
} });
}, [file.fileId, index, onReorder]);
return () => {
try { dragCleanup(); } catch {}
try { dropCleanup(); } catch {}
};
}, []); // NOTE: no `onReorder` here
const itemName = file?.name || 'Untitled'; const itemName = file?.name || 'Untitled';
const fileColorBorder = getFileColorWithOpacity(colorIndex, 1); const fileColorBorder = getFileColorWithOpacity(colorIndex, 1);
@ -107,9 +132,13 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
return ( return (
<div <div
ref={itemElementRef} ref={itemRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
if (movedRef.current) return; // ignore click after drag
onToggleSelection(file.fileId); onToggleSelection(file.fileId);
}} }}
style={{ style={{

View File

@ -1,6 +1,4 @@
import React, { useState, useCallback, useMemo } 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 { SegmentedControl, Loader } from "@mantine/core";
import { useRainbowThemeContext } from "./RainbowThemeProvider"; import { useRainbowThemeContext } from "./RainbowThemeProvider";
import rainbowStyles from '../../styles/rainbow.module.css'; import rainbowStyles from '../../styles/rainbow.module.css';
@ -11,49 +9,63 @@ import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
import { FileDropdownMenu } from './FileDropdownMenu'; import { FileDropdownMenu } from './FileDropdownMenu';
import { PageEditorFileDropdown } from './PageEditorFileDropdown'; import { PageEditorFileDropdown } from './PageEditorFileDropdown';
import { usePageEditor } from '../../contexts/PageEditorContext'; import { usePageEditor } from '../../contexts/PageEditorContext';
import { useFileState } from '../../contexts/FileContext';
import { FileId } from '../../types/file'; import { FileId } from '../../types/file';
// Local interface for PageEditor file display
interface PageEditorFile {
fileId: FileId;
name: string;
versionNumber?: number;
isSelected: boolean;
}
interface PageEditorState {
files: PageEditorFile[];
selectedCount: number;
totalCount: number;
onToggleSelection: (fileId: FileId) => void;
onReorder: (fromIndex: number, toIndex: number) => void;
fileColorMap: Map<string, number>;
}
// View option styling
const viewOptionStyle: React.CSSProperties = { const viewOptionStyle: React.CSSProperties = {
display: 'inline-flex', display: 'flex',
flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
gap: 6, gap: '0.5rem',
whiteSpace: 'nowrap', justifyContent: 'center',
paddingTop: '0.3rem',
}; };
// Helper function to create view options for SegmentedControl
// Build view options showing text always
const createViewOptions = ( const createViewOptions = (
currentView: WorkbenchType, currentView: WorkbenchType,
switchingTo: WorkbenchType | null, switchingTo: WorkbenchType | null,
activeFiles: Array<{ fileId: string | FileId; name: string; versionNumber?: number }>, activeFiles?: Array<{ fileId: string; name: string; versionNumber?: number }>,
currentFileIndex: number, currentFileIndex?: number,
onFileSelect?: (index: number) => void, onFileSelect?: (index: number) => void,
pageEditorState?: { pageEditorState?: PageEditorState
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]; // Viewer dropdown logic
const isInViewer = currentView === 'viewer'; const isInViewer = currentView === 'viewer';
const fileName = currentFile?.name || ''; const hasActiveFiles = activeFiles && activeFiles.length > 0;
const displayName = isInViewer && fileName ? fileName : 'Viewer'; const showViewerDropdown = isInViewer && hasActiveFiles;
const hasMultipleFiles = activeFiles.length > 1;
const showDropdown = isInViewer && hasMultipleFiles; let viewerDisplayName = 'Viewer';
if (isInViewer && hasActiveFiles && currentFileIndex !== undefined) {
const currentFile = activeFiles[currentFileIndex];
if (currentFile) {
viewerDisplayName = currentFile.name;
}
}
const viewerOption = { const viewerOption = {
label: showDropdown ? ( label: showViewerDropdown ? (
<FileDropdownMenu <FileDropdownMenu
displayName={displayName} displayName={viewerDisplayName}
activeFiles={activeFiles} activeFiles={activeFiles!}
currentFileIndex={currentFileIndex} currentFileIndex={currentFileIndex!}
onFileSelect={onFileSelect} onFileSelect={onFileSelect!}
switchingTo={switchingTo} switchingTo={switchingTo}
viewOptionStyle={viewOptionStyle} viewOptionStyle={viewOptionStyle}
/> />
@ -64,7 +76,7 @@ const createViewOptions = (
) : ( ) : (
<VisibilityIcon fontSize="small" /> <VisibilityIcon fontSize="small" />
)} )}
<span>{displayName}</span> <span>{viewerDisplayName}</span>
</div> </div>
), ),
value: "viewer", value: "viewer",
@ -149,12 +161,71 @@ const TopControls = ({
const { isRainbowMode } = useRainbowThemeContext(); const { isRainbowMode } = useRainbowThemeContext();
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null); const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
// Get page editor state for dropdown // Get FileContext state and PageEditor coordination functions
const { state, selectors } = useFileState();
const pageEditorContext = usePageEditor();
const { const {
files: pageEditorFiles = [],
toggleFileSelection, toggleFileSelection,
reorderFiles: pageEditorReorderFiles, reorderFiles: pageEditorReorderFiles,
} = usePageEditor(); fileOrder: pageEditorFileOrder,
} = pageEditorContext;
// Derive page editor files from PageEditorContext.fileOrder (page editor workspace order)
// Filter to only show PDF files (PageEditor only supports PDFs)
// Use stable string keys to prevent infinite loops
// Cache file objects to prevent infinite re-renders from new object references
const fileOrderKey = pageEditorFileOrder.join(',');
const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(',');
const filesSignature = selectors.getFilesSignature();
const fileObjectsRef = React.useRef(new Map<FileId, PageEditorFile>());
const pageEditorFiles = useMemo<PageEditorFile[]>(() => {
const cache = fileObjectsRef.current;
const newFiles: PageEditorFile[] = [];
// Use PageEditorContext.fileOrder instead of state.files.ids
pageEditorFileOrder.forEach(fileId => {
const stub = selectors.getStirlingFileStub(fileId);
const isSelected = state.ui.selectedFileIds.includes(fileId);
const isPdf = stub?.name?.toLowerCase().endsWith('.pdf') ?? false;
if (!isPdf) return; // Skip non-PDFs
const cached = cache.get(fileId);
// Check if data actually changed (compare by fileId, not position)
if (cached &&
cached.fileId === fileId &&
cached.name === (stub?.name || '') &&
cached.versionNumber === stub?.versionNumber &&
cached.isSelected === isSelected) {
// Reuse existing object reference
newFiles.push(cached);
} else {
// Create new object only if data changed
const newFile: PageEditorFile = {
fileId,
name: stub?.name || '',
versionNumber: stub?.versionNumber,
isSelected,
};
cache.set(fileId, newFile);
newFiles.push(newFile);
}
});
// Clean up removed files from cache
const activeIds = new Set(newFiles.map(f => f.fileId));
for (const cachedId of cache.keys()) {
if (!activeIds.has(cachedId)) {
cache.delete(cachedId);
}
}
return newFiles;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [fileOrderKey, selectedIdsKey, filesSignature, pageEditorFileOrder, state.ui.selectedFileIds, selectors]);
// Convert to counts // Convert to counts
const selectedCount = pageEditorFiles?.filter(f => f.isSelected).length || 0; const selectedCount = pageEditorFiles?.filter(f => f.isSelected).length || 0;

View File

@ -1,15 +1,11 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; import React, { createContext, useContext, useState, useCallback, ReactNode, useMemo } from 'react';
import { FileId } from '../types/file'; import { FileId } from '../types/file';
import { useFileActions } from './FileContext'; import { useFileActions, useFileState } from './FileContext';
import { PDFPage } from '../types/pageEditor'; import { PDFPage } from '../types/pageEditor';
import { MAX_PAGE_EDITOR_FILES } from '../components/pageEditor/fileColors'; import { MAX_PAGE_EDITOR_FILES } from '../components/pageEditor/fileColors';
export interface PageEditorFile { // PageEditorFile is now defined locally in consuming components
fileId: FileId; // Components should derive file list directly from FileContext
name: string;
versionNumber?: number;
isSelected: boolean;
}
/** /**
* Computes file order based on the position of each file's first page * Computes file order based on the position of each file's first page
@ -103,9 +99,6 @@ function reorderPagesForFileMove(
} }
interface PageEditorContextValue { interface PageEditorContextValue {
// Single array of files with selection state
files: PageEditorFile[];
// Current page order (updated by PageEditor, used for file reordering) // Current page order (updated by PageEditor, used for file reordering)
currentPages: PDFPage[] | null; currentPages: PDFPage[] | null;
updateCurrentPages: (pages: PDFPage[] | null) => void; updateCurrentPages: (pages: PDFPage[] | null) => void;
@ -114,45 +107,64 @@ interface PageEditorContextValue {
reorderedPages: PDFPage[] | null; reorderedPages: PDFPage[] | null;
clearReorderedPages: () => void; clearReorderedPages: () => void;
// Set file selection // Page editor's own file order (independent of FileContext global order)
fileOrder: FileId[];
setFileOrder: (order: FileId[]) => void;
// Set file selection (calls FileContext actions)
setFileSelection: (fileId: FileId, selected: boolean) => void; setFileSelection: (fileId: FileId, selected: boolean) => void;
// Toggle file selection // Toggle file selection (calls FileContext actions)
toggleFileSelection: (fileId: FileId) => void; toggleFileSelection: (fileId: FileId) => void;
// Select/deselect all files // Select/deselect all files (calls FileContext actions)
selectAll: () => void; selectAll: () => void;
deselectAll: () => void; deselectAll: () => void;
// Reorder files (simple array reordering) // Reorder files (only affects page editor's local order)
reorderFiles: (fromIndex: number, toIndex: number) => void; reorderFiles: (fromIndex: number, toIndex: number) => void;
// Update file order based on page positions (when pages are manually reordered) // Update file order based on page positions (when pages are manually reordered)
updateFileOrderFromPages: (pages: PDFPage[]) => void; updateFileOrderFromPages: (pages: PDFPage[]) => void;
// Track mutation source to prevent feedback loops
lastReorderSource: 'file' | 'page' | null;
clearReorderSource: () => void;
// Sync with FileContext when files change
syncWithFileContext: (fileContextFiles: Array<{ fileId: FileId; name: string; versionNumber?: number }>) => void;
} }
const PageEditorContext = createContext<PageEditorContextValue | undefined>(undefined); const PageEditorContext = createContext<PageEditorContextValue | undefined>(undefined);
interface PageEditorProviderProps { interface PageEditorProviderProps {
children: ReactNode; children: ReactNode;
initialFileIds?: FileId[];
} }
export function PageEditorProvider({ children, initialFileIds = [] }: PageEditorProviderProps) { export function PageEditorProvider({ children }: PageEditorProviderProps) {
// Single array of files with selection state
const [files, setFiles] = useState<PageEditorFile[]>([]);
const [currentPages, setCurrentPages] = useState<PDFPage[] | null>(null); const [currentPages, setCurrentPages] = useState<PDFPage[] | null>(null);
const [reorderedPages, setReorderedPages] = 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); // Page editor's own file order (independent of FileContext)
const [fileOrder, setFileOrder] = useState<FileId[]>([]);
// Read from FileContext (for file metadata only, not order)
const { actions: fileActions } = useFileActions(); const { actions: fileActions } = useFileActions();
const { state } = useFileState();
// Keep a ref to always read latest state in stable callbacks
const stateRef = React.useRef(state);
React.useEffect(() => {
stateRef.current = state;
}, [state]);
// Initialize fileOrder from FileContext when files change (add/remove only)
React.useEffect(() => {
const currentFileIds = state.files.ids;
// Add new files to the end
const newFileIds = currentFileIds.filter(id => !fileOrder.includes(id));
// Remove deleted files
const validFileOrder = fileOrder.filter(id => currentFileIds.includes(id));
if (newFileIds.length > 0 || validFileOrder.length !== fileOrder.length) {
setFileOrder([...validFileOrder, ...newFileIds]);
}
}, [state.files.ids, fileOrder]);
const updateCurrentPages = useCallback((pages: PDFPage[] | null) => { const updateCurrentPages = useCallback((pages: PDFPage[] | null) => {
setCurrentPages(pages); setCurrentPages(pages);
@ -162,198 +174,132 @@ export function PageEditorProvider({ children, initialFileIds = [] }: PageEditor
setReorderedPages(null); setReorderedPages(null);
}, []); }, []);
const clearReorderSource = useCallback(() => {
setLastReorderSource(null);
}, []);
const setFileSelection = useCallback((fileId: FileId, selected: boolean) => { const setFileSelection = useCallback((fileId: FileId, selected: boolean) => {
setFiles(prev => { const currentSelection = stateRef.current.ui.selectedFileIds;
const selectedCount = prev.filter(f => f.isSelected).length; const isAlreadySelected = currentSelection.includes(fileId);
// Check if we're trying to select when at limit // Check if we're trying to select when at limit
if (selected && selectedCount >= MAX_PAGE_EDITOR_FILES) { if (selected && !isAlreadySelected && currentSelection.length >= MAX_PAGE_EDITOR_FILES) {
const alreadySelected = prev.find(f => f.fileId === fileId)?.isSelected; console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`);
if (!alreadySelected) {
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: selected } : f
);
});
}, []);
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(() => {
setFiles(prev => prev.map(f => ({ ...f, isSelected: false })));
}, []);
const reorderFiles = useCallback((fromIndex: number, toIndex: number) => {
let newFileIds: FileId[] = [];
let reorderedPagesResult: PDFPage[] | null = null;
// Mark that this reorder came from file-level action
setLastReorderSource('file');
lastReorderSourceAtRef.current = Date.now();
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; return;
} }
setLastReorderSource('page'); // Update FileContext selection
lastReorderSourceAtRef.current = Date.now(); const newSelectedIds = selected
? [...currentSelection, fileId]
: currentSelection.filter(id => id !== fileId);
fileActions.setSelectedFiles(newSelectedIds);
}, [fileActions]);
const toggleFileSelection = useCallback((fileId: FileId) => {
const currentSelection = stateRef.current.ui.selectedFileIds;
const isCurrentlySelected = currentSelection.includes(fileId);
// If toggling on and at limit, don't allow
if (!isCurrentlySelected && currentSelection.length >= MAX_PAGE_EDITOR_FILES) {
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Cannot select more files.`);
return;
}
// Update FileContext selection
const newSelectedIds = isCurrentlySelected
? currentSelection.filter(id => id !== fileId)
: [...currentSelection, fileId];
fileActions.setSelectedFiles(newSelectedIds);
}, [fileActions]);
const selectAll = useCallback(() => {
const allFileIds = stateRef.current.files.ids;
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.`);
fileActions.setSelectedFiles(allFileIds.slice(0, MAX_PAGE_EDITOR_FILES));
} else {
fileActions.setSelectedFiles(allFileIds);
}
}, [fileActions]);
const deselectAll = useCallback(() => {
fileActions.setSelectedFiles([]);
}, [fileActions]);
const reorderFiles = useCallback((fromIndex: number, toIndex: number) => {
// Reorder local fileOrder array (page editor workspace only)
const newOrder = [...fileOrder];
const [movedFileId] = newOrder.splice(fromIndex, 1);
newOrder.splice(toIndex, 0, movedFileId);
setFileOrder(newOrder);
// 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 = fileOrder[fromIndex];
const targetFileId = fileOrder[toIndex];
// 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) {
const reorderedPagesResult = reorderPagesForFileMove(currentPages, pageOrderFromIndex, pageOrderToIndex, currentFileOrder);
setReorderedPages(reorderedPagesResult);
}
}
}, [fileOrder, currentPages]);
const updateFileOrderFromPages = useCallback((pages: PDFPage[]) => {
if (!pages || pages.length === 0) return;
// Compute the new file order based on page positions // Compute the new file order based on page positions
const newFileOrder = computeFileOrderFromPages(pages); const newFileOrder = computeFileOrderFromPages(pages);
if (newFileOrder.length > 0) { if (newFileOrder.length > 0) {
// Update global FileContext order // Update local page editor file order (not FileContext)
fileActions.reorderFiles(newFileOrder); setFileOrder(newFileOrder);
} }
}, [fileActions, lastReorderSource]);
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)
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 newFiles.map((f, index) => ({
...f,
isSelected: index < maxToSelect,
}));
}
// Enforce maximum file limit
if (selectedCount > MAX_PAGE_EDITOR_FILES) {
console.warn(`Page editor supports maximum ${MAX_PAGE_EDITOR_FILES} files. Limiting selection.`);
let selectedSoFar = 0;
return newFiles.map(f => ({
...f,
isSelected: f.isSelected && selectedSoFar++ < MAX_PAGE_EDITOR_FILES,
}));
}
return newFiles;
});
}, []); }, []);
const value: PageEditorContextValue = {
files, const value: PageEditorContextValue = useMemo(() => ({
currentPages, currentPages,
updateCurrentPages, updateCurrentPages,
reorderedPages, reorderedPages,
clearReorderedPages, clearReorderedPages,
fileOrder,
setFileOrder,
setFileSelection, setFileSelection,
toggleFileSelection, toggleFileSelection,
selectAll, selectAll,
deselectAll, deselectAll,
reorderFiles, reorderFiles,
updateFileOrderFromPages, updateFileOrderFromPages,
lastReorderSource, }), [
clearReorderSource, currentPages,
syncWithFileContext, updateCurrentPages,
}; reorderedPages,
clearReorderedPages,
fileOrder,
setFileSelection,
toggleFileSelection,
selectAll,
deselectAll,
reorderFiles,
updateFileOrderFromPages,
]);
return ( return (
<PageEditorContext.Provider value={value}> <PageEditorContext.Provider value={value}>

View File

@ -149,17 +149,13 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
// Validate that all IDs exist in current state // Validate that all IDs exist in current state
const validIds = orderedFileIds.filter(id => state.files.byId[id]); const validIds = orderedFileIds.filter(id => state.files.byId[id]);
// Reorder selected files by passed order
const selectedFileIds = orderedFileIds.filter(id => state.ui.selectedFileIds.includes(id)); // Don't touch selectedFileIds - it's just a reference list, order doesn't matter
return { return {
...state, ...state,
files: { files: {
...state.files, ...state.files,
ids: validIds ids: validIds
},
ui: {
...state.ui,
selectedFileIds,
} }
}; };
} }