Compare commits

..
Author SHA1 Message Date
Reece BrowneandGitHub 12fff5b4a7 Merge branch 'main' into mac-print 2026-02-04 17:15:31 +00:00
Reece BrowneandGitHub 03da0e7d68 Merge branch 'main' into mac-print 2026-01-29 20:24:16 +00:00
Reece 685b562d3d Attempt to fix print on mac 2026-01-29 18:05:01 +00:00
21 changed files with 314 additions and 671 deletions
+27
View File
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Enable printing capability -->
<key>com.apple.security.print</key>
<true/>
<!-- Enable network access for connecting to backend -->
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<!-- Enable file access for PDF handling -->
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<!-- Allow execution of bundled JRE -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
</dict>
</plist>
+2 -2
View File
@@ -5,7 +5,7 @@
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:5173",
"devUrl": "http://localhost:5174",
"beforeDevCommand": "npm run dev -- --mode desktop",
"beforeBuildCommand": "npm run build -- --mode desktop"
},
@@ -67,7 +67,7 @@
"macOS": {
"minimumSystemVersion": "10.15",
"signingIdentity": null,
"entitlements": null,
"entitlements": "entitlements.plist",
"providerShortName": null
}
},
@@ -33,11 +33,10 @@ interface DragDropItem {
interface DragDropGridProps<T extends DragDropItem> {
items: T[];
onReorderPages: (sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, activeDragIds: string[], justMoved: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
zoomLevel?: number;
selectedFileIds?: string[];
selectedPageIds?: string[];
onVisibleItemsChange?: (items: T[]) => void;
}
@@ -190,16 +189,17 @@ interface DraggableItemProps<T extends DragDropItem> {
itemRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
boxSelectedPageIds: string[];
clearBoxSelection: () => void;
getBoxSelection: () => string[];
activeId: string | null;
activeDragIds: string[];
justMoved: boolean;
getThumbnailData?: (itemId: string) => { src: string; rotation: number } | null;
onUpdateDropTarget: (itemId: string | null) => void;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, activeDragIds: string[], justMoved: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
renderItem: (item: T, index: number, refs: React.MutableRefObject<Map<string, HTMLDivElement>>, boxSelectedIds: string[], clearBoxSelection: () => void, getBoxSelection: () => string[], activeId: string | null, activeDragIds: string[], justMoved: boolean, isOver: boolean, dragHandleProps?: any, zoomLevel?: number) => React.ReactNode;
zoomLevel: number;
selectedPageIds?: string[];
}
const DraggableItemInner = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
const DraggableItemInner = <T extends DragDropItem>({ item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, getThumbnailData, renderItem, onUpdateDropTarget, zoomLevel }: DraggableItemProps<T>) => {
const isPlaceholder = Boolean(item.isPlaceholder);
const pageNumber = (item as any).pageNumber ?? index + 1;
const { attributes, listeners, setNodeRef: setDraggableRef } = useDraggable({
@@ -248,7 +248,7 @@ const DraggableItemInner = <T extends DragDropItem>({ item, index, itemRefs, box
return (
<>
{renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, activeDragIds, justMoved, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)}
{renderItem(item, index, itemRefs, boxSelectedPageIds, clearBoxSelection, getBoxSelection, activeId, activeDragIds, justMoved, isOver, { ref: setNodeRef, ...attributes, ...listeners }, zoomLevel)}
</>
);
};
@@ -266,22 +266,11 @@ const DraggableItem = React.memo(DraggableItemInner, (prevProps, nextProps) => {
return false; // Props changed, re-render needed
}
// Check if page selection changed (for checkbox selection, not box selection)
const prevSelectedSet = prevProps.selectedPageIds ? new Set(prevProps.selectedPageIds) : null;
const nextSelectedSet = nextProps.selectedPageIds ? new Set(nextProps.selectedPageIds) : null;
if (prevSelectedSet && nextSelectedSet) {
const prevSelected = prevSelectedSet.has(prevProps.item.id);
const nextSelected = nextSelectedSet.has(nextProps.item.id);
if (prevSelected !== nextSelected) {
return false; // Selection state changed for this item, re-render needed
}
}
// Item reference is same, check other props
return (
prevProps.item.id === nextProps.item.id &&
prevProps.index === nextProps.index &&
prevProps.activeId === nextProps.activeId &&
prevProps.justMoved === nextProps.justMoved &&
prevProps.zoomLevel === nextProps.zoomLevel &&
prevProps.activeDragIds.length === nextProps.activeDragIds.length &&
@@ -296,7 +285,6 @@ const DragDropGrid = <T extends DragDropItem>({
getThumbnailData,
zoomLevel = 1.0,
selectedFileIds,
selectedPageIds,
onVisibleItemsChange,
}: DragDropGridProps<T>) => {
const itemRefs = useRef<Map<string, HTMLDivElement>>(new Map());
@@ -306,10 +294,6 @@ const DragDropGrid = <T extends DragDropItem>({
return containerRef.current?.closest('[data-scrolling-container]') as HTMLElement | null;
}, []);
// Create stable signature for items to ensure useMemo detects changes
const itemsSignature = useMemo(() => items.map(item => item.id).join(','), [items]);
const selectedFileIdsSignature = useMemo(() => selectedFileIds?.join(',') || '', [selectedFileIds]);
const { filteredItems: visibleItems, filteredToOriginalIndex } = useMemo(() => {
const filtered: T[] = [];
const indexMap: number[] = [];
@@ -334,7 +318,7 @@ const DragDropGrid = <T extends DragDropItem>({
});
return { filteredItems: filtered, filteredToOriginalIndex: indexMap };
}, [items, selectedFileIds, itemsSignature, selectedFileIdsSignature]);
}, [items, selectedFileIds]);
useEffect(() => {
const visibleIdSet = new Set(visibleItems.map(item => item.id));
@@ -480,11 +464,9 @@ const DragDropGrid = <T extends DragDropItem>({
}, [virtualRows, visibleItems, itemsPerRow, onVisibleItemsChange]);
// Re-measure virtualizer when zoom or items per row changes
// Also remeasure when items change (not just length) to handle item additions/removals
const visibleItemsSignature = useMemo(() => visibleItems.map(item => item.id).join(','), [visibleItems]);
useEffect(() => {
rowVirtualizer.measure();
}, [zoomLevel, itemsPerRow, visibleItems.length, visibleItemsSignature, rowVirtualizer]);
}, [zoomLevel, itemsPerRow, visibleItems.length]);
// Cleanup highlight timeout on unmount
useEffect(() => {
@@ -583,6 +565,11 @@ const DragDropGrid = <T extends DragDropItem>({
setBoxSelectedPageIds([]);
}, []);
// Function to get current box selection (exposed to child components)
const getBoxSelection = useCallback(() => {
return boxSelectedPageIds;
}, [boxSelectedPageIds]);
// Handle drag start
const handleDragStart = useCallback((event: DragStartEvent) => {
const activeId = event.active.id as string;
@@ -804,13 +791,14 @@ const DragDropGrid = <T extends DragDropItem>({
itemRefs={itemRefs}
boxSelectedPageIds={boxSelectedPageIds}
clearBoxSelection={clearBoxSelection}
getBoxSelection={getBoxSelection}
activeId={activeId}
activeDragIds={activeDragIds}
justMoved={justMovedIds.includes(item.id)}
getThumbnailData={getThumbnailData}
onUpdateDropTarget={setHoveredItemId}
renderItem={renderItem}
zoomLevel={zoomLevel}
selectedPageIds={selectedPageIds}
/>
);
})}
@@ -1,7 +1,7 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { Text, Center, Box, LoadingOverlay, Stack } from "@mantine/core";
import { useFileState, useFileActions } from "@app/contexts/FileContext";
import { useNavigationGuard, useNavigationState } from "@app/contexts/NavigationContext";
import { useNavigationGuard } from "@app/contexts/NavigationContext";
import { usePageEditor } from "@app/contexts/PageEditorContext";
import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor";
// Thumbnail generation is now handled by individual PageThumbnail components
@@ -24,7 +24,6 @@ import { usePageSelectionManager } from "@app/components/pageEditor/hooks/usePag
import { usePageEditorCommands } from "@app/components/pageEditor/hooks/useEditorCommands";
import { usePageEditorExport } from "@app/components/pageEditor/hooks/usePageEditorExport";
import { useThumbnailGeneration } from "@app/hooks/useThumbnailGeneration";
import { convertSplitPageIdsToIndexes } from '@app/components/pageEditor/utils/splitPositions';
export interface PageEditorProps {
onFunctionsReady?: (functions: PageEditorFunctions) => void;
@@ -40,7 +39,6 @@ const PageEditor = ({
// Navigation guard for unsaved changes
const { setHasUnsavedChanges } = useNavigationGuard();
const navigationState = useNavigationState();
// Get PageEditor coordination functions
const {
@@ -50,7 +48,6 @@ const PageEditor = ({
clearReorderedPages,
updateCurrentPages,
savePersistedDocument,
clearPersistedDocument,
} = usePageEditor();
const [visiblePageIds, setVisiblePageIds] = useState<string[]>([]);
@@ -178,136 +175,15 @@ const PageEditor = ({
displayDocumentRef.current = displayDocument;
}, [displayDocument]);
const queueThumbnailRequestsForPages = useCallback((pageIds: string[]) => {
const doc = displayDocumentRef.current;
if (!doc || pageIds.length === 0) return;
const loadedCount = doc.pages.filter(p => p.thumbnail).length;
const pending = thumbnailRequestsRef.current.size;
const MAX_CONCURRENT_THUMBNAILS = loadedCount < 8 ? 1
: doc.totalPages < 20 ? 3
: doc.totalPages < 50 ? 5
: 8;
const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending);
if (available === 0) return;
const toLoad: string[] = [];
for (const pageId of pageIds) {
if (toLoad.length >= available) break;
if (thumbnailRequestsRef.current.has(pageId)) continue;
const page = doc.pages.find(p => p.id === pageId);
if (!page || page.thumbnail) continue;
toLoad.push(pageId);
}
if (toLoad.length === 0) return;
toLoad.forEach(pageId => {
const page = doc.pages.find(p => p.id === pageId);
if (!page) return;
const cached = getThumbnailFromCache(pageId);
if (cached) {
thumbnailRequestsRef.current.add(pageId);
Promise.resolve(cached)
.then(cache => {
setEditedDocument(prev => {
if (!prev) return prev;
const pageIndex = prev.pages.findIndex(p => p.id === pageId);
if (pageIndex === -1) return prev;
const updated = [...prev.pages];
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail: cache };
return { ...prev, pages: updated };
});
})
.finally(() => {
thumbnailRequestsRef.current.delete(pageId);
});
return;
}
const fileId = page.originalFileId;
if (!fileId) return;
const file = selectors.getFile(fileId);
if (!file) return;
thumbnailRequestsRef.current.add(pageId);
requestThumbnail(pageId, file, page.originalPageNumber || page.pageNumber)
.then(thumbnail => {
if (thumbnail) {
setEditedDocument(prev => {
if (!prev) return prev;
const pageIndex = prev.pages.findIndex(p => p.id === pageId);
if (pageIndex === -1) return prev;
const updated = [...prev.pages];
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail };
return { ...prev, pages: updated };
});
}
})
.catch((error) => {
console.error('[Thumbnail Loading] Error:', error);
})
.finally(() => {
thumbnailRequestsRef.current.delete(pageId);
});
});
}, [
getThumbnailFromCache,
requestThumbnail,
selectors,
setEditedDocument
]);
useEffect(() => {
if (!displayDocument) {
return;
}
queueThumbnailRequestsForPages(visiblePageIds);
}, [displayDocument, visiblePageIds, queueThumbnailRequestsForPages]);
const lastInitialDocumentSignatureRef = useRef<string | null>(null);
const displayDocumentId = displayDocument?.id ?? null;
const displayDocumentLength = displayDocument?.pages.length ?? 0;
useEffect(() => {
if (!displayDocument || displayDocument.pages.length === 0) {
lastInitialDocumentSignatureRef.current = null;
return;
}
const signature = `${displayDocumentId}:${displayDocumentLength}`;
if (lastInitialDocumentSignatureRef.current === signature) {
return;
}
const INITIAL_VISIBLE_PAGE_COUNT = 8;
const initialIds = displayDocument.pages
.slice(0, INITIAL_VISIBLE_PAGE_COUNT)
.map(page => page.id);
queueThumbnailRequestsForPages(initialIds);
lastInitialDocumentSignatureRef.current = signature;
}, [displayDocumentId, displayDocumentLength, queueThumbnailRequestsForPages]);
useEffect(() => {
setVisiblePageIds([]);
}, [displayDocumentId]);
useEffect(() => {
return () => {
if (navigationState.workbench !== 'pageEditor') {
return;
}
const doc = displayDocumentRef.current;
if (doc && doc.pages.length > 0) {
const signature = doc.pages.map(page => page.id).join(',');
savePersistedDocument(doc, signature);
}
};
}, [savePersistedDocument, navigationState.workbench]);
}, [savePersistedDocument]);
// UI state management
const {
@@ -389,10 +265,94 @@ const PageEditor = ({
exportLoading,
setExportLoading,
setSplitPositions,
clearPersistedDocument,
updateCurrentPages,
});
useEffect(() => {
if (!displayDocument || visiblePageIds.length === 0) {
return;
}
const pending = thumbnailRequestsRef.current.size;
const MAX_CONCURRENT_THUMBNAILS = 12;
const available = Math.max(0, MAX_CONCURRENT_THUMBNAILS - pending);
if (available === 0) {
return;
}
const toLoad: string[] = [];
for (const pageId of visiblePageIds) {
if (toLoad.length >= available) break;
if (thumbnailRequestsRef.current.has(pageId)) continue;
const page = displayDocument.pages.find(p => p.id === pageId);
if (!page || page.thumbnail) continue;
toLoad.push(pageId);
}
if (toLoad.length === 0) return;
toLoad.forEach(pageId => {
const page = displayDocument.pages.find(p => p.id === pageId);
if (!page) return;
const cached = getThumbnailFromCache(pageId);
if (cached) {
thumbnailRequestsRef.current.add(pageId);
Promise.resolve(cached)
.then(cache => {
setEditedDocument(prev => {
if (!prev) return prev;
const pageIndex = prev.pages.findIndex(p => p.id === pageId);
if (pageIndex === -1) return prev;
// Only create new page object for the changed page, reuse rest
const updated = [...prev.pages];
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail: cache };
return { ...prev, pages: updated };
});
})
.finally(() => {
thumbnailRequestsRef.current.delete(pageId);
});
return;
}
const fileId = page.originalFileId;
if (!fileId) return;
const file = selectors.getFile(fileId);
if (!file) return;
thumbnailRequestsRef.current.add(pageId);
requestThumbnail(pageId, file, page.originalPageNumber || page.pageNumber)
.then(thumbnail => {
if (thumbnail) {
setEditedDocument(prev => {
if (!prev) return prev;
const pageIndex = prev.pages.findIndex(p => p.id === pageId);
if (pageIndex === -1) return prev;
// Only create new page object for the changed page, reuse rest
const updated = [...prev.pages];
updated[pageIndex] = { ...prev.pages[pageIndex], thumbnail };
return { ...prev, pages: updated };
});
}
})
.catch((error) => {
console.error('[Thumbnail Loading] Error:', error);
})
.finally(() => {
thumbnailRequestsRef.current.delete(pageId);
});
});
}, [
displayDocument,
visiblePageIds,
selectors,
requestThumbnail,
getThumbnailFromCache,
setEditedDocument,
]);
// Derived values for right rail and usePageEditorRightRailButtons (must be after displayDocument)
const selectedPageCount = selectedPageIds.length;
const activeFileIds = selectedFileIds;
@@ -506,78 +466,6 @@ const PageEditor = ({
// Track color assignments by insertion order (files keep their color)
const fileColorIndexMap = useFileColorMap(orderedFileIds);
// Memoize renderItem to prevent DragDropGrid's React.memo from blocking updates
// when selectedPageIds changes
const renderItemCallback = useCallback((
page: PDFPage,
index: number,
refs: React.MutableRefObject<Map<string, HTMLDivElement>>,
boxSelectedIds: string[],
clearBoxSelection: () => void,
activeDragIds: string[],
justMoved: boolean,
dragHandleProps?: any,
zoomLevelParam?: number
) => {
gridItemRefsRef.current = refs;
const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0;
const isBoxSelected = boxSelectedIds.includes(page.id);
return (
<PageThumbnail
key={page.id}
page={page}
index={index}
totalPages={displayDocument?.pages.length || 0}
fileColorIndex={fileColorIndex}
selectedPageIds={selectedPageIds}
selectionMode={selectionMode}
movingPage={movingPage}
isAnimating={isAnimating}
isBoxSelected={isBoxSelected}
clearBoxSelection={clearBoxSelection}
activeDragIds={activeDragIds}
justMoved={justMoved}
pageRefs={refs}
dragHandleProps={dragHandleProps}
onReorderPages={handleReorderPages}
onTogglePage={togglePage}
onAnimateReorder={animateReorder}
onExecuteCommand={executeCommand}
onSetStatus={() => {}}
onSetMovingPage={setMovingPage}
onDeletePage={handleDeletePage}
createRotateCommand={createRotateCommand}
createDeleteCommand={createDeleteCommand}
createSplitCommand={createSplitCommand}
pdfDocument={displayDocument!}
setPdfDocument={setEditedDocument}
splitPositions={splitPositions}
onInsertFiles={handleInsertFiles}
zoomLevel={zoomLevelParam || zoomLevel}
/>
);
}, [
selectedPageIds,
selectionMode,
movingPage,
isAnimating,
displayDocument,
fileColorIndexMap,
handleReorderPages,
togglePage,
animateReorder,
executeCommand,
setMovingPage,
handleDeletePage,
createRotateCommand,
createDeleteCommand,
createSplitCommand,
setEditedDocument,
splitPositions,
handleInsertFiles,
zoomLevel,
]);
return (
<div
ref={containerRef}
@@ -612,6 +500,7 @@ const PageEditor = ({
{displayDocument && (
<Box ref={gridContainerRef} p={0} pt="2rem" pb="4rem" style={{ position: 'relative' }}>
{/* Split Lines Overlay */}
<div
style={{
@@ -632,9 +521,8 @@ const PageEditor = ({
}
const containerRect = containerEl.getBoundingClientRect();
const splitIndexes = convertSplitPageIdsToIndexes(displayDocument, splitPositions);
return Array.from(splitIndexes).map((position) => {
return Array.from(splitPositions).map((position) => {
const currentPage = displayedPages[position];
if (!currentPage) {
return null;
@@ -689,7 +577,6 @@ const PageEditor = ({
onReorderPages={handleReorderPages}
zoomLevel={zoomLevel}
selectedFileIds={selectedFileIds}
selectedPageIds={selectedPageIds}
onVisibleItemsChange={handleVisibleItemsChange}
getThumbnailData={(pageId) => {
const page = displayDocument.pages.find(p => p.id === pageId);
@@ -699,11 +586,50 @@ const PageEditor = ({
rotation: page.rotation || 0
};
}}
renderItem={renderItemCallback}
renderItem={(page, index, refs, boxSelectedIds, clearBoxSelection, _getBoxSelection, _activeId, activeDragIds, justMoved, _isOver, dragHandleProps, zoomLevel) => {
gridItemRefsRef.current = refs;
const fileColorIndex = page.originalFileId ? fileColorIndexMap.get(page.originalFileId) ?? 0 : 0;
const isBoxSelected = boxSelectedIds.includes(page.id);
return (
<PageThumbnail
key={page.id}
page={page}
index={index}
totalPages={displayDocument.pages.length}
fileColorIndex={fileColorIndex}
selectedPageIds={selectedPageIds}
selectionMode={selectionMode}
movingPage={movingPage}
isAnimating={isAnimating}
isBoxSelected={isBoxSelected}
clearBoxSelection={clearBoxSelection}
activeDragIds={activeDragIds}
justMoved={justMoved}
pageRefs={refs}
dragHandleProps={dragHandleProps}
onReorderPages={handleReorderPages}
onTogglePage={togglePage}
onAnimateReorder={animateReorder}
onExecuteCommand={executeCommand}
onSetStatus={() => {}}
onSetMovingPage={setMovingPage}
onDeletePage={handleDeletePage}
createRotateCommand={createRotateCommand}
createDeleteCommand={createDeleteCommand}
createSplitCommand={createSplitCommand}
pdfDocument={displayDocument}
setPdfDocument={setEditedDocument}
splitPositions={splitPositions}
onInsertFiles={handleInsertFiles}
zoomLevel={zoomLevel}
/>
);
}}
/>
</Box>
)}
<NavigationWarningModal
onApplyAndContinue={async () => {
await applyChanges();
@@ -712,6 +638,7 @@ const PageEditor = ({
await onExportAll();
}}
/>
</div>
);
};
@@ -38,7 +38,7 @@ interface PageEditorControlsProps {
displayDocument?: { pages: { id: string; pageNumber: number }[] };
// Split state (for tooltip logic)
splitPositions?: Set<string>;
splitPositions?: Set<number>;
totalPages?: number;
}
@@ -58,26 +58,31 @@ const PageEditorControls = ({
}: PageEditorControlsProps) => {
// Calculate split tooltip text using smart toggle logic
const getSplitTooltip = () => {
if (!splitPositions || !displayDocument || selectedPageIds.length === 0) {
if (!splitPositions || !totalPages || selectedPageIds.length === 0) {
return "Split Selected";
}
const totalPages = displayDocument.pages.length;
const selectedValidPageIds = displayDocument.pages
.filter((page, index) => selectedPageIds.includes(page.id) && index < totalPages - 1)
.map(page => page.id);
// Convert selected pages to split positions (same logic as handleSplit)
const selectedPageNumbers = displayDocument ? selectedPageIds.map(id => {
const page = displayDocument.pages.find(p => p.id === id);
return page?.pageNumber || 0;
}).filter(num => num > 0) : [];
const selectedSplitPositions = selectedPageNumbers.map(pageNum => pageNum - 1).filter(pos => pos < totalPages - 1);
if (selectedValidPageIds.length === 0) {
if (selectedSplitPositions.length === 0) {
return "Split Selected";
}
const existingSplitsCount = selectedValidPageIds.filter(id => splitPositions.has(id)).length;
const noSplitsCount = selectedValidPageIds.length - existingSplitsCount;
// Smart toggle logic: follow the majority, default to adding splits if equal
const existingSplitsCount = selectedSplitPositions.filter(pos => splitPositions.has(pos)).length;
const noSplitsCount = selectedSplitPositions.length - existingSplitsCount;
// Remove splits only if majority already have splits
// If equal (50/50), default to adding splits
const willRemoveSplits = existingSplitsCount > noSplitsCount;
if (willRemoveSplits) {
return existingSplitsCount === selectedValidPageIds.length
return existingSplitsCount === selectedSplitPositions.length
? "Remove All Selected Splits"
: "Remove Selected Splits";
} else {
@@ -41,10 +41,10 @@ interface PageThumbnailProps {
onDeletePage: (pageNumber: number) => void;
createRotateCommand: (pageIds: string[], rotation: number) => { execute: () => void };
createDeleteCommand: (pageIds: string[]) => { execute: () => void };
createSplitCommand: (pageId: string, pageNumber: number) => { execute: () => void };
createSplitCommand: (position: number) => { execute: () => void };
pdfDocument: PDFDocument;
setPdfDocument: (doc: PDFDocument) => void;
splitPositions: Set<string>;
splitPositions: Set<number>;
onInsertFiles?: (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => void;
zoomLevel?: number;
}
@@ -78,7 +78,6 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
justMoved = false,
}: PageThumbnailProps) => {
const pageIndex = page.pageNumber - 1;
const isSelected = Array.isArray(selectedPageIds) ? selectedPageIds.includes(page.id) : false;
const [isMouseDown, setIsMouseDown] = useState(false);
const [mouseStartPos, setMouseStartPos] = useState<{x: number, y: number} | null>(null);
@@ -156,10 +155,10 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
e.stopPropagation();
// Create a command to toggle split at this position
const command = createSplitCommand(page.id, page.pageNumber);
const command = createSplitCommand(pageIndex);
onExecuteCommand(command);
const hasSplit = splitPositions.has(page.id);
const hasSplit = splitPositions.has(pageIndex);
const action = hasSplit ? 'removed' : 'added';
onSetStatus(`Split marker ${action} after position ${pageIndex + 1}`);
}, [pageIndex, splitPositions, onExecuteCommand, onSetStatus, createSplitCommand]);
@@ -366,7 +365,7 @@ const PageThumbnail: React.FC<PageThumbnailProps> = ({
}}
>
<Checkbox
checked={isSelected}
checked={Array.isArray(selectedPageIds) ? selectedPageIds.includes(page.id) : false}
onChange={() => {
// Selection is handled by container mouseDown
}}
@@ -57,7 +57,7 @@ export class RotatePageCommand extends DOMCommand {
export class DeletePagesCommand extends DOMCommand {
private originalDocument: PDFDocument | null = null;
private originalSplitPositions: Set<string> = new Set();
private originalSplitPositions: Set<number> = new Set();
private originalSelectedPages: number[] = [];
private hasExecuted: boolean = false;
private pageIdsToDelete: string[] = [];
@@ -68,8 +68,8 @@ export class DeletePagesCommand extends DOMCommand {
private getCurrentDocument: () => PDFDocument | null,
private setDocument: (doc: PDFDocument) => void,
private setSelectedPageIds: (pageIds: string[]) => void,
private getSplitPositions: () => Set<string>,
private setSplitPositions: (positions: Set<string>) => void,
private getSplitPositions: () => Set<number>,
private setSplitPositions: (positions: Set<number>) => void,
private getSelectedPages: () => number[],
onAllPagesDeleted?: () => void
) {
@@ -133,15 +133,10 @@ export class DeletePagesCommand extends DOMCommand {
// Adjust split positions
const currentSplitPositions = this.getSplitPositions();
const remainingIndexMap = new Map<string, number>();
remainingPages.forEach((page, index) => {
remainingIndexMap.set(page.id, index);
});
const newPositions = new Set<string>();
currentSplitPositions.forEach((pageId) => {
const splitIndex = remainingIndexMap.get(pageId);
if (splitIndex !== undefined && splitIndex < remainingPages.length - 1) {
newPositions.add(pageId);
const newPositions = new Set<number>();
currentSplitPositions.forEach(pos => {
if (pos < remainingPages.length - 1) {
newPositions.add(pos);
}
});
@@ -266,13 +261,12 @@ export class ReorderPagesCommand extends DOMCommand {
}
export class SplitCommand extends DOMCommand {
private originalSplitPositions: Set<string> = new Set();
private originalSplitPositions: Set<number> = new Set();
constructor(
private pageId: string,
private pageNumber: number,
private getSplitPositions: () => Set<string>,
private setSplitPositions: (positions: Set<string>) => void
private position: number,
private getSplitPositions: () => Set<number>,
private setSplitPositions: (positions: Set<number>) => void
) {
super();
}
@@ -285,10 +279,10 @@ export class SplitCommand extends DOMCommand {
const currentPositions = this.getSplitPositions();
const newPositions = new Set(currentPositions);
if (newPositions.has(this.pageId)) {
newPositions.delete(this.pageId);
if (newPositions.has(this.position)) {
newPositions.delete(this.position);
} else {
newPositions.add(this.pageId);
newPositions.add(this.position);
}
this.setSplitPositions(newPositions);
@@ -301,8 +295,8 @@ export class SplitCommand extends DOMCommand {
get description(): string {
const currentPositions = this.getSplitPositions();
const willAdd = !currentPositions.has(this.pageId);
return `${willAdd ? 'Add' : 'Remove'} split at position ${this.pageNumber}`;
const willAdd = !currentPositions.has(this.position);
return `${willAdd ? 'Add' : 'Remove'} split at position ${this.position + 1}`;
}
}
@@ -78,12 +78,6 @@ export const useEditedDocumentState = ({
}
}, [mergedPdfDocument]);
useEffect(() => {
if (!mergedPdfDocument) {
setEditedDocument(null);
}
}, [mergedPdfDocument, setEditedDocument]);
// Keep editedDocument in sync with out-of-band insert/remove events (e.g. uploads finishing)
useEffect(() => {
const currentEditedDocument = editedDocumentRef.current;
@@ -107,8 +101,6 @@ export const useEditedDocumentState = ({
const sourcePages = mergedPdfDocument.pages;
const sourceIds = new Set(sourcePages.map((p) => p.id));
const prevIds = new Set(prev.pages.map((p) => p.id));
const hasOverlap = sourcePages.some((page) => prevIds.has(page.id));
const shouldResetToMerged = !hasOverlap;
const newPages: PDFPage[] = [];
for (const page of sourcePages) {
@@ -129,9 +121,7 @@ export const useEditedDocumentState = ({
}
}
if (shouldResetToMerged) {
pages = sourcePages.map((page) => ({ ...page }));
} else if (hasAdditions || hasRemovals) {
if (hasAdditions || hasRemovals) {
pages = [...prev.pages];
const placeholderPositions = new Map<FileId, number>();
@@ -204,9 +194,6 @@ export const useEditedDocumentState = ({
});
}
}
if (shouldResetToMerged || hasAdditions || hasRemovals) {
pages = pages.map((page, index) => ({
...page,
pageNumber: index + 1,
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef } from "react";
import { useCallback } from "react";
import {
BulkRotateCommand,
@@ -22,8 +22,8 @@ interface UsePageEditorCommandsParams {
displayDocument: PDFDocument | null;
getEditedDocument: () => PDFDocument | null;
setEditedDocument: React.Dispatch<React.SetStateAction<PDFDocument | null>>;
splitPositions: Set<string>;
setSplitPositions: React.Dispatch<React.SetStateAction<Set<string>>>;
splitPositions: Set<number>;
setSplitPositions: React.Dispatch<React.SetStateAction<Set<number>>>;
selectedPageIds: string[];
setSelectedPageIds: (ids: string[]) => void;
getPageNumbersFromIds: (pageIds: string[]) => number[];
@@ -51,12 +51,6 @@ export const usePageEditorCommands = ({
setSelectionMode,
clearUndoHistory,
}: UsePageEditorCommandsParams) => {
const splitPositionsRef = useRef(splitPositions);
useEffect(() => {
splitPositionsRef.current = splitPositions;
}, [splitPositions]);
const closePdf = useCallback(() => {
actions.clearAllFiles();
clearUndoHistory();
@@ -124,18 +118,17 @@ export const usePageEditorCommands = ({
);
const createSplitCommand = useCallback(
(pageId: string, pageNumber: number) => ({
(position: number) => ({
execute: () => {
const splitCommand = new SplitCommand(
pageId,
pageNumber,
() => splitPositionsRef.current,
position,
() => splitPositions,
setSplitPositions
);
executeCommandWithTracking(splitCommand);
},
}),
[executeCommandWithTracking, setSplitPositions]
[splitPositions, executeCommandWithTracking, setSplitPositions]
);
const executeCommand = useCallback((command: any) => {
@@ -215,34 +208,39 @@ export const usePageEditorCommands = ({
const handleSplit = useCallback(() => {
if (!displayDocument || selectedPageIds.length === 0) return;
const selectedSplitPageIds = displayDocument.pages
.filter((page, index) =>
selectedPageIds.includes(page.id) && index < displayDocument.pages.length - 1
)
.map((page) => page.id);
const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds);
const selectedPositions: number[] = [];
selectedPageNumbers.forEach((pageNum) => {
const pageIndex = displayDocument.pages.findIndex(
(p) => p.pageNumber === pageNum
);
if (pageIndex !== -1 && pageIndex < displayDocument.pages.length - 1) {
selectedPositions.push(pageIndex);
}
});
if (selectedSplitPageIds.length === 0) return;
if (selectedPositions.length === 0) return;
const existingSplitsCount = selectedSplitPageIds.filter((id) =>
splitPositions.has(id)
const existingSplitsCount = selectedPositions.filter((pos) =>
splitPositions.has(pos)
).length;
const noSplitsCount = selectedSplitPageIds.length - existingSplitsCount;
const noSplitsCount = selectedPositions.length - existingSplitsCount;
const shouldRemoveSplits = existingSplitsCount > noSplitsCount;
const newSplitPositions = new Set(splitPositions);
if (shouldRemoveSplits) {
selectedSplitPageIds.forEach((id) => newSplitPositions.delete(id));
selectedPositions.forEach((pos) => newSplitPositions.delete(pos));
} else {
selectedSplitPageIds.forEach((id) => newSplitPositions.add(id));
selectedPositions.forEach((pos) => newSplitPositions.add(pos));
}
const smartSplitCommand = {
execute: () => setSplitPositions(newSplitPositions),
undo: () => setSplitPositions(splitPositions),
description: shouldRemoveSplits
? `Remove ${selectedSplitPageIds.length} split(s)`
: `Add ${selectedSplitPageIds.length - existingSplitsCount} split(s)`,
? `Remove ${selectedPositions.length} split(s)`
: `Add ${selectedPositions.length - existingSplitsCount} split(s)`,
};
executeCommandWithTracking(smartSplitCommand);
@@ -285,36 +283,16 @@ export const usePageEditorCommands = ({
insertAfterPage: number,
isFromStorage?: boolean
) => {
console.log('[PageEditor] handleInsertFiles called:', {
fileCount: files.length,
insertAfterPage,
isFromStorage,
});
const workingDocument = getEditedDocument();
if (!workingDocument || files.length === 0) {
console.log('[PageEditor] handleInsertFiles early return:', {
hasDocument: !!workingDocument,
fileCount: files.length,
});
return;
}
if (!workingDocument || files.length === 0) return;
try {
const targetPage = workingDocument.pages.find(
(p) => p.pageNumber === insertAfterPage
);
if (!targetPage) {
console.log('[PageEditor] Target page not found:', insertAfterPage);
return;
}
if (!targetPage) return;
const insertAfterPageId = targetPage.id;
console.log('[PageEditor] Inserting files after page:', {
pageNumber: insertAfterPage,
pageId: insertAfterPageId,
});
let addedFileIds: FileId[] = [];
if (isFromStorage) {
const stubs = files as StirlingFileStub[];
@@ -329,10 +307,6 @@ export const usePageEditorCommands = ({
insertAfterPageId,
});
addedFileIds = result.map((file) => file.fileId);
console.log('[PageEditor] Files added to context:', {
addedCount: addedFileIds.length,
fileIds: addedFileIds,
});
}
await new Promise((resolve) => setTimeout(resolve, 100));
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { usePageDocument } from '@app/components/pageEditor/hooks/usePageDocument';
import { PDFDocument } from '@app/types/pageEditor';
@@ -9,29 +9,13 @@ import { PDFDocument } from '@app/types/pageEditor';
export function useInitialPageDocument(): PDFDocument | null {
const { document: liveDocument } = usePageDocument();
const [initialDocument, setInitialDocument] = useState<PDFDocument | null>(null);
const lastDocumentIdRef = useRef<string | null>(null);
const liveDocumentId = liveDocument?.id ?? null;
useEffect(() => {
if (!liveDocumentId) {
lastDocumentIdRef.current = null;
setInitialDocument(null);
return;
// Only set once when we get the first non-null document
if (liveDocument && !initialDocument) {
console.log('📄 useInitialPageDocument: Captured initial document with', liveDocument.pages.length, 'pages');
setInitialDocument(liveDocument);
}
if (liveDocumentId !== lastDocumentIdRef.current) {
lastDocumentIdRef.current = liveDocumentId;
setInitialDocument(null);
}
}, [liveDocumentId]);
useEffect(() => {
if (!liveDocument || initialDocument) {
return;
}
console.log('📄 useInitialPageDocument: Captured initial document with', liveDocument.pages.length, 'pages');
setInitialDocument(liveDocument);
}, [liveDocument, initialDocument]);
return initialDocument;
@@ -1,4 +1,4 @@
import { useMemo, useEffect, useRef, useState } from 'react';
import { useMemo, useEffect, useState } from 'react';
import { useFileState } from '@app/contexts/FileContext';
import { usePageEditor } from '@app/contexts/PageEditorContext';
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
@@ -43,11 +43,6 @@ export function usePageDocument(): PageDocumentHook {
return [];
}
const selectedSet = new Set(state.ui.selectedFileIds);
console.log('[usePageDocument] File selection:', {
totalActive: activeFileIds.length,
selectedInContext: selectedSet.size,
selectedActive: activeFileIds.filter((id) => selectedSet.has(id)).length,
});
if (selectedSet.size === 0) {
return [];
}
@@ -64,26 +59,22 @@ export function usePageDocument(): PageDocumentHook {
const processedFilePages = primaryStirlingFileStub?.processedFile?.pages;
const processedFileTotalPages = primaryStirlingFileStub?.processedFile?.totalPages;
const placeholderDocumentRef = useRef<PDFDocument | null>(null);
const [placeholderVersion, setPlaceholderVersion] = useState(0);
const [placeholderDocument, setPlaceholderDocument] = useState<PDFDocument | null>(null);
useEffect(() => {
if (!primaryFileId) {
placeholderDocumentRef.current = null;
setPlaceholderVersion(v => v + 1);
setPlaceholderDocument(null);
return;
}
if (primaryStirlingFileStub?.processedFile) {
placeholderDocumentRef.current = null;
setPlaceholderVersion(v => v + 1);
setPlaceholderDocument(null);
return;
}
const file = selectors.getFile(primaryFileId);
if (!file) {
placeholderDocumentRef.current = null;
setPlaceholderVersion(v => v + 1);
setPlaceholderDocument(null);
return;
}
@@ -104,20 +95,16 @@ export function usePageDocument(): PageDocumentHook {
originalPageNumber: index + 1,
}));
if (!canceled) {
placeholderDocumentRef.current = {
id: `placeholder-${primaryFileId}`,
name: selectors.getStirlingFileStub(primaryFileId)?.name ?? file.name,
file,
pages,
totalPages,
};
setPlaceholderVersion(v => v + 1);
}
setPlaceholderDocument({
id: `placeholder-${primaryFileId}`,
name: selectors.getStirlingFileStub(primaryFileId)?.name ?? file.name,
file,
pages,
totalPages,
});
} catch {
if (!canceled) {
placeholderDocumentRef.current = null;
setPlaceholderVersion(v => v + 1);
setPlaceholderDocument(null);
}
}
};
@@ -135,45 +122,19 @@ export function usePageDocument(): PageDocumentHook {
}, [currentPages]);
const mergedPdfDocument = useMemo((): PDFDocument | null => {
console.log('[usePageDocument] Building document:', {
activeFileIds: activeFileIds.length,
selectedActiveFileIds: selectedActiveFileIds.length,
hasPersistedDoc: !!persistedDocument,
persistedDocPages: persistedDocument?.pages.length,
persistedSig: persistedDocumentSignature?.substring(0, 50),
currentSig: currentPagesSignature.substring(0, 50),
});
if (activeFileIds.length === 0) return null;
if (activeFileIds.length === 0) {
console.log('[usePageDocument] No active files, returning null');
return null;
}
// Check if persisted document is still valid
// Must match signature AND have the same number of source files
const persistedFileIds = persistedDocument
? Array.from(new Set(persistedDocument.pages.map(p => p.originalFileId).filter(Boolean)))
: [];
const persistedIsValid =
if (
persistedDocument &&
persistedDocumentSignature &&
persistedDocumentSignature === currentPagesSignature &&
currentPagesSignature.length > 0 &&
persistedFileIds.length === activeFileIds.length; // Ensure file count matches
if (persistedIsValid) {
console.log('[usePageDocument] Using persisted document');
persistedDocumentSignature === currentPagesSignature &&
currentPagesSignature.length > 0
) {
return persistedDocument;
} else if (persistedDocument) {
console.log('[usePageDocument] Persisted document invalid - rebuilding:', {
sigMatch: persistedDocumentSignature === currentPagesSignature,
persistedFiles: persistedFileIds.length,
activeFiles: activeFileIds.length,
});
}
if (!primaryStirlingFileStub?.processedFile && placeholderDocumentRef.current) {
return placeholderDocumentRef.current;
if (!primaryStirlingFileStub?.processedFile && placeholderDocument) {
return placeholderDocument;
}
const primaryFile = primaryFileId ? selectors.getFile(primaryFileId) : null;
@@ -202,10 +163,6 @@ export function usePageDocument(): PageDocumentHook {
activeFileIds.forEach(fileId => {
const record = selectors.getStirlingFileStub(fileId);
if (record?.insertAfterPageId !== undefined) {
console.log('[usePageDocument] File has insertAfterPageId:', {
fileId,
insertAfterPageId: record.insertAfterPageId,
});
if (!insertionMap.has(record.insertAfterPageId)) {
insertionMap.set(record.insertAfterPageId, []);
}
@@ -215,12 +172,6 @@ export function usePageDocument(): PageDocumentHook {
}
});
console.log('[usePageDocument] File categorization:', {
originalFiles: originalFileIds.length,
filesToInsert: insertionMap.size,
totalActive: activeFileIds.length,
});
// Build pages by interleaving original pages with insertions
let pages: PDFPage[] = [];
@@ -252,15 +203,13 @@ export function usePageDocument(): PageDocumentHook {
if (processedFile?.pages && processedFile.pages.length > 0) {
// Use fully processed pages with thumbnails
filePages = processedFile.pages.map((page, pageIndex) => ({
id: `${fileId}-${pageIndex + 1}`,
id: `${fileId}-${page.pageNumber}`,
pageNumber: startPageNumber + pageIndex,
thumbnail: page.thumbnail || null,
rotation: page.rotation || 0,
selected: false,
splitAfter: page.splitAfter || false,
// Always use pageIndex + 1 for originalPageNumber to ensure correct numbering
// This prevents stale or incorrect page numbers from being cached
originalPageNumber: pageIndex + 1,
originalPageNumber: page.originalPageNumber || page.pageNumber || pageIndex + 1,
originalFileId: fileId,
isPlaceholder: false,
}));
@@ -277,20 +226,6 @@ export function usePageDocument(): PageDocumentHook {
splitAfter: false,
isPlaceholder: false,
}));
} else {
// No processedFile yet - create a single loading placeholder
// This will be replaced when processing completes
filePages = [{
id: `${fileId}-loading`,
pageNumber: startPageNumber,
originalPageNumber: 1,
originalFileId: fileId,
rotation: 0,
thumbnail: null,
selected: false,
splitAfter: false,
isPlaceholder: true,
}];
}
return filePages;
@@ -392,13 +327,12 @@ export function usePageDocument(): PageDocumentHook {
activeFilesSignature,
selectedFileIdsKey,
state.ui.selectedFileIds,
state.files.byId, // Force recompute when any file stub changes (including processedFile updates)
allFileIds,
currentPagesSignature,
currentPages,
persistedDocument,
persistedDocumentSignature,
placeholderVersion,
placeholderDocument,
]);
// Large document detection for smart loading
@@ -8,7 +8,7 @@ import { documentManipulationService } from "@app/services/documentManipulationS
import { pdfExportService } from "@app/services/pdfExportService";
import { exportProcessedDocumentsToFiles } from "@app/services/pdfExportHelpers";
import { FileId } from "@app/types/file";
import { PDFDocument, PDFPage } from "@app/types/pageEditor";
import { PDFDocument } from "@app/types/pageEditor";
type FileActions = ReturnType<typeof useFileActions>["actions"];
type FileSelectors = ReturnType<typeof useFileState>["selectors"];
@@ -16,16 +16,14 @@ type FileSelectors = ReturnType<typeof useFileState>["selectors"];
interface UsePageEditorExportParams {
displayDocument: PDFDocument | null;
selectedPageIds: string[];
splitPositions: Set<string>;
splitPositions: Set<number>;
selectedFileIds: FileId[];
selectors: FileSelectors;
actions: FileActions;
setHasUnsavedChanges: (dirty: boolean) => void;
exportLoading: boolean;
setExportLoading: (loading: boolean) => void;
setSplitPositions: Dispatch<SetStateAction<Set<string>>>;
clearPersistedDocument: () => void;
updateCurrentPages: (pages: PDFPage[] | null) => void;
setSplitPositions: Dispatch<SetStateAction<Set<number>>>;
}
const removePlaceholderPages = (document: PDFDocument): PDFDocument => {
@@ -69,8 +67,6 @@ export const usePageEditorExport = ({
exportLoading,
setExportLoading,
setSplitPositions,
clearPersistedDocument,
updateCurrentPages,
}: UsePageEditorExportParams) => {
const getSourceFiles = useCallback((): Map<FileId, File> | null => {
const sourceFiles = new Map<FileId, File>();
@@ -276,18 +272,6 @@ export const usePageEditorExport = ({
// Store source file IDs before adding new files
const sourceFileIds = [...selectedFileIds];
// Clear all cached page state to prevent stale data from being merged
clearPersistedDocument();
updateCurrentPages(null);
// Deselect old files immediately so the view can reset before we mutate the file list
actions.setSelectedFiles([]);
// Remove the original files before inserting the newly generated versions
if (sourceFileIds.length > 0) {
await actions.removeFiles(sourceFileIds, true);
}
const newStirlingFiles = await actions.addFiles(renamedFiles, {
selectFiles: true,
});
@@ -295,6 +279,11 @@ export const usePageEditorExport = ({
actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId));
}
// Remove source files from context
if (sourceFileIds.length > 0) {
await actions.removeFiles(sourceFileIds, true);
}
setHasUnsavedChanges(false);
setSplitPositions(new Set());
setExportLoading(false);
@@ -311,8 +300,6 @@ export const usePageEditorExport = ({
selectedFileIds,
setHasUnsavedChanges,
setExportLoading,
clearPersistedDocument,
updateCurrentPages,
]);
return {
@@ -11,7 +11,7 @@ export interface PageEditorState {
isAnimating: boolean;
// Split state
splitPositions: Set<string>;
splitPositions: Set<number>;
// Export state
exportLoading: boolean;
@@ -21,7 +21,7 @@ export interface PageEditorState {
setSelectedPageIds: (pages: string[]) => void;
setMovingPage: (pageNumber: number | null) => void;
setIsAnimating: (animating: boolean) => void;
setSplitPositions: React.Dispatch<React.SetStateAction<Set<string>>>;
setSplitPositions: React.Dispatch<React.SetStateAction<Set<number>>>;
setExportLoading: (loading: boolean) => void;
// Helper functions
@@ -44,20 +44,19 @@ export function usePageEditorState(): PageEditorState {
const [isAnimating, setIsAnimating] = useState(false);
// Split state - position-based split tracking (replaces page-based splitAfter)
const [splitPositions, setSplitPositions] = useState<Set<string>>(new Set());
const [splitPositions, setSplitPositions] = useState<Set<number>>(new Set());
// Export state
const [exportLoading, setExportLoading] = useState(false);
// Helper functions
const togglePage = useCallback((pageId: string) => {
setSelectedPageIds(prev => {
const newSelection = prev.includes(pageId)
setSelectedPageIds(prev =>
prev.includes(pageId)
? prev.filter(id => id !== pageId)
: [...prev, pageId];
return newSelection;
});
}, []); // Empty deps - uses updater function so always has latest state
: [...prev, pageId]
);
}, []);
const toggleSelectAll = useCallback((allPageIds: string[]) => {
if (!allPageIds.length) return;
@@ -94,4 +93,4 @@ export function usePageEditorState(): PageEditorState {
toggleSelectAll,
animateReorder,
};
}
}
@@ -1,36 +0,0 @@
import { PDFDocument } from '@app/types/pageEditor';
/**
* Build a map from page ID to its index in the provided document.
*/
export function buildPageIdIndexMap(document: PDFDocument | null): Map<string, number> {
const map = new Map<string, number>();
if (!document) return map;
document.pages.forEach((page, index) => {
map.set(page.id, index);
});
return map;
}
/**
* Convert a set of split page IDs (the page preceding each split) into
* the current index positions inside the document.
*/
export function convertSplitPageIdsToIndexes(document: PDFDocument | null, splitPageIds: Set<string>): Set<number> {
const indexes = new Set<number>();
if (!document || !splitPageIds || splitPageIds.size === 0) {
return indexes;
}
const totalPages = document.pages.length;
document.pages.forEach((page, index) => {
if (index >= totalPages - 1) {
return; // Cannot split after the last page.
}
if (splitPageIds.has(page.id)) {
indexes.add(index);
}
});
return indexes;
}
@@ -3,7 +3,6 @@ import { FileId } from '@app/types/file';
import { useFileActions, useFileState } from '@app/contexts/FileContext';
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
import { MAX_PAGE_EDITOR_FILES } from '@app/components/pageEditor/fileColors';
import { useNavigationState } from '@app/contexts/NavigationContext';
// PageEditorFile is now defined locally in consuming components
// Components should derive file list directly from FileContext
@@ -155,10 +154,8 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) {
}, []);
const clearPersistedDocument = useCallback(() => {
console.log('[PageEditorContext] Clearing persisted document');
setPersistedDocument(null);
setPersistedDocumentSignature(null);
setCurrentPages(null);
}, []);
// Page editor's own file order (independent of FileContext)
@@ -168,89 +165,19 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) {
const { actions: fileActions } = useFileActions();
const { state } = useFileState();
const navigationState = useNavigationState();
const prevWorkbenchRef = useRef(navigationState.workbench);
useEffect(() => {
const prevWorkbench = prevWorkbenchRef.current;
const nextWorkbench = navigationState.workbench;
const isLeavingPageEditor = prevWorkbench === 'pageEditor' && nextWorkbench !== 'pageEditor';
const isEnteringPageEditor = prevWorkbench !== 'pageEditor' && nextWorkbench === 'pageEditor';
if (isLeavingPageEditor) {
clearPersistedDocument();
}
if (isEnteringPageEditor) {
prevFileContextIdsRef.current = state.files.ids;
setReorderedPages(null);
setCurrentPages(null); // Force clear current pages when entering
setFileOrder(currentOrder => {
const validOrder = currentOrder.filter(id => state.files.ids.includes(id));
const newIds = state.files.ids.filter(id => !validOrder.includes(id));
if (newIds.length === 0 && validOrder.length === currentOrder.length) {
return currentOrder;
}
return [...validOrder, ...newIds];
});
clearPersistedDocument();
}
prevWorkbenchRef.current = nextWorkbench;
}, [
navigationState.workbench,
clearPersistedDocument,
state.files.ids,
setFileOrder,
setReorderedPages,
]);
const fileContextSignature = useMemo(() => {
const sig = state.files.ids
return state.files.ids
.map(id => `${id}:${state.files.byId[id]?.versionNumber ?? 0}`)
.join(',');
console.log('[PageEditorContext] File signature computed:', {
fileCount: state.files.ids.length,
signature: sig.substring(0, 100),
});
return sig;
}, [state.files.ids, state.files.byId]);
const prevFileContextSignature = useRef<string | null>(null);
const haveFileIdSetsChanged = (prevIds: FileId[], currentIds: FileId[]) => {
if (prevIds.length !== currentIds.length) {
return true;
}
const prevSet = new Set(prevIds);
for (const id of currentIds) {
if (!prevSet.has(id)) {
return true;
}
}
return false;
};
useEffect(() => {
const currentFileIds = state.files.ids;
const prevFileIds = prevFileContextIdsRef.current;
const idsChanged = haveFileIdSetsChanged(prevFileIds, currentFileIds);
if (!idsChanged && prevFileContextSignature.current === fileContextSignature) {
return;
if (prevFileContextSignature.current !== fileContextSignature) {
prevFileContextSignature.current = fileContextSignature;
clearPersistedDocument();
}
const previousSignature = prevFileContextSignature.current;
prevFileContextSignature.current = fileContextSignature;
if (!idsChanged) {
// Signature changed due to metadata/version updates but file set is unchanged.
return;
}
console.log('[PageEditorContext] File signature changed (IDs/versions changed), clearing persisted document:', {
prev: previousSignature?.substring(0, 50),
current: fileContextSignature.substring(0, 50),
});
clearPersistedDocument();
}, [fileContextSignature, clearPersistedDocument, state.files.ids]);
}, [fileContextSignature, clearPersistedDocument]);
// Keep a ref to always read latest state in stable callbacks
const stateRef = useRef(state);
@@ -267,21 +194,14 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) {
const prevFileIds = prevFileContextIdsRef.current;
// Only react to FileContext changes, not our own fileOrder changes
const fileContextChanged = haveFileIdSetsChanged(prevFileIds, currentFileIds);
const fileContextChanged =
currentFileIds.length !== prevFileIds.length ||
!currentFileIds.every((id, idx) => id === prevFileIds[idx]);
if (!fileContextChanged) {
return;
}
console.log('[PageEditorContext] FileContext files changed:', {
prevCount: prevFileIds.length,
currentCount: currentFileIds.length,
added: currentFileIds.filter(id => !prevFileIds.includes(id)).length,
removed: prevFileIds.filter(id => !currentFileIds.includes(id)).length,
});
clearPersistedDocument();
prevFileContextIdsRef.current = currentFileIds;
// Collect new file IDs outside the setState callback so we can clear them after
@@ -460,10 +380,6 @@ export function PageEditorProvider({ children }: PageEditorProviderProps) {
persistedDocumentSignature,
savePersistedDocument,
clearPersistedDocument,
persistedDocument,
persistedDocumentSignature,
savePersistedDocument,
clearPersistedDocument,
]);
return (
@@ -31,16 +31,14 @@ const scheduleMetadataHydration = (task: () => Promise<void>): void => {
};
const drainHydrationQueue = (): void => {
if (activeHydrations >= HYDRATION_CONCURRENCY) {
return;
}
if (activeHydrations >= HYDRATION_CONCURRENCY) return;
const nextTask = hydrationQueue.shift();
if (!nextTask) return;
activeHydrations++;
nextTask()
.catch((error) => {
console.error('[Hydration] Task failed with error:', error);
.catch(() => {
// Silently handle hydration failures
})
.finally(() => {
activeHydrations--;
@@ -343,8 +341,8 @@ export async function addFiles(
try {
const { generateThumbnailForFile } = await import('@app/utils/thumbnailUtils');
thumbnail = await generateThumbnailForFile(targetFile);
} catch (error) {
console.warn(`[addFiles] Thumbnail generation failed for ${fileId}:`, error);
} catch {
// Silently handle thumbnail generation failures
}
}
@@ -642,6 +640,7 @@ export async function addStirlingFileStubs(
scheduleMetadataHydration(async () => {
const stirlingFile = await fileStorage.getStirlingFile(fileId);
if (!stirlingFile) {
console.warn(`📄 Failed to load StirlingFile for stub: ${stub.name} (${fileId})`);
return;
}
@@ -658,7 +657,6 @@ export async function addStirlingFileStubs(
if (needsProcessing) {
// Regenerate metadata
const processedFileMetadata = await generateProcessedFileMetadata(stirlingFile);
if (processedFileMetadata) {
const updates: Partial<StirlingFileStub> = {
processedFile: processedFileMetadata
@@ -76,16 +76,6 @@ async function processRequestQueue() {
let arrayBuffer = fileArrayBufferCache.get(file);
if (!arrayBuffer) {
arrayBuffer = await file.arrayBuffer();
// Validate ArrayBuffer is not empty before caching
if (!arrayBuffer || arrayBuffer.byteLength === 0) {
console.warn(`Skipping thumbnail generation for ${file.name}: file is empty or not loaded yet`);
// Don't cache empty buffers - allow retry later when file is loaded
// Resolve all requests with null (no thumbnail)
requests.forEach(request => request.resolve(null));
continue;
}
fileArrayBufferCache.set(file, arrayBuffer);
}
@@ -128,10 +118,6 @@ async function processRequestQueue() {
if (requestQueue.length === 0) {
fileArrayBufferCache.clear();
}
// Clean up ArrayBuffer cache when queue is empty
if (requestQueue.length === 0) {
fileArrayBufferCache.clear();
}
}
}
@@ -192,9 +178,6 @@ export function useThumbnailGeneration() {
// Clear ArrayBuffer cache
fileArrayBufferCache.clear();
// Clear ArrayBuffer cache
fileArrayBufferCache.clear();
thumbnailGenerationService.destroy();
}, []);
+3 -14
View File
@@ -10,7 +10,7 @@ import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useLogoPath } from "@app/hooks/useLogoPath";
import { useLogoAssets } from '@app/hooks/useLogoAssets';
import { useFileContext } from "@app/contexts/file/fileHooks";
import { useNavigationState, useNavigationActions } from "@app/contexts/NavigationContext";
import { useNavigationActions } from "@app/contexts/NavigationContext";
import { useViewer } from "@app/contexts/ViewerContext";
import AppsIcon from '@mui/icons-material/AppsRounded';
@@ -54,7 +54,6 @@ export default function HomePage() {
const [configModalOpen, setConfigModalOpen] = useState(false);
const { activeFiles } = useFileContext();
const navigationState = useNavigationState();
const { actions } = useNavigationActions();
const { setActiveFileIndex } = useViewer();
const prevFileCountRef = useRef(activeFiles.length);
@@ -65,11 +64,7 @@ export default function HomePage() {
const prevCount = prevFileCountRef.current;
const currentCount = activeFiles.length;
if (
navigationState.workbench !== 'fileEditor' &&
prevCount === 0 &&
currentCount === 1
) {
if (prevCount === 0 && currentCount === 1) {
// PDF Text Editor handles its own empty state with a dropzone
if (selectedToolKey !== 'pdfTextEditor') {
actions.setWorkbench('viewer');
@@ -78,13 +73,7 @@ export default function HomePage() {
}
prevFileCountRef.current = currentCount;
}, [
activeFiles.length,
actions,
setActiveFileIndex,
selectedToolKey,
navigationState.workbench,
]);
}, [activeFiles.length, actions, setActiveFileIndex, selectedToolKey]);
const brandAltText = t("home.mobile.brandAlt", "Stirling PDF logo");
const brandIconSrc = useLogoPath();
@@ -1,5 +1,4 @@
import { PDFDocument, PDFPage } from '@app/types/pageEditor';
import { convertSplitPageIdsToIndexes } from '@app/components/pageEditor/utils/splitPositions';
/**
* Service for applying DOM changes to PDF document state
@@ -10,7 +9,7 @@ export class DocumentManipulationService {
* Apply all DOM changes (rotations, splits, reordering) to document state
* Returns single document or multiple documents if splits are present
*/
applyDOMChangesToDocument(pdfDocument: PDFDocument, currentDisplayOrder?: PDFDocument, splitPositions?: Set<string>): PDFDocument | PDFDocument[] {
applyDOMChangesToDocument(pdfDocument: PDFDocument, currentDisplayOrder?: PDFDocument, splitPositions?: Set<number>): PDFDocument | PDFDocument[] {
// Use current display order (from React state) if provided, otherwise use original order
const baseDocument = currentDisplayOrder || pdfDocument;
@@ -18,14 +17,10 @@ export class DocumentManipulationService {
let updatedPages = baseDocument.pages.map(page => this.applyPageChanges(page));
// Convert position-based splits to page-based splits for export
const resolvedSplitIndexes = splitPositions && splitPositions.size > 0
? convertSplitPageIdsToIndexes(baseDocument, splitPositions)
: new Set<number>();
if (resolvedSplitIndexes.size > 0) {
if (splitPositions && splitPositions.size > 0) {
updatedPages = updatedPages.map((page, index) => ({
...page,
splitAfter: resolvedSplitIndexes.has(index)
splitAfter: splitPositions.has(index)
}));
}
@@ -36,7 +31,7 @@ export class DocumentManipulationService {
};
// Check for splits and return multiple documents if needed
if (resolvedSplitIndexes.size > 0) {
if (splitPositions && splitPositions.size > 0) {
return this.createSplitDocuments(finalDocument);
}
@@ -167,4 +162,4 @@ export class DocumentManipulationService {
}
// Export singleton instance
export const documentManipulationService = new DocumentManipulationService();
export const documentManipulationService = new DocumentManipulationService();
+1 -1
View File
@@ -76,6 +76,6 @@ export interface PageEditorFunctions {
selectionMode: boolean;
selectedPageIds: string[];
displayDocument?: PDFDocument;
splitPositions: Set<string>;
splitPositions: Set<number>;
totalPages: number;
}
+6 -13
View File
@@ -390,8 +390,13 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b
return { thumbnail, pageCount: 0 };
}
// Skip very large files
if (file.size >= 100 * 1024 * 1024) {
const thumbnail = generatePlaceholderThumbnail(file);
return { thumbnail, pageCount: 1 };
}
const scale = calculateScaleFromFileSize(file.size);
const isVeryLarge = file.size >= 100 * 1024 * 1024; // 100MB threshold
try {
const arrayBuffer = await file.arrayBuffer();
@@ -425,18 +430,6 @@ export async function generateThumbnailWithMetadata(file: File, applyRotation: b
await page.render({ canvasContext: context, viewport, canvas }).promise;
const thumbnail = canvas.toDataURL();
// For very large files, skip reading rotation/dimensions for all pages (just use first page data)
if (isVeryLarge) {
const rotation = page.rotate || 0;
pdfWorkerManager.destroyDocument(pdf);
return {
thumbnail,
pageCount,
pageRotations: [rotation],
pageDimensions: [pageDimensions[0]]
};
}
// Read rotation for all pages
const pageRotations: number[] = [];
for (let i = 1; i <= pageCount; i++) {