文章
问答
冒泡
基于cropperjs实现antd带图片裁剪功能的头像组件
背景
我们经常会遇到在上传头像的时候,需要对头像进行裁剪的需求,antd 官方推荐了https://github.com/nanxiaobei/antd-img-crophttps://github.com/sekoyo/react-image-crop ,虽然这两个组件也能满足一般的需求,但是我们从自身需求出发,希望有更可控的解决方案,并且希望后面通用的解决方案可以用在我们vue的组件库中,那么这种完全基于react的方案就不是很适用,这时候cropperjs这种不依赖react或vue等框架的组件库就成为了一个比较好的选择。
cropperjs
当前是1.x 版本,但是2.x 也进入rc了,所以我们直接使用了2.x来实现,https://fengyuanchen.github.io/cropperjs/v2/。 1.x 和2.x 的版本使用上差别还是比较大的,所以要详细的去阅读下文档。
 
实现细节
在裁剪操作中,主要涉及到的就是 缩放以及旋转功能,虽然cropperjs提供了函数来支持,但是,实际使用的时候是有点出入的。所以我们主要还是基于图像的2D矩阵信息自行进行计算。
0
a:水平方向的缩放系数
b:垂直方向的倾斜角度
c:水平方向的倾斜角度
d:垂直方向的缩放系数
e:水平方向的平移距离
f:垂直方向的平移距离
根据以上信息,我们可以自由的控制图像的变化。
 
缩放
cropper自带的缩放功能,是基于当前的大小进行的,例如原来的宽度是300px,我们每点击一次放大0.1,点击1次放大是300*1.1 ,点击2次放大到300*1.1*1.1 ,实际上我们通过数值来控制的时候,希望是点击1次300*1.1,点击2次是300*1.2。如此一来,这个值就不匹配了,为了能实现通过数值的控制,我们需要自己根据图像的2D矩阵对放大的比例进行计算。
//获取当前的矩阵信息
const currentImageTransform = cropper?.getCropperImage().$getTransform();
//获取原始图像的缩放比
const scaleX_origin = Math.sqrt(initTransform![0] * initTransform![0] + initTransform![1] * initTransform![1]);
const scaleY_origin = Math.sqrt(initTransform![2] * initTransform![2] + initTransform![3] * initTransform![3]);
//获取当前图像的缩放比
const currentScaleX = Math.sqrt(currentImageTransform![0] * currentImageTransform![0] + currentImageTransform![1] * currentImageTransform![1]);
const currentScaleY = Math.sqrt(currentImageTransform![2] * currentImageTransform![2] + currentImageTransform![3] * currentImageTransform![3],);
//计算出当前图像变换到模板图像的缩放系数
const scaleX = (scaleX_origin * value) / currentScaleX;
const scaleY = (scaleY_origin * value) / currentScaleY;
//变换图像
cropper?.getCropperImage().$setTransform(
    currentImageTransform![0] * scaleX,
    currentImageTransform![1] * scaleX,
    currentImageTransform![2] * scaleY,
    currentImageTransform![3] * scaleY,
    currentImageTransform![4],
    currentImageTransform![5],
  );
 
旋转
cropper自带的旋转函数,同样是基于当前的图像进行旋转的,我们要通过值操作,就需要自己通过矩阵信息进行计算,但是这里旋转的时候,要同时考虑缩放以及旋转角度两个值的计算。
//获取当前的矩阵信息
const currentImageTransform = cropper?.getCropperImage().$getTransform();
//获取原始图像的缩放比
const currentScaleX = Math.sqrt(currentImageTransform![0] * currentImageTransform![0] + currentImageTransform![1] * currentImageTransform![1]);
const currentScaleY = Math.sqrt(currentImageTransform![2] * currentImageTransform![2] + currentImageTransform![3] * currentImageTransform![3]);
//计算出角度
const radians = (Math.PI / 180) * value;
//变换图像
cropper?.getCropperImage().$setTransform(
  Math.cos(radians) * currentScaleX,
  -Math.sin(radians) * currentScaleX,
  Math.sin(radians) * currentScaleY,
  Math.cos(radians) * currentScaleY,
  currentImageTransform![4],
  currentImageTransform![5]
);
实现代码
import {
  RotateLeftOutlined,
  RotateRightOutlined,
  ZoomInOutlined,
  ZoomOutOutlined,
} from '@ant-design/icons';
import {useCssInJs} from '@trionesdev/antd-react-ext';
import {Button, Flex, Modal, Slider, Space} from 'antd';
import classNames from 'classnames';
import Cropper from 'cropperjs';
import React, {FC, useEffect, useRef, useState} from 'react';
import {genAvatarCropModalStyle} from './styles.ts';

type AvatarCropModalProps = {
  open?: boolean;
  cropImage?: string;
  onCancel?: () => void;
  onOk?: (dataUrl: string) => void;
};

export const AvatarCropModal: FC<AvatarCropModalProps> = ({
                                                            open,
                                                            cropImage,
                                                            onCancel,
                                                            onOk,
                                                          }) => {
  const cropContainerRef = useRef<HTMLDivElement>(null);
  const [cropper, setCropper] = useState<any>();
  const [initTransform, setInitTransform] = useState<any[]>();
  const [zoom, setZoom] = useState(1);
  const [rotate, setRotate] = useState(0);

  const handleInit = () => {
    const image = new Image();
    if (typeof cropImage === 'string') {
      image.src = cropImage;
    }
    image.alt = 'Cropper';
    const cropperInstance = new Cropper(image, {
      container: cropContainerRef.current!,
      template:
        '<cropper-canvas background="true" scale-step="0.1" style="width: 100%;height: 100%;overflow: visible">\n' +
        '  <cropper-image rotatable="true" scalable="true" translatable="true" initial-center-size="cover" ></cropper-image>\n' +
        '  <cropper-shade hidden></cropper-shade>\n' +
        '  <cropper-handle action="select" plain></cropper-handle>\n' +
        '  <cropper-selection initial-coverage="1" movable="false" resizable="false">\n' +
        '    <cropper-handle action="move" theme-color="rgb(255 255 255 / 0%)"></cropper-handle>\n' +
        '  </cropper-selection>\n' +
        '</cropper-canvas>',
    });
    cropperInstance.getCropperImage()?.$ready((image) => {
      // console.log(image.naturalWidth, image.naturalHeight);
      //region copperjs 存在同一张图片第二次打开的时候,缩放比例会重置为1,这里手动设置一下
      let transform = cropperInstance.getCropperImage()!.$getTransform();
      let scale = 1;
      if (image.naturalWidth > image.naturalHeight) {
        scale = 300 / image.naturalHeight;
      } else {
        scale = 300 / image.naturalWidth;
      }
      if (Math.abs(transform[0] - scale) > 0.001) {
        transform = [scale, 0, 0, scale, transform[4], transform[5]];
        cropperInstance.getCropperImage()!.$setTransform(transform);
        setInitTransform(transform);
        console.log(transform);
      } else {
        setInitTransform(transform);
        console.log(transform);
      }
      //endregion

      cropperInstance
        .getCropperImage()
        ?.addEventListener('transform', (event: any) => {
          console.log('event', event);
          const matrix = event.detail.matrix;
          const oldMatrix = event.detail.oldMatrix;

          /**
           * 计算出动作前后的缩放比,判断当时动作是否为缩放操作
           */
          const scaleX1 = Math.sqrt(matrix[0] * matrix[0] + matrix[1] * matrix[1]);
          const scaleY1 = Math.sqrt(matrix[2] * matrix[2] + matrix[3] * matrix[3]);

          const scaleX2 = Math.sqrt(oldMatrix[0] * oldMatrix[0] + oldMatrix[1] * oldMatrix[1]);
          const scaleY2 = Math.sqrt(oldMatrix[2] * oldMatrix[2] + oldMatrix[3] * oldMatrix[3]);

          if (scaleX1 === scaleX2 && scaleY1 === scaleY2) {
            //非缩放操作
            return;
          } else {
            const scaleX_origin = Math.sqrt(transform[0] * transform[0] + transform[1] * transform[1]);
            const scaleY_origin = Math.sqrt(transform[2] * transform[2] + transform[3] * transform[3]);

            if (scaleX1 < scaleX_origin || scaleY1 < scaleY_origin) {
              //缩小到比初始化小,如果缩放小于初始化,则不允许继续缩小
              event.preventDefault();
              setZoom(1);
            } else if (
              scaleX1 > scaleX_origin * 3 + 0.001 ||
              scaleY1 > scaleY_origin * 3 + 0.001
            ) {
              //放大到3倍
              event.preventDefault();
              setZoom(3);
            } else {
              setZoom(scaleX2 / scaleX_origin);
            }
          }
        });
    });

    setCropper(cropperInstance);
  };

  // 缩放,不使用zoom接口,zoom是根据当前图像进行缩放的,我们的需求是根据原始图像进行缩放,否则图像的缩放比例会比较难以计算
  const handleZoomChange = (value: number) => {
    // console.log(initTransform)
    //获取当前的矩阵信息
    const currentImageTransform = cropper?.getCropperImage().$getTransform();
    //获取原始图像的缩放比
    const scaleX_origin = Math.sqrt(initTransform![0] * initTransform![0] + initTransform![1] * initTransform![1]);
    const scaleY_origin = Math.sqrt(initTransform![2] * initTransform![2] + initTransform![3] * initTransform![3]);
    //获取当前图像的缩放比
    const currentScaleX = Math.sqrt(currentImageTransform![0] * currentImageTransform![0] + currentImageTransform![1] * currentImageTransform![1]);
    const currentScaleY = Math.sqrt(currentImageTransform![2] * currentImageTransform![2] + currentImageTransform![3] * currentImageTransform![3],);

    const scaleX = (scaleX_origin * value) / currentScaleX;
    const scaleY = (scaleY_origin * value) / currentScaleY;
    // console.log(scaleX, scaleY)
    cropper?.getCropperImage().$setTransform(
      currentImageTransform![0] * scaleX,
      currentImageTransform![1] * scaleX,
      currentImageTransform![2] * scaleY,
      currentImageTransform![3] * scaleY,
      currentImageTransform![4],
      currentImageTransform![5],
    );
    setZoom(value);
  };

  const handleRotateChange = (value: number) => {
    //获取当前的矩阵信息
    const currentImageTransform = cropper?.getCropperImage().$getTransform();
    //获取原始图像的缩放比
    const currentScaleX = Math.sqrt(currentImageTransform![0] * currentImageTransform![0] + currentImageTransform![1] * currentImageTransform![1]);
    const currentScaleY = Math.sqrt(currentImageTransform![2] * currentImageTransform![2] + currentImageTransform![3] * currentImageTransform![3]);
    //计算出角度
    const radians = (Math.PI / 180) * value;
    //变换图像
    cropper?.getCropperImage().$setTransform(
      Math.cos(radians) * currentScaleX,
      -Math.sin(radians) * currentScaleX,
      Math.sin(radians) * currentScaleY,
      Math.cos(radians) * currentScaleY,
      currentImageTransform![4],
      currentImageTransform![5],
    );

    setRotate(value);
  };

  const handleOk = () => {
    cropper
      .getCropperSelection()
      ?.$toCanvas()
      .then((canvas: any) => {
        onOk?.(canvas.toDataURL());
      });
  };

  useEffect(() => {
    return () => {
      setInitTransform(undefined);
      cropper?.getCropperImage()?.removeEventListener('transform');
    };
  }, []);

  const prefixCls = 'triones-avatar-crop-modal';
  const {hashId, wrapSSR} = useCssInJs({
    prefix: prefixCls,
    styleFun: genAvatarCropModalStyle,
  });

  return wrapSSR(
    <Modal
      open={open}
      className={classNames(prefixCls, hashId)}
      styles={{
        content: {
          padding: '10px',
        },
      }}
      closable={false}
      onCancel={onCancel}
      width={420}
      destroyOnClose={true}
      onOk={handleOk}
      footer={(originNode) => {
        return (
          <div style={{textAlign: 'center'}}>
            <Space>{originNode}</Space>
          </div>
        );
      }}
      afterOpenChange={(open) => {
        if (open) {
          handleInit();
        } else {
          // cropper?.destroy()
          setCropper(undefined);
        }
      }}
    >
      <Space direction={'vertical'}>
        <div
          style={{
            width: 400,
            height: 400,
            overflow: 'hidden',
            display: 'flex',
            justifyContent: 'center',
            alignItems: 'center',
          }}
        >
          <div
            ref={cropContainerRef}
            className={classNames(`${prefixCls}-cropper`, hashId)}
            style={{width: 300, height: 300, position: 'relative'}}
          />
        </div>
        <Flex gap={'small'} justify={'space-between'} align={'center'}>
          <Button
            type={'text'}
            icon={<ZoomOutOutlined/>}
            disabled={zoom <= 1}
            onClick={() => {
              if (zoom - 0.1 >= 1) {
                handleZoomChange(zoom - 0.1);
              }
            }}
          />
          <Slider
            style={{width: '100%'}}
            defaultValue={zoom}
            value={zoom}
            min={1}
            max={3}
            step={0.1}
            onChange={handleZoomChange}
          />
          <Button
            type={'text'}
            icon={<ZoomInOutlined/>}
            disabled={zoom >= 3}
            onClick={() => {
              if (zoom + 0.1 <= 3) {
                handleZoomChange(zoom + 0.1);
              }
            }}
          />
        </Flex>
        <Flex gap={'small'} justify={'center'} align={'center'}>
          <Button
            type={`text`}
            icon={<RotateLeftOutlined/>}
            disabled={rotate <= -180}
            onClick={() => {
              if (rotate - 1 >= -180) {
                handleRotateChange(rotate - 1);
              }
            }}
          />
          <Slider
            style={{width: '100%'}}
            defaultValue={rotate}
            value={rotate}
            min={-180}
            max={180}
            step={1}
            onChange={handleRotateChange}
          />
          <Button
            type={`text`}
            icon={<RotateRightOutlined/>}
            disabled={rotate >= 180}
            onClick={() => {
              if (rotate + 1 <= 180) {
                handleRotateChange(rotate + 1);
              }
            }}
          />
        </Flex>
      </Space>
    </Modal>,
  );
};
 
实现效果
0
 
0
 
0
react
antd

关于作者

落雁沙
非典型码农
获得点赞
文章被阅读