diff --git a/client/src/components/common/DragAndDropZone.tsx b/client/src/components/common/DragAndDropZone.tsx index 34a44b7..9b9bc0b 100644 --- a/client/src/components/common/DragAndDropZone.tsx +++ b/client/src/components/common/DragAndDropZone.tsx @@ -2,10 +2,10 @@ import { useDropzone } from 'react-dropzone'; import type { FileRejection, Accept } from 'react-dropzone'; import { useEffect, useRef, useState, useCallback } from 'preact/hooks'; import type { ComponentChildren, RefObject } from 'preact'; +import { formatRejectionReasons } from '@/utils/fileValidation'; type DragAndDropZoneProps = { onFileDrop: (file: File) => void | Promise; - onFileReject?: (rejectedFiles: FileRejection[]) => void; accept?: Accept; multiple?: boolean; className?: string; @@ -15,7 +15,6 @@ type DragAndDropZoneProps = { const DragAndDropZone = ({ onFileDrop, - onFileReject, accept, multiple = false, className = '', @@ -47,6 +46,8 @@ const DragAndDropZone = ({ }); }, [overlayTargetRef]); + const [localError, setLocalError] = useState(); + const { getRootProps, getInputProps, isDragActive } = useDropzone({ accept, multiple, @@ -54,6 +55,7 @@ const DragAndDropZone = ({ maxFiles: multiple ? undefined : 1, onDropAccepted: async (files) => { try { + setLocalError(undefined); if (multiple) { for (const file of files) { await onFileDrop(file); @@ -67,14 +69,13 @@ const DragAndDropZone = ({ } }, onDropRejected: (rejectedFiles) => { - if (!onFileReject) return; - try { - onFileReject(rejectedFiles); - } catch (error) { - console.error('Error handling rejected files:', error); - } + if (rejectedFiles.length === 0) return; + setLocalError(`Rejected: ${formatRejectionReasons(rejectedFiles)}`); + }, + onDragEnter: () => { + setLocalError(undefined); + updateOverlayRect(); }, - onDragEnter: () => updateOverlayRect(), }); // react-dropzone returns props typed for React — we cast them for Preact @@ -148,6 +149,12 @@ const DragAndDropZone = ({ className="bg-orange-100/60 border-2 border-orange-400 rounded-md pointer-events-none" /> )} + + {localError && ( +
+ {localError} +
+ )} ); }; diff --git a/client/src/components/images/ImageConverter.tsx b/client/src/components/images/ImageConverter.tsx index 1a6a499..4d7d432 100644 --- a/client/src/components/images/ImageConverter.tsx +++ b/client/src/components/images/ImageConverter.tsx @@ -1,301 +1,61 @@ -import { useState, useRef, useEffect } from 'preact/hooks'; -import { TargetedEvent } from 'preact/compat'; -import type { Accept, FileRejection } from 'react-dropzone'; +import { useState, useRef } from 'preact/hooks'; import { useWasm } from '@/hooks/useWasm'; -import ImageJSRootPreview from './ImageJSRootPreview'; -import { - clip_pixels_with_percentiles, - median_blur_image, - load_image, - gaussian_blur_image, - apply_linear_function, - Image, -} from '@/wasm'; -import AlgorithmsContainer from '@/components/algorithms/AlgorithmsContainer'; -import { - ConversionAlgorithm, - ConversionAlgorithmType, - getAlgorithmName, -} from '@/models/algorithms'; -import DragAndDropZone from '@/components/common/DragAndDropZone'; +import { useFileUpload } from '@/hooks/useFileUpload'; +import { useImageProcessing } from '@/hooks/useImageProcessing'; +import { ACCEPTED_FILE_TYPES } from '@/utils/fileValidation'; +import { ConversionAlgorithm } from '@/models/algorithms'; -type ImageState = { - rawBytes: Uint8Array | null; - uploadedImage: Image | null; - rawPixels: Uint8Array | Uint16Array | null; - imageToConvert: Image | null; - convertedPixels: Uint8Array | Uint16Array | null; -}; +import ImagePreview from './ImageJSRootPreview'; +import AlgorithmsContainer from '../algorithms/AlgorithmsContainer'; +import DragAndDropZone from '../common/DragAndDropZone'; const ImageConverter = () => { const { wasmReady } = useWasm(); const [algorithms, setAlgorithms] = useState([]); - - const [imageState, setImageState] = useState({ - rawBytes: null, - uploadedImage: null, - rawPixels: null, - imageToConvert: null, - convertedPixels: null, - }); - - const [errorMessage, setErrorMessage] = useState(); - const [isProcessing, setIsProcessing] = useState(false); - const [processingProgress, setProcessingProgress] = useState(0); - const [currentAlgorithm, setCurrentAlgorithm] = useState(''); const [units, setUnits] = useState<'px' | 'mm'>('px'); const [mmPerPx, setMmPerPx] = useState(16 / 10); - const [previewsAspectRatios, setPreviewsAspectRatios] = useState(1); + const [previewsAspectRatios, setPreviewsAspectRatios] = useState(1); const overlayTargetRef = useRef(null); - const prevSrcUrlRef = useRef(null); - const prevResultUrlRef = useRef(null); - const YIELD_DELAY_MS = 10; - const FINAL_DISPLAY_DELAY_MS = 500; + const { + imageState, + setImageState, + errorMessage, + setErrorMessage, + processFile, + } = useFileUpload(wasmReady); - const acceptedFileTypes: Accept = { - 'image/tiff': ['.tiff', '.tif'], - 'image/png': ['.png'], - 'image/jpeg': ['.jpeg', '.jpg'], - }; + const { isProcessing, processingProgress, currentAlgorithm, handleRun } = + useImageProcessing(imageState, setImageState, setErrorMessage); - const handleFileReject = (rejectedFiles: FileRejection[]) => { - if (rejectedFiles.length === 0) return; - const reasons = rejectedFiles - .map((rejection) => { - const errors = rejection.errors - .map((err) => { - if (err.code === 'file-invalid-type') { - return `${rejection.file.name}: unsupported type`; - } - return `${rejection.file.name}: ${err.message}`; - }) - .join(', '); - return errors; - }) - .join('; '); - - setErrorMessage(`Rejected files: ${reasons}`); - }; - - const cleanupBlobUrls = () => { - if (prevSrcUrlRef.current) { - URL.revokeObjectURL(prevSrcUrlRef.current); - prevSrcUrlRef.current = null; - } - if (prevResultUrlRef.current) { - URL.revokeObjectURL(prevResultUrlRef.current); - prevResultUrlRef.current = null; - } - }; - - useEffect(() => { - return () => { - cleanupBlobUrls(); - }; - }, []); - - const processFile = async (file: File) => { - if (!wasmReady) { - setErrorMessage('WASM engine not ready — try again in a moment'); - return; - } - const allowedTypes = ['image/tiff', 'image/png', 'image/jpeg']; - if (!allowedTypes.includes(file.type)) { - setErrorMessage('Unsupported image type. Supported: [png, jpeg, tiff]'); - return; - } - const bytes = new Uint8Array(await file.arrayBuffer()); - setErrorMessage(undefined); - try { - const img = load_image(bytes); - const pixels = - img.bits_per_sample === 16 - ? img.pixels_u16() - : img.bits_per_sample === 8 - ? img.pixels_u8() - : null; - if (!pixels) { - setErrorMessage('Failed to extract pixel data from image'); - return; - } - setImageState({ - rawBytes: bytes, - uploadedImage: img, - rawPixels: pixels, - imageToConvert: img, - convertedPixels: null, - }); - } catch (err) { - setErrorMessage(`Upload error: ${err}`); - } - }; - - const handleUpload = async (e: TargetedEvent) => { - const file = e.currentTarget.files?.[0]; + const handleUpload = async (e: Event) => { + const file = (e.currentTarget as HTMLInputElement).files?.[0]; if (file) await processFile(file); }; - const handleRun = async () => { - const enabledAlgorithms = algorithms.filter((a) => a.enabled); - if (!imageState.rawBytes || !wasmReady) return; - if (enabledAlgorithms.length === 0) { - setErrorMessage('No algorithms selected'); - return; - } - if (!imageState.imageToConvert) { - const imageToConvert = load_image(imageState.rawBytes); - setImageState((prevState) => ({ - ...prevState, - imageToConvert, - })); - return; - } - - setErrorMessage(undefined); - setIsProcessing(true); - setProcessingProgress(0); - setCurrentAlgorithm(''); - - try { - for (let i = 0; i < enabledAlgorithms.length; i++) { - const algorithm = enabledAlgorithms[i]; - - setCurrentAlgorithm(getAlgorithmName(algorithm.type)); - setProcessingProgress(Math.round((i / enabledAlgorithms.length) * 100)); - - try { - convert(imageState.imageToConvert, algorithm, setErrorMessage); - } catch (error) { - console.error( - `Error occurred while processing algorithm ${algorithm.type}:`, - error - ); - return; - } - await new Promise((resolve) => setTimeout(resolve, YIELD_DELAY_MS)); - } - - const finalImage = imageState.imageToConvert; - let converted: Uint8Array | Uint16Array | null = null; - if (finalImage) { - if (finalImage.bits_per_sample === 16) { - converted = finalImage.pixels_u16() ?? null; - } else if (finalImage.bits_per_sample === 8) { - converted = finalImage.pixels_u8() ?? null; - } - } - - setImageState((prev) => ({ - ...prev, - convertedPixels: converted, - })); - - setProcessingProgress(100); - setCurrentAlgorithm('Complete'); - - if (prevResultUrlRef.current) { - URL.revokeObjectURL(prevResultUrlRef.current); - } - - setErrorMessage(undefined); - - await new Promise((resolve) => - setTimeout(resolve, FINAL_DISPLAY_DELAY_MS) - ); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : JSON.stringify(error); - setErrorMessage(`Processing error: ${errorMessage}`); - } finally { - setIsProcessing(false); - setProcessingProgress(0); - setCurrentAlgorithm(''); - } - }; - - const convert = ( - image: Image, - algorithm: ConversionAlgorithm, - setErrorMessage: (msg: string) => void - ): Uint8Array | undefined => { - switch (algorithm.type) { - case ConversionAlgorithmType.HotPixelRemoval: - clip_pixels_with_percentiles( - image, - algorithm.lowPercentile, - algorithm.highPercentile - ); - return; - case ConversionAlgorithmType.MedianBlur: - try { - median_blur_image(image, algorithm.kernelRadius); - return; - } catch (err) { - setErrorMessage(`Conversion error: ${err}`); - return; - } - case ConversionAlgorithmType.GaussianBlur: - try { - gaussian_blur_image(image, algorithm.sigma); - return; - } catch (err) { - setErrorMessage(`Conversion error: ${err}`); - return; - } - case ConversionAlgorithmType.LinearTransform: { - const a = Number(algorithm.a); - const b = Number(algorithm.b); - if (!Number.isFinite(a) || !Number.isFinite(b)) { - setErrorMessage('Linear transform requires numeric a and b'); - return; - } - try { - // This mutates the Image in-place (same pattern as gaussian_blur_image) - apply_linear_function(image, a, b); - return; - } catch (err) { - setErrorMessage(`Conversion error: ${err}`); - return; - } - } - default: - // fallback for unsupported algorithms - // This should ideally never happen if the algorithm list is properly managed - // That is why we use `as any` to avoid TypeScript errors - setErrorMessage(`Unsupported algorithm: ${(algorithm as any).type}`); - return; - } - }; - return (
+ {/* Left panel: previews */}
+ {/* Original image */}
- { />
+ + {/* Converted image */}
- - {/* Progress Indicator */} {isProcessing && (
@@ -338,7 +93,7 @@ const ImageConverter = () => {
+ />
{processingProgress}% @@ -348,6 +103,8 @@ const ImageConverter = () => { )}
+ + {/* Right panel: controls */}