File Pinning

This commit is contained in:
Connor Yoh 2025-08-12 17:53:21 +01:00
parent 63c4d98fda
commit c22ec2037d
11 changed files with 204 additions and 23 deletions

View File

@ -4,9 +4,12 @@ import { useTranslation } from 'react-i18next';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import VisibilityIcon from '@mui/icons-material/Visibility'; import VisibilityIcon from '@mui/icons-material/Visibility';
import HistoryIcon from '@mui/icons-material/History'; import HistoryIcon from '@mui/icons-material/History';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator'; import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import styles from './PageEditor.module.css'; import styles from './PageEditor.module.css';
import FileOperationHistory from '../history/FileOperationHistory'; import FileOperationHistory from '../history/FileOperationHistory';
import { useFileContext } from '../../contexts/FileContext';
interface FileItem { interface FileItem {
id: string; id: string;
@ -66,6 +69,10 @@ const FileThumbnail = ({
}: FileThumbnailProps) => { }: FileThumbnailProps) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [showHistory, setShowHistory] = useState(false); const [showHistory, setShowHistory] = useState(false);
const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
// Find the actual File object that corresponds to this FileItem
const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size);
const formatFileSize = (bytes: number) => { const formatFileSize = (bytes: number) => {
if (bytes === 0) return '0 B'; if (bytes === 0) return '0 B';
@ -301,6 +308,32 @@ const FileThumbnail = ({
</ActionIcon> </ActionIcon>
</Tooltip> </Tooltip>
{actualFile && (
<Tooltip label={isFilePinned(actualFile) ? "Unpin File" : "Pin File"}>
<ActionIcon
size="md"
variant="subtle"
c={isFilePinned(actualFile) ? "yellow" : "white"}
onClick={(e) => {
e.stopPropagation();
if (isFilePinned(actualFile)) {
unpinFile(actualFile);
onSetStatus(`Unpinned ${file.name}`);
} else {
pinFile(actualFile);
onSetStatus(`Pinned ${file.name}`);
}
}}
>
{isFilePinned(actualFile) ? (
<PushPinIcon style={{ fontSize: 20 }} />
) : (
<PushPinOutlinedIcon style={{ fontSize: 20 }} />
)}
</ActionIcon>
</Tooltip>
)}
<Tooltip label="Close File"> <Tooltip label="Close File">
<ActionIcon <ActionIcon
size="md" size="md"

View File

@ -5,10 +5,13 @@ import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
import StorageIcon from "@mui/icons-material/Storage"; import StorageIcon from "@mui/icons-material/Storage";
import VisibilityIcon from "@mui/icons-material/Visibility"; import VisibilityIcon from "@mui/icons-material/Visibility";
import EditIcon from "@mui/icons-material/Edit"; import EditIcon from "@mui/icons-material/Edit";
import PushPinIcon from "@mui/icons-material/PushPin";
import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
import { FileWithUrl } from "../../types/file"; import { FileWithUrl } from "../../types/file";
import { getFileSize, getFileDate } from "../../utils/fileUtils"; import { getFileSize, getFileDate } from "../../utils/fileUtils";
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail"; import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
import { useFileContext } from "../../contexts/FileContext";
interface FileCardProps { interface FileCardProps {
file: FileWithUrl; file: FileWithUrl;
@ -25,6 +28,9 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
const { t } = useTranslation(); const { t } = useTranslation();
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file); const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
const [isHovered, setIsHovered] = useState(false); const [isHovered, setIsHovered] = useState(false);
const { pinFile, unpinFile, isFilePinned } = useFileContext();
const isPinned = isFilePinned(file as File);
return ( return (
<Card <Card
@ -64,8 +70,25 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
position: 'relative' position: 'relative'
}} }}
> >
{/* Pin indicator - always visible when pinned */}
{isPinned && (
<div
style={{
position: 'absolute',
top: 4,
left: 4,
zIndex: 10,
backgroundColor: 'rgba(255, 165, 0, 0.9)',
borderRadius: 4,
padding: 2
}}
>
<PushPinIcon style={{ fontSize: 16, color: 'white' }} />
</div>
)}
{/* Hover action buttons */} {/* Hover action buttons */}
{isHovered && (onView || onEdit) && ( {isHovered && (onView || onEdit || true) && (
<div <div
style={{ style={{
position: 'absolute', position: 'absolute',
@ -80,6 +103,25 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
}} }}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* Pin/Unpin button */}
<Tooltip label={isPinned ? "Unpin file (will be consumed by operations)" : "Pin file (won't be consumed by operations)"}>
<ActionIcon
size="sm"
variant="subtle"
color={isPinned ? "orange" : "gray"}
onClick={(e) => {
e.stopPropagation();
if (isPinned) {
unpinFile(file as File);
} else {
pinFile(file as File);
}
}}
>
{isPinned ? <PushPinIcon style={{ fontSize: 16 }} /> : <PushPinOutlinedIcon style={{ fontSize: 16 }} />}
</ActionIcon>
</Tooltip>
{onView && ( {onView && (
<Tooltip label="View in Viewer"> <Tooltip label="View in Viewer">
<ActionIcon <ActionIcon

View File

@ -1,14 +1,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { Button, Stack, Text, NumberInput, Select, Divider } from "@mantine/core"; import { Button, Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CompressParameters } from "../../../hooks/tools/compress/useCompressOperation";
interface CompressParameters {
compressionMethod: 'quality' | 'filesize';
compressionLevel: number;
fileSizeValue: string;
fileSizeUnit: 'KB' | 'MB';
grayscale: boolean;
}
interface CompressSettingsProps { interface CompressSettingsProps {
parameters: CompressParameters; parameters: CompressParameters;

View File

@ -1,19 +1,25 @@
import React from 'react'; import React from 'react';
import { Text } from '@mantine/core'; import { Text, Box, Flex, ActionIcon, Tooltip } from '@mantine/core';
import PushPinIcon from '@mui/icons-material/PushPin';
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
import { useFileContext } from '../../../contexts/FileContext';
export interface FileStatusIndicatorProps { export interface FileStatusIndicatorProps {
selectedFiles?: File[]; selectedFiles?: File[];
isCompleted?: boolean; isCompleted?: boolean;
placeholder?: string; placeholder?: string;
showFileName?: boolean; showFileName?: boolean;
showPinControls?: boolean;
} }
const FileStatusIndicator = ({ const FileStatusIndicator = ({
selectedFiles = [], selectedFiles = [],
isCompleted = false, isCompleted = false,
placeholder = "Select a PDF file in the main view to get started", placeholder = "Select a PDF file in the main view to get started",
showFileName = true showFileName = true,
showPinControls = true
}: FileStatusIndicatorProps) => { }: FileStatusIndicatorProps) => {
const { pinFile, unpinFile, isFilePinned } = useFileContext();
if (selectedFiles.length === 0) { if (selectedFiles.length === 0) {
return ( return (
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">

View File

@ -1,5 +1,4 @@
import React from 'react'; import React from 'react';
import { Stack } from '@mantine/core';
import { createToolSteps, ToolStepProvider } from './ToolStep'; import { createToolSteps, ToolStepProvider } from './ToolStep';
import OperationButton from './OperationButton'; import OperationButton from './OperationButton';
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation'; import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';

View File

@ -35,6 +35,7 @@ const initialViewerConfig: ViewerConfig = {
const initialState: FileContextState = { const initialState: FileContextState = {
activeFiles: [], activeFiles: [],
processedFiles: new Map(), processedFiles: new Map(),
pinnedFiles: new Set(),
currentMode: 'pageEditor', currentMode: 'pageEditor',
currentView: 'fileEditor', // Legacy field currentView: 'fileEditor', // Legacy field
currentTool: null, // Legacy field currentTool: null, // Legacy field
@ -77,6 +78,9 @@ type FileContextAction =
| { type: 'SET_UNSAVED_CHANGES'; payload: boolean } | { type: 'SET_UNSAVED_CHANGES'; payload: boolean }
| { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null } | { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null }
| { type: 'SHOW_NAVIGATION_WARNING'; payload: boolean } | { type: 'SHOW_NAVIGATION_WARNING'; payload: boolean }
| { type: 'PIN_FILE'; payload: File }
| { type: 'UNPIN_FILE'; payload: File }
| { type: 'CONSUME_FILES'; payload: { inputFiles: File[]; outputFiles: File[] } }
| { type: 'RESET_CONTEXT' } | { type: 'RESET_CONTEXT' }
| { type: 'LOAD_STATE'; payload: Partial<FileContextState> }; | { type: 'LOAD_STATE'; payload: Partial<FileContextState> };
@ -317,6 +321,43 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
showNavigationWarning: action.payload showNavigationWarning: action.payload
}; };
case 'PIN_FILE':
return {
...state,
pinnedFiles: new Set([...state.pinnedFiles, action.payload])
};
case 'UNPIN_FILE':
const newPinnedFiles = new Set(state.pinnedFiles);
newPinnedFiles.delete(action.payload);
return {
...state,
pinnedFiles: newPinnedFiles
};
case 'CONSUME_FILES': {
const { inputFiles, outputFiles } = action.payload;
const unpinnedInputFiles = inputFiles.filter(file => !state.pinnedFiles.has(file));
// Remove unpinned input files and add output files
const newActiveFiles = [
...state.activeFiles.filter(file => !unpinnedInputFiles.includes(file)),
...outputFiles
];
// Update processed files map - remove consumed files, keep pinned ones
const newProcessedFiles = new Map(state.processedFiles);
unpinnedInputFiles.forEach(file => {
newProcessedFiles.delete(file);
});
return {
...state,
activeFiles: newActiveFiles,
processedFiles: newProcessedFiles
};
}
case 'RESET_CONTEXT': case 'RESET_CONTEXT':
return { return {
...initialState ...initialState
@ -562,6 +603,46 @@ export function FileContextProvider({
dispatch({ type: 'CLEAR_SELECTIONS' }); dispatch({ type: 'CLEAR_SELECTIONS' });
}, [cleanupAllFiles]); }, [cleanupAllFiles]);
// File pinning functions
const pinFile = useCallback((file: File) => {
dispatch({ type: 'PIN_FILE', payload: file });
}, []);
const unpinFile = useCallback((file: File) => {
dispatch({ type: 'UNPIN_FILE', payload: file });
}, []);
const isFilePinned = useCallback((file: File): boolean => {
return state.pinnedFiles.has(file);
}, [state.pinnedFiles]);
// File consumption function
const consumeFiles = useCallback(async (inputFiles: File[], outputFiles: File[]): Promise<void> => {
dispatch({ type: 'CONSUME_FILES', payload: { inputFiles, outputFiles } });
// Store new output files if persistence is enabled
if (enablePersistence) {
for (const file of outputFiles) {
try {
const fileId = getFileId(file);
if (!fileId) {
try {
const thumbnail = await (thumbnailGenerationService as any).generateThumbnail(file);
const storedFile = await fileStorage.storeFile(file, thumbnail);
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
} catch (thumbnailError) {
console.warn('Failed to generate thumbnail, storing without:', thumbnailError);
const storedFile = await fileStorage.storeFile(file);
Object.defineProperty(file, 'id', { value: storedFile.id, writable: false });
}
}
} catch (error) {
console.error('Failed to store output file:', error);
}
}
}
}, [enablePersistence, state.pinnedFiles]);
// Navigation guard system functions // Navigation guard system functions
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => { const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: hasChanges }); dispatch({ type: 'SET_UNSAVED_CHANGES', payload: hasChanges });
@ -785,6 +866,10 @@ export function FileContextProvider({
removeFiles, removeFiles,
replaceFile, replaceFile,
clearAllFiles, clearAllFiles,
pinFile,
unpinFile,
isFilePinned,
consumeFiles,
setCurrentMode, setCurrentMode,
setCurrentView, setCurrentView,
setCurrentTool, setCurrentTool,

View File

@ -1,8 +1,9 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
import { import {
MaxFiles, MaxFiles,
FileSelectionContextValue FileSelectionContextValue
} from '../types/tool'; } from '../types/tool';
import { useFileContext } from './FileContext';
interface FileSelectionProviderProps { interface FileSelectionProviderProps {
children: ReactNode; children: ReactNode;
@ -11,10 +12,23 @@ interface FileSelectionProviderProps {
const FileSelectionContext = createContext<FileSelectionContextValue | undefined>(undefined); const FileSelectionContext = createContext<FileSelectionContextValue | undefined>(undefined);
export function FileSelectionProvider({ children }: FileSelectionProviderProps) { export function FileSelectionProvider({ children }: FileSelectionProviderProps) {
const { activeFiles } = useFileContext();
const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [maxFiles, setMaxFiles] = useState<MaxFiles>(-1); const [maxFiles, setMaxFiles] = useState<MaxFiles>(-1);
const [isToolMode, setIsToolMode] = useState<boolean>(false); const [isToolMode, setIsToolMode] = useState<boolean>(false);
// Sync selected files with active files - remove any selected files that are no longer active
useEffect(() => {
if (selectedFiles.length > 0) {
const activeFileSet = new Set(activeFiles);
const validSelectedFiles = selectedFiles.filter(file => activeFileSet.has(file));
if (validSelectedFiles.length !== selectedFiles.length) {
setSelectedFiles(validSelectedFiles);
}
}
}, [activeFiles, selectedFiles]);
const clearSelection = useCallback(() => { const clearSelection = useCallback(() => {
setSelectedFiles([]); setSelectedFiles([]);
}, []); }, []);

View File

@ -11,7 +11,7 @@ export interface CompressParameters {
fileSizeUnit: 'KB' | 'MB'; fileSizeUnit: 'KB' | 'MB';
} }
const buildFormData = (parameters: CompressParameters, file: File): FormData => { const buildFormData = (file: File, parameters: CompressParameters): FormData => {
const formData = new FormData(); const formData = new FormData();
formData.append("fileInput", file); formData.append("fileInput", file);

View File

@ -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 } = useFileContext(); const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles } = 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);
// Add to file context // Consume input files and add output files (will replace unpinned inputs)
await addFiles(processedFiles); await consumeFiles(validFiles, processedFiles);
markOperationApplied(fileId, operationId); markOperationApplied(fileId, operationId);
} }

View File

@ -29,7 +29,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
useEffect(() => { useEffect(() => {
compressOperation.resetResults(); compressOperation.resetResults();
onPreviewFile?.(null); onPreviewFile?.(null);
}, [compressParams.parameters, selectedFiles]); }, [compressParams.parameters]);
const handleCompress = async () => { const handleCompress = async () => {
try { try {
@ -61,7 +61,6 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
const hasFiles = selectedFiles.length > 0; const hasFiles = selectedFiles.length > 0;
const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null; const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null;
const filesCollapsed = hasFiles;
const settingsCollapsed = !hasFiles || hasResults; const settingsCollapsed = !hasFiles || hasResults;
return ( return (
@ -69,7 +68,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
{createToolFlow({ {createToolFlow({
files: { files: {
selectedFiles, selectedFiles,
isCollapsed: filesCollapsed isCollapsed: hasFiles
}, },
steps: [{ steps: [{
title: "Settings", title: "Settings",
@ -86,6 +85,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
}], }],
executeButton: { executeButton: {
text: t("compress.submit", "Compress"), text: t("compress.submit", "Compress"),
isVisible: !hasResults,
loadingText: t("loading"), loadingText: t("loading"),
onClick: handleCompress, onClick: handleCompress,
disabled: !compressParams.validateParameters() || !hasFiles || !endpointEnabled disabled: !compressParams.validateParameters() || !hasFiles || !endpointEnabled

View File

@ -55,6 +55,7 @@ export interface FileContextState {
// Core file management // Core file management
activeFiles: File[]; activeFiles: File[];
processedFiles: Map<File, ProcessedFile>; processedFiles: Map<File, ProcessedFile>;
pinnedFiles: Set<File>; // Files that are pinned and won't be consumed
// Current navigation state // Current navigation state
currentMode: ModeType; currentMode: ModeType;
@ -96,6 +97,14 @@ export interface FileContextActions {
replaceFile: (oldFileId: string, newFile: File) => Promise<void>; replaceFile: (oldFileId: string, newFile: File) => Promise<void>;
clearAllFiles: () => void; clearAllFiles: () => void;
// File pinning
pinFile: (file: File) => void;
unpinFile: (file: File) => void;
isFilePinned: (file: File) => boolean;
// File consumption (replace unpinned files with outputs)
consumeFiles: (inputFiles: File[], outputFiles: File[]) => Promise<void>;
// Navigation // Navigation
setCurrentMode: (mode: ModeType) => void; setCurrentMode: (mode: ModeType) => void;
setCurrentView: (view: ViewType) => void; setCurrentView: (view: ViewType) => void;