All files / src/hooks useCropFlow.ts

100% Statements 38/38
100% Branches 11/11
100% Functions 7/7
100% Lines 37/37

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135                                                                                      8x               418x   418x 418x     418x 418x   418x 54x 22x 22x       418x   41x 37x 4x 4x     4x     33x 33x 33x 33x         418x 5x 5x     418x   20x 20x 20x 20x 16x 16x         20x             418x 132x 132x 11x 11x         418x                              
import { useCallback, useEffect, useRef, useState } from "react";
import { notify } from "../lib/notify";
 
interface UseCropFlowOptions {
	/** Aspect ratio for the cropper (e.g., 1 for square, 16/9 for hero). */
	aspectRatio: number;
	/** Filename for the cropped File (e.g., "logo.jpg"). */
	outputName: string;
	/**
	 * Maximum input file size in bytes. Files larger than this are rejected
	 * before the cropper is mounted (canvas memory protection on low-end devices).
	 * Defaults to 10 MB to match the API limit.
	 */
	maxFileSize?: number;
	/**
	 * Called with the cropped File when the user clicks Apply. The hook handles
	 * blob URL revocation and cropping-state cleanup on success. If onUpload
	 * throws, the cropping state is preserved so the user can retry — onUpload
	 * is responsible for surfacing its own error toast.
	 */
	onUpload: (file: File) => Promise<void>;
}
 
interface CropperContractProps {
	imageSrc: string;
	aspectRatio: number;
	onComplete: (blob: Blob) => void;
	onCancel: () => void;
}
 
interface UseCropFlowReturn {
	/** True while the cropper UI should be rendered. */
	isCropping: boolean;
	/** True while the upload is in flight (Apply clicked → onUpload resolves). */
	isUploading: boolean;
	/** Wire to the file input's onChange handler. */
	handleFilePick: (file: File | null | undefined) => void;
	/** Programmatically cancel cropping (e.g., closing a dialog). */
	cancelCropping: () => void;
	/** Spread onto <ImageCropper /> while isCropping is true. Null otherwise. */
	cropperProps: CropperContractProps | null;
}
 
const DEFAULT_MAX_FILE_SIZE = 10 * 1024 * 1024;
 
export function useCropFlow(options: UseCropFlowOptions): UseCropFlowReturn {
	const {
		aspectRatio,
		outputName,
		onUpload,
		maxFileSize = DEFAULT_MAX_FILE_SIZE,
	} = options;
 
	const [croppingUrl, setCroppingUrl] = useState<string | null>(null);
	const [isUploading, setIsUploading] = useState(false);
 
	// Ref mirrors state so cleanup callbacks can revoke without stale closures.
	const croppingUrlRef = useRef<string | null>(null);
	croppingUrlRef.current = croppingUrl;
 
	const revokeCurrentUrl = useCallback(() => {
		if (croppingUrlRef.current) {
			URL.revokeObjectURL(croppingUrlRef.current);
			croppingUrlRef.current = null;
		}
	}, []);
 
	const handleFilePick = useCallback(
		(file: File | null | undefined) => {
			if (!file) return;
			if (file.size > maxFileSize) {
				const mb = Math.round(maxFileSize / (1024 * 1024));
				notify.error(
					`Image is too large. Please choose a file under ${mb} MB.`,
				);
				return;
			}
			// If a previous file is still in cropping (rapid double-pick), revoke it.
			revokeCurrentUrl();
			const url = URL.createObjectURL(file);
			croppingUrlRef.current = url;
			setCroppingUrl(url);
		},
		[maxFileSize, revokeCurrentUrl],
	);
 
	const cancelCropping = useCallback(() => {
		revokeCurrentUrl();
		setCroppingUrl(null);
	}, [revokeCurrentUrl]);
 
	const handleCropComplete = useCallback(
		async (blob: Blob) => {
			const file = new File([blob], outputName, { type: "image/jpeg" });
			setIsUploading(true);
			try {
				await onUpload(file);
				revokeCurrentUrl();
				setCroppingUrl(null);
			} catch {
				// onUpload is responsible for surfacing its own error toast.
				// Stay in cropping state so the user can retry without re-picking.
			} finally {
				setIsUploading(false);
			}
		},
		[onUpload, outputName, revokeCurrentUrl],
	);
 
	// Revoke any pending blob URL on unmount (e.g., user closes modal mid-crop).
	useEffect(() => {
		return () => {
			if (croppingUrlRef.current) {
				URL.revokeObjectURL(croppingUrlRef.current);
				croppingUrlRef.current = null;
			}
		};
	}, []);
 
	return {
		isCropping: croppingUrl !== null,
		isUploading,
		handleFilePick,
		cancelCropping,
		cropperProps: croppingUrl
			? {
					imageSrc: croppingUrl,
					aspectRatio,
					onComplete: handleCropComplete,
					onCancel: cancelCropping,
				}
			: null,
	};
}