Files
mastodon-sakyey/app/javascript/mastodon/features/account_edit/modals/image_upload.tsx

439 lines
11 KiB
TypeScript

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<string | null>(null);
const [imageBlob, setImageBlob] = useState<Blob | null>(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 (
<DialogModal
title={
hasImage ? (
<FormattedMessage
id='account_edit.upload_modal.title_replace'
defaultMessage='Replace profile photo'
/>
) : (
<FormattedMessage
id='account_edit.upload_modal.title_add'
defaultMessage='Add profile photo'
/>
)
}
onClose={onClose}
wrapperClassName={classes.uploadWrapper}
noCancelButton
>
{step === 'select' && (
<StepUpload location={location} onFile={handleFile} />
)}
{step === 'crop' && imageSrc && (
<StepCrop
src={imageSrc}
location={location}
onCancel={handleCancel}
onComplete={handleCrop}
/>
)}
{step === 'alt' && imageBlob && (
<StepAlt
imageBlob={imageBlob}
onCancel={handleCancel}
onComplete={handleSave}
/>
)}
</DialogModal>
);
};
// 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<HTMLInputElement>(null);
const handleUploadClick = useCallback(() => {
inputRef.current?.click();
}, []);
const handleFileChange: ChangeEventHandler<HTMLInputElement> = 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 (
<div className={classes.uploadStepSelect}>
<FormattedMessage
id='account_edit.upload_modal.step_upload.dragging'
defaultMessage='Drop to upload'
tagName='h2'
/>
</div>
);
}
return (
<div className={classes.uploadStepSelect}>
<FormattedMessage
id='account_edit.upload_modal.step_upload.header'
defaultMessage='Choose an image'
tagName='h2'
/>
<FormattedMessage
id='account_edit.upload_modal.step_upload.hint'
defaultMessage='WEBP, PNG, GIF or JPG format, up to {limit}MB.{br}Image will be scaled to {width}x{height}px.'
description='Guideline for avatar and header images.'
values={{
br: <br />,
limit: 8,
width: location === 'avatar' ? 400 : 1500,
height: location === 'avatar' ? 400 : 500,
}}
tagName='p'
/>
<Button
onClick={handleUploadClick}
// eslint-disable-next-line jsx-a11y/no-autofocus -- This is the main input, so auto-focus on it.
autoFocus
>
<FormattedMessage
id='account_edit.upload_modal.step_upload.button'
defaultMessage='Browse files'
/>
</Button>
<input
hidden
type='file'
ref={inputRef}
accept={ALLOWED_MIME_TYPES.join(',')}
onChange={handleFileChange}
/>
</div>
);
};
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<Area | null>(null);
const [zoom, setZoom] = useState(1);
const intl = useIntl();
const handleZoomChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(event) => {
setZoom(event.currentTarget.valueAsNumber);
},
[],
);
const handleCropComplete = useCallback((_: Area, croppedAreaPixels: Area) => {
setCroppedArea(croppedAreaPixels);
}, []);
const handleNext = useCallback(() => {
if (croppedArea) {
onComplete(croppedArea);
}
}, [croppedArea, onComplete]);
return (
<>
<div className={classes.cropContainer}>
<Cropper
image={src}
crop={crop}
zoom={zoom}
onCropChange={setCrop}
onCropComplete={handleCropComplete}
aspect={location === 'avatar' ? 1 : 3 / 1}
disableAutomaticStylesInjection
/>
</div>
<div className={classes.cropActions}>
<RangeInput
min={1}
max={3}
step={0.1}
value={zoom}
onChange={handleZoomChange}
className={classes.zoomControl}
aria-label={intl.formatMessage(zoomLabel)}
/>
<Button onClick={onCancel} secondary>
<FormattedMessage
id='account_edit.upload_modal.back'
defaultMessage='Back'
/>
</Button>
<Button onClick={handleNext} disabled={!croppedArea}>
<FormattedMessage
id='account_edit.upload_modal.next'
defaultMessage='Next'
/>
</Button>
</div>
</>
);
};
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 (
<>
<ImageAltTextField
imageSrc={imageSrc}
altText={altText}
onChange={setAltText}
/>
<div className={classes.cropActions}>
<Button onClick={onCancel} secondary>
<FormattedMessage
id='account_edit.upload_modal.back'
defaultMessage='Back'
/>
</Button>
<Button onClick={handleComplete}>
<FormattedMessage
id='account_edit.upload_modal.done'
defaultMessage='Done'
/>
</Button>
</div>
</>
);
};
async function calculateCroppedImage(
imageSrc: string,
crop: Area,
): Promise<Blob> {
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<HTMLImageElement>((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;
});
}