mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 06:09:23 +00:00
File Pinning
This commit is contained in:
parent
63c4d98fda
commit
c22ec2037d
@ -4,9 +4,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
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 styles from './PageEditor.module.css';
|
||||
import FileOperationHistory from '../history/FileOperationHistory';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
|
||||
interface FileItem {
|
||||
id: string;
|
||||
@ -66,6 +69,10 @@ const FileThumbnail = ({
|
||||
}: FileThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
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) => {
|
||||
if (bytes === 0) return '0 B';
|
||||
@ -301,6 +308,32 @@ const FileThumbnail = ({
|
||||
</ActionIcon>
|
||||
</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">
|
||||
<ActionIcon
|
||||
size="md"
|
||||
|
@ -5,10 +5,13 @@ import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import StorageIcon from "@mui/icons-material/Storage";
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
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 { getFileSize, getFileDate } from "../../utils/fileUtils";
|
||||
import { useIndexedDBThumbnail } from "../../hooks/useIndexedDBThumbnail";
|
||||
import { useFileContext } from "../../contexts/FileContext";
|
||||
|
||||
interface FileCardProps {
|
||||
file: FileWithUrl;
|
||||
@ -25,6 +28,9 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
|
||||
const { t } = useTranslation();
|
||||
const { thumbnail: thumb, isGenerating } = useIndexedDBThumbnail(file);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const { pinFile, unpinFile, isFilePinned } = useFileContext();
|
||||
|
||||
const isPinned = isFilePinned(file as File);
|
||||
|
||||
return (
|
||||
<Card
|
||||
@ -64,8 +70,25 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
|
||||
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 */}
|
||||
{isHovered && (onView || onEdit) && (
|
||||
{isHovered && (onView || onEdit || true) && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
@ -80,6 +103,25 @@ const FileCard = ({ file, onRemove, onDoubleClick, onView, onEdit, isSelected, o
|
||||
}}
|
||||
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 && (
|
||||
<Tooltip label="View in Viewer">
|
||||
<ActionIcon
|
||||
|
@ -1,14 +1,7 @@
|
||||
import React, { useState } from "react";
|
||||
import { Button, Stack, Text, NumberInput, Select, Divider } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
interface CompressParameters {
|
||||
compressionMethod: 'quality' | 'filesize';
|
||||
compressionLevel: number;
|
||||
fileSizeValue: string;
|
||||
fileSizeUnit: 'KB' | 'MB';
|
||||
grayscale: boolean;
|
||||
}
|
||||
import { CompressParameters } from "../../../hooks/tools/compress/useCompressOperation";
|
||||
|
||||
interface CompressSettingsProps {
|
||||
parameters: CompressParameters;
|
||||
|
@ -1,19 +1,25 @@
|
||||
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 {
|
||||
selectedFiles?: File[];
|
||||
isCompleted?: boolean;
|
||||
placeholder?: string;
|
||||
showFileName?: boolean;
|
||||
showPinControls?: boolean;
|
||||
}
|
||||
|
||||
const FileStatusIndicator = ({
|
||||
selectedFiles = [],
|
||||
isCompleted = false,
|
||||
placeholder = "Select a PDF file in the main view to get started",
|
||||
showFileName = true
|
||||
showFileName = true,
|
||||
showPinControls = true
|
||||
}: FileStatusIndicatorProps) => {
|
||||
const { pinFile, unpinFile, isFilePinned } = useFileContext();
|
||||
if (selectedFiles.length === 0) {
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
|
@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { Stack } from '@mantine/core';
|
||||
import { createToolSteps, ToolStepProvider } from './ToolStep';
|
||||
import OperationButton from './OperationButton';
|
||||
import { ToolOperationHook } from '../../../hooks/tools/shared/useToolOperation';
|
||||
@ -57,7 +56,7 @@ export interface ToolFlowConfig {
|
||||
*/
|
||||
export function createToolFlow(config: ToolFlowConfig) {
|
||||
const steps = createToolSteps();
|
||||
|
||||
|
||||
return (
|
||||
<ToolStepProvider>
|
||||
{/* Files Step */}
|
||||
@ -69,7 +68,7 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
})}
|
||||
|
||||
{/* Middle Steps */}
|
||||
{config.steps.map((stepConfig, index) =>
|
||||
{config.steps.map((stepConfig, index) =>
|
||||
steps.create(stepConfig.title, {
|
||||
isVisible: stepConfig.isVisible,
|
||||
isCollapsed: stepConfig.isCollapsed,
|
||||
@ -99,4 +98,4 @@ export function createToolFlow(config: ToolFlowConfig) {
|
||||
})}
|
||||
</ToolStepProvider>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ const initialViewerConfig: ViewerConfig = {
|
||||
const initialState: FileContextState = {
|
||||
activeFiles: [],
|
||||
processedFiles: new Map(),
|
||||
pinnedFiles: new Set(),
|
||||
currentMode: 'pageEditor',
|
||||
currentView: 'fileEditor', // Legacy field
|
||||
currentTool: null, // Legacy field
|
||||
@ -77,6 +78,9 @@ type FileContextAction =
|
||||
| { type: 'SET_UNSAVED_CHANGES'; payload: boolean }
|
||||
| { type: 'SET_PENDING_NAVIGATION'; payload: (() => void) | null }
|
||||
| { 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: 'LOAD_STATE'; payload: Partial<FileContextState> };
|
||||
|
||||
@ -317,6 +321,43 @@ function fileContextReducer(state: FileContextState, action: FileContextAction):
|
||||
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':
|
||||
return {
|
||||
...initialState
|
||||
@ -562,6 +603,46 @@ export function FileContextProvider({
|
||||
dispatch({ type: 'CLEAR_SELECTIONS' });
|
||||
}, [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
|
||||
const setHasUnsavedChanges = useCallback((hasChanges: boolean) => {
|
||||
dispatch({ type: 'SET_UNSAVED_CHANGES', payload: hasChanges });
|
||||
@ -785,6 +866,10 @@ export function FileContextProvider({
|
||||
removeFiles,
|
||||
replaceFile,
|
||||
clearAllFiles,
|
||||
pinFile,
|
||||
unpinFile,
|
||||
isFilePinned,
|
||||
consumeFiles,
|
||||
setCurrentMode,
|
||||
setCurrentView,
|
||||
setCurrentTool,
|
||||
|
@ -1,8 +1,9 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, ReactNode } from 'react';
|
||||
import {
|
||||
MaxFiles,
|
||||
FileSelectionContextValue
|
||||
} from '../types/tool';
|
||||
import { useFileContext } from './FileContext';
|
||||
|
||||
interface FileSelectionProviderProps {
|
||||
children: ReactNode;
|
||||
@ -11,10 +12,23 @@ interface FileSelectionProviderProps {
|
||||
const FileSelectionContext = createContext<FileSelectionContextValue | undefined>(undefined);
|
||||
|
||||
export function FileSelectionProvider({ children }: FileSelectionProviderProps) {
|
||||
const { activeFiles } = useFileContext();
|
||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||
const [maxFiles, setMaxFiles] = useState<MaxFiles>(-1);
|
||||
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(() => {
|
||||
setSelectedFiles([]);
|
||||
}, []);
|
||||
|
@ -11,7 +11,7 @@ export interface CompressParameters {
|
||||
fileSizeUnit: 'KB' | 'MB';
|
||||
}
|
||||
|
||||
const buildFormData = (parameters: CompressParameters, file: File): FormData => {
|
||||
const buildFormData = (file: File, parameters: CompressParameters): FormData => {
|
||||
const formData = new FormData();
|
||||
formData.append("fileInput", file);
|
||||
|
||||
|
@ -104,7 +104,7 @@ export const useToolOperation = <TParams = void>(
|
||||
config: ToolOperationConfig<TParams>
|
||||
): ToolOperationHook<TParams> => {
|
||||
const { t } = useTranslation();
|
||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles } = useFileContext();
|
||||
const { recordOperation, markOperationApplied, markOperationFailed, addFiles, consumeFiles } = 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);
|
||||
|
||||
// Add to file context
|
||||
await addFiles(processedFiles);
|
||||
// Consume input files and add output files (will replace unpinned inputs)
|
||||
await consumeFiles(validFiles, processedFiles);
|
||||
|
||||
markOperationApplied(fileId, operationId);
|
||||
}
|
||||
|
@ -29,7 +29,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
useEffect(() => {
|
||||
compressOperation.resetResults();
|
||||
onPreviewFile?.(null);
|
||||
}, [compressParams.parameters, selectedFiles]);
|
||||
}, [compressParams.parameters]);
|
||||
|
||||
const handleCompress = async () => {
|
||||
try {
|
||||
@ -61,7 +61,6 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
|
||||
const hasFiles = selectedFiles.length > 0;
|
||||
const hasResults = compressOperation.files.length > 0 || compressOperation.downloadUrl !== null;
|
||||
const filesCollapsed = hasFiles;
|
||||
const settingsCollapsed = !hasFiles || hasResults;
|
||||
|
||||
return (
|
||||
@ -69,7 +68,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
{createToolFlow({
|
||||
files: {
|
||||
selectedFiles,
|
||||
isCollapsed: filesCollapsed
|
||||
isCollapsed: hasFiles
|
||||
},
|
||||
steps: [{
|
||||
title: "Settings",
|
||||
@ -86,6 +85,7 @@ const Compress = ({ onPreviewFile, onComplete, onError }: BaseToolProps) => {
|
||||
}],
|
||||
executeButton: {
|
||||
text: t("compress.submit", "Compress"),
|
||||
isVisible: !hasResults,
|
||||
loadingText: t("loading"),
|
||||
onClick: handleCompress,
|
||||
disabled: !compressParams.validateParameters() || !hasFiles || !endpointEnabled
|
||||
|
@ -55,6 +55,7 @@ export interface FileContextState {
|
||||
// Core file management
|
||||
activeFiles: File[];
|
||||
processedFiles: Map<File, ProcessedFile>;
|
||||
pinnedFiles: Set<File>; // Files that are pinned and won't be consumed
|
||||
|
||||
// Current navigation state
|
||||
currentMode: ModeType;
|
||||
@ -95,6 +96,14 @@ export interface FileContextActions {
|
||||
removeFiles: (fileIds: string[], deleteFromStorage?: boolean) => void;
|
||||
replaceFile: (oldFileId: string, newFile: File) => Promise<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
|
||||
setCurrentMode: (mode: ModeType) => void;
|
||||
|
Loading…
x
Reference in New Issue
Block a user