import { ref, onUnmounted, type Ref } from 'vue' interface FileUploadOptions { /** * Accepted file types (MIME types) */ accept?: Ref /** * Maximum file size in bytes */ maxSize?: Ref /** * Maximum image width in pixels */ maxWidth?: Ref /** * Maximum image height in pixels */ maxHeight?: Ref /** * Callback when file is selected */ onFileSelect?: (file: File) => void /** * Callback when file is removed */ onFileRemove?: () => void /** * Callback when validation error occurs */ onError?: (error: string) => void } interface ValidationResult { valid: boolean error?: string } /** * Composable for managing file upload functionality * Provides file validation, drag-and-drop, preview generation, and cleanup */ export function useFileUpload(options: FileUploadOptions = {}) { const { accept = ref('image/*'), maxSize = ref(5242880), // 5MB default maxWidth, maxHeight, onFileSelect, onFileRemove, onError } = options // State const isDragging = ref(false) const previewUrl = ref(null) const currentFile = ref(null) const validationError = ref(null) // Track preview URLs for cleanup const previewUrls: string[] = [] /** * Validate file type against accepted types */ const validateFileType = (file: File): ValidationResult => { const acceptedTypes = accept.value.split(',').map(type => type.trim()) // Check if file type matches any accepted type const isAccepted = acceptedTypes.some(type => { if (type === 'image/*') { return file.type.startsWith('image/') } if (type.endsWith('/*')) { const baseType = type.slice(0, -2) return file.type.startsWith(baseType) } return file.type === type }) if (!isAccepted) { return { valid: false, error: `File type "${file.type}" is not accepted. Please select ${accept.value}` } } return { valid: true } } /** * Validate file size */ const validateFileSize = (file: File): ValidationResult => { if (file.size > maxSize.value) { const maxSizeMB = (maxSize.value / (1024 * 1024)).toFixed(2) return { valid: false, error: `File size (${(file.size / (1024 * 1024)).toFixed(2)}MB) exceeds maximum allowed size of ${maxSizeMB}MB` } } return { valid: true } } /** * Validate image dimensions */ const validateImageDimensions = (file: File): Promise => { return new Promise((resolve) => { // Skip if no dimension constraints if (!maxWidth?.value && !maxHeight?.value) { resolve({ valid: true }) return } // Only validate dimensions for image files if (!file.type.startsWith('image/')) { resolve({ valid: true }) return } const img = new Image() const objectUrl = URL.createObjectURL(file) img.onload = () => { URL.revokeObjectURL(objectUrl) if (maxWidth?.value && img.width > maxWidth.value) { resolve({ valid: false, error: `Image width (${img.width}px) exceeds maximum allowed width of ${maxWidth.value}px` }) return } if (maxHeight?.value && img.height > maxHeight.value) { resolve({ valid: false, error: `Image height (${img.height}px) exceeds maximum allowed height of ${maxHeight.value}px` }) return } resolve({ valid: true }) } img.onerror = () => { URL.revokeObjectURL(objectUrl) resolve({ valid: false, error: 'Unable to load image for validation' }) } img.src = objectUrl }) } /** * Validate a file against all validation rules */ const validateFile = async (file: File): Promise => { // Type validation const typeResult = validateFileType(file) if (!typeResult.valid) return typeResult // Size validation const sizeResult = validateFileSize(file) if (!sizeResult.valid) return sizeResult // Dimension validation const dimensionResult = await validateImageDimensions(file) if (!dimensionResult.valid) return dimensionResult return { valid: true } } /** * Generate preview URL for file */ const generatePreview = (file: File): void => { // Cleanup previous preview if (previewUrl.value) { URL.revokeObjectURL(previewUrl.value) const index = previewUrls.indexOf(previewUrl.value) if (index > -1) { previewUrls.splice(index, 1) } } // Generate new preview const url = URL.createObjectURL(file) previewUrl.value = url previewUrls.push(url) } /** * Handle file selection */ const handleFileSelect = async (file: File): Promise => { validationError.value = null // Validate file const result = await validateFile(file) if (!result.valid && result.error) { validationError.value = result.error onError?.(result.error) return } // File is valid currentFile.value = file generatePreview(file) onFileSelect?.(file) } /** * Handle file removal */ const handleFileRemove = (): void => { // Cleanup preview URL if (previewUrl.value) { URL.revokeObjectURL(previewUrl.value) const index = previewUrls.indexOf(previewUrl.value) if (index > -1) { previewUrls.splice(index, 1) } } previewUrl.value = null currentFile.value = null validationError.value = null onFileRemove?.() } /** * Handle drag enter event */ const handleDragEnter = (event: DragEvent): void => { event.preventDefault() isDragging.value = true } /** * Handle drag over event */ const handleDragOver = (event: DragEvent): void => { event.preventDefault() isDragging.value = true } /** * Handle drag leave event */ const handleDragLeave = (event: DragEvent): void => { event.preventDefault() // Only set to false if we're leaving the drop zone entirely const currentTarget = event.currentTarget as HTMLElement if (!currentTarget.contains(event.relatedTarget as Node)) { isDragging.value = false } } /** * Handle drop event */ const handleDrop = async (event: DragEvent): Promise => { event.preventDefault() isDragging.value = false const files = event.dataTransfer?.files if (files && files.length > 0) { await handleFileSelect(files[0]) } } /** * Cleanup all preview URLs on unmount */ onUnmounted(() => { previewUrls.forEach(url => URL.revokeObjectURL(url)) previewUrls.length = 0 }) return { // State isDragging, previewUrl, currentFile, validationError, // Methods handleFileSelect, handleFileRemove, handleDragEnter, handleDragOver, handleDragLeave, handleDrop, validateFile } }