mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 14:19:24 +00:00
feat: Implement file pinning functionality and enhance file context management
This commit is contained in:
parent
9552772587
commit
eed06859b3
@ -1,6 +1,8 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core';
|
import { Group, Box, Text, ActionIcon, Checkbox, Divider } from '@mantine/core';
|
||||||
import DeleteIcon from '@mui/icons-material/Delete';
|
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 { getFileSize, getFileDate } from '../../utils/fileUtils';
|
||||||
import { FileMetadata } from '../../types/file';
|
import { FileMetadata } from '../../types/file';
|
||||||
|
|
||||||
@ -8,8 +10,11 @@ interface FileListItemProps {
|
|||||||
file: FileMetadata;
|
file: FileMetadata;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isSupported: boolean;
|
isSupported: boolean;
|
||||||
|
isPinned?: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
|
onPin?: () => void;
|
||||||
|
onUnpin?: () => void;
|
||||||
onDoubleClick?: () => void;
|
onDoubleClick?: () => void;
|
||||||
isLast?: boolean;
|
isLast?: boolean;
|
||||||
}
|
}
|
||||||
@ -17,9 +22,12 @@ interface FileListItemProps {
|
|||||||
const FileListItem: React.FC<FileListItemProps> = ({
|
const FileListItem: React.FC<FileListItemProps> = ({
|
||||||
file,
|
file,
|
||||||
isSelected,
|
isSelected,
|
||||||
isSupported,
|
isSupported,
|
||||||
|
isPinned = false,
|
||||||
onSelect,
|
onSelect,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onPin,
|
||||||
|
onUnpin,
|
||||||
onDoubleClick
|
onDoubleClick
|
||||||
}) => {
|
}) => {
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
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="sm" fw={500} truncate>{file.name}</Text>
|
||||||
<Text size="xs" c="dimmed">{getFileSize(file)} • {getFileDate(file)}</Text>
|
<Text size="xs" c="dimmed">{getFileSize(file)} • {getFileDate(file)}</Text>
|
||||||
</Box>
|
</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 */}
|
{/* Delete button - fades in/out on hover */}
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box } from '@mantine/core';
|
import { Box } from '@mantine/core';
|
||||||
import { FileWithUrl } from '../../types/file';
|
import { FileWithUrl, FileMetadata } from '../../types/file';
|
||||||
import DocumentThumbnail from './filePreview/DocumentThumbnail';
|
import DocumentThumbnail from './filePreview/DocumentThumbnail';
|
||||||
import DocumentStack from './filePreview/DocumentStack';
|
import DocumentStack from './filePreview/DocumentStack';
|
||||||
import HoverOverlay from './filePreview/HoverOverlay';
|
import HoverOverlay from './filePreview/HoverOverlay';
|
||||||
@ -8,7 +8,7 @@ import NavigationArrows from './filePreview/NavigationArrows';
|
|||||||
|
|
||||||
export interface FilePreviewProps {
|
export interface FilePreviewProps {
|
||||||
// Core file data
|
// Core file data
|
||||||
file: File | FileWithUrl | null;
|
file: File | FileWithUrl | FileMetadata | null;
|
||||||
thumbnail?: string | null;
|
thumbnail?: string | null;
|
||||||
|
|
||||||
// Optional features
|
// Optional features
|
||||||
@ -21,7 +21,7 @@ export interface FilePreviewProps {
|
|||||||
isAnimating?: boolean;
|
isAnimating?: boolean;
|
||||||
|
|
||||||
// Event handlers
|
// Event handlers
|
||||||
onFileClick?: (file: File | FileWithUrl | null) => void;
|
onFileClick?: (file: File | FileWithUrl | FileMetadata | null) => void;
|
||||||
onPrevious?: () => void;
|
onPrevious?: () => void;
|
||||||
onNext?: () => void;
|
onNext?: () => void;
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Center, Image } from '@mantine/core';
|
import { Box, Center, Image } from '@mantine/core';
|
||||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||||
import { FileWithUrl } from '../../../types/file';
|
import { FileWithUrl, FileMetadata } from '../../../types/file';
|
||||||
|
|
||||||
export interface DocumentThumbnailProps {
|
export interface DocumentThumbnailProps {
|
||||||
file: File | FileWithUrl | null;
|
file: File | FileWithUrl | FileMetadata | null;
|
||||||
thumbnail?: string | null;
|
thumbnail?: string | null;
|
||||||
style?: React.CSSProperties;
|
style?: React.CSSProperties;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
@ -88,6 +88,36 @@ export function FileContextProvider({
|
|||||||
return consumeFiles(inputFileIds, outputFiles, stateRef, filesRef, dispatch);
|
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
|
// Complete actions object
|
||||||
const actions = useMemo<FileContextActions>(() => ({
|
const actions = useMemo<FileContextActions>(() => ({
|
||||||
...baseActions,
|
...baseActions,
|
||||||
@ -103,7 +133,9 @@ export function FileContextProvider({
|
|||||||
filesRef.current.clear();
|
filesRef.current.clear();
|
||||||
dispatch({ type: 'RESET_CONTEXT' });
|
dispatch({ type: 'RESET_CONTEXT' });
|
||||||
},
|
},
|
||||||
// Pinned files functionality - isFilePinned available in selectors
|
// Pinned files functionality with File object wrappers
|
||||||
|
pinFile: pinFileWrapper,
|
||||||
|
unpinFile: unpinFileWrapper,
|
||||||
consumeFiles: consumeFilesWrapper,
|
consumeFiles: consumeFilesWrapper,
|
||||||
setHasUnsavedChanges,
|
setHasUnsavedChanges,
|
||||||
trackBlobUrl: lifecycleManager.trackBlobUrl,
|
trackBlobUrl: lifecycleManager.trackBlobUrl,
|
||||||
@ -118,7 +150,9 @@ export function FileContextProvider({
|
|||||||
addStoredFiles,
|
addStoredFiles,
|
||||||
lifecycleManager,
|
lifecycleManager,
|
||||||
setHasUnsavedChanges,
|
setHasUnsavedChanges,
|
||||||
consumeFilesWrapper
|
consumeFilesWrapper,
|
||||||
|
pinFileWrapper,
|
||||||
|
unpinFileWrapper
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Split context values to minimize re-renders
|
// Split context values to minimize re-renders
|
||||||
|
@ -15,6 +15,7 @@ export const initialFileContextState: FileContextState = {
|
|||||||
ids: [],
|
ids: [],
|
||||||
byId: {}
|
byId: {}
|
||||||
},
|
},
|
||||||
|
pinnedFiles: new Set(),
|
||||||
ui: {
|
ui: {
|
||||||
selectedFileIds: [],
|
selectedFileIds: [],
|
||||||
selectedPageNumbers: [],
|
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': {
|
case 'RESET_CONTEXT': {
|
||||||
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
|
// Reset UI state to clean slate (resource cleanup handled by lifecycle manager)
|
||||||
return { ...initialFileContextState };
|
return { ...initialFileContextState };
|
||||||
|
@ -212,6 +212,62 @@ export async function addFiles(
|
|||||||
return addedFiles;
|
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
|
* Action factory functions
|
||||||
*/
|
*/
|
||||||
@ -221,5 +277,7 @@ export const createFileActions = (dispatch: React.Dispatch<FileContextAction>) =
|
|||||||
clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }),
|
clearSelections: () => dispatch({ type: 'CLEAR_SELECTIONS' }),
|
||||||
setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }),
|
setProcessing: (isProcessing: boolean, progress = 0) => dispatch({ type: 'SET_PROCESSING', payload: { isProcessing, progress } }),
|
||||||
setHasUnsavedChanges: (hasChanges: boolean) => dispatch({ type: 'SET_UNSAVED_CHANGES', payload: { hasChanges } }),
|
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' })
|
resetContext: () => dispatch({ type: 'RESET_CONTEXT' })
|
||||||
});
|
});
|
@ -144,14 +144,32 @@ export function useSelectedFiles(): { files: File[]; records: FileRecord[]; file
|
|||||||
* Used by tools for core file context functionality
|
* Used by tools for core file context functionality
|
||||||
*/
|
*/
|
||||||
export function useFileContext() {
|
export function useFileContext() {
|
||||||
|
const { state, selectors } = useFileState();
|
||||||
const { actions } = useFileActions();
|
const { actions } = useFileActions();
|
||||||
|
|
||||||
return useMemo(() => ({
|
return useMemo(() => ({
|
||||||
|
// Lifecycle management
|
||||||
trackBlobUrl: actions.trackBlobUrl,
|
trackBlobUrl: actions.trackBlobUrl,
|
||||||
trackPdfDocument: actions.trackPdfDocument,
|
trackPdfDocument: actions.trackPdfDocument,
|
||||||
scheduleCleanup: actions.scheduleCleanup,
|
scheduleCleanup: actions.scheduleCleanup,
|
||||||
setUnsavedChanges: actions.setHasUnsavedChanges
|
setUnsavedChanges: actions.setHasUnsavedChanges,
|
||||||
}), [actions]);
|
|
||||||
|
// 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]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -45,6 +45,35 @@ export function createFileSelectors(
|
|||||||
.filter(Boolean);
|
.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
|
// Stable signature for effects - prevents unnecessary re-renders
|
||||||
getFilesSignature: () => {
|
getFilesSignature: () => {
|
||||||
return stateRef.current.files.ids
|
return stateRef.current.files.ids
|
||||||
|
@ -104,7 +104,7 @@ export const useToolOperation = <TParams = void>(
|
|||||||
config: ToolOperationConfig<TParams>
|
config: ToolOperationConfig<TParams>
|
||||||
): ToolOperationHook<TParams> => {
|
): ToolOperationHook<TParams> => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles } = useFileContext();
|
const { recordOperation, markOperationApplied, markOperationFailed, addFiles } = useFileContext();
|
||||||
|
|
||||||
// Composed hooks
|
// Composed hooks
|
||||||
const { state, actions } = useToolState();
|
const { state, actions } = useToolState();
|
||||||
@ -198,8 +198,8 @@ export const useToolOperation = <TParams = void>(
|
|||||||
actions.setThumbnails(thumbnails);
|
actions.setThumbnails(thumbnails);
|
||||||
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
|
actions.setDownloadInfo(downloadInfo.url, downloadInfo.filename);
|
||||||
|
|
||||||
// Consume input files and add output files (will replace unpinned inputs)
|
// Add processed files to the file context
|
||||||
await consumeFiles(validFiles, processedFiles);
|
await addFiles(processedFiles);
|
||||||
|
|
||||||
markOperationApplied(fileId, operationId);
|
markOperationApplied(fileId, operationId);
|
||||||
}
|
}
|
||||||
|
@ -17,7 +17,6 @@ import { BaseToolProps } from "../types/tool";
|
|||||||
const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useToolFileSelection();
|
||||||
// const setCurrentMode = (mode) => console.log('Navigate to:', mode); // TODO: Hook up to URL routing
|
|
||||||
|
|
||||||
const [collapsedPermissions, setCollapsedPermissions] = useState(true);
|
const [collapsedPermissions, setCollapsedPermissions] = useState(true);
|
||||||
|
|
||||||
@ -50,7 +49,6 @@ const AddPassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "addPassword");
|
sessionStorage.setItem("previousMode", "addPassword");
|
||||||
setCurrentMode("viewer");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import React, { useEffect, useState } from "react";
|
import React, { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useToolFileSelection } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
@ -25,7 +24,6 @@ import { BaseToolProps } from "../types/tool";
|
|||||||
|
|
||||||
const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setCurrentMode } = useFileContext();
|
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useToolFileSelection();
|
||||||
|
|
||||||
const [collapsedType, setCollapsedType] = useState(false);
|
const [collapsedType, setCollapsedType] = useState(false);
|
||||||
@ -71,13 +69,11 @@ const AddWatermark = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =>
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "watermark");
|
sessionStorage.setItem("previousMode", "watermark");
|
||||||
setCurrentMode("viewer");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
watermarkOperation.resetResults();
|
watermarkOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("watermark");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
@ -1,8 +1,7 @@
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
import { useEndpointEnabled } from "../hooks/useEndpointConfig";
|
||||||
import { useFileContext } from "../contexts/FileContext";
|
import { useToolFileSelection } from "../contexts/FileContext";
|
||||||
import { useToolFileSelection } from "../contexts/FileSelectionContext";
|
|
||||||
|
|
||||||
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
import { createToolFlow } from "../components/tools/shared/createToolFlow";
|
||||||
|
|
||||||
@ -15,7 +14,6 @@ import { BaseToolProps } from "../types/tool";
|
|||||||
|
|
||||||
const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { setCurrentMode } = useFileContext();
|
|
||||||
const { selectedFiles } = useToolFileSelection();
|
const { selectedFiles } = useToolFileSelection();
|
||||||
|
|
||||||
const removePasswordParams = useRemovePasswordParameters();
|
const removePasswordParams = useRemovePasswordParameters();
|
||||||
@ -46,13 +44,11 @@ const RemovePassword = ({ onPreviewFile, onComplete, onError }: BaseToolProps) =
|
|||||||
const handleThumbnailClick = (file: File) => {
|
const handleThumbnailClick = (file: File) => {
|
||||||
onPreviewFile?.(file);
|
onPreviewFile?.(file);
|
||||||
sessionStorage.setItem("previousMode", "removePassword");
|
sessionStorage.setItem("previousMode", "removePassword");
|
||||||
setCurrentMode("viewer");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSettingsReset = () => {
|
const handleSettingsReset = () => {
|
||||||
removePasswordOperation.resetResults();
|
removePasswordOperation.resetResults();
|
||||||
onPreviewFile?.(null);
|
onPreviewFile?.(null);
|
||||||
setCurrentMode("removePassword");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const hasFiles = selectedFiles.length > 0;
|
const hasFiles = selectedFiles.length > 0;
|
||||||
|
@ -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 {
|
export function calculateScaleFromFileSize(fileSize: number): number {
|
||||||
const MB = 1024 * 1024;
|
const MB = 1024 * 1024;
|
||||||
if (fileSize < 1 * MB) return 0.6;
|
if (fileSize < 10 * MB) return 1.0; // Full quality for small files
|
||||||
if (fileSize < 5 * MB) return 0.4;
|
if (fileSize < 50 * MB) return 0.8; // High quality for common file sizes
|
||||||
if (fileSize < 15 * MB) return 0.3;
|
if (fileSize < 200 * MB) return 0.6; // Good quality for typical large files
|
||||||
if (fileSize < 30 * MB) return 0.2;
|
if (fileSize < 500 * MB) return 0.4; // Readable quality for large but manageable files
|
||||||
return 0.15;
|
return 0.3; // Still usable quality, not tiny
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -341,9 +341,21 @@ export async function generateThumbnailForFile(file: File): Promise<string | und
|
|||||||
// Handle PDF files
|
// Handle PDF files
|
||||||
if (file.type.startsWith('application/pdf')) {
|
if (file.type.startsWith('application/pdf')) {
|
||||||
const scale = calculateScaleFromFileSize(file.size);
|
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 {
|
try {
|
||||||
return await generatePdfThumbnail(file, scale);
|
return await generatePDFThumbnail(arrayBuffer, file, scale);
|
||||||
} catch (error) {
|
} 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);
|
return generatePlaceholderThumbnail(file);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -369,15 +381,6 @@ export async function generateThumbnailWithMetadata(file: File): Promise<Thumbna
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scale = calculateScaleFromFileSize(file.size);
|
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 {
|
try {
|
||||||
const arrayBuffer = await file.arrayBuffer();
|
const arrayBuffer = await file.arrayBuffer();
|
||||||
@ -403,18 +406,13 @@ export async function generateThumbnailWithMetadata(file: File): Promise<Thumbna
|
|||||||
return { thumbnail, pageCount };
|
return { thumbnail, pageCount };
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error && error.name === "PasswordException") {
|
||||||
if (error.name === 'InvalidPDFException') {
|
// Handle encrypted PDFs
|
||||||
console.warn(`PDF structure issue for ${file.name} - using fallback thumbnail`);
|
const thumbnail = generateEncryptedPDFThumbnail(file);
|
||||||
// Return a placeholder or try with full file instead of chunk
|
return { thumbnail, pageCount: 1 };
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const thumbnail = generatePlaceholderThumbnail(file);
|
||||||
|
return { thumbnail, pageCount: 1 };
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
x
Reference in New Issue
Block a user