mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2025-08-26 06:09:23 +00:00
bug fixes
This commit is contained in:
parent
6aa8e42941
commit
28c5e675ac
@ -137,7 +137,7 @@ const FileManager: React.FC<FileManagerProps> = ({ selectedTool }) => {
|
||||
onDrop={handleNewFileUpload}
|
||||
onDragEnter={() => setIsDragging(true)}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
accept={["*/*"] as any}
|
||||
accept={{}}
|
||||
multiple={true}
|
||||
activateOnClick={false}
|
||||
style={{
|
||||
|
@ -14,7 +14,6 @@ import { zipFileService } from '../../services/zipFileService';
|
||||
import { detectFileExtension } from '../../utils/fileUtils';
|
||||
import styles from '../pageEditor/PageEditor.module.css';
|
||||
import FileThumbnail from '../pageEditor/FileThumbnail';
|
||||
import DragDropGrid from '../pageEditor/DragDropGrid';
|
||||
import FilePickerModal from '../shared/FilePickerModal';
|
||||
import SkeletonLoader from '../shared/SkeletonLoader';
|
||||
|
||||
@ -110,11 +109,6 @@ const FileEditor = ({
|
||||
setSelectionMode(true);
|
||||
}
|
||||
}, [toolMode]);
|
||||
const [draggedFile, setDraggedFile] = useState<string | null>(null);
|
||||
const [dropTarget, setDropTarget] = useState<string | null>(null);
|
||||
const [multiFileDrag, setMultiFileDrag] = useState<{fileIds: string[], count: number} | null>(null);
|
||||
const [dragPosition, setDragPosition] = useState<{x: number, y: number} | null>(null);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const [showFilePickerModal, setShowFilePickerModal] = useState(false);
|
||||
const [conversionProgress, setConversionProgress] = useState(0);
|
||||
const [zipExtractionProgress, setZipExtractionProgress] = useState<{
|
||||
@ -130,7 +124,6 @@ const FileEditor = ({
|
||||
extractedCount: 0,
|
||||
totalFiles: 0
|
||||
});
|
||||
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
||||
const lastActiveFilesRef = useRef<string[]>([]);
|
||||
const lastProcessedFilesRef = useRef<number>(0);
|
||||
|
||||
@ -452,113 +445,57 @@ const FileEditor = ({
|
||||
});
|
||||
}, [setContextSelectedFiles]);
|
||||
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragStart = useCallback((fileId: string) => {
|
||||
setDraggedFile(fileId);
|
||||
|
||||
if (selectionMode && localSelectedIds.includes(fileId) && localSelectedIds.length > 1) {
|
||||
setMultiFileDrag({
|
||||
fileIds: localSelectedIds,
|
||||
count: localSelectedIds.length
|
||||
});
|
||||
} else {
|
||||
setMultiFileDrag(null);
|
||||
}
|
||||
}, [selectionMode, localSelectedIds]);
|
||||
|
||||
const handleDragEnd = useCallback(() => {
|
||||
setDraggedFile(null);
|
||||
setDropTarget(null);
|
||||
setMultiFileDrag(null);
|
||||
setDragPosition(null);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!draggedFile) return;
|
||||
|
||||
if (multiFileDrag) {
|
||||
setDragPosition({ x: e.clientX, y: e.clientY });
|
||||
}
|
||||
|
||||
const elementUnderCursor = document.elementFromPoint(e.clientX, e.clientY);
|
||||
if (!elementUnderCursor) return;
|
||||
|
||||
const fileContainer = elementUnderCursor.closest('[data-file-id]');
|
||||
if (fileContainer) {
|
||||
const fileId = fileContainer.getAttribute('data-file-id');
|
||||
if (fileId && fileId !== draggedFile) {
|
||||
setDropTarget(fileId);
|
||||
return;
|
||||
// File reordering handler for drag and drop
|
||||
const handleReorderFiles = useCallback((sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => {
|
||||
setFiles(prevFiles => {
|
||||
const newFiles = [...prevFiles];
|
||||
|
||||
// Find original source and target indices
|
||||
const sourceIndex = newFiles.findIndex(f => f.id === sourceFileId);
|
||||
const targetIndex = newFiles.findIndex(f => f.id === targetFileId);
|
||||
|
||||
if (sourceIndex === -1 || targetIndex === -1) {
|
||||
console.warn('Could not find source or target file for reordering');
|
||||
return prevFiles;
|
||||
}
|
||||
}
|
||||
|
||||
const endZone = elementUnderCursor.closest('[data-drop-zone="end"]');
|
||||
if (endZone) {
|
||||
setDropTarget('end');
|
||||
return;
|
||||
}
|
||||
// Handle multi-file selection reordering
|
||||
const filesToMove = selectedFileIds.length > 1
|
||||
? selectedFileIds.map(id => newFiles.find(f => f.id === id)!).filter(Boolean)
|
||||
: [newFiles[sourceIndex]];
|
||||
|
||||
setDropTarget(null);
|
||||
}, [draggedFile, multiFileDrag]);
|
||||
// Calculate the correct target position before removing files
|
||||
let insertIndex = targetIndex;
|
||||
|
||||
// If we're moving forward (right), we need to adjust for the files we're removing
|
||||
const sourceIndices = filesToMove.map(f => newFiles.findIndex(nf => nf.id === f.id));
|
||||
const minSourceIndex = Math.min(...sourceIndices);
|
||||
|
||||
if (minSourceIndex < targetIndex) {
|
||||
// Moving forward: target moves left by the number of files we're removing before it
|
||||
const filesBeforeTarget = sourceIndices.filter(idx => idx < targetIndex).length;
|
||||
insertIndex = targetIndex - filesBeforeTarget + 1; // +1 to insert after target
|
||||
}
|
||||
|
||||
const handleDragEnter = useCallback((fileId: string) => {
|
||||
if (draggedFile && fileId !== draggedFile) {
|
||||
setDropTarget(fileId);
|
||||
}
|
||||
}, [draggedFile]);
|
||||
// Remove files to move from their current positions (in reverse order to maintain indices)
|
||||
sourceIndices
|
||||
.sort((a, b) => b - a) // Sort descending to remove from end first
|
||||
.forEach(index => {
|
||||
newFiles.splice(index, 1);
|
||||
});
|
||||
|
||||
const handleDragLeave = useCallback(() => {
|
||||
// Let dragover handle this
|
||||
}, []);
|
||||
// Insert files at the calculated position
|
||||
newFiles.splice(insertIndex, 0, ...filesToMove);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent, targetFileId: string | 'end') => {
|
||||
e.preventDefault();
|
||||
if (!draggedFile || draggedFile === targetFileId) return;
|
||||
// Update status
|
||||
const moveCount = filesToMove.length;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
|
||||
let targetIndex: number;
|
||||
if (targetFileId === 'end') {
|
||||
targetIndex = files.length;
|
||||
} else {
|
||||
targetIndex = files.findIndex(f => f.id === targetFileId);
|
||||
if (targetIndex === -1) return;
|
||||
}
|
||||
|
||||
const filesToMove = selectionMode && localSelectedIds.includes(draggedFile)
|
||||
? localSelectedIds
|
||||
: [draggedFile];
|
||||
|
||||
// Update the local files state and sync with activeFiles
|
||||
setFiles(prev => {
|
||||
const newFiles = [...prev];
|
||||
const movedFiles = filesToMove.map(id => newFiles.find(f => f.id === id)!).filter(Boolean);
|
||||
|
||||
// Remove moved files
|
||||
filesToMove.forEach(id => {
|
||||
const index = newFiles.findIndex(f => f.id === id);
|
||||
if (index !== -1) newFiles.splice(index, 1);
|
||||
});
|
||||
|
||||
// Insert at target position
|
||||
newFiles.splice(targetIndex, 0, ...movedFiles);
|
||||
|
||||
// TODO: Update context with reordered files (need to implement file reordering in context)
|
||||
// For now, just return the reordered local state
|
||||
return newFiles;
|
||||
});
|
||||
}, [setStatus]);
|
||||
|
||||
const moveCount = multiFileDrag ? multiFileDrag.count : 1;
|
||||
setStatus(`${moveCount > 1 ? `${moveCount} files` : 'File'} reordered`);
|
||||
|
||||
}, [draggedFile, files, selectionMode, localSelectedIds, multiFileDrag]);
|
||||
|
||||
const handleEndZoneDragEnter = useCallback(() => {
|
||||
if (draggedFile) {
|
||||
setDropTarget('end');
|
||||
}
|
||||
}, [draggedFile]);
|
||||
|
||||
// File operations using context
|
||||
const handleDeleteFile = useCallback((fileId: string) => {
|
||||
@ -751,7 +688,15 @@ const FileEditor = ({
|
||||
<SkeletonLoader type="fileGrid" count={6} />
|
||||
</Box>
|
||||
) : (
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', gap: '1.5rem', padding: '1rem' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))',
|
||||
gap: '1.5rem',
|
||||
padding: '1rem',
|
||||
pointerEvents: 'auto'
|
||||
}}
|
||||
>
|
||||
{files.map((file, index) => (
|
||||
<FileThumbnail
|
||||
key={file.id}
|
||||
@ -760,20 +705,11 @@ const FileEditor = ({
|
||||
totalFiles={files.length}
|
||||
selectedFiles={localSelectedIds}
|
||||
selectionMode={selectionMode}
|
||||
draggedFile={draggedFile}
|
||||
dropTarget={dropTarget}
|
||||
isAnimating={isAnimating}
|
||||
fileRefs={fileRefs}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onToggleFile={toggleFile}
|
||||
onDeleteFile={handleDeleteFile}
|
||||
onViewFile={handleViewFile}
|
||||
onSetStatus={setStatus}
|
||||
onReorderFiles={handleReorderFiles}
|
||||
toolMode={toolMode}
|
||||
isSupported={isFileSupported(file.name)}
|
||||
/>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { Text, Checkbox, Tooltip, ActionIcon, Badge } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
@ -7,6 +7,7 @@ import PreviewIcon from '@mui/icons-material/Preview';
|
||||
import PushPinIcon from '@mui/icons-material/PushPin';
|
||||
import PushPinOutlinedIcon from '@mui/icons-material/PushPinOutlined';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter';
|
||||
import styles from './PageEditor.module.css';
|
||||
import { useFileContext } from '../../contexts/FileContext';
|
||||
|
||||
@ -25,20 +26,11 @@ interface FileThumbnailProps {
|
||||
totalFiles: number;
|
||||
selectedFiles: string[];
|
||||
selectionMode: boolean;
|
||||
draggedFile: string | null;
|
||||
dropTarget: string | null;
|
||||
isAnimating: boolean;
|
||||
fileRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
|
||||
onDragStart: (fileId: string) => void;
|
||||
onDragEnd: () => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDragEnter: (fileId: string) => void;
|
||||
onDragLeave: () => void;
|
||||
onDrop: (e: React.DragEvent, fileId: string) => void;
|
||||
onToggleFile: (fileId: string) => void;
|
||||
onDeleteFile: (fileId: string) => void;
|
||||
onViewFile: (fileId: string) => void;
|
||||
onSetStatus: (status: string) => void;
|
||||
onReorderFiles?: (sourceFileId: string, targetFileId: string, selectedFileIds: string[]) => void;
|
||||
toolMode?: boolean;
|
||||
isSupported?: boolean;
|
||||
}
|
||||
@ -49,25 +41,20 @@ const FileThumbnail = ({
|
||||
totalFiles,
|
||||
selectedFiles,
|
||||
selectionMode,
|
||||
draggedFile,
|
||||
dropTarget,
|
||||
isAnimating,
|
||||
fileRefs,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDragEnter,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
onToggleFile,
|
||||
onDeleteFile,
|
||||
onViewFile,
|
||||
onSetStatus,
|
||||
onReorderFiles,
|
||||
toolMode = false,
|
||||
isSupported = true,
|
||||
}: FileThumbnailProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { pinnedFiles, pinFile, unpinFile, isFilePinned, activeFiles } = useFileContext();
|
||||
|
||||
// Drag and drop state
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const dragElementRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Find the actual File object that corresponds to this FileItem
|
||||
const actualFile = activeFiles.find(f => f.name === file.name && f.size === file.size);
|
||||
@ -80,18 +67,59 @@ const FileThumbnail = ({
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
// Memoize ref callback to prevent infinite loop
|
||||
const refCallback = useCallback((el: HTMLDivElement | null) => {
|
||||
if (el) {
|
||||
fileRefs.current.set(file.id, el);
|
||||
} else {
|
||||
fileRefs.current.delete(file.id);
|
||||
}
|
||||
}, [file.id, fileRefs]);
|
||||
// Setup drag and drop using @atlaskit/pragmatic-drag-and-drop
|
||||
const fileElementRef = useCallback((element: HTMLDivElement | null) => {
|
||||
if (!element) return;
|
||||
|
||||
dragElementRef.current = element;
|
||||
|
||||
const dragCleanup = draggable({
|
||||
element,
|
||||
getInitialData: () => ({
|
||||
type: 'file',
|
||||
fileId: file.id,
|
||||
fileName: file.name,
|
||||
selectedFiles: selectionMode && selectedFiles.includes(file.id)
|
||||
? selectedFiles
|
||||
: [file.id]
|
||||
}),
|
||||
onDragStart: () => {
|
||||
setIsDragging(true);
|
||||
},
|
||||
onDrop: () => {
|
||||
setIsDragging(false);
|
||||
}
|
||||
});
|
||||
|
||||
const dropCleanup = dropTargetForElements({
|
||||
element,
|
||||
getData: () => ({
|
||||
type: 'file',
|
||||
fileId: file.id
|
||||
}),
|
||||
canDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
return sourceData.type === 'file' && sourceData.fileId !== file.id;
|
||||
},
|
||||
onDrop: ({ source }) => {
|
||||
const sourceData = source.data;
|
||||
if (sourceData.type === 'file' && onReorderFiles) {
|
||||
const sourceFileId = sourceData.fileId as string;
|
||||
const selectedFileIds = sourceData.selectedFiles as string[];
|
||||
onReorderFiles(sourceFileId, file.id, selectedFileIds);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
dragCleanup();
|
||||
dropCleanup();
|
||||
};
|
||||
}, [file.id, file.name, selectionMode, selectedFiles, onReorderFiles]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={refCallback}
|
||||
ref={fileElementRef}
|
||||
data-file-id={file.id}
|
||||
data-testid="file-thumbnail"
|
||||
className={`
|
||||
@ -110,26 +138,12 @@ const FileThumbnail = ({
|
||||
${selectionMode
|
||||
? 'bg-white hover:bg-gray-50'
|
||||
: 'bg-white hover:bg-gray-50'}
|
||||
${draggedFile === file.id ? 'opacity-50 scale-95' : ''}
|
||||
${isDragging ? 'opacity-50 scale-95' : ''}
|
||||
`}
|
||||
style={{
|
||||
transform: (() => {
|
||||
if (!isAnimating && draggedFile && file.id !== draggedFile && dropTarget === file.id) {
|
||||
return 'translateX(20px)';
|
||||
}
|
||||
return 'translateX(0)';
|
||||
})(),
|
||||
transition: isAnimating ? 'none' : 'transform 0.2s ease-in-out',
|
||||
opacity: isSupported ? 1 : 0.5,
|
||||
opacity: isSupported ? (isDragging ? 0.5 : 1) : 0.5,
|
||||
filter: isSupported ? 'none' : 'grayscale(50%)'
|
||||
}}
|
||||
draggable
|
||||
onDragStart={() => onDragStart(file.id)}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
onDragEnter={() => onDragEnter(file.id)}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={(e) => onDrop(e, file.id)}
|
||||
>
|
||||
{selectionMode && (
|
||||
<div
|
||||
@ -188,6 +202,7 @@ const FileThumbnail = ({
|
||||
<img
|
||||
src={file.thumbnail}
|
||||
alt={file.name}
|
||||
draggable={false}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
|
@ -18,6 +18,10 @@ import {
|
||||
ToggleSplitCommand
|
||||
} from "../../commands/pageCommands";
|
||||
import { pdfExportService } from "../../services/pdfExportService";
|
||||
import { enhancedPDFProcessingService } from "../../services/enhancedPDFProcessingService";
|
||||
import { fileProcessingService } from "../../services/fileProcessingService";
|
||||
import { pdfProcessingService } from "../../services/pdfProcessingService";
|
||||
import { pdfWorkerManager } from "../../services/pdfWorkerManager";
|
||||
import { useThumbnailGeneration } from "../../hooks/useThumbnailGeneration";
|
||||
import { calculateScaleFromFileSize } from "../../utils/thumbnailUtils";
|
||||
import { fileStorage } from "../../services/fileStorage";
|
||||
@ -421,12 +425,21 @@ const PageEditor = ({
|
||||
// Cleanup thumbnail generation when component unmounts
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Stop any ongoing thumbnail generation
|
||||
// Stop all PDF.js background processing on unmount
|
||||
if (stopGeneration) {
|
||||
stopGeneration();
|
||||
}
|
||||
if (destroyThumbnails) {
|
||||
destroyThumbnails();
|
||||
}
|
||||
// Stop all processing services and destroy workers
|
||||
enhancedPDFProcessingService.emergencyCleanup();
|
||||
fileProcessingService.emergencyCleanup();
|
||||
pdfProcessingService.clearAll();
|
||||
// Final emergency cleanup of all workers
|
||||
pdfWorkerManager.emergencyCleanup();
|
||||
};
|
||||
}, [stopGeneration]);
|
||||
}, [stopGeneration, destroyThumbnails]);
|
||||
|
||||
// Clear selections when files change - use stable signature
|
||||
useEffect(() => {
|
||||
@ -557,37 +570,35 @@ const PageEditor = ({
|
||||
const saveDraftToIndexedDB = useCallback(async (doc: PDFDocument) => {
|
||||
const draftKey = `draft-${doc.id || 'merged'}`;
|
||||
|
||||
// Convert PDF document to bytes for storage
|
||||
const pdfBytes = await doc.save();
|
||||
const originalFileNames = activeFileIds.map(id => selectors.getFileRecord(id)?.name).filter(Boolean);
|
||||
|
||||
// Create a temporary file for thumbnail generation
|
||||
const tempFile = new File([pdfBytes], `Draft - ${originalFileNames.join(', ') || 'Untitled'}.pdf`, {
|
||||
type: 'application/pdf',
|
||||
lastModified: Date.now()
|
||||
});
|
||||
|
||||
// Generate thumbnail for the draft
|
||||
let thumbnail: string | undefined;
|
||||
try {
|
||||
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
|
||||
thumbnail = await generateThumbnailForFile(tempFile);
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate thumbnail for draft:', error);
|
||||
}
|
||||
|
||||
const draftData = {
|
||||
id: draftKey,
|
||||
name: `Draft - ${originalFileNames.join(', ') || 'Untitled'}`,
|
||||
pdfData: pdfBytes,
|
||||
size: pdfBytes.length,
|
||||
timestamp: Date.now(),
|
||||
thumbnail,
|
||||
originalFiles: originalFileNames
|
||||
};
|
||||
// Export the current document state as PDF bytes
|
||||
const exportedFile = await pdfExportService.exportPDF(doc, []);
|
||||
const pdfBytes = 'blob' in exportedFile ? await exportedFile.blob.arrayBuffer() : await exportedFile.blobs[0].arrayBuffer();
|
||||
const originalFileNames = activeFileIds.map(id => selectors.getFileRecord(id)?.name).filter(Boolean);
|
||||
|
||||
// Generate thumbnail for the draft
|
||||
let thumbnail: string | undefined;
|
||||
try {
|
||||
const { generateThumbnailForFile } = await import('../../utils/thumbnailUtils');
|
||||
const blob = 'blob' in exportedFile ? exportedFile.blob : exportedFile.blobs[0];
|
||||
const filename = 'filename' in exportedFile ? exportedFile.filename : exportedFile.filenames[0];
|
||||
const file = new File([blob], filename, { type: 'application/pdf' });
|
||||
thumbnail = await generateThumbnailForFile(file);
|
||||
} catch (error) {
|
||||
console.warn('Failed to generate thumbnail for draft:', error);
|
||||
}
|
||||
|
||||
const draftData = {
|
||||
id: draftKey,
|
||||
name: `Draft - ${originalFileNames.join(', ') || 'Untitled'}`,
|
||||
pdfData: pdfBytes,
|
||||
size: pdfBytes.byteLength,
|
||||
timestamp: Date.now(),
|
||||
thumbnail,
|
||||
originalFiles: originalFileNames
|
||||
};
|
||||
|
||||
try {
|
||||
// Use centralized IndexedDB manager
|
||||
// Use centralized IndexedDB manager
|
||||
const db = await indexedDBManager.openDatabase(DATABASE_CONFIGS.DRAFTS);
|
||||
const transaction = db.transaction('drafts', 'readwrite');
|
||||
const store = transaction.objectStore('drafts');
|
||||
@ -956,10 +967,27 @@ const PageEditor = ({
|
||||
}, [redo]);
|
||||
|
||||
const closePdf = useCallback(() => {
|
||||
// Use actions from context
|
||||
actions.clearAllFiles();
|
||||
// Stop all PDF.js background processing immediately
|
||||
if (stopGeneration) {
|
||||
stopGeneration();
|
||||
}
|
||||
if (destroyThumbnails) {
|
||||
destroyThumbnails();
|
||||
}
|
||||
// Stop enhanced PDF processing and destroy workers
|
||||
enhancedPDFProcessingService.emergencyCleanup();
|
||||
// Stop file processing service and destroy workers
|
||||
fileProcessingService.emergencyCleanup();
|
||||
// Stop PDF processing service
|
||||
pdfProcessingService.clearAll();
|
||||
// Emergency cleanup - destroy all PDF workers
|
||||
pdfWorkerManager.emergencyCleanup();
|
||||
|
||||
// Clear files from memory only (preserves files in storage/recent files)
|
||||
const allFileIds = selectors.getAllFileIds();
|
||||
actions.removeFiles(allFileIds, false); // false = don't delete from storage
|
||||
actions.setSelectedPages([]);
|
||||
}, [actions]);
|
||||
}, [actions, selectors, stopGeneration, destroyThumbnails]);
|
||||
|
||||
// PageEditorControls needs onExportSelected and onExportAll
|
||||
const onExportSelected = useCallback(() => showExportPreview(true), [showExportPreview]);
|
||||
@ -1102,10 +1130,8 @@ const PageEditor = ({
|
||||
}
|
||||
|
||||
|
||||
// Clean up draft if component unmounts with unsaved changes
|
||||
if (hasUnsavedChanges) {
|
||||
cleanupDraft();
|
||||
}
|
||||
// Note: We intentionally do NOT clean up drafts on unmount
|
||||
// Drafts should persist when navigating away so users can resume later
|
||||
};
|
||||
}, [hasUnsavedChanges, cleanupDraft]);
|
||||
|
||||
|
@ -59,17 +59,39 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
}, []);
|
||||
|
||||
const saveFile = useCallback(async (file: File, fileId: FileId, existingThumbnail?: string): Promise<FileMetadata> => {
|
||||
// Check for duplicate at IndexedDB level before saving
|
||||
const quickKey = `${file.name}|${file.size}|${file.lastModified}`;
|
||||
const existingFiles = await fileStorage.getAllFileMetadata();
|
||||
const duplicate = existingFiles.find(stored =>
|
||||
`${stored.name}|${stored.size}|${stored.lastModified}` === quickKey
|
||||
);
|
||||
|
||||
if (duplicate) {
|
||||
if (DEBUG) console.log(`🔍 SAVE: Skipping IndexedDB duplicate - using existing record:`, duplicate.name);
|
||||
// Return the existing file's metadata instead of saving duplicate
|
||||
return {
|
||||
id: duplicate.id,
|
||||
name: duplicate.name,
|
||||
type: duplicate.type,
|
||||
size: duplicate.size,
|
||||
lastModified: duplicate.lastModified,
|
||||
thumbnail: duplicate.thumbnail
|
||||
};
|
||||
}
|
||||
|
||||
// DEBUG: Check original file before saving
|
||||
if (DEBUG && file.type === 'application/pdf') {
|
||||
try {
|
||||
const { getDocument } = await import('pdfjs-dist');
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
console.log(`🔍 Saving file to IndexedDB:`, {
|
||||
console.log(`🔍 BEFORE SAVE - Original file:`, {
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
arrayBufferSize: arrayBuffer.byteLength,
|
||||
pages: pdf.numPages
|
||||
});
|
||||
pdf.destroy();
|
||||
} catch (error) {
|
||||
console.error(`🔍 Error validating file before save:`, error);
|
||||
}
|
||||
@ -120,7 +142,7 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
|
||||
// DEBUG: Check if file reconstruction is working
|
||||
if (DEBUG && file.type === 'application/pdf') {
|
||||
console.log(`🔍 File loaded from IndexedDB:`, {
|
||||
console.log(`🔍 AFTER LOAD - Reconstructed file:`, {
|
||||
name: file.name,
|
||||
originalSize: storedFile.size,
|
||||
reconstructedSize: file.size,
|
||||
@ -133,9 +155,10 @@ export function IndexedDBProvider({ children }: IndexedDBProviderProps) {
|
||||
const { getDocument } = await import('pdfjs-dist');
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
console.log(`🔍 PDF validation: ${pdf.numPages} pages in reconstructed file`);
|
||||
console.log(`🔍 AFTER LOAD - PDF validation: ${pdf.numPages} pages in reconstructed file`);
|
||||
pdf.destroy();
|
||||
} catch (error) {
|
||||
console.error(`🔍 PDF reconstruction error:`, error);
|
||||
console.error(`🔍 AFTER LOAD - PDF reconstruction error:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -66,6 +66,7 @@ export async function addFiles(
|
||||
|
||||
// Build quickKey lookup from existing files for deduplication
|
||||
const existingQuickKeys = buildQuickKeySet(stateRef.current.files.byId);
|
||||
if (DEBUG) console.log(`📄 addFiles(${kind}): Existing quickKeys for deduplication:`, Array.from(existingQuickKeys));
|
||||
|
||||
switch (kind) {
|
||||
case 'raw': {
|
||||
@ -77,9 +78,10 @@ export async function addFiles(
|
||||
|
||||
// Soft deduplication: Check if file already exists by metadata
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (already exists)`);
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate file: ${file.name} (quickKey: ${quickKey})`);
|
||||
continue;
|
||||
}
|
||||
if (DEBUG) console.log(`📄 Adding new file: ${file.name} (quickKey: ${quickKey})`);
|
||||
|
||||
const fileId = createFileId();
|
||||
filesRef.current.set(fileId, file);
|
||||
@ -114,19 +116,8 @@ export async function addFiles(
|
||||
fileRecords.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail });
|
||||
|
||||
// Start background processing for validation only (we already have thumbnail and page count)
|
||||
fileProcessingService.processFile(file, fileId).then(result => {
|
||||
// Only update if file still exists in context
|
||||
if (filesRef.current.has(fileId)) {
|
||||
if (result.success && result.metadata) {
|
||||
// Only log if page count differs from our immediate calculation
|
||||
const initialPageCount = pageCount;
|
||||
if (result.metadata.totalPages !== initialPageCount) {
|
||||
if (DEBUG) console.log(`📄 Background processing found different page count for ${file.name}: ${result.metadata.totalPages} vs immediate ${initialPageCount}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
// Note: No background fileProcessingService call needed - we already have immediate thumbnail and page count
|
||||
// This avoids cancellation conflicts with cleanup operations
|
||||
}
|
||||
break;
|
||||
}
|
||||
@ -172,9 +163,10 @@ export async function addFiles(
|
||||
const quickKey = createQuickKey(file);
|
||||
|
||||
if (existingQuickKeys.has(quickKey)) {
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name}`);
|
||||
if (DEBUG) console.log(`📄 Skipping duplicate stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||
continue;
|
||||
}
|
||||
if (DEBUG) console.log(`📄 Adding stored file: ${file.name} (quickKey: ${quickKey})`);
|
||||
|
||||
// Try to preserve original ID, but generate new if it conflicts
|
||||
let fileId = originalId;
|
||||
@ -192,12 +184,35 @@ export async function addFiles(
|
||||
record.thumbnailUrl = metadata.thumbnail;
|
||||
}
|
||||
|
||||
// Note: For stored files, processedFile will be restored from FileRecord if it exists
|
||||
// The metadata here is just basic file info, not processed file data
|
||||
// Generate processedFile metadata for stored files using PDF worker manager
|
||||
// This ensures stored files have proper page information and avoids cancellation conflicts
|
||||
let pageCount: number = 1;
|
||||
try {
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Generating metadata for stored file ${file.name}`);
|
||||
|
||||
// Use PDF worker manager directly for page count (avoids fileProcessingService conflicts)
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const { pdfWorkerManager } = await import('../../services/pdfWorkerManager');
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
pageCount = pdf.numPages;
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Found ${pageCount} pages in stored file ${file.name}`);
|
||||
} catch (error) {
|
||||
if (DEBUG) console.warn(`📄 addFiles(stored): Failed to generate metadata for ${file.name}:`, error);
|
||||
}
|
||||
|
||||
// Create processedFile metadata with correct page count
|
||||
if (pageCount > 0) {
|
||||
record.processedFile = createProcessedFile(pageCount, metadata.thumbnail);
|
||||
if (DEBUG) console.log(`📄 addFiles(stored): Created processedFile metadata for ${file.name} with ${pageCount} pages`);
|
||||
}
|
||||
|
||||
existingQuickKeys.add(quickKey);
|
||||
fileRecords.push(record);
|
||||
addedFiles.push({ file, id: fileId, thumbnail: metadata.thumbnail });
|
||||
|
||||
// Note: No background fileProcessingService call for stored files - we already processed them above
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
@ -1,12 +1,10 @@
|
||||
import { getDocument, GlobalWorkerOptions } from 'pdfjs-dist';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { ProcessedFile, ProcessingState, PDFPage, ProcessingStrategy, ProcessingConfig, ProcessingMetrics } from '../types/processing';
|
||||
import { ProcessingCache } from './processingCache';
|
||||
import { FileHasher } from '../utils/fileHash';
|
||||
import { FileAnalyzer } from './fileAnalyzer';
|
||||
import { ProcessingErrorHandler } from './processingErrorHandler';
|
||||
|
||||
// Set up PDF.js worker
|
||||
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||
|
||||
export class EnhancedPDFProcessingService {
|
||||
private static instance: EnhancedPDFProcessingService;
|
||||
@ -183,7 +181,7 @@ export class EnhancedPDFProcessingService {
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
state.progress = 10;
|
||||
@ -194,7 +192,7 @@ export class EnhancedPDFProcessingService {
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
// Check for cancellation
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
pdf.destroy();
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
@ -215,7 +213,7 @@ export class EnhancedPDFProcessingService {
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
pdf.destroy();
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
@ -231,7 +229,7 @@ export class EnhancedPDFProcessingService {
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
state.progress = 10;
|
||||
@ -243,7 +241,7 @@ export class EnhancedPDFProcessingService {
|
||||
// Process priority pages first
|
||||
for (let i = 1; i <= priorityCount; i++) {
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
pdf.destroy();
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
@ -274,7 +272,7 @@ export class EnhancedPDFProcessingService {
|
||||
});
|
||||
}
|
||||
|
||||
pdf.destroy();
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
@ -290,7 +288,7 @@ export class EnhancedPDFProcessingService {
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
state.progress = 10;
|
||||
@ -305,7 +303,7 @@ export class EnhancedPDFProcessingService {
|
||||
|
||||
for (let i = 1; i <= firstChunkEnd; i++) {
|
||||
if (state.cancellationToken?.signal.aborted) {
|
||||
pdf.destroy();
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
throw new Error('Processing cancelled');
|
||||
}
|
||||
|
||||
@ -342,7 +340,7 @@ export class EnhancedPDFProcessingService {
|
||||
});
|
||||
}
|
||||
|
||||
pdf.destroy();
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
@ -358,7 +356,7 @@ export class EnhancedPDFProcessingService {
|
||||
state: ProcessingState
|
||||
): Promise<ProcessedFile> {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const pdf = await getDocument({ data: arrayBuffer }).promise;
|
||||
const pdf = await pdfWorkerManager.createDocument(arrayBuffer);
|
||||
const totalPages = pdf.numPages;
|
||||
|
||||
state.progress = 50;
|
||||
@ -376,7 +374,7 @@ export class EnhancedPDFProcessingService {
|
||||
});
|
||||
}
|
||||
|
||||
pdf.destroy();
|
||||
pdfWorkerManager.destroyDocument(pdf);
|
||||
state.progress = 100;
|
||||
this.notifyListeners();
|
||||
|
||||
@ -540,6 +538,15 @@ export class EnhancedPDFProcessingService {
|
||||
this.processing.clear();
|
||||
this.notifyListeners();
|
||||
}
|
||||
|
||||
/**
|
||||
* Emergency cleanup - destroy all PDF workers
|
||||
*/
|
||||
emergencyCleanup(): void {
|
||||
this.clearAllProcessing();
|
||||
this.clearAll();
|
||||
pdfWorkerManager.destroyAllDocuments();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
@ -4,8 +4,9 @@
|
||||
* Called when files are added to FileContext, before any view sees them
|
||||
*/
|
||||
|
||||
import { getDocument } from 'pdfjs-dist';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import { generateThumbnailForFile } from '../utils/thumbnailUtils';
|
||||
import { pdfWorkerManager } from './pdfWorkerManager';
|
||||
|
||||
export interface ProcessedFileMetadata {
|
||||
totalPages: number;
|
||||
@ -90,17 +91,16 @@ class FileProcessingService {
|
||||
|
||||
// Discover page count using PDF.js (most accurate)
|
||||
try {
|
||||
const pdfDoc = await getDocument({
|
||||
data: arrayBuffer,
|
||||
const pdfDoc = await pdfWorkerManager.createDocument(arrayBuffer, {
|
||||
disableAutoFetch: true,
|
||||
disableStream: true
|
||||
}).promise;
|
||||
});
|
||||
|
||||
totalPages = pdfDoc.numPages;
|
||||
console.log(`📁 FileProcessingService: PDF.js discovered ${totalPages} pages for ${file.name}`);
|
||||
|
||||
// Clean up immediately
|
||||
pdfDoc.destroy();
|
||||
pdfWorkerManager.destroyDocument(pdfDoc);
|
||||
|
||||
// Check for cancellation after PDF.js processing
|
||||
if (abortController.signal.aborted) {
|
||||
@ -204,6 +204,15 @@ class FileProcessingService {
|
||||
});
|
||||
console.log(`📁 FileProcessingService: Cancelled ${this.processingCache.size} processing operations`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emergency cleanup - cancel all processing and destroy workers
|
||||
*/
|
||||
emergencyCleanup(): void {
|
||||
this.cancelAllProcessing();
|
||||
this.clearCache();
|
||||
pdfWorkerManager.destroyAllDocuments();
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
|
182
frontend/src/services/pdfWorkerManager.ts
Normal file
182
frontend/src/services/pdfWorkerManager.ts
Normal file
@ -0,0 +1,182 @@
|
||||
/**
|
||||
* PDF.js Worker Manager - Centralized worker lifecycle management
|
||||
*
|
||||
* Prevents infinite worker creation by managing PDF.js workers globally
|
||||
* and ensuring proper cleanup when operations complete.
|
||||
*/
|
||||
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
const { getDocument, GlobalWorkerOptions } = pdfjsLib;
|
||||
|
||||
class PDFWorkerManager {
|
||||
private static instance: PDFWorkerManager;
|
||||
private activeDocuments = new Set<any>();
|
||||
private workerCount = 0;
|
||||
private maxWorkers = 3; // Limit concurrent workers
|
||||
private isInitialized = false;
|
||||
|
||||
private constructor() {
|
||||
this.initializeWorker();
|
||||
}
|
||||
|
||||
static getInstance(): PDFWorkerManager {
|
||||
if (!PDFWorkerManager.instance) {
|
||||
PDFWorkerManager.instance = new PDFWorkerManager();
|
||||
}
|
||||
return PDFWorkerManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize PDF.js worker once globally
|
||||
*/
|
||||
private initializeWorker(): void {
|
||||
if (!this.isInitialized) {
|
||||
GlobalWorkerOptions.workerSrc = '/pdf.worker.js';
|
||||
this.isInitialized = true;
|
||||
console.log('🏭 PDF.js worker initialized');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a PDF document with proper lifecycle management
|
||||
*/
|
||||
async createDocument(
|
||||
data: ArrayBuffer | Uint8Array,
|
||||
options: {
|
||||
disableAutoFetch?: boolean;
|
||||
disableStream?: boolean;
|
||||
stopAtErrors?: boolean;
|
||||
verbosity?: number;
|
||||
} = {}
|
||||
): Promise<any> {
|
||||
// Wait if we've hit the worker limit
|
||||
if (this.activeDocuments.size >= this.maxWorkers) {
|
||||
console.warn(`🏭 PDF Worker limit reached (${this.maxWorkers}), waiting for available worker...`);
|
||||
await this.waitForAvailableWorker();
|
||||
}
|
||||
|
||||
const loadingTask = getDocument({
|
||||
data,
|
||||
disableAutoFetch: options.disableAutoFetch ?? true,
|
||||
disableStream: options.disableStream ?? true,
|
||||
stopAtErrors: options.stopAtErrors ?? false,
|
||||
verbosity: options.verbosity ?? 0
|
||||
});
|
||||
|
||||
try {
|
||||
const pdf = await loadingTask.promise;
|
||||
this.activeDocuments.add(pdf);
|
||||
this.workerCount++;
|
||||
|
||||
console.log(`🏭 PDF document created (active: ${this.activeDocuments.size}/${this.maxWorkers})`);
|
||||
|
||||
return pdf;
|
||||
} catch (error) {
|
||||
// If document creation fails, make sure to clean up the loading task
|
||||
if (loadingTask) {
|
||||
try {
|
||||
loadingTask.destroy();
|
||||
} catch (destroyError) {
|
||||
console.warn('🏭 Error destroying failed loading task:', destroyError);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Properly destroy a PDF document and clean up resources
|
||||
*/
|
||||
destroyDocument(pdf: any): void {
|
||||
if (this.activeDocuments.has(pdf)) {
|
||||
try {
|
||||
pdf.destroy();
|
||||
this.activeDocuments.delete(pdf);
|
||||
this.workerCount = Math.max(0, this.workerCount - 1);
|
||||
|
||||
console.log(`🏭 PDF document destroyed (active: ${this.activeDocuments.size}/${this.maxWorkers})`);
|
||||
} catch (error) {
|
||||
console.warn('🏭 Error destroying PDF document:', error);
|
||||
// Still remove from tracking even if destroy failed
|
||||
this.activeDocuments.delete(pdf);
|
||||
this.workerCount = Math.max(0, this.workerCount - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy all active PDF documents
|
||||
*/
|
||||
destroyAllDocuments(): void {
|
||||
console.log(`🏭 Destroying all PDF documents (${this.activeDocuments.size} active)`);
|
||||
|
||||
const documentsToDestroy = Array.from(this.activeDocuments);
|
||||
documentsToDestroy.forEach(pdf => {
|
||||
this.destroyDocument(pdf);
|
||||
});
|
||||
|
||||
this.activeDocuments.clear();
|
||||
this.workerCount = 0;
|
||||
|
||||
console.log('🏭 All PDF documents destroyed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a worker to become available
|
||||
*/
|
||||
private async waitForAvailableWorker(): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const checkAvailability = () => {
|
||||
if (this.activeDocuments.size < this.maxWorkers) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkAvailability, 100);
|
||||
}
|
||||
};
|
||||
checkAvailability();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current worker statistics
|
||||
*/
|
||||
getWorkerStats() {
|
||||
return {
|
||||
active: this.activeDocuments.size,
|
||||
max: this.maxWorkers,
|
||||
total: this.workerCount
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Force cleanup of all workers (emergency cleanup)
|
||||
*/
|
||||
emergencyCleanup(): void {
|
||||
console.warn('🏭 Emergency PDF worker cleanup initiated');
|
||||
|
||||
// Force destroy all documents
|
||||
this.activeDocuments.forEach(pdf => {
|
||||
try {
|
||||
pdf.destroy();
|
||||
} catch (error) {
|
||||
console.warn('🏭 Emergency cleanup - error destroying document:', error);
|
||||
}
|
||||
});
|
||||
|
||||
this.activeDocuments.clear();
|
||||
this.workerCount = 0;
|
||||
|
||||
console.warn('🏭 Emergency cleanup completed');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set maximum concurrent workers
|
||||
*/
|
||||
setMaxWorkers(max: number): void {
|
||||
this.maxWorkers = Math.max(1, Math.min(max, 10)); // Between 1-10 workers
|
||||
console.log(`🏭 Max workers set to ${this.maxWorkers}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const pdfWorkerManager = PDFWorkerManager.getInstance();
|
Loading…
x
Reference in New Issue
Block a user