import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ChangeEventHandler, FC } from 'react'; import { defineMessage, FormattedMessage, useIntl } from 'react-intl'; import type { Area } from 'react-easy-crop'; import Cropper from 'react-easy-crop'; import { setDragUploadEnabled } from '@/mastodon/actions/compose_typed'; import { Button } from '@/mastodon/components/button'; import { RangeInput } from '@/mastodon/components/form_fields/range_input_field'; import { selectImageInfo, uploadImage, } from '@/mastodon/reducers/slices/profile_edit'; import type { ImageLocation } from '@/mastodon/reducers/slices/profile_edit'; import { useAppDispatch, useAppSelector } from '@/mastodon/store'; import { DialogModal } from '../../ui/components/dialog_modal'; import type { DialogModalProps } from '../../ui/components/dialog_modal'; import { ImageAltTextField } from './image_alt'; import classes from './styles.module.scss'; import 'react-easy-crop/react-easy-crop.css'; export const ImageUploadModal: FC< DialogModalProps & { location: ImageLocation } > = ({ onClose, location }) => { const { src: oldSrc } = useAppSelector((state) => selectImageInfo(state, location), ); const hasImage = !!oldSrc; const [step, setStep] = useState<'select' | 'crop' | 'alt'>('select'); // State for individual steps. const [imageSrc, setImageSrc] = useState(null); const [imageBlob, setImageBlob] = useState(null); const handleFile = useCallback((file: File) => { const reader = new FileReader(); reader.addEventListener('load', () => { const result = reader.result; if (typeof result === 'string' && result.length > 0) { setImageSrc(result); setStep('crop'); } }); reader.readAsDataURL(file); }, []); const handleCrop = useCallback( (crop: Area) => { if (!imageSrc) { setStep('select'); return; } void calculateCroppedImage(imageSrc, crop).then((blob) => { setImageBlob(blob); setStep('alt'); }); }, [imageSrc], ); const dispatch = useAppDispatch(); const handleSave = useCallback( (altText: string) => { if (!imageBlob) { setStep('crop'); return; } void dispatch(uploadImage({ location, imageBlob, altText })).then( onClose, ); }, [dispatch, imageBlob, location, onClose], ); const handleCancel = useCallback(() => { switch (step) { case 'crop': setImageSrc(null); setStep('select'); break; case 'alt': setImageBlob(null); setStep('crop'); break; default: onClose(); } }, [onClose, step]); return ( ) : ( ) } onClose={onClose} wrapperClassName={classes.uploadWrapper} noCancelButton > {step === 'select' && ( )} {step === 'crop' && imageSrc && ( )} {step === 'alt' && imageBlob && ( )} ); }; // Taken from app/models/concerns/account/header.rb and app/models/concerns/account/avatar.rb const ALLOWED_MIME_TYPES = [ 'image/jpeg', 'image/png', 'image/gif', 'image/webp', ]; const StepUpload: FC<{ location: ImageLocation; onFile: (file: File) => void; }> = ({ location, onFile }) => { const inputRef = useRef(null); const handleUploadClick = useCallback(() => { inputRef.current?.click(); }, []); const handleFileChange: ChangeEventHandler = useCallback( (event) => { const file = event.currentTarget.files?.[0]; if (!file || !ALLOWED_MIME_TYPES.includes(file.type)) { return; } onFile(file); }, [onFile], ); // Handle drag and drop const [isDragging, setDragging] = useState(false); const handleDragOver = useCallback((event: DragEvent) => { event.preventDefault(); if (!event.dataTransfer?.types.includes('Files')) { return; } const items = Array.from(event.dataTransfer.items); if ( !items.some( (item) => item.kind === 'file' && ALLOWED_MIME_TYPES.includes(item.type), ) ) { return; } setDragging(true); }, []); const handleDragDrop = useCallback( (event: DragEvent) => { event.preventDefault(); setDragging(false); if (!event.dataTransfer?.files) { return; } const file = Array.from(event.dataTransfer.files).find((f) => ALLOWED_MIME_TYPES.includes(f.type), ); if (!file) { return; } onFile(file); }, [onFile], ); const handleDragLeave = useCallback((event: DragEvent) => { event.preventDefault(); setDragging(false); }, []); const dispatch = useAppDispatch(); useEffect(() => { dispatch(setDragUploadEnabled(false)); document.addEventListener('dragover', handleDragOver); document.addEventListener('drop', handleDragDrop); document.addEventListener('dragleave', handleDragLeave); return () => { document.removeEventListener('dragover', handleDragOver); document.removeEventListener('drop', handleDragDrop); document.removeEventListener('dragleave', handleDragLeave); dispatch(setDragUploadEnabled(true)); }; }, [handleDragLeave, handleDragDrop, handleDragOver, dispatch]); if (isDragging) { return (
); } return (
, limit: 8, width: location === 'avatar' ? 400 : 1500, height: location === 'avatar' ? 400 : 500, }} tagName='p' />
); }; const zoomLabel = defineMessage({ id: 'account_edit.upload_modal.step_crop.zoom', defaultMessage: 'Zoom', }); const StepCrop: FC<{ src: string; location: ImageLocation; onCancel: () => void; onComplete: (crop: Area) => void; }> = ({ src, location, onCancel, onComplete }) => { const [crop, setCrop] = useState({ x: 0, y: 0 }); const [croppedArea, setCroppedArea] = useState(null); const [zoom, setZoom] = useState(1); const intl = useIntl(); const handleZoomChange: ChangeEventHandler = useCallback( (event) => { setZoom(event.currentTarget.valueAsNumber); }, [], ); const handleCropComplete = useCallback((_: Area, croppedAreaPixels: Area) => { setCroppedArea(croppedAreaPixels); }, []); const handleNext = useCallback(() => { if (croppedArea) { onComplete(croppedArea); } }, [croppedArea, onComplete]); return ( <>
); }; const StepAlt: FC<{ imageBlob: Blob; onCancel: () => void; onComplete: (altText: string) => void; }> = ({ imageBlob, onCancel, onComplete }) => { const [altText, setAltText] = useState(''); const handleComplete = useCallback(() => { onComplete(altText); }, [altText, onComplete]); const imageSrc = useMemo(() => URL.createObjectURL(imageBlob), [imageBlob]); return ( <>
); }; async function calculateCroppedImage( imageSrc: string, crop: Area, ): Promise { const image = await dataUriToImage(imageSrc); const canvas = new OffscreenCanvas(crop.width, crop.height); const ctx = canvas.getContext('2d'); if (!ctx) { throw new Error('Failed to get canvas context'); } ctx.imageSmoothingQuality = 'high'; // Draw the image ctx.drawImage( image, crop.x, crop.y, crop.width, crop.height, 0, 0, crop.width, crop.height, ); return canvas.convertToBlob({ quality: 0.7, type: 'image/jpeg', }); } function dataUriToImage(dataUri: string) { return new Promise((resolve, reject) => { const image = new Image(); image.addEventListener('load', () => { resolve(image); }); image.addEventListener('error', (event) => { if (event.error instanceof Error) { reject(event.error); } else { reject(new Error('Failed to load image')); } }); image.src = dataUri; }); }