feat: Implement file pinning functionality and enhance file context management

This commit is contained in:
Reece Browne 2025-08-19 16:20:38 +01:00
parent 9552772587
commit eed06859b3
13 changed files with 280 additions and 54 deletions

View File

@ -1,6 +1,8 @@
import React, { useState } from 'react';
import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core';
import DeleteIcon from '@mui/icons-material/Delete';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import { getFileSize, getFileDate } from '../../utils/fileUtils';
import { FileMetadata } from '../../types/file';
@ -8,8 +10,11 @@ interface FileListItemProps {
file: FileMetadata;
isSelected: boolean;
isSupported: boolean;
isPinned?: boolean;
onSelect: () => void;
onRemove: () => void;
onPin?: () => void;
onUnpin?: () => void;
onDoubleClick?: () => void;
isLast?: boolean;
}
@ -18,8 +23,11 @@ const FileListItem: React.FC<FileListItemProps> = ({
file,
isSelected,
isSupported,
isPinned = false,
onSelect,
onRemove,
onPin,
onUnpin,
onDoubleClick
}) => {
const [isHovered, setIsHovered] = useState(false);
@ -59,6 +67,36 @@ const FileListItem: React.FC<FileListItemProps> = ({
<Text size="sm" fw={500} truncate>{file.name}</Text>
<Text size="xs" c="dimmed">{getFileSize(file)} {getFileDate(file)}</Text>
</Box>
{/* Pin button - always visible for pinned files, fades in/out on hover for unpinned */}
{(onPin || onUnpin) && (
<ActionIcon
variant="subtle"
c={isPinned ? "blue" : "dimmed"}
size="md"
onClick={(e) => {
e.stopPropagation();
if (isPinned) {
onUnpin?.();
} else {
onPin?.();
}
}}
style={{
opacity: isPinned ? 1 : (isHovered ? 1 : 0),
transform: isPinned ? 'scale(1)' : (isHovered ? 'scale(1)' : 'scale(0.8)'),
transition: 'opacity 0.3s ease, transform 0.3s ease',
pointerEvents: isPinned ? 'auto' : (isHovered ? 'auto' : 'none')
}}
>
{isPinned ? (
<PushPinIcon style={{ fontSize: 18 }} />
) : (
<PushPinOutlinedIcon style={{ fontSize: 18 }} />
)}
</ActionIcon>
)}
{/* Delete button - fades in/out on hover */}
<ActionIcon
variant="subtle"

View File

@ -1,6 +1,6 @@
import React from 'react';
import { Box } from '@mantine/core';
import { FileWithUrl } from '../../types/file';
import { FileWithUrl, FileMetadata } from '../../types/file';
import DocumentThumbnail from './filePreview/DocumentThumbnail';
import DocumentStack from './filePreview/DocumentStack';
import HoverOverlay from './filePreview/HoverOverlay';
@ -8,7 +8,7 @@ import NavigationArrows from './filePreview/NavigationArrows';
export interface FilePreviewProps {
// Core file data
file: File | FileWithUrl | null;
file: File | FileWithUrl | FileMetadata | null;
thumbnail?: string | null;
// Optional features
@ -21,7 +21,7 @@ export interface FilePreviewProps {
isAnimating?: boolean;
// Event handlers
onFileClick?: (file: File | FileWithUrl | null) => void;
onFileClick?: (file: File | FileWithUrl | FileMetadata | null) => void;
onPrevious?: () => void;
onNext?: () => void;
}

View File

@ -1,10 +1,10 @@
import React from 'react';
import { Box, Center, Image } from '@mantine/core';
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
import { FileWithUrl } from '../../../types/file';
import { FileWithUrl, FileMetadata } from '../../../types/file';
export interface DocumentThumbnailProps {
file: File | FileWithUrl | null;
file: File | FileWithUrl | FileMetadata | null;
thumbnail?: string | null;
style?: React.CSSProperties;
onClick?: () => void;

View File

@ -88,6 +88,36 @@ export function FileContextProvider({
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch);
}, []);
// Helper to find FileId from File object
const findFileId = useCallback((file: File): FileId | undefined => {
return Object.keys(stateRef.current.files.byId).find(id => {
const storedFile = filesRef.current.get(id);
return storedFile &&
storedFile.name === file.name &&
storedFile.size === file.size &&
storedFile.lastModified === file.lastModified;
});
}, []);
// File-to-ID wrapper functions for pinning
const pinFileWrapper = useCallback((file: File) => {
const fileId = findFileId(file);
if (fileId) {
baseActions.pinFile(fileId);
} else {
console.warn('File not found for pinning:', file.name);
}
}, [baseActions, findFileId]);
const unpinFileWrapper = useCallback((file: File) => {
const fileId = findFileId(file);
if (fileId) {
baseActions.unpinFile(fileId);
} else {
console.warn('File not found for unpinning:', file.name);
}
}, [baseActions, findFileId]);
// Complete actions object
const actions = useMemo<FileContextActions>(() => ({
...baseActions,
@ -103,7 +133,9 @@ export function FileContextProvider({
filesRef.current.clear();
dispatch({ type: 'RESET_CONTEXT' });
},
// Pinned files functionality - isFilePinned available in selectors
// Pinned files functionality with File object wrappers
pinFile: pinFileWrapper,
unpinFile: unpinFileWrapper,
consumeFiles: consumeFilesWrapper,
setHasUnsavedChanges,
trackBlobUrl: lifecycleManager.trackBlobUrl,
@ -118,7 +150,9 @@ export function FileContextProvider({
addStoredFiles,
lifecycleManager,
setHasUnsavedChanges,
consumeFilesWrapper
consumeFilesWrapper,
pinFileWrapper,
unpinFileWrapper
]);
// Split context values to minimize re-renders

View File

@ -15,6 +15,7 @@ export const initialFileContextState: FileContextState = {
ids: [],
byId: {}
},
pinnedFiles: new Set(),
ui: {
selectedFileIds: [],
selectedPageNumbers: [],
@ -153,6 +154,66 @@ export function fileContextReducer(state: FileContextState, action: FileContextA
};
}
case 'PIN_FILE': {
const { fileId } = action.payload;
const newPinnedFiles = new Set(state.pinnedFiles);
newPinnedFiles.add(fileId);
return {
...state,
pinnedFiles: newPinnedFiles
};
}
case 'UNPIN_FILE': {
const { fileId } = action.payload;
const newPinnedFiles = new Set(state.pinnedFiles);
newPinnedFiles.delete(fileId);
return {
...state,
pinnedFiles: newPinnedFiles
};
}
case 'CONSUME_FILES': {
const { inputFileIds, outputFileRecords } = action.payload;
// Only remove unpinned input files
const unpinnedInputIds = inputFileIds.filter(id => !state.pinnedFiles.has(id));
const remainingIds = state.files.ids.filter(id => !unpinnedInputIds.includes(id));
// Remove unpinned files from state
const newById = { ...state.files.byId };
unpinnedInputIds.forEach(id => {
delete newById[id];
});
// Add output files
const outputIds: FileId[] = [];
outputFileRecords.forEach(record => {
if (!newById[record.id]) {
outputIds.push(record.id);
newById[record.id] = record;
}
});
// Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(id => !unpinnedInputIds.includes(id));
return {
...state,
files: {
ids: [...remainingIds, ...outputIds],
byId: newById
},
ui: {
...state.ui,
selectedFileIds: validSelectedFileIds
}
};
}
case 'RESET_CONTEXT': {
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
return { ...initialFileContextState };

View File

@ -212,6 +212,62 @@ export async function addFiles(
return addedFiles;
}
/**
* Consume files helper - replace unpinned input files with output files
*/
export async function consumeFiles(
inputFileIds: FileId[],
outputFiles: File[],
stateRef: React.MutableRefObject<FileContextState>,
filesRef: React.MutableRefObject<Map<FileId, File>>,
dispatch: React.Dispatch<FileContextAction>
): Promise<void> {
if (DEBUG) console.log(`📄 consumeFiles: Processing ${inputFileIds.length} input files, ${outputFiles.length} output files`);
// Process output files through the 'processed' path to generate thumbnails
const outputFileRecords = await Promise.all(
outputFiles.map(async (file) => {
const fileId = createFileId();
filesRef.current.set(fileId, file);
// Generate thumbnail and page count for output file
let thumbnail: string | undefined;
let pageCount: number = 1;
try {
if (DEBUG) console.log(`📄 consumeFiles: Generating thumbnail for output file ${file.name}`);
const result = await generateThumbnailWithMetadata(file);
thumbnail = result.thumbnail;
pageCount = result.pageCount;
} catch (error) {
if (DEBUG) console.warn(`📄 consumeFiles: Failed to generate thumbnail for output file ${file.name}:`, error);
}
const record = toFileRecord(file, fileId);
if (thumbnail) {
record.thumbnailUrl = thumbnail;
}
if (pageCount > 0) {
record.processedFile = createProcessedFile(pageCount, thumbnail);
}
return record;
})
);
// Dispatch the consume action
dispatch({
type: 'CONSUME_FILES',
payload: {
inputFileIds,
outputFileRecords
}
});
if (DEBUG) console.log(`📄 consumeFiles: Successfully consumed files - removed ${inputFileIds.length} inputs, added ${outputFileRecords.length} outputs`);
}
/**
* Action factory functions
*/
@ -221,5 +277,7 @@ export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) =
clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }),
setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }),
setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }),
pinFile: (fileId: FileId) => dispatch({ type: 'PIN_FILE', payload: { fileId } }),
unpinFile: (fileId: FileId) => dispatch({ type: 'UNPIN_FILE', payload: { fileId } }),
resetContext: () => dispatch({ type: 'RESET_CONTEXT' })
});

View File

@ -144,14 +144,32 @@ export function useSelectedFiles(): { files: File[]; records: FileRecord[]; file
* Used by tools for core file context functionality
*/
export function useFileContext() {
const { state, selectors } = useFileState();
const { actions } = useFileActions();
return useMemo(() => ({
// Lifecycle management
trackBlobUrl: actions.trackBlobUrl,
trackPdfDocument: actions.trackPdfDocument,
scheduleCleanup: actions.scheduleCleanup,
setUnsavedChanges: actions.setHasUnsavedChanges
}), [actions]);
setUnsavedChanges: actions.setHasUnsavedChanges,
// File management
addFiles: actions.addFiles,
consumeFiles: actions.consumeFiles,
recordOperation: (fileId: string, operation: any) => {}, // TODO: Implement operation tracking
markOperationApplied: (fileId: string, operationId: string) => {}, // TODO: Implement operation tracking
markOperationFailed: (fileId: string, operationId: string, error: string) => {}, // TODO: Implement operation tracking
// Pinned files
pinnedFiles: state.pinnedFiles,
pinFile: actions.pinFile,
unpinFile: actions.unpinFile,
isFilePinned: selectors.isFilePinned,
// Active files
activeFiles: selectors.getFiles()
}), [state, selectors, actions]);
}
/**

View File

@ -45,6 +45,35 @@ export function createFileSelectors(
.filter(Boolean);
},
// Pinned files selectors
getPinnedFileIds: () => {
return Array.from(stateRef.current.pinnedFiles);
},
getPinnedFiles: () => {
return Array.from(stateRef.current.pinnedFiles)
.map(id => filesRef.current.get(id))
.filter(Boolean) as File[];
},
getPinnedFileRecords: () => {
return Array.from(stateRef.current.pinnedFiles)
.map(id => stateRef.current.files.byId[id])
.filter(Boolean);
},
isFilePinned: (file: File) => {
// Find FileId by matching File object properties
const fileId = Object.keys(stateRef.current.files.byId).find(id => {
const storedFile = filesRef.current.get(id);
return storedFile &&
storedFile.name === file.name &&
storedFile.size === file.size &&
storedFile.lastModified === file.lastModified;
});
return fileId ? stateRef.current.pinnedFiles.has(fileId) : false;
},
// Stable signature for effects - prevents unnecessary re-renders
getFilesSignature: () => {
return stateRef.current.files.ids

View File

@ -104,7 +104,7 @@ export const useToolOperation = <TParams = void>(
config: ToolOperationConfig<TParams>
): ToolOperationHook<TParams> => {
const { t } = useTranslation();
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles } = useFileContext();
const { recordOperation, markOperationApplied, markOperationFailed, addFiles } = useFileContext();
// Composed hooks
const { state, actions } = useToolState();
@ -198,8 +198,8 @@ export const useToolOperation = <TParams = void>(
actions.setThumbnails(thumbnails);
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
// Consume input files and add output files (will replace unpinned inputs)
await consumeFiles(validFiles, processedFiles);
// Add processed files to the file context
await addFiles(processedFiles);
markOperationApplied(fileId, operationId);
}

View File

@ -17,7 +17,6 @@ import { BaseToolProps } from "../types/tool";
const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { selectedFiles } = useToolFileSelection();
// const setCurrentMode = (mode) => console.log('Navigate to:', mode); // TODO: Hook up to URL routing
const [collapsedPermissions, setCollapsedPermissions] = useState(true);
@ -50,7 +49,6 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "addPassword");
setCurrentMode("viewer");
};
const handleSettingsReset = () => {

View File

@ -1,8 +1,7 @@
import React, { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import { useToolFileSelection } from "../contexts/FileContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
@ -25,7 +24,6 @@ import { BaseToolProps } from "../types/tool";
const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { setCurrentMode } = useFileContext();
const { selectedFiles } = useToolFileSelection();
const [collapsedType, setCollapsedType] = useState(false);
@ -71,13 +69,11 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =>
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "watermark");
setCurrentMode("viewer");
};
const handleSettingsReset = () => {
watermarkOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode("watermark");
};
const hasFiles = selectedFiles.length > 0;

View File

@ -1,8 +1,7 @@
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
import { useFileContext } from "../contexts/FileContext";
import { useToolFileSelection } from "../contexts/FileSelectionContext";
import { useToolFileSelection } from "../contexts/FileContext";
import { createToolFlow } from "../components/tools/shared/createToolFlow";
@ -15,7 +14,6 @@ import { BaseToolProps } from "../types/tool";
const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const { t } = useTranslation();
const { setCurrentMode } = useFileContext();
const { selectedFiles } = useToolFileSelection();
const removePasswordParams = useRemovePasswordParameters();
@ -46,13 +44,11 @@ const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
const handleThumbnailClick = (file: File) => {
onPreviewFile?.(file);
sessionStorage.setItem("previousMode", "removePassword");
setCurrentMode("viewer");
};
const handleSettingsReset = () => {
removePasswordOperation.resetResults();
onPreviewFile?.(null);
setCurrentMode("removePassword");
};
const hasFiles = selectedFiles.length > 0;

View File

@ -16,15 +16,15 @@ interface ColorScheme {
}
/**
* Calculate thumbnail scale based on file size
* Calculate thumbnail scale based on file size (modern 2024 scaling)
*/
export function calculateScaleFromFileSize(fileSize: number): number {
const MB = 1024 * 1024;
if (fileSize < 1 * MB) return 0.6;
if (fileSize < 5 * MB) return 0.4;
if (fileSize < 15 * MB) return 0.3;
if (fileSize < 30 * MB) return 0.2;
return 0.15;
if (fileSize < 10 * MB) return 1.0; // Full quality for small files
if (fileSize < 50 * MB) return 0.8; // High quality for common file sizes
if (fileSize < 200 * MB) return 0.6; // Good quality for typical large files
if (fileSize < 500 * MB) return 0.4; // Readable quality for large but manageable files
return 0.3; // Still usable quality, not tiny
}
/**
@ -341,9 +341,21 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
// Handle PDF files
if (file.type.startsWith('application/pdf')) {
const scale = calculateScaleFromFileSize(file.size);
// Only read first 2MB for thumbnail generation to save memory
const chunkSize = 2 * 1024 * 1024; // 2MB
const chunk = file.slice(0, Math.min(chunkSize, file.size));
const arrayBuffer = await chunk.arrayBuffer();
try {
return await generatePdfThumbnail(file, scale);
return await generatePDFThumbnail(arrayBuffer, file, scale);
} catch (error) {
if (error instanceof Error && error.name === 'InvalidPDFException') {
console.warn(`PDF structure issue for ${file.name} - using fallback thumbnail`);
// Try with full file instead of chunk
const fullArrayBuffer = await file.arrayBuffer();
return await generatePDFThumbnail(fullArrayBuffer, file, scale);
}
return generatePlaceholderThumbnail(file);
}
}
@ -369,15 +381,6 @@ export async function generateThumbnailWithMetadata(file: File): Promise<Thumbna
}
const scale = calculateScaleFromFileSize(file.size);
console.log(`Using scale ${scale} for ${file.name} (${(file.size / 1024 / 1024).toFixed(1)}MB)`);
// Only read first 2MB for thumbnail generation to save memory
const chunkSize = 2 * 1024 * 1024; // 2MB
const chunk = file.slice(0, Math.min(chunkSize, file.size));
const arrayBuffer = await chunk.arrayBuffer();
try {
return await generatePDFThumbnail(arrayBuffer, file, scale);
try {
const arrayBuffer = await file.arrayBuffer();
@ -403,18 +406,13 @@ export async function generateThumbnailWithMetadata(file: File): Promise<Thumbna
return { thumbnail, pageCount };
} catch (error) {
if (error instanceof Error) {
if (error.name === 'InvalidPDFException') {
console.warn(`PDF structure issue for ${file.name} - using fallback thumbnail`);
// Return a placeholder or try with full file instead of chunk
const fullArrayBuffer = await file.arrayBuffer();
return await generatePDFThumbnail(fullArrayBuffer, file, scale);
} else {
console.warn('Unknown error thrown. Failed to generate thumbnail for', file.name, error);
return undefined;
}
} else {
throw error; // Re-throw non-Error exceptions
if (error instanceof Error && error.name === "PasswordException") {
// Handle encrypted PDFs
const thumbnail = generateEncryptedPDFThumbnail(file);
return { thumbnail, pageCount: 1 };
}
const thumbnail = generatePlaceholderThumbnail(file);
return { thumbnail, pageCount: 1 };
}
}