react-cropper 实现裁剪图片、放大、缩小

967 阅读2分钟

前言

本文旨在使用 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>
  );
}

实现效果

image.png