前言
本文旨在使用 react-cropper 自定义图片尺寸,并封装一个完整的上传并可裁剪图片的组件,其中 mantine 组件库的 Dropzone 配合开发;
完整代码
import React, { useRef, useState } from 'react';
import { Dropzone } from '@mantine/dropzone';
import {
Modal,
Button,
Text,
Stack,
Center,
Box,
BackgroundImage,
MIME_TYPES,
useComponentDefaultProps,
ModalFooter,
isAcceptFile,
message,
Group,
IconZoomOut,
IconZoomIn,
} from '@mantine/core';
import Cropper, { ReactCropperElement } from 'react-cropper';
import 'cropperjs/dist/cropper.css';
import { useStyles } from '../ProductUpload/UploadCard/UploadCard.styles';
import { IconPlus, IconPlusFill } from '@capture/core';
import { getImageUrl } from 'src/api';
import { isEmpty } from 'lodash-es';
const uuid = () => (~~(Math.random() * 1e9)).toString(16);
export enum UploadStatusEnum {
SUCCESS = 'success',
FAIL = 'fail',
}
export type FieldOptions = {
name: string;
url: string;
};
interface ImageUploaderType {
/** limit upload file type, default is image & office files */
accept?: string;
/** language is English? */
promptInfo?: { name: string; tip: string };
/** file count limit */
countLimit?: number;
/** file size limit */
sizeLimit?: number;
/** uploadable or not */
disabled?: boolean;
/** alias for `key` in files */
fieldOptions?: Partial<FieldOptions>;
/** action for upload service */
onUpload?: (file: File) => Promise<string>;
}
const defaultProps: Partial<ImageUploaderType> = {
accept: [
MIME_TYPES.image,
MIME_TYPES.pdf,
MIME_TYPES.ppt,
MIME_TYPES.excel,
MIME_TYPES.word,
MIME_TYPES.csv,
].join(','), // 默认限制上传图片和office附件
countLimit: 10, // 默认最大上传数量10个文件
sizeLimit: 10 * 1024 * 1024, // 默认最大限制10M
};
const formatFileValue = (files: any) => {
if (isEmpty(files)) {
return [];
} else {
return files.map((f: any) => {
return {
uid: uuid(),
status: UploadStatusEnum.SUCCESS,
...f,
};
});
}
};
export default function ImageUploader(props: ImageUploaderType) {
const { promptInfo, disabled, onUpload, sizeLimit, accept } = useComponentDefaultProps(
'ImageDropzone',
defaultProps,
props
);
const cropperRef = useRef<ReactCropperElement>(null);
const { classes } = useStyles();
const isControlled = 'files' in props;
const propsValue = isControlled ? formatFileValue(props.files) : undefined;
const [imgFile, setImgFile] = useState(null);
const [modalOpen, setModalOpen] = useState(false);
const [croppedImage, setCroppedImage] = useState(propsValue[0]?.url || '');
const [show, setShow] = useState(false);
const handleDrop = (files: any) => {
const fileValue = files[0];
const errorMsg = verify(fileValue);
if (errorMsg) {
message.error(errorMsg);
return;
}
setImgFile(fileValue);
setModalOpen(true);
};
const handleCrop = () => {
if (typeof cropperRef?.current?.cropper?.getCroppedCanvas() === 'undefined') {
return;
}
const croppedImage = cropperRef.current.cropper?.getCroppedCanvas();
croppedImage.toBlob(async (blob: Blob | null) => {
const cropperFile = new File([blob!], 'croppedImage.jpg');
if (onUpload) {
const id = await onUpload(cropperFile);
const imgUrl = getImageUrl(id);
setCroppedImage(imgUrl);
}
setCroppedImage(cropperFile);
setModalOpen(false);
});
};
const handleCloseModal = () => {
setModalOpen(false);
};
const verify = (file: File) => {
if (file.size > sizeLimit!) {
return `文件大小超过${sizeLimit}`;
}
if (!isAcceptFile(file, accept)) {
return '不支持上传的文件类型';
}
return null;
};
return (
<div>
<Dropzone
className={classes.uploadContainer}
onDrop={handleDrop}
disabled={disabled}
onMouseOver={() => {
setShow(true);
}}
onMouseOut={() => setShow(false)}>
<Center
sx={{
textAlign: 'center',
}}>
{croppedImage || propsValue.length > 0 ? (
<Box sx={{ width: '100%', height: '100%' }}>
<BackgroundImage
sx={{
width: '100%',
height: '100%',
objectFit: 'cover',
}}
src={propsValue[0]?.id ? getImageUrl(propsValue[0]?.id) : croppedImage}
radius="sm">
<Box>
<Center
className="active"
sx={() => ({
width: 200,
height: 200,
backgroundColor: `rgba(0, 0, 0, ${show ? 0.2 : 0})`,
borderRadius: 4,
transition: 'background-color 0.18s',
})}>
{show && !disabled && (
<Button
variant="outline"
size="sm"
sx={{
fontWeight: 500,
color: '#fff',
borderColor: '#fff',
}}
leftIcon={<IconPlus size={16} />}>
重新上传
</Button>
)}
</Center>
</Box>
</BackgroundImage>
</Box>
) : (
<Stack spacing={2}>
<IconPlusFill color="#666666" size={30} style={{ margin: '0 auto' }} />
<Text sx={{ color: '#333333', fontWeight: 'bold', fontSize: 16 }}>
{promptInfo?.name}
</Text>
<Text sx={{ color: '#999999', fontSize: 12 }}> {promptInfo?.tip}</Text>
</Stack>
)}
</Center>
</Dropzone>
<Modal opened={modalOpen} onClose={handleCloseModal} title="裁剪图片" withCloseButton>
{imgFile && (
<div style={{ imageRendering: 'pixelated' }}>
<Cropper
ref={cropperRef}
src={URL.createObjectURL(imgFile)}
aspectRatio={1}
guides={true}
viewMode={1}
dragMode="move"
scalable={true}
cropBoxResizable={false}
rotatable
/>
</div>
)}
<Group position="right" sx={{ background: '#fff' }} p={10}>
<IconZoomOut onClick={() => cropperRef?.current?.cropper?.zoom(-0.1)} />
<IconZoomIn onClick={() => cropperRef?.current?.cropper?.zoom(0.1)} />
</Group>
<ModalFooter>
<Button radius={50} variant="subtle" size="sm" onClick={() => setModalOpen(false)}>
取消
</Button>
<Button radius={50} size="sm" onClick={handleCrop}>
保存
</Button>
</ModalFooter>
</Modal>
</div>
);
}
使用组件
import ImageUploader from './ImageUploader';
import { MIME_TYPES, Box, Input } from '@mantine/core';
import { useController } from 'react-hook-form';
import { FileApiService } from 'src/api';
type ProductUploadProps = {
name: string;
msg: string;
disabled?: boolean;
promptInfo?: { name: string; tip: string };
};
export default function ProductUpload({ name, msg, disabled, promptInfo }: ProductUploadProps) {
const {
field,
formState: { errors },
} = useController({ name: name });
const error = errors[field.name]?.message as string;
const onUpload = async (file: File) => {
const res = await FileApiService.upload({ file });
field.onChange(res.id);
return res.id;
};
return (
<Input.Wrapper error={error}>
<ImageUploader
// @ts-ignore
files={field.value ? [{ id: field.value }] : []}
accept={MIME_TYPES.image}
countLimit={1}
disabled={disabled}
promptInfo={promptInfo}
onUpload={onUpload}
/>
<Box c="red" fz={12}>
{msg}
</Box>
</Input.Wrapper>
);
}
实现效果