From 7a56f0504e6ea13775614fc7f01788c7045d4b4e Mon Sep 17 00:00:00 2001 From: Reece Date: Tue, 21 Oct 2025 17:35:55 +0100 Subject: [PATCH] Refactor file handling to support StirlingFileStubs and improve drag-and-drop functionality --- .../src/components/pageEditor/PageEditor.tsx | 50 ++++---- .../components/pageEditor/PageThumbnail.tsx | 7 +- .../shared/PageEditorFileDropdown.tsx | 119 +++++++++++++----- frontend/src/components/shared/Tooltip.tsx | 2 +- .../tools/overlayPdfs/OverlayPdfsSettings.tsx | 20 ++- frontend/src/contexts/FilesModalContext.tsx | 24 +--- frontend/src/contexts/file/FileReducer.ts | 5 +- 7 files changed, 139 insertions(+), 88 deletions(-) diff --git a/frontend/src/components/pageEditor/PageEditor.tsx b/frontend/src/components/pageEditor/PageEditor.tsx index 79bac508a..2f02315ce 100644 --- a/frontend/src/components/pageEditor/PageEditor.tsx +++ b/frontend/src/components/pageEditor/PageEditor.tsx @@ -4,6 +4,7 @@ import { useFileState, useFileActions } from "../../contexts/FileContext"; import { useNavigationGuard } from "../../contexts/NavigationContext"; import { usePageEditor } from "../../contexts/PageEditorContext"; import { PDFDocument, PDFPage, PageEditorFunctions } from "../../types/pageEditor"; +import { StirlingFileStub } from "../../types/fileContext"; import { pdfExportService } from "../../services/pdfExportService"; import { documentManipulationService } from "../../services/documentManipulationService"; import { exportProcessedDocumentsToFiles } from "../../services/pdfExportHelpers"; @@ -30,7 +31,6 @@ import { usePageDocument } from './hooks/usePageDocument'; import { usePageEditorState } from './hooks/usePageEditorState'; import { parseSelection } from "../../utils/bulkselection/parseSelection"; import { usePageEditorRightRailButtons } from "./pageEditorRightRailButtons"; -import { PageBreakSettingsModal } from './PageBreakSettingsModal'; export interface PageEditorProps { onFunctionsReady?: (functions: PageEditorFunctions) => void; @@ -167,10 +167,6 @@ const PageEditor = ({ const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); - // Page break modal state - const [pageBreakModalOpened, setPageBreakModalOpened] = useState(false); - const [pageBreakModalPageCount, setPageBreakModalPageCount] = useState(0); - // Update undo/redo state const updateUndoRedoState = useCallback(() => { setCanUndo(undoManagerRef.current.canUndo()); @@ -462,22 +458,13 @@ const PageEditor = ({ const handlePageBreak = useCallback(() => { if (!displayDocument || selectedPageIds.length === 0) return; - // Show modal to get page break settings - setPageBreakModalPageCount(selectedPageIds.length); - setPageBreakModalOpened(true); - }, [selectedPageIds, displayDocument]); - - const handlePageBreakConfirm = useCallback((settings: PageBreakSettings) => { - if (!displayDocument || selectedPageIds.length === 0) return; - // Convert selected page IDs to page numbers for the command const selectedPageNumbers = getPageNumbersFromIds(selectedPageIds); const pageBreakCommand = new PageBreakCommand( selectedPageNumbers, () => displayDocument, - setEditedDocument, - settings + setEditedDocument ); executeCommandWithTracking(pageBreakCommand); }, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]); @@ -496,14 +483,30 @@ const PageEditor = ({ executeCommandWithTracking(pageBreakCommand); }, [selectedPageIds, displayDocument, getPageNumbersFromIds, executeCommandWithTracking]); - const handleInsertFiles = useCallback(async (files: File[], insertAfterPage: number) => { + const handleInsertFiles = useCallback(async ( + files: File[] | StirlingFileStub[], + insertAfterPage: number, + isFromStorage?: boolean + ) => { if (!displayDocument || files.length === 0) return; try { const targetPage = displayDocument.pages.find(p => p.pageNumber === insertAfterPage); if (!targetPage) return; - await actions.addFiles(files, { insertAfterPageId: targetPage.id }); + if (isFromStorage) { + // Files from storage - use addStirlingFileStubs to avoid re-storing + await actions.addStirlingFileStubs( + files as StirlingFileStub[], + { insertAfterPageId: targetPage.id, selectFiles: true } + ); + } else { + // New uploaded files - use addFiles + await actions.addFiles( + files as File[], + { insertAfterPageId: targetPage.id, selectFiles: true } + ); + } } catch (error) { console.error('Failed to insert files:', error); } @@ -533,14 +536,9 @@ const PageEditor = ({ const handleReorderPages = useCallback((sourcePageNumber: number, targetIndex: number, selectedPageIds?: string[]) => { if (!displayDocument) return; - console.log('=== HANDLE REORDER PAGES ==='); - console.log('selectedPageIds:', selectedPageIds); - // Convert selectedPageIds to page numbers for the reorder command const selectedPages = selectedPageIds ? getPageNumbersFromIds(selectedPageIds) : undefined; - console.log('selectedPages (converted to numbers):', selectedPages); - const reorderCommand = new ReorderPagesCommand( sourcePageNumber, targetIndex, @@ -994,7 +992,7 @@ const PageEditor = ({ const gridOffset = Math.max(0, (containerWidth - gridWidth) / 2); const leftPosition = gridOffset + col * itemWithGap + ITEM_WIDTH + (ITEM_GAP / 2); - const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05); // Center vertically (5% offset since page is 90% height) + const topPosition = row * ITEM_HEIGHT + (ITEM_HEIGHT * 0.05) + ITEM_GAP; // Center vertically (5% offset since page is 90% height) + gap offset return (
- setPageBreakModalOpened(false)} - onConfirm={handlePageBreakConfirm} - selectedPageCount={pageBreakModalPageCount} - /> ); }; diff --git a/frontend/src/components/pageEditor/PageThumbnail.tsx b/frontend/src/components/pageEditor/PageThumbnail.tsx index 1b51f225c..080a60cf1 100644 --- a/frontend/src/components/pageEditor/PageThumbnail.tsx +++ b/frontend/src/components/pageEditor/PageThumbnail.tsx @@ -14,6 +14,7 @@ import { useFilesModalContext } from '../../contexts/FilesModalContext'; import { getFileColorWithOpacity } from './fileColors'; import styles from './PageEditor.module.css'; import HoverActionMenu, { HoverAction } from '../shared/HoverActionMenu'; +import { StirlingFileStub } from '../../types/fileContext'; interface PageThumbnailProps { @@ -47,7 +48,7 @@ interface PageThumbnailProps { pdfDocument: PDFDocument; setPdfDocument: (doc: PDFDocument) => void; splitPositions: Set; - onInsertFiles?: (files: File[], insertAfterPage: number) => void; + onInsertFiles?: (files: File[] | StirlingFileStub[], insertAfterPage: number, isFromStorage?: boolean) => void; zoomLevel?: number; } @@ -210,9 +211,9 @@ const PageThumbnail: React.FC = ({ // Open file manager modal with custom handler for page insertion openFilesModal({ insertAfterPage: page.pageNumber, - customHandler: (files: File[], insertAfterPage?: number) => { + customHandler: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => { if (insertAfterPage !== undefined) { - onInsertFiles(files, insertAfterPage); + onInsertFiles(files, insertAfterPage, isFromStorage); } } }); diff --git a/frontend/src/components/shared/PageEditorFileDropdown.tsx b/frontend/src/components/shared/PageEditorFileDropdown.tsx index 8038991c5..7eb4b7f72 100644 --- a/frontend/src/components/shared/PageEditorFileDropdown.tsx +++ b/frontend/src/components/shared/PageEditorFileDropdown.tsx @@ -36,13 +36,16 @@ const FileMenuItem: React.FC = ({ }) => { const [isDragging, setIsDragging] = useState(false); const [isDragOver, setIsDragOver] = useState(false); + const [dropPosition, setDropPosition] = useState<'above' | 'below'>('below'); const itemRef = useRef(null); // Keep latest values without re-registering DnD const indexRef = useRef(index); const fileIdRef = useRef(file.fileId); + const dropPositionRef = useRef<'above' | 'below'>('below'); useEffect(() => { indexRef.current = index; }, [index]); useEffect(() => { fileIdRef.current = file.fileId; }, [file.fileId]); + useEffect(() => { dropPositionRef.current = dropPosition; }, [dropPosition]); // NEW: keep latest onReorder without effect re-run const onReorderRef = useRef(onReorder); @@ -92,15 +95,46 @@ const FileMenuItem: React.FC = ({ toIndex: indexRef.current, }), onDragEnter: () => setIsDragOver((p) => (p ? p : true)), - onDragLeave: () => setIsDragOver((p) => (p ? false : p)), + onDragLeave: () => { + setIsDragOver((p) => (p ? false : p)); + setDropPosition('below'); + }, + onDrag: ({ source, self }) => { + // Determine drop position based on cursor location + const element = itemRef.current; + if (!element) return; + + const rect = element.getBoundingClientRect(); + const clientY = (source as any).element?.getBoundingClientRect().top || 0; + const midpoint = rect.top + rect.height / 2; + + setDropPosition(clientY < midpoint ? 'below' : 'above'); + }, onDrop: ({ source }) => { setIsDragOver(false); + const dropPos = dropPositionRef.current; + setDropPosition('below'); const sourceData = source.data as any; if (sourceData?.type === 'file-item') { const fromIndex = sourceData.fromIndex as number; - const toIndex = indexRef.current; + let toIndex = indexRef.current; + + // Adjust toIndex based on drop position + // If dropping below and dragging from above, or dropping above and dragging from below + if (dropPos === 'below' && fromIndex < toIndex) { + // Dragging down, drop after target - no adjustment needed + } else if (dropPos === 'above' && fromIndex > toIndex) { + // Dragging up, drop before target - no adjustment needed + } else if (dropPos === 'below' && fromIndex > toIndex) { + // Dragging up but want below target + toIndex = toIndex + 1; + } else if (dropPos === 'above' && fromIndex < toIndex) { + // Dragging down but want above target + toIndex = toIndex - 1; + } + if (fromIndex !== toIndex) { - onReorderRef.current(fromIndex, toIndex); // use ref, no re-register + onReorderRef.current(fromIndex, toIndex); } } } @@ -118,40 +152,58 @@ const FileMenuItem: React.FC = ({ return (
{ - e.stopPropagation(); - if (movedRef.current) return; // ignore click after drag - onToggleSelection(file.fileId); - }} style={{ - padding: '0.75rem 0.75rem', + position: 'relative', marginBottom: '0.5rem', - cursor: isDragging ? 'grabbing' : 'grab', - backgroundColor: isDragOver ? 'rgba(59, 130, 246, 0.15)' : (file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent'), - borderLeft: `6px solid ${fileColorBorder}`, - borderTop: isDragOver ? '3px solid rgb(59, 130, 246)' : 'none', - borderBottom: isDragOver ? '3px solid rgb(59, 130, 246)' : 'none', - opacity: isDragging ? 0.5 : 1, - transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease, border 0.15s ease', - userSelect: 'none', - }} - onMouseEnter={(e) => { - if (!isDragging) { - (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0, 0, 0, 0.05)'; - (e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover; - } - }} - onMouseLeave={(e) => { - if (!isDragging) { - (e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent'; - (e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder; - } }} > + {/* Drop indicator line */} + {isDragOver && ( +
+ )} +
{ + e.stopPropagation(); + if (movedRef.current) return; // ignore click after drag + onToggleSelection(file.fileId); + }} + style={{ + padding: '0.75rem 0.75rem', + cursor: isDragging ? 'grabbing' : 'grab', + backgroundColor: file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent', + borderLeft: `6px solid ${fileColorBorder}`, + opacity: isDragging ? 0.5 : 1, + transition: 'opacity 0.2s ease-in-out, background-color 0.15s ease', + userSelect: 'none', + }} + onMouseEnter={(e) => { + if (!isDragging) { + (e.currentTarget as HTMLDivElement).style.backgroundColor = 'rgba(0, 0, 0, 0.05)'; + (e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorderHover; + } + }} + onMouseLeave={(e) => { + if (!isDragging) { + (e.currentTarget as HTMLDivElement).style.backgroundColor = file.isSelected ? 'rgba(0, 0, 0, 0.05)' : 'transparent'; + (e.currentTarget as HTMLDivElement).style.borderLeftColor = fileColorBorder; + } + }} + >
= ({ )} +
); }; diff --git a/frontend/src/components/shared/Tooltip.tsx b/frontend/src/components/shared/Tooltip.tsx index 33491fb44..134af5ed0 100644 --- a/frontend/src/components/shared/Tooltip.tsx +++ b/frontend/src/components/shared/Tooltip.tsx @@ -172,7 +172,7 @@ export const Tooltip: React.FC = ({ const related = e.relatedTarget as Node | null; // Moving into the tooltip → keep open - if (related && tooltipRef.current && tooltipRef.current.contains(related)) { + if (related && related instanceof Node && tooltipRef.current && tooltipRef.current.contains(related)) { (children.props as any)?.onPointerLeave?.(e); return; } diff --git a/frontend/src/components/tools/overlayPdfs/OverlayPdfsSettings.tsx b/frontend/src/components/tools/overlayPdfs/OverlayPdfsSettings.tsx index 43ba64db5..1bb70c924 100644 --- a/frontend/src/components/tools/overlayPdfs/OverlayPdfsSettings.tsx +++ b/frontend/src/components/tools/overlayPdfs/OverlayPdfsSettings.tsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'; import { type OverlayPdfsParameters, type OverlayMode } from '../../../hooks/tools/overlayPdfs/useOverlayPdfsParameters'; import LocalIcon from '../../shared/LocalIcon'; import { useFilesModalContext } from '../../../contexts/FilesModalContext'; +import { StirlingFileStub } from '../../../types/fileContext'; +import { fileStorage } from '../../../services/fileStorage'; import styles from './OverlayPdfsSettings.module.css'; interface OverlayPdfsSettingsProps { @@ -36,8 +38,22 @@ export default function OverlayPdfsSettings({ parameters, onParameterChange, dis const handleOpenOverlayFilesModal = () => { if (disabled) return; openFilesModal({ - customHandler: (files: File[]) => { - handleOverlayFilesChange([...(parameters.overlayFiles || []), ...files]); + customHandler: async (files: File[] | StirlingFileStub[], _insertAfterPage?: number, isFromStorage?: boolean) => { + let resolvedFiles: File[] = []; + + if (isFromStorage) { + // Load actual File objects from storage + for (const stub of files as StirlingFileStub[]) { + const stirlingFile = await fileStorage.getStirlingFile(stub.id); + if (stirlingFile) { + resolvedFiles.push(stirlingFile); + } + } + } else { + resolvedFiles = files as File[]; + } + + handleOverlayFilesChange([...(parameters.overlayFiles || []), ...resolvedFiles]); } }); }; diff --git a/frontend/src/contexts/FilesModalContext.tsx b/frontend/src/contexts/FilesModalContext.tsx index e850fa4dd..88bd26e3f 100644 --- a/frontend/src/contexts/FilesModalContext.tsx +++ b/frontend/src/contexts/FilesModalContext.tsx @@ -6,7 +6,7 @@ import { fileStorage } from '../services/fileStorage'; interface FilesModalContextType { isFilesModalOpen: boolean; - openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => void; + openFilesModal: (options?: { insertAfterPage?: number; customHandler?: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => void }) => void; closeFilesModal: () => void; onFileUpload: (files: File[]) => void; onRecentFileSelect: (stirlingFileStubs: StirlingFileStub[]) => void; @@ -22,9 +22,9 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch const [isFilesModalOpen, setIsFilesModalOpen] = useState(false); const [onModalClose, setOnModalClose] = useState<(() => void) | undefined>(); const [insertAfterPage, setInsertAfterPage] = useState(); - const [customHandler, setCustomHandler] = useState<((files: File[], insertAfterPage?: number) => void) | undefined>(); + const [customHandler, setCustomHandler] = useState<((files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => void) | undefined>(); - const openFilesModal = useCallback((options?: { insertAfterPage?: number; customHandler?: (files: File[], insertAfterPage?: number) => void }) => { + const openFilesModal = useCallback((options?: { insertAfterPage?: number; customHandler?: (files: File[] | StirlingFileStub[], insertAfterPage?: number, isFromStorage?: boolean) => void }) => { setInsertAfterPage(options?.insertAfterPage); setCustomHandler(() => options?.customHandler); setIsFilesModalOpen(true); @@ -50,22 +50,8 @@ export const FilesModalProvider: React.FC<{ children: React.ReactNode }> = ({ ch const handleRecentFileSelect = useCallback(async (stirlingFileStubs: StirlingFileStub[]) => { if (customHandler) { - // Load the actual files from storage for custom handler - try { - const loadedFiles: File[] = []; - for (const stub of stirlingFileStubs) { - const stirlingFile = await fileStorage.getStirlingFile(stub.id); - if (stirlingFile) { - loadedFiles.push(stirlingFile); - } - } - - if (loadedFiles.length > 0) { - customHandler(loadedFiles, insertAfterPage); - } - } catch (error) { - console.error('Failed to load files for custom handler:', error); - } + // Pass stubs directly to custom handler with flag indicating they're from storage + customHandler(stirlingFileStubs, insertAfterPage, true); } else { // Normal case - use addStirlingFileStubs to preserve metadata if (actions.addStirlingFileStubs) { diff --git a/frontend/src/contexts/file/FileReducer.ts b/frontend/src/contexts/file/FileReducer.ts index 5fcd26b48..0a01c4ec4 100644 --- a/frontend/src/contexts/file/FileReducer.ts +++ b/frontend/src/contexts/file/FileReducer.ts @@ -82,13 +82,16 @@ export function fileContextReducer(state: FileContextState, action: FileContextA // Only add if not already present (dedupe by stable ID) if (!newById[record.id]) { newIds.push(record.id); - newById[record.id] = record; // Track if any file has an insertion position if (record.insertAfterPageId) { hasInsertionPosition = true; insertAfterPageId = record.insertAfterPageId; } + + // Store record but clear insertAfterPageId (it's only used once) + const { insertAfterPageId: _, ...recordWithoutInsertPosition } = record; + newById[record.id] = recordWithoutInsertPosition; } });