Refactor file handling to support StirlingFileStubs and improve drag-and-drop functionality

This commit is contained in:
Reece 2025-10-21 17:35:55 +01:00
parent f7c9855489
commit 7a56f0504e
7 changed files with 139 additions and 88 deletions

View File

@ -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 (
<div
@ -1084,12 +1082,6 @@ const PageEditor = ({
}}
/>
<PageBreakSettingsModal
opened={pageBreakModalOpened}
onClose={() => setPageBreakModalOpened(false)}
onConfirm={handlePageBreakConfirm}
selectedPageCount={pageBreakModalPageCount}
/>
</Box>
);
};

View File

@ -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<number>;
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<PageThumbnailProps> = ({
// 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);
}
}
});

View File

@ -36,13 +36,16 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
}) => {
const [isDragging, setIsDragging] = useState(false);
const [isDragOver, setIsDragOver] = useState(false);
const [dropPosition, setDropPosition] = useState<'above' | 'below'>('below');
const itemRef = useRef<HTMLDivElement>(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<FileMenuItemProps> = ({
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<FileMenuItemProps> = ({
return (
<div
ref={itemRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onClick={(e) => {
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 && (
<div
style={{
position: 'absolute',
...(dropPosition === 'above' ? { top: '-2px' } : { bottom: '-2px' }),
left: 0,
right: 0,
height: '4px',
backgroundColor: 'rgb(59, 130, 246)',
borderRadius: '2px',
zIndex: 10,
}}
/>
)}
<div
ref={itemRef}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onClick={(e) => {
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;
}
}}
>
<Group gap="xs" style={{ width: '100%' }}>
<div
style={{
@ -178,6 +230,7 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
</Text>
)}
</Group>
</div>
</div>
);
};

View File

@ -172,7 +172,7 @@ export const Tooltip: React.FC<TooltipProps> = ({
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;
}

View File

@ -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]);
}
});
};

View File

@ -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<number | undefined>();
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) {

View File

@ -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;
}
});